Repository: libnyanpasu/clash-nyanpasu Branch: main Commit: ee62b62ca329 Files: 743 Total size: 2.5 MB Directory structure: gitextract_v_30r70e/ ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yaml │ │ ├── config.yml │ │ └── feature_request.yaml │ └── workflows/ │ ├── ci.yml │ ├── daily.yml │ ├── deps-build-linux.yaml │ ├── deps-build-macos.yaml │ ├── deps-build-windows-nsis.yaml │ ├── deps-create-updater.yaml │ ├── deps-delete-releases.yaml │ ├── deps-message-telegram.yaml │ ├── deps-update-tag.yaml │ ├── deps-upload-release-assets.yaml │ ├── macos-aarch64.yaml │ ├── publish.yml │ ├── stale.yml │ ├── target-dev-build.yaml │ └── target-release-build.yaml ├── .gitignore ├── .husky/ │ ├── commit-msg │ └── pre-commit ├── .lintstagedrc.js ├── .oxlintrc.json ├── .prettierignore ├── .prettierrc.cjs ├── .stylelintignore ├── .stylelintrc.js ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── UPDATELOG.md ├── backend/ │ ├── .gitignore │ ├── Cargo.toml │ ├── Cross.toml │ ├── boa_utils/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── console/ │ │ │ ├── mod.rs │ │ │ └── tests.rs │ │ ├── lib.rs │ │ └── module/ │ │ ├── builtin/ │ │ │ └── utils.js │ │ ├── builtin.rs │ │ ├── combine.rs │ │ ├── http.rs │ │ └── mod.rs │ ├── nyanpasu-egui/ │ │ ├── .gitignore │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── ipc.rs │ │ ├── lib.rs │ │ ├── main.rs │ │ ├── small.rs │ │ ├── utils/ │ │ │ ├── mod.rs │ │ │ └── svg.rs │ │ └── widget/ │ │ ├── mod.rs │ │ ├── network_statistic_large.rs │ │ └── network_statistic_small.rs │ ├── nyanpasu-macro/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── builder_update.rs │ │ ├── enum_wrapper_combined.rs │ │ ├── lib.rs │ │ └── verge_patch.rs │ ├── rustfmt.toml │ ├── tauri/ │ │ ├── .gitignore │ │ ├── Cargo.toml │ │ ├── Info.plist │ │ ├── build.rs │ │ ├── capabilities/ │ │ │ └── main.json │ │ ├── icons/ │ │ │ └── icon.icns │ │ ├── locales/ │ │ │ ├── en.json │ │ │ ├── ru.json │ │ │ ├── zh-cn.json │ │ │ └── zh-tw.json │ │ ├── overrides/ │ │ │ ├── fixed-webview2.conf.json │ │ │ └── nightly.conf.json │ │ ├── src/ │ │ │ ├── cmds/ │ │ │ │ ├── migrate.rs │ │ │ │ └── mod.rs │ │ │ ├── config/ │ │ │ │ ├── clash/ │ │ │ │ │ └── mod.rs │ │ │ │ ├── core.rs │ │ │ │ ├── draft.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── nyanpasu/ │ │ │ │ │ ├── clash_strategy.rs │ │ │ │ │ ├── logging.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── widget.rs │ │ │ │ ├── profile/ │ │ │ │ │ ├── builder.rs │ │ │ │ │ ├── item/ │ │ │ │ │ │ ├── local.rs │ │ │ │ │ │ ├── merge.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ ├── prelude.rs │ │ │ │ │ │ ├── remote.rs │ │ │ │ │ │ ├── script.rs │ │ │ │ │ │ ├── shared.rs │ │ │ │ │ │ └── utils.rs │ │ │ │ │ ├── item_type.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── profiles.rs │ │ │ │ │ └── tests.rs │ │ │ │ └── runtime.rs │ │ │ ├── consts.rs │ │ │ ├── core/ │ │ │ │ ├── clash/ │ │ │ │ │ ├── api.rs │ │ │ │ │ ├── core.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── proxies.rs │ │ │ │ │ └── ws.rs │ │ │ │ ├── connection_interruption.rs │ │ │ │ ├── handle.rs │ │ │ │ ├── hotkey.rs │ │ │ │ ├── logger.rs │ │ │ │ ├── manager.rs │ │ │ │ ├── migration/ │ │ │ │ │ ├── db.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── units/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── unit_160.rs │ │ │ │ │ ├── unit_200/ │ │ │ │ │ │ └── profile_script_newtype.rs │ │ │ │ │ └── unit_200.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── pac.rs │ │ │ │ ├── service/ │ │ │ │ │ ├── control.rs │ │ │ │ │ ├── ipc.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── state.rs │ │ │ │ ├── state_v2/ │ │ │ │ │ ├── builder.rs │ │ │ │ │ ├── coordinator.rs │ │ │ │ │ ├── manager/ │ │ │ │ │ │ ├── persistent.rs │ │ │ │ │ │ └── simple.rs │ │ │ │ │ ├── manager.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── storage.rs │ │ │ │ ├── sysopt.rs │ │ │ │ ├── tasks/ │ │ │ │ │ ├── events.rs │ │ │ │ │ ├── executor.rs │ │ │ │ │ ├── jobs/ │ │ │ │ │ │ ├── events_rotate.rs │ │ │ │ │ │ ├── logger.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ └── profiles.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── storage.rs │ │ │ │ │ ├── task.rs │ │ │ │ │ └── utils.rs │ │ │ │ ├── tray/ │ │ │ │ │ ├── icon.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── proxies.rs │ │ │ │ ├── updater/ │ │ │ │ │ ├── instance.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── shared.rs │ │ │ │ └── win_uwp.rs │ │ │ ├── enhance/ │ │ │ │ ├── advice.rs │ │ │ │ ├── builtin/ │ │ │ │ │ ├── clash_rs_comp.lua │ │ │ │ │ ├── config_fixer.js │ │ │ │ │ ├── meta_guard.js │ │ │ │ │ └── meta_hy_alpn.js │ │ │ │ ├── chain.rs │ │ │ │ ├── field.rs │ │ │ │ ├── merge.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── script/ │ │ │ │ │ ├── js.rs │ │ │ │ │ ├── lua/ │ │ │ │ │ │ └── mod.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── runner.rs │ │ │ │ ├── tun.rs │ │ │ │ └── utils.rs │ │ │ ├── event_handler/ │ │ │ │ ├── mod.rs │ │ │ │ └── widget.rs │ │ │ ├── feat.rs │ │ │ ├── ipc.rs │ │ │ ├── lib.rs │ │ │ ├── logging/ │ │ │ │ ├── indexer.rs │ │ │ │ ├── manager.rs │ │ │ │ └── mod.rs │ │ │ ├── main.rs │ │ │ ├── server/ │ │ │ │ └── mod.rs │ │ │ ├── setup.rs │ │ │ ├── shutdown_hook.rs │ │ │ ├── utils/ │ │ │ │ ├── candy.rs │ │ │ │ ├── collect.rs │ │ │ │ ├── config.rs │ │ │ │ ├── dialog.rs │ │ │ │ ├── dirs.rs │ │ │ │ ├── dock.rs │ │ │ │ ├── downloader.rs │ │ │ │ ├── help.rs │ │ │ │ ├── init/ │ │ │ │ │ ├── logging.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── net.rs │ │ │ │ ├── open.rs │ │ │ │ ├── resolve.rs │ │ │ │ ├── sudo.rs │ │ │ │ ├── winhelp.rs │ │ │ │ ├── winreg.rs │ │ │ │ └── winreg_test.rs │ │ │ ├── widget.rs │ │ │ └── window.rs │ │ ├── tauri.conf.json │ │ ├── tauri.windows.conf.json │ │ ├── templates/ │ │ │ ├── cleanup.wxs │ │ │ ├── installer.nsi │ │ │ └── installer.wxs │ │ └── tests/ │ │ └── sample_clash_config.yaml │ └── tauri-plugin-deep-link/ │ ├── .github/ │ │ └── workflows/ │ │ ├── audit.yml │ │ ├── format.yml │ │ ├── lint.yml │ │ └── release.yml │ ├── .gitignore │ ├── CHANGELOG.md │ ├── Cargo.toml │ ├── LICENSE_APACHE-2.0 │ ├── LICENSE_MIT │ ├── README.md │ ├── cliff.toml │ ├── example/ │ │ ├── Info.plist │ │ └── main.rs │ ├── renovate.json │ └── src/ │ ├── lib.rs │ ├── linux.rs │ ├── macos.rs │ ├── template.desktop │ └── windows.rs ├── cliff.toml ├── commitlint.config.js ├── frontend/ │ ├── interface/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── hooks/ │ │ │ │ ├── index.ts │ │ │ │ └── use-kv-storage.ts │ │ │ ├── index.ts │ │ │ ├── ipc/ │ │ │ │ ├── bindings.ts │ │ │ │ ├── consts.ts │ │ │ │ ├── index.ts │ │ │ │ ├── use-clash-config.ts │ │ │ │ ├── use-clash-connections.ts │ │ │ │ ├── use-clash-cores.ts │ │ │ │ ├── use-clash-info.ts │ │ │ │ ├── use-clash-logs.ts │ │ │ │ ├── use-clash-memory.ts │ │ │ │ ├── use-clash-proxies-provider.ts │ │ │ │ ├── use-clash-proxies.ts │ │ │ │ ├── use-clash-rules-provider.ts │ │ │ │ ├── use-clash-rules.ts │ │ │ │ ├── use-clash-traffic.ts │ │ │ │ ├── use-clash-version.ts │ │ │ │ ├── use-clash-web-socket.ts │ │ │ │ ├── use-core-dir.ts │ │ │ │ ├── use-platform.ts │ │ │ │ ├── use-post-processing-output.ts │ │ │ │ ├── use-profile-content.ts │ │ │ │ ├── use-profile.ts │ │ │ │ ├── use-proxy-mode.ts │ │ │ │ ├── use-runtime-profile.ts │ │ │ │ ├── use-server-port.ts │ │ │ │ ├── use-service-prompt.ts │ │ │ │ ├── use-settings.ts │ │ │ │ ├── use-system-proxy.ts │ │ │ │ └── use-system-service.ts │ │ │ ├── openapi/ │ │ │ │ ├── geoip/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ipsb.ts │ │ │ │ ├── healthcheck/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ └── index.ts │ │ │ ├── provider/ │ │ │ │ ├── clash-ws-provider.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── mutation-provider.tsx │ │ │ ├── service/ │ │ │ │ ├── clash-api.ts │ │ │ │ ├── core.ts │ │ │ │ ├── index.ts │ │ │ │ ├── tauri.ts │ │ │ │ └── types.ts │ │ │ ├── template/ │ │ │ │ └── index.ts │ │ │ └── utils/ │ │ │ ├── get-system.ts │ │ │ ├── index.ts │ │ │ └── retry.ts │ │ └── tsconfig.json │ ├── nyanpasu/ │ │ ├── .gitignore │ │ ├── .vscode/ │ │ │ └── extensions.json │ │ ├── auto-imports.d.ts │ │ ├── index.html │ │ ├── messages/ │ │ │ ├── en.json │ │ │ ├── ru.json │ │ │ ├── zh-cn.json │ │ │ └── zh-tw.json │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── project.inlang/ │ │ │ ├── project_id │ │ │ └── settings.json │ │ ├── src/ │ │ │ ├── assets/ │ │ │ │ ├── json/ │ │ │ │ │ └── clash-field.json │ │ │ │ └── styles/ │ │ │ │ ├── fonts.scss │ │ │ │ ├── index.scss │ │ │ │ ├── tailwind.css │ │ │ │ └── theme.scss │ │ │ ├── components/ │ │ │ │ ├── app/ │ │ │ │ │ ├── app-container.module.d.scss.ts │ │ │ │ │ ├── app-container.module.scss │ │ │ │ │ ├── app-container.module.scss.d.ts │ │ │ │ │ ├── app-container.tsx │ │ │ │ │ ├── app-drawer.tsx │ │ │ │ │ ├── drawer-content.tsx │ │ │ │ │ ├── locales-provider.tsx │ │ │ │ │ └── modules/ │ │ │ │ │ └── route-list-item.tsx │ │ │ │ ├── base/ │ │ │ │ │ ├── base-empty.tsx │ │ │ │ │ ├── base-error-boundary.tsx │ │ │ │ │ ├── base-notice.tsx │ │ │ │ │ ├── content-display.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── connections/ │ │ │ │ │ ├── close-connections-button.tsx │ │ │ │ │ ├── connection-detail-dialog.tsx │ │ │ │ │ ├── connection-page.tsx │ │ │ │ │ ├── connection-search-term.tsx │ │ │ │ │ ├── connections-column-filter.tsx │ │ │ │ │ ├── connections-table.tsx │ │ │ │ │ ├── connections-total.tsx │ │ │ │ │ └── header-search.tsx │ │ │ │ ├── dashboard/ │ │ │ │ │ ├── data-panel.tsx │ │ │ │ │ ├── dataline.tsx │ │ │ │ │ ├── health-panel.tsx │ │ │ │ │ ├── modules/ │ │ │ │ │ │ ├── ipasn-panel.tsx │ │ │ │ │ │ └── timing-panel.tsx │ │ │ │ │ ├── proxy-shortcuts.tsx │ │ │ │ │ └── service-shortcuts.tsx │ │ │ │ ├── layout/ │ │ │ │ │ ├── animated-logo.module.d.scss.ts │ │ │ │ │ ├── animated-logo.module.scss │ │ │ │ │ ├── animated-logo.module.scss.d.ts │ │ │ │ │ ├── animated-logo.tsx │ │ │ │ │ ├── layout-control.tsx │ │ │ │ │ ├── mutation-provider.tsx │ │ │ │ │ ├── notice-provider.tsx │ │ │ │ │ ├── page-transition.tsx │ │ │ │ │ ├── scheme-provider.tsx │ │ │ │ │ └── use-custom-theme.tsx │ │ │ │ ├── logo/ │ │ │ │ │ └── animated-logo.tsx │ │ │ │ ├── logs/ │ │ │ │ │ ├── clear-log-button.tsx │ │ │ │ │ ├── log-filter.tsx │ │ │ │ │ ├── log-item.module.scss │ │ │ │ │ ├── log-item.module.scss.d.ts │ │ │ │ │ ├── log-item.tsx │ │ │ │ │ ├── log-level.tsx │ │ │ │ │ ├── log-list.tsx │ │ │ │ │ ├── log-page.tsx │ │ │ │ │ ├── log-provider.tsx │ │ │ │ │ ├── log-toggle.tsx │ │ │ │ │ └── los-header.tsx │ │ │ │ ├── profiles/ │ │ │ │ │ ├── modules/ │ │ │ │ │ │ ├── chain-item.tsx │ │ │ │ │ │ ├── language-chip.tsx │ │ │ │ │ │ ├── side-chain.tsx │ │ │ │ │ │ ├── side-log.tsx │ │ │ │ │ │ └── store.ts │ │ │ │ │ ├── new-profile-button.tsx │ │ │ │ │ ├── profile-dialog.tsx │ │ │ │ │ ├── profile-item.tsx │ │ │ │ │ ├── profile-monaco-diff-viewer.tsx │ │ │ │ │ ├── profile-monaco-viewer.tsx │ │ │ │ │ ├── profile-side.tsx │ │ │ │ │ ├── provider.tsx │ │ │ │ │ ├── quick-import.tsx │ │ │ │ │ ├── read-profile.tsx │ │ │ │ │ ├── runtime-config-diff-dialog.tsx │ │ │ │ │ ├── script-dialog.tsx │ │ │ │ │ └── utils.ts │ │ │ │ ├── providers/ │ │ │ │ │ ├── block-task-provider.tsx │ │ │ │ │ ├── context-menu-provider.tsx │ │ │ │ │ ├── language-provider.tsx │ │ │ │ │ ├── nyanpasu-update-provider.tsx │ │ │ │ │ ├── proxies-provider-traffic.tsx │ │ │ │ │ ├── proxies-provider.tsx │ │ │ │ │ ├── rules-provider.tsx │ │ │ │ │ ├── theme-provider.tsx │ │ │ │ │ ├── update-providers.tsx │ │ │ │ │ └── update-proxies-providers.tsx │ │ │ │ ├── proxies/ │ │ │ │ │ ├── delay-button.tsx │ │ │ │ │ ├── delay-chip.tsx │ │ │ │ │ ├── feature-chip.tsx │ │ │ │ │ ├── group-list.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── node-card.module.d.scss.ts │ │ │ │ │ ├── node-card.module.scss │ │ │ │ │ ├── node-card.module.scss.d.ts │ │ │ │ │ ├── node-card.tsx │ │ │ │ │ ├── node-list.tsx │ │ │ │ │ ├── proxy-group-name.tsx │ │ │ │ │ ├── scroll-current-node.tsx │ │ │ │ │ ├── sort-selector.tsx │ │ │ │ │ └── utils.ts │ │ │ │ ├── router/ │ │ │ │ │ └── animated-outlet.tsx │ │ │ │ ├── rules/ │ │ │ │ │ ├── modules/ │ │ │ │ │ │ └── store.ts │ │ │ │ │ ├── rule-item.tsx │ │ │ │ │ └── rule-page.tsx │ │ │ │ ├── setting/ │ │ │ │ │ ├── modules/ │ │ │ │ │ │ ├── clash-core.tsx │ │ │ │ │ │ ├── clash-field.tsx │ │ │ │ │ │ ├── clash-web.tsx │ │ │ │ │ │ ├── hotkey-dialog.tsx │ │ │ │ │ │ ├── hotkey-input.module.d.scss.ts │ │ │ │ │ │ ├── hotkey-input.module.scss │ │ │ │ │ │ ├── hotkey-input.module.scss.d.ts │ │ │ │ │ │ ├── hotkey-input.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── nyanpasu-path.tsx │ │ │ │ │ │ ├── service-manual-prompt-dialog.module.d.scss.ts │ │ │ │ │ │ ├── service-manual-prompt-dialog.module.scss │ │ │ │ │ │ ├── service-manual-prompt-dialog.module.scss.d.ts │ │ │ │ │ │ ├── service-manual-prompt-dialog.tsx │ │ │ │ │ │ ├── system-proxy.tsx │ │ │ │ │ │ └── tray-icon-dialog.tsx │ │ │ │ │ ├── setting-clash-base.tsx │ │ │ │ │ ├── setting-clash-core.tsx │ │ │ │ │ ├── setting-clash-external.tsx │ │ │ │ │ ├── setting-clash-field.tsx │ │ │ │ │ ├── setting-clash-port.tsx │ │ │ │ │ ├── setting-clash-web.tsx │ │ │ │ │ ├── setting-nyanpasu-auto-reload.tsx │ │ │ │ │ ├── setting-nyanpasu-misc.tsx │ │ │ │ │ ├── setting-nyanpasu-path.tsx │ │ │ │ │ ├── setting-nyanpasu-tasks.tsx │ │ │ │ │ ├── setting-nyanpasu-ui.tsx │ │ │ │ │ ├── setting-nyanpasu-version.tsx │ │ │ │ │ ├── setting-page.tsx │ │ │ │ │ ├── setting-system-behavior.tsx │ │ │ │ │ ├── setting-system-proxy.tsx │ │ │ │ │ └── setting-system-service.tsx │ │ │ │ ├── settings/ │ │ │ │ │ └── system-proxy.tsx │ │ │ │ ├── ui/ │ │ │ │ │ ├── animated-item.tsx │ │ │ │ │ ├── border-beam.tsx │ │ │ │ │ ├── button.tsx │ │ │ │ │ ├── card.tsx │ │ │ │ │ ├── circle.tsx │ │ │ │ │ ├── context-menu.tsx │ │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ │ ├── file-drop-zone.tsx │ │ │ │ │ ├── highlight-text.tsx │ │ │ │ │ ├── image.tsx │ │ │ │ │ ├── input.tsx │ │ │ │ │ ├── modal.tsx │ │ │ │ │ ├── progress.tsx │ │ │ │ │ ├── ripple.tsx │ │ │ │ │ ├── scroll-area.tsx │ │ │ │ │ ├── select.tsx │ │ │ │ │ ├── separator.tsx │ │ │ │ │ ├── sidebar.tsx │ │ │ │ │ ├── slider-sidebar.tsx │ │ │ │ │ ├── slider.tsx │ │ │ │ │ ├── switch.tsx │ │ │ │ │ ├── text-marquee.tsx │ │ │ │ │ └── tooltip.tsx │ │ │ │ ├── updater/ │ │ │ │ │ ├── updater-dialog-wrapper.tsx │ │ │ │ │ ├── updater-dialog.module.scss │ │ │ │ │ ├── updater-dialog.module.scss.d.ts │ │ │ │ │ └── updater-dialog.tsx │ │ │ │ └── window/ │ │ │ │ ├── window-control.tsx │ │ │ │ ├── window-header.tsx │ │ │ │ └── window-title.tsx │ │ │ ├── consts.ts │ │ │ ├── hooks/ │ │ │ │ ├── theme.ts │ │ │ │ ├── use-consts.ts │ │ │ │ ├── use-core-icon.ts │ │ │ │ ├── use-current-core-icon.ts │ │ │ │ ├── use-element-breakpoints.ts │ │ │ │ ├── use-is-moblie.tsx │ │ │ │ ├── use-lock-fn.ts │ │ │ │ ├── use-store.ts │ │ │ │ ├── use-updater.ts │ │ │ │ ├── use-visibility.ts │ │ │ │ └── use-window-maximized.ts │ │ │ ├── locales/ │ │ │ │ ├── en.json │ │ │ │ ├── ru.json │ │ │ │ ├── zh-CN.json │ │ │ │ └── zh-TW.json │ │ │ ├── main.tsx │ │ │ ├── pages/ │ │ │ │ ├── (editor)/ │ │ │ │ │ └── editor/ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ ├── chip.tsx │ │ │ │ │ │ ├── header.tsx │ │ │ │ │ │ ├── hooks.tsx │ │ │ │ │ │ ├── loading-skeleton.tsx │ │ │ │ │ │ └── utils.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── route.tsx │ │ │ │ ├── (legacy)/ │ │ │ │ │ ├── connections.tsx │ │ │ │ │ ├── dashboard.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── logs.tsx │ │ │ │ │ ├── profiles.tsx │ │ │ │ │ ├── providers.tsx │ │ │ │ │ ├── proxies.tsx │ │ │ │ │ ├── route.tsx │ │ │ │ │ ├── rules.tsx │ │ │ │ │ └── settings.tsx │ │ │ │ ├── (main)/ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ ├── header-file-action.tsx │ │ │ │ │ │ ├── header-help-action.tsx │ │ │ │ │ │ ├── header-menu.tsx │ │ │ │ │ │ ├── header-settings-action.tsx │ │ │ │ │ │ ├── header.tsx │ │ │ │ │ │ └── navbar.tsx │ │ │ │ │ ├── main/ │ │ │ │ │ │ ├── connections/ │ │ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ │ │ └── table-row.tsx │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ ├── dashboard/ │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── logs/ │ │ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ │ │ ├── consts.ts │ │ │ │ │ │ │ │ └── log-level-badge.tsx │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ ├── profiles/ │ │ │ │ │ │ │ ├── $type/ │ │ │ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ │ │ │ ├── chain-profile-import.tsx │ │ │ │ │ │ │ │ │ ├── import-button.tsx │ │ │ │ │ │ │ │ │ ├── local-profile-button.tsx │ │ │ │ │ │ │ │ │ ├── profiles-header.tsx │ │ │ │ │ │ │ │ │ ├── profiles-list.tsx │ │ │ │ │ │ │ │ │ ├── remote-profile-button.tsx │ │ │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ │ │ ├── detail/ │ │ │ │ │ │ │ │ │ ├── $uid.tsx │ │ │ │ │ │ │ │ │ └── _modules/ │ │ │ │ │ │ │ │ │ ├── action-card.tsx │ │ │ │ │ │ │ │ │ ├── active-button.tsx │ │ │ │ │ │ │ │ │ ├── chian-editor-card.tsx │ │ │ │ │ │ │ │ │ ├── delete-profile.tsx │ │ │ │ │ │ │ │ │ ├── detial-header.tsx │ │ │ │ │ │ │ │ │ ├── open-locally.tsx │ │ │ │ │ │ │ │ │ ├── profile-name-editor.tsx │ │ │ │ │ │ │ │ │ ├── subscription-card.tsx │ │ │ │ │ │ │ │ │ ├── subscription-url-editor.tsx │ │ │ │ │ │ │ │ │ ├── update-option-editor.tsx │ │ │ │ │ │ │ │ │ └── view-content.tsx │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ │ │ ├── consts.ts │ │ │ │ │ │ │ │ ├── error-item.tsx │ │ │ │ │ │ │ │ ├── profile-quick-import.tsx │ │ │ │ │ │ │ │ └── profiles-navigate.tsx │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ ├── inspect/ │ │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ ├── providers/ │ │ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ │ │ ├── providers-title.tsx │ │ │ │ │ │ │ │ ├── use-proxies-provider-update.tsx │ │ │ │ │ │ │ │ ├── use-proxies-subscription.tsx │ │ │ │ │ │ │ │ └── use-rules-provider-update.tsx │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ ├── proxies/ │ │ │ │ │ │ │ │ ├── $key.tsx │ │ │ │ │ │ │ │ └── _modules/ │ │ │ │ │ │ │ │ ├── info-card.tsx │ │ │ │ │ │ │ │ └── subscription-card.tsx │ │ │ │ │ │ │ ├── route.tsx │ │ │ │ │ │ │ └── rules/ │ │ │ │ │ │ │ ├── $key.tsx │ │ │ │ │ │ │ └── _modules/ │ │ │ │ │ │ │ └── info-card.tsx │ │ │ │ │ │ ├── proxies/ │ │ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ │ │ └── proxies-navigate.tsx │ │ │ │ │ │ │ ├── group/ │ │ │ │ │ │ │ │ ├── $name.tsx │ │ │ │ │ │ │ │ └── _modules/ │ │ │ │ │ │ │ │ ├── delay-test-button.tsx │ │ │ │ │ │ │ │ ├── group-header.tsx │ │ │ │ │ │ │ │ └── proxy-node-button.tsx │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ ├── rules/ │ │ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ │ │ └── proxy-icon.tsx │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ └── settings/ │ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ │ ├── settings-card.tsx │ │ │ │ │ │ │ ├── settings-navigate.tsx │ │ │ │ │ │ │ └── settings-title.tsx │ │ │ │ │ │ ├── about/ │ │ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ │ │ └── nyanpasu-version.tsx │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ ├── clash/ │ │ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ │ │ ├── allow-lan-switch.tsx │ │ │ │ │ │ │ │ ├── core-manager-card.tsx │ │ │ │ │ │ │ │ ├── field-filter-card.tsx │ │ │ │ │ │ │ │ ├── field-filter-switch.tsx │ │ │ │ │ │ │ │ ├── ipv6-switch.tsx │ │ │ │ │ │ │ │ ├── log-level-selector.tsx │ │ │ │ │ │ │ │ ├── mixed-port-config.tsx │ │ │ │ │ │ │ │ ├── random-port-switch.tsx │ │ │ │ │ │ │ │ └── tun-stack-selector.tsx │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ ├── debug/ │ │ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ │ │ ├── advance-tools-switch.tsx │ │ │ │ │ │ │ │ ├── block-task-viewer.tsx │ │ │ │ │ │ │ │ ├── debug-provider.tsx │ │ │ │ │ │ │ │ ├── kv-storage.tsx │ │ │ │ │ │ │ │ ├── path-utils-card.tsx │ │ │ │ │ │ │ │ └── window-debug.tsx │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── nyanpasu/ │ │ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ │ │ └── log-file-config.tsx │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ ├── route.tsx │ │ │ │ │ │ ├── system/ │ │ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ │ │ ├── auto-launch-switch.tsx │ │ │ │ │ │ │ │ ├── current-system-proxy.tsx │ │ │ │ │ │ │ │ ├── proxy-bypass-config.tsx │ │ │ │ │ │ │ │ ├── proxy-guard-config.tsx │ │ │ │ │ │ │ │ ├── proxy-guard-switch.tsx │ │ │ │ │ │ │ │ ├── slient-launch-switch.tsx │ │ │ │ │ │ │ │ ├── system-service-ctrl.tsx │ │ │ │ │ │ │ │ ├── system-service-switch.tsx │ │ │ │ │ │ │ │ └── uwp-tools-button.tsx │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ ├── user-interface/ │ │ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ │ │ ├── language-selector.tsx │ │ │ │ │ │ │ │ ├── switch-legacy.tsx │ │ │ │ │ │ │ │ ├── theme-color-config.tsx │ │ │ │ │ │ │ │ └── theme-mode-selector.tsx │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ └── web-ui/ │ │ │ │ │ │ ├── _modules/ │ │ │ │ │ │ │ ├── core-secret-config.tsx │ │ │ │ │ │ │ ├── external-controller-config.tsx │ │ │ │ │ │ │ ├── port-strategy-selector.tsx │ │ │ │ │ │ │ └── web-ui.tsx │ │ │ │ │ │ └── route.tsx │ │ │ │ │ └── route.tsx │ │ │ │ ├── -__root.module.scss │ │ │ │ ├── -__root.module.scss.d.ts │ │ │ │ └── __root.tsx │ │ │ ├── route-tree.gen.ts │ │ │ ├── services/ │ │ │ │ ├── i18n.ts │ │ │ │ ├── monaco.ts │ │ │ │ ├── storage.ts │ │ │ │ └── types.d.ts │ │ │ ├── store/ │ │ │ │ ├── clash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── proxies.ts │ │ │ │ ├── service.ts │ │ │ │ └── updater.ts │ │ │ └── utils/ │ │ │ ├── chain.ts │ │ │ ├── get-system.ts │ │ │ ├── ignore-case.ts │ │ │ ├── index.ts │ │ │ ├── language.ts │ │ │ ├── monaco-yaml.worker.ts │ │ │ ├── mui-theme.ts │ │ │ ├── mutation.ts │ │ │ ├── notification.ts │ │ │ ├── parse-hotkey.ts │ │ │ ├── parse-traffic.ts │ │ │ ├── routes-utils.ts │ │ │ ├── shiki.ts │ │ │ └── styled.ts │ │ ├── tailwind.config.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ ├── tsr.config.json │ │ └── vite.config.ts │ └── ui/ │ ├── package.json │ ├── src/ │ │ ├── chart/ │ │ │ ├── index.ts │ │ │ └── sparkline.tsx │ │ ├── hooks/ │ │ │ ├── get-system.ts │ │ │ ├── index.ts │ │ │ ├── use-breakpoint.ts │ │ │ └── use-click-position.ts │ │ ├── index.ts │ │ ├── materialYou/ │ │ │ ├── components/ │ │ │ │ ├── baseCard/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── style.module.d.scss.ts │ │ │ │ │ ├── style.module.scss │ │ │ │ │ └── style.module.scss.d.ts │ │ │ │ ├── baseDialog/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── basePage/ │ │ │ │ │ ├── baseErrorBoundary.tsx │ │ │ │ │ ├── header.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── style.scss │ │ │ │ ├── expand/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── expandMore/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── floatingButton/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── item/ │ │ │ │ │ ├── baseItem.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menuItem.tsx │ │ │ │ │ ├── numberItem.tsx │ │ │ │ │ ├── switchItem.tsx │ │ │ │ │ └── textItem.tsx │ │ │ │ ├── kbd/ │ │ │ │ │ ├── index.module.d.scss.ts │ │ │ │ │ ├── index.module.scss │ │ │ │ │ ├── index.module.scss.d.ts │ │ │ │ │ └── index.tsx │ │ │ │ ├── lazyImage/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── loadingButton/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── loadingSwitch/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── style.module.d.scss.ts │ │ │ │ │ ├── style.module.scss │ │ │ │ │ └── style.module.scss.d.ts │ │ │ │ └── sidePage/ │ │ │ │ ├── index.tsx │ │ │ │ ├── style.module.d.scss.ts │ │ │ │ ├── style.module.scss │ │ │ │ └── style.module.scss.d.ts │ │ │ ├── createTheme.ts │ │ │ ├── index.ts │ │ │ ├── themeComponents/ │ │ │ │ ├── MuiButton.ts │ │ │ │ ├── MuiCard.ts │ │ │ │ ├── MuiCardContent.ts │ │ │ │ ├── MuiDialog.ts │ │ │ │ ├── MuiDialogActions.ts │ │ │ │ ├── MuiDialogContent.ts │ │ │ │ ├── MuiDialogTitle.ts │ │ │ │ ├── MuiLinearProgress.ts │ │ │ │ ├── MuiMenu.ts │ │ │ │ ├── MuiPaper.ts │ │ │ │ ├── MuiSwitch.ts │ │ │ │ ├── MuiToggleButtonGroup.ts │ │ │ │ └── index.ts │ │ │ └── themeConsts.mjs │ │ └── utils/ │ │ ├── cn.ts │ │ ├── color-mix.ts │ │ ├── event.ts │ │ ├── index.ts │ │ └── ts-helper.ts │ ├── tsconfig.json │ └── vite.config.ts ├── knip.config.ts ├── manifest/ │ ├── site/ │ │ ├── index.html │ │ └── updater/ │ │ └── .gitkeep │ └── version.json ├── package.json ├── pnpm-workspace.yaml ├── renovate.json ├── rust-toolchain.toml ├── scripts/ │ ├── .gitignore │ ├── .vscode/ │ │ └── settings.json │ ├── deno/ │ │ ├── README.md │ │ ├── build-cache.ts │ │ ├── check.ts │ │ ├── deno.jsonc │ │ ├── generate-latest-version.ts │ │ ├── manifest.ts │ │ ├── telegram-notify.ts │ │ ├── upload-build-artifacts.ts │ │ ├── upload-macos-updater.ts │ │ └── utils/ │ │ ├── cache-client.ts │ │ ├── file-server.ts │ │ └── logger.ts │ ├── generate-git-info.ts │ ├── generate-latest-version.ts │ ├── manifest/ │ │ ├── clash-meta.ts │ │ ├── clash-premium.ts │ │ ├── clash-rs.ts │ │ └── index.ts │ ├── osx-aarch64-upload.ts │ ├── package.json │ ├── portable.ts │ ├── prepare-nightly.ts │ ├── prepare-preview.ts │ ├── prepare-release.ts │ ├── publish.ts │ ├── tsconfig.json │ ├── types/ │ │ └── index.ts │ ├── updatelog.ts │ ├── updater-nightly.ts │ ├── updater.ts │ └── utils/ │ ├── arch-check.ts │ ├── consts.ts │ ├── download.ts │ ├── env.ts │ ├── index.ts │ ├── logger.ts │ ├── manifest.ts │ ├── octokit.ts │ ├── resolve.ts │ ├── resource.ts │ ├── shell.ts │ └── telegram.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf indent_size = 2 insert_final_newline = true [*.lua] charset = utf-8 indent_size = 4 [*.rs] charset = utf-8 end_of_line = lf indent_size = 4 insert_final_newline = true ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yaml ================================================ # thanks https://github.com/Ehviewer-Overhauled/Ehviewer templates. name: Bug 反馈 / Bug report description: 提交一个问题报告 / Create a bug report labels: - 'T: Bug' - 'S: Untriaged' body: - type: markdown attributes: value: | 提交问题报告前,还请首先完成文末的自查步骤。 Please finish verify steps which list in the end first before create bug report. - type: textarea id: reproduce attributes: label: 复现步骤 / Step to reproduce description: | 请在此处写下复现的方式,并携带错误日志,必要情况请带上截图/录屏。 Please write down the reproduction steps here and include the error log. If necessary, please provide screenshots or recordings. placeholder: | 1. 2. 3. [录屏] / [Screen recording] validations: required: true - type: textarea id: expected attributes: label: 预期行为 / Expected behavior description: | 在此处说明正常情况下应用的预期行为。 Describe what should happened here. placeholder: | 它应该 XXX…… It should be ... validations: required: true - type: textarea id: actual attributes: label: 实际行为 / Actual behavior description: | 在此处描绘应用的实际行为,最好附上截图。 Describe what actually happened here, screenshots is better. placeholder: | 实际上它 XXX…… Actually it ... [截图] / [Screenshots] validations: required: true - type: textarea id: log attributes: label: 应用日志 / App logs description: | 请确保您已移除所有敏感信息,并确保你的日志等级为 `Trace` 或 `Debug`。请参考 [FAQ - 日志目录](https://nyanpasu.elaina.moe/zh-CN/others/faq.html#_2-clash-nyanpasu-%E5%BA%94%E7%94%A8-%E6%97%A5%E8%AE%B0%E7%9B%AE%E5%BD%95%E5%9C%A8%E5%93%AA%E9%87%8C) 如果您可以打开主界面,可以在设置页的“日志目录”旁找到“收集日志”按钮,点击即可收集日志。(1.5.0 不可用) 如果日志过长,请使用 [Gist](https://gist.github.com/) 或 [Hastebin](https://hastebin.com/) 并附上链接。 Please make sure you have removed all sensitive information and your log level is `Trace` or `Debug`. Please refer to [FAQ - Logs directory](https://nyanpasu.elaina.moe/others/faq.html#_2-where-is-the-clash-nyanpasu-application-logs-directory) If you can open the main interface, you can find the "Collect logs" button next to the "Open Logs Dir" in the settings page, click to collect the logs. (Not available in 1.5.0) If the log is too long, please use [Gist](https://gist.github.com/) or [Hastebin](https://hastebin.com/) and provide the link. placeholder: | 填写一个链接、一个代码块或一个压缩文件 Should be a link, a code block or a archive file validations: required: false - type: textarea id: more attributes: label: 备注 / Addition details description: | 在此处写下其他您想说的内容。 Describe addition details here. placeholder: | 其他有用的信息与附件 Additional details and attachments validations: required: false - type: textarea id: env_infos attributes: label: 环境信息 / Environment information description: | 请在此处提供您的环境信息,例如操作系统、Clash Nyanpasu 版本号、Clash 内核及其版本号等。 Please provide your environment information here, such as operating system, Clash Nyanpasu version number, Clash core and its version number, etc. placeholder: | 此处应由 Nyanpasu 设置页面的反馈按钮自动填写。如果是老版本,请手动填写。 This should be automatically filled in by the feedback button on the Nyanpasu settings page. If it is an old version, please fill it in manually. validations: required: true # - type: input # id: version # attributes: # label: Clash Nyanpasu 版本号 / Clash Nyanpasu version # description: | # 您可以在 **设置 - Nyanpasu 版本** 或在 **托盘 - 更多** 中找到版本号。 # You can find the version number in **Settings - Nyanpasu Version** or **Tray - More**. # placeholder: 1.5.0 # validations: # required: true # - type: input # id: core-version # attributes: # label: Clash 核心及其版本号 / Clash core and version # description: | # 您可以在 **设置 - Clash 内核** 中找到内核及其版本号。 # You can find the core and its version number in **Settings - Clash Core**. # placeholder: v1.18.1 Meta # validations: # required: true # - type: input # id: pre-release # attributes: # label: 是否为 Pre-release / Is pre-release version # description: | # 是否为 Pre-release 下载的应用,若是则填写对应的 commit hash。 # Is this an app downloaded from Pre-release? If so, please fill in the corresponding commit hash. # placeholder: 26f05a0 # validations: # required: true # - type: input # id: system # attributes: # label: 操作系统及版本 / OS version # description: 操作系统 + 版本号 / OS + version number # placeholder: Windows 11, macOS 14 # validations: # required: true - type: checkboxes id: check attributes: label: 自查步骤 / Verify steps description: | 请确认您已经遵守所有必选项。 Please ensure you have obtained all needed options. options: - label: 如果您有足够的时间和能力,并愿意为此提交 PR,请勾上此复选框 / Pull request is welcome. Check this if you want to start a pull request required: false - label: 您已知悉如果没有提供正确的系统信息,以及日志,您的 Issue 会直接被关闭 / You have known that if you don't provide correct system information and logs, your issue will be closed directly required: true - label: 您已仔细查看并知情 [Q&A](https://nyanpasu.elaina.moe/zh-CN/others/issues) 和 [FAQ](https://nyanpasu.elaina.moe/zh-CN/others/faq) 中的内容 / You have read and understood the contents of [Q&A](https://nyanpasu.elaina.moe/others/issues) and [FAQ](https://nyanpasu.elaina.moe/others/faq) required: true - label: 您已搜索过 [Issue Tracker](https://github.com/libnyanpasu/clash-nyanpasu/issues),没有找到类似内容 / I have searched on [Issue Tracker](https://github.com/libnyanpasu/clash-nyanpasu/issues), No duplicate or related open issue has been found required: true - label: 您确保这个 Issue 只提及一个问题。如果您有多个问题报告,烦请发起多个 Issue / Ensure there is only one bug report in this issue. Please make multiply issue for multiply bugs required: true - label: 您确保已使用最新 Pre-release 版本测试,并且该问题在最新 Pre-release 版本中并未解决 / This bug have not solved in latest Pre-release version required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: 所有其他问题 / All other questions url: https://github.com/libnyanpasu/clash-nyanpasu/discussions about: 转到 Discussions / Turn to discussions ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yaml ================================================ # thanks https://github.com/Ehviewer-Overhauled/Ehviewer templates. name: 功能请求 / Feature request description: 提出一个功能建议 / Suggest an idea labels: - 'T: Feature' - 'S: Untriaged' body: - type: markdown attributes: value: | 提交功能建议前,还请首先完成文末的自查步骤。 Please finish verify steps which list in the end first before suggest an idea. - type: textarea id: request attributes: label: 需求 / Requirement description: | 在此处描述您的需求。这通常会是一个您想要的功能。 Describe what you need here. placeholder: | 我需要 XXX 功能…… I want ABC feature ... validations: required: true - type: textarea id: impl attributes: label: 建议实现 / Suggested implements description: | 在此处表述您建议的实现方式。如有可能,UI 类功能请求还请尽量附上图示。 Describe your suggested implements here. It's recommend to add a photo if you are making a UI feature request. placeholder: | 建议在 XX 处添加 XX…… I recommend add ABC feature to DEF ... 图片(如果有)/ Photos (if exists) validations: required: true - type: textarea id: more attributes: label: 备注 / Addition details description: | 在此处写下其他您想说的内容。 Describe addition details here. placeholder: | 其他有用的信息与附件 Additional details and attachments validations: required: false - type: input id: version attributes: label: Clash Nyanpasu 版本号 / Clash Nyanpasu description: | 您可以在 **设置 - Nyanpasu 版本** 处找到版本号。 You can get version code in **Settings - Nyanpasu Version**. placeholder: 1.4.1 validations: required: true - type: input id: pre-release attributes: label: 是否为 Pre-release / Is pre-release version description: | 是否为 Pre-release 下载的应用,若是则填写对应的 commit hash。 Is this an app downloaded from Pre-release? If so, please fill in the corresponding commit hash. placeholder: 26f05a0 validations: required: true - type: checkboxes id: check attributes: label: 自查步骤 / Verify steps description: | 请确认您已经遵守所有必选项。 Please ensure you have obtained all needed options. options: - label: 如果您有足够的时间和能力,并愿意为此提交 PR,请勾上此复选框 / Pull request is welcome. Check this if you want to start a pull request required: false - label: 您已仔细查看并知情 [Q&A](https://nyanpasu.elaina.moe/zh-CN/others/issues) 中的内容 / You have checked [Q&A](https://nyanpasu.elaina.moe/others/issues) carefully required: true - label: 您已搜索过 [Issue Tracker](https://github.com/libnyanpasu/clash-nyanpasu/issues),没有找到类似内容 / I have searched on [Issue Tracker](https://github.com/libnyanpasu/clash-nyanpasu/issues), No duplicate or related open issue has been found required: true - label: 您确保这个 Issue 只提及一个功能。如果您有多个功能请求,烦请发起多个 Issue / Ensure there is only one feature request in this issue. Please make multiply issue for multiply feature request required: true - label: 您确保已使用最新 Pre-release 版本测试,并且该功能在最新 Pre-release 版本中并未实现 / This feature have not implemented in latest Pre-release version required: true ================================================ FILE: .github/workflows/ci.yml ================================================ on: pull_request: branches: - main - dev - release-* push: branches: - main - dev - release-* # the name of our workflow name: CI jobs: lint: name: Lint strategy: matrix: targets: - os: ubuntu-latest - os: macos-latest - os: windows-latest runs-on: ${{ matrix.targets.os }} steps: - uses: actions/checkout@v6 - name: Rust run: | rustup toolchain install nightly --profile minimal --no-self-update rustup default nightly rustup component add clippy rustfmt rustc --version cargo --version rustup show - name: Tauri dependencies if: startsWith(matrix.targets.os, 'ubuntu-') run: >- sudo apt-get update && sudo apt-get install -y libgtk-3-dev libayatana-appindicator3-dev libwebkit2gtk-4.1-dev librsvg2-dev libxdo-dev webkit2gtk-driver xvfb - uses: maxim-lobanov/setup-xcode@v1 if: startsWith(matrix.targets.os, 'macos-') with: xcode-version: 'latest-stable' - name: Install Node.js uses: actions/setup-node@v6 with: node-version: 24 - uses: Swatinem/rust-cache@v2 name: Cache Rust dependencies with: workspaces: 'backend' save-if: ${{ github.event_name == 'push' }} - uses: pnpm/action-setup@v5 name: Install pnpm with: run_install: false - uses: denoland/setup-deno@v2 with: deno-version: v2.x - name: Get pnpm store directory shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - uses: actions/cache@v5 name: Setup pnpm cache with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- save-always: ${{ github.event_name == 'push' }} - name: Install dependencies run: pnpm install --no-frozen-lockfile - name: Prepare fronend run: pnpm -r build # Build frontend env: NODE_OPTIONS: '--max_old_space_size=4096' - name: Prepare sidecar and resources run: pnpm prepare:check - name: Lint if: startsWith(matrix.targets.os, 'ubuntu-') run: pnpm lint # Lint - name: Lint if: startsWith(matrix.targets.os, 'ubuntu-') == false run: pnpm run-p lint:clippy lint:rustfmt # Lint env: NODE_OPTIONS: '--max_old_space_size=4096' # TODO: support test cross-platform build: name: Build Tauri strategy: matrix: targets: - os: ubuntu-latest - os: macos-latest - os: windows-latest fail-fast: false if: > github.event_name != 'pull_request' || contains(github.event.pull_request.title, 'crate') || github.event.pull_request.user.login != 'renovate[bot]' runs-on: ${{ matrix.targets.os }} needs: lint steps: - uses: actions/checkout@v6 - name: Tauri dependencies if: startsWith(matrix.targets.os, 'ubuntu-') run: >- sudo apt-get update && sudo apt-get install -y libgtk-3-dev libayatana-appindicator3-dev libwebkit2gtk-4.1-dev librsvg2-dev libxdo-dev webkit2gtk-driver xvfb - uses: maxim-lobanov/setup-xcode@v1 if: startsWith(matrix.targets.os, 'macos-') with: xcode-version: 'latest-stable' - name: Install Node.js uses: actions/setup-node@v6 with: node-version: 24 - name: Install Deno uses: denoland/setup-deno@v2 with: deno-version: v2.x - uses: Swatinem/rust-cache@v2 name: Cache Rust dependencies with: workspaces: 'backend' save-if: ${{ github.event_name == 'push' }} - uses: pnpm/action-setup@v5 name: Install pnpm with: run_install: false - name: Get pnpm store directory shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - uses: actions/cache@v5 name: Setup pnpm cache with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- save-always: ${{ github.event_name == 'push' }} - name: Install dependencies run: pnpm install --no-frozen-lockfile - name: Prepare sidecar and resources run: pnpm prepare:check - name: Prepare frontend run: pnpm -r build env: NODE_OPTIONS: '--max_old_space_size=4096' - name: Build Backend run: cargo build --release --manifest-path backend/Cargo.toml test_unit: name: Unit Test needs: lint if: > github.event_name != 'pull_request' || contains(github.event.pull_request.title, 'crate') || github.event.pull_request.user.login != 'renovate[bot]' # we want to run on the latest linux environment strategy: matrix: os: - ubuntu-latest - macos-latest - windows-latest fail-fast: false runs-on: ${{ matrix.os }} # the steps our job runs **in order** steps: # checkout the code on the workflow runner - uses: actions/checkout@v6 # install system dependencies that Tauri needs to compile on Linux. # note the extra dependencies for `tauri-driver` to run which are: `webkit2gtk-driver` and `xvfb` - name: Tauri dependencies if: startsWith(matrix.os, 'ubuntu-') run: >- sudo apt-get update && sudo apt-get install -y libgtk-3-dev libayatana-appindicator3-dev libwebkit2gtk-4.1-dev librsvg2-dev libxdo-dev webkit2gtk-driver xvfb - uses: maxim-lobanov/setup-xcode@v1 if: startsWith(matrix.os, 'macos-') with: xcode-version: 'latest-stable' - name: Install Node.js uses: actions/setup-node@v6 with: node-version: 24 - name: Install Deno uses: denoland/setup-deno@v2 with: deno-version: v2.x - uses: Swatinem/rust-cache@v2 name: Cache Rust dependencies with: workspaces: 'backend' save-if: ${{ github.event_name == 'push' }} - uses: pnpm/action-setup@v5 name: Install pnpm with: run_install: false - name: Get pnpm store directory shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - uses: actions/cache@v5 name: Setup pnpm cache with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- save-always: ${{ github.event_name == 'push' }} - name: Install dependencies run: pnpm install --no-frozen-lockfile - name: Prepare sidecar and resources run: pnpm prepare:check - name: Prepare frontend run: pnpm -r build env: NODE_OPTIONS: '--max_old_space_size=4096' - name: Free up disk space if: startsWith(matrix.os, 'ubuntu-') run: | df -h sudo rm -rf /usr/share/dotnet sudo rm -rf /usr/local/lib/android sudo rm -rf /opt/ghc sudo rm -rf /opt/hostedtoolcache/CodeQL sudo docker image prune --all --force df -h - name: Test run: pnpm test ================================================ FILE: .github/workflows/daily.yml ================================================ on: workflow_dispatch: schedule: - cron: '15 22 * * *' # 每天 06:15 UTC+8 自动构建 name: Daily jobs: generate_manifest: name: Generate Manifest runs-on: ubuntu-latest if: startsWith(github.repository, 'libnyanpasu') steps: - name: Checkout uses: actions/checkout@v6 - name: Install Node uses: actions/setup-node@v6 with: node-version: '24' - uses: pnpm/action-setup@v5 name: Install pnpm with: run_install: false - name: Install Deno uses: denoland/setup-deno@v2 with: deno-version: v2.x - name: Install dependencies run: pnpm install - name: Generate Manifest run: pnpm generate:manifest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # if nothing changed, skip commit - name: Check for changes id: git-check run: echo ::set-output name=has-changes::$(if git diff --quiet; then echo "false"; else echo "true"; fi) - uses: oleksiyrudenko/gha-git-credentials@v2-latest if: steps.git-check.outputs.has-changes == 'true' with: token: '${{ secrets.GITHUB_TOKEN }}' name: 'github-actions[bot]' email: '41898282+github-actions[bot]@users.noreply.github.com' - name: Commit Manifest if: steps.git-check.outputs.has-changes == 'true' run: | git add . git commit -m "chore(manifest): update manifest [skip ci]" git push generate_manifest_v1: name: Generate Manifest V1 runs-on: ubuntu-latest if: startsWith(github.repository, 'libnyanpasu') steps: - name: Checkout uses: actions/checkout@v6 with: ref: dev - name: Install Node uses: actions/setup-node@v6 with: node-version: '24' - uses: pnpm/action-setup@v5 name: Install pnpm with: run_install: false - name: Install Deno uses: denoland/setup-deno@v2 with: deno-version: v2.x - name: Install dependencies run: pnpm install - name: Generate Manifest run: pnpm generate:manifest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # if nothing changed, skip commit - name: Check for changes id: git-check run: echo ::set-output name=has-changes::$(if git diff --quiet; then echo "false"; else echo "true"; fi) - uses: oleksiyrudenko/gha-git-credentials@v2-latest if: steps.git-check.outputs.has-changes == 'true' with: token: '${{ secrets.GITHUB_TOKEN }}' name: 'github-actions[bot]' email: '41898282+github-actions[bot]@users.noreply.github.com' - name: Commit Manifest if: steps.git-check.outputs.has-changes == 'true' run: | git add . git commit -m "chore(manifest): update manifest [skip ci]" git push ================================================ FILE: .github/workflows/deps-build-linux.yaml ================================================ name: '[Single] Build Linux' on: workflow_dispatch: inputs: nightly: description: 'Nightly prepare' required: true type: boolean default: false tag: description: 'Release Tag' required: true type: string arch: type: choice description: 'build arch target' required: true default: 'x86_64' options: - x86_64 - i686 - aarch64 - armel - armhf workflow_call: inputs: nightly: description: 'Nightly prepare' required: true type: boolean default: false tag: description: 'Release Tag' required: true type: string arch: type: string description: 'build arch target' required: true default: 'x86_64' jobs: build: runs-on: ubuntu-24.04 steps: - name: Checkout repository uses: actions/checkout@v6 - name: Install Rust nightly run: | rustup install nightly --profile minimal --no-self-update rustup default nightly - name: Setup Cargo binstall if: ${{ inputs.arch != 'x86_64' }} uses: cargo-bins/cargo-binstall@main - name: Setup Cross Toolchain if: ${{ inputs.arch != 'x86_64' }} shell: bash run: | case "${{ inputs.arch }}" in "i686") rustup target add i686-unknown-linux-gnu ;; "aarch64") rustup target add aarch64-unknown-linux-gnu ;; "armel") rustup target add armv7-unknown-linux-gnueabi ;; "armhf") rustup target add armv7-unknown-linux-gnueabihf ;; esac cargo binstall -y cross - name: Setup Toolchain run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libxdo-dev libappindicator3-dev librsvg2-dev patchelf openssl - name: Install Node latest uses: actions/setup-node@v6 with: node-version: 24 - uses: pnpm/action-setup@v5 name: Install pnpm with: run_install: false - uses: denoland/setup-deno@v2 with: deno-version: v2.x - name: Get pnpm store directory shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - uses: actions/cache@v5 name: Setup pnpm cache with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - uses: Swatinem/rust-cache@v2 name: Cache Rust dependencies with: workspaces: 'backend' key: ${{ inputs.arch }} - name: Install Node.js dependencies run: pnpm i - name: Prepare sidecars and resources shell: bash run: | case "${{ inputs.arch }}" in "x86_64") pnpm prepare:check ;; "i686") pnpm prepare:check --arch ia32 --sidecar-host i686-unknown-linux-gnu ;; "aarch64") pnpm prepare:check --arch arm64 --sidecar-host aarch64-unknown-linux-gnu ;; "armel") pnpm prepare:check --arch armel --sidecar-host armv7-unknown-linux-gnueabi ;; "armhf") pnpm prepare:check --arch arm --sidecar-host armv7-unknown-linux-gnueabihf ;; esac - name: Nightly Prepare if: ${{ inputs.nightly == true }} run: | pnpm prepare:nightly ${{ inputs.arch != 'x86_64' && '--disable-updater'}} - name: Build UI run: pnpm -F ui build # =========================== # GTK 图标修复步骤(适用于所有架构) # =========================== - name: Fix GTK Icon Names run: | ORIGINAL_NAME="Clash Nyanpasu" FIXED_NAME="clash_nyanpasu" ICON_SIZES=("32x32" "128x128" "256x256@2") case "${{ inputs.arch }}" in "x86_64") TARGET_DIR="backend/target/release" ;; "i686") TARGET_DIR="backend/target/i686-unknown-linux-gnu/release" ;; "aarch64") TARGET_DIR="backend/target/aarch64-unknown-linux-gnu/release" ;; "armel") TARGET_DIR="backend/target/armv7-unknown-linux-gnueabi/release" ;; "armhf") TARGET_DIR="backend/target/armv7-unknown-linux-gnueabihf/release" ;; *) TARGET_DIR="backend/target/release" ;; esac for size in "${ICON_SIZES[@]}"; do ICON_PATH="$TARGET_DIR/icons/hicolor/${size}/apps/${ORIGINAL_NAME}.png" FIXED_ICON_PATH="$TARGET_DIR/icons/hicolor/${size}/apps/${FIXED_NAME}.png" if [ -f "$ICON_PATH" ]; then mv "$ICON_PATH" "$FIXED_ICON_PATH" echo "Renamed $ICON_PATH -> $FIXED_ICON_PATH" fi done DESKTOP_FILE="$TARGET_DIR/share/applications/${ORIGINAL_NAME}.desktop" if [ -f "$DESKTOP_FILE" ]; then sed -i "s/Icon=${ORIGINAL_NAME}/Icon=${FIXED_NAME}/g" "$DESKTOP_FILE" echo "Updated desktop file Icon field" fi ICON_CACHE_DIR="$TARGET_DIR/icons/hicolor" if [ -d "$ICON_CACHE_DIR" ]; then gtk-update-icon-cache -f -t "$ICON_CACHE_DIR" echo "GTK icon cache updated" fi # =========================== - name: Tauri build (x86_64) if: ${{ inputs.arch == 'x86_64' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} NIGHTLY: ${{ inputs.nightly == true && 'true' || 'false' }} run: | pnpm tauri build ${{ inputs.nightly == true && '-f nightly -c ./backend/tauri/tauri.nightly.conf.json' || '-f default-meta' }} - name: Tauri build and upload (cross) if: ${{ inputs.arch != 'x86_64' }} shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} NIGHTLY: ${{ inputs.nightly == true && 'true' || 'false' }} run: | case "${{ inputs.arch }}" in "i686") ${{ inputs.nightly == true && 'pnpm build:nightly -r cross --target i686-unknown-linux-gnu -b "rpm,deb"' || 'pnpm build -r cross --target i686-unknown-linux-gnu -b "rpm,deb" -c "{ "bundle": { "createUpdaterArtifacts": false } }"' }} ;; "aarch64") ${{ inputs.nightly == true && 'pnpm build:nightly -r cross --target aarch64-unknown-linux-gnu -b "rpm,deb"' || 'pnpm build -r cross --target aarch64-unknown-linux-gnu -b "rpm,deb" -c "{ "bundle": { "createUpdaterArtifacts": false } }"' }} ;; "armel") ${{ inputs.nightly == true && 'pnpm build:nightly -r cross --target armv7-unknown-linux-gnueabi -b "rpm,deb"' || 'pnpm build -r cross --target armv7-unknown-linux-gnueabi -b "rpm,deb" -c "{ "bundle": { "createUpdaterArtifacts": false } }"' }} ;; "armhf") ${{ inputs.nightly == true && 'pnpm build:nightly -r cross --target armv7-unknown-linux-gnueabihf -b "rpm,deb"' || 'pnpm build -r cross --target armv7-unknown-linux-gnueabihf -b "rpm,deb" -c "{ "bundle": { "createUpdaterArtifacts": false } }"' }} ;; esac - name: Calc the archive signature run: | find ./backend/target \( -name "*.deb" -o -name "*.rpm" \) | while read file; do sha_file="$file.sha256" if [[ ! -f "$sha_file" ]]; then sha256sum "$file" > "$sha_file" echo "Created checksum file for: $file" fi done - name: Upload AppImage to Github Artifact if: ${{ inputs.arch == 'x86_64' }} uses: actions/upload-artifact@v7 with: name: Clash.Nyanpasu-linux-${{ inputs.arch }}-appimage path: | ./backend/target/**/*.AppImage ./backend/target/**/*.AppImage.tar.gz ./backend/target/**/*.AppImage.tar.gz.sig - name: Upload deb to Github Artifact uses: actions/upload-artifact@v7 with: name: Clash.Nyanpasu-linux-${{ inputs.arch }}-deb path: | ./backend/target/**/*.deb ./backend/target/**/*.deb.sha256 - name: Upload rpm to Github Artifact uses: actions/upload-artifact@v7 with: name: Clash.Nyanpasu-linux-${{ inputs.arch }}-rpm path: | ./backend/target/**/*.rpm ./backend/target/**/*.rpm.sha256 - name: Set file server folder path if: ${{ inputs.nightly == true }} shell: bash run: | GIT_HASH=$(git rev-parse --short HEAD) echo "FOLDER_PATH=nightly/${GIT_HASH}" >> $GITHUB_ENV - name: Upload to file server if: ${{ inputs.nightly == true }} shell: bash continue-on-error: true run: | case "${{ inputs.arch }}" in "x86_64") deno run -A scripts/deno/upload-build-artifacts.ts \ "backend/target/**/*.deb" \ "backend/target/**/*.AppImage" ;; *) deno run -A scripts/deno/upload-build-artifacts.ts \ "backend/target/**/*.deb" \ "backend/target/**/*.rpm" ;; esac env: FILE_SERVER_TOKEN: ${{ secrets.FILE_SERVER_TOKEN }} FOLDER_PATH: ${{ env.FOLDER_PATH }} - name: Upload file server results if: ${{ inputs.nightly == true }} uses: actions/upload-artifact@v7 with: name: upload-results-linux-${{ inputs.arch }} path: ./upload-results.json if-no-files-found: ignore ================================================ FILE: .github/workflows/deps-build-macos.yaml ================================================ name: '[Single] Build macOS' on: workflow_dispatch: inputs: aarch64: description: 'Build aarch64 pkg' required: true type: boolean default: false nightly: description: 'Nightly prepare' required: true type: boolean default: false tag: description: 'Release Tag' required: true type: string workflow_call: inputs: aarch64: description: 'Build aarch64 pkg' required: true type: boolean default: false nightly: description: 'Nightly prepare' required: true type: boolean default: false tag: description: 'Release Tag' required: true type: string jobs: build: runs-on: macos-latest steps: - name: Checkout repository uses: actions/checkout@v6 - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable - name: install Rust nightly run: | rustup install nightly --profile minimal --no-self-update rustup default nightly - name: Install Rust intel target if: ${{ inputs.aarch64 == false }} run: | rustup target add x86_64-apple-darwin - name: Install Rust aarch64 target if: ${{ inputs.aarch64 == true }} run: | rustup target add aarch64-apple-darwin - name: Install Node latest uses: actions/setup-node@v6 with: node-version: 24 - uses: denoland/setup-deno@v2 with: deno-version: v2.x - uses: pnpm/action-setup@v5 name: Install pnpm with: run_install: false - uses: denoland/setup-deno@v2 with: deno-version: v2.x - name: Get pnpm store directory shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - uses: actions/cache@v5 name: Setup pnpm cache with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - uses: Swatinem/rust-cache@v2 name: Cache Rust dependencies with: workspaces: 'backend' key: ${{ inputs.aarch64 == true && 'aarch64' || 'x86_64' }} - name: Pnpm install shell: bash run: | pnpm i - name: Download Sidecars aarch64 if: ${{ inputs.aarch64 == true }} run: pnpm prepare:check --arch arm64 --sidecar-host aarch64-apple-darwin - name: Download Sidecars x64 if: ${{ inputs.aarch64 == false }} run: pnpm prepare:check --arch x64 --sidecar-host x86_64-apple-darwin - name: Nightly Prepare if: ${{ inputs.nightly == true }} run: | pnpm prepare:nightly - name: Build UI run: | pnpm -F ui build - name: Build Clash Nyanpasu (Stable) if: ${{ inputs.nightly == false }} run: | pnpm tauri build --verbose -f default-meta ${{ inputs.aarch64 == true && '--target aarch64-apple-darwin' || '--target x86_64-apple-darwin' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} NIGHTLY: ${{ inputs.nightly == true && 'true' || 'false' }} NODE_OPTIONS: '--max_old_space_size=4096' - name: Build Clash Nyanpasu (Nightly) if: ${{ inputs.nightly == true }} run: | pnpm build:nightly --verbose ${{ inputs.aarch64 == true && '--target aarch64-apple-darwin' || '--target x86_64-apple-darwin' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} NIGHTLY: ${{ inputs.nightly == true && 'true' || 'false' }} NODE_OPTIONS: '--max_old_space_size=4096' - name: Rename updater files run: | deno run -A scripts/deno/upload-macos-updater.ts env: TARGET_ARCH: ${{ inputs.aarch64 == true && 'aarch64' || 'x86_64' }} - name: Upload to Github Artifact uses: actions/upload-artifact@v7 with: name: Clash.Nyanpasu-macOS-${{ inputs.aarch64 == true && 'aarch64' || 'amd64' }} path: | ./backend/target/**/*.dmg ./backend/target/**/*.tar.gz ./backend/target/**/*.tar.gz.sig - name: Set file server folder path if: ${{ inputs.nightly == true }} shell: bash run: | GIT_HASH=$(git rev-parse --short HEAD) echo "FOLDER_PATH=nightly/${GIT_HASH}" >> $GITHUB_ENV - name: Upload to file server if: ${{ inputs.nightly == true }} shell: bash continue-on-error: true run: | deno run -A scripts/deno/upload-build-artifacts.ts \ "backend/target/**/*.dmg" env: FILE_SERVER_TOKEN: ${{ secrets.FILE_SERVER_TOKEN }} FOLDER_PATH: ${{ env.FOLDER_PATH }} - name: Upload file server results if: ${{ inputs.nightly == true }} uses: actions/upload-artifact@v7 with: name: upload-results-macos-${{ inputs.aarch64 == true && 'aarch64' || 'amd64' }} path: ./upload-results.json if-no-files-found: ignore ================================================ FILE: .github/workflows/deps-build-windows-nsis.yaml ================================================ name: '[Single] Build Windows NSIS' on: workflow_dispatch: inputs: portable: description: 'Build Portable pkg' required: true type: boolean default: false fixed-webview: description: 'Fixed WebView' required: true type: boolean default: false nightly: description: 'Nightly prepare' required: true type: boolean default: false tag: description: 'Release Tag' required: true type: string arch: type: choice description: 'build arch target' required: true default: 'x86_64' options: - x86_64 - i686 - aarch64 workflow_call: inputs: portable: description: 'Build Portable pkg' required: true type: boolean default: false fixed-webview: description: 'Fixed WebView' required: true type: boolean default: false nightly: description: 'Nightly prepare' required: true type: boolean default: false tag: description: 'Release Tag' required: true type: string arch: type: string description: 'build arch target' required: true default: 'x86_64' jobs: build: runs-on: windows-latest steps: - name: Checkout repository uses: actions/checkout@v6 - name: Install Rust nightly run: | rustup install nightly --profile minimal --no-self-update rustup default nightly - name: Setup Rust target if: ${{ inputs.arch != 'x86_64' }} run: | rustup target add ${{ inputs.arch }}-pc-windows-msvc - name: Install Node latest uses: actions/setup-node@v6 with: node-version: 24 - uses: pnpm/action-setup@v5 name: Install pnpm with: run_install: false - uses: denoland/setup-deno@v2 with: deno-version: v2.x - name: Get pnpm store directory shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - uses: actions/cache@v5 name: Setup pnpm cache with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - uses: Swatinem/rust-cache@v2 name: Cache Rust dependencies with: workspaces: 'backend' key: ${{ inputs.arch }} - name: Install Node.js dependencies run: | pnpm i - name: Prepare sidecars and resources run: | $condition = '${{ inputs.arch }}' switch ($condition) { 'x86_64' { pnpm prepare:check } 'i686' { pnpm prepare:check --arch ia32 --sidecar-host i686-pc-windows-msvc } 'aarch64' { pnpm prepare:check --arch arm64 --sidecar-host aarch64-pc-windows-msvc } } - name: Download fixed WebView if: ${{ inputs.fixed-webview == true }} run: | $condition = '${{ inputs.arch }}' switch ($condition) { 'x86_64' { $arch= 'x64' } 'i686' { $arch = 'x86' } 'aarch64' { $arch = 'arm64' } } $version = '127.0.2651.105' $uri = "https://github.com/westinyang/WebView2RuntimeArchive/releases/download/$version/Microsoft.WebView2.FixedVersionRuntime.$version.$arch.cab" $outfile = "Microsoft.WebView2.FixedVersionRuntime.$version.$arch.cab" echo "Downloading $uri to $outfile" invoke-webrequest -uri $uri -outfile $outfile echo "Download finished, attempting to extract" expand.exe $outfile -F:* ./backend/tauri echo "Extraction finished" - name: Prepare (Windows NSIS and Portable) if: ${{ inputs.fixed-webview == false }} run: ${{ inputs.nightly == true && 'pnpm prepare:nightly --nsis' || 'pnpm prepare:release --nsis' }} - name: Prepare (Windows NSIS and Portable) with fixed WebView if: ${{ inputs.fixed-webview == true }} run: ${{ inputs.nightly == true && 'pnpm prepare:nightly --nsis --fixed-webview' || 'pnpm prepare:release --nsis --fixed-webview' }} - name: Build UI run: | pnpm -F ui build # TODO: optimize strategy - name: Tauri build x86_64 if: ${{ inputs.arch == 'x86_64' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} NIGHTLY: ${{ inputs.nightly == true && 'true' || 'false' }} run: | pnpm tauri build ${{ inputs.nightly == true && '-f nightly -c ./backend/tauri/tauri.nightly.conf.json' || '-f default-meta' }} - name: Tauri build i686 if: ${{ inputs.arch == 'i686' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} NIGHTLY: ${{ inputs.nightly == true && 'true' || 'false' }} run: | pnpm tauri build ${{ inputs.nightly == true && '-f nightly -c ./backend/tauri/tauri.nightly.conf.json --target i686-pc-windows-msvc' || '-f default-meta --target i686-pc-windows-msvc' }} - name: Tauri build arm64 if: ${{ inputs.arch == 'aarch64' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} NIGHTLY: ${{ inputs.nightly == true && 'true' || 'false' }} run: | pnpm tauri build ${{ inputs.nightly == true && '-f nightly -c ./backend/tauri/tauri.nightly.conf.json --target aarch64-pc-windows-msvc' || '-f default-meta --target aarch64-pc-windows-msvc' }} - name: Rename fixed webview bundle name if: ${{ inputs.fixed-webview == true }} run: | $files = Get-ChildItem -Path "./backend/target" -Recurse -Include "*.exe", "*.zip", "*.zip.sig" | Where-Object { $_.FullName -like "*\bundle\*" } $condition = '${{ inputs.arch }}' switch ($condition) { 'x86_64' { $arch= 'x64' } 'i686' { $arch = 'x86' } 'aarch64' { $arch = 'arm64' } } foreach ($file in $files) { echo "Renaming $file" $newname = $file.FullName -replace $arch, "fixed-webview-$arch" Rename-Item -Path $file -NewName $newname } - name: Portable Bundle if: ${{ inputs.portable == true }} run: | pnpm portable ${{ inputs.fixed-webview == true && '--fixed-webview' || '' }} env: RUST_ARCH: ${{ inputs.arch }} TAG_NAME: ${{ inputs.tag }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} NIGHTLY: ${{ inputs.nightly == true && 'true' || 'false' }} VITE_WIN_PORTABLE: 1 - name: Upload NSIS Installer uses: actions/upload-artifact@v7 with: name: Clash.Nyanpasu-windows-${{ inputs.arch }}${{ inputs.fixed-webview == true && '-fixed-webview' || '' }}-nsis-installer path: | ./backend/target/**/bundle/**/*.exe ./backend/target/**/bundle/**/*.zip ./backend/target/**/bundle/**/*.zip.sig - name: Upload portable if: ${{ inputs.portable == true }} uses: actions/upload-artifact@v7 with: name: Clash.Nyanpasu-windows-${{ inputs.arch }}${{ inputs.fixed-webview == true && '-fixed-webview' || '' }}-portable path: | ./*_portable.zip - name: Set file server folder path if: ${{ inputs.nightly == true }} shell: bash run: | GIT_HASH=$(git rev-parse --short HEAD) echo "FOLDER_PATH=nightly/${GIT_HASH}" >> $GITHUB_ENV - name: Upload to file server if: ${{ inputs.nightly == true }} shell: bash continue-on-error: true run: | deno run -A scripts/deno/upload-build-artifacts.ts \ "backend/target/**/bundle/**/*.exe" \ "*_portable.zip" env: FILE_SERVER_TOKEN: ${{ secrets.FILE_SERVER_TOKEN }} FOLDER_PATH: ${{ env.FOLDER_PATH }} - name: Upload file server results if: ${{ inputs.nightly == true }} uses: actions/upload-artifact@v7 with: name: upload-results-windows-${{ inputs.arch }}${{ inputs.fixed-webview == true && '-fixed-webview' || '' }} path: ./upload-results.json if-no-files-found: ignore ================================================ FILE: .github/workflows/deps-create-updater.yaml ================================================ name: '[Single] Create Updater' on: workflow_dispatch: inputs: nightly: description: 'Nightly' required: true type: boolean default: false release_body: description: 'Release Body' required: false type: string workflow_call: inputs: nightly: description: 'Nightly' required: true type: boolean default: false release_body: description: 'Release Body' required: false type: string secrets: SURGE_TOKEN: required: true jobs: updater: name: Update Updater runs-on: ubuntu-latest permissions: id-token: write # This is required to allow the GitHub Action to authenticate with Deno Deploy. contents: write steps: - name: Checkout repository uses: actions/checkout@v6 with: ref: ${{ github.ref }} # blocked by https://github.com/actions/checkout/issues/1467 - name: Fetch git tags run: git fetch --tags - name: Install Node latest uses: actions/setup-node@v6 with: node-version: 24 - uses: pnpm/action-setup@v5 name: Install pnpm with: run_install: false - name: Pnpm install run: pnpm i - name: Update Nightly Updater if: ${{ inputs.nightly == true }} run: pnpm updater:nightly env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Update Nightly Fixed Webview Updater if: ${{ inputs.nightly == true }} run: pnpm updater:nightly --fixed-webview env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Update Stable Updater if: ${{ inputs.nightly == false }} run: pnpm updater env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_BODY: ${{ inputs.release_body || github.event.release.body }} - name: Update Stable Fixed Webview Updater if: ${{ inputs.nightly == false }} run: pnpm updater --fixed-webview env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_BODY: ${{ inputs.release_body || github.event.release.body }} - name: Download updater files from Github release uses: robinraju/release-downloader@v1 with: tag: updater repository: libnyanpasu/clash-nyanpasu fileName: '*.json' token: ${{ secrets.GITHUB_TOKEN }} out-file-path: manifest/site/updater - name: Upload updater to surge.sh run: | pnpm i -g surge surge manifest/site surge.elaina.moe surge manifest/site nyanpasu.surge.sh env: SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }} - name: Deploy to Deno Deploy uses: denoland/deployctl@v1 with: project: clash-nyanpasu-manifest entrypoint: jsr:@std/http/file-server root: manifest/site ================================================ FILE: .github/workflows/deps-delete-releases.yaml ================================================ name: '[Single] Delete Current Releases' on: workflow_dispatch: inputs: tag: description: 'Release Tag' required: true type: string workflow_call: inputs: tag: description: 'Release Tag' required: true type: string jobs: delete: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v6 - name: Delete current release assets uses: mknejp/delete-release-assets@v1 with: token: ${{ secrets.GITHUB_TOKEN }} tag: ${{ inputs.tag }} fail-if-no-assets: false fail-if-no-release: false assets: | *.zip *.gz *.AppImage *.deb *.rpm *.dmg *.msi *.sig *.sha256 *.exe *.json ================================================ FILE: .github/workflows/deps-message-telegram.yaml ================================================ name: '[Single] Send Message to Telegram' on: workflow_dispatch: inputs: nightly: description: 'Nightly' required: true type: boolean default: false from-local: description: 'Use per-build uploaded results instead of downloading from release' required: false type: boolean default: false workflow_call: inputs: nightly: description: 'Nightly' required: true type: boolean default: false from-local: description: 'Use per-build uploaded results instead of downloading from release' required: false type: boolean default: false jobs: telegram: name: Notify Telegram runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: 24 - uses: denoland/setup-deno@v2 with: deno-version: v2.x - name: Download upload results if: ${{ inputs.from-local == true }} uses: actions/download-artifact@v8 continue-on-error: true with: pattern: upload-results-* path: ./upload-results merge-multiple: false - name: Send Releases run: | ARGS="" if [ "${{ inputs.nightly }}" = "true" ]; then ARGS="$ARGS --nightly" fi if [ "${{ inputs.from-local }}" = "true" ]; then ARGS="$ARGS --from-local" fi deno run -A scripts/deno/telegram-notify.ts $ARGS env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} TELEGRAM_API_ID: ${{ secrets.TELEGRAM_API_ID }} TELEGRAM_API_HASH: ${{ secrets.TELEGRAM_API_HASH }} FILE_SERVER_TOKEN: ${{ secrets.FILE_SERVER_TOKEN }} TELEGRAM_TO: '@keikolog' TELEGRAM_TO_NIGHTLY: '@ClashNyanpasu' WORKFLOW_RUN_ID: ${{ github.run_id }} UPLOAD_RESULTS_DIR: ./upload-results ================================================ FILE: .github/workflows/deps-update-tag.yaml ================================================ name: '[Single] Update Tag' on: workflow_dispatch: inputs: tag: description: 'Release Tag' required: true type: string workflow_call: inputs: tag: description: 'Release Tag' required: true type: string jobs: update_tag: name: Update tag runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout repository uses: actions/checkout@v6 - name: Set Env run: | echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV echo "CURRENT_GIT_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV shell: bash - name: Update Tag uses: greenhat616/update-tag@v1 with: tag_name: ${{ inputs.tag }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create release body run: | cat > release.txt << 'EOF' ## Clash Nyanpasu Nightly Build Release created at ${{ env.BUILDTIME }}. Daily build of **Clash Nyanpasu** on *main* branch. You could download previous Nightly Builds from [here](https://t.me/ClashNyanpasu). ***[See the development log here](https://t.me/s/keikolog/)*** EOF - name: Update Release uses: softprops/action-gh-release@v2 with: name: Clash Nyanpasu Dev tag_name: ${{ inputs.tag }} body_path: release.txt prerelease: true generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/deps-upload-release-assets.yaml ================================================ name: '[Single] Upload Release Assets' on: workflow_call: inputs: tag: description: 'Release Tag' required: true type: string jobs: upload: runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout repository uses: actions/checkout@v6 - name: Download all build artifacts uses: actions/download-artifact@v8 with: pattern: Clash.Nyanpasu-* path: ./release-assets merge-multiple: true - name: Upload to release run: | find ./release-assets -type f -print0 | while IFS= read -r -d '' file; do echo "Uploading $file" gh release upload "${{ inputs.tag }}" "$file" --clobber done env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/macos-aarch64.yaml ================================================ name: macOS aarch64 Build on: workflow_dispatch: env: CARGO_INCREMENTAL: 0 RUST_BACKTRACE: short jobs: macos-aarch64: runs-on: macos-15 steps: - name: Checkout repository uses: actions/checkout@v6 - name: install Rust nightly run: | rustup install nightly --profile minimal --no-self-update rustup default nightly - uses: Swatinem/rust-cache@v2 with: workspaces: './backend/' prefix-key: 'rust-nightly' key: 'macos-13' shared-key: 'release' - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: 16 - name: install the missing rust target run: | rustup target add aarch64-apple-darwin - name: Install Node uses: actions/setup-node@v6 with: node-version: '24' - uses: pnpm/action-setup@v5 name: Install pnpm with: run_install: false - uses: denoland/setup-deno@v2 with: deno-version: v2.x - name: Pnpm install and check run: | pnpm i pnpm prepare:check --arch arm64 --sidecar-host aarch64-apple-darwin - name: Tauri build with Upload (cmd) env: TAG_NAME: dev GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} run: | pnpm build --target aarch64-apple-darwin pnpm upload:osx-aarch64 ================================================ FILE: .github/workflows/publish.yml ================================================ name: Publish on: workflow_dispatch: inputs: versionType: type: choice description: '' required: true default: 'patch' options: - major - minor - patch jobs: publish: name: Publish ${{ inputs.versionType }} release permissions: # Give the default GITHUB_TOKEN write permission to commit and push the # added or changed files to the repository. contents: write discussions: write runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 - name: Prepare Node uses: actions/setup-node@v6 with: node-version: 24 - uses: pnpm/action-setup@v5 name: Install pnpm with: run_install: false - name: Get pnpm store directory shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - uses: actions/cache@v5 name: Setup pnpm cache with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - name: Install run: pnpm i - name: Install git-cliff uses: taiki-e/install-action@git-cliff - id: update-version shell: bash name: Bump version # Use npm because yarn is for some reason not able to output only the version name run: | echo "version=$(pnpm run publish ${{ inputs.versionType }} | tail -n1)" >> $GITHUB_OUTPUT git add . - name: Generate a changelog for the new version shell: bash id: build-changelog run: | touch /tmp/changelog.md git-cliff --config cliff.toml --verbose --strip header --unreleased --tag v${{ steps.update-version.outputs.version }} > /tmp/changelog.md if [ $? -eq 0 ]; then CONTENT=$(cat /tmp/changelog.md) cat /tmp/changelog.md | cat - ./CHANGELOG.md > temp && mv temp ./CHANGELOG.md { echo 'content<> $GITHUB_OUTPUT echo "version=${{ steps.update-version.outputs.version }}" >> $GITHUB_OUTPUT else echo "Failed to generate changelog" exit 1 fi env: GITHUB_REPO: ${{ github.repository }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Commit changes uses: stefanzweifel/git-auto-commit-action@v7 with: commit_message: 'chore: bump version to v${{ steps.update-version.outputs.version }}' commit_user_name: 'github-actions[bot]' commit_user_email: '41898282+github-actions[bot]@users.noreply.github.com' tagging_message: 'v${{ steps.update-version.outputs.version }}' - name: Release uses: softprops/action-gh-release@v2 with: draft: true body: ${{steps.build-changelog.outputs.content}} name: Clash Nyanpasu v${{steps.update-version.outputs.version}} tag_name: 'v${{ steps.update-version.outputs.version }}' # target_commitish: ${{ steps.tag.outputs.sha }} ================================================ FILE: .github/workflows/stale.yml ================================================ name: 'Close stale issues and PRs' on: schedule: - cron: '30 1 * * *' workflow_dispatch: permissions: contents: write # only for delete-branch option issues: write pull-requests: write jobs: stale: runs-on: ubuntu-latest steps: - uses: actions/stale@v10 with: stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' close-issue-message: 'This issue is closed because it has been stale for 5 days with no activity.' days-before-stale: 30 days-before-close: 5 stale-issue-label: 'S: Stale' only-issue-labels: 'S: Untriaged' ================================================ FILE: .github/workflows/target-dev-build.yaml ================================================ name: '[Entire] Build Developer Version' on: workflow_dispatch: schedule: - cron: '15 0 * * *' # 每天 08:15 UTC+8 自动构建 concurrency: group: dev-build cancel-in-progress: true env: CARGO_INCREMENTAL: 0 RUST_BACKTRACE: short jobs: windows_amd64_build: name: Windows x86_64 Build if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.repository, 'libnyanpasu') }} uses: ./.github/workflows/deps-build-windows-nsis.yaml with: portable: true nightly: true fixed-webview: false arch: 'x86_64' tag: 'pre-release' secrets: inherit windows_aarch64_build: name: Windows aarch64 Build if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.repository, 'libnyanpasu') }} uses: ./.github/workflows/deps-build-windows-nsis.yaml with: portable: true nightly: true fixed-webview: false arch: 'aarch64' tag: 'pre-release' secrets: inherit windows_i686_build: name: Windows i686 Build if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.repository, 'libnyanpasu') }} uses: ./.github/workflows/deps-build-windows-nsis.yaml with: portable: true nightly: true fixed-webview: false arch: 'i686' tag: 'pre-release' secrets: inherit windows_amd64_build_fixed_webview: name: Windows x86_64 Build with Fixed WebView if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.repository, 'libnyanpasu') }} uses: ./.github/workflows/deps-build-windows-nsis.yaml with: portable: true nightly: true arch: 'x86_64' fixed-webview: true tag: 'pre-release' secrets: inherit windows_aarch64_build_fixed_webview: name: Windows aarch64 Build with Fixed WebView if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.repository, 'libnyanpasu') }} uses: ./.github/workflows/deps-build-windows-nsis.yaml with: portable: true nightly: true arch: 'aarch64' fixed-webview: true tag: 'pre-release' secrets: inherit windows_i686_build_fixed_webview: name: Windows i686 Build with Fixed WebView if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.repository, 'libnyanpasu') }} uses: ./.github/workflows/deps-build-windows-nsis.yaml with: portable: true nightly: true arch: 'i686' fixed-webview: true tag: 'pre-release' secrets: inherit linux_amd64_build: name: Linux amd64 Build if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.repository, 'libnyanpasu') }} uses: ./.github/workflows/deps-build-linux.yaml with: nightly: true tag: 'pre-release' arch: 'x86_64' secrets: inherit linux_i686_build: name: Linux i686 Build if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.repository, 'libnyanpasu') }} uses: ./.github/workflows/deps-build-linux.yaml with: nightly: true tag: 'pre-release' arch: 'i686' secrets: inherit linux_aarch64_build: name: Linux aarch64 Build if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.repository, 'libnyanpasu') }} uses: ./.github/workflows/deps-build-linux.yaml with: nightly: true tag: 'pre-release' arch: 'aarch64' secrets: inherit linux_armhf_build: name: Linux armhf Build if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.repository, 'libnyanpasu') }} uses: ./.github/workflows/deps-build-linux.yaml with: nightly: true tag: 'pre-release' arch: 'armhf' secrets: inherit linux_armel_build: name: Linux armel Build if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.repository, 'libnyanpasu') }} uses: ./.github/workflows/deps-build-linux.yaml with: nightly: true tag: 'pre-release' arch: 'armel' secrets: inherit macos_amd64_build: name: macOS amd64 Build if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.repository, 'libnyanpasu') }} uses: ./.github/workflows/deps-build-macos.yaml with: nightly: true aarch64: false tag: 'pre-release' secrets: inherit macos_aarch64_build: name: macOS aarch64 Build if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.repository, 'libnyanpasu') }} uses: ./.github/workflows/deps-build-macos.yaml with: nightly: true aarch64: true tag: 'pre-release' secrets: inherit update_tag: name: Update tag needs: [ windows_amd64_build, windows_i686_build, windows_aarch64_build, windows_amd64_build_fixed_webview, windows_i686_build_fixed_webview, windows_aarch64_build_fixed_webview, linux_amd64_build, linux_i686_build, linux_aarch64_build, linux_armhf_build, linux_armel_build, macos_amd64_build, macos_aarch64_build, ] uses: ./.github/workflows/deps-update-tag.yaml with: tag: 'pre-release' delete_current_releases: name: Delete Current Releases needs: [update_tag] uses: ./.github/workflows/deps-delete-releases.yaml with: tag: 'pre-release' upload_release_assets: name: Upload Release Assets needs: [delete_current_releases] uses: ./.github/workflows/deps-upload-release-assets.yaml with: tag: 'pre-release' updater: name: Create Updater needs: [upload_release_assets] uses: ./.github/workflows/deps-create-updater.yaml with: nightly: true secrets: inherit telegram: name: Send Release Message to Telegram if: startsWith(github.repository, 'libnyanpasu') needs: [update_tag] uses: ./.github/workflows/deps-message-telegram.yaml with: nightly: true from-local: true secrets: inherit ================================================ FILE: .github/workflows/target-release-build.yaml ================================================ name: '[Entire] Build Release Version' on: release: types: [published] env: CARGO_INCREMENTAL: 0 RUST_BACKTRACE: short jobs: windows_build: name: Windows Build uses: ./.github/workflows/deps-build-windows-nsis.yaml with: portable: true nightly: false tag: ${{ github.event.release.tag_name }} secrets: inherit linux_build: name: Linux Build uses: ./.github/workflows/deps-build-linux.yaml with: nightly: false tag: ${{ github.event.release.tag_name }} secrets: inherit macos_amd64_build: name: macOS amd64 Build uses: ./.github/workflows/deps-build-macos.yaml with: nightly: false aarch64: false tag: ${{ github.event.release.tag_name }} secrets: inherit macos_aarch64_build: name: macOS aarch64 Build uses: ./.github/workflows/deps-build-macos.yaml with: nightly: false aarch64: true tag: ${{ github.event.release.tag_name }} secrets: inherit upload_release_assets: name: Upload Release Assets needs: [windows_build, linux_build, macos_amd64_build, macos_aarch64_build] uses: ./.github/workflows/deps-upload-release-assets.yaml with: tag: ${{ github.event.release.tag_name }} updater: name: Create Updater needs: [upload_release_assets] uses: ./.github/workflows/deps-create-updater.yaml with: nightly: false telegram: name: Send Release Message to Telegram if: startsWith(github.repository, 'libnyanpasu') needs: [upload_release_assets] uses: ./.github/workflows/deps-message-telegram.yaml with: nightly: false secrets: inherit ================================================ FILE: .gitignore ================================================ node_modules .DS_Store dist dist-ssr *.local update.json scripts/_env.sh .eslintcache .stylelintcache tauri.nightly.conf.json tauri.preview.conf.json .idea *.tsbuildinfo manifest/site/updater/* !manifest/site/updater/.gitkeep /backend/tauri/gen/ ================================================ FILE: .husky/commit-msg ================================================ pnpm commitlint --edit ${1} ================================================ FILE: .husky/pre-commit ================================================ # If tty is available, apply fix from https://github.com/typicode/husky/issues/968#issuecomment-1176848345 if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then exec >/dev/tty 2>&1; fi pnpm lint-staged ================================================ FILE: .lintstagedrc.js ================================================ export default { '*.{js,cjs,.mjs,jsx}': (filenames) => { const configFiles = [ '.oxlintrc.json', '.lintstagedrc.js', 'commitlint.config.js', ] const filtered = filenames.filter( (file) => !configFiles.some((config) => file.endsWith(config)), ) if (filtered.length === 0) return [] return ['prettier --write', 'oxlint --fix'] }, 'scripts/**/*.{ts,tsx}': [ 'prettier --write', 'oxlint --fix', () => 'tsc -p scripts/tsconfig.json --noEmit', ], 'frontend/interface/**/*.{ts,tsx}': [ 'prettier --write', 'oxlint --fix', () => 'tsc -p frontend/interface/tsconfig.json --noEmit', ], 'frontend/ui/**/*.{ts,tsx}': [ 'prettier --write', 'oxlint --fix', () => 'tsc -p frontend/ui/tsconfig.json --noEmit', ], 'frontend/nyanpasu/**/*.{ts,tsx}': [ 'prettier --write', 'oxlint --fix', () => 'tsc -p frontend/nyanpasu/tsconfig.json --noEmit', ], 'backend/**/*.{rs,toml}': [ () => 'cargo clippy --manifest-path=./backend/Cargo.toml --all-targets --all-features', () => 'cargo fmt --manifest-path ./backend/Cargo.toml --all', // () => 'cargo test --manifest-path=./backend/Cargo.toml', // () => "cargo fmt --manifest-path=./backend/Cargo.toml --all", // do not submit untracked files // () => 'git add -u', ], '*.{html,sass,scss,less}': ['prettier --write', 'stylelint --fix'], 'package.json': ['prettier --write'], '*.{md,json,jsonc,json5,yaml,yml,toml}': (filenames) => { // exclude frontend/nyanpasu/messages directory const filtered = filenames.filter( (file) => !file.includes('frontend/nyanpasu/messages/'), ) if (filtered.length === 0) return [] return `prettier --write ${filtered.join(' ')}` }, } ================================================ FILE: .oxlintrc.json ================================================ { "$schema": "./node_modules/oxlint/configuration_schema.json", "plugins": [], "categories": { "correctness": "off" }, "env": { "builtin": true }, "ignorePatterns": [ "**/node_modules", "**/.DS_Store", "**/dist", "**/*.local", "**/update.json", "scripts/_env.sh", "**/.eslintcache", "**/.stylelintcache", "**/tauri.nightly.conf.json", "**/tauri.preview.conf.json", "**/.idea", "**/*.tsbuildinfo", "manifest/site/updater/*", "!manifest/site/updater/.gitkeep", "backend/tauri/gen/", "**/index.html", "**/node_modules/", "node_modules/", "backend/", "backend/**/target", "scripts/deno/**", ".lintstagedrc.js", "commitlint.config.js" ], "overrides": [ { "files": ["**/*.{jsx,mjsx,tsx,mtsx}"], "rules": { "react/display-name": "error", "react/jsx-key": "error", "react/jsx-no-comment-textnodes": "error", "react/jsx-no-duplicate-props": "error", "react/jsx-no-target-blank": "error", // "react/jsx-no-undef": "error", "react/no-children-prop": "error", "react/no-danger-with-children": "error", "react/no-direct-mutation-state": "error", "react/no-find-dom-node": "error", "react/no-is-mounted": "error", "react/no-render-return-value": "error", "react/no-string-refs": "error", "react/no-unknown-property": "error", "react/no-unsafe": "off", "react/react-in-jsx-scope": "error" }, "plugins": ["react"] }, { "files": ["**/*.{jsx,mjsx,tsx,mtsx}"], "rules": { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn" }, "plugins": ["react"] }, { "files": ["**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}"], "rules": { "no-var": "warn", "accessor-pairs": [ "error", { "setWithoutGet": true, "enforceForClassMembers": true } ], "array-callback-return": [ "error", { "allowImplicit": false, "checkForEach": false } ], "constructor-super": "error", "curly": ["error", "multi-line"], "default-case-last": "error", "eqeqeq": [ "error", "always", { "null": "ignore" } ], "new-cap": [ "error", { "newIsCap": true, "capIsNew": false, "properties": true } ], "no-array-constructor": "error", "no-async-promise-executor": "error", "no-caller": "error", "no-case-declarations": "error", "no-class-assign": "error", "no-compare-neg-zero": "error", "no-cond-assign": "error", "no-const-assign": "error", "no-constant-condition": [ "error", { "checkLoops": false } ], "no-control-regex": "error", "no-debugger": "error", "no-delete-var": "error", "no-dupe-class-members": "error", "no-dupe-keys": "error", "no-duplicate-case": "error", "no-useless-backreference": "error", "no-empty": [ "error", { "allowEmptyCatch": true } ], "no-empty-character-class": "error", "no-empty-pattern": "error", "no-eval": "error", "no-ex-assign": "error", "no-extend-native": "error", "no-extra-bind": "error", "no-extra-boolean-cast": "error", "no-fallthrough": "error", "no-func-assign": "error", "no-global-assign": "error", "no-import-assign": "error", "no-invalid-regexp": "error", "no-irregular-whitespace": "error", "no-iterator": "error", "no-labels": [ "error", { "allowLoop": false, "allowSwitch": false } ], "no-lone-blocks": "error", "no-loss-of-precision": "error", "no-prototype-builtins": "error", "no-useless-catch": "error", "no-multi-str": "error", "no-new": "error", "no-new-func": "error", "no-object-constructor": "error", "no-new-native-nonconstructor": "error", "no-new-wrappers": "error", "no-obj-calls": "error", "no-proto": "error", "no-redeclare": [ "error", { "builtinGlobals": false } ], "no-regex-spaces": "error", "no-return-assign": ["error", "except-parens"], "no-self-assign": [ "error", { "props": true } ], "no-self-compare": "error", "no-sequences": "error", "no-shadow-restricted-names": "error", "no-sparse-arrays": "error", "no-template-curly-in-string": "error", "no-this-before-super": "error", "no-throw-literal": "error", "no-unexpected-multiline": "error", "no-unneeded-ternary": [ "error", { "defaultAssignment": false } ], "no-unsafe-finally": "error", "no-unsafe-negation": "error", "no-unused-expressions": [ "error", { "allowShortCircuit": true, "allowTernary": true, "allowTaggedTemplates": true } ], "no-unused-vars": [ "error", { "args": "none", "caughtErrors": "none", "ignoreRestSiblings": true, "vars": "all" } ], "no-useless-call": "error", "no-useless-computed-key": "error", "no-useless-constructor": "error", "no-useless-escape": "error", "no-useless-rename": "error", "no-useless-return": "error", "no-void": "error", "no-with": "error", "prefer-const": [ "error", { "destructuring": "all" } ], "prefer-promise-reject-errors": "error", "symbol-description": "error", "unicode-bom": ["error", "never"], "use-isnan": [ "error", { "enforceForSwitchCase": true, "enforceForIndexOf": true } ], "valid-typeof": [ "error", { "requireStringLiterals": true } ], "yoda": ["error", "never"], "import-x/first": "error", "import-x/no-absolute-path": [ "error", { "esmodule": true, "commonjs": true, "amd": false } ], "import-x/no-duplicates": "error", "import-x/no-named-default": "error", "import-x/no-webpack-loader-syntax": "error", "promise/param-names": "error", "node/no-exports-assign": "error", "node/no-new-require": "error" }, "globals": { "__dirname": "readonly", "__filename": "readonly", "AbortController": "readonly", "AbortSignal": "readonly", "atob": "readonly", "Blob": "readonly", "BroadcastChannel": "readonly", "btoa": "readonly", "Buffer": "readonly", "ByteLengthQueuingStrategy": "readonly", "clearImmediate": "readonly", "clearInterval": "readonly", "clearTimeout": "readonly", "CloseEvent": "readonly", "CompressionStream": "readonly", "console": "readonly", "CountQueuingStrategy": "readonly", "crypto": "readonly", "Crypto": "readonly", "CryptoKey": "readonly", "CustomEvent": "readonly", "DecompressionStream": "readonly", "DOMException": "readonly", "Event": "readonly", "EventTarget": "readonly", "fetch": "readonly", "File": "readonly", "FormData": "readonly", "Headers": "readonly", "MessageChannel": "readonly", "MessageEvent": "readonly", "MessagePort": "readonly", "navigator": "readonly", "Navigator": "readonly", "performance": "readonly", "Performance": "readonly", "PerformanceEntry": "readonly", "PerformanceMark": "readonly", "PerformanceMeasure": "readonly", "PerformanceObserver": "readonly", "PerformanceObserverEntryList": "readonly", "PerformanceResourceTiming": "readonly", "process": "readonly", "queueMicrotask": "readonly", "ReadableByteStreamController": "readonly", "ReadableStream": "readonly", "ReadableStreamBYOBReader": "readonly", "ReadableStreamBYOBRequest": "readonly", "ReadableStreamDefaultController": "readonly", "ReadableStreamDefaultReader": "readonly", "Request": "readonly", "Response": "readonly", "setImmediate": "readonly", "setInterval": "readonly", "setTimeout": "readonly", "structuredClone": "readonly", "SubtleCrypto": "readonly", "TextDecoder": "readonly", "TextDecoderStream": "readonly", "TextEncoder": "readonly", "TextEncoderStream": "readonly", "TransformStream": "readonly", "TransformStreamDefaultController": "readonly", "URL": "readonly", "URLSearchParams": "readonly", "WebAssembly": "readonly", "WebSocket": "readonly", "WritableStream": "readonly", "WritableStreamDefaultController": "readonly", "WritableStreamDefaultWriter": "readonly", "document": "readonly", "window": "readonly" }, "env": { "commonjs": true, "es2024": true }, "plugins": ["import", "node", "promise"] }, { "files": ["**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}"], "rules": { "react/jsx-boolean-value": "error", "react/jsx-fragments": ["error", "syntax"], "react/jsx-handler-names": "error", "react/jsx-key": [ "error", { "checkFragmentShorthand": true } ], "react/jsx-no-comment-textnodes": "error", "react/jsx-no-duplicate-props": "error", "react/jsx-no-target-blank": [ "error", { "enforceDynamicLinks": "always" } ], // "react/jsx-no-undef": [ // "error", // { // "allowGlobals": true // } // ], "react/no-children-prop": "error", "react/no-danger-with-children": "error", "react/no-direct-mutation-state": "error", "react/no-find-dom-node": "error", "react/no-is-mounted": "error", "react/no-string-refs": [ "error", { "noTemplateLiterals": true } ], "react/no-unescaped-entities": "off", "react/no-render-return-value": "error", "react/self-closing-comp": "error" }, "plugins": ["react"] }, { "files": ["**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}"], "rules": { "constructor-super": "off", "no-const-assign": "off", "no-dupe-class-members": "error", "no-dupe-keys": "off", "no-func-assign": "off", "no-import-assign": "off", "no-new-native-nonconstructor": "off", "no-obj-calls": "off", "no-redeclare": [ "error", { "builtinGlobals": false } ], "no-this-before-super": "off", "no-unsafe-negation": "off", "no-array-constructor": "error", "no-loss-of-precision": "error", "no-unused-expressions": [ "error", { "allowShortCircuit": true, "allowTernary": true, "allowTaggedTemplates": true } ], "no-unused-vars": [ "error", { "args": "none", "caughtErrors": "none", "ignoreRestSiblings": true, "vars": "all" } ], "no-useless-constructor": "error" }, "plugins": ["typescript"] }, { "files": ["**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}"], "rules": { "curly": "off", "no-unexpected-multiline": "off", "unicorn/empty-brace-spaces": "off", "unicorn/no-nested-ternary": "off", "unicorn/number-literal-case": "off" }, "plugins": ["unicorn"] }, { "files": ["**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}"], "rules": { "curly": "off", "no-unexpected-multiline": "off", "unicorn/empty-brace-spaces": "off", "unicorn/no-nested-ternary": "off", "unicorn/number-literal-case": "off", "arrow-body-style": "off" }, "plugins": ["unicorn"] }, { "files": ["**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}"], "rules": { "no-console": "off", "no-debugger": "off", "no-unused-vars": "warn", "react/react-in-jsx-scope": "off" }, "plugins": ["typescript", "react"] }, { "files": ["**/*.{ts,tsx,mtsx}"], "rules": { "constructor-super": "off", "no-class-assign": "off", "no-const-assign": "off", "no-dupe-class-members": "off", "no-dupe-keys": "off", "no-func-assign": "off", "no-import-assign": "off", "no-new-native-nonconstructor": "off", "no-obj-calls": "off", "no-redeclare": "off", "no-setter-return": "off", "no-this-before-super": "off", "no-unsafe-negation": "off", "no-var": "error", "no-with": "off", "prefer-const": "error", "prefer-rest-params": "error", "prefer-spread": "error" } }, { "files": ["**/*.{ts,tsx,mtsx}"], "rules": { "@typescript-eslint/ban-ts-comment": "error", "no-array-constructor": "error", "@typescript-eslint/no-duplicate-enum-values": "error", "@typescript-eslint/no-empty-object-type": "error", "@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/no-extra-non-null-assertion": "error", "@typescript-eslint/no-misused-new": "error", "@typescript-eslint/no-namespace": "error", "@typescript-eslint/no-non-null-asserted-optional-chain": "error", "@typescript-eslint/no-require-imports": "error", "@typescript-eslint/no-this-alias": "error", "@typescript-eslint/no-unnecessary-type-constraint": "error", "@typescript-eslint/no-unsafe-declaration-merging": "error", "@typescript-eslint/no-unsafe-function-type": "error", "no-unused-expressions": "error", "no-unused-vars": "error", "@typescript-eslint/no-wrapper-object-types": "error", "@typescript-eslint/prefer-as-const": "error", "@typescript-eslint/prefer-namespace-keyword": "error", "@typescript-eslint/triple-slash-reference": "error" }, "plugins": ["typescript"] }, { "files": ["**/*.{ts,tsx,mtsx}"], "rules": { "@typescript-eslint/no-explicit-any": "warn", "no-unused-vars": "warn" }, "plugins": ["typescript"] }, { "files": [ "frontend/nyanpasu/vite.config.ts", "frontend/nyanpasu/tailwind.config.ts" ], "rules": { "constructor-super": "off", "no-class-assign": "off", "no-const-assign": "off", "no-dupe-class-members": "off", "no-dupe-keys": "off", "no-func-assign": "off", "no-import-assign": "off", "no-new-native-nonconstructor": "off", "no-obj-calls": "off", "no-redeclare": "off", "no-setter-return": "off", "no-this-before-super": "off", "no-unsafe-negation": "off", "no-var": "error", "no-with": "off", "prefer-const": "error", "prefer-rest-params": "error", "prefer-spread": "error" } }, { "files": [ "frontend/nyanpasu/vite.config.ts", "frontend/nyanpasu/tailwind.config.ts" ], "rules": { "@typescript-eslint/ban-ts-comment": "error", "no-array-constructor": "error", "@typescript-eslint/no-duplicate-enum-values": "error", "@typescript-eslint/no-empty-object-type": "error", "@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/no-extra-non-null-assertion": "error", "@typescript-eslint/no-misused-new": "error", "@typescript-eslint/no-namespace": "error", "@typescript-eslint/no-non-null-asserted-optional-chain": "error", "@typescript-eslint/no-require-imports": "error", "@typescript-eslint/no-this-alias": "error", "@typescript-eslint/no-unnecessary-type-constraint": "error", "@typescript-eslint/no-unsafe-declaration-merging": "error", "@typescript-eslint/no-unsafe-function-type": "error", "no-unused-expressions": "error", "no-unused-vars": "error", "@typescript-eslint/no-wrapper-object-types": "error", "@typescript-eslint/prefer-as-const": "error", "@typescript-eslint/prefer-namespace-keyword": "error", "@typescript-eslint/triple-slash-reference": "error" }, "plugins": ["typescript"] }, { "files": [ "frontend/nyanpasu/vite.config.ts", "frontend/nyanpasu/tailwind.config.ts" ], "rules": { "@typescript-eslint/no-explicit-any": "warn", "no-unused-vars": "warn" }, "plugins": ["typescript"] }, { "files": ["frontend/ui/vite.config.ts"], "rules": { "constructor-super": "off", "no-class-assign": "off", "no-const-assign": "off", "no-dupe-class-members": "off", "no-dupe-keys": "off", "no-func-assign": "off", "no-import-assign": "off", "no-new-native-nonconstructor": "off", "no-obj-calls": "off", "no-redeclare": "off", "no-setter-return": "off", "no-this-before-super": "off", "no-unsafe-negation": "off", "no-var": "error", "no-with": "off", "prefer-const": "error", "prefer-rest-params": "error", "prefer-spread": "error" } }, { "files": ["frontend/ui/vite.config.ts"], "rules": { "@typescript-eslint/ban-ts-comment": "error", "no-array-constructor": "error", "@typescript-eslint/no-duplicate-enum-values": "error", "@typescript-eslint/no-empty-object-type": "error", "@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/no-extra-non-null-assertion": "error", "@typescript-eslint/no-misused-new": "error", "@typescript-eslint/no-namespace": "error", "@typescript-eslint/no-non-null-asserted-optional-chain": "error", "@typescript-eslint/no-require-imports": "error", "@typescript-eslint/no-this-alias": "error", "@typescript-eslint/no-unnecessary-type-constraint": "error", "@typescript-eslint/no-unsafe-declaration-merging": "error", "@typescript-eslint/no-unsafe-function-type": "error", "no-unused-expressions": "error", "no-unused-vars": "error", "@typescript-eslint/no-wrapper-object-types": "error", "@typescript-eslint/prefer-as-const": "error", "@typescript-eslint/prefer-namespace-keyword": "error", "@typescript-eslint/triple-slash-reference": "error" }, "plugins": ["typescript"] }, { "files": ["frontend/ui/vite.config.ts"], "rules": { "@typescript-eslint/no-explicit-any": "warn", "no-unused-vars": "warn" }, "plugins": ["typescript"] }, { "files": ["**/*.{jsx,mjsx,tsx,mtsx}"], "globals": { "AudioWorkletGlobalScope": "readonly", "AudioWorkletProcessor": "readonly", "currentFrame": "readonly", "currentTime": "readonly", "registerProcessor": "readonly", "sampleRate": "readonly", "WorkletGlobalScope": "readonly" }, "env": { "browser": true, "serviceworker": true } } ] } ================================================ FILE: .prettierignore ================================================ *.rs *.lock **/target/ dist/ **/node_modules/ pnpm-lock.yaml *.lock *.wxs frontend/nyanpasu/src/route-tree.gen.ts frontend/nyanpasu/auto-imports.d.ts frontend/nyanpasu/src/paraglide/ frontend/nyanpasu/project.inlang/ backend/tauri/gen/schemas/ ================================================ FILE: .prettierrc.cjs ================================================ /** @type {import("prettier").Config} */ module.exports = { endOfLine: 'lf', semi: false, singleQuote: true, bracketSpacing: true, tabWidth: 2, trailingComma: 'all', overrides: [ { files: ['tsconfig.json', 'jsconfig.json'], options: { parser: 'jsonc', }, }, ], importOrder: [ '^@nyanpasu/ui/(.*)$', '^@nyanpasu/interface/(.*)$', '^@/(.*)$', '^@(.*)$', '^[./]', ], importOrderParserPlugins: ['typescript', 'jsx', 'decorators-legacy'], importOrderTypeScriptVersion: '5.0.0', plugins: [ '@ianvs/prettier-plugin-sort-imports', 'prettier-plugin-tailwindcss', 'prettier-plugin-toml', ], } ================================================ FILE: .stylelintignore ================================================ dist/ backend/**/target ================================================ FILE: .stylelintrc.js ================================================ import PostCssScss from 'postcss-scss' export default { root: true, defaultSeverity: 'error', plugins: [ 'stylelint-scss', 'stylelint-order', 'stylelint-declaration-block-no-ignored-properties', ], extends: [ 'stylelint-config-standard', 'stylelint-config-html/html', // the shareable html config for Stylelint. 'stylelint-config-recess-order', // 'stylelint-config-prettier' ], rules: { 'selector-pseudo-class-no-unknown': [ true, { ignorePseudoClasses: ['global'] }, ], 'font-family-name-quotes': null, 'font-family-no-missing-generic-family-keyword': null, 'max-nesting-depth': [ 10, { ignore: ['blockless-at-rules', 'pseudo-classes'], }, ], 'declaration-block-no-duplicate-properties': true, 'no-duplicate-selectors': true, 'no-descending-specificity': null, 'selector-class-pattern': null, 'value-no-vendor-prefix': [true, { ignoreValues: ['box'] }], 'at-rule-no-unknown': [ true, { ignoreAtRules: [ 'tailwind', 'unocss', 'layer', 'apply', 'variants', 'responsive', 'screen', 'config', 'plugin', 'theme', 'variant', 'custom-variant', 'utility', 'source', 'reference', ], }, ], 'at-rule-no-deprecated': [ true, { ignoreAtRules: ['apply'], }, ], }, overrides: [ { files: ['**/*.scss', '*.scss'], customSyntax: PostCssScss, rules: { 'at-rule-no-unknown': null, 'import-notation': null, 'scss/at-rule-no-unknown': [ true, { ignoreAtRules: [ 'tailwind', 'unocss', 'layer', 'apply', 'variants', 'responsive', 'screen', 'config', 'plugin', 'theme', 'variant', 'custom-variant', 'utility', 'source', 'reference', ], }, ], }, }, ], } ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "inlang.vs-code-extension", "editorconfig.editorconfig", "vadimcn.vscode-lldb", "denoland.vscode-deno", "esbenp.prettier-vscode", "yoavbls.pretty-ts-errors", "rust-lang.rust-analyzer", "syler.sass-indented", "stylelint.vscode-stylelint", "bradlc.vscode-tailwindcss", "oxc.oxc-vscode", "tamasfe.even-better-toml" ] } ================================================ FILE: .vscode/settings.json ================================================ { "tailwindCSS.experimental.classRegex": [ ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] ], "files.eol": "\n", "js/ts.tsdk.path": "node_modules\\typescript\\lib" } ================================================ FILE: CHANGELOG.md ================================================ ## [1.6.1] - 2024-09-07 ### ✨ Features - **dock:** Try to setup macos dock handler by @greenhat616 - **enhance:** Finish all filter test suites by @greenhat616 - **enhance:** Add sequence filter support and partial test suite by @greenhat616 - **enhance:** Add complex filter syntax support by @greenhat616 - **monaco:** Add onValidation before submit, and close #1491 by @greenhat616 - **monaco:** Add yaml config prompt by @greenhat616 - **nsis:** Cleanup reg while uninstall by @greenhat616 - **service:** Add manual prompt for service uninstall, stop, start by @greenhat616 - **service:** Add a manual install prompt while service install failed by @greenhat616 - **tun:** Support auto-route while clash-rs support it by @greenhat616 - Use cross-rs to build aarch64 by @greenhat616 - Try to support linux aarch64 build by @greenhat616 ### 🐛 Bug Fixes - **ci:** Update publish script by @greenhat616 - **dialog:** Position func err by @keiko233 - **nsis:** Cleanup app config and data dir if option is selected by @greenhat616 - **os:** Create no window by @greenhat616 - **shiki:** Shell lang loader by @greenhat616 - Monaco clash config prompt by @greenhat616 - Monaco url resolve issue by @greenhat616 - Try to resolve the yaml schema by @greenhat616 - Try to escape the string by @greenhat616 - Add service install error prompt by @greenhat616 - Shiki import by @greenhat616 - Try to fix create no window by @greenhat616 - Typo by @greenhat616 - Windows nightly build version issue by @greenhat616 - Build by @greenhat616 - Aarch build by @greenhat616 - Dont merge falsy theme settings by @greenhat616 ### 🔨 Refactor - Use @monaco-editor/react instead by @greenhat616 - Service shoutcuts use core manager internal state by @greenhat616 --- **Full Changelog**: https://github.com/libnyanpasu/clash-nyanpasu/compare/v1.6.0...v1.6.1 ## [1.6.0] - 2024-08-29 ### 💥 Breaking Changes - Tsconfig options by @keiko233 ### ⚡ Performance Improvements - **hook:** Add debounce callback & do nothing when minimized by @keiko233 - **proxies:** Add useTransition by @keiko233 - **ui:** Memoized children node by @keiko233 - **ui:** Add ref support for BasePage by @keiko233 - Switch log page & rule page to async component by @keiko233 ### ✨ Features - **component:** Add children props support for PaperButton by @keiko233 - **connections:** Lazy load connections and close #1208 by @greenhat616 - **connections:** Add no connection display by @keiko233 - **connections:** New design for ConnectionsPage by @keiko233 - **custom-schema:** Experimental compatible with common clash schema by @greenhat616 - **custom-scheme:** Use one desktop file to process mime by @greenhat616 - **custom-theme:** Background color picker minor tweak by @keiko233 - **dashboard:** Add service status shortcuts card by @keiko233 - **dashboard:** Add proxy shortcuts panel by @keiko233 - **dashboard:** Special grid layout for drawer by @keiko233 - **dashboard:** Add health panel by @keiko233 - **dashboard:** Init Dashboard Page by @keiko233 - **delay-button:** Minor tweaks for animetion by @keiko233 - **downloader:** Make downloader status readable by @greenhat616 - **drawer:** Enable panel collapsible by @keiko233 - **drawer:** Add small size layout by @keiko233 - **drawer:** Minor tweak for small size by @keiko233 - **enhance:** Experimental add lua runner support by @greenhat616 - **enhance:** Make merge process more powerful by @greenhat616 - **experimental:** Initial react compiler support by @keiko233 - **interface:** Initial ClashWS by @keiko233 - **interface:** Add profile js interface by @keiko233 - **interface:** Add current clash mode interface by @keiko233 - **interface:** Add useClashCore hook method by @keiko233 - **interface:** Add app tauri invoke interface by @keiko233 - **interface:** Add profiles api with SWR by @keiko233 - **interface:** Add ClashInfo interface with SWR by @keiko233 - **interface:** Init code by @keiko233 - **ipc:** Replace timing utils ofetch to tokio by @keiko233 - **ipc:** Export delay test and core status call by @greenhat616 - **layout:** Add scrollbar track margin by @keiko233 - **logs:** New design LogsPage by @keiko233 - **macos:** Try to impl dock show/hide api by @greenhat616 - **macos:** Add traffic control offset for macos by @keiko233 - **migration:** Add discard method for discarding changes while migration failed by @greenhat616 - **monaco:** Add monaco types support by @keiko233 - **monaco:** Add typescript language service by @keiko233 - **monaco:** Import lua language support by @keiko233 - **monaco-edit:** Switch to lazy load module by @keiko233 - **monaco-editor:** Support props value changes and language switching by @keiko233 - **monaco-editor:** Support language change on prop by @keiko233 - **motion:** Add lighten animation effects config by @keiko233 - **nyanpasu:** Node list support proxy delay testing by @keiko233 - **nyanpasu:** Import react devtools on dev env by @keiko233 - **nyanpasu:** Use new design Proxies Page by @keiko233 - **nyanpasu:** Import tailwind css by @keiko233 - **nyanpasu:** Experimentally added new settings interface by @keiko233 - **nyanpasu:** Add SettingLegacy component by @keiko233 - **nyanpasu:** Add SettingNyanpasuVersion component by @keiko233 - **nyanpasu:** Add SettingNyanpasuUI component by @keiko233 - **nyanpasu:** Add SettingNyanpasuPath component by @keiko233 - **nyanpasu:** Add SettingNyanpasuPath component by @keiko233 - **nyanpasu:** Add PaperButton component by @keiko233 - **nyanpasu:** Add SettingNyanpasuTasks component by @keiko233 - **nyanpasu:** Add SettingSystemService component by @keiko233 - **nyanpasu:** Add SettingSystemBehavior component by @keiko233 - **nyanpasu:** Add SettingSystemClash component by @keiko233 - **nyanpasu:** Add SettingClashCore component by @keiko233 - **nyanpasu:** Use grid layout for SettingClashWeb by @keiko233 - **nyanpasu:** Add SettingClashField component by @keiko233 - **nyanpasu:** Add SettingClashWeb component by @keiko233 - **nyanpasu:** Add SettingClashExternal component by @keiko233 - **nyanpasu:** Add SettingClashPort component by @keiko233 - **nyanpasu:** Add SettingClashBase component by @keiko233 - **nyanpasu:** Add nyanpasu setting props creator by @keiko233 - **nyanpasu:** Use new theme create method by @keiko233 - **nynapasu:** Add SettingNyanpasuMisc component by @keiko233 - **profiles:** Adapting scroll area & add position animation by @keiko233 - **profiles:** Add diff dialog hint by @greenhat616 - **profiles:** Add max log level triggered notice, and close #1291 by @greenhat616 - **profiles:** Add black touch new option by @greenhat616 - **profiles:** Add text carousel for subscription expires and updated time by @greenhat616 - **profiles:** Minor tweaks & add click card to apply profile by @keiko233 - **profiles:** Add split pane support & minor tweaks by @keiko233 - **profiles:** Profiles new design by @keiko233 - **profiles:** Add proxy chain side page by @keiko233 - **profiles:** Add monaco editor for ProfileItem by @keiko233 - **profiles:** Complete profile operation menu by @keiko233 - **profiles:** Redesign profile cards & new profile editor by @keiko233 - **profiles:** Profile dialog support edit mode by @keiko233 - **profiles:** Add QuickImport text arae component by @keiko233 - **profiles:** Init new profile page by @keiko233 - **providers:** Add proxy provider traffic display support by @keiko233 - **providers:** Support proxies providers by @keiko233 - **providers:** New design ProvidersPage by @keiko233 - **proxies:** Filter proxies nodes by @greenhat616 - **proxies:** Adapting scroll area by @keiko233 - **proxies:** Support proxy group test url by @keiko233 - **proxies:** Add scroll to current node button by @keiko233 - **proxies:** Add node card animation by @keiko233 - **proxies:** Group name transition use framer motion by @keiko233 - **proxies:** Add none proxies tips by @keiko233 - **proxies:** Add virtual scrolling to grid node list by @keiko233 - **proxies:** Group list use virtual scrolling by @keiko233 - **proxies:** Add node list sorting function by @keiko233 - **proxies:** Add group name text transition by @keiko233 - **proxies:** Add diff clash mode page layout by @keiko233 - **proxies:** Support group icon show by @keiko233 - **proxies:** Disable button when type is not selecor by @keiko233 - **rules:** Move filter text input to header by @keiko233 - **rules:** New design for RulesPage by @keiko233 - **service:** Add a service control panel and sidecar check script by @greenhat616 - **setting-clash-base:** Add uwp tools support by @keiko233 - **setting-clash-core:** Support core update by @keiko233 - **setting-clash-field:** Add ClashFieldFilter switch by @keiko233 - **sotre:** Add persistence support by @keiko233 - **theme:** Add MDYPaper style override by @keiko233 - **tray:** Add custom tray icon support by @greenhat616 - **tray:** Add submenu proxies selector by @greenhat616 - **ui:** Md3 style segmented button by @greenhat616 - **ui:** Add scroll area support for side page by @keiko233 - **ui:** Tailwind css support mui breakpoint by @keiko233 - **ui:** Base page use radix-ui scroll area by @keiko233 - **ui:** Dialog allow windows drag when prop full is true by @keiko233 - **ui:** Add full screen style for dialog by @keiko233 - **ui:** Minor tweaks for border radius by @keiko233 - **ui:** Replace Switch to LoadingSwitch for SwitchItem by @keiko233 - **ui:** Init sparkline chart by @keiko233 - **ui:** Add sideClassName props for SidePage component by @keiko233 - **ui:** Add reverse icon props for ExpandMore component by @keiko233 - **ui:** Add MuiLinearProgress material you style override by @keiko233 - **ui:** Add more props support for BaseDialog by @keiko233 - **ui:** Add side toggle animation & reverse layout props by @keiko233 - **ui:** Add SidePage component by @keiko233 - **ui:** Add TextItem component by @keiko233 - **ui:** Add BaseItem component by @keiko233 - **ui:** Add TextFieldProps for NumberItem by @keiko233 - **ui:** Add ExpandMore component by @keiko233 - **ui:** Add loading props support for BaseCard by @keiko233 - **ui:** Add LoadingSwitch component by @keiko233 - **ui:** Add divider props support for BaseDialog by @keiko233 - **ui:** Add BaseDialog component by @keiko233 - **ui:** Add MuiDialog material you override by @keiko233 - **ui:** Add disabled props for MenuItem by @keiko233 - **ui:** Add selectSx for MenuItem component by @keiko233 - **ui:** Add divider props for NumberItem by @keiko233 - **ui:** Add Expand component by @keiko233 - **ui:** Add NumberItem component by @keiko233 - **ui:** Add MenuItem component by @keiko233 - **ui:** Add SwitchItem component by @keiko233 - **ui:** Add BaseCard label props undefined type support by @keiko233 - **ui:** Add MDYBaseCard component by @keiko233 - **ui:** Add MuiSwitch material you override by @keiko233 - **ui:** Add MuiCard & MuiCardContent material you override by @keiko233 - **ui:** Custom breakpoints by @keiko233 - **ui:** Add memo suuport for MDYBasePage header by @keiko233 - **ui:** Add MuiPaper material you override by @keiko233 - **ui:** Add MDYBasePage component by @keiko233 - **ui:** Add MuiButtonGroup material you override by @keiko233 - **ui:** Add MuiButton material you override by @keiko233 - **ui:** Add new mui theme create method for material you by @keiko233 - **updater:** Add a view github button by @greenhat616 - **use-message:** Add nyanpasu title prefix by @keiko233 - **util:** Add a util to collect env infos to submit issues by @greenhat616 - **web:** Replace default utl to Dashboard Page by @keiko233 - **window:** Always on top by @greenhat616 - Minor tweaks for app layout by @keiko233 - Draft updater dialog, and close #1328 by @greenhat616 - Add core updater progress by @keiko233 - Draft core updater progres by @greenhat616 - Add lazy loading for proxies icons by @greenhat616 - Allow select on rule page & log page by @keiko233 - Add clash icon local cache by @greenhat616 - Add runtime config diff dialog by @greenhat616 - Add tun stack selector by @greenhat616 - Impl script esm and async support (#1266) by @greenhat616 in [#1266](https://github.com/libnyanpasu/clash-nyanpasu/pull/1266) - Should hidden speed chip while no history by @greenhat616 - Add auto migration before app run by @greenhat616 - Add migrations manager and cmds to run migration by @greenhat616 - Add swift feedback button by @greenhat616 - Print better build info by @greenhat616 - Add a experimental mutlithread file download util by @greenhat616 - Experimental add draggable logo by @greenhat616 - Resizable sidebar without config presistant by @greenhat616 - Use node octokit deps by @keiko233 - Profile spec chains support by @greenhat616 - Support lua script type and do a lot refactor by @greenhat616 ### 🐛 Bug Fixes - **app-setting:** Missing fields with template by @keiko233 - **chians:** Throw backend log on use native dialog by @keiko233 - **ci:** Update publish script by @greenhat616 - **ci:** Updater checkout issue by @greenhat616 - **ci:** Updater checkout issue by @greenhat616 - **ci:** Prepend changelog by @greenhat616 - **ci:** Build by @greenhat616 - **clash:** Accpet clash rs status code and handle status error by @greenhat616 - **clash:** Hidden ipv6 setting while clash rs by @greenhat616 - **clash-web:** Fix reversed Boolean value by @keiko233 - **clash-web:** Empty array err by @keiko233 - **config:** Replace enable_auto_check_update by @keiko233 - **connections:** Table type filed err by @keiko233 - **connections:** Host undefined err by @keiko233 - **csp:** Allow loading local cache server assets by @greenhat616 - **csp:** Allow img-src from https by @keiko233 - **custom-scheme:** Xdg-mime default wrong call format by @greenhat616 - **custom-scheme:** Front page redirect by @greenhat616 - **custom-scheme:** Should pass single-instance while launched by custom schema by @greenhat616 - **custom-scheme:** Support mutiple scheme by @greenhat616 - **custom-theme:** Unregister event when the themoe mode is not system by @keiko233 - **custom-theme:** Fix custom theme effect & system theme sync event by @keiko233 - **dashboard:** Data panel layer size err by @keiko233 - **dashboard:** Zero value display err by @keiko233 - **deep link:** Use different identifiers in dev mode by @keiko233 - **deps:** Add misssing deps by @keiko233 - **deps:** Vite-plugin-monaco-editor version err by @keiko233 - **dev:** When dev feature force use dev app dir by @keiko233 - **drawer:** Style prop merge err by @keiko233 - **drawer:** Offset value err by @keiko233 - **drawer:** Small size drawer layout err by @keiko233 - **drawer:** Minor tweaks by @keiko233 - **drawer:** Fix scroll err & hidden scrollbar by @keiko233 - **drawer:** Fix padding & text position by @keiko233 - **enhance:** Rm useless use_lowercase hook, and close #1323 by @greenhat616 - **enhance:** Use oxc ast to wrap function main, close #1298 by @greenhat616 - **enhance:** Should update after editing activated chain item by @greenhat616 - **enhance:** Transform allow lan decrepation by @greenhat616 - **enhance:** Should export default by @greenhat616 - **enhance:** Use indexmap to ensure the process order by @greenhat616 - **enhance:** Mark process fn async by @greenhat616 - **guard:** Remove ipv6 field while core is clash rs by @greenhat616 - **hook:** Replace DebounceFn to ThrottleFn by @keiko233 - **image-resize:** Correct image buffer extraction and resizing logic by @keiko233 - **interface:** Close all connections err by @keiko233 - **interface:** Drop defalut clash mode set by @keiko233 - **interface:** Bad references by @keiko233 - **interface:** Add clash rs version format method by @keiko233 - **interface:** Request clash when use set by @keiko233 - **interface:** Data type err by @keiko233 - **interface:** Typos by @keiko233 - **layout:** Bringup layout control to top layer by @keiko233 - **lint:** Prettier plugin load err by @keiko233 - **linux:** Replace backdrop blur to background opacity by @keiko233 - **linux:** Service controls gui prompt, and close #1443 by @greenhat616 - **linux:** Try to use symbol to fix tray issue by @greenhat616 - **linux:** Use a workaround to make tray select work by @greenhat616 - **linux:** Try to solve sysproxy resolver in appimage by @greenhat616 - **linux:** Try to solve xdg-open in AppImage by @greenhat616 - **logs:** Disable log state err by @keiko233 - **logs:** Logs page freeze by @keiko233 - **logs:** Logs page style err by @keiko233 - **macos:** App icon size by @keiko233 - **macos:** Dialog layout position err by @keiko233 - **macos:** Remove prevent close block in macos by @greenhat616 - **macos:** Rename single instance check path by @greenhat616 - **macos:** Try to use another name to fix create dir error by @greenhat616 - **node-card:** Layout err by @keiko233 - **nsis:** Uninstall service check by @greenhat616 - **nsis:** Stop running core by service while install and rm service dir while uninstall by @greenhat616 - **nyanpasu:** Missing of recoil drop commit by @keiko233 - **nyanpasu:** Missing tailwind css import by @keiko233 - **nyanpasu:** Word typos by @keiko233 - **nyanpasu:** Undfined value err by @keiko233 - **nyanpasu:** Props usage error by @keiko233 - **nyanpasu:** Drop tooltips to fix mui warning by @keiko233 - **portable:** Add nyanpasu service binary by @greenhat616 - **profile:** Dialog padding err by @keiko233 - **profile:** Just invisble progress by @greenhat616 - **profile:** Correctly handle filtering of script types in filterProfiles function by @keiko233 - **profile-viewer:** Replace default profile user agent to clash-nyanpasu by @keiko233 - **profiles:** Dont use sub component to solve the loss data issue by @greenhat616 - **profiles:** Scoped chians state update err by @keiko233 - **profiles:** Add missing open file on chains menu by @keiko233 - **profiles:** Monaco dialog style err by @keiko233 - **profiles:** Fix new chain method err by @keiko233 - **profiles:** Fix profile item selected color on dark mode by @keiko233 - **profiles:** Fix color on dark mode by @keiko233 - **profiles:** Add missing open file method by @keiko233 - **profiles:** Profile traffic percent calculation error by @keiko233 - **profiles:** Add selected props for ProfileItem by @keiko233 - **providers:** Single line layout err by @keiko233 - **proxies:** Proxy node select err & render err by @keiko233 - **proxies:** Sorting cannot be performed in global mode by @keiko233 - **proxies:** Nodecard transition by @keiko233 - **proxies:** Delay sort & timeout string by @keiko233 - **proxies:** Global proxy select err by @keiko233 - **proxies:** Incorrect judgment leading to value transfer error by @keiko233 - **proxies:** Missing import by @keiko233 - **proxies:** Current group get err by @keiko233 - **route:** Reaplce icon dashboard to Dashboard by @keiko233 - **rules:** Rules page display err by @keiko233 - **script:** Decompress nyanpasu-service by @greenhat616 - **script:** Replace appimage to rpm pkg by @keiko233 - **script:** Use latest node version by @keiko233 - **script:** Fix build with nightly prepare script by @keiko233 - **script:** Nightly prepare package.json path by @keiko233 - **scripts:** Typos by @keiko233 - **scripts:** Telegram notify failed to request github repo releases info by @keiko233 - **service:** Restart core while service mode enabled and service state changed by @greenhat616 - **service:** Adapt the current ui by @greenhat616 - **setting:** Service mod toggle by @keiko233 - **setting-clash-core:** Disable initial animetion by @keiko233 - **setting-clash-core:** Add user triger check update loading status by @keiko233 - **setting-nyanpasu-version:** Incorrect value passing by @keiko233 - **setting-system-proxy:** Grid layout breakpoint value by @keiko233 - **setting-web-ui:** Zero value for index err by @keiko233 - **settings:** Version pkg import err by @keiko233 - **settings:** Swr use err by @keiko233 - **settings:** Page masonry layout err by @keiko233 - **settings:** Fix auto check update fileld stats err by @keiko233 - **single-instance:** Should use path instead of namespace in linux by @greenhat616 - **string:** Typo in side-chain.tsx (#999) by @NalCol in [#999](https://github.com/libnyanpasu/clash-nyanpasu/pull/999) - **styles:** Try to use normalize.css to solve webkit font issue by @greenhat616 - **tauri:** Missing dialog features by @keiko233 - **tauri:** Mixed content err by @keiko233 - **theme:** Fix value merge null err by @keiko233 - **theme:** Update breakpoint value by @keiko233 - **tray:** Add a barrier to try to solve the tray selector issue in linux by @greenhat616 - **tsconfig:** Typescript type reference issue by @keiko233 - **tun:** Compatible with clash rs by @greenhat616 - **ui:** Dialog exit animation err by @keiko233 - **ui:** Close animetion position err by @keiko233 - **ui:** Fix dialog unmount err by @keiko233 - **ui:** Missing dialog z index css prop by @keiko233 - **ui:** Refactor dialog use radix ui portal by @keiko233 - **ui:** Scroll bar hidden on no padding by @keiko233 - **ui:** Base page dom layout err by @keiko233 - **ui:** Add Menu Paper box shadow by @keiko233 - **ui:** Fixed FloatingButton position by @keiko233 - **ui:** Fixed FloatingButton position by @keiko233 - **ui:** Force set FloadtingButton posotion absolute by @keiko233 - **ui:** Drop memo children too by @keiko233 - **ui:** Drop SidePage memo by @keiko233 - **ui:** Hide SidePage side content when there is no side by @keiko233 - **ui:** Drop width for MDYBasePage-content by @keiko233 - **ui:** Fix BasePage content width by @keiko233 - **ui:** Disable loading mask animetion initial for BaseCard by @keiko233 - **ui:** Default unmount dialog modal by @keiko233 - **ui:** Replace padding to Box element by @keiko233 - **ui:** Disable initial animetion for Expand component by @keiko233 - **ui:** Add disabled overlay for MuiSwitch by @keiko233 - **ui:** Fix BaseDialog content height err by @keiko233 - **ui:** Pin MenuItem width by @keiko233 - **ui:** Disbale MuiPaper override by @keiko233 - **updater:** Invaild date issue by @greenhat616 - **updater:** Fetch version.json from main branch (#968) by @aviraxp in [#968](https://github.com/libnyanpasu/clash-nyanpasu/pull/968) - **util:** Speed test should use desc order by @greenhat616 - **webkit:** Border radius not apply on absolute layout by @keiko233 - **window:** Show window when frontend mounted by @keiko233 - **windows:** Window controller position by @keiko233 - **windows:** Custom scheme call by @greenhat616 - Disable migrate app dir feature in macos, linux by @greenhat616 - Custom scheme url parser in webkit by @greenhat616 - Try to fix read profile state again by @greenhat616 - Add a key to try to solve read profile issue by @greenhat616 - Log time issue, and close #1447 by @greenhat616 - Disable core update check in linux by @greenhat616 - Disable app updater for linux expect AppImage by @greenhat616 - Rm macos unsupport transparent by @greenhat616 - Try to fix cross platform save win state issue by @greenhat616 - Lint by @greenhat616 - Lint by @greenhat616 - Use open_that workaround for appimage by @greenhat616 - React deps by @greenhat616 - Check button issue by @greenhat616 - Lint by @greenhat616 - Profile runtime config button color by @greenhat616 - Nsis build issue by @greenhat616 - Exhaustive-deps lint by @greenhat616 - Disable react complier lint until it fixes bug by @greenhat616 - Add 172.16.0.0/12 system proxy passby on windows (#1405) by @Remonli in [#1405](https://github.com/libnyanpasu/clash-nyanpasu/pull/1405) - Use tauri client for asn request by @greenhat616 - Proxies nodes list update issue, and close #1402 by @greenhat616 - Lint by @greenhat616 - Mutate core version while updater finished by @greenhat616 - Updater replace issue, and close #1377 by @greenhat616 - Script prepare gh token by @greenhat616 - Lint by @greenhat616 - Build by @greenhat616 - Build by @greenhat616 - Build by @greenhat616 - Lint by @greenhat616 - Lint by @greenhat616 - Try to fix ts project import issue by @greenhat616 - Ts project settings (#1394) by @greenhat616 in [#1394](https://github.com/libnyanpasu/clash-nyanpasu/pull/1394) - Ts project lint by @greenhat616 - Correct the update order to ensure the script changes get applied by @greenhat616 - Clash config select issue, and close #1303 by @greenhat616 - Spawn orientation random updater id by @keiko233 - Throw single instance create error by @greenhat616 - Connection page lazy loading by @greenhat616 - Config detect, and close #1305 by @greenhat616 - Quick import submit when enter press by @greenhat616 - Icon loader should not lazy by @greenhat616 - Icon lazy image by @greenhat616 - Show a error dialog while check latest cores error, and close #1302 by @greenhat616 - Issues by @greenhat616 - Marquee by @greenhat616 - No need retry while os error 232 by @greenhat616 - Not save clash overrides config, close #1295 by @greenhat616 - Fix broken pipe causing too many logs #637 by @4o3F - Fix tray not able to reset by @4o3F - Update sysproxy-rs to support KDE by @4o3F - Fix url scheme issue #902 by @4o3F - Use window open counter to prevent double-click opening the window immediately by @greenhat616 - Should update match by @greenhat616 - Make profile yaml file to be formatted by serde yaml by @greenhat616 - Update config while patch profile scoped chain by @greenhat616 - Lint by @greenhat616 - Lint by @greenhat616 - Lint by @greenhat616 - Clash rs core switch by @greenhat616 - Patch profile chains by @greenhat616 - Patch profile chains by @greenhat616 - Lint by @greenhat616 - Ignore deleteConnection error while applying new profile by @greenhat616 - Make port strategy check better by @greenhat616 - No exit code on unix platform by @greenhat616 - Try to solve the migration failed issue by @greenhat616 - Lint by @greenhat616 - Ui service control and updater path by @greenhat616 - Cleanup codes by @greenhat616 - Lint by @greenhat616 - Lint by @greenhat616 - Skip migration while home dir is not exist, and close #1235 by @greenhat616 - Skip migration while home dir is not exist, and close #1235 by @greenhat616 - Lint by @greenhat616 - Should create data dir and config dir when fetch it if not exist by @greenhat616 - Styles by @greenhat616 - Lint by @greenhat616 - Migration panic by @greenhat616 - Migrate all upcoming migrations while pending by @greenhat616 - Migration missing dirs touch by @keiko233 - Left container scrollbar gutter (#1225) by @fu050409 in [#1225](https://github.com/libnyanpasu/clash-nyanpasu/pull/1225) - Add quote prefix, and solve the undefined issue by @greenhat616 - Drawer resize panel style by @keiko233 - Lint by @greenhat616 - Lint by @greenhat616 - Build by @keiko233 - Build by @keiko233 - Missing export by @keiko233 - Lint in linux by @greenhat616 - Enhance process panic while profiles is empty by @greenhat616 - Fmt by @greenhat616 - Log path by @greenhat616 - Use webview2-com-bridge to solve ra crash issue by @greenhat616 - Lint by @greenhat616 - Minor issues (#884) by @greenhat616 in [#884](https://github.com/libnyanpasu/clash-nyanpasu/pull/884) - Ci by @greenhat616 - Lint by @greenhat616 - Vite plugin monaco editor overrides by @greenhat616 - Fix issue #776 by @4o3F - Mac x64 use mihomo compatible core (#773) by @Sakurasan - Lint by @keiko233 - Change storage_db name by @4o3F - Fix database creation issue by @4o3F ### 📚 Documentation - **readme:** Add nyanpasu 1.6.0 label by @keiko233 - **readme:** Fix resource path err by @keiko233 - Fix dev build shields card link err by @keiko233 - Update screenshot & clean up docs by @keiko233 ### 🔨 Refactor - **chains:** Use bitflags instead of custom support struct by @greenhat616 - **connections:** Drop mui/x-data-grid & use material-react-table by @keiko233 - **core:** Use new core manager from nyanpasu utils to prepare for new nyanpasu service by @greenhat616 - **custom-scheme:** Use nonblocking io and create window if window is not exist by @greenhat616 - **dashboard:** Split health panel by @keiko233 - **dirs:** Split home_dir into config_dir and data_dir by @greenhat616 - **drawer:** Use react-split-grid replace react-resizable-panels by @keiko233 - **frontend:** Make monorepo by @keiko233 - **hook:** Use-breakpoint hook with react-use by @keiko233 - **hook:** Optimize useBreakpoint hook to reduce unnecessary updates by @keiko233 - **hotkeys:** First draft hotkeys setting dialog by @greenhat616 - **interface!:** Increase code readability by @keiko233 - **interface/service:** Tauri interface writing by @keiko233 - **layout:** New layout design by @keiko233 - **nsis:** Use nsis's built-in com plugin instead of ApplicationID plugin (#9606) by @amrbashir - **profiles:** Chians component by @keiko233 - **proxies:** Drop memo use effert to update by @keiko233 - **proxies:** Delay button using tailwind css and memo by @keiko233 - **script:** Manifest generator script by @keiko233 - **script:** Resource check script by @keiko233 - **service:** Add new service backend support by @greenhat616 - **theme:** Migrating to CSS theme variables by @keiko233 - **ui:** Drop mui dialog & use redix-ui with framer motion by @keiko233 - **updater:** Support speedtest and updater concurrency by @greenhat616 - Drop async component use react suspense by @keiko233 - Proxies page use new interface by @keiko233 - Refactor rocksdb into redb, this should solve #452 by @4o3F in [#755](https://github.com/libnyanpasu/clash-nyanpasu/pull/755) - Refactor rocksdb into redb, this should fix #452 by @4o3F --- ## New Contributors - @Remonli made their first contribution in [#1405](https://github.com/libnyanpasu/clash-nyanpasu/pull/1405) - @fu050409 made their first contribution in [#1225](https://github.com/libnyanpasu/clash-nyanpasu/pull/1225) - @NalCol made their first contribution in [#999](https://github.com/libnyanpasu/clash-nyanpasu/pull/999) - @aviraxp made their first contribution in [#968](https://github.com/libnyanpasu/clash-nyanpasu/pull/968) - @amrbashir made their first contribution - @Sakurasan made their first contribution **Full Changelog**: https://github.com/libnyanpasu/clash-nyanpasu/compare/v1.5.1...v1.6.0 ## [1.5.1] - 2024-04-08 ### ✨ Features - **backend:** Allow to hide tray selector (#626) by @greenhat616 in [#626](https://github.com/libnyanpasu/clash-nyanpasu/pull/626) - **config:** Support custom app dir in windows (#582) by @greenhat616 in [#582](https://github.com/libnyanpasu/clash-nyanpasu/pull/582) - **custom-schema:** Add support for name and desc fields by @greenhat616 - Perf motion transition by @keiko233 - Lock rustup toolchain to stable channel by @4o3F - New design log page by @keiko233 - New desigin rules page by @keiko233 - Improve WebSocket reconnection in useWebsocket hook by @keiko233 ### 🐛 Bug Fixes - **bundler/nsis:** Don't use /R flag on installation dir by @keiko233 - **chains:** Only guard fields should be overwritten (#629) by @greenhat616 in [#629](https://github.com/libnyanpasu/clash-nyanpasu/pull/629) - **cmds:** Migrate custom app dir typo (#628) by @greenhat616 in [#628](https://github.com/libnyanpasu/clash-nyanpasu/pull/628) - **cmds:** `path` in changing app dir call (#591) by @greenhat616 in [#591](https://github.com/libnyanpasu/clash-nyanpasu/pull/591) - **docs:** Fix url typos by @keiko233 - **notification:** Unexpected `}` (#563) by @WOSHIZHAZHA120 in [#563](https://github.com/libnyanpasu/clash-nyanpasu/pull/563) - Revert previous commit by @greenhat616 - Subscription info parse issue, closing #729 by @greenhat616 - Fix misinterprete of tauri's application args by @4o3F - Missing github repo context by @keiko233 - Try to add a launch command to make restart application work by @greenhat616 - Try to use delayed singleton check to make restart app work by @greenhat616 - Panic while quit application by @greenhat616 - Restart application not work by @greenhat616 - Fix migration issue for path with space by @4o3F - Fix migration child process issue by @4o3F - Fix rename permission issue by @4o3F - Connection page NaN and first enter animation by @greenhat616 - Use shiki intead of shikiji by @greenhat616 - Use clash verge rev patch to resolve Content-Disposition Filename issue, closing #703 by @greenhat616 - Lint by @greenhat616 - Command path by @greenhat616 - Draft patch to resolve custom app config migration by @greenhat616 - Proxy groups virtuoso also overscan by @keiko233 - Top item no padding by @keiko233 - Use overscan to prevent blank scrolling by @keiko233 - Profiles when drag sort container scroll style by @keiko233 - Profile-box border radius value by @keiko233 - Slinet start get_window err by @keiko233 - MDYSwitch-thumb size by @keiko233 - Build by @keiko233 - Disable webview2 SwipeNavigation by @keiko233 - Fix wrong window size and position by @4o3F - Fix single instance check failing on macos by @4o3F ### 📚 Documentation - Add clash-verge-rev acknowledgement by @greenhat616 - Add twitter img tag by @keiko233 - Add license img tag by @keiko233 - Align center tag imgs by @keiko233 - Update readme by @keiko233 - Update issues template by @greenhat616 ### 🔨 Refactor - Use lazy load routes to improve performance by @greenhat616 --- ## New Contributors - @WOSHIZHAZHA120 made their first contribution in [#563](https://github.com/libnyanpasu/clash-nyanpasu/pull/563) **Full Changelog**: https://github.com/libnyanpasu/clash-nyanpasu/compare/v1.5.0...v1.5.1 ## [1.5.0] - 2024-03-03 ### 💥 Breaking Changes - **backend:** Add tray proxies selector support (#417) by @greenhat616 in [#417](https://github.com/libnyanpasu/clash-nyanpasu/pull/417) - **clash:** Add default core secret and impl port checker before clash start (#533) by @greenhat616 in [#533](https://github.com/libnyanpasu/clash-nyanpasu/pull/533) ### ✨ Features - **config:** Add migration for old config dir (#419) by @4o3F in [#419](https://github.com/libnyanpasu/clash-nyanpasu/pull/419) - **connection:** Allow filter out process name by @greenhat616 - **locale:** Use system locale as default (#437) by @greenhat616 in [#437](https://github.com/libnyanpasu/clash-nyanpasu/pull/437) - **tray:** Add tray icon resize logic to improve icon rendering (#540) by @greenhat616 in [#540](https://github.com/libnyanpasu/clash-nyanpasu/pull/540) - **tray:** Add diff check for system tray partial update (#477) by @4o3F in [#477](https://github.com/libnyanpasu/clash-nyanpasu/pull/477) - Custom schema support (#516) by @4o3F in [#516](https://github.com/libnyanpasu/clash-nyanpasu/pull/516) - Add Auto Check Updates Switch by @keiko233 - Refactor UpdateViewer by @keiko233 - OnCheckUpdate button supports loading animation & refactoring error removal notification using dialog by @keiko233 - Add margin for SettingItem extra element by @keiko233 - Add useMessage hook by @keiko233 - Refactor GuardStatus & support loading status by @keiko233 - MDYSwitch support loading prop by @keiko233 - Add MDYSwitch & replace all Switches with MDYSwitch by @keiko233 - Color select use MuiColorInput by @keiko233 - Make profile material you by @keiko233 - New style design profile item drag sort by @keiko233 ### 🐛 Bug Fixes - **ci:** Replace github workflow token by @keiko233 - **config:** Fix config migration (#433) by @4o3F in [#433](https://github.com/libnyanpasu/clash-nyanpasu/pull/433) - **custom-schema:** Fix schema not working for new opening and dialog not showing with certain route (#534) by @4o3F in [#534](https://github.com/libnyanpasu/clash-nyanpasu/pull/534) - **deps:** Update rust crates by @greenhat616 - **macos:** Use rfd to prevent panic by @greenhat616 - **nsis:** Should not stop verge service while updating by @greenhat616 - **proxies:** Use indexmap instead to correct order by @greenhat616 - **proxies:** Reduce tray updating interval by @greenhat616 - **tray:** Use base64 encoded id to fix item not found issue by @greenhat616 - **tray:** Should disable click expect Selector and Fallback type by @greenhat616 - **tray:** Proxies updating deadlock by @greenhat616 - Release ci by @greenhat616 - Release ci by @greenhat616 - Fix wrong window position and size with multiple screen by @4o3F - Resolve save windows state event by @greenhat616 - Media screen value typos by @keiko233 - Layout error when window width is small by @keiko233 - Lint by @greenhat616 - Line breaks typos by @keiko233 - MDYSwitch switchBase padding value by @keiko233 - Lint by @greenhat616 - Fmt by @greenhat616 - Build issue by @greenhat616 - Config migration issue by @greenhat616 - Ci by @greenhat616 - Proxy item box-shadow err by @keiko233 ### 🔨 Refactor - **clash:** Move api and core manager into one mod (#411) by @greenhat616 in [#411](https://github.com/libnyanpasu/clash-nyanpasu/pull/411) - **i18n:** Change backend localization to rust-i18n (#425) by @4o3F in [#425](https://github.com/libnyanpasu/clash-nyanpasu/pull/425) - **logging:** Use `tracing` instead of `log4rs` (#486) by @greenhat616 in [#486](https://github.com/libnyanpasu/clash-nyanpasu/pull/486) - **proxies:** Proxies hash and diff logic by @greenhat616 - **single-instance:** Refactor single instance check (#499) by @4o3F in [#499](https://github.com/libnyanpasu/clash-nyanpasu/pull/499) --- **Full Changelog**: https://github.com/libnyanpasu/clash-nyanpasu/compare/v1.4.5...v1.5.0 ## [1.4.5] - 2024-02-08 ### 💥 Breaking Changes - **nsis:** Switch to both installMode by @greenhat616 - **updater:** Use nsis instead of msi by @greenhat616 ### 🐛 Bug Fixes - **bundle:** Instance is running while updating app (#393) by @greenhat616 in [#393](https://github.com/libnyanpasu/clash-nyanpasu/pull/393) - **bundler:** Kill processes while updating in windows by @greenhat616 - **ci:** Daily updater issue (#392) by @greenhat616 in [#392](https://github.com/libnyanpasu/clash-nyanpasu/pull/392) - **ci:** Nightly updater issue by @greenhat616 - **nsis:** Kill nyanpasu processes while updating (#403) by @greenhat616 in [#403](https://github.com/libnyanpasu/clash-nyanpasu/pull/403) - Portable issues (#395) by @greenhat616 in [#395](https://github.com/libnyanpasu/clash-nyanpasu/pull/395) - Minimize icon is wrong while resize window (#394) by @greenhat616 in [#394](https://github.com/libnyanpasu/clash-nyanpasu/pull/394) - Sort connection in numerical comparison for `Download`, `DL Speed`, etc (#367) by @Jeremy-Hibiki in [#367](https://github.com/libnyanpasu/clash-nyanpasu/pull/367) - Resources missing by @greenhat616 in [#354](https://github.com/libnyanpasu/clash-nyanpasu/pull/354) --- ## New Contributors - @Jeremy-Hibiki made their first contribution in [#367](https://github.com/libnyanpasu/clash-nyanpasu/pull/367) **Full Changelog**: https://github.com/libnyanpasu/clash-nyanpasu/compare/v1.4.4...v1.4.5 ## [1.4.4] - 2024-01-29 ### 🐛 Bug Fixes - **backend:** Fix deadlock issue on config (#312) by @4o3F in [#312](https://github.com/libnyanpasu/clash-nyanpasu/pull/312) - **ci:** Publish & updater by @greenhat616 - **ci:** Should generate manifest in dev branch for compatible with <= 1.4.3 (#292) by @greenhat616 in [#292](https://github.com/libnyanpasu/clash-nyanpasu/pull/292) - **deps:** Update deps (#294) by @greenhat616 in [#294](https://github.com/libnyanpasu/clash-nyanpasu/pull/294) - **portable:** Portable bundle issue (#335) by @greenhat616 in [#335](https://github.com/libnyanpasu/clash-nyanpasu/pull/335) - **portable:** Do not use system notification api while app is portable (#334) by @greenhat616 in [#334](https://github.com/libnyanpasu/clash-nyanpasu/pull/334) - **updater:** Use release body as updater note (#333) by @greenhat616 in [#333](https://github.com/libnyanpasu/clash-nyanpasu/pull/333) - Use if let instead (#309) by @greenhat616 in [#309](https://github.com/libnyanpasu/clash-nyanpasu/pull/309) ### 📚 Documentation - Add ArchLinux AUR install suggestion (#293) by @Kimiblock in [#293](https://github.com/libnyanpasu/clash-nyanpasu/pull/293) ### 🔨 Refactor - **backend:** Improve code robustness (#303) by @greenhat616 in [#303](https://github.com/libnyanpasu/clash-nyanpasu/pull/303) --- **Full Changelog**: https://github.com/libnyanpasu/clash-nyanpasu/compare/v1.4.3...v1.4.4 ## [1.4.3] - 2024-01-20 ### ✨ Features - New release workflow (#284) by @greenhat616 in [#284](https://github.com/libnyanpasu/clash-nyanpasu/pull/284) - Proxies ui minor tweaks by @keiko233 - Make proxies material you by @keiko233 ### 🐛 Bug Fixes - **ci:** Pin rust version to 1.74.1 (#213) by @greenhat616 in [#213](https://github.com/libnyanpasu/clash-nyanpasu/pull/213) - **ci:** Use latest action by @greenhat616 - **ci:** Use dev commit hash when schedule dispatch by @greenhat616 - **log:** Incorrect color in light mode by @greenhat616 - **rocksdb:** Use TransactionDB instead of OptimisticTransactionDB (#194) by @greenhat616 in [#194](https://github.com/libnyanpasu/clash-nyanpasu/pull/194) - **updater:** Should use nyanpasu proxy or system proxy when performing request (#273) by @greenhat616 in [#273](https://github.com/libnyanpasu/clash-nyanpasu/pull/273) - **updater:** Add status code judge by @greenhat616 - **updater:** Allow to use elevated permission to copy and override core by @greenhat616 - **vite:** Rm useless shikiji langs support (#267) by @greenhat616 in [#267](https://github.com/libnyanpasu/clash-nyanpasu/pull/267) - Release ci by @greenhat616 - Publish ci by @greenhat616 - Notification premission check (#263) by @greenhat616 in [#263](https://github.com/libnyanpasu/clash-nyanpasu/pull/263) - Notification fallback (#262) by @greenhat616 in [#262](https://github.com/libnyanpasu/clash-nyanpasu/pull/262) - Stable channel build issue (#248) by @greenhat616 in [#248](https://github.com/libnyanpasu/clash-nyanpasu/pull/248) - Virtuoso scroller bottom not padding by @keiko233 - Windrag err by @keiko233 - Same text color for `REJECT-DROP` policy as `REJECT` (#236) by @xkww3n in [#236](https://github.com/libnyanpasu/clash-nyanpasu/pull/236) - Enable_tun block the process (#232) by @dyxushuai - #212 by @greenhat616 - Lint by @greenhat616 - Updater by @greenhat616 - Dark mode flash in win by @greenhat616 - Open file, closing #197 by @greenhat616 - Add a panic hook to collect logs and show a dialog (#191) by @greenhat616 in [#191](https://github.com/libnyanpasu/clash-nyanpasu/pull/191) --- ## New Contributors - @xkww3n made their first contribution in [#236](https://github.com/libnyanpasu/clash-nyanpasu/pull/236) **Full Changelog**: https://github.com/libnyanpasu/clash-nyanpasu/compare/v1.4.2...v1.4.3 ## [1.4.2] - 2023-12-24 ### ✨ Features - **updater:** Finish ui by @greenhat616 - **updater:** Finish core updater backend by @greenhat616 - Use christmas logo by @keiko233 - Auto add dns according this method by @yswtrue - Backport concurrency of latency test by @greenhat616 - Auto log clear by @greenhat616 - Nightly build with updater by @greenhat616 - Rules providers by @greenhat616 - Improve animations by @greenhat616 - Quick logs collect by @greenhat616 - Bundled mihomo alpha by @greenhat616 - New style win tray icon & add blue icon when tun enable by @keiko233 ### 🐛 Bug Fixes - **ci:** Release build by @greenhat616 - **ci:** Updater and dev build by @greenhat616 - **dialog:** Align center and overflow issue by @greenhat616 - **lint:** Toml fmt by @greenhat616 - **resources:** Win service support and mihomo alpha version proxy by @greenhat616 - **updater:** Copy logic by @greenhat616 - **window:** Preserve window state before window minimized by @greenhat616 - **window:** Add a workaround for close event in windows by @greenhat616 - Minor tweak base-content width by @keiko233 - Shikiji text wrapping err by @keiko233 - Dark shikiji display color err by @keiko233 - Pin runas to v1.0.0 by @greenhat616 - Lint by @greenhat616 - Bump nightly version after publish by @greenhat616 - I18n resources by @greenhat616 - Format ansi in log viewer by @greenhat616 - Delay color, closing #124 by @greenhat616 - #96 by @greenhat616 - #92 by @greenhat616 - Lint by @greenhat616 - Ci by @greenhat616 - Ci by @greenhat616 - Ci by @greenhat616 - Dev build branch issue by @greenhat616 - Icon issues, close #55 by @greenhat616 - Use a workaroud to reduce #59 by @greenhat616 - Win state by @greenhat616 ### 📚 Documentation - Put issue config into effect (#148) by @txyyh in [#148](https://github.com/libnyanpasu/clash-nyanpasu/pull/148) - Upload missing issue config by @txyyh - Update issues template & upload ISSUE.md by @keiko233 ### 🔨 Refactor - **tasks:** Provide a universal abstract layer for task managing (#15) by @greenhat616 - Profile updater by @greenhat616 --- ## New Contributors - @yswtrue made their first contribution - @txyyh made their first contribution in [#148](https://github.com/libnyanpasu/clash-nyanpasu/pull/148) **Full Changelog**: https://github.com/libnyanpasu/clash-nyanpasu/compare/v1.4.1...v1.4.2 ## [1.4.1] - 2023-12-06 ### ✨ Features - **transition:** Add none and transparent variants by @greenhat616 - Use twemoji to display flags in win (#48) by @greenhat616 in [#48](https://github.com/libnyanpasu/clash-nyanpasu/pull/48) - Add page transition mode and duration options by @keiko233 in [#42](https://github.com/libnyanpasu/clash-nyanpasu/pull/42) - Add page transition duration options by @greenhat616 - Add page transition mode switch by @greenhat616 - Use framer-motion for smooth page transition by @greenhat616 - Support new clash field by @greenhat616 - Support drag profile item (#36) by @Kuingsmile in [#36](https://github.com/libnyanpasu/clash-nyanpasu/pull/36) - Use tauri notification api by @keiko233 - Update new clash.meta close #20 (#30) by @Kuingsmile in [#30](https://github.com/libnyanpasu/clash-nyanpasu/pull/30) - Support random mixed port (#29) by @Kuingsmile in [#29](https://github.com/libnyanpasu/clash-nyanpasu/pull/29) - Use workspace in backend by @greenhat616 - New style win tray icon by @keiko233 - Add tooltip for tray (#24) by @Kuingsmile in [#24](https://github.com/libnyanpasu/clash-nyanpasu/pull/24) - Experimental support `clash-rs` (#23) by @greenhat616 in [#23](https://github.com/libnyanpasu/clash-nyanpasu/pull/23) - Add UWP tool support, fix install service bug (#19) by @Kuingsmile in [#19](https://github.com/libnyanpasu/clash-nyanpasu/pull/19) ### 🐛 Bug Fixes - Taskbar maximize toggle icon state (#46) by @greenhat616 in [#46](https://github.com/libnyanpasu/clash-nyanpasu/pull/46) - Missing scss import by @greenhat616 - Lint by @greenhat616 - Lint by @greenhat616 - Workflow script typos by @keiko233 - Osx-aarch64-upload bundlePath typos by @keiko233 - Portable target dir by @keiko233 - Portable missing clash-rs core by @keiko233 - Item col width too narrow by @keiko233 - I18n typos by @keiko233 ### 📚 Documentation - Add preview gif by @keiko233 ### 🔨 Refactor - **scripts:** Use ts and consola instead by @greenhat616 - Use `workspace` in backend by @keiko233 in [#28](https://github.com/libnyanpasu/clash-nyanpasu/pull/28) --- ## New Contributors - @Kuingsmile made their first contribution in [#36](https://github.com/libnyanpasu/clash-nyanpasu/pull/36) **Full Changelog**: https://github.com/libnyanpasu/clash-nyanpasu/compare/v1.4.0...v1.4.1 ## [1.4.0] - 2023-11-15 ### ✅ Testing - Windows service by @zzzgydi ### ✨ Features - **layout:** Add logo & update style by @zzzgydi - **macOS:** Support cmd+w and cmd+q by @zzzgydi - **proxy:** Finish proxy page ui and api support by @zzzgydi - **style:** Adjust style impl by @zzzgydi - **system tray:** Support switch rule/global/direct/script mode in system tray by @Limsanity - **traffic:** Api support & adjust by @zzzgydi - Minor tweaks by @keiko233 - Nyanpasu Misc by @keiko233 - Add baseContentIn animation by @keiko233 - Add route transition by @keiko233 - Material You! by @keiko233 - Default disable ipv6 by @keiko233 - Default enable unified-delay & tcp-concurrent with use meta core by @keiko233 - Support copy CMD & PowerShell proxy env by @keiko233 - Default use meta core by @keiko233 - Update Clash Default bypass addrs by @keiko233 - Theme: change color by @keiko233 - Profiles: import btn with loading state by @keiko233 - Profile-viewer: handleOk with loading state by @keiko233 - Base-dialog: okBtn use LoadingButton by @keiko233 - Nyanpasu Misc by @keiko233 - Theme support modify --background-color by @keiko233 - Settings use Grid layout by @keiko233 - Add Connections Info to ConnectionsPage by @keiko233 - ClashFieldViewer BaseDialog maxHeight usage percentage (#813) by @keiko233 - Add Open Dashboard to the hotkey, close #723 by @zzzgydi - Add check for updates button, close #766 by @zzzgydi - Add paste and clear icon by @zzzgydi - Subscription URL TextField use multiline (#761) by @keiko233 - Show loading when change profile by @zzzgydi - Support proxy provider update by @zzzgydi - Add repo link by @zzzgydi - Support clash meta memory usage display by @zzzgydi - Supports show connection detail by @zzzgydi - Update connection table with wider process column and click to show full detail (#696) by @whitemirror33 - More trace logs by @zzzgydi - Add Russian Language (#697) by @shvchk - Center window when out of monitor by @zzzgydi - Support copy environment variable by @zzzgydi - Save window size and position by @zzzgydi - App log level add silent by @zzzgydi - Overwrite resource file according to file modified by @zzzgydi - Support app log level settings by @zzzgydi - Use polkit to elevate permission instaed of sudo (#678) by @Kimiblock - Add unified-delay field by @zzzgydi - Add error boundary to the app root by @zzzgydi - Show tray icon variants in different status (#537) by @w568w - Auto restart core after grand permission by @zzzgydi - Add restart core button by @zzzgydi - Support update all profiles by @zzzgydi - Support to grant permission to clash core by @zzzgydi - Support clash fields filter in ui by @zzzgydi - Open dir on the tray by @zzzgydi - Support to disable clash fields filter by @zzzgydi - Adjust macOS window style by @zzzgydi - Recover core after panic, close #353 by @zzzgydi - Use decorations in Linux, close #354 by @zzzgydi - Auto proxy layout column by @zzzgydi - Support to change proxy layout column by @zzzgydi - Support to open core dir by @zzzgydi - Profile page ui by @zzzgydi - Save some fields in the runtime config, close #292 by @zzzgydi - Add meta feature by @zzzgydi - Display proxy group type by @zzzgydi - Add use clash hook by @zzzgydi - Guard the mixed-port and external-controller by @zzzgydi - Adjust builtin script and support meta guard script by @zzzgydi - Disable script mode when use clash meta by @zzzgydi - Check config when change core by @zzzgydi - Support builtin script for enhanced mode by @zzzgydi - Adjust profiles page ui by @zzzgydi - Optimize proxy page ui by @zzzgydi - Add error boundary by @zzzgydi - Adjust clash log by @zzzgydi - Add draft by @zzzgydi - Change default latency test url by @zzzgydi - Auto close connection when proxy changed by @zzzgydi - Support to change external controller by @zzzgydi - Add sub-rules by @zzzgydi - Add version on tray by @zzzgydi - Add animation by @zzzgydi - Add animation to ProfileNew component (#252) by @angryLid - Check remote profile field by @zzzgydi - System tray support zh language by @zzzgydi - Display delay check result timely by @zzzgydi - Update profile with system proxy/clash proxy by @zzzgydi - Change global mode ui, close #226 by @zzzgydi - Default user agent same with app version by @zzzgydi - Optimize config feedback by @zzzgydi - Show connections with table layout by @zzzgydi - Show loading on proxy group delay check by @zzzgydi - Add chains[0] and process to connections display (#205) by @riverscn - Adjust connection page ui by @zzzgydi - Yaml merge key by @zzzgydi - Toggle log ws by @zzzgydi - Add rule page by @zzzgydi - Hotkey viewer by @zzzgydi - Refresh ui when hotkey clicked by @zzzgydi - Support hotkey (wip) by @zzzgydi - Hide window on macos by @zzzgydi - System proxy setting by @zzzgydi - Change default singleton port and support to change the port by @zzzgydi - Log info by @zzzgydi - Kill clash by pid by @zzzgydi - Change clash port in dialog by @zzzgydi - Add proxy item check loading by @zzzgydi - Compatible with proxy providers health check by @zzzgydi - Add empty ui by @zzzgydi - Complete i18n by @zzzgydi - Windows portable version do not check update by @zzzgydi - Adjust clash info parsing logs by @zzzgydi - Adjust runtime config by @zzzgydi - Support restart app on tray by @zzzgydi - Optimize profile page by @zzzgydi - Refactor by @zzzgydi - Adjust tun mode config by @zzzgydi - Reimplement enhanced mode by @zzzgydi - Use rquickjs crate by @zzzgydi - Reimplement enhanced mode by @zzzgydi - Finish clash field control by @zzzgydi - Clash field viewer wip by @zzzgydi - Support web ui by @zzzgydi - Adjust setting page style by @zzzgydi - Runtime config viewer by @zzzgydi - Improve log rule by @zzzgydi - Theme mode support follows system by @zzzgydi - Improve yaml file error log by @zzzgydi - Save proxy page state by @zzzgydi - Light mode wip (#96) by @ctaoist - Clash meta core supports by @zzzgydi - Script mode by @zzzgydi - Clash meta core support (wip) by @zzzgydi - Reduce gpu usage when hidden by @zzzgydi - Interval update from now field by @zzzgydi - Adjust theme by @zzzgydi - Supports more remote headers close #81 by @zzzgydi - Check the remote profile by @zzzgydi - Fix typo by tianyoulan - Remove trailing comma by tianyoulan - Remove outdated config by tianyoulan - Windows service mode ui by @zzzgydi - Add some commands by @zzzgydi - Windows service mode by @zzzgydi - Add update interval by @zzzgydi - Refactor and supports cron tasks by @zzzgydi - Supports cron update profiles by @zzzgydi - Optimize traffic graph quadratic curve by @zzzgydi - Optimize the animation of the traffic graph by @zzzgydi - System tray add tun mode by @zzzgydi - Supports change config dir by @zzzgydi - Add default user agent by @zzzgydi - Connections page supports filter by @zzzgydi - Log page supports filter by @zzzgydi - Optimize delay checker concurrency strategy by @zzzgydi - Support sort proxy node and custom test url by @zzzgydi - Handle remote clash config fields by @zzzgydi - Add text color by @zzzgydi - Control final tun config by @zzzgydi - Support css injection by @zzzgydi - Support theme setting by @zzzgydi - Add text color by @zzzgydi - Add theme setting by @zzzgydi - Enhanced mode supports more fields by @zzzgydi - Supports edit profile file by @zzzgydi - Supports silent start by @zzzgydi - Use crate open by @zzzgydi - Enhance connections display order by @zzzgydi - Save global selected by @zzzgydi - System tray supports system proxy setting by @zzzgydi - Prevent context menu on Windows close #22 by @zzzgydi - Create local profile with selected file by @zzzgydi - Reduce the impact of the enhanced mode by @zzzgydi - Parse update log by @zzzgydi - Fill i18n by @zzzgydi - Dayjs i18n by @zzzgydi - Connections page simply support by @zzzgydi - Add wintun.dll by default by @zzzgydi - Event emit when clash config update by @zzzgydi - I18n supports by @zzzgydi - Change open command on linux by @zzzgydi - Support more options for remote profile by @zzzgydi - Linux system proxy by @zzzgydi - Enhance profile status by @zzzgydi - Menu item refresh enhanced mode by @zzzgydi - Profile enhanced mode by @zzzgydi - Profile enhanced ui by @zzzgydi - Profile item adjust by @zzzgydi - Enhanced profile (wip) by @zzzgydi - Edit profile item by @zzzgydi - Use nanoid by @zzzgydi - Compatible profile config by @zzzgydi - Native menu supports by @zzzgydi - Filter proxy and display type by @zzzgydi - Use lock fn by @zzzgydi - Refactor proxy page by @zzzgydi - Proxy group auto scroll to current by @zzzgydi - Clash tun mode supports by @zzzgydi - Use enhanced guard-state by @zzzgydi - Guard state supports debounce guard by @zzzgydi - Adjust clash version display by @zzzgydi - Hide command window by @zzzgydi - Enhance log data by @zzzgydi - Change window style by @zzzgydi - Fill verge template by @zzzgydi - Enable customize guard duration by @zzzgydi - System proxy guard by @zzzgydi - Enable show or hide traffic graph by @zzzgydi - Traffic line graph by @zzzgydi - Adjust profile item ui by @zzzgydi - Adjust fetch profile url by @zzzgydi - Inline config file template by @zzzgydi - Kill sidecars when update app by @zzzgydi - Delete file by @zzzgydi - Lock some async functions by @zzzgydi - Support open dir by @zzzgydi - Change allow list by @zzzgydi - Support check delay by @zzzgydi - Scroll to proxy item by @zzzgydi - Edit system proxy bypass by @zzzgydi - Disable user select by @zzzgydi - New profile able to edit name and desc by @zzzgydi - Update tauri version by @zzzgydi - Display clash core version by @zzzgydi - Adjust profile item menu by @zzzgydi - Profile item ui by @zzzgydi - Support new profile by @zzzgydi - Support open command for viewing by @zzzgydi - Global proxies use virtual list by @zzzgydi - Enable change proxy mode by @zzzgydi - Update styles by @zzzgydi - Manage clash mode by @zzzgydi - Change system porxy when changed port by @zzzgydi - Enable change mixed port by @zzzgydi - Manage clash config by @zzzgydi - Enable update clash info by @zzzgydi - Rename edit as view by @zzzgydi - Test auto gen update.json ci by @zzzgydi - Adjust setting typography by @zzzgydi - Enable force select profile by @zzzgydi - Support edit profile item by @zzzgydi - Adjust control ui by @zzzgydi - Update profile supports noproxy by @zzzgydi - Rename page by @zzzgydi - Refactor and adjust ui by @zzzgydi - Rm some commands by @zzzgydi - Change type by @zzzgydi - Supports auto launch on macos and windows by @zzzgydi - Adjust proxy page by @zzzgydi - Press esc hide the window by @zzzgydi - Show system proxy info by @zzzgydi - Support blur window by @zzzgydi - Windows support startup by @zzzgydi - Window self startup by @zzzgydi - Use tauri updater by @zzzgydi - Support update checker by @zzzgydi - Support macos proxy config by @zzzgydi - Custom window decorations by @zzzgydi - Profiles add menu and delete button by @zzzgydi - Delay put profiles and retry by @zzzgydi - Window Send and Sync by @zzzgydi - Support restart sidecar tray event by @zzzgydi - Prevent click same by @zzzgydi - Scroller stable by @zzzgydi - Compatible with macos(wip) by @zzzgydi - Record selected proxy by @zzzgydi - Display version by @zzzgydi - Enhance system proxy setting by @zzzgydi - Profile loading animation by @zzzgydi - Github actions support by @zzzgydi - Rename profile page by @zzzgydi - Add pre-dev script by @zzzgydi - Implement a simple singleton process by @zzzgydi - Use paper for list bg by @zzzgydi - Supprt log ui by @zzzgydi - Auto update profiles by @zzzgydi - Proxy page use swr by @zzzgydi - Profile item support display updated time by @zzzgydi - Change the log level order by @zzzgydi - Only put some fields by @zzzgydi - Setting page by @zzzgydi - Add serval commands by @zzzgydi - Change log file format by @zzzgydi - Adjust code by @zzzgydi - Refactor commands and support update profile by @zzzgydi - System proxy command demo by @zzzgydi - Support set system proxy command by @zzzgydi - Profiles ui and put profile support by @zzzgydi - Remove sec field by @zzzgydi - Put profile works by @zzzgydi - Distinguish level notice by @zzzgydi - Add use-notice hook by @zzzgydi - Pus_clash_profile support `secret` field by @zzzgydi - Add put_profiles cmd by @zzzgydi - Update rule page by @zzzgydi - Use external controller field by @zzzgydi - Lock profiles file and support more cmds by @zzzgydi - Put new profile to clash by default by @zzzgydi - Enhance clash caller & support more commands by @zzzgydi - Read clash config by @zzzgydi - Get profile file name from response by @zzzgydi - Change the naming strategy by @zzzgydi - Change rule page by @zzzgydi - Import profile support by @zzzgydi - Init verge config struct by @zzzgydi - Add some clash api by @zzzgydi - Optimize the proxy group order by @zzzgydi - Refactor system proxy config by @zzzgydi - Use resources dir to save files by @zzzgydi - New setting page by @zzzgydi - Sort groups by @zzzgydi - Add favicon by @zzzgydi - Update icons by @zzzgydi - Update layout style by @zzzgydi - Support dark mode by @zzzgydi - Set min windows by @zzzgydi - Finish some features by @zzzgydi - Finish main layout by @zzzgydi - Use vite by @zzzgydi ### 🐛 Bug Fixes - **icon:** Change ico file to fix windows tray by @zzzgydi - **macos:** Set auto launch path to application by @zzzgydi - **style:** Reduce my by @zzzgydi - Rust lint by @keiko233 - Valid with unified-delay & tcp-concurrent by @keiko233 - Touchpad scrolling causes blank area to appear by @keiko233 - Typos by @keiko233 - Download clash core from backup repo by @keiko233 - Use meta Country.mmdb by @keiko233 - I18n by @zzzgydi - Fix page undefined exception, close #770 by @zzzgydi - Set min window size, close #734 by @zzzgydi - Rm debug code by @zzzgydi - Use sudo when pkexec not found by @zzzgydi - Remove div by @zzzgydi - List key by @zzzgydi - Websocket disconnect when window focus by @zzzgydi - Try fix undefined error by @zzzgydi - Blurry tray icon in Windows by @zzzgydi - Enable context menu in editable element by @zzzgydi - Save window size and pos in Windows by @zzzgydi - Optimize traffic graph high CPU usage when hidden by @zzzgydi - Remove fallback group select status, close #659 by @zzzgydi - Error boundary with key by @zzzgydi - Connections is null by @zzzgydi - Font family not works in some interfaces, close #639 by @zzzgydi - EncodeURIComponent secret by @zzzgydi - Encode controller secret, close #601 by @zzzgydi - Linux not change icon by @zzzgydi - Try fix blank error by @zzzgydi - Close all connections when change mode by @zzzgydi - Macos not change icon by @zzzgydi - Error message null by @zzzgydi - Profile data undefined error, close #566 by @zzzgydi - Import url error (#543) by @yettera765 - Linux DEFAULT_BYPASS (#503) by @Mr-Spade - Open file with vscode by @zzzgydi - Do not render div as a descendant of p (#494) by @tatiustaitus - Use replace instead by @zzzgydi - Escape path space by @zzzgydi - Escape the space in path (#451) by @dyxushuai - Add target os linux by @zzzgydi - Appimage path unwrap panic by @zzzgydi - Remove esc key listener in macOS by @zzzgydi - Adjust style by @zzzgydi - Adjust swr option by @zzzgydi - Infinite retry when websocket error by @zzzgydi - Type error by @zzzgydi - Do not parse log except the clash core by @zzzgydi - Field sort for filter by @zzzgydi - Add meta fields by @zzzgydi - Runtime config user select by @zzzgydi - App_handle as_ref by @zzzgydi - Use crate by @zzzgydi - Appimage auto launch, close #403 by @zzzgydi - Compatible with UTF8 BOM, close #283 by @zzzgydi - Use selected proxy after profile changed by @zzzgydi - Error log by @zzzgydi - Adjust fields order by @zzzgydi - Add meta fields by @zzzgydi - Add os platform value by @zzzgydi - Reconnect traffic websocket by @zzzgydi - Parse bytes precision, close #334 by @zzzgydi - Trigger new profile dialog, close #356 by @zzzgydi - Parse log cause panic by @zzzgydi - Avoid setting login item repeatedly, close #326 by @zzzgydi - Adjust code by @zzzgydi - Adjust delay check concurrency by @zzzgydi - Change default column to auto by @zzzgydi - Change default app version by @zzzgydi - Adjust rule ui by @zzzgydi - Adjust log ui by @zzzgydi - Keep delay data by @zzzgydi - Use list item button by @zzzgydi - Proxy item style by @zzzgydi - Virtuoso no work in legacy browsers (#318) by @moeshin - Adjust ui by @zzzgydi - Refresh websocket by @zzzgydi - Adjust ui by @zzzgydi - Parse bytes base 1024 by @zzzgydi - Add clash fields by @zzzgydi - Direct mode hide proxies by @zzzgydi - Profile can not edit by @zzzgydi - Parse logger time by @zzzgydi - Adjust service mode ui by @zzzgydi - Adjust style by @zzzgydi - Check hotkey and optimize hotkey input, close #287 by @zzzgydi - Mutex dead lock by @zzzgydi - Adjust item ui by @zzzgydi - Regenerate config before change core by @zzzgydi - Close connections when profile change by @zzzgydi - Lint by @zzzgydi - Windows service mode by @zzzgydi - Init config file by @zzzgydi - Service mode error and fallback to sidecar by @zzzgydi - Service mode viewer ui by @zzzgydi - Create theme error, close #294 by @zzzgydi - MatchMedia().addEventListener #258 (#296) by @moeshin - Check config by @zzzgydi - Show global when no rule groups by @zzzgydi - Service viewer ref by @zzzgydi - Service ref error by @zzzgydi - Group proxies render list is null by @zzzgydi - Pretty bytes by @zzzgydi - Use verge hook by @zzzgydi - Adjust notice by @zzzgydi - Windows issue by @zzzgydi - Change dev log level by @zzzgydi - Patch clash config by @zzzgydi - Cmds params by @zzzgydi - Adjust singleton detect by @zzzgydi - Change template by @zzzgydi - Copy resource file by @zzzgydi - MediaQueryList addEventListener polyfill by @zzzgydi - Change default tun dns-hijack by @zzzgydi - Something by @zzzgydi - Provider proxy sort by delay by @zzzgydi - Profile item menu ui dense by @zzzgydi - Disable auto scroll to proxy by @zzzgydi - Check remote profile by @zzzgydi - Remove smoother by @zzzgydi - Icon button color by @zzzgydi - Init system proxy correctly by @zzzgydi - Open file by @zzzgydi - Reset proxy by @zzzgydi - Init config error by @zzzgydi - Adjust reset proxy by @zzzgydi - Adjust code by @zzzgydi - Add https proxy by @zzzgydi - Auto scroll into view when sorted proxies changed by @zzzgydi - Refresh proxies interval, close #235 by @zzzgydi - Style by @zzzgydi - Fetch profile with system proxy, close #249 by @zzzgydi - The profile is replaced when the request fails. (#246) by @loosheng - Default dns config by @zzzgydi - Kill clash when exit in service mode, close #241 by @zzzgydi - Icon button color inherit by @zzzgydi - App version to string by @zzzgydi - Break loop when core terminated by @zzzgydi - Api error handle by @zzzgydi - Clash meta not load geoip, close #212 by @zzzgydi - Sort proxy during loading, close #221 by @zzzgydi - Not create windows when enable slient start by @zzzgydi - Root background color by @zzzgydi - Create window correctly by @zzzgydi - Set_activation_policy by @zzzgydi - Disable spell check by @zzzgydi - Adjust init launch on dev by @zzzgydi - Ignore disable auto launch error by @zzzgydi - I18n by @zzzgydi - Style by @zzzgydi - Save enable log on localstorage by @zzzgydi - Typo in api.ts (#207) by @Priestch - Refresh clash ui await patch by @zzzgydi - Remove dead code by @zzzgydi - Style by @zzzgydi - Handle is none by @zzzgydi - Unused by @zzzgydi - Style by @zzzgydi - Windows logo size by @zzzgydi - Do not kill sidecar during updating by @zzzgydi - Delay update config by @zzzgydi - Reduce logo size by @zzzgydi - Window center by @zzzgydi - Log level warn value by @zzzgydi - Increase delay checker concurrency by @zzzgydi - External controller allow lan by @zzzgydi - Remove useless optimizations by @zzzgydi - Reduce unsafe unwrap by @zzzgydi - Timer restore at app launch by @FoundTheWOUT - Adjust log text by @zzzgydi - Only script profile can display console by @zzzgydi - Fill button title attr by @zzzgydi - Do not reset system proxy when consistent by @zzzgydi - Adjust web ui item style by @zzzgydi - Clash field state error by @zzzgydi - Badge color error by @zzzgydi - Web ui port value error by @zzzgydi - Delay show window by @zzzgydi - Adjust dialog action button variant by @zzzgydi - Script code error by @zzzgydi - Script exception handle by @zzzgydi - Change fields by @zzzgydi - Silent start (#150) by @FoundTheWOUT - Save profile when update by @zzzgydi - List compare wrong by @zzzgydi - Button color by @zzzgydi - Limit theme mode value by @zzzgydi - Add valid clash field by @zzzgydi - Icon style by @zzzgydi - Reduce unwrap by @zzzgydi - Import mod by @zzzgydi - Add tray separator by @zzzgydi - Instantiate core after init app, close #122 by @zzzgydi - Rm macOS transition props by @zzzgydi - Improve external-controller parse and log by @zzzgydi - Show windows on click by @zzzgydi - Adjust update profile notice error by @zzzgydi - Style issue on mac by @zzzgydi - Check script run on all OS by @FoundTheWOUT - MacOS disable transparent by @zzzgydi - Window transparent and can not get hwnd by @zzzgydi - Create main window by @zzzgydi - Adjust notice by @zzzgydi - Label text by @zzzgydi - Icon path by @zzzgydi - Icon issue by @zzzgydi - Notice ui blocking by @zzzgydi - Service mode error by @zzzgydi - Win11 drag lag by @zzzgydi - Rm unwrap by @zzzgydi - Edit profile info by @zzzgydi - Change window default size by @zzzgydi - Change service installer and uninstaller by @zzzgydi - Adjust connection scroll by @zzzgydi - Adjust something by @zzzgydi - Adjust debounce wait time by @zzzgydi - Adjust dns config by @zzzgydi - Traffic graph adapt to different fps by @zzzgydi - Optimize clash launch by @zzzgydi - Reset after exit by @zzzgydi - Adjust code by @zzzgydi - Adjust log by @zzzgydi - Check button hover style by @zzzgydi - Icon button color inherit by @zzzgydi - Remove the lonely zero by @zzzgydi - I18n add value by @zzzgydi - Proxy page first render by @zzzgydi - Console warning by @zzzgydi - Icon button title by @zzzgydi - MacOS transition flickers close #47 by @zzzgydi - Csp image data by @zzzgydi - Close dialog after save by @zzzgydi - Change to deep copy by @zzzgydi - Window style close #45 by @zzzgydi - Manage global proxy correctly by @zzzgydi - Tauri csp by @zzzgydi - Windows style by @zzzgydi - Update state by @zzzgydi - Profile item loading state by @zzzgydi - Adjust windows style by @zzzgydi - Change mixed port error by @zzzgydi - Auto launch path by @zzzgydi - Tun mode config by @zzzgydi - Adjsut open cmd error by @zzzgydi - Parse external-controller by @zzzgydi - Config file case close #18 by @zzzgydi - Patch item option by @zzzgydi - User agent not works by @zzzgydi - External-controller by @zzzgydi - Change proxy bypass on mac by @zzzgydi - Kill sidecars after install still in test by @zzzgydi - Log some error by @zzzgydi - Apply_blur parameter by @zzzgydi - Limit enhanced profile range by @zzzgydi - Profile updated field by @zzzgydi - Profile field check by @zzzgydi - Create dir panic by @zzzgydi - Only error when selected by @zzzgydi - Enhanced profile consistency by @zzzgydi - Simply compatible with proxy providers by @zzzgydi - Component warning by @zzzgydi - When updater failed by @zzzgydi - Log file by @zzzgydi - Result by @zzzgydi - Cover profile extra by @zzzgydi - Display menu only on macos by @zzzgydi - Proxy global showType by @zzzgydi - Use full clash config by @zzzgydi - Reconnect websocket when restart clash by @zzzgydi - Wrong exe path by @zzzgydi - Patch verge config by @zzzgydi - Fetch profile panic by @zzzgydi - Spawn command by @zzzgydi - Import error by @zzzgydi - Not open file when new profile by @zzzgydi - Reset value correctly by @zzzgydi - Something by @zzzgydi - Menu without fragment by @zzzgydi - Proxy list error by @zzzgydi - Something by @zzzgydi - Macos auto launch fail by @zzzgydi - Type error by @zzzgydi - Restart clash should update something by @zzzgydi - Script error... by @zzzgydi - Tag error by @zzzgydi - Script error by @zzzgydi - Remove cargo test by @zzzgydi - Reduce proxy item height by @zzzgydi - Put profile request with no proxy by @zzzgydi - Ci strategy by @zzzgydi - Version update error by @zzzgydi - Text by @zzzgydi - Update profile after restart clash by @zzzgydi - Get proxies multiple times by @zzzgydi - Delete profile item command by @zzzgydi - Initialize profiles state by @zzzgydi - Item header bgcolor by @zzzgydi - Null type error by @zzzgydi - Api loading delay by @zzzgydi - Mutate at the same time may be wrong by @zzzgydi - Port value not rerender by @zzzgydi - Change log file format by @zzzgydi - Proxy bypass add by @zzzgydi - Sidecar dir by @zzzgydi - Web resource outDir by @zzzgydi - Use io by @zzzgydi ### 💅 Styling - Resolve formatting problem by @Limsanity ### 📚 Documentation - Fix img width by @zzzgydi - Update by @zzzgydi ### 🔨 Refactor - **hotkey:** Use tauri global shortcut by @zzzgydi - Copy_clash_env by @keiko233 - Adjust base components export by @zzzgydi - Adjust setting dialog component by @zzzgydi - Done by @zzzgydi - Adjust all path methods and reduce unwrap by @zzzgydi - Rm code by @zzzgydi - Fix by @zzzgydi - Rm dead code by @zzzgydi - For windows by @zzzgydi - Wip by @zzzgydi - Wip by @zzzgydi - Wip by @zzzgydi - Rm update item block_on by @zzzgydi - Fix by @zzzgydi - Fix by @zzzgydi - Wip by @zzzgydi - Optimize by @zzzgydi - Ts path alias by @zzzgydi - Mode manage on tray by @zzzgydi - Verge by @zzzgydi - Wip by @zzzgydi - Mutex by @zzzgydi - Wip by @zzzgydi - Proxy head by @zzzgydi - Update profile menu by @zzzgydi - Enhanced mode ui component by @zzzgydi - Ui theme by @zzzgydi - Optimize enhance mode strategy by @zzzgydi - Profile config by @zzzgydi - Use anyhow to handle error by @zzzgydi - Rename profiles & command state by @zzzgydi - Something by @zzzgydi - Notice caller by @zzzgydi - Setting page by @zzzgydi - Rename by @zzzgydi - Impl structs methods by @zzzgydi - Impl as struct methods by @zzzgydi - Api and command by @zzzgydi - Import profile by @zzzgydi - Adjust dirs structure by @zzzgydi --- ## New Contributors - @zzzgydi made their first contribution - @whitemirror33 made their first contribution - @shvchk made their first contribution - @w568w made their first contribution - @yettera765 made their first contribution - @tatiustaitus made their first contribution - @Mr-Spade made their first contribution - @solancer made their first contribution - @me1ting made their first contribution - @boatrainlsz made their first contribution - @inRm3D made their first contribution - @moeshin made their first contribution - @angryLid made their first contribution - @loosheng made their first contribution - @ParticleG made their first contribution - @HougeLangley made their first contribution - @Priestch made their first contribution - @riverscn made their first contribution - @FoundTheWOUT made their first contribution - @Limsanity made their first contribution - @ctaoist made their first contribution - @ made their first contribution - @ttys3 made their first contribution ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience - Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: - The use of sexualized language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or email address, without their explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at i@elaina.moe. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Nyanpasu Welcome to **Nyanpasu** development! To ensure the quality and stability of the project, please read this guide carefully. Even if you are new, you can follow these steps to set up the development environment, write code, and submit contributions. --- ## 1. Development Guidelines Before submitting code, please follow these rules: ### 1. Code Style Checks | Language | Tools | | ----------------------- | --------------------------- | | JavaScript / TypeScript | ESLint, Prettier, Stylelint | | Rust | Clippy, Rustfmt | - ⚠️ **Ensure there are no style errors before committing** - ❌ **Do not use `git commit -n` or skip checks**, CI will automatically enforce style validation ### 2. Submission Requirements - Avoid submitting useless code, files, or folders - For major refactors or new features, open an **Issue** first for discussion - If unsure about implementation or have questions, communicate in **Issue** or **PR** ### 3. Communication & Collaboration - Respect others' code and opinions - Keep commit messages and PR descriptions clear - All discussions should be on GitHub for transparency and traceability --- ## 2. Environment Requirements To ensure the project runs correctly locally, the following dependencies are required. ### 1. Required Dependencies | Tool | Version | Link | Notes | | ------- | -------- | ----------------------------------------------------------- | --------------------------------------------- | | Rust | ≥ 1.78 | [Official Install](https://www.rust-lang.org/tools/install) | Stable version; use MSVC toolchain on Windows | | Node.js | ≥ 20 LTS | [Official Site](https://nodejs.org/) | Install LTS or Latest version | | pnpm | ≥ 9 | [Official Documentation](https://pnpm.io/) | Node.js package manager | | git | Latest | [Official Site](https://git-scm.com/) | Version control | ### 2. Build Dependencies | Tool | Link | Notes | | ----- | --------------------------------------------------------------------------------- | ----------------------------------- | | cmake | [Official Site](https://cmake.org/) | Required by `zip` crate | | llvm | [Official Site](https://llvm.org/) | Required by `rquickjs` or `rocksdb` | | patch | [Windows Installation Guide](https://gnuwin32.sourceforge.net/packages/patch.htm) | Required by `rquickjs` | ### 3. Windows Special Requirements - Use **Administrator privileges** when opening the project for the first time; `patch` requires admin rights - Recommended to install `gsudo` (via `scoop`, `choco`, or `winget`) - Always use the **MSVC toolchain** on Windows - 💡 Admin privileges are only needed for initial setup; normal terminal is fine for daily development --- ## 3. Pre-Development Setup Before starting development, initialize the environment and download required resources. ### 1. Install Frontend Dependencies ```bash pnpm i ``` > This installs all frontend dependencies including UI components, toolchains, and testing tools. ### 2. Download Core & Resource Files ``` pnpm prepare:check ``` > This command downloads binaries like `sidecar` and `resource` to ensure the project runs properly If files are missing or you want to force update: ``` pnpm prepare:check --force ``` 💡 **Tip**: Configure terminal proxy if network issues occur --- ## 4. Start Development Environment The project provides two types of development instances: ### 1. Dedicated Development Instance (Recommended) ``` pnpm dev:diff ``` > Suitable for daily development and debugging; changes do not affect the release version ### 2. Release-Like Development Instance ``` pnpm dev ``` > Behaves similarly to the official release; useful to test overall functionality --- ## 5. Commit Code & Create PR ### 1. Pull Latest Code ``` git pull origin main ``` ### 2. Create a New Branch ``` git checkout -b feature/my-feature ``` > ⚠️ Avoid developing directly on `main` ### 3. Pre-Commit Checks - Ensure code style is correct - All unit tests pass - No useless files ### 4. Commit and Push ``` git add . git commit -m "feat: add my feature" git push origin feature/my-feature ``` ### 5. Create a PR - Choose `main` as the target branch - Briefly describe the feature or changes - Link related Issue if available --- 💡 **Tips**: - Keep each commit focused on a single feature or issue; avoid large, messy commits - PR descriptions should be clear so reviewers immediately understand the changes ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================

Clash Nyanpasu Banner

Clash Nyanpasu

A Clash GUI based on Tauri.

Nyanpasu Release Dev Build Status Nyanpasu Stars GitHub Downloads (all assets, all releases) Nyanpasu License Nyanpasu Twitter Ask DeepWiki

## Features - Built-in support [Clash Premium](https://github.com/Dreamacro/clash), [Mihomo](https://github.com/MetaCubeX/mihomo) & [Clash Rust](https://github.com/Watfaq/clash-rs). - Profiles management and enhancement (by YAML, JavaScript & Lua). [Doc](https://nyanpasu.elaina.moe/tutorial/proxy-chain) - Provider management support. - Google Material You Design UI and animation support. ## Preview ![preview-light](https://nyanpasu.elaina.moe/images/screenshot/app-dashboard-light.png) ![preview-dark](https://nyanpasu.elaina.moe/images/screenshot/app-dashboard-dark.png) ## Links - [Install](https://nyanpasu.elaina.moe/tutorial/install) - [FAQ](https://nyanpasu.elaina.moe/others/faq) - [Q&A Convention](https://nyanpasu.elaina.moe/others/issues) - [How To Ask Questions](https://nyanpasu.elaina.moe/others/how-to-ask) ## Development ### Configure your development environment You should install Rust and Node.js, see [here](https://v2.tauri.app/start/prerequisites/) for more details. Clash Nyanpasu uses the pnpm package manager. See [here](https://pnpm.io/installation) for installation instructions. Then, install Node.js packages. ```shell pnpm i ``` ### Download the Clash binary & other dependencies ```shell # force update to latest version # pnpm prepare:check --force pnpm prepare:check ``` ### Run dev ```shell pnpm dev # run it in another way if app instance exists pnpm dev:diff ``` ### Build application ```shell pnpm build ``` ## Contributions Issue and PR welcome! ## Acknowledgement Clash Nyanpasu was based on or inspired by these projects and so on: - [zzzgydi/clash-verge](https://github.com/zzzgydi/clash-verge): A Clash GUI based on Tauri. Supports Windows, macOS and Linux. - [clash-verge-rev/clash-verge-rev](https://github.com/clash-verge-rev/clash-verge-rev): Another fork of Clash Verge. Some patches are included for bug fixes. - [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Build smaller, faster, and more secure desktop applications with a web frontend. - [Dreamacro/clash](https://github.com/Dreamacro/clash): A rule-based tunnel in Go. - [MetaCubeX/mihomo](https://github.com/MetaCubeX/mihomo): A rule-based tunnel in Go. - [Watfaq/clash-rs](https://github.com/Watfaq/clash-rs): A custom protocol, rule based network proxy software. - [Fndroid/clash_for_windows_pkg](https://github.com/Fndroid/clash_for_windows_pkg): A Windows/macOS GUI based on Clash. - [vitejs/vite](https://github.com/vitejs/vite): Next generation frontend tooling. It's fast! - [mui/material-ui](https://github.com/mui/material-ui): Ready-to-use foundational React components, free forever. ## Contributors ![Contributors](https://contrib.rocks/image?repo=libnyanpasu/clash-nyanpasu) ## License GPL-3.0 License. See [License here](./LICENSE) for details. ================================================ FILE: UPDATELOG.md ================================================ ## v1.4.2 ### Features - Support Clash-rs v0.1.10. [@greenhat616](https://github.com/greenhat616) - New Windows tray icon & support tun mode icon. [@keiko233](https://github.com/keiko233) - Support Log file export. [@greenhat616](https://github.com/greenhat616) - Hotkey Support toggle. [@greenhat616](https://github.com/greenhat616) - Built-in Mihomo alpha. [@greenhat616](https://github.com/greenhat616) - Use shikiji process log. [@greenhat616](https://github.com/greenhat616) - More Animation support. [@greenhat616](https://github.com/greenhat616) - New Built-in updater support. [@greenhat616](https://github.com/greenhat616) - Support DNS auto config in macos. [@greenhat616](https://github.com/greenhat616) - Support log file auto clean. [@keiko233](https://github.com/keiko233) - Use Christmas Logo. [@keiko233](https://github.com/keiko233) ### Bug Fixes - Fix Windows resize bug. [@greenhat616](https://github.com/greenhat616) - Fix Hotkey repeat binding . [@greenhat616](https://github.com/greenhat616) - Fix Proxies dalay value rending color. [@greenhat616](https://github.com/greenhat616) - Fix Mihomo alpha & Clash-rs Service not working. [@greenhat616](https://github.com/greenhat616) - Fix dialog position. [@greenhat616](https://github.com/greenhat616) - Fix shikiji color rending err. [@keiko233](https://github.com/keiko233) ### Others - Use GitHub issues template. [@greenhat616](https://github.com/greenhat616) [@keiko233](https://github.com/keiko233) [@txyyh](https://github.com/txyyh) - Support nightly builds. [@greenhat616](https://github.com/greenhat616) --- ## v1.4.1 ### Features - Support macOS aarch64 build. [@keiko233](https://github.com/keiko233) - Built-in Windows UWP Loopback Tool. [@Kuingsmile](https://github.com/Kuingsmile) - Built-in Clash-rs support. [@greenhat616](https://github.com/greenhat616) - Add tooltip for tray. [@Kuingsmile](https://github.com/Kuingsmile) - Update HD tray icons. [@keiko233](https://github.com/keiko233) - Support random mixed port. [@Kuingsmile](https://github.com/Kuingsmile) - Update Clash.Meta to v1.17.0. [@Kuingsmile](https://github.com/Kuingsmile) [@keiko233](https://github.com/keiko233) - Use system notification. [@keiko233](https://github.com/keiko233) - Support drag profile item to sort. [@Kuingsmile](https://github.com/Kuingsmile) - Add skip-auth-prefixes fields for Clash.Meta v1.17.0. [@greenhat616](https://github.com/greenhat616) - Support more animations. [@greenhat616](https://github.com/greenhat616) - Use twemoji on Windows. [@greenhat616](https://github.com/greenhat616) ### Bug Fixes - Fix install Service bug. [@Kuingsmile](https://github.com/Kuingsmile) - Fix Windows proxy bug when VPN enabled. [@greenhat616](https://github.com/greenhat616) ### Others - Fixed several build issues. [@keiko233](https://github.com/keiko233) - Switch rust code to workspace. [@greenhat616](https://github.com/greenhat616) - Add renovate bot support. [@greenhat616](https://github.com/greenhat616) --- ## v1.4.0 ### Features - Default use Meta Core. - Support copy PowerShell, CMD and sh env command. - Add Upload Traffic, Download Traffic and Active Connections to ConnectionsPage. - SettingPage use Grid layout. - Import LoadingButton & use when Download Profile. - New default theme color. - Add Nyanpasu Element. (Logo designer [@ReallySnow](https://github.com/ReallySnow)) - Default enable unified-delay & tcp-concurrent. - Use Meta Country.mmdb. - Disable IPv6 by default. - Add Material You element. - Add Router switch transition. ### Bug Fixes - Fix touchpad scrolling causes blank area to appear. --- ## v1.3.7 ### Features - update clash and clash meta core - profiles page add paste button - subscriptions url textfield use multi lines - set min window size - add check for updates buttons - add open dashboard to the hotkey list ### Bug Fixes - fix profiles page undefined exception --- ## v1.3.6 ### Features - add russian translation - support to show connection detail - support clash meta memory usage display - support proxy provider update ui - update geo data file from meta repo - adjust setting page ### Bug Fixes - center the window when it is out of screen - use `sudo` when `pkexec` not found (Linux) - reconnect websocket when window focus ### Notes - The current version of the Linux installation package is built by Ubuntu 20.04 (Github Action). --- ## v1.3.5 ### Features - update clash core ### Bug Fixes - fix blurry system tray icon (Windows) - fix v1.3.4 wintun.dll not found (Windows) - fix v1.3.4 clash core not found (macOS, Linux) --- ## v1.3.4 ### Features - update clash and clash meta core - optimize traffic graph high CPU usage when window hidden - use polkit to elevate permission (Linux) - support app log level setting - support copy environment variable - overwrite resource file according to file modified - save window size and position ### Bug Fixes - remove fallback group select status - enable context menu on editable element (Windows) --- ## v1.3.3 ### Features - update clash and clash meta core - show tray icon variants in different system proxy status (Windows) - close all connections when mode changed ### Bug Fixes - encode controller secret into uri - error boundary for each page --- ## v1.3.2 ### Features - update clash and clash meta core ### Bug Fixes - fix import url issue - fix profile undefined issue --- ## v1.3.1 ### Features - update clash and clash meta core ### Bug Fixes - fix open url issue - fix appimage path panic - fix grant root permission in macOS - fix linux system proxy default bypass --- ## v1.3.0 ### Features - update clash and clash meta - support opening dir on tray - support updating all profiles with one click - support granting root permission to clash core(Linux, macOS) - support enable/disable clash fields filter, feel free to experience the latest features of Clash Meta ### Bug Fixes - deb add openssl depend(Linux) - fix the AppImage auto launch path(Linux) - fix get the default network service(macOS) - remove the esc key listener in macOS, cmd+w instead(macOS) - fix infinite retry when websocket error --- ## v1.2.3 ### Features - update clash - adjust macOS window style - profile supports UTF8 with BOM ### Bug Fixes - fix selected proxy - fix error log --- ## v1.2.2 ### Features - update clash meta - recover clash core after panic - use system window decorations(Linux) ### Bug Fixes - flush system proxy settings(Windows) - fix parse log panic - fix ui bug --- ## v1.2.1 ### Features - update clash version - proxy groups support multi columns - optimize ui ### Bug Fixes - fix ui websocket connection - adjust delay check concurrency - avoid setting login item repeatedly(macOS) --- ## v1.2.0 ### Features - update clash meta version - support to change external-controller - support to change default latency test URL - close all connections when proxy changed or profile changed - check the config by using the core - increase the robustness of the program - optimize windows service mode (need to reinstall) - optimize ui ### Bug Fixes - invalid hotkey cause panic - invalid theme setting cause panic - fix some other glitches --- ## v1.1.2 ### Features - the system tray follows i18n - change the proxy group ui of global mode - support to update profile with the system proxy/clash proxy - check the remote profile more strictly ### Bug Fixes - use app version as default user agent - the clash not exit in service mode - reset the system proxy when quit the app - fix some other glitches --- ## v1.1.1 ### Features - optimize clash config feedback - hide macOS dock icon - use clash meta compatible version (Linux) ### Bug Fixes - fix some other glitches --- ## v1.1.0 ### Features - add rule page - supports proxy providers delay check - add proxy delay check loading status - supports hotkey/shortcut management - supports displaying connections data in table layout(refer to yacd) ### Bug Fixes - supports yaml merge key in clash config - detect the network interface and set the system proxy(macOS) - fix some other glitches --- ## v1.0.6 ### Features - update clash and clash.meta ### Bug Fixes - only script profile display console - automatic configuration update on demand at launch --- ## v1.0.5 ### Features - reimplement profile enhanced mode with quick-js - optimize the runtime config generation process - support web ui management - support clash field management - support viewing the runtime config - adjust some pages style ### Bug Fixes - fix silent start - fix incorrectly reset system proxy on exit --- ## v1.0.4 ### Features - update clash core and clash meta version - support switch clash mode on system tray - theme mode support follows system ### Bug Fixes - config load error on first use --- ## v1.0.3 ### Features - save some states such as URL test, filter, etc - update clash core and clash-meta core - new icon for macOS --- ## v1.0.2 ### Features - supports for switching clash core - supports release UI processes - supports script mode setting ### Bug Fixes - fix service mode bug (Windows) --- ## v1.0.1 ### Features - adjust default theme settings - reduce gpu usage of traffic graph when hidden - supports more remote profile response header setting - check remote profile data format when imported ### Bug Fixes - service mode install and start issue (Windows) - fix launch panic (Some Windows) --- ## v1.0.0 ### Features - update clash core - optimize traffic graph animation - supports interval update profiles - supports service mode (Windows) ### Bug Fixes - reset system proxy when exit from dock (macOS) - adjust clash dns config process strategy --- ## v0.0.29 ### Features - sort proxy node - custom proxy test url - logs page filter - connections page filter - default user agent for subscription - system tray add tun mode toggle - enable to change the config dir (Windows only) --- ## v0.0.28 ### Features - enable to use clash config fields (UI) ### Bug Fixes - remove the character - fix some icon color --- ## v0.0.27 ### Features - supports custom theme color - tun mode setting control the final config ### Bug Fixes - fix transition flickers (macOS) - reduce proxy page render --- ## v0.0.26 ### Features - silent start - profile editor - profile enhance mode supports more fields - optimize profile enhance mode strategy ### Bug Fixes - fix csp restriction on macOS - window controllers on Linux --- ## v0.0.25 ### Features - update clash core version ### Bug Fixes - app updater error - display window controllers on Linux ### Notes If you can't update the app properly, please consider downloading the latest version from github release. --- ## v0.0.24 ### Features - Connections page - add wintun.dll (Windows) - supports create local profile with selected file (Windows) - system tray enable set system proxy ### Bug Fixes - open dir error - auto launch path (Windows) - fix some clash config error - reduce the impact of the enhanced mode --- ## v0.0.23 ### Features - i18n supports - Remote profile User Agent supports ### Bug Fixes - clash config file case ignore - clash `external-controller` only port ================================================ FILE: backend/.gitignore ================================================ # Generated by Cargo # will have compiled files and executables **/target/ ================================================ FILE: backend/Cargo.toml ================================================ [workspace] resolver = "2" members = ["tauri", "boa_utils", "nyanpasu-macro", "nyanpasu-egui"] [patch.crates-io] tray-icon = { git = "https://github.com/tauri-apps/tray-icon.git", rev = "34a3442" } [workspace.package] repository = "https://github.com/keiko233/clash-nyanpasu.git" edition = "2024" license = "GPL-3.0" authors = ["zzzgydi", "keiko233"] [workspace.dependencies] thiserror = "2" tracing = "0.1" boa_engine = { version = "0.21", features = ["annex-b"] } reqwest = { version = "0.12", default-features = false, features = [ "charset", "http2", "system-proxy", "json", "stream", "rustls-tls", ] } tokio = { version = "1", features = ["full"] } nyanpasu-utils = { git = "https://github.com/libnyanpasu/nyanpasu-utils.git", features = [ "specta", ] } test-log = { version = "0.2.16", features = ["trace"] } tracing-test = { git = "https://github.com/Frando/tracing-test.git", rev = "e81ec65", features = [ "no-env-filter", "pretty-log-printing", ] } fs4 = { version = "0.13.1", features = ["fs-err3-tokio", "fs-err3"] } fs-err = { version = "3.1.2", features = ["tokio"] } [profile.release] panic = "unwind" codegen-units = 1 lto = true opt-level = "s" ================================================ FILE: backend/Cross.toml ================================================ [target.aarch64-unknown-linux-gnu] # dockerfile = "./manifest/docker/ubuntu-22.04-aarch64/Dockerfile" image = "ghcr.io/libnyanpasu/builder-debian-trixie-aarch64:latest" # pre-build = [ # "dpkg --add-architecture $CROSS_DEB_ARCH", # """echo "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ jammy main restricted universe multiverse" | tee /etc/apt/sources.list && \ # echo "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ jammy-updates main restricted universe multiverse" | tee -a /etc/apt/sources.list && \ # echo "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ jammy-security main restricted universe multiverse" | tee -a /etc/apt/sources.list && \ # echo "deb [arch=i386,amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted universe multiverse" | tee -a /etc/apt/sources.list && \ # echo "deb [arch=i386,amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates main restricted universe multiverse" | tee -a /etc/apt/sources.list && \ # echo "deb [arch=i386,amd64] http://archive.ubuntu.com/ubuntu/ jammy-security main restricted universe multiverse" | tee -a /etc/apt/sources.list && \ # apt-get update && apt-get -y install \ # build-essential \ # libgtk-3-dev:$CROSS_DEB_ARCH \ # libwebkit2gtk-4.1-dev:$CROSS_DEB_ARCH \ # libxdo-dev:$CROSS_DEB_ARCH \ # libayatana-appindicator3-dev:$CROSS_DEB_ARCH \ # librsvg2-dev:$CROSS_DEB_ARCH \ # libpango1.0-dev:$CROSS_DEB_ARCH \ # libcairo2-dev:$CROSS_DEB_ARCH \ # libatk1.0-dev:$CROSS_DEB_ARCH \ # libsoup2.4-dev:$CROSS_DEB_ARCH \ # libssl-dev:$CROSS_DEB_ARCH \ # """, # ] [target.armv7-unknown-linux-gnueabihf] image = "ghcr.io/libnyanpasu/builder-debian-trixie-armhf:latest" [target.armv7-unknown-linux-gnueabi] image = "ghcr.io/libnyanpasu/builder-debian-trixie-armel:latest" [target.i686-unknown-linux-gnu] image = "ghcr.io/libnyanpasu/builder-debian-trixie-i686:latest" ================================================ FILE: backend/boa_utils/Cargo.toml ================================================ [package] name = "boa_utils" version = "0.1.0" repository.workspace = true edition.workspace = true license.workspace = true authors.workspace = true [lib] doctest = false [dependencies] rustc-hash = { version = "2", features = ["std"] } boa_engine = { workspace = true, features = ["annex-b"] } boa_gc = { version = "0.21" } boa_parser = { version = "0.21", features = ["annex-b"] } tokio = { workspace = true } nyanpasu-utils = { workspace = true } reqwest = { workspace = true } futures-util = "0.3" futures-concurrency = "7" smol = "2" tracing = "0.1" url = "2" log = "0.4" anyhow = "1.0" include_url_macro = { git = "https://github.com/libnyanpasu/include_url_macro", rev = "fbe47bd" } include-compress-bytes = { git = "https://github.com/libnyanpasu/include-compress-bytes", rev = "4e4f25b" } phf = { version = "0.13.1", features = ["macros"] } # for cacheing mime = "0.3.17" async-fs = "2.1.2" # for encoding/decoding serde = { version = "1.0", features = ["derive"] } postcard = { version = "1.1.1", features = ["use-std"] } serde_json = { version = "1.0", features = ["preserve_order"] } brotli = "8.0.2" [dev-dependencies] indoc = "2" textwrap = "0.16" test-log = { workspace = true } tempfile = "3.17" ================================================ FILE: backend/boa_utils/src/console/mod.rs ================================================ //! Boa's implementation of JavaScript's `console` Web API object. //! //! The `console` object can be accessed from any global object. //! //! The specifics of how it works varies from browser to browser, but there is a de facto set of features that are typically provided. //! //! More information: //! - [MDN documentation][mdn] //! - [WHATWG `console` specification][spec] //! //! [spec]: https://console.spec.whatwg.org/ //! [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Console #[cfg(test)] mod tests; use boa_engine::{ Context, JsArgs, JsData, JsError, JsResult, JsStr, JsString, js_str, js_string, native_function::NativeFunction, object::{JsObject, ObjectInitializer}, value::{JsValue, Numeric}, }; use boa_gc::{Finalize, Trace}; use rustc_hash::FxHashMap; use std::{cell::RefCell, collections::hash_map::Entry, rc::Rc, time::SystemTime}; /// This represents the different types of log messages. #[derive(Debug)] pub enum LogMessage { Log(String), Info(String), Warn(String), Error(String), } /// Helper function for logging messages. fn logger(msg: LogMessage, console_state: &Console) { let indent = 2 * console_state.groups.len(); match msg { LogMessage::Error(msg) => { eprintln!("{msg:>indent$}"); } LogMessage::Log(msg) | LogMessage::Info(msg) | LogMessage::Warn(msg) => { println!("{msg:>indent$}"); } } } pub trait Logger { type Item; fn log(&mut self, msg: LogMessage, console_state: &Console); fn take(&mut self) -> Vec; } pub trait LoggerBox = Logger + Sync + Send + 'static; struct ConsoleLogger; impl Logger for ConsoleLogger { type Item = LogMessage; fn log(&mut self, msg: LogMessage, console_state: &Console) { logger(msg, console_state); } fn take(&mut self) -> Vec { vec![] } } thread_local! { static LOGGER: RefCell> = RefCell::new(Box::new(ConsoleLogger)); } pub fn inspect_logger(f: impl FnOnce(&mut dyn LoggerBox) -> R) -> R { LOGGER.with(|cell| f(cell.borrow_mut().as_mut())) } pub fn set_logger(logger: Box) { LOGGER.with(|cell| { *cell.borrow_mut() = logger; }); } /// This represents the `console` formatter. fn formatter(data: &[JsValue], context: &mut Context) -> JsResult { match data { [] => Ok(String::new()), [val] => Ok(val.to_string(context)?.to_std_string_escaped()), data => { let mut formatted = String::new(); let mut arg_index = 1; let target = data .get_or_undefined(0) .to_string(context)? .to_std_string_escaped(); let mut chars = target.chars(); while let Some(c) = chars.next() { if c == '%' { let fmt = chars.next().unwrap_or('%'); match fmt { /* integer */ 'd' | 'i' => { let arg = match data.get_or_undefined(arg_index).to_numeric(context)? { Numeric::Number(r) => (r.floor() + 0.0).to_string(), Numeric::BigInt(int) => int.to_string(), }; formatted.push_str(&arg); arg_index += 1; } /* float */ 'f' => { let arg = data.get_or_undefined(arg_index).to_number(context)?; formatted.push_str(&format!("{arg:.6}")); arg_index += 1; } /* object, FIXME: how to render this properly? */ 'o' | 'O' => { let arg = data.get_or_undefined(arg_index); formatted.push_str(&arg.display().to_string()); arg_index += 1; } /* string */ 's' => { let arg = data .get_or_undefined(arg_index) .to_string(context)? .to_std_string_escaped(); formatted.push_str(&arg); arg_index += 1; } '%' => formatted.push('%'), /* TODO: %c is not implemented */ c => { formatted.push('%'); formatted.push(c); } } } else { formatted.push(c); }; } /* unformatted data */ for rest in data.iter().skip(arg_index) { formatted.push_str(&format!( " {}", rest.to_string(context)?.to_std_string_escaped() )); } Ok(formatted) } } } /// This is the internal console object state. #[derive(Debug, Default, Trace, Finalize, JsData)] pub struct Console { count_map: FxHashMap, timer_map: FxHashMap, groups: Vec, } impl Console { /// Name of the built-in `console` property. pub const NAME: JsStr<'static> = js_str!("console"); /// Initializes the `console` built-in object. #[allow(clippy::too_many_lines)] pub fn init(context: &mut Context) -> JsObject { fn console_method( f: fn(&JsValue, &[JsValue], &Console, &mut Context) -> JsResult, state: Rc>, ) -> NativeFunction { // SAFETY: `Console` doesn't contain types that need tracing. unsafe { NativeFunction::from_closure(move |this, args, context| { f(this, args, &state.borrow(), context) }) } } fn console_method_mut( f: fn(&JsValue, &[JsValue], &mut Console, &mut Context) -> JsResult, state: Rc>, ) -> NativeFunction { // SAFETY: `Console` doesn't contain types that need tracing. unsafe { NativeFunction::from_closure(move |this, args, context| { f(this, args, &mut state.borrow_mut(), context) }) } } // let _timer = Profiler::global().start_event(std::any::type_name::(), "init"); let state = Rc::new(RefCell::new(Self::default())); ObjectInitializer::with_native_data(Self::default(), context) .function( console_method(Self::assert, state.clone()), js_string!("assert"), 0, ) .function( console_method_mut(Self::clear, state.clone()), js_string!("clear"), 0, ) .function( console_method(Self::debug, state.clone()), js_string!("debug"), 0, ) .function( console_method(Self::error, state.clone()), js_string!("error"), 0, ) .function( console_method(Self::info, state.clone()), js_string!("info"), 0, ) .function( console_method(Self::log, state.clone()), js_string!("log"), 0, ) .function( console_method(Self::trace, state.clone()), js_string!("trace"), 0, ) .function( console_method(Self::warn, state.clone()), js_string!("warn"), 0, ) .function( console_method_mut(Self::count, state.clone()), js_string!("count"), 0, ) .function( console_method_mut(Self::count_reset, state.clone()), js_string!("countReset"), 0, ) .function( console_method_mut(Self::group, state.clone()), js_string!("group"), 0, ) .function( console_method_mut(Self::group_collapsed, state.clone()), js_string!("groupCollapsed"), 0, ) .function( console_method_mut(Self::group_end, state.clone()), js_string!("groupEnd"), 0, ) .function( console_method_mut(Self::time, state.clone()), js_string!("time"), 0, ) .function( console_method(Self::time_log, state.clone()), js_string!("timeLog"), 0, ) .function( console_method_mut(Self::time_end, state.clone()), js_string!("timeEnd"), 0, ) .function( console_method(Self::dir, state.clone()), js_string!("dir"), 0, ) .function(console_method(Self::dir, state), js_string!("dirxml"), 0) .build() } /// `console.assert(condition, ...data)` /// /// Prints a JavaScript value to the standard error if first argument evaluates to `false` or there /// were no arguments. /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#assert /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/assert fn assert( _: &JsValue, args: &[JsValue], console: &Self, context: &mut Context, ) -> JsResult { let assertion = args.first().is_some_and(JsValue::to_boolean); if !assertion { let mut args: Vec = args.iter().skip(1).cloned().collect(); let message = js_string!("Assertion failed"); if args.is_empty() { args.push(JsValue::new(message)); } else if !args[0].is_string() { args.insert(0, JsValue::new(message)); } else { let value = JsString::from(args[0].display().to_string()); let concat = js_string!(message.as_str(), js_str!(": "), &value); args[0] = JsValue::new(concat); } LOGGER.with(|logger| { logger .borrow_mut() .log(LogMessage::Error(formatter(&args, context)?), console); Ok::<_, JsError>(()) })?; } Ok(JsValue::undefined()) } /// `console.clear()` /// /// Removes all groups and clears console if possible. /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#clear /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/clear #[allow(clippy::unnecessary_wraps)] fn clear(_: &JsValue, _: &[JsValue], console: &mut Self, _: &mut Context) -> JsResult { console.groups.clear(); Ok(JsValue::undefined()) } /// `console.debug(...data)` /// /// Prints a JavaScript values with "debug" logLevel. /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#debug /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/debug fn debug( _: &JsValue, args: &[JsValue], console: &Self, context: &mut Context, ) -> JsResult { LOGGER.with(|logger| { logger .borrow_mut() .log(LogMessage::Log(formatter(args, context)?), console); Ok::<_, JsError>(()) })?; Ok(JsValue::undefined()) } /// `console.error(...data)` /// /// Prints a JavaScript values with "error" logLevel. /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#error /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/error fn error( _: &JsValue, args: &[JsValue], console: &Self, context: &mut Context, ) -> JsResult { LOGGER.with(|logger| { logger .borrow_mut() .log(LogMessage::Error(formatter(args, context)?), console); Ok::<_, JsError>(()) })?; Ok(JsValue::undefined()) } /// `console.info(...data)` /// /// Prints a JavaScript values with "info" logLevel. /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#info /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/info fn info( _: &JsValue, args: &[JsValue], console: &Self, context: &mut Context, ) -> JsResult { LOGGER.with(|logger| { logger .borrow_mut() .log(LogMessage::Info(formatter(args, context)?), console); Ok::<_, JsError>(()) })?; Ok(JsValue::undefined()) } /// `console.log(...data)` /// /// Prints a JavaScript values with "log" logLevel. /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#log /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/log fn log( _: &JsValue, args: &[JsValue], console: &Self, context: &mut Context, ) -> JsResult { LOGGER.with(|logger| { logger .borrow_mut() .log(LogMessage::Log(formatter(args, context)?), console); Ok::<_, JsError>(()) })?; Ok(JsValue::undefined()) } /// `console.trace(...data)` /// /// Prints a stack trace with "trace" logLevel, optionally labelled by data. /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#trace /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/trace fn trace( _: &JsValue, args: &[JsValue], console: &Self, context: &mut Context, ) -> JsResult { if !args.is_empty() { LOGGER.with(|logger| { logger .borrow_mut() .log(LogMessage::Log(formatter(args, context)?), console); Ok::<_, JsError>(()) })?; } let stack_trace_dump = context .stack_trace() .map(|frame| frame.code_block().name()) .collect::>() .into_iter() .map(JsString::to_std_string_escaped) .collect::>() .join("\n"); LOGGER.with(|logger| { logger .borrow_mut() .log(LogMessage::Log(stack_trace_dump), console); Ok::<_, JsError>(()) })?; Ok(JsValue::undefined()) } /// `console.warn(...data)` /// /// Prints a JavaScript values with "warn" logLevel. /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#warn /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/warn fn warn( _: &JsValue, args: &[JsValue], console: &Self, context: &mut Context, ) -> JsResult { LOGGER.with(|logger| { logger .borrow_mut() .log(LogMessage::Warn(formatter(args, context)?), console); Ok::<_, JsError>(()) })?; Ok(JsValue::undefined()) } /// `console.count(label)` /// /// Prints number of times the function was called with that particular label. /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#count /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/count fn count( _: &JsValue, args: &[JsValue], console: &mut Self, context: &mut Context, ) -> JsResult { let label = match args.first() { Some(value) => value.to_string(context)?, None => "default".into(), }; let msg = format!("count {}:", label.to_std_string_escaped()); let c = console.count_map.entry(label).or_insert(0); *c += 1; let msg = format!("{msg} {c}"); LOGGER.with(|logger| { logger.borrow_mut().log(LogMessage::Info(msg), console); Ok::<_, JsError>(()) })?; Ok(JsValue::undefined()) } /// `console.countReset(label)` /// /// Resets the counter for label. /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#countreset /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/countReset fn count_reset( _: &JsValue, args: &[JsValue], console: &mut Self, context: &mut Context, ) -> JsResult { let label = match args.first() { Some(value) => value.to_string(context)?, None => "default".into(), }; console.count_map.remove(&label); logger( LogMessage::Warn(format!("countReset {}", label.to_std_string_escaped())), console, ); Ok(JsValue::undefined()) } /// Returns current system time in ms. fn system_time_in_ms() -> u128 { let now = SystemTime::now(); now.duration_since(SystemTime::UNIX_EPOCH) .expect("negative duration") .as_millis() } /// `console.time(label)` /// /// Starts the timer for given label. /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#time /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/time fn time( _: &JsValue, args: &[JsValue], console: &mut Self, context: &mut Context, ) -> JsResult { let label = match args.first() { Some(value) => value.to_string(context)?, None => "default".into(), }; if let Entry::Vacant(e) = console.timer_map.entry(label.clone()) { let time = Self::system_time_in_ms(); e.insert(time); } else { logger( LogMessage::Warn(format!( "Timer '{}' already exist", label.to_std_string_escaped() )), console, ); } Ok(JsValue::undefined()) } /// `console.timeLog(label, ...data)` /// /// Prints elapsed time for timer with given label. /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#timelog /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/timeLog fn time_log( _: &JsValue, args: &[JsValue], console: &Self, context: &mut Context, ) -> JsResult { let label = match args.first() { Some(value) => value.to_string(context)?, None => "default".into(), }; console.timer_map.get(&label).map_or_else( || { logger( LogMessage::Warn(format!( "Timer '{}' doesn't exist", label.to_std_string_escaped() )), console, ); }, |t| { let time = Self::system_time_in_ms(); let mut concat = format!("{}: {} ms", label.to_std_string_escaped(), time - t); for msg in args.iter().skip(1) { concat = concat + " " + &msg.display().to_string(); } LOGGER.with(|logger| { logger.borrow_mut().log(LogMessage::Log(concat), console); }); }, ); Ok(JsValue::undefined()) } /// `console.timeEnd(label)` /// /// Removes the timer with given label. /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#timeend /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/timeEnd fn time_end( _: &JsValue, args: &[JsValue], console: &mut Self, context: &mut Context, ) -> JsResult { let label = match args.first() { Some(value) => value.to_string(context)?, None => "default".into(), }; console.timer_map.remove(&label).map_or_else( || { logger( LogMessage::Warn(format!( "Timer '{}' doesn't exist", label.to_std_string_escaped() )), console, ); }, |t| { let time = Self::system_time_in_ms(); logger( LogMessage::Info(format!( "{}: {} ms - timer removed", label.to_std_string_escaped(), time - t )), console, ); }, ); Ok(JsValue::undefined()) } /// `console.group(...data)` /// /// Adds new group with name from formatted data to stack. /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#group /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/group fn group( _: &JsValue, args: &[JsValue], console: &mut Self, context: &mut Context, ) -> JsResult { let group_label = formatter(args, context)?; LOGGER.with(|logger| { logger .borrow_mut() .log(LogMessage::Info(format!("group: {group_label}")), console); Ok::<_, JsError>(()) })?; console.groups.push(group_label); Ok(JsValue::undefined()) } /// `console.groupCollapsed(...data)` /// /// Adds new group collapsed with name from formatted data to stack. /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#groupcollapsed /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/groupcollapsed_static fn group_collapsed( _: &JsValue, args: &[JsValue], console: &mut Self, context: &mut Context, ) -> JsResult { Console::group(&JsValue::undefined(), args, console, context) } /// `console.groupEnd(label)` /// /// Removes the last group from the stack. /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#groupend /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/groupEnd #[allow(clippy::unnecessary_wraps)] fn group_end( _: &JsValue, _: &[JsValue], console: &mut Self, _: &mut Context, ) -> JsResult { console.groups.pop(); Ok(JsValue::undefined()) } /// `console.dir(item, options)` /// /// Prints info about item /// /// More information: /// - [MDN documentation][mdn] /// - [WHATWG `console` specification][spec] /// /// [spec]: https://console.spec.whatwg.org/#dir /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/dir #[allow(clippy::unnecessary_wraps)] fn dir(_: &JsValue, args: &[JsValue], console: &Self, _: &mut Context) -> JsResult { logger( LogMessage::Info(args.get_or_undefined(0).display_obj(true)), console, ); Ok(JsValue::undefined()) } } ================================================ FILE: backend/boa_utils/src/console/tests.rs ================================================ use super::{Console, formatter}; use crate::test::{TestAction, run_test_actions, run_test_actions_with}; use boa_engine::{Context, JsValue, js_string, property::Attribute}; use indoc::indoc; #[test] fn formatter_no_args_is_empty_string() { run_test_actions([TestAction::inspect_context(|ctx| { assert_eq!(formatter(&[], ctx).unwrap(), ""); })]); } #[test] fn formatter_empty_format_string_is_empty_string() { run_test_actions([TestAction::inspect_context(|ctx| { assert_eq!(formatter(&[JsValue::new(js_string!())], ctx).unwrap(), ""); })]); } #[test] fn formatter_format_without_args_renders_verbatim() { run_test_actions([TestAction::inspect_context(|ctx| { assert_eq!( formatter(&[JsValue::new(js_string!("%d %s %% %f"))], ctx).unwrap(), "%d %s %% %f" ); })]); } #[test] fn formatter_empty_format_string_concatenates_rest_of_args() { run_test_actions([TestAction::inspect_context(|ctx| { assert_eq!( formatter( &[ JsValue::new(js_string!("")), JsValue::new(js_string!("to powinno zostać")), JsValue::new(js_string!("połączone")), ], ctx ) .unwrap(), " to powinno zostać połączone" ); })]); } #[test] fn formatter_utf_8_checks() { run_test_actions([TestAction::inspect_context(|ctx| { assert_eq!( formatter( &[ JsValue::new(js_string!("Są takie chwile %dą %są tu%sów %привет%ź")), JsValue::new(123), JsValue::new(1.23), JsValue::new(js_string!("ł")), ], ctx ) .unwrap(), "Są takie chwile 123ą 1.23ą tułów %привет%ź" ); })]); } #[test] fn formatter_trailing_format_leader_renders() { run_test_actions([TestAction::inspect_context(|ctx| { assert_eq!( formatter( &[ JsValue::new(js_string!("%%%%%")), JsValue::new(js_string!("|")) ], ctx ) .unwrap(), "%%% |" ); })]); } #[test] #[allow(clippy::approx_constant)] fn formatter_float_format_works() { run_test_actions([TestAction::inspect_context(|ctx| { assert_eq!( formatter(&[JsValue::new(js_string!("%f")), JsValue::new(3.1415)], ctx).unwrap(), "3.141500" ); })]); } #[test] fn console_log_cyclic() { let mut context = Context::default(); let console = Console::init(&mut context); context .register_global_property(js_string!(Console::NAME), console, Attribute::all()) .unwrap(); run_test_actions_with( [TestAction::run(indoc! {r#" let a = [1]; a[1] = a; console.log(a); "#})], &mut context, ); // Should not stack overflow } ================================================ FILE: backend/boa_utils/src/lib.rs ================================================ #![feature(trait_alias)] #![feature(auto_traits)] #![feature(negative_impls)] //! Boa's **boa_runtime** crate contains an example runtime and basic runtime features and //! functionality for the `boa_engine` crate for runtime implementors. //! //! # Example: Adding Web API's Console Object //! //! 1. Add **boa_runtime** as a dependency to your project along with **boa_engine**. //! //! ```no_run //! use boa_engine::{js_string, property::Attribute, Context, Source}; //! use boa_runtime::Console; //! //! // Create the context. //! let mut context = Context::default(); //! //! // Initialize the Console object. //! let console = Console::init(&mut context); //! //! // Register the console as a global property to the context. //! context //! .register_global_property(js_string!(Console::NAME), console, Attribute::all()) //! .expect("the console object shouldn't exist yet"); //! //! // JavaScript source for parsing. //! let js_code = "console.log('Hello World from a JS code string!')"; //! //! // Parse the source code //! match context.eval(Source::from_bytes(js_code)) { //! Ok(res) => { //! println!( //! "{}", //! res.to_string(&mut context).unwrap().to_std_string_escaped() //! ); //! } //! Err(e) => { //! // Pretty print the error //! eprintln!("Uncaught {e}"); //! # panic!("An error occured in boa_runtime's js_code"); //! } //! }; //! ``` #![doc( html_logo_url = "https://raw.githubusercontent.com/boa-dev/boa/main/assets/logo.svg", html_favicon_url = "https://raw.githubusercontent.com/boa-dev/boa/main/assets/logo.svg" )] #![cfg_attr(test, allow(clippy::needless_raw_string_hashes))] // Makes strings a bit more copy-pastable #![cfg_attr(not(test), forbid(clippy::unwrap_used))] #![allow( clippy::module_name_repetitions, clippy::redundant_pub_crate, clippy::let_unit_value )] mod console; pub mod module; #[doc(inline)] pub use console::Console; pub use console::{LogMessage, Logger, LoggerBox, inspect_logger, set_logger}; #[cfg(test)] pub(crate) mod test { use boa_engine::{Context, JsResult, JsValue, Source, builtins}; use std::borrow::Cow; /// A test action executed in a test function. #[allow(missing_debug_implementations)] #[derive(Clone)] pub(crate) struct TestAction(Inner); #[derive(Clone)] #[allow(dead_code)] enum Inner { RunHarness, Run { source: Cow<'static, str>, }, InspectContext { op: fn(&mut Context), }, Assert { source: Cow<'static, str>, }, AssertEq { source: Cow<'static, str>, expected: JsValue, }, AssertWithOp { source: Cow<'static, str>, op: fn(JsValue, &mut Context) -> bool, }, AssertOpaqueError { source: Cow<'static, str>, expected: JsValue, }, AssertNativeError { source: Cow<'static, str>, kind: builtins::error::ErrorKind, message: &'static str, }, AssertContext { op: fn(&mut Context) -> bool, }, } impl TestAction { /// Runs `source`, panicking if the execution throws. pub(crate) fn run(source: impl Into>) -> Self { Self(Inner::Run { source: source.into(), }) } /// Executes `op` with the currently active context. /// /// Useful to make custom assertions that must be done from Rust code. pub(crate) fn inspect_context(op: fn(&mut Context)) -> Self { Self(Inner::InspectContext { op }) } } /// Executes a list of test actions on a new, default context. #[track_caller] pub(crate) fn run_test_actions(actions: impl IntoIterator) { let context = &mut Context::default(); run_test_actions_with(actions, context); } /// Executes a list of test actions on the provided context. #[track_caller] #[allow(clippy::too_many_lines, clippy::missing_panics_doc)] pub(crate) fn run_test_actions_with( actions: impl IntoIterator, context: &mut Context, ) { #[track_caller] fn forward_val(context: &mut Context, source: &str) -> JsResult { context.eval(Source::from_bytes(source)) } #[track_caller] fn fmt_test(source: &str, test: usize) -> String { format!( "\n\nTest case {test}: \n```\n{}\n```", textwrap::indent(source, " ") ) } // Some unwrapping patterns look weird because they're replaceable // by simpler patterns like `unwrap_or_else` or `unwrap_err let mut i = 1; for action in actions.into_iter().map(|a| a.0) { match action { Inner::RunHarness => { // add utility functions for testing // TODO: extract to a file forward_val( context, r#" function equals(a, b) { if (Array.isArray(a) && Array.isArray(b)) { return arrayEquals(a, b); } return a === b; } function arrayEquals(a, b) { return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((val, index) => equals(val, b[index])); } "#, ) .expect("failed to evaluate test harness"); } Inner::Run { source } => { if let Err(e) = forward_val(context, &source) { panic!("{}\nUncaught {e}", fmt_test(&source, i)); } } Inner::InspectContext { op } => { op(context); } Inner::Assert { source } => { let val = match forward_val(context, &source) { Err(e) => panic!("{}\nUncaught {e}", fmt_test(&source, i)), Ok(v) => v, }; let Some(val) = val.as_boolean() else { panic!( "{}\nTried to assert with the non-boolean value `{}`", fmt_test(&source, i), val.display() ) }; assert!(val, "{}", fmt_test(&source, i)); i += 1; } Inner::AssertEq { source, expected } => { let val = match forward_val(context, &source) { Err(e) => panic!("{}\nUncaught {e}", fmt_test(&source, i)), Ok(v) => v, }; assert_eq!(val, expected, "{}", fmt_test(&source, i)); i += 1; } Inner::AssertWithOp { source, op } => { let val = match forward_val(context, &source) { Err(e) => panic!("{}\nUncaught {e}", fmt_test(&source, i)), Ok(v) => v, }; assert!(op(val, context), "{}", fmt_test(&source, i)); i += 1; } Inner::AssertOpaqueError { source, expected } => { let err = match forward_val(context, &source) { Ok(v) => panic!( "{}\nExpected error, got value `{}`", fmt_test(&source, i), v.display() ), Err(e) => e, }; let Some(err) = err.as_opaque() else { panic!( "{}\nExpected opaque error, got native error `{}`", fmt_test(&source, i), err ) }; assert_eq!(err, &expected, "{}", fmt_test(&source, i)); i += 1; } Inner::AssertNativeError { source, kind, message, } => { let err = match forward_val(context, &source) { Ok(v) => panic!( "{}\nExpected error, got value `{}`", fmt_test(&source, i), v.display() ), Err(e) => e, }; let native = match err.try_native(context) { Ok(err) => err, Err(e) => panic!( "{}\nCouldn't obtain a native error: {e}", fmt_test(&source, i) ), }; assert_eq!(&native.kind, &kind, "{}", fmt_test(&source, i)); assert_eq!(native.message(), message, "{}", fmt_test(&source, i)); i += 1; } Inner::AssertContext { op } => { assert!(op(context), "Test case {i}"); i += 1; } } } } } ================================================ FILE: backend/boa_utils/src/module/builtin/utils.js ================================================ import dedent from 'nyan:dedent' import YAML from 'nyan:yaml' /** * Parse template string into YAML object * @param {TemplateStringsArray} strings Template string array * @param {...any} values Template string interpolation values * @returns {Object} Parsed YAML object */ export function yaml(strings, ...values) { const str = String.raw({ raw: strings }, ...values) return YAML.parse(dedent(str)) } ================================================ FILE: backend/boa_utils/src/module/builtin.rs ================================================ use std::{cell::RefCell, io::Read, rc::Rc}; use anyhow::Context as _; use boa_engine::{Context, JsNativeError, JsResult, JsString, Module, module::ModuleLoader}; use boa_parser::Source; use include_compress_bytes::include_bytes_brotli; use include_url_macro::include_url_bytes_with_brotli; use phf::phf_map; pub(crate) const BUILTIN_MODULE_PREFIX: &str = "nyan:"; static BUILTIN_MODULES: phf::Map<&str, &[u8]> = phf_map! { // Remote resources "es-toolkit" => include_url_bytes_with_brotli!("https://fastly.jsdelivr.net/npm/es-toolkit@1.39.10/+esm"), "yaml" => include_url_bytes_with_brotli!("https://fastly.jsdelivr.net/npm/yaml@2.8.1/+esm"), "dedent" => include_url_bytes_with_brotli!("https://fastly.jsdelivr.net/npm/dedent@1.7.0/+esm"), "js-base64" => include_url_bytes_with_brotli!("https://fastly.jsdelivr.net/npm/js-base64@3.7.8/+esm"), // Local utils, "utils" => include_bytes_brotli!("./builtin/utils.js"), }; /// A ModuleLoader load resources from builtin static resources pub struct BuiltinModuleLoader; impl ModuleLoader for BuiltinModuleLoader { async fn load_imported_module( self: Rc, _referrer: boa_engine::module::Referrer, specifier: JsString, context: &RefCell<&mut Context>, ) -> JsResult { let specifier_str = specifier.to_std_string_escaped(); let result: Result<_, anyhow::Error> = (|| { let module_name = specifier_str .strip_prefix(BUILTIN_MODULE_PREFIX) .context("Not builtin module prefix")?; log::trace!("Trying to reading builtin module: {}", module_name); let module_data = BUILTIN_MODULES .get(module_name) .context("Builtin module not found")?; let mut data = Vec::with_capacity(1024 * 8); { let mut reader = brotli::Decompressor::new(&**module_data, 4096); let mut buf = [0u8; 1024 * 8]; loop { match reader.read(&mut buf) { Ok(0) => break, Ok(read) => data.extend_from_slice(&buf[..read]), Err(e) if e.kind() == std::io::ErrorKind::Interrupted => { continue; } Err(err) => Err(err).context("failed to decode br stream")?, } } } Ok(data) })(); let data = result.map_err(|err| { log::error!("Failed to loading builtin module: {}", specifier_str); JsNativeError::typ().with_message(err.to_string()) })?; log::trace!("Finishing loading builtin module: {}", specifier_str); let source = Source::from_bytes(&data); Module::parse(source, None, &mut context.borrow_mut()) } } #[cfg(test)] mod tests { use boa_engine::{JsValue, job::SimpleJobExecutor}; use super::*; #[test_log::test] fn test_builtin_module_loader() -> JsResult<()> { use boa_engine::{builtins::promise::PromiseState, js_string}; use std::rc::Rc; // A simple snippet that imports modules from the web instead of the file system. const SRC: &str = r#" import { isEqual } from 'nyan:es-toolkit'; import dedent from 'nyan:dedent'; import YAML from 'nyan:yaml'; import { Base64 } from 'nyan:js-base64'; if (isEqual(1, 2)) { throw new Error('Wrong isEqual implementation'); } const data = dedent` object: array: ["hello", "world"] key: "value" `; const object = YAML.parse(data).object; let result = [ Base64.encode(object.array[0]), Base64.encode(object.array[1]), ] export default result; "#; let queue = Rc::new(SimpleJobExecutor::new()); let mut context = Context::builder() .job_executor(queue) // NEW: sets the context module loader to our custom loader .module_loader(Rc::new(BuiltinModuleLoader)) .build()?; let module = Module::parse(Source::from_bytes(SRC.as_bytes()), None, &mut context)?; // Calling `Module::load_link_evaluate` takes care of having to define promise handlers for // `Module::load` and `Module::evaluate`. let promise = module.load_link_evaluate(&mut context); // Important to call `Context::run_jobs`, or else all the futures and promises won't be // pushed forward by the job queue. let _ = context.run_jobs(); match promise.state() { // Our job queue guarantees that all promises and futures are finished after returning // from `Context::run_jobs`. // Some other job queue designs only execute a "microtick" or a single pass through the // pending promises and futures. In that case, you can pass this logic as a promise handler // for `promise` instead. PromiseState::Pending => panic!("module didn't execute!"), // All modules after successfully evaluating return `JsValue::undefined()`. PromiseState::Fulfilled(v) => { assert_eq!(v, JsValue::undefined()) } PromiseState::Rejected(err) => { panic!("{:#?}: {}", err.display_obj(false), err.display()); } } let default = module .namespace(&mut context) .get(js_string!("default"), &mut context)?; // `default` should contain the result of our calculations. let default = default .as_object() .ok_or_else(|| JsNativeError::typ().with_message("default export was not an object"))?; assert_eq!( default.get(0, &mut context)?.as_string().ok_or_else( || JsNativeError::typ().with_message("array element was not a string") )?, js_string!("aGVsbG8=") ); assert_eq!( default.get(1, &mut context)?.as_string().ok_or_else( || JsNativeError::typ().with_message("array element was not a string") )?, js_string!("d29ybGQ=") ); Ok(()) } #[test_log::test] fn test_builtin_utils() -> JsResult<()> { use boa_engine::{builtins::promise::PromiseState, js_string}; use std::rc::Rc; // A simple snippet that imports modules from the web instead of the file system. const SRC: &str = r#" import { yaml } from 'nyan:utils'; import { Base64 } from 'nyan:js-base64'; const data = yaml` object: array: ["hello", "world"] key: "value" `; const object = data.object; let result = [ Base64.encode(object.array[0]), Base64.encode(object.array[1]), ] export default result; "#; let queue = Rc::new(SimpleJobExecutor::new()); let mut context = Context::builder() .job_executor(queue) // NEW: sets the context module loader to our custom loader .module_loader(Rc::new(BuiltinModuleLoader)) .build()?; let module = Module::parse(Source::from_bytes(SRC.as_bytes()), None, &mut context)?; // Calling `Module::load_link_evaluate` takes care of having to define promise handlers for // `Module::load` and `Module::evaluate`. let promise = module.load_link_evaluate(&mut context); // Important to call `Context::run_jobs`, or else all the futures and promises won't be // pushed forward by the job queue. let _ = context.run_jobs(); match promise.state() { // Our job queue guarantees that all promises and futures are finished after returning // from `Context::run_jobs`. // Some other job queue designs only execute a "microtick" or a single pass through the // pending promises and futures. In that case, you can pass this logic as a promise handler // for `promise` instead. PromiseState::Pending => panic!("module didn't execute!"), // All modules after successfully evaluating return `JsValue::undefined()`. PromiseState::Fulfilled(v) => { assert_eq!(v, JsValue::undefined()) } PromiseState::Rejected(err) => { panic!("{:#?}: {}", err.display_obj(false), err.display()); } } let default = module .namespace(&mut context) .get(js_string!("default"), &mut context)?; // `default` should contain the result of our calculations. let default = default .as_object() .ok_or_else(|| JsNativeError::typ().with_message("default export was not an object"))?; assert_eq!( default.get(0, &mut context)?.as_string().ok_or_else( || JsNativeError::typ().with_message("array element was not a string") )?, js_string!("aGVsbG8=") ); assert_eq!( default.get(1, &mut context)?.as_string().ok_or_else( || JsNativeError::typ().with_message("array element was not a string") )?, js_string!("d29ybGQ=") ); Ok(()) } } ================================================ FILE: backend/boa_utils/src/module/combine.rs ================================================ use std::{cell::RefCell, rc::Rc}; use boa_engine::{Context, JsResult, JsString, Module, module::ModuleLoader}; use url::Url; use crate::module::builtin::{BUILTIN_MODULE_PREFIX, BuiltinModuleLoader}; pub struct CombineModuleLoader { simple: Rc, http: Rc, builtin: Rc, } impl CombineModuleLoader { pub fn new( simple: boa_engine::module::SimpleModuleLoader, http: super::http::HttpModuleLoader, ) -> Self { Self { simple: Rc::new(simple), http: Rc::new(http), builtin: Rc::new(BuiltinModuleLoader), } } pub fn clone_simple(&self) -> Rc { self.simple.clone() } pub fn clone_http(&self) -> Rc { self.http.clone() } } impl ModuleLoader for CombineModuleLoader { async fn load_imported_module( self: Rc, referrer: boa_engine::module::Referrer, specifier: JsString, context: &RefCell<&mut Context>, ) -> JsResult { let specifier_str = specifier.to_std_string_escaped(); match Url::parse(&specifier_str) { Ok(url) if url.scheme() == "http" || url.scheme() == "https" => { self.http .clone() .load_imported_module(referrer, specifier, context) .await } _ => { if specifier_str.starts_with(BUILTIN_MODULE_PREFIX) { self.builtin .clone() .load_imported_module(referrer, specifier, context) .await } else { self.simple .clone() .load_imported_module(referrer, specifier, context) .await } } } } } ================================================ FILE: backend/boa_utils/src/module/http.rs ================================================ use std::{ cell::RefCell, path::PathBuf, rc::Rc, str::FromStr, time::{Duration, SystemTime}, }; use async_fs::create_dir_all; use boa_engine::{Context, JsNativeError, JsResult, JsString, Module, module::ModuleLoader}; use boa_parser::Source; use mime::Mime; // Tokio sync is not runtime related use tokio::sync::oneshot::channel as oneshot_channel; use url::Url; // Most of the boilerplate is taken from the `futures.rs` example. // This file only explains what is exclusive of async module loading. #[derive(Debug, Default)] pub struct HttpModuleLoader { cache_dir: PathBuf, max_age: Duration, } #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] pub struct CachedItem { pub mime: String, /// raw string content /// We have no plan for now to support binary content, /// so we just use `String` to store the content. pub content: String, } impl HttpModuleLoader { pub fn new(cache_dir: PathBuf, max_age: Duration) -> Self { Self { cache_dir, max_age } } fn mapping_cache_dir(&self, url: &url::Url) -> PathBuf { let mut buf = self.cache_dir.clone(); let host = match url.host() { Some(host) => host.to_string().replace('.', "--"), None => "unknown".to_string(), }; let port = match url.port() { Some(port) => format!("__{port}"), None => "".to_string(), }; buf.push(format!("{}_{}{}", url.scheme(), host, port)); buf.push(url.path().replace('/', "_").replace(".", "--")); buf } #[tracing::instrument(skip(context))] fn handle_cached_item(item: CachedItem, context: &mut Context) -> JsResult { let mime = Mime::from_str(item.mime.as_str()).map_err(|_| { log::error!("failed to parse mime type `{}`", item.mime); JsNativeError::typ().with_message("failed to parse mime type") })?; let source_str = match (mime.type_(), mime.subtype()) { (mime::APPLICATION, mime::JAVASCRIPT) => item.content.clone(), (mime::APPLICATION, mime::JSON) => { format!("export default {};", item.content) } _ => { let escaped_str = serde_json::to_string(&item.content).map_err(|_| { log::error!("failed to serialize content."); JsNativeError::typ().with_message("failed to serialize content") })?; format!("export const text = {escaped_str};") } }; // Could also add a path if needed. let source = Source::from_bytes(source_str.as_bytes()); Module::parse(source, None, context) } } impl ModuleLoader for HttpModuleLoader { async fn load_imported_module( self: Rc, _referrer: boa_engine::module::Referrer, specifier: JsString, context: &RefCell<&mut Context>, ) -> JsResult { let url = specifier.to_std_string_escaped(); let url = Url::from_str(&url).expect("invalid url"); // SAFETY: `url` is a valid URL, if it's not, its caller side issue let cache_path = self.mapping_cache_dir(&url); let parent_dir = cache_path .parent() .ok_or_else(|| { log::error!("failed to get parent directory for `{url}`"); JsNativeError::typ().with_message(format!( "failed to get cache parent directory for `{url}`; path: `{}`", cache_path.display() )) })? .to_path_buf(); let max_age = self.max_age; log::debug!("checking cache for `{url}`..."); let now = SystemTime::now(); let should_use_cached_content = match async_fs::metadata(&cache_path).await { Ok(metadata) if metadata .modified() .is_ok_and(|modified| modified > now - max_age) => { true } Err(err) => { // create dir if not exists if err.kind() == std::io::ErrorKind::NotFound && let Err(e) = async_fs::metadata(&parent_dir).await && e.kind() == std::io::ErrorKind::NotFound && let Err(err) = create_dir_all(parent_dir).await { log::error!( "failed to create cache directory for `{url}`; path: `{}`. error: `{}`", cache_path.display(), err ); } false } _ => false, }; let item: anyhow::Result = if should_use_cached_content { async { log::debug!("fetching `{url}` from cache..."); let item = async_fs::read(&cache_path).await?; let item = postcard::from_bytes(&item)?; log::debug!("finished fetching `{url}` from cache"); Ok(item) } .await } else { log::debug!("fetching `{url}`..."); let (tx, rx) = oneshot_channel(); let fetcher_url = url.clone(); nyanpasu_utils::runtime::spawn(async move { let result = async { let response = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::limited(5)) .build()? .get(fetcher_url.as_str()) .send() .await?; let mime = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .map(|v| v.to_string()) .unwrap_or(mime::TEXT_PLAIN.to_string()); let body = response.text().await?; log::debug!("finished fetching `{fetcher_url}`"); Ok(CachedItem { mime, content: body, }) } .await; let _ = tx.send(result); }); rx.await.expect("should never drop oneshot tx") }; if let Ok(item) = &item { match postcard::to_stdvec(&item) { Ok(item) => { if let Err(err) = async_fs::write(&cache_path, &item).await { log::error!( "failed to write cache for `{url}`; path: `{}`. error: `{}`", cache_path.display(), err ); } } Err(err) => { log::error!("failed to serialize content: {err}"); } } } let item = item.map_err(|err| JsNativeError::typ().with_message(err.to_string()))?; Self::handle_cached_item(item, &mut context.borrow_mut()) } } #[test] fn test_http_module_loader() -> JsResult<()> { use boa_engine::{builtins::promise::PromiseState, job::SimpleJobExecutor, js_string}; use std::rc::Rc; let temp_dir = tempfile::tempdir().unwrap(); // A simple snippet that imports modules from the web instead of the file system. const SRC: &str = r#" import YAML from 'https://esm.run/yaml@2.3.4'; import fromAsync from 'https://esm.run/array-from-async@3.0.0'; import { Base64 } from 'https://esm.run/js-base64@3.7.6'; // Test toolkit import { isEqual } from 'https://esm.run/es-toolkit@1.39.10'; import { text } from 'https://github.com/libnyanpasu/clash-nyanpasu/raw/refs/heads/main/pnpm-workspace.yaml'; if (isEqual(1, 2)) { throw new Error('Wrong isEqual implementation'); } const data = ` object: array: ["hello", "world"] key: "value" `; const object = YAML.parse(data).object; let result = await fromAsync([ Promise.resolve(Base64.encode(object.array[0])), Promise.resolve(Base64.encode(object.array[1])), ]); const parsed = YAML.parse(text); result.push(JSON.stringify(parsed)); export default result; "#; let queue = Rc::new(SimpleJobExecutor::new()); let mut context = Context::builder() .job_executor(queue) // NEW: sets the context module loader to our custom loader .module_loader(Rc::new(HttpModuleLoader::new( temp_dir.path().to_path_buf(), Duration::from_secs(10), ))) .build()?; let module = Module::parse(Source::from_bytes(SRC.as_bytes()), None, &mut context)?; // Calling `Module::load_link_evaluate` takes care of having to define promise handlers for // `Module::load` and `Module::evaluate`. let promise = module.load_link_evaluate(&mut context); // Important to call `Context::run_jobs`, or else all the futures and promises won't be // pushed forward by the job queue. let _ = context.run_jobs(); match promise.state() { // Our job queue guarantees that all promises and futures are finished after returning // from `Context::run_jobs`. // Some other job queue designs only execute a "microtick" or a single pass through the // pending promises and futures. In that case, you can pass this logic as a promise handler // for `promise` instead. PromiseState::Pending => panic!("module didn't execute!"), // All modules after successfully evaluating return `JsValue::undefined()`. PromiseState::Fulfilled(v) => { assert_eq!(v, boa_engine::JsValue::undefined()) } PromiseState::Rejected(err) => { panic!("{}", err.display()); } } let default = module .namespace(&mut context) .get(js_string!("default"), &mut context)?; // `default` should contain the result of our calculations. let default = default .as_object() .ok_or_else(|| JsNativeError::typ().with_message("default export was not an object"))?; assert_eq!( default .get(0, &mut context)? .as_string() .ok_or_else(|| JsNativeError::typ().with_message("array element was not a string"))?, js_string!("aGVsbG8=") ); assert_eq!( default .get(1, &mut context)? .as_string() .ok_or_else(|| JsNativeError::typ().with_message("array element was not a string"))?, js_string!("d29ybGQ=") ); assert!( default .get(2, &mut context)? .as_string() .ok_or_else(|| JsNativeError::typ().with_message("array element was not a string"))? .to_std_string_escaped() .contains("packages"), "YAML content should contain 'packages' field" ); Ok(()) } ================================================ FILE: backend/boa_utils/src/module/mod.rs ================================================ #![allow(dead_code)] pub mod builtin; pub mod combine; pub mod http; ================================================ FILE: backend/nyanpasu-egui/.gitignore ================================================ /target ================================================ FILE: backend/nyanpasu-egui/Cargo.toml ================================================ [package] name = "nyanpasu-egui" version = "0.1.0" edition = "2024" [lib] name = "nyanpasu_egui" crate-type = ["staticlib", "cdylib", "rlib"] [[bin]] name = "nyanpasu-network-statistic-widget-large" path = "./src/main.rs" [[bin]] name = "nyanpasu-network-statistic-widget-small" path = "./src/small.rs" [dependencies] eframe = { version = "0.33.0" } egui_extras = { version = "0.33.0", features = ["all_loaders"] } parking_lot = "0.12" image = { version = "0.25.6", features = ["jpeg", "png"] } humansize = "2" # for svg currentColor replacement resvg = "0.45.1" # for svg rendering usvg = "0.45.1" # for svg parsing csscolorparser = "0.8" # for color conversion ipc-channel = "0.20" # for IPC between the Widget process and the GUI process serde = { version = "1", features = ["derive"] } anyhow = "1" specta = { version = "=2.0.0-rc.22", features = ["serde"] } clap = { version = "4", features = ["derive"] } [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.1" objc2-foundation = "0.3.1" objc2-app-kit = "0.3.1" ================================================ FILE: backend/nyanpasu-egui/src/ipc.rs ================================================ pub use ipc_channel::ipc::IpcSender; use ipc_channel::ipc::{self, IpcReceiver}; use crate::widget::network_statistic_large::LogoPreset; #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] pub struct StatisticMessage { pub download_total: u64, pub upload_total: u64, pub download_speed: u64, pub upload_speed: u64, } #[derive(Debug, serde::Deserialize, serde::Serialize)] pub enum Message { Stop, UpdateStatistic(StatisticMessage), UpdateLogo(LogoPreset), } pub struct IPCServer { oneshot_server: Option>>, tx: Option>, } impl IPCServer { pub fn is_connected(&self) -> bool { self.tx.is_some() } pub fn connect(&mut self) -> anyhow::Result<()> { if self.oneshot_server.is_none() { anyhow::bail!("IPC server is already initialized"); } let (_, tx) = self.oneshot_server.take().unwrap().accept()?; self.tx = Some(tx); Ok(()) } pub fn into_tx(self) -> Option> { self.tx } } pub fn create_ipc_server() -> anyhow::Result<(IPCServer, String)> { let (oneshot_server, oneshot_server_name) = ipc::IpcOneShotServer::new()?; Ok(( IPCServer { oneshot_server: Some(oneshot_server), tx: None, }, oneshot_server_name, )) } pub(crate) fn setup_ipc_receiver(name: &str) -> anyhow::Result> { let oneshot_sender: IpcSender> = ipc::IpcSender::connect(name.to_string())?; let (tx, rx) = ipc::channel()?; oneshot_sender.send(tx)?; Ok(rx) } pub(crate) fn setup_ipc_receiver_with_env() -> anyhow::Result> { let name = std::env::var("NYANPASU_EGUI_IPC_SERVER")?; setup_ipc_receiver(&name) } ================================================ FILE: backend/nyanpasu-egui/src/lib.rs ================================================ #![feature(trait_alias)] pub mod ipc; mod utils; pub mod widget; ================================================ FILE: backend/nyanpasu-egui/src/main.rs ================================================ #![allow(dead_code)] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release #![allow(rustdoc::missing_crate_level_docs)] // it's an example use eframe::egui; use nyanpasu_egui::widget::NyanpasuNetworkStatisticLargeWidget; fn main() -> eframe::Result { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([206.0, 60.0]) .with_decorations(false) .with_transparent(true) .with_always_on_top() .with_drag_and_drop(true) .with_resizable(false) .with_taskbar(false), ..Default::default() }; eframe::run_native( "egui example: custom style", options, Box::new(|cc| Ok(Box::new(NyanpasuNetworkStatisticLargeWidget::new(cc)))), ) } ================================================ FILE: backend/nyanpasu-egui/src/small.rs ================================================ #![allow(dead_code)] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release #![allow(rustdoc::missing_crate_level_docs)] // it's an example use eframe::egui; use nyanpasu_egui::widget::NyanpasuNetworkStatisticSmallWidget; fn main() -> eframe::Result { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([80.0, 32.0]) .with_decorations(false) .with_transparent(true) .with_always_on_top() .with_drag_and_drop(true) .with_resizable(false) .with_taskbar(false), ..Default::default() }; eframe::run_native( "egui example: custom style", options, Box::new(|cc| Ok(Box::new(NyanpasuNetworkStatisticSmallWidget::new(cc)))), ) } ================================================ FILE: backend/nyanpasu-egui/src/utils/mod.rs ================================================ pub mod svg; ================================================ FILE: backend/nyanpasu-egui/src/utils/svg.rs ================================================ use csscolorparser::Color as CssColor; use eframe::egui::ColorImage; use resvg::tiny_skia::Pixmap; use usvg::{Error, Options, Transform, Tree}; // TODO: change hard coded replacement when https://github.com/RazrFalcon/resvg/issues/768 got resolved pub fn parse_svg_with_current_color_replace>( svg: &str, color: T, ) -> Result { let color: CssColor = color.into(); let svg = svg.replace(r#""currentColor""#, &format!(r#""{}""#, color.to_css_hex())); Tree::from_str(svg.as_str(), &Options::default()) } pub fn render_svg(tree: &Tree, width: u32, height: u32) -> Result { let mut pixmap = Pixmap::new(width, height).unwrap(); let original_width = tree.size().width(); let original_height = tree.size().height(); let scale_x = width as f32 / original_width; let scale_y = height as f32 / original_height; let transform = Transform::from_scale(scale_x, scale_y); resvg::render(tree, transform, &mut pixmap.as_mut()); Ok(pixmap) } pub fn render_svg_with_current_color_replace>( svg: &str, color: T, width: u32, height: u32, ) -> Result { let tree = parse_svg_with_current_color_replace(svg, color)?; render_svg(&tree, width, height) } pub struct SvgWrapper<'a>(pub &'a Pixmap); impl<'a> From<&'a Pixmap> for SvgWrapper<'a> { fn from(pixmap: &'a Pixmap) -> Self { SvgWrapper(pixmap) } } #[allow(clippy::wrong_self_convention)] pub trait SvgExt { fn into_wrapper(&self) -> SvgWrapper<'_>; } impl SvgExt for Pixmap { fn into_wrapper(&self) -> SvgWrapper<'_> { SvgWrapper(self) } } impl SvgWrapper<'_> { pub fn into_egui_image(self) -> eframe::egui::ColorImage { let (width, height) = (self.0.width(), self.0.height()); let pixels = self.0.pixels(); let mut image_data = Vec::with_capacity(width as usize * height as usize * 4); for pixel in pixels { image_data.push(pixel.red()); image_data.push(pixel.green()); image_data.push(pixel.blue()); image_data.push(pixel.alpha()); } ColorImage::from_rgba_unmultiplied([width as usize, height as usize], &image_data) } } ================================================ FILE: backend/nyanpasu-egui/src/widget/mod.rs ================================================ pub mod network_statistic_large; pub mod network_statistic_small; use std::path::PathBuf; pub use network_statistic_large::NyanpasuNetworkStatisticLargeWidget; pub use network_statistic_small::NyanpasuNetworkStatisticSmallWidget; fn get_window_state_path() -> std::io::Result { let env = std::env::var("NYANPASU_EGUI_WINDOW_STATE_PATH").map_err(|_| { std::io::Error::new( std::io::ErrorKind::NotFound, "NYANPASU_EGUI_WINDOW_STATE_PATH is not set", ) })?; let path = PathBuf::from(env); Ok(path) } #[cfg(target_os = "macos")] // TODO: move this to nyanpasu-utils fn set_application_activation_policy() { use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy}; use objc2_foundation::MainThreadMarker; use std::cell::Cell; thread_local! { static MARK: Cell = Cell::new(MainThreadMarker::new().unwrap()); } let app = NSApplication::sharedApplication(MARK.get()); app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); unsafe { app.activate(); } } // pub fn launch_widget<'app, T: Send + Sync + Sized, A: EframeAppCreator<'app, T>>( // name: &str, // opts: eframe::NativeOptions, // creator: A, // ) -> std::io::Result>> { // let (tx, rx) = mpsc::channel(); // } #[derive( Debug, serde::Serialize, serde::Deserialize, specta::Type, Copy, Clone, PartialEq, Eq, clap::ValueEnum, )] #[serde(rename_all = "snake_case")] pub enum StatisticWidgetVariant { Large, Small, } impl std::fmt::Display for StatisticWidgetVariant { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { StatisticWidgetVariant::Large => write!(f, "large"), StatisticWidgetVariant::Small => write!(f, "small"), } } } pub fn start_statistic_widget(size: StatisticWidgetVariant) -> eframe::Result { match size { StatisticWidgetVariant::Large => NyanpasuNetworkStatisticLargeWidget::run(), StatisticWidgetVariant::Small => NyanpasuNetworkStatisticSmallWidget::run(), } } ================================================ FILE: backend/nyanpasu-egui/src/widget/network_statistic_large.rs ================================================ #![allow(dead_code)] use std::sync::{Arc, LazyLock}; use crate::{ ipc::Message, utils::svg::{SvgExt, render_svg_with_current_color_replace}, }; use eframe::{ egui::{ self, Color32, CornerRadius, Id, Image, Label, Layout, Margin, Sense, Stroke, Style, TextWrapMode, TextureOptions, Theme, ThemePreference, Vec2, ViewportCommand, Visuals, style::Selection, }, epaint::CornerRadiusF32, }; use parking_lot::RwLock; // Presets const STATUS_ICON_CONTAINER_WIDTH: f32 = 20.0; const STATUS_ICON_WIDTH: f32 = 12.0; const LOGO_CONTAINER_WIDTH: f32 = 44.0; const LOGO_SIZE: Vec2 = Vec2::new(26.0, 31.0); // Themes const GLOBAL_ALPHA: u8 = 128; const LIGHT_MODE_BACKGROUND_COLOR: Color32 = Color32::from_rgba_premultiplied(254, 247, 255, GLOBAL_ALPHA); const DARK_MODE_TEXT_COLOR: Color32 = Color32::from_rgb(254, 247, 255); const DARK_MODE_BACKGROUND_COLOR: Color32 = Color32::from_rgba_premultiplied(29, 27, 32, GLOBAL_ALPHA); const DARK_MODE_STATUS_SHEET_COLOR: Color32 = Color32::from_rgba_premultiplied(73, 69, 79, GLOBAL_ALPHA); const STATUS_ICON_CONTAINER_COLOR: Color32 = Color32::from_rgb(79, 55, 139); static LOGO_CONTAINER_COLOR: LazyLock = LazyLock::new(|| Color32::from_rgba_unmultiplied(79, 55, 139, GLOBAL_ALPHA)); // Icons const DOWNLOAD_ICON: &[u8] = include_bytes!("../../assets/download.svg"); const UPLOAD_ICON: &[u8] = include_bytes!("../../assets/upload.svg"); const UP_ICON: &[u8] = include_bytes!("../../assets/up.svg"); const DOWN_ICON: &[u8] = include_bytes!("../../assets/down.svg"); fn setup_custom_style(ctx: &egui::Context) { ctx.style_mut_of(Theme::Light, use_light_green_accent); ctx.style_mut_of(Theme::Dark, use_dark_purple_accent); ctx.options_mut(|opts| { // set theme preference to dark, avoid system theme opts.theme_preference = ThemePreference::Dark; }); } fn setup_fonts(ctx: &egui::Context) { let mut fonts = egui::FontDefinitions::default(); fonts.font_data.insert( "Inter".to_owned(), Arc::new(egui::FontData::from_static(include_bytes!( "../../assets/Inter-Regular.ttf" ))), ); fonts .families .entry(egui::FontFamily::Proportional) .or_default() .insert(0, "Inter".to_owned()); ctx.set_fonts(fonts); } fn use_global_styles(styles: &mut Style) { for (text_style, font_id) in styles.text_styles.iter_mut() { if matches!(text_style, egui::TextStyle::Body) { font_id.size = 10.0; } } styles.spacing.window_margin = Margin::same(0); styles.spacing.item_spacing = Vec2::new(0.0, 0.0); styles.interaction.selectable_labels = false; // disable text selection } fn use_light_green_accent(style: &mut Style) { use_global_styles(style); style.visuals.override_text_color = Some(DARK_MODE_TEXT_COLOR); style.visuals.hyperlink_color = Color32::from_rgb(18, 180, 85); style.visuals.text_cursor.stroke.color = Color32::from_rgb(28, 92, 48); style.visuals.selection = Selection { bg_fill: Color32::from_rgb(157, 218, 169), stroke: Stroke::new(1.0, Color32::from_rgb(28, 92, 48)), }; } fn use_dark_purple_accent(style: &mut Style) { use_global_styles(style); style.visuals.override_text_color = Some(DARK_MODE_TEXT_COLOR); style.visuals.hyperlink_color = Color32::from_rgb(202, 135, 227); style.visuals.text_cursor.stroke.color = Color32::from_rgb(234, 208, 244); style.visuals.selection = Selection { bg_fill: Color32::from_rgb(105, 67, 119), stroke: Stroke::new(1.0, Color32::from_rgb(234, 208, 244)), }; } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "snake_case")] pub enum LogoPreset { #[default] Default, System, Tun, } #[derive(Debug)] pub struct NyanpasuNetworkStatisticLargeWidgetInner { // data fields logo_preset: LogoPreset, download_total: u64, upload_total: u64, download_speed: u64, upload_speed: u64, // eframe ctx egui_ctx: egui::Context, } impl NyanpasuNetworkStatisticLargeWidgetInner { fn request_repaint(&self) { self.egui_ctx.request_repaint(); } } #[derive(Debug, Clone)] pub struct NyanpasuNetworkStatisticLargeWidget { inner: Arc>, } impl NyanpasuNetworkStatisticLargeWidget { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { cc.egui_ctx.set_visuals(Visuals::dark()); setup_fonts(&cc.egui_ctx); setup_custom_style(&cc.egui_ctx); egui_extras::install_image_loaders(&cc.egui_ctx); let rx = crate::ipc::setup_ipc_receiver_with_env().unwrap(); let widget = NyanpasuNetworkStatisticLargeWidget { inner: Arc::new(RwLock::new(NyanpasuNetworkStatisticLargeWidgetInner { egui_ctx: cc.egui_ctx.clone(), logo_preset: LogoPreset::default(), download_total: 0, upload_total: 0, download_speed: 0, upload_speed: 0, })), }; let this = widget.clone(); std::thread::spawn(move || { loop { match rx.recv() { Ok(msg) => { println!("Received message: {msg:?}"); let _ = this.handle_message(msg); } Err(e) => { eprintln!("Failed to receive message: {e}"); if matches!( e, ipc_channel::ipc::IpcError::Disconnected | ipc_channel::ipc::IpcError::Io(_) ) { let _ = this.handle_message(Message::Stop); break; } } } } }); widget } pub fn run() -> eframe::Result { #[cfg(target_os = "macos")] super::set_application_activation_policy(); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([206.0, 60.0]) .with_decorations(false) .with_transparent(true) .with_always_on_top() .with_drag_and_drop(true) .with_resizable(false) .with_taskbar(false), run_and_return: false, // persist_window: true, // persistence_path: get_window_state_path().ok(), ..Default::default() }; println!("Running widget..."); eframe::run_native( "Nyanpasu Network Statistic Widget", options, Box::new(|cc| Ok(Box::new(NyanpasuNetworkStatisticLargeWidget::new(cc)))), ) } pub fn handle_message(&self, msg: Message) -> anyhow::Result<()> { let mut this = self.inner.write(); match msg { Message::UpdateStatistic(statistic) => { this.download_total = statistic.download_total; this.upload_total = statistic.upload_total; this.download_speed = statistic.download_speed; this.upload_speed = statistic.upload_speed; this.request_repaint(); } Message::UpdateLogo(logo_preset) => { this.logo_preset = logo_preset; this.request_repaint(); } Message::Stop => { std::thread::spawn(move || { // wait for 5 seconds to ensure the widget is closed, or the app will be terminated std::thread::sleep(std::time::Duration::from_secs(5)); std::process::exit(0); }); this.egui_ctx.send_viewport_cmd(ViewportCommand::Close); } } Ok(()) } } impl eframe::App for NyanpasuNetworkStatisticLargeWidget { fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { egui::Rgba::TRANSPARENT.to_array() } fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let this = self.inner.read(); let visuals = &ctx.style().visuals; egui::CentralPanel::default() .frame( egui::Frame::NONE .corner_radius(CornerRadius::same(12)) .fill(if visuals.dark_mode { DARK_MODE_BACKGROUND_COLOR } else { LIGHT_MODE_BACKGROUND_COLOR }) .inner_margin(Margin::symmetric(9, 6)), ) .show(ctx, |ui| { if ui.interact(ui.max_rect(), Id::new("window-drag"), Sense::drag()).dragged() { ctx.send_viewport_cmd(ViewportCommand::StartDrag); } let available_height = ui.available_height(); ui.horizontal_centered(|ui| { let width = ui.available_width(); // LOGO Column ui.allocate_ui_with_layout( egui::Vec2::new(LOGO_CONTAINER_WIDTH, LOGO_CONTAINER_WIDTH), egui::Layout::centered_and_justified(egui::Direction::TopDown), |ui| { egui::Frame::NONE.fill(*LOGO_CONTAINER_COLOR).corner_radius(CornerRadiusF32::same(LOGO_CONTAINER_WIDTH / 2.0)).show(ui, |ui| { ui.centered_and_justified(|ui| { ui.add(Image::new(eframe::egui::include_image!("../../assets/tray-icon.png")).max_size(LOGO_SIZE)); }); }); }, ); let grid_gap = 7.0; ui.add_space(grid_gap); // gap let col_width = (width - LOGO_CONTAINER_WIDTH - grid_gap * 2.0) / 2.0; let row_height = STATUS_ICON_CONTAINER_WIDTH; let vertical_padding = LOGO_CONTAINER_WIDTH - row_height * 2.0; let top_gap = (available_height - (row_height * 2.0 + vertical_padding)) / 2.0; // Download Column ui.allocate_ui_with_layout(egui::Vec2::new(col_width, available_height), egui::Layout::top_down(egui::Align::LEFT), |ui| { ui.add_space(top_gap); // Download Total ui.allocate_ui_with_layout(egui::Vec2::new(col_width, row_height), Layout::left_to_right(egui::Align::Center), |ui| { egui::Frame::NONE.corner_radius(CornerRadius::same(14)).fill(DARK_MODE_STATUS_SHEET_COLOR).show(ui, |ui| { ui.allocate_ui(egui::Vec2::new(STATUS_ICON_CONTAINER_WIDTH, STATUS_ICON_CONTAINER_WIDTH), |ui| { egui::Frame::NONE .fill(STATUS_ICON_CONTAINER_COLOR) .corner_radius(CornerRadius::same(STATUS_ICON_WIDTH as u8)) .show(ui, |ui| { let image = render_svg_with_current_color_replace( unsafe { String::from_utf8_unchecked(DOWNLOAD_ICON.to_vec()) }.as_str(), csscolorparser::parse(&DARK_MODE_TEXT_COLOR.to_hex()).unwrap(), (STATUS_ICON_WIDTH).round() as u32, (STATUS_ICON_WIDTH).round() as u32, ) .unwrap() .into_wrapper() .into_egui_image(); let texture_handle = ui.ctx().load_texture("download_icon", image, TextureOptions::default()); ui.centered_and_justified(|ui| { ui.add(Image::from_texture(&texture_handle)); }); }); }); let width = ui.available_width(); let height = ui.available_height(); ui.allocate_ui_with_layout(egui::Vec2::new(width, height), Layout::centered_and_justified(egui::Direction::TopDown), |ui| { ui.add( Label::new( humansize::format_size(this.download_total, humansize::DECIMAL)) .wrap_mode(TextWrapMode::Extend) ); }); }); }); ui.add_space(vertical_padding); // gap // Download Speed ui.allocate_ui_with_layout(egui::Vec2::new(col_width, row_height), Layout::left_to_right(egui::Align::Center), |ui| { egui::Frame::NONE.corner_radius(CornerRadius::same(14)).fill(DARK_MODE_STATUS_SHEET_COLOR).show(ui, |ui| { ui.allocate_ui(egui::Vec2::new(STATUS_ICON_CONTAINER_WIDTH, STATUS_ICON_CONTAINER_WIDTH), |ui| { egui::Frame::NONE .fill(STATUS_ICON_CONTAINER_COLOR) .corner_radius(CornerRadius::same(STATUS_ICON_WIDTH as u8)) .show(ui, |ui| { let image = render_svg_with_current_color_replace( unsafe { String::from_utf8_unchecked(DOWN_ICON.to_vec()) }.as_str(), csscolorparser::parse(&DARK_MODE_TEXT_COLOR.to_hex()).unwrap(), (STATUS_ICON_WIDTH).round() as u32, (STATUS_ICON_WIDTH).round() as u32, ) .unwrap() .into_wrapper() .into_egui_image(); let texture_handle = ui.ctx().load_texture("down_icon", image, TextureOptions::default()); ui.centered_and_justified(|ui| { ui.add(Image::from_texture(&texture_handle)); }); }); }); let width = ui.available_width(); let height = ui.available_height(); ui.allocate_ui_with_layout(egui::Vec2::new(width, height), Layout::centered_and_justified(egui::Direction::TopDown), |ui| { ui.add(Label::new(humansize::format_size(this.download_speed, humansize::DECIMAL.suffix("/s"))).wrap_mode(TextWrapMode::Extend)); }); }); }) }); ui.add_space(grid_gap); // gap // Upload Column ui.allocate_ui_with_layout(egui::Vec2::new(col_width, available_height), egui::Layout::top_down(egui::Align::LEFT), |ui| { ui.add_space(top_gap); // Upload Total ui.allocate_ui_with_layout(egui::Vec2::new(col_width, row_height), Layout::left_to_right(egui::Align::Center), |ui| { egui::Frame::NONE.corner_radius(CornerRadius::same(14)).fill(DARK_MODE_STATUS_SHEET_COLOR).show(ui, |ui| { ui.allocate_ui(egui::Vec2::new(STATUS_ICON_CONTAINER_WIDTH, STATUS_ICON_CONTAINER_WIDTH), |ui| { egui::Frame::NONE .fill(STATUS_ICON_CONTAINER_COLOR) .corner_radius(CornerRadius::same(STATUS_ICON_WIDTH as u8)) .show(ui, |ui| { let image = render_svg_with_current_color_replace( unsafe { String::from_utf8_unchecked(UPLOAD_ICON.to_vec()) }.as_str(), csscolorparser::parse(&DARK_MODE_TEXT_COLOR.to_hex()).unwrap(), (STATUS_ICON_WIDTH).round() as u32, (STATUS_ICON_WIDTH).round() as u32, ) .unwrap() .into_wrapper() .into_egui_image(); let texture_handle = ui.ctx().load_texture("upload_icon", image, TextureOptions::default()); ui.centered_and_justified(|ui| { ui.add(Image::from_texture(&texture_handle)); }); }); }); let width = ui.available_width(); let height = ui.available_height(); ui.allocate_ui_with_layout(egui::Vec2::new(width, height), Layout::centered_and_justified(egui::Direction::TopDown), |ui| { ui.add(Label::new(humansize::format_size(this.upload_total, humansize::DECIMAL)).wrap_mode(TextWrapMode::Extend)); }); }); }); ui.add_space(vertical_padding); // gap // Upload Speed ui.allocate_ui_with_layout(egui::Vec2::new(col_width, row_height), Layout::left_to_right(egui::Align::Center), |ui| { egui::Frame::NONE.corner_radius(CornerRadius::same(14)).fill(DARK_MODE_STATUS_SHEET_COLOR).show(ui, |ui| { ui.allocate_ui(egui::Vec2::new(STATUS_ICON_CONTAINER_WIDTH, STATUS_ICON_CONTAINER_WIDTH), |ui| { egui::Frame::NONE .fill(STATUS_ICON_CONTAINER_COLOR) .corner_radius(CornerRadius::same(STATUS_ICON_WIDTH as u8)) .show(ui, |ui| { let image = render_svg_with_current_color_replace( unsafe { String::from_utf8_unchecked(UP_ICON.to_vec()) }.as_str(), csscolorparser::parse(&DARK_MODE_TEXT_COLOR.to_hex()).unwrap(), (STATUS_ICON_WIDTH).round() as u32, (STATUS_ICON_WIDTH).round() as u32, ) .unwrap() .into_wrapper() .into_egui_image(); let texture_handle = ui.ctx().load_texture("up_icon", image, TextureOptions::default()); ui.centered_and_justified(|ui| { ui.add(Image::from_texture(&texture_handle)); }); }); }); let width = ui.available_width(); let height = ui.available_height(); ui.allocate_ui_with_layout(egui::Vec2::new(width, height), Layout::centered_and_justified(egui::Direction::TopDown), |ui| { ui.add(Label::new(humansize::format_size(this.upload_speed, humansize::DECIMAL.suffix("/s"))).wrap_mode(TextWrapMode::Extend)); }); }); }) }); }); }); } } ================================================ FILE: backend/nyanpasu-egui/src/widget/network_statistic_small.rs ================================================ #![allow(dead_code)] use std::sync::{Arc, LazyLock}; use eframe::egui::{ self, Color32, CornerRadius, Id, Image, Label, Layout, Margin, RichText, Sense, Stroke, Style, TextWrapMode, Theme, Vec2, ViewportCommand, WidgetText, include_image, style::Selection, }; use parking_lot::RwLock; use crate::ipc::Message; // Presets const STATUS_ICON_CONTAINER_WIDTH: f32 = 20.0; const LOGO_CONTAINER_WIDTH: f32 = 44.0; const LOGO_SIZE: Vec2 = Vec2::new(26.0, 31.0); // Themes const GLOBAL_ALPHA: u8 = 128; const LIGHT_MODE_BACKGROUND_COLOR: Color32 = Color32::from_rgb(234, 221, 255); const LIGHT_MODE_TEXT_COLOR: Color32 = Color32::from_rgb(29, 27, 32); const DARK_MODE_TEXT_COLOR: Color32 = Color32::from_rgb(254, 247, 255); const DARK_MODE_BACKGROUND_COLOR: Color32 = Color32::from_rgb(29, 27, 32); const DARK_MODE_STATUS_SHEET_COLOR: Color32 = Color32::from_rgb(73, 69, 79); const STATUS_ICON_CONTAINER_COLOR: Color32 = Color32::from_rgb(79, 55, 139); static LOGO_CONTAINER_COLOR: LazyLock = LazyLock::new(|| Color32::from_rgba_unmultiplied(79, 55, 139, GLOBAL_ALPHA)); // Icons const UP_ICON: &[u8] = include_bytes!("../../assets/up.svg"); const DOWN_ICON: &[u8] = include_bytes!("../../assets/down.svg"); fn setup_custom_style(ctx: &egui::Context) { ctx.style_mut_of(Theme::Light, use_light_green_accent); ctx.style_mut_of(Theme::Dark, use_dark_purple_accent); } fn setup_fonts(ctx: &egui::Context) { let mut fonts = egui::FontDefinitions::default(); fonts.font_data.insert( "Inter".to_owned(), Arc::new(egui::FontData::from_static(include_bytes!( "../../assets/Inter-Regular.ttf" ))), ); fonts .families .entry(egui::FontFamily::Proportional) .or_default() .insert(0, "Inter".to_owned()); ctx.set_fonts(fonts); } fn use_global_styles(styles: &mut Style) { styles.spacing.window_margin = Margin::same(0); styles.spacing.item_spacing = Vec2::new(0.0, 0.0); styles.interaction.selectable_labels = false; } fn use_light_green_accent(style: &mut Style) { use_global_styles(style); style.visuals.override_text_color = Some(LIGHT_MODE_TEXT_COLOR); style.visuals.hyperlink_color = Color32::from_rgb(18, 180, 85); style.visuals.text_cursor.stroke.color = Color32::from_rgb(28, 92, 48); style.visuals.selection = Selection { bg_fill: Color32::from_rgb(157, 218, 169), stroke: Stroke::new(1.0, Color32::from_rgb(28, 92, 48)), }; } fn use_dark_purple_accent(style: &mut Style) { use_global_styles(style); style.visuals.override_text_color = Some(DARK_MODE_TEXT_COLOR); style.visuals.hyperlink_color = Color32::from_rgb(202, 135, 227); style.visuals.text_cursor.stroke.color = Color32::from_rgb(234, 208, 244); style.visuals.selection = Selection { bg_fill: Color32::from_rgb(105, 67, 119), stroke: Stroke::new(1.0, Color32::from_rgb(234, 208, 244)), }; } #[derive(Clone)] pub struct NyanpasuNetworkStatisticSmallWidget { state: Arc>, } struct NyanpasuNetworkStatisticSmallWidgetState { // data fields // download_total: u64, // upload_total: u64, download_speed: u64, upload_speed: u64, // eframe ctx egui_ctx: egui::Context, } impl NyanpasuNetworkStatisticSmallWidgetState { fn request_repaint(&self) { self.egui_ctx.request_repaint(); } } impl NyanpasuNetworkStatisticSmallWidget { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { setup_fonts(&cc.egui_ctx); setup_custom_style(&cc.egui_ctx); egui_extras::install_image_loaders(&cc.egui_ctx); let rx = crate::ipc::setup_ipc_receiver_with_env().unwrap(); let widget = Self { state: Arc::new(RwLock::new(NyanpasuNetworkStatisticSmallWidgetState { egui_ctx: cc.egui_ctx.clone(), download_speed: 0, upload_speed: 0, })), }; let this = widget.clone(); std::thread::spawn(move || { loop { match rx.recv() { Ok(msg) => { println!("Received message: {msg:?}"); let _ = this.handle_message(msg); } Err(e) => { eprintln!("Failed to receive message: {e}"); if matches!( e, ipc_channel::ipc::IpcError::Disconnected | ipc_channel::ipc::IpcError::Io(_) ) { let _ = this.handle_message(Message::Stop); break; } } } } }); widget } pub fn run() -> eframe::Result { #[cfg(target_os = "macos")] super::set_application_activation_policy(); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([80.0, 32.0]) .with_decorations(false) .with_transparent(true) .with_always_on_top() .with_drag_and_drop(true) .with_resizable(false) .with_taskbar(false), run_and_return: false, // TODO: buggy feature, and should we manually save the window state // persist_window: true, // persistence_path: get_window_state_path().ok(), ..Default::default() }; println!("Running widget..."); eframe::run_native( "Nyanpasu Network Statistic Widget", options, Box::new(|cc| Ok(Box::new(NyanpasuNetworkStatisticSmallWidget::new(cc)))), ) } pub fn handle_message(&self, msg: Message) -> anyhow::Result<()> { let mut this = self.state.write(); match msg { Message::UpdateStatistic(statistic) => { // this.download_total = statistic.download_total; // this.upload_total = statistic.upload_total; this.download_speed = statistic.download_speed; this.upload_speed = statistic.upload_speed; this.request_repaint(); } Message::Stop => { std::thread::spawn(move || { // wait for 5 seconds to ensure the widget is closed, or the app will be terminated std::thread::sleep(std::time::Duration::from_secs(5)); std::process::exit(0); }); this.egui_ctx.send_viewport_cmd(ViewportCommand::Close); } _ => { eprintln!("Unsupported message: {msg:?}"); } } Ok(()) } } impl eframe::App for NyanpasuNetworkStatisticSmallWidget { fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { egui::Rgba::TRANSPARENT.to_array() } fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let visuals = &ctx.style().visuals; let this = self.state.read(); egui::CentralPanel::default() .frame( egui::Frame::NONE .corner_radius(CornerRadius::same(40)) .fill(if visuals.dark_mode { DARK_MODE_BACKGROUND_COLOR } else { LIGHT_MODE_BACKGROUND_COLOR }) .inner_margin(Margin::same(4)), ) .show(ctx, |ui| { if ui .interact(ui.max_rect(), Id::new("window-drag"), Sense::drag()) .dragged() { ctx.send_viewport_cmd(ViewportCommand::StartDrag); } ui.horizontal(|ui| { ui.allocate_ui(Vec2::new(24.0, 24.0), |ui| { egui::Frame::NONE .corner_radius(CornerRadius::same(12)) .fill(*LOGO_CONTAINER_COLOR) .show(ui, |ui| { ui.allocate_ui_with_layout( Vec2::new(24.0, 24.0), Layout::centered_and_justified(egui::Direction::TopDown), |ui| { ui.add( Image::new(include_image!( "../../assets/tray-icon.png" )) .max_size(Vec2::new(9.84, 13.78)), ) }, ) }); }); ui.add_space(1.0); ui.vertical(|ui| { let width = ui.available_width(); let height = ui.available_height() / 2.0; ui.allocate_ui_with_layout( Vec2::new(width, height), Layout::centered_and_justified(egui::Direction::LeftToRight), |ui| { ui.add( Label::new( RichText::new(humansize::format_size( this.upload_speed, humansize::DECIMAL.suffix("/s"), )) .size(8.0), ) .selectable(false) .wrap_mode(TextWrapMode::Extend), ); }, ); ui.allocate_ui_with_layout( Vec2::new(width, height), Layout::centered_and_justified(egui::Direction::LeftToRight), |ui| { ui.add( Label::new(WidgetText::from( RichText::new(humansize::format_size( this.download_speed, humansize::DECIMAL.suffix("/s"), )) .size(8.0), )) .selectable(false) .wrap_mode(TextWrapMode::Extend), ); }, ); }); }) }); } } ================================================ FILE: backend/nyanpasu-macro/Cargo.toml ================================================ [package] name = "nyanpasu-macro" version = "0.1.0" repository.workspace = true edition.workspace = true license.workspace = true authors.workspace = true [lib] proc-macro = true [dependencies] syn = "2" quote = "1" proc-macro2 = "1.0.86" ================================================ FILE: backend/nyanpasu-macro/src/builder_update.rs ================================================ use proc_macro2::TokenStream; use quote::{format_ident, quote}; use syn::{DeriveInput, Error, Ident, LitStr, Meta, Type, spanned::Spanned}; pub fn builder_update(input: DeriveInput) -> syn::Result { let name = format_ident!("{}", input.ident); // search #[builder_update(ty = "T")] let mut partial_ty: Option = None; // search #[builder_update(patch_fn = "fn_name")] let mut patch_fn: Option = None; // search #[builder_update(getter)] or #[builder_update(getter = "get_{}")] let mut generate_getter: Option = None; for attr in &input.attrs { if let Some(attr_meta_name) = attr.path().get_ident() && attr_meta_name == "builder_update" { let meta = &attr.meta; match meta { Meta::List(list) => { list.parse_nested_meta(|meta| { let path = &meta.path; match path { path if path.is_ident("ty") => { let value = meta.value()?; let lit_str: LitStr = value.parse()?; partial_ty = Some(lit_str.parse()?); } path if path.is_ident("patch_fn") => { let value = meta.value()?; let lit_str: LitStr = value.parse()?; patch_fn = Some(lit_str.parse()?); } path if path.is_ident("getter") => { match meta.value() { Ok(value) => { let lit_str: LitStr = value.parse()?; generate_getter = Some(lit_str.value()); } Err(_) => { // it should be default getter generate_getter = Some("get_{}".to_string()); } } } _ => { return Err( meta.error("Only #[builder_update(ty = \"T\")] is supported") ); } } Ok(()) })?; } _ => { return Err(Error::new( attr.span(), "Only #[builder_update(ty = \"T\")] is supported", )); } } } } let partial_ty = match partial_ty { Some(ty) => ty, None => format_ident!("{}Builder", name), }; let patch_fn = match patch_fn { Some(fn_name) => fn_name, None => format_ident!("update"), }; let mut patch_fields = quote! {}; let mut fields_getter = quote! {}; match input.data { syn::Data::Struct(ref data) => { if let syn::Fields::Named(ref fields) = data.fields { for field in &fields.named { let field_name = field.ident.as_ref().unwrap(); let field_type = &field.ty; let mut getter_type = wrap_type_in_option(field_type); // check whether the field has #[update(nest)] let mut nested = false; for attr in &field.attrs { if attr.path().is_ident("builder_update") && let Meta::List(ref list) = attr.meta { list.parse_nested_meta(|meta| { let path = &meta.path; match path { path if path.is_ident("nested") => { nested = true; } path if path.is_ident("getter_ty") => { let value = meta.value()?; let lit_str: LitStr = value.parse()?; getter_type = syn::parse_str(&lit_str.value())?; } _ => { return Err(meta .error("Only #[builder_update(nested)] is supported")); } } Ok(()) })?; } } patch_fields.extend(if nested { quote! { self.#field_name.#patch_fn(partial.#field_name); } } else { quote! { if let Some(value) = partial.#field_name { self.#field_name = value; } } }); if let Some(getter) = &generate_getter { let getter_name = format_ident!( "{}", getter.replace( "{}", field_name .to_string() .strip_prefix("r#") .unwrap_or(&field_name.to_string()) ) ); fields_getter.extend(quote! { pub fn #getter_name(&self) -> &#getter_type { &self.#field_name } }); } } } } _ => { return Err(Error::new(input.span(), "Only struct is supported")); } } let expanded = quote! { impl #name { pub fn #patch_fn(&mut self, partial: #partial_ty) { #patch_fields } } impl #partial_ty { #fields_getter } }; Ok(expanded) } fn wrap_type_in_option(ty: &Type) -> Type { syn::parse_quote! { Option<#ty> } } ================================================ FILE: backend/nyanpasu-macro/src/enum_wrapper_combined.rs ================================================ use proc_macro2::TokenStream; use quote::{format_ident, quote}; use syn::{DeriveInput, Fields}; pub fn enum_combined_wrapper(input: DeriveInput) -> syn::Result { let name = &input.ident; let data = &input.data; let mut expanded = quote! {}; let mut ty_assert_and_as = quote! {}; match data { syn::Data::Enum(e) => { for variant in e.variants.iter() { let variant_name = &variant.ident; match &variant.fields { Fields::Unnamed(fields) => { if fields.unnamed.len() != 1 { return Err(syn::Error::new_spanned( input, "EnumWrapperFrom only supports enums with a single field", )); } let field = fields.unnamed.first().unwrap(); let field_ty = &field.ty; expanded.extend(quote! { impl From<#field_ty> for #name { fn from(value: #field_ty) -> Self { Self::#variant_name(value) } } }); let is_ty = format_ident!("is_{}", variant_name.to_string().to_lowercase()); let as_ty = format_ident!("as_{}", variant_name.to_string().to_lowercase()); let as_mut_ty = format_ident!("as_{}_mut", variant_name.to_string().to_lowercase()); ty_assert_and_as.extend(quote! { pub fn #is_ty(&self) -> bool { matches!(self, Self::#variant_name(_)) } pub fn #as_ty(&self) -> Option<&#field_ty> { if let Self::#variant_name(value) = self { Some(value) } else { None } } pub fn #as_mut_ty(&mut self) -> Option<&mut #field_ty> { if let Self::#variant_name(value) = self { Some(value) } else { None } } }); } _ => { return Err(syn::Error::new_spanned( input, "EnumWrapperFrom only supports unnamed fields", )); } } } } _ => { return Err(syn::Error::new_spanned( input, "EnumWrapperFrom only supports enums", )); } } expanded.extend(quote! { impl #name { #ty_assert_and_as } }); Ok(expanded) } ================================================ FILE: backend/nyanpasu-macro/src/lib.rs ================================================ use proc_macro::TokenStream; use syn::{DeriveInput, parse_macro_input}; mod builder_update; mod enum_wrapper_combined; mod verge_patch; #[proc_macro_derive(BuilderUpdate, attributes(builder_update))] pub fn builder_update(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); match builder_update::builder_update(input) { Ok(token_stream) => TokenStream::from(token_stream), Err(e) => TokenStream::from(e.to_compile_error()), } } #[proc_macro_derive(VergePatch, attributes(verge))] pub fn verge_patch(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); match verge_patch::verge_patch(input) { Ok(token_stream) => TokenStream::from(token_stream), Err(e) => TokenStream::from(e.to_compile_error()), } } #[proc_macro_derive(EnumWrapperCombined)] pub fn enum_wrapper_from(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); match enum_wrapper_combined::enum_combined_wrapper(input) { Ok(token_stream) => TokenStream::from(token_stream), Err(e) => TokenStream::from(e.to_compile_error()), } } ================================================ FILE: backend/nyanpasu-macro/src/verge_patch.rs ================================================ use proc_macro2::TokenStream; use quote::{format_ident, quote}; use syn::{Data, DeriveInput, Error, Ident, LitStr, Meta, Result, spanned::Spanned}; pub fn verge_patch(input: DeriveInput) -> Result { let name = &input.ident; let mut patch_fn: Option = None; let mut patch_pointer: Option = None; // default is self let mut patch_type: Option = None; // default is Self for attr in &input.attrs { if attr.path().is_ident("verge") { match &attr.meta { Meta::List(list) => { list.parse_nested_meta(|meta| { match &meta.path { path if path.is_ident("patch_fn") => { let value = meta.value()?; let lit_str: LitStr = value.parse()?; patch_fn = Some(lit_str.parse()?); } path if path.is_ident("patch_pointer") => { let value = meta.value()?; let lit_str: LitStr = value.parse()?; patch_pointer = Some(lit_str.parse()?); } path if path.is_ident("patch_type") => { let value = meta.value()?; let lit_str: LitStr = value.parse()?; patch_type = Some(lit_str.parse()?); } _ => { return Err(meta.error("Unknown attribute")); } } Ok(()) })?; } _ => { return Err(Error::new(attr.span(), "Only #[verge(...)] is supported")); } } } } let patch_fn = match patch_fn { Some(fn_name) => fn_name, None => format_ident!("patch_{}", name), }; let patch_pointer = match patch_pointer { Some(pointer) => pointer, None => format_ident!("self"), }; let patch_type = match patch_type { Some(ty) => ty, None => format_ident!("{}", name), }; let mut patch_fields = quote! {}; match input.data { Data::Struct(ref data) => { if let syn::Fields::Named(ref fields) = data.fields { for field in &fields.named { let field_name = field.ident.as_ref().unwrap(); patch_fields.extend(quote! { if patch.#field_name.is_some() { #patch_pointer.#field_name = patch.#field_name; } }); } } } _ => { return Err(Error::new(input.span(), "Only struct is supported")); } } let expanded = quote! { impl #name { pub fn #patch_fn(&mut self, patch: #patch_type) { #patch_fields } } }; Ok(expanded) } ================================================ FILE: backend/rustfmt.toml ================================================ max_width = 100 hard_tabs = false tab_spaces = 4 newline_style = "Auto" use_small_heuristics = "Default" reorder_imports = true reorder_modules = true remove_nested_parens = true edition = "2024" merge_derives = true use_try_shorthand = false use_field_init_shorthand = false force_explicit_abi = true imports_granularity = "Crate" ================================================ FILE: backend/tauri/.gitignore ================================================ # Generated by Cargo # will have compiled files and executables /target/ WixTools resources sidecar tmp/ !/tmp/.gitkeep ================================================ FILE: backend/tauri/Cargo.toml ================================================ [package] name = "clash-nyanpasu" version = "0.1.0" description = "clash verge" authors = { workspace = true } license = { workspace = true } repository = { workspace = true } default-run = "clash-nyanpasu" edition = { workspace = true } build = "build.rs" [lib] name = "clash_nyanpasu_lib" crate-type = ["staticlib", "cdylib", "rlib"] doctest = false [build-dependencies] tauri-build = { version = "2.1", features = [] } serde = "1" serde_json = { version = "1.0", features = ["preserve_order"] } chrono = "0.4" rustc_version = "0.4" semver = "1.0" [dependencies] # Local Dependencies nyanpasu-ipc = { git = "https://github.com/libnyanpasu/nyanpasu-service.git", features = [ "client", "specta", ] } # IPC bridge between the UI process and service process nyanpasu-macro = { path = "../nyanpasu-macro" } nyanpasu-utils = { workspace = true } nyanpasu-egui = { path = "../nyanpasu-egui" } # Common Utilities tokio = { workspace = true } tokio-util = { version = "0.7", features = ["full"] } oneshot = "0.1" futures = "0.3" futures-util = "0.3" glob = "0.3.1" timeago = "0.6" humansize = "2.1.3" convert_case = "0.11.0" anyhow = "1.0" pretty_assertions = "1.4.0" chrono = { version = "0.4", features = ["serde"] } time = { version = "0.3", features = ["formatting", "parsing", "serde"] } once_cell = "1.19.0" async-trait = "0.1.77" dyn-clone = "1.0.16" thiserror = { workspace = true } parking_lot = { version = "0.12.1" } fs-err = { workspace = true } # for more detailed io error itertools = "0.14" # sweet iterator utilities rayon = "1.10" # for iterator parallel processing ambassador = "0.5.0" # for trait delegation derive_builder = "0.20" # for builder pattern strum = { version = "0.28", features = ["derive"] } # for enum string conversion atomic_enum = "0.3.0" # for atomic enum enumflags2 = "0.7" # for enum flags backon = { version = "1.0.1", features = ["tokio-sleep"] } # for backoff retry # Data Structures dashmap = "6" indexmap = { version = "2.2.3", features = ["serde"] } bimap = "0.6.3" bumpalo = "3.17.0" # a bump allocator for heap allocation rustc-hash = "2.1" # Terminal Utilities ansi-str = "0.9" # for ansi str stripped ctrlc = "3.4.2" colored = "3" clap = { version = "4.5.4", features = ["derive"] } # GUI Utilities rfd = { version = "0.15", default-features = false, features = [ "tokio", "gtk3", "common-controls-v6", ] } # cross platform dialog # Internationalization rust-i18n = "3" # Networking Libraries axum = "0.8" url = "2" mime = "0.3" reqwest = { workspace = true } tokio-tungstenite = "0.29" urlencoding = "2.1" port_scanner = "0.1.5" sysproxy = { git = "https://github.com/libnyanpasu/sysproxy-rs.git", version = "0.3" } # Serialization serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["preserve_order"] } serde_yaml = { version = "0.10", package = "serde_yaml_ng", branch = "feat/specta-update", git = "https://github.com/libnyanpasu/serde-yaml-ng.git", features = [ "specta", ] } postcard = { version = "1.1", features = ["alloc"] } bytes = { version = "1", features = ["serde"] } semver = "1.0" # Compression & Encoding flate2 = "1.0" zip = "8.0.0" zip-extensions = "0.13.0" base64 = "0.22" adler = "1.0.2" hex = "0.4" percent-encoding = "2.3.1" # Algorithms uuid = "1.7.0" rand = "0.10" md-5 = "0.10.6" sha2 = "0.10" nanoid = "0.4.0" rs-snowflake = "0.6" seahash = "4.1" # System Utilities auto-launch = { git = "https://github.com/libnyanpasu/auto-launch.git", version = "0.5" } delay_timer = { version = "0.11", git = "https://github.com/libnyanpasu/delay-timer.git" } # Task scheduler with timer dunce = "1.0.4" # for cross platform path normalization runas = { git = "https://github.com/libnyanpasu/rust-runas.git" } single-instance = "0.3.3" which = "8" open = "5.0.1" sysinfo = "0.38" num_cpus = "1" os_pipe = "1.2.1" whoami = "1.5.1" camino = { version = "1.1.9", features = ["serde1"] } # IO Utilities dirs = "6" tempfile = "3.9.0" fs_extra = "1.3.0" notify-debouncer-full = "0.7.0" notify = "8.0.0" # Database redb = "3.0.0" # Logging & Tracing log = "0.4.20" tracing = { workspace = true } tracing-attributes = "0.1" tracing-futures = "0.2" tracing-subscriber = { version = "0.3", features = [ "env-filter", "json", "parking_lot", ] } tracing-error = "0.2" tracing-log = { version = "0.2" } tracing-appender = { version = "0.2", features = ["parking_lot"] } test-log = { workspace = true } tracing-test = { workspace = true } # Image & Graphics image = "0.25.5" fast_image_resize = "6" display-info = "0.5.0" # should be removed after upgrading to tauri v2 # OXC (The Oxidation Compiler) # We use it to parse and transpile the old script profile to esm based script profile oxc_parser = "0.121" oxc_allocator = "0.121" oxc_span = "0.121" oxc_ast = "0.121" oxc_syntax = "0.121" oxc_ast_visit = "0.121" # Lua Integration mlua = { version = "0.11", features = [ "lua54", "async", "serialize", "vendored", "error-send", ] } # JavaScript Integration boa_utils = { path = "../boa_utils" } # should be removed when boa support console customize boa_engine = { workspace = true, features = ["annex-b"] } # Tauri Dependencies tauri = { version = "2.4", features = [ "tray-icon", "image-png", "image-ico", "rustls-tls", "specta", ] } tauri-plugin-deep-link = { path = "../tauri-plugin-deep-link", version = "0.1.2" } # This should be migrated to official tauri plugin tauri-plugin-os = "2.2" tauri-plugin-clipboard-manager = "2.2" tauri-plugin-fs = "2.2" tauri-plugin-dialog = "2.2" tauri-plugin-process = "2.2" tauri-plugin-updater = "2.2" tauri-plugin-shell = "2.2" tauri-plugin-notification = "2.2" tauri-plugin-opener = "2.5" window-vibrancy = { version = "0.7.0" } # Strong typed api binding between typescript and rust specta-typescript = "0.0.9" tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } specta = { version = "=2.0.0-rc.22", features = [ "serde", "serde_json", "serde_yaml", "uuid", "url", "indexmap", "function", ] } [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] tauri-plugin-global-shortcut = "2.2.0" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.1" objc2-app-kit = { version = "0.3.1", features = [ "NSApplication", "NSResponder", "NSRunningApplication", "NSWindow", "NSView", ] } objc2-foundation = { version = "0.3.1", features = ["NSGeometry"] } [target.'cfg(unix)'.dependencies] nix = { version = "0.31.0", features = ["user", "fs"] } [target.'cfg(windows)'.dependencies] deelevate = "0.2.0" winreg = { version = "0.55", features = ["transactions"] } windows-registry = "0.5.1" windows-sys = { version = "0.60", features = [ "Win32_System_LibraryLoader", "Win32_System_SystemInformation", "Win32_UI_WindowsAndMessaging", "Win32_System_Shutdown", "Win32_Graphics_Gdi", ] } windows-core = "0.61" webview2-com = "0.38" [features] default = ["custom-protocol", "default-meta"] nightly = ["devtools", "deadlock-detection"] custom-protocol = ["tauri/custom-protocol"] verge-dev = [] default-meta = [] devtools = ["tauri/devtools"] deadlock-detection = ["parking_lot/deadlock_detection"] ================================================ FILE: backend/tauri/Info.plist ================================================ CFBundleURLTypes CFBundleURLName Clash Nyanpasu CFBundleURLSchemes clash-nyanpasu clash ================================================ FILE: backend/tauri/build.rs ================================================ use chrono::{DateTime, SecondsFormat, Utc}; use rustc_version::version_meta; use serde::Deserialize; use std::{ env, fs::{exists, read}, process::Command, }; #[derive(Deserialize)] struct PackageJson { version: String, // we only need the version } #[derive(Deserialize)] struct GitInfo { hash: String, author: String, time: String, } fn main() { let version: String = if let Ok(true) = exists("../../package.json") { let raw = read("../../package.json").unwrap(); let pkg_json: PackageJson = serde_json::from_slice(&raw).unwrap(); pkg_json.version } else { let raw = read("./tauri.conf.json").unwrap(); // TODO: fix it when windows arm64 need it let tauri_json: PackageJson = serde_json::from_slice(&raw).unwrap(); tauri_json.version }; let version = semver::Version::parse(&version).unwrap(); let is_prerelase = !version.pre.is_empty(); println!("cargo:rustc-env=NYANPASU_VERSION={version}"); // Git Information let (commit_hash, commit_author, commit_date) = if let Ok(true) = exists("./tmp/git-info.json") { let git_info = read("./tmp/git-info.json").unwrap(); let git_info: GitInfo = serde_json::from_slice(&git_info).unwrap(); (git_info.hash, git_info.author, git_info.time) } else { let output = Command::new("git") .args([ "show", "--pretty=format:'%H,%cn,%cI'", "--no-patch", "--no-notes", ]) .output() .expect("Failed to execute git command"); // println!("{}", String::from_utf8(output.stderr.clone()).unwrap()); let command_args: Vec = String::from_utf8(output.stdout) .unwrap() .replace('\'', "") .split(',') .map(String::from) .collect(); ( command_args[0].clone(), command_args[1].clone(), command_args[2].clone(), ) }; println!("cargo:rustc-env=COMMIT_HASH={commit_hash}"); println!("cargo:rustc-env=COMMIT_AUTHOR={commit_author}"); let commit_date = DateTime::parse_from_rfc3339(&commit_date) .unwrap() .with_timezone(&Utc) .to_rfc3339_opts(SecondsFormat::Millis, true); println!("cargo:rustc-env=COMMIT_DATE={commit_date}"); // Build Date let build_date = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); println!("cargo:rustc-env=BUILD_DATE={build_date}"); // Build Profile println!( "cargo:rustc-env=BUILD_PROFILE={}", if is_prerelase { "Nightly" } else { match env::var("PROFILE").unwrap().as_str() { "release" => "Release", "debug" => "Debug", _ => "Unknown", } } ); // Build Platform println!( "cargo:rustc-env=BUILD_PLATFORM={}", env::var("TARGET").unwrap() ); // Rustc Version & LLVM Version let rustc_version = version_meta().unwrap(); println!( "cargo:rustc-env=RUSTC_VERSION={}", rustc_version.short_version_string ); println!( "cargo:rustc-env=LLVM_VERSION={}", match rustc_version.llvm_version { Some(v) => v.to_string(), None => "Unknown".to_string(), } ); tauri_build::build() } ================================================ FILE: backend/tauri/capabilities/main.json ================================================ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main-capability", "local": true, "windows": ["main", "legacy", "editor-*"], "permissions": [ "core:default", "core:app:default", "core:event:default", "updater:default", "updater:allow-check", "updater:allow-download", "updater:allow-install", "fs:allow-read-text-file", "fs:allow-read-file", "fs:allow-write-file", "fs:allow-read-dir", "fs:allow-copy-file", "fs:allow-mkdir", "fs:allow-remove", "fs:allow-rename", "fs:allow-exists", "core:window:default", "core:window:allow-create", "core:window:allow-center", "core:window:allow-request-user-attention", "core:window:allow-set-resizable", "core:window:allow-set-maximizable", "core:window:allow-set-minimizable", "core:window:allow-set-closable", "core:window:allow-set-title", "core:window:allow-maximize", "core:window:allow-toggle-maximize", "core:window:allow-unmaximize", "core:window:allow-minimize", "core:window:allow-unminimize", "core:window:allow-show", "core:window:allow-hide", "core:window:allow-close", "core:window:allow-set-decorations", "core:window:allow-set-always-on-top", "core:window:allow-set-content-protected", "core:window:allow-set-size", "core:window:allow-set-min-size", "core:window:allow-set-max-size", "core:window:allow-set-position", "core:window:allow-set-fullscreen", "core:window:allow-set-focus", "core:window:allow-set-icon", "core:window:allow-set-skip-taskbar", "core:window:allow-set-cursor-grab", "core:window:allow-set-cursor-visible", "core:window:allow-set-cursor-icon", "core:window:allow-set-cursor-position", "core:window:allow-set-ignore-cursor-events", "core:window:allow-start-dragging", "core:webview:allow-print", "shell:allow-execute", "shell:allow-open", "dialog:allow-open", "dialog:allow-save", "dialog:allow-message", "dialog:allow-ask", "dialog:allow-confirm", "notification:default", "global-shortcut:allow-is-registered", "global-shortcut:allow-register", "global-shortcut:allow-register-all", "global-shortcut:allow-unregister", "global-shortcut:allow-unregister-all", "os:allow-platform", "os:allow-version", "os:allow-os-type", "os:allow-family", "os:allow-arch", "os:allow-exe-extension", "os:allow-locale", "os:allow-hostname", "process:allow-restart", "process:allow-exit", "clipboard-manager:allow-read-text", "clipboard-manager:allow-write-text" ] } ================================================ FILE: backend/tauri/locales/en.json ================================================ { "_version": 1, "tray": { "copy_env": { "cmd": "Copy Env (CMD)", "ps": "Copy Env (PS)", "sh": "Copy Env (sh)" }, "no_proxies": "No Proxies", "select_proxies": "Select Proxies", "dashboard": "Dashboard", "direct_mode": "Direct Mode", "global_mode": "Global Mode", "more": { "menu": "More", "restart_app": "Restart App", "restart_clash": "Restart Clash" }, "open_dir": { "menu": "Open Dir", "app_config_dir": "Config Dir", "app_data_dir": "Data Dir", "core_dir": "Core Dir", "log_dir": "Log Dir" }, "proxy_action": { "on": "On", "off": "Off" }, "quit": "Quit", "rule_mode": "Rule Mode", "script_mode": "Script Mode", "system_proxy": "System Proxy", "tun_mode": "TUN Mode" }, "dialog": { "panic": "Please report this issue to Github issue tracker.", "migrate": "Old version config file detected. Migrate to new version or not?\nWARNING: This will override your current config if exists", "custom_app_dir_migrate": "You will set custom app dir to %{path}\nShall we move the current app dir to the new one?", "warning": { "enable_tun_with_no_permission": "TUN mode requires admin permission or service mode, neither is enabled, TUN mode will not work properly." }, "info": { "grant_core_permission": "Clash core needs admin permission to make TUN mode work properly, grant it?\nPlease note that this operation requires password input." } }, "setting": { "connection": { "interrupt": { "proxy": { "label": "Interrupt connections when proxy changes" }, "profile": { "label": "Interrupt connections when profile changes" }, "mode": { "label": "Interrupt connections when mode changes" } } } }, "break_when_proxy_change": "Interrupt connections when proxy changes", "break_when_profile_change": "Interrupt connections when profile changes", "break_when_mode_change": "Interrupt connections when mode changes" } ================================================ FILE: backend/tauri/locales/ru.json ================================================ { "_version": 1, "tray": { "copy_env": { "cmd": "Копировать Env (CMD)", "ps": "Копировать Env (PS)", "sh": "Копировать Env (sh)" }, "no_proxies": "Без прокси", "select_proxies": "Выбрать прокси", "dashboard": "Панель управления", "direct_mode": "Прямой режим", "global_mode": "Глобальный режим", "more": { "menu": "Еще", "restart_app": "Перезапустить приложение", "restart_clash": "Перезапустить Clash" }, "open_dir": { "menu": "Открыть папку", "app_config_dir": "Папка конфигурации", "app_data_dir": "Папка данных", "core_dir": "Папка ядра", "log_dir": "Папка журналов" }, "proxy_action": { "on": "Включить", "off": "Выключить" }, "quit": "Выйти", "rule_mode": "Режим правил", "script_mode": "Режим скриптов", "system_proxy": "Системный прокси", "tun_mode": "Режим TUN" }, "dialog": { "panic": "Пожалуйста, сообщите об этой проблеме в трекере проблем Github.", "migrate": "Обнаружен файл конфигурации старой версии\\nМигрировать на новую версию или нет?\\n ВНИМАНИЕ: Это перезапишет вашу текущую конфигурацию, если она существует", "custom_app_dir_migrate": "Вы установите пользовательскую папку приложения в %{path}\\nПереместить ли текущую папку приложения в новую?", "warning": { "enable_tun_with_no_permission": "Режим TUN требует прав администратора или режима службы, ни один из которых не включен, режим TUN не будет работать должным образом." }, "info": { "grant_core_permission": "Ядру Clash необходимы права администратора для корректной работы режима TUN, предоставить их?\\n\\nОбратите внимание: Эта операция требует ввода пароля." } }, "setting": { "connection": { "interrupt": { "proxy": { "label": "Прерывать соединения при смене прокси" }, "profile": { "label": "Прерывать соединения при смене профиля" }, "mode": { "label": "Прерывать соединения при смене режима" } } } }, "break_when_proxy_change": "Прерывать соединения при смене прокси", "break_when_profile_change": "Прерывать соединения при смене профиля", "break_when_mode_change": "Прерывать соединения при смене режима" } ================================================ FILE: backend/tauri/locales/zh-cn.json ================================================ { "_version": 1, "tray": { "copy_env": { "cmd": "复制环境变量 (CMD)", "ps": "复制环境变量 (PS)", "sh": "复制环境变量 (SH)" }, "no_proxies": "无代理", "select_proxies": "选择代理", "dashboard": "打开面板", "direct_mode": "直连模式", "global_mode": "全局模式", "more": { "menu": "更多", "restart_app": "重启应用程序", "restart_clash": "重启 Clash" }, "open_dir": { "menu": "打开目录", "app_config_dir": "配置目录", "app_data_dir": "数据目录", "core_dir": "内核目录", "log_dir": "日志目录" }, "proxy_action": { "on": "开", "off": "关" }, "quit": "退出", "rule_mode": "规则模式", "script_mode": "脚本模式", "system_proxy": "系统代理", "tun_mode": "TUN 模式" }, "dialog": { "panic": "请将此问题汇报到 GitHub Issues。", "migrate": "检测到旧版本配置文件,是否迁移到新版本?\n警告:此操作会覆盖掉现有配置文件!", "custom_app_dir_migrate": "你将要更改应用目录至 %{path}。\n需要将现有数据迁移到新目录吗?", "warning": { "enable_tun_with_no_permission": "TUN 模式需要授予管理员权限或启用服务模式,当前都未开启,因此 TUN 模式将无法正常工作。" }, "info": { "grant_core_permission": "Clash 内核需要管理员权限才能使得 TUN 模式正常工作,是否授予?\n\n请注意:此操作需要输入密码。" } }, "setting": { "connection": { "interrupt": { "proxy": { "label": "当代理切换时打断连接" }, "profile": { "label": "当配置文件切换时打断连接" }, "mode": { "label": "当模式切换时打断连接" } } } }, "break_when_proxy_change": "当代理切换时打断连接", "break_when_profile_change": "当配置文件切换时打断连接", "break_when_mode_change": "当模式切换时打断连接" } ================================================ FILE: backend/tauri/locales/zh-tw.json ================================================ { "_version": 1, "tray": { "copy_env": { "cmd": "複製環境變數 (CMD)", "ps": "複製環境變數 (PS)", "sh": "複製環境變數 (SH)" }, "no_proxies": "無代理", "select_proxies": "選取代理", "dashboard": "開啟儀表盤", "direct_mode": "直連模式", "global_mode": "全域模式", "more": { "menu": "更多", "restart_app": "重啟 App", "restart_clash": "重啟 Clash" }, "open_dir": { "menu": "打開目錄", "app_config_dir": "設定檔目錄", "app_data_dir": "資料目錄", "core_dir": "核心目錄", "log_dir": "日誌目錄" }, "proxy_action": { "on": "開", "off": "關" }, "quit": "退出", "rule_mode": "規則模式", "script_mode": "腳本模式", "system_proxy": "系統代理", "tun_mode": "TUN 模式" }, "dialog": { "panic": "請將此問題回報至 GitHub Issues。", "migrate": "檢測到舊版本設定檔,是否遷移到新版本?\n警告:此操作會覆蓋掉現有設定檔。", "custom_app_dir_migrate": "你將要更改 App 目錄至 %{path}。\n需要將現有資料遷移到新目錄嗎?", "warning": { "enable_tun_with_no_permission": "開啟 TUN 模式需要系統管理員權限或服務模式,目前都未啟用,因此 TUN 模式將無法正常工作。" }, "info": { "grant_core_permission": "Clash 核心需要系統管理員權限才能使 TUN 模式正常工作,是否授予?\n請注意:此操作需要輸入密碼。" } }, "setting": { "connection": { "interrupt": { "proxy": { "label": "當代理切換時打斷連線" }, "profile": { "label": "當設定檔切換時打斷連線" }, "mode": { "label": "當模式切換時打斷連線" } } } }, "break_when_proxy_change": "當代理切換時打斷連線", "break_when_profile_change": "當設定檔切換時打斷連線", "break_when_mode_change": "當模式切換時打斷連線" } ================================================ FILE: backend/tauri/overrides/fixed-webview2.conf.json ================================================ { "$schema": "../../../node_modules/@tauri-apps/cli/config.schema.json", "bundle": { "windows": { "webviewInstallMode": { "type": "fixedRuntime", "path": "SHOULD_BE_REPLACED_WITH_THE_PATH_TO_THE_FIXED_WEBVIEW" } } }, "plugins": { "updater": { "endpoints": [ "https://deno.elaina.moe/updater/update-fixed-webview-proxy.json", "https://nyanpasu.surge.sh/updater/update-fixed-webview-proxy.json", "https://gh-proxy.com/https://github.com/libnyanpasu/clash-nyanpasu/releases/download/updater/update-fixed-webview-proxy.json", "https://github.com/libnyanpasu/clash-nyanpasu/releases/download/updater/update-fixed-webview.json" ] } } } ================================================ FILE: backend/tauri/overrides/nightly.conf.json ================================================ { "$schema": "../../../node_modules/@tauri-apps/cli/config.schema.json", "version": "2.0.0", "plugins": { "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlBMUM0NjMxREZCNDRGMjYKUldRbVQ3VGZNVVljbW43N0FlWjA4UkNrbTgxSWxSSXJQcExXNkZjUTlTQkIyYkJzL0tsSWF2d0cK", "endpoints": [ "https://deno.elaina.moe/updater/update-nightly-proxy.json", "https://nyanpasu.surge.sh/updater/update-nightly-proxy.json", "https://gh-proxy.com/https://github.com/libnyanpasu/clash-nyanpasu/releases/download/updater/update-nightly-proxy.json", "https://github.com/libnyanpasu/clash-nyanpasu/releases/download/updater/update-nightly.json" ] } } } ================================================ FILE: backend/tauri/src/cmds/migrate.rs ================================================ use clap::Args; use crate::core::migration::{ MigrationAdvice, Runner, units::{find_migration, get_migrations}, }; use colored::Colorize; #[derive(Debug, Args)] pub struct MigrateOpts { /// force to run migration without advice #[arg(long, default_value = "false")] skip_advice: bool, /// Run specific migration #[arg(long)] migration: Option, /// Run migration up to specific version #[arg(long)] version: Option, /// List all migrations #[arg(long)] list: bool, } /// A fresh install instance should have a empty config dir, /// /// The `app_config_dir` would create a new dir while access it. fn is_fresh_install_instance() -> bool { crate::utils::dirs::app_config_dir() .ok() .and_then(|dir| std::fs::read_dir(dir).ok()) .is_some_and(|entry| { let dirs = entry.collect::>>(); dirs.is_empty() }) } pub fn parse(args: &MigrateOpts) { let runner = if args.skip_advice { Runner::new_with_skip_advice() } else { Runner::default() }; if args.list { println!("Available migrations:\n"); let migrations = get_migrations(); for migration in migrations { let advice = runner.advice_migration(migration.as_ref()); println!( "[{}] {} - {}", match &advice { MigrationAdvice::Pending => format!("{advice}").yellow(), MigrationAdvice::Ignored => format!("{advice}").cyan(), MigrationAdvice::Done => format!("{advice}").green(), }, migration.version(), migration.name() ); } std::process::exit(0); } if args.migration.is_some() && args.version.is_some() { eprintln!("Please specify only one of migration or version."); std::process::exit(1); } // When `Drop`, commit the changes to the migration file. let runner = runner.drop_guard(); if is_fresh_install_instance() { eprintln!("Fresh install detected, skip all migrations"); return; } if args.migration.is_none() && args.version.is_none() { match crate::consts::BUILD_INFO.build_profile { "Nightly" => { println!("Running all upcoming migrations."); runner.run_upcoming_units().unwrap(); } _ => { println!( "No migration or version specified. Running migrations up to current version." ); runner .run_units_up_to_version(&runner.current_version) .unwrap(); } } } if let Some(migration) = args.migration.as_ref() { let migration = find_migration(migration); match migration { Some(migration) => { runner.run_migration(migration.as_ref()).unwrap(); } None => { eprintln!("Migration not found."); std::process::exit(1); } } } else if let Some(version) = args.version.as_deref() { let version = semver::Version::parse(version).unwrap(); runner.run_units_up_to_version(&version).unwrap(); } } #[cfg(target_os = "windows")] pub fn migrate_home_dir_handler(target_path: &str) -> anyhow::Result<()> { use crate::utils::{self, dirs}; use anyhow::Context; use deelevate::{PrivilegeLevel, Token}; use std::{borrow::Cow, path::PathBuf, process::Command, str::FromStr, thread, time::Duration}; use sysinfo::System; use tauri::utils::platform::current_exe; println!("target path {target_path}"); let token = Token::with_current_process()?; if let PrivilegeLevel::NotPrivileged = token.privilege_level()? { eprintln!("Please run this command as admin to prevent authority issue."); std::process::exit(1); } let current_home_dir = dirs::app_config_dir()?; let target_home_dir = PathBuf::from_str(target_path)?; // 1. waiting for app exited println!("waiting for app exited."); let placeholder = dirs::get_single_instance_placeholder()?; let mut single_instance: single_instance::SingleInstance; loop { single_instance = single_instance::SingleInstance::new(&placeholder) .context("failed to create single instance")?; if single_instance.is_single() { break; } thread::sleep(Duration::from_secs(1)); } // 2. kill all related processes. let related_names = [ "clash-verge-service", "clash-nyanpasu-service", // for upcoming v1.6.x "clash-rs", "mihomo", "mihomo-alpha", "clash", ]; let sys = System::new_all(); 'outer: for process in sys.processes().values() { let process_name = process.name().to_string_lossy(); // TODO: check if it's utf-8 let process_name = if let Some(name) = process_name.strip_suffix(".exe") { Cow::Borrowed(name) } else { process_name }; for name in related_names.iter() { if process_name.ends_with(name) { println!("Process found: {process_name} should be killed. killing..."); if !process.kill() { eprintln!("failed to kill {process_name}.") } continue 'outer; } } } // 3. do config migrate and update the registry. utils::init::do_config_migration(¤t_home_dir, &target_home_dir)?; utils::winreg::set_app_dir(target_home_dir.as_path())?; println!("migration finished. starting application..."); drop(single_instance); // release single instance lock let app_path = current_exe()?; thread::spawn(move || { #[allow(clippy::zombie_processes)] Command::new(app_path).spawn().unwrap(); }); thread::sleep(Duration::from_secs(5)); Ok(()) } #[cfg(not(target_os = "windows"))] pub fn migrate_home_dir_handler(_target_path: &str) -> anyhow::Result<()> { Ok(()) } ================================================ FILE: backend/tauri/src/cmds/mod.rs ================================================ use std::str::FromStr; use crate::utils; use anyhow::Ok; use clap::{Parser, Subcommand}; use migrate::MigrateOpts; use nyanpasu_egui::widget::StatisticWidgetVariant; use tauri::utils::platform::current_exe; mod migrate; #[derive(Parser, Debug)] #[command(name = "clash-nyanpasu", version, about, long_about = None, disable_version_flag = true)] /// Clash Nyanpasu is a GUI client for Clash. pub struct Cli { /// Print the version #[clap(short = 'v', long, default_value = "false")] version: bool, #[command(subcommand)] command: Option, #[arg(raw = true)] args: Vec, } #[derive(Subcommand, Debug)] enum Commands { /// Migrate home directory to another path. MigrateHomeDir { target_path: String }, /// do migration Migrate(MigrateOpts), /// Collect the environment variables. Collect, /// A launch bridge to resolve the delay exit issue. Launch { #[arg(raw = true)] args: Vec, }, /// Show a panic dialog while the application is enter panic handler. PanicDialog { message: String }, /// Launch the Widget with the specified name. StatisticWidget { variant: StatisticWidgetVariant }, } struct DelayedExitGuard; impl DelayedExitGuard { pub fn new() -> Self { Self } } impl Drop for DelayedExitGuard { fn drop(&mut self) { std::thread::sleep(std::time::Duration::from_secs(5)); } } pub fn parse() -> anyhow::Result<()> { let cli = Cli::parse(); if cli.version { print_version_info(); } if let Some(commands) = &cli.command { let guard = DelayedExitGuard::new(); match commands { Commands::Migrate(opts) => { migrate::parse(opts); } Commands::MigrateHomeDir { target_path } => { migrate::migrate_home_dir_handler(target_path).unwrap(); } Commands::Launch { args } => { let _ = utils::init::check_singleton().unwrap(); let appimage: Option = { #[cfg(target_os = "linux")] { std::env::var_os("APPIMAGE").map(|s| s.to_string_lossy().to_string()) } #[cfg(not(target_os = "linux"))] None }; let path = match appimage { Some(appimage) => std::path::PathBuf::from_str(&appimage).unwrap(), None => current_exe().unwrap(), }; // let args = args.clone(); // args.extend(vec!["--".to_string()]); #[allow(clippy::zombie_processes)] std::process::Command::new(path).args(args).spawn().unwrap(); } Commands::Collect => { let envs = crate::utils::collect::collect_envs().unwrap(); println!("{envs:#?}"); } Commands::PanicDialog { message } => { crate::utils::dialog::panic_dialog(message); } Commands::StatisticWidget { variant } => { nyanpasu_egui::widget::start_statistic_widget(*variant) .expect("Failed to start statistic widget"); } } drop(guard); std::process::exit(0); } Ok(()) // bypass } fn print_version_info() { use crate::consts::*; use ansi_str::AnsiStr; use chrono::{DateTime, Utc}; use colored::*; use timeago::Formatter; let build_info = &BUILD_INFO; let now = Utc::now(); let formatter = Formatter::new(); let commit_time = formatter.convert_chrono( DateTime::parse_from_rfc3339(build_info.commit_date).unwrap(), now, ); let commit_time_width = commit_time.len() + build_info.commit_date.len() + 3; let build_time = formatter.convert_chrono( DateTime::parse_from_rfc3339(build_info.build_date).unwrap(), now, ); let build_time_width = build_time.len() + build_info.build_date.len() + 3; let commit_info_width = build_info.commit_hash.len() + build_info.commit_author.len() + 4; let col_width = commit_info_width .max(commit_time_width) .max(build_time_width) .max(build_info.build_platform.len()) .max(build_info.rustc_version.len()) .max(build_info.llvm_version.len()) + 2; let header_width = col_width + 16; println!( "{} v{} ({} Build)\n", build_info.app_name, build_info.pkg_version, build_info.build_profile.yellow() ); println!("╭{:─^width$}╮", " Build Information ", width = header_width); let mut line = format!( "{} by {}", build_info.commit_hash.green(), build_info.commit_author.blue() ); let mut pad = col_width - line.ansi_strip().len(); println!("│{:>14}: {}{}│", "Commit Info", line, " ".repeat(pad)); line = format!("{} ({})", commit_time.red(), build_info.commit_date.cyan()); pad = col_width - line.ansi_strip().len(); println!("│{:>14}: {}{}│", "Commit Time", line, " ".repeat(pad)); line = format!("{} ({})", build_time.red(), build_info.build_date.cyan()); pad = col_width - line.ansi_strip().len(); println!("│{:>14}: {}{}│", "Build Time", line, " ".repeat(pad)); println!( "│{:>14}: {:14}: {:14}: {: Self { match dirs::clash_guard_overrides_path().and_then(|path| help::read_merge_mapping(&path)) { Ok(map) => Self(Self::guard(map)), Err(err) => { log::error!(target: "app", "{err:?}"); Self::template() } } } pub fn template() -> Self { let mut map = Mapping::new(); map.insert("mixed-port".into(), 7890.into()); map.insert("log-level".into(), "info".into()); map.insert("allow-lan".into(), false.into()); map.insert("mode".into(), "rule".into()); #[cfg(debug_assertions)] map.insert("external-controller".into(), "127.0.0.1:9872".into()); #[cfg(not(debug_assertions))] map.insert("external-controller".into(), "127.0.0.1:17650".into()); map.insert( "secret".into(), uuid::Uuid::new_v4().to_string().to_lowercase().into(), // generate a uuid v4 as default secret to secure the communication between clash and the client ); #[cfg(feature = "default-meta")] map.insert("unified-delay".into(), true.into()); #[cfg(feature = "default-meta")] map.insert("tcp-concurrent".into(), true.into()); map.insert("ipv6".into(), false.into()); Self(map) } fn guard(mut config: Mapping) -> Mapping { let port = Self::guard_mixed_port(&config); let ctrl = Self::guard_server_ctrl(&config); config.insert("mixed-port".into(), port.into()); config.insert("external-controller".into(), ctrl.into()); config } pub fn patch_config(&mut self, patch: Mapping) { for (key, value) in patch.into_iter() { self.0.insert(key, value); } } pub fn save_config(&self) -> Result<()> { help::save_yaml( &dirs::clash_guard_overrides_path()?, &self.0, Some("# Generated by Clash Nyanpasu"), ) } pub fn get_mixed_port(&self) -> u16 { Self::guard_mixed_port(&self.0) } pub fn get_client_info(&self) -> ClashInfo { let config = &self.0; ClashInfo { port: Self::guard_mixed_port(config), server: Self::guard_client_ctrl(config), secret: config.get("secret").and_then(|value| match value { Value::String(val_str) => Some(val_str.clone()), Value::Bool(val_bool) => Some(val_bool.to_string()), Value::Number(val_num) => Some(val_num.to_string()), _ => None, }), } } #[allow(dead_code)] pub fn get_external_controller_port(&self) -> u16 { let server = self.get_client_info().server; let port = server.split(':').next_back().unwrap_or("9090"); port.parse().unwrap_or(9090) } #[instrument] pub fn prepare_external_controller_port(&mut self) -> Result<()> { let strategy = Config::verge() .latest() .get_external_controller_port_strategy(); let server = self.get_client_info().server; let (server_ip, server_port) = server.split_once(':').unwrap_or(("127.0.0.1", "9090")); let server_port = server_port.parse::().unwrap_or(9090); let port = get_clash_external_port(&strategy, server_port)?; if port != server_port { let new_server = format!("{server_ip}:{port}"); warn!("The external controller port has been changed to {new_server}"); let mut map = Mapping::new(); map.insert("external-controller".into(), new_server.into()); self.patch_config(map); } Ok(()) } pub fn guard_mixed_port(config: &Mapping) -> u16 { let mut port = config .get("mixed-port") .and_then(|value| match value { Value::String(val_str) => val_str.parse().ok(), Value::Number(val_num) => val_num.as_u64().map(|u| u as u16), _ => None, }) .unwrap_or(7890); if port == 0 { port = 7890; } port } pub fn guard_server_ctrl(config: &Mapping) -> String { config .get("external-controller") .and_then(|value| match value.as_str() { Some(val_str) => { let val_str = val_str.trim(); let val = match val_str.starts_with(':') { true => format!("127.0.0.1{val_str}"), false => val_str.to_owned(), }; SocketAddr::from_str(val.as_str()) .ok() .map(|s| s.to_string()) } None => None, }) .unwrap_or("127.0.0.1:9090".into()) } pub fn guard_client_ctrl(config: &Mapping) -> String { let value = Self::guard_server_ctrl(config); match SocketAddr::from_str(value.as_str()) { Ok(mut socket) => { if socket.ip().is_unspecified() { socket.set_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); } socket.to_string() } Err(_) => "127.0.0.1:9090".into(), } } #[allow(unused)] pub fn get_tun_device_ip(&self) -> String { let config = &self.0; let ip = config .get("dns") .and_then(|value| match value { Value::Mapping(val_map) => Some(val_map.get("fake-ip-range").and_then( |fake_ip_range| match fake_ip_range { Value::String(ip_range_val) => Some(ip_range_val.replace("1/16", "2")), _ => None, }, )), _ => None, }) // 默认IP .unwrap_or(Some("198.18.0.2".to_string())); ip.unwrap() } } #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)] pub struct ClashInfo { /// clash core port pub port: u16, /// same as `external-controller` pub server: String, /// clash secret pub secret: Option, } #[test] fn test_clash_info() { fn get_case, D: Into>(mp: T, ec: D) -> ClashInfo { let mut map = Mapping::new(); map.insert("mixed-port".into(), mp.into()); map.insert("external-controller".into(), ec.into()); IClashTemp(IClashTemp::guard(map)).get_client_info() } fn get_result>(port: u16, server: S) -> ClashInfo { ClashInfo { port, server: server.into(), secret: None, } } assert_eq!( IClashTemp(IClashTemp::guard(Mapping::new())).get_client_info(), get_result(7890, "127.0.0.1:9090") ); assert_eq!(get_case("", ""), get_result(7890, "127.0.0.1:9090")); assert_eq!(get_case(65537, ""), get_result(1, "127.0.0.1:9090")); assert_eq!( get_case(8888, "127.0.0.1:8888"), get_result(8888, "127.0.0.1:8888") ); assert_eq!( get_case(8888, " :98888 "), get_result(8888, "127.0.0.1:9090") ); assert_eq!( get_case(8888, "0.0.0.0:8080 "), get_result(8888, "127.0.0.1:8080") ); assert_eq!( get_case(8888, "0.0.0.0:8080"), get_result(8888, "127.0.0.1:8080") ); assert_eq!( get_case(8888, "[::]:8080"), get_result(8888, "127.0.0.1:8080") ); assert_eq!( get_case(8888, "192.168.1.1:8080"), get_result(8888, "192.168.1.1:8080") ); assert_eq!( get_case(8888, "192.168.1.1:80800"), get_result(8888, "127.0.0.1:9090") ); } #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub struct IClash { pub mixed_port: Option, pub allow_lan: Option, pub log_level: Option, pub ipv6: Option, pub mode: Option, pub external_controller: Option, pub secret: Option, pub dns: Option, pub tun: Option, pub interface_name: Option, } #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub struct IClashTUN { pub enable: Option, pub stack: Option, pub auto_route: Option, pub auto_detect_interface: Option, pub dns_hijack: Option>, } #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub struct IClashDNS { pub enable: Option, pub listen: Option, pub default_nameserver: Option>, pub enhanced_mode: Option, pub fake_ip_range: Option, pub use_hosts: Option, pub fake_ip_filter: Option>, pub nameserver: Option>, pub fallback: Option>, pub fallback_filter: Option, pub nameserver_policy: Option>, } #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub struct IClashFallbackFilter { pub geoip: Option, pub geoip_code: Option, pub ipcidr: Option>, pub domain: Option>, } ================================================ FILE: backend/tauri/src/config/core.rs ================================================ use super::{Draft, IClashTemp, IRuntime, IVerge, Profiles}; use crate::{ core::state::ManagedState, enhance, utils::{dirs, help}, }; use anyhow::{Result, anyhow}; use nyanpasu_utils::runtime::block_on; use once_cell::sync::OnceCell; use std::{env::temp_dir, path::PathBuf}; pub const RUNTIME_CONFIG: &str = "clash-config.yaml"; pub const CHECK_CONFIG: &str = "clash-config-check.yaml"; pub struct Config { clash_config: Draft, verge_config: Draft, profiles_config: ManagedState, runtime_config: Draft, } impl Config { pub fn global() -> &'static Config { static CONFIG: OnceCell = OnceCell::new(); CONFIG.get_or_init(|| Config { clash_config: Draft::from(IClashTemp::new()), verge_config: Draft::from(IVerge::new()), profiles_config: ManagedState::from(Profiles::new()), runtime_config: Draft::from(IRuntime::new()), }) } pub fn clash() -> Draft { Self::global().clash_config.clone() } pub fn verge() -> Draft { Self::global().verge_config.clone() } pub fn profiles() -> &'static ManagedState { &Self::global().profiles_config } pub fn runtime() -> Draft { Self::global().runtime_config.clone() } /// 初始化配置 pub fn init_config() -> Result<()> { crate::log_err!(block_on(Self::generate())); if let Err(err) = Self::generate_file(ConfigType::Run) { log::error!(target: "app", "{err:?}"); let runtime_path = dirs::app_config_dir()?.join(RUNTIME_CONFIG); // 如果不存在就将默认的clash文件拿过来 if !runtime_path.exists() { help::save_yaml( &runtime_path, &Config::clash().latest().0, Some("# Clash Nyanpasu Runtime"), )?; } } Ok(()) } /// 将配置丢到对应的文件中 pub fn generate_file(typ: ConfigType) -> Result { let path = match typ { ConfigType::Run => dirs::app_config_dir()?.join(RUNTIME_CONFIG), ConfigType::Check => temp_dir().join(CHECK_CONFIG), }; let runtime = Config::runtime(); let runtime = runtime.latest(); let config = runtime .config .as_ref() .ok_or(anyhow!("failed to get runtime config"))?; help::save_yaml(&path, &config, Some("# Generated by Clash Nyanpasu"))?; Ok(path) } /// 生成配置存好 pub async fn generate() -> Result<()> { let (config, exists_keys, postprocessing_outputs) = enhance::enhance().await; *Config::runtime().draft() = IRuntime { config: Some(config), exists_keys, postprocessing_output: postprocessing_outputs, }; Ok(()) } } #[derive(Debug)] pub enum ConfigType { Run, Check, } ================================================ FILE: backend/tauri/src/config/draft.rs ================================================ use super::{IClashTemp, IRuntime, IVerge}; use parking_lot::{MappedMutexGuard, Mutex, MutexGuard}; use std::sync::Arc; #[derive(Debug, Clone)] pub struct Draft { inner: Arc)>>, } macro_rules! draft_define { ($id: ident) => { impl Draft<$id> { #[allow(unused)] pub fn data(&self) -> MappedMutexGuard<$id> { MutexGuard::map(self.inner.lock(), |guard| &mut guard.0) } pub fn latest(&self) -> MappedMutexGuard<$id> { MutexGuard::map(self.inner.lock(), |inner| { if inner.1.is_none() { &mut inner.0 } else { inner.1.as_mut().unwrap() } }) } pub fn draft(&self) -> MappedMutexGuard<$id> { MutexGuard::map(self.inner.lock(), |inner| { if inner.1.is_none() { inner.1 = Some(inner.0.clone()); } inner.1.as_mut().unwrap() }) } pub fn apply(&self) -> Option<$id> { let mut inner = self.inner.lock(); match inner.1.take() { Some(draft) => { let old_value = inner.0.to_owned(); inner.0 = draft.to_owned(); Some(old_value) } None => None, } } pub fn discard(&self) -> Option<$id> { let mut inner = self.inner.lock(); inner.1.take() } } impl From<$id> for Draft<$id> { fn from(data: $id) -> Self { Draft { inner: Arc::new(Mutex::new((data, None))), } } } }; } // draft_define!(IClash); draft_define!(IClashTemp); draft_define!(IRuntime); draft_define!(IVerge); impl Draft { /// Reload configuration from file pub fn reload(&self) { let new_config = IClashTemp::new(); let mut inner = self.inner.lock(); inner.0 = new_config; inner.1 = None; // Clear any draft } } #[test] fn test_draft() { let verge = IVerge { enable_auto_launch: Some(true), enable_tun_mode: Some(false), ..IVerge::default() }; let draft = Draft::from(verge); assert_eq!(draft.data().enable_auto_launch, Some(true)); assert_eq!(draft.data().enable_tun_mode, Some(false)); assert_eq!(draft.draft().enable_auto_launch, Some(true)); assert_eq!(draft.draft().enable_tun_mode, Some(false)); let mut d = draft.draft(); d.enable_auto_launch = Some(false); d.enable_tun_mode = Some(true); drop(d); assert_eq!(draft.data().enable_auto_launch, Some(true)); assert_eq!(draft.data().enable_tun_mode, Some(false)); assert_eq!(draft.draft().enable_auto_launch, Some(false)); assert_eq!(draft.draft().enable_tun_mode, Some(true)); assert_eq!(draft.latest().enable_auto_launch, Some(false)); assert_eq!(draft.latest().enable_tun_mode, Some(true)); assert!(draft.apply().is_some()); assert!(draft.apply().is_none()); assert_eq!(draft.data().enable_auto_launch, Some(false)); assert_eq!(draft.data().enable_tun_mode, Some(true)); assert_eq!(draft.draft().enable_auto_launch, Some(false)); assert_eq!(draft.draft().enable_tun_mode, Some(true)); let mut d = draft.draft(); d.enable_auto_launch = Some(true); drop(d); assert_eq!(draft.data().enable_auto_launch, Some(false)); assert_eq!(draft.draft().enable_auto_launch, Some(true)); assert!(draft.discard().is_some()); assert_eq!(draft.data().enable_auto_launch, Some(false)); assert!(draft.discard().is_none()); assert_eq!(draft.draft().enable_auto_launch, Some(false)); } ================================================ FILE: backend/tauri/src/config/mod.rs ================================================ mod clash; mod core; mod draft; pub mod nyanpasu; pub mod profile; mod runtime; pub use self::{ clash::*, core::*, draft::*, profile::{item::*, profiles::*}, runtime::*, }; pub use self::nyanpasu::IVerge; ================================================ FILE: backend/tauri/src/config/nyanpasu/clash_strategy.rs ================================================ use serde::{Deserialize, Serialize}; use specta::Type; #[derive(Default, Debug, Clone, Deserialize, Serialize, Type)] pub struct ClashStrategy { pub external_controller_port_strategy: ExternalControllerPortStrategy, } #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Type)] #[serde(rename_all = "snake_case")] pub enum ExternalControllerPortStrategy { Fixed, Random, #[default] AllowFallback, } impl super::IVerge { pub fn get_external_controller_port_strategy(&self) -> ExternalControllerPortStrategy { self.clash_strategy .as_ref() .unwrap_or(&ClashStrategy::default()) .external_controller_port_strategy .to_owned() } } ================================================ FILE: backend/tauri/src/config/nyanpasu/logging.rs ================================================ use super::IVerge; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString}; use tracing_subscriber::filter; #[derive(Deserialize, Serialize, Debug, Clone, specta::Type, EnumString, Display)] #[strum(serialize_all = "kebab-case")] pub enum LoggingLevel { #[serde(rename = "silent", alias = "off")] Silent, #[serde(rename = "trace", alias = "tracing")] Trace, #[serde(rename = "debug")] Debug, #[serde(rename = "info")] Info, #[serde(rename = "warn", alias = "warning")] Warn, #[serde(rename = "error")] Error, } impl Default for LoggingLevel { #[cfg(debug_assertions)] fn default() -> Self { Self::Trace } #[cfg(not(debug_assertions))] fn default() -> Self { Self::Info } } impl From for filter::LevelFilter { fn from(level: LoggingLevel) -> Self { match level { LoggingLevel::Silent => filter::LevelFilter::OFF, LoggingLevel::Trace => filter::LevelFilter::TRACE, LoggingLevel::Debug => filter::LevelFilter::DEBUG, LoggingLevel::Info => filter::LevelFilter::INFO, LoggingLevel::Warn => filter::LevelFilter::WARN, LoggingLevel::Error => filter::LevelFilter::ERROR, } } } impl IVerge { pub fn get_log_level(&self) -> LoggingLevel { self.app_log_level.clone().unwrap_or_default() } } ================================================ FILE: backend/tauri/src/config/nyanpasu/mod.rs ================================================ use crate::utils::{dirs, help}; use anyhow::Result; // use log::LevelFilter; use enumflags2::bitflags; use nyanpasu_macro::VergePatch; use serde::{Deserialize, Serialize}; use specta::Type; /// Validates if a string is a valid hex color code pub fn is_hex_color(color: &str) -> bool { if color.len() != 7 || !color.starts_with('#') { return false; } color[1..].chars().all(|c| c.is_ascii_hexdigit()) } mod clash_strategy; pub mod logging; mod widget; pub use self::clash_strategy::{ClashStrategy, ExternalControllerPortStrategy}; pub use logging::LoggingLevel; pub use widget::NetworkStatisticWidgetConfig; // TODO: when support sing-box, remove this struct #[bitflags] #[repr(u8)] #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Type)] pub enum ClashCore { #[serde(rename = "clash", alias = "clash-premium")] ClashPremium = 0b0001, #[serde(rename = "clash-rs")] ClashRs, #[serde(rename = "mihomo", alias = "clash-meta")] Mihomo, #[serde(rename = "mihomo-alpha")] MihomoAlpha, #[serde(rename = "clash-rs-alpha")] ClashRsAlpha, } impl Default for ClashCore { fn default() -> Self { match cfg!(feature = "default-meta") { false => Self::ClashPremium, true => Self::Mihomo, } } } impl From for String { fn from(core: ClashCore) -> Self { match core { ClashCore::ClashPremium => "clash".into(), ClashCore::ClashRs => "clash-rs".into(), ClashCore::Mihomo => "mihomo".into(), ClashCore::MihomoAlpha => "mihomo-alpha".into(), ClashCore::ClashRsAlpha => "clash-rs-alpha".into(), } } } impl std::fmt::Display for ClashCore { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ClashCore::ClashPremium => write!(f, "clash"), ClashCore::ClashRs => write!(f, "clash-rs"), ClashCore::Mihomo => write!(f, "mihomo"), ClashCore::MihomoAlpha => write!(f, "mihomo-alpha"), ClashCore::ClashRsAlpha => write!(f, "clash-rs-alpha"), } } } impl From<&ClashCore> for nyanpasu_utils::core::CoreType { fn from(core: &ClashCore) -> Self { match core { ClashCore::ClashPremium => nyanpasu_utils::core::CoreType::Clash( nyanpasu_utils::core::ClashCoreType::ClashPremium, ), ClashCore::ClashRs => nyanpasu_utils::core::CoreType::Clash( nyanpasu_utils::core::ClashCoreType::ClashRust, ), ClashCore::Mihomo => { nyanpasu_utils::core::CoreType::Clash(nyanpasu_utils::core::ClashCoreType::Mihomo) } ClashCore::MihomoAlpha => nyanpasu_utils::core::CoreType::Clash( nyanpasu_utils::core::ClashCoreType::MihomoAlpha, ), ClashCore::ClashRsAlpha => nyanpasu_utils::core::CoreType::Clash( nyanpasu_utils::core::ClashCoreType::ClashRustAlpha, ), } } } impl TryFrom<&nyanpasu_utils::core::CoreType> for ClashCore { type Error = anyhow::Error; fn try_from(core: &nyanpasu_utils::core::CoreType) -> Result { match core { nyanpasu_utils::core::CoreType::Clash(clash) => match clash { nyanpasu_utils::core::ClashCoreType::ClashPremium => Ok(ClashCore::ClashPremium), nyanpasu_utils::core::ClashCoreType::ClashRust => Ok(ClashCore::ClashRs), nyanpasu_utils::core::ClashCoreType::ClashRustAlpha => Ok(ClashCore::ClashRsAlpha), nyanpasu_utils::core::ClashCoreType::Mihomo => Ok(ClashCore::Mihomo), nyanpasu_utils::core::ClashCoreType::MihomoAlpha => Ok(ClashCore::MihomoAlpha), }, _ => Err(anyhow::anyhow!("unsupported core type")), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default, Type)] #[serde(rename_all = "snake_case")] pub enum ProxiesSelectorMode { Hidden, #[default] Normal, Submenu, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default, Type)] #[serde(rename_all = "snake_case")] pub enum TunStack { System, #[default] Gvisor, Mixed, } impl AsRef for TunStack { fn as_ref(&self) -> &str { match self { TunStack::System => "system", TunStack::Gvisor => "gvisor", TunStack::Mixed => "mixed", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default, Type)] #[serde(rename_all = "snake_case")] pub enum BreakWhenProxyChange { #[default] None, Chain, All, } /// ### `verge.yaml` schema #[derive(Default, Debug, Clone, Deserialize, Serialize, VergePatch, specta::Type)] #[verge(patch_fn = "patch_config")] // TODO: use new managedState and builder pattern instead pub struct IVerge { /// app listening port for app singleton pub app_singleton_port: Option, /// app log level /// silent | error | warn | info | debug | trace pub app_log_level: Option, // i18n pub language: Option, /// `light` or `dark` or `system` pub theme_mode: Option, /// enable traffic graph default is true pub traffic_graph: Option, /// show memory info (only for Clash Meta) pub enable_memory_usage: Option, /// global ui framer motion effects pub lighten_animation_effects: Option, /// clash tun mode pub enable_tun_mode: Option, /// windows service mode #[serde(skip_serializing_if = "Option::is_none")] pub enable_service_mode: Option, /// can the app auto startup pub enable_auto_launch: Option, /// not show the window on launch pub enable_silent_start: Option, /// set system proxy pub enable_system_proxy: Option, /// enable proxy guard pub enable_proxy_guard: Option, /// set system proxy bypass pub system_proxy_bypass: Option, /// proxy guard interval #[serde(alias = "proxy_guard_duration")] pub proxy_guard_interval: Option, /// theme setting pub theme_color: Option, /// web ui list pub web_ui_list: Option>, /// clash core path #[serde(skip_serializing_if = "Option::is_none")] pub clash_core: Option, /// hotkey map /// format: {func},{key} pub hotkeys: Option>, /// 切换代理时自动关闭连接 (已弃用) #[deprecated(note = "use `break_when_proxy_change` instead")] pub auto_close_connection: Option, /// 切换代理时中断连接 /// None: 不中断 /// Chain: 仅中断使用该代理链的连接 /// All: 中断所有连接 pub break_when_proxy_change: Option, /// 切换配置时中断连接 /// true: 中断所有连接 /// false: 不中断连接 pub break_when_profile_change: Option, /// 切换模式时中断连接 /// true: 中断所有连接 /// false: 不中断连接 pub break_when_mode_change: Option, /// 默认的延迟测试连接 pub default_latency_test: Option, /// 支持关闭字段过滤,避免meta的新字段都被过滤掉,默认为真 pub enable_clash_fields: Option, /// 是否使用内部的脚本支持,默认为真 pub enable_builtin_enhanced: Option, /// proxy 页面布局 列数 pub proxy_layout_column: Option, /// 日志清理 /// 分钟数; 0 为不清理 #[deprecated(note = "use `max_log_files` instead")] pub auto_log_clean: Option, /// 日记轮转时间,单位:天 pub max_log_files: Option, /// window size and position #[deprecated(note = "use `window_size_state` instead")] #[serde(skip_serializing_if = "Option::is_none")] pub window_size_position: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub window_size_state: Option, /// 是否启用随机端口 pub enable_random_port: Option, /// verge mixed port 用于覆盖 clash 的 mixed port pub verge_mixed_port: Option, /// Check update when app launch pub enable_auto_check_update: Option, /// Clash 相关策略 pub clash_strategy: Option, /// 是否启用代理托盘选择 pub clash_tray_selector: Option, pub always_on_top: Option, /// Tun 堆栈选择 /// TODO: 弃用此字段,转移到 clash config 里 pub tun_stack: Option, /// 是否启用网络统计信息浮窗 #[serde(skip_serializing_if = "Option::is_none")] pub network_statistic_widget: Option, /// PAC URL for automatic proxy configuration /// This field is used to set PAC proxy without exposing it to the frontend UI #[serde(skip_serializing_if = "Option::is_none")] pub pac_url: Option, /// enable tray text display on Linux systems /// When enabled, shows proxy and TUN mode status as text next to the tray icon /// When disabled, only shows status via icon changes (prevents text display issues on Wayland) pub enable_tray_text: Option, /// Use legacy UI (original UI at "/" route) /// When true, opens legacy window; when false, opens new main window pub use_legacy_ui: Option, } #[derive(Default, Debug, Clone, Deserialize, Serialize, Type)] pub struct WindowState { pub width: u32, pub height: u32, pub x: i32, pub y: i32, pub maximized: bool, pub fullscreen: bool, } impl IVerge { pub fn new() -> Self { match dirs::nyanpasu_config_path().and_then(|path| help::read_yaml::(&path)) { Ok(mut config) => { // Validate and fix theme_color if it's invalid if let Some(ref theme_color) = config.theme_color { if !theme_color.is_empty() && !is_hex_color(theme_color) { log::warn!(target: "app", "Invalid theme color detected: {}, resetting to default", theme_color); config.theme_color = None; } } Self::merge_with_template(config) } Err(err) => { log::error!(target: "app", "{err:?}"); Self::template() } } } fn merge_with_template(mut config: IVerge) -> Self { let template = Self::template(); if config.enable_auto_check_update.is_none() { config.enable_auto_check_update = template.enable_auto_check_update; } if config.clash_tray_selector.is_none() { config.clash_tray_selector = template.clash_tray_selector; } if config.max_log_files.is_none() { config.max_log_files = template.max_log_files; } if config.lighten_animation_effects.is_none() { config.lighten_animation_effects = template.lighten_animation_effects; } if config.enable_service_mode.is_none() { config.enable_service_mode = template.enable_service_mode; } // Handle deprecated auto_close_connection by migrating to break_when_proxy_change if config.auto_close_connection.is_some() && config.break_when_proxy_change.is_none() { config.break_when_proxy_change = if config.auto_close_connection.unwrap() { Some(BreakWhenProxyChange::All) } else { Some(BreakWhenProxyChange::None) }; } // Set defaults for new options if not present if config.break_when_proxy_change.is_none() { config.break_when_proxy_change = template.break_when_proxy_change; } if config.break_when_profile_change.is_none() { config.break_when_profile_change = template.break_when_profile_change; } if config.break_when_mode_change.is_none() { config.break_when_mode_change = template.break_when_mode_change; } if config.enable_tray_text.is_none() { config.enable_tray_text = template.enable_tray_text; } if config.use_legacy_ui.is_none() { config.use_legacy_ui = template.use_legacy_ui; } config } pub fn template() -> Self { Self { clash_core: Some(ClashCore::default()), language: { let locale = crate::utils::help::get_system_locale(); Some(crate::utils::help::mapping_to_i18n_key(&locale).into()) }, app_log_level: Some(logging::LoggingLevel::default()), theme_mode: Some("system".into()), traffic_graph: Some(true), enable_memory_usage: Some(true), enable_auto_launch: Some(false), enable_silent_start: Some(false), enable_system_proxy: Some(false), enable_random_port: Some(false), verge_mixed_port: Some(7890), enable_proxy_guard: Some(false), proxy_guard_interval: Some(30), // auto_close_connection: Some(true), // Deprecated, replaced by break_when_proxy_change break_when_proxy_change: Some(BreakWhenProxyChange::All), break_when_profile_change: Some(true), break_when_mode_change: Some(true), enable_builtin_enhanced: Some(true), enable_clash_fields: Some(true), lighten_animation_effects: Some(false), // auto_log_clean: Some(60 * 24 * 7), // 7 days 自动清理日记 max_log_files: Some(7), // 7 days enable_auto_check_update: Some(true), clash_tray_selector: Some(ProxiesSelectorMode::default()), enable_service_mode: Some(false), always_on_top: Some(false), enable_tray_text: Some(false), use_legacy_ui: Some(true), ..Self::default() } } /// Save IVerge App Config pub fn save_file(&self) -> Result<()> { help::save_yaml( &dirs::nyanpasu_config_path()?, &self, Some("# Clash Nyanpasu Config"), ) } } ================================================ FILE: backend/tauri/src/config/nyanpasu/widget.rs ================================================ use nyanpasu_egui::widget::StatisticWidgetVariant; use serde::{Deserialize, Serialize}; use specta::Type; #[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Type)] #[serde(rename_all = "snake_case")] #[serde(tag = "kind", content = "value")] pub enum NetworkStatisticWidgetConfig { #[default] Disabled, Enabled(StatisticWidgetVariant), } ================================================ FILE: backend/tauri/src/config/profile/builder.rs ================================================ use crate::config::*; use super::item::{ LocalProfileBuilder, MergeProfileBuilder, RemoteProfileBuilder, ScriptProfileBuilder, }; #[derive(Debug, serde:: Serialize, serde::Deserialize, specta::Type)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ProfileBuilder { Remote(RemoteProfileBuilder), Local(LocalProfileBuilder), Merge(MergeProfileBuilder), Script(ScriptProfileBuilder), } #[derive(Debug, thiserror::Error)] pub enum ProfileBuilderError { #[error(transparent)] Remote(#[from] RemoteProfileBuilderError), #[error(transparent)] Local(#[from] LocalProfileBuilderError), #[error(transparent)] Merge(#[from] MergeProfileBuilderError), #[error(transparent)] Script(#[from] ScriptProfileBuilderError), } impl ProfileBuilder { pub fn build(self) -> Result { let profile = match self { ProfileBuilder::Remote(mut builder) => builder.build()?.into(), ProfileBuilder::Local(builder) => builder.build()?.into(), ProfileBuilder::Merge(builder) => builder.build()?.into(), ProfileBuilder::Script(builder) => builder.build()?.into(), }; Ok(profile) } } ================================================ FILE: backend/tauri/src/config/profile/item/local.rs ================================================ use super::{ ProfileCleanup, ProfileFileIo, ProfileHelper, ProfileMetaGetter, ProfileMetaSetter, ProfileShared, ProfileSharedBuilder, ambassador_impl_ProfileFileIo, ambassador_impl_ProfileMetaGetter, ambassador_impl_ProfileMetaSetter, }; use crate::config::{ ProfileKindGetter, profile::item_type::{ProfileItemType, ProfileUid}, }; use ambassador::Delegate; use derive_builder::Builder; use nyanpasu_macro::BuilderUpdate; use serde::{Deserialize, Serialize}; use std::path::PathBuf; const PROFILE_TYPE: ProfileItemType = ProfileItemType::Local; #[derive( Default, Delegate, Debug, Clone, Deserialize, Serialize, Builder, BuilderUpdate, specta::Type, )] #[builder(derive(Debug, Serialize, Deserialize, specta::Type))] #[builder_update(patch_fn = "apply")] #[delegate(ProfileMetaGetter, target = "shared")] #[delegate(ProfileMetaSetter, target = "shared")] #[delegate(ProfileFileIo, target = "shared")] pub struct LocalProfile { #[serde(flatten)] #[builder(field( ty = "ProfileSharedBuilder", build = "self.shared.build(&PROFILE_TYPE).map_err(|e| LocalProfileBuilderError::from(e.to_string()))?" ))] #[builder_field_attr(serde(flatten))] #[builder_update(nested)] pub shared: ProfileShared, #[serde(skip_serializing_if = "Option::is_none")] #[builder(setter(strip_option), default)] /// file symlinks pub symlinks: Option, /// process chain #[builder(default)] #[serde(alias = "chains", default)] #[builder_field_attr(serde(alias = "chains", default))] pub chain: Vec, } impl LocalProfile { pub fn builder() -> LocalProfileBuilder { let mut builder = LocalProfileBuilder::default(); let shared = ProfileShared::get_default_builder(&PROFILE_TYPE); builder.shared(shared); builder } } impl ProfileKindGetter for LocalProfile { fn kind(&self) -> ProfileItemType { PROFILE_TYPE } } impl ProfileHelper for LocalProfile {} impl ProfileCleanup for LocalProfile {} ================================================ FILE: backend/tauri/src/config/profile/item/merge.rs ================================================ use crate::config::{ProfileKindGetter, profile::item_type::ProfileItemType}; use super::{ ProfileCleanup, ProfileFileIo, ProfileHelper, ProfileMetaGetter, ProfileMetaSetter, ProfileShared, ProfileSharedBuilder, ambassador_impl_ProfileFileIo, ambassador_impl_ProfileMetaGetter, ambassador_impl_ProfileMetaSetter, }; use ambassador::Delegate; use derive_builder::Builder; use nyanpasu_macro::BuilderUpdate; use serde::{Deserialize, Serialize}; const PROFILE_TYPE: ProfileItemType = ProfileItemType::Merge; #[derive( Default, Delegate, Debug, Clone, Deserialize, Serialize, Builder, BuilderUpdate, specta::Type, )] #[builder(derive(Debug, Serialize, Deserialize, specta::Type))] #[builder_update(patch_fn = "apply")] #[delegate(ProfileMetaGetter, target = "shared")] #[delegate(ProfileMetaSetter, target = "shared")] #[delegate(ProfileFileIo, target = "shared")] pub struct MergeProfile { #[serde(flatten)] #[builder(field( ty = "ProfileSharedBuilder", build = "self.shared.build(&PROFILE_TYPE).map_err(|e| MergeProfileBuilderError::from(e.to_string()))?" ))] #[builder_field_attr(serde(flatten))] #[builder_update(nested)] pub shared: ProfileShared, } impl MergeProfile { pub fn builder() -> MergeProfileBuilder { let mut builder = MergeProfileBuilder::default(); let shared = ProfileShared::get_default_builder(&PROFILE_TYPE); builder.shared(shared); builder } } impl ProfileKindGetter for MergeProfile { fn kind(&self) -> ProfileItemType { PROFILE_TYPE } } impl ProfileCleanup for MergeProfile {} impl ProfileHelper for MergeProfile {} ================================================ FILE: backend/tauri/src/config/profile/item/mod.rs ================================================ #![allow(clippy::crate_in_macro_def, dead_code)] use super::item_type::ProfileItemType; use crate::utils::dirs; use ambassador::{Delegate, delegatable_trait}; use anyhow::{Context, Result, bail}; use nyanpasu_macro::EnumWrapperCombined; use std::{borrow::Borrow, fmt::Debug, fs, io::Write}; mod local; mod merge; pub mod prelude; mod remote; mod script; mod shared; mod utils; // private use utils pub use local::*; pub use merge::*; pub use remote::*; pub use script::*; pub use shared::*; /// Profile Setter Helper /// It is intended to be used in the default trait implementation, so it is PRIVATE. /// NOTE: this just a setter for fields, NOT do any file operation. #[delegatable_trait] trait ProfileMetaSetter { fn set_uid(&mut self, uid: String); fn set_name(&mut self, name: String); fn set_desc(&mut self, desc: Option); fn set_file(&mut self, file: String); fn set_updated(&mut self, updated: usize); } /// Some getter is provided due to `Profile` is a enum type, and could not be used directly. /// If access to inner data is needed, you should use the `as_xxx` or `as_mut_xxx` method to get the inner specific profile item. #[delegatable_trait] pub trait ProfileMetaGetter { fn name(&self) -> &str; fn desc(&self) -> Option<&str>; fn uid(&self) -> &str; fn updated(&self) -> usize; fn file(&self) -> &str; } #[delegatable_trait] pub trait ProfileKindGetter { fn kind(&self) -> ProfileItemType; } /// A trait that provides some common methods for profile items #[allow(private_bounds)] pub trait ProfileHelper: Sized + ProfileMetaSetter + ProfileMetaGetter + ProfileKindGetter + Clone { async fn duplicate(&self) -> Result { let mut duplicate_profile = self.clone(); let kind = duplicate_profile.kind(); let new_uid = utils::generate_uid(&kind); let new_file = ProfileSharedBuilder::default_file_name(&kind, &new_uid); let new_name = format!("{}-copy", duplicate_profile.name()); // copy file let path = dirs::profiles_path()?; let new_file_path = path.join(&new_file); let old_file_path = path.join(duplicate_profile.file()); tokio::fs::copy(&old_file_path, &new_file_path).await?; // apply new uid and name duplicate_profile.set_uid(new_uid); duplicate_profile.set_name(new_name); duplicate_profile.set_file(new_file); duplicate_profile.set_updated(chrono::Local::now().timestamp() as usize); Ok(duplicate_profile) } } pub trait ProfileCleanup: ProfileHelper { /// remove files and set the files to empty /// It should be useful when the profile is no longer needed, or pending to be deleted async fn remove_file(&mut self) -> Result<()> { let file = self.file(); let path = dirs::app_profiles_dir()?.join(file); tokio::fs::remove_file(path).await?; Ok(()) } } #[derive( serde::Deserialize, serde::Serialize, Debug, Delegate, Clone, EnumWrapperCombined, specta::Type, )] #[delegate(ProfileMetaSetter)] #[delegate(ProfileMetaGetter)] #[delegate(ProfileKindGetter)] #[delegate(ProfileFileIo)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Profile { Remote(RemoteProfile), Local(LocalProfile), Merge(MergeProfile), Script(ScriptProfile), } // what it actually did // #[derive(Default, Debug, Clone, Deserialize, Serialize)] // pub struct PrfSelected { // pub name: Option, // pub now: Option, // } impl ProfileCleanup for Profile {} impl ProfileHelper for Profile {} impl Profile { pub fn file(&self) -> &str { match self { Profile::Remote(profile) => &profile.shared.file, Profile::Local(profile) => &profile.shared.file, Profile::Merge(profile) => &profile.shared.file, Profile::Script(profile) => &profile.shared.file, } } /// get the file data pub fn read_file(&self) -> Result { let file = self.file(); let path = dirs::app_profiles_dir()?.join(file); if !path.exists() { bail!("file does not exist"); } fs::read_to_string(path).context("failed to read the file") } /// save the file data pub fn save_file>(&self, data: T) -> Result<()> { let file = self.file(); let path = dirs::app_profiles_dir()?.join(file); let mut file = std::fs::OpenOptions::new() .write(true) .truncate(true) .create(true) .open(path) .context("failed to open the file")?; file.write_all(data.borrow().as_bytes()) .context("failed to save the file") } } ================================================ FILE: backend/tauri/src/config/profile/item/prelude.rs ================================================ #![allow(unused_imports)] pub use super::{ ProfileCleanup, ProfileFileIo, ProfileHelper, ProfileKindGetter, ProfileMetaGetter, }; ================================================ FILE: backend/tauri/src/config/profile/item/remote.rs ================================================ use super::{ ProfileCleanup, ProfileFileIo, ProfileHelper, ProfileMetaGetter, ProfileMetaSetter, ProfileShared, ProfileSharedBuilder, ambassador_impl_ProfileFileIo, ambassador_impl_ProfileMetaGetter, ambassador_impl_ProfileMetaSetter, }; use crate::{ config::{ Config, ProfileKindGetter, profile::item_type::{ProfileItemType, ProfileUid}, }, utils::{config::NyanpasuReqwestProxyExt, dirs::APP_VERSION, help}, }; use ambassador::Delegate; use backon::Retryable; use derive_builder::Builder; use indexmap::IndexMap; use itertools::Itertools; use nyanpasu_macro::BuilderUpdate; use serde::{Deserialize, Serialize}; use serde_yaml::Mapping; use specta::Type; use std::time::Duration; use sysproxy::Sysproxy; use url::Url; const PROFILE_TYPE: ProfileItemType = ProfileItemType::Remote; pub trait RemoteProfileSubscription { async fn subscribe(&mut self, opts: Option) -> anyhow::Result<()>; } #[derive(Delegate, Debug, Clone, Deserialize, Serialize, Builder, BuilderUpdate, specta::Type)] #[builder(derive(Serialize, Deserialize, Debug, specta::Type))] #[builder(build_fn(skip, error = "RemoteProfileBuilderError"))] #[builder_update(patch_fn = "apply")] #[delegate(ProfileMetaSetter, target = "shared")] #[delegate(ProfileMetaGetter, target = "shared")] #[delegate(ProfileFileIo, target = "shared")] pub struct RemoteProfile { #[serde(flatten)] #[builder(field( ty = "ProfileSharedBuilder", build = "self.shared.build().map_err(Into::into)?" ))] #[builder_field_attr(serde(flatten))] #[builder_update(nested)] pub shared: ProfileShared, /// subscription url pub url: Url, /// subscription user info #[builder(default)] #[serde(default)] pub extra: SubscriptionInfo, /// remote profile options #[builder(field( ty = "RemoteProfileOptionsBuilder", build = "self.option.build().map_err(Into::into)?" ))] #[builder_update(nested)] #[builder_field_attr(serde(default))] #[serde(default)] pub option: RemoteProfileOptions, /// process chain #[builder(default)] #[serde(alias = "chains", default)] #[builder_field_attr(serde(alias = "chains", default))] pub chain: Vec, } impl RemoteProfile { pub fn builder() -> RemoteProfileBuilder { let mut builder = RemoteProfileBuilder::default(); let shared = ProfileShared::get_default_builder(&PROFILE_TYPE); builder.shared(shared); builder } } impl ProfileKindGetter for RemoteProfile { fn kind(&self) -> ProfileItemType { PROFILE_TYPE } } impl ProfileHelper for RemoteProfile {} impl ProfileCleanup for RemoteProfile {} impl RemoteProfileSubscription for RemoteProfile { #[tracing::instrument] async fn subscribe( &mut self, partial: Option, ) -> anyhow::Result<()> { let mut opts = self.option.clone(); if let Some(partial) = partial { opts.apply(partial); } let subscription = subscribe_url(&self.url, &opts).await?; self.extra = subscription.info; let content = serde_yaml::to_string(&subscription.data)?; self.write_file(content).await?; self.set_updated(chrono::Local::now().timestamp() as usize); Ok(()) } } #[derive(Debug)] struct Subscription { pub url: Url, pub filename: Option, pub data: Mapping, pub info: SubscriptionInfo, pub opts: Option, } /// perform a subscription #[tracing::instrument] async fn subscribe_url( url: &Url, options: &RemoteProfileOptions, ) -> Result { let options = options.apply_default(); let mut builder = reqwest::ClientBuilder::new() .use_rustls_tls() .no_proxy() .timeout(Duration::from_secs(30)); // TODO: 添加一个代理测试环节? let proxy_url: Option = // FIXME: 解耦此部分代理地址读取 if options.self_proxy.unwrap_or_default() && !cfg!(test) { // 使用软件自己的代理 let port = Config::verge() .latest() .verge_mixed_port .unwrap_or(Config::clash().data().get_mixed_port()); Some(format!("http://127.0.0.1:{port}")) } else if options.with_proxy.unwrap() { // 使用系统代理 if let Ok(p @ Sysproxy { enable: true, .. }) = Sysproxy::get_system_proxy() { Some(format!("http://{}:{}", p.host, p.port)) } else { None } } else { None }; if let Some(proxy_url) = proxy_url { builder = builder.swift_set_proxy(&proxy_url); } builder = builder.user_agent(options.user_agent.unwrap()); let client = builder.build().map_err(|e| SubscribeError::Network { url: url.to_string(), source: e, })?; let perform_req = || async { client.get(url.as_str()).send().await?.error_for_status() }; let resp = perform_req .retry(backon::ExponentialBuilder::default()) // Only retry on network errors or server errors .when(|result| { !result.is_status() || result.status().is_some_and(|status_code| { !matches!( status_code, reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::UNAUTHORIZED ) }) }) .await .map_err(|e| SubscribeError::Network { url: url.to_string(), source: e, })?; let header = resp.headers(); tracing::debug!("headers: {:#?}", header); // parse the Subscription UserInfo let extra = match header .get("subscription-userinfo") .or(header.get("Subscription-Userinfo")) { Some(value) => { tracing::debug!("Subscription-Userinfo: {:?}", value); let sub_info = value.to_str().unwrap_or(""); Some(SubscriptionInfo { upload: help::parse_str(sub_info, "upload").unwrap_or(0), download: help::parse_str(sub_info, "download").unwrap_or(0), total: help::parse_str(sub_info, "total").unwrap_or(0), expire: help::parse_str(sub_info, "expire").unwrap_or(0), }) } None => None, }; // Try to parse filename from headers // `Profile-Title` -> `Content-Disposition` let filename = utils::parse_profile_title_header(resp.headers()) .or_else(|| utils::parse_filename_from_content_disposition(resp.headers())); // parse the profile-update-interval let opts = match header .get("profile-update-interval") .or(header.get("Profile-Update-Interval")) { Some(value) => { tracing::debug!("profile-update-interval: {:?}", value); match value.to_str().unwrap_or("").parse::() { Ok(val) => Some(RemoteProfileOptions { update_interval: val * 60, // hour -> min ..RemoteProfileOptions::default() }), Err(_) => None, } } None => None, }; let data = resp .text_with_charset("utf-8") .await .map_err(|e| SubscribeError::Network { url: url.to_string(), source: e, })?; // process the charset "UTF-8 with BOM" let data = data.trim_start_matches('\u{feff}'); // check the data whether the valid yaml format let yaml = serde_yaml::from_str::(data).map_err(|e| SubscribeError::Parse { url: url.to_string(), source: e, })?; if !yaml.contains_key("proxies") && !yaml.contains_key("proxy-providers") { return Err(SubscribeError::ValidationFailed { url: url.to_string(), reason: "profile does not contain `proxies` or `proxy-providers`".to_string(), }); } Ok(Subscription { url: url.clone(), filename, data: yaml, info: extra.unwrap_or_default(), opts, }) } /// subscribe multiple urls #[tracing::instrument] async fn subscribe_urls( urls: &[Url], options: &RemoteProfileOptions, ) -> Result, SubscribeError> { if urls.is_empty() { return Err(SubscribeError::ValidationFailed { url: "".to_string(), reason: "urls should not be empty".to_string(), }); } let futures = urls.iter().map(|url| subscribe_url(url, options)); let results = futures::future::join_all(futures).await; let (successes, errors): (Vec<_>, Vec<_>) = results.into_iter().partition_map(|r| match r { Ok(val) => itertools::Either::Left(val), Err(err) => itertools::Either::Right(err), }); if !errors.is_empty() { return Err(SubscribeError::MultipleErrors(errors)); } Ok(successes) } /// merge the subscriptions #[tracing::instrument] fn merge_subscription( subscriptions: &[Subscription], ) -> (Mapping, IndexMap) { let mut data = Mapping::new(); let mut extra = IndexMap::new(); for (i, sub) in subscriptions.iter().enumerate() { if i == 0 { data.extend(sub.data.clone()); } else { let proxies = data.get_mut("proxies").unwrap().as_sequence_mut().unwrap(); let sub_proxies = sub.data.get("proxies").unwrap().as_sequence().unwrap(); proxies.extend(sub_proxies.iter().cloned()); } extra.insert(sub.url.clone(), sub.info); } (data, extra) } #[derive(thiserror::Error, Debug)] pub enum SubscribeError { #[error("network issue at {url}: {source}")] Network { url: String, #[source] source: reqwest::Error, }, #[error("yaml parse error at {url}: {source}")] Parse { url: String, #[source] source: serde_yaml::Error, }, #[error("invalid profile at {url}: {reason}")] ValidationFailed { url: String, reason: String }, #[error("multiple errors occurred: {0:?}")] MultipleErrors(Vec), } #[derive(thiserror::Error, Debug)] pub enum RemoteProfileBuilderError { #[error("validation error: {0}")] Validation(String), #[error("error: {0}")] UninitializedField(#[from] derive_builder::UninitializedFieldError), #[error("subscribe failed: {0}")] SubscribeFailed(#[from] SubscribeError), #[error("io error: {0}")] Io(#[from] std::io::Error), } impl RemoteProfileBuilder { fn default_shared(&self) -> ProfileSharedBuilder { ProfileShared::get_default_builder(&PROFILE_TYPE) } fn validate(&self) -> Result<(), RemoteProfileBuilderError> { if self.url.is_none() { return Err(RemoteProfileBuilderError::Validation( "url should not be null".into(), )); } Ok(()) } pub async fn build_no_blocking(&mut self) -> Result { self.validate()?; if self.shared.get_uid().is_none() { self.shared .uid(super::utils::generate_uid(&ProfileItemType::Remote)); } let url = self.url.take().unwrap(); let options = self .option .build() .map_err(|e| RemoteProfileBuilderError::Validation(e.to_string()))?; let mut subscription = subscribe_url(&url, &options).await?; let extra = subscription.info; if self.shared.get_name().is_none() && let Some(filename) = subscription.filename.take() { self.shared.name(filename); } if self.option.get_update_interval().is_none() && subscription.opts.is_some() { self.option .update_interval(subscription.opts.take().unwrap().update_interval); } let profile = RemoteProfile { shared: self .shared .build(&PROFILE_TYPE) .map_err(|e| RemoteProfileBuilderError::Validation(e.to_string()))?, url, extra, option: self.option.build().unwrap(), chain: self.chain.take().unwrap_or_default(), }; // write the profile to the file profile .shared .write_file( serde_yaml::to_string(&subscription.data) .map_err(|e| RemoteProfileBuilderError::Validation(e.to_string()))?, ) .await?; Ok(profile) } /// NOTE: this call will block current async runtime, so it should be called in a blocking context pub fn build(&mut self) -> Result { nyanpasu_utils::runtime::block_on_anywhere(self.build_no_blocking()) .map_err(|e| RemoteProfileBuilderError::Validation(e.to_string())) } } #[derive(Default, Debug, Clone, Copy, Deserialize, Serialize, Type)] pub struct SubscriptionInfo { pub upload: usize, pub download: usize, pub total: usize, pub expire: usize, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Builder, BuilderUpdate, Type)] #[builder(derive(Serialize, Deserialize, Debug, Type))] #[builder_update(patch_fn = "apply", getter)] pub struct RemoteProfileOptions { /// see issue #13 #[serde(skip_serializing_if = "Option::is_none")] #[builder(default, setter(strip_option))] pub user_agent: Option, /// for `remote` profile /// use system proxy #[serde(skip_serializing_if = "Option::is_none")] #[builder(default, setter(strip_option))] pub with_proxy: Option, /// use self proxy #[serde(skip_serializing_if = "Option::is_none")] #[builder(default = "Some(true)", setter(strip_option))] pub self_proxy: Option, /// subscription update interval #[builder(default = "120")] pub update_interval: u64, } impl Default for RemoteProfileOptions { fn default() -> Self { Self { user_agent: None, with_proxy: None, self_proxy: Some(true), update_interval: 120, // 2 hours } } } impl RemoteProfileOptions { pub fn apply_default(&self) -> Self { let mut options = self.clone(); if options.user_agent.is_none() { options.user_agent = Some(format!("clash-nyanpasu/v{APP_VERSION}")); } if options.with_proxy.is_none() { options.with_proxy = Some(false); } if options.self_proxy.is_none() { options.self_proxy = Some(false); } options } } mod utils { use base64::{Engine, engine::general_purpose}; use reqwest::header::{self, HeaderMap}; /// parse profile title from headers pub fn parse_profile_title_header(headers: &HeaderMap) -> Option { headers .get("profile-title") .and_then(|v| v.to_str().ok()) .and_then(|v| { if v.starts_with("base64:") { let encoded = v.trim_start_matches("base64:"); general_purpose::STANDARD .decode(encoded) .ok() .and_then(|bytes| String::from_utf8(bytes).ok()) } else { Some(v.to_string()) } }) } pub fn parse_filename_from_content_disposition(headers: &HeaderMap) -> Option { let filename = crate::utils::help::parse_str::( headers .get(header::CONTENT_DISPOSITION) .and_then(|v| v.to_str().ok()) .unwrap_or(""), "filename", )?; tracing::debug!("Content-Disposition: {:?}", filename); let filename = format!("{filename:?}"); let filename = filename.trim_matches('"'); match crate::utils::help::parse_str::(filename, "filename*") { Some(filename) => { let iter = percent_encoding::percent_decode(filename.as_bytes()); let filename = iter.decode_utf8().unwrap_or_default(); filename .split("''") .last() .map(|s| s.trim_matches('"').to_string()) } None => match crate::utils::help::parse_str::(filename, "filename") { Some(filename) => { let filename = filename.trim_matches('"'); Some(filename.to_string()) } None => None, }, } } } ================================================ FILE: backend/tauri/src/config/profile/item/script.rs ================================================ use super::{ ProfileCleanup, ProfileFileIo, ProfileHelper, ProfileMetaGetter, ProfileMetaSetter, ProfileShared, ProfileSharedBuilder, ambassador_impl_ProfileFileIo, ambassador_impl_ProfileMetaGetter, ambassador_impl_ProfileMetaSetter, }; use crate::{ config::{ProfileKindGetter, profile::item_type::ProfileItemType}, enhance::ScriptType, }; use ambassador::Delegate; use derive_builder::Builder; use nyanpasu_macro::BuilderUpdate; use serde::{Deserialize, Serialize}; #[derive( Default, Delegate, Debug, Clone, Deserialize, Serialize, Builder, BuilderUpdate, specta::Type, )] #[builder(derive(Debug, Serialize, Deserialize, specta::Type))] #[builder_update(patch_fn = "apply")] #[delegate(ProfileMetaSetter, target = "shared")] #[delegate(ProfileMetaGetter, target = "shared")] #[delegate(ProfileFileIo, target = "shared")] pub struct ScriptProfile { #[serde(flatten)] #[builder(field(ty = "ProfileSharedBuilder", build = "self.build_shared()?"))] #[builder_field_attr(serde(flatten))] #[builder_update(nested)] pub shared: ProfileShared, pub script_type: ScriptType, } impl ScriptProfileBuilder { fn build_shared(&self) -> Result { self.script_type .ok_or(ScriptProfileBuilderError::UninitializedField( "`script_type` is missing", )) .and_then(|script_type| { self.shared .build(&ProfileItemType::Script(script_type)) .map_err(|e| ScriptProfileBuilderError::from(e.to_string())) }) } } impl ProfileKindGetter for ScriptProfile { fn kind(&self) -> ProfileItemType { ProfileItemType::Script(self.script_type) } } impl ScriptProfile { pub fn builder(script_type: &ScriptType) -> ScriptProfileBuilder { let mut builder = ScriptProfileBuilder::default(); let shared = ProfileShared::get_default_builder(&ProfileItemType::Script(*script_type)); builder.script_type(*script_type); builder.shared(shared); builder } } impl ProfileHelper for ScriptProfile {} impl ProfileCleanup for ScriptProfile {} ================================================ FILE: backend/tauri/src/config/profile/item/shared.rs ================================================ use std::{fmt, str::FromStr}; use ambassador::delegatable_trait; use derive_builder::Builder; use nyanpasu_macro::BuilderUpdate; use serde::{Deserialize, Serialize, de::Visitor}; use crate::{ config::profile::item_type::ProfileItemType, enhance::ScriptType, utils::dirs::app_profiles_dir, }; use super::{ProfileMetaGetter, ProfileMetaSetter}; #[delegatable_trait] pub trait ProfileFileIo { async fn read_file(&self) -> std::io::Result; async fn write_file(&self, content: String) -> std::io::Result<()>; } #[derive(Default, Debug, Clone, Deserialize, Serialize, Builder, BuilderUpdate, specta::Type)] #[builder( derive(Debug, serde::Serialize, serde::Deserialize, specta::Type), build_fn(skip) )] #[builder_update(patch_fn = "apply", getter)] pub struct ProfileShared { /// Profile ID pub uid: String, /// profile name pub name: String, /// profile holds the file // #[serde(alias = "file", deserialize_with = "deserialize_option_single_or_vec")] #[builder(default = "self.default_files()?")] pub file: String, /// profile description #[builder(default, setter(strip_option))] pub desc: Option, #[builder(default = "chrono::Local::now().timestamp() as usize")] /// update time pub updated: usize, } impl ProfileShared { pub fn get_default_builder(kind: &ProfileItemType) -> ProfileSharedBuilder { let mut builder = ProfileShared::builder(); builder .name(ProfileSharedBuilder::default_name(kind).to_string()) .uid(ProfileSharedBuilder::default_uid(kind)); builder = builder.apply_default_file(kind).unwrap(); builder } } impl ProfileFileIo for ProfileShared { async fn read_file(&self) -> std::io::Result { let path = app_profiles_dir().map_err(std::io::Error::other)?; let file = path.join(&self.file); tokio::fs::read_to_string(file).await } async fn write_file(&self, content: String) -> std::io::Result<()> { let path = app_profiles_dir().map_err(std::io::Error::other)?; let file = path.join(&self.file); let mut file = tokio::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .open(&file) .await?; tokio::io::AsyncWriteExt::write_all(&mut file, content.as_bytes()).await } } impl ProfileSharedBuilder { fn default_uid(kind: &ProfileItemType) -> String { super::utils::generate_uid(kind) } pub fn default_name(kind: &ProfileItemType) -> &'static str { match kind { ProfileItemType::Remote => "Remote Profile", ProfileItemType::Local => "Local Profile", ProfileItemType::Merge => "Merge Profile", ProfileItemType::Script(_) => "Script Profile", } } pub fn default_file_name(kind: &ProfileItemType, uid: &str) -> String { match kind { ProfileItemType::Remote => format!("{uid}.yaml"), ProfileItemType::Local => format!("{uid}.yaml"), ProfileItemType::Merge => format!("{uid}.yaml"), ProfileItemType::Script(ScriptType::JavaScript) => format!("{uid}.js"), ProfileItemType::Script(ScriptType::Lua) => format!("{uid}.lua"), } } pub fn apply_default_file( mut self, kind: &ProfileItemType, ) -> Result { let file = match &self.uid { Some(uid) => Ok(Self::default_file_name(kind, uid)), None => Err("uid should not be null".to_string()), }?; self.file = Some(file); Ok(self) } pub fn is_file_none(&self) -> bool { self.file.is_none() } pub fn build( &self, kind: &ProfileItemType, ) -> Result { let mut builder = self.clone(); if self.uid.is_none() { builder.uid = Some(Self::default_uid(kind)); } if self.name.is_none() { builder.name = Some(Self::default_name(kind).to_string()); } if self.file.is_none() { builder.file = Some(Self::default_file_name(kind, builder.uid.as_ref().unwrap())); } Ok(ProfileShared { uid: builder.uid.unwrap(), name: builder.name.unwrap(), file: builder.file.unwrap(), desc: builder.desc.clone().unwrap_or_default(), updated: builder .updated .unwrap_or_else(|| chrono::Local::now().timestamp() as usize), }) } } impl ProfileShared { pub fn builder() -> ProfileSharedBuilder { ProfileSharedBuilder::default() } } impl ProfileMetaGetter for ProfileShared { fn name(&self) -> &str { &self.name } fn desc(&self) -> Option<&str> { self.desc.as_deref() } fn uid(&self) -> &str { &self.uid } fn updated(&self) -> usize { self.updated } fn file(&self) -> &str { &self.file } } impl ProfileMetaSetter for ProfileShared { fn set_name(&mut self, name: String) { self.name = name; } fn set_desc(&mut self, desc: Option) { self.desc = desc; } fn set_file(&mut self, file: String) { self.file = file; } fn set_uid(&mut self, uid: String) { self.uid = uid; } fn set_updated(&mut self, updated: usize) { self.updated = updated; } } pub fn deserialize_single_or_vec<'de, D, T>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, T: FromStr, T::Err: fmt::Display, { use serde::de::Error; struct StringOrVec(std::marker::PhantomData); impl<'de, T> Visitor<'de> for StringOrVec where T: FromStr, T::Err: fmt::Display, { type Value = Vec; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("a string or a sequence of strings") } fn visit_str(self, value: &str) -> Result where E: serde::de::Error, { T::from_str(value).map(|v| vec![v]).map_err(E::custom) } fn visit_seq(self, mut seq: A) -> Result where A: serde::de::SeqAccess<'de>, { let mut vec = Vec::new(); while let Some(value) = seq.next_element::()? { let parsed_value = T::from_str(&value).map_err(A::Error::custom)?; vec.push(parsed_value); } Ok(vec) } } deserializer.deserialize_any(StringOrVec(std::marker::PhantomData)) } ================================================ FILE: backend/tauri/src/config/profile/item/utils.rs ================================================ use crate::{config::profile::item_type::ProfileItemType, utils::help}; pub fn generate_uid(kind: &ProfileItemType) -> String { match kind { ProfileItemType::Remote => help::get_uid("r"), ProfileItemType::Local => help::get_uid("l"), ProfileItemType::Script(_) => help::get_uid("s"), ProfileItemType::Merge => help::get_uid("m"), } } ================================================ FILE: backend/tauri/src/config/profile/item_type.rs ================================================ use crate::enhance::ScriptType; use serde::{Deserialize, Serialize}; use strum::EnumString; #[derive( Debug, EnumString, Clone, Copy, Serialize, Deserialize, Default, PartialEq, specta::Type, )] #[strum(serialize_all = "snake_case")] #[serde(tag = "kind", content = "variant", rename_all = "snake_case")] pub enum ProfileItemType { #[serde(rename = "remote")] Remote, #[serde(rename = "local")] #[default] Local, #[serde(rename = "script")] Script(ScriptType), #[serde(rename = "merge")] Merge, } pub type ProfileUid = String; ================================================ FILE: backend/tauri/src/config/profile/mod.rs ================================================ pub mod builder; pub mod item; pub mod item_type; pub mod profiles; pub use builder::ProfileBuilder; use item::deserialize_single_or_vec; #[cfg(test)] mod tests; ================================================ FILE: backend/tauri/src/config/profile/profiles.rs ================================================ use super::{ builder::ProfileBuilder, item::{Profile, prelude::*}, item_type::ProfileUid, }; use crate::utils::{dirs, help}; use anyhow::{Result, bail}; use derive_builder::Builder; use indexmap::IndexMap; use nyanpasu_macro::BuilderUpdate; use rayon::prelude::*; use serde::{Deserialize, Serialize}; use serde_yaml::Mapping; use std::borrow::Borrow; use tracing_attributes::instrument; /// Define the `profiles.yaml` schema #[derive(Debug, Clone, Deserialize, Serialize, Builder, BuilderUpdate, specta::Type)] #[builder(derive(Serialize, Deserialize, specta::Type))] #[builder_update(patch_fn = "apply")] pub struct Profiles { /// same as PrfConfig.current #[serde(default)] #[serde(deserialize_with = "super::deserialize_single_or_vec")] pub current: Vec, #[serde(default)] /// same as PrfConfig.chain pub chain: Vec, #[serde(default)] /// record valid fields for clash pub valid: Vec, #[serde(default)] /// profile list pub items: Vec, } impl Default for Profiles { fn default() -> Self { Self { current: vec![], chain: vec![], valid: vec![ "dns".into(), "unified-delay".into(), "tcp-concurrent".into(), ], items: vec![], } } } impl Profiles { pub fn new() -> Self { match dirs::profiles_path().and_then(|path| help::read_yaml::(&path)) { Ok(profiles) => profiles, Err(err) => { log::error!(target: "app", "{err:?}\n - use the default profiles"); Self::default() } } } pub fn save_file(&self) -> Result<()> { help::save_yaml( &dirs::profiles_path()?, self, Some("# Profiles Config for Clash Nyanpasu"), ) } pub fn get_current(&self) -> &[ProfileUid] { &self.current } /// get items ref pub fn get_items(&self) -> &[Profile] { &self.items } /// find the item by the uid pub fn get_item(&self, uid: &str) -> Result<&Profile> { self.get_items() .iter() .find(|e| e.uid() == uid) .ok_or_else(|| anyhow::anyhow!("failed to get the profile item \"uid:{uid}\"")) } /// append new item pub fn append_item(&mut self, item: Profile) -> Result<()> { self.items.push(item); self.save_file() } /// reorder items pub fn reorder(&mut self, active_id: String, over_id: String) -> Result<()> { let items = &mut self.items; let mut old_index = None; let mut new_index = None; for (i, item) in items.iter().enumerate() { if item.uid() == active_id { old_index = Some(i); } if item.uid() == over_id { new_index = Some(i); } } if old_index.is_none() || new_index.is_none() { return Ok(()); } let item = items.remove(old_index.unwrap()); items.insert(new_index.unwrap(), item); self.save_file() } /// reorder items with the full order list pub fn reorder_by_list>(&mut self, order: &[T]) -> Result<()> { let mut items = std::mem::take(&mut self.items); let mut new_items = Vec::with_capacity(items.len()); for uid in order { if let Some(index) = items.iter().position(|e| e.uid() == uid.borrow()) { new_items.push(items.remove(index)); } } // Keep unmatched items to avoid accidental data loss when order is partial. new_items.extend(items); self.items = new_items; self.save_file() } /// update the item value #[instrument] pub fn patch_item(&mut self, uid: String, patch: ProfileBuilder) -> Result<()> { tracing::debug!("patch item: {uid} with {patch:?}"); let item = self .items .iter_mut() .find(|e| e.uid() == uid) .ok_or(anyhow::anyhow!( "failed to find the profile item \"uid:{uid}\"" ))?; tracing::debug!("patch item: {item:?}"); match (item, patch) { (Profile::Remote(item), ProfileBuilder::Remote(builder)) => item.apply(builder), (Profile::Local(item), ProfileBuilder::Local(builder)) => item.apply(builder), (Profile::Merge(item), ProfileBuilder::Merge(builder)) => item.apply(builder), (Profile::Script(item), ProfileBuilder::Script(builder)) => item.apply(builder), _ => bail!("profile type mismatch when patching"), }; self.save_file() } /// replace item pub fn replace_item>(&mut self, uid: T, item: Profile) -> Result<()> { let uid = uid.borrow(); let index = self.items.iter().position(|e| e.uid() == uid); if let Some(index) = index { unsafe { *self.items.get_unchecked_mut(index) = item; } } self.save_file() } /// delete item /// if delete the current then return true pub async fn delete_item>(&mut self, uid: T) -> Result { let uid = uid.borrow(); let items = &mut self.items; // get the index let index = items.iter().position(|e| e.uid() == uid); if let Some(index) = index { let mut profile = items.remove(index); profile.remove_file().await?; } // delete the original uid let mut current = self .current .iter() .filter(|e| e != &uid) .cloned() .collect::>(); let is_current = self.current != current; // 尝试激活存在的第一个配置 if current.is_empty() { let item = items.iter().find(|e| e.is_local() || e.is_remote()); if let Some(item) = item { current.push(item.uid().to_string()); } } self.current = current; self.save_file()?; Ok(is_current) } /// 获取current指向的配置内容 pub fn current_mappings(&self) -> Result> { let current = self .items .iter() .filter(|e| self.current.iter().any(|uid| uid == e.uid())) .collect::>(); let (successes, failures): (Vec<(&str, Mapping)>, Vec) = current .par_iter() .map(|item| { let file_path = dirs::app_profiles_dir()?.join(item.file()); if !file_path.exists() { return Err(anyhow::anyhow!("failed to find the file: {:?}", file_path)); } help::read_merge_mapping(&file_path).map(|mapping| (item.uid(), mapping)) }) .partition_map(|item| match item { Ok(item) => itertools::Either::Left(item), Err(err) => itertools::Either::Right(err), }); if !failures.is_empty() { bail!("failed to read the file: {:#?}", failures); } let map = IndexMap::from_iter(successes); Ok(map) } } ================================================ FILE: backend/tauri/src/config/profile/tests.rs ================================================ use crate::{ config::profile::{ item::{ LocalProfile, MergeProfile, Profile, RemoteProfile, RemoteProfileOptions, ScriptProfile, SubscriptionInfo, }, item_type::ProfileItemType, }, enhance::ScriptType, }; use serde_yaml; use tokio_util::sync::CancellationToken; use url::Url; const REMOTE_SAMPLE_DATA: &str = include_str!("../../../tests/sample_clash_config.yaml"); struct Guard(CancellationToken, Option>); impl Drop for Guard { fn drop(&mut self) { self.0.cancel(); if let Some(handle) = self.1.take() { nyanpasu_utils::runtime::block_on_anywhere(handle).unwrap(); } } } async fn create_test_server() -> (Guard, url::Url) { let port = port_scanner::request_open_port().unwrap(); let url = Url::parse(&format!("http://127.0.0.1:{port}")).unwrap(); let token = CancellationToken::new(); let token_clone = token.clone(); let (is_ready_tx, is_ready_rx) = tokio::sync::oneshot::channel(); let handle = tokio::spawn(async move { let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")) .await .unwrap(); let _ = is_ready_tx.send(()); let app = axum::Router::new().route( "/sample_clash_config", axum::routing::get(|| async { REMOTE_SAMPLE_DATA }), ); axum::serve(listener, app.into_make_service()) .with_graceful_shutdown(async move { token.cancelled().await }) .await .unwrap(); }); let _ = is_ready_rx.await; let guard = Guard(token_clone, Some(handle)); (guard, url) } /// 测试整数类型不匹配问题 /// 这是原始问题的核心:YAML 解析时整数类型可能不一致 #[test] fn test_integer_type_mismatch_in_yaml() { // 测试不同的整数表示形式 let yaml_with_i32 = r#" type: remote uid: "test-uid-1" name: "Test Profile" updated: 1234567890 url: "https://example.com/config.yaml" file: sample.yaml "#; let yaml_with_i64 = r#" type: remote uid: "test-uid-2" name: "Test Profile" updated: 9999999999999 url: "https://example.com/config.yaml" file: sample.yaml "#; let yaml_with_u64 = r#" type: remote uid: "test-uid-3" name: "Test Profile" updated: 18446744073709551615 url: "https://example.com/config.yaml" file: sample.yaml "#; // 应该都能成功解析 let profile1: Result = serde_yaml::from_str(yaml_with_i32); let profile2: Result = serde_yaml::from_str(yaml_with_i64); let profile3: Result = serde_yaml::from_str(yaml_with_u64); assert!(profile1.is_ok(), "Failed to parse i32: {:?}", profile1); assert!(profile2.is_ok(), "Failed to parse i64: {:?}", profile2); // u64 最大值可能会被转换为 usize,在 32 位系统上可能失败 if std::mem::size_of::() == 8 { assert!(profile3.is_ok(), "Failed to parse u64: {:?}", profile3); } } /// 测试 tagged enum 的正确序列化和反序列化 #[test] fn test_tagged_enum_serialization() { // 创建不同类型的 Profile let remote_profile = Profile::Remote(RemoteProfile { shared: crate::config::profile::item::ProfileShared { uid: "remote-1".to_string(), name: "Remote Profile".to_string(), file: "remote-1.yaml".to_string(), desc: Some("A remote profile".to_string()), updated: 1234567890, }, url: Url::parse("https://example.com/config.yaml").unwrap(), extra: SubscriptionInfo::default(), option: RemoteProfileOptions::default(), chain: vec![], }); let local_profile = Profile::Local(LocalProfile { shared: crate::config::profile::item::ProfileShared { uid: "local-1".to_string(), name: "Local Profile".to_string(), file: "local-1.yaml".to_string(), desc: None, updated: 1234567890, }, symlinks: None, chain: vec![], }); let merge_profile = Profile::Merge(MergeProfile { shared: crate::config::profile::item::ProfileShared { uid: "merge-1".to_string(), name: "Merge Profile".to_string(), file: "merge-1.yaml".to_string(), desc: Some("Merge multiple profiles".to_string()), updated: 1234567890, }, }); let script_profile = Profile::Script(ScriptProfile { shared: crate::config::profile::item::ProfileShared { uid: "script-1".to_string(), name: "Script Profile".to_string(), file: "script-1.js".to_string(), desc: None, updated: 1234567890, }, script_type: ScriptType::JavaScript, }); // 测试序列化 let remote_yaml = serde_yaml::to_string(&remote_profile).unwrap(); let local_yaml = serde_yaml::to_string(&local_profile).unwrap(); let merge_yaml = serde_yaml::to_string(&merge_profile).unwrap(); let script_yaml = serde_yaml::to_string(&script_profile).unwrap(); println!("Remote YAML:\n{}", remote_yaml); println!("Local YAML:\n{}", local_yaml); println!("Merge YAML:\n{}", merge_yaml); println!("Script YAML:\n{}", script_yaml); // 验证 YAML 包含正确的 type 标签 assert!(remote_yaml.contains("type: remote")); assert!(local_yaml.contains("type: local")); assert!(merge_yaml.contains("type: merge")); assert!(script_yaml.contains("type: script")); // 测试反序列化 let remote_parsed: Profile = serde_yaml::from_str(&remote_yaml).unwrap(); let local_parsed: Profile = serde_yaml::from_str(&local_yaml).unwrap(); let merge_parsed: Profile = serde_yaml::from_str(&merge_yaml).unwrap(); let script_parsed: Profile = serde_yaml::from_str(&script_yaml).unwrap(); // 验证反序列化后的类型正确 assert!(matches!(remote_parsed, Profile::Remote(_))); assert!(matches!(local_parsed, Profile::Local(_))); assert!(matches!(merge_parsed, Profile::Merge(_))); assert!(matches!(script_parsed, Profile::Script(_))); } #[test] fn test_backward_compatibility() { // 测试新的脚本格式能被正确识别 let new_format = r#"uid: siL1cvjnvLB6 type: script script_type: javascript name: 花☁️处理 file: siL1cvjnvLB6.js desc: '' updated: 1720954186"#; serde_yaml::from_str::(new_format).expect("new format should works"); } /// 测试 ProfileKindGetter trait #[test] fn test_profile_kind_getter() { use crate::config::ProfileKindGetter; let remote = RemoteProfile { shared: Default::default(), url: Url::parse("https://example.com").unwrap(), extra: SubscriptionInfo::default(), option: RemoteProfileOptions::default(), chain: vec![], }; assert_eq!(remote.kind(), ProfileItemType::Remote); let local = LocalProfile { shared: Default::default(), symlinks: None, chain: vec![], }; assert_eq!(local.kind(), ProfileItemType::Local); let merge = MergeProfile { shared: Default::default(), }; assert_eq!(merge.kind(), ProfileItemType::Merge); let script_js = ScriptProfile { shared: Default::default(), script_type: ScriptType::JavaScript, }; assert_eq!( script_js.kind(), ProfileItemType::Script(ScriptType::JavaScript) ); } /// 测试 builder 的默认值设置 #[test_log::test(tokio::test(flavor = "multi_thread"))] async fn test_builder_defaults() { let (_guard, mut url) = create_test_server().await; let remote_builder = RemoteProfile::builder(); let local_builder = LocalProfile::builder(); let merge_builder = MergeProfile::builder(); // let script_builder = ScriptProfile::builder(&ScriptType::JavaScript); // 构建时应该自动填充默认值 let mut remote_builder = remote_builder; url.set_path("sample_clash_config"); remote_builder.url(url.clone()); let remote = remote_builder.build().expect("build remote profile"); assert!(!remote.shared.uid.is_empty()); assert!(!remote.shared.name.is_empty()); assert!(!remote.shared.file.is_empty()); let local = local_builder.build(); assert!(local.is_ok()); let local = local.unwrap(); assert!(!local.shared.uid.is_empty()); assert_eq!(local.shared.name, "Local Profile"); let merge = merge_builder.build(); assert!(merge.is_ok()); let merge = merge.unwrap(); assert!(!merge.shared.uid.is_empty()); assert_eq!(merge.shared.name, "Merge Profile"); } /// 测试错误处理 #[test] fn test_error_handling() { // 无效的 type 值 let invalid_type = r#" type: invalid_type uid: "test" name: "Test" "#; let result: Result = serde_yaml::from_str(invalid_type); assert!(result.is_err()); // Script 类型但缺少 script_type let missing_script_type = r#" type: script uid: "script-test" name: "Script Test" "#; let result: Result = serde_yaml::from_str(missing_script_type); // 应该使用默认的 script_type 或者失败 println!("Script without script_type result: {:?}", result); // Remote 类型但缺少必需的 url 字段 let missing_url = r#" type: remote uid: "remote-test" name: "Remote Test" "#; let result: Result = serde_yaml::from_str(missing_url); assert!(result.is_err(), "Should fail without required url field"); } /// 测试大数字的处理 #[test] fn test_large_numbers() { let test_cases = vec![ (0usize, "zero"), (1234567890usize, "normal"), (usize::MAX, "max"), ]; for (value, desc) in test_cases { let profile = Profile::Local(LocalProfile { shared: crate::config::profile::item::ProfileShared { uid: format!("test-{}", desc), name: format!("Test {}", desc), file: format!("test-{}.yaml", desc), desc: None, updated: value, }, symlinks: None, chain: vec![], }); let yaml = serde_yaml::to_string(&profile).unwrap(); let parsed: Profile = serde_yaml::from_str(&yaml).unwrap(); if let Profile::Local(local) = parsed { assert_eq!(local.shared.updated, value, "Failed for {}", desc); } else { panic!("Expected Local profile"); } } } ================================================ FILE: backend/tauri/src/config/runtime.rs ================================================ use serde::{Deserialize, Serialize}; use serde_yaml::Mapping; use crate::enhance::PostProcessingOutput; #[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)] pub struct PatchRuntimeConfig { #[serde(default)] pub allow_lan: Option, #[serde(default)] pub ipv6: Option, #[serde(default)] pub log_level: Option, #[serde(default)] pub mode: Option, } #[derive(Default, Debug, Clone, Deserialize, Serialize)] pub struct IRuntime { pub config: Option, // 记录在配置中(包括merge和script生成的)出现过的keys // 这些keys不一定都生效 pub exists_keys: Vec, pub postprocessing_output: PostProcessingOutput, } impl IRuntime { pub fn new() -> Self { Self::default() } // 这里只更改 allow-lan | ipv6 | log-level | mode pub fn patch_config(&mut self, patch: Mapping) { tracing::debug!("patching runtime config: {:?}", patch); if let Some(config) = self.config.as_mut() { let patch_config: PatchRuntimeConfig = serde_yaml::from_value(serde_yaml::Value::Mapping(patch.clone())) .unwrap_or_default(); [ ( "allow-lan", patch_config.allow_lan.map(serde_yaml::Value::Bool), ), ("ipv6", patch_config.ipv6.map(serde_yaml::Value::Bool)), ( "log-level", patch_config.log_level.map(serde_yaml::Value::String), ), ("mode", patch_config.mode.map(serde_yaml::Value::String)), ] .into_iter() .filter_map(|(key, value)| value.map(|v| (key.into(), v))) .for_each(|(k, v)| { config.insert(k, v); }); } } } ================================================ FILE: backend/tauri/src/consts.rs ================================================ use once_cell::sync::{Lazy, OnceCell}; use tauri::AppHandle; pub const MAIN_WINDOW_LABEL: &str = "main"; pub const LEGACY_WINDOW_LABEL: &str = "legacy"; pub const EDITOR_WINDOW_LABEL: &str = "editor"; pub const APP_NAME: &str = "Clash Nyanpasu"; pub const APP_EDITOR_NAME: &str = "Clash Nyanpasu - Editor"; #[derive(Debug, serde::Serialize, Clone, specta::Type)] pub struct BuildInfo { pub app_name: &'static str, pub app_version: &'static str, pub pkg_version: &'static str, pub commit_hash: &'static str, pub commit_author: &'static str, pub commit_date: &'static str, pub build_date: &'static str, pub build_profile: &'static str, pub build_platform: &'static str, pub rustc_version: &'static str, pub llvm_version: &'static str, } pub static BUILD_INFO: Lazy = Lazy::new(|| BuildInfo { app_name: env!("CARGO_PKG_NAME"), app_version: env!("CARGO_PKG_VERSION"), pkg_version: env!("NYANPASU_VERSION"), commit_hash: env!("COMMIT_HASH"), commit_author: env!("COMMIT_AUTHOR"), commit_date: env!("COMMIT_DATE"), build_date: env!("BUILD_DATE"), build_profile: env!("BUILD_PROFILE"), build_platform: env!("BUILD_PLATFORM"), rustc_version: env!("RUSTC_VERSION"), llvm_version: env!("LLVM_VERSION"), }); pub static IS_APPIMAGE: Lazy = Lazy::new(|| std::env::var("APPIMAGE").is_ok()); #[cfg(target_os = "windows")] pub static IS_PORTABLE: Lazy = Lazy::new(|| { if cfg!(windows) { let dir = crate::utils::dirs::app_install_dir().unwrap(); let portable_file = dir.join(".config/PORTABLE"); portable_file.exists() } else { false } }); /// A Tauri AppHandle copy for access from global context, /// maybe only access it from panic handler static APP_HANDLE: OnceCell = OnceCell::new(); pub fn app_handle() -> &'static AppHandle { APP_HANDLE.get().expect("app handle not initialized") } pub(super) fn setup_app_handle(app_handle: AppHandle) { let _ = APP_HANDLE.set(app_handle); } ================================================ FILE: backend/tauri/src/core/clash/api.rs ================================================ use crate::config::Config; use anyhow::{Context, Result}; use indexmap::IndexMap; use reqwest::{Method, StatusCode, header::HeaderMap}; use serde::{Deserialize, Serialize}; use serde_yaml::Mapping; use specta::Type; use std::{ collections::HashMap, fmt::{self, Display, Formatter}, }; use tracing_attributes::instrument; use url::Url; /// PUT /configs /// path 是绝对路径 #[instrument] pub async fn put_configs(config_path: &str) -> Result<()> { let path = "/configs"; let mut data = HashMap::new(); data.insert("path", config_path); let _ = perform_request((Method::PUT, path, Data(data))).await?; Ok(()) } /// PATCH /configs #[instrument] pub async fn patch_configs(config: &Mapping) -> Result<()> { let path = "/configs"; let _ = perform_request((Method::PATCH, path, Data(config))).await?; Ok(()) } #[derive(Debug, Clone, Deserialize, Serialize, Type)] #[serde(rename_all = "camelCase")] pub struct ProxiesRes { #[serde(default)] pub proxies: IndexMap, } #[derive(Debug, Clone, Deserialize, Serialize, Default, Type)] #[serde(rename_all = "camelCase")] pub struct ProxyItemHistory { pub time: String, pub delay: i64, } #[derive(Debug, Clone, Deserialize, Serialize, Default, Type)] #[serde(rename_all = "camelCase")] pub struct ProxyItem { pub name: String, pub r#type: String, // TODO: 考虑改成枚举 pub udp: bool, pub history: Vec, pub all: Option>, pub now: Option, // 当前选中的代理 pub provider: Option, pub alive: Option, // Mihomo Or Premium Only #[serde(skip_serializing_if = "Option::is_none")] pub xudp: Option, // Mihomo Only #[serde(skip_serializing_if = "Option::is_none")] pub tfo: Option, // Mihomo Only #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, // Mihomo Only #[serde(default)] pub hidden: bool, // Mihomo Only // extra: {}, // Mihomo Only } impl From for ProxyItem { fn from(item: ProxyProviderItem) -> Self { let ProxyProviderItem { name, r#type, proxies, vehicle_type: _, test_url: _, expected_status: _, } = item; let now = proxies .iter() .find(|p| p.now.is_some()) .map(|p| p.name.clone()) .unwrap_or_default(); let all = proxies.iter().map(|p| p.name.clone()).collect(); Self { name, r#type: r#type.to_string(), udp: false, history: vec![], all: Some(all), now: Some(now), provider: None, alive: None, xudp: None, tfo: None, icon: None, hidden: false, } } } /// GET /proxies /// 获取代理列表 #[instrument] pub async fn get_proxies() -> Result { let path = "/proxies"; let resp: ProxiesRes = perform_request((Method::GET, path)).await?.json().await?; Ok(resp) } /// GET /proxies/{name} /// 获取单个代理 /// name: 代理名称 /// 返回代理的配置 /// #[allow(dead_code)] #[instrument] pub async fn get_proxy(name: String) -> Result { let path = format!("/proxies/{name}"); let resp: ProxyItem = perform_request((Method::GET, path.as_str())) .await? .json() .await?; Ok(resp) } /// PUT /proxies/{group} /// 选择代理 /// group: 代理分组名称 /// name: 代理名称 #[instrument] pub async fn update_proxy(group: &str, name: &str) -> Result<()> { let path = format!("/proxies/{group}"); let mut data = HashMap::new(); data.insert("name", name); let _ = perform_request((Method::PUT, path.as_str(), Data(data))).await?; Ok(()) } #[derive(Debug, Clone, Deserialize, Serialize, Type)] pub enum VehicleType { File, #[serde(rename = "HTTP")] Http, Compatible, Unknown, } #[derive(Debug, Clone, Deserialize, Serialize, specta::Type)] pub enum ProviderType { Proxy, Rule, Unknown, } impl Display for ProviderType { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { ProviderType::Proxy => write!(f, "Proxy"), ProviderType::Rule => write!(f, "Rule"), ProviderType::Unknown => write!(f, "Unknown"), } } } #[derive(Debug, Clone, Deserialize, Serialize, Type)] #[serde(rename_all = "camelCase")] pub struct ProxyProviderItem { pub name: String, pub r#type: ProviderType, pub proxies: Vec, pub vehicle_type: VehicleType, #[serde(skip_serializing_if = "Option::is_none")] pub test_url: Option, // Mihomo Only #[serde(skip_serializing_if = "Option::is_none")] pub expected_status: Option, // Mihomo Only } #[derive(Debug, Clone, Deserialize, Serialize, Type)] #[serde(rename_all = "camelCase")] pub struct ProvidersProxiesRes { #[serde(default)] pub providers: IndexMap, } /// GET /providers/proxies /// 获取所有代理集合的所有代理信息 #[instrument] pub async fn get_providers_proxies() -> Result { let path = "/providers/proxies"; let resp: ProvidersProxiesRes = perform_request((Method::GET, path)).await?.json().await?; Ok(resp) } /// GET /providers/proxies/:name /// 获取单个代理集合的所有代理信息 /// group: 代理集合名称 #[allow(dead_code)] #[instrument] pub async fn get_providers_proxies_group(group: String) -> Result { let path = format!("/providers/proxies/{group}"); let resp: ProxyProviderItem = perform_request((Method::GET, path.as_str())) .await? .json() .await?; Ok(resp) } /// PUT /providers/proxies/:name /// 更新代理集合 /// name: 代理集合名称 #[instrument] pub async fn update_providers_proxies_group(name: &str) -> Result<()> { let path = format!("/providers/proxies/{name}"); let _ = perform_request((Method::PUT, path.as_str())).await?; Ok(()) } /// GET /providers/proxies/:name/healthcheck /// 获取代理集合的健康检查 /// name: 代理集合名称 #[allow(dead_code)] #[instrument] pub async fn get_providers_proxies_healthcheck(name: String) -> Result { let path = format!("/providers/proxies/{name}/healthcheck"); let resp: Mapping = perform_request((Method::GET, path.as_str())) .await? .json() .await?; Ok(resp) } #[derive(Default, Debug, Clone, Deserialize, Serialize, Type)] pub struct DelayRes { delay: u64, } /// GET /proxies/{name}/delay /// 获取代理延迟 #[instrument] pub async fn get_proxy_delay(name: String, test_url: Option) -> Result { let path = format!("/proxies/{name}/delay"); let default_url = "http://www.gstatic.com/generate_204"; let test_url = test_url .map(|s| if s.is_empty() { default_url.into() } else { s }) .unwrap_or(default_url.into()); let query = Query([("timeout", "10000"), ("url", &test_url)]); let resp: DelayRes = perform_request((Method::GET, path.as_str(), query)) .await? .json() .await?; Ok(resp) } /// 根据clash info获取clash服务地址和请求头 #[instrument] fn clash_client_info() -> Result<(String, HeaderMap)> { let client = { Config::clash().data().get_client_info() }; let server = format!("http://{}", client.server); let mut headers = HeaderMap::new(); headers.insert("Content-Type", "application/json".parse()?); if let Some(secret) = client.secret { let secret = format!("Bearer {secret}").parse()?; headers.insert("Authorization", secret); } Ok((server, headers)) } /// The Request Parameters struct PerformRequest { method: reqwest::Method, path: String, query: Option, data: Option, } /// A newtype wrapper for query parameters struct Query(T); /// A newtype wrapper for request body struct Data(T); impl From<(reqwest::Method, &str)> for PerformRequest<(), ()> { fn from((method, path): (reqwest::Method, &str)) -> Self { Self { method, path: path.to_string(), data: None, query: None, } } } impl From<(reqwest::Method, &str, Data)> for PerformRequest where T: Serialize, { fn from((method, path, Data(data)): (reqwest::Method, &str, Data)) -> Self { Self { method, path: path.to_string(), data: Some(data), query: None, } } } impl From<(reqwest::Method, &str, Query)> for PerformRequest<(), T> where T: Serialize, { fn from((method, path, Query(query)): (reqwest::Method, &str, Query)) -> Self { Self { method, path: path.to_string(), data: None, query: Some(query), } } } impl From<(reqwest::Method, &str, Query, Data)> for PerformRequest where D: Serialize, Q: Serialize, { fn from( (method, path, Query(query), Data(data)): (reqwest::Method, &str, Query, Data), ) -> Self { Self { method, path: path.to_string(), data: Some(data), query: Some(query), } } } #[instrument(skip_all, fields( method = tracing::field::Empty, url = tracing::field::Empty, query = tracing::field::Empty, data = tracing::field::Empty, ))] async fn perform_request(param: impl Into>) -> Result where Q: Serialize + core::fmt::Debug, D: Serialize + core::fmt::Debug, { let PerformRequest { method, path, data, query, } = param.into(); let (host, headers) = clash_client_info().context("failed to get clash client info")?; let base_url = Url::parse(&host).context("failed to parse host")?; let opts = url::Url::options().base_url(Some(&base_url)); let url = opts.parse(&path).context("failed to parse path")?; let span = tracing::Span::current(); span.record("method", tracing::field::display(&method)); span.record("url", tracing::field::display(&url)); span.record("query", tracing::field::debug(&query)); span.record("data", tracing::field::debug(&data)); async { let client = reqwest::ClientBuilder::new().no_proxy().build()?; let mut builder = client.request(method.clone(), url.clone()).headers(headers); if let Some(query) = &query { builder = builder.query(query); } if let Some(data) = &data { builder = builder.json(data); } let resp = builder.send().await?; if let Err(err) = resp.error_for_status_ref() { match err.status() { // Try To parse error message Some(StatusCode::BAD_REQUEST) => { let Ok(bytes) = resp.bytes().await else { return Err(err.into()); }; let message: serde_json::Value = match serde_json::from_slice(&bytes) { Ok(v) => v, Err(_) => { let s = String::from_utf8_lossy(&bytes); serde_json::Value::String(s.to_string()) } }; return Err(err).context(format!("message: {message}")); } _ => return Err(err).context("clash api error"), } } Ok(resp) } .await .inspect_err(|e| tracing::error!(method = %method, url = %url, query = ?query, data = ?data, "failed to perform request: {:?}", e)) } /// 缩短clash的日志 #[instrument] pub fn parse_log(log: String) -> String { if log.starts_with("time=") && log.len() > 33 { return log[33..].to_owned(); } if log.len() > 9 { return log[9..].to_owned(); } log } /// 缩短clash -t的错误输出 /// 仅适配 clash p核 8-26、clash meta 1.13.1 #[instrument] pub fn parse_check_output(log: String) -> String { let t = log.find("time="); let m = log.find("msg="); let mr = log.rfind('"'); if let (Some(_), Some(m), Some(mr)) = (t, m, mr) { let e = match log.find("level=error msg=") { Some(e) => e + 17, None => m + 5, }; if mr > m { return log[e..mr].to_owned(); } } let l = log.find("error="); let r = log.find("path=").or(Some(log.len())); if let (Some(l), Some(r)) = (l, r) { return log[(l + 6)..(r - 1)].to_owned(); } log } /// DELETE /connections /// Close all connections or a specific connection by ID #[instrument] pub async fn delete_connections(id: Option<&str>) -> Result<()> { let path = match id { Some(id) => format!("/connections/{}", id), None => "/connections".to_string(), }; let _ = perform_request((Method::DELETE, path.as_str())).await?; Ok(()) } #[test] fn test_parse_check_output() { let str1 = r#"xxxx\n time="2022-11-18T20:42:58+08:00" level=error msg="proxy 0: 'alpn' expected type 'string', got unconvertible type '[]interface {}'""#; let str2 = r#"20:43:49 ERR [Config] configuration file test failed error=proxy 0: unsupport proxy type: hysteria path=xxx"#; let str3 = r#" "time="2022-11-18T21:38:01+08:00" level=info msg="Start initial configuration in progress" time="2022-11-18T21:38:01+08:00" level=error msg="proxy 0: 'alpn' expected type 'string', got unconvertible type '[]interface {}'" configuration file xxx\n "#; let res1 = parse_check_output(str1.into()); let res2 = parse_check_output(str2.into()); let res3 = parse_check_output(str3.into()); println!("res1: {res1}"); println!("res2: {res2}"); println!("res3: {res3}"); assert_eq!(res1, res3); } #[test] fn test_path() { let host = "http://127.0.0.1:9090"; let path_with_prefix = "/configs"; let base_url = Url::parse(host).context("failed to parse host").unwrap(); let opts = url::Url::options().base_url(Some(&base_url)); let url = opts .parse(path_with_prefix) .context("failed to parse path") .unwrap(); assert_eq!(url.to_string(), "http://127.0.0.1:9090/configs"); let path_without_prefix = "configs"; let url = opts .parse(path_without_prefix) .context("failed to parse path") .unwrap(); assert_eq!(url.to_string(), "http://127.0.0.1:9090/configs"); } ================================================ FILE: backend/tauri/src/core/clash/core.rs ================================================ use super::api; use crate::{ config::{Config, ConfigType, nyanpasu::ClashCore}, core::logger::Logger, log_err, utils::dirs, }; use anyhow::{Context, Result, bail}; use camino::Utf8PathBuf; #[cfg(target_os = "macos")] use nyanpasu_ipc::api::network::set_dns::NetworkSetDnsReq; use nyanpasu_ipc::{ api::{core::start::CoreStartReq, status::CoreState}, utils::get_current_ts, }; use nyanpasu_utils::{ core::{ CommandEvent, instance::{CoreInstance, CoreInstanceBuilder}, }, runtime::{block_on, spawn}, }; use once_cell::sync::OnceCell; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use specta::Type; use std::{ borrow::Cow, path::PathBuf, sync::{ Arc, atomic::{AtomicBool, AtomicI64, Ordering}, }, time::Duration, }; use tokio::time::sleep; use tracing_attributes::instrument; #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Type)] #[serde(rename_all = "snake_case")] pub enum RunType { /// Run as child process directly Normal, /// Run by Nyanpasu Service via a ipc call Service, // TODO: Not implemented yet /// Run as elevated process, if profile advice to run as elevated Elevated, } impl Default for RunType { fn default() -> Self { let enable_service = { *Config::verge() .latest() .enable_service_mode .as_ref() .unwrap_or(&false) }; if enable_service && crate::core::service::ipc::get_ipc_state().is_connected() { tracing::info!("run core as service"); RunType::Service } else { tracing::info!("run core as child process"); RunType::Normal } } } #[derive(Debug)] enum Instance { Child { child: Mutex>, stated_changed_at: Arc, kill_flag: Arc, }, Service { config_path: PathBuf, core_type: nyanpasu_utils::core::CoreType, }, } impl Instance { pub fn try_new(run_type: RunType) -> Result { let core_type: nyanpasu_utils::core::CoreType = { (Config::verge() .latest() .clash_core .as_ref() .unwrap_or(&ClashCore::ClashPremium)) .into() }; let data_dir = camino::Utf8PathBuf::from_path_buf(dirs::app_data_dir()?) .map_err(|e| anyhow::anyhow!("failed to convert data dir to utf8 path: {:?}", e))?; let binary = camino::Utf8PathBuf::from_path_buf(find_binary_path(&core_type)?) .map_err(|e| anyhow::anyhow!("failed to convert binary path to utf8 path: {:?}", e))?; let config_path = camino::Utf8PathBuf::from_path_buf(Config::generate_file( ConfigType::Run, )?) .map_err(|e| anyhow::anyhow!("failed to convert config path to utf8 path: {:?}", e))?; let pid_path = camino::Utf8PathBuf::from_path_buf(dirs::clash_pid_path()?) .map_err(|e| anyhow::anyhow!("failed to convert pid path to utf8 path: {:?}", e))?; match run_type { RunType::Normal => { let instance = Arc::new( CoreInstanceBuilder::default() .core_type(core_type) .app_dir(data_dir) .binary_path(binary) .config_path(config_path.clone()) .pid_path(pid_path) .build()?, ); Ok(Instance::Child { child: Mutex::new(instance), kill_flag: Arc::new(AtomicBool::new(false)), stated_changed_at: Arc::new(AtomicI64::new(get_current_ts())), }) } RunType::Service => Ok(Instance::Service { config_path: config_path.into(), core_type, }), RunType::Elevated => { todo!() } } } pub fn run_type(&self) -> RunType { match self { Instance::Child { .. } => RunType::Normal, Instance::Service { .. } => RunType::Service, } } pub async fn start(&self) -> Result<()> { match self { Instance::Child { child, kill_flag, stated_changed_at, } => { let instance = { let child = child.lock(); child.clone() }; let (is_premium, core_type) = { let child = child.lock(); ( matches!( child.core_type, nyanpasu_utils::core::CoreType::Clash( nyanpasu_utils::core::ClashCoreType::ClashPremium ) ), child.core_type.clone(), ) }; let (tx, mut rx) = tokio::sync::mpsc::channel::>(1); // use mpsc channel just to avoid type moved error, though it never fails let stated_changed_at = stated_changed_at.clone(); let kill_flag = kill_flag.clone(); // This block below is to handle the stdio from the core process tokio::spawn(async move { match instance.run().await { Ok((_, mut rx)) => { kill_flag.store(false, Ordering::Release); // reset kill flag let mut err_buf: Vec = Vec::with_capacity(6); loop { if let Some(event) = rx.recv().await { match event { CommandEvent::Stdout(line) => { if is_premium { let log = api::parse_log(line.clone()); log::info!(target: "app", "[{core_type}]: {log}"); } else { log::info!(target: "app", "[{core_type}]: {line}"); } Logger::global().set_log(line); } CommandEvent::Stderr(line) => { log::error!(target: "app", "[{core_type}]: {line}"); err_buf.push(line.clone()); Logger::global().set_log(line); } CommandEvent::Error(e) => { log::error!(target: "app", "[{core_type}]: {e}"); let err = anyhow::anyhow!(format!( "{}\n{}", e, err_buf.join("\n") )); Logger::global().set_log(e); let _ = tx.send(Err(err)).await; stated_changed_at .store(get_current_ts(), Ordering::Relaxed); break; } CommandEvent::Terminated(status) => { log::error!( target: "app", "core terminated with status: {status:?}" ); stated_changed_at .store(get_current_ts(), Ordering::Relaxed); if status.code != Some(0) || !matches!(status.signal, Some(9) | Some(15)) { let err = anyhow::anyhow!(format!( "core terminated with status: {:?}\n{}", status, err_buf.join("\n") )); tracing::error!("{}\n{}", err, err_buf.join("\n")); if tx.send(Err(err)).await.is_err() && !kill_flag.load(Ordering::Acquire) { std::thread::spawn(move || { block_on(async { tracing::info!( "Trying to recover core." ); let _ = CoreManager::global() .recover_core() .await; }); }); } } break; } CommandEvent::DelayCheckpointPass => { tracing::debug!("delay checkpoint pass"); stated_changed_at .store(get_current_ts(), Ordering::Relaxed); tx.send(Ok(())).await.unwrap(); } } } } } Err(err) => { spawn(async move { tx.send(Err(err.into())).await.unwrap(); }); } } }); rx.recv().await.unwrap()?; Ok(()) } Instance::Service { config_path, core_type, } => { let payload = CoreStartReq { config_file: Cow::Borrowed(config_path), core_type: Cow::Borrowed(core_type), }; nyanpasu_ipc::client::shortcuts::Client::service_default() .start_core(&payload) .await?; Ok(()) } } } pub async fn stop(&self) -> Result<()> { let state = self.state().await; match self { Instance::Child { child, stated_changed_at, kill_flag, } => { if matches!(state.as_ref(), CoreState::Stopped(_)) { anyhow::bail!("core is already stopped"); } kill_flag.store(true, Ordering::Release); let child = { let child = child.lock(); child.clone() }; child.kill().await?; stated_changed_at.store(get_current_ts(), Ordering::Relaxed); Ok(()) } Instance::Service { .. } => { Ok(nyanpasu_ipc::client::shortcuts::Client::service_default() .stop_core() .await?) } } } #[allow(dead_code)] pub async fn restart(&self) -> Result<()> { let state = self.state().await; if matches!(state.as_ref(), CoreState::Running) { self.stop().await?; } self.start().await } pub async fn state<'a>(&self) -> Cow<'a, CoreState> { match self { Instance::Child { child, .. } => { let this = child.lock(); Cow::Borrowed(match this.state() { nyanpasu_utils::core::instance::CoreInstanceState::Running => { &CoreState::Running } nyanpasu_utils::core::instance::CoreInstanceState::Stopped => { &CoreState::Stopped(None) } }) } Instance::Service { .. } => { let status = nyanpasu_ipc::client::shortcuts::Client::service_default() .status() .await .map(|info| match info.core_infos.state { nyanpasu_ipc::api::status::CoreState::Running => CoreState::Running, nyanpasu_ipc::api::status::CoreState::Stopped(_) => { CoreState::Stopped(None) } }) .unwrap_or(CoreState::Stopped(None)); Cow::Owned(status) } } } /// get core state with state changed timestamp pub async fn status<'a>(&self) -> (Cow<'a, CoreState>, i64) { match self { Instance::Child { child, stated_changed_at, .. } => { let this = child.lock(); ( Cow::Borrowed(match this.state() { nyanpasu_utils::core::instance::CoreInstanceState::Running => { &CoreState::Running } nyanpasu_utils::core::instance::CoreInstanceState::Stopped => { &CoreState::Stopped(None) } }), stated_changed_at.load(Ordering::Relaxed), ) } Instance::Service { .. } => { let status = nyanpasu_ipc::client::shortcuts::Client::service_default() .status() .await; match status { Ok(info) => ( Cow::Owned(match info.core_infos.state { nyanpasu_ipc::api::status::CoreState::Running => CoreState::Running, nyanpasu_ipc::api::status::CoreState::Stopped(_) => { CoreState::Stopped(None) } }), info.core_infos.state_changed_at, ), Err(_) => (Cow::Owned(CoreState::Stopped(None)), 0), } } } } } #[derive(Debug)] pub struct CoreManager { instance: Mutex>>, #[cfg(target_os = "macos")] previous_dns: tokio::sync::Mutex>>, } impl CoreManager { pub fn global() -> &'static CoreManager { static CORE_MANAGER: OnceCell = OnceCell::new(); CORE_MANAGER.get_or_init(|| CoreManager { instance: Mutex::new(None), #[cfg(target_os = "macos")] previous_dns: tokio::sync::Mutex::new(None), }) } pub async fn status<'a>(&self) -> (Cow<'a, CoreState>, i64, RunType) { let instance = { let instance = self.instance.lock(); instance.as_ref().cloned() }; if let Some(instance) = instance { let (state, ts) = instance.status().await; (state, ts, instance.run_type()) } else { ( Cow::Owned(CoreState::Stopped(None)), 0_i64, RunType::default(), ) } } pub fn init(&self) -> Result<()> { tauri::async_runtime::spawn(async { // 启动clash log_err!(Self::global().run_core().await); }); Ok(()) } /// 检查配置是否正确 pub async fn check_config(&self) -> Result<()> { use nyanpasu_utils::core::instance::CoreInstance; let config_path = Config::generate_file(ConfigType::Check)?; let config_path = Utf8PathBuf::from_path_buf(config_path) .map_err(|_| anyhow::anyhow!("failed to convert config path to utf8 path"))?; let clash_core = { Config::verge().latest().clash_core }; let clash_core = clash_core.unwrap_or(ClashCore::ClashPremium); let clash_core: nyanpasu_utils::core::CoreType = (&clash_core).into(); let app_dir = dirs::app_data_dir()?; let app_dir = Utf8PathBuf::from_path_buf(app_dir) .map_err(|_| anyhow::anyhow!("failed to convert app dir to utf8 path"))?; let binary_path = find_binary_path(&clash_core)?; let binary_path = Utf8PathBuf::from_path_buf(binary_path) .map_err(|_| anyhow::anyhow!("failed to convert binary path to utf8 path"))?; log::debug!(target: "app", "check config in `{clash_core}`"); CoreInstance::check_config_(&clash_core, &config_path, &binary_path, &app_dir) .await .context("failed to check config") .inspect_err(|e| log::error!(target: "app", "failed to check config: {e:?}"))?; Ok(()) } /// 启动核心 pub async fn run_core(&self) -> Result<()> { { let instance = { let instance = self.instance.lock(); instance.as_ref().cloned() }; if let Some(instance) = instance.as_ref() && matches!(instance.state().await.as_ref(), CoreState::Running) { log::debug!(target: "app", "core is already running, stop it first..."); instance.stop().await?; } } // Reload clash config from file to get latest user preferences (e.g., mode) Config::clash().reload(); log::debug!(target: "app", "reloaded clash config from file"); // Regenerate runtime config with the reloaded settings Config::generate().await?; // 检查端口是否可用 Config::clash() .latest() .prepare_external_controller_port()?; let run_type = RunType::default(); let instance = Arc::new(Instance::try_new(run_type)?); #[cfg(target_os = "macos")] { let enable_tun = Config::verge().latest().enable_tun_mode.unwrap_or(false); let _ = self .change_default_network_dns(enable_tun) .await .inspect_err(|e| log::error!(target: "app", "failed to set system dns: {:?}", e)); } { let mut this = self.instance.lock(); *this = Some(instance.clone()); } instance.start().await } /// 重启内核 pub async fn recover_core(&'static self) -> Result<()> { // 清除原来的实例 { let instance = { let mut this = self.instance.lock(); this.take() }; if let Some(instance) = instance && matches!(instance.state().await.as_ref(), CoreState::Running) { log::debug!(target: "app", "core is running, stop it first..."); instance.stop().await?; } } if let Err(err) = self.run_core().await { log::error!(target: "app", "failed to recover clash core"); log::error!(target: "app", "{err:?}"); tokio::time::sleep(Duration::from_secs(5)).await; // sleep 5s std::thread::spawn(move || { block_on(async { let _ = CoreManager::global().recover_core().await; }) }); } Ok(()) } /// 停止核心运行 pub async fn stop_core(&self) -> Result<()> { #[cfg(target_os = "macos")] let _ = self .change_default_network_dns(false) .await .inspect_err(|e| log::error!(target: "app", "failed to set system dns: {:?}", e)); let instance = { let instance = self.instance.lock(); instance.as_ref().cloned() }; if let Some(instance) = instance.as_ref() { instance.stop().await?; } Ok(()) } /// 切换核心 #[instrument(skip(self))] pub async fn change_core(&self, clash_core: Option) -> Result<()> { let clash_core = clash_core.ok_or(anyhow::anyhow!("clash core is null"))?; log::debug!(target: "app", "change core to `{clash_core}`"); Config::verge().draft().clash_core = Some(clash_core); // 更新配置 Config::generate().await?; self.check_config().await?; // 清掉旧日志 Logger::global().clear_log(); match self.run_core().await { Ok(_) => { tracing::info!("change core success"); Config::verge().apply(); Config::runtime().apply(); log_err!(Config::verge().latest().save_file()); Ok(()) } Err(err) => { tracing::error!("failed to change core: {err:?}"); Config::verge().discard(); Config::runtime().discard(); self.run_core().await?; Err(err) } } } /// 更新proxies那些 /// 如果涉及端口和外部控制则需要重启 pub async fn update_config(&self) -> Result<()> { log::debug!(target: "app", "try to update clash config"); // 更新配置 Config::generate().await?; // 检查配置是否正常 self.check_config().await?; // 更新运行时配置 let path = Config::generate_file(ConfigType::Run)?; let path = dirs::path_to_str(&path)?; // 发送请求 发送5次 for i in 0..5 { match api::put_configs(path).await { Ok(_) => break, Err(err) => { if i < 4 { log::info!(target: "app", "{err:?}"); } else { bail!(err); } } } sleep(Duration::from_millis(250)).await; } Ok(()) } #[cfg(target_os = "macos")] pub async fn change_default_network_dns(&self, enabled: bool) -> Result<()> { use anyhow::Context; use nyanpasu_utils::network::macos::*; let run_type = RunType::default(); log::debug!(target: "app", "try to set system dns"); let default_device = get_default_network_hardware_port().context("failed to get default network device")?; log::debug!(target: "app", "current default network device: {:?}", default_device); let tun_device_ip = Config::clash() .clone() .latest() .get_tun_device_ip() .parse::() .context("failed to parse tun device ip")?; log::debug!(target: "app", "current tun device ip: {:?}", tun_device_ip); let current_dns = get_dns(&default_device).context("failed to get current dns")?; log::debug!(target: "app", "current dns: {:?}", current_dns); let current_dns_contains_tun_device_ip = current_dns .as_ref() .is_some_and(|dns| dns.contains(&tun_device_ip)); let mut previous_dns = self.previous_dns.lock().await; let previous_dns_clone = previous_dns.clone(); let new_dns = match enabled { true if !current_dns_contains_tun_device_ip => { *previous_dns = current_dns; Some(Some(vec![tun_device_ip])) } false if current_dns_contains_tun_device_ip => Some(previous_dns.take()), _ => None, }; if let Some(new_dns) = new_dns { log::debug!(target: "app", "set new dns: {:?}", new_dns); let result = match run_type { RunType::Service => { nyanpasu_ipc::client::shortcuts::Client::service_default() .set_dns(&NetworkSetDnsReq { // FIXME: improve this type notation dns_servers: new_dns .as_ref() .map(|dns| dns.iter().map(Cow::Borrowed).collect()), }) .await .map_err(anyhow::Error::from) } _ => set_dns(&default_device, new_dns).map_err(anyhow::Error::from), }; if let Err(e) = result.context("failed to set system dns") { *previous_dns = previous_dns_clone; return Err(e); } } Ok(()) } } // TODO: support system path search via a config or flag // FIXME: move this fn to nyanpasu-utils /// Search the binary path of the core: Data Dir -> Sidecar Dir pub fn find_binary_path(core_type: &nyanpasu_utils::core::CoreType) -> std::io::Result { let data_dir = dirs::app_data_dir() .map_err(|err| std::io::Error::new(std::io::ErrorKind::NotFound, err.to_string()))?; let binary_path = data_dir.join(core_type.get_executable_name()); if binary_path.exists() { return Ok(binary_path); } let app_dir = dirs::app_install_dir() .map_err(|err| std::io::Error::new(std::io::ErrorKind::NotFound, err.to_string()))?; let binary_path = app_dir.join(core_type.get_executable_name()); if binary_path.exists() { return Ok(binary_path); } Err(std::io::Error::new( std::io::ErrorKind::NotFound, format!("{} not found", core_type.get_executable_name()), )) } ================================================ FILE: backend/tauri/src/core/clash/mod.rs ================================================ use backon::ExponentialBuilder; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use specta::Type; use tauri_specta::Event; pub mod api; pub mod core; pub mod proxies; pub mod ws; pub static CLASH_API_DEFAULT_BACKOFF_STRATEGY: Lazy = Lazy::new(|| { ExponentialBuilder::default() .with_min_delay(std::time::Duration::from_millis(50)) .with_max_delay(std::time::Duration::from_secs(5)) .with_max_times(5) }); #[derive(Serialize, Deserialize, Debug, Clone, Type, Event)] pub struct ClashConnectionsEvent(pub ws::ClashConnectionsConnectorEvent); pub fn setup>(manager: &M) -> anyhow::Result<()> { let ws_connector = ws::ClashConnectionsConnector::new(); manager.manage(ws_connector.clone()); let app_handle = manager.app_handle().clone(); tauri::async_runtime::spawn(async move { // TODO: refactor it while clash core manager use tauri event dispatcher to notify the core state changed { tokio::time::sleep(std::time::Duration::from_secs(10)).await; // TODO: clash-rs ws authorization is not working match ws_connector.start().await { Ok(_) => { tracing::info!( "ws_connector started successfully clash-rs may be errored here." ); } // TODO: wait for clash-rs to fix Err(e) => { tracing::error!("ws_connector failed to start: {:?}", e); } } } let mut rx = ws_connector.subscribe(); while let Ok(event) = rx.recv().await { ClashConnectionsEvent(event).emit(&app_handle).unwrap(); } }); Ok(()) } ================================================ FILE: backend/tauri/src/core/clash/proxies.rs ================================================ /// This module is used to manage the proxies for the Tauri application. /// It is used to provide the unite interface between tray and frontend. /// TODO: add a diff algorithm to reduce the data transfer, and the rerendering of the tray menu. use super::{CLASH_API_DEFAULT_BACKOFF_STRATEGY, api}; use adler::adler32; use anyhow::Result; use backon::Retryable; use indexmap::IndexMap; use log::warn; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use specta::Type; use std::sync::{Arc, OnceLock}; use tokio::{sync::broadcast, try_join}; use tracing_attributes::instrument; #[derive(Debug, Clone, Deserialize, Serialize, Default, Type)] #[serde(rename_all = "camelCase")] pub struct ProxyGroupItem { pub name: String, pub r#type: String, // TODO: 考虑改成枚举 pub udp: bool, pub history: Vec, pub all: Vec, pub now: Option, // 当前选中的代理 pub provider: Option, pub alive: Option, // Mihomo Or Premium Only #[serde(skip_serializing_if = "Option::is_none")] pub xudp: Option, // Mihomo Only #[serde(skip_serializing_if = "Option::is_none")] pub tfo: Option, // Mihomo Only #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, // Mihomo Only #[serde(default)] pub hidden: bool, // Mihomo Only // extra: {}, // Mihomo Only } impl From for ProxyGroupItem { fn from(item: api::ProxyItem) -> Self { let all = vec![]; ProxyGroupItem { name: item.name, r#type: item.r#type, udp: item.udp, history: item.history, all, now: item.now, provider: item.provider, alive: item.alive, xudp: item.xudp, tfo: item.tfo, icon: item.icon, hidden: item.hidden, } } } #[derive(Debug, Clone, Deserialize, Serialize, Default, Type)] #[serde(rename_all = "camelCase")] pub struct Proxies { pub global: ProxyGroupItem, pub direct: api::ProxyItem, pub groups: Vec, pub records: IndexMap, pub proxies: Vec, } async fn fetch_proxies() -> Result<(api::ProxiesRes, api::ProvidersProxiesRes)> { try_join!(api::get_proxies(), api::get_providers_proxies()) } impl Proxies { #[instrument] pub async fn fetch() -> Result { let (inner_proxies, providers_proxies) = fetch_proxies .retry(*CLASH_API_DEFAULT_BACKOFF_STRATEGY) .await?; let inner_proxies = inner_proxies.proxies; // 1. filter out the Http or File type provider proxies let providers_proxies: IndexMap = { let records = providers_proxies.providers; records .into_iter() .filter(|(_k, v)| { matches!( v.vehicle_type, api::VehicleType::Http | api::VehicleType::File ) }) .collect() }; // 2. mapping provider => providerProxiesItem to name => ProxyItem let mut provider_map = IndexMap::::new(); for (provider, record) in providers_proxies.iter() { let name = record.name.clone(); let mut record: api::ProxyItem = record.clone().into(); record.provider = Some(provider.clone()); provider_map.insert(name, record); } let generate_item = |name: &str| { if let Some(r) = inner_proxies.get(name) { r.clone() } else if let Some(r) = provider_map.get(name) { r.clone() } else { api::ProxyItem { name: name.to_string(), r#type: "Unknown".to_string(), udp: false, history: vec![], ..Default::default() } } }; let global = inner_proxies.get("GLOBAL"); let direct = inner_proxies .get("DIRECT") .ok_or(anyhow::anyhow!("DIRECT is missing in /proxies"))? .clone(); // It should be always exists let reject = inner_proxies .get("REJECT") .ok_or(anyhow::anyhow!("REJECT is missing in /proxies"))? .clone(); // It should be always exists // 3. generate the proxies groups let groups: Vec = match global { Some(api::ProxyItem { all: Some(all), .. }) => { let all = all.clone(); all.into_iter() .filter(|name| { matches!( inner_proxies.get(name), Some(api::ProxyItem { all: Some(_), .. }) ) }) .map(|name| { let item = inner_proxies .get(&name) .unwrap_or(&api::ProxyItem::default()) .clone(); let item_all = item.all.clone().unwrap_or_default(); let mut item: ProxyGroupItem = item.into(); item.all = item_all .into_iter() .map(|name| generate_item(&name)) .collect(); item }) .collect() } _ => { let mut groups: Vec = inner_proxies .clone() .into_values() .filter(|v| v.name == "GLOBAL" && v.all.is_some()) .map(|v| { let all = v.all.clone().unwrap_or_default(); let mut item: ProxyGroupItem = v.clone().into(); item.all = all.into_iter().map(|name| generate_item(&name)).collect(); item }) .collect(); groups.sort_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase())); groups } }; // 4. generate the proxies let mut proxies: Vec = vec![direct.clone(), reject]; proxies.extend(inner_proxies.clone().into_values().filter(|v| { matches!(v.name.as_str(), "DIRECT" | "REJECT") && (v.all.is_none() || v.all.as_ref().unwrap().is_empty()) })); // 5. generate the global let global: Option = global.map(|v| { let all = v.all.clone().unwrap_or_default(); let mut item: ProxyGroupItem = v.clone().into(); item.all = all.into_iter().map(|name| generate_item(&name)).collect(); item }); Ok(Proxies { global: global.unwrap_or_default(), direct, groups, records: inner_proxies, proxies, }) } } pub struct ProxiesGuard { inner: Proxies, checksum: Option, updated_at: u64, sender: broadcast::Sender<()>, } impl ProxiesGuard { pub fn global() -> &'static Arc> { static PROXIES: OnceLock>> = OnceLock::new(); PROXIES.get_or_init(|| { let (tx, _) = broadcast::channel(5); // 默认提供 5 个消费位置,提供一定的缓冲 Arc::new(RwLock::new(ProxiesGuard { checksum: None, sender: tx, inner: Proxies::default(), updated_at: 0, })) }) } pub fn get_receiver(&self) -> broadcast::Receiver<()> { self.sender.subscribe() } pub fn replace(&mut self, proxies: Proxies, checksum: u32) { let now = chrono::Utc::now().timestamp() as u64; self.inner = proxies; self.checksum = Some(checksum); self.updated_at = now; if let Err(e) = self.sender.send(()) { warn!( target: "clash::proxies", "send update signal failed: {e:?}" ); } } // pub async fn select_proxy(&mut self, group: &str, name: &str) -> Result<()> { // api::update_proxy(group, name).await?; // self.update().await?; // Ok(()) // } pub fn inner(&self) -> &Proxies { &self.inner } pub fn updated_at(&self) -> u64 { self.updated_at } pub fn is_updated(&self) -> bool { let now = chrono::Utc::now().timestamp() as u64; now - self.updated_at <= 3 } } pub trait ProxiesGuardExt { async fn update(&self) -> Result<()>; async fn select_proxy(&self, group: &str, name: &str) -> Result<()>; } type ProxiesGuardSingleton = &'static Arc>; impl ProxiesGuardExt for ProxiesGuardSingleton { async fn update(&self) -> Result<()> { let proxies = Proxies::fetch().await?; let buf = serde_json::to_string(&proxies)?; let checksum = adler32(buf.as_bytes())?; { let reader = self.read(); if reader.checksum == Some(checksum) { return Ok(()); } } let mut writer = self.write(); writer.replace(proxies, checksum); Ok(()) } async fn select_proxy(&self, group: &str, name: &str) -> Result<()> { api::update_proxy(group, name).await?; self.update().await?; Ok(()) } } ================================================ FILE: backend/tauri/src/core/clash/ws.rs ================================================ use std::{ future::Future, ops::Deref, sync::{Arc, atomic::Ordering}, }; use anyhow::Context; use atomic_enum::atomic_enum; use backon::Retryable; use futures_util::StreamExt; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use specta::Type; use tokio::{sync::mpsc::Receiver, task::JoinHandle}; use tokio_tungstenite::{ connect_async, tungstenite::{client::IntoClientRequest, handshake::client::Request, protocol::Message}, }; use crate::log_err; #[tracing::instrument] async fn connect_clash_server( endpoint: Request, ) -> anyhow::Result> { let (stream, _) = connect_async(endpoint).await?; let (_, mut read) = stream.split(); let (tx, rx) = tokio::sync::mpsc::channel(32); tokio::spawn(async move { while let Some(msg) = read.next().await { match msg { Ok(Message::Text(text)) => match serde_json::from_str(&text) { Ok(data) => { let _ = tx.send(data).await; } Err(e) => { tracing::error!("failed to deserialize json: {}", e); } }, Ok(Message::Binary(bin)) => match serde_json::from_slice(&bin) { Ok(data) => { let _ = tx.send(data).await; } Err(e) => { tracing::error!("failed to deserialize json: {}", e); } }, Ok(Message::Close(_)) => { tracing::info!("server closed connection"); break; } Err(e) => { tracing::error!("failed to read message: {}", e); } _ => {} } } }); Ok(rx) } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct ClashConnectionsMessage { download_total: u64, upload_total: u64, // other fields are omitted } #[derive(Debug, Clone, Default, Copy, Type, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ClashConnectionsInfo { pub download_total: u64, pub upload_total: u64, pub download_speed: u64, pub upload_speed: u64, } #[derive(Debug, Clone, Type, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[serde(tag = "kind", content = "data")] pub enum ClashConnectionsConnectorEvent { StateChanged(ClashConnectionsConnectorState), Update(ClashConnectionsInfo), } #[derive(PartialEq, Eq, Type, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[atomic_enum] pub enum ClashConnectionsConnectorState { Disconnected, Connecting, Connected, } pub struct ClashConnectionsConnectorInner { state: AtomicClashConnectionsConnectorState, connection_handler: Mutex>>, broadcast_tx: tokio::sync::broadcast::Sender, info: Mutex, } // TODO: #[derive(Clone)] pub struct ClashConnectionsConnector { inner: Arc, } impl Deref for ClashConnectionsConnector { type Target = ClashConnectionsConnectorInner; fn deref(&self) -> &Self::Target { &self.inner } } impl ClashConnectionsConnector { pub fn new() -> Self { Self { inner: Arc::new(ClashConnectionsConnectorInner::new()), } } pub fn endpoint() -> anyhow::Result { let (server, secret) = { let info = crate::Config::clash().data().get_client_info(); (info.server, info.secret) }; let url = format!("ws://{server}/connections"); let mut request = url .into_client_request() .context("failed to create client request")?; if let Some(secret) = secret { request.headers_mut().insert( "Authorization", format!("Bearer {secret}") .parse() .context("failed to create header value")?, ); } Ok(request) } #[allow(clippy::manual_async_fn)] // FIXME: move to async fn while rust new solver got merged // ref: https://github.com/rust-lang/rust/issues/123072 fn start_internal(&self) -> impl Future> + Send + use<'_> { async { self.dispatch_state_changed(ClashConnectionsConnectorState::Connecting); let endpoint = Self::endpoint().context("failed to create endpoint")?; log::debug!("connecting to clash connections ws server: {endpoint:?}"); let mut rx = connect_clash_server::(endpoint).await?; self.dispatch_state_changed(ClashConnectionsConnectorState::Connected); let this = self.clone(); let mut connection_handler = self.connection_handler.lock(); let handle = tokio::spawn(async move { loop { match rx.recv().await { Some(msg) => { this.update(msg); } None => { tracing::info!("clash ws server closed connection, trying to restart"); // The connection was closed, let's restart the connector this.dispatch_state_changed( ClashConnectionsConnectorState::Disconnected, ); tokio::spawn(async move { let restart = async || this.restart().await; log_err!( restart .retry(backon::ExponentialBuilder::default()) .sleep(tokio::time::sleep) .await .context("failed to restart clash connections") ); }); break; } } } }); *connection_handler = Some(handle); Ok(()) } } pub async fn start(&self) -> anyhow::Result<()> { self.start_internal().await.inspect_err(|_| { self.dispatch_state_changed(ClashConnectionsConnectorState::Disconnected); }) } pub async fn restart(&self) -> anyhow::Result<()> { self.stop().await; self.start().await } } impl ClashConnectionsConnectorInner { pub fn new() -> Self { Self { state: AtomicClashConnectionsConnectorState::new( ClashConnectionsConnectorState::Disconnected, ), connection_handler: Mutex::new(None), broadcast_tx: tokio::sync::broadcast::channel(5).0, info: Mutex::new(ClashConnectionsInfo::default()), } } pub fn state(&self) -> ClashConnectionsConnectorState { self.state.load(Ordering::Acquire) } fn dispatch_state_changed(&self, state: ClashConnectionsConnectorState) { self.state.store(state, Ordering::Release); // SAFETY: the failures only there no active receivers, // so that the message will be dropped directly let _ = self .broadcast_tx .send(ClashConnectionsConnectorEvent::StateChanged(state)); } /// Subscribe to the ClashConnectionsConnectorEvent pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver { self.broadcast_tx.subscribe() } fn update(&self, msg: ClashConnectionsMessage) { let mut info = self.info.lock(); let previous_download_total = std::mem::replace(&mut info.download_total, msg.download_total); let previous_upload_total = std::mem::replace(&mut info.upload_total, msg.upload_total); info.download_speed = msg .download_total .checked_sub(previous_download_total) .unwrap_or_default(); info.upload_speed = msg .upload_total .checked_sub(previous_upload_total) .unwrap_or_default(); // SAFETY: the failures only there no active receivers, // so that the message will be dropped directly let _ = self .broadcast_tx .send(ClashConnectionsConnectorEvent::Update(*info)); } pub async fn stop(&self) { log::info!("stopping clash connections ws server"); let handle = self.connection_handler.lock().take(); if let Some(handle) = handle { handle.abort(); let _ = handle.await; } self.dispatch_state_changed(ClashConnectionsConnectorState::Disconnected); } } impl Drop for ClashConnectionsConnectorInner { fn drop(&mut self) { let cleanup = async move { self.stop().await; }; match tokio::runtime::Handle::try_current() { Ok(_) => tokio::task::block_in_place(|| { tauri::async_runtime::block_on(cleanup); }), Err(_) => { tauri::async_runtime::block_on(cleanup); } } } } ================================================ FILE: backend/tauri/src/core/connection_interruption.rs ================================================ use crate::{config::Config, core::clash::api}; use anyhow::Result; use serde::{Deserialize, Serialize}; use specta::Type; #[derive(Debug, Clone, Deserialize, Serialize, Type)] pub struct ConnectionInfo { pub id: String, pub chains: Vec, } /// Connection interruption service that handles closing connections based on configuration settings pub struct ConnectionInterruptionService; impl ConnectionInterruptionService { /// Interrupt connections when proxy changes pub async fn on_proxy_change() -> Result<()> { let config = Config::verge().data().clone(); let break_when = config.break_when_proxy_change.unwrap_or_default(); match break_when { crate::config::nyanpasu::BreakWhenProxyChange::None => { // Do nothing Ok(()) } crate::config::nyanpasu::BreakWhenProxyChange::Chain => { // TODO: Implement chain-based connection interruption // This would require tracking which connections use which proxy chains // For now, we'll fall back to closing all connections api::delete_connections(None).await } crate::config::nyanpasu::BreakWhenProxyChange::All => { api::delete_connections(None).await } } } /// Interrupt connections when profile changes pub async fn on_profile_change() -> Result<()> { let config = Config::verge().data().clone(); let break_when = config.break_when_profile_change.unwrap_or_default(); if break_when { api::delete_connections(None).await } else { // Do nothing Ok(()) } } /// Interrupt connections when mode changes pub async fn on_mode_change() -> Result<()> { let config = Config::verge().data().clone(); let break_when = config.break_when_mode_change.unwrap_or_default(); if break_when { api::delete_connections(None).await } else { // Do nothing Ok(()) } } /// Interrupt all connections pub async fn interrupt_all() -> Result<()> { api::delete_connections(None).await } /// Interrupt connections based on proxy chain (not yet implemented) pub async fn interrupt_by_chain(_chain: &[String]) -> Result<()> { // TODO: Implement chain-based connection interruption // This would require: // 1. Getting the current connections from the Clash API // 2. Filtering connections that use the specified proxy chain // 3. Closing only those connections // For now, we'll close all connections as a fallback api::delete_connections(None).await } } ================================================ FILE: backend/tauri/src/core/handle.rs ================================================ use super::tray::Tray; use crate::log_err; use anyhow::{Result, bail}; use once_cell::sync::OnceCell; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager, WebviewWindow, Wry}; #[derive(Debug, Default, Clone)] pub struct Handle { pub app_handle: Arc>>, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum StateChanged { NyanpasuConfig, ClashConfig, Profiles, Proxies, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Message { SetConfig(Result<(), String>), } const STATE_CHANGED_URI: &str = "nyanpasu://mutation"; const NOTIFY_MESSAGE_URI: &str = "nyanpasu://notice-message"; impl Handle { pub fn global() -> &'static Handle { static HANDLE: OnceCell = OnceCell::new(); HANDLE.get_or_init(|| Handle { app_handle: Arc::new(Mutex::new(None)), }) } pub fn init(&self, app_handle: AppHandle) { *self.app_handle.lock() = Some(app_handle); } pub fn get_window(&self) -> Option> { self.app_handle .lock() .as_ref() .and_then(|a| a.get_webview_window("main")) } pub fn refresh_clash() { if let Some(window) = Self::global().get_window() { log_err!(window.emit(STATE_CHANGED_URI, StateChanged::ClashConfig)); } } pub fn refresh_verge() { if let Some(window) = Self::global().get_window() { log_err!(window.emit(STATE_CHANGED_URI, StateChanged::NyanpasuConfig)); } } #[allow(unused)] pub fn refresh_profiles() { if let Some(window) = Self::global().get_window() { log_err!(window.emit(STATE_CHANGED_URI, StateChanged::Profiles)); } } pub fn mutate_proxies() { if let Some(window) = Self::global().get_window() { log_err!(window.emit(STATE_CHANGED_URI, StateChanged::Proxies)); } } pub fn notice_message(message: &Message) { if let Some(window) = Self::global().get_window() { log_err!(window.emit(NOTIFY_MESSAGE_URI, message)); } } pub fn update_systray() -> Result<()> { // let app_handle = Self::global().app_handle.lock(); // if app_handle.is_none() { // bail!("update_systray unhandled error"); // } // Tray::update_systray(app_handle.as_ref().unwrap())?; Handle::emit("update_systray", ())?; Ok(()) } /// update the system tray state pub fn update_systray_part() -> Result<()> { let app_handle = Self::global().app_handle.lock(); if app_handle.is_none() { bail!("update_systray unhandled error"); } Tray::update_part(app_handle.as_ref().unwrap())?; Ok(()) } pub fn emit(event: &str, payload: S) -> Result<()> { let app_handle = Self::global().app_handle.lock(); if app_handle.is_none() { bail!("app_handle is not exist"); } app_handle.as_ref().unwrap().emit(event, payload)?; Ok(()) } } ================================================ FILE: backend/tauri/src/core/hotkey.rs ================================================ use crate::{config::Config, feat, log_err}; use anyhow::{Result, bail}; use once_cell::sync::OnceCell; use parking_lot::Mutex; use std::{collections::HashMap, sync::Arc}; use tauri::AppHandle; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; pub struct Hotkey { current: Arc>>, // 保存当前的热键设置 app_handle: Arc>>, } // (hotkey, func) type HotKeyOp<'a> = (&'a str, HotKeyOpType<'a>); #[derive(Debug)] enum HotKeyOpType<'a> { #[allow(unused)] Unbind(&'a str), #[allow(unused)] Change(&'a str, &'a str), Bind(&'a str), } impl Hotkey { pub fn global() -> &'static Hotkey { static HOTKEY: OnceCell = OnceCell::new(); HOTKEY.get_or_init(|| Hotkey { current: Arc::new(Mutex::new(Vec::new())), app_handle: Arc::new(Mutex::new(None)), }) } pub fn init(&self, app_handle: AppHandle) -> Result<()> { *self.app_handle.lock() = Some(app_handle); let verge = Config::verge(); if let Some(hotkeys) = verge.latest().hotkeys.as_ref() { for hotkey in hotkeys.iter() { let mut iter = hotkey.split(','); let func = iter.next(); let key = iter.next(); match (key, func) { (Some(key), Some(func)) => { log_err!(Self::check_key(key).and_then(|_| self.register(key, func))); } _ => { let key = key.unwrap_or("None"); let func = func.unwrap_or("None"); log::error!(target: "app", "invalid hotkey `{key}`:`{func}`"); } } } self.current.lock().clone_from(hotkeys); } Ok(()) } /// 检查一个键是否合法 fn check_key(hotkey: &str) -> Result<()> { // fix #287 // tauri的这几个方法全部有Result expect,会panic,先检测一遍避免挂了 if hotkey.parse::().is_err() { bail!("invalid hotkey `{hotkey}`"); } Ok(()) } fn register(&self, hotkey: &str, func: &str) -> Result<()> { let app_handle = self.app_handle.lock(); if app_handle.is_none() { bail!("app handle is none"); } let manager = app_handle.as_ref().unwrap().global_shortcut(); if manager.is_registered(hotkey) { manager.unregister(hotkey)?; } let f = match func.trim() { "open_or_close_dashboard" => feat::toggle_dashboard, "clash_mode_rule" => || feat::change_clash_mode("rule".into()), "clash_mode_global" => || feat::change_clash_mode("global".into()), "clash_mode_direct" => || feat::change_clash_mode("direct".into()), "clash_mode_script" => || feat::change_clash_mode("script".into()), "toggle_system_proxy" => feat::toggle_system_proxy, "enable_system_proxy" => feat::enable_system_proxy, "disable_system_proxy" => feat::disable_system_proxy, "toggle_tun_mode" => feat::toggle_tun_mode, "enable_tun_mode" => feat::enable_tun_mode, "disable_tun_mode" => feat::disable_tun_mode, _ => bail!("invalid function \"{func}\""), }; manager.on_shortcut(hotkey, move |_app_handle, hotkey, ev| { if let ShortcutState::Pressed = ev.state { tracing::info!("hotkey pressed: {}", hotkey); f(); } })?; log::info!(target: "app", "register hotkey {hotkey} {func}"); Ok(()) } fn unregister(&self, hotkey: &str) -> Result<()> { let app_handle = self.app_handle.lock(); if app_handle.is_none() { bail!("app handle is none"); } let manager = app_handle.as_ref().unwrap().global_shortcut(); manager.unregister(hotkey)?; log::info!(target: "app", "unregister hotkey {hotkey}"); Ok(()) } #[tracing::instrument(skip(self))] pub fn update(&self, new_hotkeys: Vec) -> Result<()> { let mut current = self.current.lock(); let old_map = Self::get_map_from_vec(¤t); let new_map = Self::get_map_from_vec(&new_hotkeys); let ops = Self::get_ops(old_map, new_map); // 先检查一遍所有新的热键是不是可以用的 for (hotkey, op) in ops.iter() { if matches!(op, HotKeyOpType::Bind(_) | HotKeyOpType::Change(_, _)) { Self::check_key(hotkey)? } } tracing::info!("hotkey update: {:?}", ops); for (hotkey, op) in ops.iter() { match op { HotKeyOpType::Unbind(_) => self.unregister(hotkey)?, HotKeyOpType::Change(_, new_func) => { self.unregister(hotkey)?; self.register(hotkey, new_func)?; } HotKeyOpType::Bind(func) => self.register(hotkey, func)?, } } *current = new_hotkeys; Ok(()) } fn get_map_from_vec(hotkeys: &[String]) -> HashMap<&str, &str> { let mut map = HashMap::new(); hotkeys.iter().for_each(|hotkey| { let mut iter = hotkey.split(','); let func = iter.next(); let key = iter.next(); if func.is_some() && key.is_some() { let func = func.unwrap().trim(); let key = key.unwrap().trim(); map.insert(key, func); } }); map } fn get_ops<'a>( old_map: HashMap<&'a str, &'a str>, new_map: HashMap<&'a str, &'a str>, ) -> Vec> { let mut list = Vec::>::new(); old_map.iter().for_each(|(key, func)| { match new_map.get(key) { Some(new_func) => { if new_func != func { list.push((*key, HotKeyOpType::Change(func, new_func))) } // 无变化,无需操作 } None => { list.push((*key, HotKeyOpType::Unbind(func))); } } }); new_map.iter().for_each(|(key, func)| { if !old_map.contains_key(key) { list.push((*key, HotKeyOpType::Bind(func))); } }); list } } impl Drop for Hotkey { fn drop(&mut self) { let app_handle = self.app_handle.lock(); if let Some(app_handle) = app_handle.as_ref() { let manager = app_handle.global_shortcut(); if let Ok(()) = manager.unregister_all() { log::info!(target: "app", "unregister all hotkeys"); } } } } ================================================ FILE: backend/tauri/src/core/logger.rs ================================================ use once_cell::sync::OnceCell; use parking_lot::Mutex; use std::{collections::VecDeque, sync::Arc}; const LOGS_QUEUE_LEN: usize = 100; pub struct Logger { log_data: Arc>>, } impl Logger { pub fn global() -> &'static Logger { static LOGGER: OnceCell = OnceCell::new(); LOGGER.get_or_init(|| Logger { log_data: Arc::new(Mutex::new(VecDeque::with_capacity(LOGS_QUEUE_LEN + 10))), }) } pub fn get_log(&self) -> VecDeque { self.log_data.lock().clone() } pub fn set_log(&self, text: String) { let mut logs = self.log_data.lock(); if logs.len() > LOGS_QUEUE_LEN { logs.pop_front(); } logs.push_back(text); } pub fn clear_log(&self) { let mut logs = self.log_data.lock(); logs.clear(); } } ================================================ FILE: backend/tauri/src/core/manager.rs ================================================ use std::borrow::Cow; /// 给clash内核的tun模式授权 #[cfg(any(target_os = "macos", target_os = "linux"))] pub fn grant_permission(core: &nyanpasu_utils::core::CoreType) -> anyhow::Result<()> { use std::process::Command; let path = crate::core::clash::core::find_binary_path(&core) .map_err(|_| anyhow::anyhow!("clash core not found"))? .canonicalize()? .to_string_lossy() .to_string(); log::debug!("grant_permission path: {:?}", path); #[cfg(target_os = "macos")] let output = { // the path of clash /Applications/Clash Nyanpasu.app/Contents/MacOS/clash // https://apple.stackexchange.com/questions/82967/problem-with-empty-spaces-when-executing-shell-commands-in-applescript // let path = escape(&path); let path = path.replace(' ', "\\\\ "); let shell = format!("chown root:admin {path}\nchmod +sx {path}"); let command = format!(r#"do shell script "{shell}" with administrator privileges"#); Command::new("osascript") .args(vec!["-e", &command]) .output()? }; #[cfg(target_os = "linux")] let output = { let path = path.replace(' ', "\\ "); // 避免路径中有空格 let shell = format!("setcap cap_net_bind_service,cap_net_admin=+ep {path}"); let sudo = match Command::new("which").arg("pkexec").output() { Ok(output) => { if output.stdout.is_empty() { "sudo" } else { "pkexec" } } Err(_) => "sudo", }; Command::new(sudo).arg("sh").arg("-c").arg(shell).output()? }; if output.status.success() { Ok(()) } else { let stderr = std::str::from_utf8(&output.stderr).unwrap_or(""); anyhow::bail!("{stderr}"); } } #[allow(unused)] pub fn escape(text: &str) -> Cow<'_, str> { let bytes = text.as_bytes(); let mut owned = None; for pos in 0..bytes.len() { let special = match bytes[pos] { b' ' => Some(b' '), _ => None, }; if let Some(s) = special { if owned.is_none() { owned = Some(bytes[0..pos].to_owned()); } owned.as_mut().unwrap().push(b'\\'); owned.as_mut().unwrap().push(b'\\'); owned.as_mut().unwrap().push(s); } else if let Some(owned) = owned.as_mut() { owned.push(bytes[pos]); } } if let Some(owned) = owned { Cow::Owned(String::from_utf8(owned).unwrap()) } else { Cow::Borrowed(std::str::from_utf8(bytes).unwrap()) } } ================================================ FILE: backend/tauri/src/core/migration/db.rs ================================================ use derive_builder::Builder; use once_cell::sync::Lazy; use semver::Version; /// A simple file based database for storing the migration status. /// use serde::{Deserialize, Serialize}; use std::{borrow::Cow, collections::HashMap, io::Write, path::PathBuf}; use super::MigrationState; /// A lockfile store the migrated version and the state. /// The lower version of the migration will be ignored. static MIGRATION_LOCK_FILE: Lazy = Lazy::new(|| { let mut path = crate::utils::dirs::app_config_dir().unwrap(); path.push("migration.lock"); path }); #[derive(Debug, Clone, Serialize, Deserialize, Builder)] #[builder(default)] pub struct MigrationFile<'a> { pub version: Cow<'a, Version>, pub states: HashMap, MigrationState>, } impl MigrationFileBuilder<'_> { pub fn read_file(mut self) -> Self { let content = std::fs::read_to_string(&*MIGRATION_LOCK_FILE).ok(); if let Some(content) = content { let file: Option = serde_yaml::from_str(&content).ok(); if let Some(file) = file { self.version = Some(file.version); self.states = Some(file.states); } } self } } impl Default for MigrationFile<'_> { fn default() -> Self { // since 1.6.0, we have introduced the migration system. so the last version of 1.5.x is 1.5.1. let ver = Version::parse("1.5.1").unwrap(); Self { version: Cow::Owned(ver), states: HashMap::new(), } } } impl<'a> MigrationFile<'a> { /// Create or Truncate the lock file and write the content. pub fn write_file(&self) -> Result<(), std::io::Error> { let content = serde_yaml::to_string(self).map_err(|e| { log::error!("Failed to serialize the migration file: {e}"); std::io::Error::other(e) })?; let mut file = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .open(&*MIGRATION_LOCK_FILE)?; file.write( "# This file is generated by the migration system, do not edit it manually.\n" .as_bytes(), ) .map_err(|e| { log::error!("Failed to write the migration file: {e}"); e })?; file.write_all(content.as_bytes()) } pub fn get_state(&self, name: &str) -> Option { self.states.get(name).copied() } pub fn set_state(&mut self, name: Cow<'a, str>, state: MigrationState) { self.states.insert(name, state); } } ================================================ FILE: backend/tauri/src/core/migration/mod.rs ================================================ #![allow(dead_code)] /// A migration mod indicates the migration of the old version to the new version. /// Because this runner run at the start of the app, it will use eprintln or println to print the migration log. /// /// use dyn_clone::{DynClone, clone_trait_object}; use semver::Version; use std::{borrow::Cow, cell::RefCell}; mod db; pub mod units; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum MigrationState { /// The migration is pending. NotStarted, /// The migration is in progress. InProgress, /// The migration is completed. Completed, /// The migration is failed. Failed, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MigrationAdvice { /// The migration is required to run. Pending, /// The migration is ignored. Ignored, /// The migration has been run. Done, } impl std::fmt::Display for MigrationAdvice { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { MigrationAdvice::Pending => write!(f, "Pending"), MigrationAdvice::Ignored => write!(f, "Ignored"), MigrationAdvice::Done => write!(f, "Done"), } } } #[derive(Debug, Clone)] pub enum Unit<'a, T> where T: Clone + Migration<'a> + Send + Sync, { /// A List of migrations, it should be used to wrap a list of migrations in a single version. /// Although the fn signature is T generic, it should use a Vec as the input. Batch(Cow<'a, [T]>), Single(Cow<'a, T>), } impl<'a, T> From for Unit<'a, T> where T: Clone + Migration<'a> + Send + Sync, { fn from(item: T) -> Self { Unit::Single(Cow::Owned(item)) } } impl<'a, T> From<&'a T> for Unit<'a, T> where T: Clone + Migration<'a> + Send + Sync, { fn from(item: &'a T) -> Self { Unit::Single(Cow::Borrowed(item)) } } impl<'a, T> From<&'a [T]> for Unit<'a, T> where T: Clone + Migration<'a> + Send + Sync, { fn from(list: &'a [T]) -> Self { Unit::Batch(Cow::Borrowed(list)) } } impl<'a, T> From> for Unit<'a, T> where T: Clone + Migration<'a> + Send + Sync, { fn from(list: Vec) -> Self { Unit::Batch(Cow::Owned(list)) } } type DynMigration<'a> = Box + Send + Sync + 'a>; pub trait Migration<'a>: DynClone { /// A version field to indicate the version of the migration. /// It used to compare with the current version to determine whether the migration is needed. fn version(&self) -> &'a Version; /// A name field to indicate the name of the migration. fn name(&self) -> Cow<'a, str>; fn migrate(&self) -> std::io::Result<()> { unimplemented!() } fn discard(&self) -> std::io::Result<()> { Ok(()) } } clone_trait_object!(Migration<'_>); pub trait MigrationExt<'a>: Migration<'a> where Self: Sized + 'static + Send + Sync, { fn boxed(self) -> DynMigration<'a> { Box::new(self) as DynMigration } } impl<'a, T> MigrationExt<'a> for T where T: Sized + 'static + Migration<'a> + Send + Sync {} impl<'a, T> Migration<'a> for Unit<'a, T> where T: Clone + Migration<'a> + Send + Sync, { fn version(&self) -> &'a Version { match self { Unit::Single(item) => item.version(), Unit::Batch(list) => list.first().unwrap().version(), } } fn name(&self) -> Cow<'a, str> { match self { Unit::Single(item) => item.name(), Unit::Batch(list) => Cow::Owned(format!( "{} migrations for v{}", list.len(), list.first().unwrap().version() )), } } fn migrate(&self) -> std::io::Result<()> { unimplemented!("Batch migrations should be handled by the runner.") } } impl<'a> Migration<'a> for DynMigration<'a> { fn version(&self) -> &'a Version { self.as_ref().version() } fn name(&self) -> Cow<'a, str> { self.as_ref().name() } fn migrate(&self) -> std::io::Result<()> { self.as_ref().migrate() } } #[derive(Debug)] pub struct Runner<'a> { pub current_version: Cow<'a, Version>, skip_advice: bool, store: RefCell>, } pub struct DropGuard<'a>(Runner<'a>); impl<'a> std::ops::Deref for DropGuard<'a> { type Target = Runner<'a>; fn deref(&self) -> &Self::Target { &self.0 } } impl<'a> std::ops::DerefMut for DropGuard<'a> { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } impl Default for Runner<'_> { fn default() -> Self { let ver = Version::parse(crate::consts::BUILD_INFO.pkg_version).unwrap(); let file = db::MigrationFileBuilder::default() .read_file() .build() .unwrap(); Self { current_version: Cow::Owned(ver), skip_advice: false, store: RefCell::new(file), } } } impl Drop for DropGuard<'_> { fn drop(&mut self) { let mut store = self.store.take(); store.version = Cow::Borrowed(&self.0.current_version); store.write_file().unwrap(); } } impl Runner<'_> { pub fn new_with_skip_advice() -> Self { let ver = Version::parse(crate::consts::BUILD_INFO.pkg_version).unwrap(); let file = db::MigrationFileBuilder::default() .read_file() .build() .unwrap(); Self { skip_advice: true, current_version: Cow::Owned(ver), store: RefCell::new(file), } } pub fn advice_migration<'a, T>(&self, migration: &T) -> MigrationAdvice where T: Clone + Migration<'a> + Send + Sync, { let migration_ver = migration.version(); let store = self.store.borrow(); if migration_ver >= &store.version { // Judge the migration is run or not. if let Some(state) = store.states.get(&migration.name()) { match state { MigrationState::Completed => MigrationAdvice::Done, MigrationState::Failed => MigrationAdvice::Pending, _ => MigrationAdvice::Ignored, } } else { MigrationAdvice::Pending } } else { MigrationAdvice::Ignored } } pub fn advice_unit<'a, T>(&self, unit: &Unit<'a, T>) -> MigrationAdvice where T: Clone + Migration<'a> + Send + Sync, { match unit { Unit::Single(item) => self.advice_migration(item.as_ref()), Unit::Batch(list) => { let mut advice = MigrationAdvice::Ignored; for item in list.iter() { let item_advice = self.advice_migration(item); if item_advice == MigrationAdvice::Pending { advice = MigrationAdvice::Pending; break; } else if item_advice == MigrationAdvice::Done { advice = MigrationAdvice::Done; } } advice } } } pub fn run_migration<'a, T>(&self, migration: &T) -> std::io::Result<()> where T: Clone + Migration<'a> + Send + Sync, { println!("Running migration: {}", migration.name()); let advice = self.advice_migration(migration); println!("Advice: {advice:?}"); if matches!(advice, MigrationAdvice::Ignored | MigrationAdvice::Done) { return Ok(()); } let name = migration.name(); let mut store = self.store.borrow_mut(); match migration.migrate() { Ok(_) => { println!("Migration {name} completed."); store.set_state(Cow::Owned(name.to_string()), MigrationState::Completed); Ok(()) } Err(e) => { eprintln!("Migration {name} failed: {e}; trying to discard changes"); match migration.discard() { Ok(_) => { eprintln!("Migration {name} discarded."); } Err(e) => { eprintln!("Migration {name} discard failed: {e}"); } } store.set_state(Cow::Owned(name.to_string()), MigrationState::Failed); Err(e) } } } pub fn run_unit<'a, T>(&self, unit: &Unit<'a, T>) -> std::io::Result<()> where T: Clone + Migration<'a> + Send + Sync, { println!("Running unit: {}", unit.name()); match unit { Unit::Single(item) => self.run_migration(item.as_ref()), Unit::Batch(list) => { for item in list.iter() { self.run_migration(item)?; } Ok(()) } } } pub fn run_units_up_to_version(&self, to_ver: &Version) -> std::io::Result<()> { println!("Running units up to version: {to_ver}"); let version = { let store = self.store.borrow(); store.version.clone() }; let units = units::UNITS .iter() .filter(|(ver, _)| **ver >= &version && **ver <= to_ver); for (_, unit) in units { self.run_unit(unit)?; } Ok(()) } pub fn run_upcoming_units(&self) -> std::io::Result<()> { println!( "Running all upcoming units. It is supposed to run in Nightly build. If you see this message in Stable channel, report it in Github Issues Tracker please." ); let version = { let store = self.store.borrow(); store.version.clone() }; let units = units::UNITS.iter().filter(|(ver, _)| **ver >= &version); for (_, unit) in units { self.run_unit(unit)?; } Ok(()) } } impl<'a> Runner<'a> { pub fn drop_guard(self) -> DropGuard<'a> { DropGuard(self) } } ================================================ FILE: backend/tauri/src/core/migration/units/mod.rs ================================================ use super::{DynMigration, Migration, Unit}; use once_cell::sync::Lazy; use semver::Version; use std::{borrow::Cow, collections::HashMap}; mod unit_160; mod unit_200; pub static UNITS: Lazy>> = Lazy::new(|| { let mut units: HashMap<&'static Version, Unit<'static, DynMigration>> = HashMap::new(); let unit = Unit::Batch(Cow::Borrowed(&unit_160::UNITS)); units.insert(unit.version(), unit); let unit = Unit::Batch(Cow::Borrowed(&unit_200::UNITS)); units.insert(unit.version(), unit); units }); pub fn find_migration(name: &str) -> Option>> { for unit in UNITS.values() { match unit { Unit::Batch(units) => { for unit in units.iter() { if unit.name() == name { return Some(Cow::Borrowed(unit)); } } } Unit::Single(unit) => { if unit.name() == name { return Some(Cow::Borrowed(unit)); } } } } None } pub fn get_migrations() -> Vec>> { let mut migrations = Vec::new(); for unit in UNITS.values() { match unit { Unit::Batch(units) => { for unit in units.iter() { migrations.push(Cow::Borrowed(unit)); } } Unit::Single(unit) => { migrations.push(Cow::Borrowed(unit)); } } } migrations } ================================================ FILE: backend/tauri/src/core/migration/units/unit_160.rs ================================================ use std::borrow::Cow; use once_cell::sync::Lazy; use serde_yaml::{ Mapping, value::{Tag, TaggedValue}, }; use crate::{ config::RUNTIME_CONFIG, core::migration::{DynMigration, Migration, MigrationExt}, }; pub static UNITS: Lazy> = Lazy::new(|| { vec![ MigrateAppHomeDir.boxed(), MigrateProxiesSelectorMode.boxed(), MigrateScriptProfileType.boxed(), ] }); pub static VERSION: Lazy = Lazy::new(|| semver::Version::parse("1.6.0").unwrap()); #[derive(Debug, Clone)] pub struct MigrateAppHomeDir; impl<'a> Migration<'a> for MigrateAppHomeDir { fn name(&self) -> std::borrow::Cow<'a, str> { std::borrow::Cow::Borrowed("Split App Home Dir to Config and Data") } fn version(&self) -> &'a semver::Version { &VERSION } // Allow deprecated because we are moving deprecated files to new locations #[allow(deprecated)] fn migrate(&self) -> std::io::Result<()> { let home_dir = crate::utils::dirs::app_home_dir().unwrap(); if !home_dir.exists() { println!("Home dir not found, skipping migration"); return Ok(()); } // create the app config and data dir println!("Creating app config and data dir"); let app_config_dir = crate::utils::dirs::app_config_dir().unwrap(); if !app_config_dir.exists() { std::fs::create_dir_all(&app_config_dir) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; } let app_data_dir = crate::utils::dirs::app_data_dir().unwrap(); if !app_data_dir.exists() { std::fs::create_dir_all(&app_data_dir) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; } // move the config files to the new config dir let file_opts = fs_extra::file::CopyOptions::default().skip_exist(true); let dir_opts = fs_extra::dir::CopyOptions::default() .skip_exist(true) .content_only(true); // move clash runtime config let path = home_dir.join("clash-verge.yaml"); if path.exists() { println!("Moving clash-verge.yaml to config dir"); fs_extra::file::move_file(path, app_config_dir.join(RUNTIME_CONFIG), &file_opts) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; } // move clash guard overrides let path = home_dir.join("config.yaml"); if path.exists() { println!("Moving config.yaml to config dir"); fs_extra::file::move_file( path, crate::utils::dirs::clash_guard_overrides_path().unwrap(), &file_opts, ) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; } // move nyanpasu config let path = home_dir.join("verge.yaml"); if path.exists() { println!("Moving verge.yaml to config dir"); fs_extra::file::move_file( path, crate::utils::dirs::app_config_dir() .unwrap() .join(crate::utils::dirs::NYANPASU_CONFIG), &file_opts, ) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; } // if app config dir is not set by registry, move the files and dirs to data dir if home_dir != app_config_dir { // move profiles.yaml let path = home_dir.join("profiles.yaml"); if path.exists() { println!("Moving profiles.yaml to profiles dir"); fs_extra::file::move_file(path, app_config_dir.join("profiles.yaml"), &file_opts) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; } // move profiles dir let path = home_dir.join("profiles"); if path.exists() { println!("Moving profiles dir to profiles dir"); fs_extra::dir::move_dir( path, crate::utils::dirs::app_profiles_dir().unwrap(), &dir_opts, ) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; } // move other files and dirs to data dir println!("Moving other files and dirs to data dir"); fs_extra::dir::move_dir(home_dir, app_data_dir, &dir_opts) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; } println!("Migration completed"); Ok(()) } #[allow(deprecated)] fn discard(&self) -> std::io::Result<()> { let home_dir = crate::utils::dirs::app_home_dir().unwrap(); let app_config_dir = crate::utils::dirs::app_config_dir().unwrap(); let app_data_dir = crate::utils::dirs::app_data_dir().unwrap(); if !home_dir.exists() { std::fs::create_dir_all(&home_dir) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; } let file_opts = fs_extra::file::CopyOptions::default().skip_exist(true); let dir_opts = fs_extra::dir::CopyOptions::default() .skip_exist(true) .content_only(true); if home_dir != app_config_dir { // move profiles.yaml let path = app_config_dir.join("profiles.yaml"); if path.exists() { println!("Moving profiles.yaml to home dir"); fs_extra::file::move_file(path, home_dir.join("profiles.yaml"), &file_opts) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; } // move profiles dir let path = crate::utils::dirs::app_profiles_dir().unwrap(); if path.exists() { println!("Moving profiles dir to home dir"); fs_extra::dir::move_dir(path, home_dir.join("profiles"), &dir_opts) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; } // move other files and dirs to home dir println!("Moving other files and dirs to home dir"); fs_extra::dir::move_dir(app_data_dir, &home_dir, &dir_opts) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; } // move nyanpasu config let path = app_config_dir.join(crate::utils::dirs::NYANPASU_CONFIG); if path.exists() { println!("Moving verge.yaml to home dir"); fs_extra::file::move_file(path, home_dir.join("verge.yaml"), &file_opts) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; } // move clash guard overrides let path = crate::utils::dirs::clash_guard_overrides_path().unwrap(); if path.exists() { println!("Moving config.yaml to home dir"); fs_extra::file::move_file(path, home_dir.join("config.yaml"), &file_opts) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; } // move clash runtime config let path = app_config_dir.join(RUNTIME_CONFIG); if path.exists() { println!("Moving clash-verge.yaml to home dir"); fs_extra::file::move_file(path, home_dir.join("clash-verge.yaml"), &file_opts) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; } println!("Migration discarded"); Ok(()) } } #[derive(Debug, Clone)] pub struct MigrateProxiesSelectorMode; impl<'a> Migration<'a> for MigrateProxiesSelectorMode { fn version(&self) -> &'a semver::Version { &VERSION } fn name(&self) -> std::borrow::Cow<'a, str> { Cow::Borrowed("Migrate Proxies Selector Mode") } fn migrate(&self) -> std::io::Result<()> { let config_path = crate::utils::dirs::nyanpasu_config_path().unwrap(); if !config_path.exists() { println!("Config file not found, skipping migration"); return Ok(()); } println!("parse config file..."); let config = std::fs::read_to_string(&config_path) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; let mut config: Mapping = serde_yaml::from_str(&config) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; let mode = config.get_mut("clash_tray_selector"); match mode { None => { println!("clash_tray_selector not found, skipping migration"); return Ok(()); } Some(mode) => { if mode.is_bool() { println!("detected old mode, migrating..."); let value = mode.as_bool().unwrap(); let value = if value { "normal" } else { "hidden" }; *mode = serde_yaml::Value::from(value); println!("write config file..."); let config = serde_yaml::to_string(&config) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; std::fs::write(&config_path, config)?; } println!("Migration completed"); } } Ok(()) } fn discard(&self) -> std::io::Result<()> { let config_path = crate::utils::dirs::nyanpasu_config_path().unwrap(); if !config_path.exists() { println!("Config file not found, skipping migration"); return Ok(()); } println!("parse config file..."); let config = std::fs::read_to_string(&config_path) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; let mut config: Mapping = serde_yaml::from_str(&config) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; let mode = config.get_mut("clash_tray_selector"); match mode { None => { println!("clash_tray_selector not found, skipping migration"); return Ok(()); } Some(mode) => { if mode.is_string() { println!("detected new mode, migrating..."); let value = mode.as_str().unwrap(); let value = value == "normal"; *mode = serde_yaml::Value::from(value); println!("write config file..."); let config = serde_yaml::to_string(&config) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; std::fs::write(&config_path, config)?; } println!("Migration discarded"); } } Ok(()) } } #[derive(Debug, Clone)] pub struct MigrateScriptProfileType; impl<'a> Migration<'a> for MigrateScriptProfileType { fn version(&self) -> &'a semver::Version { &VERSION } fn name(&self) -> Cow<'a, str> { Cow::Borrowed("Migrate Script Profile Type") } fn migrate(&self) -> std::io::Result<()> { let profiles_path = crate::utils::dirs::profiles_path() .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?; if !profiles_path.exists() { println!("Profiles dir not found, skipping migration"); return Ok(()); } let profiles = std::fs::read_to_string(&profiles_path)?; let mut profiles: Mapping = serde_yaml::from_str(&profiles) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; let items = profiles .get_mut("items") .and_then(|items| items.as_sequence_mut()); if let Some(items) = items { for item in items { if let Some(item) = item.as_mapping_mut() && item .get("type") .is_some_and(|ty| ty.as_str().is_some_and(|ty| ty == "script")) { item.insert( "type".into(), serde_yaml::Value::Tagged(Box::new(TaggedValue { tag: Tag::new("script"), value: serde_yaml::Value::String("javascript".to_string()), })), ); } } let profiles = serde_yaml::to_string(&profiles) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; std::fs::write(profiles_path, profiles)?; } Ok(()) } fn discard(&self) -> std::io::Result<()> { let profiles_path = crate::utils::dirs::profiles_path() .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?; if !profiles_path.exists() { println!("Profiles dir not found, skipping migration"); return Ok(()); } let profiles = std::fs::read_to_string(&profiles_path)?; let mut profiles: Mapping = serde_yaml::from_str(&profiles) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; let items = profiles .get_mut("items") .and_then(|items| items.as_sequence_mut()); if let Some(items) = items { for item in items { if let Some(item) = item.as_mapping_mut() && item.get("type").is_some_and(|ty| { if let serde_yaml::Value::Tagged(ty) = ty { ty.tag == Tag::new("script") } else { false } }) { item.insert( "type".into(), serde_yaml::Value::String("script".to_string()), ); } } let profiles = serde_yaml::to_string(&profiles) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; std::fs::write(profiles_path, profiles)?; } Ok(()) } } ================================================ FILE: backend/tauri/src/core/migration/units/unit_200/profile_script_newtype.rs ================================================ use std::borrow::Cow; use semver::Version; use serde_yaml::{ Mapping, Value, value::{Tag, TaggedValue}, }; use crate::{core::migration::Migration, utils::help}; #[derive(Debug, Clone, Copy)] /// 将 /// ```yaml /// type: !script javascript /// ``` /// 展开为 /// ```yaml /// type: script /// script_type: javascript /// ``` /// 其他不做特殊处理 pub struct MigrateProfileScriptNewtype; impl Migration<'_> for MigrateProfileScriptNewtype { fn version(&self) -> &'static Version { &super::VERSION } fn name(&self) -> Cow<'static, str> { Cow::Borrowed("MigrateProfileScriptNewtype") } fn migrate(&self) -> std::io::Result<()> { let profiles_path = crate::utils::dirs::profiles_path().map_err(std::io::Error::other)?; if !profiles_path.exists() { eprintln!("profiles dir not found, skipping migration"); return Ok(()); } eprintln!("Trying to read profiles files..."); let profiles = std::fs::read_to_string(profiles_path.clone())?; eprintln!("Trying to parse profiles files..."); let profiles: Mapping = serde_yaml::from_str(&profiles) .map_err(|e| std::io::Error::other(format!("failed to parse profiles: {e}")))?; eprintln!("Trying to migrate profiles files..."); let profiles = migrate_profile_data(profiles); eprintln!("Trying to write profiles files..."); help::save_yaml( &profiles_path, &profiles, Some("# Profiles Config for Clash Nyanpasu"), ) .map_err(std::io::Error::other)?; Ok(()) } fn discard(&self) -> std::io::Result<()> { let profiles_path = crate::utils::dirs::profiles_path().map_err(std::io::Error::other)?; if !profiles_path.exists() { eprintln!("profiles dir not found, skipping discard"); return Ok(()); } eprintln!("Trying to read profiles files..."); let profiles = std::fs::read_to_string(profiles_path.clone())?; eprintln!("Trying to parse profiles files..."); let profiles: Mapping = serde_yaml::from_str(&profiles) .map_err(|e| std::io::Error::other(format!("failed to parse profiles: {e}")))?; eprintln!("Trying to discard profiles files..."); let profiles = discard_profile_data(profiles); eprintln!("Trying to write profiles files..."); help::save_yaml( &profiles_path, &profiles, Some("# Profiles Config for Clash Nyanpasu"), ) .map_err(std::io::Error::other)?; Ok(()) } } fn migrate_profile_data(mut mapping: serde_yaml::Mapping) -> serde_yaml::Mapping { // We just need to iter items if let Some(items) = mapping.get_mut("items") && let Some(items) = items.as_sequence_mut() { for item in items { if let Some(item) = item.as_mapping_mut() && let Some(ty) = item.get("type").cloned() && let Value::Tagged(tag) = ty && tag.tag == "script" && let Some(script_kind) = tag.value.as_str() { item.insert( "type".into(), serde_yaml::Value::String("script".to_string()), ); item.insert( "script_type".into(), serde_yaml::Value::String(script_kind.to_string()), ); } } } mapping } fn discard_profile_data(mut mapping: serde_yaml::Mapping) -> serde_yaml::Mapping { // We just need to iter items if let Some(items) = mapping.get_mut("items") && let Some(items) = items.as_sequence_mut() { for item in items { if let Some(item) = item.as_mapping_mut() && let Some(ty) = item.get("type").cloned() && let Value::String(ty) = ty && ty == "script" && let Some(script_kind) = item.get("script_type").cloned() { item.insert( "type".into(), serde_yaml::Value::Tagged(Box::new(TaggedValue { tag: Tag::new("script"), value: script_kind, })), ); item.remove("script_type"); } } } mapping } #[cfg(test)] mod tests { use crate::config::Profiles; use super::*; use pretty_assertions::assert_str_eq; const ORIGINAL_SAMPLE: &str = r#"current: - rIWXPHuafvEM chain: [] valid: - dns - unified-delay - tcp-concurrent - tun - profile items: - uid: rIWXPHuafvEM type: remote name: 🌸云 file: rIWXPHuafvEM.yaml desc: null updated: 1758110672 url: https://example.com extra: upload: 3641183914 download: 39111158992 total: 42946719600 expire: 1769123200 option: with_proxy: false self_proxy: true update_interval: 1440 chain: - siL1cvjnvLB6 - sxI0dHKeqSNg - uid: siL1cvjnvLB6 type: !script javascript name: 花☁️处理 file: siL1cvjnvLB6.js desc: '' updated: 1720954186 - uid: sxI0dHKeqSNg type: !script javascript name: 🌸☁️图标 file: sxI0dHKeqSNg.js desc: '' updated: 1722656540 - uid: sZYZe33w7RKV type: !script lua name: 图标 file: sZYZe33w7RKV.lua desc: '' updated: 1724082226 - uid: lkvV5JXfzO34 type: local name: New Profile file: lkvV5JXfzO34.yaml desc: '' updated: 1725587682 chain: [] - uid: lJynXCoMMIUd type: local name: New Profile file: lJynXCoMMIUd.yaml desc: '' updated: 1726252304 chain: [] - uid: lBtaVEaMAR97 type: local name: Test file: lBtaVEaMAR97.yaml desc: '' updated: 1727621893 chain: [] "#; const MIGRATED_SAMPLE: &str = r#"current: - rIWXPHuafvEM chain: [] valid: - dns - unified-delay - tcp-concurrent - tun - profile items: - uid: rIWXPHuafvEM type: remote name: 🌸云 file: rIWXPHuafvEM.yaml desc: null updated: 1758110672 url: https://example.com extra: upload: 3641183914 download: 39111158992 total: 42946719600 expire: 1769123200 option: with_proxy: false self_proxy: true update_interval: 1440 chain: - siL1cvjnvLB6 - sxI0dHKeqSNg - uid: siL1cvjnvLB6 type: script name: 花☁️处理 file: siL1cvjnvLB6.js desc: '' updated: 1720954186 script_type: javascript - uid: sxI0dHKeqSNg type: script name: 🌸☁️图标 file: sxI0dHKeqSNg.js desc: '' updated: 1722656540 script_type: javascript - uid: sZYZe33w7RKV type: script name: 图标 file: sZYZe33w7RKV.lua desc: '' updated: 1724082226 script_type: lua - uid: lkvV5JXfzO34 type: local name: New Profile file: lkvV5JXfzO34.yaml desc: '' updated: 1725587682 chain: [] - uid: lJynXCoMMIUd type: local name: New Profile file: lJynXCoMMIUd.yaml desc: '' updated: 1726252304 chain: [] - uid: lBtaVEaMAR97 type: local name: Test file: lBtaVEaMAR97.yaml desc: '' updated: 1727621893 chain: [] "#; #[test] fn test_migrate_existing_data() { let original_data = serde_yaml::from_str::(ORIGINAL_SAMPLE).unwrap(); let migrated_data = migrate_profile_data(original_data); let output_data = serde_yaml::to_string(&migrated_data).unwrap(); assert_str_eq!(output_data, MIGRATED_SAMPLE); } #[test] fn test_discard_existing_data() { let migrated_data = serde_yaml::from_str::(MIGRATED_SAMPLE).unwrap(); let original_data = discard_profile_data(migrated_data); let output_data = serde_yaml::to_string(&original_data).unwrap(); assert_str_eq!(output_data, ORIGINAL_SAMPLE); } #[test] #[ignore] fn test_profile_parse_migrated_data() { let profiles = serde_yaml::from_str::(MIGRATED_SAMPLE).unwrap(); eprintln!("{profiles:#?}"); } } ================================================ FILE: backend/tauri/src/core/migration/units/unit_200.rs ================================================ use std::borrow::Cow; use once_cell::sync::Lazy; use semver::Version; use serde_yaml::Mapping; use crate::{ core::migration::{DynMigration, Migration, MigrationExt}, utils::dirs, }; mod profile_script_newtype; pub static UNITS: Lazy> = Lazy::new(|| { vec![ MigrateProfilesNullValue.boxed(), MigrateLanguageOption.boxed(), MigrateThemeSetting.boxed(), profile_script_newtype::MigrateProfileScriptNewtype.boxed(), ] }); pub static VERSION: Lazy = Lazy::new(|| semver::Version::parse("2.0.0").unwrap()); #[derive(Debug, Clone)] pub struct MigrateProfilesNullValue; impl Migration<'_> for MigrateProfilesNullValue { fn version(&self) -> &'static Version { &VERSION } fn name(&self) -> Cow<'static, str> { Cow::Borrowed("MigrateProfilesNullValue") } fn migrate(&self) -> std::io::Result<()> { let profiles_path = dirs::profiles_path().map_err(std::io::Error::other)?; if !profiles_path.exists() { return Ok(()); } let profiles = std::fs::read_to_string(profiles_path.clone())?; let mut profiles: Mapping = serde_yaml::from_str(&profiles) .map_err(|e| std::io::Error::other(format!("failed to parse profiles: {e}")))?; profiles.iter_mut().for_each(|(key, value)| { if value.is_null() { println!("detected null value in profiles {key:?} should be migrated"); *value = serde_yaml::Value::Sequence(Vec::new()); } }); let file = std::fs::OpenOptions::new() .write(true) .truncate(true) .open(profiles_path)?; serde_yaml::to_writer(file, &profiles).map_err(std::io::Error::other)?; Ok(()) } fn discard(&self) -> std::io::Result<()> { let profiles_path = dirs::profiles_path().map_err(std::io::Error::other)?; if !profiles_path.exists() { return Ok(()); } let profiles = std::fs::read_to_string(profiles_path.clone())?; let mut profiles: Mapping = serde_yaml::from_str(&profiles) .map_err(|e| std::io::Error::other(format!("failed to parse profiles: {e}")))?; profiles.iter_mut().for_each(|(key, value)| { if key.is_string() && key.as_str().unwrap() == "chain" && value.is_sequence() { println!("detected sequence value in profiles {key:?} should be migrated"); *value = serde_yaml::Value::Null; } if key.is_string() && key.as_str().unwrap() == "current" && value.is_sequence() { println!("detected sequence value in profiles {key:?} should be migrated"); *value = serde_yaml::Value::Null; } }); let file = std::fs::OpenOptions::new() .write(true) .truncate(true) .open(profiles_path)?; serde_yaml::to_writer(file, &profiles).map_err(std::io::Error::other)?; Ok(()) } } #[derive(Debug, Clone)] pub struct MigrateLanguageOption; impl<'a> Migration<'a> for MigrateLanguageOption { fn version(&self) -> &'a semver::Version { &VERSION } fn name(&self) -> std::borrow::Cow<'a, str> { Cow::Borrowed("Migrate Language Option") } fn migrate(&self) -> std::io::Result<()> { let config_path = crate::utils::dirs::nyanpasu_config_path().unwrap(); if !config_path.exists() { println!("Config file not found, skipping migration"); return Ok(()); } println!("parse config file..."); let config = std::fs::read_to_string(&config_path) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; let mut config: Mapping = serde_yaml::from_str(&config) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; let lang = config.get_mut("language"); match lang { None => { println!("language not found, skipping migration"); return Ok(()); } Some(lang) => { if lang == "zh" { println!("detected old language option, migrating..."); let _value = lang.as_str().unwrap(); let value = "zh-CN"; *lang = serde_yaml::Value::from(value); println!("write config file..."); let config = serde_yaml::to_string(&config) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; std::fs::write(&config_path, config)?; } println!("Migration completed"); } } Ok(()) } } #[derive(Debug, Clone)] pub struct MigrateThemeSetting; impl<'a> Migration<'a> for MigrateThemeSetting { fn version(&self) -> &'a semver::Version { &VERSION } fn name(&self) -> std::borrow::Cow<'a, str> { Cow::Borrowed("Migrate Theme Setting") } fn migrate(&self) -> std::io::Result<()> { let config_path = crate::utils::dirs::nyanpasu_config_path().unwrap(); if !config_path.exists() { return Ok(()); } let raw_config = std::fs::read_to_string(&config_path)?; let mut config: Mapping = serde_yaml::from_str(&raw_config).map_err(std::io::Error::other)?; if let Some(theme) = config.get("theme_setting") && !theme.is_null() && let Some(theme_obj) = theme.as_mapping() && let Some(color) = theme_obj.get("primary_color") { println!("color: {color:?}"); config.insert("theme_color".into(), color.clone()); } config.remove("theme_setting"); let new_config = serde_yaml::to_string(&config).map_err(std::io::Error::other)?; std::fs::write(&config_path, new_config)?; Ok(()) } fn discard(&self) -> std::io::Result<()> { let config_path = crate::utils::dirs::nyanpasu_config_path().unwrap(); if !config_path.exists() { return Ok(()); } let raw_config = std::fs::read_to_string(&config_path)?; let mut config: Mapping = serde_yaml::from_str(&raw_config).map_err(std::io::Error::other)?; if let Some(color) = config.get("theme_color") { let mut theme_obj = Mapping::new(); theme_obj.insert("primary_color".into(), color.clone()); config.insert( "theme_setting".into(), serde_yaml::Value::Mapping(theme_obj), ); config.remove("theme_color"); } let new_config = serde_yaml::to_string(&config).map_err(std::io::Error::other)?; std::fs::write(&config_path, new_config)?; Ok(()) } } ================================================ FILE: backend/tauri/src/core/mod.rs ================================================ pub mod clash; pub mod connection_interruption; pub mod handle; pub mod hotkey; pub mod logger; pub mod manager; pub mod pac; pub mod service; pub mod storage; pub mod sysopt; pub mod tasks; pub mod tray; pub mod updater; #[cfg(windows)] pub mod win_uwp; pub use self::clash::core::*; pub mod migration; pub mod state; pub mod state_v2; ================================================ FILE: backend/tauri/src/core/pac.rs ================================================ use crate::{config::Config, log_err}; use anyhow::{Context, Result}; use std::{path::PathBuf, time::Duration}; use sysproxy::Autoproxy; use tokio::fs; /// PAC module for handling Proxy Auto-Configuration pub struct PacManager; // Constants for PAC handling const PAC_DOWNLOAD_TIMEOUT: u64 = 30; // seconds const PAC_MAX_RETRIES: u32 = 3; const PAC_RETRY_DELAY: u64 = 5; // seconds impl PacManager { /// Get PAC URL from config pub fn get_pac_url() -> Option { Config::verge().latest().pac_url.clone() } /// Check if PAC is enabled (URL is set) pub fn is_pac_enabled() -> bool { Self::get_pac_url().is_some_and(|url| !url.is_empty()) } /// Download PAC script from URL with retry logic pub async fn download_pac_script(url: &str) -> Result { let client = reqwest::Client::builder() .timeout(Duration::from_secs(PAC_DOWNLOAD_TIMEOUT)) .build() .context("failed to build HTTP client")?; // Retry logic let mut last_error = None; for attempt in 1..=PAC_MAX_RETRIES { match client.get(url).send().await { Ok(response) => { if response.status().is_success() { match response.text().await { Ok(content) => return Ok(content), Err(e) => { let err = anyhow::anyhow!("failed to read PAC script content: {}", e); log::warn!(target: "app", "Attempt {}/{} failed: {}", attempt, PAC_MAX_RETRIES, err); last_error = Some(err); } } } else { let err = anyhow::anyhow!( "failed to download PAC script, status: {}", response.status() ); log::warn!(target: "app", "Attempt {}/{} failed: {}", attempt, PAC_MAX_RETRIES, err); last_error = Some(err); } } Err(e) => { let err = anyhow::anyhow!("failed to download PAC script: {}", e); log::warn!(target: "app", "Attempt {}/{} failed: {}", attempt, PAC_MAX_RETRIES, err); last_error = Some(err); } } // Wait before retrying (except on last attempt) if attempt < PAC_MAX_RETRIES { tokio::time::sleep(Duration::from_secs(PAC_RETRY_DELAY)).await; } } Err(last_error.unwrap_or_else(|| { anyhow::anyhow!( "failed to download PAC script after {} attempts", PAC_MAX_RETRIES ) })) } /// Save PAC script to cache directory pub async fn save_pac_script(script: &str) -> Result { let cache_dir = crate::utils::dirs::cache_dir()?; let pac_file = cache_dir.join("pac.js"); fs::write(&pac_file, script) .await .context("failed to save PAC script")?; Ok(pac_file) } /// Basic validation of PAC script structure - check for required functions pub async fn validate_pac_script(script: &str) -> Result<()> { // A basic validation without using the JS engine - just check if FindProxyForURL function exists if !script.contains("FindProxyForURL") { return Err(anyhow::anyhow!( "PAC script must contain FindProxyForURL function" )); } // Additional basic checks could be added here if needed Ok(()) } /// Set system proxy to use PAC URL pub fn set_pac_proxy(url: &str) -> Result<()> { // Check if Autoproxy is supported on this platform if !Autoproxy::is_support() { return Err(anyhow::anyhow!( "PAC proxy is not supported on this platform" )); } let autoproxy = Autoproxy { enable: true, url: url.to_string(), }; autoproxy .set_auto_proxy() .context("failed to set PAC proxy")?; Ok(()) } /// Disable PAC proxy and revert to direct proxy pub fn disable_pac_proxy() -> Result<()> { // Check if Autoproxy is supported on this platform if !Autoproxy::is_support() { log::info!(target: "app", "PAC proxy is not supported on this platform, skipping disable"); return Ok(()); } let autoproxy = Autoproxy { enable: false, url: String::new(), }; autoproxy .set_auto_proxy() .context("failed to disable PAC proxy")?; Ok(()) } /// Fallback to direct proxy when PAC fails pub fn fallback_to_direct_proxy() -> Result<()> { log::warn!(target: "app", "Falling back to direct proxy mode"); // Check if Sysproxy is supported on this platform if !sysproxy::Sysproxy::is_support() { return Err(anyhow::anyhow!( "Direct proxy is not supported on this platform" )); } // Get the standard proxy settings let port = Config::verge() .latest() .verge_mixed_port .unwrap_or(Config::clash().data().get_mixed_port()); let (enable, bypass) = { let verge = Config::verge(); let verge = verge.latest(); ( verge.enable_system_proxy.unwrap_or(false), verge.system_proxy_bypass.clone(), ) }; #[cfg(target_os = "windows")] let default_bypass = "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;"; #[cfg(target_os = "linux")] let default_bypass = "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,::1"; #[cfg(target_os = "macos")] let default_bypass = "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,"; let sysproxy = sysproxy::Sysproxy { enable, host: String::from("127.0.0.1"), port, bypass: bypass.unwrap_or(default_bypass.into()), }; sysproxy .set_system_proxy() .context("failed to set direct proxy as fallback")?; log::info!(target: "app", "Fallback to direct proxy successful"); Ok(()) } /// Update PAC configuration with error handling and fallback pub async fn update_pac() -> Result<()> { if !Self::is_pac_enabled() { log::info!(target: "app", "PAC is not enabled, skipping update"); return Ok(()); } // Check if Autoproxy is supported on this platform if !Autoproxy::is_support() { log::warn!(target: "app", "PAC proxy is not supported on this platform"); // Try to fallback to direct proxy log_err!(Self::fallback_to_direct_proxy()); return Err(anyhow::anyhow!( "PAC proxy is not supported on this platform" )); } let pac_url = Self::get_pac_url().unwrap(); log::info!(target: "app", "Updating PAC from URL: {}", pac_url); // Download PAC script let script = match Self::download_pac_script(&pac_url).await { Ok(script) => script, Err(e) => { log::error!(target: "app", "Failed to download PAC script: {}", e); // Try to fallback to direct proxy log_err!(Self::fallback_to_direct_proxy()); return Err(e); } }; // Validate PAC script if let Err(e) = Self::validate_pac_script(&script).await { log::error!(target: "app", "PAC script validation failed: {}", e); // Try to fallback to direct proxy log_err!(Self::fallback_to_direct_proxy()); return Err(e); } // Save PAC script to cache if let Err(e) = Self::save_pac_script(&script).await { log::warn!(target: "app", "Failed to save PAC script to cache: {}", e); // This is not critical, continue with setting the proxy } // Set system proxy to use PAC if let Err(e) = Self::set_pac_proxy(&pac_url) { log::error!(target: "app", "Failed to set PAC proxy: {}", e); // Try to fallback to direct proxy log_err!(Self::fallback_to_direct_proxy()); return Err(e); } log::info!(target: "app", "PAC updated successfully"); Ok(()) } /// Initialize PAC proxy on startup with error handling pub async fn init_pac_proxy() -> Result<()> { if !Self::is_pac_enabled() { log::info!(target: "app", "PAC is not enabled, skipping initialization"); return Ok(()); } log::info!(target: "app", "Initializing PAC proxy"); if let Err(e) = Self::update_pac().await { log::error!(target: "app", "Failed to initialize PAC proxy: {}", e); return Err(e); } Ok(()) } } #[cfg(test)] mod tests { use super::*; use tokio; #[tokio::test] async fn test_pac_download() { // Test with a known good PAC URL let pac_url = "https://raw.githubusercontent.com/Slinetrac/clash-nyanpasu/main/test.pac"; match PacManager::download_pac_script(pac_url).await { Ok(script) => { assert!(!script.is_empty()); println!( "Downloaded PAC script: {}", &script[..std::cmp::min(100, script.len())] ); } Err(e) => { eprintln!("Failed to download PAC script: {}", e); // This might fail in test environment, so we won't assert failure } } } #[tokio::test] async fn test_pac_validation() { let valid_pac_script = r#" function FindProxyForURL(url, host) { return "DIRECT"; } "#; assert!( PacManager::validate_pac_script(valid_pac_script) .await .is_ok() ); let invalid_pac_script = r#" function SomeOtherFunction(url, host) { // This script does not contain the required function return "PROXY proxy.example.com:8080"; } "#; assert!( PacManager::validate_pac_script(invalid_pac_script) .await .is_err() ); } #[tokio::test] async fn test_pac_save() { let script = "function FindProxyForURL(url, host) { return 'DIRECT'; }"; match PacManager::save_pac_script(script).await { Ok(path) => { assert!(path.exists()); // Clean up let _ = tokio::fs::remove_file(path).await; } Err(e) => { eprintln!("Failed to save PAC script: {}", e); } } } } ================================================ FILE: backend/tauri/src/core/service/control.rs ================================================ use crate::utils::dirs::{app_config_dir, app_data_dir, app_install_dir}; use runas::Command as RunasCommand; use std::ffi::OsString; use super::SERVICE_PATH; #[cfg(unix)] use std::os::unix::process::ExitStatusExt; pub async fn get_service_install_args() -> Result, anyhow::Error> { let user = { #[cfg(windows)] { nyanpasu_utils::os::get_current_user_sid().await? } #[cfg(not(windows))] { whoami::username() } }; let data_dir = app_data_dir()?; let config_dir = app_config_dir()?; let app_dir = app_install_dir()?; #[cfg(not(windows))] let args: Vec = vec![ "install".into(), "--user".into(), user.into(), "--nyanpasu-data-dir".into(), format!("\"{}\"", data_dir.to_string_lossy()).into(), "--nyanpasu-config-dir".into(), format!("\"{}\"", config_dir.to_string_lossy()).into(), "--nyanpasu-app-dir".into(), format!("\"{}\"", app_dir.to_string_lossy()).into(), ]; #[cfg(windows)] let args: Vec = vec![ "install".into(), "--user".into(), user.into(), "--nyanpasu-data-dir".into(), data_dir.into(), "--nyanpasu-config-dir".into(), config_dir.into(), "--nyanpasu-app-dir".into(), app_dir.into(), ]; Ok(args) } pub async fn install_service() -> anyhow::Result<()> { let args = get_service_install_args().await?; let child = tokio::task::spawn_blocking(move || { #[cfg(not(target_os = "macos"))] { RunasCommand::new(SERVICE_PATH.as_path()) .args(&args) .gui(true) .show(true) .status() } #[cfg(target_os = "macos")] { use crate::utils::sudo::sudo; let args = args.iter().map(|s| s.to_string_lossy()).collect::>(); match sudo(SERVICE_PATH.to_string_lossy(), &args) { Ok(()) => Ok(std::process::ExitStatus::from_raw(0)), Err(e) => { tracing::error!("failed to install service: {}", e); Err(e) } } } }) .await??; if !child.success() { anyhow::bail!( "failed to install service, exit code: {}, signal: {:?}", child.code().unwrap_or(-1), { #[cfg(unix)] { child.signal().unwrap_or(0) } #[cfg(not(unix))] { 0 } } ); } // Due to most platform, the service will be started automatically after installed if !super::ipc::HEALTH_CHECK_RUNNING.load(std::sync::atomic::Ordering::Relaxed) { super::ipc::spawn_health_check(); } Ok(()) } pub async fn update_service() -> anyhow::Result<()> { let child = tokio::task::spawn_blocking(move || { const ARGS: &[&str] = &["update"]; #[cfg(not(target_os = "macos"))] { RunasCommand::new(SERVICE_PATH.as_path()) .args(ARGS) .gui(true) .show(true) .status() } #[cfg(target_os = "macos")] { use crate::utils::sudo::sudo; match sudo(SERVICE_PATH.to_string_lossy(), ARGS) { Ok(()) => Ok(std::process::ExitStatus::from_raw(0)), Err(e) => { tracing::error!("failed to install service: {}", e); Err(e) } } } }) .await??; if !child.success() { anyhow::bail!( "failed to update service, exit code: {}, signal: {:?}", child.code().unwrap_or(-1), { #[cfg(unix)] { child.signal().unwrap_or(0) } #[cfg(not(unix))] { 0 } } ); } Ok(()) } pub async fn uninstall_service() -> anyhow::Result<()> { let child = tokio::task::spawn_blocking(move || { const ARGS: &[&str] = &["uninstall"]; #[cfg(not(target_os = "macos"))] { RunasCommand::new(SERVICE_PATH.as_path()) .args(ARGS) .gui(true) .show(true) .status() } #[cfg(target_os = "macos")] { use crate::utils::sudo::sudo; match sudo(SERVICE_PATH.to_string_lossy(), ARGS) { Ok(()) => Ok(std::process::ExitStatus::from_raw(0)), Err(e) => { tracing::error!("failed to install service: {}", e); Err(e) } } } }) .await??; if !child.success() { anyhow::bail!( "failed to uninstall service, exit code: {}", child.code().unwrap() ); } let _ = super::ipc::KILL_FLAG.compare_exchange( false, true, std::sync::atomic::Ordering::Acquire, std::sync::atomic::Ordering::Relaxed, ); Ok(()) } pub async fn start_service() -> anyhow::Result<()> { let child = tokio::task::spawn_blocking(move || { const ARGS: &[&str] = &["start"]; #[cfg(not(target_os = "macos"))] { RunasCommand::new(SERVICE_PATH.as_path()) .args(ARGS) .gui(true) .show(true) .status() } #[cfg(target_os = "macos")] { use crate::utils::sudo::sudo; match sudo(SERVICE_PATH.to_string_lossy(), ARGS) { Ok(()) => Ok(std::process::ExitStatus::from_raw(0)), Err(e) => { tracing::error!("failed to install service: {}", e); Err(e) } } } }) .await??; if !child.success() { anyhow::bail!( "failed to start service, exit code: {}, signal: {:?}", child.code().unwrap_or(-1), { #[cfg(unix)] { child.signal().unwrap_or(0) } #[cfg(not(unix))] { 0 } } ); } if !super::ipc::HEALTH_CHECK_RUNNING.load(std::sync::atomic::Ordering::Acquire) { super::ipc::spawn_health_check(); } Ok(()) } pub async fn stop_service() -> anyhow::Result<()> { let child = tokio::task::spawn_blocking(move || { const ARGS: &[&str] = &["stop"]; #[cfg(not(target_os = "macos"))] { RunasCommand::new(SERVICE_PATH.as_path()) .args(ARGS) .gui(true) .show(true) .status() } #[cfg(target_os = "macos")] { use crate::utils::sudo::sudo; match sudo(SERVICE_PATH.to_string_lossy(), ARGS) { Ok(()) => Ok(std::process::ExitStatus::from_raw(0)), Err(e) => { tracing::error!("failed to install service: {}", e); Err(e) } } } }) .await??; if !child.success() { anyhow::bail!( "failed to stop service, exit code: {}, signal: {:?}", child.code().unwrap_or(-1), { #[cfg(unix)] { child.signal().unwrap_or(0) } #[cfg(not(unix))] { 0 } } ); } let _ = super::ipc::KILL_FLAG.compare_exchange_weak( false, true, std::sync::atomic::Ordering::Acquire, std::sync::atomic::Ordering::Relaxed, ); Ok(()) } pub async fn restart_service() -> anyhow::Result<()> { let child = tokio::task::spawn_blocking(move || { const ARGS: &[&str] = &["restart"]; #[cfg(not(target_os = "macos"))] { RunasCommand::new(SERVICE_PATH.as_path()) .args(ARGS) .gui(true) .show(true) .status() } #[cfg(target_os = "macos")] { use crate::utils::sudo::sudo; match sudo(SERVICE_PATH.to_string_lossy(), ARGS) { Ok(()) => Ok(std::process::ExitStatus::from_raw(0)), Err(e) => { tracing::error!("failed to install service: {}", e); Err(e) } } } }) .await??; if !child.success() { anyhow::bail!( "failed to restart service, exit code: {}, signal: {:?}", child.code().unwrap_or(-1), { #[cfg(unix)] { child.signal().unwrap_or(0) } #[cfg(not(unix))] { 0 } } ); } if !super::ipc::HEALTH_CHECK_RUNNING.load(std::sync::atomic::Ordering::Acquire) { super::ipc::spawn_health_check(); } Ok(()) } #[tracing::instrument] pub async fn status<'a>() -> anyhow::Result> { let mut cmd = tokio::process::Command::new(SERVICE_PATH.as_path()); cmd.args(["status", "--json"]); #[cfg(windows)] cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW let output = cmd.output().await?; if !output.status.success() { anyhow::bail!( "failed to query service status, exit code: {}, signal: {:?}", output.status.code().unwrap_or(-1), { #[cfg(unix)] { output.status.signal().unwrap_or(0) } #[cfg(not(unix))] { 0 } } ); } let mut status = String::from_utf8(output.stdout)?; tracing::trace!("service status: {}", status); Ok(serde_json::from_str(&mut status)?) } ================================================ FILE: backend/tauri/src/core/service/ipc.rs ================================================ use std::sync::atomic::{AtomicBool, Ordering}; use atomic_enum::atomic_enum; use nyanpasu_ipc::types::ServiceStatus; use nyanpasu_utils::runtime::block_on; use serde::Serialize; use tracing::instrument; use crate::log_err; #[derive(PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] #[atomic_enum] pub enum IpcState { Connected, Disconnected, } impl IpcState { pub fn is_connected(&self) -> bool { *self == IpcState::Connected } } static IPC_STATE: AtomicIpcState = AtomicIpcState::new(IpcState::Disconnected); pub(super) static KILL_FLAG: AtomicBool = AtomicBool::new(false); pub(super) static HEALTH_CHECK_RUNNING: AtomicBool = AtomicBool::new(false); pub fn get_ipc_state() -> IpcState { IPC_STATE.load(Ordering::Relaxed) } pub(super) fn set_ipc_state(state: IpcState) { IPC_STATE.store(state, Ordering::Relaxed); on_ipc_state_changed(state); } fn dispatch_disconnected() { if IPC_STATE .compare_exchange_weak( IpcState::Connected, IpcState::Disconnected, Ordering::SeqCst, Ordering::Relaxed, ) .is_ok() { on_ipc_state_changed(IpcState::Disconnected) } } fn dispatch_connected() { if IPC_STATE .compare_exchange_weak( IpcState::Disconnected, IpcState::Connected, Ordering::SeqCst, Ordering::Relaxed, ) .is_ok() { on_ipc_state_changed(IpcState::Connected) } } // TODO: it might be moved to outer scope? #[instrument] fn on_ipc_state_changed(state: IpcState) { tracing::info!("IPC state changed: {:?}", state); let enabled_service = { *crate::config::Config::verge() .latest() .enable_service_mode .as_ref() .unwrap_or(&false) }; std::thread::spawn(move || { nyanpasu_utils::runtime::block_on(async move { if enabled_service { let (_, _, run_type) = crate::core::CoreManager::global().status().await; match (state, run_type) { (IpcState::Connected, crate::core::RunType::Normal) | (IpcState::Disconnected, crate::core::RunType::Service) => { tracing::info!("Restarting core due to IPC state change"); log_err!(crate::core::CoreManager::global().run_core().await); } _ => {} } } }) }); } pub(super) fn spawn_health_check() { KILL_FLAG.store(false, Ordering::Relaxed); std::thread::spawn(|| { HEALTH_CHECK_RUNNING.store(true, Ordering::Release); block_on(async { loop { if KILL_FLAG.load(Ordering::Acquire) { set_ipc_state(IpcState::Disconnected); HEALTH_CHECK_RUNNING.store(false, Ordering::Release); break; } health_check().await; tokio::time::sleep(std::time::Duration::from_secs(5)).await; } }) }); } #[instrument] async fn health_check() { match super::control::status().await { Ok(info) => match info.status { ServiceStatus::Running => { dispatch_connected(); } ServiceStatus::Stopped | ServiceStatus::NotInstalled => { dispatch_disconnected(); } }, Err(e) => { tracing::error!("IPC health check failed: {}", e); dispatch_disconnected(); } } } ================================================ FILE: backend/tauri/src/core/service/mod.rs ================================================ use std::path::PathBuf; use nyanpasu_ipc::types::StatusInfo; use once_cell::sync::Lazy; use crate::{config::Config, utils::dirs::app_install_dir}; pub mod control; pub mod ipc; const SERVICE_NAME: &str = "nyanpasu-service"; static SERVICE_PATH: Lazy = Lazy::new(|| { let app_path = app_install_dir().unwrap(); app_path.join(format!("{}{}", SERVICE_NAME, std::env::consts::EXE_SUFFIX)) }); pub async fn init_service() { let enable_service = { *Config::verge() .latest() .enable_service_mode .as_ref() .unwrap_or(&false) }; if let Ok(StatusInfo { status: nyanpasu_ipc::types::ServiceStatus::Running, .. }) = control::status().await && enable_service { ipc::spawn_health_check(); while !ipc::HEALTH_CHECK_RUNNING.load(std::sync::atomic::Ordering::Acquire) { tokio::time::sleep(std::time::Duration::from_millis(100)).await; } } } ================================================ FILE: backend/tauri/src/core/state.rs ================================================ #[allow(dead_code)] use parking_lot::{ MappedRwLockReadGuard, MappedRwLockWriteGuard, RwLock, lock_api::{RwLockReadGuard, RwLockWriteGuard}, }; use std::{ ops::Deref, sync::{Arc, atomic::AtomicBool}, }; /// State manager for the application /// It provides a way to manage the application state, draft and persist it /// Note: It is safe to clone the StateManager, as it is backed by an Arc #[derive(Clone)] pub struct ManagedState where T: Clone + Sync + Send, { inner: Arc>, } impl Deref for ManagedState where T: Clone + Sync + Send, { type Target = ManagedStateInner; fn deref(&self) -> &Self::Target { &self.inner } } impl From for ManagedState where T: Clone + Sync + Send, { fn from(state: T) -> Self { Self { inner: Arc::new(ManagedStateInner::new(state)), } } } impl ManagedState where T: Clone + Sync + Send, { /// to auto commit the state when it is dropped pub fn auto_commit(&self) -> ManagedStateAutoCommit { ManagedStateAutoCommit(self) } } pub struct ManagedStateAutoCommit<'a, T: Clone + Send + Sync>(&'a ManagedState); impl Deref for ManagedStateAutoCommit<'_, T> where T: Clone + Send + Sync, { type Target = ManagedState; fn deref(&self) -> &Self::Target { self.0 } } impl Drop for ManagedStateAutoCommit<'_, T> { fn drop(&mut self) { if self.0.is_dirty() { self.0.apply(); } } } pub struct ManagedStateInner where T: Clone + Sync + Send, { inner: RwLock, draft: RwLock>, is_dirty: AtomicBool, } impl ManagedStateInner where T: Clone + Sync + Send, { /// create a new managed state pub fn new(state: T) -> Self { Self { inner: RwLock::new(state), draft: RwLock::new(None), is_dirty: AtomicBool::new(false), } } /// Get the committed state pub fn data(&self) -> MappedRwLockReadGuard<'_, T> { RwLockReadGuard::map(self.inner.read(), |guard| guard) } /// get the current state, it will return the ManagedStateLocker for the state pub fn latest(&self) -> MappedRwLockReadGuard<'_, T> { if self.is_dirty() { let draft = self.draft.read(); if draft.is_some() { RwLockReadGuard::map(draft, |guard| guard.as_ref().unwrap()) } else { let state = self.inner.read(); RwLockReadGuard::map(state, |guard| guard) } } else { let state = self.inner.read(); RwLockReadGuard::map(state, |guard| guard) } } /// whether the state is dirty, i.e. a draft is present, and not yet committed or discarded pub fn is_dirty(&self) -> bool { self.is_dirty.load(std::sync::atomic::Ordering::Acquire) } /// You can modify the draft state, and then commit it pub fn draft(&self) -> MappedRwLockWriteGuard<'_, T> { if self.is_dirty() { let guard = self.draft.write(); if guard.is_some() { return RwLockWriteGuard::map(guard, |g| g.as_mut().unwrap()); } } let state = self.inner.read().clone(); self.is_dirty .store(true, std::sync::atomic::Ordering::Release); RwLockWriteGuard::map(self.draft.write(), move |guard| { *guard = Some(state); guard.as_mut().unwrap() }) } /// commit the draft state, and make it the new state pub fn apply(&self) -> Option { if !self.is_dirty() { return None; } let mut draft = self.draft.write(); let mut inner = self.inner.write(); let old_value = inner.to_owned(); if let Some(draft_value) = draft.take() { *inner = draft_value; self.is_dirty .store(false, std::sync::atomic::Ordering::Release); Some(old_value) } else { self.is_dirty .store(false, std::sync::atomic::Ordering::Release); None } } /// discard the draft state pub fn discard(&self) -> Option { let v = self.draft.write().take(); self.is_dirty .store(false, std::sync::atomic::Ordering::Release); v } } mod test { #![allow(unused)] use super::ManagedState; use crate::config::IVerge; #[test] fn test_managed_state() { let verge = IVerge { enable_auto_launch: Some(true), enable_tun_mode: Some(false), ..IVerge::default() }; let draft = ManagedState::from(verge); assert_eq!(draft.data().enable_auto_launch, Some(true)); assert_eq!(draft.data().enable_tun_mode, Some(false)); assert_eq!(draft.draft().enable_auto_launch, Some(true)); assert_eq!(draft.draft().enable_tun_mode, Some(false)); let mut d = draft.draft(); d.enable_auto_launch = Some(false); d.enable_tun_mode = Some(true); drop(d); assert_eq!(draft.data().enable_auto_launch, Some(true)); assert_eq!(draft.data().enable_tun_mode, Some(false)); assert_eq!(draft.draft().enable_auto_launch, Some(false)); assert_eq!(draft.draft().enable_tun_mode, Some(true)); assert_eq!(draft.latest().enable_auto_launch, Some(false)); assert_eq!(draft.latest().enable_tun_mode, Some(true)); assert!(draft.apply().is_some()); assert!(draft.apply().is_none()); assert_eq!(draft.data().enable_auto_launch, Some(false)); assert_eq!(draft.data().enable_tun_mode, Some(true)); assert_eq!(draft.draft().enable_auto_launch, Some(false)); assert_eq!(draft.draft().enable_tun_mode, Some(true)); let mut d = draft.draft(); d.enable_auto_launch = Some(true); drop(d); assert_eq!(draft.data().enable_auto_launch, Some(false)); assert_eq!(draft.draft().enable_auto_launch, Some(true)); assert!(draft.discard().is_some()); assert_eq!(draft.data().enable_auto_launch, Some(false)); assert!(draft.discard().is_none()); assert_eq!(draft.draft().enable_auto_launch, Some(false)); } } ================================================ FILE: backend/tauri/src/core/state_v2/builder.rs ================================================ pub trait StateSyncBuilder: Default + Clone { type State: Clone + Send + Sync + 'static; fn build(&self) -> anyhow::Result; } pub trait StateAsyncBuilder: Default + Clone { type State: Clone + Send + Sync + 'static; async fn build(&self) -> anyhow::Result; } impl StateAsyncBuilder for S where S: StateSyncBuilder, T: Clone + Send + Sync + 'static, { type State = T; async fn build(&self) -> anyhow::Result { self.build() } } ================================================ FILE: backend/tauri/src/core/state_v2/coordinator.rs ================================================ use super::builder::*; #[derive(thiserror::Error, Debug)] pub enum StateChangedError { #[error("builder validation error: {0}")] Validation(anyhow::Error), #[error("state migrate error: {0:#?}")] Migrate(#[from] MigrateError), #[error("state migrate and rollback error: migrate {0:#?}, rollback {1:#?}")] MigrateAndRollback(MigrateError, RollbackError), } #[derive(thiserror::Error, Debug)] #[error("state migrate error: {name}: {error:#?}")] pub struct MigrateError { pub name: String, pub error: anyhow::Error, } #[derive(thiserror::Error, Debug)] #[error("state rollback error: {name}: {error:#?}")] pub struct RollbackError { pub name: String, pub error: anyhow::Error, } #[async_trait::async_trait] #[allow(unused_variables)] pub(crate) trait StateChangedSubscriber { /// The name of the subscriber. fn name(&self) -> &str; /// Called when the state is changed, return a Error if the state change is failed. /// /// While state migrate is failed, the rollback will be called. /// /// When the prev_state is None, it means the state is not initialized. async fn migrate(&self, prev_state: Option, new_state: T) -> Result<(), anyhow::Error>; /// Called when the state migrate is failed, return a Error if the state rollback is failed. /// /// If the migration do not affect the real system/service, you can use the default implementation, /// OR you MUST implement the rollback method. async fn rollback(&self, prev_state: Option, new_state: T) -> Result<(), anyhow::Error> { Ok(()) } } #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ConcurrencyStrategy { #[default] Sequential, Concurrent, Limited(usize), } #[non_exhaustive] pub struct StateCoordinator { current_state: Option, subscribers: Vec + Send + Sync>>, // strategy: ConcurrencyStrategy, } impl StateCoordinator { pub(super) fn new() -> Self { Self { current_state: None, subscribers: Vec::new(), } } /// Add a subscriber to the state coordinator. fn add_subscriber(&mut self, subscriber: Box + Send + Sync>) { self.subscribers.push(subscriber); } /// Get the current state. pub fn current_state(&self) -> Option { self.current_state.clone() } async fn run_migration( subscriber: &S, current_state: Option<&T>, new_state: &T, ) -> Result<(), StateChangedError> where S: StateChangedSubscriber + Send + Sync + ?Sized, { if let Err(e) = subscriber .migrate(current_state.cloned(), new_state.clone()) .await { let migrate_error = MigrateError { name: subscriber.name().to_string(), error: e, }; tracing::error!("migrate error: {migrate_error:#?}"); if let Err(e) = subscriber .rollback(current_state.cloned(), new_state.clone()) .await { tracing::error!("rollback error: {e:#?}"); return Err(StateChangedError::MigrateAndRollback( migrate_error, RollbackError { name: subscriber.name().to_string(), error: e, }, )); } return Err(StateChangedError::Migrate(migrate_error)); } Ok(()) } /// Upsert the state by a builder, it was used for a builder was patched for upsert. pub async fn upsert( &mut self, builder: impl StateAsyncBuilder, ) -> Result<(), StateChangedError> { let new_state = builder .build() .await .map_err(StateChangedError::Validation)?; for subscriber in self.subscribers.iter() { Self::run_migration(subscriber.as_ref(), self.current_state.as_ref(), &new_state) .await?; } self.current_state = Some(new_state); Ok(()) } /// Upsert the state directly, it used for a small StateObject, a bool value, etc. pub async fn upsert_state(&mut self, state: T) -> Result<(), StateChangedError> { for subscriber in self.subscribers.iter() { Self::run_migration(subscriber.as_ref(), self.current_state.as_ref(), &state).await?; } self.current_state = Some(state); Ok(()) } } #[cfg(test)] mod test { use super::*; use std::sync::{ Arc, atomic::{AtomicBool, AtomicUsize, Ordering}, }; use tokio::sync::Mutex; #[derive(Debug, Clone, PartialEq)] struct TestState { value: i32, name: String, } struct MockSubscriber { name: String, migrate_calls: Arc, rollback_calls: Arc, should_fail_migrate: Arc, should_fail_rollback: Arc, migrate_history: Arc, TestState)>>>, rollback_history: Arc, TestState)>>>, } impl MockSubscriber { fn new(name: &str) -> Self { Self { name: name.to_string(), migrate_calls: Arc::new(AtomicUsize::new(0)), rollback_calls: Arc::new(AtomicUsize::new(0)), should_fail_migrate: Arc::new(AtomicBool::new(false)), should_fail_rollback: Arc::new(AtomicBool::new(false)), migrate_history: Arc::new(Mutex::new(Vec::new())), rollback_history: Arc::new(Mutex::new(Vec::new())), } } fn set_migrate_failure(&self, should_fail: bool) { self.should_fail_migrate .store(should_fail, Ordering::SeqCst); } fn set_rollback_failure(&self, should_fail: bool) { self.should_fail_rollback .store(should_fail, Ordering::SeqCst); } async fn get_migrate_history(&self) -> Vec<(Option, TestState)> { self.migrate_history.lock().await.clone() } async fn get_rollback_history(&self) -> Vec<(Option, TestState)> { self.rollback_history.lock().await.clone() } fn get_migrate_calls(&self) -> usize { self.migrate_calls.load(Ordering::SeqCst) } fn get_rollback_calls(&self) -> usize { self.rollback_calls.load(Ordering::SeqCst) } } #[async_trait::async_trait] impl StateChangedSubscriber for MockSubscriber { fn name(&self) -> &str { &self.name } async fn migrate( &self, prev_state: Option, new_state: TestState, ) -> Result<(), anyhow::Error> { self.migrate_calls.fetch_add(1, Ordering::SeqCst); self.migrate_history .lock() .await .push((prev_state.clone(), new_state.clone())); if self.should_fail_migrate.load(Ordering::SeqCst) { return Err(anyhow::anyhow!("Mock migrate failure")); } Ok(()) } async fn rollback( &self, prev_state: Option, new_state: TestState, ) -> Result<(), anyhow::Error> { self.rollback_calls.fetch_add(1, Ordering::SeqCst); self.rollback_history .lock() .await .push((prev_state.clone(), new_state.clone())); if self.should_fail_rollback.load(Ordering::SeqCst) { return Err(anyhow::anyhow!("Mock rollback failure")); } Ok(()) } } #[async_trait::async_trait] impl StateChangedSubscriber for Arc { fn name(&self) -> &str { self.as_ref().name() } async fn migrate( &self, prev_state: Option, new_state: TestState, ) -> Result<(), anyhow::Error> { self.as_ref().migrate(prev_state, new_state).await } async fn rollback( &self, prev_state: Option, new_state: TestState, ) -> Result<(), anyhow::Error> { self.as_ref().rollback(prev_state, new_state).await } } #[derive(Default, Clone, Debug)] struct TestStateBuilder { state: Option, should_fail: bool, } impl TestStateBuilder { fn new(state: TestState) -> Self { Self { state: Some(state), should_fail: false, } } fn failing() -> Self { Self { state: None, should_fail: true, } } } impl StateSyncBuilder for TestStateBuilder { type State = TestState; fn build(&self) -> anyhow::Result { if self.should_fail { return Err(anyhow::anyhow!("Builder validation failed")); } Ok(self.state.clone().unwrap()) } } #[tokio::test] async fn test_new_coordinator() { let coordinator: StateCoordinator = StateCoordinator::new(); let current_state = coordinator.current_state.clone(); assert!(current_state.is_none()); assert_eq!(coordinator.subscribers.len(), 0); } #[tokio::test] async fn test_upsert_state_success() { let mut coordinator: StateCoordinator = StateCoordinator::new(); let subscriber = Arc::new(MockSubscriber::new("test_subscriber")); coordinator.subscribers.push(Box::new(subscriber.clone()) as Box + Send + Sync>); let test_state = TestState { value: 42, name: "test".to_string(), }; let result = coordinator.upsert_state(test_state.clone()).await; assert!(result.is_ok()); // 检查状态是否更新 let current_state = coordinator.current_state.clone(); assert_eq!(current_state, Some(test_state.clone())); // 检查订阅者是否被调用 assert_eq!(subscriber.get_migrate_calls(), 1); assert_eq!(subscriber.get_rollback_calls(), 0); let history = subscriber.get_migrate_history().await; assert_eq!(history.len(), 1); assert_eq!(history[0], (None, test_state)); } #[tokio::test] async fn test_upsert_with_builder_success() { let mut coordinator: StateCoordinator = StateCoordinator::new(); let subscriber = Arc::new(MockSubscriber::new("test_subscriber")); coordinator.subscribers.push(Box::new(subscriber.clone()) as Box + Send + Sync>); let test_state = TestState { value: 100, name: "builder_test".to_string(), }; let builder = TestStateBuilder::new(test_state.clone()); let result = coordinator.upsert(builder).await; assert!(result.is_ok()); // 检查状态是否更新 let current_state = coordinator.current_state.clone(); assert_eq!(current_state, Some(test_state.clone())); // 检查订阅者是否被调用 assert_eq!(subscriber.get_migrate_calls(), 1); assert_eq!(subscriber.get_rollback_calls(), 0); } #[tokio::test] async fn test_upsert_builder_validation_failure() { let mut coordinator: StateCoordinator = StateCoordinator::new(); let builder = TestStateBuilder::failing(); let result = coordinator.upsert(builder).await; assert!(result.is_err()); match result.unwrap_err() { StateChangedError::Validation(_) => {} _ => panic!("Expected validation error"), } // 确保状态没有改变 let current_state = coordinator.current_state.clone(); assert!(current_state.is_none()); } #[tokio::test] async fn test_migrate_failure_with_successful_rollback() { let mut coordinator: StateCoordinator = StateCoordinator::new(); let subscriber = Arc::new(MockSubscriber::new("failing_subscriber")); subscriber.set_migrate_failure(true); coordinator.subscribers.push(Box::new(subscriber.clone()) as Box + Send + Sync>); let test_state = TestState { value: 42, name: "test".to_string(), }; let result = coordinator.upsert_state(test_state.clone()).await; assert!(result.is_err()); match result.unwrap_err() { StateChangedError::Migrate(migrate_error) => { assert_eq!(migrate_error.name, "failing_subscriber"); } _ => panic!("Expected migrate error"), } // 检查调用次数 assert_eq!(subscriber.get_migrate_calls(), 1); assert_eq!(subscriber.get_rollback_calls(), 1); // 确保状态没有改变 let current_state = coordinator.current_state.clone(); assert!(current_state.is_none()); } #[tokio::test] async fn test_migrate_failure_with_rollback_failure() { let mut coordinator: StateCoordinator = StateCoordinator::new(); let subscriber = Arc::new(MockSubscriber::new("double_failing_subscriber")); subscriber.set_migrate_failure(true); subscriber.set_rollback_failure(true); coordinator.subscribers.push(Box::new(subscriber.clone()) as Box + Send + Sync>); let test_state = TestState { value: 42, name: "test".to_string(), }; let result = coordinator.upsert_state(test_state).await; assert!(result.is_err()); match result.unwrap_err() { StateChangedError::MigrateAndRollback(migrate_error, rollback_error) => { assert_eq!(migrate_error.name, "double_failing_subscriber"); assert_eq!(rollback_error.name, "double_failing_subscriber"); } _ => panic!("Expected migrate and rollback error"), } // 检查调用次数 assert_eq!(subscriber.get_migrate_calls(), 1); assert_eq!(subscriber.get_rollback_calls(), 1); // 确保状态没有改变 let current_state = coordinator.current_state.clone(); assert!(current_state.is_none()); } #[tokio::test] async fn test_multiple_subscribers_success() { let mut coordinator: StateCoordinator = StateCoordinator::new(); let subscriber1 = Arc::new(MockSubscriber::new("subscriber1")); let subscriber2 = Arc::new(MockSubscriber::new("subscriber2")); let subscriber3 = Arc::new(MockSubscriber::new("subscriber3")); coordinator.subscribers.push(Box::new(subscriber1.clone()) as Box + Send + Sync>); coordinator.subscribers.push(Box::new(subscriber2.clone()) as Box + Send + Sync>); coordinator.subscribers.push(Box::new(subscriber3.clone()) as Box + Send + Sync>); let test_state = TestState { value: 42, name: "multi_test".to_string(), }; let result = coordinator.upsert_state(test_state.clone()).await; assert!(result.is_ok()); // 检查所有订阅者都被调用 assert_eq!(subscriber1.get_migrate_calls(), 1); assert_eq!(subscriber2.get_migrate_calls(), 1); assert_eq!(subscriber3.get_migrate_calls(), 1); // 检查没有回滚调用 assert_eq!(subscriber1.get_rollback_calls(), 0); assert_eq!(subscriber2.get_rollback_calls(), 0); assert_eq!(subscriber3.get_rollback_calls(), 0); // 检查状态更新 let current_state = coordinator.current_state.clone(); assert_eq!(current_state, Some(test_state)); } #[tokio::test] async fn test_multiple_subscribers_with_one_failure() { let mut coordinator: StateCoordinator = StateCoordinator::new(); let subscriber1 = Arc::new(MockSubscriber::new("subscriber1")); let subscriber2 = Arc::new(MockSubscriber::new("failing_subscriber")); let subscriber3 = Arc::new(MockSubscriber::new("subscriber3")); subscriber2.set_migrate_failure(true); coordinator.subscribers.push(Box::new(subscriber1.clone()) as Box + Send + Sync>); coordinator.subscribers.push(Box::new(subscriber2.clone()) as Box + Send + Sync>); coordinator.subscribers.push(Box::new(subscriber3.clone()) as Box + Send + Sync>); let test_state = TestState { value: 42, name: "multi_fail_test".to_string(), }; let result = coordinator.upsert_state(test_state).await; assert!(result.is_err()); // 检查调用次数 - 只有前两个订阅者被调用 assert_eq!(subscriber1.get_migrate_calls(), 1); assert_eq!(subscriber2.get_migrate_calls(), 1); assert_eq!(subscriber3.get_migrate_calls(), 0); // 第三个不应该被调用 // 检查回滚调用 assert_eq!(subscriber1.get_rollback_calls(), 0); assert_eq!(subscriber2.get_rollback_calls(), 1); assert_eq!(subscriber3.get_rollback_calls(), 0); // 确保状态没有改变 let current_state = coordinator.current_state.clone(); assert!(current_state.is_none()); } #[tokio::test] async fn test_state_update_sequence() { let mut coordinator: StateCoordinator = StateCoordinator::new(); let subscriber = Arc::new(MockSubscriber::new("sequence_subscriber")); coordinator.subscribers.push(Box::new(subscriber.clone()) as Box + Send + Sync>); // 第一次更新 let state1 = TestState { value: 1, name: "first".to_string(), }; coordinator.upsert_state(state1.clone()).await.unwrap(); // 第二次更新 let state2 = TestState { value: 2, name: "second".to_string(), }; coordinator.upsert_state(state2.clone()).await.unwrap(); // 检查历史记录 let history = subscriber.get_migrate_history().await; assert_eq!(history.len(), 2); assert_eq!(history[0], (None, state1.clone())); assert_eq!(history[1], (Some(state1), state2.clone())); // 检查当前状态 let current_state = coordinator.current_state.clone(); assert_eq!(current_state, Some(state2)); } #[tokio::test] async fn test_error_display() { let migrate_error = MigrateError { name: "test_subscriber".to_string(), error: anyhow::anyhow!("test error"), }; let error_string = format!("{}", migrate_error); assert!(error_string.contains("state migrate error: test_subscriber")); let rollback_error = RollbackError { name: "test_subscriber".to_string(), error: anyhow::anyhow!("rollback error"), }; let error_string = format!("{}", rollback_error); assert!(error_string.contains("state rollback error: test_subscriber")); let state_error = StateChangedError::Migrate(migrate_error); let error_string = format!("{}", state_error); assert!(error_string.contains("state migrate error")); } #[tokio::test] async fn test_sync_builder_to_async_conversion() { let mut coordinator: StateCoordinator = StateCoordinator::new(); let test_state = TestState { value: 123, name: "sync_to_async".to_string(), }; let sync_builder = TestStateBuilder::new(test_state.clone()); // 通过 StateAsyncBuilder trait 使用同步构建器 let result = coordinator.upsert(sync_builder).await; assert!(result.is_ok()); let current_state = coordinator.current_state.clone(); assert_eq!(current_state, Some(test_state)); } #[tokio::test] async fn test_add_subscriber() { let mut coordinator: StateCoordinator = StateCoordinator::new(); let subscriber1 = Arc::new(MockSubscriber::new("subscriber1")); let subscriber2 = Arc::new(MockSubscriber::new("subscriber2")); assert_eq!(coordinator.subscribers.len(), 0); coordinator.add_subscriber(Box::new(subscriber1.clone())); assert_eq!(coordinator.subscribers.len(), 1); coordinator.add_subscriber(Box::new(subscriber2.clone())); assert_eq!(coordinator.subscribers.len(), 2); // 测试添加的订阅者是否工作 let test_state = TestState { value: 42, name: "add_test".to_string(), }; let result = coordinator.upsert_state(test_state.clone()).await; assert!(result.is_ok()); // 检查两个订阅者都被调用 assert_eq!(subscriber1.get_migrate_calls(), 1); assert_eq!(subscriber2.get_migrate_calls(), 1); } #[tokio::test] async fn test_get_state() { let mut coordinator: StateCoordinator = StateCoordinator::new(); // 初始状态应该是 None let initial_state = coordinator.current_state(); assert!(initial_state.is_none()); // 设置状态后应该能获取到 let test_state = TestState { value: 100, name: "get_test".to_string(), }; coordinator.upsert_state(test_state.clone()).await.unwrap(); let retrieved_state = coordinator.current_state(); assert_eq!(retrieved_state, Some(test_state.clone())); // 更新状态后应该获取到新状态 let new_state = TestState { value: 200, name: "updated_test".to_string(), }; coordinator.upsert_state(new_state.clone()).await.unwrap(); let updated_retrieved_state = coordinator.current_state(); assert_eq!(updated_retrieved_state, Some(new_state)); } #[tokio::test] async fn test_empty_subscribers_list() { let mut coordinator: StateCoordinator = StateCoordinator::new(); let test_state = TestState { value: 42, name: "no_subscribers".to_string(), }; // 没有订阅者时更新状态应该成功 let result = coordinator.upsert_state(test_state.clone()).await; assert!(result.is_ok()); let current_state = coordinator.current_state(); assert_eq!(current_state, Some(test_state)); } } ================================================ FILE: backend/tauri/src/core/state_v2/manager/persistent.rs ================================================ use anyhow::Context; use camino::Utf8PathBuf; use serde::{Serialize, de::DeserializeOwned}; use crate::utils::help; use super::*; #[derive(thiserror::Error, Debug)] pub enum UpsertError { #[error("state changed error: {0}")] State(StateChangedError), #[error("write config error: {0}")] WriteConfig(anyhow::Error), } pub struct PersistentStateManager< State: Clone + Send + Sync + 'static, Builder: StateAsyncBuilder + Serialize + DeserializeOwned, > { config_prefix: Option, config_path: Utf8PathBuf, current_builder: Option, state_coordinator: StateCoordinator, } impl PersistentStateManager where State: Clone + Send + Sync + 'static, Builder: StateAsyncBuilder + Serialize + DeserializeOwned, { pub fn new( config_prefix: Option, config_path: Utf8PathBuf, state_coordinator: StateCoordinator, ) -> Self { Self { config_prefix, config_path, current_builder: None, state_coordinator, } } pub async fn try_load(&mut self) -> anyhow::Result<()> { let config: Builder = help::read_yaml(&self.config_path).context("failed to read the config file")?; self.state_coordinator.upsert(config.clone()).await?; self.current_builder = Some(config); Ok(()) } pub async fn try_load_with_defaults(&mut self) -> anyhow::Result<()> { let config: Builder = help::read_yaml(&self.config_path) .inspect_err(|e| { log::error!(target: "app", "failed to read the config file: {e:?}"); }) .unwrap_or_else(|_| Builder::default()); self.state_coordinator.upsert(config.clone()).await?; self.current_builder = Some(config); Ok(()) } async fn write_config(&self, builder: Builder) -> anyhow::Result<()> { help::save_yaml(&self.config_path, &builder, self.config_prefix.as_deref())?; Ok(()) } pub fn current_state(&self) -> Option { self.state_coordinator.current_state() } pub async fn upsert(&mut self, builder: Builder) -> Result<(), UpsertError> { self.state_coordinator .upsert(builder.clone()) .await .map_err(UpsertError::State)?; self.current_builder = Some(builder.clone()); self.write_config(builder) .await .map_err(UpsertError::WriteConfig)?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tempfile::tempdir; use tokio::fs; // 测试用的状态结构 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] struct TestState { name: String, value: i32, } // 测试用的构建器 #[derive(Debug, Clone, Default, Serialize, Deserialize)] struct TestBuilder { name: String, value: i32, should_fail: bool, } impl TestBuilder { fn new(name: String, value: i32) -> Self { Self { name, value, should_fail: false, } } fn failing() -> Self { Self { name: "".to_string(), value: 0, should_fail: true, } } } impl StateAsyncBuilder for TestBuilder { type State = TestState; async fn build(&self) -> anyhow::Result { if self.should_fail { return Err(anyhow::anyhow!("构建失败")); } Ok(TestState { name: self.name.clone(), value: self.value, }) } } // 辅助函数:创建临时配置文件 async fn create_temp_config_file( builder: &TestBuilder, ) -> anyhow::Result<(Utf8PathBuf, tempfile::TempDir)> { let temp_dir = tempdir()?; let config_path = temp_dir.path().join("test_config.yaml"); let config_path = Utf8PathBuf::from_path_buf(config_path).unwrap(); help::save_yaml(&config_path, builder, None)?; Ok((config_path, temp_dir)) } #[tokio::test] async fn test_new_persistent_state_manager() { let coordinator: StateCoordinator = StateCoordinator::new(); let config_path = Utf8PathBuf::from("/tmp/test_config.yaml"); let manager: PersistentStateManager = PersistentStateManager::new( Some("# 测试配置".to_string()), config_path.clone(), coordinator, ); // 验证初始状态 assert_eq!(manager.config_prefix, Some("# 测试配置".to_string())); assert_eq!(manager.config_path, config_path); assert!(manager.current_builder.is_none()); assert!(manager.current_state().is_none()); } #[tokio::test] async fn test_try_load_success() { let builder = TestBuilder::new("测试".to_string(), 42); let (config_path, _temp_dir) = create_temp_config_file(&builder).await.unwrap(); let coordinator: StateCoordinator = StateCoordinator::new(); let mut manager: PersistentStateManager = PersistentStateManager::new(None, config_path, coordinator); // 测试成功加载 let result = manager.try_load().await; assert!(result.is_ok(), "加载配置应该成功"); // 验证状态 let current_state = manager.current_state(); assert!(current_state.is_some()); let state = current_state.unwrap(); assert_eq!(state.name, "测试"); assert_eq!(state.value, 42); // 验证构建器 let current_builder = manager.current_builder.as_ref(); assert!(current_builder.is_some()); let loaded_builder: &TestBuilder = current_builder.as_ref().unwrap(); assert_eq!(loaded_builder.name, "测试"); assert_eq!(loaded_builder.value, 42); } #[tokio::test] async fn test_try_load_file_not_exist() { let config_path = Utf8PathBuf::from("/nonexistent/config.yaml"); let coordinator: StateCoordinator = StateCoordinator::new(); let mut manager: PersistentStateManager = PersistentStateManager::new(None, config_path, coordinator); // 测试文件不存在的情况 let result = manager.try_load().await; assert!(result.is_err(), "加载不存在的配置文件应该失败"); let error_msg = result.unwrap_err().to_string(); assert!(error_msg.contains("failed to read the config file")); } #[tokio::test] async fn test_try_load_with_defaults_success() { let builder = TestBuilder::new("默认测试".to_string(), 100); let (config_path, _temp_dir) = create_temp_config_file(&builder).await.unwrap(); let coordinator: StateCoordinator = StateCoordinator::new(); let mut manager: PersistentStateManager = PersistentStateManager::new(None, config_path, coordinator); // 测试使用默认值加载 let result = manager.try_load_with_defaults().await; assert!(result.is_ok(), "使用默认值加载应该成功"); // 验证状态 let current_state = manager.current_state(); assert!(current_state.is_some()); let state = current_state.unwrap(); assert_eq!(state.name, "默认测试"); assert_eq!(state.value, 100); } #[tokio::test] async fn test_try_load_with_defaults_file_not_exist() { let config_path = Utf8PathBuf::from("/nonexistent/config.yaml"); let coordinator: StateCoordinator = StateCoordinator::new(); let mut manager: PersistentStateManager = PersistentStateManager::new(None, config_path, coordinator); // 测试文件不存在时使用默认值 let result = manager.try_load_with_defaults().await; assert!(result.is_ok(), "文件不存在时使用默认值应该成功"); // 验证使用了默认值 let current_state = manager.current_state(); assert!(current_state.is_some()); let state = current_state.unwrap(); assert_eq!(state.name, ""); // 默认值 assert_eq!(state.value, 0); // 默认值 } #[tokio::test] async fn test_upsert_success() { let temp_dir = tempdir().unwrap(); let config_path = temp_dir.path().join("upsert_test.yaml"); let config_path = Utf8PathBuf::from_path_buf(config_path).unwrap(); let coordinator: StateCoordinator = StateCoordinator::new(); let mut manager: PersistentStateManager = PersistentStateManager::new( Some("# 更新测试".to_string()), config_path.clone(), coordinator, ); let builder = TestBuilder::new("更新测试".to_string(), 200); // 测试更新操作 let result = manager.upsert(builder.clone()).await; assert!(result.is_ok(), "更新操作应该成功"); // 验证状态更新 let current_state = manager.current_state(); assert!(current_state.is_some()); let state = current_state.unwrap(); assert_eq!(state.name, "更新测试"); assert_eq!(state.value, 200); // 验证构建器更新 let current_builder = manager.current_builder.as_ref(); assert!(current_builder.is_some()); let updated_builder = current_builder.as_ref().unwrap(); assert_eq!(updated_builder.name, "更新测试"); assert_eq!(updated_builder.value, 200); // 验证配置文件已保存 assert!(config_path.exists(), "配置文件应该被创建"); let saved_builder: TestBuilder = help::read_yaml(&config_path).unwrap(); assert_eq!(saved_builder.name, "更新测试"); assert_eq!(saved_builder.value, 200); } #[tokio::test] async fn test_upsert_builder_validation_error() { let temp_dir = tempdir().unwrap(); let config_path = temp_dir.path().join("upsert_fail_test.yaml"); let config_path = Utf8PathBuf::from_path_buf(config_path).unwrap(); let coordinator: StateCoordinator = StateCoordinator::new(); let mut manager: PersistentStateManager = PersistentStateManager::new(None, config_path, coordinator); let failing_builder = TestBuilder::failing(); // 测试构建器验证失败 let result = manager.upsert(failing_builder).await; assert!(result.is_err(), "构建器验证失败时更新应该失败"); match result.unwrap_err() { UpsertError::State(StateChangedError::Validation(_)) => { // 期望的错误类型 } other => panic!( "期望 UpsertError::State(StateChangedError::Validation), 但得到: {:?}", other ), } // 验证状态未改变 assert!(manager.current_state().is_none()); assert!(manager.current_builder.as_ref().is_none()); } #[tokio::test] async fn test_upsert_write_config_error() { // 使用只读目录路径来触发写入错误 let config_path = Utf8PathBuf::from("/proc/version"); // Linux 系统上的只读文件 let coordinator: StateCoordinator = StateCoordinator::new(); let mut manager: PersistentStateManager = PersistentStateManager::new(None, config_path, coordinator); let builder = TestBuilder::new("写入失败测试".to_string(), 300); // 在某些系统上这可能不会失败,所以我们只测试逻辑 let result = manager.upsert(builder).await; // 如果写入失败,应该得到 WriteConfig 错误 if result.is_err() { match result.unwrap_err() { UpsertError::WriteConfig(_) => { // 期望的错误类型 } UpsertError::State(_) => { // 状态更新可能成功,但写入失败 } } } } #[tokio::test] async fn test_current_state() { let coordinator: StateCoordinator = StateCoordinator::new(); let config_path = Utf8PathBuf::from("/tmp/current_state_test.yaml"); let mut manager: PersistentStateManager = PersistentStateManager::new(None, config_path, coordinator); // 初始状态应该为 None assert!(manager.current_state().is_none()); // 添加状态后应该能获取到 let builder = TestBuilder::new("当前状态测试".to_string(), 400); let _ = manager.upsert(builder).await; let current_state = manager.current_state(); assert!(current_state.is_some()); let state = current_state.unwrap(); assert_eq!(state.name, "当前状态测试"); assert_eq!(state.value, 400); } #[tokio::test] async fn test_multiple_upserts() { let temp_dir = tempdir().unwrap(); let config_path = temp_dir.path().join("multiple_upserts_test.yaml"); let config_path = Utf8PathBuf::from_path_buf(config_path).unwrap(); let coordinator: StateCoordinator = StateCoordinator::new(); let mut manager: PersistentStateManager = PersistentStateManager::new( Some("# 多次更新测试".to_string()), config_path.clone(), coordinator, ); // 第一次更新 let builder1 = TestBuilder::new("第一次".to_string(), 1); let result1 = manager.upsert(builder1).await; assert!(result1.is_ok()); let state1 = manager.current_state().unwrap(); assert_eq!(state1.name, "第一次"); assert_eq!(state1.value, 1); // 第二次更新 let builder2 = TestBuilder::new("第二次".to_string(), 2); let result2 = manager.upsert(builder2).await; assert!(result2.is_ok()); let state2 = manager.current_state().unwrap(); assert_eq!(state2.name, "第二次"); assert_eq!(state2.value, 2); // 验证配置文件包含最新的值 let saved_builder: TestBuilder = help::read_yaml(&config_path).unwrap(); assert_eq!(saved_builder.name, "第二次"); assert_eq!(saved_builder.value, 2); } #[tokio::test] async fn test_config_prefix_in_saved_file() { let temp_dir = tempdir().unwrap(); let config_path = temp_dir.path().join("prefix_test.yaml"); let config_path = Utf8PathBuf::from_path_buf(config_path).unwrap(); let coordinator: StateCoordinator = StateCoordinator::new(); let prefix = "# 这是一个测试配置文件\n# 请勿手动修改"; let mut manager: PersistentStateManager = PersistentStateManager::new(Some(prefix.to_string()), config_path.clone(), coordinator); let builder = TestBuilder::new("前缀测试".to_string(), 500); let result = manager.upsert(builder).await; assert!(result.is_ok()); // 验证保存的文件包含前缀 let file_content = fs::read_to_string(&config_path).await.unwrap(); assert!(file_content.starts_with("# 这是一个测试配置文件")); assert!(file_content.contains("# 请勿手动修改")); assert!(file_content.contains("name: 前缀测试")); } } ================================================ FILE: backend/tauri/src/core/state_v2/manager/simple.rs ================================================ use super::*; #[repr(transparent)] pub struct SimpleStateManager { state_coordinator: StateCoordinator, } impl SimpleStateManager { pub fn new(state_coordinator: StateCoordinator) -> Self { Self { state_coordinator } } pub fn current_state(&self) -> Option { self.state_coordinator.current_state() } pub async fn upsert(&mut self, state: State) -> Result<(), StateChangedError> { self.state_coordinator.upsert_state(state).await } } ================================================ FILE: backend/tauri/src/core/state_v2/manager.rs ================================================ mod persistent; mod simple; use super::{builder::*, coordinator::*}; pub use persistent::*; pub use simple::*; ================================================ FILE: backend/tauri/src/core/state_v2/mod.rs ================================================ mod builder; mod coordinator; mod manager; pub use coordinator::*; ================================================ FILE: backend/tauri/src/core/storage.rs ================================================ use crate::{log_err, utils::dirs}; use anyhow::Context; use redb::{ReadableDatabase, ReadableTable, TableDefinition}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use specta::Type; use std::{fs, ops::Deref, result::Result as StdResult, sync::Arc}; use tauri::Manager; use tauri_specta::Event; #[derive(Debug, thiserror::Error)] pub enum StorageOperationError { #[error("failed to open database: {0}")] OpenDatabase(#[from] redb::DatabaseError), #[error("internal redb error: {0}")] Redb(#[from] redb::Error), #[error("internal redb table error: {0}")] RedbTable(#[from] redb::TableError), #[error("internal redb storage error: {0}")] RedbStorage(#[from] redb::StorageError), #[error("failed to start transaction: {0}")] RedbTransaction(#[from] redb::TransactionError), #[error("failed to commit transaction: {0}")] RedbCommit(#[from] redb::CommitError), #[error("failed to serialize or deserialize data: {0}")] Serialize(#[from] serde_json::Error), } pub const NYANPASU_TABLE: TableDefinition<&[u8], &[u8]> = TableDefinition::new("clash-nyanpasu"); type Result = StdResult; /// storage is a wrapper or called a facade for the rocksdb /// Maybe provide a facade for a kv storage is a good idea? #[derive(Clone)] pub struct Storage { inner: Arc, } impl Storage { pub fn try_new(path: &std::path::Path) -> Result { let inner = StorageInner::try_new(path)?; Ok(Self { inner: Arc::new(inner), }) } } impl Deref for Storage { type Target = Arc; fn deref(&self) -> &Self::Target { &self.inner } } pub struct StorageInner { instance: redb::Database, tx: tokio::sync::broadcast::Sender<(String, Option>)>, } /// Event emitted to all windows when a storage value changes. /// Event name: `storage-value-changed-event` #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] pub struct StorageValueChangedEvent { pub key: String, /// The new JSON-encoded value, or `None` if the key was removed. pub value: Option, } pub trait WebStorage { fn get_item(&self, key: impl AsRef) -> Result>; fn set_item(&self, key: impl AsRef, value: &T) -> Result<()>; fn remove_item(&self, key: impl AsRef) -> Result<()>; /// Returns all key-value pairs as raw JSON strings (for debug use). fn get_all(&self) -> Result>; /// Removes all entries from the storage (for debug use). fn clear(&self) -> Result<()>; } impl StorageInner { fn create_and_init_database(path: &std::path::Path) -> Result { let db = redb::Database::create(path)?; // Create table let write_txn = db.begin_write()?; write_txn.open_table(NYANPASU_TABLE)?; write_txn.commit()?; Ok(db) } pub fn try_new(path: &std::path::Path) -> Result { let metadata = fs::metadata(path).ok(); let instance: redb::Database = if metadata.as_ref().is_some_and(|m| m.is_file()) { match redb::Database::open(path) { Ok(db) => db, // In redb v3 upgrading point, we only store the task history, and frontend persist state, // such as memorized router, which is NOT very valuable to make us keep two redb versions, // intended to support upgrade database formats. Err(redb::DatabaseError::UpgradeRequired(ver)) => { tracing::error!("database upgrade required {ver:?}, removing..."); fs::remove_file(path).unwrap(); Self::create_and_init_database(path)? } Err(e) => return Err(e.into()), } } else { // Remove previous rocksdb files if metadata.is_some_and(|m| m.is_dir()) { fs::remove_dir_all(path).unwrap(); } Self::create_and_init_database(path)? }; Ok(Self { instance, tx: tokio::sync::broadcast::channel(16).0, }) } pub fn get_instance(&self) -> &redb::Database { &self.instance } fn notify_subscribers(&self, key: impl AsRef, value: Option<&[u8]>) { let key = key.as_ref().to_string(); let value = value.map(|v| v.to_vec()); let tx = self.tx.clone(); std::thread::spawn(move || { let _ = tx.send((key, value)); }); } fn get_rx(&self) -> tokio::sync::broadcast::Receiver<(String, Option>)> { self.tx.subscribe() } } impl WebStorage for StorageInner { fn get_item(&self, key: impl AsRef) -> Result> { let key = key.as_ref().as_bytes(); let db = self.get_instance(); let read_txn = db.begin_read()?; let table = read_txn.open_table(NYANPASU_TABLE)?; let result = table.get(key)?; match result { Some(value) => { let value = value.value(); let value = serde_json::from_slice(value)?; Ok(Some(value)) } None => Ok(None), } } fn set_item(&self, key: impl AsRef, value: &T) -> Result<()> { let key_str = key.as_ref(); let key = key_str.as_bytes(); let value = serde_json::to_vec(value)?; let db = self.get_instance(); let write_txn = db.begin_write()?; { let mut table = write_txn.open_table(NYANPASU_TABLE)?; table.insert(key, &*value)?; } write_txn.commit()?; self.notify_subscribers(key_str, Some(&value)); Ok(()) } fn remove_item(&self, key: impl AsRef) -> Result<()> { let key_str = key.as_ref(); let key = key_str.as_bytes(); let db = self.get_instance(); let write_txn = db.begin_write()?; { let mut table = write_txn.open_table(NYANPASU_TABLE)?; table.remove(key)?; } write_txn.commit()?; self.notify_subscribers(key_str, None); Ok(()) } fn get_all(&self) -> Result> { let db = self.get_instance(); let read_txn = db.begin_read()?; let table = read_txn.open_table(NYANPASU_TABLE)?; let mut result = Vec::new(); for entry in table.iter()? { let (key, value) = entry?; let key = String::from_utf8_lossy(key.value()).to_string(); let value = String::from_utf8_lossy(value.value()).to_string(); result.push((key, value)); } Ok(result) } fn clear(&self) -> Result<()> { let db = self.get_instance(); // Collect all keys in a read transaction first let keys: Vec> = { let read_txn = db.begin_read()?; let table = read_txn.open_table(NYANPASU_TABLE)?; let mut keys = Vec::new(); for entry in table.iter()? { let (key, _) = entry?; keys.push(key.value().to_vec()); } keys }; // Remove all in a write transaction let write_txn = db.begin_write()?; { let mut table = write_txn.open_table(NYANPASU_TABLE)?; for key in &keys { table.remove(key.as_slice())?; } } write_txn.commit()?; Ok(()) } } pub fn register_web_storage_listener(app_handle: &tauri::AppHandle) { let storage = app_handle.state::(); let rx = storage.get_rx(); let app_handle = app_handle.clone(); std::thread::spawn(move || { nyanpasu_utils::runtime::block_on(async { let mut rx = rx; while let Ok((key, value)) = rx.recv().await { let value = value.map(|v| String::from_utf8_lossy(&v).to_string()); let event = StorageValueChangedEvent { key, value }; log_err!( event.emit(&app_handle), "failed to emit storage_value_changed event" ); } }); }); } pub fn setup>(app: &M) -> anyhow::Result<()> { let storage_path = dirs::storage_path().context("failed to get storage path")?; let storage = Storage::try_new(&storage_path)?; app.manage(storage); Ok(()) } ================================================ FILE: backend/tauri/src/core/sysopt.rs ================================================ use crate::{config::Config, log_err}; use anyhow::{Result, anyhow}; use auto_launch::{AutoLaunch, AutoLaunchBuilder}; use once_cell::sync::OnceCell; use parking_lot::Mutex; use std::sync::Arc; use sysproxy::Sysproxy; use tauri::{async_runtime::Mutex as TokioMutex, utils::platform::current_exe}; // Import PAC manager #[cfg(feature = "default-meta")] use crate::core::pac::PacManager; #[cfg(target_os = "linux")] use std::process::Command; pub struct Sysopt { /// current system proxy setting cur_sysproxy: Arc>>, /// record the original system proxy /// recover it when exit old_sysproxy: Arc>>, /// helps to auto launch the app auto_launch: Arc>>, /// record whether the guard async is running or not guard_state: Arc>, } #[cfg(target_os = "windows")] static DEFAULT_BYPASS: &str = "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;"; #[cfg(target_os = "linux")] static DEFAULT_BYPASS: &str = "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,::1"; #[cfg(target_os = "macos")] static DEFAULT_BYPASS: &str = "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,"; #[cfg(target_os = "linux")] fn detect_desktop_environment() -> String { std::env::var("XDG_CURRENT_DESKTOP") .or_else(|_| std::env::var("DESKTOP_SESSION")) .unwrap_or_else(|_| "unknown".to_string()) .to_lowercase() } #[cfg(target_os = "linux")] fn get_autostart_requirements(desktop_env: &str) -> (bool, Vec) { match desktop_env { "kde" | "plasma" => { // KDE 可能需要特殊的桌面文件格式或权限 (true, vec!["X-KDE-autostart-after=panel".to_string()]) } _ => (false, vec![]), } } impl Sysopt { pub fn global() -> &'static Sysopt { static SYSOPT: OnceCell = OnceCell::new(); SYSOPT.get_or_init(|| Sysopt { cur_sysproxy: Arc::new(Mutex::new(None)), old_sysproxy: Arc::new(Mutex::new(None)), auto_launch: Arc::new(Mutex::new(None)), guard_state: Arc::new(TokioMutex::new(false)), }) } /// init the sysproxy pub fn init_sysproxy(&self) -> Result<()> { // Check if PAC is enabled first #[cfg(feature = "default-meta")] if PacManager::is_pac_enabled() { log::info!(target: "app", "Initializing PAC proxy"); // For PAC, we don't set the regular system proxy // Instead, we let the PAC manager handle it tauri::async_runtime::spawn(async { if let Err(e) = PacManager::init_pac_proxy().await { log::error!(target: "app", "Failed to initialize PAC proxy: {}", e); } }); // run the system proxy guard self.guard_proxy(); return Ok(()); } let port = Config::verge() .latest() .verge_mixed_port .unwrap_or(Config::clash().data().get_mixed_port()); let (enable, bypass) = { let verge = Config::verge(); let verge = verge.latest(); ( verge.enable_system_proxy.unwrap_or(false), verge.system_proxy_bypass.clone(), ) }; let current = Sysproxy { enable, host: String::from("127.0.0.1"), port, bypass: bypass.unwrap_or(DEFAULT_BYPASS.into()), }; if enable { let old = Sysproxy::get_system_proxy().ok(); if let Err(e) = current.set_system_proxy() { log::error!(target: "app", "Failed to set system proxy: {}", e); return Err(e.into()); // Convert sysproxy::Error to anyhow::Error } *self.old_sysproxy.lock() = old; *self.cur_sysproxy.lock() = Some(current); } // run the system proxy guard self.guard_proxy(); Ok(()) } /// update the system proxy pub fn update_sysproxy(&self) -> Result<()> { // Check if PAC is enabled first #[cfg(feature = "default-meta")] if PacManager::is_pac_enabled() { log::info!(target: "app", "Updating PAC proxy"); // For PAC, we don't set the regular system proxy // Instead, we let the PAC manager handle it tauri::async_runtime::spawn(async { log_err!(PacManager::update_pac().await); }); return Ok(()); } let mut cur_sysproxy = self.cur_sysproxy.lock(); let old_sysproxy = self.old_sysproxy.lock(); if cur_sysproxy.is_none() || old_sysproxy.is_none() { drop(cur_sysproxy); drop(old_sysproxy); return self.init_sysproxy(); } let (enable, bypass) = { let verge = Config::verge(); let verge = verge.latest(); ( verge.enable_system_proxy.unwrap_or(false), verge.system_proxy_bypass.clone(), ) }; let mut sysproxy = cur_sysproxy.take().unwrap(); sysproxy.enable = enable; sysproxy.bypass = bypass.unwrap_or(DEFAULT_BYPASS.into()); sysproxy.set_system_proxy()?; *cur_sysproxy = Some(sysproxy); Ok(()) } /// reset the sysproxy pub fn reset_sysproxy(&self) -> Result<()> { // Check if PAC is enabled first #[cfg(feature = "default-meta")] if PacManager::is_pac_enabled() { log::info!(target: "app", "Resetting PAC proxy"); // Disable PAC proxy log_err!(PacManager::disable_pac_proxy()); } let mut cur_sysproxy = self.cur_sysproxy.lock(); let mut old_sysproxy = self.old_sysproxy.lock(); let cur_sysproxy = cur_sysproxy.take(); if let Some(mut old) = old_sysproxy.take() { // 如果原代理和当前代理 端口一致,就disable关闭,否则就恢复原代理设置 // 当前没有设置代理的时候,不确定旧设置是否和当前一致,全关了 let port_same = cur_sysproxy.is_none_or(|cur| old.port == cur.port); if old.enable && port_same { old.enable = false; log::info!(target: "app", "reset proxy by disabling the original proxy"); } else { log::info!(target: "app", "reset proxy to the original proxy"); } old.set_system_proxy()?; } else if let Some(mut cur @ Sysproxy { enable: true, .. }) = cur_sysproxy { // 没有原代理,就按现在的代理设置disable即可 log::info!(target: "app", "reset proxy by disabling the current proxy"); cur.enable = false; cur.set_system_proxy()?; } else { log::info!(target: "app", "reset proxy with no action"); } Ok(()) } /// init the auto launch pub fn init_launch(&self) -> Result<()> { let enable = { Config::verge().latest().enable_auto_launch }; let enable = enable.unwrap_or(false); log::info!(target: "app", "Initializing auto-launch with enable={}", enable); let app_exe = current_exe()?; let app_exe = dunce::canonicalize(app_exe)?; log::debug!(target: "app", "Resolved app executable path: {:?}", app_exe); let app_name = app_exe .file_stem() .and_then(|f| f.to_str()) .ok_or(anyhow!("failed to get file stem"))?; let app_path = app_exe .as_os_str() .to_str() .ok_or(anyhow!("failed to get app_path"))? .to_string(); log::debug!(target: "app", "Initial app path: {}", app_path); // fix issue #26 #[cfg(target_os = "windows")] let app_path = format!("\"{app_path}\""); #[cfg(target_os = "windows")] log::debug!(target: "app", "Windows formatted app path: {}", app_path); // use the /Applications/Clash Nyanpasu.app path #[cfg(target_os = "macos")] let app_path = (|| -> Option { let path = std::path::PathBuf::from(&app_path); let path = path.parent()?.parent()?.parent()?; let extension = path.extension()?.to_str()?; match extension == "app" { true => Some(path.as_os_str().to_str()?.to_string()), false => None, } })() .unwrap_or(app_path); #[cfg(target_os = "macos")] log::debug!(target: "app", "macOS app path: {}", app_path); // fix #403 #[cfg(target_os = "linux")] let app_path = { use crate::core::handle::Handle; use tauri::Manager; let handle = Handle::global(); let appimage_path = match handle.app_handle.lock().as_ref() { Some(app_handle) => { // 优先使用 Tauri 环境变量 let appimage = app_handle.env().appimage; appimage.and_then(|p| p.to_str().map(|s| s.to_string())) } None => None, }; // 备用方法:检查环境变量 let fallback_appimage = std::env::var("APPIMAGE").ok(); let final_path = appimage_path.or(fallback_appimage).unwrap_or(app_path); log::info!(target: "app", "Using executable path for auto-launch: {}", final_path); final_path }; log::info!(target: "app", "Using executable path for auto-launch: {}", app_path); let auto = AutoLaunchBuilder::new() .set_app_name(app_name) .set_app_path(&app_path) .build()?; log::debug!(target: "app", "AutoLaunch builder created with app_name: {}", app_name); // 避免在开发时将自启动关了 #[cfg(feature = "verge-dev")] if !enable { log::info!(target: "app", "Skipping auto-launch setup in development mode"); return Ok(()); } #[cfg(target_os = "macos")] { if enable && !auto.is_enabled().unwrap_or(false) { // 避免重复设置登录项 log::debug!(target: "app", "macOS: Disabling existing auto-launch"); let _ = auto.disable(); log::debug!(target: "app", "macOS: Enabling auto-launch"); auto.enable()?; } else if !enable { log::debug!(target: "app", "macOS: Disabling auto-launch"); let _ = auto.disable(); } } #[cfg(not(target_os = "macos"))] { if enable { log::debug!(target: "app", "Enabling auto-launch for non-macOS platform"); auto.enable()?; } else { log::debug!(target: "app", "Disabling auto-launch for non-macOS platform"); let _ = auto.disable(); } } *self.auto_launch.lock() = Some(auto); Ok(()) } /// update the startup pub fn update_launch(&self) -> Result<()> { let auto_launch = self.auto_launch.lock(); if auto_launch.is_none() { drop(auto_launch); return self.init_launch(); } let enable = { Config::verge().latest().enable_auto_launch }; let enable = enable.unwrap_or(false); let auto_launch = auto_launch.as_ref().unwrap(); match enable { true => auto_launch.enable()?, false => log_err!(auto_launch.disable()), // 忽略关闭的错误 }; Ok(()) } /// launch a system proxy guard /// read config from file directly pub fn guard_proxy(&self) { use tokio::time::{Duration, sleep}; let guard_state = self.guard_state.clone(); tauri::async_runtime::spawn(async move { // if it is running, exit let mut state = guard_state.lock().await; if *state { return; } *state = true; drop(state); // default duration is 10s let mut wait_secs = 10u64; loop { sleep(Duration::from_secs(wait_secs)).await; let (enable, guard, guard_interval, bypass) = { let verge = Config::verge(); let verge = verge.latest(); ( verge.enable_system_proxy.unwrap_or(false), verge.enable_proxy_guard.unwrap_or(false), verge.proxy_guard_interval.unwrap_or(10), verge.system_proxy_bypass.clone(), ) }; // stop loop if !enable || !guard { break; } // update duration wait_secs = guard_interval; log::debug!(target: "app", "try to guard the system proxy"); let port = { Config::verge() .latest() .verge_mixed_port .unwrap_or(Config::clash().data().get_mixed_port()) }; let sysproxy = Sysproxy { enable: true, host: "127.0.0.1".into(), port, bypass: bypass.unwrap_or(DEFAULT_BYPASS.into()), }; log_err!(sysproxy.set_system_proxy()); } let mut state = guard_state.lock().await; *state = false; drop(state); }); } } ================================================ FILE: backend/tauri/src/core/tasks/events.rs ================================================ use anyhow::Context; use chrono::Utc; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use super::{ storage::TaskStorage, task::{TaskEventID, TaskID, TaskRunResult, Timestamp}, utils::Result, }; use std::{collections::HashMap, sync::Arc}; pub struct TaskEvents { storage: Arc>, } /// TaskEventDispatcher is a dispatcher for a task event, /// currently, it's designed for a single thread task to dispatch event. pub struct TaskEventDispatcher { storage: Arc>, event: TaskEvent, } impl TaskEvents { pub fn new(storage: Arc>) -> Self { TaskEvents { storage } } pub fn new_event(&self, task_id: TaskID, event_id: TaskEventID) -> Result { tracing::debug!("create new event: {:?} for task: {:?}", event_id, task_id); let mut dispatcher = { let storage = self.storage.lock(); let event = TaskEvent { id: event_id, task_id, ..TaskEvent::default() }; storage.add_event(&event).context("failed to add event")?; TaskEventDispatcher::new(self.storage.clone(), event) }; dispatcher .dispatch(TaskEventState::Pending) .context("failed to dispatch pending event")?; Ok(dispatcher) } } impl TaskEventDispatcher { pub fn new(storage: Arc>, event: TaskEvent) -> Self { TaskEventDispatcher { storage, event } } pub fn dispatch(&mut self, state: TaskEventState) -> Result<()> { tracing::debug!( "dispatch state: {:?} for event: {:?} of task: {:?}", state, self.event.id, self.event.task_id ); self.event.dispatch(state); let storage = self.storage.lock(); storage.update_event(&self.event)?; Ok(()) } } #[derive(Serialize, Deserialize, Debug)] pub struct TaskEvent { pub id: TaskEventID, pub task_id: TaskID, pub state: TaskEventState, pub timeline: HashMap, pub updated_at: Timestamp, } #[derive(Serialize, Deserialize, Debug)] pub enum TaskEventState { Pending, // added to the queue, alias of created Running, Finished(TaskRunResult), Cancelled, } impl TaskEventState { pub fn fmt(&self) -> &'static str { match self { Self::Pending => "pending", Self::Running => "running", Self::Finished(_) => "finished", Self::Cancelled => "cancelled", } } } impl Default for TaskEvent { fn default() -> Self { TaskEvent { id: 0, task_id: 0, state: TaskEventState::Pending, timeline: HashMap::with_capacity(4), // 4 states updated_at: Utc::now().timestamp_millis(), } } } impl TaskEvent { fn dispatch(&mut self, state: TaskEventState) { let now = Utc::now().timestamp_millis(); self.state = state; self.timeline.insert(self.state.fmt().into(), now); self.updated_at = now; } } ================================================ FILE: backend/tauri/src/core/tasks/executor.rs ================================================ use std::fmt::{self, Formatter}; use anyhow::Result; use async_trait::async_trait; use dyn_clone::{DynClone, clone_trait_object}; /// JobExecutor is a trait for job executor /// It is used to define a sync job /// /// For example, you can define a job to print hello. /// ``` rust /// use anyhow::Result; /// #[derive(Clone)] /// pub struct HelloJob {} /// impl JobExecutor for HelloJob { /// fn execute(&self) -> Result<()> { /// println!("hello"); /// Ok(()) /// } /// } /// ``` /// Then you can pass it to the task manager to execute it. /// /// pub trait JobExecutor: DynClone { fn execute(&self) -> Result<()>; } clone_trait_object!(JobExecutor); pub type Job = Box; #[async_trait] pub trait AsyncJobExecutor: DynClone { async fn execute(&self) -> Result<()>; } clone_trait_object!(AsyncJobExecutor); pub type AsyncJob = Box; #[derive(Clone)] pub enum TaskExecutor { Sync(Job), Async(AsyncJob), } impl fmt::Debug for TaskExecutor { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::Sync(_) => write!(f, "Sync"), Self::Async(_) => write!(f, "Async"), } } } impl Default for TaskExecutor { fn default() -> Self { Self::Sync(Job::default()) // default job executor } } impl From for TaskExecutor { fn from(job: Job) -> Self { Self::Sync(job) } } impl From for TaskExecutor { fn from(job: AsyncJob) -> Self { Self::Async(job) } } #[derive(Clone, Debug)] struct DefaultJobExecutor {} impl JobExecutor for DefaultJobExecutor { fn execute(&self) -> Result<()> { unimplemented!("not implemented"); } } #[async_trait] impl AsyncJobExecutor for DefaultJobExecutor { async fn execute(&self) -> Result<()> { unimplemented!("not implemented"); } } impl Default for Job { fn default() -> Self { Box::new(DefaultJobExecutor {}) } } impl Default for AsyncJob { fn default() -> Self { Box::new(DefaultJobExecutor {}) } } ================================================ FILE: backend/tauri/src/core/tasks/jobs/events_rotate.rs ================================================ use crate::core::tasks::{ executor::{AsyncJobExecutor, TaskExecutor}, storage::TaskStorage, task::TaskSchedule, }; use anyhow::Context; use parking_lot::Mutex; use std::sync::Arc; use super::JobExt; const CLEAR_EVENTS_TASK_NAME: &str = "Task Events Rotate"; #[derive(Clone)] pub struct EventsRotateJob { task_storage: Arc>, } impl EventsRotateJob { pub fn new(task_storage: Arc>) -> Self { Self { task_storage } } } #[async_trait::async_trait] impl AsyncJobExecutor for EventsRotateJob { // TODO: optimize performance if we got reported that this job is slow async fn execute(&self) -> anyhow::Result<()> { let storage = self.task_storage.lock(); let task_ids = storage.list_tasks().context("failed to list tasks")?; for task_id in task_ids { let event_ids = storage .get_event_ids(task_id) .context(format!("failed to get event ids for task {task_id}"))? .unwrap_or_default(); let mut events_to_remove = Vec::new(); let mut events = event_ids .into_iter() .filter_map(|id| { let event = storage.get_event(id).ok().flatten(); if event.is_none() { events_to_remove.push(id); } event }) .collect::>(); // DESC sort events by updated_at events.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); // keep max 10 events let events = events .into_iter() .skip(10) .map(|e| e.id) .collect::>(); events_to_remove.extend(events); // remove events for event_id in events_to_remove { log::debug!("removing event {event_id} for task {task_id}"); storage .remove_event(event_id, task_id) .context(format!("failed to remove event {event_id}"))?; } } Ok(()) } } impl JobExt for EventsRotateJob { fn name(&self) -> &'static str { CLEAR_EVENTS_TASK_NAME } fn setup(&self) -> Option { Some(crate::core::tasks::task::Task { name: CLEAR_EVENTS_TASK_NAME.to_string(), schedule: TaskSchedule::Cron("@hourly".to_string()), executor: TaskExecutor::Async(Box::new(self.clone())), ..Default::default() }) } } ================================================ FILE: backend/tauri/src/core/tasks/jobs/logger.rs ================================================ use super::JobExt; use crate::{ config::Config, core::tasks::{ executor::{AsyncJobExecutor, TaskExecutor}, task::TaskSchedule, }, utils::dirs, }; use anyhow::Result; use async_trait::async_trait; use chrono::{DateTime, Local, TimeZone}; use std::{ fs::{self, DirEntry}, str::FromStr, time::Duration, }; const CLEAR_LOG_TASK_NAME: &str = "clear_logs"; #[derive(Clone, Default)] pub struct ClearLogsJob; /// Clear logs from the logs directory pub fn clear_logs() -> Result<()> { let log_dir = dirs::app_logs_dir()?; if !log_dir.exists() { return Ok(()); } let minutes = { let verge = Config::verge(); let verge = verge.data(); #[allow(deprecated)] verge.auto_log_clean.unwrap_or(0) }; if minutes == 0 { return Ok(()); // 0 means disable } log::debug!(target: "app", "try to delete log files, minutes: {minutes}"); // %Y-%m-%d to NaiveDateTime let parse_time_str = |s: &str| { let sa: Vec<&str> = s.split('-').collect(); if sa.len() != 4 { return Err(anyhow::anyhow!("invalid time str")); } let year = i32::from_str(sa[0])?; let month = u32::from_str(sa[1])?; let day = u32::from_str(sa[2])?; let time = chrono::NaiveDate::from_ymd_opt(year, month, day) .ok_or(anyhow::anyhow!("invalid time str"))? .and_hms_opt(0, 0, 0) .ok_or(anyhow::anyhow!("invalid time str"))?; Ok(time) }; let process_file = |file: DirEntry| -> Result<()> { let file_name = file.file_name(); let file_name = file_name.to_str().unwrap_or_default(); if file_name.ends_with(".log") { let now = Local::now(); let created_time = parse_time_str(&file_name[0..file_name.len() - 4])?; let created_time: DateTime = Local.from_local_datetime(&created_time).unwrap(); // It is safe to use `unwrap` here because we just parsed it let duration = now.signed_duration_since(created_time); if duration.num_minutes() > minutes { let file_path = file.path(); let _ = fs::remove_file(file_path); log::info!(target: "app", "delete log file: {file_name}"); } } Ok(()) }; for file in fs::read_dir(&log_dir)? { match file { Ok(file) => { let _ = process_file(file); } Err(err) => { log::error!(target: "app", "read log dir error: {err:?}"); } } } Ok(()) } #[async_trait] impl AsyncJobExecutor for ClearLogsJob { async fn execute(&self) -> Result<()> { clear_logs() } } impl JobExt for ClearLogsJob { fn name(&self) -> &'static str { CLEAR_LOG_TASK_NAME } fn setup(&self) -> Option { Some(crate::core::tasks::task::Task { name: CLEAR_LOG_TASK_NAME.to_string(), schedule: TaskSchedule::Interval(Duration::from_secs(30 * 60)), // 30 minutes 清理一次 executor: TaskExecutor::Async(Box::new(self.clone())), ..Default::default() }) } } ================================================ FILE: backend/tauri/src/core/tasks/jobs/mod.rs ================================================ mod events_rotate; mod logger; mod profiles; use super::{ task::{Task, TaskManager}, utils::{ConfigChangedNotifier, Result}, }; use anyhow::anyhow; use parking_lot::RwLock; pub use profiles::ProfilesJobGuard; use std::sync::Arc; pub trait JobExt { fn name(&self) -> &'static str; fn setup(&self) -> Option; // called when the app starts or the config changed } pub struct JobsManager { jobs: Vec>, task_manager: Arc>, } impl JobsManager { pub fn new(task_manager: Arc>) -> Self { Self { jobs: Vec::new(), task_manager, } } pub fn setup(&mut self) -> anyhow::Result<()> { let jobs: Vec> = vec![Box::new( events_rotate::EventsRotateJob::new(self.task_manager.read().get_inner_task_storage()), )]; for job in jobs { let task = job.setup(); if let Some(task) = task { self.task_manager.write().add_task(task)?; } self.jobs.push(job); } Ok(()) } } impl ConfigChangedNotifier for JobsManager { fn notify_config_changed(&self, job_name: &str) -> Result<()> { let job = self .jobs .iter() .find(|job| job.name() == job_name) .ok_or(anyhow!("job not exist"))?; let task = job.setup(); if let Some(task) = task { let mut task_manager = self.task_manager.write(); task_manager.remove_task(task.id)?; task_manager.add_task(task)?; } Ok(()) } } ================================================ FILE: backend/tauri/src/core/tasks/jobs/profiles.rs ================================================ use super::super::{ executor::{AsyncJobExecutor, TaskExecutor}, task::{Task, TaskID, TaskManager, TaskSchedule}, }; use crate::{ config::{Config, ProfileMetaGetter}, feat, }; use anyhow::Result; use async_trait::async_trait; use parking_lot::RwLock; use std::{collections::HashMap, ops::Deref, sync::Arc, time::Duration}; const INITIAL_TASK_ID: TaskID = 10000000; // 留一个初始的 TaskID,避免和其他任务的 ID 冲突 type Minutes = u64; type ProfileUID = String; #[derive(Clone)] pub struct ProfileUpdater(ProfileUID); impl ProfileUpdater { #[allow(dead_code)] pub fn new(profile_uid: &str) -> Self { Self(profile_uid.to_string()) } } #[async_trait] impl AsyncJobExecutor for ProfileUpdater { async fn execute(&self) -> Result<()> { log::info!(target: "app", "running timer task `{}`", self.0); match feat::update_profile(self.0.clone(), None).await { Ok(_) => Ok(()), Err(err) => { log::error!(target: "app", "failed to update profile: {err:?}"); Err(err) } } } } enum ProfileTaskOp { Add(TaskID, Minutes), Remove(TaskID), Update(TaskID, Minutes), } pub struct ProfilesJob { task_map: HashMap, task_manager: Arc>, // next_id: TaskID, } pub struct ProfilesJobGuard { job: Arc>, } impl ProfilesJobGuard { pub fn new(task_manager: Arc>) -> Self { Self { job: Arc::new(RwLock::new(ProfilesJob::new(task_manager))), } } } impl Deref for ProfilesJobGuard { type Target = Arc>; fn deref(&self) -> &Self::Target { &self.job } } impl ProfilesJob { pub fn new(task_manager: Arc>) -> Self { Self { task_map: HashMap::new(), task_manager, } } /// restore timer pub fn init(&mut self) -> Result<()> { self.refresh(); let cur_timestamp = chrono::Local::now().timestamp(); let task_map = &self.task_map; Config::profiles() .latest() .items .iter() .filter_map(|item| { if !item.is_remote() { return None; } let item = item.as_remote().unwrap(); // mins to seconds let interval = ((item.option.update_interval) as i64) * 60; let updated = item.updated() as i64; if interval > 0 && cur_timestamp - updated >= interval { Some(item) } else { None } }) .for_each(|item| { if let Some((task_id, _)) = task_map.get(item.uid()) { crate::log_err!(self.task_manager.write().advance_task(*task_id)); } }); Ok(()) } /// Correctly update all cron tasks pub fn refresh(&mut self) { let diff_map = self.diff(); for (uid, diff) in diff_map.into_iter() { match diff { ProfileTaskOp::Add(task_id, interval) => { let task = new_task(task_id, &uid, interval); crate::log_err!(self.task_manager.write().add_task(task)); self.task_map.insert(uid, (task_id, interval)); } ProfileTaskOp::Remove(task_id) => { crate::log_err!(self.task_manager.write().remove_task(task_id)); self.task_map.remove(&uid); } ProfileTaskOp::Update(task_id, interval) => { crate::log_err!(self.task_manager.write().remove_task(task_id)); let task = new_task(task_id, &uid, interval); crate::log_err!(self.task_manager.write().add_task(task)); self.task_map.insert(uid, (task_id, interval)); } } } } // fn get_next_task_id(&mut self) -> TaskID { // let id = self.next_id; // self.next_id += 1; // id // } /// generate the diff map for refresh fn diff(&self) -> HashMap { let mut diff_map = HashMap::new(); let timer_map = &self.task_map; let new_map = gen_map(); timer_map.iter().for_each(|(uid, (tid, val))| { let new_val = new_map.get(uid).unwrap_or(&0); if *new_val == 0 { diff_map.insert(uid.clone(), ProfileTaskOp::Remove(*tid)); } else if new_val != val { diff_map.insert(uid.clone(), ProfileTaskOp::Update(*tid, *new_val)); } }); new_map.iter().for_each(|(uid, val)| { if timer_map.get(uid).is_none() { let task_id = get_task_id(uid); diff_map.insert(uid.clone(), ProfileTaskOp::Add(task_id, *val)); } }); diff_map } } /// generate a uid -> update_interval map fn gen_map() -> HashMap { let mut new_map = HashMap::new(); Config::profiles() .latest() .get_items() .iter() .filter_map(|item| item.as_remote()) .for_each(|item| { let interval = item.option.update_interval; if interval > 0 { new_map.insert(item.uid().to_string(), interval); } }); new_map } /// get_task_id Get a u64 task id by profile uid fn get_task_id(uid: &str) -> TaskID { let task_id = seahash::hash(uid.as_bytes()); if task_id < INITIAL_TASK_ID { INITIAL_TASK_ID + task_id } else { task_id } } fn new_task(task_id: TaskID, profile_uid: &str, interval: Minutes) -> Task { Task { id: task_id, name: format!("profile-updater-{profile_uid}"), executor: TaskExecutor::Async(Box::new(ProfileUpdater(profile_uid.to_owned().to_string()))), schedule: TaskSchedule::Interval(Duration::from_secs(interval * 60)), ..Task::default() } } ================================================ FILE: backend/tauri/src/core/tasks/mod.rs ================================================ mod events; pub mod executor; pub mod jobs; mod storage; pub mod task; mod utils; pub fn setup>( app: &M, storage: super::storage::Storage, ) -> anyhow::Result<()> { use anyhow::Context; use parking_lot::RwLock; let task_storage = storage::TaskStorage::new(storage); let task_manager = task::TaskManager::new(task_storage); let task_manager = std::sync::Arc::new(RwLock::new(task_manager)); // job manager let mut job_manager = jobs::JobsManager::new(task_manager.clone()); job_manager.setup().context("failed to setup job manager")?; let job_manager = std::sync::Arc::new(RwLock::new(job_manager)); app.manage(job_manager); // profiles job let profiles_job = jobs::ProfilesJobGuard::new(task_manager.clone()); { let mut profiles_job = profiles_job.write(); profiles_job.init()?; } app.manage(profiles_job); app.manage(task_manager); Ok(()) } ================================================ FILE: backend/tauri/src/core/tasks/storage.rs ================================================ //! store is a interface to save and restore task states use super::{ events::TaskEvent, task::{TaskEventID, TaskID, TaskManager}, utils::Result, }; use crate::core::{ storage::{NYANPASU_TABLE, Storage}, tasks::task::Task, }; use log::debug; use redb::{ReadableDatabase, ReadableTable}; use std::{collections::HashSet, str}; pub struct TaskStorage { // TODO: hold storage instance, and better concurrency safety storage: Storage, } /// TaskStorage is a bridge between the task events and the storage impl TaskStorage { const TASKS_KEY: &str = "tasks"; pub fn new(storage: Storage) -> Self { Self { storage } } /// list_tasks list all tasks, for reduce the number of read operations pub fn list_tasks(&self) -> Result> { let db = self.storage.get_instance(); let read_txn = db.begin_read()?; let table = read_txn.open_table(NYANPASU_TABLE)?; let value = table.get(Self::TASKS_KEY.as_bytes())?; match value { Some(value) => { let tasks: Vec = serde_json::from_slice(value.value())?; Ok(tasks) } None => Ok(Vec::new()), } } /// add_task add a task id to the storage pub fn add_task(&self, task_id: TaskID) -> Result<()> { let db = self.storage.get_instance(); let write_txn = db.begin_write()?; { let mut table = write_txn.open_table(NYANPASU_TABLE)?; let mut tasks = table .get(Self::TASKS_KEY.as_bytes())? .and_then(|val| { let tasks: HashSet = serde_json::from_slice(val.value()).ok()?; Some(tasks) }) .unwrap_or_default(); tasks.insert(task_id); let value = serde_json::to_vec(&tasks)?; table.insert(Self::TASKS_KEY.as_bytes(), value.as_slice())?; } write_txn.commit()?; Ok(()) } /// remove_task remove a task id from the storage pub fn remove_task(&self, _task_id: TaskID) -> Result<()> { let db = self.storage.get_instance(); let write_txn = db.begin_write()?; { let mut table = write_txn.open_table(NYANPASU_TABLE)?; table.remove(Self::TASKS_KEY.as_bytes())?; } write_txn.commit()?; Ok(()) } /// get_event get a task event by event id pub fn get_event(&self, event_id: TaskEventID) -> Result> { let db = self.storage.get_instance(); let key = format!("task:event:id:{event_id}"); let read_txn = db.begin_read()?; let table = read_txn.open_table(NYANPASU_TABLE)?; let value = table.get(key.as_bytes())?; match value { Some(value) => { let event: TaskEvent = serde_json::from_slice(value.value())?; Ok(Some(event)) } None => Ok(None), } } /// get_events get all events of a task #[allow(dead_code)] pub fn get_events(&self, task_id: TaskID) -> Result>> { let mut value = match self.get_event_ids(task_id)? { Some(value) => value, None => return Ok(None), }; let mut events = Vec::with_capacity(value.len()); for event_id in value.drain(..) { let event = self.get_event(event_id)?.unwrap(); // unwrap because it should be exist here, if not, it's a bug events.push(event); } Ok(Some(events)) } pub fn get_event_ids(&self, task_id: TaskID) -> Result>> { let db = self.storage.get_instance(); let key = format!("task:events:task_id:{task_id}"); let read_txn = db.begin_read()?; let table = read_txn.open_table(NYANPASU_TABLE)?; let value = table.get(key.as_bytes())?; let value: Vec = match value { Some(value) => serde_json::from_slice(value.value())?, None => return Ok(None), }; Ok(Some(value)) } /// add_event add a new event to the storage pub fn add_event(&self, event: &TaskEvent) -> Result<()> { let mut event_ids = (self.get_event_ids(event.task_id)?).unwrap_or_default(); event_ids.push(event.id); let db = self.storage.get_instance(); let event_key = format!("task:event:id:{}", event.id); let event_ids_key = format!("task:events:task_id:{}", event.task_id); let event_value = serde_json::to_vec(event)?; let event_ids = serde_json::to_vec(&event_ids)?; let write_txn = db.begin_write()?; { let mut table = write_txn.open_table(NYANPASU_TABLE)?; table.insert(event_key.as_bytes(), event_value.as_slice())?; table.insert(event_ids_key.as_bytes(), event_ids.as_slice())?; } write_txn.commit()?; Ok(()) } /// update_event update a event in the storage pub fn update_event(&self, event: &TaskEvent) -> Result<()> { let db = self.storage.get_instance(); let event_key = format!("task:event:id:{}", event.id); let event_value = serde_json::to_vec(event)?; let write_txn = db.begin_write()?; { let mut table = write_txn.open_table(NYANPASU_TABLE)?; table.insert(event_key.as_bytes(), event_value.as_slice())?; } write_txn.commit()?; Ok(()) } /// remove_event remove a event from the storage #[allow(dead_code)] pub fn remove_event(&self, event_id: TaskEventID, task_id: TaskID) -> Result<()> { let event_ids: Vec = match self.get_event_ids(task_id)? { Some(value) => value.into_iter().filter(|v| v != &event_id).collect(), None => return Ok(()), }; let db = self.storage.get_instance(); let event_key = format!("task:event:id:{event_id}"); let event_ids_key = format!("task:events:task_id:{event_id}"); let write_txn = db.begin_write()?; { let mut table = write_txn.open_table(NYANPASU_TABLE)?; table.remove(event_key.as_bytes())?; if event_ids.is_empty() { table.remove(event_ids_key.as_bytes())?; } else { let event_ids = serde_json::to_vec(&event_ids)?; table.insert(event_ids_key.as_bytes(), event_ids.as_slice())?; } } write_txn.commit()?; Ok(()) } /// get_instance get the raw storage instance fn get_instance(&self) -> &redb::Database { self.storage.get_instance() } } // pub struct TaskGuard; pub trait TaskGuard { fn restore(&mut self) -> Result<()>; fn dump(&self) -> Result<()>; } /// TaskGuard is a bridge between the tasks and the storage impl TaskGuard for TaskManager { fn restore(&mut self) -> Result<()> { let tasks = { let db = self.storage.lock(); let instance = db.get_instance(); let mut tasks = Vec::new(); let read_txn = instance.begin_read()?; let table = read_txn.open_table(NYANPASU_TABLE)?; for item in table.iter()? { let (key, value) = item?; let key = key.value(); let mut value = value.value().to_owned(); if key.starts_with(b"task:id:") { let task = serde_json::from_slice::(value.as_mut_slice())?; debug!( "restore task: {:?} {:?}", str::from_utf8(key).unwrap(), str::from_utf8(value.as_slice()).unwrap() ); tasks.push(task); } } tasks }; self.restore_tasks(tasks); Ok(()) } fn dump(&self) -> Result<()> { let tasks = self.list(); let db = self.storage.lock(); let instance = db.get_instance(); let write_txn = instance.begin_write()?; { let mut table = write_txn.open_table(NYANPASU_TABLE)?; for task in tasks { let key = format!("task:id:{}", task.id); let value = serde_json::to_vec(&task)?; table.insert(key.as_bytes(), value.as_slice())?; } } write_txn.commit()?; Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_hashset_eq_vec() { let json = r#" [1, 2, 3] "# .trim(); let hashset: HashSet = serde_json::from_str(json).unwrap(); let new_json = serde_json::to_string(&hashset).unwrap(); println!("{new_json}"); } } ================================================ FILE: backend/tauri/src/core/tasks/task.rs ================================================ use super::{ events::{TaskEventState, TaskEvents}, executor::{Job, TaskExecutor}, storage::TaskStorage, utils::{Error, Result, TaskCreationError}, }; use crate::error; use chrono::Utc; use delay_timer::{ entity::{DelayTimer, DelayTimerBuilder}, timer::task::TaskBuilder as TimerTaskBuilder, utils::convenience::cron_expression_grammatical_candy::{CandyCronStr, CandyFrequency}, }; use parking_lot::{Mutex, RwLock as RW}; use serde::{Deserialize, Serialize}; use snowflake::SnowflakeIdGenerator; use std::{sync::Arc, time::Duration}; pub type TaskID = u64; pub type TaskEventID = i64; // 任务事件 ID,适用于任务并发执行,区分不同的执行事件 #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub enum TaskState { Cancelled, // 任务已取消,不再执行 #[default] Idle, // 空闲 Running(TaskEventID), // 任务执行中,存储最新执行的事件 ID } #[derive(Serialize, Deserialize, Debug, Clone)] pub enum TaskRunResult { Ok, Err(String), } #[derive(Debug, Clone)] pub enum TaskSchedule { Once(Duration), // 一次性执行 Interval(Duration), // 按间隔执行 #[allow(dead_code)] Cron(String), // 按 cron 表达式执行 } impl Default for TaskSchedule { fn default() -> Self { Self::Once(Duration::from_secs(0)) } } // TODO: 如果需要的话,未来可以添加执行日记(历史记录) #[derive(Debug, Clone)] pub struct TaskOptions { pub maximum_parallel_runnable_num: u64, // 最大同时并发数 } impl Default for TaskOptions { fn default() -> Self { Self { maximum_parallel_runnable_num: 5, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Task { pub id: TaskID, pub name: String, #[serde(skip_serializing, skip_deserializing)] pub(super) schedule: TaskSchedule, #[serde(skip_serializing, skip_deserializing)] pub(super) state: TaskState, #[serde(skip_serializing, skip_deserializing)] pub(super) opts: TaskOptions, pub(super) last_run: Option<(Timestamp, TaskRunResult)>, pub(super) next_run: Option, // timestamp #[serde(skip_serializing, skip_deserializing)] pub(super) executor: TaskExecutor, pub created_at: Timestamp, } impl Default for Task { fn default() -> Self { Task { id: 0, name: String::new(), schedule: TaskSchedule::Once(Duration::from_secs(0)), state: TaskState::Idle, opts: TaskOptions::default(), executor: TaskExecutor::Sync(Job::default()), // a unimplemented job last_run: None, next_run: None, created_at: 0, } } } pub type Timestamp = i64; // 参数校验失败 macro_rules! params_validated_failed { ($fmt:expr) => { Err(Error::ParamsValidationFailed($fmt)) }; } // 检查任务输入 macro_rules! check_task_input { ($task:ident) => { if $task.name.is_empty() { return params_validated_failed!("task name is empty"); } match &$task.schedule { TaskSchedule::Once(duration) => { if duration.as_secs() <= 0 { return params_validated_failed!("task interval must be greater than 0"); } } TaskSchedule::Interval(duration) => { if duration.as_secs() <= 0 { return params_validated_failed!("task interval must be greater than 0"); } } TaskSchedule::Cron(cron) => { if cron.is_empty() { return params_validated_failed!("task cron is empty"); } } } }; } // 构建任务 fn build_task<'a>(task: Task, len: usize) -> (Task, TimerTaskBuilder<'a>) { let task = Task { id: match task.id { 0 => len as u64 + 1, _ => task.id, }, created_at: match task.created_at { 0 => Utc::now().timestamp(), _ => task.created_at, }, ..task }; let mut builder = TimerTaskBuilder::default(); builder.set_task_id(task.id); match &task.schedule { TaskSchedule::Cron(cron) => { // NOTE: 由于 DelayTimer 的设计,因此继续使用弃用的 candy 方法 // NOTE: 请注意一定需要回收内存,否则会造成内存泄漏 let cron = cron.clone(); #[allow(deprecated)] builder.set_frequency_by_candy(CandyFrequency::Repeated(CandyCronStr(cron))); } TaskSchedule::Interval(duration) => { builder.set_frequency_repeated_by_seconds(duration.as_secs()); } // 一次性延迟任务,目前设计只支持 Interval // TODO: 支持即时任务? TaskSchedule::Once(duration) => { builder.set_frequency_once_by_seconds(duration.as_secs()); } } builder.set_maximum_parallel_runnable_num(task.opts.maximum_parallel_runnable_num); // 最大同时并发数 (task, builder) } macro_rules! wrap_job { ($exec:expr, $list:expr, $id_generator:expr, $task_id:expr, $task_events:expr) => {{ let event_id = $id_generator.generate(); let _ = $list.set_task_state($task_id, TaskState::Running(event_id), None); let mut dispatcher = $task_events.new_event($task_id, event_id).unwrap(); dispatcher.dispatch(TaskEventState::Running).unwrap(); let res = $exec; let res = match res { Ok(_) => TaskRunResult::Ok, Err(e) => { error!(format!("task error: {}", e.to_string())); TaskRunResult::Err(e.to_string()) } }; if let TaskState::Running(latest_event_id) = $list.get_task_state($task_id).unwrap() { if latest_event_id == event_id { let _ = $list.set_task_state($task_id, TaskState::Idle, Some(res.clone())); } } dispatcher.dispatch(TaskEventState::Finished(res)).unwrap(); }}; } // TaskList 语法糖 type TaskList = Arc>>; trait TaskListOps { fn get_task_state(&self, task_id: TaskID) -> Result; fn set_task_state( &self, task_id: TaskID, state: TaskState, result: Option, ) -> Result<()>; } impl TaskListOps for TaskList { fn get_task_state(&self, task_id: TaskID) -> Result { let list = self.read(); let item = list .iter() .find(|t| t.id == task_id) .ok_or(Error::CreateTaskFailed(TaskCreationError::NotFound))?; Ok(item.state.clone()) } fn set_task_state( &self, task_id: TaskID, state: TaskState, result: Option, ) -> Result<()> { let mut list = self.write(); let item = list .iter_mut() .find(|t| t.id == task_id) .ok_or(Error::CreateTaskFailed(TaskCreationError::NotFound))?; match state { TaskState::Running(event_id) => { item.state = TaskState::Running(event_id); } TaskState::Idle => { if let TaskState::Running(_) = item.state { item.last_run = Some(( Utc::now().timestamp(), result.ok_or(Error::CreateTaskFailed(TaskCreationError::NotFound))?, )); } item.state = TaskState::Idle; } TaskState::Cancelled => { item.state = TaskState::Cancelled; } } Ok(()) } } pub struct TaskManager { /// cron manager timer: Arc>, // Add a mutex to protect the concurrency of the storage pub(super) storage: Arc>, task_events: Arc, /// task list list: TaskList, restore_list: TaskList, id_generator: SnowflakeIdGenerator, } impl TaskManager { pub fn new(storage: TaskStorage) -> Self { let storage = Arc::new(Mutex::new(storage)); let task_events = TaskEvents::new(storage.clone()); Self { timer: Arc::new(Mutex::new(DelayTimerBuilder::default().build())), storage, task_events: Arc::new(task_events), restore_list: Arc::new(RW::new(Vec::new())), list: Arc::new(RW::new(Vec::new())), id_generator: SnowflakeIdGenerator::new(1, 1), } } #[doc(hidden)] /// a hidden method to get the inner task storage /// just for internal jobs to use pub(super) fn get_inner_task_storage(&self) -> Arc> { self.storage.clone() } pub fn restore_tasks(&mut self, tasks: Vec) { let mut list = self.restore_list.write(); list.clear(); for task in tasks { list.push(task); } } /// add task /// /// # Example /// ```rust /// let task = Task { /// name: "test".to_string(), /// schedule: TaskSchedule::Once(Duration::from_secs(1)), /// ..Task::default() /// }; /// let job = Job::default(); /// task_manager.add_task(task, job.into()); pub fn add_task(&mut self, task: Task) -> Result<()> { check_task_input!(task); let (mut task, mut builder) = { let list = self.list.read(); build_task(task, list.len()) }; let restored_task = self.get_task_from_restored(task.id); if let Some(restored_task) = restored_task && restored_task.name == task.name { task.last_run = restored_task.last_run; task.created_at = restored_task.created_at; } let task_id = task.id; let id_generator = self.id_generator; let list_ref = self.list.clone(); let executor = task.executor.clone(); let task_events = self.task_events.clone(); let timer_task = match executor { TaskExecutor::Sync(job) => { let body = move || { let list = list_ref.clone(); let mut id_generator = id_generator; wrap_job!(job.execute(), list, id_generator, task_id, task_events); }; builder.spawn_routine(body) } TaskExecutor::Async(async_job) => { let body = move || { let list = list_ref.clone(); let async_job = async_job.clone(); let mut id_generator = id_generator; let task_events = task_events.clone(); async move { wrap_job!( async_job.execute().await, list, id_generator, task_id, task_events ); } }; builder.spawn_async_routine(body) } }; { builder.free(); // 在错误处理之前,先释放内存 } let timer = self.timer.lock(); let mut list = self.list.write(); timer .add_task(timer_task.map_err(|e| { Error::new_task_error("failed to create a delay task instance".to_string(), e) })?) .map_err(|e| { Error::new_task_error("failed to add a task to scheduler".to_string(), e) })?; let storage = self.storage.lock(); let task_id = task.id; list.push(task); storage.add_task(task_id)?; Ok(()) } fn get_task_from_restored(&self, task_id: TaskID) -> Option { let list = self.restore_list.read(); list.iter().find(|t| t.id == task_id).cloned() } #[allow(dead_code)] pub fn pick_task(&self, task_id: TaskID) -> Result { let list = self.list.read(); list.iter() .find(|t| t.id == task_id) .cloned() .ok_or(Error::CreateTaskFailed(TaskCreationError::NotFound)) } #[allow(dead_code)] pub fn total(&self) -> usize { let list = self.list.read(); list.len() } // get current task list // note: this method will clone the task list pub fn list(&self) -> Vec { let list = self.list.read(); list.clone() } pub fn remove_task(&mut self, task_id: TaskID) -> Result<()> { let mut list = self.list.write(); let index = list .iter() .position(|t| t.id == task_id) .ok_or(Error::CreateTaskFailed(TaskCreationError::NotFound))?; self.timer .lock() .remove_task(task_id) .map_err(|e| Error::new_task_error("failed to remove task".to_string(), e))?; list.remove(index); let storage = self.storage.lock(); storage.remove_task(task_id)?; Ok(()) } pub fn advance_task(&mut self, task_id: TaskID) -> Result<()> { let timer = self.timer.lock(); timer .advance_task(task_id) .map_err(|e| Error::new_task_error("failed to advance a task".to_string(), e))?; Ok(()) } } ================================================ FILE: backend/tauri/src/core/tasks/utils.rs ================================================ use thiserror::Error; #[derive(Debug)] pub enum TaskCreationError { #[allow(unused)] AlreadyExist, NotFound, } #[derive(Error, Debug)] pub enum Error { #[error("create task failed: {0:?}")] CreateTaskFailed(TaskCreationError), #[error("params validation failed: {0}")] ParamsValidationFailed(&'static str), #[error("database operation failed: {0:?}")] DatabaseOperationFailed(#[from] redb::DatabaseError), #[error("database transaction failed: {0:?}")] DatabaseTransactionFailed(#[from] redb::TransactionError), #[error("database table operation failed: {0:?}")] DatabaseTableOperationFailed(#[from] redb::TableError), #[error("database storage operation failed: {0:?}")] DatabaseStorageOperationFailed(#[from] redb::StorageError), #[error("database commit operation failed: {0:?}")] DatabaseCommitOperationFailed(#[from] redb::CommitError), #[error("json parse failed: {0:?}")] JsonParseFailed(#[from] serde_json::Error), #[error("task issue failed: {message:?}")] InnerTask { message: String, #[source] source: delay_timer::error::TaskError, }, #[error(transparent)] Other(#[from] anyhow::Error), } pub type Result = std::result::Result; impl Error { pub fn new_task_error(message: String, source: delay_timer::error::TaskError) -> Self { Self::InnerTask { message, source } } } pub trait ConfigChangedNotifier { #[allow(dead_code)] fn notify_config_changed(&self, task_name: &str) -> Result<()>; } ================================================ FILE: backend/tauri/src/core/tray/icon.rs ================================================ use crate::utils::dirs::tray_icons_path; use serde::{Deserialize, Serialize}; use specta::Type; use std::{ borrow::Cow, fmt::{Display, Formatter}, path::PathBuf, }; #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, Type)] #[serde(rename_all = "snake_case")] pub enum TrayIcon { #[default] Normal, Tun, SystemProxy, } impl Display for TrayIcon { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { TrayIcon::Normal => write!(f, "normal"), TrayIcon::Tun => write!(f, "tun"), TrayIcon::SystemProxy => write!(f, "system_proxy"), } } } impl From for &'static str { fn from(icon: TrayIcon) -> Self { match icon { TrayIcon::Normal => "normal", TrayIcon::Tun => "tun", TrayIcon::SystemProxy => "system_proxy", } } } impl From<&TrayIcon> for &'static str { fn from(icon: &TrayIcon) -> Self { match icon { TrayIcon::Normal => "normal", TrayIcon::Tun => "tun", TrayIcon::SystemProxy => "system_proxy", } } } impl TrayIcon { pub fn raw_bytes(&self) -> &'static [u8] { match self { TrayIcon::Normal => include_bytes!("../../../icons/win-tray-icon.png"), TrayIcon::Tun => include_bytes!("../../../icons/win-tray-icon-blue.png"), TrayIcon::SystemProxy => include_bytes!("../../../icons/win-tray-icon-pink.png"), } } pub fn all_supported() -> &'static [TrayIcon] { &[TrayIcon::Normal, TrayIcon::Tun, TrayIcon::SystemProxy] } pub fn as_str(&self) -> &'static str { match self { TrayIcon::Normal => "normal", TrayIcon::Tun => "tun", TrayIcon::SystemProxy => "system_proxy", } } } #[tracing_attributes::instrument] pub fn get_raw_icon<'n>(mode: TrayIcon) -> Cow<'n, [u8]> { match tray_icons_path(mode.as_str()) { Ok(path) if path.exists() => match std::fs::read(path) { Ok(bytes) => Cow::Owned(bytes), Err(e) => { tracing::error!("failed to read icon file: {:?}", e); Cow::Borrowed(mode.raw_bytes()) } }, _ => Cow::Borrowed(mode.raw_bytes()), } } #[tracing_attributes::instrument] fn resize_image(mode: TrayIcon, scale_factor: f64) { let raw_icon: Cow<[u8]> = get_raw_icon(mode); let icon = match crate::utils::help::resize_tray_image(&raw_icon, scale_factor) { Ok(icon) => icon, Err(e) => { tracing::error!("failed to resize icon: {:?}", e); raw_icon.to_vec() } }; let cache_dir = crate::utils::dirs::cache_dir().unwrap().join("icons"); if !cache_dir.exists() && let Err(e) = std::fs::create_dir_all(&cache_dir) { tracing::error!("failed to create cache dir: {:?}", e); } if let Err(e) = std::fs::write(cache_dir.join(format!("tray_{mode}.png")), icon) { tracing::error!("failed to write icon file: {:?}", e); } } // TODO: migrate to async fn #[tracing_attributes::instrument] pub fn resize_images(scale_factor: f64) { for item in TrayIcon::all_supported() { resize_image(*item, scale_factor); } } pub fn set_icon(mode: TrayIcon, path: Option) -> anyhow::Result<()> { match path { Some(path) => { // try parse path and convert image to png let image = image::open(&path)?; image.save(tray_icons_path(mode.as_str())?)?; } None => { // use default icon std::fs::remove_file(tray_icons_path(mode.as_str())?)?; } } let factor = crate::utils::help::get_max_scale_factor(); resize_image(mode, factor); Ok(()) } pub fn on_scale_factor_changed(scale_factor: f64) { resize_images(scale_factor); } #[allow(dead_code)] pub fn get_icon(mode: &TrayIcon) -> Vec { let cache_file = crate::utils::dirs::cache_dir() .unwrap() .join("icons") .join(format!("tray_{mode}.png")); match std::fs::read(&cache_file) { Ok(bytes) if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) => { tracing::info!("use cached icon: {:?}", cache_file); bytes } Err(e) => { tracing::error!("failed to read icon file: {:?}", e); mode.raw_bytes().to_vec() } _ => { tracing::error!("invalid icon file: {:?}", cache_file); mode.raw_bytes().to_vec() } } } ================================================ FILE: backend/tauri/src/core/tray/mod.rs ================================================ use std::borrow::Cow; use crate::{ config::{Config, nyanpasu::ClashCore}, feat, ipc, log_err, utils::{help, resolve}, }; use anyhow::Result; use once_cell::sync::Lazy; use parking_lot::Mutex; use rust_i18n::t; use tauri::{ AppHandle, Manager, Runtime, menu::{Menu, MenuBuilder, MenuEvent, MenuItemBuilder, SubmenuBuilder}, tray::{MouseButton, TrayIcon, TrayIconBuilder, TrayIconEvent}, }; use tracing_attributes::instrument; pub mod icon; pub mod proxies; pub use self::icon::on_scale_factor_changed; use self::proxies::SystemTrayMenuProxiesExt; #[cfg(target_os = "linux")] use std::sync::atomic::AtomicU16; struct TrayState { menu: Mutex>, } pub struct Tray {} static UPDATE_SYSTRAY_MUTEX: Lazy> = Lazy::new(|| parking_lot::Mutex::new(())); const TRAY_ID: &str = "main-tray"; #[cfg(target_os = "linux")] static LINUX_TRAY_ID: AtomicU16 = AtomicU16::new(0); // #[cfg(target_os = "linux")] // fn bump_tray_id() -> Cow<'static, str> { // let id = LINUX_TRAY_ID.fetch_add(1, std::sync::atomic::Ordering::Release) + 1; // Cow::Owned(format!("{}-{}", TRAY_ID, id)) // } #[inline] fn get_tray_id<'n>() -> Cow<'n, str> { #[cfg(target_os = "linux")] { let id = LINUX_TRAY_ID.load(std::sync::atomic::Ordering::Acquire); Cow::Owned(format!("{}-{}", TRAY_ID, id)) } #[cfg(not(target_os = "linux"))] { Cow::Borrowed(TRAY_ID) } } // fn dummy_print_submenu(submenu: &Submenu) { // for item in submenu.items().unwrap() { // tracing::debug!("item: {:#?}", item.id()); // match item { // tauri::menu::MenuItemKind::MenuItem(item) => { // tracing::debug!( // "item: {:#?}, type: MenuItem, text: {:#?}", // item.id(), // item.text() // ); // } // tauri::menu::MenuItemKind::Submenu(submenu) => { // tracing::debug!( // "item: {:#?}, type: Submenu, text: {:#?}", // submenu.id(), // submenu.text() // ); // dummy_print_submenu(&submenu); // } // tauri::menu::MenuItemKind::Predefined(item) => { // tracing::debug!( // "item: {:#?}, type: Predefined, text: {:#?}", // item.id(), // item.text() // ); // } // tauri::menu::MenuItemKind::Check(item) => { // tracing::debug!( // "item: {:#?}, type: Check, text: {:#?}", // item.id(), // item.text() // ); // } // tauri::menu::MenuItemKind::Icon(item) => { // tracing::debug!( // "item: {:#?}, type: Icon, text: {:#?}", // item.id(), // item.text() // ); // } // } // } // } // fn dummy_print_menu(menu: &Menu) { // for item in menu.items().unwrap() { // tracing::debug!("item: {:#?}", item.id()); // match item { // tauri::menu::MenuItemKind::MenuItem(item) => { // tracing::debug!( // "item: {:#?}, type: MenuItem, text: {:#?}", // item.id(), // item.text() // ); // } // tauri::menu::MenuItemKind::Submenu(submenu) => { // tracing::debug!( // "item: {:#?}, type: Submenu, text: {:#?}", // submenu.id(), // submenu.text() // ); // dummy_print_submenu(&submenu); // } // tauri::menu::MenuItemKind::Predefined(item) => { // tracing::debug!( // "item: {:#?}, type: Predefined, text: {:#?}", // item.id(), // item.text() // ); // } // tauri::menu::MenuItemKind::Check(item) => { // tracing::debug!( // "item: {:#?}, type: Check, text: {:#?}", // item.id(), // item.text() // ); // } // tauri::menu::MenuItemKind::Icon(item) => { // tracing::debug!( // "item: {:#?}, type: Icon, text: {:#?}", // item.id(), // item.text() // ); // } // } // } // } impl Tray { #[instrument(skip(app_handle))] pub fn tray_menu(app_handle: &AppHandle) -> Result> { let version = env!("NYANPASU_VERSION"); let core = { *Config::verge() .latest() .clash_core .as_ref() .unwrap_or(&ClashCore::default()) }; let mut menu = MenuBuilder::new(app_handle) .text("open_window", t!("tray.dashboard")) .setup_proxies(app_handle)? // Setup the proxies menu .separator() .check("rule_mode", t!("tray.rule_mode")) .check("global_mode", t!("tray.global_mode")) .check("direct_mode", t!("tray.direct_mode")); if core == ClashCore::ClashPremium { menu = menu.check("script_mode", t!("tray.script_mode")); } menu = menu .separator() .check("system_proxy", t!("tray.system_proxy")) .check("tun_mode", t!("tray.tun_mode")) .separator() .text("copy_env_sh", t!("tray.copy_env.sh")) .text("copy_env_cmd", t!("tray.copy_env.cmd")) .text("copy_env_ps", t!("tray.copy_env.ps")) .item( &SubmenuBuilder::new(app_handle, t!("tray.open_dir.menu")) .text("open_app_config_dir", t!("tray.open_dir.app_config_dir")) .text("open_app_data_dir", t!("tray.open_dir.app_data_dir")) .text("open_core_dir", t!("tray.open_dir.core_dir")) .text("open_logs_dir", t!("tray.open_dir.log_dir")) .build()?, ) .item( &SubmenuBuilder::new(app_handle, t!("tray.more.menu")) .text("restart_clash", t!("tray.more.restart_clash")) .text("restart_app", t!("tray.more.restart_app")) .item( &MenuItemBuilder::new(format!("Version {version}")) .id("app_version") .enabled(false) .build(app_handle)?, ) .build()?, ) .separator() .item( &MenuItemBuilder::new(t!("tray.quit")) .id("quit") .accelerator("CmdOrControl+Q") .build(app_handle)?, ); Ok(menu.build()?) } #[instrument(skip(app_handle))] pub fn update_systray(app_handle: &AppHandle) -> Result<()> { let _guard = UPDATE_SYSTRAY_MUTEX.lock(); let tray_id = get_tray_id(); let tray = { // if cfg!(target_os = "linux") { // tracing::debug!("removing tray by id: {}", tray_id); // let mut tray = app_handle.remove_tray_by_id(tray_id.as_ref()); // tray.take(); // Drop the tray // tray_id = bump_tray_id(); // tracing::debug!("bumped tray id to: {}", tray_id); // } app_handle.tray_by_id(tray_id.as_ref()) }; let menu = Tray::tray_menu(app_handle)?; let tray = match tray { None => { let mut builder = TrayIconBuilder::with_id(tray_id); #[cfg(any(windows, target_os = "linux"))] { builder = builder.icon(tauri::image::Image::from_bytes(&icon::get_icon( &icon::TrayIcon::Normal, ))?); } #[cfg(target_os = "macos")] { builder = builder .icon(tauri::image::Image::from_bytes(include_bytes!( "../../../icons/tray-icon.png" ))?) .icon_as_template(true); } builder .menu(&menu) .on_menu_event(|app, event| { Tray::on_menu_item_event(app, event); }) .on_tray_icon_event(|tray_icon, event| { Tray::on_system_tray_event(tray_icon, event); }) .show_menu_on_left_click(false) .build(app_handle)? } Some(tray) => { // This is a workaround for linux tray menu update. Due to the api disallow set_menu again // and recreate tray icon will cause buggy tray. No icon and no menu. // So this block is a dirty inheritance of the menu items from the previous tray menu. if cfg!(target_os = "linux") { let state = app_handle.state::>(); let previous_menu = state.menu.lock(); if let Ok(items) = previous_menu.items() { tracing::debug!("removing previous tray menu items"); for item in items { log_err!(previous_menu.remove(&item), "failed to remove menu item"); } } // migrate the menu items if let Ok(items) = menu.items() { tracing::debug!("migrating new tray menu items"); for item in items { log_err!(previous_menu.append(&item), "failed to append menu item"); } } } else { tray.set_menu(Some(menu.clone()))?; } tray } }; tray.set_visible(true)?; { match app_handle.try_state::>() { Some(state) if cfg!(not(target_os = "linux")) => { tracing::debug!("replacing previous tray menu"); *state.menu.lock() = menu; } None => { tracing::debug!("creating new tray menu"); app_handle.manage(TrayState { menu: Mutex::new(menu), }); } _ => {} } } tracing::debug!("full update tray finished"); Tray::update_part(app_handle)?; Ok(()) } #[instrument(skip(app_handle))] pub fn update_part(app_handle: &AppHandle) -> Result<()> { let mode = crate::utils::config::get_current_clash_mode(); let core = { *Config::verge() .latest() .clash_core .as_ref() .unwrap_or(&ClashCore::default()) }; let tray_id = get_tray_id(); tracing::debug!("updating tray part: {}", tray_id); let tray = app_handle .tray_by_id(tray_id.as_ref()) .expect("tray not found"); let state = app_handle.state::>(); let menu = state.menu.lock(); let _ = menu .get("rule_mode") .and_then(|item| item.as_check_menuitem()?.set_checked(mode == "rule").ok()); let _ = menu .get("global_mode") .and_then(|item| item.as_check_menuitem()?.set_checked(mode == "global").ok()); let _ = menu .get("direct_mode") .and_then(|item| item.as_check_menuitem()?.set_checked(mode == "direct").ok()); if core == ClashCore::ClashPremium { let _ = menu .get("script_mode") .and_then(|item| item.as_check_menuitem()?.set_checked(mode == "script").ok()); } #[allow(unused_variables)] let (system_proxy, tun_mode, enable_tray_text) = { let verge = Config::verge(); let verge = verge.latest(); ( *verge.enable_system_proxy.as_ref().unwrap_or(&false), *verge.enable_tun_mode.as_ref().unwrap_or(&false), *verge.enable_tray_text.as_ref().unwrap_or(&false), ) }; #[cfg(any(target_os = "windows", target_os = "linux"))] { use icon::TrayIcon; let mode = if tun_mode { TrayIcon::Tun } else if system_proxy { TrayIcon::SystemProxy } else { TrayIcon::Normal }; let icon = icon::get_icon(&mode); let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&icon)?)); } let _ = menu .get("system_proxy") .and_then(|item| item.as_check_menuitem()?.set_checked(system_proxy).ok()); let _ = menu .get("tun_mode") .and_then(|item| item.as_check_menuitem()?.set_checked(tun_mode).ok()); let switch_map = { let mut map = std::collections::HashMap::new(); map.insert(true, t!("tray.proxy_action.on")); map.insert(false, t!("tray.proxy_action.off")); map }; #[cfg(not(target_os = "linux"))] { let _ = tray.set_tooltip(Some(&format!( "{}: {}\n{}: {}", t!("tray.system_proxy"), switch_map[&system_proxy], t!("tray.tun_mode"), switch_map[&tun_mode] ))); } #[cfg(target_os = "linux")] { if enable_tray_text { let _ = tray.set_title(Some(&format!( "{}: {}\n{}: {}", t!("tray.system_proxy"), switch_map[&system_proxy], t!("tray.tun_mode"), switch_map[&tun_mode] ))); } else { let _ = tray.set_title::<&str>(None); } } Ok(()) } #[instrument(skip(app_handle, event))] pub fn on_menu_item_event(app_handle: &AppHandle, event: MenuEvent) { let id = event.id().0.as_str(); match id { mode @ ("rule_mode" | "global_mode" | "direct_mode" | "script_mode") => { let mode = &mode[0..mode.len() - 5]; feat::change_clash_mode(mode.into()); } "open_window" => resolve::create_window(app_handle), "system_proxy" => feat::toggle_system_proxy(), "tun_mode" => feat::toggle_tun_mode(), "copy_env_sh" => feat::copy_clash_env(app_handle, "sh"), #[cfg(target_os = "windows")] "copy_env_cmd" => feat::copy_clash_env(app_handle, "cmd"), #[cfg(target_os = "windows")] "copy_env_ps" => feat::copy_clash_env(app_handle, "ps"), "open_app_config_dir" => crate::log_err!(ipc::open_app_config_dir()), "open_app_data_dir" => crate::log_err!(ipc::open_app_data_dir()), "open_core_dir" => crate::log_err!(ipc::open_core_dir()), "open_logs_dir" => crate::log_err!(ipc::open_logs_dir()), "restart_clash" => feat::restart_clash_core(), "restart_app" => help::restart_application(app_handle), "quit" => { help::quit_application(app_handle); } _ => { proxies::on_system_tray_event(id); } } } pub fn on_system_tray_event(tray_icon: &TrayIcon, event: TrayIconEvent) { if let TrayIconEvent::Click { button: MouseButton::Left, .. } = event { resolve::create_window(tray_icon.app_handle()); } } } ================================================ FILE: backend/tauri/src/core/tray/proxies.rs ================================================ use crate::{ config::{Config, nyanpasu::ProxiesSelectorMode}, core::{ clash::proxies::{Proxies, ProxiesGuard, ProxiesGuardExt}, handle::Handle, }, }; use anyhow::Context; use indexmap::IndexMap; use tauri::{AppHandle, Manager, Runtime, menu::MenuBuilder}; use tracing::{debug, error, warn}; use tracing_attributes::instrument; #[instrument] async fn loop_task() { loop { match ProxiesGuard::global().update().await { Ok(_) => { debug!("update proxies success"); } Err(e) => { warn!("update proxies failed: {:?}", e); } } { let guard = ProxiesGuard::global().read(); if guard.updated_at() == 0 { error!("proxies not updated yet!!!!"); // TODO: add a error dialog or notification, and panic? } // else { // let proxies = guard.inner(); // let str = simd_json::to_string_pretty(proxies).unwrap(); // debug!(target: "tray", "proxies info: {:?}", str); // } } tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; // TODO: add a config to control the interval } } type GroupName = String; type ProxyName = String; type FromProxy = ProxyName; type ToProxy = ProxyName; type ProxySelectAction = (GroupName, FromProxy, ToProxy); #[derive(PartialEq)] enum TrayUpdateType { None, Full, Part(Vec), } struct TrayProxyItem { current: Option, all: Vec, r#type: String, // TODO: 转成枚举 } type TrayProxies = IndexMap; /// Convert raw proxies to tray proxies fn to_tray_proxies(mode: &str, raw_proxies: &Proxies) -> TrayProxies { let mut tray_proxies = TrayProxies::new(); if matches!(mode, "global" | "rule" | "script") { if mode == "global" || raw_proxies.proxies.is_empty() { let global = TrayProxyItem { current: raw_proxies.global.now.clone(), all: raw_proxies .global .all .iter() .map(|x| x.name.to_owned()) .collect(), r#type: "Selector".to_string(), }; tray_proxies.insert("global".to_owned(), global); } for raw_group in raw_proxies.groups.iter() { let group = TrayProxyItem { current: raw_group.now.clone(), all: raw_group.all.iter().map(|x| x.name.to_owned()).collect(), r#type: raw_group.r#type.clone(), }; tray_proxies.insert(raw_group.name.to_owned(), group); } } tray_proxies } fn diff_proxies(old_proxies: &TrayProxies, new_proxies: &TrayProxies) -> TrayUpdateType { // 1. check if the length of two map is different if old_proxies.len() != new_proxies.len() { return TrayUpdateType::Full; } // 2. check if the group matching let group_matching = new_proxies .keys() .cloned() .collect::>() .iter() .zip(&old_proxies.keys().cloned().collect::>()) .filter(|&(new, old)| new == old) .count(); if group_matching != old_proxies.len() { return TrayUpdateType::Full; } // 3. start checking the group content let mut actions = Vec::new(); for (group, item) in new_proxies.iter() { let old_item = old_proxies.get(group).unwrap(); // safe to unwrap // check if the length of all list is different if item.all.len() != old_item.all.len() { return TrayUpdateType::Full; } // first diff the all list let all_matching = item .all .iter() .zip(&old_item.all) .filter(|&(new, old)| new == old) .count(); if all_matching != old_item.all.len() { return TrayUpdateType::Full; } // then diff the current if item.current != old_item.current { actions.push(( group.clone(), old_item.current.clone().unwrap(), item.current.clone().unwrap(), )); } } if actions.is_empty() { TrayUpdateType::None } else { TrayUpdateType::Part(actions) } } #[instrument] pub async fn proxies_updated_receiver() { let (mut rx, mut tray_proxies_holder) = { let guard = ProxiesGuard::global().read(); let proxies = guard.inner().to_owned(); let mode = crate::utils::config::get_current_clash_mode(); ( guard.get_receiver(), to_tray_proxies(mode.as_str(), &proxies), ) }; loop { match rx.recv().await { Ok(_) => { debug!("proxies updated"); if Handle::global().app_handle.lock().is_none() { warn!("app handle not found"); continue; } Handle::mutate_proxies(); { let is_tray_selector_enabled = Config::verge() .latest() .clash_tray_selector .unwrap_or_default() != ProxiesSelectorMode::Hidden; if !is_tray_selector_enabled { continue; } } // Do diff check let mode = crate::utils::config::get_current_clash_mode(); let current_tray_proxies = to_tray_proxies(mode.as_str(), ProxiesGuard::global().read().inner()); match diff_proxies(&tray_proxies_holder, ¤t_tray_proxies) { TrayUpdateType::Full => { debug!("should do full update"); tray_proxies_holder = current_tray_proxies; match Handle::emit("update_systray", ()) { Ok(_) => { debug!("update systray success"); } Err(e) => { warn!("update systray failed: {:?}", e); } } } TrayUpdateType::Part(action_list) => { debug!("should do partial update, op list: {:?}", action_list); tray_proxies_holder = current_tray_proxies; platform_impl::update_selected_proxies(&action_list); debug!("update selected proxies success"); } _ => {} } } Err(e) => { warn!("proxies updated receiver failed: {:?}", e); } } } } pub fn setup_proxies() { tauri::async_runtime::spawn(loop_task()); tauri::async_runtime::spawn(proxies_updated_receiver()); } mod platform_impl { use super::{GroupName, ProxyName, ProxySelectAction, TrayProxyItem}; use crate::{ config::nyanpasu::ProxiesSelectorMode, core::{clash::proxies::ProxiesGuard, handle::Handle}, }; use bimap::BiMap; use once_cell::sync::Lazy; use parking_lot::Mutex; use rust_i18n::t; use std::sync::atomic::AtomicBool; use tauri::{ AppHandle, Manager, Runtime, menu::{ CheckMenuItemBuilder, Menu, MenuBuilder, MenuItemBuilder, MenuItemKind, Submenu, SubmenuBuilder, }, }; use tracing::warn; // It store a map of proxy nodes like "GROUP_PROXY" -> ID // TODO: use Cow instead of String pub(super) static ITEM_IDS: Lazy>> = Lazy::new(|| Mutex::new(BiMap::new())); pub fn generate_group_selector( app_handle: &AppHandle, group_name: &str, group: &TrayProxyItem, ) -> anyhow::Result> { let mut item_ids = ITEM_IDS.lock(); let mut group_menu = SubmenuBuilder::new(app_handle, group_name); if group.all.is_empty() { group_menu = group_menu.item( &MenuItemBuilder::new(t!("tray.no_proxies")) .enabled(false) .build(app_handle)?, ); return Ok(group_menu.build()?); } for item in group.all.iter() { let key = (group_name.to_string(), item.to_string()); let id = item_ids.len(); item_ids.insert(key, id); let mut sub_item_builder = CheckMenuItemBuilder::new(item.clone()) .id(format!("proxy_node_{id}")) .checked(false); if let Some(now) = group.current.clone() && now == item.as_str() { sub_item_builder = sub_item_builder.checked(true); } if !matches!(group.r#type.as_str(), "Selector" | "Fallback") { sub_item_builder = sub_item_builder.enabled(false); } group_menu = group_menu.item(&sub_item_builder.build(app_handle)?); } Ok(group_menu.build()?) } pub fn generate_selectors( app_handle: &AppHandle, proxies: &super::TrayProxies, ) -> anyhow::Result>> { let mut items = Vec::new(); if proxies.is_empty() { items.push(MenuItemKind::MenuItem( MenuItemBuilder::new(t!("tray.no_proxies")) .id("no_proxies") .enabled(false) .build(app_handle)?, )); return Ok(items); } { let mut item_ids = ITEM_IDS.lock(); item_ids.clear(); // clear the item ids } for (group, item) in proxies.iter() { let group_menu = generate_group_selector(app_handle, group, item)?; items.push(MenuItemKind::Submenu(group_menu)); } Ok(items) } pub fn setup_tray<'m, R: Runtime, M: Manager>( app_handle: &AppHandle, mut menu: MenuBuilder<'m, R, M>, ) -> anyhow::Result> { let selector_mode = crate::config::Config::verge() .latest() .clash_tray_selector .unwrap_or_default(); menu = match selector_mode { ProxiesSelectorMode::Hidden => return Ok(menu), ProxiesSelectorMode::Normal => menu.separator(), ProxiesSelectorMode::Submenu => menu, }; let proxies = ProxiesGuard::global().read().inner().to_owned(); let mode = crate::utils::config::get_current_clash_mode(); let tray_proxies = super::to_tray_proxies(mode.as_str(), &proxies); let items = generate_selectors::(app_handle, &tray_proxies)?; match selector_mode { ProxiesSelectorMode::Normal => { for item in items { menu = menu.item(&item); } } ProxiesSelectorMode::Submenu => { let mut submenu = SubmenuBuilder::with_id( app_handle, "select_proxies", t!("tray.select_proxies"), ); for item in items { submenu = submenu.item(&item); } menu = menu.item(&submenu.build()?); } _ => {} } Ok(menu) } static TRAY_ITEM_UPDATE_BARRIER: AtomicBool = AtomicBool::new(false); #[tracing_attributes::instrument] pub fn update_selected_proxies(actions: &[ProxySelectAction]) { if TRAY_ITEM_UPDATE_BARRIER.load(std::sync::atomic::Ordering::Acquire) { warn!("tray item update is in progress, skip this update"); return; } let app_handle = Handle::global().app_handle.lock(); let tray_state = app_handle .as_ref() .unwrap() .state::>(); TRAY_ITEM_UPDATE_BARRIER.store(true, std::sync::atomic::Ordering::Release); let menu = tray_state.menu.lock(); // comment it just because we could not get the access to the menu item via the id // If the tauri team fixes this issue, we could use the following code to update the tray item // let item_ids = ITEM_IDS.lock(); for action in actions { // #[cfg(not(target_os = "linux"))] // { // tracing::debug!("update selected proxies: {:?}", action); // let from_id = match item_ids.get_by_left(&(action.0.clone(), action.1.clone())) { // Some(id) => *id, // None => { // warn!("from item not found: {:?}", action); // continue; // } // }; // let from_id = format!("proxy_node_{}", from_id); // let to_id = match item_ids.get_by_left(&(action.0.clone(), action.2.clone())) { // Some(id) => *id, // None => { // warn!("to item not found: {:?}", action); // continue; // } // }; // let to_id = format!("proxy_node_{}", to_id); // match menu.get(&from_id) { // Some(item) => match item.kind() { // MenuItemKind::Check(item) => { // if item.is_checked().is_ok_and(|x| x) { // let _ = item.set_checked(false); // } // } // MenuItemKind::MenuItem(item) => { // let _ = item.set_text(action.1.clone()); // } // _ => { // warn!("failed to deselect, item is not a check item: {}", from_id); // } // }, // None => { // warn!("failed to deselect, item not found: {}", from_id); // } // } // match menu.get(&to_id) { // Some(item) => match item.kind() { // MenuItemKind::Check(item) => { // if item.is_checked().is_ok_and(|x| !x) { // let _ = item.set_checked(true); // } // } // MenuItemKind::MenuItem(item) => { // let _ = item.set_text(action.2.clone()); // } // _ => { // warn!("failed to select, item is not a check item: {}", to_id); // } // }, // None => { // warn!("failed to select, item not found: {}", to_id); // } // } // } // } // here is a fucking workaround for id getter #[inline] fn find_check_item( menu: &Menu, group: GroupName, proxy: ProxyName, ) -> Option> { menu.items() .ok() .and_then(|items| { items.into_iter().find(|i| matches!(i, tauri::menu::MenuItemKind::Submenu(submenu) if submenu.text().is_ok_and(|text| text == group) || submenu.id() == "select_proxies")) }) .and_then(|submenu| { let submenu = submenu.as_submenu_unchecked(); if submenu.id() == "select_proxies" { submenu.items().ok().and_then(|items| { items.into_iter().find(|i| matches!(i, tauri::menu::MenuItemKind::Submenu(submenu) if submenu.text().is_ok_and(|text| text == group))) }) .and_then(|submenu| { submenu.as_submenu_unchecked().items().ok() }) } else { submenu.items().ok() } }) .and_then(|items| { items.into_iter().find(|i| matches!(i, tauri::menu::MenuItemKind::Check(item) if item.text().is_ok_and(|text| text == proxy))) }).map(|item| item.as_check_menuitem_unchecked().clone()) } let from_item = find_check_item(&menu, action.0.clone(), action.1.clone()); match from_item { Some(item) => { let _ = item.set_checked(false); } None => { warn!( "failed to deselect, item not found: {} {}", action.0, action.1 ); } } let to_item = find_check_item(&menu, action.0.clone(), action.2.clone()); match to_item { Some(item) => { let _ = item.set_checked(true); } None => { warn!( "failed to select, item not found: {} {}", action.0, action.2 ); } } } TRAY_ITEM_UPDATE_BARRIER.store(false, std::sync::atomic::Ordering::Release); } } pub trait SystemTrayMenuProxiesExt { fn setup_proxies(self, app_handle: &AppHandle) -> anyhow::Result where Self: Sized; } impl> SystemTrayMenuProxiesExt for MenuBuilder<'_, R, M> { fn setup_proxies(self, app_handle: &AppHandle) -> anyhow::Result { platform_impl::setup_tray(app_handle, self) } } #[instrument] pub fn on_system_tray_event(event: &str) { if !event.starts_with("proxy_node_") { return; // bypass non-select event } let node_id = event.split('_').next_back().unwrap(); // safe to unwrap let node_id = match node_id.parse::() { Ok(id) => id, Err(e) => { error!("parse node id failed: {:?}", e); return; } }; let (group, name) = { let map = platform_impl::ITEM_IDS.lock(); let item = map.get_by_right(&node_id); match item { Some((group, name)) => (group.clone(), name.clone()), None => { error!("node id not found: {}", node_id); return; } } }; let wrapper = move || -> anyhow::Result<()> { tracing::debug!("received select proxy event: {} {}", group, name); tauri::async_runtime::block_on(async move { ProxiesGuard::global() .select_proxy(&group, &name) .await .with_context(|| format!("select proxy failed, {group} {name}, cause: "))?; debug!("select proxy success: {} {}", group, name); Ok::<(), anyhow::Error>(()) })?; Ok(()) }; if let Err(e) = wrapper() { // TODO: add a error dialog or notification error!("on_system_tray_event failed: {:?}", e); } } ================================================ FILE: backend/tauri/src/core/updater/instance.rs ================================================ use super::shared::{self, CoreTypeMeta}; use crate::{ config::nyanpasu::ClashCore, core::CoreManager, utils::downloader::{DownloadStatus, Downloader, DownloaderBuilder, DownloaderState}, }; use anyhow::anyhow; use runas::Command as RunasCommand; use serde::Serialize; use specta::Type; #[cfg(target_family = "unix")] use std::os::unix::fs::PermissionsExt; use std::sync::Arc; use tempfile::TempDir; use tokio::sync::Mutex; #[derive(Debug, Clone, Serialize, Default, specta::Type)] #[serde(rename_all = "snake_case")] pub enum UpdaterState { #[default] Idle, Downloading, Decompressing, Replacing, Restarting, Done, Failed(String), } type DownloaderWithDynCallback = Downloader>; pub(super) struct Updater { id: usize, temp_dir: TempDir, core_type: ClashCore, artifact: String, inner: parking_lot::RwLock, rx: Mutex>, downloader: Arc, } struct UpdaterInner { state: UpdaterState, } #[derive(Debug, Serialize, Type)] pub struct UpdaterSummary { pub id: usize, pub state: UpdaterState, pub downloader: DownloadStatus, } pub(super) struct UpdaterBuilder { client: Option, core_type: Option, mirror: Option, artifact: Option, tag: Option, } impl UpdaterBuilder { pub fn new() -> Self { Self { client: None, core_type: None, mirror: None, artifact: None, tag: None, } } pub fn set_client(mut self, client: reqwest::Client) -> Self { self.client = Some(client); self } pub fn set_core_type(mut self, core_type: ClashCore) -> Self { self.core_type = Some(core_type); self } pub fn set_artifact(mut self, artifact: String) -> Self { self.artifact = Some(artifact); self } pub fn set_tag(mut self, tag: CoreTypeMeta) -> Self { self.tag = Some(tag); self } pub fn set_mirror(mut self, mirror: String) -> Self { self.mirror = Some(mirror); self } pub async fn build(self) -> anyhow::Result { let client = self.client.ok_or(anyhow::anyhow!("client is required"))?; let core_type = self .core_type .ok_or(anyhow::anyhow!("core_type is required"))?; let artifact = self .artifact .ok_or(anyhow::anyhow!("artifact is required"))?; let tag = self.tag.ok_or(anyhow::anyhow!("tag is required"))?; let mirror = self.mirror.ok_or(anyhow::anyhow!("mirror is required"))?; let temp_dir = TempDir::new()?; let inner = UpdaterInner { state: UpdaterState::Idle, }; // setup downloader let download_path = shared::get_download_path(tag, &artifact); let mut download_url = url::Url::parse("https://github.com")?; download_url.set_path(&download_path); let download_url = crate::utils::candy::parse_gh_url(&mirror, download_url.as_str())?; let file = tokio::fs::File::create(temp_dir.path().join(&artifact)).await?; tracing::debug!("downloader url: {}", download_url); tracing::debug!("downloader file: {:?}", file); let (tx, rx) = tokio::sync::mpsc::channel::(1); let callback: Box = Box::new(move |state| { let tx = tx.clone(); tokio::spawn(async move { if let Err(e) = tx.send(state).await { tracing::warn!("failed to send downloader state: {}", e); } }); }); let downloader = Arc::new( DownloaderBuilder::new() .set_client(client) .set_url(download_url)? .set_file(file) .set_event_callback(callback) .build()?, ); Ok(Updater { id: rand::random::() as usize, temp_dir, core_type, inner: parking_lot::RwLock::new(inner), artifact, rx: Mutex::new(rx), downloader, }) } } impl Updater { fn dispatch_state(&self, state: UpdaterState) { tracing::debug!("dispatching updater state: {:?}", state); let mut inner = self.inner.write(); inner.state = state; } async fn decompress_and_set_permission(&self) -> anyhow::Result<()> { self.dispatch_state(UpdaterState::Decompressing); let path = self.temp_dir.path().join(&self.artifact); tracing::debug!("decompressing file: {:?}", path); let mut tmp_file = std::fs::File::open(path)?; tracing::debug!("file size: {}", tmp_file.metadata()?.len()); let artifact = self.artifact.clone(); let buff = tokio::task::spawn_blocking(move || { let mut buff = Vec::::new(); match artifact { fname if fname.ends_with(".gz") => { tracing::debug!("decompressing gz file"); let mut decoder = flate2::read::GzDecoder::new(&mut tmp_file); std::io::copy(&mut decoder, &mut buff)?; } fname if fname.ends_with(".zip") => { tracing::debug!("decompressing zip file"); let mut archive = zip::ZipArchive::new(tmp_file)?; let len = archive.len(); for i in 0..len { let mut file = archive.by_index(i)?; let file_name = file.name(); tracing::debug!("Filename: {}", file.name()); // TODO: 在 enum 做点魔法 if file_name.contains("mihomo") || file_name.contains("clash") { tracing::debug!("extract file: {}", file_name); tracing::debug!("extract file size: {}", file.size()); std::io::copy(&mut file, &mut buff)?; break; } if i == len - 1 { anyhow::bail!("failed to find core file in a zip archive"); } } } _ => { tracing::debug!("directly copying file"); std::io::copy(&mut tmp_file, &mut buff)?; } }; Ok::<_, anyhow::Error>(buff) }) .await??; let tmp_core = self.temp_dir.path().join(format!( "{}{}", self.core_type, std::env::consts::EXE_SUFFIX )); tracing::debug!("writing core to {:?} ({} bytes)", tmp_core, buff.len()); let mut core_file = tokio::fs::File::create(&tmp_core).await?; tokio::io::copy(&mut buff.as_slice(), &mut core_file).await?; #[cfg(target_family = "unix")] { std::fs::set_permissions(&tmp_core, std::fs::Permissions::from_mode(0o755))?; } Ok(()) } async fn replace_core(&self) -> anyhow::Result<()> { self.dispatch_state(UpdaterState::Replacing); let current_core = crate::config::Config::verge() .latest() .clash_core .unwrap_or_default(); tracing::debug!("current core: {}", current_core); if current_core == self.core_type { tracing::debug!("stopping core to replace"); CoreManager::global().stop_core().await?; } #[cfg(target_os = "windows")] let target_core = format!("{}.exe", self.core_type); #[cfg(not(target_os = "windows"))] let target_core = self.core_type.clone().to_string(); let core_dir = tauri::utils::platform::current_exe()?; let core_dir = core_dir.parent().ok_or(anyhow!("failed to get core dir"))?; let target_core = core_dir.join(target_core); tracing::debug!("copying core to {:?}", target_core); let tmp_core_path = self.temp_dir.path().join(format!( "{}{}", self.core_type, std::env::consts::EXE_SUFFIX )); match tokio::fs::copy(tmp_core_path.clone(), target_core.clone()).await { Ok(size) => { tracing::debug!("copied core to {:?} ({} bytes)", target_core, size); } Err(err) => { tracing::warn!( "failed to copy core: {}, trying to use elevated permission to copy and override core", err ); let mut target_core_str = target_core.to_str().unwrap().to_string(); if target_core_str.starts_with("\\\\?\\") { target_core_str = target_core_str[4..].to_string(); } tracing::debug!("tmp core path: {:?}", tmp_core_path); tracing::debug!("target core path: {:?}", target_core_str); // 防止 UAC 弹窗堵塞主线程 let status_code = tokio::task::spawn_blocking(move || { #[cfg(target_os = "windows")] { RunasCommand::new("cmd") .args(&[ "/C", "copy", "/Y", tmp_core_path.to_str().unwrap(), &target_core_str, ]) .status() } #[cfg(not(target_os = "windows"))] { RunasCommand::new("cp") .args(&["-f", tmp_core_path.to_str().unwrap(), &target_core_str]) .status() } }) .await??; if !status_code.success() { anyhow::bail!("failed to copy core: {}", status_code); } } }; if current_core == self.core_type { self.dispatch_state(UpdaterState::Restarting); CoreManager::global().run_core().await?; } Ok(()) } pub async fn start(&self) { { let mut inner = self.inner.write(); if !matches!(inner.state, UpdaterState::Idle) { return; } inner.state = UpdaterState::Downloading; } let downloader = self.downloader.clone(); tokio::spawn(async move { if let Err(e) = downloader.start().await { tracing::error!("failed to start downloader: {}", e); } }); let mut rx = self.rx.lock().await; loop { match rx.recv().await { Some(state) => match state { DownloaderState::Downloading => { tracing::debug!("start to download core."); self.dispatch_state(UpdaterState::Downloading); } DownloaderState::Finished => { tracing::debug!("download finished and start to incoming update logic"); if let Err(e) = self.decompress_and_set_permission().await { tracing::error!("failed to decompress and set permission: {}", e); self.dispatch_state(UpdaterState::Failed(e.to_string())); return; } if let Err(e) = self.replace_core().await { tracing::error!("failed to replace core: {}", e); self.dispatch_state(UpdaterState::Failed(e.to_string())); return; } self.dispatch_state(UpdaterState::Done); break; } DownloaderState::Failed(e) => { tracing::error!("download failed: {}", e); self.dispatch_state(UpdaterState::Failed(e)); break; } _ => { tracing::debug!("downloader enter state: {:?}", state); } }, None => { tracing::error!("downloader channel closed"); } } } } pub fn get_report(&self) -> UpdaterSummary { UpdaterSummary { id: self.id, state: self.inner.read().state.clone(), downloader: self.downloader.get_current_status(), } } pub fn get_updater_id(&self) -> usize { self.id } } unsafe impl Send for Updater {} ================================================ FILE: backend/tauri/src/core/updater/mod.rs ================================================ use std::{ collections::HashMap, sync::{Arc, OnceLock}, }; use crate::{ config::nyanpasu::ClashCore, utils::candy::{ReqwestSpeedTestExt, parse_gh_url}, }; use anyhow::{Result, anyhow}; use dashmap::DashMap; use serde::{Deserialize, Serialize}; use shared::{CoreTypeMeta, get_arch}; use specta::Type; use tokio::sync::RwLock; mod instance; mod shared; pub use instance::UpdaterSummary; pub struct UpdaterManager { manifest_version: ManifestVersion, client: reqwest::Client, mirror: Arc>>, instances: Arc>>, } impl Default for UpdaterManager { fn default() -> Self { Self { manifest_version: ManifestVersion::default(), client: crate::utils::candy::get_reqwest_client().unwrap(), mirror: Arc::new(parking_lot::RwLock::new(None)), instances: Arc::new(DashMap::new()), } } } #[derive(Deserialize, Serialize, Clone, Debug)] pub struct ManifestVersion { manifest_version: u64, latest: ManifestVersionLatest, arch_template: ArchTemplate, updated_at: String, } // TODO: manifest v2 should be kebad-case #[derive(Deserialize, Serialize, Clone, Debug, Type)] pub struct ManifestVersionLatest { mihomo: String, mihomo_alpha: String, clash_rs: String, clash_rs_alpha: String, clash_premium: String, } #[derive(Deserialize, Serialize, Default, Clone, Debug)] pub struct ArchTemplate { mihomo: HashMap, mihomo_alpha: HashMap, clash_rs: HashMap, clash_rs_alpha: HashMap, clash_premium: HashMap, } impl Default for ManifestVersion { fn default() -> Self { Self { manifest_version: 0, latest: ManifestVersionLatest::default(), arch_template: ArchTemplate::default(), updated_at: "".to_string(), } } } impl Default for ManifestVersionLatest { fn default() -> Self { Self { mihomo: "".to_string(), mihomo_alpha: "".to_string(), clash_rs: "".to_string(), clash_rs_alpha: "".to_string(), clash_premium: "".to_string(), } } } impl ManifestVersion { pub(self) fn get_matches(&self, core_type: &ClashCore) -> Option<(String, CoreTypeMeta)> { let arch = get_arch().ok()?; match core_type { ClashCore::ClashPremium => Some(( self.arch_template .clash_premium .get(arch)? .clone() .replace("{}", &self.latest.clash_premium), CoreTypeMeta::ClashPremium(self.latest.clash_premium.clone()), )), ClashCore::Mihomo => Some(( self.arch_template .mihomo .get(arch)? .clone() .replace("{}", &self.latest.mihomo), CoreTypeMeta::Mihomo(self.latest.mihomo.clone()), )), ClashCore::MihomoAlpha => Some(( self.arch_template .mihomo_alpha .get(arch)? .clone() .replace("{}", &self.latest.mihomo_alpha), CoreTypeMeta::MihomoAlpha, )), ClashCore::ClashRs => Some(( self.arch_template .clash_rs .get(arch)? .clone() .replace("{}", &self.latest.clash_rs), CoreTypeMeta::ClashRs(self.latest.clash_rs.clone()), )), ClashCore::ClashRsAlpha => Some(( self.arch_template .clash_rs_alpha .get(arch)? .clone() .replace("{}", &self.latest.clash_rs_alpha), CoreTypeMeta::ClashRsAlpha, )), } } } impl UpdaterManager { pub fn new() -> Self { Self::default() } pub fn global() -> &'static RwLock { static INSTANCE: OnceLock> = OnceLock::new(); INSTANCE.get_or_init(|| RwLock::new(UpdaterManager::new())) } pub fn get_latest_versions(&self) -> ManifestVersionLatest { self.manifest_version.latest.clone() } pub fn get_mirror(&self) -> Option { self.mirror.read().clone().map(|(mirror, _)| mirror) } async fn get_latest_version_manifest(&self, mirror: &str) -> Result { let url = parse_gh_url( mirror, "/libnyanpasu/clash-nyanpasu/raw/main/manifest/version.json", )?; log::debug!("{url}"); let res = self.client.get(url).send().await?; let status_code = res.status(); if !status_code.is_success() { anyhow::bail!( "failed to get latest version manifest: response status is {}, expected 200", status_code ); } Ok(res.json::().await?) } pub async fn fetch_latest(&mut self) -> Result<()> { self.mirror_speed_test().await?; let mirror = self.get_mirror().unwrap(); let latest = self.get_latest_version_manifest(&mirror).await?; log::debug!("latest version: {latest:?}"); self.manifest_version = latest; Ok(()) } // TODO: add user-spec mirror support pub async fn mirror_speed_test(&self) -> Result<()> { { let mirror = self.mirror.read(); if let Some((_, timestamp)) = mirror.as_ref() && chrono::Utc::now().timestamp() - (*timestamp as i64) < 3600 { return Ok(()); } } let mirrors = crate::utils::candy::INTERNAL_MIRRORS; let path = "https://github.com/libnyanpasu/clash-nyanpasu/raw/main/manifest/version.json"; let client = crate::utils::candy::get_reqwest_client()?; let results = client.mirror_speed_test(mirrors, path).await?; let (fastest_mirror, speed) = results.first().ok_or(anyhow!("no mirrors found"))?; if speed - 1.0 < 0.0001 { anyhow::bail!("all mirrors are too slow"); } tracing::debug!("fastest mirror: {}, speed: {}", fastest_mirror, speed); { let mut mirror = self.mirror.write(); *mirror = Some(( fastest_mirror.to_string(), chrono::Utc::now().timestamp() as u64, )); } Ok(()) } pub async fn update_core(&mut self, core_type: &ClashCore) -> Result { self.mirror_speed_test().await?; let (artifact, tag) = self .manifest_version .get_matches(core_type) .ok_or(anyhow!("no matches found for core type: {:?}", core_type))?; let mirror = self.get_mirror().unwrap(); let updater = Arc::new( instance::UpdaterBuilder::new() .set_client(self.client.clone()) .set_core_type(*core_type) .set_mirror(mirror) .set_artifact(artifact) .set_tag(tag) .build() .await?, ); let updater_ref = updater.clone(); let updater_id = updater.get_updater_id(); self.instances.insert(updater_id, updater); tokio::spawn(async move { updater_ref.start().await; }); Ok(updater_id) } pub fn inspect_updater(&self, updater_id: usize) -> Option { let updater = self.instances.get(&updater_id)?; let report = updater.get_report(); if matches!( report.state, instance::UpdaterState::Done | instance::UpdaterState::Failed(_) ) { let map = self.instances.clone(); tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(5)).await; map.remove(&updater_id); }); } Some(report) } } ================================================ FILE: backend/tauri/src/core/updater/shared.rs ================================================ pub(super) fn get_arch() -> anyhow::Result<&'static str> { let env = { let arch = std::env::consts::ARCH; let os = std::env::consts::OS; #[cfg(all(target_arch = "arm", target_abi = "eabihf"))] let arch = "armhf"; #[cfg(all(target_arch = "arm", target_abi = ""))] let arch = "armel"; (arch, os) }; match env { ("x86_64", "macos") => Ok("darwin-x64"), ("x86_64", "linux") => Ok("linux-amd64"), ("x86_64", "windows") => Ok("windows-x86_64"), ("i686", "windows") => Ok("windows-i386"), ("i686", "linux") => Ok("linux-i386"), ("armhf", "linux") => Ok("linux-armv7hf"), ("armel", "linux") => Ok("linux-armv7"), ("aarch64", "macos") => Ok("darwin-arm64"), ("aarch64", "linux") => Ok("linux-aarch64"), ("aarch64", "windows") => Ok("windows-arm64"), _ => anyhow::bail!("unsupported platform"), } } pub(super) enum CoreTypeMeta { ClashPremium(String), Mihomo(String), MihomoAlpha, ClashRs(String), ClashRsAlpha, } pub(super) fn get_download_path(core_type: CoreTypeMeta, artifact: &str) -> String { match core_type { CoreTypeMeta::Mihomo(tag) => { format!("MetaCubeX/mihomo/releases/download/{tag}/{artifact}") } CoreTypeMeta::MihomoAlpha => { format!("MetaCubeX/mihomo/releases/download/Prerelease-Alpha/{artifact}") } CoreTypeMeta::ClashRs(tag) => { format!("Watfaq/clash-rs/releases/download/{tag}/{artifact}") } CoreTypeMeta::ClashRsAlpha => { format!("Watfaq/clash-rs/releases/download/latest/{artifact}") } CoreTypeMeta::ClashPremium(tag) => { format!("zhongfly/Clash-premium-backup/releases/download/{tag}/{artifact}") } } } ================================================ FILE: backend/tauri/src/core/win_uwp.rs ================================================ #![cfg(target_os = "windows")] use crate::utils::dirs; use anyhow::{Result, bail}; use deelevate::{PrivilegeLevel, Token}; use runas::Command as RunasCommand; use std::process::Command as StdCommand; pub async fn invoke_uwptools() -> Result<()> { let resource_dir = dirs::app_resources_dir()?; let tool_path = resource_dir.join("enableLoopback.exe"); if !tool_path.exists() { bail!("enableLoopback exe not found"); } let token = Token::with_current_process()?; let level = token.privilege_level()?; match level { PrivilegeLevel::NotPrivileged => RunasCommand::new(tool_path).status()?, _ => StdCommand::new(tool_path).status()?, }; Ok(()) } ================================================ FILE: backend/tauri/src/enhance/advice.rs ================================================ #[allow(unused_imports)] use crate::enhance::{Logs, LogsExt, script::runner::ProcessOutput}; use rust_i18n::t; use serde_yaml::Mapping; // TODO: add more advice for chain pub fn chain_advice(config: &Mapping) -> ProcessOutput { #[allow(unused_mut)] let mut logs = Logs::default(); if config.get("tun").is_some_and(|val| { val.is_mapping() && val .as_mapping() .unwrap() .get("enable") .is_some_and(|val| val.as_bool().unwrap_or(false)) }) { let service_state = crate::core::service::ipc::get_ipc_state(); // show a warning dialog if the user has no permission to enable tun #[cfg(windows)] { use deelevate::{PrivilegeLevel, Token}; let level = { match Token::with_current_process() { Ok(token) => token .privilege_level() .unwrap_or(PrivilegeLevel::NotPrivileged), Err(_) => PrivilegeLevel::NotPrivileged, } }; if level == PrivilegeLevel::NotPrivileged && !service_state.is_connected() { let msg = t!("dialog.warning.enable_tun_with_no_permission"); logs.warn(msg.as_ref()); crate::utils::dialog::warning_dialog(msg.as_ref()); } } // If the core file is not granted the necessary permissions, grant it #[cfg(any(target_os = "macos", target_os = "linux"))] { if !service_state.is_connected() { let core: nyanpasu_utils::core::CoreType = { crate::config::Config::verge() .latest() .clash_core .as_ref() .unwrap_or(&crate::config::nyanpasu::ClashCore::default()) .into() }; if crate::utils::dirs::check_core_permission(&core) .inspect_err(|v| { log::error!(target: "app", "clash core is not granted the necessary permissions, grant it: {v:?}"); }) .is_ok_and(|v| !v && *crate::consts::IS_APPIMAGE) { tracing::warn!("The core file is not granted the necessary permissions, grant it"); let msg = t!("dialog.info.grant_core_permission"); if crate::utils::dialog::ask_dialog(msg.as_ref()) { if let Err(err) = crate::core::manager::grant_permission(&core) { tracing::error!( "Failed to grant permission to the core file: {}", err ); crate::utils::dialog::error_dialog(format!( "failed to grant core permission:\n{:#?}", err )); } } } } } } (Ok(Mapping::new()), logs) } ================================================ FILE: backend/tauri/src/enhance/builtin/clash_rs_comp.lua ================================================ -- compatible with ipv6 decrepation if config["ipv6"] ~= nil then config["ipv6"] = nil if config["dns"] ~= nil and config["dns"]["enabled"] == true then config["dns"]["ipv6"] = true end end -- compatible with allow lan decrepation if config["allow_lan"] == true then config["allow_lan"] = nil config["bind_address"] = "0.0.0.0" end -- compatible with proxies strict port type if config["proxies"] ~= nil and type(config["proxies"]) == "table" then for _, proxy in pairs(config["proxies"]) do if proxy["port"] ~= nil and type(proxy["port"]) == "string" then proxy["port"] = tonumber(proxy["port"]) or error("invalid port: " .. proxy["port"]) end end end return config ================================================ FILE: backend/tauri/src/enhance/builtin/config_fixer.js ================================================ export default function main(params) { if (typeof params['log-level'] === 'boolean') { params['log-level'] = 'debug' } return params } ================================================ FILE: backend/tauri/src/enhance/builtin/meta_guard.js ================================================ export default function main(params) { if (params.mode === 'script') { params.mode = 'rule' } return params } ================================================ FILE: backend/tauri/src/enhance/builtin/meta_hy_alpn.js ================================================ export default function main(params) { if (Array.isArray(params.proxies)) { params.proxies.forEach((p, i) => { if (p.type === 'hysteria' && typeof p.alpn === 'string') { params.proxies[i].alpn = [p.alpn] } }) } return params } ================================================ FILE: backend/tauri/src/enhance/chain.rs ================================================ use crate::{ config::{ Profile, nyanpasu::ClashCore, profile::{ item::prelude::*, item_type::{ProfileItemType, ProfileUid}, }, }, utils::{dirs, help}, }; use enumflags2::{BitFlag, BitFlags}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_yaml::Mapping; use std::fs; use strum::EnumString; use super::Logs; #[derive(Default, Debug, Clone, Serialize, Deserialize, specta::Type)] /// 后处理输出 pub struct PostProcessingOutput { /// 局部链的输出 pub scopes: IndexMap>, /// 全局链的输出 pub global: IndexMap, /// 根据配置进行的分析建议 pub advice: Logs, // TODO: 增加 Meta 信息 } #[derive(Debug, Clone)] pub struct ChainItem { pub uid: String, pub data: ChainTypeWrapper, } #[derive(Debug, Clone)] pub enum ChainTypeWrapper { Merge(Mapping), Script(ScriptWrapper), } impl ChainTypeWrapper { pub fn new_js(data: Data) -> Self { Self::Script(ScriptWrapper(ScriptType::JavaScript, data)) } pub fn new_lua(data: Data) -> Self { Self::Script(ScriptWrapper(ScriptType::Lua, data)) } pub fn new_merge(data: Mapping) -> Self { Self::Merge(data) } } impl TryFrom<&Profile> for ChainTypeWrapper { type Error = anyhow::Error; fn try_from(item: &Profile) -> Result { use anyhow::Context; let r#type = item.kind(); let file = item.file(); let path = dirs::app_profiles_dir() .context("profiles dir not found")? .join(file); if !path.exists() { anyhow::bail!("file not found: {:?}", path); } match r#type { ProfileItemType::Script(ScriptType::JavaScript) => Ok(ChainTypeWrapper::Script( ScriptWrapper(ScriptType::JavaScript, fs::read_to_string(path)?), )), ProfileItemType::Script(ScriptType::Lua) => Ok(ChainTypeWrapper::Script( ScriptWrapper(ScriptType::Lua, fs::read_to_string(path)?), )), ProfileItemType::Merge => Ok(ChainTypeWrapper::Merge(help::read_merge_mapping(&path)?)), _ => anyhow::bail!("unsupported type: {:?}", r#type), } } } impl TryFrom<&Profile> for ChainItem { type Error = anyhow::Error; fn try_from(item: &Profile) -> Result { let uid = item.uid().to_string(); let data = ChainTypeWrapper::try_from(item)?; Ok(Self { uid, data }) } } impl From<&Profile> for Option { fn from(item: &Profile) -> Self { let uid = item.uid().to_string(); let data = ChainTypeWrapper::try_from(item); match data { Err(_) => None, Ok(data) => Some(ChainItem { uid, data }), } } } type Data = String; #[derive(Debug, Clone)] pub struct ScriptWrapper(pub ScriptType, pub Data); #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ChainType { #[serde(rename = "merge")] Merge, #[serde(rename = "script")] Script(ScriptType), } #[derive( Debug, EnumString, Clone, Copy, Serialize, Deserialize, Default, Eq, PartialEq, Hash, specta::Type, )] #[strum(serialize_all = "snake_case")] pub enum ScriptType { #[default] #[serde(rename = "javascript")] #[strum(serialize = "javascript")] JavaScript, #[serde(rename = "lua")] Lua, } impl ChainItem { /// 内建支持一些脚本 pub fn builtin() -> Vec<(BitFlags, ChainItem)> { // meta 的一些处理 let meta_guard = ChainItem::to_script( "verge_meta_guard", ChainTypeWrapper::new_js(include_str!("./builtin/meta_guard.js").to_string()), ); // meta 1.13.2 alpn string 转 数组 let hy_alpn = ChainItem::to_script( "verge_hy_alpn", ChainTypeWrapper::new_js(include_str!("./builtin/meta_hy_alpn.js").to_string()), ); // 修复配置的一些问题 let config_fixer = ChainItem::to_script( "config_fixer", ChainTypeWrapper::new_js(include_str!("./builtin/config_fixer.js").to_string()), ); // 移除或转换 Clash Rs 不支持的字段 let clash_rs_comp = ChainItem::to_script( "clash_rs_comp", ChainTypeWrapper::new_lua(include_str!("./builtin/clash_rs_comp.lua").to_string()), ); vec![ (ClashCore::Mihomo | ClashCore::MihomoAlpha, hy_alpn), (ClashCore::Mihomo | ClashCore::MihomoAlpha, meta_guard), (ClashCore::all(), config_fixer), (ClashCore::ClashRs.into(), clash_rs_comp), ] } pub fn to_script, D: Into>(uid: U, data: D) -> Self { Self { uid: uid.into(), data: data.into(), } } } ================================================ FILE: backend/tauri/src/enhance/field.rs ================================================ use serde_yaml::{Mapping, Value}; use std::collections::HashSet; pub const HANDLE_FIELDS: [&str; 9] = [ "mode", "port", "socks-port", "mixed-port", "allow-lan", "log-level", "ipv6", "secret", "external-controller", ]; pub const DEFAULT_FIELDS: [&str; 5] = [ "proxies", "proxy-groups", "proxy-providers", "rules", "rule-providers", ]; pub const OTHERS_FIELDS: [&str; 31] = [ "dns", "tun", "ebpf", "hosts", "script", "profile", "payload", "tunnels", "auto-redir", "experimental", "interface-name", "routing-mark", "redir-port", "tproxy-port", "iptables", "external-ui", "bind-address", "authentication", "tls", // meta "sniffer", // meta "geox-url", // meta "listeners", // meta "sub-rules", // meta "geodata-mode", // meta "unified-delay", // meta "tcp-concurrent", // meta "enable-process", // meta "find-process-mode", // meta "skip-auth-prefixes", // meta "external-controller-tls", // meta "global-client-fingerprint", // meta ]; pub fn use_clash_fields() -> Vec { DEFAULT_FIELDS .into_iter() .chain(HANDLE_FIELDS) .chain(OTHERS_FIELDS) .map(|s| s.to_string()) .collect() } pub fn use_valid_fields(valid: &[String]) -> Vec { let others = Vec::from(OTHERS_FIELDS); valid .iter() .cloned() .map(|s| s.to_ascii_lowercase()) .filter(|s| others.contains(&s.as_str())) .chain(DEFAULT_FIELDS.iter().map(|s| s.to_string())) .collect() } /// 使用白名单过滤配置字段 pub fn use_whitelist_fields_filter(config: Mapping, filter: &[String], enable: bool) -> Mapping { if !enable { return config; } let mut ret = Mapping::new(); for (key, value) in config.into_iter() { if let Some(key) = key.as_str() && filter.contains(&key.to_string()) { ret.insert(Value::from(key), value); } } ret } pub fn use_lowercase(config: Mapping) -> Mapping { let mut ret = Mapping::new(); for (key, value) in config.into_iter() { if let Some(key_str) = key.as_str() { let mut key_str = String::from(key_str); key_str.make_ascii_lowercase(); // recursive transform the key of the nested mapping let value = if let Value::Mapping(value) = value { Value::Mapping(use_lowercase(value)) } else { value // TODO: maybe should handle other types, Tagged, Sequence, etc. }; ret.insert(Value::from(key_str), value); } } ret } pub fn use_sort(config: Mapping, enable_filter: bool) -> Mapping { let mut ret = Mapping::new(); HANDLE_FIELDS .into_iter() .chain(OTHERS_FIELDS) .chain(DEFAULT_FIELDS) .for_each(|key| { let key = Value::from(key); if let Some(value) = config.get(&key) { ret.insert(key, value.clone()); } }); if !enable_filter { let supported_keys: HashSet<&str> = HANDLE_FIELDS .into_iter() .chain(OTHERS_FIELDS) .chain(DEFAULT_FIELDS) .collect(); let config_keys: HashSet<&str> = config.keys().filter_map(|e| e.as_str()).collect(); config_keys.difference(&supported_keys).for_each(|&key| { let key = Value::from(key); if let Some(value) = config.get(&key) { ret.insert(key, value.clone()); } }); } ret } pub fn use_keys(config: &Mapping) -> Vec { config .iter() .filter_map(|(key, _)| key.as_str()) .map(|s| { let mut s = s.to_string(); s.make_ascii_lowercase(); s }) .collect() } ================================================ FILE: backend/tauri/src/enhance/merge.rs ================================================ use super::{Logs, LogsExt, runner::ProcessOutput}; use mlua::LuaSerdeExt; use serde::de::DeserializeOwned; use serde_yaml::{Mapping, Value}; use tracing_attributes::instrument; // Override recursive, and if the value is sequence, it should be append to the end. fn override_recursive(config: &mut Mapping, key: &Value, data: Value) { if let Some(value) = config.get_mut(key) { if value.is_mapping() { let value = value.as_mapping_mut().unwrap(); let data = data.as_mapping().unwrap(); for (k, v) in data.iter() { override_recursive(value, k, v.clone()); } } else { tracing::trace!("override key: {:#?}", key); *value = data; } } else { tracing::trace!("insert key: {:#?}", key); config.insert(key.clone(), data); } } /// Key should be a.b.c to access the value fn find_field<'a>(config: &'a mut Value, key: &'a str) -> Option<&'a mut Value> { let mut keys = key.split('.').peekable(); let mut value = config; while let Some(k) = keys.next() { if let Some(v) = match k.parse::() { Ok(i) => value.get_mut(i), Err(_) => value.get_mut(k), } { if keys.peek().is_none() { return Some(v); } if v.is_mapping() || v.is_sequence() { value = v } else { return None; } } else { return None; } } None } fn merge_sequence(target: &mut Value, to_merge: &Value, append: bool) { if target.is_sequence() && to_merge.is_sequence() { let target = target.as_sequence_mut().unwrap(); let to_merge = to_merge.as_sequence().unwrap(); if append { target.extend(to_merge.clone()); } else { target.splice(0..0, to_merge.iter().cloned()); } } } fn run_expr(logs: &mut Logs, item: &Value, expr: &str) -> Option { let lua_runtime = match super::script::create_lua_context() { Ok(lua) => lua, Err(e) => { logs.error(e.to_string()); return None; } }; let item = match lua_runtime.to_value(item) { Ok(v) => v, Err(e) => { logs.error(format!("failed to convert item to lua value: {e:#?}")); return None; } }; if let Err(e) = lua_runtime.globals().set("item", item) { logs.error(e.to_string()); return None; } let res = lua_runtime.load(expr).eval::(); match res { Ok(v) => { if let Ok(v) = lua_runtime.from_value(v) { Some(v) } else { logs.error("failed to convert lua value to serde value"); None } } Err(e) => { logs.error(format!("failed to run expr: {e:#?}")); None } } } fn do_filter(logs: &mut Logs, config: &mut Value, field_str: &str, filter: &Value) { let field = match find_field(config, field_str) { Some(field) if !field.is_sequence() => { logs.warn(format!("field is not sequence: {field_str:#?}")); return; } Some(field) => field, None => { logs.warn(format!("field not found: {field_str:#?}")); return; } }; match filter { Value::Sequence(filters) => { for filter in filters { do_filter(logs, config, field_str, filter); } } Value::String(filter) => { let list = field.as_sequence_mut().unwrap(); list.retain(|item| run_expr(logs, item, filter).unwrap_or(false)); } Value::Mapping(filter) if filter.get("when").is_some_and(|v| v.is_string()) && filter.get("expr").is_some_and(|v| v.is_string()) => { let when = filter.get("when").unwrap().as_str().unwrap(); let expr = filter.get("expr").unwrap().as_str().unwrap(); let list = field.as_sequence_mut().unwrap(); list.iter_mut().for_each(|item| { let r#match = run_expr(logs, item, when); if r#match.unwrap_or(false) { let res: Option = run_expr(logs, item, expr); if let Some(res) = res { *item = res; } } }); } Value::Mapping(filter) if filter.get("when").is_some_and(|v| v.is_string()) && filter.contains_key("override") => { let when = filter.get("when").unwrap().as_str().unwrap(); let r#override = filter.get("override").unwrap(); let list = field.as_sequence_mut().unwrap(); list.iter_mut().for_each(|item| { let r#match = run_expr(logs, item, when); if r#match.unwrap_or(false) { *item = r#override.clone(); } }); } Value::Mapping(filter) if filter.get("when").is_some_and(|v| v.is_string()) && filter.get("merge").is_some_and(|v| v.is_mapping()) => { let when = filter.get("when").unwrap().as_str().unwrap(); let merge = filter.get("merge").unwrap().as_mapping().unwrap(); let list = field.as_sequence_mut().unwrap(); list.iter_mut().for_each(|item| { let r#match = run_expr(logs, item, when); if r#match.unwrap_or(false) { for (key, value) in merge.iter() { let item = item.as_mapping_mut().unwrap(); if item.contains_key(key) { override_recursive(item, key, value.clone()); } else { item.insert(key.clone(), value.clone()); } } } }); } Value::Mapping(filter) if filter.get("when").is_some_and(|v| v.is_string()) && filter.get("remove").is_some_and(|v| v.is_sequence()) => { let when = filter.get("when").unwrap().as_str().unwrap(); let remove = filter.get("remove").unwrap().as_sequence().unwrap(); let list = field.as_sequence_mut().unwrap(); list.iter_mut().for_each(|item| { let r#match = run_expr(logs, item, when); if r#match.unwrap_or(false) { remove.iter().for_each(|key| { if key.is_string() && item.is_mapping() { let key_str = key.as_str().unwrap(); // 对 key_str 做一下处理,跳过最后一个元素 let mut keys = key_str.split('.').collect::>(); let last_key = if keys.len() > 1 { keys.pop() } else { None }; let key_str = keys.join("."); match last_key { None => { item.as_mapping_mut().unwrap().remove(key_str); } Some(last_key) => { let field = find_field(item, &key_str); if let Some(field) = field { match field { Value::Mapping(map) => { map.remove(last_key); } Value::Sequence(list) if last_key.parse::().is_ok() => { let index = last_key.parse::().unwrap(); if index < list.len() { list.remove(index); } } _ => { logs.info(format!("invalid key: {last_key:#?}")); } } } } } } else { match item { Value::Sequence(list) if key.is_i64() => { let index = key.as_i64().unwrap(); if index >= 0 && (index as usize) < list.len() { list.remove(index as usize); } } _ => { logs.info(format!("invalid key: {key:#?}")); } } } }); } }); } _ => { logs.warn(format!("invalid filter: {filter:#?}")); } } } #[instrument(skip(merge, config))] pub fn use_merge(merge: &Mapping, mut config: Mapping) -> ProcessOutput { tracing::trace!("original config: {:#?}", config); tracing::trace!("merge: {:#?}", merge); let mut logs = Logs::new(); let mut map = Value::from(config); for (key, value) in merge.iter() { let key_str = key.as_str().unwrap_or_default().to_lowercase(); match key_str { key_str if key_str.starts_with("prepend__") || key_str.starts_with("prepend-") => { if !value.is_sequence() { logs.warn(format!("prepend value is not sequence: {key_str:#?}")); continue; } let key_str = key_str.replace("prepend__", "").replace("prepend-", ""); let field = find_field(&mut map, &key_str); match field { Some(field) => { if field.is_sequence() { merge_sequence(field, value, false); } else { logs.warn(format!("field is not sequence: {key_str:#?}")); } } None => { logs.warn(format!("field not found: {key_str:#?}")); } } continue; } key_str if key_str.starts_with("append__") || key_str.starts_with("append-") => { if !value.is_sequence() { logs.warn(format!("append value is not sequence: {key_str:#?}")); continue; } let key_str = key_str.replace("append__", "").replace("append-", ""); let field = find_field(&mut map, &key_str); match field { Some(field) => { if field.is_sequence() { merge_sequence(field, value, true); } else { logs.warn(format!("field is not sequence: {key_str:#?}")); } } None => { logs.warn(format!("field not found: {key_str:#?}")); } } continue; } key_str if key_str.starts_with("override__") => { let key_str = key_str.replace("override__", ""); let field = find_field(&mut map, &key_str); match field { Some(field) => { *field = value.clone(); } None => { logs.warn(format!("field not found: {key_str:#?}")); } } continue; } key_str if key_str.starts_with("filter__") => { let key_str = key_str.replace("filter__", ""); do_filter(&mut logs, &mut map, &key_str, value); continue; } _ => { override_recursive(map.as_mapping_mut().unwrap(), key, value.clone()); } } } config = map.as_mapping().unwrap().clone(); tracing::trace!("merged config: {:#?}", config); (Ok(config), logs) } mod tests { #[allow(unused_imports)] use pretty_assertions::{assert_eq, assert_ne}; #[test] fn test_find_field() { let config = r" a: b: c: - 111 - 222 "; let mut config = serde_yaml::from_str::(config).unwrap(); eprintln!("{config:#?}"); let field = super::find_field(&mut config, "a.b.c"); assert!(field.is_some(), "a.b.c should be found"); let field = super::find_field(&mut config, "a.b"); assert!(field.is_some(), "a.b should be found"); let field = super::find_field(&mut config, "a.b.c.0"); assert!(field.is_some(), "a.b.c.0 should be found"); let field = super::find_field(&mut config, "a.b.c.1"); assert!(field.is_some(), "a.b.c.1 should be found"); let field = super::find_field(&mut config, "a.b.c.2"); assert!(field.is_none(), "a.b.c.2 should not be found"); } #[test] fn test_merge_append() { let merge = r" append-proxies: - 666 append__proxies: - 555 append__a.b.c: - 12321 - 44444 append__nothing: - nothing "; let config = r" proxies: - 123 a: b: c: - 111 - 222 "; let expected = r" proxies: - 123 - 666 - 555 a: b: c: - 111 - 222 - 12321 - 44444 "; let merge = serde_yaml::from_str::(merge).unwrap(); let config = serde_yaml::from_str::(config).unwrap(); let (result, logs) = super::use_merge(&merge, config); eprintln!("{logs:#?}\n\n{result:#?}"); let expected = serde_yaml::from_str::(expected).unwrap(); assert_eq!(logs.len(), 1); // field not found: nothing assert_eq!(result.unwrap(), expected); } #[test] fn test_prepend() { let merge = r" prepend-proxies: - 666 prepend__proxies: - 555 prepend__a.b.c: - 12321 - 44444 prepend__nothing: - nothing "; let config = r" proxies: - 123 a: b: c: - 111 - 222 "; let expected = r" proxies: - 555 - 666 - 123 a: b: c: - 12321 - 44444 - 111 - 222 "; let merge = serde_yaml::from_str::(merge).unwrap(); let config = serde_yaml::from_str::(config).unwrap(); let (result, logs) = super::use_merge(&merge, config); eprintln!("{logs:#?}\n\n{result:#?}"); let expected = serde_yaml::from_str::(expected).unwrap(); assert_eq!(logs.len(), 1); // field not found: nothing assert_eq!(result.unwrap(), expected); } #[test] fn test_override() { let merge = r" override__proxies: - 555 override__a.b.c: - 12321 - 44444 override__nothing: - nothing override__a.f.0: wow "; let config = r" proxies: - 123 a: b: c: - 111 - 222 f: - 444 "; let expected = r" proxies: - 555 a: b: c: - 12321 - 44444 f: - wow "; let merge = serde_yaml::from_str::(merge).unwrap(); let config = serde_yaml::from_str::(config).unwrap(); let (result, logs) = super::use_merge(&merge, config); eprintln!("{logs:#?}\n\n{result:#?}"); let expected = serde_yaml::from_str::(expected).unwrap(); assert_eq!(logs.len(), 1); // field not found: nothing assert_eq!(result.unwrap(), expected); } #[test] fn test_filter_string() { let merge = r" filter__proxies: | type(item) == 'table' and (item.type == 'ss' or item.type == 'hysteria2') filter__wow: | item == 'wow' "; let config = r#" wow: 123 proxies: - 123 - 555 - name: "hysteria2" type: hysteria2 server: server.com port: 443 ports: 443-8443 password: yourpassword up: "30 Mbps" down: "200 Mbps" obfs: salamander # 默认为空,如果填写则开启obfs,目前仅支持salamander obfs-password: yourpassword sni: server.com skip-cert-verify: false fingerprint: xxxx alpn: - h3 ca: "./my.ca" ca-str: "xyz" - name: "hysteria2" type: ss server: server.com port: 443 ports: 443-8443 password: yourpassword up: "30 Mbps" down: "200 Mbps" obfs: salamander # 默认为空,如果填写则开启obfs,目前仅支持salamander obfs-password: yourpassword sni: server.com skip-cert-verify: false fingerprint: xxxx alpn: - h3 ca: "./my.ca" ca-str: "xyz" "#; let expected = r#" wow: 123 proxies: - name: hysteria2 type: hysteria2 server: server.com port: 443 ports: 443-8443 password: yourpassword up: "30 Mbps" down: "200 Mbps" obfs: salamander obfs-password: yourpassword sni: server.com skip-cert-verify: false fingerprint: xxxx alpn: - h3 ca: "./my.ca" ca-str: "xyz" - name: hysteria2 type: ss server: server.com port: 443 ports: 443-8443 password: yourpassword up: "30 Mbps" down: "200 Mbps" obfs: salamander obfs-password: yourpassword sni: server.com skip-cert-verify: false fingerprint: xxxx alpn: - h3 ca: "./my.ca" ca-str: "xyz" "#; let merge = serde_yaml::from_str::(merge).unwrap(); let config = serde_yaml::from_str::(config).unwrap(); let (result, logs) = super::use_merge(&merge, config); eprintln!("{logs:#?}\n\n{result:#?}"); assert!(logs.len() == 1, "filter_wow should not work"); let expected = serde_yaml::from_str::(expected).unwrap(); assert_eq!(result.unwrap(), expected); } #[test] fn test_filter_when_and_expr() { let merge = r" filter__proxies: - when: | type(item) == 'table' and (item.type == 'ss' or item.type == 'hysteria2') expr: | item filter__proxy-groups: - when: | item.name == 'Spotify' expr: | item.icon = 'https://raw.githubusercontent.com/Koolson/Qure/master/IconSet/Color/Spotify.png' return item "; let config = r#"proxy-groups: - name: Spotify type: select proxies: - Proxies - DIRECT - HK - JP - SG - TW - US - name: Steam type: select proxies: - Proxies - DIRECT - HK - JP - SG - TW - US - name: Telegram type: select proxies: - Proxies - HK - JP - SG - TW - US"#; let expected = r#"proxy-groups: - name: Spotify icon: https://raw.githubusercontent.com/Koolson/Qure/master/IconSet/Color/Spotify.png type: select proxies: - Proxies - DIRECT - HK - JP - SG - TW - US - name: Steam type: select proxies: - Proxies - DIRECT - HK - JP - SG - TW - US - name: Telegram type: select proxies: - Proxies - HK - JP - SG - TW - US"#; let merge = serde_yaml::from_str::(merge).unwrap(); let config = serde_yaml::from_str::(config).unwrap(); let (result, logs) = super::use_merge(&merge, config); eprintln!("{logs:#?}\n\n{result:#?}"); assert_eq!(logs.len(), 1); let expected = serde_yaml::from_str::(expected).unwrap(); assert_eq!(result.unwrap(), expected); } #[test] fn test_filter_when_and_override() { let merge = r" filter__proxies: - when: | type(item) == 'table' and (item.type == 'ss' or item.type == 'hysteria2') override: OVERRIDDEN "; let config = r#" proxies: - 123 - 555 - name: "hysteria2" type: hysteria2 server: server.com port: 443 ports: 443-8443 password: yourpassword up: "30 Mbps" down: "200 Mbps" obfs: salamander # 默认为空,如果填写则开启obfs,目前仅支持salamander obfs-password: yourpassword sni: server.com skip-cert-verify: false fingerprint: xxxx alpn: - h3 ca: "./my.ca" ca-str: "xyz" - name: "hysteria2" type: ss server: server.com port: 443 ports: 443-8443 password: yourpassword up: "30 Mbps" down: "200 Mbps" obfs: salamander # 默认为空,如果填写则开启obfs,目前仅支持salamander obfs-password: yourpassword sni: server.com skip-cert-verify: false fingerprint: xxxx alpn: - h3 ca: "./my.ca" ca-str: "xyz" "#; let expected = r#" proxies: - 123 - 555 - OVERRIDDEN - OVERRIDDEN "#; let merge = serde_yaml::from_str::(merge).unwrap(); let config = serde_yaml::from_str::(config).unwrap(); let (result, logs) = super::use_merge(&merge, config); eprintln!("{logs:#?}\n\n{result:#?}"); assert_eq!(logs.len(), 0); let expected = serde_yaml::from_str::(expected).unwrap(); assert_eq!(result.unwrap(), expected); } #[test] fn test_filter_when_and_merge() { let merge = r" filter__proxy-groups: when: | item.name == 'Spotify' merge: icon: 'https://raw.githubusercontent.com/Koolson/Qure/master/IconSet/Color/Spotify.png' filter__wow: when: | item == 'wow' merge: item: 'wow'"; let config = r#"proxy-groups: - name: Spotify type: select proxies: - Proxies - DIRECT - HK - JP - SG - TW - US - name: Steam type: select proxies: - Proxies - DIRECT - HK - JP - SG - TW - US - name: Telegram type: select proxies: - Proxies - HK - JP - SG - TW - US"#; let expected = r#"proxy-groups: - name: Spotify type: select icon: https://raw.githubusercontent.com/Koolson/Qure/master/IconSet/Color/Spotify.png proxies: - Proxies - DIRECT - HK - JP - SG - TW - US - name: Steam type: select proxies: - Proxies - DIRECT - HK - JP - SG - TW - US - name: Telegram type: select proxies: - Proxies - HK - JP - SG - TW - US"#; let merge = serde_yaml::from_str::(merge).unwrap(); let config = serde_yaml::from_str::(config).unwrap(); let (result, logs) = super::use_merge(&merge, config); eprintln!("{logs:#?}\n\n{result:#?}"); assert_eq!(logs.len(), 1); let expected = serde_yaml::from_str::(expected).unwrap(); assert_eq!(result.unwrap(), expected); } #[test] fn test_filter_when_and_remove() { let merge = r" filter__proxies: when: | type(item) == 'table' and (item.type == 'ss' or item.type == 'hysteria2') remove: - name - type filter__list: # note that Lua table index starts from 1 when: | item[1] == 123 remove: - 0 filter__wow: when: | item.flag == true remove: - test.1 - good.should_remove "; let config = r#" wow: - test: - 123 - 456 flag: true - good: should_remove: true should_not_remove: true flag: true list: - - 123 - 456 - 222 - - 123 - 456 - 222 proxies: - 123 - 555 - name: "hysteria2" type: hysteria2 server: server.com port: 443 ports: 443-8443 password: yourpassword up: "30 Mbps" down: "200 Mbps" obfs: salamander # 默认为空,如果填写则开启obfs,目前仅支持salamander obfs-password: yourpassword sni: server.com skip-cert-verify: false fingerprint: xxxx alpn: - h3 ca: "./my.ca" ca-str: "xyz" - name: "hysteria2" type: ss server: server.com port: 443 ports: 443-8443 password: yourpassword up: "30 Mbps" down: "200 Mbps" obfs: salamander # 默认为空,如果填写则开启obfs,目前仅支持salamander obfs-password: yourpassword sni: server.com skip-cert-verify: false fingerprint: xxxx alpn: - h3 ca: "./my.ca" ca-str: "xyz" "#; let expected = r#" wow: - test: - 123 flag: true - good: should_not_remove: true flag: true list: - - 456 - 222 - - 456 - 222 proxies: - 123 - 555 - server: server.com port: 443 ports: 443-8443 password: yourpassword up: "30 Mbps" down: "200 Mbps" obfs: salamander obfs-password: yourpassword sni: server.com skip-cert-verify: false fingerprint: xxxx alpn: - h3 ca: "./my.ca" ca-str: "xyz" - server: server.com port: 443 ports: 443-8443 password: yourpassword up: "30 Mbps" down: "200 Mbps" obfs: salamander obfs-password: yourpassword sni: server.com skip-cert-verify: false fingerprint: xxxx alpn: - h3 ca: "./my.ca" ca-str: "xyz" "#; let merge = serde_yaml::from_str::(merge).unwrap(); let config = serde_yaml::from_str::(config).unwrap(); let (result, logs) = super::use_merge(&merge, config); eprintln!("{logs:#?}\n\n{result:#?}"); assert_eq!(logs.len(), 0); let expected = serde_yaml::from_str::(expected).unwrap(); assert_eq!(result.unwrap(), expected); } #[test] fn test_filter_sequence() { let merge = r" filter__proxy-groups: - when: | item.name == 'Spotify' merge: icon: 'https://raw.githubusercontent.com/Koolson/Qure/master/IconSet/Color/Spotify.png' - when: | item.name == 'Steam' merge: icon: 'https://raw.githubusercontent.com/Koolson/Qure/master/IconSet/Color/Steam.png' - when: | item.name == 'Telegram' merge: icon: 'https://raw.githubusercontent.com/Koolson/Qure/master/IconSet/Color/Telegram.png' "; let config = r#"proxy-groups: - name: Spotify type: select proxies: - Proxies - DIRECT - HK - JP - SG - TW - US - name: Steam type: select proxies: - Proxies - DIRECT - HK - JP - SG - TW - US - name: Telegram type: select proxies: - Proxies - HK - JP - SG - TW - US"#; let expected = r#"proxy-groups: - name: Spotify type: select icon: https://raw.githubusercontent.com/Koolson/Qure/master/IconSet/Color/Spotify.png proxies: - Proxies - DIRECT - HK - JP - SG - TW - US - name: Steam type: select icon: https://raw.githubusercontent.com/Koolson/Qure/master/IconSet/Color/Steam.png proxies: - Proxies - DIRECT - HK - JP - SG - TW - US - name: Telegram type: select icon: https://raw.githubusercontent.com/Koolson/Qure/master/IconSet/Color/Telegram.png proxies: - Proxies - HK - JP - SG - TW - US"#; let merge = serde_yaml::from_str::(merge).unwrap(); let config = serde_yaml::from_str::(config).unwrap(); let (result, logs) = super::use_merge(&merge, config); eprintln!("{logs:#?}\n\n{result:#?}"); assert_eq!(logs.len(), 0); let expected = serde_yaml::from_str::(expected).unwrap(); assert_eq!(result.unwrap(), expected); } #[test] fn test_override_recursive() { let merge = r" a: b: c: d: 22323 f: - wow e: ttt "; let config = r#" a: b: c: d: 123 f: - 123 - 456 t: will preserve "#; let merge = serde_yaml::from_str::(merge).unwrap(); let config = serde_yaml::from_str::(config).unwrap(); let (result, logs) = super::use_merge(&merge, config); eprintln!("{logs:#?}\n\n{result:#?}"); assert_eq!(logs.len(), 0); let expected = r#" a: b: c: d: 22323 f: - wow t: will preserve e: ttt "#; let expected = serde_yaml::from_str::(expected).unwrap(); assert_eq!(result.unwrap(), expected); } } ================================================ FILE: backend/tauri/src/enhance/mod.rs ================================================ mod advice; mod chain; mod field; mod merge; mod script; mod tun; mod utils; pub use self::chain::ScriptType; use self::{chain::*, field::*, merge::*, script::*, tun::*}; use crate::config::{Config, ProfileMetaGetter, nyanpasu::ClashCore}; pub use chain::PostProcessingOutput; use futures::future::join_all; use indexmap::IndexMap; use serde_yaml::{Mapping, Value}; use std::collections::HashSet; pub use utils::{Logs, LogsExt}; use utils::{merge_profiles, process_chain}; /// Enhance mode /// 返回最终配置、该配置包含的键、和script执行的结果 pub async fn enhance() -> (Mapping, Vec, PostProcessingOutput) { // config.yaml 的配置 let clash_config = { Config::clash().latest().0.clone() }; let (clash_core, enable_tun, enable_builtin, enable_filter) = { let verge = Config::verge(); let verge = verge.latest(); ( verge.clash_core, verge.enable_tun_mode.unwrap_or(false), verge.enable_builtin_enhanced.unwrap_or(true), verge.enable_clash_fields.unwrap_or(true), ) }; // 从profiles里拿东西 let (profiles, profile_chain, global_chain, valid) = { let profiles = Config::profiles(); let profiles = profiles.latest(); let profile_chain_mapping = profiles .get_current() .iter() .filter_map(|uid| profiles.get_item(uid).ok()) .map(|item| { ( item.uid().to_string(), match item { profile if profile.is_local() => { let profile = profile.as_local().unwrap(); utils::convert_uids_to_scripts(&profiles, &profile.chain) } profile if profile.is_remote() => { let profile = profile.as_remote().unwrap(); utils::convert_uids_to_scripts(&profiles, &profile.chain) } _ => vec![], }, ) }) .collect::>(); let current_mappings = profiles .current_mappings() .unwrap_or_default() .into_iter() .map(|(k, v)| (k.to_string(), v)) .collect::>(); let global_chain = utils::convert_uids_to_scripts(&profiles, &profiles.chain); let valid = profiles.valid.clone(); (current_mappings, profile_chain_mapping, global_chain, valid) }; let mut postprocessing_output = PostProcessingOutput::default(); let valid = use_valid_fields(&valid); // 执行 scoped chain let profiles_outputs = join_all(profiles.into_iter().map(|(uid, mapping)| async { let chain = profile_chain.get(&uid).map_or(&[] as &[_], |v| v); let output = process_chain(mapping, chain).await; (uid, output) })) .await; let mut profiles = IndexMap::new(); for (uid, (config, output)) in profiles_outputs { postprocessing_output.scopes.insert(uid.to_string(), output); profiles.insert(uid.to_string(), config); } // 合并多个配置 // TODO: 此步骤需要提供针对每个配置的 Meta 信息 // TODO: 需要支持自定义合并逻辑 let config = merge_profiles(profiles); // 执行全局 chain let (mut config, global_chain_output) = process_chain(config, &global_chain).await; postprocessing_output.global = global_chain_output; // 记录当前配置包含的键 let mut exists_keys = use_keys(&config); config = use_whitelist_fields_filter(config, &valid, enable_filter); // 合并默认的config clash_config .iter() // only guarded fields should be overwritten .filter(|(k, _)| HANDLE_FIELDS.contains(&k.as_str().unwrap_or_default())) .for_each(|(key, value)| { config.insert(key.to_owned(), value.clone()); }); let clash_fields = use_clash_fields(); // 内建脚本最后跑 if enable_builtin { let mut script_runner = RunnerManager::new(); for item in ChainItem::builtin() .into_iter() .filter(|(s, _)| s.contains(*clash_core.as_ref().unwrap_or(&ClashCore::default()))) .map(|(_, c)| c) { log::debug!(target: "app", "run builtin script {}", item.uid); if let ChainTypeWrapper::Script(script) = item.data { let (res, _) = script_runner .process_script(&script, config.to_owned()) .await; match res { Ok(res_config) => { config = res_config; } Err(err) => { log::error!(target: "app", "builtin script error `{err:?}`"); } } } } } config = use_whitelist_fields_filter(config, &clash_fields, enable_filter); config = use_tun(config, enable_tun); config = use_include_all_proxy_groups(config); config = use_cache(config); config = use_sort(config, enable_filter); let (_, logs) = advice::chain_advice(&config); postprocessing_output.advice = logs; let mut exists_set = HashSet::new(); exists_set.extend(exists_keys.into_iter().filter(|s| clash_fields.contains(s))); exists_keys = exists_set.into_iter().collect(); (config, exists_keys, postprocessing_output) } /// Process proxy groups with include-all field fn use_include_all_proxy_groups(mut config: Mapping) -> Mapping { // Collect all proxy names from proxies and proxy-providers first (before mutable borrow) let mut all_proxy_names = Vec::new(); // Collect from proxies section if let Some(proxies_value) = config.get("proxies") { if let Some(proxies_seq) = proxies_value.as_sequence() { for proxy in proxies_seq { if let Some(proxy_map) = proxy.as_mapping() { if let Some(name_value) = proxy_map.get("name") { if let Some(name) = name_value.as_str() { all_proxy_names.push(name.to_string()); } } } } } } // Collect from proxy-providers section if let Some(providers_value) = config.get("proxy-providers") { if let Some(providers_map) = providers_value.as_mapping() { for (provider_name, _) in providers_map { if let Some(name) = provider_name.as_str() { all_proxy_names.push(name.to_string()); } } } } // Check if we have proxy-groups field if let Some(proxy_groups_value) = config.get_mut("proxy-groups") { if let Some(proxy_groups) = proxy_groups_value.as_sequence_mut() { // Process each proxy group for group in proxy_groups.iter_mut() { if let Some(group_map) = group.as_mapping_mut() { // Check if this group has include-all: true if let Some(include_all_value) = group_map.get("include-all") { if include_all_value.as_bool().unwrap_or(false) { // Check if this is the GLOBAL group or any group with include-all if let Some(name_value) = group_map.get("name") { let _name = name_value.as_str().unwrap_or(""); // Preserve existing proxies let mut existing_proxies = Vec::new(); if let Some(existing) = group_map.get("proxies") { if let Some(existing_seq) = existing.as_sequence() { for proxy in existing_seq { if let Some(proxy_str) = proxy.as_str() { existing_proxies.push(proxy_str.to_string()); } } } } // Create new proxies list with all proxies let mut new_proxies = Vec::new(); // Add all collected proxy names for proxy_name in &all_proxy_names { new_proxies.push(Value::String(proxy_name.clone())); } // Add existing proxies that aren't in the all list for existing_proxy in existing_proxies { if !all_proxy_names.contains(&existing_proxy) { new_proxies.push(Value::String(existing_proxy)); } } // Update the proxies field group_map.insert( Value::String("proxies".to_string()), Value::Sequence(new_proxies), ); // Remove the include-all field since it's been processed group_map.remove("include-all"); } } } } } } } config } fn use_cache(mut config: Mapping) -> Mapping { if !config.contains_key("profile") { tracing::debug!("Don't detect profile, set default profile for memorized profile"); let mut profile = Mapping::new(); profile.insert("store-selected".into(), true.into()); // Disable fake-ip store, due to the slow speed. // each dns query should indirect to the file io, which is very very slow. profile.insert("store-fake-ip".into(), false.into()); config.insert("profile".into(), profile.into()); } config } #[cfg(test)] mod tests { use super::*; #[test] fn test_use_cache() { let config = Mapping::new(); dbg!(&config); let config = use_cache(config); dbg!(&config); assert!(config.contains_key("profile")); let mut config = Mapping::new(); let mut profile = Mapping::new(); profile.insert("do-not-override".into(), true.into()); config.insert("profile".into(), profile.into()); dbg!(&config); let config = use_cache(config); dbg!(&config); assert!(config.contains_key("profile")); assert!( config .get("profile") .unwrap() .as_mapping() .unwrap() .contains_key("do-not-override") ); } #[test] fn test_use_include_all_proxy_groups() { let yaml = r#" proxies: - name: "Proxy1" type: ss server: server.com port: 443 - name: "Proxy2" type: vmess server: server2.com port: 8080 proxy-providers: provider1: type: http url: "http://example.com/provider1.yaml" interval: 3600 provider2: type: file path: ./providers/provider2.yaml proxy-groups: - name: GLOBAL type: select include-all: true proxies: - DIRECT - name: Proxies type: select proxies: - DIRECT "#; let config: Mapping = serde_yaml::from_str(yaml).unwrap(); let result = use_include_all_proxy_groups(config); // Check that GLOBAL group now contains all proxies let proxy_groups = result.get("proxy-groups").unwrap().as_sequence().unwrap(); let global_group = proxy_groups .iter() .find(|group| { if let Some(mapping) = group.as_mapping() { if let Some(name) = mapping.get("name") { return name.as_str().unwrap() == "GLOBAL"; } } false }) .unwrap(); // Check that include-all field was removed assert!( global_group .as_mapping() .unwrap() .get("include-all") .is_none() ); let global_proxies = global_group .as_mapping() .unwrap() .get("proxies") .unwrap() .as_sequence() .unwrap(); let proxy_names: Vec<&str> = global_proxies.iter().map(|p| p.as_str().unwrap()).collect(); // Should contain all proxies from the config assert!(proxy_names.contains(&"Proxy1")); assert!(proxy_names.contains(&"Proxy2")); assert!(proxy_names.contains(&"provider1")); assert!(proxy_names.contains(&"provider2")); // Should still contain original proxies assert!(proxy_names.contains(&"DIRECT")); } } ================================================ FILE: backend/tauri/src/enhance/script/js.rs ================================================ use super::runner::{ProcessOutput, Runner, wrap_result}; use crate::enhance::utils::{LogSpan, Logs, LogsExt}; use anyhow::Context as _; use async_trait::async_trait; use boa_engine::{ Context, JsError, JsNativeError, JsValue, Source, builtins::promise::PromiseState, job::SimpleJobExecutor, js_string, module::{Module, SimpleModuleLoader}, property::Attribute, }; use boa_utils::{ Console, module::{combine::CombineModuleLoader, http::HttpModuleLoader}, }; use once_cell::sync::Lazy; use serde_yaml::Mapping; use std::{ cell::RefCell, path::{Path, PathBuf}, rc::Rc, time::Duration, }; use tracing_attributes::instrument; use utils::wrap_script_if_not_esm; use std::result::Result as StdResult; type Result = StdResult; static CUSTOM_SCRIPTS_DIR: Lazy = Lazy::new(|| { let path = crate::utils::dirs::app_data_dir().unwrap().join("scripts"); if !path.exists() { std::fs::create_dir_all(&path).unwrap(); } dunce::canonicalize(path).unwrap() }); // define a JsRunnerError due to boa engine error is not Send #[derive(Debug, thiserror::Error)] pub enum JsRunnerError { #[error("JsError: {0}")] JsError(#[from] boa_engine::JsError), #[error("JsNativeError: {0}")] JsNativeError(#[from] boa_engine::JsNativeError), #[error("TryNativeError: {0}")] TryNativeError(#[from] boa_engine::error::TryNativeError), #[error("IoError: {0}")] IoError(#[from] std::io::Error), #[error("Other: {0}")] Other(String), } pub struct BoaConsoleLogger(Logs); impl boa_utils::Logger for BoaConsoleLogger { type Item = boa_utils::LogMessage; fn log(&mut self, msg: boa_utils::LogMessage, _: &Console) { match msg { boa_utils::LogMessage::Log(msg) => self.0.log(msg), boa_utils::LogMessage::Info(msg) => self.0.info(msg), boa_utils::LogMessage::Warn(msg) => self.0.warn(msg), boa_utils::LogMessage::Error(msg) => self.0.error(msg), } } #[inline] fn take(&mut self) -> Vec { std::mem::take(&mut self.0) .into_iter() .map(|(span, msg)| match span { LogSpan::Log => boa_utils::LogMessage::Log(msg), LogSpan::Info => boa_utils::LogMessage::Info(msg), LogSpan::Warn => boa_utils::LogMessage::Warn(msg), LogSpan::Error => boa_utils::LogMessage::Error(msg), }) .collect() } } impl BoaConsoleLogger { pub fn take(&mut self) -> Logs { std::mem::take(&mut self.0) } } #[inline] fn take_console_logs() -> Logs { let logs = boa_utils::inspect_logger(|logger| logger.take()); logs.into_iter() .map(|msg| match msg { boa_utils::LogMessage::Log(msg) => (LogSpan::Log, msg), boa_utils::LogMessage::Info(msg) => (LogSpan::Info, msg), boa_utils::LogMessage::Warn(msg) => (LogSpan::Warn, msg), boa_utils::LogMessage::Error(msg) => (LogSpan::Error, msg), }) .collect() } pub struct JSRunner; // boa engine is single-thread runner so that we can not define it in runner trait directly pub struct BoaRunner { ctx: Rc>, simple_loader: Rc, } impl BoaRunner { pub fn try_new() -> Result { let cache_dir = crate::utils::dirs::cache_dir().unwrap(); let loader = Rc::new(CombineModuleLoader::new( SimpleModuleLoader::new(CUSTOM_SCRIPTS_DIR.as_path())?, HttpModuleLoader::new(cache_dir, Duration::from_secs(60 * 60 * 24 * 30)), )); let simple_loader = loader.clone_simple(); let queue = Rc::new(SimpleJobExecutor::new()); let context = Context::builder() .job_executor(queue) .module_loader(loader.clone()) .build()?; Ok(Self { ctx: Rc::new(RefCell::new(context)), simple_loader, }) } pub fn setup_console(&self, logger: BoaConsoleLogger) -> Result<()> { let ctx = &mut self.ctx.borrow_mut(); // it not concurrency safe. we should move to new boa_runtime console when it is ready for custom logger boa_utils::set_logger(Box::new(logger) as Box); let console = Console::init(ctx); ctx.register_global_property(js_string!(Console::NAME), console, Attribute::all())?; Ok(()) } pub fn get_ctx(&self) -> Rc> { self.ctx.clone() } /// Parse a module to prepare for execution. pub fn parse_module(&self, source: &str, name: &str) -> Result { let ctx = &mut self.ctx.borrow_mut(); let path_name = format!("./{name}.mjs"); let source = Source::from_reader(source.as_bytes(), Some(Path::new(&path_name))); // Can also pass a `Some(realm)` if you need to execute the module in another realm. let module = Module::parse(source, None, ctx)?; // Don't forget to insert the parsed module into the loader itself, since the root module // is not automatically inserted by the `ModuleLoader::load_imported_module` impl. // // Simulate as if the "fake" module is located in the modules root, just to ensure that // the loader won't double load in case someone tries to import "./main.mjs". self.simple_loader .insert(CUSTOM_SCRIPTS_DIR.join(&path_name), module.clone()); Ok(module) } pub fn execute_module(&self, module: &Module) -> Result<()> { let ctx = &mut self.ctx.borrow_mut(); let promise_result = module.load_link_evaluate(ctx); // Very important to push forward the job queue after queueing promises. let _ = ctx.run_jobs(); // Checking if the final promise didn't return an error. for i in 0..20 { match promise_result.state() { PromiseState::Pending => { if i == 19 { return Err(JsRunnerError::Other("module didn't execute!".to_string())); } } PromiseState::Fulfilled(v) => { assert_eq!(v, JsValue::undefined()); break; } PromiseState::Rejected(err) => { return Err(JsError::from_opaque(err).try_native(ctx)?.into()); } } std::thread::sleep(std::time::Duration::from_millis(100)); } Ok(()) } } #[async_trait] impl Runner for JSRunner { #[instrument] fn try_new() -> Result { Ok(JSRunner) } async fn process(&self, mapping: Mapping, path: &str) -> ProcessOutput { let content = wrap_result!( tokio::fs::read_to_string(path) .await .context("failed to read the script file") ); self.process_honey(mapping, &content).await } async fn process_honey(&self, mapping: Mapping, script: &str) -> ProcessOutput { let script = wrap_result!(wrap_script_if_not_esm(script)); let hash = crate::utils::help::get_uid("script"); let path = CUSTOM_SCRIPTS_DIR.join(format!("{hash}.mjs")); wrap_result!( tokio::fs::write(&path, script.as_bytes()) .await .context("failed to write the script file") ); // boa engine is single-thread runner so that we can use it in tokio::task::spawn_blocking let res = tokio::task::spawn_blocking(move || { let wrapped_fn = move || { let mut logger = BoaConsoleLogger(Logs::new()); let boa_runner = wrap_result!(BoaRunner::try_new(), logger.take()); wrap_result!(boa_runner.setup_console(logger), take_console_logs()); let config = wrap_result!( serde_json::to_string(&mapping) .map_err(|e| { std::io::Error::new(std::io::ErrorKind::InvalidData, e) }), take_console_logs() ); let config = serde_json::to_string(&config).unwrap(); // escape the string let execute_module = format!( r#"import process from "./{hash}.mjs"; let config = JSON.parse({config}); export let result = JSON.stringify(await process(config)); "# ); // let process_module = wrap_result!( // boa_runner.parse_module(&script, "process").map_err(|e| { // logs.error(format!("failed to parse the process module: {:?}", e)); // e // }), // logs // ); // wrap_result!(boa_runner.execute_module(&process_module)); let main_module = wrap_result!( boa_runner.parse_module(&execute_module, "main"), take_console_logs() ); wrap_result!(boa_runner.execute_module(&main_module)); let ctx = boa_runner.get_ctx(); let namespace = main_module.namespace(&mut ctx.borrow_mut()); let result = wrap_result!( namespace.get(js_string!("result"), &mut ctx.borrow_mut()), take_console_logs() ); let result = wrap_result!( result .as_string() .ok_or_else(|| JsNativeError::typ().with_message("Expected string")) .map(|str| str.to_std_string_escaped()), take_console_logs() ); let mapping = wrap_result!( serde_json::from_str(&result) .map_err(|e| { std::io::Error::new(std::io::ErrorKind::InvalidData, e) }), take_console_logs() ); (Ok::(mapping), take_console_logs()) }; let (res, logs) = wrapped_fn(); match res { Ok(mapping) => (Ok(mapping), logs), Err(e) => { tracing::error!("error: {:?}", e); (Err(anyhow::anyhow!("{:?}", e)), logs) } } }) .await; let _ = tokio::fs::remove_file(&path).await; match res { Ok(output) => output, Err(e) => (Err(e.into()), vec![]), } } } mod utils { use oxc_allocator::Allocator; use oxc_ast_visit::{ Visit, walk::{walk_function, walk_module_export_name}, }; use oxc_parser::Parser; use oxc_span::{SourceType, Span}; use oxc_syntax::scope::ScopeFlags; use std::borrow::Cow; #[derive(Debug)] // TODO: support fn params check and support typescript type erase struct DefaultExport { span: Span, is_function: bool, } #[derive(Debug, Default)] struct FunctionVisitor<'n> { exported_name: Vec>, declared_functions: Vec<(Cow<'n, str>, Cow<'n, Span>)>, default_export: Option, } impl<'n> Visit<'n> for FunctionVisitor<'n> { // Visit module exported name to confirm whether exists default export fn visit_module_export_name(&mut self, it: &oxc_ast::ast::ModuleExportName<'n>) { match it { oxc_ast::ast::ModuleExportName::IdentifierName(id) => { self.exported_name.push(Cow::Borrowed(id.name.as_str())) } oxc_ast::ast::ModuleExportName::IdentifierReference(id) => { self.exported_name.push(Cow::Borrowed(id.name.as_str())) } oxc_ast::ast::ModuleExportName::StringLiteral(s) => { self.exported_name.push(Cow::Borrowed(s.value.as_str())) } } walk_module_export_name(self, it); } // Visit function declaration to save the function name and span and check whether it is default export fn visit_function(&mut self, it: &oxc_ast::ast::Function<'n>, flags: ScopeFlags) { // eprintln!("function: {:#?}", it); if let Some(id) = it.id.clone() { self.declared_functions .push((Cow::Borrowed(id.name.as_str()), Cow::Owned(it.span))); } walk_function(self, it, flags); } // Visit export default declaration to save the default export fn visit_export_default_declaration( &mut self, it: &oxc_ast::ast::ExportDefaultDeclaration<'n>, ) { self.default_export = Some(DefaultExport { is_function: matches!( it.declaration, oxc_ast::ast::ExportDefaultDeclarationKind::FunctionDeclaration(_) ), span: it.span, }); } } /// This is a tool function to wrap the script if it is not a ESM script. pub fn wrap_script_if_not_esm(script: &str) -> Result, anyhow::Error> { let allocator = Allocator::default(); let source_type = SourceType::default().with_module(true); let source_text = script.trim_matches(['\t', '\n', '\r', ' ']); let result = Parser::new(&allocator, source_text, source_type).parse(); if !result.errors.is_empty() { let mut errors = String::new(); for error in result.errors { errors.push_str(&format!( "{:?}\n", error.with_source_code(source_text.to_string()) )); } return Err(anyhow::anyhow!("parse error: {}", errors)); } #[cfg(test)] eprintln!("result: {:#?}", result.program); let mut visitor = FunctionVisitor::default(); visitor.visit_program(&result.program); #[cfg(test)] eprintln!("visitor: {:#?}", visitor); if visitor.default_export.is_some() { return Ok(Cow::Borrowed(script)); } // check whether `function main` exists match visitor .declared_functions .iter() .find(|(name, _)| name.contains("main")) { Some((_, span)) => { // just insert `export default` before the function let mut script = script.to_string(); script.insert_str(span.start as usize, "export default "); Ok(Cow::Owned(script)) } None => Err(anyhow::anyhow!("no default export or main function")), } } } #[cfg(test)] mod test { #[test] fn test_wrap_script_if_not_esm() { let script = r#"function main(config) { return config };"#; let script = super::utils::wrap_script_if_not_esm(script).unwrap(); assert_eq!( script, "export default function main(config) {\n return config\n };" ); } #[test] fn test_wrap_script_if_esm() { let script = "export default function main(config) {\n return config\n };"; let script = super::utils::wrap_script_if_not_esm(script).unwrap(); assert_eq!( script, "export default function main(config) {\n return config\n };" ); } #[test] fn test_wrap_script_if_not_esm_sample_2() { let script = r#"// 国内DNS服务器 const domesticNameservers = [ "https://dns.alidns.com/dns-query", // 阿里云公共DNS "https://doh.pub/dns-query", // 腾讯DNSPod "https://doh.360.cn/dns-query" // 360安全DNS ]; // 国外DNS服务器 const foreignNameservers = [ "https://1.1.1.1/dns-query", // Cloudflare(主) "https://1.0.0.1/dns-query", // Cloudflare(备) "https://208.67.222.222/dns-query", // OpenDNS(主) "https://208.67.220.220/dns-query", // OpenDNS(备) "https://194.242.2.2/dns-query", // Mullvad(主) "https://194.242.2.3/dns-query" // Mullvad(备) ]; function main(config) { // do something return config };"#; let script = super::utils::wrap_script_if_not_esm(script).unwrap(); assert_eq!( script, r#"// 国内DNS服务器 const domesticNameservers = [ "https://dns.alidns.com/dns-query", // 阿里云公共DNS "https://doh.pub/dns-query", // 腾讯DNSPod "https://doh.360.cn/dns-query" // 360安全DNS ]; // 国外DNS服务器 const foreignNameservers = [ "https://1.1.1.1/dns-query", // Cloudflare(主) "https://1.0.0.1/dns-query", // Cloudflare(备) "https://208.67.222.222/dns-query", // OpenDNS(主) "https://208.67.220.220/dns-query", // OpenDNS(备) "https://194.242.2.2/dns-query", // Mullvad(主) "https://194.242.2.3/dns-query" // Mullvad(备) ]; export default function main(config) { // do something return config };"# ); } #[test] fn test_process_honey() { use super::{super::runner::Runner, JSRunner}; let runner = JSRunner::try_new().unwrap(); let mapping = serde_yaml::from_str( r#" rules: - RULE-SET,custom-reject,REJECT - RULE-SET,custom-direct,DIRECT - RULE-SET,custom-proxy,🚀 tun: enable: false dns: enable: false "#, ) .unwrap(); let script = r#" export default async function main(config) { if (Array.isArray(config.rules)) { config.rules = [...config.rules, "MATCH,🚀"]; } // print(JSON.stringify(config)); console.log("Test console log"); console.warn("Test console log"); console.error("Test console log"); config.proxies = ["Test"]; return config; }"#; tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap() .block_on(async move { let (res, logs) = runner.process_honey(mapping, script).await; eprintln!("logs: {logs:?}"); let mapping = res.unwrap(); assert_eq!( mapping["rules"], serde_yaml::Value::Sequence(vec![ serde_yaml::Value::String("RULE-SET,custom-reject,REJECT".to_string()), serde_yaml::Value::String("RULE-SET,custom-direct,DIRECT".to_string()), serde_yaml::Value::String("RULE-SET,custom-proxy,🚀".to_string()), serde_yaml::Value::String("MATCH,🚀".to_string()) ]) ); assert_eq!( mapping["proxies"], serde_yaml::Value::Sequence(vec![serde_yaml::Value::String( "Test".to_string() ),]) ); let outs = serde_json::to_string(&logs).unwrap(); assert_eq!( outs, r#"[["log","Test console log"],["warn","Test console log"],["error","Test console log"]]"# ); }); } #[test_log::test] fn test_process_honey_with_fetch() { use super::{super::runner::Runner, JSRunner}; let runner = JSRunner::try_new().unwrap(); let mapping = serde_yaml::from_str( r#" rules: - RULE-SET,custom-reject,REJECT - RULE-SET,custom-direct,DIRECT - RULE-SET,custom-proxy,🚀 tun: enable: false dns: enable: false "#, ) .unwrap(); let script = r#" import YAML from 'https://esm.run/yaml@2.3.4'; import fromAsync from 'https://esm.run/array-from-async@3.0.0'; import { Base64 } from 'https://esm.run/js-base64@3.7.6'; export default async function main(config) { const data = ` object: array: ["hello", "world"] key: "value" `; const object = YAML.parse(data).object; let result = await fromAsync([ Promise.resolve(Base64.encode(object.array[0])), Promise.resolve(Base64.encode(object.array[1])), ]); // add result to config.rules config.rules.push(`${result[0]}`); config.rules.push(`${result[1]}`); return config; }"#; tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap() .block_on(async move { let (res, logs) = runner.process_honey(mapping, script).await; eprintln!("logs: {logs:?}"); let mapping = res.unwrap(); assert_eq!( mapping["rules"], serde_yaml::Value::Sequence(vec![ serde_yaml::Value::String("RULE-SET,custom-reject,REJECT".to_string()), serde_yaml::Value::String("RULE-SET,custom-direct,DIRECT".to_string()), serde_yaml::Value::String("RULE-SET,custom-proxy,🚀".to_string()), serde_yaml::Value::String("aGVsbG8=".to_string()), serde_yaml::Value::String("d29ybGQ=".to_string()), ]) ); let outs = serde_json::to_string(&logs).unwrap(); assert_eq!(outs, r#"[]"#); }); } #[test_log::test] fn test_process_honey_with_builtin_modules() { use super::{super::runner::Runner, JSRunner}; let runner = JSRunner::try_new().unwrap(); let mapping = serde_yaml::from_str( r#" rules: - RULE-SET,custom-reject,REJECT - RULE-SET,custom-direct,DIRECT - RULE-SET,custom-proxy,🚀 tun: enable: false dns: enable: false "#, ) .unwrap(); let script = r#" import { yaml } from "nyan:utils"; import { Base64 } from "nyan:js-base64"; export default async function main(config) { const data = yaml` object: array: ["hello", "world"] key: "value" `; const object = data.object; let result = [ Base64.encode(object.array[0]), Base64.encode(object.array[1]), ]; // add result to config.rules config.rules.push(`${result[0]}`); config.rules.push(`${result[1]}`); return config; }"#; tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap() .block_on(async move { let (res, logs) = runner.process_honey(mapping, script).await; eprintln!("logs: {logs:?}"); let mapping = res.unwrap(); assert_eq!( mapping["rules"], serde_yaml::Value::Sequence(vec![ serde_yaml::Value::String("RULE-SET,custom-reject,REJECT".to_string()), serde_yaml::Value::String("RULE-SET,custom-direct,DIRECT".to_string()), serde_yaml::Value::String("RULE-SET,custom-proxy,🚀".to_string()), serde_yaml::Value::String("aGVsbG8=".to_string()), serde_yaml::Value::String("d29ybGQ=".to_string()), ]) ); let outs = serde_json::to_string(&logs).unwrap(); assert_eq!(outs, r#"[]"#); }); } } ================================================ FILE: backend/tauri/src/enhance/script/lua/mod.rs ================================================ use std::sync::Arc; use anyhow::Error; use mlua::prelude::*; use parking_lot::Mutex; use serde_yaml::{Mapping, Value}; use crate::enhance::{Logs, LogsExt, runner::wrap_result, utils::take_logs}; use super::runner::{ProcessOutput, Runner}; pub fn create_lua_context() -> Result { let lua = Lua::new(); lua.load_std_libs(LuaStdLib::ALL_SAFE)?; Ok(lua) } fn create_console(lua: &Lua, logger: Arc>>) -> Result<(), anyhow::Error> { let table = lua.create_table()?; let logger_ = logger.clone(); let log = lua.create_function(move |_, msg: String| { let mut logger = logger_.lock(); logger.as_mut().unwrap().log(msg); Ok(()) })?; let logger_ = logger.clone(); let info = lua.create_function(move |_, msg: String| { let mut logger = logger_.lock(); logger.as_mut().unwrap().info(msg); Ok(()) })?; let logger_ = logger.clone(); let warn = lua.create_function(move |_, msg: String| { let mut logger = logger_.lock(); logger.as_mut().unwrap().warn(msg); Ok(()) })?; let error = lua.create_function(move |_, msg: String| { let mut logger = logger.lock(); logger.as_mut().unwrap().error(msg); Ok(()) })?; table.set("log", log)?; table.set("info", info)?; table.set("warn", warn)?; table.set("error", error)?; lua.globals().set("console", table)?; Ok(()) } /// This is a workaround for mihomo's yaml config based on the index of the map. /// We compare the keys of the index order of the original mapping with the target mapping, /// and then we correct the order of the target mapping. /// This is a recursive call, so it will correct the order of the nested mapping. fn correct_original_mapping_order(target: &mut Value, original: &Value) { if !target.is_mapping() && !target.is_sequence() { return; } match (target, original) { (Value::Mapping(target_mapping), Value::Mapping(original_mapping)) => { let original_keys: Vec<_> = original_mapping.keys().collect(); let mut new_mapping = serde_yaml::Mapping::new(); for key in original_keys { if let Some(mut value) = target_mapping.remove(key) { if let Some(original_value) = original_mapping.get(key) { correct_original_mapping_order(&mut value, original_value); } new_mapping.insert(key.clone(), value); } } let remaining_keys = target_mapping.keys().cloned().collect::>(); for key in remaining_keys { if let Some(value) = target_mapping.remove(&key) { new_mapping.insert(key, value); } } *target_mapping = new_mapping; } (Value::Sequence(target), Value::Sequence(original)) if target.len() == original.len() => { for (target_value, original_value) in target.iter_mut().zip(original.iter()) { // TODO: Maybe here exist a bug when the mappings was not in the same order correct_original_mapping_order(target_value, original_value); } } _ => {} } } pub struct LuaRunner; #[async_trait::async_trait] impl Runner for LuaRunner { fn try_new() -> Result { Ok(Self) } async fn process(&self, mapping: Mapping, path: &str) -> ProcessOutput { let file = wrap_result!(tokio::fs::read_to_string(path).await); self.process_honey(mapping, &file).await } // TODO: Keep the order of the dictionary structure in the configuration when processing lua. Because mihomo needs ordered dictionaries for dns policy. async fn process_honey(&self, mapping: Mapping, script: &str) -> ProcessOutput { let lua = wrap_result!(create_lua_context()); let logger = Arc::new(Mutex::new(Some(Logs::new()))); wrap_result!(create_console(&lua, logger.clone()), take_logs(logger)); let config = wrap_result!( lua.to_value(&mapping) .context("Failed to convert mapping to value"), take_logs(logger) ); wrap_result!( lua.globals() .set("config", config) .context("Failed to set config"), take_logs(logger) ); let output = wrap_result!( lua.load(script) .eval::() .context("Failed to load script"), take_logs(logger) ); if !output.is_table() { return wrap_result!( Err(anyhow::anyhow!( "Script must return a table, data: {:?}", output )), take_logs(logger) ); } let config: Mapping = wrap_result!( lua.from_value(output) .context("Failed to convert output to config"), take_logs(logger) ); // Correct the order of the mapping correct_original_mapping_order( &mut Value::Mapping(config.clone()), &Value::Mapping(mapping), ); (Ok(config), take_logs(logger)) } } mod tests { #[test] fn test_process_honey() { use super::*; use crate::enhance::runner::Runner; use serde_yaml::Mapping; let runner = LuaRunner; let mapping = r#" proxies: - 123 - 12312 - asdxxx shoud_remove: 123 "#; let mapping = serde_yaml::from_str::(mapping).unwrap(); let script = r#" console.log("Hello, world!"); console.warn("Hello, world!"); console.error("Hello, world!"); config["proxies"] = {1, 2, 3}; config["shoud_remove"] = nil; return config; "#; let expected = r#" proxies: - 1 - 2 - 3 "#; let (result, logs) = tokio::runtime::Runtime::new() .unwrap() .block_on(runner.process_honey(mapping, script)); eprintln!("{logs:?}\n{result:?}"); assert!(result.is_ok()); assert_eq!(logs.len(), 3); let expected = serde_yaml::from_str::(expected).unwrap(); assert_eq!(expected, result.unwrap()); } #[test] fn test_correct_original_mapping_order() { use super::*; let mut target = serde_yaml::from_str::( r#" ######### 锚点 start ####### TroxyInPort: &TroxyInPort 65535 ShareInPort: &ShareInPort 65534 # TailscaleOutPort: &TailscaleOutPort 65528 ReqableOutPort: &ReqableOutPort 9000 DNSSocket: &DNSSocket 127.0.0.1:65533 UISocket: &UISocket 127.0.0.1:65532 direct_dns: &direct_dns - 114.114.114.114#直连DNS - https://doh.pub/dns-query#直连DNS - https://dns.alidns.com/dns-query#直连DNS - system cn_dns: &cn_dns - 114.114.114.114#中国DNS - https://doh.pub/dns-query#中国DNS - https://dns.alidns.com/dns-query#中国DNS - system international_dns: &international_dns - "https://dns.cloudflare.com/dns-query#国际DNS" - "https://doh.opendns.com/dns-query#国际DNS" - "https://dns.w3ctag.org/dns-query#国际DNS" - "https://dns.google/dns-query#国际DNS" us_dns: &us_dns - "https://dns.koala.us.to/dns-query#美国" - "https://dns.dns-53.us/dns-query#美国" - "https://cloudflare-dns.com/dns-query#美国" - "https://doh.opendns.com/dns-query#美国" - "https://dns.google/dns-query#美国" uk_dns: &uk_dns - "https://dns.aa.net.uk/dns-query#英国" - "https://princez.uk/dns-query#英国" - "https://dns.dns-53.uk/dns-query#英国" de_dns: &de_dns - "https://doh.ffmuc.net/dns-query#德国" - "https://dns.dnshome.de/dns-query#德国" - "https://dnsforge.de/dns-query#德国" - "https://bahopir188.dnshome.de/dns-query#德国" - "https://dns.csa-rz.de/dns-query#德国" - "https://dns.datenquark.de/dns-query#德国" - "https://doh-de.blahdns.com/dns-query#德国" - "https://dns.telekom.de/dns-query#德国" - "https://dns.csaonline.de/dns-query#德国" fr_dns: &fr_dns - "https://ns0.fdn.fr/dns-query#法国" - "https://qlf-doh.inria.fr/dns-query#法国" - "https://dns.k3nny.fr/dns-query#法国" - "https://doh.ffmuc.net/dns-query#法国" jp_dns: &jp_dns - "https://public.dns.iij.jp/dns-query#日本" - "https://dns.google/dns-query#日本" hk_dns: &hk_dns - "https://dns.cloudflare.com/dns-query#香港" - "https://doh.opendns.com/dns-query#香港" - "https://dns.w3ctag.org/dns-query#香港" - "https://dns.google/dns-query#香港" mo_dns: &mo_dns - "https://dns.cloudflare.com/dns-query#澳门" - "https://doh.opendns.com/dns-query#澳门" - "https://dns.w3ctag.org/dns-query#澳门" - "https://dns.google/dns-query#澳门" tw_dns: &tw_dns - "https://dns.cloudflare.com/dns-query#台湾" - "https://doh.opendns.com/dns-query#台湾" - "https://dns.w3ctag.org/dns-query#台湾" - "https://dns.google/dns-query#台湾" sg_dns: &sg_dns - "https://dns.cloudflare.com/dns-query#新加坡" - "https://doh.opendns.com/dns-query#新加坡" - "https://dns.w3ctag.org/dns-query#新加坡" - "https://dns.google/dns-query#新加坡" ru_dns: &ru_dns - "https://dns.ch295.ru/dns-query#俄国" - "https://dns.yandex.com/dns-query#俄国" - "https://unfiltered.adguard-dns.com/dns-query#俄国" in_dns: &in_dns - "https://dns.gutwe.in/dns-query#印度" - "https://dns.brahma.world/dns-query#印度" br_dns: &br_dns - "https://adguard.frutuozo.com.br/dns-query#巴西" - "https://dns.google/dns-query#巴西" ca_dns: &ca_dns - "https://dns1.dnscrypt.ca/dns-query#加拿大" - "https://dns.cloudflare.com/dns-query#加拿大" au_dns: &au_dns - "https://dns.netraptor.com.au/dns-query#澳大利亚" - "https://dns.quad9.net/dns-query#澳大利亚" it_dns: &it_dns - "https://doh.libredns.gr/dns-query#意大利" nl_dns: &nl_dns - "https://doh.nl.ahadns.net/dns-query#荷兰" - "https://dns.melvin2204.nl/dns-query#荷兰" - "https://dns.quad9.net/dns-query#荷兰" se_dns: &se_dns - "https://dns.mullvad.net/dns-query#瑞典" - "https://resolver.sunet.se/dns-query#瑞典" - "https://dns.haka.se/dns-query#瑞典" ch_dns: &ch_dns - "https://dns10.quad9.net/dns-query#瑞士" - "https://doh.immerda.ch/dns-query#瑞士" - "https://c.cicitt.ch/dns-query#瑞士" - "https://dns.digitale-gesellschaft.ch/dns-query#瑞士" - "https://doh.li/dns-query#瑞士" dns: enable: true listen: *DNSSocket ipv6: true enhanced-mode: redir-host default-nameserver: # proxy-server-nameserver,nameserver-policy,nameserver、fallback域名的解析 - 223.5.5.5#DNSDNS - 114.114.114.114#DNSDNS - 8.8.8.8#DNSDNS - https://120.53.53.53/dns-query#DNSDNS - https://223.5.5.5/dns-query#DNSDNS - https://1.12.12.12/dns-query#DNSDNS - system proxy-server-nameserver: # 节点域名的解析 - https://120.53.53.53/dns-query#节点直连DNS - https://223.5.5.5/dns-query#节点直连DNS - https://1.1.1.1/dns-query#节点直连DNS - https://dns.google/dns-query#节点直连DNS - https://1.1.1.1/dns-query#节点国际DNS - https://dns.google/dns-query#节点国际DNS prefer-h3: false direct-nameserver-follow-policy: false direct-nameserver: # [动态回环出口:direct,中国:direct出站]时 *direct_dns respect-rules: true # [中国非direct,其他地区,不出站]时,依据[nameserver-policy,nameserver、fallback]分类,使用不同dns nameserver-policy: "rule-set:loopback_classical": *direct_dns #动态回环出口 "rule-set:firewall_classical": rcode://success #个人文件 "rule-set:international_classical": *international_dns #个人文件 "rule-set:domestic_classical": *cn_dns #个人文件 "rule-set:category-ads-all_classical": rcode://success #广告拦截 "rule-set:download_domain,bing_domain,openai_domain,github_domain,twitter_domain,instagram_domain,facebook_domain,youtube_domain,netflix_domain,spotify_domain,apple_domain,adobe_domain,telegram_domain,discord_domain,reddit_domain,biliintl_domain,bahamut_domain,ehentai_domain,pixiv_domain,steam_domain,epic_domain,microsoft_domain,google_domain": *international_dns #中国 "+.cn": *cn_dns #美国 "+.us": *us_dns #英国 "+.uk": *uk_dns #德国 "+.de,+.eu": *de_dns #法国 "+.fr": *fr_dns #日本 "+.jp,+.nico": *jp_dns #香港 "+.hk": *hk_dns #澳门 "+.mo": *mo_dns #台湾 "+.tw": *tw_dns #新加坡 "+.sg": *sg_dns #俄罗斯 "+.ru": *ru_dns #印度 "+.in": *in_dns #巴西 "+.br": *br_dns #加拿大 "+.ca": *ca_dns #澳大利亚 "+.au": *au_dns #意大利 "+.it": *it_dns #荷兰 "+.nl": *nl_dns #瑞士 "+.ch": *ch_dns #瑞典 "+.se": *se_dns #国际 "rule-set:geolocation-!cn,tld-!cn": *international_dns "rule-set:cn_domain,private_domain": *cn_dns"#, ) .unwrap(); let mut original = serde_yaml::from_str::( r#"######### 锚点 start ####### TroxyInPort: &TroxyInPort 65535 ShareInPort: &ShareInPort 65534 # TailscaleOutPort: &TailscaleOutPort 65528 ReqableOutPort: &ReqableOutPort 9000 DNSSocket: &DNSSocket 127.0.0.1:65533 UISocket: &UISocket 127.0.0.1:65532 direct_dns: &direct_dns - 114.114.114.114#直连DNS - https://doh.pub/dns-query#直连DNS - https://dns.alidns.com/dns-query#直连DNS - system cn_dns: &cn_dns - 114.114.114.114#中国DNS - https://doh.pub/dns-query#中国DNS - https://dns.alidns.com/dns-query#中国DNS - system international_dns: &international_dns - "https://dns.cloudflare.com/dns-query#国际DNS" - "https://doh.opendns.com/dns-query#国际DNS" - "https://dns.w3ctag.org/dns-query#国际DNS" - "https://dns.google/dns-query#国际DNS" us_dns: &us_dns - "https://dns.koala.us.to/dns-query#美国" - "https://dns.dns-53.us/dns-query#美国" - "https://cloudflare-dns.com/dns-query#美国" - "https://doh.opendns.com/dns-query#美国" - "https://dns.google/dns-query#美国" uk_dns: &uk_dns - "https://dns.aa.net.uk/dns-query#英国" - "https://princez.uk/dns-query#英国" - "https://dns.dns-53.uk/dns-query#英国" de_dns: &de_dns - "https://doh.ffmuc.net/dns-query#德国" - "https://dns.dnshome.de/dns-query#德国" - "https://dnsforge.de/dns-query#德国" - "https://bahopir188.dnshome.de/dns-query#德国" - "https://dns.csa-rz.de/dns-query#德国" - "https://dns.datenquark.de/dns-query#德国" - "https://doh-de.blahdns.com/dns-query#德国" - "https://dns.telekom.de/dns-query#德国" - "https://dns.csaonline.de/dns-query#德国" fr_dns: &fr_dns - "https://ns0.fdn.fr/dns-query#法国" - "https://qlf-doh.inria.fr/dns-query#法国" - "https://dns.k3nny.fr/dns-query#法国" - "https://doh.ffmuc.net/dns-query#法国" jp_dns: &jp_dns - "https://public.dns.iij.jp/dns-query#日本" - "https://dns.google/dns-query#日本" hk_dns: &hk_dns - "https://dns.cloudflare.com/dns-query#香港" - "https://doh.opendns.com/dns-query#香港" - "https://dns.w3ctag.org/dns-query#香港" - "https://dns.google/dns-query#香港" mo_dns: &mo_dns - "https://dns.cloudflare.com/dns-query#澳门" - "https://doh.opendns.com/dns-query#澳门" - "https://dns.w3ctag.org/dns-query#澳门" - "https://dns.google/dns-query#澳门" tw_dns: &tw_dns - "https://dns.cloudflare.com/dns-query#台湾" - "https://doh.opendns.com/dns-query#台湾" - "https://dns.w3ctag.org/dns-query#台湾" - "https://dns.google/dns-query#台湾" sg_dns: &sg_dns - "https://dns.cloudflare.com/dns-query#新加坡" - "https://doh.opendns.com/dns-query#新加坡" - "https://dns.w3ctag.org/dns-query#新加坡" - "https://dns.google/dns-query#新加坡" ru_dns: &ru_dns - "https://dns.ch295.ru/dns-query#俄国" - "https://dns.yandex.com/dns-query#俄国" - "https://unfiltered.adguard-dns.com/dns-query#俄国" in_dns: &in_dns - "https://dns.gutwe.in/dns-query#印度" - "https://dns.brahma.world/dns-query#印度" br_dns: &br_dns - "https://adguard.frutuozo.com.br/dns-query#巴西" - "https://dns.google/dns-query#巴西" ca_dns: &ca_dns - "https://dns1.dnscrypt.ca/dns-query#加拿大" - "https://dns.cloudflare.com/dns-query#加拿大" au_dns: &au_dns - "https://dns.netraptor.com.au/dns-query#澳大利亚" - "https://dns.quad9.net/dns-query#澳大利亚" it_dns: &it_dns - "https://doh.libredns.gr/dns-query#意大利" nl_dns: &nl_dns - "https://doh.nl.ahadns.net/dns-query#荷兰" - "https://dns.melvin2204.nl/dns-query#荷兰" - "https://dns.quad9.net/dns-query#荷兰" se_dns: &se_dns - "https://dns.mullvad.net/dns-query#瑞典" - "https://resolver.sunet.se/dns-query#瑞典" - "https://dns.haka.se/dns-query#瑞典" ch_dns: &ch_dns - "https://dns10.quad9.net/dns-query#瑞士" - "https://doh.immerda.ch/dns-query#瑞士" - "https://c.cicitt.ch/dns-query#瑞士" - "https://dns.digitale-gesellschaft.ch/dns-query#瑞士" - "https://doh.li/dns-query#瑞士" dns: enable: true listen: *DNSSocket ipv6: true enhanced-mode: redir-host default-nameserver: # proxy-server-nameserver,nameserver-policy,nameserver、fallback域名的解析 - 223.5.5.5#DNSDNS - 114.114.114.114#DNSDNS - 8.8.8.8#DNSDNS - https://120.53.53.53/dns-query#DNSDNS - https://223.5.5.5/dns-query#DNSDNS - https://1.12.12.12/dns-query#DNSDNS - system proxy-server-nameserver: # 节点域名的解析 - https://120.53.53.53/dns-query#节点直连DNS - https://223.5.5.5/dns-query#节点直连DNS - https://1.1.1.1/dns-query#节点直连DNS - https://dns.google/dns-query#节点直连DNS - https://1.1.1.1/dns-query#节点国际DNS - https://dns.google/dns-query#节点国际DNS prefer-h3: false direct-nameserver-follow-policy: false direct-nameserver: # [动态回环出口:direct,中国:direct出站]时 *direct_dns respect-rules: true # [中国非direct,其他地区,不出站]时,依据[nameserver-policy,nameserver、fallback]分类,使用不同dns nameserver-policy: "rule-set:loopback_classical": *direct_dns #动态回环出口 "rule-set:firewall_classical": rcode://success #个人文件 "rule-set:international_classical": *international_dns #个人文件 "rule-set:domestic_classical": *cn_dns #个人文件 "rule-set:category-ads-all_classical": rcode://success #广告拦截 "rule-set:download_domain,bing_domain,openai_domain,github_domain,twitter_domain,instagram_domain,facebook_domain,youtube_domain,netflix_domain,spotify_domain,apple_domain,adobe_domain,telegram_domain,discord_domain,reddit_domain,biliintl_domain,bahamut_domain,ehentai_domain,pixiv_domain,steam_domain,epic_domain,microsoft_domain,google_domain": *international_dns #中国 "+.cn": *cn_dns #美国 "+.us": *us_dns #英国 "+.uk": *uk_dns #德国 "+.de,+.eu": *de_dns #法国 "+.fr": *fr_dns #日本 "+.jp,+.nico": *jp_dns #香港 "+.hk": *hk_dns #澳门 "+.mo": *mo_dns #台湾 "+.tw": *tw_dns #新加坡 "+.sg": *sg_dns #俄罗斯 "+.ru": *ru_dns #印度 "+.in": *in_dns #巴西 "+.br": *br_dns #加拿大 "+.ca": *ca_dns #澳大利亚 "+.au": *au_dns #意大利 "+.it": *it_dns #荷兰 "+.nl": *nl_dns #瑞典 "+.se": *se_dns #瑞士 "+.ch": *ch_dns #国际 "rule-set:geolocation-!cn,tld-!cn": *international_dns "rule-set:cn_domain,private_domain": *cn_dns "#, ) .unwrap(); original.apply_merge().unwrap(); target.apply_merge().unwrap(); correct_original_mapping_order(&mut target, &original); let mut expected = serde_yaml::from_str::( r#"######### 锚点 start ####### TroxyInPort: &TroxyInPort 65535 ShareInPort: &ShareInPort 65534 # TailscaleOutPort: &TailscaleOutPort 65528 ReqableOutPort: &ReqableOutPort 9000 DNSSocket: &DNSSocket 127.0.0.1:65533 UISocket: &UISocket 127.0.0.1:65532 direct_dns: &direct_dns - 114.114.114.114#直连DNS - https://doh.pub/dns-query#直连DNS - https://dns.alidns.com/dns-query#直连DNS - system cn_dns: &cn_dns - 114.114.114.114#中国DNS - https://doh.pub/dns-query#中国DNS - https://dns.alidns.com/dns-query#中国DNS - system international_dns: &international_dns - "https://dns.cloudflare.com/dns-query#国际DNS" - "https://doh.opendns.com/dns-query#国际DNS" - "https://dns.w3ctag.org/dns-query#国际DNS" - "https://dns.google/dns-query#国际DNS" us_dns: &us_dns - "https://dns.koala.us.to/dns-query#美国" - "https://dns.dns-53.us/dns-query#美国" - "https://cloudflare-dns.com/dns-query#美国" - "https://doh.opendns.com/dns-query#美国" - "https://dns.google/dns-query#美国" uk_dns: &uk_dns - "https://dns.aa.net.uk/dns-query#英国" - "https://princez.uk/dns-query#英国" - "https://dns.dns-53.uk/dns-query#英国" de_dns: &de_dns - "https://doh.ffmuc.net/dns-query#德国" - "https://dns.dnshome.de/dns-query#德国" - "https://dnsforge.de/dns-query#德国" - "https://bahopir188.dnshome.de/dns-query#德国" - "https://dns.csa-rz.de/dns-query#德国" - "https://dns.datenquark.de/dns-query#德国" - "https://doh-de.blahdns.com/dns-query#德国" - "https://dns.telekom.de/dns-query#德国" - "https://dns.csaonline.de/dns-query#德国" fr_dns: &fr_dns - "https://ns0.fdn.fr/dns-query#法国" - "https://qlf-doh.inria.fr/dns-query#法国" - "https://dns.k3nny.fr/dns-query#法国" - "https://doh.ffmuc.net/dns-query#法国" jp_dns: &jp_dns - "https://public.dns.iij.jp/dns-query#日本" - "https://dns.google/dns-query#日本" hk_dns: &hk_dns - "https://dns.cloudflare.com/dns-query#香港" - "https://doh.opendns.com/dns-query#香港" - "https://dns.w3ctag.org/dns-query#香港" - "https://dns.google/dns-query#香港" mo_dns: &mo_dns - "https://dns.cloudflare.com/dns-query#澳门" - "https://doh.opendns.com/dns-query#澳门" - "https://dns.w3ctag.org/dns-query#澳门" - "https://dns.google/dns-query#澳门" tw_dns: &tw_dns - "https://dns.cloudflare.com/dns-query#台湾" - "https://doh.opendns.com/dns-query#台湾" - "https://dns.w3ctag.org/dns-query#台湾" - "https://dns.google/dns-query#台湾" sg_dns: &sg_dns - "https://dns.cloudflare.com/dns-query#新加坡" - "https://doh.opendns.com/dns-query#新加坡" - "https://dns.w3ctag.org/dns-query#新加坡" - "https://dns.google/dns-query#新加坡" ru_dns: &ru_dns - "https://dns.ch295.ru/dns-query#俄国" - "https://dns.yandex.com/dns-query#俄国" - "https://unfiltered.adguard-dns.com/dns-query#俄国" in_dns: &in_dns - "https://dns.gutwe.in/dns-query#印度" - "https://dns.brahma.world/dns-query#印度" br_dns: &br_dns - "https://adguard.frutuozo.com.br/dns-query#巴西" - "https://dns.google/dns-query#巴西" ca_dns: &ca_dns - "https://dns1.dnscrypt.ca/dns-query#加拿大" - "https://dns.cloudflare.com/dns-query#加拿大" au_dns: &au_dns - "https://dns.netraptor.com.au/dns-query#澳大利亚" - "https://dns.quad9.net/dns-query#澳大利亚" it_dns: &it_dns - "https://doh.libredns.gr/dns-query#意大利" nl_dns: &nl_dns - "https://doh.nl.ahadns.net/dns-query#荷兰" - "https://dns.melvin2204.nl/dns-query#荷兰" - "https://dns.quad9.net/dns-query#荷兰" se_dns: &se_dns - "https://dns.mullvad.net/dns-query#瑞典" - "https://resolver.sunet.se/dns-query#瑞典" - "https://dns.haka.se/dns-query#瑞典" ch_dns: &ch_dns - "https://dns10.quad9.net/dns-query#瑞士" - "https://doh.immerda.ch/dns-query#瑞士" - "https://c.cicitt.ch/dns-query#瑞士" - "https://dns.digitale-gesellschaft.ch/dns-query#瑞士" - "https://doh.li/dns-query#瑞士" dns: enable: true listen: *DNSSocket ipv6: true enhanced-mode: redir-host default-nameserver: # proxy-server-nameserver,nameserver-policy,nameserver、fallback域名的解析 - 223.5.5.5#DNSDNS - 114.114.114.114#DNSDNS - 8.8.8.8#DNSDNS - https://120.53.53.53/dns-query#DNSDNS - https://223.5.5.5/dns-query#DNSDNS - https://1.12.12.12/dns-query#DNSDNS - system proxy-server-nameserver: # 节点域名的解析 - https://120.53.53.53/dns-query#节点直连DNS - https://223.5.5.5/dns-query#节点直连DNS - https://1.1.1.1/dns-query#节点直连DNS - https://dns.google/dns-query#节点直连DNS - https://1.1.1.1/dns-query#节点国际DNS - https://dns.google/dns-query#节点国际DNS prefer-h3: false direct-nameserver-follow-policy: false direct-nameserver: # [动态回环出口:direct,中国:direct出站]时 *direct_dns respect-rules: true # [中国非direct,其他地区,不出站]时,依据[nameserver-policy,nameserver、fallback]分类,使用不同dns nameserver-policy: "rule-set:loopback_classical": *direct_dns #动态回环出口 "rule-set:firewall_classical": rcode://success #个人文件 "rule-set:international_classical": *international_dns #个人文件 "rule-set:domestic_classical": *cn_dns #个人文件 "rule-set:category-ads-all_classical": rcode://success #广告拦截 "rule-set:download_domain,bing_domain,openai_domain,github_domain,twitter_domain,instagram_domain,facebook_domain,youtube_domain,netflix_domain,spotify_domain,apple_domain,adobe_domain,telegram_domain,discord_domain,reddit_domain,biliintl_domain,bahamut_domain,ehentai_domain,pixiv_domain,steam_domain,epic_domain,microsoft_domain,google_domain": *international_dns #中国 "+.cn": *cn_dns #美国 "+.us": *us_dns #英国 "+.uk": *uk_dns #德国 "+.de,+.eu": *de_dns #法国 "+.fr": *fr_dns #日本 "+.jp,+.nico": *jp_dns #香港 "+.hk": *hk_dns #澳门 "+.mo": *mo_dns #台湾 "+.tw": *tw_dns #新加坡 "+.sg": *sg_dns #俄罗斯 "+.ru": *ru_dns #印度 "+.in": *in_dns #巴西 "+.br": *br_dns #加拿大 "+.ca": *ca_dns #澳大利亚 "+.au": *au_dns #意大利 "+.it": *it_dns #荷兰 "+.nl": *nl_dns #瑞典 "+.se": *se_dns #瑞士 "+.ch": *ch_dns #国际 "rule-set:geolocation-!cn,tld-!cn": *international_dns "rule-set:cn_domain,private_domain": *cn_dns "#, ) .unwrap(); expected.apply_merge().unwrap(); assert_eq!(expected, target); } } ================================================ FILE: backend/tauri/src/enhance/script/mod.rs ================================================ mod js; mod lua; pub use lua::create_lua_context; pub mod runner; pub use runner::RunnerManager; // TODO: add test // pub fn use_script( // script: ScriptWrapper, // config: Mapping, // ) -> Result<(Mapping, Vec<(String, String)>)> { // match script.0 { // ScriptType::JavaScript => { // }, // _ => unimplemented!("unsupported script type"), // } // } // #[test] // fn test_script() { // let script = r#" // function main(config) { // if (Array.isArray(config.rules)) { // config.rules = [...config.rules, "add"]; // } // console.log(config); // config.proxies = ["111"]; // return config; // } // "#; // let config = r#" // rules: // - 111 // - 222 // tun: // enable: false // dns: // enable: false // "#; // let config = serde_yaml::from_str(config).unwrap(); // let (config, results) = process_js(script.into(), config).unwrap(); // let config_str = serde_yaml::to_string(&config).unwrap(); // println!("{config_str}"); // dbg!(results); // } ================================================ FILE: backend/tauri/src/enhance/script/runner.rs ================================================ use anyhow::Error; use async_trait::async_trait; use serde_yaml::Mapping; use std::collections::HashMap; use super::{js, lua}; use crate::enhance::{Logs, ScriptType, ScriptWrapper}; /// The output of the process function is a tuple of the mapping and the logs. /// Although the process fails, the logs should be returned. pub type ProcessOutput = (Result, Logs); /// warp a result and return the ProcessOutput macro_rules! wrap_result { ($result:expr) => { match $result { Ok(inner) => inner, Err(e) => return (Err(e.into()), Vec::new()), } }; ($result:expr, $logs:expr) => { match $result { Ok(inner) => inner, Err(e) => return (Err(e.into()), $logs), } }; } pub(super) use wrap_result; #[async_trait] pub trait Runner: Send + Sync { fn try_new() -> Result where Self: std::marker::Sized; #[allow(dead_code)] /// Process profiles by script file path async fn process(&self, mapping: Mapping, path: &str) -> ProcessOutput; /// Honey replacement - use in memory code str to load module and exec it! /// It might not be implemented - due to some embeded engine is not support. async fn process_honey(&self, mapping: Mapping, script: &str) -> ProcessOutput { tracing::debug!("mapping: {:?}\nscript:{}", mapping, script); unimplemented!() } } pub struct RunnerManager { runners: HashMap>, } impl RunnerManager { pub fn new() -> Self { Self { runners: HashMap::new(), } } // If the script runner is not exist, it should be created. pub fn get_or_init_runner(&mut self, script_type: &ScriptType) -> anyhow::Result<&dyn Runner> { if !self.runners.contains_key(script_type) { let runner = match script_type { ScriptType::JavaScript => Box::new(js::JSRunner::try_new()?) as Box, ScriptType::Lua => Box::new(lua::LuaRunner::try_new()?) as Box, }; self.runners.insert(script_type.clone(), runner); } Ok(self.runners.get(script_type).unwrap().as_ref()) } pub async fn process_script( &mut self, script: &ScriptWrapper, config: Mapping, ) -> ProcessOutput { let runner = wrap_result!(self.get_or_init_runner(&script.0)); tracing::debug!("script: {:?}", script); runner.process_honey(config, script.1.as_str()).await } } ================================================ FILE: backend/tauri/src/enhance/tun.rs ================================================ use serde_yaml::{Mapping, Value}; use crate::config::{ Config, nyanpasu::{ClashCore, TunStack}, }; macro_rules! revise { ($map: expr, $key: expr, $val: expr) => { let ret_key = Value::String($key.into()); $map.insert(ret_key, Value::from($val)); }; } // if key not exists then append value macro_rules! append { ($map: expr, $key: expr, $val: expr) => { let ret_key = Value::String($key.into()); if !$map.contains_key(&ret_key) { $map.insert(ret_key, Value::from($val)); } }; } #[tracing_attributes::instrument(skip(config))] pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping { let tun_key = Value::from("tun"); let tun_val = config.get(&tun_key); tracing::debug!("tun_val: {:?}", tun_val); if !enable && tun_val.is_none() { return config; } let mut tun_val = tun_val.map_or(Mapping::new(), |val| { val.as_mapping().cloned().unwrap_or(Mapping::new()) }); revise!(tun_val, "enable", enable); if enable { let core = { *Config::verge() .latest() .clash_core .as_ref() .unwrap_or(&ClashCore::default()) }; if core == ClashCore::ClashRs { append!(tun_val, "device-id", "dev://utun1989"); append!(tun_val, "auto-route", true); } else { let mut tun_stack = { *Config::verge() .latest() .tun_stack .as_ref() .unwrap_or(&TunStack::default()) }; if core == ClashCore::ClashPremium && tun_stack == TunStack::Mixed { tun_stack = TunStack::Gvisor; } append!(tun_val, "stack", AsRef::::as_ref(&tun_stack)); append!(tun_val, "dns-hijack", vec!["any:53"]); append!(tun_val, "auto-route", true); append!(tun_val, "auto-detect-interface", true); } } revise!(config, "tun", tun_val); if enable { use_dns_for_tun(config) } else { config } } fn use_dns_for_tun(mut config: Mapping) -> Mapping { let dns_key = Value::from("dns"); let dns_val = config.get(&dns_key); let mut dns_val = dns_val.map_or(Mapping::new(), |val| { val.as_mapping().cloned().unwrap_or(Mapping::new()) }); // 开启tun将同时开启dns revise!(dns_val, "enable", true); append!(dns_val, "enhanced-mode", "fake-ip"); append!(dns_val, "fake-ip-range", "198.18.0.1/16"); append!( dns_val, "nameserver", vec!["114.114.114.114", "223.5.5.5", "8.8.8.8"] ); append!(dns_val, "fallback", vec![] as Vec<&str>); #[cfg(target_os = "windows")] append!( dns_val, "fake-ip-filter", vec![ "dns.msftncsi.com", "www.msftncsi.com", "www.msftconnecttest.com" ] ); revise!(config, "dns", dns_val); config } ================================================ FILE: backend/tauri/src/enhance/utils.rs ================================================ use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_yaml::Mapping; use crate::config::profile::{item_type::ProfileUid, profiles::Profiles}; use super::{ChainItem, ChainTypeWrapper, RunnerManager, use_merge}; use parking_lot::Mutex; use std::{borrow::Borrow, sync::Arc}; pub fn convert_uids_to_scripts(profiles: &Profiles, uids: &[ProfileUid]) -> Vec { uids.iter() .filter_map(|uid| profiles.get_item(uid).ok()) .filter_map(>::from) .collect::>() } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "lowercase")] pub enum LogSpan { Log, Info, Warn, Error, } impl AsRef for LogSpan { fn as_ref(&self) -> &str { match self { LogSpan::Log => "log", LogSpan::Info => "info", LogSpan::Warn => "warn", LogSpan::Error => "error", } } } pub type Logs = Vec<(LogSpan, String)>; pub trait LogsExt { fn span>(&mut self, span: LogSpan, msg: T); fn log>(&mut self, msg: T); fn info>(&mut self, msg: T); fn warn>(&mut self, msg: T); fn error>(&mut self, msg: T); } impl LogsExt for Logs { fn span>(&mut self, span: LogSpan, msg: T) { self.push((span, msg.as_ref().to_string())); } fn log>(&mut self, msg: T) { self.span(LogSpan::Log, msg); } fn info>(&mut self, msg: T) { self.span(LogSpan::Info, msg); } fn warn>(&mut self, msg: T) { self.span(LogSpan::Warn, msg); } fn error>(&mut self, msg: T) { self.span(LogSpan::Error, msg); } } pub fn take_logs(logs: Arc>>) -> Logs { logs.lock().take().unwrap() } /// 合并多个配置 // TODO: 可能移动到其他地方 // TODO: 增加自定义合并逻辑 // TODO: 添加元信息 pub fn merge_profiles>(mappings: IndexMap) -> Mapping { mappings .into_iter() .enumerate() .fold(Mapping::new(), |mut acc, (idx, (_key, value))| { // full extend the first one, others just extend proxies // TODO: custom merge logic // TODO: add meta info if idx == 0 { acc.extend(value); } else { let proxies = value.get("proxies").unwrap().as_sequence().unwrap().clone(); let acc_proxies = acc.get_mut("proxies").unwrap().as_sequence_mut().unwrap(); acc_proxies.extend(proxies); } acc }) } /// 处理链 pub async fn process_chain( mut config: Mapping, nodes: &[ChainItem], ) -> (Mapping, IndexMap) { let mut result_map = IndexMap::new(); let mut script_runner = RunnerManager::new(); for item in nodes.iter() { match &item.data { ChainTypeWrapper::Merge(merge) => { let mut logs = vec![]; let (res, process_logs) = use_merge(merge, config.clone()); config = res.unwrap(); logs.extend(process_logs); result_map.insert(item.uid.to_string(), logs); } ChainTypeWrapper::Script(script) => { let mut logs = vec![]; let (res, process_logs) = script_runner.process_script(script, config.clone()).await; logs.extend(process_logs); // TODO: 修改日记 level 格式? match res { Ok(res_config) => { config = res_config; } Err(err) => logs.error(err.to_string()), } // TODO: 这里添加对 field 的检查,触发 WARN 日记。此外,需要对 Merge 的结果进行检查? result_map.insert(item.uid.to_string(), logs); } } } (config, result_map) } #[cfg(test)] mod tests { use crate::enhance::chain::ChainTypeWrapper; use super::*; use serde_yaml::Value; #[tokio::test] async fn test_process_chain_order() { // 准备初始配置 let mut initial_config = Mapping::new(); initial_config.insert( Value::String("value".to_string()), Value::String("initial".to_string()), ); // 创建两个 ChainItem let item_a = ChainItem { uid: "a".to_string(), data: ChainTypeWrapper::new_js( "function main(cfg) { cfg.value = 'a'; return cfg; }".to_string(), ), }; let item_b = ChainItem { uid: "b".to_string(), data: ChainTypeWrapper::new_js( "function main(cfg) { cfg.value = cfg.value + '_b'; return cfg; }".to_string(), ), }; let chain = vec![item_a, item_b]; // 执行处理链 let (final_config, logs) = process_chain(initial_config, &chain).await; // 验证最终结果 assert_eq!( final_config.get("value").unwrap().as_str().unwrap(), "a_b", "链式处理应该按顺序执行:A 将值设为 'a',然后 B 将 'a' 修改为 'a_b'" ); // 验证日志存在 assert!(logs.contains_key("a"), "应该包含 A 的处理日志"); assert!(logs.contains_key("b"), "应该包含 B 的处理日志"); } } ================================================ FILE: backend/tauri/src/event_handler/mod.rs ================================================ /// This module is a tauri event based handler. /// Some state is good to be managed by the Tauri Manager. we should not hold the singletons in the global state in some cases. use tauri::{Emitter, Listener, Manager, Runtime}; mod widget; pub fn mount_handlers(_app: &mut M) where M: Manager + Listener + Emitter, R: Runtime, { } ================================================ FILE: backend/tauri/src/event_handler/widget.rs ================================================ use tauri::{AppHandle, Event, Runtime}; pub enum WidgetInstance { Small(nyanpasu_egui::widget::NyanpasuNetworkStatisticSmallWidget), Large(nyanpasu_egui::widget::NyanpasuNetworkStatisticLargeWidget), } #[tracing::instrument(skip(_app_handle))] pub(super) fn on_network_statistic_config_changed( _app_handle: &AppHandle, event: Event, ) -> anyhow::Result<()> { // let config: NetworkStatisticWidgetConfig = // serde_json::from_str(event.payload()).context("failed to deserialize the new config")?; // match config { // NetworkStatisticWidgetConfig::Disabled => { // app_handle // .emit_all("network-statistic-widget:hide") // .context("failed to emit the hide event")?; // } // NetworkStatisticWidgetConfig::Large => { // app_handle // .emit_all("network-statistic-widget:show-large") // .context("failed to emit the show-large event")?; // } // NetworkStatisticWidgetConfig::Small => { // app_handle // .emit_all("network-statistic-widget:show-small") // .context("failed to emit the show-small event")?; // } // } Ok(()) } ================================================ FILE: backend/tauri/src/feat.rs ================================================ //! //! feat mod 里的函数主要用于 //! - hotkey 快捷键 //! - timer 定时器 //! - cmds 页面调用 //! use std::borrow::Borrow; use crate::{ config::{ nyanpasu::NetworkStatisticWidgetConfig, profile::{ builder::ProfileBuilder, item::{ LocalProfileBuilder, MergeProfileBuilder, ProfileSharedBuilder, ScriptProfileBuilder, }, }, *, }, core::{service::ipc::get_ipc_state, *}, log_err, utils::{self, help::get_clash_external_port, resolve}, }; use anyhow::{Result, bail}; use handle::Message; use nyanpasu_ipc::api::status::CoreState; use serde_yaml::{Mapping, Value}; use tauri::{AppHandle, Manager}; use tauri_plugin_clipboard_manager::ClipboardExt; // 打开面板 #[allow(unused)] pub fn open_dashboard() { let handle = handle::Handle::global(); let app_handle = handle.app_handle.lock(); if let Some(app_handle) = app_handle.as_ref() { resolve::create_window(app_handle); } } // 关闭面板 #[allow(unused)] pub fn close_dashboard() { let handle = handle::Handle::global(); let app_handle = handle.app_handle.lock(); if let Some(app_handle) = app_handle.as_ref() { resolve::close_window(app_handle); } } // 开关面板 pub fn toggle_dashboard() { let handle = handle::Handle::global(); let app_handle = handle.app_handle.lock(); if let Some(app_handle) = app_handle.as_ref() { match resolve::is_window_open(app_handle) { true => resolve::close_window(app_handle), false => resolve::create_window(app_handle), } } } // 重启clash pub fn restart_clash_core() { tauri::async_runtime::spawn(async { match CoreManager::global().run_core().await { Ok(_) => { handle::Handle::refresh_clash(); handle::Handle::notice_message(&Message::SetConfig(Ok(()))); } Err(err) => { handle::Handle::notice_message(&Message::SetConfig(Err(format!("{err:?}")))); log::error!(target:"app", "{err:?}"); } } }); } // 切换模式 rule/global/direct/script mode pub fn change_clash_mode(mode: String) { let mut mapping = Mapping::new(); mapping.insert(Value::from("mode"), mode.clone().into()); let (tx, rx) = tokio::sync::oneshot::channel(); tauri::async_runtime::spawn(async move { log::debug!(target: "app", "change clash mode to {mode}"); match clash::api::patch_configs(&mapping).await { Ok(_) => { // 更新配置 Config::clash().data().patch_config(mapping); if Config::clash().data().save_config().is_ok() { handle::Handle::refresh_clash(); log_err!(handle::Handle::update_systray_part()); } } Err(err) => log::error!(target: "app", "{err:?}"), } if tx.send(()).is_err() { log::error!(target: "app::change_clash_mode", "failed to send tx"); } }); // refresh proxies update_proxies_buff(Some(rx)); // Interrupt connections based on configuration tauri::async_runtime::spawn(async move { let _ = crate::core::connection_interruption::ConnectionInterruptionService::on_mode_change() .await; }); } // 切换系统代理 pub fn toggle_system_proxy() { let enable = Config::verge().draft().enable_system_proxy; let enable = enable.unwrap_or(false); tauri::async_runtime::spawn(async move { match patch_verge(IVerge { enable_system_proxy: Some(!enable), ..IVerge::default() }) .await { Ok(_) => handle::Handle::refresh_verge(), Err(err) => log::error!(target: "app", "{err:?}"), } }); } // 打开系统代理 pub fn enable_system_proxy() { tauri::async_runtime::spawn(async { match patch_verge(IVerge { enable_system_proxy: Some(true), ..IVerge::default() }) .await { Ok(_) => handle::Handle::refresh_verge(), Err(err) => log::error!(target: "app", "{err:?}"), } }); } // 关闭系统代理 pub fn disable_system_proxy() { tauri::async_runtime::spawn(async { match patch_verge(IVerge { enable_system_proxy: Some(false), ..IVerge::default() }) .await { Ok(_) => handle::Handle::refresh_verge(), Err(err) => log::error!(target: "app", "{err:?}"), } }); } // 切换tun模式 pub fn toggle_tun_mode() { let enable = Config::verge().data().enable_tun_mode; let enable = enable.unwrap_or(false); tauri::async_runtime::spawn(async move { match patch_verge(IVerge { enable_tun_mode: Some(!enable), ..IVerge::default() }) .await { Ok(_) => handle::Handle::refresh_verge(), Err(err) => log::error!(target: "app", "{err:?}"), } }); } // 打开tun模式 pub fn enable_tun_mode() { tauri::async_runtime::spawn(async { match patch_verge(IVerge { enable_tun_mode: Some(true), ..IVerge::default() }) .await { Ok(_) => handle::Handle::refresh_verge(), Err(err) => log::error!(target: "app", "{err:?}"), } }); } // 关闭tun模式 pub fn disable_tun_mode() { tauri::async_runtime::spawn(async { match patch_verge(IVerge { enable_tun_mode: Some(false), ..IVerge::default() }) .await { Ok(_) => handle::Handle::refresh_verge(), Err(err) => log::error!(target: "app", "{err:?}"), } }); } /// 修改clash的配置 pub async fn patch_clash(patch: Mapping) -> Result<()> { Config::clash().draft().patch_config(patch.clone()); let run = move || async move { let mixed_port = patch.get("mixed-port"); let enable_random_port = Config::verge().latest().enable_random_port.unwrap_or(false); if mixed_port.is_some() && !enable_random_port { let changed = mixed_port.unwrap() != Config::verge() .latest() .verge_mixed_port .unwrap_or(Config::clash().data().get_mixed_port()); // 检查端口占用 if changed && let Some(port) = mixed_port.unwrap().as_u64() && !port_scanner::local_port_available(port as u16) { Config::clash().discard(); bail!("port already in use"); } }; // 检测 external-controller port 是否修改 if let Some(external_controller) = patch.get("external-controller") { let external_controller = external_controller.as_str().unwrap(); let changed = external_controller != Config::clash().data().get_client_info().server; if changed { let (_, port) = external_controller.split_once(':').unwrap(); let port = port.parse::()?; let strategy = Config::verge() .latest() .get_external_controller_port_strategy(); let core_state = crate::core::CoreManager::global().status().await; if matches!(core_state.0.as_ref(), &CoreState::Running) && get_clash_external_port(&strategy, port).is_err() { Config::clash().discard(); bail!("can not select fixed: current port is not available."); } } } // 激活配置 if mixed_port.is_some() || patch.get("secret").is_some() || patch.get("external-controller").is_some() { Config::generate().await?; CoreManager::global().run_core().await?; handle::Handle::refresh_clash(); } // 更新系统代理 if mixed_port.is_some() { log_err!(sysopt::Sysopt::global().init_sysproxy()); } if patch.get("mode").is_some() { crate::feat::update_proxies_buff(None); log_err!(handle::Handle::update_systray_part()); } Config::runtime().latest().patch_config(patch); >::Ok(()) }; match run().await { Ok(()) => { Config::clash().apply(); Config::clash().data().save_config()?; Ok(()) } Err(err) => { Config::clash().discard(); Err(err) } } } /// 修改verge的配置 /// 一般都是一个个的修改 pub async fn patch_verge(patch: IVerge) -> Result<()> { // Validate theme_color if it's being updated if let Some(ref theme_color) = patch.theme_color { if !theme_color.is_empty() && !crate::config::nyanpasu::is_hex_color(theme_color) { anyhow::bail!("Invalid theme color: {}", theme_color); } } Config::verge().draft().patch_config(patch.clone()); let tun_mode = patch.enable_tun_mode; let auto_launch = patch.enable_auto_launch; let system_proxy = patch.enable_system_proxy; let proxy_bypass = patch.system_proxy_bypass; let language = patch.language; let log_level = patch.app_log_level; let log_max_files = patch.max_log_files; let enable_tray_selector = patch.clash_tray_selector; let enable_tray_text = patch.enable_tray_text; let network_statistic_widget = patch.network_statistic_widget; let res = || async move { let service_mode = patch.enable_service_mode; let ipc_state = get_ipc_state(); if service_mode.is_some() && ipc_state.is_connected() { log::debug!(target: "app", "change service mode to {}", service_mode.unwrap()); Config::generate().await?; CoreManager::global().run_core().await?; } if tun_mode.is_some() { log::debug!(target: "app", "toggle tun mode"); #[allow(unused_mut)] let mut flag = false; #[cfg(any(target_os = "macos", target_os = "linux"))] { use crate::utils::dirs::check_core_permission; let current_core = Config::verge().data().clash_core.unwrap_or_default(); let current_core: nyanpasu_utils::core::CoreType = (¤t_core).into(); let service_state = crate::core::service::ipc::get_ipc_state(); if !service_state.is_connected() && check_core_permission(¤t_core).inspect_err(|e| { log::error!(target: "app", "clash core is not granted the necessary permissions, grant it: {e:?}"); }).is_ok_and(|v| !v) { log::debug!(target: "app", "grant core permission, and restart core"); flag = true; } } let (state, _, _) = CoreManager::global().status().await; if flag || matches!(state.as_ref(), CoreState::Stopped(_)) { log::debug!(target: "app", "core is stopped, restart core"); Config::generate().await?; CoreManager::global().run_core().await?; } else { log::debug!(target: "app", "update core config"); #[cfg(target_os = "macos")] let _ = CoreManager::global() .change_default_network_dns(tun_mode.unwrap_or(false)) .await .inspect_err( |e| log::error!(target: "app", "failed to set system dns: {:?}", e), ); update_core_config().await?; } } if auto_launch.is_some() { sysopt::Sysopt::global().update_launch()?; } if system_proxy.is_some() || proxy_bypass.is_some() { sysopt::Sysopt::global().update_sysproxy()?; sysopt::Sysopt::global().guard_proxy(); } if let Some(true) = patch.enable_proxy_guard { sysopt::Sysopt::global().guard_proxy(); } if let Some(hotkeys) = patch.hotkeys { hotkey::Hotkey::global().update(hotkeys)?; } if language.is_some() { rust_i18n::set_locale(language.unwrap().as_str()); handle::Handle::update_systray()?; } else if system_proxy.or(tun_mode).or(enable_tray_text).is_some() { handle::Handle::update_systray_part()?; } if log_level.is_some() || log_max_files.is_some() { utils::init::refresh_logger((log_level, log_max_files))?; } if enable_tray_selector.is_some() { handle::Handle::update_systray()?; } // TODO: refactor config with changed notify if let Some(network_statistic_widget) = network_statistic_widget { let widget_manager = crate::consts::app_handle().state::(); let is_running = widget_manager.is_running().await; match network_statistic_widget { NetworkStatisticWidgetConfig::Disabled => { if is_running { widget_manager.stop().await?; } } NetworkStatisticWidgetConfig::Enabled(variant) => { widget_manager.start(variant).await?; } } } >::Ok(()) }; match res().await { Ok(()) => { Config::verge().apply(); Config::verge().data().save_file()?; Ok(()) } Err(err) => { Config::verge().discard(); Err(err) } } } /// 更新某个profile /// 如果更新当前配置就激活配置 pub async fn update_profile>( uid: T, opts: Option, ) -> Result<()> { let uid = uid.borrow(); let profile_item = Config::profiles().latest().get_item(uid)?.clone(); let is_remote = profile_item.is_remote(); let should_update = if is_remote { let mut item = profile_item.as_remote().unwrap().clone(); item.subscribe(opts).await?; let committer = Config::profiles().auto_commit(); let mut profiles = committer.draft(); profiles.replace_item(uid, item.into())?; profiles.get_current().contains(uid) } else { // For local profiles, we need to update the timestamp let committer = Config::profiles().auto_commit(); let mut profiles = committer.draft(); // Create a builder to update the timestamp match profile_item { Profile::Local(_) => { let mut shared_builder = ProfileSharedBuilder::default(); shared_builder.updated(chrono::Local::now().timestamp() as usize); let mut builder = LocalProfileBuilder::default(); builder.shared(shared_builder); profiles.patch_item(uid.to_string(), ProfileBuilder::Local(builder))?; } Profile::Merge(_) => { let mut shared_builder = ProfileSharedBuilder::default(); shared_builder.updated(chrono::Local::now().timestamp() as usize); let mut builder = MergeProfileBuilder::default(); builder.shared(shared_builder); profiles.patch_item(uid.to_string(), ProfileBuilder::Merge(builder))?; } Profile::Script(_) => { let mut shared_builder = ProfileSharedBuilder::default(); shared_builder.updated(chrono::Local::now().timestamp() as usize); let mut builder = ScriptProfileBuilder::default(); builder.shared(shared_builder); profiles.patch_item(uid.to_string(), ProfileBuilder::Script(builder))?; } _ => {} } profiles.get_current().contains(uid) }; if should_update { update_core_config().await?; } Ok(()) } /// 更新配置 async fn update_core_config() -> Result<()> { match CoreManager::global().update_config().await { Ok(_) => { handle::Handle::refresh_clash(); handle::Handle::notice_message(&Message::SetConfig(Ok(()))); Ok(()) } Err(err) => { handle::Handle::notice_message(&Message::SetConfig(Err(format!("{err:?}")))); Err(err) } } } /// copy env variable pub fn copy_clash_env(app_handle: &AppHandle, option: &str) { let port = { Config::verge().latest().verge_mixed_port.unwrap_or(7890) }; let http_proxy = format!("http://127.0.0.1:{port}"); let socks5_proxy = format!("socks5://127.0.0.1:{port}"); let sh = format!("export https_proxy={http_proxy} http_proxy={http_proxy} all_proxy={socks5_proxy}"); let cmd: String = format!("set http_proxy={http_proxy} \n set https_proxy={http_proxy}"); let ps: String = format!("$env:HTTP_PROXY=\"{http_proxy}\"; $env:HTTPS_PROXY=\"{http_proxy}\""); let clipboard = app_handle.clipboard(); match option { "sh" => { if let Err(e) = clipboard.write_text(sh) { log::error!(target: "app", "copy_clash_env failed: {e}"); } } "cmd" => { if let Err(e) = clipboard.write_text(cmd) { log::error!(target: "app", "copy_clash_env failed: {e}"); } } "ps" => { if let Err(e) = clipboard.write_text(ps) { log::error!(target: "app", "copy_clash_env failed: {e}"); } } _ => log::error!(target: "app", "copy_clash_env: Invalid option! {option}"), } } pub fn update_proxies_buff(rx: Option>) { use crate::core::clash::proxies::{ProxiesGuard, ProxiesGuardExt}; tauri::async_runtime::spawn(async move { if let Some(rx) = rx && let Err(e) = rx.await { log::error!(target: "app::clash::proxies", "update proxies buff by rx failed: {e}"); } match ProxiesGuard::global().update().await { Ok(_) => { log::debug!(target: "app::clash::proxies", "update proxies buff success"); } Err(e) => { log::error!(target: "app::clash::proxies", "update proxies buff failed: {e}"); } } }); } ================================================ FILE: backend/tauri/src/ipc.rs ================================================ use crate::{ config::{profile::ProfileBuilder, *}, core::{ logger::Logger, storage::Storage, tasks::jobs::ProfilesJobGuard, updater::ManifestVersionLatest, *, }, enhance::PostProcessingOutput, feat, utils::{candy, collect::EnvInfo, dirs, help, resolve}, }; use anyhow::{Context, anyhow}; use chrono::Local; use log::debug; use nyanpasu_ipc::api::status::CoreState; use profile::item_type::ProfileItemType; use serde_yaml::Mapping; use std::{borrow::Cow, collections::VecDeque, path::PathBuf, result::Result as StdResult}; use storage::{StorageOperationError, WebStorage}; use sysproxy::Sysproxy; use tauri::{AppHandle, Manager}; use tray::icon::TrayIcon; use tauri_plugin_dialog::{DialogExt, FileDialogBuilder}; #[derive(Debug, thiserror::Error)] pub enum IpcError { #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] SerdeYaml(#[from] serde_yaml::Error), #[error(transparent)] SerdeJson(#[from] serde_json::Error), #[error(transparent)] Tauri(#[from] tauri::Error), #[error(transparent)] Storage(#[from] StorageOperationError), #[error(transparent)] Anyhow(#[from] anyhow::Error), #[error("{0}")] Custom(String), } impl From for IpcError { fn from(s: String) -> Self { IpcError::Custom(s) } } impl serde::Serialize for IpcError { fn serialize(&self, serializer: S) -> StdResult where S: serde::ser::Serializer, { serializer.serialize_str(format!("{self:#?}").as_str()) } } impl specta::Type for IpcError { fn inline( type_map: &mut specta::TypeMap, generics: specta::Generics, ) -> specta::datatype::DataType { specta::datatype::DataType::Primitive(specta::datatype::PrimitiveType::String) } } type Result = StdResult; // TODO: remove this struct use Sysproxy #[derive(specta::Type, serde::Serialize)] pub struct GetSysProxyResponse { // Sysproxy fields (manually defined), // because specta not support serde(flatten) pub enable: bool, pub host: String, pub port: u16, pub bypass: String, // old version compatible pub server: String, } #[tauri::command] #[specta::specta] pub fn get_profiles() -> Result { Ok(Config::profiles().data().clone()) } #[cfg(target_os = "windows")] #[tauri::command] #[specta::specta] pub fn is_portable() -> Result { Ok(crate::utils::dirs::get_portable_flag()) } #[cfg(not(target_os = "windows"))] #[tauri::command] #[specta::specta] pub fn is_portable() -> Result { Ok(false) } #[tauri::command] #[specta::specta] pub async fn enhance_profiles() -> Result { CoreManager::global().update_config().await?; handle::Handle::refresh_clash(); Ok(()) } #[tauri::command] #[specta::specta] pub async fn import_profile(url: String, option: Option) -> Result { let url = url::Url::parse(&url).context("failed to parse the url")?; let mut builder = crate::config::profile::item::RemoteProfileBuilder::default(); builder.url(url); if let Some(option) = option { builder.option(option.clone()); } let profile = builder .build_no_blocking() .await .context("failed to build a remote profile")?; // 根据是否为 Some(uid) 来判断是否要激活配置 let profile_id = { if Config::profiles().draft().current.is_empty() { Some(profile.uid().to_string()) } else { None } }; { let committer = Config::profiles().auto_commit(); (committer.draft().append_item(profile.into()))?; } // TODO: 使用 activate_profile 来激活配置 if let Some(profile_id) = profile_id { let mut builder = ProfilesBuilder::default(); builder.current(vec![profile_id]); patch_profiles_config(builder).await?; } Ok(()) } /// create a new profile #[tauri::command] #[specta::specta] pub async fn create_profile(item: ProfileBuilder, file_data: Option) -> Result { tracing::trace!("create profile: {item:?}"); let is_remote = matches!(&item, ProfileBuilder::Remote(_)); let profile: Profile = match item { ProfileBuilder::Local(builder) => builder .build() .context("failed to build local profile")? .into(), ProfileBuilder::Remote(mut builder) => builder .build_no_blocking() .await .context("failed to build remote profile")? .into(), ProfileBuilder::Merge(builder) => builder .build() .context("failed to build merge profile")? .into(), ProfileBuilder::Script(builder) => builder .build() .context("failed to build script profile")? .into(), }; tracing::info!("created new profile: {:#?}", profile); // Save file data for non-remote profiles if let Some(file_data) = file_data && !file_data.is_empty() && !is_remote { profile.save_file(file_data)?; } // 根据是否为 Some(uid) 来判断是否要激活配置 let profile_id = { if (profile.is_local() || profile.is_remote()) && Config::profiles().draft().current.is_empty() { Some(profile.uid().to_string()) } else { None } }; // Save the profile { let committer = Config::profiles().auto_commit(); committer.draft().append_item(profile)?; }; // TODO: 使用 activate_profile 来激活配置 if let Some(profile_id) = profile_id { let mut builder = ProfilesBuilder::default(); builder.current(vec![profile_id]); patch_profiles_config(builder).await?; } Ok(()) } #[tauri::command] #[specta::specta] pub async fn reorder_profile(active_id: String, over_id: String) -> Result { let committer = Config::profiles().auto_commit(); (committer.draft().reorder(active_id, over_id))?; Ok(()) } #[tauri::command] #[specta::specta] pub fn reorder_profiles_by_list(list: Vec) -> Result { let committer = Config::profiles().auto_commit(); (committer.draft().reorder_by_list(&list))?; Ok(()) } #[tauri::command] #[specta::specta] pub async fn update_profile(uid: String, option: Option) -> Result { (feat::update_profile(uid, option).await)?; Ok(()) } #[tauri::command] #[specta::specta] pub async fn delete_profile(uid: String) -> Result { let should_update = tokio::task::spawn_blocking(move || { #[allow(clippy::let_and_return)] // a bug in clippy nyanpasu_utils::runtime::block_on_current_thread(async move { let committer = Config::profiles().auto_commit(); let x = committer.draft().delete_item(&uid).await; x }) }) .await .context("failed to join the task")? .context("failed to delete the profile")?; if should_update { (CoreManager::global().update_config().await)?; handle::Handle::refresh_clash(); } Ok(()) } /// 修改profiles的 #[tauri::command] #[specta::specta] pub async fn patch_profiles_config(profiles: ProfilesBuilder) -> Result { Config::profiles().draft().apply(profiles); match CoreManager::global().update_config().await { Ok(_) => { handle::Handle::refresh_clash(); Config::profiles().apply(); (Config::profiles().data().save_file())?; // Interrupt connections based on configuration let _ = crate::core::connection_interruption::ConnectionInterruptionService::on_profile_change().await; Ok(()) } Err(err) => { Config::profiles().discard(); log::error!(target: "app", "{err:?}"); Err(IpcError::from(err)) } } } /// update profile by uid #[tauri::command] #[specta::specta] pub async fn patch_profile(app_handle: AppHandle, uid: String, profile: ProfileBuilder) -> Result { tracing::debug!("patch profile: {uid} with {profile:?}"); { let committer = Config::profiles().auto_commit(); (committer.draft().patch_item(uid.clone(), profile))?; } { let profiles_jobs = app_handle.state::(); profiles_jobs.write().refresh(); } let need_update = { let profiles = Config::profiles(); let profiles = profiles.latest(); match (&profiles.chain, &profiles.current) { (chains, _) if chains.contains(&uid) => true, (_, current_chain) if current_chain.contains(&uid) => true, (_, current_chain) => { current_chain .iter() .any(|chain_uid| match profiles.get_item(chain_uid) { Ok(item) if item.is_local() => { item.as_local().unwrap().chain.contains(&uid) } Ok(item) if item.is_remote() => { item.as_remote().unwrap().chain.contains(&uid) } _ => false, }) } } }; if need_update { match CoreManager::global().update_config().await { Ok(_) => { handle::Handle::refresh_clash(); } Err(err) => { log::error!(target: "app", "{err:?}"); } } } Ok(()) } #[tauri::command] #[specta::specta] pub fn view_profile(app_handle: tauri::AppHandle, uid: String) -> Result { let file = { Config::profiles() .latest() .get_item(&uid)? .file() .to_string() }; let path = (dirs::app_profiles_dir())?.join(file); if !path.exists() { return Err(anyhow!("file not exists: {:#?}", path).into()); } help::open_file(app_handle, path)?; Ok(()) } #[tauri::command] #[specta::specta] pub fn read_profile_file(uid: String) -> Result { let profiles = Config::profiles(); let profiles = profiles.latest(); let item = (profiles.get_item(&uid))?; let data = match item.kind() { ProfileItemType::Local | ProfileItemType::Remote => { let raw = (item.read_file())?; let data = (serde_yaml::from_str::(&raw))?; (serde_yaml::to_string(&data).context("failed to convert yaml to string"))? } _ => (item.read_file())?, }; Ok(data) } #[tauri::command] #[specta::specta] pub fn save_profile_file(uid: String, file_data: Option) -> Result { if file_data.is_none() { return Ok(()); } let profiles = Config::profiles(); let profiles = profiles.latest(); let item = (profiles.get_item(&uid))?; (item.save_file(file_data.unwrap()))?; Ok(()) } #[tauri::command] #[specta::specta] pub fn get_clash_info() -> Result { Ok(Config::clash().latest().get_client_info()) } /// get the runtime config #[tauri::command] #[specta::specta] pub fn get_runtime_config() -> Result> { let config = Config::runtime().latest().config.clone(); match config { Some(cfg) => { let yaml_value = serde_yaml::to_value(cfg)?; let json_value = serde_json::to_value(&yaml_value)?; Ok(Some(json_value)) } None => Ok(None), } } #[tauri::command] #[specta::specta] pub fn get_runtime_yaml() -> Result { let runtime = Config::runtime(); let runtime = runtime.latest(); let config = runtime.config.as_ref(); let mapping = (config .ok_or(anyhow::anyhow!("failed to parse config to yaml file")) .and_then(|config| { serde_yaml::to_string(config).context("failed to convert config to yaml") }))?; Ok(mapping) } #[tauri::command] #[specta::specta] pub fn get_runtime_exists() -> Result> { Ok(Config::runtime().latest().exists_keys.clone()) } #[tauri::command] #[specta::specta] pub fn get_postprocessing_output() -> Result { Ok(Config::runtime().latest().postprocessing_output.clone()) } #[tauri::command] #[specta::specta] pub async fn get_core_status<'n>() -> Result<(Cow<'n, CoreState>, i64, RunType)> { Ok(CoreManager::global().status().await) } #[tauri::command] #[specta::specta] pub async fn url_delay_test(url: &str, expected_status: u16) -> Result> { Ok(crate::utils::net::url_delay_test(url, expected_status).await) } #[tauri::command] #[specta::specta] pub async fn get_ipsb_asn() -> Result { Ok(crate::utils::net::get_ipsb_asn().await?) } /// patch clash runtime config #[tauri::command] #[specta::specta] #[tracing_attributes::instrument] pub async fn patch_clash_config(payload: PatchRuntimeConfig) -> Result { tracing::debug!("patch_clash_config: {payload:?}"); let mapping = match serde_yaml::to_value(&payload)? { serde_yaml::Value::Mapping(m) => m, _ => return Err(IpcError::Custom("Expected a mapping".to_string())), }; (crate::core::clash::api::patch_configs(&mapping).await)?; if let Err(e) = feat::patch_clash(mapping).await { tracing::error!("{e}"); return Err(IpcError::from(e)); } feat::update_proxies_buff(None); Ok(()) } #[tauri::command] #[specta::specta] pub fn get_verge_config() -> Result { Ok(Config::verge().data().clone()) } #[tauri::command] #[specta::specta] pub async fn patch_verge_config(payload: IVerge) -> Result { (feat::patch_verge(payload).await)?; Ok(()) } #[tauri::command] #[specta::specta] pub async fn change_clash_core(clash_core: Option) -> Result { (CoreManager::global().change_core(clash_core).await)?; Ok(()) } /// restart the sidecar #[tauri::command] #[specta::specta] pub async fn restart_sidecar() -> Result { (CoreManager::global().run_core().await)?; Ok(()) } /// get the system proxy /// server field is the combination of host and port #[tauri::command] #[specta::specta] pub fn get_sys_proxy() -> Result { let current = (Sysproxy::get_system_proxy()).context("failed to get system proxy")?; let server = format!("{}:{}", current.host, current.port); Ok(GetSysProxyResponse { enable: current.enable, host: current.host, port: current.port, bypass: current.bypass, server, }) } #[tauri::command] #[specta::specta] pub fn get_clash_logs() -> Result> { Ok(Logger::global().get_log()) } #[tauri::command] #[specta::specta] pub fn open_app_config_dir() -> Result<()> { let config_dir = (dirs::app_config_dir())?; (crate::utils::open::that(config_dir))?; Ok(()) } #[tauri::command] #[specta::specta] pub fn open_app_data_dir() -> Result<()> { let data_dir = (dirs::app_data_dir())?; (crate::utils::open::that(data_dir))?; Ok(()) } #[tauri::command] #[specta::specta] pub fn open_core_dir() -> Result<()> { let core_dir = (tauri::utils::platform::current_exe())?; let core_dir = core_dir .parent() .ok_or("failed to get core dir".to_string())?; (crate::utils::open::that(core_dir))?; Ok(()) } #[tauri::command] #[specta::specta] pub fn get_core_dir() -> Result { let core_dir = (tauri::utils::platform::current_exe())?; let core_dir = core_dir .parent() .ok_or("failed to get core dir".to_string())?; let core_dir = dunce::canonicalize(core_dir)?; Ok(core_dir.to_string_lossy().to_string()) } #[tauri::command] #[specta::specta] pub fn open_logs_dir() -> Result<()> { let log_dir = (dirs::app_logs_dir())?; (crate::utils::open::that(log_dir))?; Ok(()) } #[tauri::command] #[specta::specta] pub fn open_web_url(url: String) -> Result<()> { (crate::utils::open::that(url))?; Ok(()) } #[tauri::command] #[specta::specta] pub async fn fetch_latest_core_versions() -> Result { let mut updater = updater::UpdaterManager::global().write().await; // It is intended to block here (updater.fetch_latest().await)?; // TODO: result key should be kebab-case Ok(updater.get_latest_versions()) } #[tauri::command] #[specta::specta] pub async fn get_core_version( app_handle: AppHandle, core_type: nyanpasu::ClashCore, ) -> Result { match resolve::resolve_core_version(&app_handle, &core_type).await { Ok(version) => Ok(version), Err(err) => Err(IpcError::from(err)), } } #[tauri::command] #[specta::specta] pub async fn collect_logs(app_handle: AppHandle) -> Result { let now = Local::now().format("%Y-%m-%d"); let fname = format!("{now}-log"); let builder = FileDialogBuilder::new(app_handle.dialog().clone()); builder .add_filter("archive files", &["zip"]) .set_file_name(&fname) .set_title("Save log archive") .save_file(|file_path| match file_path { Some(path) if path.as_path().is_some() => { debug!("{path:#?}"); match candy::collect_logs(path.as_path().unwrap()) { Ok(_) => (), Err(err) => { log::error!(target: "app", "{err:?}"); } } } _ => (), }); Ok(()) } #[tauri::command] #[specta::specta] pub async fn update_core(core_type: nyanpasu::ClashCore) -> Result { let event_id = (updater::UpdaterManager::global() .write() .await .update_core(&core_type) .await)?; Ok(event_id) } #[tauri::command] #[specta::specta] pub async fn inspect_updater(updater_id: usize) -> Result { let updater = (updater::UpdaterManager::global() .read() .await .inspect_updater(updater_id) .ok_or(anyhow::anyhow!("updater is not exist")))?; Ok(updater) } #[tauri::command] #[specta::specta] pub async fn clash_api_get_proxy_delay( name: String, url: Option, ) -> Result { match clash::api::get_proxy_delay(name, url).await { Ok(res) => Ok(res), Err(err) => Err(err.into()), } } #[tauri::command] #[specta::specta] pub async fn get_proxies() -> Result { use crate::core::clash::proxies::{ProxiesGuard, ProxiesGuardExt}; { let guard = ProxiesGuard::global().read(); if guard.is_updated() { return Ok(guard.inner().clone()); } } match ProxiesGuard::global().update().await { Ok(_) => { let proxies = ProxiesGuard::global().read().inner().clone(); Ok(proxies) } Err(err) => Err(err.into()), } } #[tauri::command] #[specta::specta] pub async fn mutate_proxies() -> Result { use crate::core::clash::proxies::{ProxiesGuard, ProxiesGuardExt}; (ProxiesGuard::global().update().await)?; Ok(ProxiesGuard::global().read().inner().clone()) } #[tauri::command] #[specta::specta] pub async fn select_proxy(group: String, name: String) -> Result<()> { use crate::core::clash::proxies::{ProxiesGuard, ProxiesGuardExt}; (ProxiesGuard::global().select_proxy(&group, &name).await)?; // Interrupt connections based on configuration let _ = crate::core::connection_interruption::ConnectionInterruptionService::on_proxy_change() .await; Ok(()) } #[tauri::command] #[specta::specta] pub async fn update_proxy_provider(name: String) -> Result<()> { use crate::core::clash::{ api, proxies::{ProxiesGuard, ProxiesGuardExt}, }; (api::update_providers_proxies_group(&name).await)?; (ProxiesGuard::global().update().await)?; Ok(()) } #[tauri::command] #[specta::specta] pub fn collect_envs<'a>() -> Result> { Ok((crate::utils::collect::collect_envs())?) } #[tauri::command] #[specta::specta] pub fn open_that(path: String) -> Result { (crate::utils::open::that(path))?; Ok(()) } #[tauri::command] #[specta::specta] pub fn is_appimage() -> Result { Ok(*crate::consts::IS_APPIMAGE) } #[cfg(windows)] #[tauri::command] #[specta::specta] pub fn get_custom_app_dir() -> Result> { use crate::utils::winreg::get_app_dir; match get_app_dir() { Ok(Some(path)) => Ok(Some(path.to_string_lossy().to_string())), Ok(None) => Ok(None), Err(err) => Err(IpcError::from(err)), } } #[cfg(not(windows))] #[tauri::command] #[specta::specta] pub fn get_custom_app_dir() -> Result> { Ok(None) } #[cfg(windows)] #[tauri::command] #[specta::specta] pub async fn set_custom_app_dir(app_handle: tauri::AppHandle, path: String) -> Result { use crate::utils::{self, dialog::migrate_dialog, winreg::set_app_dir}; use rust_i18n::t; use std::path::PathBuf; let path_str = path.clone(); let path = PathBuf::from(path); // show a dialog to ask whether to migrate the data let res = tauri::async_runtime::spawn_blocking(move || { let msg = t!("dialog.custom_app_dir_migrate", path = path_str).to_string(); if migrate_dialog(&msg) { let app_exe = tauri::utils::platform::current_exe()?; let app_exe = dunce::canonicalize(app_exe)?.to_string_lossy().to_string(); std::process::Command::new("powershell") .arg("-Command") .arg( format!( r#"Start-Process '{}' -ArgumentList 'migrate-home-dir','"{}"' -Verb runAs"#, app_exe.as_str(), path_str.as_str() ) .as_str(), ).spawn().unwrap().wait()?; utils::help::quit_application(&app_handle); } else { set_app_dir(&path)?; } Ok::<_, anyhow::Error>(()) }) .await; ((res)?)?; Ok(()) } #[tauri::command] #[specta::specta] pub fn restart_application(app_handle: tauri::AppHandle) -> Result { crate::utils::help::restart_application(&app_handle); Ok(()) } #[tauri::command] #[specta::specta] pub fn get_server_port() -> Result { Ok(*crate::server::SERVER_PORT) } #[cfg(not(windows))] #[tauri::command] #[specta::specta] pub async fn set_custom_app_dir(_path: String) -> Result { Ok(()) } #[cfg(windows)] pub mod uwp { use super::Result; use crate::core::win_uwp; #[tauri::command] #[specta::specta] pub async fn invoke_uwp_tool() -> Result { (win_uwp::invoke_uwptools().await)?; Ok(()) } } #[tauri::command] #[specta::specta] pub async fn set_tray_icon( app_handle: tauri::AppHandle, mode: TrayIcon, path: Option, ) -> Result { (crate::core::tray::icon::set_icon(mode, path))?; (crate::core::tray::Tray::update_part(&app_handle))?; Ok(()) } #[tauri::command] #[specta::specta] pub async fn is_tray_icon_set(mode: TrayIcon) -> Result { let icon_path = (crate::utils::dirs::tray_icons_path(mode.as_str()))?; Ok(tokio::fs::metadata(icon_path).await.is_ok()) } pub mod service { use super::Result; use crate::core::service; #[tauri::command] #[specta::specta] pub async fn status_service<'a>() -> Result> { let res = (service::control::status().await)?; Ok(res) } #[tauri::command] #[specta::specta] pub async fn install_service() -> Result { (service::control::install_service().await)?; Ok(()) } #[tauri::command] #[specta::specta] pub async fn uninstall_service() -> Result { (service::control::uninstall_service().await)?; Ok(()) } #[tauri::command] #[specta::specta] pub async fn start_service() -> Result { let res = service::control::start_service().await; let enabled_service = { *crate::config::Config::verge() .latest() .enable_service_mode .as_ref() .unwrap_or(&false) }; if enabled_service && let Err(e) = crate::core::CoreManager::global().run_core().await { log::error!(target: "app", "{e}"); } Ok(res?) } #[tauri::command] #[specta::specta] pub async fn stop_service() -> Result { let res = service::control::stop_service().await; let enabled_service = { *crate::config::Config::verge() .latest() .enable_service_mode .as_ref() .unwrap_or(&false) }; if enabled_service && let Err(e) = crate::core::CoreManager::global().run_core().await { log::error!(target: "app", "{e}"); } Ok(res?) } #[tauri::command] #[specta::specta] pub async fn restart_service() -> Result { let res = service::control::restart_service().await; let enabled_service = { *crate::config::Config::verge() .latest() .enable_service_mode .as_ref() .unwrap_or(&false) }; if enabled_service && let Err(e) = crate::core::CoreManager::global().run_core().await { log::error!(target: "app", "{e}"); } Ok(res?) } } #[cfg(not(windows))] pub mod uwp { use super::*; #[tauri::command] #[specta::specta] pub async fn invoke_uwp_tool() -> Result { Ok(()) } } #[tauri::command] #[specta::specta] pub async fn get_service_install_prompt() -> Result { let args = (crate::core::service::control::get_service_install_args().await)? .into_iter() .map(|arg| arg.to_string_lossy().to_string()) .collect::>() .join(" "); let mut prompt = format!("./nyanpasu-service {args}"); if cfg!(not(windows)) { prompt = format!("sudo {prompt}"); } Ok(prompt) } #[tauri::command] #[specta::specta] pub fn cleanup_processes(app_handle: AppHandle) -> Result { crate::utils::help::cleanup_processes(&app_handle); Ok(()) } /// Namespace prefix for all frontend-visible KV entries. /// Internal subsystems (e.g. task storage) use un-prefixed keys and are /// never exposed to the frontend through these IPC commands. const WEB_STORAGE_KEY_PREFIX: &str = "web:"; fn web_key(key: &str) -> String { format!("{WEB_STORAGE_KEY_PREFIX}{key}") } #[tauri::command] #[specta::specta] pub fn get_storage_item(app_handle: AppHandle, key: String) -> Result> { let storage = app_handle.state::(); let value = (storage.get_item(&web_key(&key)))?; Ok(value) } #[tauri::command] #[specta::specta] pub fn set_storage_item(app_handle: AppHandle, key: String, value: String) -> Result { let storage = app_handle.state::(); (storage.set_item(&web_key(&key), &value))?; Ok(()) } #[tauri::command] #[specta::specta] pub fn remove_storage_item(app_handle: AppHandle, key: String) -> Result { let storage = app_handle.state::(); (storage.remove_item(&web_key(&key)))?; Ok(()) } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] pub struct StorageEntry { pub key: String, /// Raw JSON-encoded value string. pub value: String, } /// Debug: returns all frontend KV entries (keys with the `web:` prefix). /// Internal storage entries used by other subsystems are excluded. #[tauri::command] #[specta::specta] pub fn get_all_storage_items(app_handle: AppHandle) -> Result> { let storage = app_handle.state::(); let items = storage.get_all()?; Ok(items .into_iter() .filter_map(|(raw_key, value)| { raw_key .strip_prefix(WEB_STORAGE_KEY_PREFIX) .map(|key| StorageEntry { key: key.to_string(), value, }) }) .collect()) } /// Debug: clears all frontend KV entries (keys with the `web:` prefix). /// Internal storage entries used by other subsystems are left intact. #[tauri::command] #[specta::specta] pub fn clear_storage(app_handle: AppHandle) -> Result { let storage = app_handle.state::(); let web_keys: Vec = storage .get_all()? .into_iter() .filter(|(k, _)| k.starts_with(WEB_STORAGE_KEY_PREFIX)) .map(|(k, _)| k) .collect(); for key in web_keys { storage.remove_item(&key)?; } Ok(()) } #[tauri::command] #[specta::specta] pub async fn get_clash_ws_connections_state( app_handle: AppHandle, ) -> Result { let ws_connector = app_handle.state::(); Ok(ws_connector.state()) } // Updater block #[derive(Default, Clone, serde::Serialize, serde::Deserialize, specta::Type)] // TODO: a copied from updater metadata, and should be moved a separate updater module pub struct UpdateWrapper { rid: tauri::ResourceId, available: bool, current_version: String, version: String, date: Option, body: Option, raw_json: serde_json::Value, } #[tauri::command] #[specta::specta] pub async fn check_update(webview: tauri::Webview) -> Result> { use crate::utils::config::{get_self_proxy, get_system_proxy}; use std::cmp::Ordering; use tauri_plugin_updater::UpdaterExt; let build_time = time::OffsetDateTime::parse( crate::consts::BUILD_INFO.build_date, &time::format_description::well_known::Rfc3339, ) .context("failed to parse build time")?; let mut builder = webview .updater_builder() .version_comparator(move |_, remote| { use semver::Version; let local = Version::parse(crate::consts::BUILD_INFO.pkg_version).ok(); log::trace!("[check] local: {:?}, remote: {:?}", local, remote.version); match local { Some(local) => { if !local.build.is_empty() && !remote.version.build.is_empty() { // ignore build info to compare the version directly match local.cmp_precedence(&remote.version) { Ordering::Less => true, Ordering::Equal => match remote.pub_date { // prefer newer build if pub_date is available Some(pub_date) => { local.build != remote.version.build && pub_date > build_time } None => local.build != remote.version.build, }, Ordering::Greater => false, } } else { local < remote.version } } None => false, } }); // apply proxy if let Ok(proxy) = get_self_proxy() { builder = builder.proxy(proxy.parse().context("failed to parse proxy")?); } if let Ok(Some(proxy)) = get_system_proxy() { builder = builder.proxy(proxy.parse().context("failed to parse system proxy")?); } let updater = builder.build().context("failed to build updater")?; let update = updater.check().await.context("failed to check update")?; Ok(update.map(|u| { let mut wrapper = UpdateWrapper { available: true, current_version: u.current_version.clone(), version: u.version.clone(), date: u.date.and_then(|d| { d.format(&time::format_description::well_known::Rfc3339) .ok() }), body: u.body.clone(), raw_json: u.raw_json.clone(), ..Default::default() }; wrapper.rid = webview.resources_table().add(u); wrapper })) } #[tauri::command] #[specta::specta] pub fn save_window_size_state(app_handle: AppHandle, label: String) -> Result<()> { match label.as_str() { crate::consts::MAIN_WINDOW_LABEL => { resolve::save_main_window_state(&app_handle, true)?; } crate::consts::LEGACY_WINDOW_LABEL => { resolve::save_legacy_window_state(&app_handle, true)?; } _ => { log::warn!("Unknown window label: {}", label); } } Ok(()) } #[tauri::command] #[specta::specta] pub fn create_main_window(app_handle: AppHandle) -> Result<()> { // Spawn window creation to avoid blocking std::thread::spawn(move || { // Small delay to let the IPC return first std::thread::sleep(std::time::Duration::from_millis(10)); let handle_inner = app_handle.clone(); let _ = app_handle.run_on_main_thread(move || { resolve::create_main_window(&handle_inner); }); }); Ok(()) } #[tauri::command] #[specta::specta] pub fn create_legacy_window(app_handle: AppHandle) -> Result<()> { // Spawn window creation to avoid blocking std::thread::spawn(move || { // Small delay to let the IPC return first std::thread::sleep(std::time::Duration::from_millis(10)); let handle_inner = app_handle.clone(); let _ = app_handle.run_on_main_thread(move || { resolve::create_legacy_window(&handle_inner); }); }); Ok(()) } #[tauri::command] #[specta::specta] pub fn create_editor_window(app_handle: AppHandle, uid: String) -> Result<()> { // Spawn window creation to avoid blocking std::thread::spawn(move || { // Small delay to let the IPC return first std::thread::sleep(std::time::Duration::from_millis(10)); let handle_inner = app_handle.clone(); let _ = app_handle.run_on_main_thread(move || { let _ = resolve::create_editor_window(&handle_inner, &uid); }); }); Ok(()) } ================================================ FILE: backend/tauri/src/lib.rs ================================================ #![feature(auto_traits, negative_impls, trait_alias, impl_trait_in_assoc_type)] #![cfg_attr( all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows" )] // This lint was needed by ambassador #![allow(clippy::duplicated_attributes)] mod cmds; mod config; mod consts; mod core; mod enhance; mod event_handler; mod feat; mod ipc; mod logging; mod server; mod setup; #[cfg(windows)] mod shutdown_hook; mod utils; mod widget; mod window; use std::io; use crate::{ config::Config, core::handle::Handle, utils::{init, resolve}, }; use anyhow::Context; use specta_typescript::{BigIntExportBehavior, Typescript}; use tauri::{Emitter, Manager}; use tauri_specta::{collect_commands, collect_events}; use utils::resolve::{is_window_opened, reset_window_open_counter}; rust_i18n::i18n!("./locales"); #[cfg(feature = "deadlock-detection")] fn deadlock_detection() { use parking_lot::deadlock; use std::{thread, time::Duration}; use tracing::error; thread::spawn(move || { loop { thread::sleep(Duration::from_secs(10)); let deadlocks = deadlock::check_deadlock(); if deadlocks.is_empty() { continue; } error!("{} deadlocks detected", deadlocks.len()); for (i, threads) in deadlocks.iter().enumerate() { error!("Deadlock #{}", i); for t in threads { error!("Thread Id {:#?}", t.thread_id()); error!("{:#?}", t.backtrace()); } } } }); } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() -> std::io::Result<()> { // share the tauri async runtime to nyanpasu-utils #[cfg(feature = "deadlock-detection")] deadlock_detection(); // Should be in first place in order prevent single instance check block everything // Custom scheme check #[cfg(not(target_os = "macos"))] // on macos the plugin handles this (macos doesn't use cli args for the url) let custom_scheme = match std::env::args().nth(1) { Some(url) => url::Url::parse(&url).ok(), None => None, }; #[cfg(target_os = "macos")] let custom_scheme: Option = None; if custom_scheme.is_none() { // Parse commands cmds::parse().unwrap(); }; #[cfg(feature = "verge-dev")] tauri_plugin_deep_link::prepare("moe.elaina.clash.nyanpasu.dev"); #[cfg(not(feature = "verge-dev"))] tauri_plugin_deep_link::prepare("moe.elaina.clash.nyanpasu"); // 单例检测 with robust logging let single_instance_result = utils::init::check_singleton(); match &single_instance_result { Ok(Some(_)) => { tracing::info!(target: "app", "Acquired single-instance lock"); } Ok(None) => { tracing::warn!(target: "app", "Another instance is running; exiting"); std::process::exit(0); } Err(e) => { tracing::error!(target: "app", "Failed to check single-instance lock: {e:?}"); // Policy: continue startup in best-effort mode } } // Use system locale as default let locale = { let locale = utils::help::get_system_locale(); utils::help::mapping_to_i18n_key(&locale) }; rust_i18n::set_locale(locale.to_lowercase().as_str()); if single_instance_result .as_ref() .is_ok_and(|instance| instance.is_some()) && let Err(e) = init::run_pending_migrations() { // Try to open migration log files if let Ok(data_dir) = crate::utils::dirs::app_data_dir() { let _ = crate::utils::open::that(data_dir.join("migration.log")); } utils::dialog::panic_dialog(&format!( "Failed to finish migration event: {e}\nYou can see the detailed information at migration.log in your local data dir.\nYou're supposed to submit it as the attachment of new issue.", )); std::process::exit(1); } crate::log_err!(init::init_config()); // Panic Hook to show a panic dialog and save logs std::panic::set_hook(Box::new(move |panic_info| { use std::backtrace::{Backtrace, BacktraceStatus}; let payload = panic_info.payload(); #[allow(clippy::manual_map)] let payload = if let Some(s) = payload.downcast_ref::<&str>() { Some(&**s) } else if let Some(s) = payload.downcast_ref::() { Some(s.as_str()) } else { None }; let location = panic_info.location().map(|l| l.to_string()); let (backtrace, note) = { let backtrace = Backtrace::force_capture(); let note = (backtrace.status() == BacktraceStatus::Disabled) .then_some("run with RUST_BACKTRACE=1 environment variable to display a backtrace"); (Some(backtrace), note) }; tracing::error!( panic.payload = payload, panic.location = location, panic.backtrace = backtrace.as_ref().map(tracing::field::display), panic.note = note, "A panic occurred", ); // This is a workaround for the upstream issue: https://github.com/tauri-apps/tauri/issues/10546 if let Some(s) = payload.as_ref() && s.contains("PostMessage failed ; is the messages queue full?") { return; } // FIXME: maybe move this logic to a util function? let msg = format!( "Oops, we encountered some issues and program will exit immediately.\n\npayload: {payload:#?}\nlocation: {location:?}\nbacktrace: {backtrace:#?}\n\n", ); let child = std::process::Command::new(tauri::utils::platform::current_exe().unwrap()) .arg("panic-dialog") .arg(msg.as_str()) .spawn(); // fallback to show a dialog directly if child.is_err() { utils::dialog::panic_dialog(msg.as_str()); } match Handle::global().app_handle.lock().as_ref() { Some(app_handle) => { app_handle.exit(1); } None => { log::error!("app handle is not initialized"); std::process::exit(1); } } })); // setup specta let specta_builder = tauri_specta::Builder::::new() .commands(collect_commands![ // common ipc::get_sys_proxy, ipc::open_app_config_dir, ipc::open_app_data_dir, ipc::open_logs_dir, ipc::open_web_url, ipc::open_core_dir, // cmds::kill_sidecar, ipc::restart_sidecar, // clash ipc::get_clash_info, ipc::get_clash_logs, ipc::patch_clash_config, ipc::change_clash_core, ipc::get_runtime_config, ipc::get_runtime_yaml, ipc::get_runtime_exists, ipc::get_postprocessing_output, ipc::clash_api_get_proxy_delay, ipc::uwp::invoke_uwp_tool, // updater ipc::fetch_latest_core_versions, ipc::update_core, ipc::inspect_updater, ipc::get_core_version, // utils ipc::collect_logs, // verge ipc::get_verge_config, ipc::patch_verge_config, // cmds::update_hotkeys, // profile ipc::get_profiles, ipc::enhance_profiles, ipc::patch_profiles_config, ipc::view_profile, ipc::patch_profile, ipc::create_profile, ipc::import_profile, ipc::reorder_profile, ipc::reorder_profiles_by_list, ipc::update_profile, ipc::delete_profile, ipc::read_profile_file, ipc::save_profile_file, ipc::get_custom_app_dir, ipc::set_custom_app_dir, // service mode ipc::service::status_service, ipc::service::install_service, ipc::service::uninstall_service, ipc::service::start_service, ipc::service::stop_service, ipc::service::restart_service, ipc::is_portable, ipc::get_proxies, ipc::select_proxy, ipc::update_proxy_provider, ipc::restart_application, ipc::collect_envs, ipc::get_server_port, ipc::set_tray_icon, ipc::is_tray_icon_set, ipc::get_core_status, ipc::url_delay_test, ipc::get_ipsb_asn, ipc::open_that, ipc::is_appimage, ipc::get_service_install_prompt, ipc::cleanup_processes, ipc::get_storage_item, ipc::set_storage_item, ipc::remove_storage_item, ipc::get_all_storage_items, ipc::clear_storage, ipc::mutate_proxies, ipc::get_core_dir, // clash layer ipc::get_clash_ws_connections_state, // updater layer ipc::check_update, // window management ipc::save_window_size_state, ipc::create_main_window, ipc::create_legacy_window, ipc::create_editor_window, ]) .events(collect_events![ core::clash::ClashConnectionsEvent, window::WindowMessageEvent, window::ReactAppMountedEvent, core::storage::StorageValueChangedEvent ]); #[cfg(debug_assertions)] { const SPECTA_BINDINGS_PATH: &str = "../../frontend/interface/src/ipc/bindings.ts"; match specta_builder.export( Typescript::default() .formatter(specta_typescript::formatter::prettier) .formatter(|file| { let npx_command = if cfg!(target_os = "windows") { "npx.cmd" } else { "npx" }; std::process::Command::new(npx_command) .arg("prettier") .arg("--write") .arg(file) .output() .map(|_| ()) .map_err(io::Error::other) }) .bigint(BigIntExportBehavior::Number) .header("/* oxlint-disable */\n// @ts-nocheck"), SPECTA_BINDINGS_PATH, ) { Ok(_) => { log::debug!("Exported typescript bindings, path: {SPECTA_BINDINGS_PATH}"); } Err(e) => { panic!("Failed to export typescript bindings: {e}"); } }; } let verge = { Config::verge().latest().language.clone().unwrap() }; rust_i18n::set_locale(verge.to_lowercase().as_str()); // show a dialog to print the single instance error // Hold the guard until the end of the program if acquired let _singleton = match single_instance_result { Ok(Some(guard)) => Some(guard), _ => None, }; #[allow(unused_mut)] let mut builder = tauri::Builder::default() .invoke_handler(specta_builder.invoke_handler()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_global_shortcut::Builder::default().build()) .setup(move |app| { specta_builder.mount_events(app); setup::setup(app) .context("Failed to setup the app") .inspect_err(|e| { tracing::error!("Failed to setup the app: {:#?}", e); })?; #[cfg(target_os = "macos")] { use tauri::menu::{MenuBuilder, SubmenuBuilder}; let submenu = SubmenuBuilder::new(app, "Edit") .undo() .redo() .copy() .paste() .cut() .select_all() .close_window() .quit() .build() .unwrap(); let menu = MenuBuilder::new(app).item(&submenu).build().unwrap(); app.set_menu(menu).unwrap(); } resolve::resolve_setup(app); // setup custom scheme let handle = app.handle().clone(); // For start new app from schema #[cfg(not(target_os = "macos"))] if let Some(url) = custom_scheme { log::info!(target: "app", "started with schema"); resolve::create_window(&handle.clone()); while !is_window_opened() { log::info!(target: "app", "waiting for window open"); std::thread::sleep(std::time::Duration::from_millis(100)); } Handle::global() .app_handle .lock() .as_ref() .unwrap() .emit("scheme-request-received", url.clone()) .unwrap(); } // This operation should terminate the app if app is called by custom scheme and this instance is not the primary instance log_err!(tauri_plugin_deep_link::register( &["clash-nyanpasu", "clash"], move |request| { log::info!(target: "app", "scheme request received: {:?}", &request); resolve::create_window(&handle.clone()); // create window if not exists while !is_window_opened() { log::info!(target: "app", "waiting for window open"); std::thread::sleep(std::time::Duration::from_millis(100)); } handle.emit("scheme-request-received", request).unwrap(); } )); std::thread::spawn(move || { nyanpasu_utils::runtime::block_on(async move { server::run(*server::SERVER_PORT) .await .expect("failed to start server"); }); }); Ok(()) }); let app = builder .build(tauri::generate_context!()) .expect("error while running tauri application"); app.run(|app_handle, e| match e { tauri::RunEvent::ExitRequested { api, code, .. } if code.is_none() => { api.prevent_exit(); } tauri::RunEvent::ExitRequested { .. } => { utils::help::cleanup_processes(app_handle); } tauri::RunEvent::WindowEvent { label, event, .. } if label == "main" => match event { tauri::WindowEvent::ScaleFactorChanged { scale_factor, .. } => { core::tray::on_scale_factor_changed(scale_factor); } tauri::WindowEvent::CloseRequested { .. } => { log::debug!(target: "app", "window close requested"); let _ = resolve::save_window_state(app_handle, true); #[cfg(target_os = "macos")] crate::utils::dock::macos::hide_dock_icon(); } tauri::WindowEvent::Destroyed => { log::debug!(target: "app", "window destroyed"); reset_window_open_counter(); } tauri::WindowEvent::Moved(_) | tauri::WindowEvent::Resized(_) => { log::debug!(target: "app", "window moved or resized"); std::thread::sleep(std::time::Duration::from_nanos(1)); let _ = resolve::save_window_state(app_handle, false); } _ => {} }, #[cfg(target_os = "macos")] tauri::RunEvent::Reopen { .. } => { resolve::create_window(app_handle); } _ => {} }); Ok(()) } ================================================ FILE: backend/tauri/src/logging/indexer.rs ================================================ use std::{ collections::{BTreeMap, BTreeSet}, io::SeekFrom, ops::Range, }; use anyhow::Context; use bumpalo::Bump; use camino::Utf8PathBuf; use chrono::{DateTime, Local}; use derive_builder::Builder; use itertools::Itertools; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use specta::Type; use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader}; #[derive( Debug, Clone, Copy, Serialize, Deserialize, Type, Hash, Eq, PartialEq, Ord, PartialOrd, )] #[serde(rename_all = "UPPERCASE")] #[allow(clippy::upper_case_acronyms)] pub enum LoggingLevel { DEBUG, INFO, WARN, ERROR, FATAL, } #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "snake_case")] pub struct LogEntry { /// The line number of the log entry. /// For query limit, and offset. pub line_number: u64, /// The level of the log entry. pub level: LoggingLevel, /// The timestamp of the log entry. pub timestamp: u64, /// The target of the log entry. /// eg: "backend::logging::indexer" pub target: String, /// The start position of the log entry in the file. pub start_pos: usize, /// The end position of the log entry in the file. pub end_pos: usize, } #[derive(Debug, Default, Clone, Serialize, Deserialize, Type)] struct CurrentPos { line: u64, end_pos: usize, } #[derive(Debug, Builder, Clone, Serialize, Deserialize, Type)] pub struct Query { #[builder(default)] offset: usize, #[builder(default = 100)] limit: usize, #[builder(default, setter(into, strip_option))] level: Option>, #[builder(default, setter(into, strip_option))] target: Option>, #[builder(default, setter(into, strip_option))] timestamp: Option>, } pub type LineNumber = u64; pub type Timestamp = u64; struct LogIndex { /// a bump allocator for heap allocation arena: Bump, /// index by line number line_index: BTreeMap, /// index by timestamp /// in our case, the timestamp is nanoseconds, so only one item per timestamp timestamp_index: BTreeMap, /// index by level level_index: FxHashMap>, /// index by target target_index: FxHashMap>, last_line_number: Option, } impl core::fmt::Debug for LogIndex { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { let lines = self .line_index .values() .map(|v| unsafe { let v = &**v; v.clone() }) .collect_vec(); let levels = BTreeMap::from_iter(self.level_index.iter().map(|(k, v)| { (k, unsafe { let v = &**v; v.clone() }) })); let targets = BTreeMap::from_iter(self.target_index.iter().map(|(k, v)| { (k, unsafe { let v = &**v; v.clone() }) })); write!( f, "LogIndex {{ lines: {:?}; timestamp_index: {:?}; level_index: {:?}; target_index: {:?}; last_line_number: {:?} }}", lines, self.timestamp_index, levels, targets, self.last_line_number ) } } impl LogIndex { pub fn new() -> Self { Self { arena: Bump::new(), line_index: BTreeMap::new(), timestamp_index: BTreeMap::new(), level_index: FxHashMap::default(), target_index: FxHashMap::default(), last_line_number: None, } } #[inline] /// add an entry to the index pub fn add_entry(&mut self, entry: LogEntry) { let line_number = entry.line_number; let timestamp = entry.timestamp; let level = entry.level; let target = entry.target.clone(); let entry_ptr = self.arena.alloc(entry) as *mut LogEntry; // update level index { let entry = self.level_index.entry(level); entry .and_modify(|v| { // SAFETY: we are sure that the vec_ptr is valid unsafe { let v = &mut **v; v.push(line_number); } }) .or_insert_with(|| { let vec = self.arena.alloc(vec![line_number]); vec as *mut Vec }); } // update timestamp index { let entry = self.timestamp_index.entry(timestamp); entry .and_modify(|v| { tracing::warn!( "duplicate timestamp: {}; previous: {}, new: {}", timestamp, v, line_number ); *v = line_number; }) .or_insert(line_number); } // update target index { let entry = self.target_index.entry(target); entry .and_modify(|v| { // SAFETY: we are sure that the vec_ptr is valid unsafe { let v = &mut **v; v.push(line_number); } }) .or_insert_with(|| { let vec = self.arena.alloc(vec![line_number]); vec as *mut Vec }); } // update line index { self.line_index.insert(line_number, entry_ptr); } self.last_line_number = Some(line_number); } // TODO: optimize query performance pub fn query(&self, query: Query) -> Option> { // query by timestamp let mut matching_lines: Option> = None; if let Some(range) = query.timestamp { let mut range = self.timestamp_index.range(range); let (_, start) = range.next()?; let end = match range.last() { Some((_, end_line)) => *end_line, None => *start, }; matching_lines = Some(Vec::from_iter(*start..=end)); } // query by level if let Some(levels) = query.level { let mut matched_lines = BTreeSet::new(); for level in levels { if let Some(lines) = self.level_index.get(&level) { // SAFETY: we have allocated the vec on the heap by bumpalo unsafe { let lines = &**lines; matched_lines.extend(lines.iter()); } } } matching_lines = match matching_lines { Some(lines) => Some( lines .into_iter() .filter(|line| matched_lines.contains(line)) .collect_vec(), ), None => Some(matched_lines.into_iter().collect_vec()), } } // query by target if let Some(targets) = query.target { let mut matched_lines = BTreeSet::new(); for target in targets { if let Some(lines) = self.target_index.get(&target) { // SAFETY: we have allocated the vec on the heap by bumpalo unsafe { let lines = &**lines; matched_lines.extend(lines.iter()); } } } matching_lines = match matching_lines { Some(lines) => Some( lines .into_iter() .filter(|line| matched_lines.contains(line)) .collect_vec(), ), None => Some(matched_lines.into_iter().collect_vec()), } } let matching_lines = match matching_lines { Some(lines) if lines.is_empty() => return None, None => { let last_line = self.last_line_number.as_ref()?; Vec::from_iter(0..=*last_line) } Some(lines) => lines, }; #[cfg(test)] dbg!(&matching_lines); let results = matching_lines .into_iter() .skip(query.offset) .take(query.limit) // SAFETY: we are sure that the line_index is valid, which is allocated by bumpalo, // and the pool only be dropped when this index is dropped .map(|line_number| unsafe { let entry = &**self.line_index.get(&line_number).unwrap(); entry.clone() }) .collect_vec(); if results.is_empty() { None } else { Some(results) } } } pub struct Indexer { index: LogIndex, path: Utf8PathBuf, current: CurrentPos, } #[derive(Debug, Serialize, Deserialize)] struct TracingJson { level: LoggingLevel, target: String, timestamp: DateTime, } impl Indexer { pub fn new(path: Utf8PathBuf) -> Self { Self { index: LogIndex::new(), path, current: CurrentPos::default(), } } fn handle_line( &mut self, line: &str, current: &mut CurrentPos, bytes_read: usize, ) -> anyhow::Result<()> { let tracing_json: TracingJson = serde_json::from_str(line).context("failed to parse log line")?; let end_pos = current.end_pos + bytes_read; let entry = LogEntry { line_number: current.line, level: tracing_json.level, timestamp: tracing_json.timestamp.timestamp_millis() as u64, target: tracing_json.target, start_pos: current.end_pos, end_pos, }; self.index.add_entry(entry); current.line += 1; current.end_pos = end_pos; Ok(()) } pub async fn build_index(&mut self) -> anyhow::Result<()> { // read file line by line let mut file = tokio::fs::File::open(&self.path).await?; let mut reader = BufReader::new(&mut file); let mut current = CurrentPos::default(); let mut line = String::new(); loop { let bytes_read = reader.read_line(&mut line).await?; if bytes_read == 0 { break; } self.handle_line(&line, &mut current, bytes_read)?; line.clear(); } #[cfg(test)] { let bytes_count = file.metadata().await?.len(); pretty_assertions::assert_eq!(bytes_count, current.end_pos as u64); } self.current = current; Ok(()) } pub fn query(&self, query: Query) -> Option> { self.index.query(query) } pub async fn on_file_change(&mut self) -> anyhow::Result<()> { let mut file = tokio::fs::File::open(&self.path).await?; file.seek(SeekFrom::Start(self.current.end_pos as u64)) .await?; let mut reader = BufReader::new(file); let mut current = std::mem::take(&mut self.current); let mut line = String::new(); loop { let bytes_read = reader.read_line(&mut line).await?; if bytes_read == 0 { break; } self.handle_line(&line, &mut current, bytes_read)?; line.clear(); } self.current = current; Ok(()) } } #[cfg(test)] mod tests { use super::*; use camino::Utf8PathBuf; use std::io::Write; use tempfile::NamedTempFile; #[test] fn test_log_index() { let mut index = LogIndex::new(); let query = QueryBuilder::default().build().unwrap(); let results = index.query(query); assert!(results.is_none(), "results should be empty"); let entry = LogEntry { line_number: 1, level: LoggingLevel::INFO, timestamp: 1740504078324, target: "test".to_string(), start_pos: 0, end_pos: 0, }; index.add_entry(entry); let entry = LogEntry { line_number: 2, level: LoggingLevel::WARN, timestamp: 1740417699000, target: "test".to_string(), start_pos: 0, end_pos: 0, }; index.add_entry(entry); let entry = LogEntry { line_number: 3, level: LoggingLevel::ERROR, timestamp: 1740417699001, target: "test".to_string(), start_pos: 0, end_pos: 0, }; index.add_entry(entry); let entry = LogEntry { line_number: 4, level: LoggingLevel::INFO, timestamp: 1740331299000, target: "different_target".to_string(), start_pos: 0, end_pos: 0, }; index.add_entry(entry); dbg!(&index); // Test offset limit let query = QueryBuilder::default().offset(1).limit(1).build().unwrap(); let results = index.query(query).unwrap(); dbg!(&results); assert_eq!(results.len(), 1, "results should have 1 entries"); assert_eq!(results[0].line_number, 1); // Test filter by level let query = QueryBuilder::default() .level(vec![LoggingLevel::INFO]) .build() .unwrap(); let results = index.query(query).unwrap(); dbg!(&results); assert_eq!(results.len(), 2, "results should have 2 entries"); assert_eq!(results[0].line_number, 1); assert_eq!(results[1].line_number, 4); let query = QueryBuilder::default() .level(vec![LoggingLevel::INFO, LoggingLevel::WARN]) .build() .unwrap(); let results = index.query(query).unwrap(); dbg!(&results); assert_eq!(results.len(), 3, "results should have 3 entries"); assert_eq!(results[0].line_number, 1); assert_eq!(results[1].line_number, 2); assert_eq!(results[2].line_number, 4); // test filter by target let query = QueryBuilder::default() .target(vec!["test".to_string()]) .build() .unwrap(); let results = index.query(query).unwrap(); dbg!(&results); assert_eq!(results.len(), 3, "results should have 3 entries"); assert_eq!(results[0].line_number, 1); assert_eq!(results[1].line_number, 2); assert_eq!(results[2].line_number, 3); // test filter by timestamp let query = QueryBuilder::default() .timestamp(1740417699000..1740504078324) .build() .unwrap(); let results = index.query(query).unwrap(); dbg!(&results); assert_eq!(results.len(), 2, "results should have 2 entries"); assert_eq!(results[0].line_number, 2); assert_eq!(results[1].line_number, 3); // a complex query let query = QueryBuilder::default() .level(vec![LoggingLevel::INFO, LoggingLevel::WARN]) .target(vec!["test".to_string()]) .timestamp(1740417699000..1740504078324) .build() .unwrap(); let results = index.query(query).unwrap(); dbg!(&results); assert_eq!(results.len(), 1, "results should have 1 entries"); assert_eq!(results[0].line_number, 2); } fn create_test_log_file(entries: Vec<&str>) -> anyhow::Result<(NamedTempFile, Utf8PathBuf)> { let mut file = NamedTempFile::new()?; for entry in entries { writeln!(file, "{entry}")?; } file.flush()?; let path = file.path().to_str().unwrap().to_string(); let utf8_path = Utf8PathBuf::from(path); Ok((file, utf8_path)) } fn append_to_log_file(file: &mut NamedTempFile, entries: Vec<&str>) -> anyhow::Result<()> { for entry in entries { writeln!(file, "{entry}")?; } file.flush()?; Ok(()) } fn get_sample_log_entries() -> Vec<&'static str> { vec![ r#"{"level":"INFO","target":"app::module1","timestamp":"2023-02-25T10:15:30+00:00"}"#, r#"{"level":"WARN","target":"app::module2","timestamp":"2023-02-25T10:16:30+00:00"}"#, r#"{"level":"ERROR","target":"app::module1","timestamp":"2023-02-25T10:17:30+00:00"}"#, r#"{"level":"DEBUG","target":"app::module3","timestamp":"2023-02-25T10:18:30+00:00"}"#, ] } fn get_additional_log_entries() -> Vec<&'static str> { vec![ r#"{"level":"INFO","target":"app::module2","timestamp":"2023-02-25T10:19:30+00:00"}"#, r#"{"level":"FATAL","target":"app::module1","timestamp":"2023-02-25T10:20:30+00:00"}"#, ] } #[test] fn test_indexer_creation() { let entries = get_sample_log_entries(); let (_guard, path) = create_test_log_file(entries).unwrap(); let indexer = Indexer::new(path); assert!(indexer.current.line == 0, "Initial line count should be 0"); assert!( indexer.current.end_pos == 0, "Initial end position should be 0" ); } #[tokio::test] async fn test_build_index() -> anyhow::Result<()> { let entries = get_sample_log_entries(); let (_guard, path) = create_test_log_file(entries.clone()).unwrap(); let mut indexer = Indexer::new(path); indexer.build_index().await.unwrap(); // Verify that all entries were indexed assert_eq!( indexer.current.line, entries.len() as u64, "Line count should match number of entries" ); // Query the index to verify entries let query = QueryBuilder::default().build().unwrap(); let results = indexer.index.query(query).unwrap(); assert_eq!( results.len(), entries.len(), "Query should return all indexed entries" ); // Verify specific entries by level let info_query = QueryBuilder::default() .level(vec![LoggingLevel::INFO]) .build()?; let info_results = indexer.index.query(info_query).unwrap(); assert_eq!(info_results.len(), 1, "Should have 1 INFO entry"); let warn_query = QueryBuilder::default() .level(vec![LoggingLevel::WARN]) .build()?; let warn_results = indexer.index.query(warn_query).unwrap(); assert_eq!(warn_results.len(), 1, "Should have 1 WARN entry"); let error_query = QueryBuilder::default() .level(vec![LoggingLevel::ERROR]) .build()?; let error_results = indexer.index.query(error_query).unwrap(); assert_eq!(error_results.len(), 1, "Should have 1 ERROR entry"); let debug_query = QueryBuilder::default() .level(vec![LoggingLevel::DEBUG]) .build()?; let debug_results = indexer.index.query(debug_query).unwrap(); assert_eq!(debug_results.len(), 1, "Should have 1 DEBUG entry"); Ok(()) } #[tokio::test] async fn test_on_file_change() -> anyhow::Result<()> { let initial_entries = get_sample_log_entries(); let (mut file, path) = create_test_log_file(initial_entries.clone()).unwrap(); // Initialize and build the initial index let mut indexer = Indexer::new(path); indexer.build_index().await.unwrap(); // Verify initial indexing assert_eq!( indexer.current.line, initial_entries.len() as u64, "Line count should match initial entries" ); // Add more entries to the file let additional_entries = get_additional_log_entries(); append_to_log_file(&mut file, additional_entries.clone()).unwrap(); // Process file changes indexer.on_file_change().await?; // Verify that all entries are now indexed let total_entries = initial_entries.len() + additional_entries.len(); assert_eq!( indexer.current.line, total_entries as u64, "Line count should match total entries" ); // Query all entries let query = QueryBuilder::default().build().unwrap(); let results = indexer.index.query(query).unwrap(); assert_eq!( results.len(), total_entries, "Query should return all indexed entries" ); // Check for specific new entry let fatal_query = QueryBuilder::default() .level(vec![LoggingLevel::FATAL]) .build()?; let fatal_results = indexer.index.query(fatal_query).unwrap(); assert_eq!( fatal_results.len(), 1, "Should have 1 FATAL entry from file change" ); Ok(()) } #[tokio::test] async fn test_indexer_with_target_filter() -> anyhow::Result<()> { let entries = get_sample_log_entries(); let (_guard, path) = create_test_log_file(entries).unwrap(); let mut indexer = Indexer::new(path); indexer.build_index().await.unwrap(); // Query by target let target_query = QueryBuilder::default() .target(vec!["app::module1".to_string()]) .build()?; let target_results = indexer.index.query(target_query).unwrap(); assert_eq!( target_results.len(), 2, "Should have 2 entries for app::module1" ); // Verify the levels of the filtered results let has_info = target_results.iter().any(|e| e.level == LoggingLevel::INFO); let has_error = target_results .iter() .any(|e| e.level == LoggingLevel::ERROR); assert!(has_info, "app::module1 should have an INFO entry"); assert!(has_error, "app::module1 should have an ERROR entry"); Ok(()) } #[tokio::test] async fn test_indexer_complex_query() -> anyhow::Result<()> { let entries = get_sample_log_entries(); let additional_entries = get_additional_log_entries(); let mut all_entries = entries.clone(); all_entries.extend(additional_entries.clone()); let (_guard, path) = create_test_log_file(all_entries).unwrap(); let mut indexer = Indexer::new(path); indexer.build_index().await.unwrap(); // Complex query with multiple filters let complex_query = QueryBuilder::default() .level(vec![LoggingLevel::INFO, LoggingLevel::WARN]) .target(vec!["app::module2".to_string()]) .build()?; let complex_results = indexer.index.query(complex_query).unwrap(); assert_eq!( complex_results.len(), 2, "Complex query should return 2 entries" ); // Verify specific entries let has_info = complex_results .iter() .any(|e| e.level == LoggingLevel::INFO); let has_warn = complex_results .iter() .any(|e| e.level == LoggingLevel::WARN); assert!( has_info, "Results should include INFO entry for app::module2" ); assert!( has_warn, "Results should include WARN entry for app::module2" ); Ok(()) } } ================================================ FILE: backend/tauri/src/logging/manager.rs ================================================ use std::{collections::HashMap, ops::Deref, sync::Arc}; use crate::logging::indexer::Indexer; use anyhow::Context; use camino::{Utf8Path, Utf8PathBuf}; use notify::EventKind; use notify_debouncer_full::{ DebounceEventResult, DebouncedEvent, Debouncer, RecommendedCache, new_debouncer, notify::{RecommendedWatcher, RecursiveMode}, }; use tokio::{ sync::{ mpsc::{Receiver, UnboundedSender}, oneshot, }, task::{JoinHandle, LocalSet}, }; use tokio_util::sync::CancellationToken; use super::{LogEntry, Query}; #[derive(Clone)] pub struct IndexerManager { inner: Arc, } impl Deref for IndexerManager { type Target = IndexerRunnerGuard; fn deref(&self) -> &Self::Target { &self.inner } } async fn is_log_file(path: &Utf8Path) -> anyhow::Result { let metadata = tokio::fs::metadata(path).await?; Ok(metadata.is_file() && path .file_name() .is_some_and(|name| name.to_ascii_lowercase().ends_with(".log"))) } impl IndexerManager { pub async fn try_new(logging_dir: Utf8PathBuf) -> anyhow::Result { let inner = IndexerManagerRunner::new_and_spawn().await; let manager = Self { inner: Arc::new(inner), }; manager.watch(&logging_dir).await?; Ok(manager) } } // TODO: only keep latest log file when we detect a serious memory report on it pub struct IndexerManagerRunner { map: HashMap, debouncer: Option>, } pub enum IndexerRunnerCmd { /// scan the logging directory for new log files Watch(Utf8PathBuf, oneshot::Sender>), /// remove the indexer for the given path // Unwatch(Utf8PathBuf, oneshot::Sender>), /// query the indexer for the given path AddLogFile(Utf8PathBuf, oneshot::Sender>), RemoveLogFile(Utf8PathBuf, oneshot::Sender>), LogFileChanged(Utf8PathBuf, oneshot::Sender>), Query(Utf8PathBuf, Query, oneshot::Sender>>), } pub struct IndexerRunnerGuard { cancel_token: CancellationToken, handle: JoinHandle<()>, tx: tokio::sync::mpsc::UnboundedSender, } impl IndexerManagerRunner { pub async fn new_and_spawn() -> IndexerRunnerGuard { let cancel_token = CancellationToken::new(); let (handle, rx) = Self::spawn_task(cancel_token.clone()); let tx = rx.await.unwrap(); IndexerRunnerGuard { cancel_token, handle, tx, } } fn spawn_task( cancel_token: CancellationToken, ) -> ( JoinHandle<()>, tokio::sync::oneshot::Receiver>, ) { let (tx, rx) = oneshot::channel(); let handle = tauri::async_runtime::spawn_blocking(move || { let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel(); let mut runner = Self { map: HashMap::new(), debouncer: None, }; let local = LocalSet::new(); let cmd_tx_clone = cmd_tx.clone(); local.spawn_local(async move { while let Some(cmd) = cmd_rx.recv().await { runner.run_cmd(&cmd_tx_clone, cmd).await; } }); tx.send(cmd_tx).unwrap(); tauri::async_runtime::block_on(async { tokio::select! { _ = cancel_token.cancelled() => { tracing::info!("cancel token triggered, shutting down"); } _ = local => {} } }); }); // unwrap the join handle match handle { tauri::async_runtime::JoinHandle::Tokio(handle) => (handle, rx), } } async fn run_cmd(&mut self, cmd_tx: &UnboundedSender, cmd: IndexerRunnerCmd) { match cmd { IndexerRunnerCmd::Watch(path, tx) => { if let Err(err) = self.scan(&path).await { tx.send(Err(err)).unwrap(); return; } let watcher = self.recommended_watcher(&path).unwrap(); let cmd_tx = cmd_tx.clone(); nyanpasu_utils::runtime::spawn(Self::spawn_watcher(watcher, cmd_tx)); tx.send(Ok(())).unwrap(); } IndexerRunnerCmd::Query(path, query, tx) => { let indexer = self.map.get(&path).unwrap(); let result = indexer.query(query); tx.send(result).unwrap(); } IndexerRunnerCmd::AddLogFile(path, tx) => { let mut indexer = Indexer::new(path.clone()); if let Err(err) = indexer.build_index().await { tx.send(Err(err)).unwrap(); return; } self.map.insert(path, indexer); tx.send(Ok(())).unwrap(); } IndexerRunnerCmd::RemoveLogFile(path, tx) => { self.map.remove(&path); tx.send(Ok(())).unwrap(); } IndexerRunnerCmd::LogFileChanged(path, tx) => { let indexer = self.map.get_mut(&path).unwrap(); if let Err(err) = indexer.on_file_change().await { tx.send(Err(err)).unwrap(); return; } tx.send(Ok(())).unwrap(); } } } async fn spawn_watcher( mut watcher: Receiver>, cmd_tx: UnboundedSender, ) { while let Some(events) = watcher.recv().await { for event in events { let path = Utf8Path::from_path(event.paths.first().unwrap()).unwrap(); match event.kind { EventKind::Create(_) => { if is_log_file(path).await.is_ok_and(|ok| ok) { tracing::debug!("create indexer for {}", path); let (tx, rx) = oneshot::channel(); cmd_tx .send(IndexerRunnerCmd::AddLogFile(path.to_path_buf(), tx)) .unwrap(); match rx.await { Ok(_) => { tracing::debug!("indexer for {} created", path); } Err(_err) => { tracing::error!("failed to create indexer for {}", path); } } } } EventKind::Remove(_) => { let (tx, rx) = oneshot::channel(); cmd_tx .send(IndexerRunnerCmd::RemoveLogFile(path.to_path_buf(), tx)) .unwrap(); match rx.await { Ok(_) => { tracing::debug!("indexer for {} removed", path); } Err(_err) => { tracing::error!("failed to remove indexer for {}", path); } } } EventKind::Modify(_) => { if is_log_file(path).await.is_ok_and(|ok| ok) { let (tx, rx) = oneshot::channel(); cmd_tx .send(IndexerRunnerCmd::LogFileChanged(path.to_path_buf(), tx)) .unwrap(); match rx.await { Ok(_) => { tracing::debug!("indexer for {} updated", path); } Err(_err) => { tracing::error!("failed to update indexer for {}", path); } } } } _ => (), } } } } #[tracing::instrument(skip(self))] pub async fn scan(&mut self, logging_dir: &Utf8Path) -> anyhow::Result<()> { let mut entries = tokio::fs::read_dir(logging_dir).await?; while let Some(entry) = entries.next_entry().await? { let path = Utf8PathBuf::from_path_buf(entry.path()) .map_err(|e| anyhow::anyhow!("failed to convert path: {:?}", e))?; if is_log_file(&path).await? { tracing::debug!("create indexer for {}", path); let mut indexer = Indexer::new(path.clone()); indexer.build_index().await?; self.map.insert(path, indexer); } } Ok(()) } #[tracing::instrument(skip(self))] pub fn recommended_watcher( &mut self, logging_dir: &Utf8Path, ) -> anyhow::Result>> { let (tx, rx) = tokio::sync::mpsc::channel(10); let mut debouncer = new_debouncer( std::time::Duration::from_secs(2), None, move |events_result: DebounceEventResult| match events_result { Ok(events) => { let tx = tx.clone(); nyanpasu_utils::runtime::spawn(async move { if let Err(err) = tx.send(events).await { tracing::error!("failed to send events to channel: {:?}", err); } }); } Err(errors) => { tracing::error!( "failed to receive events from logging directory: {:?}", errors ); } }, )?; debouncer .watch(logging_dir, RecursiveMode::Recursive) .context("failed to watch logging directory")?; self.debouncer = Some(debouncer); Ok(rx) } } impl IndexerRunnerGuard { pub async fn watch(&self, logging_dir: &Utf8Path) -> anyhow::Result<()> { let (tx, rx) = oneshot::channel(); self.tx .send(IndexerRunnerCmd::Watch(logging_dir.to_path_buf(), tx)) .context("failed to send watch command")?; rx.await.context("failed to receive watch command")??; Ok(()) } } ================================================ FILE: backend/tauri/src/logging/mod.rs ================================================ mod indexer; mod manager; const LOGGING_NS: &str = "logging"; const LOGGING_DB_PREFIX: &str = "logging"; use anyhow::Context; use camino::Utf8PathBuf; pub use indexer::*; use manager::IndexerManager; use tauri::Manager; pub fn setup>(app: &M) -> anyhow::Result<()> { let app_handle = app.app_handle().clone(); // FIXME: this is a background setup, so be careful use this state in ipc. If use state when it is not ready, it will cause panic. nyanpasu_utils::runtime::spawn(async move { let logging_dir = crate::utils::dirs::app_logs_dir() .context("failed to get app logs dir") .unwrap(); let logging_dir = Utf8PathBuf::from_path_buf(logging_dir).unwrap(); let manager = IndexerManager::try_new(logging_dir) .await .context("failed to create indexer manager") .unwrap(); app_handle.manage(manager); }); Ok(()) } ================================================ FILE: backend/tauri/src/main.rs ================================================ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { clash_nyanpasu_lib::run().unwrap(); } ================================================ FILE: backend/tauri/src/server/mod.rs ================================================ use anyhow::{Context, Result, anyhow}; use axum::{ Router, body::Body, extract::Query, http::{HeaderValue, Response, StatusCode}, routing::get, }; use base64::{Engine, prelude::BASE64_STANDARD}; use bytes::Bytes; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tokio::io::AsyncWriteExt; use tracing_attributes::instrument; use url::Url; use std::{borrow::Cow, path::Path, time::Duration}; pub(crate) use crate::utils::candy::get_reqwest_client; pub static SERVER_PORT: Lazy = Lazy::new(|| port_scanner::request_open_port().unwrap()); const CACHE_TIMEOUT: Duration = Duration::from_secs(60 * 60 * 24 * 7); // 7 days #[derive(Debug, Deserialize)] struct CacheIcon { /// should be encoded as base64 url: String, } #[derive(Debug, Clone, Deserialize, Serialize)] struct CacheFile<'n> { mime: Cow<'n, str>, bytes: Bytes, } impl TryFrom> for (HeaderValue, Bytes) { type Error = anyhow::Error; fn try_from(value: CacheFile<'static>) -> Result { Ok(( value .mime .parse::() .context("failed to parse mime")?, value.bytes, )) } } // TODO: use Reader instead of Vec async fn read_cache_file(path: &Path) -> Result<(HeaderValue, Bytes)> { let cache_file = tokio::fs::read(path).await?; let cache_file: CacheFile<'static> = postcard::from_bytes(&cache_file)?; cache_file.try_into() } // TODO: use Writer instead of Vec async fn write_cache_file(path: &Path, cache_file: &CacheFile<'_>) -> Result<()> { let mut file = tokio::fs::File::create(path).await?; let cache_file = postcard::to_allocvec(cache_file)?; file.write_all(&cache_file).await?; Ok(()) } async fn remove_cache_file(cache_file: &Path) { if let Err(e) = tokio::fs::remove_file(&cache_file).await { tracing::error!("failed to remove cache file: {}", e); } } async fn cache_icon_inner(url: &str) -> Result<(HeaderValue, Bytes)> { let url = BASE64_STANDARD.decode(url)?; let url = String::from_utf8_lossy(&url); let url = Url::parse(&url)?; // get filename let hash = Sha256::digest(url.as_str().as_bytes()); let cache_dir = crate::utils::dirs::cache_dir()?.join("icons"); if !cache_dir.exists() { std::fs::create_dir_all(&cache_dir)?; } // TODO: if face performance issue, abstract a task to schedule cache file removal let now = std::time::SystemTime::now(); let outdated_time = now .checked_sub(CACHE_TIMEOUT) .expect("cache timeout is too long"); let cache_file = cache_dir.join(format!("{hash:x}.bin")); let meta = tokio::fs::metadata(&cache_file).await.ok(); match meta { Some(meta) if meta.modified().is_ok_and(|t| t < outdated_time) => { tracing::debug!("cache file is outdate, removing it"); remove_cache_file(&cache_file).await; } Some(_) => { let span = tracing::span!(tracing::Level::DEBUG, "read_cache_file", path = ?cache_file); let _enter = span.enter(); match read_cache_file(&cache_file).await { Ok((mime, bytes)) => return Ok((mime, bytes)), Err(e) => { tracing::error!("failed to read cache file: {}", e); remove_cache_file(&cache_file).await; } } } _ => (), } let client = get_reqwest_client()?; let response = client.get(url).send().await?.error_for_status()?; let mime = response .headers() .get("content-type") .ok_or(anyhow!("no content-type"))? .to_str()? .to_string(); let bytes = response.bytes().await?; let data = CacheFile { mime: Cow::Owned(mime), bytes, }; if let Err(e) = write_cache_file(&cache_file, &data).await { tracing::error!("failed to write cache file: {}", e); } Ok(data .try_into() .expect("It's impossible to fail, if failed, it must a bug, or memory corruption")) } #[tracing_attributes::instrument] async fn cache_icon(query: Query) -> Response { match cache_icon_inner(&query.url).await { Ok((mime, bytes)) => { let mut response = Response::new(Body::from(bytes)); response.headers_mut().insert("content-type", mime); response } Err(e) => { tracing::error!("{}", e); let mut response = Response::new(Body::from(e.to_string())); *response.status_mut() = StatusCode::BAD_REQUEST; response } } } #[derive(Deserialize)] struct TrayIconReq { mode: crate::core::tray::icon::TrayIcon, } async fn tray_icon(query: Query) -> Response { let mode = query.mode; let icon = crate::core::tray::icon::get_raw_icon(mode); let mut response = Response::new(Body::from(icon)); response .headers_mut() .insert("content-type", "image/png".parse().unwrap()); response } #[instrument] pub async fn run(port: u16) -> std::io::Result<()> { let app = Router::new() .route("/cache/icon", get(cache_icon)) .route("/tray/icon", get(tray_icon)); let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")).await?; tracing::debug!( "internal http server listening on {}", listener.local_addr()? ); axum::serve(listener, app).await } ================================================ FILE: backend/tauri/src/setup.rs ================================================ //! Setup logic for the app use anyhow::Context; pub fn setup>(app: &M) -> Result<(), anyhow::Error> { let app_handle = app.app_handle().clone(); #[cfg(target_os = "windows")] super::shutdown_hook::setup_shutdown_hook(move || { tracing::info!("Shutdown hook triggered, exiting app..."); app_handle.exit(0); }) .context("Failed to setup the shutdown hook")?; // FIXME: this is a background setup, so be careful use this state in ipc. // crate::logging::setup(app).context("Failed to setup logging")?; Ok(()) } ================================================ FILE: backend/tauri/src/shutdown_hook.rs ================================================ //! a shutdown handler for Windows use atomic_enum::atomic_enum; use once_cell::sync::OnceCell; use windows_core::{Error, w}; use windows_sys::Win32::{ Foundation::{HINSTANCE, HWND, LPARAM, WPARAM}, System::{ LibraryLoader::GetModuleHandleW, Shutdown::{ShutdownBlockReasonCreate, ShutdownBlockReasonDestroy}, }, UI::WindowsAndMessaging::{ CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW, MSG, PostMessageW, RegisterClassExW, TranslateMessage, WM_CLOSE, WM_ENDSESSION, WM_QUERYENDSESSION, WNDCLASSEXW, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, }, }; static SHUTDOWN_HOOK_INSTANCE: OnceCell> = OnceCell::new(); #[atomic_enum] #[derive(PartialEq, Eq)] pub enum ShutdownState { Idle, CleaningUp, ReadyForShutdown, } static SHUTDOWN_STATE: AtomicShutdownState = AtomicShutdownState::new(ShutdownState::Idle); pub fn setup_shutdown_hook(f: impl Fn() + Send + Sync + 'static) -> anyhow::Result<()> { if SHUTDOWN_HOOK_INSTANCE.get().is_some() { anyhow::bail!("Shutdown hook already set"); } let (initd_tx, initd_rx) = oneshot::channel(); let handle = std::thread::spawn(move || setup_shutdown_hook_inner(f, initd_tx)); if let Err(oneshot::RecvError) = initd_rx.recv() { handle .join() .map_err(|_| anyhow::anyhow!("Failed to join the shutdown hook thread"))??; } Ok(()) } struct WindowHandle { hwnd: HWND, h_instance: HINSTANCE, } impl Drop for WindowHandle { fn drop(&mut self) { unsafe { tracing::debug!("Destroying window handle..."); // Post a message to the window to tell it to exit PostMessageW(self.hwnd, WM_CLOSE, 0, 0); } } } unsafe extern "system" fn callback(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> isize { let is_ready = SHUTDOWN_STATE.load(std::sync::atomic::Ordering::Relaxed); match msg { WM_QUERYENDSESSION | WM_ENDSESSION if is_ready == ShutdownState::Idle => { tracing::info!("Shutdown hook triggered, received WM_QUERYENDSESSION"); if let Some(tx) = SHUTDOWN_HOOK_INSTANCE.get() { tracing::info!("Blocking shutdown for cleanup..."); let reason = w!("Clash Nyanpasu is cleaning up..."); if unsafe { ShutdownBlockReasonCreate(hwnd, reason.as_ptr()) } == 0 { let err = Error::from_win32(); tracing::error!("Failed to create shutdown block reason: {err}"); } tx.send(()).unwrap(); while SHUTDOWN_STATE.load(std::sync::atomic::Ordering::Relaxed) != ShutdownState::ReadyForShutdown { std::thread::sleep(std::time::Duration::from_millis(10)); } tracing::info!("Shutdown hook is ready for shutdown"); if unsafe { ShutdownBlockReasonDestroy(hwnd) } == 0 { let err = Error::from_win32(); tracing::error!("Failed to destroy shutdown block reason: {err}"); } } 0 } WM_QUERYENDSESSION | WM_ENDSESSION if is_ready == ShutdownState::CleaningUp => { loop { if SHUTDOWN_STATE.load(std::sync::atomic::Ordering::Relaxed) == ShutdownState::ReadyForShutdown { break; } std::thread::sleep(std::time::Duration::from_millis(10)); } 0 } _ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }, } } /// Only called on tauri cleanup thread finished pub fn set_ready_for_shutdown() { SHUTDOWN_STATE.store( ShutdownState::ReadyForShutdown, std::sync::atomic::Ordering::Relaxed, ); } fn setup_shutdown_hook_inner( f: impl Fn() + Send + Sync + 'static, initd_tx: oneshot::Sender<()>, ) -> anyhow::Result<()> { let class_name = w!("TAURI_SHUTDOWN_HOOK"); let (tx, rx) = std::sync::mpsc::channel::<()>(); std::thread::spawn(move || { while let Ok(()) = rx.recv() { f(); } }); SHUTDOWN_HOOK_INSTANCE.set(tx).unwrap(); let h_instance = unsafe { GetModuleHandleW(std::ptr::null()) }; if h_instance.is_null() { let err = Error::from_win32(); anyhow::bail!("Failed to get module handle: {err}"); } let mut window_class_ex = unsafe { std::mem::zeroed::() }; window_class_ex.cbSize = std::mem::size_of::() as u32; window_class_ex.lpszClassName = class_name.as_ptr(); window_class_ex.lpfnWndProc = Some(callback); window_class_ex.hInstance = h_instance; unsafe { if RegisterClassExW(&window_class_ex) == 0 { let err = Error::from_win32(); anyhow::bail!("Failed to register window class: {err}"); } } let window_name = w!("TAURI_SHUTDOWN_HOOK_WINDOW"); let hidden_window = unsafe { CreateWindowExW( WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, class_name.as_ptr(), window_name.as_ptr(), 0, 0, 0, 0, 0, std::ptr::null_mut(), std::ptr::null_mut(), h_instance, std::ptr::null_mut(), ) }; if hidden_window.is_null() { let err = Error::from_win32(); anyhow::bail!("Failed to create hidden window: {err}"); } let window_handle = WindowHandle { hwnd: hidden_window, h_instance, }; if let Err(e) = initd_tx.send(()) { anyhow::bail!("Failed to send initd signal: {e}"); } let mut msg = unsafe { std::mem::zeroed::() }; unsafe { loop { let result = GetMessageW(&mut msg, window_handle.hwnd, 0, 0); if result > 0 { TranslateMessage(&msg); DispatchMessageW(&msg); } else { let err = Error::from_win32(); tracing::error!( "GetMessageW failed with {result}, shutdown hook thread exiting: {err}" ); break; } } } Ok(()) } ================================================ FILE: backend/tauri/src/utils/candy.rs ================================================ use super::{config::NyanpasuReqwestProxyExt, dirs::app_logs_dir}; use anyhow::Result; use chrono::Local; use glob::glob; use std::{path::Path, time::Duration}; use url::Url; use zip::{ZipWriter, write::SimpleFileOptions}; pub fn collect_logs(target_path: &Path) -> Result<()> { let logs_dir = app_logs_dir()?; let now = Local::now().format("%Y-%m-%d"); let globstr = format!("{}/*.{}.app.log", logs_dir.to_str().unwrap(), now); let mut paths = Vec::new(); for entry in glob(&globstr)? { match entry { Ok(path) => paths.push(path), Err(e) => return Err(e.into()), } } let file = std::fs::File::create(target_path)?; let mut zip = ZipWriter::new(file); for path in paths { let file_name = path.file_name().unwrap().to_str().unwrap(); zip.start_file(file_name, SimpleFileOptions::default())?; let mut file = std::fs::File::open(path)?; std::io::copy(&mut file, &mut zip)?; } zip.finish()?; Ok(()) } // TODO: 添加自定义 User-Agent 等配置,说白了就是重构一下 prfitem 的那坨代码 pub fn get_reqwest_client() -> Result { let builder = reqwest::ClientBuilder::new(); let app_version = super::dirs::get_app_version(); let client = builder .swift_set_nyanpasu_proxy() .user_agent(format!("clash-nyanpasu/{app_version}")) .build()?; Ok(client) } pub const INTERNAL_MIRRORS: &[&str] = &[ "https://github.com/", "https://gh-proxy.com/", // too many restrictions, not recommended // "https://gh.idayer.com/", ]; pub fn parse_gh_url(mirror: &str, path: &str) -> Result { if mirror.contains("github.com") && !path.starts_with('/') { Url::parse(path) } else { let mut url = Url::parse(mirror)?; url.set_path(path); Ok(url) } } #[async_trait::async_trait] pub trait ReqwestSpeedTestExt { async fn mirror_speed_test<'a>( &self, mirrors: &'a [&'a str], path: &'a str, ) -> Result>; } #[async_trait::async_trait] impl ReqwestSpeedTestExt for reqwest::Client { async fn mirror_speed_test<'a>( &self, mirrors: &'a [&'a str], path: &'a str, ) -> Result> { let results = futures::future::join_all(mirrors.iter().map(|&mirror| { let client = self; async move { let start = tokio::time::Instant::now(); // if mirror is github.com, we should use it directly let url = parse_gh_url(mirror, path)?; tracing::debug!("Testing {}", url.as_str()); let _ = tokio::time::timeout(Duration::from_secs(3), client.get(url.as_str()).send()) .await; // warm up let result: Result = tokio::time::timeout(Duration::from_secs(3), client.get(url).send()) .await .map_err(anyhow::Error::msg) .and_then(|v| v.map_err(anyhow::Error::msg)) .and_then(|v| v.error_for_status().map_err(anyhow::Error::msg)); match result { Ok(response) => { let content_length = response.content_length().unwrap_or(0) as f64; // should read all the response body to get the correct speed match response.bytes().await { Ok(_) => { let elapsed = start.elapsed().as_secs_f64(); let speed = content_length / elapsed; Ok((mirror, speed)) } Err(e) => { tracing::warn!("test mirror {} failed: {}", mirror, e); Ok((mirror, 0.0)) } } } Err(e) => { tracing::warn!("test mirror {} failed: {}", mirror, e); Ok((mirror, 0.0)) } } } })) .await; let collected_result: Result, anyhow::Error> = results.into_iter().collect(); let mut results = collected_result?; results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); Ok(results) } } mod test { #[allow(unused_imports)] use super::*; #[tokio::test] #[allow(clippy::needless_return)] // a bug in clippy async fn test_mirror_speed_test() { let client = reqwest::Client::builder().user_agent( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" ).build().unwrap(); let results = client .mirror_speed_test( INTERNAL_MIRRORS, "https://raw.githubusercontent.com/simonw/github-large-file-test/master/1.5mb.txt", ) .await .unwrap(); println!("{results:?}"); } } ================================================ FILE: backend/tauri/src/utils/collect.rs ================================================ use std::{borrow::Cow, collections::HashMap}; #[cfg(windows)] use std::os::windows::process::CommandExt; use crate::consts::{BUILD_INFO, BuildInfo}; use humansize::{BINARY, SizeFormatter}; use nyanpasu_utils::core::{ClashCoreType, CoreType}; use serde::Serialize; use sysinfo::System; #[derive(Debug, Serialize, specta::Type)] pub struct DeviceInfo<'a> { /// Device name, such as "Intel Core i5-8250U CPU @ 1.60GHz x 8" pub cpu: Vec>, /// GPU name, such as "Intel UHD Graphics 620 (Kabylake GT2)" // pub gpu: Cow<'a, str>, /// Memory size in bytes pub memory: Cow<'a, str>, } #[derive(Debug, Serialize, specta::Type)] pub struct EnvInfo<'a> { pub os: Cow<'a, str>, pub arch: Cow<'a, str>, pub core: CoreInfo<'a>, pub device: DeviceInfo<'a>, pub build_info: Cow<'a, BuildInfo>, // TODO: add service info // pub service_info: xxx } pub type CoreInfo<'a> = HashMap, Cow<'a, str>>; pub fn collect_envs<'a>() -> Result, std::io::Error> { let mut system = sysinfo::System::new_all(); system.refresh_all(); let device = DeviceInfo { cpu: { let mut cpus: Vec<(u64, &str, i32)> = Vec::new(); for cpu in system.cpus().iter() { let item = cpus.iter_mut().find(|(_, name, _)| name == &cpu.brand()); match item { Some((_, _, count)) => *count += 1, None => cpus.push((cpu.frequency(), cpu.brand(), 1)), } } cpus.iter() .map(|(freq, name, count)| { Cow::Owned(format!( "{} @ {:.2}GHz x {}", name, *freq as f64 / 1000.0, count )) }) .collect() }, memory: Cow::Owned(SizeFormatter::new(system.total_memory(), BINARY).to_string()), }; let mut core = HashMap::new(); for c in CoreType::get_supported_cores() { let name: &str = c.as_ref(); let mut command = std::process::Command::new( super::dirs::get_data_or_sidecar_path(name) .map_err(|e| std::io::Error::other(e.to_string()))?, ); command.args(if matches!(c, CoreType::Clash(ClashCoreType::ClashRust)) { ["-V"] } else { ["-v"] }); #[cfg(windows)] let command = command.creation_flags(0x08000000); let output = command.output().expect("failed to execute sidecar command"); let stdout = String::from_utf8_lossy(&output.stdout); core.insert( Cow::Borrowed(name), Cow::Owned(stdout.replace("\n\n", " ").trim().to_owned()), ); } Ok(EnvInfo { os: Cow::Owned( format!( "{} {}", System::long_os_version().unwrap_or("".to_string()), System::kernel_version().unwrap_or("".to_string()), ) .trim() .to_owned(), ), arch: Cow::Owned(System::cpu_arch()), core, device, build_info: Cow::Borrowed(&BUILD_INFO), }) } ================================================ FILE: backend/tauri/src/utils/config.rs ================================================ use anyhow::Result; use sysproxy::Sysproxy; use crate::config::Config; pub fn get_self_proxy() -> Result { let port = Config::verge() .latest() .verge_mixed_port .unwrap_or(Config::clash().data().get_mixed_port()); let proxy_scheme = format!("http://127.0.0.1:{port}"); Ok(proxy_scheme) } pub fn get_system_proxy() -> Result> { let p = Sysproxy::get_system_proxy()?; if p.enable { let proxy_scheme = format!("http://{}:{}", p.host, p.port); return Ok(Some(proxy_scheme)); } Ok(None) } pub fn get_current_clash_mode() -> String { Config::clash() .latest() .0 .get("mode") .map(|val| val.as_str().unwrap_or("rule")) .unwrap_or("rule") .to_owned() } pub trait NyanpasuReqwestProxyExt { fn swift_set_proxy(self, url: &str) -> Self; fn swift_set_nyanpasu_proxy(self) -> Self; } impl NyanpasuReqwestProxyExt for reqwest::ClientBuilder { fn swift_set_proxy(self, url: &str) -> Self { let mut builder = self; if let Ok(proxy) = reqwest::Proxy::http(url) { builder = builder.proxy(proxy); } if let Ok(proxy) = reqwest::Proxy::https(url) { builder = builder.proxy(proxy); } if let Ok(proxy) = reqwest::Proxy::all(url) { builder = builder.proxy(proxy); } builder } // TODO: 修改成按枚举配置 fn swift_set_nyanpasu_proxy(self) -> Self { let mut builder = self; if let Ok(proxy) = get_self_proxy() { builder = builder.swift_set_proxy(&proxy); } if let Ok(Some(proxy)) = get_system_proxy() { builder = builder.swift_set_proxy(&proxy); } builder } } ================================================ FILE: backend/tauri/src/utils/dialog.rs ================================================ #![allow(dead_code)] use rfd::{MessageButtons, MessageDialog, MessageDialogResult, MessageLevel}; use rust_i18n::t; pub fn panic_dialog(msg: &str) { let msg = format!("{}\n\n{}", msg, t!("dialog.panic")); MessageDialog::new() .set_level(MessageLevel::Error) .set_title("Clash Nyanpasu Crash") .set_description(msg.as_str()) .set_buttons(MessageButtons::Ok) .show(); } pub fn migrate_dialog(msg: &str) -> bool { matches!( MessageDialog::new() .set_level(MessageLevel::Warning) .set_title("Clash Nyanpasu Migration") .set_buttons(MessageButtons::YesNo) .set_description(msg) .show(), MessageDialogResult::Yes ) } pub fn error_dialog>(msg: T) { MessageDialog::new() .set_level(MessageLevel::Error) .set_title("Clash Nyanpasu Error") .set_description(msg.into()) .set_buttons(MessageButtons::Ok) .show(); } pub fn warning_dialog>(msg: T) { MessageDialog::new() .set_level(MessageLevel::Warning) .set_title("Clash Nyanpasu Warning") .set_description(msg.into()) .set_buttons(MessageButtons::Ok) .show(); } pub fn ask_dialog>(msg: T) -> bool { matches!( MessageDialog::new() .set_level(MessageLevel::Info) .set_title("Clash Nyanpasu") .set_buttons(MessageButtons::YesNo) .set_description(msg.into()) .show(), MessageDialogResult::Yes ) } ================================================ FILE: backend/tauri/src/utils/dirs.rs ================================================ use crate::{core::handle, log_err}; use anyhow::Result; use fs_err as fs; use nyanpasu_utils::dirs::{suggest_config_dir, suggest_data_dir}; use once_cell::sync::Lazy; use std::{borrow::Cow, path::PathBuf}; use tauri::{Env, utils::platform::resource_dir}; #[cfg(not(feature = "verge-dev"))] #[allow(unused)] const PREVIOUS_APP_NAME: &str = "clash-verge"; #[cfg(feature = "verge-dev")] const PREVIOUS_APP_NAME: &str = "clash-verge-dev"; #[cfg(not(feature = "verge-dev"))] pub const APP_NAME: &str = "clash-nyanpasu"; #[cfg(feature = "verge-dev")] pub const APP_NAME: &str = "clash-nyanpasu-dev"; /// App Dir placeholder /// It is used to create the config and data dir in the filesystem /// For windows, the style should be similar to `C:/Users/nyanapasu/AppData/Roaming/Clash Nyanpasu` /// For macos, it should be similar to `/Users/nyanpasu/Library/Application Support/Clash Nyanpasu` /// For other platforms, it should be similar to `/home/nyanpasu/.config/clash-nyanpasu` pub static APP_DIR_PLACEHOLDER: Lazy> = Lazy::new(|| { use convert_case::{Case, Casing}; if cfg!(any(target_os = "windows", target_os = "macos")) { Cow::Owned(APP_NAME.to_case(Case::Title)) } else { Cow::Borrowed(APP_NAME) } }); pub const CLASH_CFG_GUARD_OVERRIDES: &str = "clash-guard-overrides.yaml"; pub const NYANPASU_CONFIG: &str = "nyanpasu-config.yaml"; pub const PROFILE_YAML: &str = "profiles.yaml"; pub const STORAGE_DB: &str = "storage.db"; pub static APP_VERSION: &str = env!("NYANPASU_VERSION"); pub fn get_app_version() -> &'static str { APP_VERSION } #[cfg(target_os = "windows")] pub fn get_portable_flag() -> bool { *crate::consts::IS_PORTABLE } pub fn app_config_dir() -> Result { let path: Option = { #[cfg(target_os = "windows")] { if get_portable_flag() { let app_dir = app_install_dir()?; Some(app_dir.join(".config").join(APP_NAME)) } else if let Ok(Some(path)) = super::winreg::get_app_dir() { Some(path) } else { None } } #[cfg(not(target_os = "windows"))] { None } }; match path { Some(path) => Ok(path), None => suggest_config_dir(&APP_DIR_PLACEHOLDER) .ok_or(anyhow::anyhow!("failed to get the app config dir")), } .and_then(|dir| { create_dir_all(&dir)?; Ok(dir) }) } pub fn app_data_dir() -> Result { let path: Option = { #[cfg(target_os = "windows")] { if get_portable_flag() { let app_dir = app_install_dir()?; Some(app_dir.join(".data").join(APP_NAME)) } else { None } } #[cfg(not(target_os = "windows"))] { None } }; match path { Some(path) => Ok(path), None => suggest_data_dir(&APP_DIR_PLACEHOLDER) .ok_or(anyhow::anyhow!("failed to get the app data dir")), } .and_then(|dir| { create_dir_all(&dir)?; Ok(dir) }) } /// get the verge app home dir #[deprecated( since = "1.6.0", note = "should use self::app_config_dir or self::app_data_dir instead" )] pub fn app_home_dir() -> Result { if cfg!(feature = "verge-dev") { return Ok(dirs::home_dir() .ok_or(anyhow::anyhow!("failed to get the app home dir"))? .join(".config") .join(APP_NAME)); } #[cfg(target_os = "windows")] { use crate::utils::winreg::get_app_dir; if !get_portable_flag() { let reg_app_dir = get_app_dir()?; if let Some(reg_app_dir) = reg_app_dir { return Ok(reg_app_dir); } return Ok(dirs::home_dir() .ok_or(anyhow::anyhow!("failed to get app home dir"))? .join(".config") .join(APP_NAME)); } Ok((app_install_dir()?).join(".config").join(APP_NAME)) } #[cfg(not(target_os = "windows"))] Ok(dirs::home_dir() .ok_or(anyhow::anyhow!("failed to get the app home dir"))? .join(".config") .join(APP_NAME)) } /// get the resources dir pub fn app_resources_dir() -> Result { let handle = handle::Handle::global(); let app_handle = handle.app_handle.lock(); if let Some(app_handle) = app_handle.as_ref() { let res_dir = resource_dir(app_handle.package_info(), &Env::default()) .map_err(|_| anyhow::anyhow!("failed to get the resource dir"))? .join("resources"); return Ok(res_dir); }; Err(anyhow::anyhow!("failed to get the resource dir")) } // /// Cache dir, it safe to clean up // pub fn cache_dir() -> Result { // let mut dir = dirs::cache_dir() // .ok_or(anyhow::anyhow!("failed to get the cache dir"))? // .join(APP_DIR_PLACEHOLDER.as_ref()); // if cfg!(windows) { // dir.push("cache"); // } // if !dir.exists() { // fs::create_dir_all(&dir)?; // } // Ok(dir) // } /// App install dir, sidecars should placed here pub fn app_install_dir() -> Result { let exe = tauri::utils::platform::current_exe()?; let exe = dunce::canonicalize(exe)?; let dir = exe .parent() .ok_or(anyhow::anyhow!("failed to get the app install dir"))?; Ok(PathBuf::from(dir)) } /// profiles dir pub fn app_profiles_dir() -> Result { let path = app_config_dir()?.join("profiles"); static INIT: std::sync::Once = std::sync::Once::new(); INIT.call_once(|| { log_err!(create_dir_all(&path)); }); Ok(path) } /// logs dir pub fn app_logs_dir() -> Result { let path = app_data_dir()?.join("logs"); static INIT: std::sync::Once = std::sync::Once::new(); INIT.call_once(|| { log_err!(create_dir_all(&path)); }); Ok(path) } pub fn clash_guard_overrides_path() -> Result { Ok(app_config_dir()?.join(CLASH_CFG_GUARD_OVERRIDES)) } pub fn nyanpasu_config_path() -> Result { Ok(app_config_dir()?.join(NYANPASU_CONFIG)) } pub fn profiles_path() -> Result { Ok(app_config_dir()?.join(PROFILE_YAML)) } pub fn storage_path() -> Result { Ok(app_data_dir()?.join(STORAGE_DB)) } pub fn clash_pid_path() -> Result { Ok(app_data_dir()?.join("clash.pid")) } pub fn cache_dir() -> Result { let path = app_data_dir()?.join("cache"); static INIT: std::sync::Once = std::sync::Once::new(); INIT.call_once(|| { log_err!(create_dir_all(&path)); }); Ok(path) } pub fn tray_icons_path(mode: &str) -> Result { let icons_dir = app_config_dir()?.join("icons"); static INIT: std::sync::Once = std::sync::Once::new(); INIT.call_once(|| { log_err!(create_dir_all(&icons_dir)); }); Ok(icons_dir.join(format!("{mode}.png"))) } #[cfg(windows)] #[deprecated(since = "1.6.0", note = "should use nyanpasu_utils::dirs mod instead")] pub fn service_log_file() -> Result { use chrono::Local; let log_dir = app_logs_dir()?.join("service"); let local_time = Local::now().format("%Y-%m-%d-%H%M").to_string(); let log_file = format!("{local_time}.log"); let log_file = log_dir.join(log_file); let _ = std::fs::create_dir_all(&log_dir); Ok(log_file) } pub fn path_to_str(path: &PathBuf) -> Result<&str> { let path_str = path .as_os_str() .to_str() .ok_or(anyhow::anyhow!("failed to get path from {:?}", path))?; Ok(path_str) } pub fn get_single_instance_placeholder() -> Result { let cfg_dir = crate::utils::dirs::app_config_dir()?; #[cfg(windows)] { // Try to get user SID for better user isolation match crate::utils::winreg::get_current_user_sid() { Ok(sid) => { // Use session-local namespace and include app name + user SID to ensure per-user uniqueness return Ok(format!("Local\\{}-{}", APP_NAME, sid)); } Err(_) => { // Fallback to config dir hashing if SID retrieval fails use std::{ collections::hash_map::DefaultHasher, hash::{Hash, Hasher}, }; let mut hasher = DefaultHasher::new(); cfg_dir.to_string_lossy().hash(&mut hasher); let hash = hasher.finish(); return Ok(format!("Local\\{}-{:x}", APP_NAME, hash)); } } } #[cfg(not(windows))] { return Ok(cfg_dir.join("instance.lock").to_string_lossy().to_string()); } } fn create_dir_all(dir: &PathBuf) -> Result<(), std::io::Error> { let meta = fs::metadata(dir); if let Ok(meta) = meta { if !meta.is_dir() { fs_err::remove_file(dir)?; } else { return Ok(()); } } fs_extra::dir::create_all(dir, false).map_err(|e| { std::io::Error::other(format!("failed to create dir: {:?}, kind: {:?}", e, e.kind)) })?; Ok(()) } pub fn get_data_or_sidecar_path(binary_name: impl AsRef) -> Result { let binary_name = binary_name.as_ref(); let data_dir = app_data_dir()?; let path = data_dir.join(if cfg!(windows) && !binary_name.ends_with(".exe") { format!("{binary_name}.exe") } else { binary_name.to_string() }); if path.exists() { return Ok(data_dir); } let install_dir = app_install_dir()?; let path = install_dir.join(if cfg!(windows) && !binary_name.ends_with(".exe") { format!("{binary_name}.exe") } else { binary_name.to_string() }); Ok(path) } #[cfg(any(target_os = "macos", target_os = "linux"))] pub fn check_core_permission(core: &nyanpasu_utils::core::CoreType) -> anyhow::Result { #[cfg(target_os = "macos")] const ROOT_GROUP: &str = "admin"; #[cfg(target_os = "linux")] const ROOT_GROUP: &str = "root"; use anyhow::Context; use nix::unistd::{Gid, Group as NixGroup, Uid, User}; use std::os::unix::fs::MetadataExt; let core_path = crate::core::clash::core::find_binary_path(core).context("clash core not found")?; let metadata = std::fs::metadata(&core_path).context("failed to get core metadata")?; let uid = metadata.uid(); let gid = metadata.gid(); let user = User::from_uid(Uid::from_raw(uid)).ok().flatten(); let group = NixGroup::from_gid(Gid::from_raw(gid)).ok().flatten(); if let (Some(user), Some(group)) = (user, group) { if user.name == "root" && group.name == ROOT_GROUP { return Ok(true); } } Ok(false) } mod test { #[test] #[ignore] fn test_dir_placeholder() { let placeholder = super::APP_DIR_PLACEHOLDER.clone(); if cfg!(windows) { assert_eq!(placeholder, "Clash Nyanpasu"); } else { assert_eq!(placeholder, "clash-nyanpasu"); } } } ================================================ FILE: backend/tauri/src/utils/dock.rs ================================================ #[cfg(target_os = "macos")] pub mod macos { use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy}; use objc2_foundation::MainThreadMarker; use std::cell::Cell; thread_local! { static MARK: Cell = Cell::new(MainThreadMarker::new().unwrap()); } pub fn show_dock_icon() { let app = NSApplication::sharedApplication(MARK.get()); app.setActivationPolicy(NSApplicationActivationPolicy::Regular); unsafe { app.activate(); } } pub fn hide_dock_icon() { let app = NSApplication::sharedApplication(MARK.get()); app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); } } ================================================ FILE: backend/tauri/src/utils/downloader.rs ================================================ /// Downloader is a utility to download file with parallel requests and progress bar. /// TODO: use &str instead of String to avoid unnecessary allocation /// TODO: support no RANGE support server /// TODO: add dynamic increase chunks features /// use futures::StreamExt; use num_cpus; use parking_lot::RwLock; use reqwest::{Client, IntoUrl}; use serde::Serialize; use std::{fs::File as StdFile, io::Write, sync::Arc, time}; use tempfile::tempfile; use thiserror::Error; use tokio::{ fs::File, io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}, sync::{ Semaphore, mpsc::{self, Sender}, }, time::sleep, }; use url::Url; pub struct Downloader { inner: RwLock, client: Client, url: Arc, event_callback: Option, } impl std::fmt::Debug for Downloader { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let inner = self.inner.read(); f.debug_struct("Downloader") .field("inner", &inner) .field("client", &self.client) .field("url", &self.url) .finish() } } #[derive(Debug)] struct DownloaderInner { state: DownloaderState, file: Option, total_size: u64, semaphore: Arc, chunks: Vec>>, mode: DownloadMode, } impl Default for DownloaderInner { fn default() -> Self { Self { state: DownloaderState::default(), file: None, total_size: 0, semaphore: Arc::new(Semaphore::new(1)), chunks: Vec::new(), mode: DownloadMode::default(), } } } pub struct DownloaderBuilder { client: Option, url: Option, file: Option, event_callback: Option, } impl DownloaderBuilder { pub fn new() -> Self { Self { client: None, url: None, file: None, event_callback: None, } } pub fn set_client(mut self, client: Client) -> Self { self.client = Some(client); self } pub fn set_url(mut self, url: U) -> Result { self.url = Some(url.into_url()?); Ok(self) } pub fn set_file(mut self, file: File) -> Self { self.file = Some(file); self } pub fn set_event_callback(mut self, callback: F) -> Self { self.event_callback = Some(callback); self } pub fn build(self) -> Result, DownloaderError> { let client = self.client.unwrap_or_default(); let url = self .url .ok_or(DownloaderError::Other("URL is not set".to_string()))?; let nums = num_cpus::get(); Ok(Downloader { inner: RwLock::new(DownloaderInner { file: self.file, semaphore: Arc::new(Semaphore::new(nums)), chunks: Vec::with_capacity(nums), ..Default::default() }), event_callback: self.event_callback, client, url: Arc::new(url), }) } } #[derive(Debug, Serialize, Default, Clone, specta::Type)] #[serde(rename_all = "snake_case")] pub enum DownloaderState { #[default] Idle, Downloading, WaitingForMerge, Merging, Failed(String), Finished, } #[derive(Debug, Serialize, Default)] pub enum DownloadMode { SingleThread, #[default] MultiThread, } #[derive(Debug, Serialize, specta::Type)] pub struct DownloadStatus { pub state: DownloaderState, pub downloaded: u64, pub total: u64, pub speed: f64, pub chunks: Vec, pub now: u64, } #[derive(Debug, Serialize, specta::Type)] #[allow(private_interfaces)] pub struct ChunkStatus { pub state: ChunkThreadState, pub start: usize, pub end: usize, pub downloaded: usize, pub speed: f64, } enum ChunkThreadEvent { DecreaseSemaphore(DecreaseSemaphoreReason), Finish, } enum DecreaseSemaphoreReason { Reason(String), Cause(anyhow::Error), } #[derive(Debug, Clone, Serialize, specta::Type)] enum ChunkThreadState { Idle, Downloading, Finished, } #[derive(Debug)] struct ChunkThread { client: Client, sender: Sender, semaphore: Arc, file: StdFile, url: Arc, pub state: ChunkThreadState, pub start: usize, pub end: usize, pub downloaded: usize, pub speed: f64, } #[derive(Error, Debug)] pub enum DownloaderError { #[error("Failed to perform a request, reason: {0}")] RequestFailed(#[from] reqwest::Error), #[error("Failed to download file, reason: {0}")] DownloadFailed(#[from] anyhow::Error), #[error("Failed to write file")] WriteFailed(#[from] std::io::Error), #[error("Failed to confirm file size")] ConfirmSizeFailed, #[error("Other error: {0}")] Other(String), } impl Serialize for DownloaderError { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { format!("{self}").serialize(serializer) } } #[allow(unused)] impl Downloader { pub fn set_file(&self, file: File) { let mut inner = self.inner.write(); inner.file = Some(file); } fn dispatch_event(&self, state: DownloaderState) { { let mut inner = self.inner.write(); inner.state = state.clone(); } if let Some(callback) = &self.event_callback { callback(state); } } // get file status, get remote content size, and return server filename async fn confirm_file_status(&self) -> Result<(String, u64), DownloaderError> { let response = self .client .head(self.url.clone().as_str()) .send() .await? .error_for_status()?; let headers = response.headers(); // TODO: fallback to single thread download // TODO:考慮到相當一部分服務端不發送 ACCEPT_RANGES,因此需要動態嘗試來確認 if headers.get(reqwest::header::ACCEPT_RANGES).is_none() { tracing::warn!( "Server does not provide ACCEPT_RANGES header. Though dynamic confirm whether server support RANGE requests is required, we have to use multi-thread download mode directly." ) } if headers .get(reqwest::header::ACCEPT_RANGES) .is_some_and(|v| v == "none") { return Err(DownloaderError::Other( "Server does not support RANGE requests".to_string(), )); } let total_size = headers .get(reqwest::header::CONTENT_LENGTH) .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse().ok()) .unwrap_or(0); if total_size == 0 { return Err(DownloaderError::ConfirmSizeFailed); } { let mut inner = self.inner.write(); inner.total_size = total_size; } let filename = headers .get(reqwest::header::CONTENT_DISPOSITION) .and_then(|v| v.to_str().ok()) .and_then(|v| { let parts: Vec<&str> = v.split(';').collect(); parts .iter() .find(|part| part.trim().starts_with("filename=")) .map(|part| { part.trim() .split('=') .next_back() .unwrap() .trim_matches(['"', ';', '\'']) }) }) .unwrap_or(self.url.path_segments().unwrap().next_back().unwrap()); Ok((filename.to_string(), total_size)) } async fn merge_chunks(&self) -> Result<(), DownloaderError> { { let inner = self.inner.read(); if !matches!(inner.state, DownloaderState::WaitingForMerge) { return Err(DownloaderError::Other( "Download is not finished".to_string(), )); } if inner.file.is_none() { return Err(DownloaderError::Other("File is not set".to_string())); } } self.dispatch_event(DownloaderState::Merging); let mut file = { let mut inner = self.inner.write(); inner.file.take().unwrap() }; { let chunks = self.get_cloned_chunks(); for part in &chunks { let mut part_file = { let part = part.read(); File::from_std(part.file.try_clone().unwrap()) }; part_file.seek(tokio::io::SeekFrom::Start(0)).await?; let mut buffer = vec![0u8; 1024 * 1024]; loop { let read = part_file.read(&mut buffer).await?; if read == 0 { break; } file.write_all(&buffer[..read]).await?; } } file.flush().await?; } self.dispatch_event(DownloaderState::Finished); Ok(()) } async fn download(&self) -> Result<(), DownloaderError> { let mut total_size = { let inner = self.inner.read(); if inner.file.is_none() { return Err(DownloaderError::Other("File is not set".to_string())); } inner.total_size }; if total_size == 0 { let (_, size) = self.confirm_file_status().await?; total_size = size; } let mut file = { let mut inner = self.inner.write(); inner.file.take().unwrap() }; file.set_len(total_size).await?; { let mut inner = self.inner.write(); inner.file = Some(file); } let counts = { let inner = self.inner.read(); inner.semaphore.available_permits() as u64 }; let chunk_size = total_size / counts; let (tx, mut rx) = mpsc::channel(10); self.dispatch_event(DownloaderState::Downloading); for chunk in 0..counts { let start = (chunk * chunk_size) as usize; let end = if chunk == counts - 1 { total_size as usize } else { start + (chunk_size as usize) - 1 }; let thread = { let inner = self.inner.read(); Arc::new(RwLock::new(ChunkThread::try_new( self.client.clone(), tx.clone(), inner.semaphore.clone(), start, end, self.url.clone(), )?)) }; let thread_clone = thread.clone(); tokio::spawn(async move { thread_clone.start().await; }); { let mut inner = self.inner.write(); inner.chunks.push(thread); } } // TODO: 根據情況嘗試恢復 semaphore 數目 let mut downloaded = 0; let mut total_permits = counts; while let Some(event) = rx.recv().await { match event { ChunkThreadEvent::Finish => { downloaded += 1; if downloaded == counts { { let mut inner = self.inner.write(); inner.semaphore.close(); // 關閉 semaphore } self.dispatch_event(DownloaderState::WaitingForMerge); break; } } ChunkThreadEvent::DecreaseSemaphore(reason) => { total_permits -= 1; // 儅 semaphore 為 0 時,表示無可用下載綫程,説明文件無法下載 if total_permits == 0 { let mut inner = self.inner.write(); inner.semaphore.close(); // 關閉 semaphore match reason { DecreaseSemaphoreReason::Cause(e) => { return Err(DownloaderError::DownloadFailed(e)); } DecreaseSemaphoreReason::Reason(e) => { return Err(DownloaderError::Other(e)); } } } } } } // 合并文件 self.merge_chunks().await?; Ok(()) } pub async fn start(&self) -> Result<(), DownloaderError> { let result = self.download().await; match result { Ok(_) => Ok(()), Err(e) => { self.dispatch_event(DownloaderState::Failed(format!("{e}"))); Err(e) } } } fn get_cloned_chunks(&self) -> Vec>> { let inner = self.inner.read(); inner.chunks.to_vec() } fn get_total_size(&self) -> u64 { let inner = self.inner.read(); inner.total_size } pub fn get_current_status(&self) -> DownloadStatus { let mut downloaded = 0; let mut speed = 0.0; let inner = self.inner.read(); let total = inner.total_size; let mut chunks = Vec::with_capacity(inner.chunks.len()); for chunk in &inner.chunks { let chunk = chunk.read(); downloaded += chunk.downloaded as u64; speed += chunk.speed; chunks.push(ChunkStatus { start: chunk.start, end: chunk.end, downloaded: chunk.downloaded, speed: chunk.speed, state: chunk.state.clone(), }); } DownloadStatus { downloaded, total, speed, state: inner.state.clone(), chunks, now: chrono::Utc::now().timestamp() as u64, } } } #[async_trait::async_trait] trait SafeChunkThread { fn dispatch_event(&self, state: ChunkThreadState); async fn download_chunk(&self) -> Result<(), anyhow::Error>; async fn start(&self); } impl ChunkThread { pub fn try_new( client: Client, sender: Sender, semaphore: Arc, start: usize, end: usize, url: Arc, ) -> std::io::Result { let file = tempfile()?; Ok(Self { client, sender, semaphore, state: ChunkThreadState::Idle, start, end, file, url, downloaded: 0, speed: 0.0, }) } } #[async_trait::async_trait] impl SafeChunkThread for RwLock { fn dispatch_event(&self, state: ChunkThreadState) { let mut thread = self.write(); tracing::debug!("ChunkThread state: {:?}", state); if matches!(state, ChunkThreadState::Finished) { thread.speed = 0.0; } thread.state = state; } async fn download_chunk(&self) -> Result<(), anyhow::Error> { { let thread = self.read(); tracing::debug!("start downloading chunk: {}-{}", thread.start, thread.end); } self.dispatch_event(ChunkThreadState::Downloading); let response = { let (client, url, start, end) = { let thread = self.read(); let client = thread.client.clone(); (client, thread.url.clone(), thread.start, thread.end) }; client .get(url.as_str()) .header(reqwest::header::RANGE, format!("bytes={start}-{end}")) .send() .await? .error_for_status()? }; let mut stream = response.bytes_stream(); let mut tick = time::Instant::now(); while let Some(chunk) = stream.next().await { let chunk = chunk?; { let mut thread = self.write(); let elapsed = tick.elapsed().as_secs_f64(); // 防止除零错误和异常大的速度值 if elapsed > 0.0 { thread.speed = chunk.len() as f64 / elapsed; } else { thread.speed = 0.0; } thread.file.write_all(&chunk)?; thread.downloaded += chunk.len(); tracing::debug!( "ChunkThread downloading chunk size: {}, current downloaded pos: {}, speed: {}", chunk.len(), thread.downloaded, thread.speed ); } tick = time::Instant::now(); } { let mut thread = self.write(); thread.file.flush()?; } Ok(()) } async fn start(&self) { let mut attempts = 0; let semaphore = { let thread = self.read(); thread.semaphore.clone() }; loop { let result = { let _permit = match semaphore.acquire().await { Ok(permit) => permit, Err(_) => { tracing::debug!("ChunkThread semaphore is released"); break; // semaphore 已經被釋放 } }; self.download_chunk().await }; match result { Ok(_) => { self.dispatch_event(ChunkThreadState::Finished); let sender = { let thread = self.read(); thread.sender.clone() }; sender.send(ChunkThreadEvent::Finish).await.unwrap(); break; } Err(_) if attempts < 3 => { tracing::debug!("ChunkThread download failed, retrying..."); attempts += 1; self.dispatch_event(ChunkThreadState::Idle); sleep(time::Duration::from_secs(1)).await; } Err(e) => { tracing::debug!("ChunkThread download failed: {}", e); self.dispatch_event(ChunkThreadState::Idle); let sender = { let thread = self.read(); thread.sender.clone() }; sender .send(ChunkThreadEvent::DecreaseSemaphore( DecreaseSemaphoreReason::Cause(e), )) .await .unwrap(); semaphore.forget_permits(1); // 釋放自身的 semaphore attempts = 0; } } } } } #[allow(unused)] mod test { use super::*; use tokio::fs::File as TokioFile; #[test_log::test(tokio::test)] #[ignore] async fn test_downloader() { use md5::{Digest, Md5}; let file = TokioFile::create("QQ9.7.17.29225.exe").await.unwrap(); let tick = time::Instant::now(); let on_event = |state: DownloaderState| { println!("{state:?}"); match state { DownloaderState::Failed(e) => { panic!("{}", e); } DownloaderState::Finished => { println!("Download finished"); } _ => {} } }; let client = Client::builder() .user_agent( "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0", ) .build() .unwrap(); let url = "https://dldir1.qq.com/qqfile/qq/PCQQ9.7.17/QQ9.7.17.29225.exe"; let head = client.head(url).send().await.unwrap(); let md5crc = head .headers() .get("X-COS-META-MD5") .unwrap() .to_str() .unwrap(); let mut downloader = Arc::new( DownloaderBuilder::new() // .set_url("http://hkg.download.datapacket.com/100mb.bin") .set_url(url) .unwrap() .set_client(client) .set_file(file) .set_event_callback(on_event) .build() .unwrap(), ); let downloader_clone = downloader.clone(); let barrier = Arc::new(std::sync::Barrier::new(2)); let barrier_clone = barrier.clone(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(10)); loop { let status = downloader_clone.get_current_status(); println!("{status:#?}"); if matches!( status.state, DownloaderState::Finished | DownloaderState::Failed(_) ) { println!("Download finished"); break; } std::thread::sleep(std::time::Duration::from_millis(100)); } barrier_clone.wait(); }); tokio::time::timeout(std::time::Duration::from_secs(30), downloader.start()) .await .unwrap() .unwrap(); println!("Time elapsed: {:?}", tick.elapsed()); barrier.wait(); drop(downloader); // check file md5 let mut hasher = Md5::new(); let mut file = StdFile::open("QQ9.7.17.29225.exe").unwrap(); let n = std::io::copy(&mut file, &mut hasher).unwrap(); let hash = hasher.finalize(); assert_eq!(hex::encode_upper(hash), md5crc.to_uppercase()); } } ================================================ FILE: backend/tauri/src/utils/help.rs ================================================ use crate::config::nyanpasu::ExternalControllerPortStrategy; use anyhow::{Context, Result, anyhow, bail}; use display_info::DisplayInfo; use fast_image_resize::{ FilterType, PixelType, ResizeAlg, ResizeOptions, Resizer, images::{Image, ImageRef}, }; use fs_err as fs; use image::{ColorType, ImageEncoder, ImageReader, codecs::png::PngEncoder}; use nanoid::nanoid; use serde::{Serialize, de::DeserializeOwned}; use serde_yaml::{Mapping, Value}; use std::{ io::{BufWriter, Cursor}, path::{Path, PathBuf}, str::FromStr, }; use tauri::{AppHandle, Manager, process::current_binary}; use tauri_plugin_shell::ShellExt; use tracing::{debug, warn}; use tracing_attributes::instrument; use crate::trace_err; use tauri_plugin_opener::OpenerExt; /// read data from yaml as struct T pub fn read_yaml>(path: P) -> Result { let path = path.as_ref(); if !path.exists() { bail!("file not found \"{}\"", path.display()); } let yaml_str = fs::read_to_string(path) .with_context(|| format!("failed to read the file \"{}\"", path.display()))?; serde_yaml::from_str::(&yaml_str).with_context(|| { format!( "failed to read the file with yaml format \"{}\"", path.display() ) }) } /// read mapping from yaml fix #165 pub fn read_merge_mapping(path: &PathBuf) -> Result { let mut val: Value = read_yaml(path)?; val.apply_merge() .with_context(|| format!("failed to apply merge \"{}\"", path.display()))?; Ok(val .as_mapping() .ok_or(anyhow!( "failed to transform to yaml mapping \"{}\"", path.display() ))? .to_owned()) } /// save the data to the file /// can set `prefix` string to add some comments pub fn save_yaml>( path: P, data: &T, prefix: Option<&str>, ) -> Result<()> { let path = path.as_ref(); let data_str = serde_yaml::to_string(data)?; let yaml_str = match prefix { Some(prefix) => format!("{prefix}\n\n{data_str}"), None => data_str, }; let path_str = path.as_os_str().to_string_lossy().to_string(); fs::write(path, yaml_str.as_bytes()) .with_context(|| format!("failed to save file \"{path_str}\"")) } const ALPHABET: [char; 62] = [ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', ]; /// generate the uid pub fn get_uid(prefix: &str) -> String { let id = nanoid!(11, &ALPHABET); format!("{prefix}{id}") } /// parse the string /// xxx=123123; => 123123 pub fn parse_str(target: &str, key: &str) -> Option { target.split(';').map(str::trim).find_map(|s| { let mut parts = s.splitn(2, '='); match (parts.next(), parts.next()) { (Some(k), Some(v)) if k == key => v.parse::().ok(), _ => None, } }) } /// open file /// use vscode by default pub fn open_file(app: tauri::AppHandle, path: PathBuf) -> Result<()> { #[cfg(target_os = "macos")] let code = "Visual Studio Code"; #[cfg(windows)] let code = "code.cmd"; #[cfg(all(not(windows), not(target_os = "macos")))] let code = "code"; let shell = app.shell(); trace_err!( match which::which(code) { Ok(code_path) => { log::debug!(target: "app", "find VScode `{}`", code_path.display()); #[cfg(not(windows))] { crate::utils::open::with(path, code) } #[cfg(windows)] { use std::ffi::OsString; let mut buf = OsString::with_capacity(path.as_os_str().len() + 2); buf.push("\""); buf.push(path.as_os_str()); buf.push("\""); open::with_detached(buf, code) } } Err(err) => { log::error!(target: "app", "Can't find VScode `{err:?}`"); // default open app.opener() .open_url(path.to_string_lossy().to_string(), None::) .map_err(std::io::Error::other) } }, "Can't open file" ); Ok(()) } pub fn get_system_locale() -> String { tauri_plugin_os::locale().unwrap_or("en-US".to_string()) } pub fn mapping_to_i18n_key(locale_key: &str) -> &'static str { if locale_key.starts_with("zh-TW") { "zh-TW" } else if locale_key.starts_with("zh-") { "zh-CN" } else { "en" } } pub fn get_clash_external_port( strategy: &ExternalControllerPortStrategy, port: u16, ) -> anyhow::Result { match strategy { ExternalControllerPortStrategy::Fixed => { if !port_scanner::local_port_available(port) { bail!("Port {} is not available", port); } } ExternalControllerPortStrategy::Random | ExternalControllerPortStrategy::AllowFallback => { if ExternalControllerPortStrategy::AllowFallback == *strategy && port_scanner::local_port_available(port) { return Ok(port); } let new_port = port_scanner::request_open_port() .ok_or_else(|| anyhow!("Can't find an open port"))?; return Ok(new_port); } } Ok(port) } pub fn resize_tray_image(img: &[u8], scale_factor: f64) -> Result> { let img = ImageReader::new(Cursor::new(img)) .with_guessed_format()? .decode()?; let width = img.width(); let height = img.height(); let src_pixels = img.into_rgba8().into_raw(); let src_image = ImageRef::new(width, height, &src_pixels, PixelType::U8x4) .context("failed to parse image")?; // Create container for data of destination image let size = (32_f64 * scale_factor).round() as u32; // 32px is the base tray size as the dpi is 96 let dst_width = size; let dst_height = size; let mut dst_image = Image::new(dst_width, dst_height, src_image.pixel_type()); // Create Resizer instance and resize source image // into buffer of destination image let mut resizer = Resizer::new(); let resizer_options = ResizeOptions { algorithm: ResizeAlg::Convolution(FilterType::Lanczos3), ..Default::default() }; resizer .resize(&src_image, &mut dst_image, &resizer_options) .context("failed to resize image")?; // Extract raw pixel data from the destination image let dst_image_data = dst_image.buffer().to_vec(); // Write destination image as PNG-file let mut result_buf = BufWriter::new(Vec::new()); PngEncoder::new(&mut result_buf).write_image( &dst_image_data, dst_width, dst_height, ColorType::Rgba8.into(), )?; Ok(result_buf.into_inner()?) } #[instrument] pub fn get_max_scale_factor() -> f64 { match DisplayInfo::all() { Ok(displays) => { let mut scale_factor = 0.0; debug!("displays: {:?}", displays); for display in displays { if display.scale_factor > scale_factor { scale_factor = display.scale_factor; } } scale_factor as f64 } Err(err) => { warn!("failed to get display info: {:?}", err); 1.0_f64 } } } #[instrument(skip(app_handle))] pub fn cleanup_processes(app_handle: &AppHandle) { let _ = super::resolve::save_window_state(app_handle, true); super::resolve::resolve_reset(); let widget_manager = app_handle.state::(); let _ = nyanpasu_utils::runtime::block_on(async { if let Err(e) = widget_manager.stop().await { log::error!("failed to stop widget manager: {e:?}"); }; crate::core::CoreManager::global().stop_core().await }); #[cfg(windows)] crate::shutdown_hook::set_ready_for_shutdown(); } #[instrument(skip(app_handle))] pub fn quit_application(app_handle: &AppHandle) { app_handle.exit(0); } #[instrument(skip(app_handle))] pub fn restart_application(app_handle: &AppHandle) { cleanup_processes(app_handle); let env = app_handle.env(); let path = current_binary(&env).unwrap(); let arg = std::env::args().collect::>(); let mut args = vec!["launch".to_string(), "--".to_string()]; // filter out the first arg if arg.len() > 1 { args.extend(arg.iter().skip(1).cloned()); } tracing::info!("restart app: {:#?} with args: {:#?}", path, args); std::process::Command::new(path) .args(args) .spawn() .expect("application failed to start"); app_handle.exit(0); std::process::exit(0); } #[macro_export] macro_rules! error { ($result: expr) => { log::error!(target: "app", "{:?}", $result); }; } #[macro_export] macro_rules! log_err { ($result: expr) => { if let Err(err) = $result { log::error!(target: "app", "{:#?}", err); } }; ($result: expr, $label: expr) => { if let Err(err) = $result { log::error!(target: "app", "{}: {:#?}", $label, err); } }; } #[macro_export] macro_rules! dialog_err { ($result: expr) => { if let Err(err) = $result { $crate::utils::dialog::error_dialog(format!("{:?}", err)); } }; ($result: expr, $err_str: expr) => { if let Err(_) = $result { $crate::utils::dialog::error_dialog($err_str.into()); } }; } #[macro_export] macro_rules! trace_err { ($result: expr, $err_str: expr) => { if let Err(err) = $result { log::trace!(target: "app", "{}, err {:?}", $err_str, err); } } } #[test] fn test_parse_value() { let test_1 = "upload=111; download=2222; total=3333; expire=444"; let test_2 = "attachment; filename=Clash.yaml"; assert_eq!(parse_str::(test_1, "upload").unwrap(), 111); assert_eq!(parse_str::(test_1, "download").unwrap(), 2222); assert_eq!(parse_str::(test_1, "total").unwrap(), 3333); assert_eq!(parse_str::(test_1, "expire").unwrap(), 444); assert_eq!( parse_str::(test_2, "filename").unwrap(), format!("Clash.yaml") ); assert_eq!(parse_str::(test_1, "aaa"), None); assert_eq!(parse_str::(test_1, "upload1"), None); assert_eq!(parse_str::(test_1, "expire1"), None); assert_eq!(parse_str::(test_2, "attachment"), None); } ================================================ FILE: backend/tauri/src/utils/init/logging.rs ================================================ use crate::{Config, config, utils::dirs}; use anyhow::{Result, anyhow, bail}; use parking_lot::Mutex; use std::{ fs, io::IsTerminal, sync::{ OnceLock, mpsc::{self, Sender}, }, thread, }; use tracing::error; use tracing_appender::{ non_blocking::{NonBlocking, WorkerGuard}, rolling::Rotation, }; use tracing_log::log_tracer; use tracing_subscriber::{EnvFilter, filter, fmt, layer::SubscriberExt, reload}; use super::nyanpasu::LoggingLevel; pub type ReloadSignal = (Option, Option); struct Channel(Option>); impl Channel { fn globals() -> &'static Mutex { static CHANNEL: OnceLock> = OnceLock::new(); CHANNEL.get_or_init(|| Mutex::new(Channel(None))) } } pub fn refresh_logger(signal: ReloadSignal) -> Result<()> { let channel = Channel::globals().lock(); match &channel.0 { Some(sender) => { let _ = sender.send(signal); Ok(()) } None => bail!("no logger channel"), } } fn get_file_appender(max_files: usize) -> Result<(NonBlocking, WorkerGuard)> { let log_dir = dirs::app_logs_dir().unwrap(); let file_appender = tracing_appender::rolling::Builder::new() .filename_prefix("clash-nyanpasu") .filename_suffix("app.log") .rotation(Rotation::DAILY) .max_log_files(max_files) .build(log_dir)?; Ok(tracing_appender::non_blocking(file_appender)) } /// initial instance global logger pub fn init() -> Result<()> { let log_dir = dirs::app_logs_dir().unwrap(); if !log_dir.exists() { let _ = fs::create_dir_all(&log_dir); } let (log_level, log_max_files) = { (LoggingLevel::Debug, 7) }; // This is intended to capture config loading errors let (filter, filter_handle) = reload::Layer::new( EnvFilter::builder() .with_default_directive( std::convert::Into::::into(LoggingLevel::Warn).into(), ) .from_env_lossy() .add_directive(format!("nyanpasu={log_level}").parse().unwrap()) .add_directive(format!("clash_nyanpasu={log_level}").parse().unwrap()), ); // register the logger let (appender, _guard) = get_file_appender(log_max_files)?; let (file_layer, file_handle) = reload::Layer::new( fmt::layer() .json() .with_writer(appender) .with_current_span(true) .with_line_number(true) .with_file(true), ); // spawn a thread to handle the reload signal thread::spawn(move || { let mut _guard = _guard; // just hold here to keep the file open let (sender, receiver) = mpsc::channel::(); { let mut channel = Channel::globals().lock(); channel.0 = Some(sender); } loop { let signal = receiver.recv().unwrap(); if let Some(level) = signal.0 { filter_handle .reload( EnvFilter::builder() .with_default_directive( std::convert::Into::::into(LoggingLevel::Warn) .into(), ) .from_env_lossy() .add_directive(format!("nyanpasu={level}").parse().unwrap()) .add_directive(format!("clash_nyanpasu={level}").parse().unwrap()), ) .unwrap(); // panic if error } if let Some(max_files) = signal.1 { let (appender, guard) = match get_file_appender(max_files) { Ok(x) => x, Err(e) => { error!("failed to create file appender: {}", e); continue; } }; _guard = guard; if let Err(e) = file_handle.modify(|layer| *layer.writer_mut() = appender) { error!("failed to modify file appender: {}", e); } } } }); // if debug build, log to stdout and stderr with all levels #[cfg(debug_assertions)] let terminal_layer = fmt::Layer::new() .with_ansi(std::io::stdout().is_terminal()) .compact() .with_target(false) .with_file(true) .with_line_number(true) .with_writer(std::io::stdout); let subscriber = tracing_subscriber::registry().with(filter).with(file_layer); #[cfg(debug_assertions)] let subscriber = subscriber.with(terminal_layer); log_tracer::LogTracer::init()?; tracing::subscriber::set_global_default(subscriber) .map_err(|x| anyhow!("setup logging error: {}", x))?; // reload the log level std::thread::spawn(move || { let config = Config::verge(); let log_level = config.latest().get_log_level(); let log_max_files = config.latest().max_log_files; let _ = refresh_logger((Some(log_level), log_max_files)); }); Ok(()) } ================================================ FILE: backend/tauri/src/utils/init/mod.rs ================================================ use crate::{ config::*, utils::{dirs, help}, }; use anyhow::{Context, Result, anyhow}; use fs_extra::dir::CopyOptions; #[cfg(windows)] use runas::Command as RunasCommand; use std::{ fs, io::{BufReader, Write}, path::PathBuf, sync::Arc, }; use tauri::utils::platform::current_exe; use tracing_attributes::instrument; mod logging; pub use logging::refresh_logger; pub fn run_pending_migrations() -> Result<()> { let current_exe = current_exe()?; let current_exe = dunce::canonicalize(current_exe)?; let file = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .open(crate::utils::dirs::app_data_dir()?.join("migration.log"))?; let file = Arc::new(parking_lot::Mutex::new(file)); let (stdout_reader, stdout_writer) = os_pipe::pipe()?; let (stderr_reader, stderr_writer) = os_pipe::pipe()?; let errs = Arc::new(parking_lot::Mutex::new(String::new())); let guard = Arc::new(parking_lot::RwLock::new(())); let mut child = std::process::Command::new(current_exe) .arg("migrate") .stderr(stderr_writer) .stdout(stdout_writer) .spawn()?; let file_ = file.clone(); let guard_ = guard.clone(); let errs_ = errs.clone(); std::thread::spawn(move || { let _l = guard_.read(); let mut reader = BufReader::new(stdout_reader); let mut buf = Vec::new(); loop { buf.clear(); match nyanpasu_utils::io::read_line(&mut reader, &mut buf) { Ok(0) => break, Ok(_) => { let mut file = file_.lock(); let _ = file.write_all(&buf); } Err(e) => { eprintln!("failed to read stdout: {e:?}"); let mut errs = errs_.lock(); errs.push_str(&format!("failed to read stdout: {e:?}\n")); break; } } } }); let errs_ = errs.clone(); let guard_ = guard.clone(); std::thread::spawn(move || { let _l = guard_.read(); let mut reader = BufReader::new(stderr_reader); let mut buf = Vec::new(); loop { buf.clear(); match nyanpasu_utils::io::read_line(&mut reader, &mut buf) { Ok(0) => break, Ok(_) => { let mut file = file.lock(); let _ = file.write_all(&buf); let mut errs = errs_.lock(); errs.push_str(unsafe { std::str::from_utf8_unchecked(&buf) }); } Err(e) => { eprintln!("failed to read stderr: {e:?}"); let mut errs = errs_.lock(); errs.push_str(&format!("failed to read stderr: {e:?}\n")); break; } } } }); let result = child.wait(); let _l = guard.write(); // Just for waiting the thread read all the output let err = errs.lock(); result .map_err(|e| anyhow!("Failed to wait for child: {:?}, errs: {}", e, err)) .and_then(|status| { if !status.success() { Err(anyhow!("child process failed: {:?}, err: {}", status, err)) } else { Ok(()) } }) } /// Initialize all the config files /// before tauri setup pub fn init_config() -> Result<()> { // Check if old config dir exist and new config dir is not exist // let mut old_app_dir: Option = None; // let mut app_dir: Option = None; // crate::dialog_err!(dirs::old_app_home_dir().map(|_old_app_dir| { // old_app_dir = Some(_old_app_dir); // })); // crate::dialog_err!(dirs::app_home_dir().map(|_app_dir| { // app_dir = Some(_app_dir); // })); // if let (Some(app_dir), Some(old_app_dir)) = (app_dir, old_app_dir) { // let msg = t!("dialog.migrate"); // if !app_dir.exists() && old_app_dir.exists() && migrate_dialog(msg.to_string().as_str()) { // if let Err(e) = do_config_migration(&old_app_dir, &app_dir) { // super::dialog::error_dialog(format!("failed to do migration: {:?}", e)) // } // } // if !app_dir.exists() { // let _ = fs::create_dir_all(app_dir); // } // } // init log logging::init().unwrap(); crate::log_err!(dirs::app_profiles_dir().map(|profiles_dir| { if !profiles_dir.exists() { let _ = fs::create_dir_all(&profiles_dir); } })); crate::log_err!(dirs::clash_guard_overrides_path().map(|path| { if !path.exists() { help::save_yaml( &path, &IClashTemp::template().0, Some("# Clash Nyanpasuasu"), )?; } >::Ok(()) })); crate::log_err!(dirs::nyanpasu_config_path().map(|path| { if !path.exists() { help::save_yaml(&path, &IVerge::template(), Some("# Clash Nyanpasu"))?; } >::Ok(()) })); crate::log_err!(dirs::profiles_path().map(|path| { if !path.exists() { help::save_yaml(&path, &Profiles::default(), Some("# Clash Nyanpasu"))?; } >::Ok(()) })); Ok(()) } /// initialize app resources /// after tauri setup pub fn init_resources() -> Result<()> { let app_dir = dirs::app_data_dir()?; let res_dir = dirs::app_resources_dir()?; if !app_dir.exists() { let _ = fs::create_dir_all(&app_dir); } if !res_dir.exists() { let _ = fs::create_dir_all(&res_dir); } #[cfg(target_os = "windows")] let file_list = ["Country.mmdb", "geoip.dat", "geosite.dat", "wintun.dll"]; #[cfg(not(target_os = "windows"))] let file_list = ["Country.mmdb", "geoip.dat", "geosite.dat"]; // copy the resource file // if the source file is newer than the destination file, copy it over for file in file_list.iter() { let src_path = res_dir.join(file); let dest_path = app_dir.join(file); let handle_copy = || { match fs::copy(&src_path, &dest_path) { Ok(_) => log::debug!(target: "app", "resources copied '{file}'"), Err(err) => { log::error!(target: "app", "failed to copy resources '{file}', {err:?}") } }; }; if src_path.exists() && !dest_path.exists() { handle_copy(); continue; } let src_modified = fs::metadata(&src_path).and_then(|m| m.modified()); let dest_modified = fs::metadata(&dest_path).and_then(|m| m.modified()); match (src_modified, dest_modified) { (Ok(src_modified), Ok(dest_modified)) => { if src_modified > dest_modified { handle_copy(); } else { log::debug!(target: "app", "skipping resource copy '{file}'"); } } _ => { log::debug!(target: "app", "failed to get modified '{file}'"); handle_copy(); } }; } Ok(()) } /// initialize service resources /// after tauri setup #[instrument] pub fn init_service() -> Result<()> { use nyanpasu_utils::runtime::block_on; tracing::debug!("init services"); block_on(async move { let enable_service = { *Config::verge() .latest() .enable_service_mode .as_ref() .unwrap_or(&false) }; if enable_service { match crate::core::service::control::status().await { Ok(status) => { tracing::info!( "service mode is enabled and service is running, do a update check" ); if let Some(info) = status.server { let server_ver = semver::Version::parse(info.version.as_ref()).unwrap(); let app_ver = semver::Version::parse(status.version.as_ref()).unwrap(); if app_ver > server_ver { tracing::info!( "client service ver is newer than exist one, do service update" ); if let Err(e) = crate::core::service::control::update_service().await { log::error!(target: "app", "failed to update service: {e:?}"); } } } } Err(e) => { log::error!(target: "app", "failed to get service status: {e:?}"); } } } crate::core::service::init_service().await; }); Ok(()) } pub fn check_singleton() -> Result> { let placeholder = super::dirs::get_single_instance_placeholder()?; for i in 0..5 { let instance = single_instance::SingleInstance::new(&placeholder) .context("failed to create single instance")?; if instance.is_single() { return Ok(Some(instance)); } if i != 4 { std::thread::sleep(std::time::Duration::from_secs(1)); } } Ok(None) } pub fn do_config_migration(old_app_dir: &PathBuf, app_dir: &PathBuf) -> anyhow::Result<()> { let copy_option = CopyOptions::new(); let copy_option = copy_option.overwrite(true); let copy_option = copy_option.content_only(true); if let Err(e) = fs_extra::dir::move_dir(old_app_dir, app_dir, ©_option) { match e.kind { #[cfg(windows)] fs_extra::error::ErrorKind::PermissionDenied => { // It seems that clash-verge-service is running, so kill it. let status = RunasCommand::new("cmd") .args(&["/C", "taskkill", "/IM", "clash-verge-service.exe", "/F"]) .status()?; if !status.success() { anyhow::bail!("failed to kill clash-verge-service.exe") } fs::rename(old_app_dir, app_dir)?; } _ => return Err(e.into()), }; } Ok(()) } ================================================ FILE: backend/tauri/src/utils/mod.rs ================================================ pub mod candy; pub mod config; pub mod dialog; pub mod dirs; pub mod help; pub mod init; pub mod resolve; // mod winhelp; pub mod downloader; #[cfg(windows)] pub mod winreg; pub mod collect; pub mod net; pub mod open; pub mod dock; pub mod sudo; #[cfg(test)] #[cfg(windows)] mod winreg_test; ================================================ FILE: backend/tauri/src/utils/net.rs ================================================ use std::time::Duration; use super::candy::get_reqwest_client; #[tracing_attributes::instrument] pub async fn url_delay_test(url: &str, expected_status: u16) -> Option { // heat up let client = get_reqwest_client().ok()?; let _ = tokio::time::timeout(Duration::from_secs(10), client.get(url).send()) .await .ok()? .ok()?; let tick = tokio::time::Instant::now(); let response = tokio::time::timeout(Duration::from_secs(10), client.get(url).send()) .await .ok()? .ok()?; if response.status().as_u16() != expected_status { return None; } Some(tick.elapsed().as_millis() as u64) } #[tracing_attributes::instrument] pub async fn get_ipsb_asn() -> anyhow::Result { let client = get_reqwest_client()?; let response = client .get("https://api.ip.sb/geoip") .send() .await? .error_for_status()?; let data: serde_json::Value = response.json().await?; Ok(data) } ================================================ FILE: backend/tauri/src/utils/open.rs ================================================ use std::ffi::OsStr; pub fn that>(path: T) -> std::io::Result<()> { // A dirty workaround for AppImage if std::env::var("APPIMAGE").is_ok() { std::process::Command::new("xdg-open") .arg(path) .env_remove("LD_LIBRARY_PATH") .status()?; Ok(()) } else { open::that(path) } } pub fn with>(path: T, program: &str) -> std::io::Result<()> { // A dirty workaround for AppImage if std::env::var("APPIMAGE").is_ok() { std::process::Command::new(program) .arg(path) .env_remove("LD_LIBRARY_PATH") .status()?; Ok(()) } else { open::with(path, program) } } ================================================ FILE: backend/tauri/src/utils/resolve.rs ================================================ use crate::{ config::{ Config, IVerge, nyanpasu::{ClashCore, WindowState}, }, core::{storage::Storage, tray::proxies, *}, log_err, utils::init, window::{AppWindow, ReactAppMountedEvent, WindowConfig, WindowParamsBuilder}, }; use anyhow::Result; use semver::Version; use serde_yaml::Mapping; use std::{ net::TcpListener, sync::atomic::{AtomicU16, Ordering}, }; use tauri::{App, AppHandle, Emitter, Listener, Manager, async_runtime::block_on}; use tauri_plugin_shell::ShellExt; use tauri_specta::Event; static OPEN_WINDOWS_COUNTER: AtomicU16 = AtomicU16::new(0); pub fn is_window_opened() -> bool { OPEN_WINDOWS_COUNTER.load(Ordering::Acquire) == 0 // 0 means no window open or windows is initialized } pub fn reset_window_open_counter() { OPEN_WINDOWS_COUNTER.store(0, Ordering::Release); } #[cfg(target_os = "macos")] fn set_window_controls_pos( window: objc2::rc::Retained, x: f64, y: f64, ) -> anyhow::Result<()> { use objc2_app_kit::NSWindowButton; use objc2_foundation::NSRect; let close = window .standardWindowButton(NSWindowButton::CloseButton) .ok_or(anyhow::anyhow!("failed to get close button"))?; let miniaturize = window .standardWindowButton(NSWindowButton::MiniaturizeButton) .ok_or(anyhow::anyhow!("failed to get miniaturize button"))?; let zoom = window .standardWindowButton(NSWindowButton::ZoomButton) .ok_or(anyhow::anyhow!("failed to get zoom button"))?; let title_bar_container_view = unsafe { close .superview() .and_then(|view| view.superview()) .ok_or(anyhow::anyhow!("failed to get title bar container view"))? }; let close_rect = close.frame(); let button_height = close_rect.size.height; let title_bar_frame_height = button_height + y; let mut title_bar_rect = title_bar_container_view.frame(); title_bar_rect.size.height = title_bar_frame_height; title_bar_rect.origin.y = window.frame().size.height - title_bar_frame_height; unsafe { title_bar_container_view.setFrame(title_bar_rect); } let space_between = miniaturize.frame().origin.x - close.frame().origin.x; let window_buttons = vec![close, miniaturize, zoom]; for (i, button) in window_buttons.into_iter().enumerate() { let mut rect: NSRect = button.frame(); rect.origin.x = x + (i as f64 * space_between); unsafe { button.setFrameOrigin(rect.origin); } } Ok(()) } pub fn find_unused_port() -> Result { match TcpListener::bind("127.0.0.1:0") { Ok(listener) => { let port = listener.local_addr()?.port(); Ok(port) } Err(_) => { let port = Config::verge() .latest() .verge_mixed_port .unwrap_or(Config::clash().data().get_mixed_port()); log::warn!(target: "app", "use default port: {port}"); Ok(port) } } } /// handle something when start app pub fn resolve_setup(app: &mut App) { #[cfg(target_os = "macos")] app.set_activation_policy(tauri::ActivationPolicy::Accessory); #[cfg(target_os = "macos")] let app_handle = app.app_handle().clone(); ReactAppMountedEvent::listen(app, move |_| { tracing::debug!("Frontend React App is mounted, reset open window counter"); reset_window_open_counter(); #[cfg(target_os = "macos")] log_err!(app_handle.run_on_main_thread(move || { crate::utils::dock::macos::show_dock_icon(); })); }); handle::Handle::global().init(app.app_handle().clone()); crate::consts::setup_app_handle(app.app_handle().clone()); log_err!(init::init_resources()); log_err!(init::init_service()); // 处理随机端口 let enable_random_port = Config::verge().latest().enable_random_port.unwrap_or(false); let mut port = Config::verge() .latest() .verge_mixed_port .unwrap_or(Config::clash().data().get_mixed_port()); if enable_random_port { port = find_unused_port().unwrap_or( Config::verge() .latest() .verge_mixed_port .unwrap_or(Config::clash().data().get_mixed_port()), ); } Config::verge().data().patch_config(IVerge { verge_mixed_port: Some(port), ..IVerge::default() }); let _ = Config::verge().data().save_file(); let mut mapping = Mapping::new(); mapping.insert("mixed-port".into(), port.into()); Config::clash().data().patch_config(mapping); let _ = Config::clash().latest().prepare_external_controller_port(); let _ = Config::clash().data().save_config(); // 启动核心 log::trace!("init config"); log_err!(Config::init_config()); log::trace!("init storage"); log_err!(crate::core::storage::setup(app)); log::trace!("launch core"); log_err!(CoreManager::global().init()); log::trace!("init clash connection connector"); log_err!(crate::core::clash::setup(app)); log::trace!("init widget manager"); log_err!(tauri::async_runtime::block_on(async { crate::widget::setup(app, { let manager = app.state::(); manager.subscribe() }) .await })); #[cfg(any(windows, target_os = "linux"))] log::trace!("init system tray"); #[cfg(any(windows, target_os = "linux"))] tray::icon::resize_images(crate::utils::help::get_max_scale_factor()); // generate latest cache icon by current scale factor let app_handle = app.app_handle().clone(); app.listen("update_systray", move |_| { // Fix the GTK should run on main thread issue let app_handle_clone = app_handle.clone(); log_err!(app_handle.run_on_main_thread(move || { log_err!( tray::Tray::update_systray(&app_handle_clone), "failed to update systray" ); })); }); log_err!(app.emit("update_systray", ())); let silent_start = { Config::verge().data().enable_silent_start }; if !silent_start.unwrap_or(false) { create_window(app.app_handle()); } log_err!(sysopt::Sysopt::global().init_launch()); log_err!(sysopt::Sysopt::global().init_sysproxy()); log_err!(handle::Handle::update_systray_part()); log_err!(hotkey::Hotkey::global().init(app.app_handle().clone())); // setup jobs log::trace!("setup jobs"); { let storage = app.state::(); let storage = (*storage).clone(); log_err!(crate::core::tasks::setup(app, storage)); } // test job proxies::setup_proxies(); crate::core::storage::register_web_storage_listener(app.app_handle()); } /// reset system proxy pub fn resolve_reset() { log_err!(sysopt::Sysopt::global().reset_sysproxy()); log_err!(block_on(CoreManager::global().stop_core())); } /// Main window implementation (new UI) struct MainWindow; impl AppWindow for MainWindow { fn label(&self) -> &str { crate::consts::MAIN_WINDOW_LABEL } fn title(&self) -> &str { crate::consts::APP_NAME } fn url(&self) -> &str { "/main" } fn config(&self) -> WindowConfig { WindowConfig::new() .singleton(true) .visible_on_create(true) .default_size(800.0, 636.0) .min_size(400.0, 600.0) .center(true) } fn get_window_state(&self) -> Option { Config::verge().latest().window_size_state.clone() } fn set_window_state(&self, state: Option) { Config::verge().data().patch_config(IVerge { window_size_state: state, ..IVerge::default() }); } } /// Editor window struct EditorWindow { label: String, } impl EditorWindow { fn new(uid: &str) -> Self { Self { label: format!("{}-{}", crate::consts::EDITOR_WINDOW_LABEL, uid), } } } impl AppWindow for EditorWindow { fn label(&self) -> &str { &self.label } fn title(&self) -> &str { &crate::consts::APP_EDITOR_NAME } fn url(&self) -> &str { "/editor" } fn config(&self) -> WindowConfig { WindowConfig::new() .singleton(false) // Allow multiple editor windows with different uids .visible_on_create(true) .default_size(800.0, 636.0) .min_size(400.0, 600.0) .center(true) } fn get_window_state(&self) -> Option { // EditorWindow does not remember window state None } fn set_window_state(&self, _state: Option) { // EditorWindow does not remember window state } } /// Legacy window implementation (original UI) struct LegacyWindow; impl AppWindow for LegacyWindow { fn label(&self) -> &str { crate::consts::LEGACY_WINDOW_LABEL } fn title(&self) -> &str { crate::consts::APP_NAME } fn url(&self) -> &str { "/" } fn config(&self) -> WindowConfig { WindowConfig::new() .singleton(true) .visible_on_create(true) .default_size(800.0, 636.0) .min_size(400.0, 600.0) .center(true) } fn get_window_state(&self) -> Option { Config::verge().latest().window_size_state.clone() } fn set_window_state(&self, state: Option) { Config::verge().data().patch_config(IVerge { window_size_state: state, ..IVerge::default() }); } } /// create main window #[tracing_attributes::instrument(skip(app_handle))] pub fn create_main_window(app_handle: &AppHandle) { log_err!(MainWindow.create(app_handle)); } /// close main window pub fn close_main_window(app_handle: &AppHandle) { MainWindow.close(app_handle); } /// is main window open pub fn is_main_window_open(app_handle: &AppHandle) -> bool { MainWindow.is_open(app_handle) } pub fn save_main_window_state(app_handle: &AppHandle, save_to_file: bool) -> Result<()> { MainWindow.save_state(app_handle, save_to_file) } /// create legacy window #[tracing_attributes::instrument(skip(app_handle))] pub fn create_legacy_window(app_handle: &AppHandle) { log_err!(LegacyWindow.create(app_handle)); } /// close legacy window pub fn close_legacy_window(app_handle: &AppHandle) { LegacyWindow.close(app_handle); } /// is legacy window open pub fn is_legacy_window_open(app_handle: &AppHandle) -> bool { LegacyWindow.is_open(app_handle) } pub fn save_legacy_window_state(app_handle: &AppHandle, save_to_file: bool) -> Result<()> { LegacyWindow.save_state(app_handle, save_to_file) } /// Create window based on use_legacy_ui config /// This is the primary function to use when opening window from tray, etc. #[tracing_attributes::instrument(skip(app_handle))] pub fn create_window(app_handle: &AppHandle) { let use_legacy = Config::verge().latest().use_legacy_ui.unwrap_or(true); if use_legacy { create_legacy_window(app_handle); } else { create_main_window(app_handle); } } /// Close the currently active window based on use_legacy_ui config pub fn close_window(app_handle: &AppHandle) { let use_legacy = Config::verge().latest().use_legacy_ui.unwrap_or(true); if use_legacy { close_legacy_window(app_handle); } else { close_main_window(app_handle); } } /// Check if the configured window is open pub fn is_window_open(app_handle: &AppHandle) -> bool { let use_legacy = Config::verge().latest().use_legacy_ui.unwrap_or(true); if use_legacy { is_legacy_window_open(app_handle) } else { is_main_window_open(app_handle) } } /// Save window state for the configured window type pub fn save_window_state(app_handle: &AppHandle, save_to_file: bool) -> Result<()> { let use_legacy = Config::verge().latest().use_legacy_ui.unwrap_or(true); if use_legacy { save_legacy_window_state(app_handle, save_to_file) } else { save_main_window_state(app_handle, save_to_file) } } /// Create editor window with uid #[tracing_attributes::instrument(skip(app_handle))] pub fn create_editor_window(app_handle: &AppHandle, uid: &str) -> Result<()> { let editor_window = EditorWindow::new(uid); let params = WindowParamsBuilder::new().param("uid", uid).build(); editor_window.create_with_params(app_handle, params)?; Ok(()) } /// Close editor window by uid pub fn close_editor_window(app_handle: &AppHandle, uid: &str) { let editor_window = EditorWindow::new(uid); editor_window.close_by_label(app_handle, &editor_window.label()); } /// Check if editor window with uid is open pub fn is_editor_window_open(app_handle: &AppHandle, uid: &str) -> bool { let editor_window = EditorWindow::new(uid); app_handle .get_webview_window(editor_window.label()) .is_some() } /// resolve core version // TODO: use enum instead pub async fn resolve_core_version(app_handle: &AppHandle, core_type: &ClashCore) -> Result { let shell = app_handle.shell(); let core = core_type.clone().to_string(); log::debug!(target: "app", "check config in `{core}`"); let cmd = match core_type { ClashCore::ClashPremium | ClashCore::Mihomo | ClashCore::MihomoAlpha => { shell.sidecar(core)?.args(["-v"]) } ClashCore::ClashRs | ClashCore::ClashRsAlpha => shell.sidecar(core)?.args(["-V"]), }; let out = cmd.output().await?; if !out.status.success() { return Err(anyhow::anyhow!("failed to get core version")); } let out = String::from_utf8_lossy(&out.stdout); log::trace!(target: "app", "get core version: {out:?}"); let out = out.trim().split(' ').collect::>(); for item in out { log::debug!(target: "app", "check item: {item}"); if item.starts_with('v') || item.starts_with('n') || item.starts_with("alpha") || Version::parse(item).is_ok() { match core_type { ClashCore::ClashRs => return Ok(format!("v{}", item)), _ => return Ok(item.to_string()), } } } Err(anyhow::anyhow!("failed to get core version")) } ================================================ FILE: backend/tauri/src/utils/sudo.rs ================================================ #[cfg(target_os = "macos")] mod macos { use std::{os::unix::process::ExitStatusExt, path::PathBuf}; /// use runas to run the command with bash and pipe the output to the tmp output file pub fn sudo, T: AsRef>(bin: M, args: &[T]) -> std::io::Result<()> { let dir = tempfile::tempdir()?; let script = dir.path().join("script.sh"); let out = dir.path().join("output.txt"); if !out.exists() { std::fs::write(&out, String::new())?; } let bin = PathBuf::from(bin.as_ref()); let parent = bin.parent(); let mut script_content = String::with_capacity(1024); if let Some(parent) = parent { script_content.push_str("cd "); script_content.push('"'); script_content.push_str(parent.to_string_lossy().as_ref()); script_content.push('"'); script_content.push_str(" && ./"); } script_content.push_str(bin.file_name().unwrap().to_string_lossy().as_ref()); script_content.push(' '); script_content.push_str( args.iter() .map(|s| s.as_ref()) .collect::>() .join(" ") .as_ref(), ); tracing::debug!("prepare script: {}", script_content); std::fs::write(&script, script_content)?; let status = std::process::Command::new("osascript") .arg("-e") .args([&format!( r#"do shell script "bash {} &> {}" with administrator privileges"#, script.to_string_lossy(), out.to_string_lossy() )]) .status(); match status { Ok(status) if status.success() => Ok(()), Ok(status) => { // read the output file let output = std::fs::read_to_string(out) .unwrap_or_else(|e| format!("failed to read output file: {}", e)); Err(std::io::Error::new( std::io::ErrorKind::Other, format!( "exit code: {:?}, signal: {:?}, output: {}", status.code(), status.signal(), output ), )) } Err(e) => Err(std::io::Error::new( std::io::ErrorKind::Other, e.to_string(), )), } } } #[cfg(target_os = "macos")] pub use macos::sudo; ================================================ FILE: backend/tauri/src/utils/winhelp.rs ================================================ #![cfg(target_os = "windows")] #![allow(non_snake_case)] #![allow(non_camel_case_types)] //! //! From https://github.com/tauri-apps/window-vibrancy/blob/dev/src/windows.rs //! use windows_sys::Win32::{ Foundation::*, System::{LibraryLoader::*, SystemInformation::*}, }; fn get_function_impl(library: &str, function: &str) -> Option { assert_eq!(library.chars().last(), Some('\0')); assert_eq!(function.chars().last(), Some('\0')); let module = unsafe { LoadLibraryA(library.as_ptr()) }; if module == 0 { return None; } Some(unsafe { GetProcAddress(module, function.as_ptr()) }) } macro_rules! get_function { ($lib:expr, $func:ident) => { get_function_impl(concat!($lib, '\0'), concat!(stringify!($func), '\0')).map(|f| unsafe { std::mem::transmute::<::windows_sys::Win32::Foundation::FARPROC, $func>(f) }) }; } /// Returns a tuple of (major, minor, buildnumber) fn get_windows_ver() -> Option<(u32, u32, u32)> { type RtlGetVersion = unsafe extern "system" fn(*mut OSVERSIONINFOW) -> i32; let handle = get_function!("ntdll.dll", RtlGetVersion); if let Some(rtl_get_version) = handle { unsafe { let mut vi = OSVERSIONINFOW { dwOSVersionInfoSize: 0, dwMajorVersion: 0, dwMinorVersion: 0, dwBuildNumber: 0, dwPlatformId: 0, szCSDVersion: [0; 128], }; let status = (rtl_get_version)(&mut vi as _); if status >= 0 { Some((vi.dwMajorVersion, vi.dwMinorVersion, vi.dwBuildNumber)) } else { None } } } else { None } } pub fn is_win11() -> bool { let v = get_windows_ver().unwrap_or_default(); v.2 >= 22000 } #[test] fn test_version() { dbg!(get_windows_ver().unwrap_or_default()); } ================================================ FILE: backend/tauri/src/utils/winreg.rs ================================================ use std::{ io::ErrorKind, path::{Path, PathBuf}, }; use super::dirs::APP_DIR_PLACEHOLDER; use anyhow::Result; use once_cell::sync::Lazy; use winreg::{RegKey, enums::*}; static SOFTWARE_KEY: Lazy<&'static str> = Lazy::new(|| { let key = format!("Software\\{}", *APP_DIR_PLACEHOLDER); Box::leak(key.into_boxed_str()) // safe to leak }); pub fn get_app_dir() -> Result> { let hcu = RegKey::predef(HKEY_CURRENT_USER); let key = match hcu.open_subkey(*SOFTWARE_KEY) { Ok(key) => key, Err(e) => { if let ErrorKind::NotFound = e.kind() { return Ok(None); } return Err(e.into()); } }; let path: String = key.get_value("AppDir")?; if path.is_empty() { return Ok(None); } let path = PathBuf::from(path); // Basic validation: ensure absolute path if !path.is_absolute() { return Ok(None); } Ok(Some(path)) } pub fn set_app_dir(path: &Path) -> Result<()> { let hcu = RegKey::predef(HKEY_CURRENT_USER); let (key, _) = hcu.create_subkey(*SOFTWARE_KEY)?; let path = path.to_str().unwrap(); // safe to unwrap key.set_value("AppDir", &path)?; Ok(()) } /// Get current Windows user SID #[cfg(windows)] pub fn get_current_user_sid() -> Result { use std::{os::windows::process::CommandExt, process::Command}; // Try PowerShell method first (more reliable) let output = Command::new("powershell") .args(&[ "-Command", "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value", ]) .creation_flags(0x08000000) // CREATE_NO_WINDOW .output(); if let Ok(output) = output { if output.status.success() { let sid = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !sid.is_empty() { return Ok(sid); } } } // Fallback to WMIC method let output = Command::new("wmic") .args(&[ "useraccount", "where", "name='%username%'", "get", "sid", "/value", ]) .creation_flags(0x08000000) // CREATE_NO_WINDOW .output(); if let Ok(output) = output { if output.status.success() { let result = String::from_utf8_lossy(&output.stdout); for line in result.lines() { if line.starts_with("SID=") { let sid = line[4..].trim().to_string(); if !sid.is_empty() { return Ok(sid); } } } } } // If both methods fail, fall back to the config dir hashing approach use std::{ collections::hash_map::DefaultHasher, hash::{Hash, Hasher}, }; let cfg_dir = super::dirs::app_config_dir()?; let mut hasher = DefaultHasher::new(); cfg_dir.to_string_lossy().hash(&mut hasher); let hash = hasher.finish(); Ok(format!("{:x}", hash)) } ================================================ FILE: backend/tauri/src/utils/winreg_test.rs ================================================ #[cfg(test)] mod tests { use crate::utils::{dirs::get_single_instance_placeholder, winreg::get_current_user_sid}; #[test] #[cfg(windows)] fn test_get_current_user_sid() { let sid = get_current_user_sid(); assert!(sid.is_ok()); let sid = sid.unwrap(); assert!(!sid.is_empty()); // SID should start with "S-" followed by numbers assert!(sid.starts_with("S-")); println!("Current user SID: {}", sid); } #[test] #[cfg(windows)] fn test_get_single_instance_placeholder_with_sid() { let placeholder = get_single_instance_placeholder(); assert!(placeholder.is_ok()); let placeholder = placeholder.unwrap(); assert!(!placeholder.is_empty()); // Should contain the app name assert!( placeholder.contains("clash-nyanpasu") || placeholder.contains("clash-nyanpasu-dev") ); println!("Single instance placeholder: {}", placeholder); } } ================================================ FILE: backend/tauri/src/widget.rs ================================================ use crate::config::{Config, nyanpasu::NetworkStatisticWidgetConfig}; use super::core::clash::ws::ClashConnectionsConnectorEvent; use anyhow::Context; use nyanpasu_egui::{ ipc::{IpcSender, Message, StatisticMessage, create_ipc_server}, widget::StatisticWidgetVariant, }; use std::sync::{Arc, atomic::AtomicBool}; use tauri::{Manager, Runtime, utils::platform::current_exe}; use tokio::{ process::Child, sync::{ Mutex, broadcast::{Receiver as BroadcastReceiver, error::RecvError as BroadcastRecvError}, }, }; #[derive(Clone)] pub struct WidgetManager { instance: Arc>>, listener_initd: Arc, } struct WidgetManagerInstance { tx: IpcSender, process: Child, } impl WidgetManager { pub fn new() -> Self { Self { instance: Arc::new(Mutex::new(None)), listener_initd: Arc::new(AtomicBool::new(false)), } } fn register_listener(&self, mut receiver: BroadcastReceiver) { if self .listener_initd .load(std::sync::atomic::Ordering::Acquire) { return; } let signal = self.listener_initd.clone(); let this = self.clone(); tokio::spawn(async move { loop { match receiver.recv().await { Ok(event) => { if let Err(e) = this.handle_event(event).await { log::error!("Failed to handle event: {e}"); } } Err(e) => { log::error!("Error receiving event: {e}"); if BroadcastRecvError::Closed == e { signal.store(false, std::sync::atomic::Ordering::Release); break; } } } } }); self.listener_initd .store(true, std::sync::atomic::Ordering::Release); } async fn handle_event(&self, event: ClashConnectionsConnectorEvent) -> anyhow::Result<()> { let mut instance = self.instance.clone().lock_owned().await; if let ClashConnectionsConnectorEvent::Update(info) = event && instance .as_mut() .is_some_and(|instance| instance.is_alive()) { tokio::task::spawn_blocking(move || { let instance = instance.as_ref().unwrap(); // we only care about the update event now instance .send_message(Message::UpdateStatistic(StatisticMessage { download_total: info.download_total, upload_total: info.upload_total, download_speed: info.download_speed, upload_speed: info.upload_speed, })) .context("Failed to send event to widget")?; Ok::<(), anyhow::Error>(()) }) .await .context("Failed to send event to widget")??; } Ok(()) } pub async fn start(&self, widget: StatisticWidgetVariant) -> anyhow::Result<()> { if (self.instance.lock().await).is_some() { log::info!("Widget already running, stopping it first..."); self.stop().await.context("Failed to stop widget")?; } let mut instance = self.instance.lock().await; let current_exe = current_exe().context("Failed to get current executable")?; // This operation is blocking, but it internal just a system call, so I think it's okay let (mut ipc_server, server_name) = create_ipc_server()?; // spawn a process to run the widget let variant = format!("{widget}"); tracing::debug!("Spawning widget process for {}...", variant); let widget_win_state_path = crate::utils::dirs::app_data_dir() .context("Failed to get app data dir")? .join(format!("widget_{variant}.state")); let mut child = tokio::process::Command::new(current_exe) .arg("statistic-widget") .arg(variant) .env("NYANPASU_EGUI_IPC_SERVER", server_name) .env("NYANPASU_EGUI_WINDOW_STATE_PATH", widget_win_state_path) .stdin(std::process::Stdio::inherit()) .stdout(os_pipe::dup_stdout()?) .stderr(os_pipe::dup_stderr()?) .spawn() .context("Failed to spawn widget process")?; tracing::debug!("Waiting for widget process to start..."); let tx = tokio::select! { res = tokio::task::spawn_blocking(move || { ipc_server .connect() .context("Failed to connect to widget")?; ipc_server.into_tx().context("Failed to get ipc sender") }) => res.context("Failed to get ipc sender")??, res = child.wait() => { match res { Ok(status) => { return Err(anyhow::anyhow!("Widget process exited: {}", status)); } Err(e) => { return Err(anyhow::anyhow!("Failed to wait for widget process: {}", e)); } } } }; instance.replace(WidgetManagerInstance { tx, process: child }); Ok(()) } pub async fn stop(&self) -> anyhow::Result<()> { let Some(mut instance) = self.instance.lock().await.take() else { tracing::debug!("Widget instance is not exists, skipping..."); return Ok(()); }; if !instance.is_alive() { tracing::debug!("Widget instance is not alive, skipping..."); return Ok(()); } // first try to stop the process gracefully let mut instance = tokio::task::spawn_blocking(move || { instance .send_message(Message::Stop) .context("Failed to send stop message to widget")?; Ok::(instance) }) .await .context("Failed to kill widget process")??; for _ in 0..5 { if instance.is_alive() { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } else { return Ok(()); } } // force kill the process instance .process .kill() .await .context("Failed to kill widget process")?; Ok(()) } pub async fn is_running(&self) -> bool { let mut instance = self.instance.lock().await; instance .as_mut() .is_some_and(|instance| instance.is_alive()) } } impl WidgetManagerInstance { pub fn is_alive(&mut self) -> bool { self.process.try_wait().is_ok_and(|status| status.is_none()) } fn send_message(&self, message: Message) -> anyhow::Result<()> { #[cfg(debug_assertions)] tracing::debug!("Sending message to widget: {:?}", message); self.tx .send(message) .context("Failed to send message to widget")?; Ok(()) } } impl Drop for WidgetManager { fn drop(&mut self) { let cleanup = async { let _ = self.stop().await; }; match tokio::runtime::Handle::try_current() { Ok(_) => { tokio::task::block_in_place(move || { tauri::async_runtime::block_on(cleanup); }); } Err(_) => { tauri::async_runtime::block_on(cleanup); } } } } pub async fn setup>( manager: &M, ws_connections_receiver: BroadcastReceiver, ) -> anyhow::Result<()> { let widget_manager = WidgetManager::new(); // TODO: use the app_handle to read initial config. let option = Config::verge() .data() .network_statistic_widget .unwrap_or_default(); widget_manager.register_listener(ws_connections_receiver); if let NetworkStatisticWidgetConfig::Enabled(widget) = option { widget_manager.start(widget).await?; } // TODO: subscribe to the config change event manager.manage(widget_manager); Ok(()) } ================================================ FILE: backend/tauri/src/window.rs ================================================ //! Tauri window management mod //! //! This module provides a flexible window management system that supports: //! - URL parameters for windows //! - Multiple instances of the same window type (e.g., main, main-1, main-2) //! - Inter-window communication //! - Configurable window properties (singleton, visibility, size, etc.) use crate::{ config::{Config, nyanpasu::WindowState}, log_err, trace_err, }; use anyhow::Result; use serde::{Deserialize, Serialize}; use specta::Type; use std::{ collections::HashMap, sync::{ Mutex, OnceLock, atomic::{AtomicU16, Ordering}, }, }; use tauri::{AppHandle, Manager}; use tauri_specta::Event; /// Global counter for tracking open windows static OPEN_WINDOWS_COUNTER: AtomicU16 = AtomicU16::new(0); /// Global window manager instance static WINDOW_MANAGER: OnceLock> = OnceLock::new(); /// Window configuration options #[derive(Debug, Clone)] pub struct WindowConfig { /// Whether only one instance of this window type is allowed pub singleton: bool, /// Whether the window should be visible when created pub visible_on_create: bool, /// Default window size (width, height) pub default_size: (f64, f64), /// Minimum window size (width, height) pub min_size: Option<(f64, f64)>, /// Maximum window size (width, height) pub max_size: Option<(f64, f64)>, /// Whether to center the window on creation pub center: bool, /// Whether the window is resizable pub resizable: bool, /// Whether the window should always be on top (None = use global config) pub always_on_top: Option, /// Whether to use decorations (None = use platform default) pub decorations: Option, /// Whether the window is transparent (None = use platform default) pub transparent: Option, /// Whether to skip taskbar pub skip_taskbar: bool, } impl Default for WindowConfig { fn default() -> Self { Self { singleton: true, visible_on_create: true, default_size: (800.0, 636.0), min_size: Some((400.0, 600.0)), max_size: None, center: true, resizable: true, always_on_top: None, decorations: None, transparent: None, skip_taskbar: false, } } } impl WindowConfig { /// Create a new WindowConfig with default values pub fn new() -> Self { Self::default() } /// Set whether only one instance is allowed pub fn singleton(mut self, singleton: bool) -> Self { self.singleton = singleton; self } /// Set whether window is visible on creation pub fn visible_on_create(mut self, visible: bool) -> Self { self.visible_on_create = visible; self } /// Set default window size pub fn default_size(mut self, width: f64, height: f64) -> Self { self.default_size = (width, height); self } /// Set minimum window size pub fn min_size(mut self, width: f64, height: f64) -> Self { self.min_size = Some((width, height)); self } /// Set maximum window size pub fn max_size(mut self, width: f64, height: f64) -> Self { self.max_size = Some((width, height)); self } /// Set whether to center the window pub fn center(mut self, center: bool) -> Self { self.center = center; self } /// Set whether the window is resizable pub fn resizable(mut self, resizable: bool) -> Self { self.resizable = resizable; self } /// Set always on top pub fn always_on_top(mut self, always_on_top: bool) -> Self { self.always_on_top = Some(always_on_top); self } /// Set whether to skip taskbar pub fn skip_taskbar(mut self, skip: bool) -> Self { self.skip_taskbar = skip; self } } /// Window URL parameters pub type WindowParams = HashMap; /// Builder for constructing URL parameters #[derive(Debug, Clone, Default)] pub struct WindowParamsBuilder { params: WindowParams, } impl WindowParamsBuilder { pub fn new() -> Self { Self::default() } /// Add a string parameter pub fn param(mut self, key: impl Into, value: impl Into) -> Self { self.params.insert(key.into(), value.into()); self } /// Add a parameter if condition is true pub fn param_if( self, condition: bool, key: impl Into, value: impl Into, ) -> Self { if condition { self.param(key, value) } else { self } } /// Add an optional parameter pub fn param_opt(self, key: impl Into, value: Option>) -> Self { match value { Some(v) => self.param(key, v), None => self, } } /// Build the parameters pub fn build(self) -> Option { if self.params.is_empty() { None } else { Some(self.params) } } } /// Build URL with optional parameters pub fn build_url_with_params(base_url: &str, params: Option<&WindowParams>) -> String { match params { Some(params) if !params.is_empty() => { let query: Vec = params .iter() .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) .collect(); format!("{}?{}", base_url, query.join("&")) } _ => base_url.to_string(), } } /// Window manager for tracking window instances #[derive(Debug, Default)] pub struct WindowManager { /// Maps base label to list of instance labels instances: HashMap>, } impl WindowManager { /// Get global window manager instance pub fn global() -> &'static Mutex { WINDOW_MANAGER.get_or_init(|| Mutex::new(Self::default())) } /// Generate a unique label for a window /// /// For singleton windows, returns None if an instance already exists. /// For non-singleton windows, generates labels like: base, base-1, base-2, etc. pub fn generate_label(&mut self, base_label: &str, singleton: bool) -> Option { let instances = self.instances.entry(base_label.to_string()).or_default(); if singleton && !instances.is_empty() { return None; // Singleton window already exists } if instances.is_empty() { instances.push(base_label.to_string()); return Some(base_label.to_string()); } // Find the next available number let mut next_num = 1; loop { let label = format!("{}-{}", base_label, next_num); if !instances.contains(&label) { instances.push(label.clone()); return Some(label); } next_num += 1; } } /// Remove a window instance pub fn remove_instance(&mut self, label: &str) { for instances in self.instances.values_mut() { instances.retain(|l| l != label); } } /// Get all instances for a base label pub fn get_instances(&self, base_label: &str) -> Vec { self.instances.get(base_label).cloned().unwrap_or_default() } /// Check if a specific label exists pub fn has_instance(&self, label: &str) -> bool { self.instances .values() .any(|instances| instances.contains(&label.to_string())) } /// Get the count of instances for a base label pub fn instance_count(&self, base_label: &str) -> usize { self.instances.get(base_label).map(|v| v.len()).unwrap_or(0) } } /// Event emitted by the frontend when the React app is mounted. /// Event name: `react-app-mounted-event` #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] pub struct ReactAppMountedEvent; /// Message for inter-window communication #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] pub struct WindowMessageEvent { /// Source window label pub from: String, /// Target window label (use "*" for broadcast) pub to: String, /// Message type/event name pub event: String, /// Message payload pub payload: serde_json::Value, } impl WindowMessageEvent { /// Create a new window message pub fn new( from: impl Into, to: impl Into, event: impl Into, payload: serde_json::Value, ) -> Self { Self { from: from.into(), to: to.into(), event: event.into(), payload, } } /// Create a broadcast message to all windows pub fn broadcast( from: impl Into, event: impl Into, payload: serde_json::Value, ) -> Self { Self::new(from, "*", event, payload) } } /// Send a message to a specific window pub fn send_message_to_window(app_handle: &AppHandle, message: WindowMessageEvent) -> Result<()> { // Verify window exists let _ = app_handle .get_webview_window(&message.to) .ok_or_else(|| anyhow::anyhow!("Window '{}' not found", message.to))?; let target = message.to.clone(); message.emit_to(app_handle, target)?; Ok(()) } /// Send a message to all instances of a window type pub fn broadcast_to_window_type( app_handle: &AppHandle, base_label: &str, from: &str, event: &str, payload: serde_json::Value, ) -> Result<()> { let instances = { let manager = WindowManager::global().lock().unwrap(); manager.get_instances(base_label) }; for label in instances { if app_handle.get_webview_window(&label).is_some() { let message = WindowMessageEvent::new(from, &label, event, payload.clone()); trace_err!( message.emit_to(app_handle, &label), "failed to emit message" ); } } Ok(()) } /// Broadcast a message to all open windows pub fn broadcast_to_all_windows( app_handle: &AppHandle, from: &str, event: &str, payload: serde_json::Value, ) -> Result<()> { WindowMessageEvent::broadcast(from, event, payload).emit(app_handle)?; Ok(()) } /// Result of window creation #[derive(Debug, Clone)] pub struct WindowCreateResult { /// The actual label of the created window pub label: String, /// Whether this was a newly created window or an existing one was shown pub is_new: bool, } impl WindowCreateResult { fn new(label: String) -> Self { Self { label, is_new: true, } } fn existing(label: String) -> Self { Self { label, is_new: false, } } } /// Trait for window management pub trait AppWindow { /// Get window base label (e.g., "main", "editor") fn label(&self) -> &str; /// Get window title fn title(&self) -> &str; /// Get window URL path fn url(&self) -> &str; /// Get window configuration fn config(&self) -> WindowConfig { WindowConfig::default() } /// Get window state from config fn get_window_state(&self) -> Option; /// Set window state to config fn set_window_state(&self, state: Option); fn reset_window_open_counter(&self) { OPEN_WINDOWS_COUNTER.fetch_sub(1, Ordering::Release); } /// Create window with optional URL parameters /// /// Returns the label of the created (or existing) window fn create_with_params( &self, app_handle: &AppHandle, params: Option, ) -> Result { let config = self.config(); let base_label = self.label(); // Clean up stale window records before generating label // This handles cases where the window was destroyed but the record wasn't cleaned up { let mut manager = WindowManager::global().lock().unwrap(); let stale_labels: Vec = manager .get_instances(base_label) .into_iter() .filter(|label| app_handle.get_webview_window(label).is_none()) .collect(); for label in stale_labels { tracing::debug!("cleaning up stale window record: {}", label); manager.remove_instance(&label); } } // Generate unique label let label = { let mut manager = WindowManager::global().lock().unwrap(); // After cleanup above, generate_label should work correctly // For singleton windows, if it returns None, the window truly exists manager .generate_label(base_label, config.singleton) .unwrap_or_else(|| { // Singleton window already exists - try to focus it if let Some(window) = app_handle.get_webview_window(base_label) { tracing::debug!("{} window is already opened, try to focus it", base_label); trace_err!(window.unminimize(), "set win unminimize"); trace_err!(window.show(), "set win visible"); trace_err!(window.set_focus(), "set win focus"); } // Return early indicator - we'll handle this below String::new() }) }; // Handle singleton window that already exists if label.is_empty() { return Ok(WindowCreateResult::existing(base_label.to_string())); } let always_on_top = config.always_on_top.unwrap_or_else(|| { *Config::verge() .latest() .always_on_top .as_ref() .unwrap_or(&false) }); // Build URL with params let url = build_url_with_params(self.url(), params.as_ref()); tracing::debug!("create {} window (label: {})...", base_label, label); let mut builder = tauri::WebviewWindowBuilder::new( app_handle, label.clone(), tauri::WebviewUrl::App(url.into()), ) .title(self.title()) .fullscreen(false) .always_on_top(always_on_top) .resizable(config.resizable) .skip_taskbar(config.skip_taskbar) .disable_drag_drop_handler(); // Apply min/max size if let Some((w, h)) = config.min_size { builder = builder.min_inner_size(w, h); } if let Some((w, h)) = config.max_size { builder = builder.max_inner_size(w, h); } let win_state = &self.get_window_state(); match win_state { Some(_) => { builder = builder.inner_size(800., 800.).position(0., 0.); } _ => { let (default_width, default_height) = config.default_size; #[cfg(target_os = "windows")] { builder = builder.inner_size(default_width, default_height); } #[cfg(target_os = "macos")] { // macOS has slightly different height due to title bar builder = builder.inner_size(default_width, default_height + 6.0); } #[cfg(target_os = "linux")] { builder = builder.inner_size(default_width, default_height + 6.0); } if config.center { builder = builder.center(); } } }; #[cfg(windows)] let win_res = builder .decorations(false) .transparent(true) .visible(false) .additional_browser_args("--enable-features=msWebView2EnableDraggableRegions --disable-features=OverscrollHistoryNavigation,msExperimentalScrolling") .build(); #[cfg(target_os = "macos")] let win_res = { let decorations = config.decorations.unwrap_or(true); builder .decorations(decorations) .hidden_title(true) .title_bar_style(tauri::TitleBarStyle::Overlay) .build() }; #[cfg(target_os = "linux")] let win_res = { let decorations = config.decorations.unwrap_or(true); let transparent = config.transparent.unwrap_or(false); builder .decorations(decorations) .transparent(transparent) .build() }; match win_res { Ok(win) => { use tauri::{PhysicalPosition, PhysicalSize}; if win_state.is_some() { let state = win_state.as_ref().unwrap(); let _ = win.set_position(PhysicalPosition { x: state.x, y: state.y, }); // Clamp restored size to min_size to prevent 0x0 windows let mut width = state.width; let mut height = state.height; if let Some((min_w, min_h)) = config.min_size { let scale_factor = win.scale_factor().unwrap_or(1.0); let min_w_physical = (min_w * scale_factor) as u32; let min_h_physical = (min_h * scale_factor) as u32; if width < min_w_physical { width = min_w_physical; } if height < min_h_physical { height = min_h_physical; } } let _ = win.set_size(PhysicalSize { width, height }); } if let Some(state) = win_state { if state.maximized { trace_err!(win.maximize(), "set win maximize"); } if state.fullscreen { trace_err!(win.set_fullscreen(true), "set win fullscreen"); } } #[cfg(windows)] trace_err!(win.set_shadow(true), "set win shadow"); log::trace!("try to calculate the monitor size"); let center = (|| -> Result { let center; if let Some(state) = win_state { let monitor = win.current_monitor()?.ok_or(anyhow::anyhow!(""))?; let PhysicalPosition { x, y } = *monitor.position(); let PhysicalSize { width, height } = *monitor.size(); let left = x; let right = x + width as i32; let top = y; let bottom = y + height as i32; let x = state.x; let y = state.y; let width = state.width as i32; let height = state.height as i32; center = ![ (x, y), (x + width, y), (x, y + height), (x + width, y + height), ] .into_iter() .any(|(x, y)| x >= left && x < right && y >= top && y < bottom); } else { center = true; } Ok(center) })(); if center.unwrap_or(true) { trace_err!(win.center(), "set win center"); } #[cfg(debug_assertions)] { if let Some(webview_window) = win.get_webview_window(&label) { webview_window.open_devtools(); } } #[cfg(target_os = "macos")] { tracing::trace!("setup traffic lights pos"); let mtm = objc2_foundation::MainThreadMarker::new().unwrap(); crate::window::macos::setup_traffic_lights_pos(win.clone(), (18.0, 22.0), mtm); } // Register window close event to clean up WindowManager let label_clone = label.clone(); win.on_window_event(move |event| { if let tauri::WindowEvent::Destroyed = event { tracing::debug!("window {} destroyed, removing from manager", label_clone); let mut manager = WindowManager::global().lock().unwrap(); manager.remove_instance(&label_clone); OPEN_WINDOWS_COUNTER.fetch_sub(1, Ordering::Release); } }); OPEN_WINDOWS_COUNTER.fetch_add(1, Ordering::Release); Ok(WindowCreateResult::new(label)) } Err(err) => { log::error!(target: "app", "failed to create window, {err:?}"); // Remove from manager on failure { let mut manager = WindowManager::global().lock().unwrap(); manager.remove_instance(&label); } if let Some(win) = app_handle.get_webview_window(&label) { // Cleanup window if failed to create, it's a workaround for tauri bug log_err!( win.destroy(), "occur error when close window while failed to create" ); } Err(err.into()) } } } /// Create window with default implementation (no params) fn create(&self, app_handle: &AppHandle) -> Result<()> { let result = self.create_with_params(app_handle, None)?; // Configure webview settings asynchronously to avoid blocking #[cfg(target_os = "windows")] if result.is_new { let label = result.label.clone(); let app_handle = app_handle.clone(); std::thread::spawn(move || { use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2Settings6; use windows_core::Interface; // Wait a bit for webview to be ready std::thread::sleep(std::time::Duration::from_millis(100)); if let Some(window) = app_handle.get_webview_window(&label) { let _ = window.with_webview(|webview| unsafe { if let Ok(core) = webview.controller().CoreWebView2() { if let Ok(settings) = core.Settings() { if let Ok(settings6) = settings.cast::() { let _ = settings6.SetIsSwipeNavigationEnabled(false); } } } }); } }); } Ok(()) } /// Close window by label /// /// Note: The WindowManager cleanup is handled automatically by the /// on_window_event callback registered during window creation. fn close_by_label(&self, app_handle: &AppHandle, label: &str) { if let Some(window) = app_handle.get_webview_window(label) { trace_err!(window.close(), "close window"); // WindowManager cleanup is handled by on_window_event(Destroyed) } } /// Close window with default implementation (closes the base label window) fn close(&self, app_handle: &AppHandle) { self.close_by_label(app_handle, self.label()); } /// Close all instances of this window type fn close_all(&self, app_handle: &AppHandle) { let instances = { let manager = WindowManager::global().lock().unwrap(); manager.get_instances(self.label()) }; for label in instances { self.close_by_label(app_handle, &label); } } /// Check if the base label window is open fn is_open(&self, app_handle: &AppHandle) -> bool { app_handle.get_webview_window(self.label()).is_some() } /// Check if any instance of this window type is open fn has_any_instance(&self, app_handle: &AppHandle) -> bool { let manager = WindowManager::global().lock().unwrap(); let instances = manager.get_instances(self.label()); instances .iter() .any(|label| app_handle.get_webview_window(label).is_some()) } /// Get all open window labels for this type fn get_open_instances(&self, app_handle: &AppHandle) -> Vec { let manager = WindowManager::global().lock().unwrap(); manager .get_instances(self.label()) .into_iter() .filter(|label| app_handle.get_webview_window(label).is_some()) .collect() } /// Send a message to another window fn send_message( &self, app_handle: &AppHandle, to: &str, event: &str, payload: serde_json::Value, ) -> Result<()> { let message = WindowMessageEvent::new(self.label(), to, event, payload); send_message_to_window(app_handle, message) } /// Broadcast a message to all instances of another window type fn broadcast_to_type( &self, app_handle: &AppHandle, target_type: &str, event: &str, payload: serde_json::Value, ) -> Result<()> { broadcast_to_window_type(app_handle, target_type, self.label(), event, payload) } /// Save window state with default implementation fn save_state(&self, app_handle: &AppHandle, save_to_file: bool) -> Result<()> { let win = app_handle .get_webview_window(self.label()) .ok_or(anyhow::anyhow!("failed to get window"))?; let current_monitor = win.current_monitor()?; let state = match current_monitor { Some(_) => { let maximized = win.is_maximized()?; let fullscreen = win.is_fullscreen()?; let is_minimized = win.is_minimized()?; let size = win.inner_size()?; // During system shutdown, Windows sends resize events with 0x0 dimensions. // Skip saving in this case to preserve the last valid window state. if size.width == 0 || size.height == 0 { if !maximized && !fullscreen && !is_minimized { tracing::debug!( "skipping window state save: invalid size {}x{} in normal state", size.width, size.height ); return Ok(()); } } let mut state = WindowState { maximized, fullscreen, ..WindowState::default() }; if size.width > 0 && size.height > 0 && !state.maximized && !is_minimized { state.width = size.width; state.height = size.height; } let position = win.outer_position()?; if !state.maximized && !is_minimized { state.x = position.x; state.y = position.y; } Some(state) } None => None, }; self.set_window_state(state); if save_to_file { Config::verge().data().save_file()?; } Ok(()) } } #[cfg(target_os = "macos")] pub mod macos { #![allow(non_snake_case)] use std::cell::RefCell; use objc2::{ DeclaredClass, MainThreadOnly, define_class, msg_send, rc::Retained, runtime::ProtocolObject, }; use objc2_app_kit::{NSApplicationPresentationOptions, NSWindow, NSWindowDelegate}; use objc2_foundation::{MainThreadMarker, NSNotification, NSObject, NSObjectProtocol}; use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow, Window, WindowEvent}; #[derive(Debug, Clone, Copy)] pub struct Position { pub x: f64, pub y: f64, } impl From<(f64, f64)> for Position { fn from(value: (f64, f64)) -> Self { Self { x: value.0, y: value.1, } } } impl From for (f64, f64) { fn from(value: Position) -> Self { (value.x, value.y) } } fn set_traffic_lights_pos( window: objc2::rc::Retained, pos: Position, ) -> anyhow::Result<()> { use objc2_app_kit::NSWindowButton; use objc2_foundation::NSRect; let close = window .standardWindowButton(NSWindowButton::CloseButton) .ok_or(anyhow::anyhow!("failed to get close button"))?; let miniaturize = window .standardWindowButton(NSWindowButton::MiniaturizeButton) .ok_or(anyhow::anyhow!("failed to get miniaturize button"))?; let zoom = window .standardWindowButton(NSWindowButton::ZoomButton) .ok_or(anyhow::anyhow!("failed to get zoom button"))?; let title_bar_container_view = unsafe { close .superview() .and_then(|view| view.superview()) .ok_or(anyhow::anyhow!("failed to get title bar container view"))? }; let close_rect = close.frame(); let button_height = close_rect.size.height; let title_bar_frame_height = button_height + pos.y; let mut title_bar_rect = title_bar_container_view.frame(); title_bar_rect.size.height = title_bar_frame_height; title_bar_rect.origin.y = window.frame().size.height - title_bar_frame_height; unsafe { title_bar_container_view.setFrame(title_bar_rect); } let space_between = miniaturize.frame().origin.x - close.frame().origin.x; let window_buttons = vec![close, miniaturize, zoom]; for (i, button) in window_buttons.into_iter().enumerate() { let mut rect: NSRect = button.frame(); rect.origin.x = pos.x + (i as f64 * space_between); unsafe { button.setFrameOrigin(rect.origin); } } Ok(()) } #[derive(Debug, Clone)] struct WindowState { window: WebviewWindow, traffic_lights_pos: Position, } impl WindowState { fn new(window: WebviewWindow, traffic_lights_pos: Position) -> Self { Self { window, traffic_lights_pos, } } fn with_ns_window(&self, func: impl FnOnce(Retained) -> T) -> T { let ns_window = self.window.ns_window().expect("window not found"); let ns_window = unsafe { Retained::retain_autoreleased(ns_window as *mut NSWindow) } .expect("failed to retain window"); func(ns_window) } fn apply_traffic_lights_pos(&self) { self.with_ns_window(|win| { set_traffic_lights_pos(win, self.traffic_lights_pos) .expect("failed to set traffic lights pos"); }); } } #[derive(Debug)] struct TrafficLightsWindowDelegateIvars { app_box: WindowState, super_class: Retained>, } const WINDOW_DID_ENTER_FULL_SCREEN: &str = "internal:://window-did-enter-full-screen"; const WINDOW_WILL_ENTER_FULL_SCREEN: &str = "internal:://window-will-enter-full-screen"; const WINDOW_WILL_EXIT_FULL_SCREEN: &str = "internal:://window-will-exit-full-screen"; const WINDOW_DID_EXIT_FULL_SCREEN: &str = "internal:://window-did-exit-full-screen"; define_class! { #[unsafe(super(NSObject))] #[name = "TrafficLightsPosWindowDelegate"] #[thread_kind = MainThreadOnly] #[ivars = TrafficLightsWindowDelegateIvars] struct WindowDelegate; unsafe impl NSObjectProtocol for WindowDelegate {} unsafe impl NSWindowDelegate for WindowDelegate { #[unsafe(method(windowShouldClose:))] unsafe fn windowShouldClose(&self, sender: &NSWindow) -> bool { tracing::trace!("passthrough `windowShouldClose` to TAO layer"); unsafe { self.ivars().super_class.windowShouldClose(sender) } } #[unsafe(method(windowWillClose:))] unsafe fn windowWillClose(&self, notification: &NSNotification) { tracing::trace!("passthrough `windowWillClose` to TAO layer"); unsafe { self.ivars().super_class.windowWillClose(notification) } } #[unsafe(method(windowDidResize:))] unsafe fn windowDidResize(&self, notification: &NSNotification) { self.ivars().app_box.apply_traffic_lights_pos(); tracing::trace!("passthrough `windowDidResize` to TAO layer"); unsafe { self.ivars().super_class.windowDidResize(notification) } } #[unsafe(method(windowDidMove:))] unsafe fn windowDidMove(&self, notification: &NSNotification) { tracing::trace!("passthrough `windowDidMove` to TAO layer"); unsafe { self.ivars().super_class.windowDidMove(notification) } } #[unsafe(method(windowDidChangeBackingProperties:))] unsafe fn windowDidChangeBackingProperties(&self, notification: &NSNotification) { self.ivars().app_box.apply_traffic_lights_pos(); tracing::trace!("passthrough `windowDidChangeBackingProperties` to TAO layer"); unsafe { self.ivars().super_class.windowDidChangeBackingProperties(notification) } } #[unsafe(method(windowDidBecomeKey:))] unsafe fn windowDidBecomeKey(&self, notification: &NSNotification) { tracing::trace!("passthrough `windowDidBecomeKey` to TAO layer"); unsafe { self.ivars().super_class.windowDidBecomeKey(notification) } } #[unsafe(method(windowDidResignKey:))] unsafe fn windowDidResignKey(&self, notification: &NSNotification) { tracing::trace!("passthrough `windowDidResignKey` to TAO layer"); unsafe { self.ivars().super_class.windowDidResignKey(notification) } } #[unsafe(method(window:willUseFullScreenPresentationOptions:))] unsafe fn window_willUseFullScreenPresentationOptions(&self, window: &NSWindow, options: NSApplicationPresentationOptions) -> NSApplicationPresentationOptions { tracing::trace!("passthrough `window_willUseFullScreenPresentationOptions` to TAO layer"); unsafe { self.ivars().super_class.window_willUseFullScreenPresentationOptions(window, options) } } #[unsafe(method(windowDidEnterFullScreen:))] unsafe fn windowDidEnterFullScreen(&self, notification: &NSNotification) { if let Err(e) = self.ivars().app_box.window.emit(WINDOW_DID_ENTER_FULL_SCREEN, ()) { log::error!("failed to emit window-did-enter-full-screen event: {}", e); } tracing::trace!("passthrough `windowDidEnterFullScreen` to TAO layer"); unsafe { self.ivars().super_class.windowDidEnterFullScreen(notification) } } #[unsafe(method(windowWillEnterFullScreen:))] unsafe fn windowWillEnterFullScreen(&self, notification: &NSNotification) { if let Err(e) = self.ivars().app_box.window.emit(WINDOW_WILL_ENTER_FULL_SCREEN, ()) { log::error!("failed to emit window-will-enter-full-screen event: {}", e); } unsafe { self.ivars().super_class.windowWillEnterFullScreen(notification) } } #[unsafe(method(windowWillExitFullScreen:))] unsafe fn windowWillExitFullScreen(&self, notification: &NSNotification) { if let Err(e) = self.ivars().app_box.window.emit(WINDOW_WILL_EXIT_FULL_SCREEN, ()) { log::error!("failed to emit window-will-exit-full-screen event: {}", e); } tracing::trace!("passthrough `windowWillExitFullScreen` to TAO layer"); unsafe { self.ivars().super_class.windowWillExitFullScreen(notification) } } #[unsafe(method(windowDidExitFullScreen:))] unsafe fn windowDidExitFullScreen(&self, notification: &NSNotification) { if let Err(e) = self.ivars().app_box.window.emit(WINDOW_DID_EXIT_FULL_SCREEN, ()) { log::error!("failed to emit window-did-exit-full-screen event: {}", e); } self.ivars().app_box.apply_traffic_lights_pos(); tracing::trace!("passthrough `windowDidExitFullScreen` to TAO layer"); unsafe { self.ivars().super_class.windowDidExitFullScreen(notification) } } #[unsafe(method(windowDidFailToEnterFullScreen:))] unsafe fn windowDidFailToEnterFullScreen(&self,window: &NSWindow) { tracing::trace!("passthrough `windowDidFailToEnterFullScreen` to TAO layer"); unsafe { self.ivars().super_class.windowDidFailToEnterFullScreen(window) } } } } impl WindowDelegate { pub fn new(window_state: WindowState, mtm: MainThreadMarker) -> Retained { let this = Self::alloc(mtm); let super_class = window_state .with_ns_window(|win| unsafe { win.delegate().expect("failed to get delegate") }); let ivars = TrafficLightsWindowDelegateIvars { app_box: window_state, super_class, }; let this = this.set_ivars(ivars); unsafe { msg_send![super(this), init] } } } pub struct TrafficLightsWindowDelegateGuard { _delegate: Retained, } thread_local! { /// This is used to keep the delegate alive until the window is destroyed static TRAFFIC_LIGHTS_WINDOW_DELEGATE_GUARD: RefCell> = const { RefCell::new(None) }; } pub fn setup_traffic_lights_pos(window: WebviewWindow, pos: (f64, f64), mtm: MainThreadMarker) { let window_state = WindowState::new(window.clone(), pos.into()); let ns_window = window_state.with_ns_window(|win| win); let window_state_clone = window_state.clone(); window.on_window_event(move |event| match event { WindowEvent::ThemeChanged(_) => { window_state_clone.apply_traffic_lights_pos(); } WindowEvent::Destroyed => { let _ = TRAFFIC_LIGHTS_WINDOW_DELEGATE_GUARD.take(); } _ => {} }); // first apply the traffic lights pos window_state.apply_traffic_lights_pos(); let delegate = WindowDelegate::new(window_state, mtm); let object: &ProtocolObject = ProtocolObject::from_ref(&*delegate); ns_window.setDelegate(Some(object)); TRAFFIC_LIGHTS_WINDOW_DELEGATE_GUARD.replace(Some(TrafficLightsWindowDelegateGuard { _delegate: delegate, })); } } ================================================ FILE: backend/tauri/tauri.conf.json ================================================ { "$schema": "../../node_modules/@tauri-apps/cli/config.schema.json", "mainBinaryName": "Clash Nyanpasu", "bundle": { "active": true, "targets": "all", "windows": { "certificateThumbprint": null, "digestAlgorithm": "sha256", "timestampUrl": "", "webviewInstallMode": { "type": "embedBootstrapper" }, "wix": { "language": ["en-US", "ru-RU", "zh-CN", "zh-TW"], "template": "./templates/installer.wxs", "fragmentPaths": ["./templates/cleanup.wxs"] }, "nsis": { "displayLanguageSelector": true, "installerIcon": "icons/icon.ico", "languages": ["English", "Russian", "SimpChinese", "TradChinese"], "template": "./templates/installer.nsi", "installMode": "both" } }, "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" ], "resources": ["resources"], "externalBin": [ "sidecar/clash", "sidecar/mihomo", "sidecar/mihomo-alpha", "sidecar/clash-rs", "sidecar/clash-rs-alpha", "sidecar/nyanpasu-service" ], "copyright": "© 2024-2026 Clash Nyanpasu All Rights Reserved", "category": "DeveloperTool", "shortDescription": "Clash Nyanpasu! (∠・ω< )⌒☆", "longDescription": "Clash Nyanpasu! (∠・ω< )⌒☆", "macOS": { "frameworks": [], "minimumSystemVersion": "12.6", "exceptionDomain": "", "signingIdentity": null, "entitlements": null }, "linux": { "deb": { "depends": [] } }, "licenseFile": "../../LICENSE", "createUpdaterArtifacts": "v1Compatible" }, "build": { "beforeBuildCommand": "pnpm run-p web:build generate:git-info && echo $(pwd)", "frontendDist": "./tmp/dist", "beforeDevCommand": "pnpm run web:dev", "devUrl": "http://localhost:3000/" }, "productName": "Clash Nyanpasu", "version": "1.6.0", "identifier": "moe.elaina.clash.nyanpasu", "plugins": { "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlBMUM0NjMxREZCNDRGMjYKUldRbVQ3VGZNVVljbW43N0FlWjA4UkNrbTgxSWxSSXJQcExXNkZjUTlTQkIyYkJzL0tsSWF2d0cK", "endpoints": [ "https://deno.elaina.moe/updater/update-proxy.json", "https://nyanpasu.surge.sh/updater/update-proxy.json", "https://gh-proxy.com/https://github.com/libnyanpasu/clash-nyanpasu/releases/download/updater/update-proxy.json", "https://github.com/libnyanpasu/clash-nyanpasu/releases/download/updater/update.json" ] } }, "app": { "windows": [], "security": { "csp": "script-src 'unsafe-eval' 'self'; default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self'; img-src 'self' data: asset: blob: http://localhost:* https:; connect-src ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* wss://*", "capabilities": ["main-capability"] } } } ================================================ FILE: backend/tauri/tauri.windows.conf.json ================================================ { "$schema": "../../node_modules/@tauri-apps/cli/config.schema.json", "bundle": { "targets": ["nsis"] } } ================================================ FILE: backend/tauri/templates/cleanup.wxs ================================================ ================================================ FILE: backend/tauri/templates/installer.nsi ================================================ ; This file is copied from https://github.com/tauri-apps/tauri/blob/tauri-v1.5/tooling/bundler/src/bundle/windows/templates/installer.nsi ; and edit to fit the needs of the project. the latest tauri 2.x has a different base nsi script. Unicode true ; Set the compression algorithm. Default is LZMA. !if "{{compression}}" == "" SetCompressor /SOLID lzma !else SetCompressor /SOLID "{{compression}}" !endif !include MUI2.nsh !include FileFunc.nsh !include x64.nsh !include WordFunc.nsh !include "LogicLib.nsh" !include "StrFunc.nsh" !include "Win\COM.nsh" !include "Win\Propkey.nsh" ${StrCase} ${StrLoc} !define MANUFACTURER "{{manufacturer}}" !define PRODUCTNAME "{{product_name}}" !define VERSION "{{version}}" !define VERSIONWITHBUILD "{{version_with_build}}" !define SHORTDESCRIPTION "{{short_description}}" !define INSTALLMODE "{{install_mode}}" !define LICENSE "{{license}}" !define INSTALLERICON "{{installer_icon}}" !define SIDEBARIMAGE "{{sidebar_image}}" !define HEADERIMAGE "{{header_image}}" !define MAINBINARYNAME "{{main_binary_name}}" !define MAINBINARYSRCPATH "{{main_binary_path}}" !define BUNDLEID "{{bundle_id}}" !define COPYRIGHT "{{copyright}}" !define OUTFILE "{{out_file}}" !define ARCH "{{arch}}" !define PLUGINSPATH "{{additional_plugins_path}}" !define ALLOWDOWNGRADES "{{allow_downgrades}}" !define DISPLAYLANGUAGESELECTOR "{{display_language_selector}}" !define INSTALLWEBVIEW2MODE "{{install_webview2_mode}}" !define WEBVIEW2INSTALLERARGS "{{webview2_installer_args}}" !define WEBVIEW2BOOTSTRAPPERPATH "{{webview2_bootstrapper_path}}" !define WEBVIEW2INSTALLERPATH "{{webview2_installer_path}}" !define UNINSTKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCTNAME}" !define MANUPRODUCTKEY "Software\${MANUFACTURER}\${PRODUCTNAME}" !define UNINSTALLERSIGNCOMMAND "{{uninstaller_sign_cmd}}" !define ESTIMATEDSIZE "{{estimated_size}}" Var ProgramDataPathVar Name "${PRODUCTNAME}" BrandingText "${COPYRIGHT}" OutFile "${OUTFILE}" VIProductVersion "${VERSIONWITHBUILD}" VIAddVersionKey "ProductName" "${PRODUCTNAME}" VIAddVersionKey "FileDescription" "${SHORTDESCRIPTION}" VIAddVersionKey "LegalCopyright" "${COPYRIGHT}" VIAddVersionKey "FileVersion" "${VERSION}" VIAddVersionKey "ProductVersion" "${VERSION}" ; Plugins path, currently exists for linux only !if "${PLUGINSPATH}" != "" !addplugindir "${PLUGINSPATH}" !endif !if "${UNINSTALLERSIGNCOMMAND}" != "" !uninstfinalize '${UNINSTALLERSIGNCOMMAND}' !endif ; Handle install mode, `perUser`, `perMachine` or `both` !if "${INSTALLMODE}" == "perMachine" RequestExecutionLevel highest !endif !if "${INSTALLMODE}" == "currentUser" RequestExecutionLevel user !endif !if "${INSTALLMODE}" == "both" !define MULTIUSER_MUI !define MULTIUSER_INSTALLMODE_INSTDIR "${PRODUCTNAME}" !define MULTIUSER_INSTALLMODE_COMMANDLINE !if "${ARCH}" == "x64" !define MULTIUSER_USE_PROGRAMFILES64 !else if "${ARCH}" == "arm64" !define MULTIUSER_USE_PROGRAMFILES64 !endif !define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_KEY "${UNINSTKEY}" !define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_VALUENAME "CurrentUser" !define MULTIUSER_INSTALLMODEPAGE_SHOWUSERNAME !define MULTIUSER_INSTALLMODE_FUNCTION RestorePreviousInstallLocation !define MULTIUSER_EXECUTIONLEVEL Highest !include MultiUser.nsh !endif ; installer icon !if "${INSTALLERICON}" != "" !define MUI_ICON "${INSTALLERICON}" !endif ; installer sidebar image !if "${SIDEBARIMAGE}" != "" !define MUI_WELCOMEFINISHPAGE_BITMAP "${SIDEBARIMAGE}" !endif ; installer header image !if "${HEADERIMAGE}" != "" !define MUI_HEADERIMAGE !define MUI_HEADERIMAGE_BITMAP "${HEADERIMAGE}" !endif ; Define registry key to store installer language !define MUI_LANGDLL_REGISTRY_ROOT "HKCU" !define MUI_LANGDLL_REGISTRY_KEY "${MANUPRODUCTKEY}" !define MUI_LANGDLL_REGISTRY_VALUENAME "Installer Language" ; Installer pages, must be ordered as they appear ; 1. Welcome Page !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive !insertmacro MUI_PAGE_WELCOME ; 2. License Page (if defined) !if "${LICENSE}" != "" !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive !insertmacro MUI_PAGE_LICENSE "${LICENSE}" !endif ; 3. Install mode (if it is set to `both`) !if "${INSTALLMODE}" == "both" !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive !insertmacro MULTIUSER_PAGE_INSTALLMODE !endif ; 4. Custom page to ask user if he wants to reinstall/uninstall ; only if a previous installtion was detected Var ReinstallPageCheck Page custom PageReinstall PageLeaveReinstall Function PageReinstall ; Uninstall previous WiX installation if exists. ; ; A WiX installer stores the isntallation info in registry ; using a UUID and so we have to loop through all keys under ; `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall` ; and check if `DisplayName` and `Publisher` keys match ${PRODUCTNAME} and ${MANUFACTURER} ; ; This has a potentional issue that there maybe another installation that matches ; our ${PRODUCTNAME} and ${MANUFACTURER} but wasn't installed by our WiX installer, ; however, this should be fine since the user will have to confirm the uninstallation ; and they can chose to abort it if doesn't make sense. StrCpy $0 0 wix_loop: EnumRegKey $1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" $0 StrCmp $1 "" wix_done ; Exit loop if there is no more keys to loop on IntOp $0 $0 + 1 ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "DisplayName" ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "Publisher" StrCmp "$R0$R1" "${PRODUCTNAME}${MANUFACTURER}" 0 wix_loop ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "UninstallString" ${StrCase} $R1 $R0 "L" ${StrLoc} $R0 $R1 "msiexec" ">" StrCmp $R0 0 0 wix_done StrCpy $R7 "wix" StrCpy $R6 "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" Goto compare_version wix_done: ; Check if there is an existing installation, if not, abort the reinstall page ReadRegStr $R0 SHCTX "${UNINSTKEY}" "" ReadRegStr $R1 SHCTX "${UNINSTKEY}" "UninstallString" ${IfThen} "$R0$R1" == "" ${|} Abort ${|} ; Compare this installar version with the existing installation ; and modify the messages presented to the user accordingly compare_version: StrCpy $R4 "$(older)" ${If} $R7 == "wix" ReadRegStr $R0 HKLM "$R6" "DisplayVersion" ${Else} ReadRegStr $R0 SHCTX "${UNINSTKEY}" "DisplayVersion" ${EndIf} ${IfThen} $R0 == "" ${|} StrCpy $R4 "$(unknown)" ${|} nsis_tauri_utils::SemverCompare "${VERSION}" $R0 Pop $R0 ; Reinstalling the same version ${If} $R0 == 0 StrCpy $R1 "$(alreadyInstalledLong)" StrCpy $R2 "$(addOrReinstall)" StrCpy $R3 "$(uninstallApp)" !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(chooseMaintenanceOption)" StrCpy $R5 "2" ; Upgrading ${ElseIf} $R0 == 1 StrCpy $R1 "$(olderOrUnknownVersionInstalled)" StrCpy $R2 "$(uninstallBeforeInstalling)" StrCpy $R3 "$(dontUninstall)" !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(choowHowToInstall)" StrCpy $R5 "1" ; Downgrading ${ElseIf} $R0 == -1 StrCpy $R1 "$(newerVersionInstalled)" StrCpy $R2 "$(uninstallBeforeInstalling)" !if "${ALLOWDOWNGRADES}" == "true" StrCpy $R3 "$(dontUninstall)" !else StrCpy $R3 "$(dontUninstallDowngrade)" !endif !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(choowHowToInstall)" StrCpy $R5 "1" ${Else} Abort ${EndIf} Call SkipIfPassive nsDialogs::Create 1018 Pop $R4 ${IfThen} $(^RTL) == 1 ${|} nsDialogs::SetRTL $(^RTL) ${|} ${NSD_CreateLabel} 0 0 100% 24u $R1 Pop $R1 ${NSD_CreateRadioButton} 30u 50u -30u 8u $R2 Pop $R2 ${NSD_OnClick} $R2 PageReinstallUpdateSelection ${NSD_CreateRadioButton} 30u 70u -30u 8u $R3 Pop $R3 ; disable this radio button if downgrading and downgrades are disabled !if "${ALLOWDOWNGRADES}" == "false" ${IfThen} $R0 == -1 ${|} EnableWindow $R3 0 ${|} !endif ${NSD_OnClick} $R3 PageReinstallUpdateSelection ; Check the first radio button if this the first time ; we enter this page or if the second button wasn't ; selected the last time we were on this page ${If} $ReinstallPageCheck != 2 SendMessage $R2 ${BM_SETCHECK} ${BST_CHECKED} 0 ${Else} SendMessage $R3 ${BM_SETCHECK} ${BST_CHECKED} 0 ${EndIf} ${NSD_SetFocus} $R2 nsDialogs::Show FunctionEnd Function PageReinstallUpdateSelection ${NSD_GetState} $R2 $R1 ${If} $R1 == ${BST_CHECKED} StrCpy $ReinstallPageCheck 1 ${Else} StrCpy $ReinstallPageCheck 2 ${EndIf} FunctionEnd Function PageLeaveReinstall ${NSD_GetState} $R2 $R1 ; $R5 holds whether we are reinstalling the same version or not ; $R5 == "1" -> different versions ; $R5 == "2" -> same version ; ; $R1 holds the radio buttons state. its meaning is dependant on the context StrCmp $R5 "1" 0 +2 ; Existing install is not the same version? StrCmp $R1 "1" reinst_uninstall reinst_done ; $R1 == "1", then user chose to uninstall existing version, otherwise skip uninstalling StrCmp $R1 "1" reinst_done ; Same version? skip uninstalling reinst_uninstall: HideWindow ClearErrors ${If} $R7 == "wix" ReadRegStr $R1 HKLM "$R6" "UninstallString" ExecWait '$R1' $0 ${Else} ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" ReadRegStr $R1 SHCTX "${UNINSTKEY}" "UninstallString" ExecWait '$R1 /P _?=$4' $0 ${EndIf} BringToFront ${IfThen} ${Errors} ${|} StrCpy $0 2 ${|} ; ExecWait failed, set fake exit code ${If} $0 <> 0 ${OrIf} ${FileExists} "$INSTDIR\${MAINBINARYNAME}.exe" ${If} $0 = 1 ; User aborted uninstaller? StrCmp $R5 "2" 0 +2 ; Is the existing install the same version? Quit ; ...yes, already installed, we are done Abort ${EndIf} MessageBox MB_ICONEXCLAMATION "$(unableToUninstall)" Abort ${Else} StrCpy $0 $R1 1 ${IfThen} $0 == '"' ${|} StrCpy $R1 $R1 -1 1 ${|} ; Strip quotes from UninstallString Delete $R1 RMDir $INSTDIR ${EndIf} reinst_done: FunctionEnd ; 5. Choose install directoy page !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive !insertmacro MUI_PAGE_DIRECTORY ; 6. Start menu shortcut page !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive Var AppStartMenuFolder !insertmacro MUI_PAGE_STARTMENU Application $AppStartMenuFolder ; 7. Installation page !insertmacro MUI_PAGE_INSTFILES ; 8. Finish page ; ; Don't auto jump to finish page after installation page, ; because the installation page has useful info that can be used debug any issues with the installer. !define MUI_FINISHPAGE_NOAUTOCLOSE ; Use show readme button in the finish page as a button create a desktop shortcut !define MUI_FINISHPAGE_SHOWREADME !define MUI_FINISHPAGE_SHOWREADME_TEXT "$(createDesktop)" !define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateDesktopShortcut ; Show run app after installation. !define MUI_FINISHPAGE_RUN "$INSTDIR\${MAINBINARYNAME}.exe" !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive !insertmacro MUI_PAGE_FINISH ; Uninstaller Pages ; 1. Confirm uninstall page Var DeleteAppDataCheckbox Var DeleteAppDataCheckboxState !define /ifndef WS_EX_LAYOUTRTL 0x00400000 !define MUI_PAGE_CUSTOMFUNCTION_SHOW un.ConfirmShow Function un.ConfirmShow FindWindow $1 "#32770" "" $HWNDPARENT ; Find inner dialog ${If} $(^RTL) == 1 System::Call 'USER32::CreateWindowEx(i${__NSD_CheckBox_EXSTYLE}|${WS_EX_LAYOUTRTL},t"${__NSD_CheckBox_CLASS}",t "$(deleteAppData)",i${__NSD_CheckBox_STYLE},i 50,i 100,i 400, i 25,i$1,i0,i0,i0)i.s' ${Else} System::Call 'USER32::CreateWindowEx(i${__NSD_CheckBox_EXSTYLE},t"${__NSD_CheckBox_CLASS}",t "$(deleteAppData)",i${__NSD_CheckBox_STYLE},i 0,i 100,i 400, i 25,i$1,i0,i0,i0)i.s' ${EndIf} Pop $DeleteAppDataCheckbox SendMessage $HWNDPARENT ${WM_GETFONT} 0 0 $1 SendMessage $DeleteAppDataCheckbox ${WM_SETFONT} $1 1 FunctionEnd !define MUI_PAGE_CUSTOMFUNCTION_LEAVE un.ConfirmLeave Function un.ConfirmLeave SendMessage $DeleteAppDataCheckbox ${BM_GETCHECK} 0 0 $DeleteAppDataCheckboxState FunctionEnd !insertmacro MUI_UNPAGE_CONFIRM ; 2. Uninstalling Page !insertmacro MUI_UNPAGE_INSTFILES ;Languages {{#each languages}} !insertmacro MUI_LANGUAGE "{{this}}" {{/each}} !insertmacro MUI_RESERVEFILE_LANGDLL {{#each language_files}} !include "{{this}}" {{/each}} !macro SetContext !if "${INSTALLMODE}" == "currentUser" SetShellVarContext current !else if "${INSTALLMODE}" == "perMachine" SetShellVarContext all !endif ${If} ${RunningX64} !if "${ARCH}" == "x64" SetRegView 64 !else if "${ARCH}" == "arm64" SetRegView 64 !else SetRegView 32 !endif ${EndIf} !macroend !define FOLDERID_ProgramData "{62AB5D82-FDC1-4DC3-A9DD-070D1D495D97}" !macro GetProgramDataPath ; 调用SHGetKnownFolderIDList获取PIDL System::Call 'shell32::SHGetKnownFolderIDList(g"${FOLDERID_ProgramData}", i0x1000, i0, *i.r1)i.r0' ${If} $0 = 0 ; 调用SHGetPathFromIDList将PIDL转换为路径 System::Call 'shell32::SHGetPathFromIDList(ir1,t.r0)' StrCpy $ProgramDataPathVar $0 ; 将结果保存到变量 ; DetailPrint "ProgramData Path: $ProgramDataPathVar" ; 释放PIDL内存 System::Call 'ole32::CoTaskMemFree(ir1)' ${Else} DetailPrint "Failed to get ProgramData path, error code: $0" ${EndIf} !macroend Var PassiveMode Function .onInit ${GetOptions} $CMDLINE "/P" $PassiveMode IfErrors +2 0 StrCpy $PassiveMode 1 !if "${DISPLAYLANGUAGESELECTOR}" == "true" !insertmacro MUI_LANGDLL_DISPLAY !endif !insertmacro SetContext ${If} $INSTDIR == "" ; Set default install location !if "${INSTALLMODE}" == "perMachine" ${If} ${RunningX64} !if "${ARCH}" == "x64" StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" !else if "${ARCH}" == "arm64" StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" !else StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" !endif ${Else} StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" ${EndIf} !else if "${INSTALLMODE}" == "currentUser" StrCpy $INSTDIR "$LOCALAPPDATA\${PRODUCTNAME}" !endif Call RestorePreviousInstallLocation ${EndIf} !if "${INSTALLMODE}" == "both" !insertmacro MULTIUSER_INIT !endif FunctionEnd !macro CheckNyanpasuProcess Process ID !if "${INSTALLMODE}" == "currentUser" nsis_tauri_utils::FindProcessCurrentUser "${Process}" !else nsis_tauri_utils::FindProcess "${Process}" !endif Pop $R0 ${If} $R0 = 0 DetailPrint "${Process} is running" IfSilent kill${ID} 0 ${IfThen} $PassiveMode != 1 ${|} MessageBox MB_OKCANCEL "${Process} is running, ok to kill?" IDOK kill${ID} IDCANCEL cancel${ID} ${|} kill${ID}: !if "${INSTALLMODE}" == "currentUser" nsis_tauri_utils::KillProcessCurrentUser "${Process}" !else nsis_tauri_utils::KillProcess "${Process}" !endif Pop $R0 Sleep 500 ${If} $R0 = 0 Goto process_check_done${ID} ${Else} IfSilent silent${ID} ui${ID} silent${ID}: System::Call 'kernel32::AttachConsole(i -1)i.r0' ${If} $0 != 0 System::Call 'kernel32::GetStdHandle(i -11)i.r0' System::call 'kernel32::SetConsoleTextAttribute(i r0, i 0x0004)' ; set red color FileWrite $0 "${Process} is running\n" ${EndIf} Abort ui${ID}: Abort "${Process} is running, failed to kill it" ${EndIf} cancel${ID}: Abort "${Process} is running, aborting installation" ${EndIf} process_check_done${ID}: !macroend !macro CheckAllNyanpasuProcesses !insertmacro CheckNyanpasuProcess "Clash Nyanpasu.exe" "1" !insertmacro CheckNyanpasuProcess "clash-nyanpasu.exe" "2" ; !insertmacro CheckNyanpasuProcess "clash-verge-service.exe" "3" !insertmacro CheckNyanpasuProcess "clash.exe" "4" !insertmacro CheckNyanpasuProcess "clash-rs.exe" "5" !insertmacro CheckNyanpasuProcess "mihomo.exe" "6" !insertmacro CheckNyanpasuProcess "mihomo-alpha.exe" "7" !macroend ; Section CheckProcesses ; !insertmacro CheckAllNyanpasuProcesses ; SectionEnd Section EarlyChecks ; Abort silent installer if downgrades is disabled !if "${ALLOWDOWNGRADES}" == "false" IfSilent 0 silent_downgrades_done ; If downgrading ${If} $R0 == -1 System::Call 'kernel32::AttachConsole(i -1)i.r0' ${If} $0 != 0 System::Call 'kernel32::GetStdHandle(i -11)i.r0' System::call 'kernel32::SetConsoleTextAttribute(i r0, i 0x0004)' ; set red color FileWrite $0 "$(silentDowngrades)" ${EndIf} Abort ${EndIf} silent_downgrades_done: !endif SectionEnd Section WebView2 ; Check if Webview2 is already installed and skip this section ${If} ${RunningX64} ReadRegStr $4 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" ${Else} ReadRegStr $4 HKLM "SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" ${EndIf} ReadRegStr $5 HKCU "SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" StrCmp $4 "" 0 webview2_done StrCmp $5 "" 0 webview2_done ; Webview2 install modes !if "${INSTALLWEBVIEW2MODE}" == "downloadBootstrapper" Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" DetailPrint "$(webview2Downloading)" NSISdl::download "https://go.microsoft.com/fwlink/p/?LinkId=2124703" "$TEMP\MicrosoftEdgeWebview2Setup.exe" Pop $0 ${If} $0 == 0 DetailPrint "$(webview2DownloadSuccess)" ${Else} DetailPrint "$(webview2DownloadError)" Abort "$(webview2AbortError)" ${EndIf} StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" Goto install_webview2 !endif !if "${INSTALLWEBVIEW2MODE}" == "embedBootstrapper" Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" File "/oname=$TEMP\MicrosoftEdgeWebview2Setup.exe" "${WEBVIEW2BOOTSTRAPPERPATH}" DetailPrint "$(installingWebview2)" StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" Goto install_webview2 !endif !if "${INSTALLWEBVIEW2MODE}" == "offlineInstaller" Delete "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" File "/oname=$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" "${WEBVIEW2INSTALLERPATH}" DetailPrint "$(installingWebview2)" StrCpy $6 "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" Goto install_webview2 !endif Goto webview2_done install_webview2: DetailPrint "$(installingWebview2)" ; $6 holds the path to the webview2 installer ExecWait "$6 ${WEBVIEW2INSTALLERARGS} /install" $1 ${If} $1 == 0 DetailPrint "$(webview2InstallSuccess)" ${Else} DetailPrint "$(webview2InstallError)" Abort "$(webview2AbortError)" ${EndIf} webview2_done: SectionEnd ; !macro CheckIfAppIsRunning ; !if "${INSTALLMODE}" == "currentUser" ; nsis_tauri_utils::FindProcessCurrentUser "${MAINBINARYNAME}.exe" ; !else ; nsis_tauri_utils::FindProcess "${MAINBINARYNAME}.exe" ; !endif ; Pop $R0 ; ${If} $R0 = 0 ; IfSilent kill 0 ; ${IfThen} $PassiveMode != 1 ${|} MessageBox MB_OKCANCEL "$(appRunningOkKill)" IDOK kill IDCANCEL cancel ${|} ; kill: ; !if "${INSTALLMODE}" == "currentUser" ; nsis_tauri_utils::KillProcessCurrentUser "${MAINBINARYNAME}.exe" ; !else ; nsis_tauri_utils::KillProcess "${MAINBINARYNAME}.exe" ; !endif ; Pop $R0 ; Sleep 500 ; ${If} $R0 = 0 ; Goto app_check_done ; ${Else} ; IfSilent silent ui ; silent: ; System::Call 'kernel32::AttachConsole(i -1)i.r0' ; ${If} $0 != 0 ; System::Call 'kernel32::GetStdHandle(i -11)i.r0' ; System::call 'kernel32::SetConsoleTextAttribute(i r0, i 0x0004)' ; set red color ; FileWrite $0 "$(appRunning)$\n" ; ${EndIf} ; Abort ; ui: ; Abort "$(failedToKillApp)" ; ${EndIf} ; cancel: ; Abort "$(appRunning)" ; ${EndIf} ; app_check_done: ; !macroend !macro StopCoreByService ; 构建服务可执行文件的完整路径 StrCpy $1 "$ProgramDataPathVar\nyanpasu-service\data\nyanpasu-service.exe" ; 检查文件是否存在 IfFileExists "$1" 0 SkipStopCore ; 文件存在,执行停止核心服务 nsExec::ExecToLog '"$1" rpc stop-core' Pop $0 ; 弹出命令执行的返回值 ${If} $0 == "0" DetailPrint "Core service stopped successfully." ${Else} DetailPrint "Core stop failed with exit code $0" ${EndIf} SkipStopCore: ; 如果文件不存在,打印错误 DetailPrint "Nyanpasu Service is not installed, skipping stop-core" !macroend Section Install !insertmacro GetProgramDataPath !insertmacro StopCoreByService SetOutPath $INSTDIR !insertmacro CheckAllNyanpasuProcesses ; !insertmacro CheckIfAppIsRunning ; Copy main executable File "${MAINBINARYSRCPATH}" ; Copy resources {{#each resources_dirs}} CreateDirectory "$INSTDIR\\{{this}}" {{/each}} {{#each resources}} File /a "/oname={{this.[1]}}" "{{@key}}" {{/each}} ; Copy external binaries {{#each binaries}} File /a "/oname={{this}}" "{{@key}}" {{/each}} ; Create uninstaller WriteUninstaller "$INSTDIR\uninstall.exe" ; Save $INSTDIR in registry for future installations WriteRegStr SHCTX "${MANUPRODUCTKEY}" "" $INSTDIR !if "${INSTALLMODE}" == "both" ; Save install mode to be selected by default for the next installation such as updating ; or when uninstalling WriteRegStr SHCTX "${UNINSTKEY}" $MultiUser.InstallMode 1 !endif ; Registry information for add/remove programs WriteRegStr SHCTX "${UNINSTKEY}" "DisplayName" "${PRODUCTNAME}" WriteRegStr SHCTX "${UNINSTKEY}" "DisplayIcon" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\"" WriteRegStr SHCTX "${UNINSTKEY}" "DisplayVersion" "${VERSION}" WriteRegStr SHCTX "${UNINSTKEY}" "Publisher" "${MANUFACTURER}" WriteRegStr SHCTX "${UNINSTKEY}" "InstallLocation" "$\"$INSTDIR$\"" WriteRegStr SHCTX "${UNINSTKEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" WriteRegDWORD SHCTX "${UNINSTKEY}" "NoModify" "1" WriteRegDWORD SHCTX "${UNINSTKEY}" "NoRepair" "1" WriteRegDWORD SHCTX "${UNINSTKEY}" "EstimatedSize" "${ESTIMATEDSIZE}" ; Create start menu shortcut (GUI) !insertmacro MUI_STARTMENU_WRITE_BEGIN Application Call CreateStartMenuShortcut !insertmacro MUI_STARTMENU_WRITE_END ; Create shortcuts for silent and passive installers, which ; can be disabled by passing `/NS` flag ; GUI installer has buttons for users to control creating them IfSilent check_ns_flag 0 ${IfThen} $PassiveMode == 1 ${|} Goto check_ns_flag ${|} Goto shortcuts_done check_ns_flag: ${GetOptions} $CMDLINE "/NS" $R0 IfErrors 0 shortcuts_done Call CreateDesktopShortcut Call CreateStartMenuShortcut shortcuts_done: ; Auto close this page for passive mode ${IfThen} $PassiveMode == 1 ${|} SetAutoClose true ${|} SectionEnd Function .onInstSuccess ; Check for `/R` flag only in silent and passive installers because ; GUI installer has a toggle for the user to (re)start the app IfSilent check_r_flag 0 ${IfThen} $PassiveMode == 1 ${|} Goto check_r_flag ${|} Goto run_done check_r_flag: ${GetOptions} $CMDLINE "/R" $R0 IfErrors run_done 0 Exec '"$INSTDIR\${MAINBINARYNAME}.exe"' run_done: FunctionEnd Function un.onInit !insertmacro SetContext !if "${INSTALLMODE}" == "both" !insertmacro MULTIUSER_UNINIT !endif !insertmacro MUI_UNGETLANGUAGE FunctionEnd !macro DeleteAppUserModelId !insertmacro ComHlpr_CreateInProcInstance ${CLSID_DestinationList} ${IID_ICustomDestinationList} r1 "" ${If} $1 P<> 0 ${ICustomDestinationList::DeleteList} $1 '("${BUNDLEID}")' ${IUnknown::Release} $1 "" ${EndIf} !insertmacro ComHlpr_CreateInProcInstance ${CLSID_ApplicationDestinations} ${IID_IApplicationDestinations} r1 "" ${If} $1 P<> 0 ${IApplicationDestinations::SetAppID} $1 '("${BUNDLEID}")i.r0' ${If} $0 >= 0 ${IApplicationDestinations::RemoveAllDestinations} $1 '' ${EndIf} ${IUnknown::Release} $1 "" ${EndIf} !macroend ; From https://stackoverflow.com/a/42816728/16993372 !macro UnpinShortcut shortcut !insertmacro ComHlpr_CreateInProcInstance ${CLSID_StartMenuPin} ${IID_IStartMenuPinnedList} r0 "" ${If} $0 P<> 0 System::Call 'SHELL32::SHCreateItemFromParsingName(ws, p0, g "${IID_IShellItem}", *p0r1)' "${shortcut}" ${If} $1 P<> 0 ${IStartMenuPinnedList::RemoveFromList} $0 '(r1)' ${IUnknown::Release} $1 "" ${EndIf} ${IUnknown::Release} $0 "" ${EndIf} !macroend !macro StopAndRemoveServiceDirectory ; 构建服务路径 StrCpy $1 "$ProgramDataPathVar\nyanpasu-service\data\nyanpasu-service.exe" ; 检查服务可执行文件是否存在 IfFileExists "$1" 0 Skip nsExec::ExecToLog '"$1" uninstall' Pop $0 DetailPrint "uninstall service with exit code $0" ; 检查停止服务是否成功(假设0, 100, 102为成功) IntCmp $0 0 RemoveDirectories UninstallServiceFailed 0 IntCmp $0 100 RemoveDirectories UninstallServiceFailed 0 IntCmp $0 102 RemoveDirectories UninstallServiceFailed UninstallServiceFailed UninstallServiceFailed: Abort "Failed to stop the service. Aborting installation." RemoveDirectories: ; 如果服务成功停止,继续检查目录是否存在并删除 StrCpy $2 "$ProgramDataPathVar\nyanpasu-service" IfFileExists "$2\*" 0 Skip RMDir /r "$2" DetailPrint "Removed service directory successfully" Skip: DetailPrint "Service directory does not exist, skipping stop and remove service directory" !macroend !macro RemoveRegs ; cleanup auto start registry keys DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "Clash Nyanpasu" DeleteRegValue HKLM "SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run" "Clash Nyanpasu" ; cleanup custom protocol handler DeleteRegKey HKCU "Software\Classes\clash" DeleteRegKey HKCU "Software\Classes\clash-nyanpasu" DeleteRegKey HKCR "clash" DeleteRegKey HKCR "clash-nyanpasu" !macroend Section Uninstall !insertmacro GetProgramDataPath !insertmacro StopAndRemoveServiceDirectory !insertmacro CheckAllNyanpasuProcesses !insertmacro RemoveRegs ; !insertmacro CheckIfAppIsRunning ; Delete the app directory and its content from disk ; Copy main executable Delete "$INSTDIR\${MAINBINARYNAME}.exe" ; Delete resources {{#each resources}} Delete "$INSTDIR\\{{this.[1]}}" {{/each}} ; Delete external binaries {{#each binaries}} Delete "$INSTDIR\\{{this}}" {{/each}} ; Delete uninstaller Delete "$INSTDIR\uninstall.exe" {{#each resources_ancestors}} RMDir /REBOOTOK "$INSTDIR\\{{this}}" {{/each}} RMDir "$INSTDIR" !insertmacro DeleteAppUserModelId !insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\${MAINBINARYNAME}.lnk" !insertmacro UnpinShortcut "$DESKTOP\${MAINBINARYNAME}.lnk" ; Remove start menu shortcut !insertmacro MUI_STARTMENU_GETFOLDER Application $AppStartMenuFolder Delete "$SMPROGRAMS\$AppStartMenuFolder\${MAINBINARYNAME}.lnk" RMDir "$SMPROGRAMS\$AppStartMenuFolder" ; Remove desktop shortcuts Delete "$DESKTOP\${MAINBINARYNAME}.lnk" ; Remove registry information for add/remove programs !if "${INSTALLMODE}" == "both" DeleteRegKey SHCTX "${UNINSTKEY}" !else if "${INSTALLMODE}" == "perMachine" DeleteRegKey HKLM "${UNINSTKEY}" !else DeleteRegKey HKCU "${UNINSTKEY}" !endif DeleteRegValue HKCU "${MANUPRODUCTKEY}" "Installer Language" ; Delete app data ${If} $DeleteAppDataCheckboxState == 1 SetShellVarContext current RmDir /r "$APPDATA\${BUNDLEID}" RmDir /r "$LOCALAPPDATA\${BUNDLEID}" RmDir /r "$APPDATA\Clash Nyanpasu" RmDir /r "$LOCALAPPDATA\Clash Nyanpasu" ${EndIf} ${GetOptions} $CMDLINE "/P" $R0 IfErrors +2 0 SetAutoClose true SectionEnd Function RestorePreviousInstallLocation ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" StrCmp $4 "" +2 0 StrCpy $INSTDIR $4 FunctionEnd Function SkipIfPassive ${IfThen} $PassiveMode == 1 ${|} Abort ${|} FunctionEnd !macro SetLnkAppUserModelId shortcut !insertmacro ComHlpr_CreateInProcInstance ${CLSID_ShellLink} ${IID_IShellLink} r0 "" ${If} $0 P<> 0 ${IUnknown::QueryInterface} $0 '("${IID_IPersistFile}",.r1)' ${If} $1 P<> 0 ${IPersistFile::Load} $1 '("${shortcut}", ${STGM_READWRITE})' ${IUnknown::QueryInterface} $0 '("${IID_IPropertyStore}",.r2)' ${If} $2 P<> 0 System::Call 'Oleaut32::SysAllocString(w "${BUNDLEID}") i.r3' System::Call '*${SYSSTRUCT_PROPERTYKEY}(${PKEY_AppUserModel_ID})p.r4' System::Call '*${SYSSTRUCT_PROPVARIANT}(${VT_BSTR},,&i4 $3)p.r5' ${IPropertyStore::SetValue} $2 '($4,$5)' System::Call 'Oleaut32::SysFreeString($3)' System::Free $4 System::Free $5 ${IPropertyStore::Commit} $2 "" ${IUnknown::Release} $2 "" ${IPersistFile::Save} $1 '("${shortcut}",1)' ${EndIf} ${IUnknown::Release} $1 "" ${EndIf} ${IUnknown::Release} $0 "" ${EndIf} !macroend Function CreateDesktopShortcut CreateShortcut "$DESKTOP\${MAINBINARYNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" !insertmacro SetLnkAppUserModelId "$DESKTOP\${MAINBINARYNAME}.lnk" FunctionEnd Function CreateStartMenuShortcut CreateDirectory "$SMPROGRAMS\$AppStartMenuFolder" CreateShortcut "$SMPROGRAMS\$AppStartMenuFolder\${MAINBINARYNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\$AppStartMenuFolder\${MAINBINARYNAME}.lnk" FunctionEnd ================================================ FILE: backend/tauri/templates/installer.wxs ================================================ {{#if allow_downgrades}} {{else}} {{/if}} Installed AND NOT UPGRADINGPRODUCTCODE {{#if banner_path}} {{/if}} {{#if dialog_image_path}} {{/if}} {{#if license}} {{/if}} WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed {{#unless license}} 1 1 {{/unless}} {{#each binaries as |bin| ~}} {{/each~}} {{#if enable_elevated_update_task}} {{/if}} {{resources}} {{#each merge_modules as |msm| ~}} {{/each~}} {{#each resource_file_ids as |resource_file_id| ~}} {{/each~}} {{#if enable_elevated_update_task}} {{/if}} {{#each binaries as |bin| ~}} {{/each~}} {{#each component_group_refs as |id| ~}} {{/each~}} {{#each component_refs as |id| ~}} {{/each~}} {{#each feature_group_refs as |id| ~}} {{/each~}} {{#each feature_refs as |id| ~}} {{/each~}} {{#each merge_refs as |id| ~}} {{/each~}} {{#if install_webview}} {{#if download_bootstrapper}} {{/if}} {{#if webview2_bootstrapper_path}} {{/if}} {{#if webview2_installer_path}} {{/if}} {{/if}} {{#if enable_elevated_update_task}} NOT(REMOVE) (REMOVE = "ALL") AND NOT UPGRADINGPRODUCTCODE {{/if}} ================================================ FILE: backend/tauri/tests/sample_clash_config.yaml ================================================ # port: 7890 # HTTP(S) 代理服务器端口 # socks-port: 7891 # SOCKS5 代理端口 mixed-port: 10801 # HTTP(S) 和 SOCKS 代理混合端口 # redir-port: 7892 # 透明代理端口,用于 Linux 和 MacOS # Transparent proxy server port for Linux (TProxy TCP and TProxy UDP) # tproxy-port: 7893 allow-lan: true # 允许局域网连接 bind-address: '*' # 绑定 IP 地址,仅作用于 allow-lan 为 true,'*'表示所有地址 authentication: # http,socks 入口的验证用户名,密码 - 'username:password' skip-auth-prefixes: # 设置跳过验证的 IP 段 - 127.0.0.1/8 - ::1/128 lan-allowed-ips: # 允许连接的 IP 地址段,仅作用于 allow-lan 为 true, 默认值为 0.0.0.0/0 和::/0 - 0.0.0.0/0 - ::/0 lan-disallowed-ips: # 禁止连接的 IP 地址段,黑名单优先级高于白名单,默认值为空 - 192.168.0.3/32 # find-process-mode has 3 values:always, strict, off # - always, 开启,强制匹配所有进程 # - strict, 默认,由 mihomo 判断是否开启 # - off, 不匹配进程,推荐在路由器上使用此模式 find-process-mode: strict mode: rule #自定义 geodata url geox-url: geoip: 'https://fastly.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.dat' geosite: 'https://fastly.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geosite.dat' mmdb: 'https://fastly.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.metadb' geo-auto-update: false # 是否自动更新 geodata geo-update-interval: 24 # 更新间隔,单位:小时 # Matcher implementation used by GeoSite, available implementations: # - succinct (default, same as rule-set) # - mph (from V2Ray, also `hybrid` in Xray) # geosite-matcher: succinct log-level: debug # 日志等级 silent/error/warning/info/debug ipv6: true # 开启 IPv6 总开关,关闭阻断所有 IPv6 链接和屏蔽 DNS 请求 AAAA 记录 tls: certificate: string # 证书 PEM 格式,或者 证书的路径 private-key: string # 证书对应的私钥 PEM 格式,或者私钥路径 # 如果填写则开启ech(可由 mihomo generate ech-keypair <明文域名> 生成) # ech-key: | # -----BEGIN ECH KEYS----- # ACATwY30o/RKgD6hgeQxwrSiApLaCgU+HKh7B6SUrAHaDwBD/g0APwAAIAAgHjzK # madSJjYQIf9o1N5GXjkW4DEEeb17qMxHdwMdNnwADAABAAEAAQACAAEAAwAIdGVz # dC5jb20AAA== # -----END ECH KEYS----- custom-certifactes: - | -----BEGIN CERTIFICATE----- format/pem... -----END CERTIFICATE----- external-controller: 0.0.0.0:9093 # RESTful API 监听地址 external-controller-tls: 0.0.0.0:9443 # RESTful API HTTPS 监听地址,需要配置 tls 部分配置文件 # secret: "123456" # `Authorization:Bearer ${secret}` # RESTful API CORS标头配置 external-controller-cors: allow-origins: - '*' allow-private-network: true # RESTful API Unix socket 监听地址( windows版本大于17063也可以使用,即大于等于1803/RS4版本即可使用 ) # !!!注意: 从Unix socket访问api接口不会验证secret, 如果开启请自行保证安全问题 !!! # 测试方法: curl -v --unix-socket "mihomo.sock" http://localhost/ external-controller-unix: mihomo.sock # RESTful API Windows namedpipe 监听地址 # !!!注意: 从Windows namedpipe访问api接口不会验证secret, 如果开启请自行保证安全问题 !!! external-controller-pipe: \\.\pipe\mihomo # tcp-concurrent: true # TCP 并发连接所有 IP, 将使用最快握手的 TCP # 配置 WEB UI 目录,使用 http://{{external-controller}}/ui 访问 external-ui: /path/to/ui/folder/ external-ui-name: xd # 目前支持下载zip,tgz格式的压缩包 external-ui-url: 'https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip' # 在RESTful API端口上开启DOH服务器 # !!!该URL不会验证secret, 如果开启请自行保证安全问题 !!! external-doh-server: /dns-query # interface-name: en0 # 设置出口网卡 # 全局 TLS 指纹,优先低于 proxy 内的 client-fingerprint # 可选: "chrome","firefox","safari","ios","random","none" options. # Utls is currently support TLS transport in TCP/grpc/WS/HTTP for VLESS/Vmess and trojan. global-client-fingerprint: chrome # TCP keep alive interval # disable-keep-alive: false #目前在android端强制为true # keep-alive-idle: 15 # keep-alive-interval: 15 # routing-mark:6666 # 配置 fwmark 仅用于 Linux experimental: # Disable quic-go GSO support. This may result in reduced performance on Linux. # This is not recommended for most users. # Only users encountering issues with quic-go's internal implementation should enable this, # and they should disable it as soon as the issue is resolved. # This field will be removed when quic-go fixes all their issues in GSO. # This equivalent to the environment variable QUIC_GO_DISABLE_GSO=1. #quic-go-disable-gso: true # 类似于 /etc/hosts, 仅支持配置单个 IP hosts: # '*.mihomo.dev': 127.0.0.1 # '.dev': 127.0.0.1 # 'alpha.mihomo.dev': '::1' # test.com: [1.1.1.1, 2.2.2.2] # home.lan: lan # lan 为特别字段,将加入本地所有网卡的地址 # baidu.com: google.com # 只允许配置一个别名 profile: # 存储 select 选择记录 store-selected: false # 持久化 fake-ip store-fake-ip: true # Tun 配置 tun: enable: false stack: system # gvisor/mixed dns-hijack: - 0.0.0.0:53 # 需要劫持的 DNS # auto-detect-interface: true # 自动识别出口网卡 # auto-route: true # 配置路由表 # mtu: 9000 # 最大传输单元 # gso: false # 启用通用分段卸载,仅支持 Linux # gso-max-size: 65536 # 通用分段卸载包的最大大小 auto-redirect: false # 自动配置 iptables 以重定向 TCP 连接。仅支持 Linux。带有 auto-redirect 的 auto-route 现在可以在路由器上按预期工作,无需干预。 # strict-route: true # 将所有连接路由到 tun 来防止泄漏,但你的设备将无法其他设备被访问 route-address-set: # 将指定规则集中的目标 IP CIDR 规则添加到防火墙, 不匹配的流量将绕过路由, 仅支持 Linux,且需要 nftables,`auto-route` 和 `auto-redirect` 已启用。 - ruleset-1 - ruleset-2 route-exclude-address-set: # 将指定规则集中的目标 IP CIDR 规则添加到防火墙, 匹配的流量将绕过路由, 仅支持 Linux,且需要 nftables,`auto-route` 和 `auto-redirect` 已启用。 - ruleset-3 - ruleset-4 route-address: # 启用 auto-route 时使用自定义路由而不是默认路由 - 0.0.0.0/1 - 128.0.0.0/1 - '::/1' - '8000::/1' # inet4-route-address: # 启用 auto-route 时使用自定义路由而不是默认路由(旧写法) # - 0.0.0.0/1 # - 128.0.0.0/1 # inet6-route-address: # 启用 auto-route 时使用自定义路由而不是默认路由(旧写法) # - "::/1" # - "8000::/1" # endpoint-independent-nat: false # 启用独立于端点的 NAT # include-interface: # 限制被路由的接口。默认不限制,与 `exclude-interface` 冲突 # - "lan0" # exclude-interface: # 排除路由的接口,与 `include-interface` 冲突 # - "lan1" # include-uid: # UID 规则仅在 Linux 下被支持,并且需要 auto-route # - 0 # include-uid-range: # 限制被路由的的用户范围 # - 1000:9999 # exclude-uid: # 排除路由的的用户 #- 1000 # exclude-uid-range: # 排除路由的的用户范围 # - 1000:9999 # Android 用户和应用规则仅在 Android 下被支持 # 并且需要 auto-route # include-android-user: # 限制被路由的 Android 用户 # - 0 # - 10 # include-package: # 限制被路由的 Android 应用包名 # - com.android.chrome # exclude-package: # 排除被路由的 Android 应用包名 # - com.android.captiveportallogin # 嗅探域名 可选配置 sniffer: enable: false ## 对 redir-host 类型识别的流量进行强制嗅探 ## 如:Tun、Redir 和 TProxy 并 DNS 为 redir-host 皆属于 # force-dns-mapping: false ## 对所有未获取到域名的流量进行强制嗅探 # parse-pure-ip: false # 是否使用嗅探结果作为实际访问,默认 true # 全局配置,优先级低于 sniffer.sniff 实际配置 override-destination: false sniff: # TLS 和 QUIC 默认如果不配置 ports 默认嗅探 443 QUIC: # ports: [ 443 ] TLS: # ports: [443, 8443] # 默认嗅探 80 HTTP: # 需要嗅探的端口 ports: [80, 8080-8880] # 可覆盖 sniffer.override-destination override-destination: true force-domain: - +.v2ex.com # skip-src-address: # 对于来源ip跳过嗅探 # - 192.168.0.3/32 # skip-dst-address: # 对于目标ip跳过嗅探 # - 192.168.0.3/32 ## 对嗅探结果进行跳过 # skip-domain: # - Mijia Cloud # 需要嗅探协议 # 已废弃,若 sniffer.sniff 配置则此项无效 sniffing: - tls - http # 强制对此域名进行嗅探 # 仅对白名单中的端口进行嗅探,默认为 443,80 # 已废弃,若 sniffer.sniff 配置则此项无效 port-whitelist: - '80' - '443' # - 8000-9999 tunnels: # one line config - tcp/udp,127.0.0.1:6553,114.114.114.114:53,proxy - tcp,127.0.0.1:6666,rds.mysql.com:3306,vpn # full yaml config - network: [tcp, udp] address: 127.0.0.1:7777 target: target.com proxy: proxy # DNS 配置 dns: cache-algorithm: arc enable: false # 关闭将使用系统 DNS prefer-h3: false # 是否开启 DoH 支持 HTTP/3,将并发尝试 listen: 0.0.0.0:53 # 开启 DNS 服务器监听 # ipv6: false # false 将返回 AAAA 的空结果 # ipv6-timeout: 300 # 单位:ms,内部双栈并发时,向上游查询 AAAA 时,等待 AAAA 的时间,默认 100ms # 用于解析 nameserver,fallback 以及其他 DNS 服务器配置的,DNS 服务域名 # 只能使用纯 IP 地址,可使用加密 DNS default-nameserver: - 114.114.114.114 - 8.8.8.8 - tls://1.12.12.12:853 - tls://223.5.5.5:853 - system # append DNS server from system configuration. If not found, it would print an error log and skip. enhanced-mode: fake-ip # or redir-host fake-ip-range: 198.18.0.1/16 # fake-ip 池设置 # 配置不使用 fake-ip 的域名 fake-ip-filter: - '*.lan' - localhost.ptlogin2.qq.com # fakeip-filter 为 rule-providers 中的名为 fakeip-filter 规则订阅, # 且 behavior 必须为 domain/classical,当为 classical 时仅会生效域名类规则 - rule-set:fakeip-filter # fakeip-filter 为 geosite 中名为 fakeip-filter 的分类(需要自行保证该分类存在) - geosite:fakeip-filter # 配置fake-ip-filter的匹配模式,默认为blacklist,即如果匹配成功不返回fake-ip # 可设置为whitelist,即只有匹配成功才返回fake-ip fake-ip-filter-mode: blacklist # use-hosts: true # 查询 hosts # 配置后面的nameserver、fallback和nameserver-policy向dns服务器的连接过程是否遵守遵守rules规则 # 如果为false(默认值)则这三部分的dns服务器在未特别指定的情况下会直连 # 如果为true,将会按照rules的规则匹配链接方式(走代理或直连),如果有特别指定则任然以指定值为准 # 仅当proxy-server-nameserver非空时可以开启此选项, 强烈不建议和prefer-h3一起使用 # 此外,这三者配置中的dns服务器如果出现域名会采用default-nameserver配置项解析,也请确保正确配置default-nameserver respect-rules: false # DNS 主要域名配置 # 支持 UDP,TCP,DoT,DoH,DoQ # 这部分为主要 DNS 配置,影响所有直连,确保使用对大陆解析精准的 DNS nameserver: - 114.114.114.114 # default value - 8.8.8.8 # default value - tls://223.5.5.5:853 # DNS over TLS - https://doh.pub/dns-query # DNS over HTTPS - https://dns.alidns.com/dns-query#h3=true # 强制 HTTP/3,与 perfer-h3 无关,强制开启 DoH 的 HTTP/3 支持,若不支持将无法使用 - https://mozilla.cloudflare-dns.com/dns-query#DNS&h3=true # 指定策略组和使用 HTTP/3 - dhcp://en0 # dns from dhcp - quic://dns.adguard.com:784 # DNS over QUIC # - '8.8.8.8#RULES' # 效果同respect-rules,但仅对该服务器生效 # - '8.8.8.8#en0' # 兼容指定 DNS 出口网卡 # 当配置 fallback 时,会查询 nameserver 中返回的 IP 是否为 CN,非必要配置 # 当不是 CN,则使用 fallback 中的 DNS 查询结果 # 确保配置 fallback 时能够正常查询 # fallback: # - tcp://1.1.1.1 # - 'tcp://1.1.1.1#ProxyGroupName' # 指定 DNS 过代理查询,ProxyGroupName 为策略组名或节点名,过代理配置优先于配置出口网卡,当找不到策略组或节点名则设置为出口网卡 # 专用于节点域名解析的 DNS 服务器,非必要配置项,如果不填则遵循nameserver-policy、nameserver和fallback的配置 # proxy-server-nameserver: # - https://dns.google/dns-query # - tls://one.one.one.one # 专用于direct出口域名解析的 DNS 服务器,非必要配置项,如果不填则遵循nameserver-policy、nameserver和fallback的配置 # direct-nameserver: # - system:// # direct-nameserver-follow-policy: false # 是否遵循nameserver-policy,默认为不遵守,仅当direct-nameserver不为空时生效 # 配置 fallback 使用条件 # fallback-filter: # geoip: true # 配置是否使用 geoip # geoip-code: CN # 当 nameserver 域名的 IP 查询 geoip 库为 CN 时,不使用 fallback 中的 DNS 查询结果 # 配置强制 fallback,优先于 IP 判断,具体分类自行查看 geosite 库 # geosite: # - gfw # 如果不匹配 ipcidr 则使用 nameservers 中的结果 # ipcidr: # - 240.0.0.0/4 # domain: # - '+.google.com' # - '+.facebook.com' # - '+.youtube.com' # 配置查询域名使用的 DNS 服务器 nameserver-policy: # 'www.baidu.com': '114.114.114.114' # '+.internal.crop.com': '10.0.0.1' 'geosite:cn,private,apple': - https://doh.pub/dns-query - https://dns.alidns.com/dns-query 'geosite:category-ads-all': rcode://success 'www.baidu.com,+.google.cn': [223.5.5.5, https://dns.alidns.com/dns-query] ## global,dns 为 rule-providers 中的名为 global 和 dns 规则订阅, ## 且 behavior 必须为 domain/classical,当为 classical 时仅会生效域名类规则 # "rule-set:global,dns": 8.8.8.8 proxies: # socks5 - name: 'socks' type: socks5 server: server port: 443 # username: username # password: password # tls: true # fingerprint: xxxx # skip-cert-verify: true # udp: true # ip-version: ipv6 # http - name: 'http' type: http server: server port: 443 # username: username # password: password # tls: true # https # skip-cert-verify: true # sni: custom.com # fingerprint: xxxx # 同 experimental.fingerprints 使用 sha256 指纹,配置协议独立的指纹,将忽略 experimental.fingerprints # ip-version: dual # Snell # Beware that there's currently no UDP support yet - name: 'snell' type: snell server: server port: 44046 psk: yourpsk # version: 2 # obfs-opts: # mode: http # or tls # host: bing.com # Shadowsocks # cipher支持: # aes-128-gcm aes-192-gcm aes-256-gcm # aes-128-cfb aes-192-cfb aes-256-cfb # aes-128-ctr aes-192-ctr aes-256-ctr # rc4-md5 chacha20-ietf xchacha20 # chacha20-ietf-poly1305 xchacha20-ietf-poly1305 # 2022-blake3-aes-128-gcm 2022-blake3-aes-256-gcm 2022-blake3-chacha20-poly1305 - name: 'ss1' type: ss server: server port: 443 cipher: chacha20-ietf-poly1305 password: 'password' # udp: true # udp-over-tcp: false # ip-version: ipv4 # 设置节点使用 IP 版本,可选:dual,ipv4,ipv6,ipv4-prefer,ipv6-prefer。默认使用 dual # ipv4:仅使用 IPv4 ipv6:仅使用 IPv6 # ipv4-prefer:优先使用 IPv4 对于 TCP 会进行双栈解析,并发链接但是优先使用 IPv4 链接, # UDP 则为双栈解析,获取结果中的第一个 IPv4 # ipv6-prefer 同 ipv4-prefer # 现有协议都支持此参数,TCP 效果仅在开启 tcp-concurrent 生效 smux: enabled: false protocol: smux # smux/yamux/h2mux # max-connections: 4 # Maximum connections. Conflict with max-streams. # min-streams: 4 # Minimum multiplexed streams in a connection before opening a new connection. Conflict with max-streams. # max-streams: 0 # Maximum multiplexed streams in a connection before opening a new connection. Conflict with max-connections and min-streams. # padding: false # Enable padding. Requires sing-box server version 1.3-beta9 or later. # statistic: false # 控制是否将底层连接显示在面板中,方便打断底层连接 # only-tcp: false # 如果设置为 true, smux 的设置将不会对 udp 生效,udp 连接会直接走底层协议 - name: 'ss2' type: ss server: server port: 443 cipher: chacha20-ietf-poly1305 password: 'password' plugin: obfs plugin-opts: mode: tls # or http # host: bing.com - name: 'ss3' type: ss server: server port: 443 cipher: chacha20-ietf-poly1305 password: 'password' plugin: v2ray-plugin plugin-opts: mode: websocket # no QUIC now # tls: true # wss # 可使用 openssl x509 -noout -fingerprint -sha256 -inform pem -in yourcert.pem 获取 # 配置指纹将实现 SSL Pining 效果 # fingerprint: xxxx # ech-opts: # enable: true # 必须手动开启 # # 如果config为空则通过dns解析,不为空则通过该值指定,格式为经过base64编码的ech参数(dig +short TYPE65 tls-ech.dev) # config: AEn+DQBFKwAgACABWIHUGj4u+PIggYXcR5JF0gYk3dCRioBW8uJq9H4mKAAIAAEAAQABAANAEnB1YmxpYy50bHMtZWNoLmRldgAA # skip-cert-verify: true # host: bing.com # path: "/" # mux: true # headers: # custom: value # v2ray-http-upgrade: false # v2ray-http-upgrade-fast-open: false - name: 'ss4-shadow-tls' type: ss server: server port: 443 cipher: chacha20-ietf-poly1305 password: 'password' plugin: shadow-tls client-fingerprint: chrome plugin-opts: host: 'cloud.tencent.com' password: 'shadow_tls_password' version: 2 # support 1/2/3 # alpn: ["h2","http/1.1"] - name: 'ss5' type: ss server: server port: 443 cipher: chacha20-ietf-poly1305 password: 'password' plugin: gost-plugin plugin-opts: mode: websocket # tls: true # wss # 可使用 openssl x509 -noout -fingerprint -sha256 -inform pem -in yourcert.pem 获取 # 配置指纹将实现 SSL Pining 效果 # fingerprint: xxxx # skip-cert-verify: true # host: bing.com # path: "/" # mux: true # headers: # custom: value - name: 'ss-restls-tls13' type: ss server: [YOUR_SERVER_IP] port: 443 cipher: chacha20-ietf-poly1305 password: [YOUR_SS_PASSWORD] client-fingerprint: chrome # One of: chrome, ios, firefox or safari # 可以是 chrome, ios, firefox, safari 中的一个 plugin: restls plugin-opts: host: 'www.microsoft.com' # Must be a TLS 1.3 server # 应当是一个 TLS 1.3 服务器 password: [YOUR_RESTLS_PASSWORD] version-hint: 'tls13' # Control your post-handshake traffic through restls-script # Hide proxy behaviors like "tls in tls". # see https://github.com/3andne/restls/blob/main/Restls-Script:%20Hide%20Your%20Proxy%20Traffic%20Behavior.md # 用 restls 剧本来控制握手后的行为,隐藏"tls in tls"等特征 # 详情:https://github.com/3andne/restls/blob/main/Restls-Script:%20%E9%9A%90%E8%97%8F%E4%BD%A0%E7%9A%84%E4%BB%A3%E7%90%86%E8%A1%8C%E4%B8%BA.md restls-script: '300?100<1,400~100,350~100,600~100,300~200,300~100' - name: 'ss-restls-tls12' type: ss server: [YOUR_SERVER_IP] port: 443 cipher: chacha20-ietf-poly1305 password: [YOUR_SS_PASSWORD] client-fingerprint: chrome # One of: chrome, ios, firefox or safari # 可以是 chrome, ios, firefox, safari 中的一个 plugin: restls plugin-opts: host: 'vscode.dev' # Must be a TLS 1.2 server # 应当是一个 TLS 1.2 服务器 password: [YOUR_RESTLS_PASSWORD] version-hint: 'tls12' restls-script: '1000?100<1,500~100,350~100,600~100,400~200' # vmess # cipher 支持 auto/aes-128-gcm/chacha20-poly1305/none - name: 'vmess' type: vmess server: server port: 443 uuid: uuid alterId: 32 cipher: auto # udp: true # tls: true # fingerprint: xxxx # client-fingerprint: chrome # Available: "chrome","firefox","safari","ios","random", currently only support TLS transport in TCP/GRPC/WS/HTTP for VLESS/Vmess and trojan. # skip-cert-verify: true # servername: example.com # priority over wss host # network: ws # ech-opts: # enable: true # 必须手动开启 # # 如果config为空则通过dns解析,不为空则通过该值指定,格式为经过base64编码的ech参数(dig +short TYPE65 tls-ech.dev) # config: AEn+DQBFKwAgACABWIHUGj4u+PIggYXcR5JF0gYk3dCRioBW8uJq9H4mKAAIAAEAAQABAANAEnB1YmxpYy50bHMtZWNoLmRldgAA # ws-opts: # path: /path # headers: # Host: v2ray.com # max-early-data: 2048 # early-data-header-name: Sec-WebSocket-Protocol # v2ray-http-upgrade: false # v2ray-http-upgrade-fast-open: false - name: 'vmess-h2' type: vmess server: server port: 443 uuid: uuid alterId: 32 cipher: auto network: h2 tls: true # fingerprint: xxxx h2-opts: host: - http.example.com - http-alt.example.com path: / - name: 'vmess-http' type: vmess server: server port: 443 uuid: uuid alterId: 32 cipher: auto # udp: true # network: http # http-opts: # method: "GET" # path: # - '/' # - '/video' # headers: # Connection: # - keep-alive # ip-version: ipv4 # 设置使用 IP 类型偏好,可选:ipv4,ipv6,dual,默认值:dual - name: vmess-grpc server: server port: 443 type: vmess uuid: uuid alterId: 32 cipher: auto network: grpc tls: true # fingerprint: xxxx servername: example.com # skip-cert-verify: true grpc-opts: grpc-service-name: 'example' # ip-version: ipv4 # vless - name: 'vless-tcp' type: vless server: server port: 443 uuid: uuid network: tcp servername: example.com # AKA SNI # flow: xtls-rprx-direct # xtls-rprx-origin # enable XTLS # skip-cert-verify: true # fingerprint: xxxx # client-fingerprint: random # Available: "chrome","firefox","safari","random","none" # ech-opts: # enable: true # 必须手动开启 # # 如果config为空则通过dns解析,不为空则通过该值指定,格式为经过base64编码的ech参数(dig +short TYPE65 tls-ech.dev) # config: AEn+DQBFKwAgACABWIHUGj4u+PIggYXcR5JF0gYk3dCRioBW8uJq9H4mKAAIAAEAAQABAANAEnB1YmxpYy50bHMtZWNoLmRldgAA - name: 'vless-vision' type: vless server: server port: 443 uuid: uuid network: tcp tls: true udp: true flow: xtls-rprx-vision client-fingerprint: chrome # fingerprint: xxxx # skip-cert-verify: true - name: 'vless-encryption' type: vless server: server port: 443 uuid: uuid network: tcp # ------------------------- # vless encryption客户端配置: # (native/xorpub 的 XTLS 可以 Splice。只使用 1-RTT 模式 / 若服务端发的 ticket 中秒数不为零则 0-RTT 复用) # / 是只能选一个,后面 base64 至少一个,无限串联,使用 mihomo generate vless-x25519 和 mihomo generate vless-mlkem768 生成,替换值时需去掉括号 # ------------------------- encryption: 'mlkem768x25519plus.native/xorpub/random.1rtt/0rtt.(X25519 Password).(ML-KEM-768 Client)...' tls: false #可以不开启tls udp: true - name: 'vless-reality-vision' type: vless server: server port: 443 uuid: uuid network: tcp tls: true udp: true flow: xtls-rprx-vision servername: www.microsoft.com # REALITY servername reality-opts: public-key: xxx short-id: xxx # optional support-x25519mlkem768: false # 如果服务端支持可手动设置为true client-fingerprint: chrome # cannot be empty - name: 'vless-reality-grpc' type: vless server: server port: 443 uuid: uuid network: grpc tls: true udp: true flow: # skip-cert-verify: true client-fingerprint: chrome servername: testingcf.jsdelivr.net grpc-opts: grpc-service-name: 'grpc' reality-opts: public-key: CrrQSjAG_YkHLwvM2M-7XkKJilgL5upBKCp0od0tLhE short-id: 10f897e26c4b9478 support-x25519mlkem768: false # 如果服务端支持可手动设置为true - name: 'vless-ws' type: vless server: server port: 443 uuid: uuid udp: true tls: true network: ws # client-fingerprint: random # Available: "chrome","firefox","safari","random","none" servername: example.com # priority over wss host # skip-cert-verify: true # fingerprint: xxxx ws-opts: path: '/' headers: Host: example.com # v2ray-http-upgrade: false # v2ray-http-upgrade-fast-open: false # Trojan - name: 'trojan' type: trojan server: server port: 443 password: yourpsk # client-fingerprint: random # Available: "chrome","firefox","safari","random","none" # fingerprint: xxxx # udp: true # sni: example.com # aka server name # alpn: # - h2 # - http/1.1 # skip-cert-verify: true # ss-opts: # like trojan-go's `shadowsocks` config # enabled: false # method: aes-128-gcm # aes-128-gcm/aes-256-gcm/chacha20-ietf-poly1305 # password: "example" # ech-opts: # enable: true # 必须手动开启 # # 如果config为空则通过dns解析,不为空则通过该值指定,格式为经过base64编码的ech参数(dig +short TYPE65 tls-ech.dev) # config: AEn+DQBFKwAgACABWIHUGj4u+PIggYXcR5JF0gYk3dCRioBW8uJq9H4mKAAIAAEAAQABAANAEnB1YmxpYy50bHMtZWNoLmRldgAA - name: trojan-grpc server: server port: 443 type: trojan password: 'example' network: grpc sni: example.com # skip-cert-verify: true # fingerprint: xxxx udp: true grpc-opts: grpc-service-name: 'example' - name: trojan-ws server: server port: 443 type: trojan password: 'example' network: ws sni: example.com # skip-cert-verify: true # fingerprint: xxxx udp: true # ws-opts: # path: /path # headers: # Host: example.com # v2ray-http-upgrade: false # v2ray-http-upgrade-fast-open: false - name: 'trojan-xtls' type: trojan server: server port: 443 password: yourpsk flow: 'xtls-rprx-direct' # xtls-rprx-origin xtls-rprx-direct flow-show: true # udp: true # sni: example.com # aka server name # skip-cert-verify: true # fingerprint: xxxx #hysteria - name: 'hysteria' type: hysteria server: server.com port: 443 # ports: 1000,2000-3000,5000 # port 不可省略 auth-str: yourpassword # obfs: obfs_str # alpn: # - h3 protocol: udp # 支持 udp/wechat-video/faketcp up: '30 Mbps' # 若不写单位,默认为 Mbps down: '200 Mbps' # 若不写单位,默认为 Mbps # sni: server.com # ech-opts: # enable: true # 必须手动开启 # # 如果config为空则通过dns解析,不为空则通过该值指定,格式为经过base64编码的ech参数(dig +short TYPE65 tls-ech.dev) # config: AEn+DQBFKwAgACABWIHUGj4u+PIggYXcR5JF0gYk3dCRioBW8uJq9H4mKAAIAAEAAQABAANAEnB1YmxpYy50bHMtZWNoLmRldgAA # skip-cert-verify: false # recv-window-conn: 12582912 # recv-window: 52428800 # ca: "./my.ca" # ca-str: "xyz" # disable-mtu-discovery: false # fingerprint: xxxx # fast-open: true # 支持 TCP 快速打开,默认为 false #hysteria2 - name: 'hysteria2' type: hysteria2 server: server.com port: 443 # ports: 1000,2000-3000,5000 # port 不可省略 # hop-interval: 15 # up 和 down 均不写或为 0 则使用 BBR 流控 # up: "30 Mbps" # 若不写单位,默认为 Mbps # down: "200 Mbps" # 若不写单位,默认为 Mbps password: yourpassword # obfs: salamander # 默认为空,如果填写则开启 obfs,目前仅支持 salamander # obfs-password: yourpassword # sni: server.com # ech-opts: # enable: true # 必须手动开启 # # 如果config为空则通过dns解析,不为空则通过该值指定,格式为经过base64编码的ech参数(dig +short TYPE65 tls-ech.dev) # config: AEn+DQBFKwAgACABWIHUGj4u+PIggYXcR5JF0gYk3dCRioBW8uJq9H4mKAAIAAEAAQABAANAEnB1YmxpYy50bHMtZWNoLmRldgAA # skip-cert-verify: false # fingerprint: xxxx # alpn: # - h3 # ca: "./my.ca" # ca-str: "xyz" ###quic-go特殊配置项,不要随意修改除非你知道你在干什么### # initial-stream-receive-window: 8388608 # max-stream-receive-window: 8388608 # initial-connection-receive-window: 20971520 # max-connection-receive-window: 20971520 # wireguard - name: 'wg' type: wireguard server: 162.159.192.1 port: 2480 ip: 172.16.0.2 ipv6: fd01:5ca1:ab1e:80fa:ab85:6eea:213f:f4a5 public-key: Cr8hWlKvtDt7nrvf+f0brNQQzabAqrjfBvas9pmowjo= # pre-shared-key: 31aIhAPwktDGpH4JDhA8GNvjFXEf/a6+UaQRyOAiyfM= private-key: eCtXsJZ27+4PbhDkHnB923tkUn2Gj59wZw5wFA75MnU= udp: true reserved: 'U4An' # 数组格式也是合法的 # reserved: [209,98,59] # 一个出站代理的标识。当值不为空时,将使用指定的 proxy 发出连接 # dialer-proxy: "ss1" # remote-dns-resolve: true # 强制 dns 远程解析,默认值为 false # dns: [ 1.1.1.1, 8.8.8.8 ] # 仅在 remote-dns-resolve 为 true 时生效 # refresh-server-ip-interval: 60 # 重新解析server ip的间隔,单位为秒,默认值为0即仅第一次链接时解析server域名,仅应在server域名对应的IP会发生变化时启用该选项(如家宽ddns) # 如果 peers 不为空,该段落中的 allowed-ips 不可为空;前面段落的 server,port,public-key,pre-shared-key 均会被忽略,但 private-key 会被保留且只能在顶层指定 # peers: # - server: 162.159.192.1 # port: 2480 # public-key: Cr8hWlKvtDt7nrvf+f0brNQQzabAqrjfBvas9pmowjo= # # pre-shared-key: 31aIhAPwktDGpH4JDhA8GNvjFXEf/a6+UaQRyOAiyfM= # allowed-ips: ['0.0.0.0/0'] # reserved: [209,98,59] # 如果存在则开启AmneziaWG功能 # amnezia-wg-option: # jc: 5 # jmin: 500 # jmax: 501 # s1: 30 # s2: 40 # h1: 123456 # h2: 67543 # h4: 32345 # h3: 123123 # # AmneziaWG v1.5 # i1: # i2: # i3: "" # i4: "" # i5: "" # j1: # j2: # j3: # itime: 60 # tuic - name: tuic server: www.example.com port: 10443 type: tuic # tuicV4 必须填写 token(不可同时填写 uuid 和 password) token: TOKEN # tuicV5 必须填写 uuid 和 password(不可同时填写 token) uuid: 00000000-0000-0000-0000-000000000001 password: PASSWORD_1 # ip: 127.0.0.1 # for overwriting the DNS lookup result of the server address set in option 'server' # heartbeat-interval: 10000 # alpn: [h3] disable-sni: true reduce-rtt: true request-timeout: 8000 udp-relay-mode: native # Available: "native", "quic". Default: "native" # congestion-controller: bbr # Available: "cubic", "new_reno", "bbr". Default: "cubic" # cwnd: 10 # default: 32 # max-udp-relay-packet-size: 1500 # fast-open: true # skip-cert-verify: true # max-open-streams: 20 # default 100, too many open streams may hurt performance # sni: example.com # ech-opts: # enable: true # 必须手动开启 # # 如果config为空则通过dns解析,不为空则通过该值指定,格式为经过base64编码的ech参数(dig +short TYPE65 tls-ech.dev) # config: AEn+DQBFKwAgACABWIHUGj4u+PIggYXcR5JF0gYk3dCRioBW8uJq9H4mKAAIAAEAAQABAANAEnB1YmxpYy50bHMtZWNoLmRldgAA # # meta 和 sing-box 私有扩展,将 ss-uot 用于 udp 中继,开启此选项后 udp-relay-mode 将失效 # 警告,与原版 tuic 不兼容!!! # udp-over-stream: false # udp-over-stream-version: 1 # ShadowsocksR # The supported ciphers (encryption methods): all stream ciphers in ss # The supported obfses: # plain http_simple http_post # random_head tls1.2_ticket_auth tls1.2_ticket_fastauth # The supported protocols: # origin auth_sha1_v4 auth_aes128_md5 # auth_aes128_sha1 auth_chain_a auth_chain_b - name: 'ssr' type: ssr server: server port: 443 cipher: chacha20-ietf password: 'password' obfs: tls1.2_ticket_auth protocol: auth_sha1_v4 # obfs-param: domain.tld # protocol-param: "#" # udp: true - name: 'ssh-out' type: ssh server: 127.0.0.1 port: 22 username: root password: password privateKey: path # mieru - name: mieru type: mieru server: 1.2.3.4 port: 2999 # port-range: 2090-2099 #(不可同时填写 port 和 port-range) transport: TCP # 只支持 TCP udp: true # 支持 UDP over TCP username: user password: password # 可以使用的值包括 MULTIPLEXING_OFF, MULTIPLEXING_LOW, MULTIPLEXING_MIDDLE, MULTIPLEXING_HIGH。其中 MULTIPLEXING_OFF 会关闭多路复用功能。默认值为 MULTIPLEXING_LOW。 # multiplexing: MULTIPLEXING_LOW # 如果想开启 0-RTT 握手,请设置为 HANDSHAKE_NO_WAIT,否则请设置为 HANDSHAKE_STANDARD。默认值为 HANDSHAKE_STANDARD # handshake-mode: HANDSHAKE_STANDARD # anytls - name: anytls type: anytls server: 1.2.3.4 port: 443 password: '' # client-fingerprint: chrome udp: true # idle-session-check-interval: 30 # seconds # idle-session-timeout: 30 # seconds # min-idle-session: 0 # sni: "example.com" # alpn: # - h2 # - http/1.1 # skip-cert-verify: true # dns 出站会将请求劫持到内部 dns 模块,所有请求均在内部处理 - name: 'dns-out' type: dns # 配置指定 interface-name 和 fwmark 的 DIRECT - name: en1-direct type: direct interface-name: en1 routing-mark: 6667 proxy-groups: # 代理链,目前 relay 可以支持 udp 的只有 vmess/vless/trojan/ss/ssr/tuic # wireguard 目前不支持在 relay 中使用,请使用 proxy 中的 dialer-proxy 配置项 # Traffic: mihomo <-> http <-> vmess <-> ss1 <-> ss2 <-> Internet - name: 'relay' type: relay proxies: - http - vmess - ss1 - ss2 # url-test 将按照 url 测试结果使用延迟最低节点 - name: 'auto' type: url-test proxies: - ss1 - ss2 - vmess1 # tolerance: 150 # lazy: true # expected-status: 204 # 当健康检查返回状态码与期望值不符时,认为节点不可用 url: 'https://cp.cloudflare.com/generate_204' interval: 300 # fallback 将按照 url 测试结果按照节点顺序选择 - name: 'fallback-auto' type: fallback proxies: - ss1 - ss2 - vmess1 url: 'https://cp.cloudflare.com/generate_204' interval: 300 # load-balance 将按照算法随机选择节点 - name: 'load-balance' type: load-balance proxies: - ss1 - ss2 - vmess1 url: 'https://cp.cloudflare.com/generate_204' interval: 300 # strategy: consistent-hashing # 可选 round-robin 和 sticky-sessions # select 用户自行选择节点 - name: Proxy type: select # disable-udp: true proxies: - ss1 - ss2 - vmess1 - auto - name: UseProvider type: select filter: 'HK|TW' # 正则表达式,过滤 provider1 中节点名包含 HK 或 TW use: - provider1 proxies: - Proxy - DIRECT # Mihomo 格式的节点或支持 *ray 的分享格式 proxy-providers: provider1: type: http # http 的 path 可空置,默认储存路径为 homedir 的 proxies 文件夹,文件名为 url 的 md5 url: 'url' interval: 3600 path: ./provider1.yaml # 默认只允许存储在 mihomo 的 Home Dir,如果想存储到其他位置,请通过设置 SAFE_PATHS 环境变量指定额外的安全路径。该环境变量的语法同本操作系统的PATH环境变量解析规则(即Windows下以分号分割,其他系统下以冒号分割) proxy: DIRECT # size-limit: 10240 # 限制下载文件最大为10kb,默认为0即不限制文件大小 header: User-Agent: - 'Clash/v1.18.0' - 'mihomo/1.18.3' # Accept: # - 'application/vnd.github.v3.raw' # Authorization: # - 'token 1231231' health-check: enable: true interval: 600 # lazy: true url: https://cp.cloudflare.com/generate_204 # expected-status: 204 # 当健康检查返回状态码与期望值不符时,认为节点不可用 override: # 覆写节点加载时的一些配置项 skip-cert-verify: true udp: true # down: "50 Mbps" # up: "10 Mbps" # dialer-proxy: proxy # interface-name: tailscale0 # routing-mark: 233 # ip-version: ipv4-prefer # additional-prefix: "[provider1]" # additional-suffix: "test" # # 名字替换,支持正则表达式 # proxy-name: # - pattern: "test" # target: "TEST" # - pattern: "IPLC-(.*?)倍" # target: "iplc x $1" provider2: type: inline dialer-proxy: proxy payload: - name: 'ss1' type: ss server: server port: 443 cipher: chacha20-ietf-poly1305 password: 'password' test: type: file path: /test.yaml health-check: enable: true interval: 36000 url: https://cp.cloudflare.com/generate_204 rule-providers: rule1: behavior: classical # domain ipcidr interval: 259200 path: /path/to/save/file.yaml # 默认只允许存储在 mihomo 的 Home Dir,如果想存储到其他位置,请通过设置 SAFE_PATHS 环境变量指定额外的安全路径。该环境变量的语法同本操作系统的PATH环境变量解析规则(即Windows下以分号分割,其他系统下以冒号分割) type: http # http 的 path 可空置,默认储存路径为 homedir 的 rules 文件夹,文件名为 url 的 md5 url: 'url' proxy: DIRECT # size-limit: 10240 # 限制下载文件最大为10kb,默认为0即不限制文件大小 rule2: behavior: classical interval: 259200 path: /path/to/save/file.yaml type: file rule3: # mrs类型ruleset,目前仅支持domain和ipcidr(即不支持classical), # # 对于behavior=domain: # - format=yaml 可以通过“mihomo convert-ruleset domain yaml XXX.yaml XXX.mrs”转换到mrs格式 # - format=text 可以通过“mihomo convert-ruleset domain text XXX.text XXX.mrs”转换到mrs格式 # - XXX.mrs 可以通过"mihomo convert-ruleset domain mrs XXX.mrs XXX.text"转换回text格式(暂不支持转换回yaml格式) # # 对于behavior=ipcidr: # - format=yaml 可以通过“mihomo convert-ruleset ipcidr yaml XXX.yaml XXX.mrs”转换到mrs格式 # - format=text 可以通过“mihomo convert-ruleset ipcidr text XXX.text XXX.mrs”转换到mrs格式 # - XXX.mrs 可以通过"mihomo convert-ruleset ipcidr mrs XXX.mrs XXX.text"转换回text格式(暂不支持转换回yaml格式) # type: http url: 'url' format: mrs behavior: domain path: /path/to/save/file.mrs rule4: type: inline behavior: domain # classical / ipcidr payload: - '.blogger.com' - '*.*.microsoft.com' - 'books.itunes.apple.com' rules: - RULE-SET,rule1,REJECT - IP-ASN,1,PROXY - DOMAIN-REGEX,^abc,DIRECT - DOMAIN-SUFFIX,baidu.com,DIRECT - DOMAIN-KEYWORD,google,ss1 - DOMAIN-WILDCARD,test.*.mihomo.com,ss1 - IP-CIDR,1.1.1.1/32,ss1 - IP-CIDR6,2409::/64,DIRECT # 当满足条件是 TCP 或 UDP 流量时,使用名为 sub-rule-name1 的规则集 - SUB-RULE,(OR,((NETWORK,TCP),(NETWORK,UDP))),sub-rule-name1 - SUB-RULE,(AND,((NETWORK,UDP))),sub-rule-name2 # 定义多个子规则集,规则将以分叉匹配,使用 SUB-RULE 使用 # google.com(not match)--> baidu.com(match) # / | # / | # https://baidu.com --> rule1 --> rule2 --> sub-rule-name1(match tcp) 使用 DIRECT # # # google.com(not match)--> baidu.com(not match) # / | # / | # dns 1.1.1.1 --> rule1 --> rule2 --> sub-rule-name1(match udp) sub-rule-name2(match udp) # | # | # 使用 REJECT <-- 1.1.1.1/32(match) # sub-rules: sub-rule-name1: - DOMAIN,google.com,ss1 - DOMAIN,baidu.com,DIRECT sub-rule-name2: - IP-CIDR,1.1.1.1/32,REJECT - IP-CIDR,8.8.8.8/32,ss1 - DOMAIN,dns.alidns.com,REJECT # 流量入站 listeners: - name: socks5-in-1 type: socks port: 10808 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 #listen: 0.0.0.0 # 默认监听 0.0.0.0 # rule: sub-rule-name1 # 默认使用 rules,如果未找到 sub-rule 则直接使用 rules # proxy: proxy # 如果不为空则直接将该入站流量交由指定 proxy 处理 # udp: false # 默认 true # users: # 如果不填写users项,则遵从全局authentication设置,如果填写会忽略全局设置, 如想跳过该入站的验证可填写 users: [] # - username: aaa # password: aaa # 下面两项如果填写则开启 tls(需要同时填写) # certificate: ./server.crt # private-key: ./server.key # 如果填写则开启ech(可由 mihomo generate ech-keypair <明文域名> 生成) # ech-key: | # -----BEGIN ECH KEYS----- # ACATwY30o/RKgD6hgeQxwrSiApLaCgU+HKh7B6SUrAHaDwBD/g0APwAAIAAgHjzK # madSJjYQIf9o1N5GXjkW4DEEeb17qMxHdwMdNnwADAABAAEAAQACAAEAAwAIdGVz # dC5jb20AAA== # -----END ECH KEYS----- - name: http-in-1 type: http port: 10809 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 listen: 0.0.0.0 # rule: sub-rule-name1 # 默认使用 rules,如果未找到 sub-rule 则直接使用 rules # proxy: proxy # 如果不为空则直接将该入站流量交由指定 proxy 处理 (当 proxy 不为空时,这里的 proxy 名称必须合法,否则会出错) # users: # 如果不填写users项,则遵从全局authentication设置,如果填写会忽略全局设置, 如想跳过该入站的验证可填写 users: [] # - username: aaa # password: aaa # 下面两项如果填写则开启 tls(需要同时填写) # certificate: ./server.crt # private-key: ./server.key # 如果填写则开启ech(可由 mihomo generate ech-keypair <明文域名> 生成) # ech-key: | # -----BEGIN ECH KEYS----- # ACATwY30o/RKgD6hgeQxwrSiApLaCgU+HKh7B6SUrAHaDwBD/g0APwAAIAAgHjzK # madSJjYQIf9o1N5GXjkW4DEEeb17qMxHdwMdNnwADAABAAEAAQACAAEAAwAIdGVz # dC5jb20AAA== # -----END ECH KEYS----- - name: mixed-in-1 type: mixed # HTTP(S) 和 SOCKS 代理混合 port: 10810 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 listen: 0.0.0.0 # rule: sub-rule-name1 # 默认使用 rules,如果未找到 sub-rule 则直接使用 rules # proxy: proxy # 如果不为空则直接将该入站流量交由指定 proxy 处理 (当 proxy 不为空时,这里的 proxy 名称必须合法,否则会出错) # udp: false # 默认 true # users: # 如果不填写users项,则遵从全局authentication设置,如果填写会忽略全局设置, 如想跳过该入站的验证可填写 users: [] # - username: aaa # password: aaa # 下面两项如果填写则开启 tls(需要同时填写) # certificate: ./server.crt # private-key: ./server.key # 如果填写则开启ech(可由 mihomo generate ech-keypair <明文域名> 生成) # ech-key: | # -----BEGIN ECH KEYS----- # ACATwY30o/RKgD6hgeQxwrSiApLaCgU+HKh7B6SUrAHaDwBD/g0APwAAIAAgHjzK # madSJjYQIf9o1N5GXjkW4DEEeb17qMxHdwMdNnwADAABAAEAAQACAAEAAwAIdGVz # dC5jb20AAA== # -----END ECH KEYS----- - name: reidr-in-1 type: redir port: 10811 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 listen: 0.0.0.0 # rule: sub-rule-name1 # 默认使用 rules,如果未找到 sub-rule 则直接使用 rules # proxy: proxy # 如果不为空则直接将该入站流量交由指定 proxy 处理 (当 proxy 不为空时,这里的 proxy 名称必须合法,否则会出错) - name: tproxy-in-1 type: tproxy port: 10812 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 listen: 0.0.0.0 # rule: sub-rule-name1 # 默认使用 rules,如果未找到 sub-rule 则直接使用 rules # proxy: proxy # 如果不为空则直接将该入站流量交由指定 proxy 处理 (当 proxy 不为空时,这里的 proxy 名称必须合法,否则会出错) # udp: false # 默认 true - name: shadowsocks-in-1 type: shadowsocks port: 10813 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 listen: 0.0.0.0 # rule: sub-rule-name1 # 默认使用 rules,如果未找到 sub-rule 则直接使用 rules # proxy: proxy # 如果不为空则直接将该入站流量交由指定 proxy 处理 (当 proxy 不为空时,这里的 proxy 名称必须合法,否则会出错) password: vlmpIPSyHH6f4S8WVPdRIHIlzmB+GIRfoH3aNJ/t9Gg= cipher: 2022-blake3-aes-256-gcm # shadow-tls: # enable: false # 设置为true时开启 # version: 3 # 支持v1/v2/v3 # password: password # v2设置项 # users: # v3设置项 # - name: 1 # password: password # handshake: # dest: test.com:443 - name: vmess-in-1 type: vmess port: 10814 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 listen: 0.0.0.0 # rule: sub-rule-name1 # 默认使用 rules,如果未找到 sub-rule 则直接使用 rules # proxy: proxy # 如果不为空则直接将该入站流量交由指定 proxy 处理 (当 proxy 不为空时,这里的 proxy 名称必须合法,否则会出错) users: - username: 1 uuid: 9d0cb9d0-964f-4ef6-897d-6c6b3ccf9e68 alterId: 1 # ws-path: "/" # 如果不为空则开启 websocket 传输层 # grpc-service-name: "GunService" # 如果不为空则开启 grpc 传输层 # 下面两项如果填写则开启 tls(需要同时填写) # certificate: ./server.crt # private-key: ./server.key # 如果填写则开启ech(可由 mihomo generate ech-keypair <明文域名> 生成) # ech-key: | # -----BEGIN ECH KEYS----- # ACATwY30o/RKgD6hgeQxwrSiApLaCgU+HKh7B6SUrAHaDwBD/g0APwAAIAAgHjzK # madSJjYQIf9o1N5GXjkW4DEEeb17qMxHdwMdNnwADAABAAEAAQACAAEAAwAIdGVz # dC5jb20AAA== # -----END ECH KEYS----- # 如果填写reality-config则开启reality(注意不可与certificate和private-key同时填写) # reality-config: # dest: test.com:443 # private-key: jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0 # 可由 mihomo generate reality-keypair 命令生成 # short-id: # - 0123456789abcdef # server-names: # - test.com # #下列两个 limit 为选填,可对未通过验证的回落连接限速,bytesPerSec 默认为 0 即不启用 # #回落限速是一种特征,不建议启用,如果您是面板/一键脚本开发者,务必让这些参数随机化 # limit-fallback-upload: # after-bytes: 0 # 传输指定字节后开始限速 # bytes-per-sec: 0 # 基准速率(字节/秒) # burst-bytes-per-sec: 0 # 突发速率(字节/秒),大于 bytesPerSec 时生效 # limit-fallback-download: # after-bytes: 0 # 传输指定字节后开始限速 # bytes-per-sec: 0 # 基准速率(字节/秒) # burst-bytes-per-sec: 0 # 突发速率(字节/秒),大于 bytesPerSec 时生效 - name: tuic-in-1 type: tuic port: 10815 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 listen: 0.0.0.0 # rule: sub-rule-name1 # 默认使用 rules,如果未找到 sub-rule 则直接使用 rules # proxy: proxy # 如果不为空则直接将该入站流量交由指定 proxy 处理 (当 proxy 不为空时,这里的 proxy 名称必须合法,否则会出错) # token: # tuicV4 填写(可以同时填写 users) # - TOKEN # users: # tuicV5 填写(可以同时填写 token) # 00000000-0000-0000-0000-000000000000: PASSWORD_0 # 00000000-0000-0000-0000-000000000001: PASSWORD_1 # certificate: ./server.crt # private-key: ./server.key # 如果填写则开启ech(可由 mihomo generate ech-keypair <明文域名> 生成) # ech-key: | # -----BEGIN ECH KEYS----- # ACATwY30o/RKgD6hgeQxwrSiApLaCgU+HKh7B6SUrAHaDwBD/g0APwAAIAAgHjzK # madSJjYQIf9o1N5GXjkW4DEEeb17qMxHdwMdNnwADAABAAEAAQACAAEAAwAIdGVz # dC5jb20AAA== # -----END ECH KEYS----- # congestion-controller: bbr # max-idle-time: 15000 # authentication-timeout: 1000 # alpn: # - h3 # max-udp-relay-packet-size: 1500 - name: tunnel-in-1 type: tunnel port: 10816 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 listen: 0.0.0.0 # rule: sub-rule-name1 # 默认使用 rules,如果未找到 sub-rule 则直接使用 rules # proxy: proxy # 如果不为空则直接将该入站流量交由指定 proxy 处理 (当 proxy 不为空时,这里的 proxy 名称必须合法,否则会出错) network: [tcp, udp] target: target.com - name: vless-in-1 type: vless port: 10817 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 listen: 0.0.0.0 # rule: sub-rule-name1 # 默认使用 rules,如果未找到 sub-rule 则直接使用 rules # proxy: proxy # 如果不为空则直接将该入站流量交由指定 proxy 处理 (当 proxy 不为空时,这里的 proxy 名称必须合法,否则会出错) users: - username: 1 uuid: 9d0cb9d0-964f-4ef6-897d-6c6b3ccf9e68 flow: xtls-rprx-vision # ws-path: "/" # 如果不为空则开启 websocket 传输层 # grpc-service-name: "GunService" # 如果不为空则开启 grpc 传输层 # ------------------------- # vless encryption服务端配置: # (原生外观 / 只 XOR 公钥 / 全随机数。只允许 1-RTT 模式 / 同时允许 1-RTT 模式与 600 秒复用的 0-RTT 模式) # / 是只能选一个,后面 base64 至少一个,无限串联,使用 mihomo generate vless-x25519 和 mihomo generate vless-mlkem768 生成,替换值时需去掉括号 # ------------------------- # decryption: "mlkem768x25519plus.native/xorpub/random.1rtt/600s.(X25519 PrivateKey).(ML-KEM-768 Seed)..." # 下面两项如果填写则开启 tls(需要同时填写) # certificate: ./server.crt # private-key: ./server.key # 如果填写则开启ech(可由 mihomo generate ech-keypair <明文域名> 生成) # ech-key: | # -----BEGIN ECH KEYS----- # ACATwY30o/RKgD6hgeQxwrSiApLaCgU+HKh7B6SUrAHaDwBD/g0APwAAIAAgHjzK # madSJjYQIf9o1N5GXjkW4DEEeb17qMxHdwMdNnwADAABAAEAAQACAAEAAwAIdGVz # dC5jb20AAA== # -----END ECH KEYS----- # 如果填写reality-config则开启reality(注意不可与certificate和private-key同时填写) reality-config: dest: test.com:443 private-key: jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0 # 可由 mihomo generate reality-keypair 命令生成 short-id: - 0123456789abcdef server-names: - test.com #下列两个 limit 为选填,可对未通过验证的回落连接限速,bytesPerSec 默认为 0 即不启用 #回落限速是一种特征,不建议启用,如果您是面板/一键脚本开发者,务必让这些参数随机化 limit-fallback-upload: after-bytes: 0 # 传输指定字节后开始限速 bytes-per-sec: 0 # 基准速率(字节/秒) burst-bytes-per-sec: 0 # 突发速率(字节/秒),大于 bytesPerSec 时生效 limit-fallback-download: after-bytes: 0 # 传输指定字节后开始限速 bytes-per-sec: 0 # 基准速率(字节/秒) burst-bytes-per-sec: 0 # 突发速率(字节/秒),大于 bytesPerSec 时生效 ### 注意,对于vless listener, 至少需要填写 “certificate和private-key” 或 “reality-config” 或 “decryption” 的其中一项 ### - name: anytls-in-1 type: anytls port: 10818 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 listen: 0.0.0.0 users: username1: password1 username2: password2 # "certificate" and "private-key" are required certificate: ./server.crt private-key: ./server.key # 如果填写则开启ech(可由 mihomo generate ech-keypair <明文域名> 生成) # ech-key: | # -----BEGIN ECH KEYS----- # ACATwY30o/RKgD6hgeQxwrSiApLaCgU+HKh7B6SUrAHaDwBD/g0APwAAIAAgHjzK # madSJjYQIf9o1N5GXjkW4DEEeb17qMxHdwMdNnwADAABAAEAAQACAAEAAwAIdGVz # dC5jb20AAA== # -----END ECH KEYS----- # padding-scheme: "" # https://github.com/anytls/anytls-go/blob/main/docs/protocol.md#cmdupdatepaddingscheme - name: trojan-in-1 type: trojan port: 10819 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 listen: 0.0.0.0 # rule: sub-rule-name1 # 默认使用 rules,如果未找到 sub-rule 则直接使用 rules # proxy: proxy # 如果不为空则直接将该入站流量交由指定 proxy 处理 (当 proxy 不为空时,这里的 proxy 名称必须合法,否则会出错) users: - username: 1 password: 9d0cb9d0-964f-4ef6-897d-6c6b3ccf9e68 # ws-path: "/" # 如果不为空则开启 websocket 传输层 # grpc-service-name: "GunService" # 如果不为空则开启 grpc 传输层 # 下面两项如果填写则开启 tls(需要同时填写) certificate: ./server.crt private-key: ./server.key # 如果填写则开启ech(可由 mihomo generate ech-keypair <明文域名> 生成) # ech-key: | # -----BEGIN ECH KEYS----- # ACATwY30o/RKgD6hgeQxwrSiApLaCgU+HKh7B6SUrAHaDwBD/g0APwAAIAAgHjzK # madSJjYQIf9o1N5GXjkW4DEEeb17qMxHdwMdNnwADAABAAEAAQACAAEAAwAIdGVz # dC5jb20AAA== # -----END ECH KEYS----- # 如果填写reality-config则开启reality(注意不可与certificate和private-key同时填写) # reality-config: # dest: test.com:443 # private-key: jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0 # 可由 mihomo generate reality-keypair 命令生成 # short-id: # - 0123456789abcdef # server-names: # - test.com # #下列两个 limit 为选填,可对未通过验证的回落连接限速,bytesPerSec 默认为 0 即不启用 # #回落限速是一种特征,不建议启用,如果您是面板/一键脚本开发者,务必让这些参数随机化 # limit-fallback-upload: # after-bytes: 0 # 传输指定字节后开始限速 # bytes-per-sec: 0 # 基准速率(字节/秒) # burst-bytes-per-sec: 0 # 突发速率(字节/秒),大于 bytesPerSec 时生效 # limit-fallback-download: # after-bytes: 0 # 传输指定字节后开始限速 # bytes-per-sec: 0 # 基准速率(字节/秒) # burst-bytes-per-sec: 0 # 突发速率(字节/秒),大于 bytesPerSec 时生效 # ss-option: # like trojan-go's `shadowsocks` config # enabled: false # method: aes-128-gcm # aes-128-gcm/aes-256-gcm/chacha20-ietf-poly1305 # password: "example" ### 注意,对于trojan listener, 至少需要填写 “certificate和private-key” 或 “reality-config” 或 “ss-option” 的其中一项 ### - name: hysteria2-in-1 type: hysteria2 port: 10820 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 listen: 0.0.0.0 # rule: sub-rule-name1 # 默认使用 rules,如果未找到 sub-rule 则直接使用 rules # proxy: proxy # 如果不为空则直接将该入站流量交由指定 proxy 处理 (当 proxy 不为空时,这里的 proxy 名称必须合法,否则会出错) users: 00000000-0000-0000-0000-000000000000: PASSWORD_0 00000000-0000-0000-0000-000000000001: PASSWORD_1 # certificate: ./server.crt # private-key: ./server.key # 如果填写则开启ech(可由 mihomo generate ech-keypair <明文域名> 生成) # ech-key: | # -----BEGIN ECH KEYS----- # ACATwY30o/RKgD6hgeQxwrSiApLaCgU+HKh7B6SUrAHaDwBD/g0APwAAIAAgHjzK # madSJjYQIf9o1N5GXjkW4DEEeb17qMxHdwMdNnwADAABAAEAAQACAAEAAwAIdGVz # dC5jb20AAA== # -----END ECH KEYS----- ## up 和 down 均不写或为 0 则使用 BBR 流控 # up: "30 Mbps" # 若不写单位,默认为 Mbps # down: "200 Mbps" # 若不写单位,默认为 Mbps # obfs: salamander # 默认为空,如果填写则开启 obfs,目前仅支持 salamander # obfs-password: yourpassword # max-idle-time: 15000 # alpn: # - h3 # ignore-client-bandwidth: false # HTTP3 服务器认证失败时的行为 (URL 字符串配置),如果 masquerade 未配置,则返回 404 页 # masquerade: file:///var/www # 作为文件服务器 # masquerade: http://127.0.0.1:8080 #作为反向代理 # masquerade: https://127.0.0.1:8080 #作为反向代理 # 注意,listeners中的tun仅提供给高级用户使用,普通用户应使用顶层配置中的tun - name: tun-in-1 type: tun # rule: sub-rule-name1 # 默认使用 rules,如果未找到 sub-rule 则直接使用 rules # proxy: proxy # 如果不为空则直接将该入站流量交由指定 proxy 处理 (当 proxy 不为空时,这里的 proxy 名称必须合法,否则会出错) stack: system # gvisor / mixed dns-hijack: - 0.0.0.0:53 # 需要劫持的 DNS # auto-detect-interface: false # 自动识别出口网卡 # auto-route: false # 配置路由表 # mtu: 9000 # 最大传输单元 inet4-address: # 必须手动设置 ipv4 地址段 - 198.19.0.1/30 inet6-address: # 必须手动设置 ipv6 地址段 - 'fdfe:dcba:9877::1/126' # strict-route: true # 将所有连接路由到 tun 来防止泄漏,但你的设备将无法其他设备被访问 # inet4-route-address: # 启用 auto-route 时使用自定义路由而不是默认路由 # - 0.0.0.0/1 # - 128.0.0.0/1 # inet6-route-address: # 启用 auto-route 时使用自定义路由而不是默认路由 # - "::/1" # - "8000::/1" # endpoint-independent-nat: false # 启用独立于端点的 NAT # include-uid: # UID 规则仅在 Linux 下被支持,并且需要 auto-route # - 0 # include-uid-range: # 限制被路由的的用户范围 # - 1000:99999 # exclude-uid: # 排除路由的的用户 # - 1000 # exclude-uid-range: # 排除路由的的用户范围 # - 1000:99999 # Android 用户和应用规则仅在 Android 下被支持 # 并且需要 auto-route # include-android-user: # 限制被路由的 Android 用户 # - 0 # - 10 # include-package: # 限制被路由的 Android 应用包名 # - com.android.chrome # exclude-package: # 排除被路由的 Android 应用包名 # - com.android.captiveportallogin # 入口配置与 Listener 等价,传入流量将和 socks,mixed 等入口一样按照 mode 所指定的方式进行匹配处理 # shadowsocks,vmess 入口配置(传入流量将和 socks,mixed 等入口一样按照 mode 所指定的方式进行匹配处理) # ss-config: ss://2022-blake3-aes-256-gcm:vlmpIPSyHH6f4S8WVPdRIHIlzmB+GIRfoH3aNJ/t9Gg=@:23456 # vmess-config: vmess://1:9d0cb9d0-964f-4ef6-897d-6c6b3ccf9e68@:12345 # tuic 服务器入口(传入流量将和 socks,mixed 等入口一样按照 mode 所指定的方式进行匹配处理) # tuic-server: # enable: true # listen: 127.0.0.1:10443 # token: # tuicV4 填写(可以同时填写 users) # - TOKEN # users: # tuicV5 填写(可以同时填写 token) # 00000000-0000-0000-0000-000000000000: PASSWORD_0 # 00000000-0000-0000-0000-000000000001: PASSWORD_1 # certificate: ./server.crt # private-key: ./server.key # congestion-controller: bbr # max-idle-time: 15000 # authentication-timeout: 1000 # alpn: # - h3 # max-udp-relay-packet-size: 1500 ================================================ FILE: backend/tauri-plugin-deep-link/.github/workflows/audit.yml ================================================ name: Audit on: schedule: - cron: '0 0 * * *' push: branches: - main paths: - '**/Cargo.lock' - '**/Cargo.toml' pull_request: branches: - main paths: - '**/Cargo.lock' - '**/Cargo.toml' jobs: audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions-rs/audit-check@v1 with: token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: backend/tauri-plugin-deep-link/.github/workflows/format.yml ================================================ name: Format on: push: branches: - main pull_request: branches: - main - dev jobs: format: runs-on: ubuntu-latest strategy: fail-fast: false steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt - uses: Swatinem/rust-cache@v2 - run: cargo fmt --all -- --check ================================================ FILE: backend/tauri-plugin-deep-link/.github/workflows/lint.yml ================================================ name: Clippy on: push: branches: - main pull_request: branches: - main - dev jobs: clippy: strategy: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: components: clippy - uses: Swatinem/rust-cache@v2 - run: cargo clippy --all-targets --all-features -- -D warnings ================================================ FILE: backend/tauri-plugin-deep-link/.github/workflows/release.yml ================================================ name: Publish on: push: tags: - 'v*.*.*' jobs: release: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Release to crates.io run: | cargo login ${{ secrets.CRATES_IO }} cargo publish - name: Generate Changelog uses: orhun/git-cliff-action@v4 id: git-cliff with: config: cliff.toml args: -vv --latest --strip header - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: body: ${{ steps.git-cliff.outputs.content }} ================================================ FILE: backend/tauri-plugin-deep-link/.gitignore ================================================ /target /Cargo.lock ================================================ FILE: backend/tauri-plugin-deep-link/CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. ## [0.1.2] - 2023-08-15 **This plugin will be migrated to https://github.com/tauri-apps/plugins-workspace/.** Therefore, this will likely be the last release in this repository. ### Miscellaneous Tasks - Update rust crate objc2 to 0.4.0 (#30) - Update rust crate objc2 to 0.4.1 (#31) ## [0.1.1] - 2023-04-04 ### Bug Fixes - Info.plist formatting (#22) - Fixed inability to focus when launched from a Windows notification. (#27) ### Documentation - Add env::args getter to example ### Miscellaneous Tasks - Update rust crate winreg to 0.50.0 (#28) - Switch from dirs-next to dirs ## [0.1.0] - 2023-02-27 ### Features - Initial release ================================================ FILE: backend/tauri-plugin-deep-link/Cargo.toml ================================================ [package] name = "tauri-plugin-deep-link" version = "0.1.2" authors = ["FabianLars "] description = "A Tauri plugin for deep linking support" repository = "https://github.com/FabianLars/tauri-plugin-deep-link" edition = "2021" rust-version = "1.64" license = "MIT OR Apache-2.0" readme = "README.md" include = ["src/**", "Cargo.toml", "LICENSE_*"] [lib] doctest = false [dependencies] dirs = "6" log = "0.4" once_cell = "1" tauri-utils = { version = "2" } [target.'cfg(windows)'.dependencies] interprocess = { version = "2", features = ["tokio"] } windows-sys = { version = "0.60", features = [ "Win32_Foundation", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", ] } winreg = "0.55.0" tokio = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.1" ================================================ FILE: backend/tauri-plugin-deep-link/LICENSE_APACHE-2.0 ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS ================================================ FILE: backend/tauri-plugin-deep-link/LICENSE_MIT ================================================ MIT License Copyright (c) 2022 - Present FabianLars 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: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 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. ================================================ FILE: backend/tauri-plugin-deep-link/README.md ================================================ # Deep link plugin for Tauri [![](https://img.shields.io/crates/v/tauri-plugin-deep-link.svg)](https://crates.io/crates/tauri-plugin-deep-link) [![](https://img.shields.io/docsrs/tauri-plugin-deep-link)](https://docs.rs/tauri-plugin-deep-link) **This plugin will be migrated to https://github.com/tauri-apps/plugins-workspace/.** `0.1.2` will be the last release in this repo. ~~Temporary solution until https://github.com/tauri-apps/tauri/issues/323 lands.~~ Depending on your use case, for example a `Login with Google` button, you may want to take a look at https://github.com/FabianLars/tauri-plugin-oauth instead. It uses a minimalistic localhost server for the OAuth process instead of custom uri schemes because some oauth providers, like the aforementioned Google, require this setup. Personally, I think it's easier to use too. Check out the [`example/`](https://github.com/FabianLars/tauri-plugin-deep-link/tree/main/example) directory for a minimal example. You must copy it into an actual tauri app first! ## macOS In case you're one of the very few people that didn't know this already: macOS hates developers! Not only is that why the macOS implementation took me so long, it also means _you_ have to be a bit more careful if your app targets macOS: - Read through the methods' platform-specific notes. - On macOS you need to register the schemes in a `Info.plist` file at build time, the plugin can't change the schemes at runtime. - macOS apps are in single-instance by default so this plugin will not manually shut down secondary instances in release mode. - To make development via `tauri dev` a little bit more pleasant, the plugin will work similar-ish to Linux and Windows _in debug mode_ but you will see secondary instances show on the screen for a split second and the event will trigger twice in the primary instance (one of these events will be an empty string). You still have to install a `.app` bundle you got from `tauri build --debug` for this to work! ================================================ FILE: backend/tauri-plugin-deep-link/cliff.toml ================================================ # configuration file for git-cliff # see https://github.com/orhun/git-cliff#configuration-file [changelog] # changelog header header = """ # Changelog\n All notable changes to this project will be documented in this file.\n """ # template for the changelog body # https://tera.netlify.app/docs/#introduction body = """ {% if version %}\ ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} {% else %}\ ## [unreleased] {% endif %}\ {% for group, commits in commits | group_by(attribute="group") %} ### {{ group | upper_first }} {% for commit in commits %} - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ {% endfor %} {% endfor %}\n """ # remove the leading and trailing whitespace from the template trim = true # changelog footer footer = "" [git] # parse the commits based on https://www.conventionalcommits.org conventional_commits = true # filter out the commits that are not conventional filter_unconventional = true # process each line of a commit as an individual commit split_commits = false # regex for preprocessing the commit messages commit_preprocessors = [ # { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/orhun/git-cliff/issues/${2}))"}, # replace issue numbers ] # regex for parsing and grouping commits commit_parsers = [ { message = "^feat", group = "Features" }, { message = "^fix", group = "Bug Fixes" }, { message = "^doc", group = "Documentation" }, { message = "^perf", group = "Performance" }, { message = "^refactor", group = "Refactor" }, { message = "^style", group = "Styling" }, { message = "^test", group = "Testing" }, { message = "^chore\\(release\\): [Pp]repare for", skip = true }, { message = "^chore", group = "Miscellaneous Tasks" }, { message = "^ci", group = "CI", skip = true }, { body = ".*security", group = "Security" }, ] # protect breaking changes from being skipped due to matching a skipping commit_parser protect_breaking_commits = false # filter out the commits that are not matched by commit parsers filter_commits = false # glob pattern for matching git tags tag_pattern = "v[0-9]*" # regex for skipping tags # skip_tags = "v0.1.0-beta.1" # regex for ignoring tags ignore_tags = "" # sort the tags topologically topo_order = false # sort the commits inside sections by oldest/newest order sort_commits = "oldest" # limit the number of commits included in the changelog. # limit_commits = 42 ================================================ FILE: backend/tauri-plugin-deep-link/example/Info.plist ================================================ CFBundleURLTypes CFBundleURLName de.fabianlars.deep-link-test CFBundleURLSchemes myapp myscheme ================================================ FILE: backend/tauri-plugin-deep-link/example/main.rs ================================================ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use tauri::Manager; fn main() { // prepare() checks if it's a single instance and tries to send the args otherwise. // It should always be the first line in your main function (with the exception of loggers or similar) tauri_plugin_deep_link::prepare("de.fabianlars.deep-link-test"); // It's expected to use the identifier from tauri.conf.json // Unfortuenetly getting it is pretty ugly without access to sth that implements `Manager`. tauri::Builder::default() .setup(|app| { // If you need macOS support this must be called in .setup() ! // Otherwise this could be called right after prepare() but then you don't have access to tauri APIs let handle = app.handle(); tauri_plugin_deep_link::register( "my-scheme", move |request| { dbg!(&request); handle.emit_all("scheme-request-received", request).unwrap(); }, ) .unwrap(/* If listening to the scheme is optional for your app, you don't want to unwrap here. */); // If you also need the url when the primary instance was started by the custom scheme, you currently have to read it yourself /* #[cfg(not(target_os = "macos"))] // on macos the plugin handles this (macos doesn't use cli args for the url) if let Some(url) = std::env::args().nth(1) { app.emit_all("scheme-request-received", url).unwrap(); } */ Ok(()) }) // .plugin(tauri_plugin_deep_link::init()) // consider adding a js api later .run(tauri::generate_context!()) .expect("error while running tauri application"); } ================================================ FILE: backend/tauri-plugin-deep-link/renovate.json ================================================ { "extends": ["config:base", ":semanticCommitTypeAll(chore)"] } ================================================ FILE: backend/tauri-plugin-deep-link/src/lib.rs ================================================ use std::io::{ErrorKind, Result}; use once_cell::sync::OnceCell; #[cfg(target_os = "windows")] #[path = "windows.rs"] mod platform_impl; #[cfg(target_os = "linux")] #[path = "linux.rs"] mod platform_impl; #[cfg(target_os = "macos")] #[path = "macos.rs"] mod platform_impl; static ID: OnceCell = OnceCell::new(); /// This function is meant for use-cases where the default [`prepare()`] function can't be used. /// /// # Errors /// If ID was already set this functions returns an AlreadyExists error. pub fn set_identifier(identifier: &str) -> Result<()> { ID.set(identifier.to_string()) .map_err(|_| ErrorKind::AlreadyExists.into()) } // Consider adding a function to register without starting the listener. /// Registers a handler for the given scheme. /// /// ## Platform-specific: /// /// - **macOS**: On macOS schemes must be defined in an Info.plist file, therefore this function only calls [`listen()`] without registering the scheme. This function can only be called once on macOS. pub fn register(scheme: &[&str], handler: F) -> Result<()> { platform_impl::register(scheme, handler) } /// Starts the event listener without registering any schemes. /// /// ## Platform-specific: /// /// - **macOS**: This function can only be called once on macOS. pub fn listen(handler: F) -> Result<()> { platform_impl::listen(handler) } /// Unregister a previously registered scheme. /// /// ## Platform-specific: /// /// - **macOS**: This function has no effect on macOS. pub fn unregister(scheme: &[&str]) -> Result<()> { platform_impl::unregister(scheme) } /// Checks if current instance is the primary instance. /// Also sends the URL event data to the primary instance and stops the process afterwards. /// /// ## Platform-specific: /// /// - **macOS**: Only registers the identifier (only relevant in debug mode). It does not interact with the primary instance and does not exit the app. pub fn prepare(identifier: &str) { platform_impl::prepare(identifier) } ================================================ FILE: backend/tauri-plugin-deep-link/src/linux.rs ================================================ use std::{ fs::{create_dir_all, remove_file, File}, io::{Error, ErrorKind, Read, Result, Write}, os::unix::net::{UnixListener, UnixStream}, process::Command, }; use dirs::data_dir; use crate::ID; pub fn register(schemes: &[&str], handler: F) -> Result<()> { listen(handler)?; let mut target = data_dir() .ok_or_else(|| Error::new(ErrorKind::NotFound, "data directory not found."))? .join("applications"); create_dir_all(&target)?; let exe = tauri_utils::platform::current_exe()?; let file_name = format!( "{}-handler.desktop", exe.file_name() .ok_or_else(|| Error::new( ErrorKind::NotFound, "Couldn't get file name of curent executable.", ))? .to_string_lossy() ); target.push(&file_name); let mime_types = format!( "{};", schemes .iter() .map(|s| format!("x-scheme-handler/{}", s)) .collect::>() .join(";") ); let mut file = File::create(&target)?; file.write_all( format!( include_str!("template.desktop"), name = ID .get() .expect("Called register() before prepare()") .split('.') .last() .unwrap(), exec = std::env::var("APPIMAGE").unwrap_or_else(|_| exe.display().to_string()), mime_types = mime_types ) .as_bytes(), )?; // update-desktop-database [-q|--quiet] [-v|--verbose] [DIRECTORY...] target.pop(); Command::new("update-desktop-database") .arg(&target) .status()?; for scheme in schemes { Command::new("xdg-mime") .args([ "default", &file_name, &format!("x-scheme-handler/{}", scheme), ]) .status()?; } Ok(()) } pub fn unregister(_schemes: &[&str]) -> Result<()> { let mut target = data_dir().ok_or_else(|| Error::new(ErrorKind::NotFound, "data directory not found."))?; target.push("applications"); target.push(format!( "{}-handler.desktop", tauri_utils::platform::current_exe()? .file_name() .ok_or_else(|| Error::new( ErrorKind::NotFound, "Couldn't get file name of current executable.", ))? .to_string_lossy() )); remove_file(&target)?; target.pop(); Ok(()) } pub fn listen(mut handler: F) -> Result<()> { std::thread::spawn(move || { let addr = format!( "/tmp/{}-deep-link.sock", ID.get().expect("listen() called before prepare()") ); let listener = UnixListener::bind(addr).expect("Can't create listener"); for stream in listener.incoming() { match stream { Ok(mut stream) => { let mut buffer = String::new(); if let Err(io_err) = stream.read_to_string(&mut buffer) { log::error!("Error reading incoming connection: {}", io_err.to_string()); }; handler(dbg!(buffer)); } Err(err) => { log::error!("Incoming connection failed: {}", err); continue; } } } }); Ok(()) } pub fn prepare(identifier: &str) { let addr = format!("/tmp/{}-deep-link.sock", identifier); match UnixStream::connect(&addr) { Ok(mut stream) => { if let Err(io_err) = stream.write_all(std::env::args().nth(1).unwrap_or_default().as_bytes()) { log::error!( "Error sending message to primary instance: {}", io_err.to_string() ); }; std::process::exit(0); } Err(err) => { log::error!("Error creating socket listener: {}", err.to_string()); if err.kind() == ErrorKind::ConnectionRefused { let _ = remove_file(&addr); } } }; ID.set(identifier.to_string()) .expect("prepare() called more than once with different identifiers."); } ================================================ FILE: backend/tauri-plugin-deep-link/src/macos.rs ================================================ use std::{ fs::remove_file, io::{ErrorKind, Read, Result, Write}, os::unix::net::{UnixListener, UnixStream}, sync::Mutex, }; use objc2::{ class, define_class, msg_send, msg_send_id, rc::Retained, runtime::{AnyObject, NSObject}, sel, ClassType, }; use once_cell::sync::OnceCell; use crate::ID; type THandler = OnceCell>>; // If the Mutex turns out to be a problem, or FnMut turns out to be useless, we can remove the Mutex and turn FnMut into Fn static HANDLER: THandler = OnceCell::new(); pub fn register(_scheme: &[&str], handler: F) -> Result<()> { listen(handler)?; Ok(()) } pub fn unregister(_scheme: &[&str]) -> Result<()> { Ok(()) } // kInternetEventClass const EVENT_CLASS: u32 = 0x4755524c; // kAEGetURL const EVENT_GET_URL: u32 = 0x4755524c; // Adapted from https://github.com/mrmekon/fruitbasket/blob/aad14e400d710d1d46317c0d8c55ff742bfeaadd/src/osx.rs#L848 fn parse_url_event(event: *mut AnyObject) -> Option { if event as u64 == 0u64 { return None; } unsafe { let class: u32 = msg_send![event, eventClass]; let id: u32 = msg_send![event, eventID]; if class != EVENT_CLASS || id != EVENT_GET_URL { return None; } let subevent: *mut AnyObject = msg_send![event, paramDescriptorForKeyword: 0x2d2d2d2d_u32]; let nsstring: *mut AnyObject = msg_send![subevent, stringValue]; let cstr: *const i8 = msg_send![nsstring, UTF8String]; if !cstr.is_null() { Some(std::ffi::CStr::from_ptr(cstr).to_string_lossy().to_string()) } else { None } } } define_class!( #[unsafe(super(NSObject))] #[name = "TauriPluginDeepLinkHandler"] struct Handler; impl Handler { #[unsafe(method(handleEvent:withReplyEvent:))] fn handle_event(&self, event: *mut AnyObject, _replace: *const AnyObject) { let s = parse_url_event(event).unwrap_or_default(); let mut cb = HANDLER.get().unwrap().lock().unwrap(); cb(s); } } ); impl Handler { pub fn new() -> Retained { let cls = Self::class(); unsafe { msg_send_id![msg_send_id![cls, alloc], init] } } } #[cfg(debug_assertions)] fn secondary_handler(s: String) { let addr = format!( "/tmp/{}-deep-link.sock", ID.get() .expect("URL event received before prepare() was called") ); if let Ok(mut stream) = UnixStream::connect(addr) { if let Err(io_err) = stream.write_all(s.as_bytes()) { log::error!( "Error sending message to primary instance: {}", io_err.to_string() ); }; } std::process::exit(0); } pub fn listen(handler: F) -> Result<()> { #[cfg(debug_assertions)] let addr = format!( "/tmp/{}-deep-link.sock", ID.get().expect("listen() called before prepare()") ); #[cfg(debug_assertions)] if HANDLER .set(match UnixStream::connect(&addr) { Ok(_) => Mutex::new(Box::new(secondary_handler)), Err(err) => { log::error!("Error creating socket listener: {}", err.to_string()); if err.kind() == ErrorKind::ConnectionRefused { let _ = remove_file(&addr); } Mutex::new(Box::new(handler)) } }) .is_err() { return Err(std::io::Error::new( ErrorKind::AlreadyExists, "Handler was already set", )); } #[cfg(not(debug_assertions))] if HANDLER.set(Mutex::new(Box::new(handler))).is_err() { return Err(std::io::Error::new( ErrorKind::AlreadyExists, "Handler was already set", )); } unsafe { let event_manager: Retained = msg_send_id![class!(NSAppleEventManager), sharedAppleEventManager]; let handler = Handler::new(); let handler_boxed = Box::into_raw(Box::new(handler)); let _: () = msg_send![&event_manager, setEventHandler: &**handler_boxed andSelector: sel!(handleEvent:withReplyEvent:) forEventClass:EVENT_CLASS andEventID:EVENT_GET_URL]; } #[cfg(debug_assertions)] std::thread::spawn(move || { let listener = UnixListener::bind(addr).expect("Can't create listener"); for stream in listener.incoming() { match stream { Ok(mut stream) => { let mut buffer = String::new(); if let Err(io_err) = stream.read_to_string(&mut buffer) { log::error!("Error reading incoming connection: {}", io_err.to_string()); }; let mut cb = HANDLER.get().unwrap().lock().unwrap(); cb(buffer); } Err(err) => { log::error!("Incoming connection failed: {}", err); continue; } } } }); Ok(()) } pub fn prepare(identifier: &str) { ID.set(identifier.to_string()) .expect("prepare() called more than once with different identifiers."); } ================================================ FILE: backend/tauri-plugin-deep-link/src/template.desktop ================================================ [Desktop Entry] Type=Application Name={name} Exec={exec} %u Terminal=false MimeType={mime_types} NoDisplay=true ================================================ FILE: backend/tauri-plugin-deep-link/src/windows.rs ================================================ use std::{ path::Path, sync::atomic::{AtomicU16, Ordering}, }; use interprocess::{ bound_util::RefTokioAsyncRead, local_socket::{ tokio::prelude::*, traits::tokio::{Listener, Stream}, GenericNamespaced, ListenerNonblockingMode, ListenerOptions, Name, ToNsName, }, os::windows::{ local_socket::ListenerOptionsExt, security_descriptor::SecurityDescriptor, ToWtf16, }, }; use std::io::Result; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use windows_sys::Win32::UI::{ Input::KeyboardAndMouse::{SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT}, WindowsAndMessaging::{AllowSetForegroundWindow, ASFW_ANY}, // WindowsAndMessaging::{AllowSetForegroundWindow, ASFW_ANY}, }; use winreg::{enums::HKEY_CURRENT_USER, RegKey}; use crate::ID; pub fn register(schemes: &[&str], handler: F) -> Result<()> { listen(handler)?; for scheme in schemes { let hkcu = RegKey::predef(HKEY_CURRENT_USER); let base = Path::new("Software").join("Classes").join(scheme); let exe = tauri_utils::platform::current_exe()? .display() .to_string() .replace("\\\\?\\", ""); let (key, _) = hkcu.create_subkey(&base)?; key.set_value( "", &format!( "URL:{}", ID.get().expect("register() called before prepare()") ), )?; key.set_value("URL Protocol", &"")?; let (icon, _) = hkcu.create_subkey(base.join("DefaultIcon"))?; icon.set_value("", &format!("\"{}\",0", &exe))?; let (cmd, _) = hkcu.create_subkey(base.join("shell").join("open").join("command"))?; cmd.set_value("", &format!("\"{}\" \"%1\"", &exe))?; } Ok(()) } pub fn unregister(schemes: &[&str]) -> Result<()> { for scheme in schemes { let hkcu = RegKey::predef(HKEY_CURRENT_USER); let base = Path::new("Software").join("Classes").join(scheme); hkcu.delete_subkey_all(base)?; } Ok(()) } static CRASH_COUNT: AtomicU16 = AtomicU16::new(0); pub fn listen(mut handler: F) -> Result<()> { if CRASH_COUNT.load(Ordering::Acquire) > 5 { panic!("Local socket too many crashes"); } std::thread::spawn(move || { let name = ID .get() .expect("listen() called before prepare()") .as_str() .to_ns_name::() .unwrap(); tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("failed to create tokio runtime") .block_on(async move { let sdsf = "D:(A;;GA;;;WD)".to_wtf_16().unwrap(); let sd = SecurityDescriptor::deserialize(&sdsf).expect("Failed to deserialize SD"); let listener = ListenerOptions::new() .name(name) .nonblocking(ListenerNonblockingMode::Both) .security_descriptor(sd) .create_tokio() .expect("Can't create listener"); loop { match listener.accept().await { Ok(conn) => { let (rx, mut tx) = conn.split(); let mut reader = BufReader::new(rx); let mut buf = String::new(); if let Err(e) = reader.read_line(&mut buf).await { log::error!("Error reading from connection: {e}"); continue; } buf.pop(); let current_pid = std::process::id(); let response = format!("{current_pid}\n"); if let Err(e) = tx.write_all(response.as_bytes()).await { log::error!("Error writing to connection: {e}"); continue; } handler(buf); } Err(e) if e.raw_os_error() == Some(232) => { // 234 is WSAEINTR, which means the listener was closed. break; } Err(e) => { log::error!("Error accepting connection: {e}"); } } } CRASH_COUNT.fetch_add(1, Ordering::Release); let _ = listen(handler); }); }); Ok(()) } #[inline(never)] pub fn prepare(identifier: &str) { let name: Name = identifier .to_ns_name::() .expect("Invalid identifier"); tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("failed to create tokio runtime") .block_on(async move { for _ in 0..3 { match LocalSocketStream::connect(name.clone()).await { Ok(conn) => { // We are the secondary instance. // Prep to activate primary instance by allowing another process to take focus. // A workaround to allow AllowSetForegroundWindow to succeed - press a key. // This was originally used by Chromium: https://bugs.chromium.org/p/chromium/issues/detail?id=837796 // dummy_keypress(); // let primary_instance_pid = conn.peer_pid().unwrap_or(ASFW_ANY); // unsafe { // let success = AllowSetForegroundWindow(primary_instance_pid) != 0; // if !success { // log::warn!("AllowSetForegroundWindow failed."); // } // } let (socket_rx, mut socket_tx) = conn.split(); let mut socket_rx = socket_rx.as_tokio_async_read(); let url = std::env::args().nth(1).expect("URL not provided"); socket_tx .write_all(url.as_bytes()) .await .expect("Failed to write to socket"); socket_tx .write_all(b"\n") .await .expect("Failed to write to socket"); socket_tx.flush().await.expect("Failed to flush socket"); let mut reader = BufReader::new(&mut socket_rx); let mut buf = String::new(); if let Err(e) = reader.read_line(&mut buf).await { eprintln!("Error reading from connection: {e}"); } buf.pop(); dummy_keypress(); let pid = buf.parse::().unwrap_or(ASFW_ANY); unsafe { let success = AllowSetForegroundWindow(pid) != 0; if !success { eprintln!("AllowSetForegroundWindow failed."); } } std::process::exit(0); } Err(e) => { eprintln!("Failed to connect to local socket: {e}"); std::thread::sleep(std::time::Duration::from_millis(1)); } }; } }); ID.set(identifier.to_string()) .expect("prepare() called more than once with different identifiers."); } /// Send a dummy keypress event so AllowSetForegroundWindow can succeed fn dummy_keypress() { let keyboard_input_down = KEYBDINPUT { wVk: 0, // This doesn't correspond to any actual keyboard key, but should still function for the workaround. dwExtraInfo: 0, wScan: 0, time: 0, dwFlags: 0, }; let mut keyboard_input_up = keyboard_input_down; keyboard_input_up.dwFlags = 0x0002; // KEYUP flag let input_down_u = INPUT_0 { ki: keyboard_input_down, }; let input_up_u = INPUT_0 { ki: keyboard_input_up, }; let input_down = INPUT { r#type: INPUT_KEYBOARD, Anonymous: input_down_u, }; let input_up = INPUT { r#type: INPUT_KEYBOARD, Anonymous: input_up_u, }; let ipsize = std::mem::size_of::() as i32; unsafe { SendInput(2, [input_down, input_up].as_ptr(), ipsize); }; } ================================================ FILE: cliff.toml ================================================ # git-cliff ~ configuration file # https://git-cliff.org/docs/configuration [changelog] # changelog header header = """ # Changelog\n All notable changes to this project will be documented in this file.\n """ # template for the changelog body # https://keats.github.io/tera/docs/#introduction body = """ {% set whitespace = " " %} {% if version %}\ ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} {% else %}\ ## [unreleased] {% endif %}\ {% for group, commits in commits | filter(attribute="breaking", value=true) | group_by(attribute="group") %} ### {{ group | upper_first }} {% for commit in commits | filter(attribute="scope") | sort(attribute="scope") %} - **{{ commit.scope | trim_end}}:**{{ whitespace }}{{ commit.message | upper_first | trim_end }}\ {% if commit.github.username %} by @{{ commit.github.username }} {% else %} by {{ commit.author.name }} {%- endif -%} {% if commit.github.pr_number %} in \ [#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}) \ {%- endif %} {% endfor %}\ {% for commit in commits %}{% if not commit.scope %} - {{ commit.message | upper_first | trim_end }}\ {% if commit.github.username %} by @{{ commit.github.username }} {% else %} by {{ commit.author.name }} {%- endif -%} {% if commit.github.pr_number %} in \ [#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}) \ {%- endif %} {% else %}{%- endif -%} {% endfor %} {% endfor %} {% for group, commits in commits | filter(attribute="breaking", value=false) | group_by(attribute="group") %} ### {{ group | upper_first }} {% for commit in commits | filter(attribute="scope") | sort(attribute="scope") %} - **{{ commit.scope | trim_end}}:**{{ whitespace }}{{ commit.message | upper_first | trim_end }}\ {% if commit.github.username %} by @{{ commit.github.username }} {% else %} by {{ commit.author.name }} {%- endif -%} {% if commit.github.pr_number %} in \ [#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}) \ {%- endif %} {% endfor %}\ {% for commit in commits %}{% if not commit.scope %} - {{ commit.message | upper_first | trim_end }}\ {% if commit.github.username %} by @{{ commit.github.username }} {% else %} by {{ commit.author.name }} {%- endif -%} {% if commit.github.pr_number %} in \ [#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}) \ {%- endif %} {% else %}{%- endif -%} {% endfor %} {% endfor %}\n {%- if github -%} ----------------- {% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} {% raw %}\n{% endraw -%} ## New Contributors {%- endif %}\ {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} * @{{ contributor.username }} made their first contribution {%- if contributor.pr_number %} in \ [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ {%- endif %} {%- endfor -%} {% if version %} {% if previous.version %} **Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }} {% endif %} {% else -%} {% raw %}\n{% endraw %} {% endif %} {% endif %} {%- macro remote_url() -%} https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} {%- endmacro -%} """ # template for the changelog footer footer = """""" # remove the leading and trailing whitespace from the templates trim = true [git] # parse the commits based on https://www.conventionalcommits.org conventional_commits = true # filter out the commits that are not conventional filter_unconventional = false # process each line of a commit as an individual commit split_commits = false # regex for parsing and grouping commits commit_parsers = [ { field = "author.name", pattern = "renovate\\[bot\\]", group = "Renovate", skip = true }, { field = "scope", pattern = "manifest", message = "^chore", skip = true }, { field = "breaking", pattern = "true", group = "💥 Breaking Changes" }, { message = "^feat", group = "✨ Features" }, { message = "^fix", group = "🐛 Bug Fixes" }, { message = "^doc", group = "📚 Documentation" }, { message = "^perf", group = "⚡ Performance Improvements" }, { message = "^refactor", group = "🔨 Refactor" }, { message = "^style", group = "💅 Styling" }, { message = "^test", group = "✅ Testing" }, { message = "^chore\\(release\\): prepare for", skip = true }, { message = "^chore: bump version", skip = true }, { message = "^chore", group = "🧹 Miscellaneous Tasks" }, { body = ".*security", group = "🛡️ Security" }, { body = ".*", group = "Other (unconventional)", skip = true }, ] # protect breaking changes from being skipped due to matching a skipping commit_parser protect_breaking_commits = false # filter out the commits that are not matched by commit parsers filter_commits = false # regex for matching git tags tag_pattern = "v[0-9].*" # regex for skipping tags skip_tags = "v0.1.0-beta.1" # regex for ignoring tags ignore_tags = "" # sort the tags topologically topo_order = false # sort the commits inside sections by oldest/newest order sort_commits = "newest" ================================================ FILE: commitlint.config.js ================================================ export default { extends: ['@commitlint/config-conventional'] } ================================================ FILE: frontend/interface/package.json ================================================ { "name": "@nyanpasu/interface", "version": "2.0.0", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", "require": { ".": "dist/src/index.js" }, "type": "module", "scripts": { "build": "tsc" }, "dependencies": { "@tanstack/react-query": "5.91.2", "@tauri-apps/api": "2.10.1", "ahooks": "3.9.6", "dayjs": "1.11.20", "lodash-es": "4.17.23", "ofetch": "1.5.1", "react": "19.2.4", "swr": "2.4.1" }, "devDependencies": { "@types/lodash-es": "4.17.12", "@types/react": "19.2.14" } } ================================================ FILE: frontend/interface/src/hooks/index.ts ================================================ export * from './use-kv-storage' ================================================ FILE: frontend/interface/src/hooks/use-kv-storage.ts ================================================ import { useCallback, useEffect, useRef, useState } from 'react' import { commands, events } from '../ipc/bindings' const LOCAL_CACHE_PREFIX = 'nyanpasu-kv-:' /** Mirrors the `WEB_STORAGE_KEY_PREFIX` constant on the backend. */ const WEB_KEY_PREFIX = 'web:' function getLocalCache(key: string, defaultValue: T): T { try { const raw = localStorage.getItem(LOCAL_CACHE_PREFIX + btoa(key)) if (raw === null) { return defaultValue } return JSON.parse(raw) as T } catch { return defaultValue } } function setLocalCache(key: string, value: T): void { try { localStorage.setItem(LOCAL_CACHE_PREFIX + btoa(key), JSON.stringify(value)) } catch { // ignore quota / security errors } } function removeLocalCache(key: string): void { localStorage.removeItem(LOCAL_CACHE_PREFIX + btoa(key)) } export interface UseKvStorageOptions { /** * Called with the raw parsed value when it is loaded from the backend. * Use this to transform old data shapes into the current shape. */ migrate?: (value: unknown) => T } /** * A `useState`-like hook backed by the Tauri/redb KV storage. * * - Reads the localStorage cache immediately so the UI has a value on first * render without flickering. * - Fetches the authoritative value from the backend on mount; the backend * always wins. * - Listens for `StorageValueChangedEvent` so all open windows stay in sync. * - Writing calls `commands.setStorageItem` and optimistically updates local * state; the subsequent backend event confirms the change. */ export function useKvStorage( key: string, defaultValue: T, options?: UseKvStorageOptions, ): readonly [ T, (value: T | ((prev: T) => T)) => Promise, { isLoading: boolean }, ] { const [value, setValueState] = useState(() => getLocalCache(key, defaultValue), ) const [isLoading, setIsLoading] = useState(true) // Stable refs to avoid stale closures const defaultValueRef = useRef(defaultValue) const valueRef = useRef(value) valueRef.current = value const migrateRef = useRef(options?.migrate) migrateRef.current = options?.migrate const applyMigrate = useCallback((raw: unknown): T => { return migrateRef.current ? migrateRef.current(raw) : (raw as T) }, []) // When key changes: reset to local cache and re-fetch from backend useEffect(() => { setValueState(getLocalCache(key, defaultValueRef.current)) setIsLoading(true) commands.getStorageItem(key).then((result) => { if (result.status === 'ok') { if (result.data !== null) { try { const parsed = JSON.parse(result.data) const migrated = applyMigrate(parsed) setValueState(migrated) setLocalCache(key, migrated) } catch { // backend returned non-JSON; keep local cache } } setIsLoading(false) } }) }, [key, applyMigrate]) // Listen for changes emitted from backend (any window). // The backend emits the raw storage key which includes the `web:` prefix. useEffect(() => { const unlistenPromise = events.storageValueChangedEvent.listen((event) => { if (event.payload.key !== WEB_KEY_PREFIX + key) { return } if (event.payload.value === null) { setValueState(defaultValueRef.current) removeLocalCache(key) } else { try { const parsed = JSON.parse(event.payload.value) const migrated = applyMigrate(parsed) setValueState(migrated) setLocalCache(key, migrated) } catch { // ignore invalid JSON from event } } }) return () => { unlistenPromise.then((fn) => fn()) } }, [key, applyMigrate]) const setValue = useCallback( async (newValue: T | ((prev: T) => T)) => { const resolved = typeof newValue === 'function' ? (newValue as (prev: T) => T)(valueRef.current) : newValue // Optimistic update — the backend event will also arrive and confirm setValueState(resolved) setLocalCache(key, resolved) const result = await commands.setStorageItem( key, JSON.stringify(resolved), ) if (result.status === 'error') { console.error('[useKvStorage] setStorageItem failed:', result.error) } }, [key], ) return [value, setValue, { isLoading }] as const } /** * Debug utilities for the backend KV store. * Not intended for production use — these bypass per-key subscriptions. */ export const kvStorageDebug = { /** Returns all stored key-value pairs with values deserialized from JSON. */ async getAll(): Promise> { const result = await commands.getAllStorageItems() if (result.status === 'error') { throw new Error(result.error) } return Object.fromEntries( result.data.map(({ key, value }) => { try { return [key, JSON.parse(value)] } catch { return [key, value] } }), ) }, /** Removes every entry from the backend storage. */ async clear(): Promise { const result = await commands.clearStorage() if (result.status === 'error') { throw new Error(result.error) } }, } ================================================ FILE: frontend/interface/src/index.ts ================================================ export * from './hooks' export * from './ipc' export * from './openapi' export * from './provider' export * from './service' export * from './template' export * from './utils' ================================================ FILE: frontend/interface/src/ipc/bindings.ts ================================================ /** tauri-specta globals **/ import { Channel as TAURI_CHANNEL, invoke as TAURI_INVOKE, } from '@tauri-apps/api/core' import * as TAURI_API_EVENT from '@tauri-apps/api/event' import { type WebviewWindow as __WebviewWindow__ } from '@tauri-apps/api/webviewWindow' /* oxlint-disable */ // @ts-nocheck // This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. /** user-defined commands **/ export const commands = { /** * get the system proxy * server field is the combination of host and port */ async getSysProxy(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_sys_proxy') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async openAppConfigDir(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('open_app_config_dir') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async openAppDataDir(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('open_app_data_dir') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async openLogsDir(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('open_logs_dir') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async openWebUrl(url: string): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('open_web_url', { url }) } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async openCoreDir(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('open_core_dir') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, /** * restart the sidecar */ async restartSidecar(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('restart_sidecar') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getClashInfo(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_clash_info') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getClashLogs(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_clash_logs') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, /** * patch clash runtime config */ async patchClashConfig( payload: PatchRuntimeConfig, ): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('patch_clash_config', { payload }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async changeClashCore( clashCore: ClashCore | null, ): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('change_clash_core', { clashCore }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, /** * get the runtime config */ async getRuntimeConfig(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_runtime_config') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getRuntimeYaml(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_runtime_yaml') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getRuntimeExists(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_runtime_exists') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getPostprocessingOutput(): Promise< Result > { try { return { status: 'ok', data: await TAURI_INVOKE('get_postprocessing_output'), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async clashApiGetProxyDelay( name: string, url: string | null, ): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('clash_api_get_proxy_delay', { name, url }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async invokeUwpTool(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('invoke_uwp_tool') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async fetchLatestCoreVersions(): Promise< Result > { try { return { status: 'ok', data: await TAURI_INVOKE('fetch_latest_core_versions'), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async updateCore(coreType: ClashCore): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('update_core', { coreType }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async inspectUpdater( updaterId: number, ): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('inspect_updater', { updaterId }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getCoreVersion(coreType: ClashCore): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_core_version', { coreType }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async collectLogs(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('collect_logs') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getVergeConfig(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_verge_config') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async patchVergeConfig(payload: IVerge): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('patch_verge_config', { payload }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getProfiles(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_profiles') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async enhanceProfiles(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('enhance_profiles') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, /** * 修改profiles的 */ async patchProfilesConfig( profiles: ProfilesBuilder, ): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('patch_profiles_config', { profiles }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async viewProfile(uid: string): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('view_profile', { uid }) } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, /** * update profile by uid */ async patchProfile( uid: string, profile: ProfileBuilder, ): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('patch_profile', { uid, profile }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, /** * create a new profile */ async createProfile( item: ProfileBuilder, fileData: string | null, ): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('create_profile', { item, fileData }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async importProfile( url: string, option: RemoteProfileOptionsBuilder | null, ): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('import_profile', { url, option }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async reorderProfile( activeId: string, overId: string, ): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('reorder_profile', { activeId, overId }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async reorderProfilesByList(list: string[]): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('reorder_profiles_by_list', { list }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async updateProfile( uid: string, option: RemoteProfileOptionsBuilder | null, ): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('update_profile', { uid, option }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async deleteProfile(uid: string): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('delete_profile', { uid }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async readProfileFile(uid: string): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('read_profile_file', { uid }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async saveProfileFile( uid: string, fileData: string | null, ): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('save_profile_file', { uid, fileData }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getCustomAppDir(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_custom_app_dir') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async setCustomAppDir(path: string): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('set_custom_app_dir', { path }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async statusService(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('status_service') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async installService(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('install_service') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async uninstallService(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('uninstall_service') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async startService(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('start_service') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async stopService(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('stop_service') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async restartService(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('restart_service') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async isPortable(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('is_portable') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getProxies(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_proxies') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async selectProxy( group: string, name: string, ): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('select_proxy', { group, name }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async updateProxyProvider(name: string): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('update_proxy_provider', { name }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async restartApplication(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('restart_application') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async collectEnvs(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('collect_envs') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getServerPort(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_server_port') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async setTrayIcon( mode: TrayIcon, path: string | null, ): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('set_tray_icon', { mode, path }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async isTrayIconSet(mode: TrayIcon): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('is_tray_icon_set', { mode }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getCoreStatus(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_core_status') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async urlDelayTest( url: string, expectedStatus: number, ): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('url_delay_test', { url, expectedStatus }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getIpsbAsn(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_ipsb_asn') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async openThat(path: string): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('open_that', { path }) } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async isAppimage(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('is_appimage') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getServiceInstallPrompt(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_service_install_prompt'), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async cleanupProcesses(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('cleanup_processes') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getStorageItem(key: string): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_storage_item', { key }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async setStorageItem( key: string, value: string, ): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('set_storage_item', { key, value }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async removeStorageItem(key: string): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('remove_storage_item', { key }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, /** * Debug: returns all frontend KV entries (keys with the `web:` prefix). * Internal storage entries used by other subsystems are excluded. */ async getAllStorageItems(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_all_storage_items') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, /** * Debug: clears all frontend KV entries (keys with the `web:` prefix). * Internal storage entries used by other subsystems are left intact. */ async clearStorage(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('clear_storage') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async mutateProxies(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('mutate_proxies') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getCoreDir(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('get_core_dir') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async getClashWsConnectionsState(): Promise< Result > { try { return { status: 'ok', data: await TAURI_INVOKE('get_clash_ws_connections_state'), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async checkUpdate(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('check_update') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async saveWindowSizeState(label: string): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('save_window_size_state', { label }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async createMainWindow(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('create_main_window') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async createLegacyWindow(): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('create_legacy_window') } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, async createEditorWindow(uid: string): Promise> { try { return { status: 'ok', data: await TAURI_INVOKE('create_editor_window', { uid }), } } catch (e) { if (e instanceof Error) throw e else return { status: 'error', error: e as any } } }, } /** user-defined events **/ export const events = __makeEvents__<{ clashConnectionsEvent: ClashConnectionsEvent reactAppMountedEvent: ReactAppMountedEvent storageValueChangedEvent: StorageValueChangedEvent windowMessageEvent: WindowMessageEvent }>({ clashConnectionsEvent: 'clash-connections-event', reactAppMountedEvent: 'react-app-mounted-event', storageValueChangedEvent: 'storage-value-changed-event', windowMessageEvent: 'window-message-event', }) /** user-defined constants **/ /** user-defined types **/ export type BreakWhenProxyChange = 'none' | 'chain' | 'all' export type BuildInfo = { app_name: string app_version: string pkg_version: string commit_hash: string commit_author: string commit_date: string build_date: string build_profile: string build_platform: string rustc_version: string llvm_version: string } export type ChunkStatus = { state: ChunkThreadState start: number end: number downloaded: number speed: number } export type ChunkThreadState = 'Idle' | 'Downloading' | 'Finished' export type ClashConnectionsConnectorEvent = | { kind: 'state_changed'; data: ClashConnectionsConnectorState } | { kind: 'update'; data: ClashConnectionsInfo } export type ClashConnectionsConnectorState = | 'disconnected' | 'connecting' | 'connected' export type ClashConnectionsEvent = ClashConnectionsConnectorEvent export type ClashConnectionsInfo = { downloadTotal: number uploadTotal: number downloadSpeed: number uploadSpeed: number } export type ClashCore = | 'clash' | 'clash-rs' | 'mihomo' | 'mihomo-alpha' | 'clash-rs-alpha' export type ClashCoreType = | 'mihomo' | 'mihomo-alpha' | 'clash-rs' | 'clash-rs-alpha' | 'clash' export type ClashInfo = { /** * clash core port */ port: number /** * same as `external-controller` */ server: string /** * clash secret */ secret: string | null } export type ClashStrategy = { external_controller_port_strategy: ExternalControllerPortStrategy } export type CoreInfos = { type: CoreType | null state: CoreState state_changed_at: number config_path: string | null } export type CoreState = 'Running' | { Stopped: string | null } export type CoreType = { clash: ClashCoreType } | 'singbox' export type DelayRes = { delay: number } export type DeviceInfo = { /** * Device name, such as "Intel Core i5-8250U CPU @ 1.60GHz x 8" */ cpu: string[] /** * GPU name, such as "Intel UHD Graphics 620 (Kabylake GT2)" * Memory size in bytes */ memory: string } export type DownloadStatus = { state: DownloaderState downloaded: number total: number speed: number chunks: ChunkStatus[] now: number } export type DownloaderState = | 'idle' | 'downloading' | 'waiting_for_merge' | 'merging' | { failed: string } | 'finished' export type EnvInfo = { os: string arch: string core: Partial<{ [key in string]: string }> device: DeviceInfo build_info: BuildInfo } export type ExternalControllerPortStrategy = | 'fixed' | 'random' | 'allow_fallback' export type GetSysProxyResponse = { enable: boolean host: string port: number bypass: string server: string } /** * ### `verge.yaml` schema */ export type IVerge = { /** * app listening port for app singleton */ app_singleton_port: number | null /** * app log level * silent | error | warn | info | debug | trace */ app_log_level: LoggingLevel | null language: string | null /** * `light` or `dark` or `system` */ theme_mode: string | null /** * enable traffic graph default is true */ traffic_graph: boolean | null /** * show memory info (only for Clash Meta) */ enable_memory_usage: boolean | null /** * global ui framer motion effects */ lighten_animation_effects: boolean | null /** * clash tun mode */ enable_tun_mode: boolean | null /** * windows service mode */ enable_service_mode?: boolean | null /** * can the app auto startup */ enable_auto_launch: boolean | null /** * not show the window on launch */ enable_silent_start: boolean | null /** * set system proxy */ enable_system_proxy: boolean | null /** * enable proxy guard */ enable_proxy_guard: boolean | null /** * set system proxy bypass */ system_proxy_bypass: string | null /** * proxy guard interval */ proxy_guard_interval: number | null /** * theme setting */ theme_color: string | null /** * web ui list */ web_ui_list: string[] | null /** * clash core path */ clash_core?: ClashCore | null /** * hotkey map * format: {func},{key} */ hotkeys: string[] | null /** * 切换代理时自动关闭连接 (已弃用) * @deprecated use `break_when_proxy_change` instead */ auto_close_connection: boolean | null /** * 切换代理时中断连接 * None: 不中断 * Chain: 仅中断使用该代理链的连接 * All: 中断所有连接 */ break_when_proxy_change: BreakWhenProxyChange | null /** * 切换配置时中断连接 * true: 中断所有连接 * false: 不中断连接 */ break_when_profile_change: boolean | null /** * 切换模式时中断连接 * true: 中断所有连接 * false: 不中断连接 */ break_when_mode_change: boolean | null /** * 默认的延迟测试连接 */ default_latency_test: string | null /** * 支持关闭字段过滤,避免meta的新字段都被过滤掉,默认为真 */ enable_clash_fields: boolean | null /** * 是否使用内部的脚本支持,默认为真 */ enable_builtin_enhanced: boolean | null /** * proxy 页面布局 列数 */ proxy_layout_column: number | null /** * 日志清理 * 分钟数; 0 为不清理 * @deprecated use `max_log_files` instead */ auto_log_clean: number | null /** * 日记轮转时间,单位:天 */ max_log_files: number | null /** * window size and position * @deprecated use `window_size_state` instead */ window_size_position?: number[] | null window_size_state?: WindowState | null /** * 是否启用随机端口 */ enable_random_port: boolean | null /** * verge mixed port 用于覆盖 clash 的 mixed port */ verge_mixed_port: number | null /** * Check update when app launch */ enable_auto_check_update: boolean | null /** * Clash 相关策略 */ clash_strategy: ClashStrategy | null /** * 是否启用代理托盘选择 */ clash_tray_selector: ProxiesSelectorMode | null always_on_top: boolean | null /** * Tun 堆栈选择 * TODO: 弃用此字段,转移到 clash config 里 */ tun_stack: TunStack | null /** * 是否启用网络统计信息浮窗 */ network_statistic_widget?: NetworkStatisticWidgetConfig | null /** * PAC URL for automatic proxy configuration * This field is used to set PAC proxy without exposing it to the frontend UI */ pac_url?: string | null /** * enable tray text display on Linux systems * When enabled, shows proxy and TUN mode status as text next to the tray icon * When disabled, only shows status via icon changes (prevents text display issues on Wayland) */ enable_tray_text: boolean | null /** * Use legacy UI (original UI at "/" route) * When true, opens legacy window; when false, opens new main window */ use_legacy_ui: boolean | null } export type JsonValue = | null | boolean | number | string | JsonValue[] | Partial<{ [key in string]: JsonValue }> export type LocalProfile = { /** * Profile ID */ uid: string /** * profile name */ name: string /** * profile holds the file */ file: string /** * profile description */ desc: string | null /** * update time */ updated: number } & { /** * file symlinks */ symlinks?: string | null /** * process chain */ chain?: string[] } /** * Builder for [`LocalProfile`](struct.LocalProfile.html). * */ export type LocalProfileBuilder = { /** * Profile ID */ uid: string | null /** * profile name */ name: string | null /** * profile holds the file */ file: string | null /** * profile description */ desc: string | null /** * update time */ updated: number | null } & { /** * file symlinks */ symlinks: string | null /** * process chain */ chain?: string[] | null } export type LogSpan = 'log' | 'info' | 'warn' | 'error' export type LoggingLevel = | 'silent' | 'trace' | 'debug' | 'info' | 'warn' | 'error' export type ManifestVersionLatest = { mihomo: string mihomo_alpha: string clash_rs: string clash_rs_alpha: string clash_premium: string } export type MergeProfile = { /** * Profile ID */ uid: string /** * profile name */ name: string /** * profile holds the file */ file: string /** * profile description */ desc: string | null /** * update time */ updated: number } /** * Builder for [`MergeProfile`](struct.MergeProfile.html). * */ export type MergeProfileBuilder = { /** * Profile ID */ uid: string | null /** * profile name */ name: string | null /** * profile holds the file */ file: string | null /** * profile description */ desc: string | null /** * update time */ updated: number | null } export type NetworkStatisticWidgetConfig = | { kind: 'disabled' } | { kind: 'enabled'; value: StatisticWidgetVariant } export type PatchRuntimeConfig = { allow_lan?: boolean | null ipv6?: boolean | null log_level?: string | null mode?: string | null } /** * 后处理输出 */ export type PostProcessingOutput = { /** * 局部链的输出 */ scopes: Partial<{ [key in string]: Partial<{ [key in string]: [LogSpan, string][] }> }> /** * 全局链的输出 */ global: Partial<{ [key in string]: [LogSpan, string][] }> /** * 根据配置进行的分析建议 */ advice: [LogSpan, string][] } export type Profile = | ({ type: 'remote' } & RemoteProfile) | ({ type: 'local' } & LocalProfile) | ({ type: 'merge' } & MergeProfile) | ({ type: 'script' } & ScriptProfile) export type ProfileBuilder = | ({ type: 'remote' } & RemoteProfileBuilder) | ({ type: 'local' } & LocalProfileBuilder) | ({ type: 'merge' } & MergeProfileBuilder) | ({ type: 'script' } & ScriptProfileBuilder) /** * Define the `profiles.yaml` schema */ export type Profiles = { /** * same as PrfConfig.current */ current?: string[] /** * same as PrfConfig.chain */ chain?: string[] /** * record valid fields for clash */ valid?: string[] /** * profile list */ items?: Profile[] } /** * Builder for [`Profiles`](struct.Profiles.html). * */ export type ProfilesBuilder = { /** * same as PrfConfig.current */ current: string[] | null /** * same as PrfConfig.chain */ chain: string[] | null /** * record valid fields for clash */ valid: string[] | null /** * profile list */ items: Profile[] | null } export type Proxies = { global: ProxyGroupItem direct: ProxyItem groups: ProxyGroupItem[] records: Partial<{ [key in string]: ProxyItem }> proxies: ProxyItem[] } export type ProxiesSelectorMode = 'hidden' | 'normal' | 'submenu' export type ProxyGroupItem = { name: string type: string udp: boolean history: ProxyItemHistory[] all: ProxyItem[] now: string | null provider: string | null alive: boolean | null xudp?: boolean | null tfo?: boolean | null icon?: string | null hidden?: boolean } export type ProxyItem = { name: string type: string udp: boolean history: ProxyItemHistory[] all: string[] | null now: string | null provider: string | null alive: boolean | null xudp?: boolean | null tfo?: boolean | null icon?: string | null hidden?: boolean } export type ProxyItemHistory = { time: string; delay: number } /** * Event emitted by the frontend when the React app is mounted. * Event name: `react-app-mounted-event` */ export type ReactAppMountedEvent = null export type RemoteProfile = { /** * Profile ID */ uid: string /** * profile name */ name: string /** * profile holds the file */ file: string /** * profile description */ desc: string | null /** * update time */ updated: number } & { /** * subscription url */ url: string /** * subscription user info */ extra?: SubscriptionInfo /** * remote profile options */ option?: RemoteProfileOptions /** * process chain */ chain?: string[] } /** * Builder for [`RemoteProfile`](struct.RemoteProfile.html). * */ export type RemoteProfileBuilder = { /** * Profile ID */ uid: string | null /** * profile name */ name: string | null /** * profile holds the file */ file: string | null /** * profile description */ desc: string | null /** * update time */ updated: number | null } & { /** * subscription url */ url: string | null /** * subscription user info */ extra: SubscriptionInfo | null /** * remote profile options */ option?: RemoteProfileOptionsBuilder /** * process chain */ chain?: string[] | null } export type RemoteProfileOptions = { /** * see issue #13 */ user_agent?: string | null /** * for `remote` profile * use system proxy */ with_proxy?: boolean | null /** * use self proxy */ self_proxy?: boolean | null /** * subscription update interval */ update_interval: number } /** * Builder for [`RemoteProfileOptions`](struct.RemoteProfileOptions.html). * */ export type RemoteProfileOptionsBuilder = { /** * see issue #13 */ user_agent: string | null /** * for `remote` profile * use system proxy */ with_proxy: boolean | null /** * use self proxy */ self_proxy: boolean | null /** * subscription update interval */ update_interval: number | null } export type RunType = /** * Run as child process directly */ | 'normal' /** * Run by Nyanpasu Service via a ipc call */ | 'service' /** * Run as elevated process, if profile advice to run as elevated */ | 'elevated' export type RuntimeInfos = { service_data_dir: string service_config_dir: string nyanpasu_config_dir: string nyanpasu_data_dir: string } export type ScriptProfile = { /** * Profile ID */ uid: string /** * profile name */ name: string /** * profile holds the file */ file: string /** * profile description */ desc: string | null /** * update time */ updated: number } & { script_type: ScriptType } /** * Builder for [`ScriptProfile`](struct.ScriptProfile.html). * */ export type ScriptProfileBuilder = { /** * Profile ID */ uid: string | null /** * profile name */ name: string | null /** * profile holds the file */ file: string | null /** * profile description */ desc: string | null /** * update time */ updated: number | null } & { script_type: ScriptType | null } export type ScriptType = 'javascript' | 'lua' export type ServiceStatus = 'not_installed' | 'stopped' | 'running' export type StatisticWidgetVariant = 'large' | 'small' export type StatusInfo = { name: string version: string status: ServiceStatus server: StatusResBody | null } export type StatusResBody = { version: string core_infos: CoreInfos runtime_infos: RuntimeInfos } export type StorageEntry = { key: string /** * Raw JSON-encoded value string. */ value: string } /** * Event emitted to all windows when a storage value changes. * Event name: `storage-value-changed-event` */ export type StorageValueChangedEvent = { key: string /** * The new JSON-encoded value, or `None` if the key was removed. */ value: string | null } export type SubscriptionInfo = { upload: number download: number total: number expire: number } export type TrayIcon = 'normal' | 'tun' | 'system_proxy' export type TunStack = 'system' | 'gvisor' | 'mixed' export type UpdateWrapper = { rid: number available: boolean current_version: string version: string date: string | null body: string | null raw_json: JsonValue } export type UpdaterState = | 'idle' | 'downloading' | 'decompressing' | 'replacing' | 'restarting' | 'done' | { failed: string } export type UpdaterSummary = { id: number state: UpdaterState downloader: DownloadStatus } /** * Message for inter-window communication */ export type WindowMessageEvent = { /** * Source window label */ from: string /** * Target window label (use "*" for broadcast) */ to: string /** * Message type/event name */ event: string /** * Message payload */ payload: JsonValue } export type WindowState = { width: number height: number x: number y: number maximized: boolean fullscreen: boolean } type __EventObj__ = { listen: ( cb: TAURI_API_EVENT.EventCallback, ) => ReturnType> once: ( cb: TAURI_API_EVENT.EventCallback, ) => ReturnType> emit: null extends T ? (payload?: T) => ReturnType : (payload: T) => ReturnType } export type Result = | { status: 'ok'; data: T } | { status: 'error'; error: E } function __makeEvents__>( mappings: Record, ) { return new Proxy( {} as unknown as { [K in keyof T]: __EventObj__ & { (handle: __WebviewWindow__): __EventObj__ } }, { get: (_, event) => { const name = mappings[event as keyof T] return new Proxy((() => {}) as any, { apply: (_, __, [window]: [__WebviewWindow__]) => ({ listen: (arg: any) => window.listen(name, arg), once: (arg: any) => window.once(name, arg), emit: (arg: any) => window.emit(name, arg), }), get: (_, command: keyof __EventObj__) => { switch (command) { case 'listen': return (arg: any) => TAURI_API_EVENT.listen(name, arg) case 'once': return (arg: any) => TAURI_API_EVENT.once(name, arg) case 'emit': return (arg: any) => TAURI_API_EVENT.emit(name, arg) } }, }) }, }, ) } ================================================ FILE: frontend/interface/src/ipc/consts.ts ================================================ import { getSystem } from '@/utils/get-system' /** * Operating system, used by useUpdaterSupported hook */ export const OS = getSystem() /** * Nyanpasu backend event name, use tauri event api to listen this event */ export const NYANPASU_BACKEND_EVENT_NAME = 'nyanpasu://mutation' /** * Is appimage query key, used by useIsAppImage hook */ export const IS_APPIMAGE_QUERY_KEY = 'is-appimage' /** * Service prompt query key, used by useServicePrompt hook */ export const SERVICE_PROMPT_QUERY_KEY = 'service-prompt' /** * Core dir query key, used by useCoreDir hook */ export const CORE_DIR_QUERY_KEY = 'core-dir' /** * Server port query key, used by useServerPort hook */ export const SERVER_PORT_QUERY_KEY = 'server-port' /** * Nyanpasu setting query key, used by useSettings hook */ export const NYANPASU_SETTING_QUERY_KEY = 'settings' /** * Nyanpasu system proxy query key, used by useSystemProxy hook */ export const NYANPASU_SYSTEM_PROXY_QUERY_KEY = 'system-proxy' /** * Nyanpasu chains log query key, fn: getPostProcessingOutput */ export const NYANPASU_POST_PROCESSING_QUERY_KEY = 'post-processing' /** * Clash version query key, used to fetch clash version from query */ export const CLASH_VERSION_QUERY_KEY = 'clash-version' /** * Nyanpasu profile query key, used to fetch profiles from query */ export const RROFILES_QUERY_KEY = 'profiles' /** * Clash log query key, used by clash ws provider to mutate logs via clash logs ws api */ export const CLASH_LOGS_QUERY_KEY = 'clash-logs' /** * Clash traffic query key, used by clash ws provider to mutate memory via clash traffic ws api */ export const CLASH_TRAAFFIC_QUERY_KEY = 'clash-traffic' /** * Clash memory query key, used by clash ws provider to mutate memory via clash memory ws api */ export const CLASH_MEMORY_QUERY_KEY = 'clash-memory' /** * Clash connections query key, used by clash ws provider to mutate connections via clash connections ws api */ export const CLASH_CONNECTIONS_QUERY_KEY = 'clash-connections' /** * Clash config query key, used by useClashConfig hook */ export const CLASH_CONFIG_QUERY_KEY = 'clash-config' /** * Clash core query key, used by useClashCores hook */ export const CLASH_CORE_QUERY_KEY = 'clash-core' /** * Clash info query key, used by useClashInfo hook */ export const CLASH_INFO_QUERY_KEY = 'clash-info' /** * Clash proxies query key, used by useClashProxies hook */ export const CLASH_PROXIES_QUERY_KEY = 'clash-proxies' /** * Clash rules query key, used by useClashRules hook */ export const CLASH_RULES_QUERY_KEY = 'clash-rules' /** * Clash rules provider query key, used by useClashRulesProvider hook */ export const CLASH_RULES_PROVIDER_QUERY_KEY = 'clash-rules-provider' /** * Clash proxies provider query key, used by useClashProxiesProvider hook */ export const CLASH_PROXIES_PROVIDER_QUERY_KEY = 'clash-proxies-provider' /** * Maximum connections history length, used by clash ws provider to limit connections history length */ export const MAX_CONNECTIONS_HISTORY = 32 /** * Maximum memory history length, used by clash ws provider to limit memory history length */ export const MAX_MEMORY_HISTORY = 32 /** * Maximum traffic history length, used by clash ws provider to limit traffic history length */ export const MAX_TRAFFIC_HISTORY = 32 /** * Maximum logs history length, used by clash ws provider to limit logs history length */ export const MAX_LOGS_HISTORY = 1024 ================================================ FILE: frontend/interface/src/ipc/index.ts ================================================ import { commands } from './bindings' export * from './consts' export * from './use-server-port' export * from './use-clash-config' export * from './use-clash-connections' export * from './use-clash-cores' export * from './use-clash-info' export * from './use-clash-logs' export * from './use-clash-memory' export * from './use-clash-proxies-provider' export * from './use-clash-proxies' export * from './use-clash-rules-provider' export * from './use-clash-rules' export * from './use-clash-traffic' export * from './use-clash-version' export * from './use-post-processing-output' export * from './use-profile-content' export * from './use-profile' export * from './use-proxy-mode' export * from './use-runtime-profile' export * from './use-settings' export * from './use-system-proxy' export * from './use-system-service' export * from './use-service-prompt' export * from './use-core-dir' export * from './use-platform' export { commands, events } from './bindings' export type * from './bindings' // manually added export const openUWPTool = commands.invokeUwpTool ================================================ FILE: frontend/interface/src/ipc/use-clash-config.ts ================================================ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ClashConfig, useClashAPI } from '../service/clash-api' import { unwrapResult } from '../utils' import { commands, PatchRuntimeConfig } from './bindings' import { CLASH_CONFIG_QUERY_KEY } from './consts' /** * A hook that manages fetching and updating the Clash configuration. * * @remarks * This hook fetches the current Clash configuration using a query keyed by `['clash-config']` * and allows updates via an upsert mutation. The upsert mutation: * - First updates the local configuration using `setConfigs`. * - Then patches the remote configuration through `commands.patchClashConfig`. * - On success, it invalidates the `['clash-config']` query, prompting a refetch to keep the configuration up-to-date. * * @returns An object with: * - `query`: The result of the useQuery hook that retrieves the current configuration. * - `upsert`: The mutation object that can be used to update the configuration. * * @example * const { query, upsert } = useClashConfig(); */ export const useClashConfig = () => { const { configs, patchConfigs } = useClashAPI() const queryClient = useQueryClient() /** * Retrieves the Clash configuration using a query. * * @remarks * The query is configured with the key 'clash-config' and uses the * getConfigs function as its query function. This setup ensures that: * - The data is uniquely identified and cached based on the query key. * - The asynchronous retrieval of configuration data is handled * via the getConfigs function. * * @see useQuery - For additional configuration options and usage details. */ const query = useQuery({ queryKey: [CLASH_CONFIG_QUERY_KEY], queryFn: configs, }) /** * Performs an upsert operation to update or insert the Clash configuration. * * This mutation function accepts a payload that extends both PatchRuntimeConfig and a partial version * of Clash.Config. It first updates the local configuration via the setConfigs function, then proceeds * to patch the remote configuration with commands.patchClashConfig. On a successful operation, it * invalidates the 'clash-config' query to prompt refetching of the newest configuration data. * * @remarks * Ensure that the payload conforms to both the PatchRuntimeConfig specifications and the partial structure * of Clash.Config as expected by the remote configuration endpoint. * * @returns A Promise resolving to the updated configuration, obtained by unwrapping the result of the * commands.patchClashConfig call. */ const upsert = useMutation({ mutationFn: async (payload: PatchRuntimeConfig & Partial) => { await patchConfigs(payload) return unwrapResult(await commands.patchClashConfig(payload)) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: [CLASH_CONFIG_QUERY_KEY] }) }, }) return { query, upsert, } } ================================================ FILE: frontend/interface/src/ipc/use-clash-connections.ts ================================================ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useClashAPI } from '../service/clash-api' import { CLASH_CONNECTIONS_QUERY_KEY } from './consts' export type ClashConnection = { downloadTotal: number uploadTotal: number memory?: number connections?: ClashConnectionItem[] } export type ClashConnectionItem = { id: string metadata: ClashConnectionMetadata upload: number download: number start: string chains: string[] rule: string rulePayload: string } export type ClashConnectionMetadata = { network: string type: string host: string sourceIP: string sourcePort: string destinationPort: string destinationIP?: string destinationIPASN?: string process?: string processPath?: string dnsMode?: string dscp?: number inboundIP?: string inboundName?: string inboundPort?: string inboundUser?: string remoteDestination?: string sniffHost?: string specialProxy?: string specialRules?: string } export const useClashConnections = () => { const queryClient = useQueryClient() const clashApi = useClashAPI() const query = useQuery({ queryKey: [CLASH_CONNECTIONS_QUERY_KEY], queryFn: () => { return ( queryClient.getQueryData([ CLASH_CONNECTIONS_QUERY_KEY, ]) || [] ) }, // Ensure the query is enabled and properly initialized enabled: true, staleTime: 0, // Data is always fresh as it comes from WebSocket }) const deleteConnections = useMutation({ mutationFn: async (id?: string | null) => { await clashApi.deleteConnections(id || undefined) const currentData = queryClient.getQueryData([ CLASH_CONNECTIONS_QUERY_KEY, ]) as ClashConnection[] if (id) { const lastConnections = currentData.at(-1)?.connections if (lastConnections) { const filteredConnections = lastConnections.filter( (conn) => conn.id !== id, ) const lastData = { ...currentData.at(-1)!, connections: filteredConnections, } queryClient.setQueryData( [CLASH_CONNECTIONS_QUERY_KEY], [...currentData.slice(0, -1), lastData], ) } } else { const lastData = currentData.at(-1) if (lastData) { const { downloadTotal, uploadTotal } = lastData queryClient.setQueryData( [CLASH_CONNECTIONS_QUERY_KEY], [ ...currentData.slice(0, -1), { downloadTotal, uploadTotal, }, ], ) } } }, }) return { query, deleteConnections, } } ================================================ FILE: frontend/interface/src/ipc/use-clash-cores.ts ================================================ import { kebabCase } from 'lodash-es' import { unwrapResult } from '@/utils' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { commands, type ClashCore } from './bindings' import { CLASH_CORE_QUERY_KEY, CLASH_VERSION_QUERY_KEY, NYANPASU_SETTING_QUERY_KEY, } from './consts' export const ClashCores = { clash: 'Clash Premium', mihomo: 'Mihomo', 'mihomo-alpha': 'Mihomo Alpha', 'clash-rs': 'Clash Rust', 'clash-rs-alpha': 'Clash Rust Alpha', } as Record export type ClashCoresInfo = Record export type ClashCoresDetail = { name: string currentVersion: string latestVersion?: string } export const useClashCores = () => { const queryClient = useQueryClient() const query = useQuery({ queryKey: [CLASH_CORE_QUERY_KEY], queryFn: async () => { return await Object.keys(ClashCores).reduce( async (acc, key) => { const result = await acc try { const currentVersion = unwrapResult(await commands.getCoreVersion(key as ClashCore)) ?? 'N/A' result[key as ClashCore] = { name: ClashCores[key as ClashCore], currentVersion, } } catch (e) { console.error('failed to fetch core version', e) result[key as ClashCore] = { name: ClashCores[key as ClashCore], currentVersion: 'N/A', } } return result }, Promise.resolve({} as ClashCoresInfo), ) }, }) const fetchRemote = useMutation({ mutationFn: async () => { const results = unwrapResult(await commands.fetchLatestCoreVersions()) if (!results) { return } const currentData = queryClient.getQueryData([ CLASH_CORE_QUERY_KEY, ]) as ClashCoresInfo if (currentData && results) { const updatedData = { ...currentData } Object.entries(results).forEach(([_key, latestVersion]) => { const key = kebabCase(_key) if (updatedData[key as ClashCore]) { updatedData[key as ClashCore] = { ...updatedData[key as ClashCore], latestVersion, } } }) queryClient.setQueryData([CLASH_CORE_QUERY_KEY], updatedData) } return results }, }) const updateCore = useMutation({ mutationFn: async (core: ClashCore) => { return unwrapResult(await commands.updateCore(core)) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: [CLASH_CORE_QUERY_KEY] }) }, }) const upsert = useMutation({ mutationFn: async (core: ClashCore) => { return unwrapResult(await commands.changeClashCore(core)) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: [CLASH_CORE_QUERY_KEY] }) queryClient.invalidateQueries({ queryKey: [NYANPASU_SETTING_QUERY_KEY] }) queryClient.invalidateQueries({ queryKey: [CLASH_VERSION_QUERY_KEY] }) }, }) const restartSidecar = async () => { return await commands.restartSidecar() } return { query, updateCore, upsert, restartSidecar, fetchRemote, } } ================================================ FILE: frontend/interface/src/ipc/use-clash-info.ts ================================================ import { unwrapResult } from '@/utils' import { useQuery } from '@tanstack/react-query' import { commands } from './bindings' import { CLASH_INFO_QUERY_KEY } from './consts' /** * A hook that retrieves and returns clash information using react-query. * * This hook leverages the useQuery hook to asynchronously fetch clash information by invoking * the getClashInfo command. The fetched result is processed via unwrapResult before being returned * alongside the query's state and metadata. * * @returns An object containing the properties of the query returned by useQuery, including loading, * error states, and the fetched data. */ export const useClashInfo = () => { const query = useQuery({ queryKey: [CLASH_INFO_QUERY_KEY], queryFn: async () => { return unwrapResult(await commands.getClashInfo()) }, }) return { ...query, } } ================================================ FILE: frontend/interface/src/ipc/use-clash-logs.ts ================================================ import { useMemoizedFn } from 'ahooks' import { useClashWSContext } from '@/provider/clash-ws-provider' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { CLASH_LOGS_QUERY_KEY } from './consts' export type ClashLog = { type: string time?: string payload: string } export const useClashLogs = () => { const { recordLogs, setRecordLogs } = useClashWSContext() const queryClient = useQueryClient() const query = useQuery({ queryKey: [CLASH_LOGS_QUERY_KEY], queryFn: () => { return queryClient.getQueryData([CLASH_LOGS_QUERY_KEY]) || [] }, }) const clean = useMutation({ mutationFn: async () => { await queryClient.setQueryData([CLASH_LOGS_QUERY_KEY], []) }, }) const status = recordLogs const enable = useMemoizedFn(() => { setRecordLogs(true) }) const disable = useMemoizedFn(() => { setRecordLogs(false) }) return { query, clean, status, enable, disable, } } ================================================ FILE: frontend/interface/src/ipc/use-clash-memory.ts ================================================ import { useQuery, useQueryClient } from '@tanstack/react-query' import { CLASH_MEMORY_QUERY_KEY } from './consts' export type ClashMemory = { inuse: number oslimit: number } export const useClashMemory = () => { const queryClient = useQueryClient() const query = useQuery({ queryKey: [CLASH_MEMORY_QUERY_KEY], queryFn: () => { return ( queryClient.getQueryData([CLASH_MEMORY_QUERY_KEY]) || [] ) }, }) return query } ================================================ FILE: frontend/interface/src/ipc/use-clash-proxies-provider.ts ================================================ import { useQuery } from '@tanstack/react-query' import { useClashAPI, type ClashProviderProxies } from '../service/clash-api' import { CLASH_PROXIES_PROVIDER_QUERY_KEY } from './consts' export interface ClashProxiesProviderQueryItem extends ClashProviderProxies { mutate: () => Promise } export type ClashProxiesProviderQuery = Record< string, ClashProxiesProviderQueryItem > export const useClashProxiesProvider = () => { const { providersProxies, putProvidersProxies } = useClashAPI() const query = useQuery({ queryKey: [CLASH_PROXIES_PROVIDER_QUERY_KEY], queryFn: async () => { const { providers } = await providersProxies() return Object.fromEntries( Object.entries(providers).map(([key, value]) => [ key, { ...value, mutate: async () => { await putProvidersProxies(key) await query.refetch() }, }, ]), ) }, }) return { ...query, } } ================================================ FILE: frontend/interface/src/ipc/use-clash-proxies.ts ================================================ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useClashAPI, type ClashDelayOptions } from '../service/clash-api' import { unwrapResult } from '../utils' import { commands, ProxyItemHistory, type Proxies, type ProxyGroupItem, type ProxyItem, } from './bindings' import { CLASH_PROXIES_QUERY_KEY } from './consts' export type ClashProxiesQueryHelperFn = { mutateDelay: (options?: ClashDelayOptions) => Promise } export interface ClashProxiesQueryProxyItem extends ProxyItem, ClashProxiesQueryHelperFn { mutateSelect: () => Promise } export interface ClashProxiesQueryGroupItem extends ProxyGroupItem, ClashProxiesQueryHelperFn { all: ClashProxiesQueryProxyItem[] } export interface ClashProxiesQuery extends Proxies { global: ClashProxiesQueryGroupItem groups: ClashProxiesQueryGroupItem[] } // Create a new proxy item with updated history const createUpdatedProxy = ( proxy: ClashProxiesQueryProxyItem, { name, delay }: { name: string; delay: number }, ) => { if (proxy.name !== name) return proxy const newHistory = [ ...proxy.history, { time: new Date().toISOString(), delay }, ] satisfies ProxyItemHistory[] return { ...proxy, history: newHistory } } export const useClashProxies = () => { const queryClient = useQueryClient() const { proxiesDelay, groupDelay } = useClashAPI() const proxies = useQuery({ queryKey: [CLASH_PROXIES_QUERY_KEY], queryFn: async () => { const result = unwrapResult(await commands.getProxies()) if (!result) { return } // Create helper functions to reduce code duplication const createProxyWithHelpers = ( proxy: ProxyItem, groupName: string, ): ClashProxiesQueryProxyItem => ({ ...proxy, mutateDelay: async (options?: ClashDelayOptions) => { await updateProxiesDelay.mutateAsync([proxy.name, options]) }, mutateSelect: async () => { await commands.selectProxy(groupName, proxy.name) await proxies.refetch() }, }) const createGroupWithHelpers = ( group: ProxyGroupItem, ): ClashProxiesQueryGroupItem => ({ ...group, mutateDelay: async (options?: ClashDelayOptions) => { await updateGroupDelay.mutateAsync([group.name, options]) }, all: group.all.map((proxy) => createProxyWithHelpers(proxy, group.name), ), }) // Apply helper functions to groups and global const groups = result.groups .filter((g) => !g.hidden) .map(createGroupWithHelpers) const global = createGroupWithHelpers(result.global) // merge the results & type validation const merged = { ...result, groups, global, } satisfies ClashProxiesQuery return merged }, }) const getQueryData = () => { return queryClient.getQueryData([CLASH_PROXIES_QUERY_KEY]) as | ClashProxiesQuery | undefined } const setQueryData = (data: ClashProxiesQuery) => { queryClient.setQueryData([CLASH_PROXIES_QUERY_KEY], data) } const updateProxiesDelay = useMutation({ mutationFn: async (args: Parameters) => { return { name: args[0], delay: (await proxiesDelay(...args)).delay, } }, onSuccess: ({ name, delay }) => { const oldData = getQueryData() if (!oldData) { return } // Create new data structure with updated proxies const newData = { ...oldData, global: { ...oldData.global, all: oldData.global.all.map((proxy) => createUpdatedProxy(proxy, { name, delay }), ), }, groups: oldData.groups.map((group) => ({ ...group, all: group.all.map((proxy) => createUpdatedProxy(proxy, { name, delay }), ), })), } satisfies ClashProxiesQuery setQueryData(newData) }, }) const updateGroupDelay = useMutation< Awaited>, unknown, Parameters, ReturnType >({ mutationFn: async (args: Parameters) => { return await groupDelay(...args) }, onMutate: () => { // Start polling proxies every 0.5 seconds const intervalId = setInterval(() => { proxies.refetch() }, 500) // Return interval ID to be used in onSettled return intervalId }, onSuccess: (data) => { const oldData = getQueryData() if (!oldData) { return } // Create new data structure with updated proxies const newData = { ...oldData, global: { ...oldData.global, all: oldData.global.all.map((proxy) => Object.prototype.hasOwnProperty.call(data, proxy.name) ? createUpdatedProxy(proxy, { name: proxy.name, delay: data[proxy.name], }) : { ...proxy, history: [], }, ), }, groups: oldData.groups.map((group) => ({ ...group, all: group.all.map((proxy) => Object.prototype.hasOwnProperty.call(data, proxy.name) ? createUpdatedProxy(proxy, { name: proxy.name, delay: data[proxy.name], }) : { ...proxy, history: [], }, ), })), } satisfies ClashProxiesQuery setQueryData(newData) }, onSettled: (_, __, ___, context) => { // Clear interval when mutation is settled (success or error) if (context !== undefined) { clearInterval(context) } }, }) return { proxies, updateProxiesDelay, updateGroupDelay, } } ================================================ FILE: frontend/interface/src/ipc/use-clash-rules-provider.ts ================================================ import { useQuery } from '@tanstack/react-query' import { useClashAPI, type ClashProviderRule } from '../service/clash-api' import { CLASH_RULES_PROVIDER_QUERY_KEY } from './consts' export interface ClashRulesProviderQueryItem extends ClashProviderRule { mutate: () => Promise } export type ClashRulesProviderQuery = Record< string, ClashRulesProviderQueryItem > export const useClashRulesProvider = () => { const { providersRules, putProvidersRules } = useClashAPI() const query = useQuery({ queryKey: [CLASH_RULES_PROVIDER_QUERY_KEY], queryFn: async () => { const { providers } = await providersRules() return Object.fromEntries( Object.entries(providers).map(([key, value]) => [ key, { ...value, mutate: async () => { await putProvidersRules(key) await query.refetch() }, }, ]), ) satisfies ClashRulesProviderQuery }, }) return { ...query, } } ================================================ FILE: frontend/interface/src/ipc/use-clash-rules.ts ================================================ import { useQuery } from '@tanstack/react-query' import { useClashAPI } from '../service/clash-api' import { CLASH_RULES_QUERY_KEY } from './consts' export const useClashRules = () => { const { rules } = useClashAPI() const query = useQuery({ queryKey: [CLASH_RULES_QUERY_KEY], queryFn: async () => { return await rules() }, }) return { ...query, } } ================================================ FILE: frontend/interface/src/ipc/use-clash-traffic.ts ================================================ import { useQuery, useQueryClient } from '@tanstack/react-query' import { CLASH_TRAAFFIC_QUERY_KEY } from './consts' export type ClashTraffic = { up: number down: number } export const useClashTraffic = () => { const queryClient = useQueryClient() const query = useQuery({ queryKey: [CLASH_TRAAFFIC_QUERY_KEY], queryFn: () => { return ( queryClient.getQueryData([CLASH_TRAAFFIC_QUERY_KEY]) || [] ) }, }) return query } ================================================ FILE: frontend/interface/src/ipc/use-clash-version.ts ================================================ import { useQuery } from '@tanstack/react-query' import { useClashAPI } from '../service/clash-api' import { CLASH_VERSION_QUERY_KEY } from './consts' export const useClashVersion = () => { const { version } = useClashAPI() const query = useQuery({ queryKey: [CLASH_VERSION_QUERY_KEY], queryFn: async () => { return await version() }, }) return query } ================================================ FILE: frontend/interface/src/ipc/use-clash-web-socket.ts ================================================ import { useWebSocket } from 'ahooks' import { useCallback, useMemo } from 'react' import { useClashInfo } from './use-clash-info' export const useClashWebSocket = () => { const { data: info } = useClashInfo() const wsBaseUrl = useMemo(() => `ws://${info?.server}`, [info?.server]) const tokenParams = useMemo( // must have token=, otherwise clash will return 403 () => `token=${encodeURIComponent(info?.secret || '')}`, [info?.secret], ) const resolveUrl = useCallback( (path: string) => { return `${wsBaseUrl}/${path}?${tokenParams}` }, [wsBaseUrl, tokenParams], ) const urls = useMemo(() => { if (info) { return { connections: resolveUrl('connections'), logs: resolveUrl('logs'), traffic: resolveUrl('traffic'), memory: resolveUrl('memory'), } } }, [info, resolveUrl]) const connectionsWS = useWebSocket(urls?.connections ?? '') const logsWS = useWebSocket(urls?.logs ?? '') const trafficWS = useWebSocket(urls?.traffic ?? '') const memoryWS = useWebSocket(urls?.memory ?? '') return { connectionsWS, logsWS, trafficWS, memoryWS, } } ================================================ FILE: frontend/interface/src/ipc/use-core-dir.ts ================================================ import { unwrapResult } from '@/utils' import { useQuery } from '@tanstack/react-query' import { commands } from './bindings' import { CORE_DIR_QUERY_KEY } from './consts' export const useCoreDir = () => { const query = useQuery({ queryKey: [CORE_DIR_QUERY_KEY], queryFn: async () => { return unwrapResult(await commands.getCoreDir()) }, }) return { ...query, } } ================================================ FILE: frontend/interface/src/ipc/use-platform.ts ================================================ import { useEffect, useState } from 'react' import { unwrapResult } from '@/utils' import { useQuery } from '@tanstack/react-query' import { commands } from './bindings' import { IS_APPIMAGE_QUERY_KEY, OS } from './consts' export const useIsAppImage = () => { const query = useQuery({ queryKey: [IS_APPIMAGE_QUERY_KEY], queryFn: async () => unwrapResult(await commands.isAppimage()), }) return { ...query, } } export function useUpdaterSupported() { const [supported, setSupported] = useState(false) const isAppImage = useIsAppImage() useEffect(() => { switch (OS) { case 'macos': case 'windows': setSupported(true) break case 'linux': setSupported(!!isAppImage.data) break } }, [isAppImage.data]) return supported } ================================================ FILE: frontend/interface/src/ipc/use-post-processing-output.ts ================================================ import { unwrapResult } from '@/utils' import { useQuery } from '@tanstack/react-query' import { commands } from './bindings' import { NYANPASU_POST_PROCESSING_QUERY_KEY } from './consts' /** * Custom hook for fetching post-processing output using React Query. * Another name is chains/script logs. * * This hook queries post-processing output data using a predefined query key * and fetches the data through the `commands.getPostprocessingOutput` command. * The result is unwrapped using the `unwrapResult` utility function. */ export const usePostProcessingOutput = () => { const query = useQuery({ queryKey: [NYANPASU_POST_PROCESSING_QUERY_KEY], queryFn: async () => { return unwrapResult(await commands.getPostprocessingOutput()) }, }) return { ...query, } } ================================================ FILE: frontend/interface/src/ipc/use-profile-content.ts ================================================ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { unwrapResult } from '../utils' import { commands } from './bindings' /** * A custom hook that manages profile content data fetching and updating. * * @remarks * This hook provides functionality to read and write profile content using React Query. * It includes both query and mutation capabilities for profile data management. * * @param uid - The unique identifier for the profile * * @returns An object containing: * - `query` - The React Query result object for fetching profile content * - `upsert` - Mutation object for saving/updating profile content * * @example * ```tsx * const { query, upsert } = useProfileContent("user123"); * const { data, isLoading } = query; * * // To update profile content * upsert.mutate("new profile content"); * ``` */ export const useProfileContent = (uid: string) => { const queryClient = useQueryClient() /** * A React Query hook that fetches profile content based on a user ID. * * @remarks * This query uses the `readProfileFile` command to retrieve profile data * and unwraps the result. * * @param uid - The user ID used to fetch the profile content * @returns A React Query result object containing the profile content data, * loading state, and error state * * @example * ```tsx * const { data, isLoading } = useQuery(['profileContent', userId]); * ``` */ const query = useQuery({ queryKey: ['profile-content', uid], queryFn: async () => { return unwrapResult(await commands.readProfileFile(uid)) }, enabled: !!uid, }) /** * Mutation hook for saving and updating profile file data * * @remarks * This mutation will invalidate the profile content query cache on success * * @example * ```ts * const { mutate } = upsert; * mutate("profile content"); * ``` * * @returns A mutation object that handles saving profile file data */ const upsert = useMutation({ mutationFn: async (fileData: string) => { return unwrapResult(await commands.saveProfileFile(uid, fileData)) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['profile-content', uid] }) }, }) return { query, upsert, } } ================================================ FILE: frontend/interface/src/ipc/use-profile.ts ================================================ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { unwrapResult } from '../utils' import { commands, Profile, type ProfileBuilder, type ProfilesBuilder, type RemoteProfileOptionsBuilder, } from './bindings' import { RROFILES_QUERY_KEY } from './consts' export type URLImportParams = Parameters export type ManualImportParams = Parameters export type CreateParams = | { type: 'url' data: { url: URLImportParams[0] option: URLImportParams[1] } } | { type: 'manual' data: { item: ManualImportParams[0] fileData: ManualImportParams[1] } } type ProfileHelperFn = { view: () => Promise update: (option: RemoteProfileOptionsBuilder) => Promise drop: () => Promise } export type ProfileQueryResult = NonNullable< ReturnType['query']['data'] > export type ProfileQueryResultItem = Profile & Partial /** * A custom hook for managing profiles with various operations including creation, updating, sorting, and deletion. * * @remarks * This hook provides comprehensive profile management functionality through React Query: * - Fetching profiles with optional helper functions * - Creating/importing profiles from URLs or files * - Updating existing profiles * - Reordering profiles * - Upserting profile configurations * - Deleting profiles * * Each operation automatically handles cache invalidation and refetching when successful. * * @param options - Configuration options for the hook * @param options.without_helper_fn - When true, disables the addition of helper functions to profile items * * @returns An object containing: * - query: Query result for fetching profiles * - create: Mutation for creating/importing profiles * - update: Mutation for updating existing profiles * - sort: Mutation for reordering profiles * - upsert: Mutation for upserting profile configurations * - drop: Mutation for deleting profiles * * @example * ```tsx * const { query, create, update, sort, upsert, drop } = useProfile(); * * // Fetch profiles * const profiles = query.data?.items; * * // Create a new profile * create.mutate({ type: 'file', data: { item: newProfile, fileData: 'config' }}); * * // Update a profile * update.mutate({ uid: 'profile-id', profile: updatedProfile }); * ``` */ export const useProfile = (options?: { without_helper_fn?: boolean }) => { const queryClient = useQueryClient() function addHelperFn(item: Profile): Profile & ProfileHelperFn { return { ...item, view: async () => unwrapResult(await commands.viewProfile(item.uid)), update: async (option: RemoteProfileOptionsBuilder) => await update.mutateAsync({ uid: item.uid, option }), drop: async () => await drop.mutateAsync(item.uid), } } /** * Retrieves and processes a list of profiles. * * This query uses the `useQuery` hook to fetch profile data by invoking the `commands.getProfiles()` command. * The raw result is first unwrapped using `unwrapResult`, and then each profile item is augmented with additional * helper functions: * * - view: Invokes `commands.viewProfile` with the profile's UID. * - update: Executes the update mutation by passing an object containing the UID and the new profile data. * - drop: Executes the drop mutation using the profile's UID. * * @returns A promise resolving to an object containing the profile list along with the extended helper functions. */ const query = useQuery({ queryKey: [RROFILES_QUERY_KEY], queryFn: async () => { const result = unwrapResult(await commands.getProfiles()) // Skip helper functions if without_helper_fn is set if (options?.without_helper_fn) { return result } return { ...result, items: result?.items?.map((item) => { return addHelperFn(item) }), } }, }) /** * Mutation hook for creating or importing profiles * * @remarks * This mutation handles two types of profile creation: * 1. URL-based import using `importProfile` command * 2. Direct creation using `createProfile` command * * @returns A mutation object that accepts CreateParams and handles profile creation * * @throws Will throw an error if the profile creation/import fails * * @example * ```ts * const { mutate } = create(); * // Import from URL * mutate({ type: 'url', data: { url: 'https://example.com/config.yaml', option: {...} }}); * // Create directly * mutate({ type: 'file', data: { item: {...}, fileData: '...' }}); * ``` */ const create = useMutation({ mutationFn: async ({ type, data }: CreateParams) => { if (type === 'url') { const { url, option } = data return unwrapResult(await commands.importProfile(url, option)) } else { const { item, fileData } = data return unwrapResult(await commands.createProfile(item, fileData)) } }, onSuccess: () => { // Invalidate and refetch queryClient.invalidateQueries({ queryKey: [RROFILES_QUERY_KEY] }) }, }) /** * Mutation hook for updating a profile. * Uses React Query's useMutation to handle the update operation. * * @param {Object} params - The parameters for the update operation * @param {string} params.uid - The unique identifier of the profile to update * @param {RemoteProfileOptionsBuilder} params.profile - The profile data to update * * @returns {UseMutationResult} A mutation result object containing the update operation status and methods * * @remarks * On successful update, it invalidates the profiles query cache */ const update = useMutation({ mutationFn: async ({ uid, option, }: { uid: string option: RemoteProfileOptionsBuilder }) => { return unwrapResult(await commands.updateProfile(uid, option)) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: [RROFILES_QUERY_KEY] }) }, }) /** * A mutation hook for updating a profile. * Uses React Query's useMutation to handle the profile update operation. * * @property {Function} mutationFn - Async function that patches the profile * @param {Object} params - The parameters for the mutation * @param {string} params.uid - The unique identifier of the profile * @param {ProfileBuilder} params.profile - The profile data to update * * @returns {UseMutationResult} A mutation result object containing the mutation state and functions * * @remarks * On successful mutation, it invalidates the profiles query cache, * triggering a refetch of the profiles data. */ const patch = useMutation({ mutationFn: async ({ uid, profile, }: { uid: string profile: ProfileBuilder }) => { return unwrapResult(await commands.patchProfile(uid, profile)) }, onSuccess: () => { // Invalidate and refetch queryClient.invalidateQueries({ queryKey: [RROFILES_QUERY_KEY] }) }, }) /** * Mutation hook for reordering profiles. * Uses the React Query's useMutation hook to handle profile reordering operations. * * @remarks * This mutation takes an array of profile UIDs and reorders them according to the new sequence. * On successful reordering, it invalidates the 'profiles' query cache to trigger a refresh. * * @example * ```typescript * const { mutate } = sort; * mutate(['uid1', 'uid2', 'uid3']); * ``` */ const sort = useMutation({ mutationFn: async (uids: string[]) => { return unwrapResult(await commands.reorderProfilesByList(uids)) }, onSuccess: () => { // Invalidate and refetch queryClient.invalidateQueries({ queryKey: [RROFILES_QUERY_KEY] }) }, }) /** * Mutation hook for upserting profile configurations. * * @remarks * This mutation handles the update/insert of profile configurations and invalidates * the profiles query cache on success. * * @returns A mutation object that: * - Accepts a ProfilesBuilder parameter for the mutation * - Returns the unwrapped result from patchProfilesConfig command * - Automatically invalidates the 'profiles' query cache on successful mutation */ const upsert = useMutation({ mutationFn: async (options: Partial) => { return unwrapResult( await commands.patchProfilesConfig(options as ProfilesBuilder), ) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: [RROFILES_QUERY_KEY] }) }, }) /** * A mutation hook for deleting a profile. * * @returns {UseMutationResult} A mutation object that: * - Accepts a profile UID as parameter * - Deletes the profile via commands.deleteProfile * - Automatically invalidates 'profiles' queries on success */ const drop = useMutation({ mutationFn: async (uid: string) => { return unwrapResult(await commands.deleteProfile(uid)) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: [RROFILES_QUERY_KEY] }) }, }) return { query, create, update, patch, sort, upsert, drop, } } ================================================ FILE: frontend/interface/src/ipc/use-proxy-mode.ts ================================================ import { useMemo } from 'react' import { useClashAPI } from '../service/clash-api' import { useClashConfig } from './use-clash-config' import { useSetting } from './use-settings' /** * Hook for managing proxy mode in Clash configuration * * @returns {Object} An object containing: * - value: Record of available proxy modes (rule, global, direct, script) with their active states * - upsert: Function to update the proxy mode and delete existing connections * * @remarks * - Script mode is only available when using Clash Premium * - Default mode is 'rule' if current mode is invalid or not set * - Changes to proxy mode will clear all existing connections */ export const useProxyMode = () => { const clashConfig = useClashConfig() const clashCore = useSetting('clash_core') const { deleteConnections } = useClashAPI() const value = useMemo(() => { const modes: Record<'rule' | 'global' | 'direct', boolean> & { script?: boolean } = { rule: false, global: false, direct: false, } // only clash premium support script mode if (clashCore.value === 'clash') { modes.script = false } const currentMode = clashConfig.query.data?.mode?.toLowerCase() if ( currentMode && Object.prototype.hasOwnProperty.call(modes, currentMode) ) { modes[currentMode as keyof typeof modes] = true } else { modes.rule = true } return modes }, [clashConfig.query.data, clashCore.value]) const upsert = async (mode: string) => { await deleteConnections() await clashConfig.upsert.mutateAsync({ mode }) } return { value, upsert, } } ================================================ FILE: frontend/interface/src/ipc/use-runtime-profile.ts ================================================ import { useQuery } from '@tanstack/react-query' import { unwrapResult } from '../utils' import { commands } from './bindings' /** * Custom hook for retrieving the runtime profile. * * This hook leverages the useQuery API to asynchronously retrieve and unwrap the runtime's YAML profile data * via the commands.getRuntimeYaml call. The resulting query object includes properties such as data, error, * status, and other metadata necessary to manage the loading state. * * @returns An object containing the query state and helper methods related to the runtime profile. */ export const useRuntimeProfile = () => { const query = useQuery({ queryKey: ['runtime-profile'], queryFn: async () => { return unwrapResult(await commands.getRuntimeYaml()) }, }) return { ...query, } } ================================================ FILE: frontend/interface/src/ipc/use-server-port.ts ================================================ import { getServerPort } from '@/service' import { useQuery } from '@tanstack/react-query' import { SERVER_PORT_QUERY_KEY } from './consts' export const useServerPort = () => { const { data: serverPort } = useQuery({ queryKey: [SERVER_PORT_QUERY_KEY], queryFn: () => getServerPort(), }) return serverPort } ================================================ FILE: frontend/interface/src/ipc/use-service-prompt.ts ================================================ import { unwrapResult } from '@/utils' import { useQuery } from '@tanstack/react-query' import { commands } from './bindings' import { SERVICE_PROMPT_QUERY_KEY } from './consts' export const useServicePrompt = () => { const query = useQuery({ queryKey: [SERVICE_PROMPT_QUERY_KEY], queryFn: async () => { return unwrapResult(await commands.getServiceInstallPrompt()) }, }) return { ...query, } } ================================================ FILE: frontend/interface/src/ipc/use-settings.ts ================================================ import { merge } from 'lodash-es' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { unwrapResult } from '../utils' import { commands, type IVerge } from './bindings' import { NYANPASU_SETTING_QUERY_KEY } from './consts' /** * Custom hook for managing Verge configuration settings using React Query. * Provides functionality to fetch and update settings with automatic cache invalidation. * * @returns An object containing: * - query: UseQueryResult for fetching settings * - data: Current Verge configuration * - status: Query status ('loading', 'error', 'success') * - error: Error object if query fails * - upsert: UseMutationResult for updating settings * - mutate: Function to update configuration * - status: Mutation status * * @example * ```tsx * const { query, upsert } = useSettings(); * * // Get current settings * const settings = query.data; * * // Update settings * upsert.mutate({ theme: 'dark' }); * ``` */ export const useSettings = () => { const queryClient = useQueryClient() /** * A query hook that fetches Verge configuration settings. * Uses React Query to manage the data fetching state. * * @returns UseQueryResult containing: * - data: The unwrapped Verge configuration data * - status: Current status of the query ('loading', 'error', 'success') * - error: Error object if the query fails * - other standard React Query properties */ const query = useQuery({ queryKey: [NYANPASU_SETTING_QUERY_KEY], queryFn: async () => { return unwrapResult(await commands.getVergeConfig()) }, }) /** * Mutation hook for updating Verge configuration settings * * @remarks * Uses React Query's useMutation to manage state and side effects * * @param options - Partial configuration options to update * @returns Mutation object containing mutate function and mutation state * * @example * ```ts * const { mutate } = upsert(); * mutate({ theme: 'dark' }); * ``` */ const upsert = useMutation({ // Partial to allow for partial updates mutationFn: async (options: Partial) => { return unwrapResult(await commands.patchVergeConfig(options as IVerge)) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: [NYANPASU_SETTING_QUERY_KEY], }) }, }) return { query, upsert, } } /** * A custom hook that manages a specific setting from the Verge configuration. * * @template K - The key type extending keyof IVerge * @param key - The specific setting key to manage * @returns An object containing: * - value: The current value of the specified setting * - upsert: Function to update the setting value * - Additional merged hook status properties * * @example * ```typescript * const { value, upsert } = useSetting('theme'); * // value contains current theme setting * // upsert can be used to update theme setting * ``` */ export const useSetting = (key: K) => { const { query: { data, ...query }, upsert: update, } = useSettings() /** * The value retrieved from the data object using the specified key. * May be undefined if either data is undefined or the key doesn't exist in data. */ const value = data?.[key] /** * Updates a specific setting value in the Verge configuration * @param value - The new value to be set for the specified key * @returns void * @remarks This function will not execute if the data is not available */ const upsert = async (value: IVerge[K]) => { if (!data) { return } await update.mutateAsync({ [key]: value }) } return { value, upsert, // merge hook status ...merge(query, update), } } ================================================ FILE: frontend/interface/src/ipc/use-system-proxy.ts ================================================ import { useUpdateEffect } from 'ahooks' import { useQuery } from '@tanstack/react-query' import { unwrapResult } from '../utils' import { commands } from './bindings' import { NYANPASU_SYSTEM_PROXY_QUERY_KEY } from './consts' import { useSetting } from './use-settings' /** * Custom hook to fetch and manage the system proxy settings. * * This hook leverages the `useQuery` hook to perform an asynchronous request * to obtain system proxy data via `commands.getSysProxy()`. The result of the query * is processed with `unwrapResult` to extract the proxy information. * * @returns An object containing the query results and helper properties/methods * (e.g., loading status, error, and refetch function) provided by `useQuery`. */ export const useSystemProxy = () => { const query = useQuery({ queryKey: [NYANPASU_SYSTEM_PROXY_QUERY_KEY], queryFn: async () => { return unwrapResult(await commands.getSysProxy()) }, refetchInterval: 5000, refetchIntervalInBackground: true, }) const { value } = useSetting('enable_system_proxy') useUpdateEffect(() => { query.refetch() }, [value]) return { ...query, } } ================================================ FILE: frontend/interface/src/ipc/use-system-service.ts ================================================ import { unwrapResult } from '@/utils' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { commands } from './bindings' export type ServiceType = 'install' | 'uninstall' | 'start' | 'stop' /** * Custom hook to fetch and manage the system service status using TanStack Query. * * @returns An object containing the query result for the system service status. */ export const useSystemService = () => { const queryClient = useQueryClient() const query = useQuery({ queryKey: ['system-service'], queryFn: async () => { return unwrapResult(await commands.statusService()) }, }) const upsert = useMutation({ mutationFn: async (type: ServiceType) => { switch (type) { case 'install': await commands.installService() break case 'uninstall': await commands.uninstallService() break case 'start': await commands.startService() break case 'stop': await commands.stopService() break } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['system-service'] }) }, }) return { query, upsert, } } ================================================ FILE: frontend/interface/src/openapi/geoip/index.ts ================================================ export * from './ipsb' ================================================ FILE: frontend/interface/src/openapi/geoip/ipsb.ts ================================================ import useSWR, { SWRConfiguration } from 'swr' import { getIpsbASN } from '@/service' export interface IPSBResponse { organization: string longitude: number timezone: string isp: string offset: number asn: number asn_organization: string country: string ip: string latitude: number continent_code: string country_code: string } export const useIPSB = (config?: SWRConfiguration) => { return useSWR('https://api.ip.sb/geoip', () => getIpsbASN(), config) } ================================================ FILE: frontend/interface/src/openapi/healthcheck/index.ts ================================================ import { createTiming } from './utils' export const timing = { Google: createTiming('https://www.gstatic.com/generate_204'), GitHub: createTiming('https://github.com/', 200), BingCN: createTiming('https://cn.bing.com/', 200), Baidu: createTiming('https://www.baidu.com/', 200), } ================================================ FILE: frontend/interface/src/openapi/healthcheck/utils.ts ================================================ import { urlDelayTest } from '@/service' export const timing = async (url: string, code: number) => { return (await urlDelayTest(url, code)) ?? 0 } export const createTiming = (url: string, code: number = 204) => { return () => timing(url, code) } ================================================ FILE: frontend/interface/src/openapi/index.ts ================================================ export * from './geoip' export * from './healthcheck' ================================================ FILE: frontend/interface/src/provider/clash-ws-provider.tsx ================================================ import { useUpdateEffect } from 'ahooks' import dayjs from 'dayjs' import { createContext, useContext, useState, type PropsWithChildren, } from 'react' import { useQueryClient } from '@tanstack/react-query' import { CLASH_CONNECTIONS_QUERY_KEY, CLASH_LOGS_QUERY_KEY, CLASH_MEMORY_QUERY_KEY, CLASH_TRAAFFIC_QUERY_KEY, MAX_CONNECTIONS_HISTORY, MAX_LOGS_HISTORY, MAX_MEMORY_HISTORY, MAX_TRAFFIC_HISTORY, } from '../ipc/consts' import type { ClashConnection } from '../ipc/use-clash-connections' import type { ClashLog } from '../ipc/use-clash-logs' import type { ClashMemory } from '../ipc/use-clash-memory' import type { ClashTraffic } from '../ipc/use-clash-traffic' import { useClashWebSocket } from '../ipc/use-clash-web-socket' // Utility functions for localStorage persistence const createPersistedState = (key: string, defaultValue: boolean) => { const getStoredValue = (): boolean => { try { const item = localStorage.getItem(key) return item ? JSON.parse(item) : defaultValue } catch { return defaultValue } } const setStoredValue = (value: boolean) => { try { localStorage.setItem(key, JSON.stringify(value)) } catch { // Ignore storage errors } } return { getStoredValue, setStoredValue } } const ClashWSContext = createContext<{ recordLogs: boolean setRecordLogs: (value: boolean) => void recordTraffic: boolean setRecordTraffic: (value: boolean) => void recordMemory: boolean setRecordMemory: (value: boolean) => void recordConnections: boolean setRecordConnections: (value: boolean) => void } | null>(null) export const useClashWSContext = () => { const context = useContext(ClashWSContext) if (!context) { throw new Error('useClashWSContext must be used in a ClashWSProvider') } return context } export const ClashWSProvider = ({ children }: PropsWithChildren) => { // Create persisted state handlers const logsStorage = createPersistedState('clash-ws-record-logs', true) const trafficStorage = createPersistedState('clash-ws-record-traffic', true) const memoryStorage = createPersistedState('clash-ws-record-memory', true) const connectionsStorage = createPersistedState( 'clash-ws-record-connections', true, ) // Initialize states with persisted values const [recordLogs, setRecordLogsState] = useState(logsStorage.getStoredValue) const [recordTraffic, setRecordTrafficState] = useState( trafficStorage.getStoredValue, ) const [recordMemory, setRecordMemoryState] = useState( memoryStorage.getStoredValue, ) const [recordConnections, setRecordConnectionsState] = useState( connectionsStorage.getStoredValue, ) // Wrapped setters that also persist to localStorage const setRecordLogs = (value: boolean) => { setRecordLogsState(value) logsStorage.setStoredValue(value) } const setRecordTraffic = (value: boolean) => { setRecordTrafficState(value) trafficStorage.setStoredValue(value) } const setRecordMemory = (value: boolean) => { setRecordMemoryState(value) memoryStorage.setStoredValue(value) } const setRecordConnections = (value: boolean) => { setRecordConnectionsState(value) connectionsStorage.setStoredValue(value) } const { connectionsWS, memoryWS, trafficWS, logsWS } = useClashWebSocket() const queryClient = useQueryClient() // clash connections useUpdateEffect(() => { if (!recordConnections) { return } const data = JSON.parse( connectionsWS.latestMessage?.data, ) as ClashConnection const currentData = queryClient.getQueryData([ CLASH_CONNECTIONS_QUERY_KEY, ]) as ClashConnection[] const newData = [...(currentData || []), data] if (newData.length > MAX_CONNECTIONS_HISTORY) { newData.shift() } queryClient.setQueryData([CLASH_CONNECTIONS_QUERY_KEY], newData) }, [connectionsWS.latestMessage]) // clash memory useUpdateEffect(() => { if (!recordMemory) { return } const data = JSON.parse(memoryWS.latestMessage?.data) as ClashMemory const currentData = queryClient.getQueryData([ CLASH_MEMORY_QUERY_KEY, ]) as ClashMemory[] const newData = [...(currentData || []), data] if (newData.length > MAX_MEMORY_HISTORY) { newData.shift() } queryClient.setQueryData([CLASH_MEMORY_QUERY_KEY], newData) }, [memoryWS.latestMessage]) // clash traffic useUpdateEffect(() => { if (!recordTraffic) { return } const data = JSON.parse(trafficWS.latestMessage?.data) as ClashTraffic const currentData = queryClient.getQueryData([ CLASH_TRAAFFIC_QUERY_KEY, ]) as ClashTraffic[] const newData = [...(currentData || []), data] if (newData.length > MAX_TRAFFIC_HISTORY) { newData.shift() } queryClient.setQueryData([CLASH_TRAAFFIC_QUERY_KEY], newData) }, [trafficWS.latestMessage]) // clash logs useUpdateEffect(() => { if (!recordLogs) { return } const data = { ...JSON.parse(logsWS.latestMessage?.data), time: dayjs(new Date()).format('HH:mm:ss'), } as ClashLog const currentData = queryClient.getQueryData([ CLASH_LOGS_QUERY_KEY, ]) as ClashLog[] const newData = [...(currentData || []), data] if (newData.length > MAX_LOGS_HISTORY) { newData.shift() } queryClient.setQueryData([CLASH_LOGS_QUERY_KEY], newData) }, [logsWS.latestMessage]) return ( {children} ) } ================================================ FILE: frontend/interface/src/provider/index.tsx ================================================ import type { PropsWithChildren } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ClashWSProvider, useClashWSContext } from './clash-ws-provider' import { MutationProvider } from './mutation-provider' const queryClient = new QueryClient() export const NyanpasuProvider = ({ children }: PropsWithChildren) => { return ( {children} ) } export { useClashWSContext } ================================================ FILE: frontend/interface/src/provider/mutation-provider.tsx ================================================ import { useMount } from 'ahooks' import { PropsWithChildren, useRef } from 'react' import { useQueryClient } from '@tanstack/react-query' import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { CLASH_CONFIG_QUERY_KEY, CLASH_INFO_QUERY_KEY, CLASH_VERSION_QUERY_KEY, NYANPASU_BACKEND_EVENT_NAME, NYANPASU_SETTING_QUERY_KEY, NYANPASU_SYSTEM_PROXY_QUERY_KEY, RROFILES_QUERY_KEY, } from '../ipc/consts' type EventPayload = 'nyanpasu_config' | 'clash_config' | 'proxies' | 'profiles' const NYANPASU_CONFIG_MUTATION_KEYS = [ NYANPASU_SETTING_QUERY_KEY, NYANPASU_SYSTEM_PROXY_QUERY_KEY, // TODO: proxies hook refetch // TODO: profiles hook refetch ] as const const CLASH_CONFIG_MUTATION_KEYS = [ CLASH_VERSION_QUERY_KEY, CLASH_INFO_QUERY_KEY, CLASH_CONFIG_QUERY_KEY, RROFILES_QUERY_KEY, // TODO: clash rules hook refetch // TODO: clash rules providers hook refetch // TODO: proxies hook refetch // TODO: proxies providers hook refetch // TODO: profiles hook refetch // TODO: all profiles providers hook refetch, key.includes('getAllProxiesProviders') ] as const const PROFILES_MUTATION_KEYS = [ CLASH_VERSION_QUERY_KEY, CLASH_INFO_QUERY_KEY, // TODO: clash rules hook refetch // TODO: clash rules providers hook refetch // TODO: proxies hook refetch // TODO: proxies providers hook refetch // TODO: profiles hook refetch // TODO: all profiles providers hook refetch, key.includes('getAllProxiesProviders') ] const PROXIES_MUTATION_KEYS = [ // TODO: key.includes('getProxies') ] as const export const MutationProvider = ({ children }: PropsWithChildren) => { const unlistenFn = useRef(null) const queryClient = useQueryClient() const refetchQueries = (keys: readonly string[]) => { Promise.all( keys.map((key) => queryClient.refetchQueries({ queryKey: [key], }), ), ).catch((e) => console.error(e)) } useMount(() => { listen(NYANPASU_BACKEND_EVENT_NAME, ({ payload }) => { console.log('MutationProvider', payload) switch (payload) { case 'nyanpasu_config': refetchQueries(NYANPASU_CONFIG_MUTATION_KEYS) break case 'clash_config': refetchQueries(CLASH_CONFIG_MUTATION_KEYS) break case 'profiles': refetchQueries(PROFILES_MUTATION_KEYS) break case 'proxies': refetchQueries(PROXIES_MUTATION_KEYS) break } }) .then((unlisten) => { unlistenFn.current = unlisten }) .catch((e) => { console.error(e) }) }) return children } ================================================ FILE: frontend/interface/src/service/clash-api.ts ================================================ import { ofetch } from 'ofetch' import { useMemo } from 'react' import type { ProxyGroupItem, SubscriptionInfo } from '../ipc/bindings' import { useClashInfo } from '../ipc/use-clash-info' const prepareServer = (server?: string) => { if (server?.startsWith(':')) { return `127.0.0.1${server}` } else if (server && /^\d+$/.test(server)) { return `127.0.0.1:${server}` } else { return server } } export interface ClashConfig { port: number mode: string ipv6: boolean 'socket-port': number 'allow-lan': boolean 'log-level': string 'mixed-port': number 'redir-port': number 'socks-port': number 'tproxy-port': number 'external-controller': string secret: string } export type ClashVersion = { premium?: boolean meta?: boolean version: string } export type ClashDelayOptions = { url?: string timeout?: number } export type ClashProxyGroupItem = ProxyGroupItem export type ClashProviderRule = { behavior: string format: string name: string ruleCount: number type: string updatedAt: string vehicleType: string } export type ClashProviderProxies = { name: string type: string proxies: ClashProxyGroupItem[] updatedAt?: string vehicleType: string subscriptionInfo?: SubscriptionInfo testUrl?: string } export type ClashRule = { type: string payload: string proxy: string } export const useClashAPI = () => { const { data } = useClashInfo() const request = useMemo(() => { return ofetch.create({ baseURL: `http://${prepareServer(data?.server)}`, headers: data?.secret ? { Authorization: `Bearer ${data?.secret}` } : undefined, }) }, [data]) /** * Fetches Clash configurations from the server. */ const configs = async () => { return await request('/configs') } /** * Update basic configuration; data must be sent in the format '{"mixed-port": 7890}', * modified as needed for the configuration items to be updated. */ const patchConfigs = async (config: Partial) => { return await request('/configs', { method: 'PATCH', body: config, }) } /** * Reload basic configuration; data must be sent, and the URL must include ?force=true to enforce execution. */ const putConfigs = async (config: Partial, force?: boolean) => { const url = force ? '/configs?force=true' : '/configs' return await request(url, { method: 'PUT', body: config, }) } const deleteConnections = async (id?: string) => { const url = id ? `/connections/${id}` : '/connections' return await request(url, { method: 'DELETE', }) } const version = async () => { return await request('/version') } const proxiesDelay = async (name: string, options?: ClashDelayOptions) => { return await request<{ delay: number }>( `/proxies/${encodeURIComponent(name)}/delay`, { params: { timeout: options?.timeout || 10000, url: options?.url || 'http://www.gstatic.com/generate_204', }, }, ) } const groupDelay = async (group: string, options?: ClashDelayOptions) => { return await request>( `/group/${encodeURIComponent(group)}/delay`, { params: { timeout: options?.timeout || 10000, url: options?.url || 'http://www.gstatic.com/generate_204', }, }, ) } const proxies = async () => { return await request<{ proxies: ClashProxyGroupItem[] }>('/proxies') } const putProxies = async ({ group, proxy, }: { group: string proxy: string }) => { return await request(`/proxies/${encodeURIComponent(group)}`, { method: 'PUT', body: { name: proxy }, }) } const rules = async () => { return await request<{ rules: ClashRule[] }>('/rules') } const providersRules = async () => { return await request<{ providers: Record }>( '/providers/rules', ) } const putProvidersRules = async (name: string) => { return await request(`/providers/rules/${encodeURIComponent(name)}`, { method: 'PUT', }) } const providersProxies = async (all?: string) => { const result = await request<{ providers: Record }>('/providers/proxies') if (all) { return result } return { providers: Object.fromEntries( Object.entries(result.providers).filter(([, value]) => ['http', 'file'].includes(value.vehicleType.toLowerCase()), ), ), } } const putProvidersProxies = async (name: string) => { return await request(`/providers/proxies/${encodeURIComponent(name)}`, { method: 'PUT', }) } return { configs, patchConfigs, putConfigs, deleteConnections, version, proxiesDelay, groupDelay, proxies, putProxies, rules, providersRules, putProvidersRules, providersProxies, putProvidersProxies, } } ================================================ FILE: frontend/interface/src/service/core.ts ================================================ import type { ClashCore } from '../ipc/bindings' import { fetchLatestCoreVersions, getCoreVersion } from './tauri' export interface Core { name: string core: ClashCore version?: string latest?: string } export const VALID_CORE: Core[] = [ { name: 'Clash Premium', core: 'clash' }, { name: 'Mihomo', core: 'mihomo' }, { name: 'Mihomo Alpha', core: 'mihomo-alpha' }, { name: 'Clash Rust', core: 'clash-rs' }, { name: 'Clash Rust Alpha', core: 'clash-rs-alpha' }, ] export const fetchCoreVersion = async () => { return await Promise.all( VALID_CORE.map(async (item) => { try { const version = await getCoreVersion(item.core) return { ...item, version } } catch (e) { console.error('failed to fetch core version', e) return { ...item, version: 'N/A' } } }), ) } export const fetchLatestCore = async () => { const results = await fetchLatestCoreVersions() const cores = VALID_CORE.map((item) => { const key = item.core.replace(/-/g, '_') as keyof typeof results let latest: string switch (item.core) { case 'clash': latest = `n${results['clash_premium']}` break case 'clash-rs': latest = results[key].replace(/v/, '') break default: latest = results[key] break } return { ...item, latest, } }) return cores } export enum SupportedArch { // blocked by clash-rs // WindowsX86 = "windows-x86", WindowsX86_64 = 'windows-x86_64', // blocked by clash-rs#212 // WindowsArm64 = "windows-arm64", LinuxAarch64 = 'linux-aarch64', LinuxAmd64 = 'linux-amd64', DarwinArm64 = 'darwin-arm64', DarwinX64 = 'darwin-x64', } export enum SupportedCore { Mihomo = 'mihomo', MihomoAlpha = 'mihomo_alpha', ClashRs = 'clash_rs', ClashPremium = 'clash_premium', } export type ArchMapping = { [key in SupportedArch]: string } export interface ManifestVersion { manifest_version: number latest: { [K in SupportedCore]: string } arch_template: { [K in SupportedCore]: ArchMapping } updated_at: string // ISO 8601 } ================================================ FILE: frontend/interface/src/service/index.ts ================================================ export * from './types' export * from './tauri' export * from './clash-api' export * from './core' ================================================ FILE: frontend/interface/src/service/tauri.ts ================================================ // oxlint-disable typescript/no-explicit-any import { IPSBResponse } from '@/openapi' import { invoke } from '@tauri-apps/api/core' import type { ClashInfo, Profile, Profiles, ProfilesBuilder, Proxies, RemoteProfileOptionsBuilder, } from '../ipc/bindings' import { ManifestVersion } from './core' import { EnvInfos, InspectUpdater, SystemProxy, VergeConfig } from './types' export const getNyanpasuConfig = async () => { return await invoke('get_verge_config') } export const patchNyanpasuConfig = async (payload: VergeConfig) => { return await invoke('patch_verge_config', { payload }) } export const getClashInfo = async () => { return await invoke('get_clash_info') } export const patchClashConfig = async (payload: Partial) => { return await invoke('patch_clash_config', { payload }) } export const getRuntimeExists = async () => { return await invoke('get_runtime_exists') } export const getRuntimeLogs = async () => { return await invoke>('get_runtime_logs') } export const createProfile = async ( item: Partial, fileData?: string | null, ) => { return await invoke('create_profile', { item, fileData }) } export const updateProfile = async ( uid: string, option?: RemoteProfileOptionsBuilder, ) => { return await invoke('update_profile', { uid, option }) } export const deleteProfile = async (uid: string) => { return await invoke('delete_profile', { uid }) } export const viewProfile = async (uid: string) => { return await invoke('view_profile', { uid }) } export const getProfiles = async () => { return await invoke('get_profiles') } export const setProfiles = async (payload: { uid: string profile: Partial }) => { return await invoke('patch_profile', payload) } export const setProfilesConfig = async (profiles: ProfilesBuilder) => { return await invoke('patch_profiles_config', { profiles }) } export const readProfileFile = async (uid: string) => { return await invoke('read_profile_file', { uid }) } export const saveProfileFile = async (uid: string, fileData: string) => { return await invoke('save_profile_file', { uid, fileData }) } export const importProfile = async ( url: string, option: RemoteProfileOptionsBuilder, ) => { return await invoke('import_profile', { url, option, }) } export const getCoreVersion = async ( coreType: Required['clash_core'], ) => { return await invoke('get_core_version', { coreType }) } export const setClashCore = async ( clashCore: Required['clash_core'], ) => { return await invoke('change_clash_core', { clashCore }) } export const restartSidecar = async () => { return await invoke('restart_sidecar') } export const fetchLatestCoreVersions = async () => { return await invoke('fetch_latest_core_versions') } export const updateCore = async ( coreType: Required['clash_core'], ) => { return await invoke('update_core', { coreType }) } export const inspectUpdater = async (updaterId: number) => { return await invoke('inspect_updater', { updaterId }) } export const pullupUWPTool = async () => { return await invoke('invoke_uwp_tool') } export const getSystemProxy = async () => { return await invoke('get_sys_proxy') } export const statusService = async () => { try { const result = await invoke<{ status: 'running' | 'stopped' | 'not_installed' }>('status_service') return result.status } catch (e) { console.error(e) return 'not_installed' } } export const installService = async () => { return await invoke('install_service') } export const uninstallService = async () => { return await invoke('uninstall_service') } export const startService = async () => { return await invoke('start_service') } export const stopService = async () => { return await invoke('stop_service') } export const restartService = async () => { return await invoke('restart_service') } export const openAppConfigDir = async () => { return await invoke('open_app_config_dir') } export const openAppDataDir = async () => { return await invoke('open_app_data_dir') } export const openCoreDir = async () => { return await invoke('open_core_dir') } export const getCoreDir = async () => { return await invoke('get_core_dir') } export const openLogsDir = async () => { return await invoke('open_logs_dir') } export const collectLogs = async () => { return await invoke('collect_logs') } export const setCustomAppDir = async (path: string) => { return await invoke('set_custom_app_dir', { path }) } export const restartApplication = async () => { return await invoke('restart_application') } export const isPortable = async () => { return await invoke('is_portable') } export const getProxies = async () => { return await invoke('get_proxies') } export const mutateProxies = async () => { return await invoke('mutate_proxies') } export const selectProxy = async (group: string, name: string) => { return await invoke('select_proxy', { group, name }) } export const updateProxyProvider = async (name: string) => { return await invoke('update_proxy_provider', { name }) } export const saveWindowSizeState = async () => { return await invoke('save_window_size_state') } export const collectEnvs = async () => { return await invoke('collect_envs') } export const getRuntimeYaml = async () => { return await invoke('get_runtime_yaml') } export const getServerPort = async () => { return await invoke('get_server_port') } export const setTrayIcon = async ( mode: 'tun' | 'system_proxy' | 'normal', path?: string, ) => { return await invoke('set_tray_icon', { mode, path }) } export const isTrayIconSet = async ( mode: 'tun' | 'system_proxy' | 'normal', ) => { return await invoke('is_tray_icon_set', { mode, }) } export const getCoreStatus = async () => { return await invoke< ['Running' | { Stopped: string | null }, number, 'normal' | 'service'] >('get_core_status') } export const urlDelayTest = async (url: string, expectedStatus: number) => { return await invoke('url_delay_test', { url, expectedStatus, }) } export const getIpsbASN = async () => invoke('get_ipsb_asn') export const openThat = async (path: string) => { return await invoke('open_that', { path }) } export const isAppImage = async () => { return await invoke('is_appimage') } export const getServiceInstallPrompt = async () => { return await invoke('get_service_install_prompt') } export const cleanupProcesses = async () => { return await invoke('cleanup_processes') } export const getStorageItem = async (key: string) => { return await invoke('get_storage_item', { key }) } export const setStorageItem = async (key: string, value: string) => { return await invoke('set_storage_item', { key, value }) } export const removeStorageItem = async (key: string) => { return await invoke('remove_storage_item', { key }) } export const reorderProfilesByList = async (list: string[]) => { return await invoke('reorder_profiles_by_list', { list }) } ================================================ FILE: frontend/interface/src/service/types.ts ================================================ export interface VergeConfig { app_log_level?: 'trace' | 'debug' | 'info' | 'warn' | 'error' | string language?: string clash_core?: | 'mihomo' | 'mihomo-alpha' | 'clash-rs' | 'clash-rs-alpha' | 'clash' theme_mode?: 'light' | 'dark' | 'system' theme_blur?: boolean traffic_graph?: boolean enable_memory_usage?: boolean lighten_animation_effects?: boolean enable_auto_check_update?: boolean enable_tun_mode?: boolean enable_auto_launch?: boolean enable_service_mode?: boolean enable_silent_start?: boolean enable_system_proxy?: boolean enable_random_port?: boolean verge_mixed_port?: number enable_proxy_guard?: boolean proxy_guard_interval?: number system_proxy_bypass?: string web_ui_list?: string[] hotkeys?: string[] theme_setting?: { primary_color?: string secondary_color?: string primary_text?: string secondary_text?: string info_color?: string error_color?: string warning_color?: string success_color?: string font_family?: string css_injection?: string page_transition_duration?: number } max_log_files?: number auto_close_connection?: boolean default_latency_test?: string enable_clash_fields?: boolean enable_builtin_enhanced?: boolean proxy_layout_column?: number clash_tray_selector?: 'normal' | 'hidden' | 'submenu' clash_strategy?: { external_controller_port_strategy: 'fixed' | 'random' | 'allow_fallback' } enable_tray_text?: boolean tun_stack?: 'system' | 'gvisor' | 'mixed' always_on_top?: boolean } export interface AutoReloadConfig { enabled: boolean onProxyChange: boolean onProfileChange: boolean onModeChange: boolean } export interface SystemProxy { enable: boolean server: string bypass: string } // eslint-disable-next-line @typescript-eslint/no-namespace export namespace Connection { export interface Item { id: string metadata: Metadata upload: number download: number start: string chains: string[] rule: string rulePayload: string } export interface Metadata { network: string type: string host: string sourceIP: string sourcePort: string destinationPort: string destinationIP?: string destinationIPASN?: string process?: string processPath?: string dnsMode?: string dscp?: number inboundIP?: string inboundName?: string inboundPort?: string inboundUser?: string remoteDestination?: string sniffHost?: string specialProxy?: string specialRules?: string } export interface Response { downloadTotal: number uploadTotal: number memory?: number connections?: Item[] } } export interface LogMessage { type: string time?: string payload: string } export interface ProviderRules { behavior: string format: string name: string ruleCount: number type: string updatedAt: string vehicleType: string } export interface Traffic { up: number down: number } export interface Memory { inuse: number oslimit: number } export interface EnvInfos { os: string arch: string core: { [key: string]: string } device: { cpu: Array memory: string } build_info: { [key: string]: string } } export interface InspectUpdater { id: number state: | 'idle' | 'downloading' | 'decompressing' | 'replacing' | 'restarting' | 'done' | { failed: string } downloader: { state: | 'idle' | 'downloading' | 'waiting_for_merge' | 'merging' | { failed: string } | 'finished' downloaded: number total: number speed: number chunks: Array<{ state: 'idle' | 'downloading' | 'finished' start: number end: number downloaded: number speed: number }> now: number } } ================================================ FILE: frontend/interface/src/template/index.ts ================================================ // nyanpasu merge profile chain template const merge = `# Clash Nyanpasu Merge Template (YAML) # Documentation on https://nyanpasu.elaina.moe/ # Set the default merge strategy to recursive merge. # Enable the old mode with the override__ prefix. # Use the filter__ prefix to filter lists (removing unwanted content). # All prefixes should support accessing maps or lists with a.b.c syntax. ` // nyanpasu javascript profile chain template const javascript = `// Clash Nyanpasu JavaScript Template // Documentation on https://nyanpasu.elaina.moe/ /** @type {config} */ export default function (profile) { return profile; } ` // nyanpasu lua profile chain template const luascript = `-- Clash Nyanpasu Lua Script Template -- Documentation on https://nyanpasu.elaina.moe/ return config; ` // clash profile template example const profile = `# Clash Nyanpasu Profile Template # Documentation on https://nyanpasu.elaina.moe/ proxies: proxy-groups: rules: ` export const ProfileTemplate = { merge, javascript, luascript, profile, } as const ================================================ FILE: frontend/interface/src/utils/get-system.ts ================================================ type Platform = | 'aix' | 'android' | 'darwin' | 'freebsd' | 'haiku' | 'linux' | 'openbsd' | 'sunos' | 'win32' | 'cygwin' | 'netbsd' declare const OS_PLATFORM: Platform | undefined // get the system os // according to UA export function getSystem() { const ua = typeof window === 'undefined' ? '' : window.navigator?.userAgent const platform = typeof OS_PLATFORM !== 'undefined' ? OS_PLATFORM : 'unknown' if (ua.includes('Mac OS X') || platform === 'darwin') return 'macos' if (/win64|win32/i.test(ua) || platform === 'win32') return 'windows' if (/linux/i.test(ua)) return 'linux' return 'unknown' } ================================================ FILE: frontend/interface/src/utils/index.ts ================================================ import type { Result } from '../ipc/bindings' export function unwrapResult(res: Result) { if (res.status === 'error') { throw res.error } return res.status === 'ok' ? res.data : undefined } export * from './get-system' export * from './retry' ================================================ FILE: frontend/interface/src/utils/retry.ts ================================================ /** * Retry a function with exponential backoff * * @param fn - The function to retry * @param options - Retry options * @returns The result of the function * @throws The last error encountered */ export async function retry( fn: () => Promise, options: { maxRetries?: number initialDelay?: number maxDelay?: number factor?: number retryCondition?: (error: Error) => boolean } = {}, ): Promise { const { maxRetries = 3, initialDelay = 200, maxDelay = 5000, factor = 2, retryCondition = () => true, } = options let lastError: Error = new Error('Unknown error occurred') let delay = initialDelay for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn() } catch (error) { if (attempt === maxRetries || !retryCondition(error as Error)) { throw error } lastError = error as Error // Wait for the specified delay await new Promise((resolve) => setTimeout(resolve, delay)) // Increase the delay for the next attempt (exponential backoff) delay = Math.min(delay * factor, maxDelay) } } throw lastError } ================================================ FILE: frontend/interface/tsconfig.json ================================================ { "compilerOptions": { "baseUrl": ".", "target": "ESNext", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ESNext"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "composite": true, "declaration": true, "paths": { "@/*": ["./src/*"], }, "outDir": "./dist", "sourceMap": true, }, "include": ["src"], } ================================================ FILE: frontend/nyanpasu/.gitignore ================================================ .tanstack ================================================ FILE: frontend/nyanpasu/.vscode/extensions.json ================================================ { "recommendations": ["inlang.vs-code-extension"] } ================================================ FILE: frontend/nyanpasu/auto-imports.d.ts ================================================ /* eslint-disable */ /* prettier-ignore */ // @ts-nocheck // noinspection JSUnusedGlobalSymbols // Generated by unplugin-auto-import // biome-ignore lint: disable export {} declare global { } ================================================ FILE: frontend/nyanpasu/index.html ================================================ <%- title %> <%- injectScript %>
================================================ FILE: frontend/nyanpasu/messages/en.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "language": "English", "navbar_label_dashboard": "Dashboard", "navbar_label_proxies": "Proxies", "navbar_label_profiles": "Profiles", "navbar_label_connections": "Connections", "navbar_label_logs": "Logs", "navbar_label_rules": "Rules", "navbar_label_settings": "Settings", "navbar_label_providers": "Providers", "header_help_action_title": "Help", "header_help_action_wiki": "Official Wiki", "header_help_action_issues": "Report Issue", "header_help_action_collect_logs": "Collect Logs", "header_help_action_about": "About", "header_settings_action_title": "Settings", "header_settings_action_language": "Language", "header_settings_action_theme_mode": "Theme Mode", "header_file_action_title": "File", "header_file_action_import_local_profile": "Import Local Profile", "settings_system_proxy_title": "System Settings", "settings_system_proxy_proxy_mode_label": "Proxy Mode", "settings_system_proxy_system_proxy_label": "System Proxy", "settings_system_proxy_tun_mode_label": "TUN Mode", "settings_system_proxy_proxy_guard_label": "Proxy Guard", "settings_system_proxy_proxy_guard_switch_label": "System Proxy Guard", "settings_system_proxy_proxy_guard_switch_description": "After enabling, the system proxy will automatically detect the proxy settings and automatically modify them to the settings in the program", "settings_system_proxy_proxy_guard_interval_label": "System Proxy Guard Interval", "settings_system_proxy_proxy_bypass_label": "System Proxy Bypass", "settings_system_proxy_current_system_proxy_label": "Current System Proxy", "settings_system_proxy_service_mode_label": "Service Mode", "settings_system_proxy_service_mode_description": "Service is used to manage the Clash core, to achieve minimum permission acquisition, only providing the necessary permissions to the core, for scenarios such as the TUN mode.", "settings_system_proxy_service_mode_disabled_tooltip": "To enable service mode, make sure the Clash Nyanpasu service is installed and started", "settings_system_proxy_system_service_ctrl_label": "System Service", "settings_system_proxy_system_service_ctrl_detail": "Service Detail", "settings_system_proxy_system_service_ctrl_install": "Install", "settings_system_proxy_system_service_ctrl_uninstall": "Uninstall", "settings_system_proxy_system_service_ctrl_failed_install": "Install failed", "settings_system_proxy_system_service_ctrl_failed_uninstall": "Uninstall failed", "settings_system_proxy_system_service_ctrl_prompt": "Service Prompt", "settings_system_proxy_system_service_ctrl_manual_prompt": "Service Manual Tips", "settings_system_proxy_system_service_ctrl_manual_operation_prompt": "Unable to control the service automatically. Please navigate to the core directory, run PowerShell as administrator on Windows or a terminal emulator on macOS/Linux, and execute the following commands:", "settings_system_proxy_system_service_ctrl_start": "Start", "settings_system_proxy_system_service_ctrl_stop": "Stop", "settings_system_proxy_launch_label": "Launch Settings", "settings_system_proxy_auto_launch_label": "Auto Launch", "settings_system_proxy_silent_start_label": "Silent Start", "settings_system_proxy_windows_tools_label": "Windows Tools", "settings_system_proxy_uwp_tools_label": "UWP Loopback Tools", "settings_system_proxy_uwp_tools_description": "Used to solve the problem of Windows UWP applications not being able to access the network through the local proxy", "settings_user_interface_title": "User Interface", "settings_user_interface_language_group": "Language Settings", "settings_user_interface_language_label": "Language", "settings_user_interface_theme_mode_group": "Theme Settings", "settings_user_interface_theme_mode_label": "Theme Mode", "settings_user_interface_theme_mode_light": "Light", "settings_user_interface_theme_mode_dark": "Dark", "settings_user_interface_theme_mode_system": "System", "settings_user_interface_theme_color_label": "Theme Color", "settings_user_interface_theme_color_custom": "Custom", "settings_clash_settings_title": "Clash Settings", "settings_clash_settings_allow_lan_label": "Allow LAN", "settings_clash_settings_ipv6_label": "Enable IPv6", "settings_clash_settings_tun_stack_label": "TUN Stack", "settings_clash_settings_log_level_label": "Log Level", "settings_clash_settings_port_label": "Port Settings", "settings_clash_settings_mixed_port_label": "Mixed Port", "settings_clash_settings_random_port_label": "Random Port", "settings_clash_settings_random_port_enabled": "Random port enabled, after restart to take effect.", "settings_clash_settings_random_port_disabled": "Random port disabled, after restart to take effect.", "settings_clash_settings_external_controll_label": "External Controller", "settings_clash_settings_port_strategy_label": "Port Strategy", "settings_clash_settings_allow_fallback_label": "Allow Fallback", "settings_clash_settings_fixed_label": "Fixed", "settings_clash_settings_random_label": "Random", "settings_clash_settings_core_secret_label": "API Secret", "settings_clash_settings_field_filter_label": "Clash Field Filter", "settings_clash_settings_field_filter_nyanpasu_control_fields": "Nyanpasu Control Fields", "settings_web_ui_title": "Web UI", "settings_web_ui_add_button": "Add new", "settings_web_ui_empty_item": "No records found, try adding one", "settings_web_ui_input_label": "Enter HTTP address", "settings_web_ui_replace_with_label": "Replace host, port and secret with", "settings_web_ui_preview_title": "Preview", "settings_clash_core_manager_card_title": "Core Manager", "settings_clash_core_manager_card_loading": "Executing operation...", "settings_clash_core_manager_card_loading_error": "Execution failed, please check the log", "settings_clash_core_manager_card_loading_success": "Execution successful", "settings_clash_core_manager_card_restart_sidecar": "Restart Core", "settings_clash_core_manager_card_restart_sidecar_error": "Restart core failed, please check the log", "settings_clash_core_manager_card_restart_sidecar_success": "Restart core successfully", "settings_clash_core_manager_card_fetch_remote": "Check Updates", "settings_clash_core_manager_card_click_to_update": "Click to update", "settings_clash_core_manager_card_decompressing": "Decompressing...", "settings_clash_core_manager_card_replacing": "Replacing...", "settings_clash_core_manager_card_restarting": "Restarting...", "settings_clash_core_manager_card_done": "Done", "settings_debug_utils_open_config_directory": "Open Config Directory", "settings_debug_utils_open_data_directory": "Open Data Directory", "settings_debug_utils_open_core_directory": "Open Core Directory", "settings_debug_utils_open_log_directory": "Open Log Directory", "settings_nyanpasu_max_log_files_label": "Max Log Files", "settings_label_system": "System Settings", "settings_label_system_description": "Proxy mode, proxy bypass, auto start, silent start and more.", "settings_label_user_interface": "User Interface", "settings_label_user_interface_description": "Language, theme mode, theme color and more.", "settings_label_clash_settings": "Clash Settings", "settings_label_clash_settings_description": "Clash configuration, log level, mixed port, random port and more.", "settings_label_external_controll": "Clash External Control", "settings_label_external_controll_description": "Web UI address, port strategy, API key and more.", "settings_label_nyanpasu": "Nyanpasu Settings", "settings_label_nyanpasu_description": "Nyanpasu specific settings", "settings_label_debug": "Debug Tools", "settings_label_debug_description": "Debug tools and more.", "settings_label_about": "About", "settings_label_about_description": "About Clash Nyanpasu", "settings_label_about_update": "Check Update", "settings_label_about_auto_check_updates": "Auto Check Updates", "settings_label_about_update_to_github_releases": "Visit GitHub Releases", "settings_label_about_version": "Version: v{version}", "settings_label_about_update_has_new_version": "A new version is available", "settings_label_about_update_no_update": "The current version is the latest version, no update information found", "settings_label_about_update_to_update_button": "Download and Install", "settings_label_about_update_installing": "Installing...", "profile_subscription_title": "Subscription", "profile_subscription_updated_at": "{updated} updated", "profile_subscription_next_update_at": "Next update at {next} update", "profile_subscription_expires_in": "{expires} expires", "profile_subscription_update": "Update", "profile_base_info_title": "Basic Info", "profile_name_editor_title": "Edit Name", "profile_name_label": "Profile Name", "profile_update_option_edit": "Sub. Opts", "profile_update_option_editor_title": "Edit Subscription Options", "profile_user_agent_label": "User Agent (UA)", "profile_with_proxy_label": "Use System Proxy", "profile_self_proxy_label": "Use Clash Proxy", "profile_update_interval_label": "Auto Update Interval (minutes)", "profile_subscription_url_editor_label": "Edit Sub. URL", "profile_subscription_url_label": "Subscription URL", "profile_delete_title": "Delete Profile", "profile_delete_description": "This action cannot be reverted. Are you sure you want to delete this profile?", "profile_view_content_title": "Profile Content", "profile_pending_mask_message": "Executing operation…", "profile_active_title": "Activate Profile", "profile_is_active_description": "The current profile is already active, no need to repeat the operation.", "profile_active_title_success": "Profile {name} activated successfully!", "profile_active_title_error": "Profile {name} activation failed, please check the profile or proxy chain is correct", "profile_open_locally_title": "Open Profile File", "profile_chain_editor_active_column": "Active Proxy Chain", "profile_chain_editor_inactive_column": "Inactive Proxy Chain", "profile_chain_editor_apply_message": "Applying proxy chain…", "profile_quick_import_placeholder": "Enter URL or paste link to quickly import profile", "profile_quick_import_success_message": "Profile imported successfully, please check the list", "profile_view_details_title": "Profile Details", "profile_no_more_profiles": "No more profiles 0.0", "profile_import_title": "Import Profile", "profile_import_remote_title": "Remote Profile", "profile_import_local_title": "Local Profile", "profile_empty_list_message": "No profiles found, please try importing or creating a profile.", "profile_import_remote_url_label": "Remote Profile URL", "profile_profile_label": "Profiles", "profile_javascript_label": "JavaScript scripts", "profile_lua_label": "Lua scripts", "profile_merge_label": "Merge (YAML)", "profile_profile_label_count": "{count} profiles", "profile_form_name_label": "Name", "profile_form_desc_label": "Description", "profile_form_url_label": "Subscription URL", "profile_form_option_label": "Profile Options", "profile_form_option_user_agent_label": "User Agent (UA)", "profile_form_option_with_proxy_label": "Use System Proxy", "profile_form_option_self_proxy_label": "Use Clash Proxy", "profile_form_option_update_interval_label": "Auto Update Interval (minutes)", "profile_import_local_file_placeholder": "Click or drag file here to import", "profile_import_local_file_type_label": "Supported files: {types}", "profile_import_local_file_size_label": "File size: {size}", "profile_import_chain_title": "New {type}", "proxies_group_delay_test_title": "Delay Test", "proxies_group_delay_test_pending_title": "Testing…", "proxies_group_empty_message": "No proxy groups found, please try switching profiles", "proxies_group_empty_button_text": "Switch to profiles", "profile_is_active_label": "Current Profile", "profile_remote_label": "Remote Profile", "profile_local_label": "Local Profile", "logs_search_placeholder": "Search logs (time, type, or message)...", "logs_empty_message": "No logs recorded", "logs_action_clear_log": "Clear Logs", "rules_list_all_proxies": "All Groups", "connections_all_connections": "All Connections", "connections_search_placeholder": "Search connections (host, process, rule, chains...)...", "connections_close_all_connections": "Close All Connections", "connections_empty_message": "No connections found", "connections_close_connection": "Close Connection", "connections_view_details": "Details", "providers_proxies_title": "Proxy Groups", "providers_rules_title": "Rule Sets", "providers_no_proxies_message": "Current profile does not have any proxy groups", "providers_no_rules_message": "Current profile does not have any rule sets", "providers_proxies_proxy_count_label": "{count} Proxies", "providers_rules_rule_count_label": "{count} Rules", "providers_info_title": "Resource Info", "providers_subscription_title": "Subscription Info", "providers_update_provider": "Update", "editor_before_close_message": "You have not saved the edited content, are you sure you want to close the editor?", "editor_validate_error_message": "Please fix the error before saving content", "editor_read_only_chip": "Read Only", "unit_seconds": "s", "common_submit": "Submit", "common_cancel": "Cancel", "common_apply": "Apply", "common_reset": "Reset", "common_save": "Save", "common_validate": "Validate", "common_close": "Close", "common_copy": "Copy", "common_open": "Open", "common_cut": "Cut", "common_paste": "Paste" } ================================================ FILE: frontend/nyanpasu/messages/ru.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "language": "Русский", "navbar_label_dashboard": "Панель управления", "navbar_label_proxies": "Прокси", "navbar_label_profiles": "Профили", "navbar_label_connections": "Соединения", "navbar_label_logs": "Журналы", "navbar_label_rules": "Правила", "navbar_label_settings": "Настройки", "navbar_label_providers": "Поставщики", "header_help_action_title": "Помощь", "header_help_action_wiki": "Онлайн-документация", "header_help_action_issues": "Сообщить о проблеме", "header_help_action_collect_logs": "Собрать журналы", "header_help_action_about": "О программе", "header_settings_action_title": "Настройки", "header_settings_action_language": "Язык", "header_settings_action_theme_mode": "Режим темы", "header_file_action_title": "Файл", "header_file_action_import_local_profile": "Импорт локального профиля", "settings_system_proxy_title": "Системный прокси", "settings_system_proxy_proxy_mode_label": "Режим прокси", "settings_system_proxy_system_proxy_label": "Системный прокси", "settings_system_proxy_tun_mode_label": "TUN режим", "settings_system_proxy_proxy_guard_label": "Охрана прокси", "settings_system_proxy_proxy_guard_switch_label": "Системный прокси", "settings_system_proxy_proxy_guard_switch_description": "После включения, системный прокси будет автоматически обнаруживать настройки прокси и автоматически изменять их на настройки в программе", "settings_system_proxy_proxy_guard_interval_label": "Интервал охраны прокси", "settings_system_proxy_proxy_bypass_label": "Обход прокси", "settings_system_proxy_current_system_proxy_label": "Текущий системный прокси", "settings_system_proxy_service_mode_label": "Режим службы", "settings_system_proxy_service_mode_description": "Служба используется для управления ядром, чтобы достичь минимального получения прав, только предоставляя ядру необходимые права, для сценариев, таких как режим TUN.", "settings_system_proxy_service_mode_disabled_tooltip": "Чтобы включить режим службы, убедитесь, что служба Clash Nyanpasu установлена и запущена", "settings_system_proxy_system_service_ctrl_label": "Системный сервис", "settings_system_proxy_system_service_ctrl_detail": "Подробности сервиса", "settings_system_proxy_system_service_ctrl_install": "Установить", "settings_system_proxy_system_service_ctrl_uninstall": "Удалить", "settings_system_proxy_system_service_ctrl_failed_install": "Установка не удалась", "settings_system_proxy_system_service_ctrl_failed_uninstall": "Удаление не удалось", "settings_system_proxy_system_service_ctrl_prompt": "Подсказка по сервису", "settings_system_proxy_system_service_ctrl_manual_prompt": "Руководство по ручному управлению", "settings_system_proxy_system_service_ctrl_manual_operation_prompt": "Не удалось автоматически управлять сервисом. Пожалуйста, перейдите в каталог ядра, запустите PowerShell как администратор на Windows или эмулятор терминала на macOS/Linux и выполните следующие команды:", "settings_system_proxy_system_service_ctrl_start": "Запустить", "settings_system_proxy_system_service_ctrl_stop": "Остановить", "settings_system_proxy_launch_label": "Настройки запуска", "settings_system_proxy_auto_launch_label": "Автозапуск", "settings_system_proxy_silent_start_label": "Тихий запуск", "settings_system_proxy_windows_tools_label": "Инструменты Windows", "settings_system_proxy_uwp_tools_label": "Инструменты UWP Loopback", "settings_system_proxy_uwp_tools_description": "Используется для решения проблемы, когда Windows UWP приложения не могут получить доступ к сети через локальный прокси", "settings_user_interface_title": "Интерфейс пользователя", "settings_user_interface_language_group": "Настройки языка", "settings_user_interface_language_label": "Язык", "settings_user_interface_theme_mode_group": "Настройки темы", "settings_user_interface_theme_mode_label": "Режим темы", "settings_user_interface_theme_mode_light": "Светлый", "settings_user_interface_theme_mode_dark": "Темный", "settings_user_interface_theme_mode_system": "Системный", "settings_user_interface_theme_color_label": "Цвет темы", "settings_user_interface_theme_color_custom": "Пользовательский", "settings_clash_settings_title": "Настройки Clash", "settings_clash_settings_allow_lan_label": "Разрешить LAN", "settings_clash_settings_ipv6_label": "Включить IPv6", "settings_clash_settings_tun_stack_label": "Стек TUN", "settings_clash_settings_log_level_label": "Уровень журнала", "settings_clash_settings_port_label": "Настройки порта", "settings_clash_settings_mixed_port_label": "Смешанный порт", "settings_clash_settings_random_port_label": "Случайный порт", "settings_clash_settings_random_port_enabled": "Случайный порт включен, после перезапуска для вступления в силу.", "settings_clash_settings_random_port_disabled": "Случайный порт отключен, после перезапуска для вступления в силу.", "settings_clash_settings_external_controll_label": "Внешнее управление", "settings_clash_settings_port_strategy_label": "Стратегия порта", "settings_clash_settings_allow_fallback_label": "Разрешить откат", "settings_clash_settings_fixed_label": "Фиксированный", "settings_clash_settings_random_label": "Случайный", "settings_clash_settings_core_secret_label": "API ключ", "settings_clash_settings_field_filter_label": "Фильтр полей Clash", "settings_clash_settings_field_filter_nyanpasu_control_fields": "Управляющие поля Nyanpasu", "settings_web_ui_title": "Web UI", "settings_web_ui_add_button": "Добавить", "settings_web_ui_empty_item": "Не найдено записей, попробуйте добавить одну", "settings_web_ui_input_label": "Введите HTTP-адрес", "settings_web_ui_replace_with_label": "Заменить хост, порт и секрет на", "settings_web_ui_preview_title": "Предварительный просмотр", "settings_clash_core_manager_card_title": "Менеджер ядра", "settings_clash_core_manager_card_loading": "Выполнение операции...", "settings_clash_core_manager_card_loading_error": "Выполнение операции не удалось, пожалуйста, проверьте журнал", "settings_clash_core_manager_card_loading_success": "Выполнение операции успешно", "settings_clash_core_manager_card_restart_sidecar": "Перезапустить ядро", "settings_clash_core_manager_card_restart_sidecar_error": "Перезапуск ядра не удалось, пожалуйста, проверьте журнал", "settings_clash_core_manager_card_restart_sidecar_success": "Перезапуск ядра успешно", "settings_clash_core_manager_card_fetch_remote": "Проверить обновления", "settings_clash_core_manager_card_click_to_update": "Нажмите для обновления", "settings_clash_core_manager_card_decompressing": "Распаковка...", "settings_clash_core_manager_card_replacing": "Замена...", "settings_clash_core_manager_card_restarting": "Перезапуск...", "settings_clash_core_manager_card_done": "Готово", "settings_debug_utils_open_config_directory": "Открыть конфигурационную директорию", "settings_debug_utils_open_data_directory": "Открыть директорию данных", "settings_debug_utils_open_core_directory": "Открыть директорию ядра", "settings_debug_utils_open_log_directory": "Открыть директорию журналов", "settings_nyanpasu_max_log_files_label": "Максимальное количество файлов журнала", "settings_label_system": "Системные настройки", "settings_label_system_description": "Режим прокси, обход прокси, автозапуск, старт без отображения окна и т.д.", "settings_label_user_interface": "Интерфейс пользователя", "settings_label_user_interface_description": "Язык, режим темы, цвет темы и т.д.", "settings_label_clash_settings": "Настройки Clash", "settings_label_clash_settings_description": "Конфигурация Clash, уровень логирования, смешанный порт, случайный порт и т.д.", "settings_label_external_controll": "Внешнее управление Clash", "settings_label_external_controll_description": "Адрес Web UI, стратегия порта, API ключ и т.д.", "settings_label_nyanpasu": "Настройки Nyanpasu", "settings_label_nyanpasu_description": "Специфические настройки Nyanpasu", "settings_label_debug": "Отладочные инструменты", "settings_label_debug_description": "Отладочные инструменты и т.д.", "settings_label_about": "О программе", "settings_label_about_description": "О Clash Nyanpasu", "settings_label_about_update": "Проверить обновление", "settings_label_about_auto_check_updates": "Автоматически проверять обновления", "settings_label_about_update_to_github_releases": "Перейти на GitHub Releases", "settings_label_about_version": "Версия: v{version}", "settings_label_about_update_has_new_version": "Доступна новая версия", "settings_label_about_update_no_update": "Текущая версия является последней версией, не найдено информации об обновлении", "settings_label_about_update_to_update_button": "Скачать и установить", "settings_label_about_update_installing": "Установка...", "profile_subscription_title": "Информация о подписке", "profile_subscription_updated_at": "{updated} обновлено", "profile_subscription_next_update_at": "Следующее обновление: {next}", "profile_subscription_expires_in": "{expires} истекает", "profile_subscription_update": "Обновить", "profile_base_info_title": "Основная информация", "profile_name_editor_title": "Редактировать название", "profile_name_label": "Название профиля", "profile_update_option_edit": "Опции подписки", "profile_update_option_editor_title": "Редактировать опции подписки", "profile_user_agent_label": "Пользовательский агент (UA)", "profile_with_proxy_label": "Использовать системный прокси", "profile_self_proxy_label": "Использовать прокси Clash", "profile_update_interval_label": "Интервал автоматического обновления (минуты)", "profile_subscription_url_editor_label": "Редактировать URL подписки", "profile_subscription_url_label": "URL подписки", "profile_delete_title": "Удалить профиль", "profile_delete_description": "Это действие не может быть отменено. Вы уверены, что хотите удалить этот профиль?", "profile_view_content_title": "Содержимое профиля", "profile_pending_mask_message": "Выполнение операции…", "profile_active_title": "Активировать профиль", "profile_is_active_description": "Текущий профиль уже активирован, повторная активация не требуется.", "profile_active_title_success": "Профиль {name} успешно активирован!", "profile_active_title_error": "Активация профиля {name} не удалась, пожалуйста, проверьте профиль или цепочку прокси", "profile_open_locally_title": "Открыть файл профиля", "profile_chain_editor_active_column": "Активный профиль", "profile_chain_editor_inactive_column": "Неактивный профиль", "profile_chain_editor_apply_message": "Применение цепочки прокси…", "profile_quick_import_placeholder": "Введите URL или вставьте ссылку для быстрого импорта профиля", "profile_quick_import_success_message": "Профиль успешно импортирован, пожалуйста, проверьте список", "profile_view_details_title": "Подробности профиля", "profile_no_more_profiles": "Нет больше профилей 0.0", "profile_import_title": "Импорт профиля", "profile_import_remote_title": "Удаленный профиль", "profile_import_local_title": "Локальный профиль", "profile_empty_list_message": "Не найдено ни одного профиля, пожалуйста, попробуйте импортировать или создать профиль.", "profile_import_remote_url_label": "URL удаленного профиля", "profile_profile_label": "Профили", "profile_javascript_label": "JavaScript скрипты", "profile_lua_label": "Lua скрипты", "profile_merge_label": "Слияние скриптов (YAML)", "profile_profile_label_count": "{count} профилей", "profile_form_name_label": "Название", "profile_form_desc_label": "Описание", "profile_form_url_label": "URL подписки", "profile_form_option_label": "Опции профиля", "profile_form_option_user_agent_label": "Пользовательский агент (UA)", "profile_form_option_with_proxy_label": "Использовать системный прокси", "profile_form_option_self_proxy_label": "Использовать прокси Clash", "profile_form_option_update_interval_label": "Интервал автоматического обновления (минуты)", "profile_import_local_file_placeholder": "Нажмите или перетащите файл сюда для импорта", "profile_import_local_file_type_label": "Поддерживаемые файлы: {types}", "profile_import_local_file_size_label": "Размер файла: {size}", "profile_import_chain_title": "Новый {type}", "proxies_group_delay_test_title": "Тест задержки", "proxies_group_delay_test_pending_title": "Тестирование…", "proxies_group_empty_message": "Не найдено ни одной группы прокси, пожалуйста, попробуйте переключить профили", "proxies_group_empty_button_text": "Перейти к профилям", "profile_is_active_label": "Текущий профиль", "profile_remote_label": "Удаленный профиль", "profile_local_label": "Локальный профиль", "logs_search_placeholder": "Поиск журналов (время, тип или сообщение)...", "logs_empty_message": "Нет записей в журналах", "logs_action_clear_log": "Очистить журналы", "rules_list_all_proxies": "Все группы", "connections_all_connections": "Все соединения", "connections_search_placeholder": "Поиск соединений (хост, процесс, правило, цепочки)...", "connections_close_all_connections": "Закрыть все соединения", "connections_empty_message": "Не найдено ни одного соединения", "connections_close_connection": "Закрыть соединение", "connections_view_details": "Просмотреть детали", "providers_proxies_title": "Группы прокси", "providers_rules_title": "Наборы правил", "providers_no_proxies_message": "Текущий профиль не имеет групп прокси", "providers_no_rules_message": "Текущий профиль не имеет наборов правил", "providers_proxies_proxy_count_label": "{count} прокси", "providers_rules_rule_count_label": "{count} правил", "providers_info_title": "Информация о ресурсах", "providers_subscription_title": "Информация о подписке", "providers_update_provider": "Обновить ресурсы", "editor_before_close_message": "Вы не сохранили измененное содержимое, вы уверены, что хотите закрыть редактор?", "editor_validate_error_message": "Пожалуйста, исправьте ошибки перед сохранением содержимого", "editor_read_only_chip": "Только для чтения", "unit_seconds": "сек.", "common_submit": "Отправить", "common_cancel": "Отменить", "common_apply": "Применить", "common_reset": "Сбросить", "common_save": "Сохранить", "common_validate": "Проверить", "common_close": "Закрыть", "common_copy": "Копировать", "common_open": "Открыть", "common_cut": "Вырезать", "common_paste": "Вставить" } ================================================ FILE: frontend/nyanpasu/messages/zh-cn.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "language": "简体中文", "navbar_label_dashboard": "概览", "navbar_label_proxies": "代理", "navbar_label_profiles": "配置", "navbar_label_connections": "连接", "navbar_label_logs": "日志", "navbar_label_rules": "规则", "navbar_label_settings": "设置", "navbar_label_providers": "资源", "header_help_action_title": "帮助", "header_help_action_wiki": "在线文档", "header_help_action_issues": "报告问题", "header_help_action_collect_logs": "收集日志", "header_help_action_about": "关于", "header_settings_action_title": "设置", "header_settings_action_language": "语言", "header_settings_action_theme_mode": "主题模式", "header_file_action_title": "文件", "header_file_action_import_local_profile": "导入本地配置", "settings_system_proxy_title": "系统设置", "settings_system_proxy_proxy_mode_label": "代理模式", "settings_system_proxy_system_proxy_label": "系统代理", "settings_system_proxy_tun_mode_label": "TUN 模式", "settings_system_proxy_proxy_guard_label": "代理守卫", "settings_system_proxy_proxy_guard_switch_label": "系统代理守卫", "settings_system_proxy_proxy_guard_switch_description": "启用后,系统代理将自动检测代理设置,并自动修改为软件内设置", "settings_system_proxy_proxy_guard_interval_label": "系统代理守卫间隔", "settings_system_proxy_proxy_bypass_label": "系统代理绕过", "settings_system_proxy_current_system_proxy_label": "当前系统代理", "settings_system_proxy_service_mode_label": "服务模式", "settings_system_proxy_service_mode_description": "服务用于管理内核,达到权限获取最小化,只给予内核必要的权限,用于例如 TUN 模式等需要特权权限的场景", "settings_system_proxy_service_mode_disabled_tooltip": "要启用服务模式,请确保 Clash Nyanpasu 服务已安装并启动", "settings_system_proxy_system_service_ctrl_label": "系统服务", "settings_system_proxy_system_service_ctrl_detail": "服务详情", "settings_system_proxy_system_service_ctrl_install": "安装", "settings_system_proxy_system_service_ctrl_uninstall": "卸载", "settings_system_proxy_system_service_ctrl_failed_install": "安装失败", "settings_system_proxy_system_service_ctrl_failed_uninstall": "卸载失败", "settings_system_proxy_system_service_ctrl_prompt": "服务提示", "settings_system_proxy_system_service_ctrl_manual_prompt": "手动操作服务提示", "settings_system_proxy_system_service_ctrl_manual_operation_prompt": "无法自动操作服务。请导航到内核所在目录,在 Windows 上以管理员身份打开 PowerShell 或在 macOS/Linux 上打开终端仿真器,然后执行以下命令:", "settings_system_proxy_system_service_ctrl_start": "启动", "settings_system_proxy_system_service_ctrl_stop": "停止", "settings_system_proxy_launch_label": "启动设置", "settings_system_proxy_auto_launch_label": "开机自启", "settings_system_proxy_silent_start_label": "静默启动", "settings_system_proxy_windows_tools_label": "Windows 工具", "settings_system_proxy_uwp_tools_label": "UWP 回环工具", "settings_system_proxy_uwp_tools_description": "用于解决 Windows UWP 应用无法通过本地代理访问网络的问题", "settings_user_interface_title": "用户界面", "settings_user_interface_language_group": "语言设置", "settings_user_interface_language_label": "语言", "settings_user_interface_theme_mode_group": "主题设置", "settings_user_interface_theme_mode_label": "主题模式", "settings_user_interface_theme_mode_light": "浅色", "settings_user_interface_theme_mode_dark": "深色", "settings_user_interface_theme_mode_system": "跟随系统", "settings_user_interface_theme_color_label": "主题颜色", "settings_user_interface_theme_color_custom": "自定义", "settings_clash_settings_title": "Clash 设置", "settings_clash_settings_allow_lan_label": "允许局域网连接", "settings_clash_settings_ipv6_label": "启用 IPv6", "settings_clash_settings_tun_stack_label": "TUN 堆栈", "settings_clash_settings_log_level_label": "日志级别", "settings_clash_settings_port_label": "端口设置", "settings_clash_settings_mixed_port_label": "混合端口", "settings_clash_settings_random_port_label": "随机端口", "settings_clash_settings_random_port_enabled": "随机端口已启用,重启后生效。", "settings_clash_settings_random_port_disabled": "随机端口已禁用,重启后生效。", "settings_clash_settings_external_controll_label": "外部控制器监听地址", "settings_clash_settings_port_strategy_label": "端口策略", "settings_clash_settings_allow_fallback_label": "允许回退", "settings_clash_settings_fixed_label": "固定", "settings_clash_settings_random_label": "随机", "settings_clash_settings_core_secret_label": "API 访问密钥", "settings_clash_settings_field_filter_label": "Clash 字段过滤", "settings_clash_settings_field_filter_nyanpasu_control_fields": "Nyanpasu 覆写控制字段", "settings_web_ui_title": "Web UI", "settings_web_ui_add_button": "添加", "settings_web_ui_empty_item": "没有找到记录,尝试添加一个吧", "settings_web_ui_input_label": "输入 HTTP 地址", "settings_web_ui_replace_with_label": "替换主机、端口和密钥为", "settings_web_ui_preview_title": "预览", "settings_clash_core_manager_card_title": "核心管理", "settings_clash_core_manager_card_loading": "正在执行操作...", "settings_clash_core_manager_card_loading_error": "执行操作失败,请检查日志", "settings_clash_core_manager_card_loading_success": "执行操作成功", "settings_clash_core_manager_card_restart_sidecar": "重启核心", "settings_clash_core_manager_card_restart_sidecar_error": "重启核心失败,请检查日志", "settings_clash_core_manager_card_restart_sidecar_success": "重启核心成功", "settings_clash_core_manager_card_fetch_remote": "检查更新", "settings_clash_core_manager_card_click_to_update": "点击更新", "settings_clash_core_manager_card_decompressing": "解压中...", "settings_clash_core_manager_card_replacing": "替换中...", "settings_clash_core_manager_card_restarting": "重启中...", "settings_clash_core_manager_card_done": "完成", "settings_debug_utils_open_config_directory": "打开配置路径", "settings_debug_utils_open_data_directory": "打开数据路径", "settings_debug_utils_open_core_directory": "打开内核路径", "settings_debug_utils_open_log_directory": "打开日志路径", "settings_nyanpasu_max_log_files_label": "最大日志文件数量", "settings_label_system": "系统设置", "settings_label_system_description": "代理模式、代理绕过、开机自启、静默启动等设置", "settings_label_user_interface": "用户界面", "settings_label_user_interface_description": "语言、主题模式、主题颜色等设置", "settings_label_clash_settings": "Clash 设置", "settings_label_clash_settings_description": "Clash 配置、日志级别、混合端口、随机端口等设置", "settings_label_external_controll": "Web UI 与外部控制", "settings_label_external_controll_description": "Web UI 地址、端口策略、API 密钥等设置", "settings_label_nyanpasu": "Nyanpasu 配置", "settings_label_nyanpasu_description": "Nyanpasu 特性配置", "settings_label_debug": "调试工具", "settings_label_debug_description": "调试工具配置", "settings_label_about": "关于", "settings_label_about_description": "关于 Clash Nyanpasu", "settings_label_about_update": "检查更新", "settings_label_about_auto_check_updates": "自动检查更新", "settings_label_about_update_to_github_releases": "前往 GitHub 检查更新", "settings_label_about_version": "版本: v{version}", "settings_label_about_update_has_new_version": "有新版本可用", "settings_label_about_update_no_update": "当前已经是最新版本,没有找到更新信息", "settings_label_about_update_to_update_button": "下载并安装", "settings_label_about_update_installing": "正在安装...", "profile_subscription_title": "订阅信息", "profile_subscription_updated_at": "{updated}更新", "profile_subscription_next_update_at": "下次更新于 {next} 更新", "profile_subscription_expires_in": "{expires}到期", "profile_subscription_update": "更新", "profile_base_info_title": "基本信息", "profile_name_editor_title": "编辑名称", "profile_name_label": "配置名称", "profile_update_option_edit": "订阅选项", "profile_update_option_editor_title": "编辑订阅选项", "profile_user_agent_label": "用户代理 (UA)", "profile_with_proxy_label": "使用系统代理", "profile_self_proxy_label": "使用 Clash 代理", "profile_update_interval_label": "自动更新间隔 (分钟)", "profile_subscription_url_editor_label": "编辑订阅 URL", "profile_subscription_url_label": "订阅 URL", "profile_delete_title": "删除配置", "profile_delete_description": "此操作无法撤销。确定要删除此配置吗?", "profile_view_content_title": "查看配置内容", "profile_pending_mask_message": "正在执行操作……", "profile_active_title": "启用配置", "profile_is_active_description": "当前配置已启用,无需重复操作。", "profile_active_title_success": "配置 {name} 已成功启用!", "profile_active_title_error": "配置 {name} 启用失败,请检查配置或代理链是否正确", "profile_open_locally_title": "打开配置文件", "profile_chain_editor_active_column": "激活的代理链", "profile_chain_editor_inactive_column": "未激活的代理链", "profile_chain_editor_apply_message": "正在应用代理链……", "profile_quick_import_placeholder": "键入订阅 URL 或粘贴订阅链接以快速导入配置", "profile_quick_import_success_message": "配置导入成功,请检查列表", "profile_view_details_title": "配置详情", "profile_no_more_profiles": "没有更多配置了 0.0", "profile_import_title": "导入配置", "profile_import_remote_title": "远程配置", "profile_import_local_title": "本地配置", "profile_empty_list_message": "没有找到任何配置,请尝试导入或创建配置。", "profile_import_remote_url_label": "远程配置 URL", "profile_profile_label": "代理配置", "profile_javascript_label": "JavaScript 脚本", "profile_lua_label": "Lua 脚本", "profile_merge_label": "合并脚本 (YAML)", "profile_profile_label_count": "{count} 个配置", "profile_form_name_label": "名称", "profile_form_desc_label": "描述", "profile_form_url_label": "订阅 URL", "profile_form_option_label": "配置选项", "profile_form_option_user_agent_label": "用户代理(UA)", "profile_form_option_with_proxy_label": "使用系统代理", "profile_form_option_self_proxy_label": "使用 Clash 代理", "profile_form_option_update_interval_label": "自动更新间隔(分钟)", "profile_import_local_file_placeholder": "点击或拖拽文件到此处导入", "profile_import_local_file_type_label": "支持 {types} 文件", "profile_import_local_file_size_label": "文件大小: {size}", "profile_import_chain_title": "新建 {type}", "proxies_group_delay_test_title": "延迟测试", "proxies_group_delay_test_pending_title": "正在测试中……", "proxies_group_empty_message": "没有找到任何代理组,请尝试切换配置", "proxies_group_empty_button_text": "切换到配置页面", "profile_is_active_label": "当前配置", "profile_remote_label": "远程配置", "profile_local_label": "本地配置", "logs_search_placeholder": "通过时间、类型或消息等等搜索日志", "logs_empty_message": "没有任何日志记录", "logs_action_clear_log": "清空日志", "rules_list_all_proxies": "所有分组", "connections_all_connections": "所有连接", "connections_search_placeholder": "通过进程、类型、地址等信息搜索连接", "connections_close_all_connections": "关闭所有连接", "connections_empty_message": "没有找到任何连接", "connections_close_connection": "关闭连接", "connections_view_details": "查看详情", "providers_proxies_title": "代理集", "providers_rules_title": "规则集", "providers_no_proxies_message": "当前配置没有任何代理集", "providers_no_rules_message": "当前配置没有任何规则集", "providers_proxies_proxy_count_label": "{count}个节点", "providers_rules_rule_count_label": "{count}个规则", "providers_info_title": "资源信息", "providers_subscription_title": "订阅信息", "providers_update_provider": "更新资源", "editor_before_close_message": "你尚未保存编辑的内容,确定要关闭编辑器吗?", "editor_validate_error_message": "请修复错误后再保存内容", "editor_read_only_chip": "只读", "unit_seconds": "秒", "common_submit": "提交", "common_cancel": "取消", "common_apply": "应用", "common_reset": "重置", "common_save": "保存", "common_validate": "验证", "common_close": "关闭", "common_copy": "复制", "common_open": "打开", "common_cut": "剪切", "common_paste": "粘贴" } ================================================ FILE: frontend/nyanpasu/messages/zh-tw.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "language": "繁體中文", "navbar_label_dashboard": "概覽", "navbar_label_proxies": "代理組", "navbar_label_profiles": "配置", "navbar_label_connections": "連接", "navbar_label_logs": "日誌", "navbar_label_rules": "規則", "navbar_label_settings": "設置", "navbar_label_providers": "資源", "header_help_action_title": "幫助", "header_help_action_wiki": "線上文檔", "header_help_action_issues": "報告問題", "header_help_action_collect_logs": "收集日誌", "header_help_action_about": "關於", "header_settings_action_title": "設置", "header_settings_action_language": "語言", "header_settings_action_theme_mode": "主題模式", "header_file_action_title": "文件", "header_file_action_import_local_profile": "匯入本機配置", "settings_system_proxy_title": "系統設置", "settings_system_proxy_proxy_mode_label": "代理模式", "settings_system_proxy_system_proxy_label": "系統代理", "settings_system_proxy_tun_mode_label": "TUN 模式", "settings_system_proxy_proxy_guard_label": "代理守衛", "settings_system_proxy_proxy_guard_switch_label": "系統代理守衛", "settings_system_proxy_proxy_guard_switch_description": "啟用後,系統代理將自動偵測代理設定,並自動修改為軟體內設定", "settings_system_proxy_proxy_guard_interval_label": "系統代理守衛間隔", "settings_system_proxy_proxy_bypass_label": "系統代理繞過", "settings_system_proxy_current_system_proxy_label": "當前系統代理", "settings_system_proxy_service_mode_label": "服務模式", "settings_system_proxy_service_mode_description": "服務用於管理內核,達到權限獲取最小化,只給予內核必要的權限,用於例如 TUN 模式等需要特權權限的場景", "settings_system_proxy_service_mode_disabled_tooltip": "要啟用服務模式,請確保 Clash Nyanpasu 服務已安裝並啟動", "settings_system_proxy_system_service_ctrl_label": "系統服務", "settings_system_proxy_system_service_ctrl_detail": "服務詳情", "settings_system_proxy_system_service_ctrl_install": "安裝", "settings_system_proxy_system_service_ctrl_uninstall": "卸載", "settings_system_proxy_system_service_ctrl_failed_install": "安裝失敗", "settings_system_proxy_system_service_ctrl_failed_uninstall": "卸載失敗", "settings_system_proxy_system_service_ctrl_prompt": "服務提示", "settings_system_proxy_system_service_ctrl_manual_prompt": "手動操作服務提示", "settings_system_proxy_system_service_ctrl_manual_operation_prompt": "無法自動操作服務。請開啟核心所在目錄,在 Windows 上以管理員身分開啟 PowerShell 或在 macOS/Linux 上開啟終端模擬器,然後執行以下指令:", "settings_system_proxy_system_service_ctrl_start": "啟動", "settings_system_proxy_system_service_ctrl_stop": "停止", "settings_system_proxy_launch_label": "啟動設置", "settings_system_proxy_auto_launch_label": "開機自啟", "settings_system_proxy_silent_start_label": "靜默啟動", "settings_system_proxy_windows_tools_label": "Windows 工具", "settings_system_proxy_uwp_tools_label": "UWP 回環工具", "settings_system_proxy_uwp_tools_description": "用於解決 Windows UWP 應用程式無法透過本機代理存取網路的問題", "settings_user_interface_title": "使用者介面", "settings_user_interface_language_group": "語言設置", "settings_user_interface_language_label": "語言", "settings_user_interface_theme_mode_group": "主題設置", "settings_user_interface_theme_mode_label": "主題模式", "settings_user_interface_theme_mode_light": "淺色", "settings_user_interface_theme_mode_dark": "深色", "settings_user_interface_theme_mode_system": "跟隨系統", "settings_user_interface_theme_color_label": "主題顏色", "settings_user_interface_theme_color_custom": "自訂", "settings_clash_settings_title": "Clash 設置", "settings_clash_settings_allow_lan_label": "允許區域網路連線", "settings_clash_settings_ipv6_label": "啟用 IPv6", "settings_clash_settings_tun_stack_label": "TUN 堆疊", "settings_clash_settings_log_level_label": "日誌級別", "settings_clash_settings_port_label": "端口設置", "settings_clash_settings_mixed_port_label": "混合端口", "settings_clash_settings_random_port_label": "隨機端口", "settings_clash_settings_random_port_enabled": "隨機端口已啟用,重啟後生效。", "settings_clash_settings_random_port_disabled": "隨機端口已禁用,重啟後生效。", "settings_clash_settings_external_controll_label": "外部控制器監聽地址", "settings_clash_settings_port_strategy_label": "端口策略", "settings_clash_settings_allow_fallback_label": "允許回退", "settings_clash_settings_fixed_label": "固定", "settings_clash_settings_random_label": "隨機", "settings_clash_settings_core_secret_label": "API 訪問密鑰", "settings_clash_settings_field_filter_label": "Clash 字段過濾", "settings_clash_settings_field_filter_nyanpasu_control_fields": "Nyanpasu 覆寫控制字段", "settings_web_ui_title": "Web UI 設置", "settings_web_ui_add_button": "添加", "settings_web_ui_empty_item": "沒有找到記錄,嘗試添加一個吧", "settings_web_ui_input_label": "輸入 HTTP 地址", "settings_web_ui_replace_with_label": "替換主機、端口和密鑰為", "settings_web_ui_preview_title": "預覽", "settings_clash_core_manager_card_title": "核心管理", "settings_clash_core_manager_card_loading": "正在執行操作...", "settings_clash_core_manager_card_loading_error": "執行操作失敗,請檢查日誌", "settings_clash_core_manager_card_loading_success": "執行操作成功", "settings_clash_core_manager_card_restart_sidecar": "重啟核心", "settings_clash_core_manager_card_restart_sidecar_error": "重啟核心失敗,請檢查日誌", "settings_clash_core_manager_card_restart_sidecar_success": "重啟核心成功", "settings_clash_core_manager_card_fetch_remote": "檢查更新", "settings_clash_core_manager_card_click_to_update": "點擊更新", "settings_clash_core_manager_card_decompressing": "解壓縮中...", "settings_clash_core_manager_card_replacing": "替換中...", "settings_clash_core_manager_card_restarting": "重啟中...", "settings_clash_core_manager_card_done": "完成", "settings_debug_utils_open_config_directory": "開啟設定路徑", "settings_debug_utils_open_data_directory": "開啟資料路徑", "settings_debug_utils_open_core_directory": "開啟核心路徑", "settings_debug_utils_open_log_directory": "開啟日誌路徑", "settings_nyanpasu_max_log_files_label": "最大日誌文件數量", "settings_label_system": "系統設置", "settings_label_system_description": "代理模式、代理繞過、開機自啟、靜默啟動等設定", "settings_label_user_interface": "使用者介面", "settings_label_user_interface_description": "語言、主題模式、主題顏色等設定", "settings_label_clash_settings": "Clash 設置", "settings_label_clash_settings_description": "Clash 配置、日誌級別、混合端口、隨機端口等設定", "settings_label_external_controll": "Web UI 與外部控制", "settings_label_external_controll_description": "Web UI 位址、端口策略、API 密鑰等設定", "settings_label_nyanpasu": "Nyanpasu 設置", "settings_label_nyanpasu_description": "Nyanpasu 特性設定", "settings_label_debug": "調試工具", "settings_label_debug_description": "調試工具設定", "settings_label_about": "關於", "settings_label_about_description": "關於 Clash Nyanpasu", "settings_label_about_update": "檢查更新", "settings_label_about_auto_check_updates": "自動檢查更新", "settings_label_about_update_to_github_releases": "前往 GitHub 檢查更新", "settings_label_about_version": "版本: v{version}", "settings_label_about_update_has_new_version": "有新版本可用", "settings_label_about_update_no_update": "當前已經是最新版本,沒有找到更新信息", "settings_label_about_update_to_update_button": "下載並安裝", "settings_label_about_update_installing": "正在安裝...", "profile_subscription_title": "訂閱信息", "profile_subscription_updated_at": "{updated}更新", "profile_subscription_next_update_at": "下次更新於 {next} 更新", "profile_subscription_expires_in": "{expires}到期", "profile_subscription_update": "更新", "profile_base_info_title": "基本資訊", "profile_name_editor_title": "編輯名稱", "profile_name_label": "配置名稱", "profile_update_option_edit": "訂閱選項", "profile_update_option_editor_title": "編輯訂閱選項", "profile_user_agent_label": "用戶代理 (UA)", "profile_with_proxy_label": "使用系統代理", "profile_self_proxy_label": "使用 Clash 代理", "profile_update_interval_label": "自動更新間隔 (分鐘)", "profile_subscription_url_editor_label": "編輯訂閱 URL", "profile_subscription_url_label": "訂閱 URL", "profile_delete_title": "刪除配置", "profile_delete_description": "此操作無法撤銷。確定要刪除此配置嗎?", "profile_view_content_title": "查看配置內容", "profile_pending_mask_message": "正在執行操作……", "profile_active_title": "啟用配置", "profile_is_active_description": "當前配置已啟用,無需重複操作。", "profile_active_title_success": "配置 {name} 已成功啟用!", "profile_active_title_error": "配置 {name} 啟用失敗,請檢查配置或代理鏈是否正確", "profile_open_locally_title": "開啟設定檔", "profile_chain_editor_active_column": "激活的代理链", "profile_chain_editor_inactive_column": "未激活的代理链", "profile_chain_editor_apply_message": "正在應用代理鏈……", "profile_quick_import_placeholder": "輸入訂閱 URL 或貼上訂閱連結以快速匯入設定", "profile_quick_import_success_message": "配置導入成功,請檢查列表", "profile_view_details_title": "配置詳情", "profile_no_more_profiles": "沒有更多配置了 0.0", "profile_import_title": "導入配置", "profile_import_remote_title": "遠程配置", "profile_import_local_title": "本地配置", "profile_empty_list_message": "沒有找到任何配置,請嘗試導入或創建配置。", "profile_import_remote_url_label": "遠程配置 URL", "profile_profile_label": "代理配置", "profile_javascript_label": "JavaScript 腳本", "profile_lua_label": "Lua 腳本", "profile_merge_label": "合併腳本 (YAML)", "profile_profile_label_count": "{count} 個配置", "profile_form_name_label": "名稱", "profile_form_desc_label": "描述", "profile_form_url_label": "訂閱 URL", "profile_form_option_label": "配置選項", "profile_form_option_user_agent_label": "用戶代理 (UA)", "profile_form_option_with_proxy_label": "使用系統代理", "profile_form_option_self_proxy_label": "使用 Clash 代理", "profile_form_option_update_interval_label": "自動更新間隔 (分鐘)", "profile_import_local_file_placeholder": "點擊或拖曳檔案至此處匯入", "profile_import_local_file_type_label": "支持 {types} 文件", "profile_import_local_file_size_label": "文件大小: {size}", "profile_import_chain_title": "新建 {type}", "proxies_group_delay_test_title": "延遲測試", "proxies_group_delay_test_pending_title": "正在測試中……", "proxies_group_empty_message": "沒有找到任何代理組,請嘗試切換配置", "proxies_group_empty_button_text": "切換到配置頁面", "profile_is_active_label": "當前配置", "profile_remote_label": "遠程配置", "profile_local_label": "本地配置", "logs_search_placeholder": "透過時間、類型或訊息等等搜尋日誌", "logs_empty_message": "沒有任何日誌記錄", "logs_action_clear_log": "清空日誌", "rules_list_all_proxies": "所有分組", "connections_all_connections": "所有連接", "connections_search_placeholder": "透過進程、類型、地址等資訊搜尋連接", "connections_close_all_connections": "關閉所有連接", "connections_empty_message": "沒有找到任何連接", "connections_close_connection": "關閉連接", "connections_view_details": "查看詳情", "providers_proxies_title": "代理集", "providers_rules_title": "規則集", "providers_no_proxies_message": "當前配置沒有任何代理集", "providers_no_rules_message": "當前配置沒有任何規則集", "providers_proxies_proxy_count_label": "{count}個節點", "providers_rules_rule_count_label": "{count}個規則", "providers_info_title": "資源信息", "providers_subscription_title": "訂閱信息", "providers_update_provider": "更新資源", "editor_before_close_message": "你尚未儲存編輯的內容,確定要關閉編輯器嗎?", "editor_validate_error_message": "請修正錯誤後再儲存內容", "editor_read_only_chip": "只讀", "unit_seconds": "秒", "common_submit": "提交", "common_cancel": "取消", "common_apply": "應用", "common_reset": "重置", "common_save": "儲存", "common_validate": "驗證", "common_close": "關閉", "common_copy": "複製", "common_open": "開啟", "common_cut": "剪下", "common_paste": "貼上" } ================================================ FILE: frontend/nyanpasu/package.json ================================================ { "name": "@nyanpasu/nyanpasu", "version": "2.0.0-alpha+10a82d25", "license": "GPL-3.0", "type": "module", "scripts": { "build": "vite build", "bundle:visualize": "vite-bundle-visualizer", "dev": "vite", "serve": "vite preview" }, "dependencies": { "@dnd-kit/core": "6.3.1", "@dnd-kit/helpers": "0.3.2", "@dnd-kit/react": "0.3.2", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", "@emotion/styled": "11.14.1", "@hookform/resolvers": "5.2.2", "@inlang/paraglide-js": "2.15.0", "@juggle/resize-observer": "3.4.0", "@material/material-color-utilities": "0.4.0", "@mui/icons-material": "7.3.9", "@mui/lab": "7.0.0-beta.17", "@mui/material": "7.3.9", "@mui/x-date-pickers": "8.27.2", "@nyanpasu/interface": "workspace:^", "@nyanpasu/ui": "workspace:^", "@paper-design/shaders-react": "0.0.72", "@radix-ui/react-use-controllable-state": "1.2.2", "@tailwindcss/postcss": "4.2.2", "@tanstack/react-table": "8.21.3", "@tanstack/react-virtual": "3.13.23", "@tanstack/router-zod-adapter": "1.81.5", "@tauri-apps/api": "2.10.1", "@types/json-schema": "7.0.15", "@uidotdev/usehooks": "2.4.1", "@uiw/react-color": "2.9.6", "ahooks": "3.9.6", "allotment": "1.20.5", "class-variance-authority": "0.7.1", "country-code-emoji": "2.3.0", "country-emoji": "1.5.6", "dayjs": "1.11.20", "framer-motion": "12.38.0", "i18next": "25.8.20", "jotai": "2.18.1", "json-schema": "0.4.0", "material-react-table": "3.2.1", "monaco-editor": "0.55.1", "mui-color-input": "7.0.0", "radix-ui": "1.4.3", "react": "19.2.4", "react-dom": "19.2.4", "react-error-boundary": "6.0.0", "react-fast-marquee": "1.6.5", "react-hook-form": "7.71.2", "react-hook-form-mui": "8.2.0", "react-i18next": "15.7.4", "react-markdown": "10.1.0", "react-split-grid": "1.0.4", "react-use": "17.6.0", "rxjs": "7.8.2", "swr": "2.4.1", "virtua": "0.46.6", "vite-bundle-visualizer": "1.2.1" }, "devDependencies": { "@csstools/normalize.css": "12.1.1", "@emotion/babel-plugin": "11.13.5", "@emotion/react": "11.14.0", "@iconify/json": "2.2.452", "@monaco-editor/react": "4.7.0", "@tanstack/react-query": "5.91.2", "@tanstack/react-router": "1.167.5", "@tanstack/react-router-devtools": "1.166.9", "@tanstack/router-plugin": "1.166.14", "@tauri-apps/plugin-clipboard-manager": "2.3.2", "@tauri-apps/plugin-dialog": "2.6.0", "@tauri-apps/plugin-fs": "2.4.5", "@tauri-apps/plugin-notification": "2.3.3", "@tauri-apps/plugin-os": "2.3.2", "@tauri-apps/plugin-process": "2.3.1", "@tauri-apps/plugin-shell": "2.3.5", "@tauri-apps/plugin-updater": "2.10.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@types/validator": "13.15.10", "@vitejs/plugin-legacy": "7.2.1", "@vitejs/plugin-react": "5.2.0", "@vitejs/plugin-react-swc": "4.3.0", "change-case": "5.4.4", "clsx": "2.1.1", "core-js": "3.49.0", "filesize": "11.0.13", "meta-json-schema": "1.19.21", "monaco-yaml": "5.4.1", "nanoid": "5.1.7", "sass-embedded": "1.98.0", "shiki": "4.0.2", "unplugin-auto-import": "21.0.0", "unplugin-icons": "23.0.1", "validator": "13.15.26", "vite": "7.3.1", "vite-plugin-html": "3.2.2", "vite-plugin-sass-dts": "1.3.35", "vite-plugin-svgr": "4.5.0", "vite-tsconfig-paths": "6.1.1", "zod": "4.3.6" } } ================================================ FILE: frontend/nyanpasu/postcss.config.js ================================================ export default { plugins: { '@tailwindcss/postcss': {}, }, } ================================================ FILE: frontend/nyanpasu/project.inlang/project_id ================================================ hmmAR8W6ML07bAYbAQ ================================================ FILE: frontend/nyanpasu/project.inlang/settings.json ================================================ { "$schema": "https://inlang.com/schema/project-settings", "baseLocale": "en", "locales": ["en", "zh-cn", "zh-tw", "ru"], "modules": [ "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" ], "plugin.inlang.messageFormat": { "pathPattern": "./messages/{locale}.json" } } ================================================ FILE: frontend/nyanpasu/src/assets/json/clash-field.json ================================================ { "default": { "proxies": "https://nyanpasu.elaina.moe/others/field.html#proxies", "proxy-groups": "https://nyanpasu.elaina.moe/others/field.html#proxy-groups", "proxy-providers": "https://nyanpasu.elaina.moe/others/field.html#proxy-providers", "rules": "https://nyanpasu.elaina.moe/others/field.html#rules", "rule-providers": "https://nyanpasu.elaina.moe/others/field.html#rule-providers" }, "handle": { "mode": "https://nyanpasu.elaina.moe/others/field.html#mode", "port": "https://nyanpasu.elaina.moe/others/field.html#port", "socks-port": "https://nyanpasu.elaina.moe/others/field.html#socks-port", "mixed-port": "https://nyanpasu.elaina.moe/others/field.html#mixed-port", "allow-lan": "https://nyanpasu.elaina.moe/others/field.html#allow-lan", "log-level": "https://nyanpasu.elaina.moe/others/field.html#log-level", "ipv6": "https://nyanpasu.elaina.moe/others/field.html#ipv6", "secret": "https://nyanpasu.elaina.moe/others/field.html#secret", "external-controller": "https://nyanpasu.elaina.moe/others/field.html#external-controller" }, "other": { "dns": "https://nyanpasu.elaina.moe/others/field.html#dns", "tun": "https://nyanpasu.elaina.moe/others/field.html#tun", "ebpf": "https://nyanpasu.elaina.moe/others/field.html#ebpf", "hosts": "https://nyanpasu.elaina.moe/others/field.html#hosts", "script": "https://nyanpasu.elaina.moe/others/field.html#script", "profile": "https://nyanpasu.elaina.moe/others/field.html#profile", "payload": "https://nyanpasu.elaina.moe/others/field.html#payload", "tunnels": "https://nyanpasu.elaina.moe/others/field.html#tunnels", "auto-redir": "https://nyanpasu.elaina.moe/others/field.html#auto-redir", "experimental": "https://nyanpasu.elaina.moe/others/field.html#experimental", "interface-name": "https://nyanpasu.elaina.moe/others/field.html#interface-name", "routing-mark": "https://nyanpasu.elaina.moe/others/field.html#routing-mark", "redir-port": "https://nyanpasu.elaina.moe/others/field.html#redir-port", "tproxy-port": "https://nyanpasu.elaina.moe/others/field.html#tproxy-port", "iptables": "https://nyanpasu.elaina.moe/others/field.html#iptables", "external-ui": "https://nyanpasu.elaina.moe/others/field.html#external-ui", "bind-address": "https://nyanpasu.elaina.moe/others/field.html#bind-address", "authentication": "https://nyanpasu.elaina.moe/others/field.html#authentication" }, "meta": { "tls": "https://nyanpasu.elaina.moe/others/field.html#tls", "sniffer": "https://nyanpasu.elaina.moe/others/field.html#sniffer", "geox-url": "https://nyanpasu.elaina.moe/others/field.html#geox-url", "listeners": "https://nyanpasu.elaina.moe/others/field.html#listeners", "sub-rules": "https://nyanpasu.elaina.moe/others/field.html#sub-rules", "geodata-mode": "https://nyanpasu.elaina.moe/others/field.html#geodata-mode", "unified-delay": "https://nyanpasu.elaina.moe/others/field.html#unified-delay", "tcp-concurrent": "https://nyanpasu.elaina.moe/others/field.html#tcp-concurrent", "enable-process": "https://nyanpasu.elaina.moe/others/field.html#enable-process", "find-process-mode": "https://nyanpasu.elaina.moe/others/field.html#find-process-mode", "skip-auth-prefixes": "https://nyanpasu.elaina.moe/others/field.html#skip-auth-prefixes", "external-controller-tls": "https://nyanpasu.elaina.moe/others/field.html#external-controller-tls", "global-client-fingerprint": "https://nyanpasu.elaina.moe/others/field.html#global-client-fingerprint" } } ================================================ FILE: frontend/nyanpasu/src/assets/styles/fonts.scss ================================================ // 这个字体是为了解决在 Windows 系统下,微软因为政策问题,不支持显示国旗 emoji 的问题 @font-face { font-family: 'Color Emoji Flags'; src: local('Apple Color Emoji'), local('Noto Color Emoji'), url('../fonts/Twemoji.Mozilla.ttf'); unicode-range: U+1F1E6-1F1FF; } // use local emoji font for better backward compatibility @font-face { font-family: 'Color Emoji'; src: local('Apple Color Emoji'), local('Segoe UI Emoji'), local('Segoe UI Symbol'), local('Noto Color Emoji'), url('../fonts/Twemoji.Mozilla.ttf'); } ================================================ FILE: frontend/nyanpasu/src/assets/styles/index.scss ================================================ @use './fonts.scss'; @use './theme.scss'; body { margin: 0; overflow: hidden; font-family: system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Color Emoji Flags', 'Color Emoji'; color: var(--color-on-surface); user-select: none; background-color: #f7f7f7; -webkit-font-smoothing: antialiased; } .dark body { background-color: #0b0b0b; } :root { --primary-main: #5b5c9d; --text-primary: #637381; --selection-color: #f5f5f5; --scroller-color: #90939980; --background-color: #fff; --background-color-alpha: rgb(24 103 192 / 10%); --border-radius: 12px; } ::selection { color: var(--selection-color); background-color: var(--primary-main); } *::-webkit-scrollbar { width: 6px; height: 6px; background: transparent; } *::-webkit-scrollbar-thumb { background-color: var(--scroller-color); border-radius: 6px; } *::-webkit-scrollbar-track { margin-block: calc(var(--border-radius) + 3px); } @media (prefers-color-scheme: dark) { :root { background-color: rgb(18 18 18 / 100%); } } .user-none { user-select: none; } .bg-inherit-allow-fallback { background-color: var(--fallback-bg, inherit); } ================================================ FILE: frontend/nyanpasu/src/assets/styles/tailwind.css ================================================ /* stylelint-disable value-keyword-case */ /* stylelint-disable at-rule-no-unknown */ /* stylelint-disable custom-property-pattern */ /* stylelint-disable import-notation */ @import 'tailwindcss'; @config '../../../tailwind.config.ts'; @tailwind utilities; @theme { --font-mono: 'Cascadia Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', '等距更纱黑体 SC', monospace, 'Color Emoji Flags', 'Color Emoji'; --custom-text-shadow: 0px 0px 1px rgb(0 0 0 / 20%), 0px 0px 1px rgb(1 0 5 / 10%); --custom-text-shadow-sm: 1px 1px 3px rgb(36 37 47 / 25%); --custom-text-shadow-md: 0px 1px 2px rgb(30 29 39 / 19%), 1px 2px 4px rgb(54 64 147 / 18%); --custom-text-shadow-lg: 3px 3px 6px rgb(0 0 0 / 26%), 0 0 5px rgb(15 3 86 / 22%); --custom-text-shadow-xl: 1px 1px 3px rgb(0 0 0 / 29%), 2px 4px 7px rgb(73 64 125 / 35%); --custom-text-shadow-none: none; /* Material Design 3 Color System */ --color-primary: var(--color-md-primary); --color-on-primary: var(--color-md-on-primary); --color-primary-container: var(--color-md-primary-container); --color-on-primary-container: var(--color-md-on-primary-container); --color-secondary: var(--color-md-secondary); --color-on-secondary: var(--color-md-on-secondary); --color-secondary-container: var(--color-md-secondary-container); --color-on-secondary-container: var(--color-md-on-secondary-container); --color-tertiary: var(--color-md-tertiary); --color-on-tertiary: var(--color-md-on-tertiary); --color-tertiary-container: var(--color-md-tertiary-container); --color-on-tertiary-container: var(--color-md-on-tertiary-container); --color-error: var(--color-md-error); --color-on-error: var(--color-md-on-error); --color-error-container: var(--color-md-error-container); --color-on-error-container: var(--color-md-on-error-container); --color-background: var(--color-md-background); --color-on-background: var(--color-md-on-background); --color-surface: var(--color-md-surface); --color-on-surface: var(--color-md-on-surface); --color-surface-variant: var(--color-md-surface-variant); --color-on-surface-variant: var(--color-md-on-surface-variant); --color-outline: var(--color-md-outline); --color-outline-variant: var(--color-md-outline-variant); --color-shadow: var(--color-md-shadow); --color-scrim: var(--color-md-scrim); --color-inverse-surface: var(--color-md-inverse-surface); --color-inverse-on-surface: var(--color-md-inverse-on-surface); --color-inverse-primary: var(--color-md-inverse-primary); /* Progress Spin Animation (Circular) */ --animate-progress-spin: progress-spin 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; --animate-progress-spin-left: progress-spin-left 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; --animate-progress-spin-right: progress-spin-right 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; /* Linear Progress Animation (Indeterminate) */ --animate-linear-progress-primary: linear-progress-primary 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite; --animate-linear-progress-secondary: linear-progress-secondary 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) 1.15s infinite; } /* Custom Mixed Colors (light) */ @theme { --color-mixed-background: color-mix( in srgb, var(--color-background) 95%, white 5% ); } /* Custom Mixed Colors (dark) */ .dark { --color-mixed-background: color-mix( in srgb, var(--color-background) 50%, black 50% ); } @utility text-shadow-* { /* prettier-ignore */ text-shadow: --value(--custom-text-shadow-*); } @utility bg-transparent-fallback-* { background-color: transparent; --fallback-bg: --value(--color-*); } @layer components { svg.logo-colorized #element { fill: var(--color-primary); } svg.logo-colorized #bg { fill: var(--color-surface); } .dark svg.logo-colorized #element { fill: var(--color-on-primary); } .dark svg.logo-colorized #bg { fill: var(--color-on-surface); } } @keyframes progress-spin { 12.5% { transform: rotate(135deg); } 25% { transform: rotate(270deg); } 37.5% { transform: rotate(405deg); } 50% { transform: rotate(540deg); } 62.5% { transform: rotate(675deg); } 75% { transform: rotate(810deg); } 87.5% { transform: rotate(945deg); } 100% { transform: rotate(1080deg); } } @keyframes progress-spin-left { 0% { transform: rotate(265deg); } 50% { transform: rotate(130deg); } 100% { transform: rotate(265deg); } } @keyframes progress-spin-right { 0% { transform: rotate(-265deg); } 50% { transform: rotate(-130deg); } 100% { transform: rotate(-265deg); } } /* Material You Linear Progress Indeterminate Animation */ @keyframes linear-progress-primary { 0% { right: 100%; left: -35%; } 60% { right: -90%; left: 100%; } 100% { right: -90%; left: 100%; } } @keyframes linear-progress-secondary { 0% { right: 100%; left: -200%; } 60% { right: -8%; left: 107%; } 100% { right: -8%; left: 107%; } } ================================================ FILE: frontend/nyanpasu/src/assets/styles/theme.scss ================================================ // default theme, generated by material-color-utilities // frontend/nyanpasu/src/components/providers/theme-provider.tsx // this is fallback theme, if custom theme not set :root { --color-md-primary: #005db5; --color-md-on-primary: #fff; --color-md-primary-container: #d6e3ff; --color-md-on-primary-container: #001b3d; --color-md-secondary: #555f71; --color-md-on-secondary: #fff; --color-md-secondary-container: #d9e3f8; --color-md-on-secondary-container: #121c2b; --color-md-tertiary: #6f5675; --color-md-on-tertiary: #fff; --color-md-tertiary-container: #f9d8fe; --color-md-on-tertiary-container: #28132f; --color-md-error: #ba1a1a; --color-md-on-error: #fff; --color-md-error-container: #ffdad6; --color-md-on-error-container: #410002; --color-md-background: #fdfbff; --color-md-on-background: #1a1b1e; --color-md-surface: #fdfbff; --color-md-on-surface: #1a1b1e; --color-md-surface-variant: #e0e2ec; --color-md-on-surface-variant: #43474e; --color-md-outline: #74777f; --color-md-outline-variant: #c4c6cf; --color-md-shadow: #000; --color-md-scrim: #000; --color-md-inverse-surface: #2f3033; --color-md-inverse-on-surface: #f1f0f4; --color-md-inverse-primary: #a8c8ff; } :root.dark { --color-md-primary: #a8c8ff; --color-md-on-primary: #003062; --color-md-primary-container: #00468b; --color-md-on-primary-container: #d6e3ff; --color-md-secondary: #bdc7dc; --color-md-on-secondary: #273141; --color-md-secondary-container: #3e4758; --color-md-on-secondary-container: #d9e3f8; --color-md-tertiary: #dcbce1; --color-md-on-tertiary: #3e2845; --color-md-tertiary-container: #563e5c; --color-md-on-tertiary-container: #f9d8fe; --color-md-error: #ffb4ab; --color-md-on-error: #690005; --color-md-error-container: #93000a; --color-md-on-error-container: #ffb4ab; --color-md-background: #1a1b1e; --color-md-on-background: #e3e2e6; --color-md-surface: #1a1b1e; --color-md-on-surface: #e3e2e6; --color-md-surface-variant: #43474e; --color-md-on-surface-variant: #c4c6cf; --color-md-outline: #8e9099; --color-md-outline-variant: #43474e; --color-md-shadow: #000; --color-md-scrim: #000; --color-md-inverse-surface: #e3e2e6; --color-md-inverse-on-surface: #2f3033; --color-md-inverse-primary: #005db5; } ================================================ FILE: frontend/nyanpasu/src/components/app/app-container.module.d.scss.ts ================================================ declare const classNames: { readonly layout: 'layout' readonly container: 'container' } export default classNames ================================================ FILE: frontend/nyanpasu/src/components/app/app-container.module.scss ================================================ :root { --focus-border: transparent !important; --separator-border: transparent !important; } .layout { display: flex; width: 100%; height: 100vh; overflow: hidden; background-color: var(--background-color); .container { position: relative; flex: 1 1 75%; height: 100%; background-color: var(--background-color-alpha); } } ================================================ FILE: frontend/nyanpasu/src/components/app/app-container.module.scss.d.ts ================================================ declare const classNames: { readonly layout: 'layout' readonly container: 'container' } export default classNames ================================================ FILE: frontend/nyanpasu/src/components/app/app-container.tsx ================================================ import getSystem from '@/utils/get-system' import { Box } from '@mui/material' import Paper from '@mui/material/Paper' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import 'allotment/dist/style.css' import { useAtomValue } from 'jotai' import { ReactNode, useEffect, useRef } from 'react' import { atomIsDrawerOnlyIcon } from '@/store' import { alpha, cn } from '@nyanpasu/ui' import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { TauriEvent, UnlistenFn } from '@tauri-apps/api/event' import { LayoutControl } from '../layout/layout-control' import styles from './app-container.module.scss' import AppDrawer from './app-drawer' import DrawerContent from './drawer-content' const appWindow = getCurrentWebviewWindow() const OS = getSystem() export const AppContainer = ({ children, isDrawer, }: { children?: ReactNode isDrawer?: boolean }) => { const { data: isMaximized } = useSuspenseQuery({ queryKey: ['isMaximized'], queryFn: () => appWindow.isMaximized(), }) const queryClient = useQueryClient() const unlistenRef = useRef(null) const onlyIcon = useAtomValue(atomIsDrawerOnlyIcon) useEffect(() => { appWindow .listen(TauriEvent.WINDOW_RESIZED, () => { queryClient.invalidateQueries({ queryKey: ['isMaximized'] }) }) .then((unlisten) => { unlistenRef.current = unlisten }) .catch((error) => { console.error(error) }) return () => { unlistenRef.current?.() } }, [queryClient]) return ( { if ((e.target as HTMLElement)?.dataset?.windrag) { appWindow.startDragging() } }} > {isDrawer && } {!isDrawer && (
)}
{OS === 'windows' && ( )} {/* TODO: add a framer motion animation to toggle the maximized state */} {OS === 'macos' && !isMaximized && ( ({ backgroundColor: alpha(theme.vars.palette.primary.main, 0.1), })} /> )}
{children}
) } export default AppContainer ================================================ FILE: frontend/nyanpasu/src/components/app/app-drawer.tsx ================================================ import { AnimatePresence, motion } from 'framer-motion' import { useState } from 'react' import getSystem from '@/utils/get-system' import { MenuOpen } from '@mui/icons-material' import { Backdrop, IconButton } from '@mui/material' import type { SxProps, Theme } from '@mui/material/styles' import { alpha, cn } from '@nyanpasu/ui' import AnimatedLogo from '../layout/animated-logo' import DrawerContent from './drawer-content' const OS = getSystem() export const AppDrawer = () => { const [open, setOpen] = useState(false) const DrawerTitle = () => { return (
({ backgroundColor: alpha(theme.vars.palette.primary.main, 0.1), svg: { transform: 'scale(0.9)' }, }), ]} onClick={() => setOpen(true)} >
Clash Nyanpasu
) } return ( <> ({ backgroundColor: alpha(theme.vars.palette.primary.light, 0.1), ...theme.applyStyles('dark', { backgroundColor: alpha(theme.vars.palette.primary.dark, 0.1), }), })) as SxProps } open={open} onClick={() => setOpen(false)} >
) } export default AppDrawer ================================================ FILE: frontend/nyanpasu/src/components/app/drawer-content.tsx ================================================ import getSystem from '@/utils/get-system' import { getRoutesWithIcon } from '@/utils/routes-utils' import { Box } from '@mui/material' import { cn } from '@nyanpasu/ui' import AnimatedLogo from '../layout/animated-logo' import RouteListItem from './modules/route-list-item' export const DrawerContent = ({ className, onlyIcon, }: { className?: string onlyIcon?: boolean }) => { const routes = getRoutesWithIcon() return (
{!onlyIcon && (
{'Clash\nNyanpasu'}
)}
{Object.entries(routes).map(([name, { path, icon }]) => { return ( ) })}
) } export default DrawerContent ================================================ FILE: frontend/nyanpasu/src/components/app/locales-provider.tsx ================================================ import { locale } from 'dayjs' import { changeLanguage } from 'i18next' import { useEffect } from 'react' import { useSetting } from '@nyanpasu/interface' export const LocalesProvider = () => { const { value } = useSetting('language') useEffect(() => { if (value) { locale(value === 'zh' ? 'zh-cn' : value) changeLanguage(value) } }, [value]) return null } export default LocalesProvider ================================================ FILE: frontend/nyanpasu/src/components/app/modules/route-list-item.tsx ================================================ import { createElement } from 'react' import { useTranslation } from 'react-i18next' import { languageQuirks } from '@/utils/language' import { SvgIconComponent } from '@mui/icons-material' import { Box, ListItemButton, ListItemIcon, Tooltip } from '@mui/material' import { useSetting } from '@nyanpasu/interface' import { alpha, cn } from '@nyanpasu/ui' import { useLocation, useNavigate } from '@tanstack/react-router' export const RouteListItem = ({ name, path, icon, onlyIcon, }: { name: string path: string icon: SvgIconComponent onlyIcon?: boolean }) => { const { t } = useTranslation() const location = useLocation() const match = location.pathname === path const navigate = useNavigate() const { value: language } = useSetting('language') const listItemButton = ( ({ backgroundColor: match ? alpha(theme.vars.palette.primary.main, 0.3) : alpha(theme.vars.palette.background.paper, 0.15), }), (theme) => ({ '&:hover': { backgroundColor: match ? alpha(theme.vars.palette.primary.main, 0.5) : null, }, }), ]} onClick={() => { navigate({ to: path, }) }} > {createElement(icon, { sx: (theme) => ({ fill: match ? theme.vars.palette.primary.main : undefined, }), className: onlyIcon ? '!size-8' : undefined, })} {!onlyIcon && ( ({ color: match ? theme.vars.palette.primary.main : undefined, })} > {t(`label_${name}`)} )} ) return onlyIcon ? ( {listItemButton} ) : ( listItemButton ) } export default RouteListItem ================================================ FILE: frontend/nyanpasu/src/components/base/base-empty.tsx ================================================ import { InboxRounded } from '@mui/icons-material' import { Box, Typography } from '@mui/material' import { alpha } from '@nyanpasu/ui' interface Props { text?: React.ReactNode extra?: React.ReactNode } export const BaseEmpty = (props: Props) => { const { text = 'Empty', extra } = props return ( ({ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: alpha(theme.vars.palette.text.secondary, 0.75), })} > {text} {extra} ) } ================================================ FILE: frontend/nyanpasu/src/components/base/base-error-boundary.tsx ================================================ import { ReactNode } from 'react' import { ErrorBoundary, FallbackProps } from 'react-error-boundary' function ErrorFallback({ error }: FallbackProps) { return (

Something went wrong:(

{error.message}
Error Stack
{error.stack}
) } interface Props { children?: ReactNode } export const BaseErrorBoundary = (props: Props) => { return ( {props.children} ) } ================================================ FILE: frontend/nyanpasu/src/components/base/base-notice.tsx ================================================ import { ReactNode, useState } from 'react' import { createRoot } from 'react-dom/client' import { CheckCircleRounded, Close, ErrorRounded } from '@mui/icons-material' import { Box, IconButton, Slide, Snackbar, Typography } from '@mui/material' interface InnerProps { type: string duration?: number message: ReactNode onClose: () => void } const NoticeInner = (props: InnerProps) => { const { type, message, duration = 1500, onClose } = props const [visible, setVisible] = useState(true) const onBtnClose = () => { setVisible(false) onClose() } // oxlint-disable-next-line typescript/no-explicit-any const onAutoClose = (_e: any, reason: string) => { if (reason !== 'clickaway') onBtnClose() } const msgElement = type === 'info' ? ( message ) : ( {type === 'error' && } {type === 'success' && } {message} ) return ( } transitionDuration={200} action={ } /> ) } interface NoticeInstance { (props: Omit): void info(message: ReactNode, duration?: number): void error(message: ReactNode, duration?: number): void success(message: ReactNode, duration?: number): void } let parent: HTMLDivElement = null! // @ts-expect-error 90 行动态添加了 info、error、success 属性 export const Notice: NoticeInstance = (props) => { if (!parent) { parent = document.createElement('div') document.body.appendChild(parent) } const container = document.createElement('div') parent.appendChild(container) const root = createRoot(container) const onUnmount = () => { root.unmount() if (parent) setTimeout(() => parent.removeChild(container), 500) } root.render() } ;(['info', 'error', 'success'] as const).forEach((type) => { Notice[type] = (message, duration) => { setTimeout(() => Notice({ type, message, duration }), 0) } }) ================================================ FILE: frontend/nyanpasu/src/components/base/content-display.tsx ================================================ import { ReactNode } from 'react' import { Public } from '@mui/icons-material' import { cn } from '@nyanpasu/ui' export interface ContentDisplayProps { className?: string message?: string children?: ReactNode } export const ContentDisplay = ({ message, children, className, }: ContentDisplayProps) => (
{children || ( <> {message} )}
) export default ContentDisplay ================================================ FILE: frontend/nyanpasu/src/components/base/index.ts ================================================ export { BaseEmpty } from './base-empty' export { BaseErrorBoundary } from './base-error-boundary' export { Notice } from './base-notice' ================================================ FILE: frontend/nyanpasu/src/components/connections/close-connections-button.tsx ================================================ import { useLockFn } from 'ahooks' import { useTranslation } from 'react-i18next' import { Close } from '@mui/icons-material' import { Tooltip } from '@mui/material' import { useClashConnections } from '@nyanpasu/interface' import { FloatingButton } from '@nyanpasu/ui' export const CloseConnectionsButton = () => { const { t } = useTranslation() const { deleteConnections } = useClashConnections() const onCloseAll = useLockFn(async () => { await deleteConnections.mutateAsync(undefined) }) return ( ) } export default CloseConnectionsButton ================================================ FILE: frontend/nyanpasu/src/components/connections/connection-detail-dialog.tsx ================================================ import { sentenceCase } from 'change-case' import dayjs from 'dayjs' import { filesize } from 'filesize' import * as React from 'react' import { useTranslation } from 'react-i18next' import { Tooltip } from '@mui/material' import { Connection } from '@nyanpasu/interface' import { BaseDialog, BaseDialogProps, cn } from '@nyanpasu/ui' export type ConnectionDetailDialogProps = { item?: Connection.Item } & Omit< BaseDialogProps, 'title' > // eslint-disable-next-line @typescript-eslint/no-explicit-any const formatValue = (key: string, value: any): React.ReactElement => { if (Array.isArray(value)) { return {value.join(' / ')} } key = key.toLowerCase() if (key.includes('speed')) { return {filesize(value)}/s } if (key.includes('download') || key.includes('upload')) { return {filesize(value)} } if (key.includes('port') || key.includes('id') || key.includes('ip')) { return {value} } const date = dayjs(value) if (date.isValid()) { return ( {date.fromNow()} ) } return value } // eslint-disable-next-line @typescript-eslint/no-explicit-any const Row = ({ label, value }: { label: string; value: any }) => { const key = label.toLowerCase() return ( <>
{sentenceCase(label)}
{formatValue(key, value)}
) } export default function ConnectionDetailDialog({ item, ...others }: ConnectionDetailDialogProps) { const { t } = useTranslation() if (!item) return null return (
{Object.entries(item) .filter(([key, value]) => key !== 'metadata' && !!value) .map(([key, value]) => ( ))}

{t('Metadata')}

{Object.entries(item.metadata) .filter(([, value]) => !!value) .map(([key, value]) => ( ))}
) } ================================================ FILE: frontend/nyanpasu/src/components/connections/connection-page.tsx ================================================ import { use } from 'react' import CloseConnectionsButton from './close-connections-button' import { SearchTermCtx } from './connection-search-term' import ConnectionsTable from './connections-table' export default function ConnectionPage() { const searchTerm = use(SearchTermCtx) return ( <> ) } ================================================ FILE: frontend/nyanpasu/src/components/connections/connection-search-term.tsx ================================================ import { createContext } from 'react' export const SearchTermCtx = createContext(undefined) ================================================ FILE: frontend/nyanpasu/src/components/connections/connections-column-filter.tsx ================================================ /* eslint-disable camelcase */ import { useLockFn } from 'ahooks' import { snakeCase } from 'change-case' import dayjs from 'dayjs' import { AnimatePresence, Reorder, useDragControls } from 'framer-motion' import { useAtom } from 'jotai' import { type MRT_ColumnDef } from 'material-react-table' import { MouseEventHandler, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { connectionTableColumnsAtom } from '@/store' import parseTraffic from '@/utils/parse-traffic' import { Cancel, Menu } from '@mui/icons-material' import { Checkbox, CircularProgress, IconButton } from '@mui/material' import { useClashConnections } from '@nyanpasu/interface' import { BaseDialog, BaseDialogProps } from '@nyanpasu/ui' import { TableConnection } from './connections-table' function CloseConnectionButton({ id }: { id: string }) { const { deleteConnections } = useClashConnections() const closeConnect = useLockFn(async (id?: string) => { await deleteConnections.mutateAsync(id) }) const [loading, setLoading] = useState(false) const onClick: MouseEventHandler = useCallback( (e) => { e.preventDefault() e.stopPropagation() setLoading(true) closeConnect(id).finally(() => setLoading(false)) }, [closeConnect, id], ) return (
{loading ? : }
) } export const useColumns = (): Array> => { const { t } = useTranslation() return useMemo( () => ( [ { header: 'Actions', size: 60, enableSorting: false, enableGlobalFilter: false, enableResizing: false, accessorFn: ({ id }) => , }, { header: 'Host', size: 240, accessorFn: ({ metadata }) => metadata.host || metadata.destinationIP, }, { header: 'Process', size: 140, accessorFn: ({ metadata }) => metadata.process, }, { header: 'Downloaded', size: 88, accessorFn: ({ download }) => parseTraffic(download).join(' '), sortingFn: (rowA, rowB) => rowA.original.download - rowB.original.download, }, { header: 'Uploaded', size: 88, accessorFn: ({ upload }) => parseTraffic(upload).join(' '), sortingFn: (rowA, rowB) => rowA.original.upload - rowB.original.upload, }, { header: 'DL Speed', size: 88, accessorFn: ({ downloadSpeed }) => parseTraffic(downloadSpeed).join(' ') + '/s', sortingFn: (rowA, rowB) => (rowA.original.downloadSpeed || 0) - (rowB.original.downloadSpeed || 0), }, { header: 'UL Speed', size: 88, accessorFn: ({ uploadSpeed }) => parseTraffic(uploadSpeed).join(' ') + '/s', sortingFn: (rowA, rowB) => (rowA.original.uploadSpeed || 0) - (rowB.original.uploadSpeed || 0), }, { header: 'Chains', size: 360, accessorFn: ({ chains }) => [...chains].reverse().join(' / '), }, { header: 'Rule', size: 200, accessorFn: ({ rule, rulePayload }) => rulePayload ? `${rule} (${rulePayload})` : rule, }, { header: 'Time', size: 120, accessorFn: ({ start }) => dayjs(start).fromNow(), sortingFn: (rowA, rowB) => dayjs(rowA.original.start).diff(rowB.original.start), }, { header: 'Source', size: 200, accessorFn: ({ metadata: { sourceIP, sourcePort } }) => `${sourceIP}:${sourcePort}`, }, { header: 'Destination IP', size: 200, accessorFn: ({ metadata: { destinationIP, destinationPort } }) => `${destinationIP}:${destinationPort}`, }, { header: 'Destination ASN', size: 200, accessorFn: ({ metadata: { destinationIPASN } }) => `${destinationIPASN}`, }, { header: 'Type', size: 160, accessorFn: ({ metadata }) => `${metadata.type} (${metadata.network})`, }, ] satisfies Array> ).map( (column) => ({ ...column, id: snakeCase(column.header), header: t(column.header), }) satisfies MRT_ColumnDef, ), [t], ) } export type ConnectionColumnFilterDialogProps = {} & Omit< BaseDialogProps, 'title' > function ColItem({ column, checked, onChange, value, }: { column: MRT_ColumnDef checked: boolean onChange: (e: React.ChangeEvent) => void value: [string, boolean] }) { const controls = useDragControls() return (
{column.header}
controls.start(e)}>
) } export default function ConnectionColumnFilterDialog( props: ConnectionColumnFilterDialogProps, ) { const { t } = useTranslation() const columns = useColumns() const [filteredCols, setFilteredCols] = useAtom(connectionTableColumnsAtom) const sortedCols = useMemo( () => columns .filter((o) => o.id !== 'actions') .sort((a, b) => { const aIndex = filteredCols.findIndex((o) => o[0] === a.id) const bIndex = filteredCols.findIndex((o) => o[0] === b.id) if (aIndex === -1 && bIndex === -1) { return 0 } if (aIndex === -1) { return 1 } if (bIndex === -1) { return -1 } return aIndex - bIndex }), [columns, filteredCols], ) const latestFilteredCols = sortedCols.map((column) => [ column.id, filteredCols.find((o) => o[0] === column.id)?.[1] ?? true, ]) as Array<[string, boolean]> return (
{sortedCols.map((column, index) => ( o[0] === column.id)?.[1] ?? true } onChange={(e) => { console.log(e.target.checked) const newCols = [...filteredCols] newCols[index] = [newCols[index][0], e.target.checked] console.log(newCols) setFilteredCols(newCols) }} value={latestFilteredCols[index]} /> ))}
) } ================================================ FILE: frontend/nyanpasu/src/components/connections/connections-table.tsx ================================================ /* eslint-disable camelcase */ import { useAtomValue } from 'jotai' import { cloneDeep } from 'lodash-es' import { MaterialReactTable, useMaterialReactTable } from 'material-react-table' import { MRT_Localization_EN } from 'material-react-table/locales/en' import { MRT_Localization_RU } from 'material-react-table/locales/ru' import { MRT_Localization_ZH_HANS } from 'material-react-table/locales/zh-Hans' import { MRT_Localization_ZH_HANT } from 'material-react-table/locales/zh-Hant' import { lazy, useDeferredValue, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { connectionTableColumnsAtom } from '@/store' import { containsSearchTerm } from '@/utils' import { useClashConnections, type ClashConnection, type ClashConnectionItem, } from '@nyanpasu/interface' import ContentDisplay from '../base/content-display' import { useColumns } from './connections-column-filter' const ConnectionDetailDialog = lazy(() => import('./connection-detail-dialog')) export type TableConnection = ClashConnectionItem & { downloadSpeed?: number uploadSpeed?: number } export interface TableMessage extends Omit { connections: TableConnection[] } export const ConnectionsTable = ({ searchTerm }: { searchTerm?: string }) => { const { t, i18n } = useTranslation() const { query: { data: clashConnections, isLoading }, } = useClashConnections() const historyMessage = useRef(null) const connectionsMessage = useMemo(() => { const result = clashConnections?.at(-1) if (!result) { return historyMessage.current } const updatedConnections: TableConnection[] = [] const filteredConnections = searchTerm ? result.connections?.filter((connection) => containsSearchTerm(connection, searchTerm), ) : result.connections filteredConnections?.forEach((connection) => { const previousConnection = historyMessage.current?.connections.find( (history) => history.id === connection.id, ) const downloadSpeed = previousConnection ? connection.download - previousConnection.download : 0 const uploadSpeed = previousConnection ? connection.upload - previousConnection.upload : 0 updatedConnections.push({ ...connection, downloadSpeed, uploadSpeed, }) }) const data = { ...result, connections: updatedConnections } historyMessage.current = data return data }, [clashConnections, searchTerm]) const deferredTableData = useDeferredValue(connectionsMessage?.connections) const locale = useMemo(() => { switch (i18n.language) { case 'zh-CN': return MRT_Localization_ZH_HANS case 'zh-TW': return MRT_Localization_ZH_HANT case 'ru': return MRT_Localization_RU case 'en': default: return MRT_Localization_EN } }, [i18n.language]) const columns = useColumns() const tableColsOrder = useAtomValue(connectionTableColumnsAtom) const filteredColumns = useMemo( () => columns .filter( (column) => tableColsOrder.find((o) => o[0] === column.id)?.[1] ?? true, ) .sort((a, b) => { const aIndex = tableColsOrder.findIndex((o) => o[0] === a.id) const bIndex = tableColsOrder.findIndex((o) => o[0] === b.id) if (aIndex === -1 && bIndex === -1) { return 0 } if (aIndex === -1) { return 1 } if (bIndex === -1) { return -1 } return aIndex - bIndex }), [columns, tableColsOrder], ) const columnOrder = useMemo( () => filteredColumns.map((column) => column.id) as string[], [filteredColumns], ) const columnVisibility = useMemo(() => { return filteredColumns.reduce( (acc, column) => { acc[column.id as string] = tableColsOrder.find((o) => o[0] === column.id)?.[1] ?? true return acc }, {} as Record, ) }, [filteredColumns, tableColsOrder]) const [connectionDetailDialogOpen, setConnectionDetailDialogOpen] = useState(false) const [connectioNDetailDialogItem, setConnectionDetailDialogItem] = useState< ClashConnectionItem | undefined >(undefined) const table = useMaterialReactTable({ columns: filteredColumns, data: deferredTableData ?? [], initialState: { density: 'compact', columnPinning: { left: ['actions'], }, }, state: { columnOrder, columnVisibility, }, defaultDisplayColumn: { enableResizing: true, }, enableTopToolbar: false, enableColumnActions: false, enablePagination: false, enableBottomToolbar: false, enableColumnResizing: true, enableGlobalFilterModes: true, enableColumnPinning: true, muiTableContainerProps: { sx: { minHeight: '100%' }, className: '!absolute !h-full !w-full', }, muiTableBodyRowProps({ row }) { return { onClick() { const id = row.original.id const item = connectionsMessage?.connections.find((o) => o.id === id) if (item) { setConnectionDetailDialogItem(cloneDeep(item)) setConnectionDetailDialogOpen(true) } }, } }, localization: locale, enableRowVirtualization: true, enableColumnVirtualization: true, rowVirtualizerOptions: { overscan: 5 }, columnVirtualizerOptions: { overscan: 2 }, }) // Show loading state while data is being fetched if (isLoading && !connectionsMessage) { // Don't show a separate loading indicator here since the parent component already handles it return null } return connectionsMessage?.connections.length ? ( <> setConnectionDetailDialogOpen(false)} /> ) : ( ) } export default ConnectionsTable ================================================ FILE: frontend/nyanpasu/src/components/connections/connections-total.tsx ================================================ import { filesize } from 'filesize' import { useEffect, useRef, useState } from 'react' import { Download, Upload } from '@mui/icons-material' import { Paper, Skeleton } from '@mui/material' import type { SxProps, Theme } from '@mui/material/styles' import { useClashConnections } from '@nyanpasu/interface' import { darken, lighten } from '@nyanpasu/ui' export default function ConnectionTotal() { const { query: { data: clashConnections, isLoading }, } = useClashConnections() const latestClashConnections = clashConnections?.at(-1) const [downloadHighlight, setDownloadHighlight] = useState(false) const [uploadHighlight, setUploadHighlight] = useState(false) const downloadHighlightTimerRef = useRef(null) const uploadHighlightTimerRef = useRef(null) useEffect(() => { if ( latestClashConnections?.downloadTotal && latestClashConnections?.downloadTotal > 0 ) { setDownloadHighlight(true) if (downloadHighlightTimerRef.current) { clearTimeout(downloadHighlightTimerRef.current) } downloadHighlightTimerRef.current = window.setTimeout(() => { setDownloadHighlight(false) }, 300) } }, [latestClashConnections?.downloadTotal]) useEffect(() => { if ( latestClashConnections?.uploadTotal && latestClashConnections?.uploadTotal > 0 ) { setUploadHighlight(true) if (uploadHighlightTimerRef.current) { clearTimeout(uploadHighlightTimerRef.current) } uploadHighlightTimerRef.current = window.setTimeout(() => { setUploadHighlight(false) }, 300) } }, [latestClashConnections?.uploadTotal]) // Show skeleton loading state while data is being fetched if (isLoading || !latestClashConnections) { return (
) } return (
({ color: darken( theme.vars.palette.primary.main, downloadHighlight ? 0.9 : 0.3, ), ...theme.applyStyles('dark', { color: lighten( theme.vars.palette.primary.main, downloadHighlight ? 0.2 : 0.9, ), }), })) as SxProps } />{' '} {filesize(latestClashConnections.downloadTotal, { pad: true })} ({ color: darken( theme.vars.palette.primary.main, uploadHighlight ? 0.9 : 0.3, ), ...theme.applyStyles('dark', { color: lighten( theme.vars.palette.primary.main, downloadHighlight ? 0.2 : 0.9, ), }), })) as SxProps } />{' '} {filesize(latestClashConnections.uploadTotal, { pad: true })}
) } ================================================ FILE: frontend/nyanpasu/src/components/connections/header-search.tsx ================================================ import { useTranslation } from 'react-i18next' import { FilledInputProps, TextField, TextFieldProps } from '@mui/material' import { alpha } from '@nyanpasu/ui' export const HeaderSearch = (props: TextFieldProps) => { const { t } = useTranslation() const inputProps: Partial = { sx: (theme) => ({ borderRadius: 7, backgroundColor: alpha(theme.vars.palette.primary.main, 0.1), '&::before': { display: 'none', }, '&::after': { display: 'none', }, }), } return ( ) } export default HeaderSearch ================================================ FILE: frontend/nyanpasu/src/components/dashboard/data-panel.tsx ================================================ import { useAtomValue } from 'jotai' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Dataline, { DatalineProps } from '@/components/dashboard/dataline' import { atomIsDrawer } from '@/store' import { ArrowDownward, ArrowUpward, MemoryOutlined, SettingsEthernet, } from '@mui/icons-material' import Grid from '@mui/material/Grid' import { MAX_CONNECTIONS_HISTORY, MAX_MEMORY_HISTORY, MAX_TRAFFIC_HISTORY, useClashConnections, useClashMemory, useClashTraffic, useSetting, } from '@nyanpasu/interface' export const DataPanel = ({ visible = true }: { visible?: boolean }) => { const { t } = useTranslation() const { data: clashTraffic } = useClashTraffic() const { data: clashMemory } = useClashMemory() const { query: { data: clashConnections }, } = useClashConnections() const { value } = useSetting('clash_core') const supportMemory = value && ['mihomo', 'mihomo-alpha'].includes(value) const padData = (data: (number | undefined)[] = [], max: number) => Array(Math.max(0, max - data.length)) .fill(0) .concat(data.slice(-max)) const Datalines: (DatalineProps & { visible?: boolean })[] = [ { data: padData( clashTraffic?.map((item) => item.down), MAX_TRAFFIC_HISTORY, ), icon: ArrowDownward, title: t('Download Traffic'), total: clashConnections?.at(-1)?.downloadTotal, type: 'speed', visible, }, { data: padData( clashTraffic?.map((item) => item.up), MAX_TRAFFIC_HISTORY, ), icon: ArrowUpward, title: t('Upload Traffic'), total: clashConnections?.at(-1)?.uploadTotal, type: 'speed', visible, }, { data: padData( clashConnections?.map((item) => item.connections?.length ?? 0), MAX_CONNECTIONS_HISTORY, ), icon: SettingsEthernet, title: t('Active Connections'), type: 'raw', visible, }, ] if (supportMemory) { Datalines.splice(2, 0, { data: padData( clashMemory?.map((item) => item.inuse), MAX_MEMORY_HISTORY, ), icon: MemoryOutlined, title: t('Memory'), visible, }) } const isDrawer = useAtomValue(atomIsDrawer) const gridLayout = useMemo( () => ({ sm: isDrawer ? 6 : 12, md: 6, lg: supportMemory ? 3 : 4, xl: supportMemory ? 3 : 4, }), [isDrawer, supportMemory], ) return Datalines.map((props, index) => { return ( ) }) } export default DataPanel ================================================ FILE: frontend/nyanpasu/src/components/dashboard/dataline.tsx ================================================ import { cloneElement, FC } from 'react' import { useTranslation } from 'react-i18next' import parseTraffic from '@/utils/parse-traffic' import { type SvgIconComponent } from '@mui/icons-material' import { Paper } from '@mui/material' import { cn, Sparkline } from '@nyanpasu/ui' export interface DatalineProps { className?: string data: number[] icon: SvgIconComponent title: string total?: number type?: 'speed' | 'raw' visible?: boolean } export const Dataline: FC = ({ data, icon, title, total, type, className, visible = true, }) => { const { t } = useTranslation() return (
{/* @ts-expect-error icon should be cloneable */} {cloneElement(icon)}
{title}
{type === 'raw' ? data.at(-1) : parseTraffic(data.at(-1)).join(' ')} {type === 'speed' && '/s'}
{total !== undefined && ( {t('Total')}: {parseTraffic(total).join(' ')} )}
) } export default Dataline ================================================ FILE: frontend/nyanpasu/src/components/dashboard/health-panel.tsx ================================================ import { useInterval } from 'ahooks' import { useRef, useState } from 'react' import { timing } from '@nyanpasu/interface' import IPASNPanel from './modules/ipasn-panel' import TimingPanel from './modules/timing-panel' const REFRESH_SECONDS = 5 export const HealthPanel = () => { const [health, setHealth] = useState({ Google: 0, GitHub: 0, BingCN: 0, Baidu: 0, }) const healthCache = useRef({ Google: 0, GitHub: 0, BingCN: 0, Baidu: 0, }) const [refreshCount, setRefreshCount] = useState(0) useInterval(async () => { setHealth(healthCache.current) setRefreshCount(refreshCount + REFRESH_SECONDS) healthCache.current = { Google: await timing.Google(), GitHub: await timing.GitHub(), BingCN: await timing.BingCN(), Baidu: await timing.Baidu(), } }, 1000 * REFRESH_SECONDS) return ( <> ) } export default HealthPanel ================================================ FILE: frontend/nyanpasu/src/components/dashboard/modules/ipasn-panel.tsx ================================================ import { flag as countryCodeEmoji } from 'country-emoji' import { useAtomValue } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { atomIsDrawer } from '@/store' import { Visibility, VisibilityOff } from '@mui/icons-material' import { Button, CircularProgress, IconButton, Paper, Tooltip, } from '@mui/material' import Grid from '@mui/material/Grid' import { useIPSB, useSetting } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' const IP_REFRESH_SECONDS = 180 const EmojiCounty = ({ countryCode }: { countryCode: string }) => { let emoji = countryCodeEmoji(countryCode) if (!emoji) { emoji = '🇺🇳' } return (
{emoji} {emoji}
) } const MAX_WIDTH = 'calc(100% - 48px - 16px)' export const IPASNPanel = ({ refreshCount }: { refreshCount: number }) => { const { t } = useTranslation() const { data, mutate, isValidating } = useIPSB() const handleRefreshIP = () => { mutate() } const [showIPAddress, setShowIPAddress] = useState(false) const isDrawer = useAtomValue(atomIsDrawer) const { value } = useSetting('clash_core') const supportMemory = value && ['mihomo', 'mihomo-alpha'].includes(value) return ( {data ? ( <> {data.country_code && ( )}
{data.country}
{data.organization}
AS{data.asn}
{data.ip}
setShowIPAddress(!showIPAddress)} > {showIPAddress ? : }
) : ( <>
)} ) } export default IPASNPanel ================================================ FILE: frontend/nyanpasu/src/components/dashboard/modules/timing-panel.tsx ================================================ import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { useColorSxForDelay } from '@/hooks/theme' import { atomIsDrawer } from '@/store' import { Box, Paper } from '@mui/material' import Grid from '@mui/material/Grid' import { useSetting } from '@nyanpasu/interface' function LatencyTag({ name, value }: { name: string; value: number }) { const { t } = useTranslation() const sx = useColorSxForDelay(value) return (
{name}:
{value ? `${value.toFixed(0)} ms` : t('Timeout')}
) } export const TimingPanel = ({ data }: { data: { [key: string]: number } }) => { const isDrawer = useAtomValue(atomIsDrawer) const { value } = useSetting('clash_core') const supportMemory = value && ['mihomo', 'mihomo-alpha'].includes(value) return (
{Object.entries(data).map(([name, value]) => ( ))}
) } export default TimingPanel ================================================ FILE: frontend/nyanpasu/src/components/dashboard/proxy-shortcuts.tsx ================================================ import { useLockFn } from 'ahooks' import { useAtomValue } from 'jotai' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { atomIsDrawer } from '@/store' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { NetworkPing, SettingsEthernet } from '@mui/icons-material' import { Chip, Paper, type ChipProps } from '@mui/material' import Grid from '@mui/material/Grid' import { useClashConfig, useSetting, useSystemProxy } from '@nyanpasu/interface' import { PaperSwitchButton } from '../setting/modules/system-proxy' const TitleComp = () => { const { t } = useTranslation() const { data } = useSystemProxy() const { query: { data: clashConfigs }, } = useClashConfig() const status = useMemo<{ label: string color: ChipProps['color'] }>(() => { if (data?.enable) { const port = Number(data.server.split(':')[1]) if (port === clashConfigs?.['mixed-port']) { return { label: t('Successful'), color: 'success', } } else { return { label: t('Occupied'), color: 'warning', } } } else { return { label: t('Disabled'), color: 'error', } } }, [clashConfigs, data?.enable, data?.server, t]) return (
{t('Proxy Takeover Status')}
) } export const ProxyShortcuts = () => { const { t } = useTranslation() const isDrawer = useAtomValue(atomIsDrawer) const systemProxy = useSetting('enable_system_proxy') const handleSystemProxy = useLockFn(async () => { try { await systemProxy.upsert(!systemProxy.value) } catch (error) { message( `Activation System Proxy failed!\n Error: ${formatError(error)}`, { title: t('Error'), kind: 'error', }, ) } }) const tunMode = useSetting('enable_tun_mode') const handleTunMode = useLockFn(async () => { try { await tunMode.upsert(!tunMode.value) } catch (error) { message(`Activation TUN Mode failed! \n Error: ${formatError(error)}`, { title: t('Error'), kind: 'error', }) } }) return (
{t('System Proxy')}
{t('TUN Mode')}
) } export default ProxyShortcuts ================================================ FILE: frontend/nyanpasu/src/components/dashboard/service-shortcuts.tsx ================================================ import dayjs from 'dayjs' import { useAtomValue } from 'jotai' import { isObject } from 'lodash-es' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import useSWR from 'swr' import { atomIsDrawer } from '@/store' import { Box, CircularProgress, Paper, Tooltip } from '@mui/material' import Grid from '@mui/material/Grid' import type { SxProps, Theme } from '@mui/material/styles' import { getCoreStatus, useSystemService } from '@nyanpasu/interface' import { alpha } from '@nyanpasu/ui' type Status = { label: string sx: SxProps } export const ServiceShortcuts = () => { const { t } = useTranslation() const isDrawer = useAtomValue(atomIsDrawer) const { query: { data: serviceStatus }, } = useSystemService() // TODO: refactor to use tanstack query const coreStatusSWR = useSWR('/coreStatus', getCoreStatus, { refreshInterval: 2000, revalidateOnFocus: false, }) const status: Status = useMemo(() => { switch (serviceStatus?.status) { case 'running': { return { label: t('running'), sx: ((theme) => ({ backgroundColor: alpha(theme.vars.palette.success.light, 0.3), ...theme.applyStyles('dark', { backgroundColor: alpha(theme.vars.palette.success.dark, 0.3), }), })) as SxProps, } } case 'stopped': { return { label: t('stopped'), sx: ((theme) => ({ backgroundColor: alpha(theme.vars.palette.error.light, 0.3), ...theme.applyStyles('dark', { backgroundColor: alpha(theme.vars.palette.error.dark, 0.3), }), })) as SxProps, } } case 'not_installed': default: { return { label: t('not_installed'), sx: ((theme) => ({ backgroundColor: theme.vars.palette.grey[100], ...theme.applyStyles('dark', { backgroundColor: theme.vars.palette.background.paper, }), })) as SxProps, } } } }, [serviceStatus, t]) const coreStatus: Status = useMemo(() => { const status = coreStatusSWR.data || [{ Stopped: null }, 0, 'normal'] if ( isObject(status[0]) && Object.prototype.hasOwnProperty.call(status[0], 'Stopped') ) { const { Stopped } = status[0] return { label: !!Stopped && Stopped.trim() ? t('stopped_reason', { reason: Stopped }) : t('stopped'), sx: ((theme) => ({ backgroundColor: alpha(theme.vars.palette.success.light, 0.3), ...theme.applyStyles('dark', { backgroundColor: alpha(theme.vars.palette.success.dark, 0.3), }), })) as SxProps, } } return { label: t('service_shortcuts.core_started_by', { by: t(status[2] === 'normal' ? 'UI' : 'service'), }), sx: ((theme) => ({ backgroundColor: alpha(theme.vars.palette.success.light, 0.3), ...theme.applyStyles('dark', { backgroundColor: alpha(theme.vars.palette.success.dark, 0.3), }), })) as SxProps, } }, [coreStatusSWR.data, t]) return ( {serviceStatus ? ( <>
{t('service_shortcuts.title')}
{t('service_shortcuts.service_status')}
{t(status.label)}
{t('service_shortcuts.core_status')}
{coreStatus.label}
) : (
Loading...
)}
) } export default ServiceShortcuts ================================================ FILE: frontend/nyanpasu/src/components/layout/animated-logo.module.d.scss.ts ================================================ declare const classNames: { readonly LogoSchema: 'LogoSchema' } export default classNames ================================================ FILE: frontend/nyanpasu/src/components/layout/animated-logo.module.scss ================================================ .LogoSchema { fill: var(--primary-main); :global(#bg) { fill: var(--background-color); } } ================================================ FILE: frontend/nyanpasu/src/components/layout/animated-logo.module.scss.d.ts ================================================ declare const classNames: { readonly LogoSchema: 'LogoSchema' } export default classNames ================================================ FILE: frontend/nyanpasu/src/components/layout/animated-logo.tsx ================================================ import { AnimatePresence, motion, type Transition, type Variants, } from 'framer-motion' import { CSSProperties } from 'react' import LogoSvg from '@/assets/image/logo.svg?react' import { useSetting } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' import styles from './animated-logo.module.scss' const Logo = motion.create(LogoSvg) const transition = { type: 'spring', stiffness: 260, damping: 20, } satisfies Transition const motionVariants: { [name: string]: Variants } = { default: { initial: { opacity: 0, scale: 0.5, transition, }, animate: { opacity: 1, scale: 1, transition, }, exit: { opacity: 0, scale: 0.5, transition, }, whileHover: { scale: 1.1, transition, }, }, none: { initial: {}, animate: {}, exit: {}, }, } export default function AnimatedLogo({ className, style, disableMotion, }: { className?: string style?: CSSProperties disableMotion?: boolean }) { const { value } = useSetting('lighten_animation_effects') const disable = disableMotion ?? value return ( ) } ================================================ FILE: frontend/nyanpasu/src/components/layout/layout-control.tsx ================================================ import { useMemoizedFn } from 'ahooks' import { useEffect, useRef } from 'react' import { CloseRounded, CropSquareRounded, FilterNoneRounded, HorizontalRuleRounded, PushPin, PushPinOutlined, } from '@mui/icons-material' import { Button, ButtonProps } from '@mui/material' import { commands, useSetting } from '@nyanpasu/interface' import { alpha, cn } from '@nyanpasu/ui' import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { listen, TauriEvent, UnlistenFn } from '@tauri-apps/api/event' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import { platform as getPlatform } from '@tauri-apps/plugin-os' const appWindow = getCurrentWebviewWindow() const CtrlButton = (props: ButtonProps) => { return ( setAnchorEl(null)} > {Object.entries(mapping).map(([key, value], index) => { return ( handleClick(key)}> {value} ) })} ) } ================================================ FILE: frontend/nyanpasu/src/components/logs/log-list.tsx ================================================ import { useDebounceEffect } from 'ahooks' import { RefObject, useDeferredValue, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { Virtualizer, VirtualizerHandle } from 'virtua' import { cn } from '@nyanpasu/ui' import ContentDisplay from '../base/content-display' import LogItem from './log-item' import { useLogContext } from './log-provider' export const LogList = ({ scrollRef, }: { scrollRef: RefObject }) => { const { t } = useTranslation() const { logs, logLevel } = useLogContext() const virtualizerRef = useRef(null) const shouldStickToBottom = useRef(true) const isFirstScroll = useRef(true) useDebounceEffect( () => { if (shouldStickToBottom && logs?.length) { virtualizerRef.current?.scrollToIndex(logs?.length - 1, { align: 'end', smooth: !isFirstScroll.current, }) isFirstScroll.current = false } }, [logs], { wait: 100 }, ) useEffect(() => { isFirstScroll.current = true }, [logLevel]) const handleScroll = (_offset: number) => { const end = virtualizerRef.current?.findEndIndex() || 0 if (end + 1 === logs?.length) { shouldStickToBottom.current = true } else { shouldStickToBottom.current = false } } const deferredLogs = useDeferredValue(logs) return deferredLogs?.length ? ( {deferredLogs?.map((item, index) => { return ( ) })} ) : ( ) } ================================================ FILE: frontend/nyanpasu/src/components/logs/log-page.tsx ================================================ import { RefObject } from 'react' import ClearLogButton from './clear-log-button' import { LogList } from './log-list' export const LogPage = ({ scrollRef, }: { scrollRef: RefObject }) => { return ( <> ) } export default LogPage ================================================ FILE: frontend/nyanpasu/src/components/logs/log-provider.tsx ================================================ import { createContext, PropsWithChildren, useContext, useMemo, useState, } from 'react' import { useClashLogs, type ClashLog } from '@nyanpasu/interface' const LogContext = createContext<{ logs?: ClashLog[] filterText: string setFilterText: (text: string) => void logLevel: string setLogLevel: (level: string) => void } | null>(null) export const useLogContext = () => { const context = useContext(LogContext) if (!context) { throw new Error('useLogContext must be used within LogProvider') } return context } export const LogProvider = ({ children }: PropsWithChildren) => { const [filterText, setFilterText] = useState('') const [logLevel, setLogLevel] = useState('all') const { query: { data }, } = useClashLogs() const logs = useMemo(() => { return data?.filter((log) => { const matchesFilter = !filterText || log.payload.toLowerCase().includes(filterText.toLowerCase()) const matchesLevel = logLevel === 'all' ? true : log.type === logLevel return matchesFilter && matchesLevel }) }, [data, filterText, logLevel]) return ( {children} ) } ================================================ FILE: frontend/nyanpasu/src/components/logs/log-toggle.tsx ================================================ import { PauseCircleOutlineRounded, PlayCircleOutlineRounded, } from '@mui/icons-material' import { IconButton } from '@mui/material' import { useClashLogs } from '@nyanpasu/interface' export const LogToggle = () => { const { status, disable, enable } = useClashLogs() const handleClick = () => { if (status) { disable() } else { enable() } } return ( {status ? : } ) } export default LogToggle ================================================ FILE: frontend/nyanpasu/src/components/logs/los-header.tsx ================================================ import { LogFilter } from './log-filter' import { LogLevel } from './log-level' import LogToggle from './log-toggle' export const LogHeader = () => { return (
) } export default LogHeader ================================================ FILE: frontend/nyanpasu/src/components/profiles/modules/chain-item.tsx ================================================ import { Reorder } from 'framer-motion' import { memo, PointerEvent, useRef, useState, useTransition } from 'react' import { useTranslation } from 'react-i18next' import { Menu as MenuIcon } from '@mui/icons-material' import { Button, ListItemButton, Menu, MenuItem } from '@mui/material' import { ProfileQueryResultItem } from '@nyanpasu/interface' import { alpha, cleanDeepClickEvent } from '@nyanpasu/ui' const longPressDelay = 200 interface Context { global: boolean scoped: boolean } export const ChainItem = memo(function ChainItem({ item, selected, context, onClick, onChainEdit, }: { item: ProfileQueryResultItem selected?: boolean context?: Context onClick: () => Promise onChainEdit: () => void }) { const { t } = useTranslation() const [isPending, startTransition] = useTransition() const handleClick = () => { startTransition(onClick) } const [anchorEl, setAnchorEl] = useState(null) const isChainIncluded = selected // Based on the 'selected' prop which indicates if the chain is active const menuMapping = { [isChainIncluded ? 'Disable' : 'Enable']: () => handleClick(), 'Edit Info': () => onChainEdit(), 'Open File': () => item.view && item.view(), Delete: () => item.drop && item.drop(), } const handleMenuClick = (func: () => void) => { setAnchorEl(null) func() } // const controls = useDragControls(); const onLongPress = (e: PointerEvent) => { cleanDeepClickEvent(e) // controls.start(e); } const longPressTimerRef = useRef(null) return ( <> { longPressTimerRef.current = window.setTimeout(() => { longPressTimerRef.current = null onLongPress(e as unknown as PointerEvent) }, longPressDelay) }} onPointerUp={(e: PointerEvent) => { if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current!) } else { cleanDeepClickEvent(e) longPressTimerRef.current = null } }} > ({ backgroundColor: selected ? alpha(theme.vars.palette.primary.main, 0.3) : alpha(theme.vars.palette.secondary.main, 0.1), }), (theme) => ({ '&:hover': { backgroundColor: selected ? alpha(theme.vars.palette.primary.main, 0.5) : null, }, }), ]} onClick={handleClick} disabled={isPending} >
{item.name}
{context?.global && ( G )} {context?.scoped && ( S )}
setAnchorEl(null)} > {Object.entries(menuMapping).map(([key, func], index) => { return ( { cleanDeepClickEvent(e) handleMenuClick(func) }} > {t(key)} ) })} ) }) export default ChainItem ================================================ FILE: frontend/nyanpasu/src/components/profiles/modules/language-chip.tsx ================================================ import { Box } from '@mui/material' import { alpha } from '@nyanpasu/ui' export const LanguageChip = ({ lang }: { lang: string }) => { return ( lang && ( ({ backgroundColor: alpha(theme.vars.palette.primary.main, 0.1), color: theme.vars.palette.primary.main, })} > {lang} ) ) } export default LanguageChip ================================================ FILE: frontend/nyanpasu/src/components/profiles/modules/side-chain.tsx ================================================ import { useLockFn } from 'ahooks' import { Reorder } from 'framer-motion' import { useAtomValue } from 'jotai' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { Add } from '@mui/icons-material' import { ListItemButton } from '@mui/material' import { ProfileQueryResultItem, useProfile } from '@nyanpasu/interface' import { alpha } from '@nyanpasu/ui' import { ClashProfile, ClashProfileBuilder, filterProfiles } from '../utils' import ChainItem from './chain-item' import { atomChainsSelected, atomGlobalChainCurrent } from './store' export interface SideChainProps { onChainEdit: (item?: ProfileQueryResultItem) => void | Promise } export const SideChain = ({ onChainEdit }: SideChainProps) => { const { t } = useTranslation() const isGlobalChainCurrent = useAtomValue(atomGlobalChainCurrent) const currentProfileUid = useAtomValue(atomChainsSelected) const { query, upsert, patch, sort } = useProfile() const profiles = query.data const { clash, chain } = filterProfiles(profiles?.items) const currentProfile = useMemo(() => { return clash?.find((item) => item.uid === currentProfileUid) as ClashProfile }, [clash, currentProfileUid]) // Filter chains to show only relevant ones based on global/local context const filteredChains = useMemo(() => { if (isGlobalChainCurrent) { // When in global chain mode, show all chain profiles return chain || [] } else { // In local chain mode, show all chain profiles so user can add them to the current profile's chain // This is the expected behavior for local chains - users should see all available chains to choose from return chain || [] } }, [chain, isGlobalChainCurrent]) const handleChainClick = useLockFn(async (uid: string) => { const chains = isGlobalChainCurrent ? (profiles?.chain ?? []) : (currentProfile?.chain ?? []) const updatedChains = chains.includes(uid) ? chains.filter((chain) => chain !== uid) : [...chains, uid] try { if (isGlobalChainCurrent) { await upsert.mutateAsync({ chain: updatedChains }) } else { if (!currentProfile?.uid) { return } await patch.mutateAsync({ uid: currentProfile.uid, profile: { ...(currentProfile as ClashProfileBuilder), chain: updatedChains, }, }) } } catch (e) { message(`Apply error: ${formatError(e)}`, { kind: 'error', title: t('Error'), }) } }) const reorderValues = useMemo( () => filteredChains?.map((item) => item.uid) || [], [filteredChains], ) return (
{ const profileUids = clash?.map((item) => item.uid) || [] sort.mutate([...profileUids, ...values]) }} layoutScroll style={{ overflowY: 'scroll' }} > {filteredChains?.map((item, index) => { const selected = isGlobalChainCurrent ? profiles?.chain?.includes(item.uid) : currentProfile?.chain?.includes(item.uid) // Check if chain is used in global context const usedInGlobal = profiles?.chain?.includes(item.uid) ?? false // Check if chain is used in current profile context const usedInCurrentProfile = currentProfile?.chain?.includes(item.uid) ?? false return ( await handleChainClick(item.uid)} onChainEdit={() => onChainEdit(item)} /> ) })} ({ backgroundColor: alpha(theme.vars.palette.secondary.main, 0.1), borderRadius: 4, })} onClick={() => onChainEdit()} >
{t('New Chain')}
) } ================================================ FILE: frontend/nyanpasu/src/components/profiles/modules/side-log.tsx ================================================ import { useAtomValue } from 'jotai' import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { VList } from 'virtua' import { RamenDining, Terminal } from '@mui/icons-material' import { Divider } from '@mui/material' import { usePostProcessingOutput, useProfile } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' import { atomChainsSelected, atomGlobalChainCurrent } from './store' const LogListItem = memo(function LogListItem({ name, item, showDivider, }: { name?: string item?: [string, string] showDivider?: boolean }) { return ( <> {showDivider && }
{name} [{item?.[0]}]: {item?.[1]}
) }) export interface SideLogProps { className?: string } export const SideLog = ({ className }: SideLogProps) => { const { t } = useTranslation() const { query } = useProfile() const profiles = query.data?.items const { data } = usePostProcessingOutput() const isGlobalChainCurrent = useAtomValue(atomGlobalChainCurrent) const currentProfileUid = useAtomValue(atomChainsSelected) const currentLogs = useMemo(() => { if (currentProfileUid) { return data?.scopes[currentProfileUid] } if (isGlobalChainCurrent) { return data?.scopes.global } }, [currentProfileUid, data, isGlobalChainCurrent]) return (
{t('Console')}
{currentLogs ? ( Object.entries(currentLogs).map(([uid, content]) => { return content?.map((item, index) => { const name = profiles?.find((script) => script.uid === uid)?.name return ( ) }) }) ) : (

{t('No Logs')}

)}
) } ================================================ FILE: frontend/nyanpasu/src/components/profiles/modules/store.ts ================================================ import { atom } from 'jotai' export const atomGlobalChainCurrent = atom(false) export const atomChainsSelected = atom() ================================================ FILE: frontend/nyanpasu/src/components/profiles/new-profile-button.tsx ================================================ import { use, useEffect, useState } from 'react' import { Add } from '@mui/icons-material' import { cn, FloatingButton } from '@nyanpasu/ui' import { AddProfileContext, ProfileDialog } from './profile-dialog' export const NewProfileButton = ({ className }: { className?: string }) => { const addProfileCtx = use(AddProfileContext) const [open, setOpen] = useState(!!addProfileCtx) useEffect(() => { setOpen(!!addProfileCtx) }, [addProfileCtx]) return ( <> setOpen(true)}> setOpen(false)} /> ) } export default NewProfileButton ================================================ FILE: frontend/nyanpasu/src/components/profiles/profile-dialog.tsx ================================================ import { version } from '~/package.json' import { useAsyncEffect } from 'ahooks' import { type editor } from 'monaco-editor' import { createContext, lazy, Suspense, use, useEffect, useMemo, useRef, useState, } from 'react' import { Controller, SelectElement, TextFieldElement, useForm, } from 'react-hook-form-mui' import { useTranslation } from 'react-i18next' import { useLatest } from 'react-use' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { Divider, InputAdornment } from '@mui/material' import { ProfileQueryResultItem, ProfileTemplate, RemoteProfile, useProfile, useProfileContent, } from '@nyanpasu/interface' import { BaseDialog } from '@nyanpasu/ui' import { LabelSwitch } from '../setting/modules/clash-field' import { ReadProfile } from './read-profile' import { ClashProfile, ClashProfileBuilder } from './utils' const ProfileMonacoViewer = lazy(() => import('./profile-monaco-viewer')) export interface ProfileDialogProps { profile?: ProfileQueryResultItem open: boolean onClose: () => void } export type AddProfileContextValue = { name: string | null desc: string | null url: string } export const AddProfileContext = createContext( null, ) export const ProfileDialog = ({ profile, open, onClose, }: ProfileDialogProps) => { const { t } = useTranslation() const { create, patch } = useProfile() const contentFn = useProfileContent(profile?.uid ?? '') const localProfile = useRef('') const addProfileCtx = use(AddProfileContext) const [localProfileMessage, setLocalProfileMessage] = useState('') const { control, watch, handleSubmit, reset, setValue } = useForm({ defaultValues: (profile as ClashProfile) || { type: 'remote', name: addProfileCtx?.name || t(`New Profile`), desc: addProfileCtx?.desc || '', url: addProfileCtx?.url || '', option: { // user_agent: "", with_proxy: false, self_proxy: false, }, }, }) useEffect(() => { if (addProfileCtx) { setValue('url', addProfileCtx.url) if (addProfileCtx.desc) setValue('desc', addProfileCtx.desc) if (addProfileCtx.name) setValue('name', addProfileCtx.name) } }, [addProfileCtx, setValue]) const isRemote = watch('type') === 'remote' const [isEdit, setIsEdit] = useState(!!profile) useEffect(() => { setIsEdit(!!profile) }, [profile]) const commonProps = useMemo( () => ({ autoComplete: 'off', autoCorrect: 'off', fullWidth: true, }), [], ) const handleProfileSelected = (content: string) => { localProfile.current = content setLocalProfileMessage('') } const [editor, setEditor] = useState({ value: '', language: 'yaml', }) const latestEditor = useLatest(editor) const editorMarks = useRef([]) const editorHasError = () => editorMarks.current.length > 0 && editorMarks.current.some((m) => m.severity === 8) const onSubmit = handleSubmit(async (form) => { if (editorHasError()) { message('Please fix the error before saving', { kind: 'error', }) return } const toCreate = async () => { if (isRemote) { const data = form as RemoteProfile await create.mutateAsync({ type: 'url', data: { url: data.url, // TODO: define backend serde(option) to move null option: data.option ? { ...data.option, user_agent: data.option.user_agent ?? null, with_proxy: data.option.with_proxy ?? null, self_proxy: data.option.self_proxy ?? null, } : null, }, }) } else { if (localProfile.current) { await create.mutateAsync({ type: 'manual', data: { item: form, fileData: localProfile.current, }, }) } else { await create.mutateAsync({ type: 'manual', data: { item: form, fileData: ProfileTemplate.profile, }, }) } } } const toUpdate = async () => { const value = latestEditor.current.value await contentFn.upsert.mutateAsync(value) await patch.mutateAsync({ uid: form.uid!, profile: form, }) } try { if (isEdit) { await toUpdate() } else { await toCreate() } setTimeout(() => reset(), 300) onClose() } catch (err) { message('Failed to save profile: \n' + formatError(err), { kind: 'error', }) console.error(err) } }) const dialogProps = isEdit && { contentStyle: { overflow: 'hidden', padding: 0, }, full: true, } const MetaInfo = useMemo( () => (
{!isEdit && ( )} {isRemote && ( <> {t('minutes')} ), }} /> ( )} /> ( )} /> )} {!isRemote && !isEdit && ( <> {localProfileMessage && (
{localProfileMessage}
)} * {t('Choose file to import or leave it blank to create new one')} )}
), [commonProps, control, isEdit, isRemote, localProfileMessage, t], ) useAsyncEffect(async () => { if (profile) { reset(profile as ClashProfileBuilder) } if (isEdit) { try { const value = contentFn.query.data ?? '' setEditor((editor) => ({ ...editor, value })) } catch (error) { console.error(error) } } }, [open]) return ( onClose()} onOk={onSubmit} divider {...dialogProps} > {isEdit ? (
{MetaInfo}
{open && ( setEditor((editor) => ({ ...editor, value })) } onValidate={(marks) => (editorMarks.current = marks)} language={editor.language} /> )}
) : ( MetaInfo )}
) } ================================================ FILE: frontend/nyanpasu/src/components/profiles/profile-item.tsx ================================================ import { useLockFn, useMemoizedFn, useSetState } from 'ahooks' import dayjs from 'dayjs' import { AnimatePresence, motion } from 'framer-motion' import { memo, use, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { formatError } from '@/utils' import { message } from '@/utils/notification' import parseTraffic from '@/utils/parse-traffic' import { FiberManualRecord, FilterDrama, InsertDriveFile, Menu as MenuIcon, Terminal, Update, } from '@mui/icons-material' import { Badge, Button, Chip, LinearProgress, Menu, MenuItem, Paper, Tooltip, } from '@mui/material' import { ProfileQueryResultItem, RemoteProfile, RemoteProfileOptionsBuilder, useClashConnections, useProfile, } from '@nyanpasu/interface' import { alpha, cleanDeepClickEvent, cn } from '@nyanpasu/ui' import { ProfileDialog } from './profile-dialog' import { GlobalUpdatePendingContext } from './provider' import { ClashProfile } from './utils' export interface ProfileItemProps { item: ProfileQueryResultItem selected?: boolean maxLogLevelTriggered?: { global: undefined | 'info' | 'error' | 'warn' current: undefined | 'info' | 'error' | 'warn' } onClickChains: (item: ClashProfile) => void chainsSelected?: boolean } export const ProfileItem = memo(function ProfileItem({ item, selected, onClickChains, chainsSelected, maxLogLevelTriggered, }: ProfileItemProps) { const { t } = useTranslation() const { deleteConnections } = useClashConnections() const { upsert } = useProfile() const globalUpdatePending = use(GlobalUpdatePendingContext) const [loading, setLoading] = useSetState({ update: false, card: false, }) const calc = () => { let progress = 0 let total = 0 let used = 0 if ('extra' in item && item.extra) { const { download, upload, total: t } = item.extra total = t used = download + upload progress = (used / (total || 1)) * 100 } return { progress, total, used } } const { progress, total, used } = calc() const isRemote = item.type === 'remote' const IconComponent = isRemote ? FilterDrama : InsertDriveFile const [anchorEl, setAnchorEl] = useState(null) const handleSelect = useLockFn(async () => { if (selected) { return } try { setLoading({ card: true }) await upsert.mutateAsync({ current: [item.uid] }) await deleteConnections.mutateAsync(undefined) } catch (err) { // This FetchError was triggered by the `DELETE /connections` API const isFetchError = err instanceof Error && err.name === 'FetchError' message( isFetchError ? `Failed to delete connections: \n ${err instanceof Error ? err.message : String(err)}` : `Error setting profile: \n ${err instanceof Error ? err.message : String(err)}`, { title: isFetchError ? t('DeleteConnectionsError') : t('Error'), kind: isFetchError ? 'warning' : 'error', }, ) } finally { setLoading({ card: false }) } }) const handleUpdate = useLockFn(async (proxy?: boolean) => { // TODO: define backend serde(option) to move null const selfOption = 'option' in item ? item.option : undefined const options: RemoteProfileOptionsBuilder = { with_proxy: false, self_proxy: false, update_interval: 0, user_agent: null, ...selfOption, } if (proxy) { if (selfOption?.self_proxy) { options.with_proxy = false options.self_proxy = true } else { options.with_proxy = true options.self_proxy = false } } try { setLoading({ update: true }) await item?.update?.(options) } catch (e) { message(`Update failed: \n ${formatError(e)}`, { title: t('Error'), kind: 'error', }) } finally { setLoading({ update: false }) } }) const handleDelete = useLockFn(async () => { try { // await deleteProfile(item.uid) await item?.drop?.() } catch (err) { message(`Delete failed: \n ${JSON.stringify(err)}`, { title: t('Error'), kind: 'error', }) } }) const menuMapping = useMemo( () => ({ Select: () => handleSelect(), 'Edit Info': () => setOpen(true), 'Proxy Chains': () => onClickChains(item as ClashProfile), 'Open File': () => item?.view?.(), Update: () => handleUpdate(), 'Update(Proxy)': () => handleUpdate(true), Delete: () => handleDelete(), }), [handleDelete, handleSelect, handleUpdate, item, onClickChains], ) const MenuComp = useMemo(() => { const handleClick = (func: () => void) => { setAnchorEl(null) func() } return ( setAnchorEl(null)} > {Object.entries(menuMapping).map(([key, func], index) => { return ( { cleanDeepClickEvent(e) handleClick(func) }} > {t(key)} ) })} ) }, [anchorEl, menuMapping, t]) const [open, setOpen] = useState(false) return ( <> ({ backgroundColor: selected ? alpha(theme.vars.palette.primary.main, 0.2) : null, }), ]} >
} label={isRemote ? t('Remote') : t('Local')} /> {selected && ( ({ fill: theme.vars.palette.success.main, })} /> )} ), !!(item as RemoteProfile).extra?.expire && ( ), ]} />

{item.name}

{item.desc}

{progress.toFixed(2)}%
{t('Applying Profile')}
{MenuComp} setOpen(false)} profile={item} /> ) }) function TimeSpan({ ts, k }: { ts: number; k: string }) { const time = dayjs(ts * 1000) const { t } = useTranslation() return (
{t(k, { time: time.fromNow(), })}
) } function TextCarousel(props: { nodes: React.ReactNode[]; className?: string }) { const [index, setIndex] = useState(0) const nodes = useMemo( () => props.nodes.filter((item) => !!item), [props.nodes], ) const nextNode = useMemoizedFn(() => { setIndex((i) => (i + 1) % nodes.length) }) useEffect(() => { if (nodes.length <= 1) { return } const timer = setInterval(() => { nextNode() }, 8000) return () => clearInterval(timer) }, [index, nextNode, nodes.length]) if (nodes.length === 0) { return null } return (
nextNode()} > {nodes.map( (node, i) => i === index && ( {node} ), )}
) } export default ProfileItem ================================================ FILE: frontend/nyanpasu/src/components/profiles/profile-monaco-diff-viewer.tsx ================================================ import '@/services/monaco' import { DiffEditor, DiffEditorProps } from '@monaco-editor/react' import { beforeEditorMount } from './profile-monaco-viewer' export default function ProfileMonacoDiffViewer( props: Omit, ) { return } ================================================ FILE: frontend/nyanpasu/src/components/profiles/profile-monaco-viewer.tsx ================================================ import { OS } from '@/consts' import '@/services/monaco' import { useAtomValue } from 'jotai' import { type JSONSchema7 } from 'json-schema' import nyanpasuMergeSchema from 'meta-json-schema/schemas/clash-nyanpasu-merge-json-schema.json' import clashMetaSchema from 'meta-json-schema/schemas/meta-json-schema.json' import { type editor } from 'monaco-editor' import * as monaco from 'monaco-editor' import { configureMonacoYaml } from 'monaco-yaml' import { nanoid } from 'nanoid' import { useCallback, useMemo, useRef } from 'react' // schema import { themeMode } from '@/store' import MonacoEditor from '@monaco-editor/react' import { openThat } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' export interface ProfileMonacoViewProps { value?: string onChange?: (value: string) => void language?: string className?: string readonly?: boolean schemaType?: 'clash' | 'merge' onValidate?: (markers: editor.IMarker[]) => void } export interface ProfileMonacoViewRef { getValue: () => string | undefined } let initd = false export const beforeEditorMount = () => { if (!initd) { monaco.typescript.javascriptDefaults.setCompilerOptions({ target: monaco.typescript.ScriptTarget.ES2020, allowNonTsExtensions: true, allowJs: true, }) console.log(clashMetaSchema) console.log(nyanpasuMergeSchema) configureMonacoYaml(monaco, { validate: true, enableSchemaRequest: true, completion: true, schemas: [ { uri: 'http://example.com/schema-name.json', fileMatch: ['**/*.clash.yaml'], // @ts-expect-error JSONSchema7 as JSONSchema schema: clashMetaSchema as JSONSchema7, }, { uri: 'http://example.com/schema-name.json', fileMatch: ['**/*.merge.yaml'], // @ts-expect-error JSONSchema7 as JSONSchema schema: nyanpasuMergeSchema as JSONSchema7, }, ], }) // Register link provider for all supported languages const registerLinkProvider = (language: string) => { monaco.languages.registerLinkProvider(language, { provideLinks: (model) => { const links = [] // More robust URL regex pattern const urlRegex = /\b(?:https?:\/\/|www\.)[^\s<>"']*[^<>\s"',.!?]/gi for (let i = 1; i <= model.getLineCount(); i++) { const line = model.getLineContent(i) let match while ((match = urlRegex.exec(line)) !== null) { const url = match[0].startsWith('http') ? match[0] : `https://${match[0]}` links.push({ range: new monaco.Range( i, match.index + 1, i, match.index + match[0].length + 1, ), url, }) } } return { links, dispose: () => {}, } }, }) } // Register link provider for all languages we support registerLinkProvider('javascript') registerLinkProvider('lua') registerLinkProvider('yaml') } initd = true } export default function ProfileMonacoViewer({ value, language, readonly = false, schemaType, className, onValidate, ...others }: ProfileMonacoViewProps) { const mode = useAtomValue(themeMode) const path = useMemo( () => `${nanoid()}.${schemaType ? `${schemaType}.` : ''}${language}`, [schemaType, language], ) const editorRef = useRef(null) const onChange = useCallback( (value: string | undefined) => { if (value && others.onChange) { others.onChange(value) } }, [others], ) const handleEditorDidMount = useCallback( (editor: editor.IStandaloneCodeEditor) => { editorRef.current = editor // Enable URL detection and handling editor.onMouseDown((e) => { const position = e.target.position if (!position) return // Get the model const model = editor.getModel() if (!model) return // Get the word at the clicked position const wordAtPosition = model.getWordAtPosition(position) if (!wordAtPosition) return // More comprehensive URL regex pattern const urlRegex = /\b(?:https?:\/\/|www\.)[^\s<>"']*[^<>\s"',.!?]/gi // Check if the clicked word is part of a URL const lineContent = model.getLineContent(position.lineNumber) let match while ((match = urlRegex.exec(lineContent)) !== null) { const urlStart = match.index + 1 const urlEnd = urlStart + match[0].length // Check if the click position is within the URL if (position.column >= urlStart && position.column <= urlEnd) { // Only handle Ctrl+Click or Cmd+Click if (e.event.ctrlKey || e.event.metaKey) { // Add protocol if missing (for www. URLs) const url = match[0].startsWith('http') ? match[0] : `https://${match[0]}` openThat(url) e.event.preventDefault() break } } } }) }, [], ) return ( ) } ================================================ FILE: frontend/nyanpasu/src/components/profiles/profile-side.tsx ================================================ import { Allotment } from 'allotment' import 'allotment/dist/style.css' import { useAtomValue } from 'jotai' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Close } from '@mui/icons-material' import { IconButton } from '@mui/material' import { Profile, useProfile } from '@nyanpasu/interface' import { SideChain } from './modules/side-chain' import { SideLog } from './modules/side-log' import { atomChainsSelected, atomGlobalChainCurrent } from './modules/store' import { ScriptDialog } from './script-dialog' export interface ProfileSideProps { onClose: () => void } export const ProfileSide = ({ onClose }: ProfileSideProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const [item, setItem] = useState() const isGlobalChainCurrent = useAtomValue(atomGlobalChainCurrent) const currentProfileUid = useAtomValue(atomChainsSelected) const { query } = useProfile() const currentProfile = useMemo(() => { return query.data?.items?.find((item) => item.uid === currentProfileUid) }, [query.data?.items, currentProfileUid]) const handleEditChain = async (_item?: Profile) => { setItem(_item) setOpen(true) } return (
{isGlobalChainCurrent ? t('Global Proxy Chains') : t('Proxy Chains')}
{isGlobalChainCurrent ? t('All Profiles') : currentProfile?.name}
{ setOpen(false) setItem(undefined) }} />
) } export default ProfileSide ================================================ FILE: frontend/nyanpasu/src/components/profiles/provider.tsx ================================================ import { createContext } from 'react' export const GlobalUpdatePendingContext = createContext(false) ================================================ FILE: frontend/nyanpasu/src/components/profiles/quick-import.tsx ================================================ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { ClearRounded, ContentCopyRounded, Download } from '@mui/icons-material' import { CircularProgress, FilledInputProps, IconButton, TextField, Tooltip, } from '@mui/material' import { useProfile } from '@nyanpasu/interface' import { alpha } from '@nyanpasu/ui' import { readText } from '@tauri-apps/plugin-clipboard-manager' export const QuickImport = () => { const { t } = useTranslation() const [url, setUrl] = useState('') const [loading, setLoading] = useState(false) const { create } = useProfile() const onCopyLink = async () => { const text = await readText() if (text) { setUrl(text) } } const endAdornment = () => { if (loading) { return } if (url) { return ( <> setUrl('')}> ) } return ( ) } const handleImport = async () => { try { setLoading(true) await create.mutateAsync({ type: 'url', data: { url, option: null, }, }) } finally { setUrl('') setLoading(false) } } const inputProps: Partial = { sx: (theme) => ({ borderRadius: 7, backgroundColor: alpha(theme.vars.palette.primary.main, 0.1), fieldset: { border: 'none', }, }), endAdornment: endAdornment(), } return ( setUrl(e.target.value)} onKeyDown={(e) => url !== '' && e.key === 'Enter' && handleImport()} sx={{ input: { py: 1, px: 2 } }} slotProps={{ input: inputProps, }} /> ) } ================================================ FILE: frontend/nyanpasu/src/components/profiles/read-profile.tsx ================================================ import { useState } from 'react' import { useTranslation } from 'react-i18next' import getSystem from '@/utils/get-system' import { Button } from '@mui/material' import { open } from '@tauri-apps/plugin-dialog' import { readTextFile } from '@tauri-apps/plugin-fs' const isWin = getSystem() === 'windows' export interface ReadProfileProps { onSelected: (content: string) => void } export const ReadProfile = ({ onSelected }: ReadProfileProps) => { const { t } = useTranslation() const [loading, setLoading] = useState(false) const [label, setLabel] = useState('') const handleSelectFile = async () => { try { setLoading(true) const selected = await open({ directory: false, multiple: false, filters: [ { name: t('Profile'), extensions: ['yaml', 'yml'], }, ], }) // user cancelled the selection if (!selected || Array.isArray(selected)) { return null } onSelected(await readTextFile(selected)) if (isWin) { setLabel(selected.split('\\').at(-1) as string) } else { setLabel(selected.split('/').at(-1) as string) } } catch (e) { console.error(e) } finally { setLoading(false) } } return ( ) } ================================================ FILE: frontend/nyanpasu/src/components/profiles/runtime-config-diff-dialog.tsx ================================================ import { useCreation } from 'ahooks' import { useAtomValue } from 'jotai' import { nanoid } from 'nanoid' import { lazy, Suspense } from 'react' import { useTranslation } from 'react-i18next' import { themeMode } from '@/store' import { useProfile, useProfileContent, useRuntimeProfile, } from '@nyanpasu/interface' import { BaseDialog, cn } from '@nyanpasu/ui' const MonacoDiffEditor = lazy(() => import('./profile-monaco-diff-viewer')) export type RuntimeConfigDiffDialogProps = { open: boolean onClose: () => void } export default function RuntimeConfigDiffDialog({ open, onClose, }: RuntimeConfigDiffDialogProps) { const { t } = useTranslation() const { query } = useProfile() const currentProfileUid = query.data?.current?.[0] const contentFn = useProfileContent(currentProfileUid || '') // need manual refetch contentFn.query.refetch() const runtimeProfile = useRuntimeProfile() const loaded = !contentFn.query.isLoading && !query.isLoading const mode = useAtomValue(themeMode) const originalModelPath = useCreation(() => `${nanoid()}.clash.yaml`, []) const modifiedModelPath = useCreation(() => `${nanoid()}.runtime.yaml`, []) if (!currentProfileUid) { return null } return (
{t('Original Config')} {t('Runtime Config')}
{loaded && ( )}
) } ================================================ FILE: frontend/nyanpasu/src/components/profiles/script-dialog.tsx ================================================ import { useAsyncEffect, useReactive } from 'ahooks' import { type editor } from 'monaco-editor' import { lazy, Suspense, useEffect, useRef, useState } from 'react' import { SelectElement, TextFieldElement, useForm } from 'react-hook-form-mui' import { useTranslation } from 'react-i18next' import { message } from '@/utils/notification' import { Divider } from '@mui/material' import { Profile, ProfileTemplate, useProfile, useProfileContent, } from '@nyanpasu/interface' import { BaseDialog, BaseDialogProps } from '@nyanpasu/ui' import LanguageChip from './modules/language-chip' import { ChainProfileBuilder, getLanguage, ProfileType, ProfileTypes, } from './utils' const ProfileMonacoViewer = lazy(() => import('./profile-monaco-viewer')) const formCommonProps = { autoComplete: 'off', autoCorrect: 'off', fullWidth: true, } const optionTypeMapping = [ { id: 'js', value: ProfileTypes.JavaScript, language: 'javascript', label: 'JavaScript', }, { id: 'lua', value: ProfileTypes.LuaScript, language: 'lua', label: 'LuaScript', }, { id: 'merge', value: ProfileTypes.Merge, language: 'yaml', label: 'Merge', }, ] const convertTypeMapping = (data: Profile) => { optionTypeMapping.forEach((option) => { if (option.id === data.type) { data = { ...data, ...option, } } }) return data } export interface ScriptDialogProps extends Omit { open: boolean onClose: () => void profile?: Profile } export const ScriptDialog = ({ open, profile, onClose, ...props }: ScriptDialogProps) => { const { t } = useTranslation() const { create, patch } = useProfile() const contentFn = useProfileContent(profile?.uid ?? '') const form = useForm() const isEdit = Boolean(profile) useEffect(() => { if (isEdit) { form.reset(profile) } else { form.reset({ type: 'merge', name: t('New Script'), desc: '', }) } }, [form, isEdit, profile, t]) const [openMonaco, setOpenMonaco] = useState(false) const editor = useReactive<{ value: string displayLanguage: string language: string rawType: ProfileType }>({ value: ProfileTemplate.merge, displayLanguage: 'YAML', language: 'yaml', rawType: 'merge', }) const editorMarks = useRef([]) const editorHasError = () => editorMarks.current.length > 0 && editorMarks.current.some((m) => m.severity === 8) const onSubmit = form.handleSubmit(async (data) => { if (editorHasError()) { message(t('Please fix the error before submitting'), { kind: 'error', }) return } convertTypeMapping(data) const editorValue = editor.value if (!editorValue) { return } try { if (isEdit) { await contentFn.upsert.mutateAsync(editorValue) await patch.mutateAsync({ uid: data.uid, profile: data as ChainProfileBuilder, }) } else { await create.mutateAsync({ type: 'manual', data: { item: data as ChainProfileBuilder, fileData: editorValue, }, }) } } finally { onClose() } }) useAsyncEffect(async () => { if (isEdit) { const result = await contentFn.query.refetch() editor.value = result.data ?? '' editor.displayLanguage = getLanguage(profile!) editor.language = editor.displayLanguage.toLowerCase() } else { editor.value = ProfileTemplate.merge editor.displayLanguage = 'YAML' editor.language = editor.displayLanguage.toLowerCase() } setOpenMonaco(open) }, [open]) const handleTypeChange = () => { const data = form.getValues() editor.rawType = convertTypeMapping(data).type const lang = getLanguage(data) if (!lang) { return } editor.displayLanguage = lang editor.language = editor.displayLanguage.toLowerCase() switch (editor.language) { case 'yaml': { editor.value = ProfileTemplate.merge break } case 'lua': { editor.value = ProfileTemplate.luascript break } case 'javascript': { editor.value = ProfileTemplate.javascript break } } } return ( {isEdit ? t('Edit Script') : t('New Script')}
} open={open} onClose={() => onClose()} onOk={onSubmit} divider contentStyle={{ overflow: 'hidden', padding: 0, }} full {...props} >
{!isEdit && ( handleTypeChange()} /> )}
{openMonaco && !contentFn.query.isPending && ( { editor.value = value }} language={editor.language} onValidate={(marks) => { editorMarks.current = marks }} schemaType={editor.rawType === 'merge' ? 'merge' : undefined} /> )}
) } ================================================ FILE: frontend/nyanpasu/src/components/profiles/utils.ts ================================================ import type { Profile, ProfileBuilder } from '@nyanpasu/interface' /** * Represents a Clash configuration profile, which can be either locally stored or fetched from a remote source. */ export type ClashProfile = Extract export type ClashProfileBuilder = Extract< ProfileBuilder, { type: 'remote' | 'local' } > /** * Represents a Clash configuration profile that is a chain of multiple profiles. */ export type ChainProfile = Extract export type ChainProfileBuilder = Extract< ProfileBuilder, { type: 'merge' | 'script' } > /** * Filters an array of profiles into two categories: clash and chain profiles. * * @param items - Array of Profile objects to be filtered * @returns An object containing two arrays: * - clash: Array of profiles where type is 'remote' or 'local' * - chain: Array of profiles where type is 'merge' or has a script property */ export function filterProfiles(items?: T[]) { /** * Filters the input array to include only items of type 'remote' or 'local' * @param items - Array of items to filter * @returns {Array} Filtered array containing only remote and local items */ const clash = items?.filter( (item) => item.type === 'remote' || item.type === 'local', ) /** * Filters an array of items to get a chain of either 'merge' type items * or items with a script property in their type object. * * @param {Array<{ type: string | { script: 'javascript' | 'lua' } }>} items - The array of items to filter * @returns {Array<{ type: string | { script: 'javascript' | 'lua' } }>} A filtered array containing only merge items or items with scripts */ const chain = items?.filter( (item) => item.type === 'merge' || item.type === 'script', ) return { clash, chain, } } export type ProfileType = Profile['type'] export const ProfileTypes = { JavaScript: { type: 'script', script_type: 'javascript' }, LuaScript: { type: 'script', script_type: 'lua' }, Merge: { type: 'merge' }, } as const export const getLanguage = (profile: Profile) => { switch (profile.type) { case 'script': switch (profile.script_type) { case 'javascript': return 'JavaScript' case 'lua': return 'Lua' } break case 'merge': case 'local': case 'remote': return 'YAML' } } ================================================ FILE: frontend/nyanpasu/src/components/providers/block-task-provider.tsx ================================================ import { createContext, PropsWithChildren, useCallback, useContext, useRef, useState, } from 'react' import { useLockFn } from '@/hooks/use-lock-fn' type BlockTaskStatus = 'idle' | 'pending' | 'success' | 'error' // eslint-disable-next-line @typescript-eslint/no-explicit-any interface BlockTask { id: string status: BlockTaskStatus data?: T error?: Error startTime: number endTime?: number } interface BlockTaskContextType { tasks: Record run: (key: string, fn: (...args: unknown[]) => Promise) => Promise getTask: (key: string) => BlockTask | undefined clearTask: (key: string) => void } const BlockContext = createContext(null) export const useBlockTaskContext = () => { const context = useContext(BlockContext) if (!context) { throw new Error('useBlockContext must be used within a BlockProvider') } return context } export const useBlockTask = ( key: string, fn: (...args: Args) => Promise, ) => { const { run, tasks } = useBlockTaskContext() const execute = useLockFn(async (...args: Args) => { return await run(key, () => fn(...args)) }) return { execute, isPending: tasks[key]?.status === 'pending', isSuccess: tasks[key]?.status === 'success', isError: tasks[key]?.status === 'error', data: tasks[key]?.data, error: tasks[key]?.error, } } export const BlockTaskProvider = ({ children }: PropsWithChildren) => { const [tasks, setTasks] = useState>({}) const tasksRef = useRef>({}) const run = useCallback( async (key: string, fn: () => Promise): Promise => { const task: BlockTask = { id: key, status: 'pending', startTime: Date.now(), } setTasks((prev) => ({ ...prev, [key]: task })) tasksRef.current[key] = task try { const data = await fn() const successTask: BlockTask = { ...task, status: 'success', data, endTime: Date.now(), } setTasks((prev) => ({ ...prev, [key]: successTask, })) tasksRef.current[key] = successTask return data } catch (error) { const errorTask: BlockTask = { ...task, status: 'error', error: error instanceof Error ? error : new Error(String(error)), endTime: Date.now(), } setTasks((prev) => ({ ...prev, [key]: errorTask, })) tasksRef.current[key] = errorTask throw error } }, [], ) const getTask = useCallback((key: string) => tasks[key], [tasks]) const clearTask = useCallback((key: string) => { setTasks((prev) => { const newTasks = { ...prev } delete newTasks[key] return newTasks }) delete tasksRef.current[key] }, []) return ( {children} ) } ================================================ FILE: frontend/nyanpasu/src/components/providers/context-menu-provider.tsx ================================================ import ContentCopy from '~icons/material-symbols/content-copy-rounded' import ContentCut from '~icons/material-symbols/content-cut-rounded' import ContentPaste from '~icons/material-symbols/content-paste-rounded' import { Children, cloneElement, createContext, PropsWithChildren, ReactElement, ReactNode, Ref, RefCallback, RefObject, useCallback, useContext, useEffect, useRef, useState, } from 'react' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuTrigger, } from '@/components/ui/context-menu' import { m } from '@/paraglide/messages' import { readText, writeText } from '@tauri-apps/plugin-clipboard-manager' type ContextMenuRegistryValue = { registerElement: (el: Element, getChildren: () => ReactNode) => void unregisterElement: (el: Element) => void } const ContextMenuRegistryContext = createContext(null) const useContextMenuRegistry = () => { const context = useContext(ContextMenuRegistryContext) if (!context) { throw new Error( 'useContextMenuRegistry must be used within a ContextMenuRegistryContext', ) } return context } export function useRegisterContextMenu( menuChildren: ReactNode, ): RefCallback { const { registerElement, unregisterElement } = useContextMenuRegistry() const elementRef = useRef(null) const childrenRef = useRef(menuChildren) childrenRef.current = menuChildren const getChildren = useCallback(() => childrenRef.current, []) return useCallback( (el: T | null) => { if (elementRef.current) { unregisterElement(elementRef.current) elementRef.current = null } if (el) { elementRef.current = el registerElement(el, getChildren) } }, [registerElement, unregisterElement, getChildren], ) } type RegisterContextMenuInternalCtxValue = { childrenRef: RefObject setTriggerEl: (el: Element | null) => void } const RegisterContextMenuInternalCtx = createContext(null) const useRegisterContextMenuInternal = () => { const ctx = useContext(RegisterContextMenuInternalCtx) if (!ctx) { throw new Error( 'RegisterContextMenuTrigger/Content must be used within RegisterContextMenu', ) } return ctx } export function RegisterContextMenu({ children }: PropsWithChildren) { const { registerElement, unregisterElement } = useContextMenuRegistry() const triggerElRef = useRef(null) const childrenRef = useRef(null) const getChildren = useCallback(() => childrenRef.current, []) const setTriggerEl = useCallback( (el: Element | null) => { if (triggerElRef.current) { unregisterElement(triggerElRef.current) } triggerElRef.current = el if (el) { registerElement(el, getChildren) } }, [registerElement, unregisterElement, getChildren], ) return ( {children} ) } /** * Attaches context-menu registration to its child element. * * - `asChild` (default `false`): wraps children in a ``. * - `asChild={true}`: merges the registration ref directly into the single * child element, preserving any existing ref on it. */ export function RegisterContextMenuTrigger({ children, asChild = false, }: { children: ReactElement asChild?: boolean }) { const { setTriggerEl } = useRegisterContextMenuInternal() // For asChild: keep the child's original ref in a stable container so // mergedRef doesn't have it as a dep and stays stable across renders. const child = Children.only(children) as ReactElement<{ ref?: Ref }> const originalRefLatest = useRef | undefined>(child.props.ref) originalRefLatest.current = child.props.ref const mergedRef = useCallback( (el: Element | null) => { setTriggerEl(el) const orig = originalRefLatest.current if (typeof orig === 'function') { orig(el) } else if (orig != null) { ;(orig as RefObject).current = el } }, [setTriggerEl], ) if (asChild) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return cloneElement(child, { ref: mergedRef } as any) } return {children} } export function RegisterContextMenuContent({ children }: PropsWithChildren) { const { childrenRef } = useRegisterContextMenuInternal() // Update the ref synchronously during render so getChildren() always returns // the latest JSX when the menu opens (safe — mutating a ref, not state). childrenRef.current = children useEffect(() => { // Also set in the effect body so that React StrictMode's cleanup+re-invoke // cycle restores the value after the cleanup sets it to null. childrenRef.current = children return () => { childrenRef.current = null } }) return null } const isEditable = (el: Element | null): boolean => { if (!el || !(el instanceof HTMLElement)) { return false } const tag = el.tagName.toLowerCase() if (tag === 'input' || tag === 'textarea') { return true } if (el.isContentEditable) { return true } return false } export default function ContextMenuProvider({ children }: PropsWithChildren) { const [hasSelection, setHasSelection] = useState(false) const [editable, setEditable] = useState(false) const [customChildren, setCustomChildren] = useState(null) const targetRef = useRef(null) const [open, setOpen] = useState(false) const registryRef = useRef(new Map ReactNode>()) const lastRightClickTargetRef = useRef(null) // Capture the right-clicked element before the context menu opens. // Use pointerdown (button === 2) instead of contextmenu so the target is // always recorded before any contextmenu listener (including Radix's) fires. useEffect(() => { const handler = (e: PointerEvent) => { if (e.button === 2) { lastRightClickTargetRef.current = e.target as Element } } document.addEventListener('pointerdown', handler, true) return () => document.removeEventListener('pointerdown', handler, true) }, []) const registerElement = useCallback( (el: Element, getChildren: () => ReactNode) => { registryRef.current.set(el, getChildren) }, [], ) const unregisterElement = useCallback((el: Element) => { registryRef.current.delete(el) }, []) const handleOpenChange = useCallback((nextOpen: boolean) => { setOpen(nextOpen) if (nextOpen) { const selection = window.getSelection() setHasSelection(!!selection && selection.toString().length > 0) const active = document.activeElement setEditable(isEditable(active)) targetRef.current = active // Traverse up the DOM from the right-clicked element to find registered children. let el: Element | null = lastRightClickTargetRef.current let found: ReactNode = null while (el) { const getter = registryRef.current.get(el) if (getter) { found = getter() break } el = el.parentElement } setCustomChildren(found) } }, []) const handleCopy = useCallback(async () => { const selection = window.getSelection() const text = selection?.toString() ?? '' if (!text) { return } await writeText(text) }, []) const handleCut = useCallback(async () => { const selection = window.getSelection() const text = selection?.toString() ?? '' if (!text || !editable) { return } await writeText(text) const el = targetRef.current if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { const start = el.selectionStart ?? 0 const end = el.selectionEnd ?? 0 const currentValue = el.value const nativeInputValueSetter = Object.getOwnPropertyDescriptor( el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype, 'value', )?.set nativeInputValueSetter?.call( el, currentValue.slice(0, start) + currentValue.slice(end), ) el.dispatchEvent(new Event('input', { bubbles: true })) el.setSelectionRange(start, start) return } if (el instanceof HTMLElement && el.isContentEditable && selection) { selection.deleteFromDocument() } }, [editable]) const handlePaste = useCallback(async () => { try { const text = await readText() const el = targetRef.current if (el && isEditable(el)) { if ( el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement ) { const start = el.selectionStart ?? 0 const end = el.selectionEnd ?? 0 const currentValue = el.value const nativeInputValueSetter = Object.getOwnPropertyDescriptor( el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype, 'value', )?.set nativeInputValueSetter?.call( el, currentValue.slice(0, start) + text + currentValue.slice(end), ) el.dispatchEvent(new Event('input', { bubbles: true })) const newPos = start + text.length el.setSelectionRange(newPos, newPos) } else { const editableEl = el as HTMLElement editableEl.focus() const selection = window.getSelection() if (!selection || selection.rangeCount === 0) { return } const range = selection.getRangeAt(0) range.deleteContents() range.insertNode(document.createTextNode(text)) range.collapse(false) selection.removeAllRanges() selection.addRange(range) } } } catch { // Ignore clipboard read failures (e.g. permission denied). } }, []) return ( {children} {customChildren != null && ( <> {customChildren} )} {m.common_cut()} Ctrl+X {m.common_copy()} Ctrl+C {m.common_paste()} Ctrl+V ) } ================================================ FILE: frontend/nyanpasu/src/components/providers/language-provider.tsx ================================================ import { locale } from 'dayjs' import { createContext, PropsWithChildren, useContext, useEffect } from 'react' import { useLockFn } from '@/hooks/use-lock-fn' import { getLocale, Locale, setLocale } from '@/paraglide/runtime' import { useSetting } from '@nyanpasu/interface' const LanguageContext = createContext<{ language?: Locale setLanguage: (value: Locale) => Promise } | null>(null) export const useLanguage = () => { const context = useContext(LanguageContext) if (!context) { throw new Error('useLanguage must be used within a LanguageProvider') } return context } export const LanguageProvider = ({ children }: PropsWithChildren) => { const language = useSetting('language') const setLanguage = useLockFn(async (value: Locale) => { await language.upsert(value) setLocale(value) }) // sync dayjs locale useEffect(() => { if (language) { locale(language.value || 'en') } }, [language]) return ( {children} ) } ================================================ FILE: frontend/nyanpasu/src/components/providers/nyanpasu-update-provider.tsx ================================================ import { createContext, PropsWithChildren, use, useEffect, useState, } from 'react' import { Action as AboutAction } from '@/pages/(main)/main/settings/about/route' import { commands, unwrapResult, useSetting, useUpdaterSupported, } from '@nyanpasu/interface' import packageJson from '@root/package.json' import { useNavigate } from '@tanstack/react-router' import { Update } from '@tauri-apps/plugin-updater' import { useBlockTask } from './block-task-provider' const NyanpasuUpdateContext = createContext<{ currentVersion: string hasNewVersion: boolean newVersion: Update | null isChecking: boolean checkNewVersion: () => Promise isSupported: boolean } | null>(null) export const useNyanpasuUpdate = () => { const context = use(NyanpasuUpdateContext) if (!context) { throw new Error( 'useNyanpasuUpdate must be used within a NyanpasuUpdateProvider', ) } return context } export default function NyanpasuUpdateProvider({ children, }: PropsWithChildren) { const { value: enableAutoCheckUpdate } = useSetting( 'enable_auto_check_update', ) const isSupported = useUpdaterSupported() const [hasNewVersion, setHasNewVersion] = useState(false) const [newVersion, setNewVersion] = useState(null) const blockTask = useBlockTask('check-nyanpasu-update', async () => { const metadata = unwrapResult(await commands.checkUpdate()) if (metadata) { const update = new Update({ rid: metadata.rid, currentVersion: metadata.current_version, version: metadata.version, rawJson: metadata.raw_json as Record, }) setNewVersion(update) setHasNewVersion(true) return update } return null }) const navigate = useNavigate() // auto check update useEffect(() => { if (enableAutoCheckUpdate) { blockTask.execute().then((update) => { // if there is a new version, navigate to the about page if (update) { navigate({ to: '/main/settings/about', search: { action: AboutAction.NEED_UPDATE, }, }) } }) } // oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps }, [enableAutoCheckUpdate, blockTask.execute]) return ( {children} ) } ================================================ FILE: frontend/nyanpasu/src/components/providers/proxies-provider-traffic.tsx ================================================ import parseTraffic from '@/utils/parse-traffic' import { LinearProgress, Tooltip } from '@mui/material' import { ProxiesProviderProps } from './proxies-provider' export const ProxiesProviderTraffic = ({ provider }: ProxiesProviderProps) => { const calc = () => { let progress = 0 let total = 0 let used = 0 if (provider.subscriptionInfo) { const { download, upload, total: t } = provider.subscriptionInfo total = t ?? 0 used = (download ?? 0) + (upload ?? 0) progress = (used / (total ?? 0)) * 100 } return { progress, total, used } } const { progress, total, used } = calc() return (
{progress.toFixed(2)}%
) } export default ProxiesProviderTraffic ================================================ FILE: frontend/nyanpasu/src/components/providers/proxies-provider.tsx ================================================ import { useLockFn } from 'ahooks' import dayjs from 'dayjs' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { message } from '@/utils/notification' import { Refresh } from '@mui/icons-material' import { Button, Chip, Paper } from '@mui/material' import { ClashProxiesProviderQueryItem } from '@nyanpasu/interface' import ProxiesProviderTraffic from './proxies-provider-traffic' export interface ProxiesProviderProps { provider: ClashProxiesProviderQueryItem } export const ProxiesProvider = ({ provider }: ProxiesProviderProps) => { const { t } = useTranslation() const [loading, setLoading] = useState(false) const handleClick = useLockFn(async () => { try { setLoading(true) await provider.mutate() } catch (e) { message(`Update ${provider.name} failed.\n${String(e)}`, { kind: 'error', title: t('Error'), }) } finally { setLoading(false) } }) return (

{provider.name}

{provider.vehicleType}/{provider.type}

{t('Last Update', { fromNow: dayjs(provider.updatedAt).fromNow(), })}
{provider.subscriptionInfo && ( )}
) } export default ProxiesProvider ================================================ FILE: frontend/nyanpasu/src/components/providers/rules-provider.tsx ================================================ import { useLockFn } from 'ahooks' import dayjs from 'dayjs' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { message } from '@/utils/notification' import { Refresh } from '@mui/icons-material' import { Button, Chip, Paper } from '@mui/material' import { ClashRulesProviderQueryItem } from '@nyanpasu/interface' export interface RulesProviderProps { provider: ClashRulesProviderQueryItem } export default function RulesProvider({ provider }: RulesProviderProps) { const { t } = useTranslation() const [loading, setLoading] = useState(false) const handleClick = useLockFn(async () => { try { setLoading(true) await provider.mutate() } catch (e) { message(`Update ${provider.name} failed.\n${String(e)}`, { kind: 'error', title: t('Error'), }) } finally { setLoading(false) } }) return (

{provider.name}

{provider.vehicleType}/{provider.behavior}

{t('Last Update', { fromNow: dayjs(provider.updatedAt).fromNow(), })}
) } ================================================ FILE: frontend/nyanpasu/src/components/providers/theme-provider.tsx ================================================ import { isEqual, kebabCase } from 'lodash-es' import { createContext, PropsWithChildren, useCallback, useContext, useEffect, useMemo, } from 'react' import { insertStyle } from '@/utils/styled' import { argbFromHex, hexFromArgb, Theme, themeFromSourceColor, } from '@material/material-color-utilities' import { useSetting } from '@nyanpasu/interface' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import { useLocalStorage } from '@uidotdev/usehooks' const appWindow = getCurrentWebviewWindow() export const DEFAULT_COLOR = '#1867C0' export enum ThemeMode { LIGHT = 'light', DARK = 'dark', SYSTEM = 'system', } const CUSTOM_THEME_KEY = 'custom-theme' as const const THEME_PALETTE_KEY = 'theme-palette-v1' as const const THEME_CSS_VARS_KEY = 'theme-css-vars-v1' as const const generateThemeCssVars = ({ schemes }: Theme) => { let lightCssVars = ':root{' let darkCssVars = ':root.dark{' Object.entries(schemes).forEach(([mode, scheme]) => { let inputScheme // Safely convert scheme to JSON if possible, otherwise use as-is if (typeof scheme.toJSON === 'function') { inputScheme = scheme.toJSON() } else { inputScheme = scheme } Object.entries(inputScheme).forEach(([key, value]) => { if (mode === 'light') { lightCssVars += `--color-md-${kebabCase(key)}: ${hexFromArgb(value)};` } else { darkCssVars += `--color-md-${kebabCase(key)}: ${hexFromArgb(value)};` } }) }) lightCssVars += '}' darkCssVars += '}' return lightCssVars + darkCssVars } const changeHtmlThemeMode = (mode: Omit) => { const root = document.documentElement if (mode === ThemeMode.DARK) { root.classList.add(ThemeMode.DARK) } else { root.classList.remove(ThemeMode.DARK) } if (mode === ThemeMode.LIGHT) { root.classList.add(ThemeMode.LIGHT) } else { root.classList.remove(ThemeMode.LIGHT) } } const getSystemThemeMode = () => { return window.matchMedia('(prefers-color-scheme: dark)').matches ? ThemeMode.DARK : ThemeMode.LIGHT } const ThemeContext = createContext<{ themePalette: Theme themeCssVars: string themeColor: string setThemeColor: (color: string) => Promise themeMode: ThemeMode currentThemeMode: Omit setThemeMode: (mode: ThemeMode) => Promise } | null>(null) export function useExperimentalThemeContext() { const context = useContext(ThemeContext) if (!context) { throw new Error( 'useExperimentalThemeContext must be used within a ExperimentalThemeProvider', ) } return context } export function ExperimentalThemeProvider({ children }: PropsWithChildren) { const themeMode = useSetting('theme_mode') const themeColor = useSetting('theme_color') const [cachedThemePalette, setCachedThemePalette] = useLocalStorage( THEME_PALETTE_KEY, themeFromSourceColor( // use default color if theme color is not set argbFromHex(themeColor.value || DEFAULT_COLOR), ), ) const [cachedThemeCssVars, setCachedThemeCssVars] = useLocalStorage( THEME_CSS_VARS_KEY, // initialize theme css vars from cached theme palette generateThemeCssVars(cachedThemePalette), ) // automatically insert custom theme css vars into document head useEffect(() => { insertStyle(CUSTOM_THEME_KEY, cachedThemeCssVars) }, [cachedThemeCssVars]) const setThemeColor = useCallback( async (color: string) => { if (color === themeColor.value) { return } else { await themeColor.upsert(color) } const materialColor = themeFromSourceColor( // use default color if theme color is not set argbFromHex(color || DEFAULT_COLOR), ) if (isEqual(materialColor, cachedThemePalette)) { return } else { setCachedThemePalette(materialColor) } const themeCssVars = generateThemeCssVars(materialColor) setCachedThemeCssVars(themeCssVars) }, [ themeColor, cachedThemePalette, setCachedThemeCssVars, setCachedThemePalette, ], ) // initialize theme mode on mount useEffect(() => { const initializeTheme = async () => { if (themeMode.value === ThemeMode.SYSTEM) { // Apply a synchronous system fallback first to avoid a light flash. changeHtmlThemeMode(getSystemThemeMode()) const systemTheme = await appWindow.theme() changeHtmlThemeMode( systemTheme === ThemeMode.DARK ? ThemeMode.DARK : ThemeMode.LIGHT, ) } else if ( themeMode.value === ThemeMode.LIGHT || themeMode.value === ThemeMode.DARK ) { changeHtmlThemeMode(themeMode.value) } else { // Setting value may still be loading; keep current class to avoid visual flicker. } } initializeTheme() }, [themeMode.value]) // listen to theme changed event and change html theme mode useEffect(() => { const unlisten = appWindow.onThemeChanged((e) => { if (themeMode.value === ThemeMode.SYSTEM) { changeHtmlThemeMode(e.payload) } }) return () => { unlisten.then((fn) => fn()) } }, [themeMode.value]) const setThemeMode = useCallback( async (mode: ThemeMode) => { // if theme mode is not system, change html theme mode if (mode !== ThemeMode.SYSTEM) { changeHtmlThemeMode(mode) } if (mode !== themeMode.value) { await themeMode.upsert(mode) } }, [themeMode], ) const currentThemeMode = useMemo>(() => { if (themeMode.value === ThemeMode.DARK) { return ThemeMode.DARK } if (themeMode.value === ThemeMode.LIGHT) { return ThemeMode.LIGHT } return getSystemThemeMode() }, [themeMode.value]) return ( {children} ) } ================================================ FILE: frontend/nyanpasu/src/components/providers/update-providers.tsx ================================================ import { useLockFn } from 'ahooks' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { message } from '@/utils/notification' import { Refresh } from '@mui/icons-material' import { Button } from '@mui/material' import { useClashRulesProvider } from '@nyanpasu/interface' export const UpdateProviders = () => { const { t } = useTranslation() const [loading, setLoading] = useState(false) const rulesProvider = useClashRulesProvider() const handleProviderUpdate = useLockFn(async () => { if (!rulesProvider.data) { message(`No Providers.`, { kind: 'info', title: t('Info'), }) return } try { setLoading(true) await Promise.all( Object.values(rulesProvider.data).map((provider) => { return provider.mutate() }), ) } catch (e) { message(`Update all failed.\n${String(e)}`, { kind: 'error', title: t('Error'), }) } finally { setLoading(false) } }) return ( ) } export default UpdateProviders ================================================ FILE: frontend/nyanpasu/src/components/providers/update-proxies-providers.tsx ================================================ import { useLockFn } from 'ahooks' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { message } from '@/utils/notification' import { Refresh } from '@mui/icons-material' import { Button } from '@mui/material' import { useClashProxiesProvider } from '@nyanpasu/interface' export const UpdateProxiesProviders = () => { const { t } = useTranslation() const [loading, setLoading] = useState(false) const proxiesProvider = useClashProxiesProvider() const handleProviderUpdate = useLockFn(async () => { if (!proxiesProvider.data) { message(`No Providers.`, { kind: 'info', title: t('Info'), }) return } try { setLoading(true) await Promise.all( Object.entries(proxiesProvider.data).map(([_, provider]) => provider.mutate(), ), ) } catch (e) { message(`Update all failed.\n${String(e)}`, { kind: 'error', title: t('Error'), }) } finally { setLoading(false) } }) return ( ) } export default UpdateProxiesProviders ================================================ FILE: frontend/nyanpasu/src/components/proxies/delay-button.tsx ================================================ import { useDebounceFn, useLockFn } from 'ahooks' import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Bolt, Done } from '@mui/icons-material' import { Button, CircularProgress, Tooltip } from '@mui/material' import { alpha, cn } from '@nyanpasu/ui' export const DelayButton = memo(function DelayButton({ onClick, }: { onClick: () => Promise }) { const { t } = useTranslation() const [loading, setLoading] = useState(false) const [mounted, setMounted] = useState(false) const { run: runMounted, cancel: cancelMounted } = useDebounceFn( () => setMounted(false), { wait: 1000 }, ) const handleClick = useLockFn(async () => { try { setLoading(true) setMounted(true) cancelMounted() await onClick() } finally { setLoading(false) runMounted() } }) const isSuccess = mounted && !loading return ( ) }) ================================================ FILE: frontend/nyanpasu/src/components/proxies/delay-chip.tsx ================================================ import { memo, useState } from 'react' import { useColorSxForDelay } from '@/hooks/theme' import { mergeSxProps } from '@/utils/mui-theme' import { Bolt } from '@mui/icons-material' import { CircularProgress } from '@mui/material' import { cn } from '@nyanpasu/ui' import FeatureChip from './feature-chip' export const DelayChip = memo(function DelayChip({ className, delay, onClick, }: { className?: string delay: number onClick: () => Promise }) { const [loading, setLoading] = useState(false) const handleClick = async () => { try { setLoading(true) await onClick() } finally { setLoading(false) } } return ( {delay === -1 ? ( ) : !!delay && delay < 10000 ? ( `${delay} ms` ) : ( 'timeout' )} } variant="filled" onClick={(e) => { e.preventDefault() e.stopPropagation() handleClick() }} /> ) }) export default DelayChip ================================================ FILE: frontend/nyanpasu/src/components/proxies/feature-chip.tsx ================================================ import { memo } from 'react' import { mergeSxProps } from '@/utils/mui-theme' import { Chip, ChipProps } from '@mui/material' export const FeatureChip = memo(function FeatureChip(props: ChipProps) { return ( ) }) export default FeatureChip ================================================ FILE: frontend/nyanpasu/src/components/proxies/group-list.tsx ================================================ import { useAtom, useAtomValue } from 'jotai' import { memo, RefObject, useDeferredValue, useMemo } from 'react' import useSWR from 'swr' import { Virtualizer } from 'virtua' import { proxyGroupAtom } from '@/store' import { proxiesFilterAtom } from '@/store/proxies' import { ListItem, ListItemButton, ListItemButtonProps, ListItemIcon, ListItemText, } from '@mui/material' import { getServerPort, useClashProxies } from '@nyanpasu/interface' import { alpha, LazyImage } from '@nyanpasu/ui' const IconRender = memo(function IconRender({ icon }: { icon: string }) { const { data: serverPort, isLoading, error, } = useSWR('/getServerPort', getServerPort) const src = icon.trim().startsWith(' { if (!src.startsWith('http')) { return src } return `http://localhost:${serverPort}/cache/icon?url=${btoa(src)}` }, [src, serverPort]) if (isLoading || error) { return null } return ( ) }) export interface GroupListProps extends ListItemButtonProps { scrollRef: RefObject } export const GroupList = ({ scrollRef, ...listItemButtonProps }: GroupListProps) => { const { proxies: { data }, } = useClashProxies() const [proxyGroup, setProxyGroup] = useAtom(proxyGroupAtom) const proxiesFilter = useAtomValue(proxiesFilterAtom) const deferredProxiesFilter = useDeferredValue(proxiesFilter) const handleSelect = (index: number) => { setProxyGroup({ selector: index }) } const groups = useMemo(() => { if (!data?.groups) { return [] } return data.groups.filter((group) => { return ( !deferredProxiesFilter || group.name .toLowerCase() .includes(deferredProxiesFilter.toLowerCase()) || group.all?.some((proxy) => { return proxy.name .toLowerCase() .includes(deferredProxiesFilter.toLowerCase()) }) || false ) }) }, [data?.groups, deferredProxiesFilter]) return ( {groups.map((group, index) => { const selected = index === proxyGroup.selector return ( handleSelect(index)} sx={[ (theme) => ({ backgroundColor: selected ? `${alpha(theme.vars.palette.primary.main, 0.3)} !important` : null, }), ]} {...listItemButtonProps} > {group.icon && } ) })} ) } ================================================ FILE: frontend/nyanpasu/src/components/proxies/index.ts ================================================ export * from './group-list' export * from './node-list' export * from './delay-button' ================================================ FILE: frontend/nyanpasu/src/components/proxies/node-card.module.d.scss.ts ================================================ declare const classNames: { readonly Card: 'Card' readonly NoDelay: 'NoDelay' readonly DelayChip: 'DelayChip' } export default classNames ================================================ FILE: frontend/nyanpasu/src/components/proxies/node-card.module.scss ================================================ .Card { &.NoDelay { .DelayChip { visibility: hidden; } &:hover { .DelayChip { visibility: visible; } } } } ================================================ FILE: frontend/nyanpasu/src/components/proxies/node-card.module.scss.d.ts ================================================ declare const classNames: { readonly Card: 'Card' readonly NoDelay: 'NoDelay' readonly DelayChip: 'DelayChip' } export default classNames ================================================ FILE: frontend/nyanpasu/src/components/proxies/node-card.tsx ================================================ import { useLockFn } from 'ahooks' import { CSSProperties, memo, useMemo } from 'react' import Box from '@mui/material/Box' import type { SxProps, Theme } from '@mui/material/styles' import { ClashProxiesQueryProxyItem } from '@nyanpasu/interface' import { alpha, cn } from '@nyanpasu/ui' import { PaperSwitchButton } from '../setting/modules/system-proxy' import DelayChip from './delay-chip' import FeatureChip from './feature-chip' import styles from './node-card.module.scss' import { filterDelay } from './utils' export const NodeCard = memo(function NodeCard({ node, now, disabled, style, }: { node: ClashProxiesQueryProxyItem now?: string | null disabled?: boolean style?: CSSProperties }) { const delay = useMemo(() => filterDelay(node.history), [node.history]) const checked = node.name === now const handleDelayClick = useLockFn(async () => { await node.mutateDelay() }) const handleClick = useLockFn(async () => { await node.mutateSelect() }) return ( ({ backgroundColor: checked ? alpha(theme.vars.palette.primary.main, 0.3) : theme.vars.palette.grey[100], ...theme.applyStyles('dark', { backgroundColor: checked ? alpha(theme.vars.palette.primary.main, 0.3) : theme.vars.palette.grey[900], }), })) as SxProps } > {node.udp && } ) }) export default NodeCard ================================================ FILE: frontend/nyanpasu/src/components/proxies/node-list.tsx ================================================ import { AnimatePresence, motion } from 'framer-motion' import { useAtomValue } from 'jotai' import { forwardRef, RefObject, useCallback, useDeferredValue, useEffect, useImperativeHandle, useRef, useState, } from 'react' import { Virtualizer, VListHandle } from 'virtua' import { proxyGroupAtom, proxyGroupSortAtom } from '@/store' import { proxiesFilterAtom } from '@/store/proxies' import { ClashProxiesQueryProxyItem, ProxyGroupItem, useClashProxies, useProxyMode, useSetting, } from '@nyanpasu/interface' import { cn, useBreakpointValue } from '@nyanpasu/ui' import NodeCard from './node-card' import { nodeSortingFn } from './utils' type RenderClashProxy = ClashProxiesQueryProxyItem & { renderLayoutKey: string } export interface NodeListRef { scrollToCurrent: () => void } export const NodeList = forwardRef(function NodeList( { scrollRef }: { scrollRef: RefObject }, ref, ) { const { proxies: { data }, } = useClashProxies() const { value: proxyMode } = useProxyMode() const proxyGroup = useAtomValue(proxyGroupAtom) const proxiesFilter = useAtomValue(proxiesFilterAtom) const deferredProxiesFilter = useDeferredValue(proxiesFilter) const proxyGroupSort = useAtomValue(proxyGroupSortAtom) const [group, setGroup] = useState() const sortGroup = useCallback(() => { if (!proxyMode.global) { if (proxyGroup.selector !== null) { // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain const selectedGroup = data?.groups[proxyGroup.selector]! if (selectedGroup) { setGroup(nodeSortingFn(selectedGroup, proxyGroupSort)) } } } else { if (data?.global) { setGroup(nodeSortingFn(data?.global, proxyGroupSort)) } else { setGroup(data?.global) } } }, [ proxyMode.global, proxyGroup.selector, data?.groups, data?.global, proxyGroupSort, ]) useEffect(() => { sortGroup() }, [sortGroup]) const column = useBreakpointValue({ xs: 1, sm: 1, md: 2, lg: 3, xl: 4, }) const [renderList, setRenderList] = useState([]) useEffect(() => { if (!group?.all) return const nodeNames: string[] = [] let nodes = group?.all || [] if (!!deferredProxiesFilter && deferredProxiesFilter !== group?.name) { nodes = nodes.filter((node) => node.name.toLowerCase().includes(deferredProxiesFilter.toLowerCase()), ) } const list = nodes.reduce((result, value, index) => { const getKey = () => { const filter = nodeNames.filter((i) => i === value.name) if (filter.length === 0) { return value.name } else { return `${value.name}-${filter.length}` } } if (index % column === 0) { result.push([]) } result[Math.floor(index / column)].push({ ...(value as ClashProxiesQueryProxyItem), renderLayoutKey: getKey(), }) nodeNames.push(value.name) return result }, []) setRenderList(list) }, [group?.all, group?.name, column, deferredProxiesFilter]) const { value: disableMotion } = useSetting('lighten_animation_effects') const vListRef = useRef(null) useImperativeHandle(ref, () => ({ scrollToCurrent: () => { const index = renderList.findIndex((node) => node.some((item) => item.name === group?.now), ) vListRef.current?.scrollToIndex(index, { align: 'center', smooth: true, }) }, })) return ( {renderList?.map((node, index) => { return (
{node.map((render) => { const Card = () => ( ) return disableMotion ? (
) : ( ) })}
) })}
) }) ================================================ FILE: frontend/nyanpasu/src/components/proxies/proxy-group-name.tsx ================================================ import { AnimatePresence, motion } from 'framer-motion' import { memo } from 'react' import { useSetting } from '@nyanpasu/interface' export const ProxyGroupName = memo(function ProxyGroupName({ name, }: { name: string }) { const { value: disbaleMotion } = useSetting('lighten_animation_effects') return disbaleMotion ? ( <>{name} ) : ( {name} ) }) export default ProxyGroupName ================================================ FILE: frontend/nyanpasu/src/components/proxies/scroll-current-node.tsx ================================================ import { useTranslation } from 'react-i18next' import { Radar } from '@mui/icons-material' import { Button, Tooltip } from '@mui/material' import { alpha } from '@nyanpasu/ui' export const ScrollCurrentNode = ({ onClick }: { onClick?: () => void }) => { const { t } = useTranslation() return ( ) } export default ScrollCurrentNode ================================================ FILE: frontend/nyanpasu/src/components/proxies/sort-selector.tsx ================================================ import { useAtom } from 'jotai' import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' import { proxyGroupSortAtom } from '@/store' import { Button, Menu, MenuItem } from '@mui/material' import { alpha } from '@nyanpasu/ui' export const SortSelector = memo(function SortSelector() { const { t } = useTranslation() const [proxyGroupSort, setProxyGroupSort] = useAtom(proxyGroupSortAtom) type SortType = typeof proxyGroupSort const [anchorEl, setAnchorEl] = useState(null) const handleClick = (sort: SortType) => { setAnchorEl(null) setProxyGroupSort(sort) } const tmaps: { [key: string]: string } = { default: 'Sort by default', delay: 'Sort by latency', name: 'Sort by name', } return ( <> setAnchorEl(null)} > {Object.entries(tmaps).map(([key, value], index) => { return ( handleClick(key as SortType)}> {t(value)} ) })} ) }) export default SortSelector ================================================ FILE: frontend/nyanpasu/src/components/proxies/utils.ts ================================================ import type { ProxyGroupItem, ProxyItemHistory } from '@nyanpasu/interface' export const filterDelay = (history?: ProxyItemHistory[]): number => { if (!history || history.length === 0) { return -1 } else { return history[history.length - 1].delay } } export enum SortType { Default = 'default', Delay = 'delay', Name = 'name', } export const nodeSortingFn = ( selectedGroup: ProxyGroupItem, type: SortType, ) => { let sortedList = selectedGroup.all?.slice() switch (type) { case SortType.Delay: { sortedList = sortedList?.sort((a, b) => { const delayA = filterDelay(a.history) const delayB = filterDelay(b.history) if (delayA === -1 || delayA === -2) return 1 if (delayB === -1 || delayB === -2) return -1 if (delayA === 0) return 1 if (delayB === 0) return -1 return delayA - delayB }) break } case SortType.Name: { sortedList = sortedList?.sort((a, b) => a.name.localeCompare(b.name)) break } } return { ...selectedGroup, all: sortedList, } } ================================================ FILE: frontend/nyanpasu/src/components/router/animated-outlet.tsx ================================================ import { AnimatePresence, motion, useIsPresent, Variants } from 'framer-motion' import { ComponentProps, useRef } from 'react' import { Outlet, RouterContextProvider, useMatch, useMatches, useRouter, useRouterState, } from '@tanstack/react-router' type TransitionDirection = 1 | -1 const directionalSlideVariants = { forward: { initial: { translateX: '30%', opacity: 0, }, visible: { translateX: '0%', opacity: 1, }, hidden: { translateX: '-30%', opacity: 0, }, }, backward: { initial: { translateX: '-30%', opacity: 0, }, visible: { translateX: '0%', opacity: 1, }, hidden: { translateX: '30%', opacity: 0, }, }, } satisfies Record<'forward' | 'backward', Variants> function getDirectionalVariant(direction: TransitionDirection) { return direction === 1 ? directionalSlideVariants.forward : directionalSlideVariants.backward } export function AnimatedOutlet({ ref, ...props }: ComponentProps) { const isPresent = useIsPresent() const matches = useMatches() const prevMatches = useRef(matches) const router = useRouter() // Frozen router for the exit animation, created once when isPresent becomes false const frozenRouterRef = useRef(null) let renderedRouter = router if (isPresent) { prevMatches.current = matches frozenRouterRef.current = null } else { if (!frozenRouterRef.current) { // Build patched matches: old route data (prevMatches) but new match IDs const patched = [ ...matches.map((m, i) => ({ ...(prevMatches.current[i] || m), id: m.id, })), ...prevMatches.current.slice(matches.length), ] // Snapshot of router state with old route's matches const patchedState = { ...router.__store.state, matches: patched } // Create a fake store that always returns the frozen patched state. // Object.create delegates everything else (subscribe, atom, etc.) to the real // store via the prototype chain, so subscriptions still work — but the snapshot // always returns patchedState, which never changes, so there are no re-renders. const fakeStore = Object.create(router.__store) Object.defineProperty(fakeStore, 'get', { value: () => patchedState, configurable: true, }) Object.defineProperty(fakeStore, 'state', { get: () => patchedState, configurable: true, }) // Create a fake router that delegates everything to the real router except __store const fakeRouter = Object.create(router) Object.defineProperty(fakeRouter, '__store', { value: fakeStore, configurable: true, }) frozenRouterRef.current = fakeRouter } // force type safety renderedRouter = frozenRouterRef.current! } return ( ) } export function AnimatedOutletPreset(props: ComponentProps) { const matches = useMatches() const match = useMatch({ strict: false }) const pathname = useRouterState({ select: (state) => state.location.pathname, }) const nextMatchIndex = matches.findIndex((d) => d.id === match.id) + 1 const nextMatch = matches[nextMatchIndex] const id = nextMatch ? nextMatch.id : '' const prevPathRef = useRef(pathname) const directionRef = useRef(1) if (prevPathRef.current !== pathname) { const prevPath = prevPathRef.current const nextPath = pathname if (nextPath.startsWith(`${prevPath}/`)) { directionRef.current = 1 } else if (prevPath.startsWith(`${nextPath}/`)) { directionRef.current = -1 } else { // Non-ancestor navigation (including sibling routes) uses forward animation. directionRef.current = 1 } prevPathRef.current = pathname } const direction = directionRef.current const selectedVariants = getDirectionalVariant(direction) return ( getDirectionalVariant(customDirection).initial, visible: selectedVariants.visible, hidden: (customDirection: TransitionDirection) => getDirectionalVariant(customDirection).hidden, }} transition={{ type: 'spring', bounce: 0.1, duration: 0.35, }} {...props} /> ) } ================================================ FILE: frontend/nyanpasu/src/components/rules/modules/store.ts ================================================ import { atom } from 'jotai' import { RefObject } from 'react' import { ClashRule } from '@nyanpasu/interface' export const atomRulePage = atom<{ data?: ClashRule[] scrollRef?: RefObject }>() ================================================ FILE: frontend/nyanpasu/src/components/rules/rule-item.tsx ================================================ import { Box, SxProps, Theme } from '@mui/material' import { ClashRule } from '@nyanpasu/interface' interface Props { index: number value: ClashRule } const COLOR = [ (theme) => ({ color: theme.vars.palette.primary.main, }), (theme) => ({ color: theme.vars.palette.secondary.main, }), (theme) => ({ color: theme.vars.palette.info.main, }), (theme) => ({ color: theme.vars.palette.warning.main, }), (theme) => ({ color: theme.vars.palette.success.main, }), ] satisfies SxProps[] const RuleItem = ({ index, value }: Props) => { const parseColorSx: (text: string) => SxProps = (text) => { const TYPE = { reject: ['REJECT', 'REJECT-DROP'], direct: ['DIRECT'], } if (TYPE.reject.includes(text)) return (theme) => ({ color: theme.vars.palette.error.main }) if (TYPE.direct.includes(text)) return (theme) => ({ color: theme.vars.palette.text.primary }) let sum = 0 for (let i = 0; i < text.length; i++) { sum += text.charCodeAt(i) } return COLOR[sum % COLOR.length] } return (
({ color: theme.vars.palette.text.secondary })} className="min-w-14" > {index + 1}
({ color: theme.vars.palette.text.primary })}> {value.payload || '-'}
{value.type}
{value.proxy}
) } export default RuleItem ================================================ FILE: frontend/nyanpasu/src/components/rules/rule-page.tsx ================================================ import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { Virtualizer } from 'virtua' import ContentDisplay from '../base/content-display' import { atomRulePage } from './modules/store' import RuleItem from './rule-item' export const RulePage = () => { const { t } = useTranslation() const rule = useAtomValue(atomRulePage) return rule?.data?.length ? ( {rule.data.map((item, index) => { return })} ) : ( ) } export default RulePage ================================================ FILE: frontend/nyanpasu/src/components/setting/modules/clash-core.tsx ================================================ import { motion } from 'framer-motion' import { isObject } from 'lodash-es' import { useState } from 'react' import { useTranslation } from 'react-i18next' import ClashRs from '@/assets/image/core/clash-rs.png' import ClashMeta from '@/assets/image/core/clash.meta.png' import Clash from '@/assets/image/core/clash.png' import { formatError } from '@/utils' import { message } from '@/utils/notification' import parseTraffic from '@/utils/parse-traffic' import FiberManualRecord from '@mui/icons-material/FiberManualRecord' import Update from '@mui/icons-material/Update' import { Box, Button } from '@mui/material' import ListItem from '@mui/material/ListItem' import ListItemButton from '@mui/material/ListItemButton' import Tooltip from '@mui/material/Tooltip' import { ClashCore, ClashCoresDetail, InspectUpdater, inspectUpdater, useClashCores, } from '@nyanpasu/interface' import { alpha, cleanDeepClickEvent, cn } from '@nyanpasu/ui' export const getImage = (core: ClashCore) => { switch (core) { case 'mihomo': case 'mihomo-alpha': { return ClashMeta } case 'clash-rs': case 'clash-rs-alpha': { return ClashRs } default: { return Clash } } } const calcProgress = (data?: InspectUpdater) => { return ( (Number(data?.downloader?.downloaded) / Number(data?.downloader?.total)) * 100 ) } const CardProgress = ({ data, show, }: { data?: InspectUpdater show?: boolean }) => { const parsedState = () => { if (data?.downloader?.state) { return 'waiting' } else if (isObject(data?.downloader.state)) { return data?.downloader.state.failed } else { return data?.downloader.state } } return ( ({ backgroundColor: alpha(theme.vars.palette.primary.main, 0.3), })} animate={show ? 'open' : 'closed'} initial={{ opacity: 0 }} variants={{ open: { opacity: 1, display: 'flex', }, closed: { opacity: 0, transitionEnd: { display: 'none', }, }, }} > ({ backgroundColor: alpha(theme.vars.palette.primary.main, 0.3), width: `${calcProgress(data) < 10 ? 10 : calcProgress(data)}%`, })} />
{parsedState()}
{calcProgress(data).toFixed(0)}%{''} ({parseTraffic(data?.downloader.speed || 0)}/s)
) } export interface ClashCoreItemProps { selected: boolean data: ClashCoresDetail core: ClashCore onClick: (core: ClashCore) => void } /** * @example * changeClashCore(item.core)} /> * * `Design for Clash Core used.` * * @author keiko233 * @copyright LibNyanpasu org. 2024 */ export const ClashCoreItem = ({ selected, data, core, onClick, }: ClashCoreItemProps) => { const { t } = useTranslation() const { query, updateCore } = useClashCores() const haveNewVersion = data.latestVersion ? data.latestVersion !== data.currentVersion : false const [downloadState, setDownloadState] = useState(false) const [updater, setUpdater] = useState() const handleUpdateCore = async () => { try { setDownloadState(true) const updaterId = await updateCore.mutateAsync(core) if (!updaterId) { throw new Error('Failed to update') } await new Promise((resolve, reject) => { const interval = setInterval(async () => { const result = await inspectUpdater(updaterId) setUpdater(result) if ( isObject(result.downloader.state) && Object.prototype.hasOwnProperty.call( result.downloader.state, 'failed', ) ) { reject(result.downloader.state.failed) clearInterval(interval) } if (result.state === 'done') { resolve() clearInterval(interval) } }, 100) }) await query.refetch() message(t('Successfully updated the core', { core: `${data.name}` }), { kind: 'info', title: t('Successful'), }) } catch (e) { message(t('Failed to update', { error: `${formatError(e)}` }), { kind: 'error', title: t('Error'), }) } finally { setDownloadState(false) } } return ( ({ borderRadius: '16px', backgroundColor: alpha(theme.vars.palette.background.paper, 0.3), '&.Mui-selected': { backgroundColor: alpha(theme.vars.palette.primary.main, 0.3), }, })} selected={selected} onClick={() => { if (!downloadState) { onClick(core) } }} >
{data.name} {haveNewVersion && ( ({ height: 10, fill: theme.vars.palette.success.main, })} /> )}
{data.currentVersion}
{haveNewVersion && (
New: {data.latestVersion}
)}
{haveNewVersion && ( )}
) } ================================================ FILE: frontend/nyanpasu/src/components/setting/modules/clash-field.tsx ================================================ import { ChangeEvent, useState } from 'react' import Marquee from 'react-fast-marquee' import ArrowForwardIos from '@mui/icons-material/ArrowForwardIos' import OpenInNewRounded from '@mui/icons-material/OpenInNewRounded' import Box from '@mui/material/Box' import ButtonBase, { ButtonBaseProps } from '@mui/material/ButtonBase' import Grid from '@mui/material/Grid' import IconButton from '@mui/material/IconButton' import Paper from '@mui/material/Paper' import { SwitchProps } from '@mui/material/Switch' import Tooltip from '@mui/material/Tooltip' import Typography from '@mui/material/Typography' import { openThat } from '@nyanpasu/interface' import { alpha, LoadingSwitch } from '@nyanpasu/ui' export interface LabelSwitchProps extends SwitchProps { label: string url?: string onChange?: ( event: ChangeEvent, checked: boolean, ) => Promise | void } /** * @example * console.log(key)} /> * `Design for Clash Filed use.` * * @author keiko233 * @copyright LibNyanpasu org. 2024 */ export const LabelSwitch = ({ label, url, onChange, ...props }: LabelSwitchProps) => { const [loading, setLoading] = useState(false) const handleChange = async ( event: ChangeEvent, checked: boolean, ) => { if (onChange) { try { setLoading(true) await onChange(event, checked) } finally { setLoading(false) } } } return ( ({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 2, borderRadius: 6, backgroundColor: alpha(theme.vars.palette.primary.main, 0.1), })} elevation={0} > {label} {url && ( openThat(url)}> )} {/* */} ) } export interface ClashFieldItemProps extends ButtonBaseProps { label: string fields: string[] } /** * @example * console.log("open")} /> * `Design for Clash Filed use.` * * @author keiko233 * @copyright LibNyanpasu org. 2024 */ export const ClashFieldItem = ({ label, fields, ...props }: ClashFieldItemProps) => { return ( ({ borderRadius: 6, backgroundColor: alpha(theme.vars.palette.primary.main, 0.1), })} > {label} Enabled: {fields.map((item, index) => { return {item} })} ) } ================================================ FILE: frontend/nyanpasu/src/components/setting/modules/clash-web.tsx ================================================ import { ReactElement, ReactNode } from 'react' import Marquee from 'react-fast-marquee' import DeleteRounded from '@mui/icons-material/DeleteRounded' import EditRounded from '@mui/icons-material/EditRounded' import OpenInNewRounded from '@mui/icons-material/OpenInNewRounded' import Box from '@mui/material/Box' import Chip from '@mui/material/Chip' import IconButton from '@mui/material/IconButton' import Paper, { PaperProps } from '@mui/material/Paper' import { styled } from '@mui/material/styles' import Typography from '@mui/material/Typography' import { openThat } from '@nyanpasu/interface' import { alpha } from '@nyanpasu/ui' /** * @example * renderChip("http://localhost?server=%host", labels) * * @returns { (string | JSX.Element)[] } * (string | JSX.Element)[] * * `replace key string to Mui Chip.` * * @author keiko233 * @copyright LibNyanpasu org. 2024 */ export const renderChip = ( string: string, labels: { [label: string]: string | number | undefined | null }, ): (string | ReactElement)[] => { return string.split(/(%[^&?]+)/).map((part, index) => { if (part.startsWith('%')) { const label = labels[part.replace('%', '')] // TODO: may should return part string if (!label) { return '' } return ( ) } else { return part } }) } /** * @example * extractServer("127.0.0.1:7789") * * @returns { { host: string; port: number } } * { host: "127.0.0.1"; port: 7789 } * * @author keiko233 * @copyright LibNyanpasu org. 2024 */ export const extractServer = ( string?: string, ): { host: string; port: number } => { if (!string) { // fallback default values return { host: '127.0.0.1', port: 7890 } } else { const [host, port] = string.split(':') return { host, port: Number(port) } } } /** * @example * openWebUrl("http://localhost?server=%host", labels) * * @returns { void } * void * * `open clash external web url with browser.` * * @author keiko233 * @copyright LibNyanpasu org. 2024 */ export const openWebUrl = ( string: string, labels: { [label: string]: string | number | undefined | null }, ): void => { let url = '' for (const key in labels) { const regex = new RegExp(`%${key}`, 'g') url = string.replace(regex, labels[key] as string) } openThat(url) } /** * @example * * * * * `Material You list Item. Extend MuiPaper.` * * @author keiko233 * @copyright LibNyanpasu org. 2024 */ export const Item = styled(Paper)(({ theme }) => ({ backgroundColor: alpha(theme.vars.palette.primary.main, 0.1), padding: 16, borderRadius: 16, display: 'flex', flexDirection: 'column', gap: 8, })) as typeof Paper export interface ClashWebItemProps { label: ReactNode onOpen: () => void onDelete: () => void onEdit: () => void } /** * @example * openWebUrl(item, labels)} onEdit={() => { setEditString(item); setOpen(true); }} onDelete={() => {}} /> * `Clash Web UI list Item.` * * @author keiko233 * @copyright LibNyanpasu org. 2024 */ export const ClashWebItem = ({ label, onOpen, onDelete, onEdit, }: ClashWebItemProps) => { return ( {label} ) } ================================================ FILE: frontend/nyanpasu/src/components/setting/modules/hotkey-dialog.tsx ================================================ import { useLockFn } from 'ahooks' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { Typography } from '@mui/material' import { useSetting } from '@nyanpasu/interface' import { BaseDialog, BaseDialogProps } from '@nyanpasu/ui' import HotkeyInput from './hotkey-input' export type HotkeyDialogProps = Omit const HOTKEY_FUNC = [ 'open_or_close_dashboard', 'clash_mode_rule', 'clash_mode_global', 'clash_mode_direct', 'clash_mode_script', 'toggle_system_proxy', // "enable_system_proxy", // "disable_system_proxy", 'toggle_tun_mode', // "enable_tun_mode", // "disable_tun_mode", ] as const type AllowedHotkeyFunc = (typeof HOTKEY_FUNC)[number] type Key = string type HotKeyErrorMessages = { [K in AllowedHotkeyFunc]: string | null } type HotKeyLoading = { [K in AllowedHotkeyFunc]: boolean } type HotkeyMap = { [K in AllowedHotkeyFunc]: Key[] } export default function HotkeyDialog({ open, onClose, children, ...rest }: HotkeyDialogProps) { const { t } = useTranslation() // 检查是否有快捷键重复 const [duplicateItems, setDuplicateItems] = useState([]) const { value, upsert } = useSetting('hotkeys') const [hotkeyMap, setHotkeyMap] = useState({} as HotkeyMap) useEffect(() => { if (open && Object.keys(hotkeyMap).length === 0) { const map = {} as typeof hotkeyMap value?.forEach((text) => { const [func, key] = text.split(',').map((i) => i.trim()) if (!func || !key) return map[func as AllowedHotkeyFunc] = key .split('+') .map((e) => e.trim()) .map((k) => (k === 'PLUS' ? '+' : k)) }) setHotkeyMap(map) setDuplicateItems([]) } }, [hotkeyMap, open, value]) const [errorMessages, setErrorMessages] = useState( HOTKEY_FUNC.reduce( (acc, cur) => ({ ...acc, [cur]: null }), {} as HotKeyErrorMessages, ), ) const [loading, setLoading] = useState( HOTKEY_FUNC.reduce( (acc, cur) => ({ ...acc, [cur]: false }), {} as HotKeyLoading, ), ) const saveState = useLockFn( async (func: AllowedHotkeyFunc, hotkeyMap: HotkeyMap) => { const hotkeys = Object.entries(hotkeyMap) .map(([func, keys]) => { if (!func || !keys?.length) return '' const key = keys .map((k) => k.trim()) .filter(Boolean) .map((k) => (k === '+' ? 'PLUS' : k)) .join('+') if (!key) return '' return `${func},${key}` }) .filter(Boolean) try { await upsert(hotkeys) } catch (err: unknown) { setErrorMessages((prev) => ({ ...prev, [func]: formatError(err), })) await message(formatError(err), { kind: 'error', }) } }, ) const onBlurCb = useCallback( (e: React.FocusEvent, func: string) => { const keys = Object.values(hotkeyMap).flat().filter(Boolean) const set = new Set(keys) if (keys.length !== set.size) { setDuplicateItems([...duplicateItems, func]) return } else { setDuplicateItems(duplicateItems.filter((e) => e !== func)) } setLoading((prev) => ({ ...prev, [func]: true })) saveState(func as AllowedHotkeyFunc, hotkeyMap) .catch(() => { setDuplicateItems([...duplicateItems, func]) }) .finally(() => { setLoading((prev) => ({ ...prev, [func]: false })) }) }, [duplicateItems, hotkeyMap, saveState], ) return ( {children}
{HOTKEY_FUNC.map((func) => (
{t(func)} setHotkeyMap((prev) => ({ ...prev, [func]: v })) } />
))}
) } ================================================ FILE: frontend/nyanpasu/src/components/setting/modules/hotkey-input.module.d.scss.ts ================================================ declare const classNames: { readonly wrapper: 'wrapper' readonly input: 'input' readonly items: 'items' } export default classNames ================================================ FILE: frontend/nyanpasu/src/components/setting/modules/hotkey-input.module.scss ================================================ .wrapper { .input { &:hover { + .items { border-color: var(--input-hover-border-color); } } &:focus { + .items { border-color: var(--input-focus-border-color); border-width: 2px; } } } .items { border-color: var(--border-color); } } ================================================ FILE: frontend/nyanpasu/src/components/setting/modules/hotkey-input.module.scss.d.ts ================================================ declare const classNames: { readonly wrapper: 'wrapper' readonly input: 'input' readonly items: 'items' } export default classNames ================================================ FILE: frontend/nyanpasu/src/components/setting/modules/hotkey-input.tsx ================================================ import { parseHotkey } from '@/utils/parse-hotkey' import { Dangerous, DeleteRounded } from '@mui/icons-material' import { CircularProgress, IconButton, useTheme } from '@mui/material' import type {} from '@mui/material/themeCssVarsAugmentation' import { CSSProperties, useEffect, useRef, useState } from 'react' import { alpha, cn, Kbd } from '@nyanpasu/ui' import styles from './hotkey-input.module.scss' export interface Props extends React.HTMLAttributes { isDuplicate?: boolean value?: string[] onValueChange?: (value: string[]) => void func: string onBlurCb?: (e: React.FocusEvent, func: string) => void loading?: boolean } export default function HotkeyInput({ isDuplicate = false, value, func, onValueChange, onBlurCb, // native className, loading, ...rest }: Props) { const theme = useTheme() const changeRef = useRef([]) const [keys, setKeys] = useState(value || []) const [isClearing, setIsClearing] = useState(false) useEffect(() => { if (isClearing) { onBlurCb?.({} as React.FocusEvent, func) setIsClearing(false) } }, [func, isClearing, onBlurCb]) return (
{ const ret = changeRef.current.slice() if (ret.length) { onValueChange?.(ret) changeRef.current = [] } }} onKeyDown={(e) => { const evt = e.nativeEvent e.preventDefault() e.stopPropagation() const key = parseHotkey(evt.key) if (key === 'UNIDENTIFIED') return changeRef.current = [...new Set([...changeRef.current, key])] setKeys(changeRef.current) }} onBlur={(e) => { onBlurCb?.(e, func) }} {...rest} />
{keys.map((key) => ( {key} ))} {loading && ( )} {isDuplicate && ( ({ color: theme.vars.palette.error.main, }), ]} /> )}
{ onValueChange?.([]) setKeys([]) setIsClearing(true) }} >
) } ================================================ FILE: frontend/nyanpasu/src/components/setting/modules/index.ts ================================================ export * from './clash-web' ================================================ FILE: frontend/nyanpasu/src/components/setting/modules/nyanpasu-path.tsx ================================================ import { memo, ReactNode } from 'react' import { mergeSxProps } from '@/utils/mui-theme' import { ButtonBase, ButtonBaseProps, Paper, SxProps, Theme, Typography, } from '@mui/material' import { alpha } from '@nyanpasu/ui' export interface PaperButtonProps extends ButtonBaseProps { label?: string children?: ReactNode sxPaper?: SxProps sxButton?: SxProps } export const PaperButton = memo(function PaperButton({ label, children, sxPaper, sxButton, ...props }: PaperButtonProps) { return ( ({ borderRadius: 6, backgroundColor: alpha(theme.vars.palette.primary.main, 0.1), }), sxPaper, )} > {label && ( {label} )} {children} ) }) ================================================ FILE: frontend/nyanpasu/src/components/setting/modules/service-manual-prompt-dialog.module.d.scss.ts ================================================ declare const classNames: { readonly prompt: 'prompt' readonly shiki: 'shiki' readonly dark: 'dark' } export default classNames ================================================ FILE: frontend/nyanpasu/src/components/setting/modules/service-manual-prompt-dialog.module.scss ================================================ .prompt { :global(.shiki) { width: 100%; padding: 16px; overflow-x: auto; user-select: text; border-radius: 4px; } } .dark { &.prompt { :global(.shiki), :global(.shiki span) { /* Optional, if you also want font styles */ font-style: var(--shiki-dark-font-style) !important; font-weight: var(--shiki-dark-font-weight) !important; color: var(--shiki-dark) !important; text-decoration: var(--shiki-dark-text-decoration) !important; background-color: var(--shiki-dark-bg) !important; } } } ================================================ FILE: frontend/nyanpasu/src/components/setting/modules/service-manual-prompt-dialog.module.scss.d.ts ================================================ declare const classNames: { readonly prompt: 'prompt' readonly shiki: 'shiki' readonly dark: 'dark' } export default classNames ================================================ FILE: frontend/nyanpasu/src/components/setting/modules/service-manual-prompt-dialog.tsx ================================================ import { useAsyncEffect } from 'ahooks' import { useAtom, useSetAtom } from 'jotai' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import useSWR from 'swr' import { OS } from '@/consts' import { serviceManualPromptDialogAtom } from '@/store/service' import { notification } from '@/utils/notification' import { getShikiSingleton } from '@/utils/shiki' import ContentPasteIcon from '@mui/icons-material/ContentPaste' import { IconButton, Tooltip } from '@mui/material' import { useColorScheme } from '@mui/material/styles' import { getCoreDir, getServiceInstallPrompt } from '@nyanpasu/interface' import { BaseDialog, BaseDialogProps, cn } from '@nyanpasu/ui' import styles from './service-manual-prompt-dialog.module.scss' type CopyToClipboardButtonProps = { onClick: () => void } function CopyToClipboardButton({ onClick }: CopyToClipboardButtonProps) { const { t } = useTranslation() return ( ) } export type ServerManualPromptDialogProps = Omit & { operation: 'uninstall' | 'install' | 'start' | 'stop' | null } // TODO: maybe support more commands prompt? export default function ServerManualPromptDialog({ open, onClose, operation, ...props }: ServerManualPromptDialogProps) { const { t } = useTranslation() const { mode } = useColorScheme() const { data: serviceInstallPrompt, error } = useSWR( operation === 'install' ? '/service_install_prompt' : null, getServiceInstallPrompt, ) const { data: coreDir } = useSWR('/core_dir', () => getCoreDir()) const commands = useMemo(() => { if (operation === 'install' && serviceInstallPrompt) { return `cd "${coreDir}"\n${serviceInstallPrompt}` } else if (operation) { return `cd "${coreDir}"\n${OS !== 'windows' ? 'sudo ' : ''}./nyanpasu-service ${operation}` } return '' }, [operation, serviceInstallPrompt, coreDir]) const [codes, setCodes] = useState(null) useAsyncEffect(async () => { const shiki = await getShikiSingleton() const code = await shiki.codeToHtml(commands, { lang: 'shell', themes: { dark: 'nord', light: 'min-light', }, }) setCodes(code) }, [serviceInstallPrompt, operation, coreDir, commands]) const handleCopyToClipboard = useCallback(() => { if (commands) { const item = new ClipboardItem({ 'text/plain': new Blob([commands], { type: 'text/plain' }), }) navigator.clipboard .write([item]) .then(() => { console.log('copied') notification({ title: `Clash Nyanpasu - ${t('Service Manual Tips')}`, body: t('Copied to clipboard'), }) }) .catch((error) => { console.error(error) notification({ title: `Clash Nyanpasu - ${t('Service Manual Tips')}`, body: t('Failed to copy to clipboard'), }) }) } }, [commands, t]) return (

{t('Unable to operation the service automatically', { operation: t(`${operation}`), })}

{error &&

{error.message}

} {!!codes && (
)}
) } export function ServerManualPromptDialogWrapper() { const [prompt, setPrompt] = useAtom(serviceManualPromptDialogAtom) return ( setPrompt(null)} operation={prompt} /> ) } export function useServerManualPromptDialog() { const setPrompt = useSetAtom(serviceManualPromptDialogAtom) return { show: (prompt: 'install' | 'uninstall' | 'stop' | 'start') => setPrompt(prompt), close: () => setPrompt(null), } } ================================================ FILE: frontend/nyanpasu/src/components/setting/modules/system-proxy.tsx ================================================ import { useControllableValue } from 'ahooks' import { memo, ReactNode } from 'react' import { mergeSxProps } from '@/utils/mui-theme' import { CircularProgress } from '@mui/material' import type { SxProps, Theme } from '@mui/material/styles' import { alpha } from '@nyanpasu/ui' import { PaperButton, PaperButtonProps } from './nyanpasu-path' export interface PaperSwitchButtonProps extends PaperButtonProps { label?: string checked: boolean loading?: boolean disableLoading?: boolean children?: ReactNode onClick?: () => Promise | void sxPaper?: SxProps } export const PaperSwitchButton = memo(function PaperSwitchButton({ label, checked, loading, disableLoading, children, onClick, sxPaper, ...props }: PaperSwitchButtonProps) { const [pending, setPending] = useControllableValue( { loading }, { defaultValue: false, }, ) const handleClick = async () => { if (onClick) { if (disableLoading) { return onClick() } setPending(true) await onClick() setPending(false) } } return ( ({ backgroundColor: checked ? alpha(theme.vars.palette.primary.main, 0.1) : theme.vars.palette.grey[100], ...theme.applyStyles('dark', { backgroundColor: checked ? alpha(theme.vars.palette.primary.main, 0.1) : theme.vars.palette.common.black, }), })) as SxProps, sxPaper, )} sxButton={{ flexDirection: 'column', alignItems: 'start', gap: 0.5, }} onClick={handleClick} {...props} > {pending === true && ( )} {children} ) }) ================================================ FILE: frontend/nyanpasu/src/components/setting/modules/tray-icon-dialog.tsx ================================================ import { useMemoizedFn } from 'ahooks' import { useState, useTransition } from 'react' import { useTranslation } from 'react-i18next' import useSWR from 'swr' import { formatError, sleep } from '@/utils' import { message } from '@/utils/notification' import { Button } from '@mui/material' import { getServerPort, isTrayIconSet, setTrayIcon as setTrayIconCall, } from '@nyanpasu/interface' import { BaseDialog, BaseDialogProps } from '@nyanpasu/ui' import { open } from '@tauri-apps/plugin-dialog' function TrayIconItem({ mode }: { mode: 'system_proxy' | 'tun' | 'normal' }) { const { t } = useTranslation() const [ts, setTs] = useState(Date.now()) const { data: isSetTrayIcon, isLoading, mutate, } = useSWR('/isSetTrayIcon?mode=' + mode, () => isTrayIconSet(mode), { revalidateOnFocus: true, }) const { data: serverPort } = useSWR('/getServerPort', getServerPort) const src = `http://localhost:${serverPort}/tray/icon?mode=${mode}&ts=${ts}` const [loading, startTransition] = useTransition() const selectImage = async () => { const selected = await open({ directory: false, multiple: false, filters: [ { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'bmp', 'ico'] }, ], }) if (Array.isArray(selected)) { throw new Error('Not Support') } else if (selected === null) { return null } else { return selected } } const setTrayIcon = useMemoizedFn((reset?: boolean) => { startTransition(async () => { try { const selected = reset ? undefined : await selectImage() if (selected === null) { return } return await setTrayIconCall(mode, selected) } catch (e) { message(formatError(e), { kind: 'error', }) } finally { setTs(Date.now()) await sleep(2000) await mutate() } }) }) return (
{t(mode)}
{isSetTrayIcon ? (
) : ( )}
) } export type TrayIconDialogProps = Omit export default function TrayIconDialog({ open, onClose, ...props }: TrayIconDialogProps) { const { t } = useTranslation() return (
) } ================================================ FILE: frontend/nyanpasu/src/components/setting/setting-clash-base.tsx ================================================ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useCoreType } from '@/hooks/use-store' import { formatError } from '@/utils' import getSystem from '@/utils/get-system' import { message } from '@/utils/notification' import { Button, List, ListItem, ListItemText } from '@mui/material' import { openUWPTool, useClashConfig, useRuntimeProfile, useSetting, type TunStack as TunStackType, } from '@nyanpasu/interface' import { BaseCard, MenuItem, SwitchItem } from '@nyanpasu/ui' const isWIN = getSystem() === 'windows' const AllowLan = () => { const { t } = useTranslation() const { query, upsert } = useClashConfig() const value = useMemo(() => query.data?.['allow-lan'], [query.data]) return ( { await upsert.mutateAsync({ 'allow-lan': !value, }) }} /> ) } const IPv6 = () => { const { t } = useTranslation() const { query, upsert } = useClashConfig() const value = useMemo(() => query.data?.['ipv6'], [query.data]) return ( { await upsert.mutateAsync({ ipv6: !value, }) }} /> ) } const TunStack = () => { const { t } = useTranslation() const [coreType] = useCoreType() const { value, upsert: upsertTunStack } = useSetting('tun_stack') const { value: enableTun, upsert: upsertTun } = useSetting('enable_tun_mode') const runtimeProfile = useRuntimeProfile() const tunStackOptions = useMemo(() => { const options: { [key: string]: string } = { system: 'System', gvisor: 'gVisor', mixed: 'Mixed', } // clash not support mixed if (coreType === 'clash') { delete options.mixed } return options }, [coreType]) const selected = useMemo(() => { const stack = value || 'gvisor' return stack in tunStackOptions ? stack : 'gvisor' }, [tunStackOptions, value]) return ( { try { await upsertTunStack(value as TunStackType) if (enableTun) { // just to reload clash config await upsertTun(true) } // need manual mutate to refetch runtime profile await runtimeProfile.refetch() } catch (error) { message(`Change Tun Stack failed ! \n Error: ${formatError(error)}`, { title: t('Error'), kind: 'error', }) } }} /> ) } const LogLevel = () => { const { t } = useTranslation() const { query, upsert } = useClashConfig() const options = { debug: 'Debug', info: 'Info', warning: 'Warn', error: 'Error', silent: 'Silent', } const value = useMemo(() => query.data?.['log-level'], [query.data]) return ( { await upsert.mutateAsync({ 'log-level': value as string, }) }} /> ) } const UWPTool = () => { const { t } = useTranslation() const handleClick = async () => { try { await openUWPTool() } catch (e) { message(`Failed to Open UWP Tools.\n${JSON.stringify(e)}`, { title: t('Error'), kind: 'error', }) } } return ( ) } export const SettingClashBase = () => { const { t } = useTranslation() const [coreType] = useCoreType() return ( {coreType !== 'clash-rs' && } {isWIN && } ) } export default SettingClashBase ================================================ FILE: frontend/nyanpasu/src/components/setting/setting-clash-core.tsx ================================================ import { useLockFn, useReactive } from 'ahooks' import { motion } from 'framer-motion' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { OS } from '@/consts' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { Box, Button, List, ListItem } from '@mui/material' import { ClashCore, ClashCores, useClashConnections, useClashCores, useClashVersion, useSetting, } from '@nyanpasu/interface' import { BaseCard, ExpandMore, LoadingButton } from '@nyanpasu/ui' import { ClashCoreItem } from './modules/clash-core' export const SettingClashCore = () => { const { t } = useTranslation() const loading = useReactive({ mask: false, }) const [expand, setExpand] = useState(false) const { value: currentCore } = useSetting('clash_core') const { query: clashCores, upsert: switchCore, restartSidecar, fetchRemote, } = useClashCores() const { data: clashVersion } = useClashVersion() const { deleteConnections } = useClashConnections() const version = useMemo(() => { return clashVersion?.premium ? `${clashVersion.version} Premium` : clashVersion?.meta ? `${clashVersion.version} Meta` : clashVersion?.version || '-' }, [clashVersion]) const changeClashCore = useLockFn(async (core: ClashCore) => { try { loading.mask = true try { await deleteConnections.mutateAsync(undefined) } catch (e) { console.error(e) } await switchCore.mutateAsync(core) message( t('Successfully switched to the clash core', { core: ClashCores[core], }), { kind: 'info', title: t('Successful'), }, ) } catch (e) { message( t('Failed to switch. You could see the details in the log', { error: `${e instanceof Error ? e.message : String(e)}`, }), { kind: 'error', title: t('Error'), }, ) } finally { loading.mask = false } }) const handleRestart = async () => { try { await restartSidecar() message(t('Successfully restarted the core'), { kind: 'info', title: t('Successful'), }) } catch (e) { message( t('Failed to restart. You could see the details in the log') + formatError(e), { kind: 'error', title: t('Error'), }, ) } } const handleCheckUpdates = async () => { try { await fetchRemote.mutateAsync() } catch (e) { message( t('Failed to fetch. Please check your network connection') + '\n' + formatError(e), { kind: 'error', title: t('Error'), }, ) } } return ( {version}} > {clashCores.data && Object.entries(clashCores.data).map(([core, item]) => { const show = expand || core === currentCore return ( changeClashCore(core as ClashCore)} /> ) })} {/** TODO: Support Linux when Manifest v2 released */} {OS !== 'linux' && ( {t('Check Updates')} )} setExpand(!expand)} /> ) } export default SettingClashCore ================================================ FILE: frontend/nyanpasu/src/components/setting/setting-clash-external.tsx ================================================ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { sleep } from '@/utils' import Done from '@mui/icons-material/Done' import { Button, List, ListItem, ListItemText, TextField } from '@mui/material' import { ExternalControllerPortStrategy, useClashConfig, useClashInfo, useRuntimeProfile, useSetting, } from '@nyanpasu/interface' import { BaseCard, Expand, MenuItem, TextItemProps } from '@nyanpasu/ui' const TextItem = ({ value, label, onApply, applyLabel, placeholder, }: TextItemProps) => { const { t } = useTranslation() const [textString, setTextString] = useState(value) useEffect(() => { setTextString(value) }, [value]) return ( <> setTextString(e.target.value)} placeholder={placeholder} size="small" variant="outlined" sx={{ width: 160 }} inputProps={{ 'aria-autocomplete': 'none', }} />
) } const ExternalController = () => { const { t } = useTranslation() const { data, refetch } = useClashInfo() const { upsert } = useClashConfig() const runtimeProfile = useRuntimeProfile() return ( { await upsert.mutateAsync({ 'external-controller': value }) await refetch() // Wait for the server to apply await sleep(300) await runtimeProfile.refetch() }} /> ) } const PortStrategy = () => { const { t } = useTranslation() const portStrategyOptions = { allow_fallback: t('Allow Fallback'), fixed: t('Fixed'), random: t('Random'), } const { value, upsert } = useSetting('clash_strategy') const selected = useMemo( () => value?.external_controller_port_strategy || 'allow_fallback', [value], ) return ( { await upsert({ external_controller_port_strategy: value as ExternalControllerPortStrategy, }) }} selectSx={{ width: 160 }} /> ) } const CoreSecret = () => { const { t } = useTranslation() const { data, refetch } = useClashInfo() const { upsert } = useClashConfig() return ( { await upsert.mutateAsync({ secret: value }) await refetch() }} /> ) } export const SettingClashExternal = () => { const { t } = useTranslation() return ( ) } export default SettingClashExternal ================================================ FILE: frontend/nyanpasu/src/components/setting/setting-clash-field.tsx ================================================ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import CLASH_FIELD from '@/assets/json/clash-field.json' import { Box, Typography } from '@mui/material' import Grid from '@mui/material/Grid' import { useProfile, useSetting } from '@nyanpasu/interface' import { BaseCard, BaseDialog } from '@nyanpasu/ui' import { ClashFieldItem, LabelSwitch } from './modules/clash-field' const FieldsControl = ({ label, fields, enabledFields, onChange, }: { label: string fields: { [key: string]: string } enabledFields?: string[] onChange?: (key: string) => void }) => { const [open, setOpen] = useState(false) // Nyanpasu Control Fields object key const disabled = label === 'default' || label === 'handle' const showFields: string[] = disabled ? Object.entries(fields).map(([key]) => key) : (enabledFields as string[]) const Item = () => { return Object.entries(fields).map(([fKey, fValue], fIndex) => { const checked = enabledFields?.includes(fKey) return ( onChange(fKey) : undefined} /> ) }) } return ( <> setOpen(true)} /> setOpen(false)} divider contentStyle={{ overflow: 'auto' }} > {disabled && Clash Nyanpasu Control Fields.} ) } const ClashFieldSwitch = () => { const { t } = useTranslation() const { value, upsert } = useSetting('enable_clash_fields') return ( upsert(!value)} /> ) } export const SettingClashField = () => { const { t } = useTranslation() const { query, upsert } = useProfile() const mergeFields = useMemo( () => [ ...Object.keys(CLASH_FIELD.default), ...Object.keys(CLASH_FIELD.handle), ...(query.data?.valid ?? []), ], [query.data], ) const filteredField = (fields: { [key: string]: string }): string[] => { const usedObjects = [] for (const key in fields) { if ( Object.prototype.hasOwnProperty.call(fields, key) && mergeFields.includes(key) ) { usedObjects.push(key) } } return usedObjects } const updateFiled = async (key: string) => { const getFields = (): string[] => { const valid = query.data?.valid ?? [] if (valid.includes(key)) { return valid.filter((item) => item !== key) } else { valid.push(key) return valid } } await upsert.mutateAsync({ valid: getFields() }) } return ( {Object.entries(CLASH_FIELD).map(([key, value], index) => { const filtered = filteredField(value) return ( ) })} ) } export default SettingClashField ================================================ FILE: frontend/nyanpasu/src/components/setting/setting-clash-port.tsx ================================================ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { message } from '@/utils/notification' import { List } from '@mui/material' import { useClashConfig, useSetting } from '@nyanpasu/interface' import { BaseCard, NumberItem, SwitchItem } from '@nyanpasu/ui' const ClashPort = () => { const { t } = useTranslation() const { value, upsert } = useSetting('verge_mixed_port') const { query, upsert: upsertClash } = useClashConfig() const port = useMemo(() => { return query.data?.['mixed-port'] || value || 7890 }, [query.data, value]) return ( input > 65535 || input < 1} checkLabel="Port must be between 1 and 65535." onApply={async (value) => { await upsertClash.mutateAsync({ 'mixed-port': value }) await upsert(value) }} /> ) } const RandomPort = () => { const { t } = useTranslation() const { value, upsert } = useSetting('enable_random_port') const handleRandomPort = async () => { try { await upsert(!value) } catch (e) { message(JSON.stringify(e), { title: t('Error'), kind: 'error', }) } finally { message(t('After restart to take effect'), { title: t('Successful'), kind: 'info', }) } } return ( ) } export const SettingClashPort = () => { const { t } = useTranslation() return ( ) } export default SettingClashPort ================================================ FILE: frontend/nyanpasu/src/components/setting/setting-clash-web.tsx ================================================ import { useLockFn } from 'ahooks' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import AddIcon from '@mui/icons-material/Add' import { Box, Chip, Divider, IconButton, TextField, Tooltip, Typography, } from '@mui/material' import Grid from '@mui/material/Grid' import { useClashInfo, useSetting } from '@nyanpasu/interface' import { BaseCard, BaseDialog, Expand } from '@nyanpasu/ui' import { ClashWebItem, extractServer, openWebUrl, renderChip } from './modules' const AddRecordButton = ({ onClick }: { onClick: () => void }) => { const { t } = useTranslation() return ( ) } export const SettingClashWeb = () => { const { t } = useTranslation() const { value, upsert } = useSetting('web_ui_list') const { data } = useClashInfo() const labels = useMemo(() => { const { host, port } = extractServer(data?.server) return { host, port, secret: data?.secret, } }, [data]) const [open, setOpen] = useState(false) const [editString, setEditString] = useState('') const [editIndex, setEditIndex] = useState(null) const deleteItem = useLockFn(async (index: number) => { await upsert( value ? value.slice(0, index).concat(value.slice(index + 1)) : null, ) }) const updateItem = useLockFn(async () => { const list = [...(value || [])] if (!list) return if (editIndex !== null) { list[editIndex] = editString } else { list.push(editString) } await upsert(list) }) return ( <> { setEditString('') setEditIndex(null) setOpen(true) }} /> } > {value && ( {value.map((item, index) => { return ( openWebUrl(item, labels)} onEdit={() => { setEditIndex(index) setEditString(item) setOpen(true) }} onDelete={() => deleteItem(index)} /> ) })} )} { setOpen(false) setEditIndex(null) }} onOk={() => { updateItem() setOpen(false) setEditIndex(null) setEditString('') }} ok={t('Ok')} close={t('Close')} contentStyle={{ overflow: editString ? 'auto' : 'hidden' }} divider > {t('Input')} setEditString(e.target.value)} /> {t('Replace host, port, and secret with')} {Object.entries(labels).map(([key], index) => { return })} {t('Result')} {renderChip(editString, labels)} ) } export default SettingClashWeb ================================================ FILE: frontend/nyanpasu/src/components/setting/setting-nyanpasu-auto-reload.tsx ================================================ // oxlint-disable typescript/no-explicit-any import { useTranslation } from 'react-i18next' import { useSetting } from '@nyanpasu/interface' import { SwitchItem } from '@nyanpasu/ui' // 定义各语言的翻译文本 const translations = { 'zh-CN': { proxy: '当代理切换时打断连接', profile: '当配置文件切换时打断连接', mode: '当模式切换时打断连接', }, 'zh-TW': { proxy: '當代理切換時打斷連線', profile: '當設定檔切換時打斷連線', mode: '當模式切換時打斷連線', }, ru: { proxy: 'Прерывать соединения при смене прокси', profile: 'Прерывать соединения при смене профиля', mode: 'Прерывать соединения при смене режима', }, en: { proxy: 'Interrupt connections when proxy changes', profile: 'Interrupt connections when profile changes', mode: 'Interrupt connections when mode changes', }, // 默认使用英文 default: { proxy: 'Interrupt connections when proxy changes', profile: 'Interrupt connections when profile changes', mode: 'Interrupt connections when mode changes', }, } const BreakWhenProxyChangeSetting = () => { const { i18n } = useTranslation() const currentLang = i18n.language // 获取当前语言的翻译,如果找不到则使用默认英文 const currentTranslations = translations[currentLang as keyof typeof translations] || translations.default const { value, upsert } = useSetting('break_when_proxy_change' as any) return ( { if (value === 'none') { upsert('all' as any) } else { upsert('none' as any) } }} /> ) } const BreakWhenProfileChangeSetting = () => { const { i18n } = useTranslation() const currentLang = i18n.language // 获取当前语言的翻译,如果找不到则使用默认英文 const currentTranslations = translations[currentLang as keyof typeof translations] || translations.default const { value, upsert } = useSetting('break_when_profile_change' as any) return ( { if (value === true) { upsert(false as any) } else { upsert(true as any) } }} /> ) } const BreakWhenModeChangeSetting = () => { const { i18n } = useTranslation() const currentLang = i18n.language // 获取当前语言的翻译,如果找不到则使用默认英文 const currentTranslations = translations[currentLang as keyof typeof translations] || translations.default const { value, upsert } = useSetting('break_when_mode_change' as any) return ( { if (value === true) { upsert(false as any) } else { upsert(true as any) } }} /> ) } export { BreakWhenProxyChangeSetting, BreakWhenProfileChangeSetting, BreakWhenModeChangeSetting, } ================================================ FILE: frontend/nyanpasu/src/components/setting/setting-nyanpasu-misc.tsx ================================================ import { useTranslation } from 'react-i18next' import { List } from '@mui/material' import { LoggingLevel, ProxiesSelectorMode, useSetting, type NetworkStatisticWidgetConfig, } from '@nyanpasu/interface' import { BaseCard, MenuItem, SwitchItem, TextItem } from '@nyanpasu/ui' import { BreakWhenModeChangeSetting, BreakWhenProfileChangeSetting, BreakWhenProxyChangeSetting, } from './setting-nyanpasu-auto-reload' const EnableBuiltinEnhanced = () => { const { t } = useTranslation() const { value, upsert } = useSetting('enable_builtin_enhanced') return ( upsert(!value)} /> ) } const LightenAnimationEffects = () => { const { t } = useTranslation() const { value, upsert } = useSetting('lighten_animation_effects') return ( upsert(!value)} /> ) } const AppLogLevel = () => { const { t } = useTranslation() const { value, upsert } = useSetting('app_log_level') const logOptions = { trace: 'Trace', debug: 'Debug', info: 'Info', warn: 'Warn', error: 'Error', silent: 'Silent', } return ( upsert(value as LoggingLevel)} /> ) } const TrayProxiesSelector = () => { const { t } = useTranslation() const { value, upsert } = useSetting('clash_tray_selector') const trayProxiesSelectorMode = { normal: t('Normal'), hidden: t('Hidden'), submenu: t('Submenu'), } return ( upsert(value as ProxiesSelectorMode)} /> ) } const NetworkWidgetVariant = () => { const { t } = useTranslation() const { value, upsert } = useSetting('network_statistic_widget') const options = { disabled: t('Disabled'), small: 'Small', large: 'Large', } const mapping: { [key: string]: NetworkStatisticWidgetConfig } = { disabled: { kind: 'disabled', }, small: { kind: 'enabled', value: 'small', }, large: { kind: 'enabled', value: 'large', }, } return ( config.kind === 'disabled' ? value?.kind === 'disabled' : value?.kind === 'enabled' && config.value === value.value, )?.[0] || 'disabled' } onSelected={(val) => upsert(mapping[val as string])} /> ) } const DefaultLatencyTest = () => { const { t } = useTranslation() const { value, upsert } = useSetting('default_latency_test') return ( upsert(value)} /> ) } export const SettingNyanpasuMisc = () => { const { t } = useTranslation() return ( ) } export default SettingNyanpasuMisc ================================================ FILE: frontend/nyanpasu/src/components/setting/setting-nyanpasu-path.tsx ================================================ import { useLockFn } from 'ahooks' import { useTranslation } from 'react-i18next' import { OS } from '@/consts' import { sleep } from '@/utils' import { message } from '@/utils/notification' import Grid from '@mui/material/Grid' import { collectLogs, openAppConfigDir, openAppDataDir, openCoreDir, openLogsDir, restartApplication, setCustomAppDir, } from '@nyanpasu/interface' import { BaseCard } from '@nyanpasu/ui' import { open } from '@tauri-apps/plugin-dialog' import { PaperButton } from './modules/nyanpasu-path' export const SettingNyanpasuPath = () => { const { t } = useTranslation() const migrateAppPath = useLockFn(async () => { try { // TODO: use current app dir as defaultPath const selected = await open({ directory: true, multiple: false, }) // user cancelled the selection if (!selected) { return } if (Array.isArray(selected)) { message(t('Multiple directories are not supported'), { title: t('Error'), kind: 'error', }) return } await setCustomAppDir(selected) message(t('Successfully changed the app directory'), { title: t('Successful'), kind: 'error', }) await sleep(1000) await restartApplication() } catch (e) { message(t('Failed to migrate', { error: `${JSON.stringify(e)}` }), { title: t('Error'), kind: 'error', }) } }) const gridLists = [ { label: t('Open Config Dir'), onClick: openAppConfigDir }, { label: t('Open Data Dir'), onClick: openAppDataDir }, OS === 'windows' && { label: t('Migrate Config Dir'), onClick: migrateAppPath, }, { label: t('Open Core Dir'), onClick: openCoreDir }, { label: t('Open Log Dir'), onClick: openLogsDir }, { label: t('Collect Logs'), onClick: collectLogs }, ].filter((x) => !!x) return ( {gridLists.map(({ label, onClick }) => ( ))} ) } export default SettingNyanpasuPath ================================================ FILE: frontend/nyanpasu/src/components/setting/setting-nyanpasu-tasks.tsx ================================================ import { useTranslation } from 'react-i18next' import { List } from '@mui/material' import { useSetting } from '@nyanpasu/interface' import { BaseCard, NumberItem } from '@nyanpasu/ui' export const SettingNyanpasuTasks = () => { const { t } = useTranslation() const { value, upsert } = useSetting('max_log_files') return ( value <= 0} checkLabel="Value must larger than 0." onApply={(v) => upsert(v)} /> ) } export default SettingNyanpasuTasks ================================================ FILE: frontend/nyanpasu/src/components/setting/setting-nyanpasu-ui.tsx ================================================ import { useAtom } from 'jotai' import { MuiColorInput } from 'mui-color-input' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { isHexColor } from 'validator' import { useLockFn } from '@/hooks/use-lock-fn' import { atomIsDrawerOnlyIcon } from '@/store' import { languageOptions } from '@/utils/language' import Done from '@mui/icons-material/Done' import { Button, List, ListItem, ListItemText } from '@mui/material' import { commands, useSetting } from '@nyanpasu/interface' import { BaseCard, Expand, MenuItem, SwitchItem } from '@nyanpasu/ui' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import { DEFAULT_COLOR } from '../layout/use-custom-theme' const currentWindow = getCurrentWebviewWindow() const commonSx = { width: 128, } const LanguageSwitch = () => { const { t } = useTranslation() const language = useSetting('language') return ( language.upsert(value as string)} /> ) } const ThemeSwitch = () => { const { t } = useTranslation() const themeOptions = { dark: t('theme.dark'), light: t('theme.light'), system: t('theme.system'), } const themeMode = useSetting('theme_mode') return ( themeMode.upsert(value as string)} /> ) } const ThemeColor = () => { const { t } = useTranslation() const theme = useSetting('theme_color') const [value, setValue] = useState(theme.value ?? DEFAULT_COLOR) useEffect(() => { setValue(theme.value ?? DEFAULT_COLOR) }, [theme.value]) return ( <> { if (!isHexColor(value ?? DEFAULT_COLOR)) { setValue(theme.value ?? DEFAULT_COLOR) } }} onChange={(color: string) => setValue(color)} />
) } const ExperimentalSwitch = () => { const { upsert } = useSetting('use_legacy_ui') const handleClick = useLockFn(async () => { await upsert(false) await commands.createMainWindow() await currentWindow.close() }) return ( ) } export const SettingNyanpasuUI = () => { const { t } = useTranslation() const [onlyIcon, setOnlyIcon] = useAtom(atomIsDrawerOnlyIcon) return ( setOnlyIcon(!onlyIcon)} /> ) } export default SettingNyanpasuUI ================================================ FILE: frontend/nyanpasu/src/components/setting/setting-nyanpasu-version.tsx ================================================ import { useLockFn } from 'ahooks' import { useSetAtom } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' import LogoSvg from '@/assets/image/logo.svg?react' import { checkUpdate, useUpdaterPlatformSupported } from '@/hooks/use-updater' import { UpdaterInstanceAtom } from '@/store/updater' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { Box, Button, List, ListItem, Paper, Typography } from '@mui/material' import { useSetting } from '@nyanpasu/interface' import { alpha, BaseCard } from '@nyanpasu/ui' import { version } from '@root/package.json' import { LabelSwitch } from './modules/clash-field' const AutoCheckUpdate = () => { const { t } = useTranslation() const { value, upsert } = useSetting('enable_auto_check_update') return ( upsert(!value)} /> ) } export const SettingNyanpasuVersion = () => { const { t } = useTranslation() const [loading, setLoading] = useState(false) const setUpdaterInstance = useSetAtom(UpdaterInstanceAtom) const isPlatformSupported = useUpdaterPlatformSupported() const onCheckUpdate = useLockFn(async () => { try { setLoading(true) const update = await checkUpdate() if (!update) { message(t('No update available.'), { title: t('Info'), kind: 'info', }) } else { setUpdaterInstance(update || null) } } catch (e) { message( `Update check failed. Please verify your network connection.\n\n${formatError(e)}`, { title: t('Error'), kind: 'error', }, ) } finally { setLoading(false) } }) return ( ({ mt: 1, padding: 2, backgroundColor: alpha(theme.vars.palette.primary.main, 0.1), borderRadius: 6, width: '100%', })} > {'Clash Nyanpasu~(∠・ω< )⌒☆'}  Version: v{version} {isPlatformSupported && ( <>
)}
) } export default SettingNyanpasuVersion ================================================ FILE: frontend/nyanpasu/src/components/setting/setting-page.tsx ================================================ import { useAtomValue } from 'jotai' import { useWindowSize } from 'react-use' import { useIsAppImage } from '@/hooks/use-consts' import { atomIsDrawerOnlyIcon } from '@/store' import Masonry from '@mui/lab/Masonry' import SettingClashBase from './setting-clash-base' import SettingClashCore from './setting-clash-core' import SettingClashExternal from './setting-clash-external' import SettingClashField from './setting-clash-field' import SettingClashPort from './setting-clash-port' import SettingClashWeb from './setting-clash-web' import SettingNyanpasuMisc from './setting-nyanpasu-misc' import SettingNyanpasuPath from './setting-nyanpasu-path' import SettingNyanpasuTasks from './setting-nyanpasu-tasks' import SettingNyanpasuUI from './setting-nyanpasu-ui' import SettingNyanpasuVersion from './setting-nyanpasu-version' import SettingSystemBehavior from './setting-system-behavior' import SettingSystemProxy from './setting-system-proxy' import SettingSystemService from './setting-system-service' export const SettingPage = () => { const isAppImage = useIsAppImage() const isDrawerOnlyIcon = useAtomValue(atomIsDrawerOnlyIcon) const { width } = useWindowSize() return ( 1000 ? 2 : 1, lg: 2, xl: 2, }} spacing={3} sequential > {!isAppImage.data && } ) } export default SettingPage ================================================ FILE: frontend/nyanpasu/src/components/setting/setting-system-behavior.tsx ================================================ import { useTranslation } from 'react-i18next' import Grid from '@mui/material/Grid' import { useSetting } from '@nyanpasu/interface' import { BaseCard } from '@nyanpasu/ui' import { PaperSwitchButton } from './modules/system-proxy' export const SettingSystemBehavior = () => { const { t } = useTranslation() const autoLaunch = useSetting('enable_auto_launch') const silentStart = useSetting('enable_silent_start') return ( autoLaunch.upsert(!autoLaunch.value)} /> silentStart.upsert(!silentStart.value)} /> ) } export default SettingSystemBehavior ================================================ FILE: frontend/nyanpasu/src/components/setting/setting-system-proxy.tsx ================================================ import { useLockFn } from 'ahooks' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { InputAdornment, List, ListItem } from '@mui/material' import Grid from '@mui/material/Grid' import { useSetting, useSystemProxy } from '@nyanpasu/interface' import { BaseCard, Expand, ExpandMore, NumberItem, SwitchItem, TextItem, } from '@nyanpasu/ui' import { PaperSwitchButton } from './modules/system-proxy' const TunModeButton = () => { const { t } = useTranslation() const tunMode = useSetting('enable_tun_mode') const handleTunMode = useLockFn(async () => { try { await tunMode.upsert(!tunMode.value) } catch (error) { message(`Activation TUN Mode failed! \n Error: ${formatError(error)}`, { title: t('Error'), kind: 'error', }) } }) return ( ) } const SystemProxyButton = () => { const { t } = useTranslation() const systemProxy = useSetting('enable_system_proxy') const handleSystemProxy = useLockFn(async () => { try { await systemProxy.upsert(!systemProxy.value) } catch (error) { message( `Activation System Proxy failed!\n Error: ${formatError(error)}`, { title: t('Error'), kind: 'error', }, ) } }) return ( ) } const ProxyGuardSwitch = () => { const { t } = useTranslation() const proxyGuard = useSetting('enable_proxy_guard') const handleProxyGuard = useLockFn(async () => { try { await proxyGuard.upsert(!proxyGuard.value) } catch (error) { message(`Activation Proxy Guard failed!\n Error: ${formatError(error)}`, { title: t('Error'), kind: 'error', }) } }) return ( ) } const ProxyGuardInterval = () => { const { t } = useTranslation() const proxyGuardInterval = useSetting('proxy_guard_interval') return ( input <= 0} checkLabel={t('The interval must be greater than 0 second')} onApply={(value) => { proxyGuardInterval.upsert(value) }} textFieldProps={{ inputProps: { 'aria-autocomplete': 'none', }, InputProps: { endAdornment: ( {t('seconds')} ), }, }} /> ) } const DEFAULT_BYPASS = 'localhost;127.;192.168.;10.;172.16.;172.17.;172.18.;172.19.;172.20.;172.21.;172.22.;172.23.;172.24.;172.25.;172.26.;172.27.;172.28.;172.29.;172.30.;172.31.*' const SystemProxyBypass = () => { const { t } = useTranslation() const systemProxyBypass = useSetting('system_proxy_bypass') return ( { if (!value || value.trim() === '') { // 输入为空 → 重置为默认规则 systemProxyBypass.upsert(DEFAULT_BYPASS) } else { // 正常写入用户配置 systemProxyBypass.upsert(value) } }} /> ) } const CurrentSystemProxy = () => { const { t } = useTranslation() const { data } = useSystemProxy() return (
{t('Current System Proxy')}
{Object.entries(data ?? []).map(([key, value], index) => { return (
{key}:
{String(value)}
) })}
) } export const SettingSystemProxy = () => { const { t } = useTranslation() const [expand, setExpand] = useState(false) return ( setExpand(!expand)} /> } > ) } export default SettingSystemProxy ================================================ FILE: frontend/nyanpasu/src/components/setting/setting-system-service.tsx ================================================ import { useMemoizedFn } from 'ahooks' import { useTransition } from 'react' import { useTranslation } from 'react-i18next' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { Button, List, ListItem, ListItemText, Typography } from '@mui/material' import { restartSidecar, useSetting, useSystemService, } from '@nyanpasu/interface' import { BaseCard, SwitchItem } from '@nyanpasu/ui' import { ServerManualPromptDialogWrapper, useServerManualPromptDialog, } from './modules/service-manual-prompt-dialog' export const SettingSystemService = () => { const { t } = useTranslation() const { query, upsert } = useSystemService() const getInstallButtonString = () => { switch (query.data?.status) { case 'running': case 'stopped': { return t('uninstall') } case 'not_installed': { return t('install') } } } const getControlButtonString = () => { switch (query.data?.status) { case 'running': { return t('stop') } case 'stopped': { return t('start') } } } const isDisabled = query.data?.status === 'not_installed' const promptDialog = useServerManualPromptDialog() const [installOrUninstallPending, startInstallOrUninstall] = useTransition() const handleInstallClick = useMemoizedFn(() => { startInstallOrUninstall(async () => { try { switch (query.data?.status) { case 'running': case 'stopped': await upsert.mutateAsync('uninstall') break case 'not_installed': await upsert.mutateAsync('install') break default: break } await restartSidecar() } catch (e) { const errorMessage = `${ query.data?.status === 'not_installed' ? t('Failed to install') : t('Failed to uninstall') }: ${formatError(e)}` message(errorMessage, { kind: 'error', title: t('Error'), }) // If the installation fails, prompt the user to manually install the service promptDialog.show( query.data?.status === 'not_installed' ? 'install' : 'uninstall', ) } }) }) const [serviceControlPending, startServiceControl] = useTransition() const handleControlClick = useMemoizedFn(() => { startServiceControl(async () => { try { switch (query.data?.status) { case 'running': await upsert.mutateAsync('stop') break case 'stopped': await upsert.mutateAsync('start') break default: break } await restartSidecar() } catch (e) { const errorMessage = query.data?.status === 'running' ? `Stop failed: ${formatError(e)}` : `Start failed: ${formatError(e)}` message(errorMessage, { kind: 'error', title: t('Error'), }) // If start failed show a prompt to user to start the service manually promptDialog.show(query.data?.status === 'running' ? 'stop' : 'start') } }) }) const serviceMode = useSetting('enable_service_mode') return ( serviceMode.upsert(!serviceMode.value)} /> {isDisabled && ( {t( 'Information: To enable service mode, make sure the Clash Nyanpasu service is installed and started', )} )}
{!isDisabled && ( )} {import.meta.env.DEV && ( )}
) } export default SettingSystemService ================================================ FILE: frontend/nyanpasu/src/components/settings/system-proxy.tsx ================================================ import NetworkPing from '~icons/material-symbols/network-ping-rounded' import SettingsEthernet from '~icons/material-symbols/settings-ethernet-rounded' import { useBlockTask } from '@/components/providers/block-task-provider' import { Button, ButtonProps } from '@/components/ui/button' import { CircularProgress } from '@/components/ui/progress' import { m } from '@/paraglide/messages' import { useSetting } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' const ProxyButton = ({ className, isActive, loading, children, ...props }: ButtonProps & { isActive?: boolean }) => { return ( ) } export const SystemProxyButton = ( props: Omit, ) => { const systemProxy = useSetting('enable_system_proxy') const { execute, isPending } = useBlockTask('system-proxy', async () => { await systemProxy.upsert(!systemProxy.value) }) return ( {m.settings_system_proxy_system_proxy_label()} ) } export const TunModeButton = ( props: Omit, ) => { const tunMode = useSetting('enable_tun_mode') const { execute, isPending } = useBlockTask('tun-mode', async () => { await tunMode.upsert(!tunMode.value) }) return ( {m.settings_system_proxy_tun_mode_label()} ) } ================================================ FILE: frontend/nyanpasu/src/components/ui/animated-item.tsx ================================================ import { motion } from 'framer-motion' import { ComponentProps } from 'react' import { cn } from '@nyanpasu/ui' export function AnimatedItem({ className, ...props }: ComponentProps) { return ( ) } ================================================ FILE: frontend/nyanpasu/src/components/ui/border-beam.tsx ================================================ import { motion, MotionStyle, Transition } from 'framer-motion' import { ComponentProps } from 'react' import { cn } from '@nyanpasu/ui' export default function BorderBeam({ className, size = 50, delay = 0, duration = 6, transition, style, reverse = false, initialOffset = 0, }: ComponentProps & { size?: number duration?: number delay?: number transition?: Transition className?: string reverse?: boolean initialOffset?: number }) { return (
) } ================================================ FILE: frontend/nyanpasu/src/components/ui/button.tsx ================================================ import { cva, type VariantProps } from 'class-variance-authority' import { AnimatePresence, motion } from 'framer-motion' import { Slot } from 'radix-ui' import { lazy, Suspense, useCallback } from 'react' import { chains } from '@/utils/chain' import { cn } from '@nyanpasu/ui' import { CircularProgress } from './progress' import { useRipple } from './ripple' export const buttonVariants = cva( [ 'cursor-pointer select-none', 'focus:outline-hidden', 'relative overflow-hidden', 'h-10 text-sm font-medium', 'rounded-full', 'transition-[background-color,color,shadow,filter]', ], { variants: { variant: { basic: [ 'px-4', 'text-primary dark:text-primary', 'bg-transparent-fallback-surface dark:bg-transparent-fallback-surface-variant', 'hover:bg-primary-container dark:hover:bg-surface-variant', ], raised: [ 'px-6', 'text-primary dark:text-on-surface', 'shadow-xs hover:shadow-sm focus:shadow-sm', 'bg-surface', 'hover:bg-surface-variant', ], stroked: [ 'px-6', 'text-primary', 'border border-primary', 'bg-transparent-fallback-surface dark:bg-transparent-fallback-surface-variant', 'hover:bg-primary-container dark:hover:bg-surface-variant', ], flat: [ 'px-6', 'text-surface dark:text-on-surface', 'bg-primary dark:bg-primary-container', 'dark:hover:bg-on-primary', ], fab: [ 'px-4 h-14', 'rounded-2xl', 'shadow-sm', 'text-on-primary-container dark:text-on-primary-container', 'bg-primary-container dark:bg-on-primary', 'hover:shadow-md', 'hover:brightness-95 dark:hover:brightness-105', ], }, disabled: { true: 'cursor-not-allowed shadow-none hover:shadow-none focus:shadow-none', false: '', }, icon: { true: 'p-0 grid place-content-center', false: 'min-w-16', }, }, compoundVariants: [ { variant: 'basic', disabled: true, className: 'text-zinc-900/40 hover:bg-transparent', }, { variant: 'raised', disabled: true, className: 'bg-gray-900/20 text-zinc-900/40 hover:bg-gray-900/20', }, { variant: 'stroked', disabled: true, className: 'text-zinc-900/40 hover:bg-transparent border-zinc-300', }, { variant: 'flat', disabled: true, className: 'bg-gray-900/20 text-gray-900/40 hover:bg-primary', }, { variant: 'fab', disabled: true, className: 'bg-gray-900/20 text-gray-900/40 hover:brightness-100 hover:shadow-container-xl', }, { icon: true, className: 'w-10', }, { variant: 'fab', icon: true, className: 'w-14', }, ], defaultVariants: { variant: 'basic', disabled: false, icon: false, }, }, ) export type ButtonVariantsProps = VariantProps const LazyRipple = lazy(() => import('./ripple').then((mod) => ({ default: mod.Ripple })), ) export interface ButtonProps extends Omit, 'disabled'>, ButtonVariantsProps { asChild?: boolean loading?: boolean } export const Button = ({ loading, asChild, variant, disabled, icon, className, children, onClick, ...props }: ButtonProps) => { const Comp = asChild ? Slot.Root : 'button' const ripple = useRipple() const handleClick = disabled ? undefined : chains(onClick, ripple.onClick) const handleClear = useCallback( (key: React.Key) => { ripple.onClear(key) }, [ripple], ) return ( {children} {loading && ( )} {ripple && !loading && !disabled && ( )} ) } ================================================ FILE: frontend/nyanpasu/src/components/ui/card.tsx ================================================ import { cva, type VariantProps } from 'class-variance-authority' import { Slot } from 'radix-ui' import { createContext, HTMLAttributes, useContext } from 'react' import { cn } from '@nyanpasu/ui' export const cardVariants = cva('rounded-3xl text-on-surface overflow-hidden', { variants: { variant: { basic: ['shadow-sm', 'bg-surface dark:bg-surface'], raised: ['shadow-sm', 'bg-primary-container dark:bg-on-primary'], outline: [ 'bg-surface dark:bg-surface', 'border border-outline-variant dark:border-outline-variant', ], }, }, defaultVariants: { variant: 'basic', }, }) export type CardVariantsProps = VariantProps export const cardContentVariants = cva(['flex flex-col gap-4 p-4']) export type CardContentVariantsProps = VariantProps export const cardHeaderVariants = cva( ['flex items-center gap-4 text-xl', 'px-4'], { variants: { variant: { basic: 'border-surface-variant dark:border-surface-variant', raised: 'border-inverse-primary dark:border-primary-container', outline: 'border-outline-variant dark:border-outline-variant', }, divider: { true: 'border-b py-4 ', false: 'pt-4', }, }, defaultVariants: { divider: false, variant: 'basic', }, }, ) export type CardHeaderVariantsProps = VariantProps export const cardFooterVariants = cva( ['flex flex-row-reverse items-center gap-4', 'px-2'], { variants: { variant: { basic: 'border-surface-variant dark:border-surface-variant', raised: 'border-inverse-primary dark:border-primary-container', outline: 'border-outline-variant dark:border-outline-variant', }, divider: { true: 'border-t py-2', false: 'pb-2', }, }, defaultVariants: { divider: false, variant: 'basic', }, }, ) export type CardFooterVariantsProps = VariantProps type CardContextType = { variant: CardVariantsProps['variant'] divider: CardHeaderVariantsProps['divider'] & CardFooterVariantsProps['divider'] } const CardContext = createContext(null) const useCardContext = () => { const context = useContext(CardContext) if (!context) { throw new Error('useCardContext must be used within a CardProvider') } return context } export interface CardProps extends HTMLAttributes, CardVariantsProps, Partial { asChild?: boolean } export const Card = ({ variant, divider, asChild, className, ...props }: CardProps) => { const Comp = asChild ? Slot.Root : 'div' return ( ) } export type CardContentProps = HTMLAttributes & CardContentVariantsProps & { asChild?: boolean } export const CardContent = ({ className, asChild, ...props }: CardContentProps) => { const Comp = asChild ? Slot.Root : 'div' return } export type CardHeaderProps = HTMLAttributes & CardHeaderVariantsProps & { asChild?: boolean } export const CardHeader = ({ divider, variant, className, ...props }: CardHeaderProps) => { const context = useCardContext() return (
) } export interface CardFooterProps extends HTMLAttributes, CardFooterVariantsProps {} export const CardFooter = ({ divider, variant, className, ...props }: CardFooterProps) => { const context = useCardContext() return (
) } ================================================ FILE: frontend/nyanpasu/src/components/ui/circle.tsx ================================================ import { ComponentProps } from 'react' import { cn } from '@nyanpasu/ui' const BASE_STROKE_WIDTH = 10 const BASE_SIZE = 100 const getCircleRefence = (value: number) => { const radius = (BASE_SIZE - BASE_STROKE_WIDTH) / 2 const strokeDasharray = 2 * Math.PI * radius const strokeDashoffset = (strokeDasharray * (100 - value)) / 100 return { radius, strokeDasharray, strokeDashoffset, } } export function Circle({ value, className, style, ...props }: ComponentProps<'circle'> & { value: number }) { const { strokeDasharray, strokeDashoffset } = getCircleRefence(value) return ( ) } export function CircleSVG({ className, ...props }: ComponentProps<'svg'>) { return ( ) } ================================================ FILE: frontend/nyanpasu/src/components/ui/context-menu.tsx ================================================ import ArrowRight from '~icons/material-symbols/arrow-right-rounded' import Check from '~icons/material-symbols/check-rounded' import { AnimatePresence, motion } from 'framer-motion' import { ContextMenu as ContextMenuPrimitive } from 'radix-ui' import { ComponentProps, createContext, useContext } from 'react' import { cn } from '@nyanpasu/ui' import { useControllableState } from '@radix-ui/react-use-controllable-state' const MotionContent = ({ children, className, style, ...props }: ComponentProps) => { return ( {children} ) } const ContextMenuContext = createContext<{ open: boolean } | null>(null) const useContextMenuContext = () => { const context = useContext(ContextMenuContext) if (context === null) { throw new Error( 'ContextMenu compound components cannot be rendered outside the ContextMenu component', ) } return context } export const ContextMenu = ({ open: inputOpen, onOpenChange, ...props }: ComponentProps & { open?: boolean }) => { const [open, setOpen] = useControllableState({ prop: inputOpen, defaultProp: false, onChange: onOpenChange, }) return ( ) } export const ContextMenuTrigger = ContextMenuPrimitive.Trigger export const ContextMenuGroup = ContextMenuPrimitive.Group export const ContextMenuPortal = ContextMenuPrimitive.Portal const ContextMenuSubContext = createContext<{ open: boolean } | null>(null) const useContextMenuSubContext = () => { const context = useContext(ContextMenuSubContext) if (context === null) { throw new Error( 'ContextMenuSub compound components cannot be rendered outside the ContextMenuSub component', ) } return context } export const ContextMenuSub = ({ open: inputOpen, defaultOpen, onOpenChange, children, ...props }: ComponentProps) => { const [open, setOpen] = useControllableState({ prop: inputOpen, defaultProp: defaultOpen ?? false, onChange: onOpenChange, }) return ( {children} ) } export function ContextMenuSubTrigger({ children, className, ...props }: ComponentProps) { return ( {children} ) } export function ContextMenuSubContent({ children, className, ...props }: ComponentProps) { const { open } = useContextMenuSubContext() return ( {open && ( {children} )} ) } export const ContextMenuContent = ({ children, className, ...props }: ComponentProps) => { const { open } = useContextMenuContext() return ( {open && ( { e.preventDefault() }} > {children} )} ) } export const ContextMenuItem = ({ className, ...props }: ComponentProps) => { return ( ) } export const ContextMenuCheckboxItem = ({ children, className, ...props }: ComponentProps) => { return ( {children} ) } export const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup export const ContextMenuRadioItem = ({ children, className, ...props }: ComponentProps) => { return ( {children} ) } export const ContextMenuLabel = ({ className, ...props }: ComponentProps) => { return ( ) } export const ContextMenuSeparator = ({ className, ...props }: ComponentProps) => { return ( ) } export const ContextMenuShortcut = ({ className, ...props }: ComponentProps<'span'>) => { return ( ) } ================================================ FILE: frontend/nyanpasu/src/components/ui/dropdown-menu.tsx ================================================ import ArrowRight from '~icons/material-symbols/arrow-right-rounded' import Check from '~icons/material-symbols/check-rounded' import RadioChecked from '~icons/material-symbols/radio-button-checked' import Radio from '~icons/material-symbols/radio-button-unchecked' import { AnimatePresence, motion } from 'framer-motion' import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui' import { ComponentProps, createContext, useContext } from 'react' import { cn } from '@nyanpasu/ui' import { useControllableState } from '@radix-ui/react-use-controllable-state' const MotionContent = ({ children, className, ...props }: ComponentProps) => { return ( {children} ) } const DropdownMenuContext = createContext<{ open: boolean } | null>(null) const useDropdownMenuContext = () => { const context = useContext(DropdownMenuContext) if (context === null) { throw new Error( 'DropdownMenu compound components cannot be rendered outside the DropdownMenu component', ) } return context } export const DropdownMenu = ({ open: inputOpen, defaultOpen, onOpenChange, ...props }: ComponentProps) => { const [open, setOpen] = useControllableState({ prop: inputOpen, defaultProp: defaultOpen ?? false, onChange: onOpenChange, }) return ( ) } export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger export const DropdownMenuGroup = DropdownMenuPrimitive.Group export const DropdownMenuPortal = DropdownMenuPrimitive.Portal const DropdownMenuSubContext = createContext<{ open: boolean } | null>(null) const useDropdownMenuSubContext = () => { const context = useContext(DropdownMenuSubContext) if (context === null) { throw new Error( 'DropdownMenuSub compound components cannot be rendered outside the DropdownMenuSub component', ) } return context } export const DropdownMenuSub = ({ open: inputOpen, defaultOpen, onOpenChange, children, ...props }: ComponentProps) => { const [open, setOpen] = useControllableState({ prop: inputOpen, defaultProp: defaultOpen ?? false, onChange: onOpenChange, }) return ( {children} ) } export function DropdownMenuSubTrigger({ children, className, ...props }: ComponentProps) { return ( {children} ) } export function DropdownMenuSubContent({ children, className, ...props }: ComponentProps) { const { open } = useDropdownMenuSubContext() return ( {open && ( {children} )} ) } const DropdownMenuRadioGroupContext = createContext<{ value: string | null }>({ value: null }) const useDropdownMenuRadioGroupContext = () => { const context = useContext(DropdownMenuRadioGroupContext) if (context === undefined) { throw new Error( 'DropdownMenuRadioGroup compound components cannot be rendered outside the DropdownMenuRadioGroup component', ) } return context } export const DropdownMenuRadioGroup = ({ value: inputValue, defaultValue, onValueChange, ...props }: ComponentProps) => { const [value, setValue] = useControllableState({ prop: inputValue, defaultProp: String(defaultValue), onChange: onValueChange, }) return ( ) } export const DropdownMenuContent = ({ children, className, ...props }: ComponentProps) => { const { open } = useDropdownMenuContext() return ( {open && ( {children} )} ) } export const DropdownMenuItem = ({ className, ...props }: ComponentProps) => { return ( ) } export const DropdownMenuCheckboxItem = ({ children, className, ...props }: ComponentProps) => { return ( {children} ) } export const DropdownMenuRadioItem = ({ value, children, className, ...props }: ComponentProps) => { const context = useDropdownMenuRadioGroupContext() const selected = context.value === value return ( {!selected && ( )}
{children}
) } export const DropdownMenuLabel = ({ className, ...props }: ComponentProps) => { return ( ) } export const DropdownMenuSeparator = ({ className, ...props }: ComponentProps) => { return ( ) } ================================================ FILE: frontend/nyanpasu/src/components/ui/file-drop-zone.tsx ================================================ import { cva, type VariantProps } from 'class-variance-authority' import { ChangeEvent, ComponentProps, createContext, DragEvent, RefObject, useContext, useEffect, useRef, useState, } from 'react' import getSystem from '@/utils/get-system' import { cn } from '@nyanpasu/ui' import { readTextFile } from '@tauri-apps/plugin-fs' const isWin = getSystem() === 'windows' const FileDropZoneContext = createContext<{ isDragging: boolean isLoading: boolean fileName: string | null accept: string[] disabled: boolean fileInputRef: RefObject handleClick: () => void } | null>(null) const useFileDropZoneContext = () => { const context = useContext(FileDropZoneContext) if (!context) { throw new Error('FileDropZone components must be used within FileDropZone') } return context } export const fileDropZoneVariants = cva( [ 'relative flex min-h-24 flex-col items-center justify-center gap-2', 'rounded-md border border-dashed p-4', 'transition-colors duration-200', 'cursor-pointer', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', ], { variants: { variant: { default: [ 'border-outline-variant', 'bg-transparent', 'hover:border-primary/50', 'hover:bg-surface-variant/30', ], outline: [ 'border-outline-variant', 'bg-surface dark:bg-surface', 'hover:border-primary/50', 'hover:bg-surface-variant/30', ], }, isDragging: { true: 'border-primary bg-primary-container/20', false: '', }, disabled: { true: 'cursor-not-allowed opacity-50', false: '', }, }, compoundVariants: [ { disabled: true, className: 'hover:border-outline-variant hover:bg-transparent', }, ], defaultVariants: { variant: 'default', isDragging: false, disabled: false, }, }, ) export type FileDropZoneVariants = VariantProps export interface FileDropZoneProps extends Omit< ComponentProps<'div'>, | 'onChange' | 'onDragEnter' | 'onDragLeave' | 'onDragOver' | 'onDrop' | 'onClick' >, FileDropZoneVariants { value?: string | null onChange?: (filePath: string) => void onFileRead?: (content: string) => void accept: string[] disabled?: boolean } export function FileDropZone({ value, onChange, onFileRead, accept, className, disabled = false, variant, children, ...props }: FileDropZoneProps) { const [isDragging, setIsDragging] = useState(false) const [isLoading, setIsLoading] = useState(false) const [fileName, setFileName] = useState( value ? ((isWin ? value.split('\\').at(-1) : value.split('/').at(-1)) ?? null) : null, ) const fileInputRef = useRef(null) // Update fileName when value changes useEffect(() => { if (value) { const name = isWin ? value.split('\\').at(-1) : value.split('/').at(-1) setFileName(name || null) } else { setFileName(null) } }, [value]) const handleFile = async (filePath: string, file?: File) => { if (disabled) return try { setIsLoading(true) let content: string // If file object is provided (from drag & drop), use FileReader // Otherwise, use Tauri's readTextFile API if (file) { content = await new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = (e) => { resolve(e.target?.result as string) } reader.onerror = reject reader.readAsText(file) }) } else { content = await readTextFile(filePath) } // Read file content if callback is provided if (onFileRead) { onFileRead(content) } // Update file path onChange?.(filePath) // Extract file name const name = file?.name || (isWin ? filePath.split('\\').at(-1) : filePath.split('/').at(-1)) setFileName(name || null) } catch (error) { console.error('Failed to read file:', error) } finally { setIsLoading(false) } } const handleDragEnter = (e: DragEvent) => { if (disabled) return e.preventDefault() e.stopPropagation() setIsDragging(true) } const handleDragLeave = (e: DragEvent) => { if (disabled) return e.preventDefault() e.stopPropagation() setIsDragging(false) } const handleDragOver = (e: DragEvent) => { if (disabled) return e.preventDefault() e.stopPropagation() } const handleDrop = async (e: DragEvent) => { if (disabled) return e.preventDefault() e.stopPropagation() setIsDragging(false) const files = e.dataTransfer.files if (files.length === 0) return const file = files[0] // Check file extension const fileExt = file.name .toLowerCase() .substring(file.name.lastIndexOf('.')) if (!accept.some((ext) => fileExt === ext.toLowerCase())) { console.error('File type not accepted') return } // In Tauri, try to get file path from the file object // If not available, use FileReader API const filePath = (file as File & { path?: string }).path as | string | undefined if (filePath) { // File path is available (Tauri native drag & drop) await handleFile(filePath, file) } else { // Fallback: use file name as identifier and read content via FileReader // Note: In this case, we use the file name as the path identifier await handleFile(file.name, file) } } const handleClick = () => { if (disabled || isLoading) { return } fileInputRef.current?.click() } const handleFileInputChange = async (e: ChangeEvent) => { const files = e.target.files if (!files || files.length === 0) return const file = files[0] // In Tauri, file input may have path property const filePath = (file as File & { path?: string }).path as | string | undefined if (filePath) { // File path is available (Tauri file dialog) await handleFile(filePath) } else { // Fallback: use file name and read via FileReader await handleFile(file.name, file) } // Reset input if (fileInputRef.current) { fileInputRef.current.value = '' } } return (
{children} {/* {isLoading ? ( ) : fileName ? ( (fileSelected?.(fileName) ?? ( )) ) : ( )} */}
) } export function FileDropZoneLoading(props: ComponentProps<'div'>) { const { isLoading } = useFileDropZoneContext() if (!isLoading) { return null } return
} export function FileDropZonePlaceholder(props: ComponentProps<'div'>) { const { isLoading, fileName } = useFileDropZoneContext() if (isLoading || fileName) { return null } return
} export function FileDropZoneFileSelected(props: ComponentProps<'div'>) { const { fileName } = useFileDropZoneContext() if (!fileName) { return null } return
} ================================================ FILE: frontend/nyanpasu/src/components/ui/highlight-text.tsx ================================================ export default function HighlightText({ searchText, className, children, }: { searchText: string className?: string children: string }) { if (!searchText.trim()) { return {children} } const parts: { text: string; isHighlight: boolean }[] = [] const searchLower = searchText.toLowerCase() const textLower = children.toLowerCase() let lastIndex = 0 let index = textLower.indexOf(searchLower, lastIndex) while (index !== -1) { // Add text before match if (index > lastIndex) { parts.push({ text: children.slice(lastIndex, index), isHighlight: false, }) } // Add matched text parts.push({ text: children.slice(index, index + searchText.length), isHighlight: true, }) lastIndex = index + searchText.length index = textLower.indexOf(searchLower, lastIndex) } // Add remaining text if (lastIndex < children.length) { parts.push({ text: children.slice(lastIndex), isHighlight: false, }) } return ( {parts.map((part, index) => part.isHighlight ? ( {part.text} ) : ( {part.text} ), )} ) } ================================================ FILE: frontend/nyanpasu/src/components/ui/image.tsx ================================================ import { ComponentProps, useMemo } from 'react' import { useServerPort } from '@nyanpasu/interface' import { LazyImage } from '@nyanpasu/ui' export default function Image({ icon, ...porps }: Omit, 'src'> & { icon: string }) { const serverPort = useServerPort() const src = icon.trim().startsWith(' { if (!src.startsWith('http')) { return src } return `http://localhost:${serverPort}/cache/icon?url=${btoa(src)}` }, [src, serverPort]) return } ================================================ FILE: frontend/nyanpasu/src/components/ui/input.tsx ================================================ import { useCreation } from 'ahooks' import { cva, type VariantProps } from 'class-variance-authority' import { ChangeEvent, ComponentProps, createContext, isValidElement, useContext, useEffect, useState, } from 'react' import { cn } from '@nyanpasu/ui' export const inputContainerVariants = cva( [ 'group relative box-border inline-flex w-full flex-auto items-baseline', 'cursor-pointer', 'px-4 py-4 outline-hidden', // TODO: size variants, fix this 'flex items-center justify-between h-14', 'dark:text-on-surface', ], { variants: { variant: { filled: 'rounded-t bg-surface-variant/30 dark:bg-surface', // outlined use inputLabelFieldsetVariants outlined: '', }, }, defaultVariants: { variant: 'filled', }, }, ) export type InputContainerVariants = VariantProps export const inputVariants = cva( [ 'peer', 'w-full border-none p-0', 'bg-transparent placeholder-transparent outline-hidden', 'transition-[margin] duration-200', ], { variants: { variant: { filled: '', outlined: '', }, haveValue: { true: '', false: '', }, haveLabel: { true: '', false: '', }, }, compoundVariants: [ { variant: 'filled', haveValue: true, haveLabel: true, className: 'mt-3!', }, ], defaultVariants: { variant: 'filled', haveValue: false, haveLabel: false, }, }, ) export type InputVariants = VariantProps export const inputLabelVariants = cva( [ 'absolute', 'left-4 top-4', 'pointer-events-none', 'text-base select-none', // TODO: only transition position, not text color 'transition-all duration-200', ], { variants: { variant: { filled: [ 'group-data-[state=open]:top-2 group-data-[state=open]:dark:text-surface', 'group-data-[state=open]:text-xs group-data-[state=open]:text-primary', ], outlined: [ 'group-data-[state=open]:-top-2', 'group-data-[state=open]:text-sm', 'group-data-[state=open]:text-primary', 'dark:group-data-[state=open]:text-inverse-primary', 'dark:group-data-[state=closed]:text-primary-container', // "before:absolute before:inset-0 before:content-['']", // "before:-z-10 before:-mx-1", // "before:bg-transparent ", // "before:inline-block", ], }, focus: { true: '', false: '', }, }, compoundVariants: [ { variant: 'filled', focus: true, className: 'top-2 text-xs', }, { variant: 'outlined', focus: true, className: '-top-2 text-sm', }, ], defaultVariants: { variant: 'filled', focus: false, }, }, ) export type InputLabelVariants = VariantProps export const inputLineVariants = cva('', { variants: { variant: { filled: [ 'absolute inset-x-0 bottom-0 w-full border-b border-b-outline-variant', 'transition-all duration-200', // pseudo elements be overlay parent element, will not affect the box size 'after:absolute after:inset-x-0 after:bottom-0 after:z-10', "after:scale-x-0 after:border-b-2 after:opacity-0 after:content-['']", 'after:transition-all after:duration-200', 'after:border-primary dark:after:border-on-primary-container', // sync parent group state, state from radix-ui 'group-data-[state=open]:border-b-0', 'group-data-[state=open]:after:scale-x-100', 'group-data-[state=open]:after:opacity-100', 'peer-focus:border-b-0', 'peer-focus:after:scale-x-100', 'peer-focus:after:opacity-100', ], // hidden line for outlined variant outlined: 'hidden', }, }, defaultVariants: { variant: 'filled', }, }) export type InputLineVariants = VariantProps export const inputLabelFieldsetVariants = cva('pointer-events-none', { variants: { variant: { // only for outlined variant filled: 'hidden', outlined: [ 'absolute inset-0 text-left', 'rounded transition-all duration-200', // may open border width will be 1.5, idk 'group-data-[state=closed]:border', 'group-data-[state=open]:border-2', 'peer-not-focus:border', 'peer-focus:border-2', // different material web border color, i think this looks better 'group-data-[state=closed]:border-outline-variant', 'group-data-[state=open]:border-primary', 'peer-not-focus:border-primary-container', 'peer-focus:border-primary', // dark must be prefixed 'dark:group-data-[state=closed]:border-outline-variant', 'dark:group-data-[state=open]:border-primary-container', 'dark:peer-not-focus:border-outline-variant', 'dark:peer-focus:border-primary-container', ], }, }, defaultVariants: { variant: 'filled', }, }) export type InputLabelFieldsetVariants = VariantProps< typeof inputLabelFieldsetVariants > export const inputLabelLegendVariants = cva('', { variants: { variant: { // only for outlined variant filled: 'hidden', outlined: 'invisible ml-2 px-2 text-sm h-0', }, haveValue: { true: '', false: '', }, }, compoundVariants: [ { variant: 'outlined', haveValue: false, className: ['group-data-[state=closed]:hidden', 'group-not-focus:hidden'], }, ], defaultVariants: { variant: 'filled', haveValue: false, }, }) export type InputLabelLegendVariants = VariantProps< typeof inputLabelLegendVariants > type InputContextType = { haveLabel?: boolean haveValue?: boolean } & InputContainerVariants const InputContext = createContext(null) const useInputContext = () => { const context = useContext(InputContext) if (!context) { throw new Error('InputContext is undefined') } return context } export const InputContainer = ({ className, ...props }: ComponentProps<'div'>) => { const { variant } = useInputContext() return (
) } export const InputLine = ({ className, ...props }: ComponentProps<'input'>) => { const { variant } = useInputContext() return (
) } export type InputProps = ComponentProps<'input'> & { label?: string } & InputContainerVariants export const Input = ({ variant, className, label, children, onChange, ...props }: InputProps) => { const [haveValue, setHaveValue] = useState(false) const haveLabel = useCreation(() => { if (label) { return true } if (isValidElement(children)) { if (typeof children.type !== 'string') { if ('displayName' in children.type) { if (children.type.displayName === InputLabel.displayName) { return true } } } } return false }, []) useEffect(() => { if (props.value || props.defaultValue) { setHaveValue(true) } else { setHaveValue(false) } }, [props.value, props.defaultValue]) const handleChange = (event: ChangeEvent) => { setHaveValue(event.target.value.length > 0) onChange?.(event) } useEffect(() => { setHaveValue(Boolean(props.value || props.defaultValue)) }, [props.value, props.defaultValue]) return ( {label && ( <>
{label}
{label} )} {children}
) } Input.displayName = 'Input' export const InputLabel = ({ className, ...props }: ComponentProps<'label'>) => { const { haveValue, variant } = useInputContext() return (
) } export function CircularProgress({ value, indeterminate, className, children, ...props }: ComponentProps<'div'> & { indeterminate?: boolean value?: number }) { return (
{indeterminate ? (
{/* left */} {/* right */}
) : (
)} {children && (
{children}
)}
) } export function LinearProgress({ value, indeterminate, className, ...props }: ComponentProps<'div'> & { indeterminate?: boolean value?: number }) { const clampedValue = Math.min(100, Math.max(0, value ?? 0)) return (
{indeterminate ? ( <> {/* Primary indicator - moves from left to right */}
{/* Secondary indicator - follows with different timing */}
) : (
)}
) } ================================================ FILE: frontend/nyanpasu/src/components/ui/ripple.tsx ================================================ import { AnimatePresence, clamp, domAnimation, LazyMotion, motion, } from 'framer-motion' import { Key, MouseEvent, useCallback, useState } from 'react' export type RippleConfig = { key: Key x: number y: number size: number } export interface RippleProps { ripples: RippleConfig[] color?: string onClear: (key: Key) => void } export const Ripple = ({ ripples, color, onClear }: RippleProps) => { return ripples.map((ripple) => { const duration = clamp( ripple.size > 100 ? 0.6 : 0.4, 0.01 * ripple.size, 0.3, ) return ( { onClear(ripple.key) }} /> ) }) } export const useRipple = () => { const [ripples, setRipples] = useState([]) const onClick = useCallback((e: MouseEvent) => { const target = e.currentTarget const size = Math.max(target.clientWidth, target.clientHeight) const rect = target.getBoundingClientRect() setRipples((prev) => [ ...prev, { key: new Date().getTime(), size, x: e.clientX - rect.left - size / 2, y: e.clientY - rect.top - size / 2, }, ]) }, []) const onClear = useCallback((key: Key) => { setRipples((prev) => prev.filter((ripple) => ripple.key !== key)) }, []) return { ripples, onClick, onClear } } ================================================ FILE: frontend/nyanpasu/src/components/ui/scroll-area.tsx ================================================ import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui' import * as React from 'react' import { createContext, useContext, useRef, useState } from 'react' import { cn } from '@nyanpasu/ui' interface ScrollAreaContextValue { isScrolling: boolean isTop: boolean isBottom: boolean scrollDirection: 'up' | 'down' | 'left' | 'right' | 'none' offset: { top: number bottom: number left: number right: number } viewportRef: React.RefObject } const ScrollAreaContext = createContext(null) export function useScrollArea() { const context = useContext(ScrollAreaContext) if (!context) { throw new Error('useScrollArea must be used within a ScrollArea component') } return context } function useScrollTracking(threshold = 50) { const [isScrolling, setIsScrolling] = useState(false) const [isTop, setIsTop] = useState(true) const [isBottom, setIsBottom] = useState(false) const [scrollDirection, setScrollDirection] = useState< 'up' | 'down' | 'left' | 'right' | 'none' >('none') const [offset, setOffset] = useState({ top: 0, left: 0, right: 0, bottom: 0, }) const lastScrollTop = useRef(0) const lastScrollLeft = useRef(0) const timeoutRef = useRef | null>(null) const handleScroll = (e: React.UIEvent) => { const target = e.currentTarget as HTMLElement const { scrollTop, scrollLeft, scrollWidth, clientWidth, scrollHeight, clientHeight, } = target if (timeoutRef.current) { clearTimeout(timeoutRef.current) } setIsScrolling(true) setIsTop(scrollTop === 0) setOffset({ top: scrollTop, left: scrollLeft, right: scrollWidth - clientWidth, bottom: scrollHeight - clientHeight, }) // check if is at bottom, allow a small threshold const isAtBottom = scrollHeight - scrollTop - clientHeight < threshold setIsBottom(isAtBottom) const deltaY = scrollTop - lastScrollTop.current const deltaX = scrollLeft - lastScrollLeft.current // Determine primary scroll direction if (Math.abs(deltaY) > Math.abs(deltaX)) { if (deltaY > 0) { setScrollDirection('down') } else if (deltaY < 0) { setScrollDirection('up') } } else if (Math.abs(deltaX) > Math.abs(deltaY)) { if (deltaX > 0) { setScrollDirection('right') } else if (deltaX < 0) { setScrollDirection('left') } } lastScrollTop.current = scrollTop lastScrollLeft.current = scrollLeft timeoutRef.current = setTimeout(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current) } setIsScrolling(false) }, threshold) } return { isTop, isBottom, scrollDirection, handleScroll, isScrolling, offset, } } export function Viewport({ className, children, ...props }: React.ComponentProps) { return ( {children} ) } export const Corner = ScrollAreaPrimitive.Corner export const Root = ScrollAreaPrimitive.Root export function ScrollArea({ className, children, scrollbars = 'vertical', ...props }: React.ComponentProps & { scrollbars?: 'vertical' | 'horizontal' | 'both' }) { const viewportRef = useRef(null) const { isTop, isBottom, scrollDirection, handleScroll, isScrolling, offset, } = useScrollTracking() return ( {children} {(scrollbars === 'vertical' || scrollbars === 'both') && ( )} {(scrollbars === 'horizontal' || scrollbars === 'both') && ( )} ) } export function ScrollBar({ className, orientation = 'vertical', ...props }: React.ComponentProps) { return ( ) } export function AppContentScrollArea({ className, children, scrollbars = 'vertical', ...props }: React.ComponentProps & { scrollbars?: 'vertical' | 'horizontal' | 'both' }) { const viewportRef = useRef(null) const { isTop, isBottom, scrollDirection, handleScroll, isScrolling, offset, } = useScrollTracking() return ( div]:min-h-[calc(100vh-40px-64px)]', className)} ref={viewportRef} onScroll={handleScroll} > {children} {(scrollbars === 'vertical' || scrollbars === 'both') && ( )} {(scrollbars === 'horizontal' || scrollbars === 'both') && ( )} ) } ================================================ FILE: frontend/nyanpasu/src/components/ui/select.tsx ================================================ import ArrowDropDown from '~icons/material-symbols/arrow-drop-down-rounded' import Check from '~icons/material-symbols/check-rounded' import { cva, type VariantProps } from 'class-variance-authority' import { AnimatePresence, motion } from 'framer-motion' import { Select as SelectPrimitive } from 'radix-ui' import { ComponentProps, createContext, useCallback, useContext, useEffect, useState, } from 'react' import { chains } from '@/utils/chain' import { cn } from '@nyanpasu/ui' import { useControllableState } from '@radix-ui/react-use-controllable-state' export const selectTriggerVariants = cva( [ 'group relative box-border inline-flex w-full flex-auto items-baseline', 'cursor-pointer', 'px-4 py-4 outline-hidden', // TODO: size variants, fix this 'flex items-center justify-between h-14', 'dark:text-on-surface', ], { variants: { variant: { filled: 'rounded-t bg-surface-variant/30 dark:bg-surface', // outlined use selectValuePlaceholderFieldsetVariants outlined: '', }, }, defaultVariants: { variant: 'filled', }, }, ) export type SelectTriggerVariants = VariantProps export const selectLineVariants = cva('', { variants: { variant: { filled: [ 'absolute inset-x-0 bottom-0 w-full border-b border-on-primary-container', 'transition-all duration-200', // pseudo elements be overlay parent element, will not affect the box size 'after:absolute after:inset-x-0 after:bottom-0 after:z-10', "after:scale-x-0 after:border-b-2 after:opacity-0 after:content-['']", 'after:transition-all after:duration-200', 'after:border-primary dark:after:border-on-primary-container', // sync parent group state, state from radix-ui 'group-data-[state=open]:border-b-0', 'group-data-[state=open]:after:scale-x-100', 'group-data-[state=open]:after:opacity-100', 'peer-focus:border-b-0', 'peer-focus:after:scale-x-100', 'peer-focus:after:opacity-100', ], // hidden line for outlined variant outlined: 'hidden', }, }, defaultVariants: { variant: 'filled', }, }) export type SelectLineVariants = VariantProps export const selectValueVariants = cva( 'pointer-events-none transition-[margin] duration-200', { variants: { variant: { filled: '', outlined: '', }, haveValue: { true: '', false: '', }, }, compoundVariants: [ { variant: 'filled', haveValue: true, className: 'mt-3!', }, ], defaultVariants: { variant: 'filled', haveValue: false, }, }, ) export type SelectValueVariants = VariantProps export const selectValuePlaceholderVariants = cva( [ 'absolute', 'left-4 top-4', 'pointer-events-none', 'text-base select-none', // TODO: only transition position, not text color 'transition-all duration-200', ], { variants: { variant: { filled: [ 'group-data-[state=open]:top-2', 'group-data-[state=open]:text-xs group-data-[state=open]:text-primary', ], outlined: [ 'group-data-[state=open]:-top-2', 'group-data-[state=open]:text-sm', 'group-data-[state=open]:text-primary', 'dark:group-data-[state=open]:text-inverse-primary', 'dark:group-data-[state=closed]:text-on-primary-container', ], }, focus: { true: '', false: '', }, }, compoundVariants: [ { variant: 'filled', focus: true, className: 'top-2 text-xs', }, { variant: 'outlined', focus: true, className: '-top-2 text-sm', }, ], defaultVariants: { variant: 'filled', focus: false, }, }, ) export type SelectValuePlaceholderVariants = VariantProps< typeof selectValuePlaceholderVariants > export const selectValuePlaceholderFieldsetVariants = cva( 'pointer-events-none', { variants: { variant: { // only for outlined variant filled: 'hidden', outlined: [ 'absolute inset-0 text-left', 'rounded transition-all duration-200', // may open border width will be 1.5, idk 'group-data-[state=closed]:border', 'group-data-[state=open]:border-2', 'peer-not-focus:border', 'peer-focus:border-2', // different material web border color, i think this looks better 'group-data-[state=closed]:border-outline-variant', 'group-data-[state=open]:border-primary', 'peer-not-focus:border-primary-container', 'peer-focus:border-primary', // dark must be prefixed 'dark:group-data-[state=closed]:border-outline-variant', 'dark:group-data-[state=open]:border-primary-container', 'dark:peer-not-focus:border-outline-variant', 'dark:peer-focus:border-primary-container', ], }, }, defaultVariants: { variant: 'filled', }, }, ) export type SelectValuePlaceholderFieldsetVariants = VariantProps< typeof selectValuePlaceholderFieldsetVariants > export const selectValuePlaceholderLegendVariants = cva('', { variants: { variant: { // only for outlined variant filled: 'hidden', outlined: 'invisible ml-2 px-2 text-sm h-0', }, haveValue: { true: '', false: '', }, }, compoundVariants: [ { variant: 'outlined', haveValue: false, className: 'group-data-[state=closed]:hidden group-not-focus:hidden', }, ], defaultVariants: { variant: 'filled', haveValue: false, }, }) export type SelectValuePlaceholderLegendVariants = VariantProps< typeof selectValuePlaceholderLegendVariants > export const selectContentVariants = cva( [ 'relative w-full overflow-auto rounded shadow-container z-50', 'bg-inverse-on-surface dark:bg-surface', 'dark:text-on-surface', ], { variants: { variant: { filled: 'rounded-t-none', outlined: '', }, }, defaultVariants: { variant: 'filled', }, }, ) export type SelectContentVariants = VariantProps type SelectContextType = { haveValue?: boolean open?: boolean } & SelectTriggerVariants const SelectContext = createContext(null) const useSelectContext = () => { const context = useContext(SelectContext) if (!context) { throw new Error('useSelectContext must be used within a SelectProvider') } return context } export const SelectLine = ({ className, ...props }: ComponentProps<'div'>) => { const { variant } = useSelectContext() return (
) } export const Select = ({ onValueChange, variant, open: inputOpen, defaultOpen, onOpenChange, ...props }: React.ComponentProps & SelectTriggerVariants) => { const [open, setOpen] = useControllableState({ prop: inputOpen, defaultProp: defaultOpen ?? false, onChange: onOpenChange, }) const [haveValue, setHaveValue] = useState( Boolean(props.value || props.defaultValue), ) const handleOnChange = useCallback((value?: string) => { setHaveValue(Boolean(value)) }, []) useEffect(() => { setHaveValue(Boolean(props.value || props.defaultValue)) }, [props.value, props.defaultValue]) return ( ) } export type SelectProps = ComponentProps export const SelectValue = ({ className, placeholder, ...props }: ComponentProps) => { const { haveValue, open, variant } = useSelectContext() return ( <>
{placeholder}
{placeholder}
) } export const SelectGroup = ( props: ComponentProps, ) => { return } export const SelectLabel = ({ className, ...props }: ComponentProps) => { return ( ) } export const SelectTrigger = ({ className, children, ...props }: ComponentProps) => { const { variant } = useSelectContext() return ( {children} ) } export const SelectIcon = ({ asChild, children, className, ...props }: ComponentProps) => { return ( {asChild ? children : } ) } export const SelectContent = ({ className, children, ...props }: ComponentProps) => { const { open, variant } = useSelectContext() return ( {open && ( {children} )} ) } export const SelectItem = ({ className, children, ...props }: ComponentProps) => { return ( {children} ) } ================================================ FILE: frontend/nyanpasu/src/components/ui/separator.tsx ================================================ import { Separator as SeparatorPrimitive } from 'radix-ui' import { ComponentProps } from 'react' import { cn } from '@nyanpasu/ui' export function Separator({ className, orientation = 'horizontal', decorative = true, ...props }: ComponentProps) { return ( ) } ================================================ FILE: frontend/nyanpasu/src/components/ui/sidebar.tsx ================================================ import { ComponentProps, createContext, useContext } from 'react' import useIsMobile from '@/hooks/use-is-moblie' import { cn } from '@nyanpasu/ui' import { AppContentScrollArea } from './scroll-area' const SidebarContext = createContext<{ isHiddenSide: boolean } | null>(null) export const useSidebarContext = () => { const context = useContext(SidebarContext) if (!context) { throw new Error( 'useSidebarContext must be used within a SidebarContext.Provider', ) } return context } export function Sidebar({ className, ...props }: ComponentProps<'div'>) { const isMobile = useIsMobile() return (
) } export function SidebarContent({ className, ...props }: ComponentProps) { const { isHiddenSide } = useSidebarContext() if (isHiddenSide) { return null } return ( ) } ================================================ FILE: frontend/nyanpasu/src/components/ui/slider-sidebar.tsx ================================================ import MenuOpenRounded from '~icons/material-symbols/menu-open-rounded' import { motion } from 'framer-motion' import { merge } from 'lodash-es' import { createContext, use, type ComponentProps, type PropsWithChildren, } from 'react' import { cn } from '@nyanpasu/ui' import { useControllableState } from '@radix-ui/react-use-controllable-state' import { Button } from './button' const DEFAULT_SIDEBAR_WIDTH = { open: 280, closed: 48 + 8 * 2, } const SidebarContext = createContext<{ open: boolean setOpen: (isOpen: boolean) => void } | null>(null) export const useSidebar = () => { const context = use(SidebarContext) if (!context) { throw new Error('useSidebar must be used within a SidebarProvider') } return context } export function SidebarProvider({ open: inputOpen, onOpenChange, defaultOpen, children, }: PropsWithChildren & { open?: boolean onOpenChange?: (isOpen: boolean) => void defaultOpen?: boolean }) { const [open, setOpen] = useControllableState({ prop: inputOpen, defaultProp: defaultOpen ?? false, onChange: onOpenChange, }) return ( {children} ) } export function Sidebar({ className, animate, transition, width = DEFAULT_SIDEBAR_WIDTH, ...props }: ComponentProps & { width?: { open?: number closed?: number } }) { const { open } = useSidebar() return ( <>
) } export function SidebarLabelItem({ className, animate, transition, ...props }: ComponentProps) { const { open } = useSidebar() return ( ) } export const SidebarToggleButton = () => { const { open, setOpen } = useSidebar() return ( ) } ================================================ FILE: frontend/nyanpasu/src/components/ui/slider.tsx ================================================ import { clamp, motion, Transition } from 'framer-motion' import { ComponentProps } from 'react' import { cn } from '@nyanpasu/ui' import { useControllableState } from '@radix-ui/react-use-controllable-state' const EDGE_OFFSET_PX = 16 const PADDING_PX = 8 export function Slider({ className, defaultValue, value, min = 0, max = 100, disabled, step = 1, onValueChange, onValueCommit, onMouseUp, onTouchEnd, onKeyUp, onBlur, ...props }: Omit< ComponentProps<'input'>, 'type' | 'value' | 'defaultValue' | 'min' | 'max' | 'onChange' > & { value?: number defaultValue?: number min?: number max?: number onValueChange?: (value: number) => void onValueCommit?: (value: number) => void }) { const controlledValue = typeof value === 'number' ? clamp(min, max, value) : undefined const defaultSliderValue = clamp(min, max, defaultValue ?? min) const [rawValue, setRawValue] = useControllableState({ prop: controlledValue, defaultProp: defaultSliderValue, onChange: (nextValue) => { onValueChange?.(clamp(min, max, nextValue)) }, }) const currentValue = clamp(min, max, rawValue ?? min) const percentage = max === min ? 0 : ((currentValue - min) / (max - min)) * 100 const ratio = percentage / 100 const thumbOffsetPx = EDGE_OFFSET_PX + PADDING_PX const thumbLeft = `calc(${thumbOffsetPx}px + (100% - ${thumbOffsetPx * 2}px) * ${ratio})` const rangeWidth = `calc(${thumbLeft} - ${PADDING_PX}px)` const trackWidth = `calc(100% - ${thumbLeft} - ${PADDING_PX}px)` const motionTransition: Transition = disabled ? { duration: 0 } : { type: 'spring' as const, stiffness: 380, damping: 35, mass: 0.2 } const handleValueChange: ComponentProps<'input'>['onChange'] = (event) => { const nextValue = clamp(min, max, Number(event.target.value)) setRawValue(nextValue) } const commitValue = () => { onValueCommit?.(currentValue) } return (
{ commitValue() onMouseUp?.(event) }} onTouchEnd={(event) => { commitValue() onTouchEnd?.(event) }} onKeyUp={(event) => { commitValue() onKeyUp?.(event) }} onBlur={(event) => { commitValue() onBlur?.(event) }} className="absolute inset-0 h-full w-full cursor-pointer appearance-none bg-transparent opacity-0 disabled:cursor-not-allowed" {...props} />
) } ================================================ FILE: frontend/nyanpasu/src/components/ui/switch.tsx ================================================ import { Switch as SwitchPrimitives } from 'radix-ui' import React, { ComponentProps } from 'react' import { cn } from '@nyanpasu/ui' import { CircularProgress } from './progress' export const Switch = ({ className, loading, ...props }: React.ComponentProps & { loading?: boolean }) => { return ( {loading && ( )} ) } export function SwitchItem({ children, className, ...props }: ComponentProps) { return (
{children}
) } ================================================ FILE: frontend/nyanpasu/src/components/ui/text-marquee.tsx ================================================ import { motion, useAnimationControls } from 'framer-motion' import { useCallback, useEffect, useRef, useState } from 'react' import { sleep } from '@/utils' import { cn } from '@nyanpasu/ui' export default function TextMarquee({ children, className, speed = 30, gap = 32, pauseDuration = 1, // pauseOnHover = true, }: { children: React.ReactNode className?: string speed?: number gap?: number pauseDuration?: number // pauseOnHover?: boolean }) { const containerRef = useRef(null) const textRef = useRef(null) const [shouldAnimate, setShouldAnimate] = useState(false) const [textWidth, setTextWidth] = useState(0) const controls = useAnimationControls() const isHoveredRef = useRef(false) // Check if text overflows container const checkOverflow = useCallback(() => { if (!containerRef.current || !textRef.current) { return } const container = containerRef.current const text = textRef.current const containerW = container.offsetWidth const textW = text.scrollWidth setTextWidth(textW) setShouldAnimate(textW > containerW) }, []) // Observe container size changes useEffect(() => { checkOverflow() const resizeObserver = new ResizeObserver(() => { checkOverflow() }) if (containerRef.current) { resizeObserver.observe(containerRef.current) } return () => { resizeObserver.disconnect() } }, [checkOverflow, children]) // Animate when shouldAnimate changes useEffect(() => { if (!shouldAnimate) { controls.set({ x: 0 }) return } const totalDistance = textWidth + gap const animDuration = totalDistance / speed const cancelledRef = { current: false } const runAnimationLoop = async () => { // Wait at start position await sleep(pauseDuration * 1000) if (cancelledRef.current) { return } // Check if hovered, wait and retry if (isHoveredRef.current) { await sleep(100) if (!cancelledRef.current) { runAnimationLoop() } return } // Animate to end await controls.start({ x: -totalDistance, transition: { duration: animDuration, ease: 'linear', }, }) if (cancelledRef.current) { return } // Reset to start position instantly and loop controls.set({ x: 0 }) if (!cancelledRef.current) { runAnimationLoop() } } runAnimationLoop() return () => { cancelledRef.current = true controls.stop() } }, [shouldAnimate, textWidth, gap, speed, pauseDuration, controls]) // const handleMouseEnter = () => { // if (!pauseOnHover) { // return // } // isHoveredRef.current = true // controls.stop() // } // const handleMouseLeave = () => { // if (!pauseOnHover || !shouldAnimate) { // return // } // isHoveredRef.current = false // resumeAnimation() // } // const resumeAnimation = () => { // const totalDistance = textWidth + gap // // Resume animation // const marqueeContent = containerRef.current?.querySelector( // '[data-marquee-content]', // ) // if (marqueeContent) { // const transform = window.getComputedStyle(marqueeContent).transform // const matrix = new DOMMatrix(transform) // const currentPosition = matrix.m41 // const remainingDistance = -totalDistance - currentPosition // const remainingDuration = Math.abs(remainingDistance) / speed // controls.start({ // x: -totalDistance, // transition: { // duration: remainingDuration, // ease: 'linear', // }, // }) // } // } return (
{shouldAnimate ? ( {children} {children} ) : (
{children}
)}
) } ================================================ FILE: frontend/nyanpasu/src/components/ui/tooltip.tsx ================================================ import { Tooltip as TooltipPrimitive } from 'radix-ui' import * as React from 'react' import { cn } from '@nyanpasu/ui' export function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps) { return ( ) } export function Tooltip({ ...props }: React.ComponentProps) { return ( ) } export function TooltipTrigger({ ...props }: React.ComponentProps) { return } export function TooltipContent({ className, sideOffset = 0, children, disableArrow = false, ...props }: React.ComponentProps & { disableArrow?: boolean }) { return ( {children} {!disableArrow && ( )} ) } ================================================ FILE: frontend/nyanpasu/src/components/updater/updater-dialog-wrapper.tsx ================================================ import { useAtom } from 'jotai' import { lazy, Suspense, useState } from 'react' import { UpdaterInstanceAtom } from '@/store/updater' const UpdaterDialog = lazy(() => import('./updater-dialog')) export const UpdaterDialogWrapper = () => { const [open, setOpen] = useState(true) const [manifest, setManifest] = useAtom(UpdaterInstanceAtom) if (!manifest) return null return ( { setOpen(false) setManifest(null) }} update={manifest} /> ) } export default UpdaterDialogWrapper ================================================ FILE: frontend/nyanpasu/src/components/updater/updater-dialog.module.scss ================================================ @reference "tailwindcss"; .UpdaterDialog { .MarkdownContent { h1 { @apply pb-2 text-3xl font-bold; } h2 { @apply pb-2 text-2xl font-bold; } h3 { @apply pb-2 text-xl font-bold; } h4 { @apply text-lg font-bold; } h5 { @apply text-base font-bold; } h6 { @apply text-sm font-bold; } p, li { @apply text-base; } a { @apply text-blue-500 underline underline-offset-2; } ul { @apply list-inside list-disc pb-4; } ol { @apply list-inside list-decimal; } } } ================================================ FILE: frontend/nyanpasu/src/components/updater/updater-dialog.module.scss.d.ts ================================================ declare const classNames: { readonly UpdaterDialog: 'UpdaterDialog' readonly MarkdownContent: 'MarkdownContent' } export default classNames ================================================ FILE: frontend/nyanpasu/src/components/updater/updater-dialog.tsx ================================================ import { useLockFn } from 'ahooks' import dayjs from 'dayjs' import { useSetAtom } from 'jotai' import { lazy, Suspense, useCallback, useState, useTransition } from 'react' import { useTranslation } from 'react-i18next' import { IS_NIGHTLY } from '@/consts' import { UpdaterIgnoredAtom } from '@/store/updater' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { Button, LinearProgress } from '@mui/material' import { cleanupProcesses, openThat } from '@nyanpasu/interface' import { BaseDialog, BaseDialogProps, cn } from '@nyanpasu/ui' import { relaunch } from '@tauri-apps/plugin-process' import { DownloadEvent, type Update } from '@tauri-apps/plugin-updater' import styles from './updater-dialog.module.scss' const Markdown = lazy(() => import('react-markdown')) export interface UpdaterDialogProps extends Omit { update: Update } export default function UpdaterDialog({ open, update, onClose, ...others }: UpdaterDialogProps) { const { t } = useTranslation() const setUpdaterIgnore = useSetAtom(UpdaterIgnoredAtom) const [contentLength, setContentLength] = useState(0) const [contentDownloaded, setContentDownloaded] = useState(0) const [pending, startPending] = useTransition() const progress = contentDownloaded && contentLength ? (contentDownloaded / contentLength) * 100 : 0 const date = update.date || (typeof update.rawJson.pub_date === 'string' ? update.rawJson.pub_date : undefined) console.info(date) const onDownloadEvent = useCallback((e: DownloadEvent) => { switch (e.event) { case 'Started': setContentLength(e.data.contentLength || 0) break case 'Progress': setContentDownloaded((prev) => prev + e.data.chunkLength) break } }, []) const handleUpdate = useLockFn(async () => { startPending(async () => { try { // Install the update. This will also restart the app on Windows! await update.download(onDownloadEvent) await cleanupProcesses() // cleanup and stop core await update.install() // On macOS and Linux you will need to restart the app manually. // You could use this step to display another confirmation dialog. await relaunch() } catch (e) { console.error(e) message(formatError(e), { kind: 'error', title: t('Error') }) } }) }) const releasesPageUrl = IS_NIGHTLY ? `https://github.com/libnyanpasu/clash-nyanpasu/releases/tag/pre-release` : `https://github.com/libnyanpasu/clash-nyanpasu/releases/tag/v${update.version}` return ( { setUpdaterIgnore(update.version) // TODO: control this behavior onClose?.() }} onOk={handleUpdate} loading={pending} close={t('updater.close')} ok={t('updater.update')} divider >
{update.version} {date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : 'Invalid date'}
{t('loading')}
}> { e.preventDefault() e.stopPropagation() if (typeof node?.properties.href === 'string') { openThat(node.properties.href) } }} > {children} ) }, }} > {update.body || 'New version available.'}
{pending && (
{progress.toFixed(2)}%
)}
) } ================================================ FILE: frontend/nyanpasu/src/components/window/window-control.tsx ================================================ import CloseRounded from '~icons/material-symbols/close-rounded' import Crop54Outline from '~icons/material-symbols/crop-5-4-outline' import FilterNoneRounded from '~icons/material-symbols/filter-none-outline-rounded' import HorizontalRuleRounded from '~icons/material-symbols/horizontal-rule-rounded' import PushPin from '~icons/material-symbols/push-pin' import PushPinOutline from '~icons/material-symbols/push-pin-outline' import { AnimatePresence, motion } from 'framer-motion' import { ComponentProps, useCallback } from 'react' import { Button, ButtonProps } from '@/components/ui/button' import useWindowMaximized from '@/hooks/use-window-maximized' import { useSetting } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' const appWindow = getCurrentWebviewWindow() const CtrlButton = ({ className, ...props }: ButtonProps) => { return (
) } const Rules = ({ data }: { data: ClashRulesProviderQueryItem }) => { const blockTask = useRulesProviderUpdate(data) const handleClick = useLockFn(blockTask.execute) return (
{data.name}
{dayjs(data.updatedAt).fromNow()}
{data.vehicleType}/{data.type}
{m.providers_rules_rule_count_label({ count: data.ruleCount, })}
) } function RouteComponent() { const proxiesProvider = useClashProxiesProvider() const proxies = proxiesProvider.data ? Object.entries(proxiesProvider.data) : null const proxiesBlockTask = useBlockTask('update-proxies-provider', async () => { if (!proxies) { return } try { await Promise.all(proxies.map(([_, data]) => data.mutate())) } catch (error) { console.error('Failed to update proxies provider', error) message(`Update provider failed: \n ${formatError(error)}`, { title: 'Error', kind: 'error', }) } }) const handleUpdateProxies = useLockFn(proxiesBlockTask.execute) const rulesProvider = useClashRulesProvider() const rules = rulesProvider.data ? Object.entries(rulesProvider.data) : null const rulesBlockTask = useBlockTask('update-rules-provider', async () => { if (!rules) { return } try { await Promise.all(rules.map(([_, data]) => data.mutate())) } catch (error) { console.error('Failed to update rules provider', error) message(`Update provider failed: \n ${formatError(error)}`, { title: 'Error', kind: 'error', }) } }) const handleUpdateRules = useLockFn(rulesBlockTask.execute) return (
{m.providers_proxies_title()} {proxies && proxies.length ? ( {proxies.map(([key, data]) => ( ))} ) : (

{m.providers_no_proxies_message()}

)}
{m.providers_rules_title()} {rules && rules.length ? ( {rules.map(([key, data]) => ( ))} ) : (

{m.providers_no_rules_message()}

)}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/providers/proxies/$key.tsx ================================================ import { useClashProxiesProvider } from '@nyanpasu/interface' import { createFileRoute } from '@tanstack/react-router' import { ProvidersTitle } from '../_modules/providers-title' import { InfoCard } from './_modules/info-card' import { SubscriptionCard } from './_modules/subscription-card' export const Route = createFileRoute('/(main)/main/providers/proxies/$key')({ component: RouteComponent, }) function RouteComponent() { const { key } = Route.useParams() const proxiesProvider = useClashProxiesProvider() const currentProxy = proxiesProvider.data?.[key] if (!currentProxy) { return null } return ( <> {key}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/providers/proxies/_modules/info-card.tsx ================================================ import RefreshRounded from '~icons/material-symbols/refresh-rounded' import dayjs from 'dayjs' import { Button } from '@/components/ui/button' import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { ClashProxiesProviderQueryItem } from '@nyanpasu/interface' import { useProxiesProviderUpdate } from '../../_modules/use-proxies-provider-update' export const InfoCard = ({ data }: { data: ClashProxiesProviderQueryItem }) => { const blockTask = useProxiesProviderUpdate(data) const handleRefreshClick = useLockFn(async () => { await blockTask.execute() }) return ( {m.providers_info_title()}
{m.providers_proxies_proxy_count_label({ count: data.proxies.length, })}
{data.vehicleType}/{data.type}
{m.profile_subscription_updated_at({ updated: dayjs(data.updatedAt).fromNow(), })}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/providers/proxies/_modules/subscription-card.tsx ================================================ import { filesize } from 'filesize' import { Card, CardContent, CardHeader } from '@/components/ui/card' import { LinearProgress } from '@/components/ui/progress' import { m } from '@/paraglide/messages' import { ClashProxiesProviderQueryItem } from '@nyanpasu/interface' import { useProxiesSubscription } from '../../_modules/use-proxies-subscription' export const SubscriptionCard = ({ data, }: { data: ClashProxiesProviderQueryItem }) => { const { progress, total, used, hasSubscriptionInfo } = useProxiesSubscription(data) if (!hasSubscriptionInfo) { return null } return ( {m.providers_subscription_title()}
{progress.toFixed(2)}%
{filesize(used)} / {filesize(total)}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/providers/route.tsx ================================================ import { motion } from 'framer-motion' import { ComponentProps } from 'react' import { AnimatedOutletPreset } from '@/components/router/animated-outlet' import { Button } from '@/components/ui/button' import { AppContentScrollArea } from '@/components/ui/scroll-area' import { Separator } from '@/components/ui/separator' import { Sidebar, SidebarContent } from '@/components/ui/sidebar' import TextMarquee from '@/components/ui/text-marquee' import useIsMobile from '@/hooks/use-is-moblie' import { useClashProxiesProvider, useClashRulesProvider, } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' import { createFileRoute, Link, useLocation } from '@tanstack/react-router' export const Route = createFileRoute('/(main)/main/providers')({ component: RouteComponent, }) const NavigateButton = ({ className, ...props }: ComponentProps) => { return (
{m.profile_subscription_updated_at({ updated: dayjs(data.updatedAt).fromNow(), })}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/proxies/_modules/hooks.ts ================================================ import { useMemo } from 'react' import { ClashProxiesQueryGroupItem, useClashConnections, } from '@nyanpasu/interface' export function useCurrentGroupConnection( currentGroup?: ClashProxiesQueryGroupItem, ) { const { query: { data: clashConnections }, } = useClashConnections() return useMemo(() => { if (!currentGroup?.name) { return } return clashConnections ?.at(-1) ?.connections?.find((connection) => connection.chains.includes(currentGroup?.name), ) }, [clashConnections, currentGroup?.name]) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/proxies/_modules/proxies-navigate.tsx ================================================ import { Button } from '@/components/ui/button' import Image from '@/components/ui/image' import { useClashProxies } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' import { Link, useLocation } from '@tanstack/react-router' export default function ProxiesNavigate() { const { proxies: { data: proxies }, } = useClashProxies() const location = useLocation() return (
{proxies?.groups.map((group) => ( ))}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/proxies/group/$name.tsx ================================================ import ArrowDownwardAltRounded from '~icons/material-symbols/arrow-downward-alt-rounded' import ArrowUpwardAltRounded from '~icons/material-symbols/arrow-upward-alt-rounded' import Radar from '~icons/material-symbols/radar' import { filesize } from 'filesize' import { useCallback } from 'react' import { Button } from '@/components/ui/button' import { useScrollArea } from '@/components/ui/scroll-area' import { useClashProxies } from '@nyanpasu/interface' import { useContainerBreakpointValue } from '@nyanpasu/ui' import { createFileRoute } from '@tanstack/react-router' import { useVirtualizer } from '@tanstack/react-virtual' import { useCurrentGroupConnection } from '../_modules/hooks' import DelayTestButton from './_modules/delay-test-button' import GroupHeader from './_modules/group-header' import ProxyNodeButton from './_modules/proxy-node-button' export const Route = createFileRoute('/(main)/main/proxies/group/$name')({ component: RouteComponent, }) function RouteComponent() { const { name: proxyGroupName } = Route.useParams() const { proxies: { data: proxies }, } = useClashProxies() const currentGroup = proxies?.groups.find( (group) => group.name === proxyGroupName, ) const { viewportRef } = useScrollArea() // define the number of lanes based on the container breakpoint const lanes = useContainerBreakpointValue( viewportRef, { xs: 2, sm: 3, md: 4, lg: 5, xl: 6, }, 4, ) const virtualizer = useVirtualizer({ count: currentGroup?.all?.length || 0, getScrollElement: () => viewportRef.current, estimateSize: () => 60, overscan: 5, lanes, measureElement: (element) => element?.getBoundingClientRect().height, }) const virtualItems = virtualizer.getVirtualItems() const handleScrollToCurrentNode = useCallback(() => { const index = currentGroup?.all?.findIndex( (proxy) => proxy.name === currentGroup?.now, ) // unwarp undefined index if (index !== undefined) { virtualizer.scrollToIndex(index, { align: 'center', behavior: 'smooth', }) } }, [currentGroup?.all, currentGroup?.now, virtualizer]) const currentGroupConnection = useCurrentGroupConnection(currentGroup) return ( <>
{currentGroup?.name}
{filesize(currentGroupConnection?.download ?? 0)}/s
{filesize(currentGroupConnection?.upload ?? 0)}/s
{virtualItems.map((virtualItem) => { const proxy = currentGroup?.all?.[virtualItem.index] if (!proxy) { return null } return (
) })}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/proxies/group/_modules/delay-test-button.tsx ================================================ import BoltRounded from '~icons/material-symbols/bolt-rounded' import { useState } from 'react' import { useBlockTask } from '@/components/providers/block-task-provider' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { sleep } from '@/utils' import { useClashProxies } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' import { Route as NameRoute } from '../$name' export default function DelayTestButton() { const { name } = NameRoute.useParams() const { updateGroupDelay } = useClashProxies() const [isSuccess, setIsSuccess] = useState(false) const blockTask = useBlockTask(`delay-group-test-${name}`, async () => { await updateGroupDelay.mutateAsync([name]) }) const handleClick = useLockFn(async () => { await blockTask.execute() // success effect setIsSuccess(true) await sleep(1000) setIsSuccess(false) }) return (
{blockTask.isPending ? m.proxies_group_delay_test_pending_title() : m.proxies_group_delay_test_title()}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/proxies/group/_modules/group-header.tsx ================================================ import ArrowBackIosNewRounded from '~icons/material-symbols/arrow-back-ios-new-rounded' import { ComponentProps } from 'react' import { Button } from '@/components/ui/button' import { cn } from '@nyanpasu/ui' import { Link } from '@tanstack/react-router' const BackButton = () => { return ( ) } export default function GroupHeader({ children, className, ...props }: ComponentProps<'div'>) { return (
{children}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/proxies/group/_modules/proxy-node-button.tsx ================================================ import FlashOnRounded from '~icons/material-symbols/flash-on-rounded' import { ComponentProps, MouseEvent, useMemo } from 'react' import { useBlockTask } from '@/components/providers/block-task-provider' import { Button } from '@/components/ui/button' import { useLockFn } from '@/hooks/use-lock-fn' import { ClashProxiesQueryProxyItem } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' export default function ProxyNodeButton({ proxy, ...props }: Omit, 'onClick' | 'children'> & { proxy: ClashProxiesQueryProxyItem }) { const handleSelectProxy = useLockFn(async () => { await proxy.mutateSelect() }) const delayTask = useBlockTask( `proxy-delay-check-${proxy.name.toLowerCase()}`, async () => { await proxy.mutateDelay() }, ) const handleDelayClick = useLockFn( async (e: MouseEvent) => { e.preventDefault() e.stopPropagation() await delayTask.execute() }, ) const currentDelay = useMemo(() => { if (!proxy.history || proxy.history.length === 0) { return -1 } else { return proxy.history[proxy.history.length - 1].delay } }, [proxy.history]) return (
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/proxies/index.tsx ================================================ import { AppContentScrollArea } from '@/components/ui/scroll-area' import useIsMobile from '@/hooks/use-is-moblie' import { createFileRoute } from '@tanstack/react-router' import ProxiesNavigate from './_modules/proxies-navigate' export const Route = createFileRoute('/(main)/main/proxies/')({ component: RouteComponent, }) function RouteComponent() { const isMobile = useIsMobile() if (!isMobile) { return null } return ( ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/proxies/route.tsx ================================================ import BoxOutlineRounded from '~icons/material-symbols/box-outline-rounded' import { z } from 'zod' import { AnimatedOutletPreset } from '@/components/router/animated-outlet' import { Button } from '@/components/ui/button' import { AppContentScrollArea } from '@/components/ui/scroll-area' import { Sidebar, SidebarContent } from '@/components/ui/sidebar' import { m } from '@/paraglide/messages' import { useClashProxies } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' import { createFileRoute, Link } from '@tanstack/react-router' import { zodSearchValidator } from '@tanstack/router-zod-adapter' import { ProfileType } from '../profiles/_modules/consts' import ProxiesNavigate from './_modules/proxies-navigate' const searchSchema = z.object({ searchQuery: z.string().optional().nullable(), }) export const Route = createFileRoute('/(main)/main/proxies')({ component: RouteComponent, validateSearch: zodSearchValidator(searchSchema), }) const NoProxies = () => { return (

{m.proxies_group_empty_message()}

) } function RouteComponent() { const { proxies: { data: proxies }, } = useClashProxies() const isNoProxies = !proxies?.groups?.length || proxies?.groups?.length === 0 return ( {!isNoProxies && ( )} {!isNoProxies ? (
) : ( )}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/rules/_modules/proxy-icon.tsx ================================================ import { useMemo } from 'react' import Image from '@/components/ui/image' import { useClashProxies } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' export default function ProxyIcon({ groupName }: { groupName: string }) { const { proxies: { data: proxies }, } = useClashProxies() const icon = useMemo(() => { const proxyInfo = proxies?.groups.find((p) => p.name === groupName) return proxyInfo?.icon }, [groupName, proxies]) return icon ? ( ) : (
{groupName?.toLocaleUpperCase().slice(0, 2)}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/rules/index.tsx ================================================ import { useMemo, useState } from 'react' import HighlightText from '@/components/ui/highlight-text' import { ScrollArea, useScrollArea } from '@/components/ui/scroll-area' import { useClashRules } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' import { createFileRoute } from '@tanstack/react-router' import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable, } from '@tanstack/react-table' import { useVirtualizer } from '@tanstack/react-virtual' import { Route as IndexRoute } from './route' export const Route = createFileRoute('/(main)/main/rules/')({ component: RouteComponent, }) const Viewer = ({ search }: { search: string }) => { const { data } = useClashRules() const { proxy } = IndexRoute.useSearch() const { viewportRef } = useScrollArea() const filteredRules = useMemo(() => { const rules = data?.rules ?? [] const proxyFilteredRules = proxy ? rules.filter((rule) => rule.proxy === proxy) : rules if (!search.trim()) { return proxyFilteredRules } const searchLower = search.toLowerCase() return proxyFilteredRules.filter((rule) => { return ( rule.type?.toLowerCase().includes(searchLower) || rule.payload?.toLowerCase().includes(searchLower) || rule.proxy?.toLowerCase().includes(searchLower) ) }) }, [data?.rules, proxy, search]) const rowVirtualizer = useVirtualizer({ count: filteredRules.length, getScrollElement: () => viewportRef.current, estimateSize: () => 48, overscan: 10, measureElement: (element) => element?.getBoundingClientRect().height, }) const virtualItems = rowVirtualizer.getVirtualItems() const table = useReactTable({ data: filteredRules, columns: [ { accessorKey: 'Index', header: 'Index', cell: (info) => info.row.index + 1, }, { accessorKey: 'type', header: 'Type', cell: (info) => ( {info.row.original.type || ''} ), }, { accessorKey: 'payload', header: 'Payload', cell: (info) => ( {info.row.original.payload || ''} ), }, { accessorKey: 'proxy', header: 'Proxy', cell: (info) => ( {info.row.original.proxy || ''} ), }, ], // state: { // sorting, // }, // onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), debugTable: true, }) const { rows } = table.getRowModel() return (
{virtualItems.map((virtualRow, index) => { const row = rows[virtualRow.index] const offset = virtualRow.start - index * virtualRow.size return ( {row.getVisibleCells().map(({ column, id, getContext }) => ( ))} ) })}
{flexRender(column.columnDef.cell, getContext())}
) } function RouteComponent() { const [search, setSearch] = useState('') return (
setSearch(e.target.value)} />
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/rules/route.tsx ================================================ import ListRounded from '~icons/material-symbols/lists-rounded' import { ComponentProps, PropsWithChildren, ReactNode, useMemo } from 'react' import z from 'zod' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' import { Sidebar, SidebarLabelItem, SidebarProvider, SidebarToggleButton, useSidebar, } from '@/components/ui/slider-sidebar' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' import { useIsMobileOrTablet } from '@/hooks/use-is-moblie' import { m } from '@/paraglide/messages' import { useClashRules } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' import { createFileRoute, Link, Outlet } from '@tanstack/react-router' import ProxyIcon from './_modules/proxy-icon' export const Route = createFileRoute('/(main)/main/rules')({ component: RouteComponent, validateSearch: z.object({ proxy: z.string().optional().nullable(), }), }) const SidebarContent = ({ className, ...props }: ComponentProps<'div'>) => { return
} const Item = ({ item, children, icon, }: PropsWithChildren<{ item?: string icon?: ReactNode }>) => { const { proxy } = Route.useSearch() const { open, setOpen } = useSidebar() const isMobileOrTablet = useIsMobileOrTablet() const handleClick = () => { if (isMobileOrTablet) { setOpen(false) } } return (

{children}

) } const ProxySelector = () => { const { data } = useClashRules() const allProxy = useMemo(() => { const proxies = data?.rules .map((rule) => rule.proxy) .filter((proxy): proxy is string => !!proxy) ?? [] return [...new Set(proxies)] }, [data]) return ( }>{m.rules_list_all_proxies()} {allProxy.map((item) => ( }> {item} ))} ) } function RouteComponent() { return (
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/_modules/settings-card.tsx ================================================ import { motion } from 'framer-motion' import { ComponentProps } from 'react' import { AnimatedItem } from '@/components/ui/animated-item' import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' import { cn } from '@nyanpasu/ui' export function SettingsLabel({ className, ...props }: ComponentProps<'div'>) { return (
) } export function SettingsGroup({ className, ...props }: ComponentProps<'div'>) { return (
*:first-child:not(:only-child)]:rounded-b-sm', '[&>*:last-child:not(:only-child)]:rounded-t-sm', '[&>*:not(:first-child):not(:last-child)]:rounded-sm', className, )} data-slot="settings-group" {...props} /> ) } export function SettingsCard({ className, ...props }: ComponentProps) { return } export function SettingsCardHeader({ className, ...props }: ComponentProps) { return ( ) } export function SettingsCardFooter({ className, ...props }: ComponentProps) { return ( ) } export function SettingsCardContent({ className, ...props }: ComponentProps) { return ( ) } export function ItemContainer({ className, ...props }: ComponentProps<'div'>) { return (
) } export function ItemLabel({ className, ...props }: ComponentProps<'div'>) { return (
) } export function ItemLabelText({ className, ...props }: ComponentProps) { return (

) } export function ItemLabelDescription({ className, ...props }: ComponentProps<'p'>) { return (

) } export const SettingsCardAnimatedItem = AnimatedItem ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/_modules/settings-navigate.tsx ================================================ import DisplayExternalInput from '~icons/material-symbols/display-external-input-rounded' import FrameBugOutlineRounded from '~icons/material-symbols/frame-bug-outline-rounded' import SettingsBoltRounded from '~icons/material-symbols/settings-b-roll-rounded' import SettingsEthernet from '~icons/material-symbols/settings-ethernet-rounded' import SettingsRounded from '~icons/material-symbols/settings-rounded' import ViewQuilt from '~icons/material-symbols/view-quilt-rounded' import { ComponentProps, ReactNode } from 'react' import LogoSvg from '@/assets/image/logo.svg?react' import { Button } from '@/components/ui/button' import TextMarquee from '@/components/ui/text-marquee' import useCurrentCoreIcon from '@/hooks/use-current-core-icon' import { m } from '@/paraglide/messages' import { cn } from '@nyanpasu/ui' import { Link, useLocation } from '@tanstack/react-router' const NyanpasuLogo = () => { return ( ) } const CurrentCoreIcon = ({ className, ...props }: Omit, 'src'>) => { const currentCoreIconUrl = useCurrentCoreIcon() return ( ) } const NavigateButton = ({ icon, label, description, className, ...props }: ComponentProps & { icon: ReactNode label: string description: string }) => { const location = useLocation() const isActive = location.pathname === props.to return ( ) } const SystemButton = () => { return ( } label={m.settings_label_system()} description={m.settings_label_system_description()} to="/main/settings/system" /> ) } const UserInterfaceButton = () => { return ( } label={m.settings_label_user_interface()} description={m.settings_label_user_interface_description()} to="/main/settings/user-interface" /> ) } const ClashButton = () => { return ( } label={m.settings_label_clash_settings()} description={m.settings_label_clash_settings_description()} to="/main/settings/clash" /> ) } const ExternalControllButton = () => { return (

} label={m.settings_label_external_controll()} description={m.settings_label_external_controll_description()} to="/main/settings/web-ui" /> ) } const NyanpasuButton = () => { return (
} label={m.settings_label_nyanpasu()} description={m.settings_label_nyanpasu_description()} to="/main/settings/nyanpasu" /> ) } const DebugButton = () => { return ( } label={m.settings_label_debug()} description={m.settings_label_debug_description()} to="/main/settings/debug" /> ) } const AboutButton = () => { return ( } label={m.settings_label_about()} description={m.settings_label_about_description()} to="/main/settings/about" /> ) } export default function SettingsNavigate() { return (
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/_modules/settings-title.tsx ================================================ import ArrowBackIosNewRounded from '~icons/material-symbols/arrow-back-ios-new-rounded' import { AnimatePresence, motion } from 'framer-motion' import { ComponentProps, useId } from 'react' import { Button } from '@/components/ui/button' import { useScrollArea } from '@/components/ui/scroll-area' import { cn } from '@nyanpasu/ui' import { Link } from '@tanstack/react-router' const BackButton = () => { return ( ) } const Title = (props: ComponentProps) => { return ( ) } export function SettingsTitle({ className, children, ...props }: ComponentProps<'div'>) { const { offset } = useScrollArea() const id = useId() const showTopTitle = offset.top > 40 return ( <>
{showTopTitle && ( {children} )}
{!showTopTitle && ( {children} )}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/about/_modules/nyanpasu-version.tsx ================================================ import { PropsWithChildren, useEffect, useState } from 'react' import Markdown from 'react-markdown' import AnimatedLogo from '@/components/logo/animated-logo' import { useNyanpasuUpdate } from '@/components/providers/nyanpasu-update-provider' import { Button } from '@/components/ui/button' import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' import { Modal, ModalClose, ModalContent, ModalTitle, ModalTrigger, } from '@/components/ui/modal' import { LinearProgress } from '@/components/ui/progress' import { ScrollArea } from '@/components/ui/scroll-area' import { SwitchItem } from '@/components/ui/switch' import { useLockFn } from '@/hooks/use-lock-fn' import { Action as AboutAction, Route as AboutRoute, } from '@/pages/(main)/main/settings/about/route' import { m } from '@/paraglide/messages' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { commands, useSetting } from '@nyanpasu/interface' import { relaunch } from '@tauri-apps/plugin-process' import { SettingsCard, SettingsCardContent, SettingsCardFooter, } from '../../_modules/settings-card' const TITLE = 'Clash Nyanpasu~(∠・ω< )⌒☆' const GITHUB_RELEASES_URL = 'https://github.com/libnyanpasu/clash-nyanpasu/releases' const AutoCheckUpdate = () => { const { value, upsert, isPending } = useSetting('enable_auto_check_update') return ( upsert(checked)} loading={isPending} >

{m.settings_label_about_auto_check_updates()}

) } const NewVersionModal = ({ children }: PropsWithChildren) => { const { action } = AboutRoute.useSearch() const { newVersion } = useNyanpasuUpdate() const [isInstalling, setIsInstalling] = useState(false) const [contentLength, setContentLength] = useState(0) const [contentDownloaded, setContentDownloaded] = useState(0) const progress = contentDownloaded && contentLength ? (contentDownloaded / contentLength) * 100 : 0 const [open, setOpen] = useState(false) useEffect(() => { // for animation duration to open the modal if (action === AboutAction.NEED_UPDATE) { setOpen(true) } }, [action]) const handleOpenChange = (open: boolean) => { if (isInstalling) { return } setOpen(open) } // const newVersionReleasesPageUrl = IS_NIGHTLY // ? `https://github.com/libnyanpasu/clash-nyanpasu/releases/tag/pre-release` // : `https://github.com/libnyanpasu/clash-nyanpasu/releases/tag/v${newVersion?.version}` const handleUpdate = useLockFn(async () => { if (!newVersion) { return } try { setIsInstalling(true) // Install the update. This will also restart the app on Windows! await newVersion.download((e) => { switch (e.event) { case 'Started': setContentLength(e.data.contentLength || 0) break case 'Progress': setContentDownloaded((prev) => prev + e.data.chunkLength) break } }) await commands.cleanupProcesses() // cleanup and stop core await newVersion.install() // On macOS and Linux you will need to restart the app manually. // You could use this step to display another confirmation dialog. await relaunch() } catch (e) { console.error(e) message(formatError(e), { kind: 'error', title: 'Error', }) } finally { setIsInstalling(false) } }) return ( {children} {m.settings_label_about_update_has_new_version()} {isInstalling ? (
{m.settings_label_about_update_installing()} {progress.toFixed(2)}%
) : ( { e.preventDefault() e.stopPropagation() if (typeof node?.properties.href === 'string') { commands.openThat(node.properties.href) } }} > {children} ) }, }} > {newVersion?.body || 'New version available.'} )}
{!isInstalling && {m.common_close()}}
) } export default function NyanpasuVersion() { const { currentVersion, hasNewVersion, isChecking, checkNewVersion, isSupported, } = useNyanpasuUpdate() const handleUpdateToGithubReleases = useLockFn( async () => await commands.openThat(GITHUB_RELEASES_URL), ) const handleCheckNewVersion = useLockFn(async () => { const update = await checkNewVersion() if (update) { message(m.settings_label_about_update_has_new_version(), { kind: 'info', title: m.settings_label_about_update(), }) } else { message(m.settings_label_about_update_no_update(), { kind: 'info', title: m.settings_label_about_update(), }) } }) return (
{TITLE}
{m.settings_label_about_version({ version: currentVersion, })}
{isSupported ? ( {hasNewVersion ? ( ) : ( )} ) : ( )}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/about/route.tsx ================================================ import z from 'zod' import { m } from '@/paraglide/messages' import { createFileRoute } from '@tanstack/react-router' import { SettingsTitle } from '../_modules/settings-title' import NyanpasuVersion from './_modules/nyanpasu-version' export enum Action { NEED_UPDATE = 'need-update', } export const Route = createFileRoute('/(main)/main/settings/about')({ component: RouteComponent, validateSearch: z.object({ action: z.enum(Action).optional().nullable(), }), }) function RouteComponent() { return ( <> {m.settings_label_about()}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/clash/_modules/allow-lan-switch.tsx ================================================ import { useMemo } from 'react' import { Switch } from '@/components/ui/switch' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { useClashConfig } from '@nyanpasu/interface' import { ItemContainer, ItemLabel, ItemLabelText, } from '../../_modules/settings-card' export default function AllowLanSwitch() { const { query, upsert } = useClashConfig() const value = useMemo(() => query.data?.['allow-lan'], [query.data]) const handleAllowLan = useLockFn(async (input: boolean) => { try { await upsert.mutateAsync({ 'allow-lan': input, }) } catch (error) { message(`Activation Allow LAN failed!\n Error: ${formatError(error)}`, { title: 'Error', kind: 'error', }) } }) return ( {m.settings_clash_settings_allow_lan_label()} ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/clash/_modules/core-manager-card.tsx ================================================ import ArrowRightAltRounded from '~icons/material-symbols/arrow-right-alt-rounded' import DeployedCodeUpdateOutlineRounded from '~icons/material-symbols/deployed-code-update-outline-rounded' import RestartAltRounded from '~icons/material-symbols/restart-alt-rounded' import { filesize } from 'filesize' import { AnimatePresence, motion } from 'framer-motion' import { isObject } from 'lodash-es' import { useMemo, useState } from 'react' import { useBlockTask } from '@/components/providers/block-task-provider' import { Button } from '@/components/ui/button' import { CircularProgress } from '@/components/ui/progress' import TextMarquee from '@/components/ui/text-marquee' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' import useCoreIcon from '@/hooks/use-core-icon' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { ClashCore, ClashCoresDetail, InspectUpdater, inspectUpdater, useClashConnections, useClashCores, useSetting, } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' import { SettingsCard, SettingsCardContent, SettingsCardFooter, SettingsCardHeader, } from '../../_modules/settings-card' function useCoreUpdateTask( core?: ClashCore | null, item?: ClashCoresDetail | null, ) { const { query, updateCore } = useClashCores() const [updater, setUpdater] = useState() const task = useBlockTask(`core-manager-update-${core}`, async () => { try { const updaterId = await updateCore.mutateAsync(core!) if (!updaterId) { throw new Error('Failed to update') } await new Promise((resolve, reject) => { const interval = setInterval(async () => { const result = await inspectUpdater(updaterId) setUpdater(result) if ( isObject(result.downloader.state) && Object.prototype.hasOwnProperty.call( result.downloader.state, 'failed', ) ) { reject(result.downloader.state.failed) clearInterval(interval) } if (result.state === 'done') { resolve() clearInterval(interval) } }, 100) }) await query.refetch() message( `Successfully updated the core ${item?.name} to ${item?.latestVersion}`, { kind: 'info', title: 'Successful', }, ) } catch (e) { console.error(e) message(formatError(e), { kind: 'error', title: 'Error', }) } }) const progress = useMemo(() => { if (!updater || !task.isPending) { return 0 } const { downloaded, total } = updater.downloader if (total <= 0) { return 0 } return Math.min((downloaded / total) * 100, 100) }, [updater, task.isPending]) const stateLabel = useMemo(() => { if (!updater || !task.isPending) { return null } const state = updater.state if (state === 'downloading') { const { downloaded, total, speed } = updater.downloader return `${filesize(downloaded)} / ${filesize(total)} · ${filesize(speed)}/s` } if (state === 'decompressing') { return m.settings_clash_core_manager_card_decompressing() } if (state === 'replacing') { return m.settings_clash_core_manager_card_replacing() } if (state === 'restarting') { return m.settings_clash_core_manager_card_restarting() } if (state === 'done') { return m.settings_clash_core_manager_card_done() } return null }, [updater, task.isPending]) return { task, progress, stateLabel } } const UpdateProgressBar = ({ isPending, progress, }: { isPending: boolean progress: number }) => { if (!isPending) { return null } return ( ) } const CoreItem = ({ core, item, onClick, }: { core: ClashCore item: ClashCoresDetail onClick: (core: ClashCore) => void }) => { const { value: currentCore } = useSetting('clash_core') const icon = useCoreIcon(core) const isSelected = core === currentCore const haveNewVersion = item.latestVersion ? item.latestVersion !== item.currentVersion : false const { task: updateCoreTask, progress, stateLabel: updaterStateLabel, } = useCoreUpdateTask(core, item) return ( {m.settings_clash_core_manager_card_click_to_update()}
)} ) } export default function CoreManagerCard() { const { query: clashCores, upsert: switchCore, restartSidecar, fetchRemote, } = useClashCores() const { deleteConnections } = useClashConnections() const { value: currentCoreKey } = useSetting('clash_core') const currentCoreIcon = useCoreIcon(currentCoreKey) const currentCore = currentCoreKey && clashCores.data?.[currentCoreKey] const switchCoreTask = useBlockTask( 'core-manager-switch', async (core: ClashCore) => { try { await deleteConnections.mutateAsync(null) await switchCore.mutateAsync(core) message(m.settings_clash_core_manager_card_loading_success(), { kind: 'info', title: 'Successful', }) } catch (e) { console.error(e) message( `${m.settings_clash_core_manager_card_loading_error()} \n${formatError(e)}`, { kind: 'error', title: 'Error', }, ) } }, ) const restartSidecarTask = useBlockTask( 'core-manager-restart-sidecar', async () => { try { await restartSidecar() message(m.settings_clash_core_manager_card_restart_sidecar_success(), { kind: 'info', title: 'Successful', }) } catch (e) { console.error(e) message( `${m.settings_clash_core_manager_card_restart_sidecar_error()} \n${formatError(e)}`, { kind: 'error', title: 'Error', }, ) } }, ) const handleFetchRemote = useLockFn(async () => { try { await fetchRemote.mutateAsync() } catch (e) { console.error(e) message(formatError(e), { kind: 'error', title: 'Error', }) } }) const isLoading = clashCores.isPending || switchCoreTask.isPending || restartSidecarTask.isPending const loadingMessage = m.settings_clash_core_manager_card_loading() const haveNewVersion = currentCore?.latestVersion ? currentCore.latestVersion !== currentCore.currentVersion : false const currentCoreUpdate = useCoreUpdateTask(currentCoreKey, currentCore) return ( {isLoading && (

{loadingMessage}

)}
{currentCore?.name}

{currentCore?.name}

{currentCoreUpdate.task.isPending && currentCoreUpdate.stateLabel ? ( {currentCoreUpdate.stateLabel} ) : haveNewVersion ? ( <> {currentCore?.currentVersion} {currentCore?.latestVersion} ) : ( currentCore?.currentVersion )}

{haveNewVersion && ( {m.settings_clash_core_manager_card_click_to_update()} )} {m.settings_clash_core_manager_card_restart_sidecar()}
{Object.entries(clashCores.data ?? {}).map(([core, item]) => { if (core === currentCoreKey) { return null } return ( switchCoreTask.execute(core as ClashCore)} /> ) })}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/clash/_modules/field-filter-card.tsx ================================================ import ArrowForwardIosRounded from '~icons/material-symbols/arrow-forward-ios-rounded' import OpenInNewRounded from '~icons/material-symbols/open-in-new-rounded' import { PropsWithChildren, useMemo } from 'react' import CLASH_FIELD from '@/assets/json/clash-field.json' import { useBlockTask } from '@/components/providers/block-task-provider' import { Button } from '@/components/ui/button' import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' import { Modal, ModalClose, ModalContent, ModalTitle, ModalTrigger, } from '@/components/ui/modal' import { ScrollArea } from '@/components/ui/scroll-area' import TextMarquee from '@/components/ui/text-marquee' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { commands, useProfile } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' type Item = { url?: string enabled: boolean } const OpenLinkButton = ({ data }: { data: Item }) => { const handleOpen = useLockFn(async () => { if (!data.url) { return } await commands.openThat(data.url) }) return ( ) } const FieldButton = ({ data, disabled, label, }: { data: Item disabled: boolean label: string }) => { const { query, upsert } = useProfile() const blockTask = useBlockTask(`update-clash-field-${label}`, async () => { let valid = query.data?.valid ?? [] if (data.enabled) { valid = valid.filter((item) => item !== label) } else { valid.push(label) } await upsert.mutateAsync({ valid }) }) return ( ) } const ItemButton = ({ items, children, }: PropsWithChildren<{ items: Record }>) => { // Nyanpasu Control Fields object key const isNyanpasuControlField = ['default', 'handle'].includes( children as string, ) const enableFields = Object.keys(items).filter((key) => items[key].enabled) return ( {children} {isNyanpasuControlField && (
{m.settings_clash_settings_field_filter_nyanpasu_control_fields()}
)}
{Object.entries(items).map(([item, data]) => { return ( ) })}
{m.common_close()}
) } export default function FieldFilterCard() { const { query } = useProfile() const mergeFields = useMemo( () => [ ...Object.keys(CLASH_FIELD.default), ...Object.keys(CLASH_FIELD.handle), ...(query.data?.valid ?? []), ], [query.data], ) const filteredField = (fields: Record) => { const usedObjects: Record = {} for (const key in fields) { if (Object.prototype.hasOwnProperty.call(fields, key)) { usedObjects[key] = { url: fields[key], enabled: mergeFields.includes(key), } } } return usedObjects } return (
{Object.entries(CLASH_FIELD).map(([key, value], index) => { const filtered = filteredField(value) return ( {key} ) })}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/clash/_modules/field-filter-switch.tsx ================================================ import { Switch } from '@/components/ui/switch' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { useSetting } from '@nyanpasu/interface' import { ItemContainer, ItemLabel, ItemLabelText, } from '../../_modules/settings-card' export default function FieldFilterButton() { const { value, upsert } = useSetting('enable_clash_fields') const handleFieldFilter = useLockFn(async (input: boolean) => { try { await upsert(input) } catch (error) { message( `Activation Field Filter failed!\n Error: ${formatError(error)}`, { title: 'Error', kind: 'error', }, ) } }) return ( {m.settings_clash_settings_field_filter_label()} ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/clash/_modules/ipv6-switch.tsx ================================================ import { useMemo } from 'react' import { Switch } from '@/components/ui/switch' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { useClashConfig } from '@nyanpasu/interface' import { ItemContainer, ItemLabel, ItemLabelText, } from '../../_modules/settings-card' export default function IPv6Switch() { const { query, upsert } = useClashConfig() const value = useMemo(() => query.data?.['ipv6'], [query.data]) const handleIPv6 = useLockFn(async (input: boolean) => { try { await upsert.mutateAsync({ ipv6: input, }) } catch (error) { message(`Activation IPv6 failed!\n Error: ${formatError(error)}`, { title: 'Error', kind: 'error', }) } }) return ( {m.settings_clash_settings_ipv6_label()} ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/clash/_modules/log-level-selector.tsx ================================================ import { useCallback, useMemo } from 'react' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { m } from '@/paraglide/messages' import { useClashConfig } from '@nyanpasu/interface' const LOG_LEVEL_OPTIONS = { debug: 'Debug', info: 'Info', warning: 'Warn', error: 'Error', silent: 'Silent', } as const export default function LogLevelSelector() { const { query, upsert } = useClashConfig() const value = useMemo( () => query.data?.['log-level'] as keyof typeof LOG_LEVEL_OPTIONS, [query.data], ) const handleLogLevelChange = useCallback( async (value: string) => { await upsert.mutateAsync({ 'log-level': value as string, }) }, [upsert], ) return ( ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/clash/_modules/mixed-port-config.tsx ================================================ import { AnimatePresence } from 'framer-motion' import { useCallback, useEffect, useMemo } from 'react' import { Controller, useForm } from 'react-hook-form' import { z } from 'zod' import { Button } from '@/components/ui/button' import { NumericInput } from '@/components/ui/input' import { m } from '@/paraglide/messages' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { zodResolver } from '@hookform/resolvers/zod' import { useClashConfig, useSetting } from '@nyanpasu/interface' import { SettingsCardAnimatedItem } from '../../_modules/settings-card' const DEFAULT_MIXED_PORT = 7890 const formSchema = z.object({ mixedPort: z.number().min(1).max(65535), }) export default function MixedPortConfig() { const mixedPort = useSetting('verge_mixed_port') const clashConfig = useClashConfig() const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { mixedPort: DEFAULT_MIXED_PORT, }, }) // get current mixed port from clash config or verge setting const currentMixedPort = useMemo(() => { return ( clashConfig.query.data?.['mixed-port'] || mixedPort.value || DEFAULT_MIXED_PORT ) }, [clashConfig.query.data, mixedPort.value]) // sync current mixed port to form useEffect(() => { form.setValue('mixedPort', currentMixedPort) }, [currentMixedPort, form]) const handleSubmit = form.handleSubmit(async (data) => { try { await clashConfig.upsert.mutateAsync({ 'mixed-port': data.mixedPort, }) await mixedPort.upsert(data.mixedPort) form.reset({ mixedPort: data.mixedPort, }) } catch (error) { message(formatError(error), { title: 'Error', kind: 'error', }) } }) const handleReset = useCallback(() => { form.reset({ mixedPort: currentMixedPort, }) }, [form, currentMixedPort]) return (
{ const handleChange = (value: number | null) => { field.onChange(value) } return ( <> {fieldState.error && ( {fieldState.error.message} )} ) }} /> {form.formState.isDirty && (
)}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/clash/_modules/random-port-switch.tsx ================================================ import { Switch } from '@/components/ui/switch' import { m } from '@/paraglide/messages' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { useSetting } from '@nyanpasu/interface' import { ItemContainer, ItemLabel, ItemLabelText, } from '../../_modules/settings-card' export default function RandomPortSwitch() { const enableRandomPort = useSetting('enable_random_port') const handleRandomPort = async () => { try { await enableRandomPort.upsert(!enableRandomPort.value) } catch (e) { message(formatError(e), { title: 'Error', kind: 'error', }) } finally { message( enableRandomPort.value ? m.settings_clash_settings_random_port_disabled() : m.settings_clash_settings_random_port_enabled(), { title: 'Successful', kind: 'info', }, ) } } return ( {m.settings_clash_settings_random_port_label()} ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/clash/_modules/tun-stack-selector.tsx ================================================ import { useCallback, useMemo } from 'react' import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { useCoreType } from '@/hooks/use-store' import { m } from '@/paraglide/messages' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { TunStack, useRuntimeProfile, useSetting } from '@nyanpasu/interface' export default function TunStackSelector() { const [coreType] = useCoreType() const tunStack = useSetting('tun_stack') const enableTunMode = useSetting('enable_tun_mode') const runtimeProfile = useRuntimeProfile() const tunStackOptions = useMemo(() => { const options: { [key: string]: string } = { system: 'System', gvisor: 'gVisor', mixed: 'Mixed', } // clash not support mixed if (coreType === 'clash') { delete options.mixed } return options }, [coreType]) const currentTunStack = useMemo(() => { const stack = tunStack.value || 'gvisor' return stack in tunStackOptions ? stack : 'gvisor' }, [tunStackOptions, tunStack.value]) const handleTunStackChange = useCallback( async (value: string) => { try { await tunStack.upsert(value as TunStack) if (enableTunMode.value) { // just to reload clash config await enableTunMode.upsert(true) } // need manual mutate to refetch runtime profile await runtimeProfile.refetch() } catch (error) { message(`Change Tun Stack failed ! \n Error: ${formatError(error)}`, { title: 'Error', kind: 'error', }) } }, [tunStack, enableTunMode, runtimeProfile], ) return ( ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/clash/route.tsx ================================================ import { m } from '@/paraglide/messages' import { createFileRoute } from '@tanstack/react-router' import { SettingsCard, SettingsCardContent, SettingsGroup, SettingsLabel, } from '../_modules/settings-card' import { SettingsTitle } from '../_modules/settings-title' import AllowLanSwitch from './_modules/allow-lan-switch' import CoreManagerCard from './_modules/core-manager-card' import FieldFilterCard from './_modules/field-filter-card' import FieldFilterSwitch from './_modules/field-filter-switch' import IPv6Switch from './_modules/ipv6-switch' import LogLevelSelector from './_modules/log-level-selector' import MixedPortConfig from './_modules/mixed-port-config' import RandomPortSwitch from './_modules/random-port-switch' import TunStackSelector from './_modules/tun-stack-selector' export const Route = createFileRoute('/(main)/main/settings/clash')({ component: RouteComponent, }) const PatchSettings = () => { return (
{m.settings_clash_settings_title()}
) } const PortSettings = () => { return (
{m.settings_clash_settings_port_label()}
) } const CoreManagerSettings = () => { return (
{m.settings_clash_core_manager_card_title()}
) } const FieldFilterSettings = () => { return (
{m.settings_clash_settings_field_filter_label()}
) } function RouteComponent() { return ( <> {m.settings_clash_settings_title()}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/debug/_modules/advance-tools-switch.tsx ================================================ import { Switch } from '@/components/ui/switch' import { ItemContainer, ItemLabel, ItemLabelText, } from '../../_modules/settings-card' import { useDebugContext } from './debug-provider' export default function AdvanceToolsSwitch() { const { advanceTools, setAdvanceTools } = useDebugContext() return ( Advance Tools ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/debug/_modules/block-task-viewer.tsx ================================================ import { useBlockTaskContext } from '@/components/providers/block-task-provider' import { Button } from '@/components/ui/button' import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' import { Modal, ModalClose, ModalContent, ModalTitle, ModalTrigger, } from '@/components/ui/modal' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { SettingsCard, SettingsCardAnimatedItem, SettingsCardContent, SettingsCardFooter, SettingsCardHeader, } from '../../_modules/settings-card' export default function BlockTaskViewer() { const { tasks, clearTask } = useBlockTaskContext() const handleClearAllTask = useLockFn(async () => { Object.keys(tasks).forEach((key) => { clearTask(key) }) }) return ( Block Task Viewer {Object.entries(tasks).map(([key, task]) => (
Label: {key}
Status: {task.status}
Task Detail
                        {JSON.stringify(task, null, 2)}
                      
{m.common_close()}
))}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/debug/_modules/debug-provider.tsx ================================================ import { createContext, PropsWithChildren, use, useState } from 'react' const isDev = import.meta.env.DEV const DebugContext = createContext<{ advanceTools: boolean setAdvanceTools: (value: boolean) => void } | null>(null) export const useDebugContext = () => { const context = use(DebugContext) if (!context) { throw new Error('useDebugContext must be used within a DebugProvider') } return context } export default function DebugProvider({ children }: PropsWithChildren) { const [advanceTools, setAdvanceTools] = useState(isDev) return ( {children} ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/debug/_modules/kv-storage.tsx ================================================ import { Button } from '@/components/ui/button' import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' import { Modal, ModalClose, ModalContent, ModalTitle, ModalTrigger, } from '@/components/ui/modal' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { commands, unwrapResult } from '@nyanpasu/interface' import { useQuery } from '@tanstack/react-query' import { SettingsCard, SettingsCardAnimatedItem, SettingsCardContent, SettingsCardFooter, SettingsCardHeader, } from '../../_modules/settings-card' export default function KVStorage() { const query = useQuery({ queryKey: ['kv-storage'], queryFn: async () => { const result = await commands.getAllStorageItems() return unwrapResult(result) }, }) const handleClearAllTask = useLockFn(async () => { await commands.clearStorage() await query.refetch() }) return ( KV Storage
Total Items: {query.isLoading ? 'Loading...' : query.data?.length}
{query.data && query.data.map((storage) => (
Key: {storage.key}
Storage Detail
                          {JSON.stringify(storage, null, 2)}
                        
{m.common_close()}
))}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/debug/_modules/path-utils-card.tsx ================================================ import { Button, ButtonProps } from '@/components/ui/button' import TextMarquee from '@/components/ui/text-marquee' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { commands } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' const PathButton = ({ className, children, ...props }: Omit) => { return ( ) } export default function PathUtilsCard() { const handleOpenConfigDirectory = useLockFn(async () => { await commands.openAppConfigDir() }) const handleOpenDataDirectory = useLockFn(async () => { await commands.openAppDataDir() }) const handleOpenCoreDirectory = useLockFn(async () => { await commands.openCoreDir() }) const handleOpenLogDirectory = useLockFn(async () => { await commands.openLogsDir() }) return (
{m.settings_debug_utils_open_config_directory()} {m.settings_debug_utils_open_data_directory()} {m.settings_debug_utils_open_core_directory()} {m.settings_debug_utils_open_log_directory()}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/debug/_modules/window-debug.tsx ================================================ import { Button } from '@/components/ui/button' import { useLockFn } from '@/hooks/use-lock-fn' import { commands } from '@nyanpasu/interface' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import { SettingsCard, SettingsCardAnimatedItem, SettingsCardContent, SettingsCardHeader, } from '../../_modules/settings-card' const currentWindow = getCurrentWebviewWindow() export default function WindowDebug() { const handleCreateLegacyWindow = useLockFn(async () => { await commands.createLegacyWindow() }) const handleCreateEditorWindow = useLockFn(async () => { await commands.createEditorWindow('test') }) return ( Window Debug Utils
Current Window Label: {currentWindow.label}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/debug/index.tsx ================================================ import { AnimatePresence } from 'framer-motion' import { m } from '@/paraglide/messages' import { createFileRoute } from '@tanstack/react-router' import { SettingsCard, SettingsCardContent, SettingsGroup, SettingsLabel, } from '../_modules/settings-card' import { SettingsTitle } from '../_modules/settings-title' import AdvanceToolsSwitch from './_modules/advance-tools-switch' import BlockTaskViewer from './_modules/block-task-viewer' import { useDebugContext } from './_modules/debug-provider' import KVStorage from './_modules/kv-storage' import PathUtilsCard from './_modules/path-utils-card' import WindowDebug from './_modules/window-debug' export const Route = createFileRoute('/(main)/main/settings/debug/')({ component: RouteComponent, }) const PathUtilsSettings = () => { return (
{m.settings_label_debug()}
) } const AdvanceToolsSettings = () => { const { advanceTools } = useDebugContext() return (
Advance Tools {advanceTools && ( <> )}
) } function RouteComponent() { return ( <> {m.settings_label_debug()}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/debug/route.tsx ================================================ import { createFileRoute, Outlet } from '@tanstack/react-router' import DebugProvider from './_modules/debug-provider' export const Route = createFileRoute('/(main)/main/settings/debug')({ component: RouteComponent, }) function RouteComponent() { return ( ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/index.tsx ================================================ import { AppContentScrollArea } from '@/components/ui/scroll-area' import useIsMobile from '@/hooks/use-is-moblie' import { cn } from '@nyanpasu/ui' import { createFileRoute } from '@tanstack/react-router' import SettingsNavigate from './_modules/settings-navigate' export const Route = createFileRoute('/(main)/main/settings/')({ component: RouteComponent, }) function RouteComponent() { const isMobile = useIsMobile() if (!isMobile) { return null } return ( div>div]:block!')} data-slot="settings-sidebar-scroll-area" > ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/nyanpasu/_modules/log-file-config.tsx ================================================ import { useEffect, useState } from 'react' import { Slider } from '@/components/ui/slider' import { m } from '@/paraglide/messages' import { useSetting } from '@nyanpasu/interface' import { SettingsCard, SettingsCardContent } from '../../_modules/settings-card' const MAX_LOG_FILES = 7 export default function LogFileConfig() { const { value, upsert } = useSetting('max_log_files') const committedValue = value ?? 1 const [cachedValue, setCachedValue] = useState(committedValue) // sync the cached value with the committed value useEffect(() => { setCachedValue(committedValue) }, [committedValue]) return (
{m.settings_nyanpasu_max_log_files_label()} {cachedValue}
{ setCachedValue(value) }} onValueCommit={(value) => { if (value !== committedValue) { upsert(value) } }} />
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/nyanpasu/route.tsx ================================================ import { m } from '@/paraglide/messages' import { createFileRoute } from '@tanstack/react-router' import { SettingsLabel } from '../_modules/settings-card' import { SettingsTitle } from '../_modules/settings-title' import LogFileConfig from './_modules/log-file-config' export const Route = createFileRoute('/(main)/main/settings/nyanpasu')({ component: RouteComponent, }) const AppSettings = () => { return (
{m.settings_label_nyanpasu()}
) } function RouteComponent() { return ( <> {m.settings_label_nyanpasu()}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/route.tsx ================================================ import { AnimatedOutletPreset } from '@/components/router/animated-outlet' import { AppContentScrollArea } from '@/components/ui/scroll-area' import { Sidebar, SidebarContent } from '@/components/ui/sidebar' import { cn } from '@nyanpasu/ui' import { createFileRoute } from '@tanstack/react-router' import SettingsNavigate from './_modules/settings-navigate' export const Route = createFileRoute('/(main)/main/settings')({ component: RouteComponent, }) function RouteComponent() { return (
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/system/_modules/auto-launch-switch.tsx ================================================ import { Switch } from '@/components/ui/switch' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { useSetting } from '@nyanpasu/interface' import { ItemContainer, ItemLabel, ItemLabelText, } from '../../_modules/settings-card' export default function AutoLaunchSwitch() { const autoLaunch = useSetting('enable_auto_launch') const handleAutoLaunch = useLockFn(async () => { try { await autoLaunch.upsert(!autoLaunch.value) } catch (error) { message(`Activation Auto Launch failed!\n Error: ${formatError(error)}`, { title: 'Error', kind: 'error', }) } }) return ( {m.settings_system_proxy_auto_launch_label()} ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/system/_modules/current-system-proxy.tsx ================================================ import { Card, CardContent } from '@/components/ui/card' import { m } from '@/paraglide/messages' import { useSystemProxy } from '@nyanpasu/interface' export default function CurrentSystemProxy() { const { data } = useSystemProxy() return (
{Object.entries(data ?? []).map(([key, value], index) => { return (
{key}:
{String(value)}
) })}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/system/_modules/proxy-bypass-config.tsx ================================================ import { AnimatePresence } from 'framer-motion' import { ChangeEvent, useCallback, useEffect } from 'react' import { Controller, useForm } from 'react-hook-form' import { z } from 'zod' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { m } from '@/paraglide/messages' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { zodResolver } from '@hookform/resolvers/zod' import { useSetting } from '@nyanpasu/interface' import { SettingsCardAnimatedItem } from '../../_modules/settings-card' const DEFAULT_BYPASS = 'localhost;127.;192.168.;10.;' + '172.16.;172.17.;172.18.;172.19.;172.20.;172.21.;172.22.;172.23.;' + '172.24.;172.25.;172.26.;172.27.;172.28.;172.29.;172.30.;172.31.*' const formSchema = z.object({ systemProxyBypass: z.string().nullable().optional(), }) export default function ProxyBypassConfig() { const systemProxyBypass = useSetting('system_proxy_bypass') const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { systemProxyBypass: systemProxyBypass.value, }, }) useEffect(() => { form.setValue('systemProxyBypass', systemProxyBypass.value) }, [systemProxyBypass.value, form]) const handleSubmit = form.handleSubmit(async (data) => { try { await systemProxyBypass.upsert(data.systemProxyBypass || DEFAULT_BYPASS) form.reset({ systemProxyBypass: data.systemProxyBypass || DEFAULT_BYPASS, }) } catch (error) { message(formatError(error), { title: 'Error', kind: 'error', }) } }) const handleReset = useCallback(() => { form.reset({ systemProxyBypass: systemProxyBypass.value, }) }, [systemProxyBypass.value, form]) return (
{ const handleChange = (event: ChangeEvent) => { field.onChange(event.target.value) } return ( <> {form.formState.errors.systemProxyBypass && ( {form.formState.errors.systemProxyBypass.message} )} ) }} /> {form.formState.isDirty && (
)}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/system/_modules/proxy-guard-config.tsx ================================================ import { AnimatePresence } from 'framer-motion' import { isNumber } from 'lodash-es' import { useCallback, useEffect } from 'react' import { Controller, useForm } from 'react-hook-form' import { z } from 'zod' import { Button } from '@/components/ui/button' import { NumericInput } from '@/components/ui/input' import { m } from '@/paraglide/messages' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { zodResolver } from '@hookform/resolvers/zod' import { useSetting } from '@nyanpasu/interface' import { SettingsCardAnimatedItem } from '../../_modules/settings-card' const formSchema = z.object({ proxyGuardInterval: z.number().min(1), }) export default function ProxyGuardConfig() { const proxyGuardInterval = useSetting('proxy_guard_interval') const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { proxyGuardInterval: proxyGuardInterval.value || 1, }, }) useEffect(() => { if (isNumber(proxyGuardInterval.value)) { form.setValue('proxyGuardInterval', proxyGuardInterval.value) } }, [proxyGuardInterval.value, form]) const handleSubmit = form.handleSubmit(async (data) => { try { await proxyGuardInterval.upsert(data.proxyGuardInterval) form.reset({ proxyGuardInterval: data.proxyGuardInterval, }) } catch (error) { message(formatError(error), { title: 'Error', kind: 'error', }) } }) const handleReset = useCallback(() => { form.reset({ proxyGuardInterval: proxyGuardInterval.value || 1, }) }, [proxyGuardInterval.value, form]) return (
{ const handleChange = (value: number | null) => { field.onChange(value) } return ( <> {form.formState.errors.proxyGuardInterval && ( {form.formState.errors.proxyGuardInterval.message} )} ) }} /> {form.formState.isDirty && (
)}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/system/_modules/proxy-guard-switch.tsx ================================================ import { Switch } from '@/components/ui/switch' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { useSetting } from '@nyanpasu/interface' import { ItemContainer, ItemLabel, ItemLabelDescription, ItemLabelText, } from '../../_modules/settings-card' export default function ProxyGuardSwitch() { const proxyGuard = useSetting('enable_proxy_guard') const handleProxyGuard = useLockFn(async () => { try { await proxyGuard.upsert(!proxyGuard.value) } catch (error) { message(`Activation Proxy Guard failed!\n Error: ${formatError(error)}`, { title: 'Error', kind: 'error', }) } }) return ( {m.settings_system_proxy_proxy_guard_switch_label()} {m.settings_system_proxy_proxy_guard_switch_description()} ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/system/_modules/slient-launch-switch.tsx ================================================ import { Switch } from '@/components/ui/switch' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { useSetting } from '@nyanpasu/interface' import { ItemContainer, ItemLabel, ItemLabelText, } from '../../_modules/settings-card' export default function SilentLaunchSwitch() { const silentStart = useSetting('enable_silent_start') const handleSilentStart = useLockFn(async () => { try { await silentStart.upsert(!silentStart.value) } catch (error) { message( `Activation Silent Start failed!\n Error: ${formatError(error)}`, { title: 'Error', kind: 'error', }, ) } }) return ( {m.settings_system_proxy_silent_start_label()} ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/system/_modules/system-service-ctrl.tsx ================================================ import { startCase } from 'lodash-es' import { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' import { Modal, ModalClose, ModalContent, ModalTitle, ModalTrigger, } from '@/components/ui/modal' import { OS } from '@/consts' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { getShikiSingleton } from '@/utils/shiki' import { commands, useCoreDir, useServicePrompt, useSystemService, } from '@nyanpasu/interface' import { cn } from '@nyanpasu/ui' import { writeText } from '@tauri-apps/plugin-clipboard-manager' import { SettingsCard, SettingsCardContent, SettingsCardFooter, } from '../../_modules/settings-card' const SystemServiceCtrlItem = ({ name, value, }: { name: string value?: string }) => { return (
{name}:
{value ?? '-'}
) } const ServiceDetailButton = () => { const { query } = useSystemService() return ( {m.settings_system_proxy_system_service_ctrl_detail()}
              {JSON.stringify(query.data, null, 2)}
            
{m.common_close()}
) } const ServiceInstallButton = () => { const { upsert } = useSystemService() const handleInstallClick = useLockFn(async () => { try { await upsert.mutateAsync('install') await commands.restartSidecar() } catch (e) { const errorMessage = `${m.settings_system_proxy_system_service_ctrl_failed_install()}: ${formatError(e)}` message(errorMessage, { kind: 'error', }) // // If the installation fails, prompt the user to manually install the service // promptDialog.show( // query.data?.status === 'not_installed' ? 'install' : 'uninstall', // ) } }) return ( ) } const ServiceUninstallButton = () => { const { upsert } = useSystemService() const handleUninstallClick = useLockFn(async () => { await upsert.mutateAsync('uninstall') }) return ( ) } // { // operation: 'uninstall' | 'install' | 'start' | 'stop' | null // } const ServicePromptButton = () => { const { query: { data: systemService }, } = useSystemService() const { data: serviceInstallPrompt } = useServicePrompt() const { data: coreDir } = useCoreDir() const [codes, setCodes] = useState(null) const userOperationCommands = useMemo(() => { if (systemService?.status === 'not_installed' && serviceInstallPrompt) { return `cd "${coreDir}"\n${serviceInstallPrompt}` } else if (systemService?.status) { const operation = systemService?.status === 'running' ? 'stop' : 'start' return `cd "${coreDir}"\n${OS !== 'windows' ? 'sudo ' : ''}./nyanpasu-service ${operation}` } return '' }, [systemService?.status, serviceInstallPrompt, coreDir]) useEffect(() => { const handleGenerateCodes = async () => { const shiki = await getShikiSingleton() const code = shiki.codeToHtml(userOperationCommands, { lang: 'shell', themes: { dark: 'nord', light: 'min-light', }, }) setCodes(code) } handleGenerateCodes() }, [userOperationCommands]) const handleCopyToClipboard = useLockFn(async () => { if (!userOperationCommands) { return } await writeText(userOperationCommands) }) return ( {m.settings_system_proxy_system_service_ctrl_manual_prompt()}

{m.settings_system_proxy_system_service_ctrl_manual_operation_prompt()}

{codes && (
pre]:overflow-auto [&>pre]:p-2', '[&>pre]:bg-surface-variant! dark:[&>pre]:bg-black!', )} dangerouslySetInnerHTML={{ __html: codes, }} /> )} {m.common_close()} ) } const ServiceControlButtons = () => { const { query, upsert } = useSystemService() const handleToggleClick = useLockFn(async () => { await upsert.mutateAsync( query.data?.status === 'running' ? 'stop' : 'start', ) }) return ( ) } export default function SystemServiceCtrl() { const { query } = useSystemService() const isInstalled = query.data?.status !== 'not_installed' return ( {isInstalled ? ( <> ) : ( )}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/system/_modules/system-service-switch.tsx ================================================ import { Switch } from '@/components/ui/switch' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { useSetting, useSystemService } from '@nyanpasu/interface' import { ItemContainer, ItemLabel, ItemLabelDescription, ItemLabelText, } from '../../_modules/settings-card' export default function SystemServiceSwitch() { const serviceMode = useSetting('enable_service_mode') const { query } = useSystemService() const disabled = query.data?.status === 'not_installed' const handleServiceMode = useLockFn(async () => { try { await serviceMode.upsert(!serviceMode.value) } catch (error) { message( `Activation Service Mode failed!\n Error: ${formatError(error)}`, { title: 'Error', kind: 'error', }, ) } }) return ( {m.settings_system_proxy_service_mode_label()} {m.settings_system_proxy_service_mode_description()}
{disabled && ( {m.settings_system_proxy_service_mode_disabled_tooltip()} )}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/system/_modules/uwp-tools-button.tsx ================================================ import ArrowForwardIosRounded from '~icons/material-symbols/arrow-forward-ios-rounded' import { Button } from '@/components/ui/button' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { commands } from '@nyanpasu/interface' import { ItemContainer, ItemLabel, ItemLabelDescription, ItemLabelText, SettingsCard, SettingsCardContent, } from '../../_modules/settings-card' export default function UwpToolsButton() { const handleOpenUwpTools = useLockFn(async () => { await commands.invokeUwpTool() }) return ( ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/system/route.tsx ================================================ import { AnimatePresence } from 'framer-motion' import { SystemProxyButton, TunModeButton, } from '@/components/settings/system-proxy' import { isWindows } from '@/consts' import { m } from '@/paraglide/messages' import { useSetting } from '@nyanpasu/interface' import { createFileRoute } from '@tanstack/react-router' import { SettingsCard, SettingsCardAnimatedItem, SettingsCardContent, SettingsGroup, SettingsLabel, } from '../_modules/settings-card' import { SettingsTitle } from '../_modules/settings-title' import AutoLaunchSwitch from './_modules/auto-launch-switch' import CurrentSystemProxy from './_modules/current-system-proxy' import ProxyBypassConfig from './_modules/proxy-bypass-config' import ProxyGuardConfig from './_modules/proxy-guard-config' import ProxyGuardSwitch from './_modules/proxy-guard-switch' import SilentLaunchSwitch from './_modules/slient-launch-switch' import SystemServiceCtrl from './_modules/system-service-ctrl' import SystemServiceSwitch from './_modules/system-service-switch' import UwpToolsButton from './_modules/uwp-tools-button' export const Route = createFileRoute('/(main)/main/settings/system')({ component: RouteComponent, }) const ProxyMode = () => { return (
{m.settings_system_proxy_proxy_mode_label()}
) } const ProxyGuard = () => { const { value } = useSetting('enable_proxy_guard') return (
{m.settings_system_proxy_proxy_guard_label()} {value && ( )}
) } const CurrentProxy = () => { return (
{m.settings_system_proxy_current_system_proxy_label()}
) } const SystemService = () => { return (
{m.settings_system_proxy_system_service_ctrl_label()}
) } const SystemLaunch = () => { return (
{m.settings_system_proxy_launch_label()}
) } const WindowsTools = () => { return (
{m.settings_system_proxy_windows_tools_label()}
) } function RouteComponent() { return ( <> {m.settings_label_system()}
{isWindows && }
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/user-interface/_modules/language-selector.tsx ================================================ import { useLanguage } from '@/components/providers/language-provider' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { m } from '@/paraglide/messages' import { Locale, locales } from '@/paraglide/runtime' export default function LanguageSelector() { const { language, setLanguage } = useLanguage() const handleLanguageChange = (value: string) => { setLanguage(value as Locale) } return ( ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/user-interface/_modules/switch-legacy.tsx ================================================ import { Button } from '@/components/ui/button' import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' import { Modal, ModalClose, ModalContent, ModalTitle, ModalTrigger, } from '@/components/ui/modal' import { useLockFn } from '@/hooks/use-lock-fn' import { commands, useSetting } from '@nyanpasu/interface' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import { SettingsCard, SettingsCardContent } from '../../_modules/settings-card' const currentWindow = getCurrentWebviewWindow() export default function SwitchLegacy() { const { upsert } = useSetting('use_legacy_ui') const handleClick = useLockFn(async () => { await upsert(true) await commands.createLegacyWindow() await currentWindow.close() }) return ( Switch to Legacy UI Are you sure you want to switch to Legacy UI?

Switching to Legacy UI will revert the UI to the original design.

) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/user-interface/_modules/theme-color-config.tsx ================================================ import Check from '~icons/material-symbols/check-rounded' import { useCallback, useState } from 'react' import { DEFAULT_COLOR, useExperimentalThemeContext, } from '@/components/providers/theme-provider' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { m } from '@/paraglide/messages' import { Wheel } from '@uiw/react-color' import { SettingsCard, SettingsCardContent, SettingsCardFooter, SettingsCardHeader, } from '../../_modules/settings-card' const PERSETS = [ DEFAULT_COLOR, '#9e1e67', '#3d009e', '#00089e', '#066b9e', '#9e5a00', ] as const export default function ThemeColorConfig() { const { themeColor, setThemeColor } = useExperimentalThemeContext() const [open, setOpen] = useState(false) const [cachedThemeColor, setCachedThemeColor] = useState(themeColor) const handleSubmit = useCallback(async () => { setOpen(false) await setThemeColor(cachedThemeColor) }, [cachedThemeColor, setThemeColor]) return ( {m.settings_user_interface_theme_color_label()}
{PERSETS.map((color) => ( ))}
{ setCachedThemeColor(color.hex) }} />
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/user-interface/_modules/theme-mode-selector.tsx ================================================ import { ThemeMode, useExperimentalThemeContext, } from '@/components/providers/theme-provider' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { m } from '@/paraglide/messages' export default function ThemeModeSelector() { const { themeMode, setThemeMode } = useExperimentalThemeContext() const handleThemeModeChange = (value: string) => { setThemeMode(value as ThemeMode) } const messages = { [ThemeMode.LIGHT]: m.settings_user_interface_theme_mode_light(), [ThemeMode.DARK]: m.settings_user_interface_theme_mode_dark(), [ThemeMode.SYSTEM]: m.settings_user_interface_theme_mode_system(), } satisfies Record return ( ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/user-interface/route.tsx ================================================ import { m } from '@/paraglide/messages' import { createFileRoute } from '@tanstack/react-router' import { SettingsCard, SettingsCardContent, SettingsGroup, SettingsLabel, } from '../_modules/settings-card' import { SettingsTitle } from '../_modules/settings-title' import LanguageSelector from './_modules/language-selector' import SwitchLegacy from './_modules/switch-legacy' import ThemeColorConfig from './_modules/theme-color-config' import ThemeModeSelector from './_modules/theme-mode-selector' export const Route = createFileRoute('/(main)/main/settings/user-interface')({ component: RouteComponent, head: () => ({ meta: [ { title: m.settings_user_interface_title(), }, ], }), }) const LanguageSettings = () => { return (
{m.settings_user_interface_language_group()}
) } const ThemeModeSettings = () => { return (
{m.settings_user_interface_theme_mode_group()}
) } function RouteComponent() { return ( <> {m.settings_user_interface_title()}
{/* */} ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/web-ui/_modules/core-secret-config.tsx ================================================ import { AnimatePresence } from 'framer-motion' import { ChangeEvent, useCallback, useEffect } from 'react' import { Controller, useForm } from 'react-hook-form' import { z } from 'zod' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { m } from '@/paraglide/messages' import { formatError, sleep } from '@/utils' import { message } from '@/utils/notification' import { zodResolver } from '@hookform/resolvers/zod' import { useClashConfig, useClashInfo, useRuntimeProfile, } from '@nyanpasu/interface' import { SettingsCardAnimatedItem } from '../../_modules/settings-card' const formSchema = z.object({ coreSecret: z.string(), }) export default function CoreSecretConfig() { const { data, refetch } = useClashInfo() const { upsert } = useClashConfig() const runtimeProfile = useRuntimeProfile() const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { coreSecret: data?.secret || '', }, }) useEffect(() => { form.reset({ coreSecret: data?.secret || '', }) }, [data?.secret, form]) const handleSubmit = form.handleSubmit( async (data) => { await upsert.mutateAsync({ secret: data.coreSecret, }) await refetch() // Wait for the server to apply await sleep(300) await runtimeProfile.refetch() }, (error) => { message(formatError(error), { title: 'Error', kind: 'error', }) }, ) const handleReset = useCallback(() => { form.reset({ coreSecret: data?.secret || '', }) }, [form, data?.secret]) return (
{ const handleChange = (event: ChangeEvent) => { field.onChange(event.target.value) } return ( <> {form.formState.errors.coreSecret && ( {form.formState.errors.coreSecret.message} )} ) }} /> {form.formState.isDirty && (
)}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/web-ui/_modules/external-controller-config.tsx ================================================ import { AnimatePresence } from 'framer-motion' import { ChangeEvent, useCallback, useEffect } from 'react' import { Controller, useForm } from 'react-hook-form' import { z } from 'zod' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { m } from '@/paraglide/messages' import { formatError, sleep } from '@/utils' import { message } from '@/utils/notification' import { zodResolver } from '@hookform/resolvers/zod' import { useClashConfig, useClashInfo, useRuntimeProfile, } from '@nyanpasu/interface' import { SettingsCardAnimatedItem } from '../../_modules/settings-card' const formSchema = z.object({ externalController: z.string(), }) export default function ExternalControllerConfig() { const { data, refetch } = useClashInfo() const { upsert } = useClashConfig() const runtimeProfile = useRuntimeProfile() const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { externalController: data?.server || '', }, }) useEffect(() => { form.reset({ externalController: data?.server || '', }) }, [data?.server, form]) const handleSubmit = form.handleSubmit( async (data) => { await upsert.mutateAsync({ 'external-controller': data.externalController, }) await refetch() // Wait for the server to apply await sleep(300) await runtimeProfile.refetch() }, (error) => { message(formatError(error), { title: 'Error', kind: 'error', }) }, ) const handleReset = useCallback(() => { form.reset({ externalController: data?.server || '', }) }, [form, data?.server]) return (
{ const handleChange = (event: ChangeEvent) => { field.onChange(event.target.value) } return ( <> {form.formState.errors.externalController && ( {form.formState.errors.externalController.message} )} ) }} /> {form.formState.isDirty && (
)}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/web-ui/_modules/port-strategy-selector.tsx ================================================ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { m } from '@/paraglide/messages' import { ExternalControllerPortStrategy, useSetting } from '@nyanpasu/interface' export default function PortStrategySelector() { const { value, upsert } = useSetting('clash_strategy') const messages = { allow_fallback: m.settings_clash_settings_allow_fallback_label(), fixed: m.settings_clash_settings_fixed_label(), random: m.settings_clash_settings_random_label(), } as Record const handlePortStrategyChange = async ( value: ExternalControllerPortStrategy, ) => { await upsert({ external_controller_port_strategy: value, }) } return ( ) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/web-ui/_modules/web-ui.tsx ================================================ import AddIcon from '~icons/material-symbols/add-rounded' import AllInboxRounded from '~icons/material-symbols/all-inbox-outline-rounded' import DeleteRounded from '~icons/material-symbols/delete-rounded' import EditSquareRounded from '~icons/material-symbols/edit-square-rounded' import OpenInNewRounded from '~icons/material-symbols/open-in-new-rounded' import { AnimatePresence, motion } from 'framer-motion' import { ChangeEvent, PropsWithChildren, useEffect, useMemo, useState, } from 'react' import { Controller, useForm } from 'react-hook-form' import z from 'zod' import { Button } from '@/components/ui/button' import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Modal, ModalClose, ModalContent, ModalTitle, ModalTrigger, } from '@/components/ui/modal' import TextMarquee from '@/components/ui/text-marquee' import { useLockFn } from '@/hooks/use-lock-fn' import { m } from '@/paraglide/messages' import { formatError } from '@/utils' import { message } from '@/utils/notification' import { zodResolver } from '@hookform/resolvers/zod' import { commands, useClashInfo, useSetting } from '@nyanpasu/interface' import { SettingsCard, SettingsCardAnimatedItem, SettingsCardContent, } from '../../_modules/settings-card' const useUrlLabels = () => { const { data } = useClashInfo() return useMemo(() => { let host = '127.0.0.1' let port = 7890 if (data?.server) { const [h, p] = data.server.split(':') host = h port = Number(p) } return { host, port, secret: data?.secret, } }, [data]) } const useFormattedUrl = (url: string) => { const labels = useUrlLabels() return useMemo(() => { let result = url for (const key of Object.keys(labels) as Array) { const regex = new RegExp(`%${key}`, 'g') result = result.replace(regex, String(labels[key] ?? '')) } return result }, [url, labels]) } const PreviewItem = ({ url }: { url: string }) => { const formattedUrl = useFormattedUrl(url) return (
{m.settings_web_ui_preview_title()}
{formattedUrl}
) } const formSchema = z.object({ url: z.httpUrl(), }) const EditItemButton = ({ defaultUrl, children, }: PropsWithChildren<{ defaultUrl?: string }>) => { const [open, setOpen] = useState(false) const { value, upsert } = useSetting('web_ui_list') const labels = useUrlLabels() const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { url: defaultUrl, }, }) // sync default url to form useEffect(() => { form.reset({ url: defaultUrl, }) }, [defaultUrl, form]) const handleOpenChange = (open: boolean) => { if (!open) { form.reset({ url: defaultUrl, }) } setOpen(open) } const urlValue = form.watch('url') const handleSubmit = form.handleSubmit( async (data) => { try { await upsert([...(value || []), data.url]) handleOpenChange(false) } catch (error) { message(formatError(error), { title: 'Error', kind: 'error', }) } }, (error) => { message(formatError(error), { title: 'Error', kind: 'error', }) }, ) return ( {children} {m.settings_web_ui_add_button()} { const handleChange = (event: ChangeEvent) => { field.onChange(event.target.value) } return ( <> {form.formState.errors.url && ( {form.formState.errors.url.message} )} ) }} />

{m.settings_web_ui_replace_with_label()} {Object.entries(labels).map(([key], index) => { return ( %{key} ) })}

{urlValue && }
{m.common_cancel()}
) } const WebUIItem = ({ url }: { url: string }) => { const formattedUrl = useFormattedUrl(url) const handleOpen = useLockFn(async () => { await commands.openWebUrl(formattedUrl) }) const { value, upsert } = useSetting('web_ui_list') const handleDelete = useLockFn(async () => { await upsert(value?.filter((item) => item !== url) || []) }) return ( {formattedUrl} ) } const EmptyItem = () => { return (

{m.settings_web_ui_empty_item()}

) } export default function WebUI() { const { value } = useSetting('web_ui_list') return (
{value && value.length > 0 ? ( value.map((item, index) => ) ) : ( )}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/main/settings/web-ui/route.tsx ================================================ import { m } from '@/paraglide/messages' import { createFileRoute } from '@tanstack/react-router' import { SettingsCard, SettingsCardContent, SettingsGroup, SettingsLabel, } from '../_modules/settings-card' import { SettingsTitle } from '../_modules/settings-title' import CoreSecretConfig from './_modules/core-secret-config' import ExternalControllerConfig from './_modules/external-controller-config' import PortStrategySelector from './_modules/port-strategy-selector' import WebUI from './_modules/web-ui' export const Route = createFileRoute('/(main)/main/settings/web-ui')({ component: RouteComponent, }) const ExternalController = () => { return (
{m.settings_label_external_controll()}
) } const WebUISettings = () => { return (
{m.settings_web_ui_title()}
) } function RouteComponent() { return ( <> {m.settings_label_external_controll()}
) } ================================================ FILE: frontend/nyanpasu/src/pages/(main)/route.tsx ================================================ import ContextMenuProvider from '@/components/providers/context-menu-provider' import NyanpasuUpdateProvider from '@/components/providers/nyanpasu-update-provider' import { AnimatedOutletPreset } from '@/components/router/animated-outlet' import { cn } from '@nyanpasu/ui' import packageJson from '@root/package.json' import { createFileRoute } from '@tanstack/react-router' import Header from './_modules/header' import Navbar from './_modules/navbar' export const Route = createFileRoute('/(main)')({ component: RouteComponent, }) const AppContent = () => { return ( ) } function RouteComponent() { return (
) } ================================================ FILE: frontend/nyanpasu/src/pages/-__root.module.scss ================================================ .oops { display: flex; flex-direction: column; align-items: center; justify-content: center; } .dark { color: bisque; } ================================================ FILE: frontend/nyanpasu/src/pages/-__root.module.scss.d.ts ================================================ declare const classNames: { readonly oops: 'oops' readonly dark: 'dark' } export default classNames ================================================ FILE: frontend/nyanpasu/src/pages/__root.tsx ================================================ import { useMount } from 'ahooks' import dayjs from 'dayjs' import { useNyanpasuStorageSubscribers } from '@/hooks/use-store' import { cn } from '@nyanpasu/ui' import { createRootRoute, ErrorComponentProps, Outlet, } from '@tanstack/react-router' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import 'dayjs/locale/ru' import 'dayjs/locale/zh-cn' import 'dayjs/locale/zh-tw' import customParseFormat from 'dayjs/plugin/customParseFormat' import relativeTime from 'dayjs/plugin/relativeTime' import { lazy } from 'react' import { BlockTaskProvider } from '@/components/providers/block-task-provider' import { LanguageProvider } from '@/components/providers/language-provider' import { ExperimentalThemeProvider } from '@/components/providers/theme-provider' import { events, NyanpasuProvider } from '@nyanpasu/interface' dayjs.extend(relativeTime) dayjs.extend(customParseFormat) const appWindow = getCurrentWebviewWindow() export const Catch = ({ error }: ErrorComponentProps) => { return (

Oops!

Something went wrong... Caught in error boundary.

        {error.message}
        {error.stack}
      
) } export const Pending = () =>
Loading from _root...
const TanStackRouterDevtools = import.meta.env.PROD ? () => null // Render nothing in production : lazy(() => // Lazy load in development import('@tanstack/react-router-devtools').then((res) => ({ default: res.TanStackRouterDevtools, // For Embedded Mode // default: res.TanStackRouterDevtoolsPanel })), ) export const Route = createRootRoute({ component: App, errorComponent: Catch, pendingComponent: Pending, }) export default function App() { useNyanpasuStorageSubscribers() useMount(() => { Promise.all([ appWindow.show(), appWindow.unminimize(), appWindow.setFocus(), ]).finally(() => { events.reactAppMountedEvent.emit(null) }) }) return ( ) } ================================================ FILE: frontend/nyanpasu/src/route-tree.gen.ts ================================================ /* eslint-disable */ // @ts-nocheck // noinspection JSUnusedGlobalSymbols // This file was automatically generated by TanStack Router. // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './pages/__root' import { Route as mainRouteRouteImport } from './pages/(main)/route' import { Route as legacyRouteRouteImport } from './pages/(legacy)/route' import { Route as legacyIndexRouteImport } from './pages/(legacy)/index' import { Route as legacySettingsRouteImport } from './pages/(legacy)/settings' import { Route as legacyRulesRouteImport } from './pages/(legacy)/rules' import { Route as legacyProxiesRouteImport } from './pages/(legacy)/proxies' import { Route as legacyProvidersRouteImport } from './pages/(legacy)/providers' import { Route as legacyProfilesRouteImport } from './pages/(legacy)/profiles' import { Route as legacyLogsRouteImport } from './pages/(legacy)/logs' import { Route as legacyDashboardRouteImport } from './pages/(legacy)/dashboard' import { Route as legacyConnectionsRouteImport } from './pages/(legacy)/connections' import { Route as editorEditorRouteRouteImport } from './pages/(editor)/editor/route' import { Route as mainMainIndexRouteImport } from './pages/(main)/main/index' import { Route as editorEditorIndexRouteImport } from './pages/(editor)/editor/index' import { Route as mainMainSettingsRouteRouteImport } from './pages/(main)/main/settings/route' import { Route as mainMainRulesRouteRouteImport } from './pages/(main)/main/rules/route' import { Route as mainMainProxiesRouteRouteImport } from './pages/(main)/main/proxies/route' import { Route as mainMainProvidersRouteRouteImport } from './pages/(main)/main/providers/route' import { Route as mainMainProfilesRouteRouteImport } from './pages/(main)/main/profiles/route' import { Route as mainMainLogsRouteRouteImport } from './pages/(main)/main/logs/route' import { Route as mainMainDashboardRouteRouteImport } from './pages/(main)/main/dashboard/route' import { Route as mainMainConnectionsRouteRouteImport } from './pages/(main)/main/connections/route' import { Route as mainMainSettingsIndexRouteImport } from './pages/(main)/main/settings/index' import { Route as mainMainRulesIndexRouteImport } from './pages/(main)/main/rules/index' import { Route as mainMainProxiesIndexRouteImport } from './pages/(main)/main/proxies/index' import { Route as mainMainProvidersIndexRouteImport } from './pages/(main)/main/providers/index' import { Route as mainMainProfilesIndexRouteImport } from './pages/(main)/main/profiles/index' import { Route as mainMainLogsIndexRouteImport } from './pages/(main)/main/logs/index' import { Route as mainMainConnectionsIndexRouteImport } from './pages/(main)/main/connections/index' import { Route as mainMainSettingsWebUiRouteRouteImport } from './pages/(main)/main/settings/web-ui/route' import { Route as mainMainSettingsUserInterfaceRouteRouteImport } from './pages/(main)/main/settings/user-interface/route' import { Route as mainMainSettingsSystemRouteRouteImport } from './pages/(main)/main/settings/system/route' import { Route as mainMainSettingsNyanpasuRouteRouteImport } from './pages/(main)/main/settings/nyanpasu/route' import { Route as mainMainSettingsDebugRouteRouteImport } from './pages/(main)/main/settings/debug/route' import { Route as mainMainSettingsClashRouteRouteImport } from './pages/(main)/main/settings/clash/route' import { Route as mainMainSettingsAboutRouteRouteImport } from './pages/(main)/main/settings/about/route' import { Route as mainMainProfilesInspectRouteRouteImport } from './pages/(main)/main/profiles/inspect/route' import { Route as mainMainSettingsDebugIndexRouteImport } from './pages/(main)/main/settings/debug/index' import { Route as mainMainProfilesTypeIndexRouteImport } from './pages/(main)/main/profiles/$type/index' import { Route as mainMainProxiesGroupNameRouteImport } from './pages/(main)/main/proxies/group/$name' import { Route as mainMainProvidersRulesKeyRouteImport } from './pages/(main)/main/providers/rules/$key' import { Route as mainMainProvidersProxiesKeyRouteImport } from './pages/(main)/main/providers/proxies/$key' import { Route as mainMainProfilesTypeDetailUidRouteImport } from './pages/(main)/main/profiles/$type/detail/$uid' const mainRouteRoute = mainRouteRouteImport.update({ id: '/(main)', getParentRoute: () => rootRouteImport, } as any) const legacyRouteRoute = legacyRouteRouteImport.update({ id: '/(legacy)', getParentRoute: () => rootRouteImport, } as any) const legacyIndexRoute = legacyIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => legacyRouteRoute, } as any) const legacySettingsRoute = legacySettingsRouteImport.update({ id: '/settings', path: '/settings', getParentRoute: () => legacyRouteRoute, } as any) const legacyRulesRoute = legacyRulesRouteImport.update({ id: '/rules', path: '/rules', getParentRoute: () => legacyRouteRoute, } as any) const legacyProxiesRoute = legacyProxiesRouteImport.update({ id: '/proxies', path: '/proxies', getParentRoute: () => legacyRouteRoute, } as any) const legacyProvidersRoute = legacyProvidersRouteImport.update({ id: '/providers', path: '/providers', getParentRoute: () => legacyRouteRoute, } as any) const legacyProfilesRoute = legacyProfilesRouteImport.update({ id: '/profiles', path: '/profiles', getParentRoute: () => legacyRouteRoute, } as any) const legacyLogsRoute = legacyLogsRouteImport.update({ id: '/logs', path: '/logs', getParentRoute: () => legacyRouteRoute, } as any) const legacyDashboardRoute = legacyDashboardRouteImport.update({ id: '/dashboard', path: '/dashboard', getParentRoute: () => legacyRouteRoute, } as any) const legacyConnectionsRoute = legacyConnectionsRouteImport.update({ id: '/connections', path: '/connections', getParentRoute: () => legacyRouteRoute, } as any) const editorEditorRouteRoute = editorEditorRouteRouteImport.update({ id: '/(editor)/editor', path: '/editor', getParentRoute: () => rootRouteImport, } as any) const mainMainIndexRoute = mainMainIndexRouteImport.update({ id: '/main/', path: '/main/', getParentRoute: () => mainRouteRoute, } as any) const editorEditorIndexRoute = editorEditorIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => editorEditorRouteRoute, } as any) const mainMainSettingsRouteRoute = mainMainSettingsRouteRouteImport.update({ id: '/main/settings', path: '/main/settings', getParentRoute: () => mainRouteRoute, } as any) const mainMainRulesRouteRoute = mainMainRulesRouteRouteImport.update({ id: '/main/rules', path: '/main/rules', getParentRoute: () => mainRouteRoute, } as any) const mainMainProxiesRouteRoute = mainMainProxiesRouteRouteImport.update({ id: '/main/proxies', path: '/main/proxies', getParentRoute: () => mainRouteRoute, } as any) const mainMainProvidersRouteRoute = mainMainProvidersRouteRouteImport.update({ id: '/main/providers', path: '/main/providers', getParentRoute: () => mainRouteRoute, } as any) const mainMainProfilesRouteRoute = mainMainProfilesRouteRouteImport.update({ id: '/main/profiles', path: '/main/profiles', getParentRoute: () => mainRouteRoute, } as any) const mainMainLogsRouteRoute = mainMainLogsRouteRouteImport.update({ id: '/main/logs', path: '/main/logs', getParentRoute: () => mainRouteRoute, } as any) const mainMainDashboardRouteRoute = mainMainDashboardRouteRouteImport.update({ id: '/main/dashboard', path: '/main/dashboard', getParentRoute: () => mainRouteRoute, } as any) const mainMainConnectionsRouteRoute = mainMainConnectionsRouteRouteImport.update({ id: '/main/connections', path: '/main/connections', getParentRoute: () => mainRouteRoute, } as any) const mainMainSettingsIndexRoute = mainMainSettingsIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => mainMainSettingsRouteRoute, } as any) const mainMainRulesIndexRoute = mainMainRulesIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => mainMainRulesRouteRoute, } as any) const mainMainProxiesIndexRoute = mainMainProxiesIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => mainMainProxiesRouteRoute, } as any) const mainMainProvidersIndexRoute = mainMainProvidersIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => mainMainProvidersRouteRoute, } as any) const mainMainProfilesIndexRoute = mainMainProfilesIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => mainMainProfilesRouteRoute, } as any) const mainMainLogsIndexRoute = mainMainLogsIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => mainMainLogsRouteRoute, } as any) const mainMainConnectionsIndexRoute = mainMainConnectionsIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => mainMainConnectionsRouteRoute, } as any) const mainMainSettingsWebUiRouteRoute = mainMainSettingsWebUiRouteRouteImport.update({ id: '/web-ui', path: '/web-ui', getParentRoute: () => mainMainSettingsRouteRoute, } as any) const mainMainSettingsUserInterfaceRouteRoute = mainMainSettingsUserInterfaceRouteRouteImport.update({ id: '/user-interface', path: '/user-interface', getParentRoute: () => mainMainSettingsRouteRoute, } as any) const mainMainSettingsSystemRouteRoute = mainMainSettingsSystemRouteRouteImport.update({ id: '/system', path: '/system', getParentRoute: () => mainMainSettingsRouteRoute, } as any) const mainMainSettingsNyanpasuRouteRoute = mainMainSettingsNyanpasuRouteRouteImport.update({ id: '/nyanpasu', path: '/nyanpasu', getParentRoute: () => mainMainSettingsRouteRoute, } as any) const mainMainSettingsDebugRouteRoute = mainMainSettingsDebugRouteRouteImport.update({ id: '/debug', path: '/debug', getParentRoute: () => mainMainSettingsRouteRoute, } as any) const mainMainSettingsClashRouteRoute = mainMainSettingsClashRouteRouteImport.update({ id: '/clash', path: '/clash', getParentRoute: () => mainMainSettingsRouteRoute, } as any) const mainMainSettingsAboutRouteRoute = mainMainSettingsAboutRouteRouteImport.update({ id: '/about', path: '/about', getParentRoute: () => mainMainSettingsRouteRoute, } as any) const mainMainProfilesInspectRouteRoute = mainMainProfilesInspectRouteRouteImport.update({ id: '/inspect', path: '/inspect', getParentRoute: () => mainMainProfilesRouteRoute, } as any) const mainMainSettingsDebugIndexRoute = mainMainSettingsDebugIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => mainMainSettingsDebugRouteRoute, } as any) const mainMainProfilesTypeIndexRoute = mainMainProfilesTypeIndexRouteImport.update({ id: '/$type/', path: '/$type/', getParentRoute: () => mainMainProfilesRouteRoute, } as any) const mainMainProxiesGroupNameRoute = mainMainProxiesGroupNameRouteImport.update({ id: '/group/$name', path: '/group/$name', getParentRoute: () => mainMainProxiesRouteRoute, } as any) const mainMainProvidersRulesKeyRoute = mainMainProvidersRulesKeyRouteImport.update({ id: '/rules/$key', path: '/rules/$key', getParentRoute: () => mainMainProvidersRouteRoute, } as any) const mainMainProvidersProxiesKeyRoute = mainMainProvidersProxiesKeyRouteImport.update({ id: '/proxies/$key', path: '/proxies/$key', getParentRoute: () => mainMainProvidersRouteRoute, } as any) const mainMainProfilesTypeDetailUidRoute = mainMainProfilesTypeDetailUidRouteImport.update({ id: '/$type/detail/$uid', path: '/$type/detail/$uid', getParentRoute: () => mainMainProfilesRouteRoute, } as any) export interface FileRoutesByFullPath { '/editor': typeof editorEditorRouteRouteWithChildren '/connections': typeof legacyConnectionsRoute '/dashboard': typeof legacyDashboardRoute '/logs': typeof legacyLogsRoute '/profiles': typeof legacyProfilesRoute '/providers': typeof legacyProvidersRoute '/proxies': typeof legacyProxiesRoute '/rules': typeof legacyRulesRoute '/settings': typeof legacySettingsRoute '/': typeof legacyIndexRoute '/main/connections': typeof mainMainConnectionsRouteRouteWithChildren '/main/dashboard': typeof mainMainDashboardRouteRoute '/main/logs': typeof mainMainLogsRouteRouteWithChildren '/main/profiles': typeof mainMainProfilesRouteRouteWithChildren '/main/providers': typeof mainMainProvidersRouteRouteWithChildren '/main/proxies': typeof mainMainProxiesRouteRouteWithChildren '/main/rules': typeof mainMainRulesRouteRouteWithChildren '/main/settings': typeof mainMainSettingsRouteRouteWithChildren '/editor/': typeof editorEditorIndexRoute '/main/': typeof mainMainIndexRoute '/main/profiles/inspect': typeof mainMainProfilesInspectRouteRoute '/main/settings/about': typeof mainMainSettingsAboutRouteRoute '/main/settings/clash': typeof mainMainSettingsClashRouteRoute '/main/settings/debug': typeof mainMainSettingsDebugRouteRouteWithChildren '/main/settings/nyanpasu': typeof mainMainSettingsNyanpasuRouteRoute '/main/settings/system': typeof mainMainSettingsSystemRouteRoute '/main/settings/user-interface': typeof mainMainSettingsUserInterfaceRouteRoute '/main/settings/web-ui': typeof mainMainSettingsWebUiRouteRoute '/main/connections/': typeof mainMainConnectionsIndexRoute '/main/logs/': typeof mainMainLogsIndexRoute '/main/profiles/': typeof mainMainProfilesIndexRoute '/main/providers/': typeof mainMainProvidersIndexRoute '/main/proxies/': typeof mainMainProxiesIndexRoute '/main/rules/': typeof mainMainRulesIndexRoute '/main/settings/': typeof mainMainSettingsIndexRoute '/main/providers/proxies/$key': typeof mainMainProvidersProxiesKeyRoute '/main/providers/rules/$key': typeof mainMainProvidersRulesKeyRoute '/main/proxies/group/$name': typeof mainMainProxiesGroupNameRoute '/main/profiles/$type/': typeof mainMainProfilesTypeIndexRoute '/main/settings/debug/': typeof mainMainSettingsDebugIndexRoute '/main/profiles/$type/detail/$uid': typeof mainMainProfilesTypeDetailUidRoute } export interface FileRoutesByTo { '/connections': typeof legacyConnectionsRoute '/dashboard': typeof legacyDashboardRoute '/logs': typeof legacyLogsRoute '/profiles': typeof legacyProfilesRoute '/providers': typeof legacyProvidersRoute '/proxies': typeof legacyProxiesRoute '/rules': typeof legacyRulesRoute '/settings': typeof legacySettingsRoute '/': typeof legacyIndexRoute '/main/dashboard': typeof mainMainDashboardRouteRoute '/editor': typeof editorEditorIndexRoute '/main': typeof mainMainIndexRoute '/main/profiles/inspect': typeof mainMainProfilesInspectRouteRoute '/main/settings/about': typeof mainMainSettingsAboutRouteRoute '/main/settings/clash': typeof mainMainSettingsClashRouteRoute '/main/settings/nyanpasu': typeof mainMainSettingsNyanpasuRouteRoute '/main/settings/system': typeof mainMainSettingsSystemRouteRoute '/main/settings/user-interface': typeof mainMainSettingsUserInterfaceRouteRoute '/main/settings/web-ui': typeof mainMainSettingsWebUiRouteRoute '/main/connections': typeof mainMainConnectionsIndexRoute '/main/logs': typeof mainMainLogsIndexRoute '/main/profiles': typeof mainMainProfilesIndexRoute '/main/providers': typeof mainMainProvidersIndexRoute '/main/proxies': typeof mainMainProxiesIndexRoute '/main/rules': typeof mainMainRulesIndexRoute '/main/settings': typeof mainMainSettingsIndexRoute '/main/providers/proxies/$key': typeof mainMainProvidersProxiesKeyRoute '/main/providers/rules/$key': typeof mainMainProvidersRulesKeyRoute '/main/proxies/group/$name': typeof mainMainProxiesGroupNameRoute '/main/profiles/$type': typeof mainMainProfilesTypeIndexRoute '/main/settings/debug': typeof mainMainSettingsDebugIndexRoute '/main/profiles/$type/detail/$uid': typeof mainMainProfilesTypeDetailUidRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/(legacy)': typeof legacyRouteRouteWithChildren '/(main)': typeof mainRouteRouteWithChildren '/(editor)/editor': typeof editorEditorRouteRouteWithChildren '/(legacy)/connections': typeof legacyConnectionsRoute '/(legacy)/dashboard': typeof legacyDashboardRoute '/(legacy)/logs': typeof legacyLogsRoute '/(legacy)/profiles': typeof legacyProfilesRoute '/(legacy)/providers': typeof legacyProvidersRoute '/(legacy)/proxies': typeof legacyProxiesRoute '/(legacy)/rules': typeof legacyRulesRoute '/(legacy)/settings': typeof legacySettingsRoute '/(legacy)/': typeof legacyIndexRoute '/(main)/main/connections': typeof mainMainConnectionsRouteRouteWithChildren '/(main)/main/dashboard': typeof mainMainDashboardRouteRoute '/(main)/main/logs': typeof mainMainLogsRouteRouteWithChildren '/(main)/main/profiles': typeof mainMainProfilesRouteRouteWithChildren '/(main)/main/providers': typeof mainMainProvidersRouteRouteWithChildren '/(main)/main/proxies': typeof mainMainProxiesRouteRouteWithChildren '/(main)/main/rules': typeof mainMainRulesRouteRouteWithChildren '/(main)/main/settings': typeof mainMainSettingsRouteRouteWithChildren '/(editor)/editor/': typeof editorEditorIndexRoute '/(main)/main/': typeof mainMainIndexRoute '/(main)/main/profiles/inspect': typeof mainMainProfilesInspectRouteRoute '/(main)/main/settings/about': typeof mainMainSettingsAboutRouteRoute '/(main)/main/settings/clash': typeof mainMainSettingsClashRouteRoute '/(main)/main/settings/debug': typeof mainMainSettingsDebugRouteRouteWithChildren '/(main)/main/settings/nyanpasu': typeof mainMainSettingsNyanpasuRouteRoute '/(main)/main/settings/system': typeof mainMainSettingsSystemRouteRoute '/(main)/main/settings/user-interface': typeof mainMainSettingsUserInterfaceRouteRoute '/(main)/main/settings/web-ui': typeof mainMainSettingsWebUiRouteRoute '/(main)/main/connections/': typeof mainMainConnectionsIndexRoute '/(main)/main/logs/': typeof mainMainLogsIndexRoute '/(main)/main/profiles/': typeof mainMainProfilesIndexRoute '/(main)/main/providers/': typeof mainMainProvidersIndexRoute '/(main)/main/proxies/': typeof mainMainProxiesIndexRoute '/(main)/main/rules/': typeof mainMainRulesIndexRoute '/(main)/main/settings/': typeof mainMainSettingsIndexRoute '/(main)/main/providers/proxies/$key': typeof mainMainProvidersProxiesKeyRoute '/(main)/main/providers/rules/$key': typeof mainMainProvidersRulesKeyRoute '/(main)/main/proxies/group/$name': typeof mainMainProxiesGroupNameRoute '/(main)/main/profiles/$type/': typeof mainMainProfilesTypeIndexRoute '/(main)/main/settings/debug/': typeof mainMainSettingsDebugIndexRoute '/(main)/main/profiles/$type/detail/$uid': typeof mainMainProfilesTypeDetailUidRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/editor' | '/connections' | '/dashboard' | '/logs' | '/profiles' | '/providers' | '/proxies' | '/rules' | '/settings' | '/' | '/main/connections' | '/main/dashboard' | '/main/logs' | '/main/profiles' | '/main/providers' | '/main/proxies' | '/main/rules' | '/main/settings' | '/editor/' | '/main/' | '/main/profiles/inspect' | '/main/settings/about' | '/main/settings/clash' | '/main/settings/debug' | '/main/settings/nyanpasu' | '/main/settings/system' | '/main/settings/user-interface' | '/main/settings/web-ui' | '/main/connections/' | '/main/logs/' | '/main/profiles/' | '/main/providers/' | '/main/proxies/' | '/main/rules/' | '/main/settings/' | '/main/providers/proxies/$key' | '/main/providers/rules/$key' | '/main/proxies/group/$name' | '/main/profiles/$type/' | '/main/settings/debug/' | '/main/profiles/$type/detail/$uid' fileRoutesByTo: FileRoutesByTo to: | '/connections' | '/dashboard' | '/logs' | '/profiles' | '/providers' | '/proxies' | '/rules' | '/settings' | '/' | '/main/dashboard' | '/editor' | '/main' | '/main/profiles/inspect' | '/main/settings/about' | '/main/settings/clash' | '/main/settings/nyanpasu' | '/main/settings/system' | '/main/settings/user-interface' | '/main/settings/web-ui' | '/main/connections' | '/main/logs' | '/main/profiles' | '/main/providers' | '/main/proxies' | '/main/rules' | '/main/settings' | '/main/providers/proxies/$key' | '/main/providers/rules/$key' | '/main/proxies/group/$name' | '/main/profiles/$type' | '/main/settings/debug' | '/main/profiles/$type/detail/$uid' id: | '__root__' | '/(legacy)' | '/(main)' | '/(editor)/editor' | '/(legacy)/connections' | '/(legacy)/dashboard' | '/(legacy)/logs' | '/(legacy)/profiles' | '/(legacy)/providers' | '/(legacy)/proxies' | '/(legacy)/rules' | '/(legacy)/settings' | '/(legacy)/' | '/(main)/main/connections' | '/(main)/main/dashboard' | '/(main)/main/logs' | '/(main)/main/profiles' | '/(main)/main/providers' | '/(main)/main/proxies' | '/(main)/main/rules' | '/(main)/main/settings' | '/(editor)/editor/' | '/(main)/main/' | '/(main)/main/profiles/inspect' | '/(main)/main/settings/about' | '/(main)/main/settings/clash' | '/(main)/main/settings/debug' | '/(main)/main/settings/nyanpasu' | '/(main)/main/settings/system' | '/(main)/main/settings/user-interface' | '/(main)/main/settings/web-ui' | '/(main)/main/connections/' | '/(main)/main/logs/' | '/(main)/main/profiles/' | '/(main)/main/providers/' | '/(main)/main/proxies/' | '/(main)/main/rules/' | '/(main)/main/settings/' | '/(main)/main/providers/proxies/$key' | '/(main)/main/providers/rules/$key' | '/(main)/main/proxies/group/$name' | '/(main)/main/profiles/$type/' | '/(main)/main/settings/debug/' | '/(main)/main/profiles/$type/detail/$uid' fileRoutesById: FileRoutesById } export interface RootRouteChildren { legacyRouteRoute: typeof legacyRouteRouteWithChildren mainRouteRoute: typeof mainRouteRouteWithChildren editorEditorRouteRoute: typeof editorEditorRouteRouteWithChildren } declare module '@tanstack/react-router' { interface FileRoutesByPath { '/(main)': { id: '/(main)' path: '' fullPath: '' preLoaderRoute: typeof mainRouteRouteImport parentRoute: typeof rootRouteImport } '/(legacy)': { id: '/(legacy)' path: '' fullPath: '' preLoaderRoute: typeof legacyRouteRouteImport parentRoute: typeof rootRouteImport } '/(legacy)/': { id: '/(legacy)/' path: '/' fullPath: '/' preLoaderRoute: typeof legacyIndexRouteImport parentRoute: typeof legacyRouteRoute } '/(legacy)/settings': { id: '/(legacy)/settings' path: '/settings' fullPath: '/settings' preLoaderRoute: typeof legacySettingsRouteImport parentRoute: typeof legacyRouteRoute } '/(legacy)/rules': { id: '/(legacy)/rules' path: '/rules' fullPath: '/rules' preLoaderRoute: typeof legacyRulesRouteImport parentRoute: typeof legacyRouteRoute } '/(legacy)/proxies': { id: '/(legacy)/proxies' path: '/proxies' fullPath: '/proxies' preLoaderRoute: typeof legacyProxiesRouteImport parentRoute: typeof legacyRouteRoute } '/(legacy)/providers': { id: '/(legacy)/providers' path: '/providers' fullPath: '/providers' preLoaderRoute: typeof legacyProvidersRouteImport parentRoute: typeof legacyRouteRoute } '/(legacy)/profiles': { id: '/(legacy)/profiles' path: '/profiles' fullPath: '/profiles' preLoaderRoute: typeof legacyProfilesRouteImport parentRoute: typeof legacyRouteRoute } '/(legacy)/logs': { id: '/(legacy)/logs' path: '/logs' fullPath: '/logs' preLoaderRoute: typeof legacyLogsRouteImport parentRoute: typeof legacyRouteRoute } '/(legacy)/dashboard': { id: '/(legacy)/dashboard' path: '/dashboard' fullPath: '/dashboard' preLoaderRoute: typeof legacyDashboardRouteImport parentRoute: typeof legacyRouteRoute } '/(legacy)/connections': { id: '/(legacy)/connections' path: '/connections' fullPath: '/connections' preLoaderRoute: typeof legacyConnectionsRouteImport parentRoute: typeof legacyRouteRoute } '/(editor)/editor': { id: '/(editor)/editor' path: '/editor' fullPath: '/editor' preLoaderRoute: typeof editorEditorRouteRouteImport parentRoute: typeof rootRouteImport } '/(main)/main/': { id: '/(main)/main/' path: '/main' fullPath: '/main/' preLoaderRoute: typeof mainMainIndexRouteImport parentRoute: typeof mainRouteRoute } '/(editor)/editor/': { id: '/(editor)/editor/' path: '/' fullPath: '/editor/' preLoaderRoute: typeof editorEditorIndexRouteImport parentRoute: typeof editorEditorRouteRoute } '/(main)/main/settings': { id: '/(main)/main/settings' path: '/main/settings' fullPath: '/main/settings' preLoaderRoute: typeof mainMainSettingsRouteRouteImport parentRoute: typeof mainRouteRoute } '/(main)/main/rules': { id: '/(main)/main/rules' path: '/main/rules' fullPath: '/main/rules' preLoaderRoute: typeof mainMainRulesRouteRouteImport parentRoute: typeof mainRouteRoute } '/(main)/main/proxies': { id: '/(main)/main/proxies' path: '/main/proxies' fullPath: '/main/proxies' preLoaderRoute: typeof mainMainProxiesRouteRouteImport parentRoute: typeof mainRouteRoute } '/(main)/main/providers': { id: '/(main)/main/providers' path: '/main/providers' fullPath: '/main/providers' preLoaderRoute: typeof mainMainProvidersRouteRouteImport parentRoute: typeof mainRouteRoute } '/(main)/main/profiles': { id: '/(main)/main/profiles' path: '/main/profiles' fullPath: '/main/profiles' preLoaderRoute: typeof mainMainProfilesRouteRouteImport parentRoute: typeof mainRouteRoute } '/(main)/main/logs': { id: '/(main)/main/logs' path: '/main/logs' fullPath: '/main/logs' preLoaderRoute: typeof mainMainLogsRouteRouteImport parentRoute: typeof mainRouteRoute } '/(main)/main/dashboard': { id: '/(main)/main/dashboard' path: '/main/dashboard' fullPath: '/main/dashboard' preLoaderRoute: typeof mainMainDashboardRouteRouteImport parentRoute: typeof mainRouteRoute } '/(main)/main/connections': { id: '/(main)/main/connections' path: '/main/connections' fullPath: '/main/connections' preLoaderRoute: typeof mainMainConnectionsRouteRouteImport parentRoute: typeof mainRouteRoute } '/(main)/main/settings/': { id: '/(main)/main/settings/' path: '/' fullPath: '/main/settings/' preLoaderRoute: typeof mainMainSettingsIndexRouteImport parentRoute: typeof mainMainSettingsRouteRoute } '/(main)/main/rules/': { id: '/(main)/main/rules/' path: '/' fullPath: '/main/rules/' preLoaderRoute: typeof mainMainRulesIndexRouteImport parentRoute: typeof mainMainRulesRouteRoute } '/(main)/main/proxies/': { id: '/(main)/main/proxies/' path: '/' fullPath: '/main/proxies/' preLoaderRoute: typeof mainMainProxiesIndexRouteImport parentRoute: typeof mainMainProxiesRouteRoute } '/(main)/main/providers/': { id: '/(main)/main/providers/' path: '/' fullPath: '/main/providers/' preLoaderRoute: typeof mainMainProvidersIndexRouteImport parentRoute: typeof mainMainProvidersRouteRoute } '/(main)/main/profiles/': { id: '/(main)/main/profiles/' path: '/' fullPath: '/main/profiles/' preLoaderRoute: typeof mainMainProfilesIndexRouteImport parentRoute: typeof mainMainProfilesRouteRoute } '/(main)/main/logs/': { id: '/(main)/main/logs/' path: '/' fullPath: '/main/logs/' preLoaderRoute: typeof mainMainLogsIndexRouteImport parentRoute: typeof mainMainLogsRouteRoute } '/(main)/main/connections/': { id: '/(main)/main/connections/' path: '/' fullPath: '/main/connections/' preLoaderRoute: typeof mainMainConnectionsIndexRouteImport parentRoute: typeof mainMainConnectionsRouteRoute } '/(main)/main/settings/web-ui': { id: '/(main)/main/settings/web-ui' path: '/web-ui' fullPath: '/main/settings/web-ui' preLoaderRoute: typeof mainMainSettingsWebUiRouteRouteImport parentRoute: typeof mainMainSettingsRouteRoute } '/(main)/main/settings/user-interface': { id: '/(main)/main/settings/user-interface' path: '/user-interface' fullPath: '/main/settings/user-interface' preLoaderRoute: typeof mainMainSettingsUserInterfaceRouteRouteImport parentRoute: typeof mainMainSettingsRouteRoute } '/(main)/main/settings/system': { id: '/(main)/main/settings/system' path: '/system' fullPath: '/main/settings/system' preLoaderRoute: typeof mainMainSettingsSystemRouteRouteImport parentRoute: typeof mainMainSettingsRouteRoute } '/(main)/main/settings/nyanpasu': { id: '/(main)/main/settings/nyanpasu' path: '/nyanpasu' fullPath: '/main/settings/nyanpasu' preLoaderRoute: typeof mainMainSettingsNyanpasuRouteRouteImport parentRoute: typeof mainMainSettingsRouteRoute } '/(main)/main/settings/debug': { id: '/(main)/main/settings/debug' path: '/debug' fullPath: '/main/settings/debug' preLoaderRoute: typeof mainMainSettingsDebugRouteRouteImport parentRoute: typeof mainMainSettingsRouteRoute } '/(main)/main/settings/clash': { id: '/(main)/main/settings/clash' path: '/clash' fullPath: '/main/settings/clash' preLoaderRoute: typeof mainMainSettingsClashRouteRouteImport parentRoute: typeof mainMainSettingsRouteRoute } '/(main)/main/settings/about': { id: '/(main)/main/settings/about' path: '/about' fullPath: '/main/settings/about' preLoaderRoute: typeof mainMainSettingsAboutRouteRouteImport parentRoute: typeof mainMainSettingsRouteRoute } '/(main)/main/profiles/inspect': { id: '/(main)/main/profiles/inspect' path: '/inspect' fullPath: '/main/profiles/inspect' preLoaderRoute: typeof mainMainProfilesInspectRouteRouteImport parentRoute: typeof mainMainProfilesRouteRoute } '/(main)/main/settings/debug/': { id: '/(main)/main/settings/debug/' path: '/' fullPath: '/main/settings/debug/' preLoaderRoute: typeof mainMainSettingsDebugIndexRouteImport parentRoute: typeof mainMainSettingsDebugRouteRoute } '/(main)/main/profiles/$type/': { id: '/(main)/main/profiles/$type/' path: '/$type' fullPath: '/main/profiles/$type/' preLoaderRoute: typeof mainMainProfilesTypeIndexRouteImport parentRoute: typeof mainMainProfilesRouteRoute } '/(main)/main/proxies/group/$name': { id: '/(main)/main/proxies/group/$name' path: '/group/$name' fullPath: '/main/proxies/group/$name' preLoaderRoute: typeof mainMainProxiesGroupNameRouteImport parentRoute: typeof mainMainProxiesRouteRoute } '/(main)/main/providers/rules/$key': { id: '/(main)/main/providers/rules/$key' path: '/rules/$key' fullPath: '/main/providers/rules/$key' preLoaderRoute: typeof mainMainProvidersRulesKeyRouteImport parentRoute: typeof mainMainProvidersRouteRoute } '/(main)/main/providers/proxies/$key': { id: '/(main)/main/providers/proxies/$key' path: '/proxies/$key' fullPath: '/main/providers/proxies/$key' preLoaderRoute: typeof mainMainProvidersProxiesKeyRouteImport parentRoute: typeof mainMainProvidersRouteRoute } '/(main)/main/profiles/$type/detail/$uid': { id: '/(main)/main/profiles/$type/detail/$uid' path: '/$type/detail/$uid' fullPath: '/main/profiles/$type/detail/$uid' preLoaderRoute: typeof mainMainProfilesTypeDetailUidRouteImport parentRoute: typeof mainMainProfilesRouteRoute } } } interface legacyRouteRouteChildren { legacyConnectionsRoute: typeof legacyConnectionsRoute legacyDashboardRoute: typeof legacyDashboardRoute legacyLogsRoute: typeof legacyLogsRoute legacyProfilesRoute: typeof legacyProfilesRoute legacyProvidersRoute: typeof legacyProvidersRoute legacyProxiesRoute: typeof legacyProxiesRoute legacyRulesRoute: typeof legacyRulesRoute legacySettingsRoute: typeof legacySettingsRoute legacyIndexRoute: typeof legacyIndexRoute } const legacyRouteRouteChildren: legacyRouteRouteChildren = { legacyConnectionsRoute: legacyConnectionsRoute, legacyDashboardRoute: legacyDashboardRoute, legacyLogsRoute: legacyLogsRoute, legacyProfilesRoute: legacyProfilesRoute, legacyProvidersRoute: legacyProvidersRoute, legacyProxiesRoute: legacyProxiesRoute, legacyRulesRoute: legacyRulesRoute, legacySettingsRoute: legacySettingsRoute, legacyIndexRoute: legacyIndexRoute, } const legacyRouteRouteWithChildren = legacyRouteRoute._addFileChildren( legacyRouteRouteChildren, ) interface mainMainConnectionsRouteRouteChildren { mainMainConnectionsIndexRoute: typeof mainMainConnectionsIndexRoute } const mainMainConnectionsRouteRouteChildren: mainMainConnectionsRouteRouteChildren = { mainMainConnectionsIndexRoute: mainMainConnectionsIndexRoute, } const mainMainConnectionsRouteRouteWithChildren = mainMainConnectionsRouteRoute._addFileChildren( mainMainConnectionsRouteRouteChildren, ) interface mainMainLogsRouteRouteChildren { mainMainLogsIndexRoute: typeof mainMainLogsIndexRoute } const mainMainLogsRouteRouteChildren: mainMainLogsRouteRouteChildren = { mainMainLogsIndexRoute: mainMainLogsIndexRoute, } const mainMainLogsRouteRouteWithChildren = mainMainLogsRouteRoute._addFileChildren(mainMainLogsRouteRouteChildren) interface mainMainProfilesRouteRouteChildren { mainMainProfilesInspectRouteRoute: typeof mainMainProfilesInspectRouteRoute mainMainProfilesIndexRoute: typeof mainMainProfilesIndexRoute mainMainProfilesTypeIndexRoute: typeof mainMainProfilesTypeIndexRoute mainMainProfilesTypeDetailUidRoute: typeof mainMainProfilesTypeDetailUidRoute } const mainMainProfilesRouteRouteChildren: mainMainProfilesRouteRouteChildren = { mainMainProfilesInspectRouteRoute: mainMainProfilesInspectRouteRoute, mainMainProfilesIndexRoute: mainMainProfilesIndexRoute, mainMainProfilesTypeIndexRoute: mainMainProfilesTypeIndexRoute, mainMainProfilesTypeDetailUidRoute: mainMainProfilesTypeDetailUidRoute, } const mainMainProfilesRouteRouteWithChildren = mainMainProfilesRouteRoute._addFileChildren( mainMainProfilesRouteRouteChildren, ) interface mainMainProvidersRouteRouteChildren { mainMainProvidersIndexRoute: typeof mainMainProvidersIndexRoute mainMainProvidersProxiesKeyRoute: typeof mainMainProvidersProxiesKeyRoute mainMainProvidersRulesKeyRoute: typeof mainMainProvidersRulesKeyRoute } const mainMainProvidersRouteRouteChildren: mainMainProvidersRouteRouteChildren = { mainMainProvidersIndexRoute: mainMainProvidersIndexRoute, mainMainProvidersProxiesKeyRoute: mainMainProvidersProxiesKeyRoute, mainMainProvidersRulesKeyRoute: mainMainProvidersRulesKeyRoute, } const mainMainProvidersRouteRouteWithChildren = mainMainProvidersRouteRoute._addFileChildren( mainMainProvidersRouteRouteChildren, ) interface mainMainProxiesRouteRouteChildren { mainMainProxiesIndexRoute: typeof mainMainProxiesIndexRoute mainMainProxiesGroupNameRoute: typeof mainMainProxiesGroupNameRoute } const mainMainProxiesRouteRouteChildren: mainMainProxiesRouteRouteChildren = { mainMainProxiesIndexRoute: mainMainProxiesIndexRoute, mainMainProxiesGroupNameRoute: mainMainProxiesGroupNameRoute, } const mainMainProxiesRouteRouteWithChildren = mainMainProxiesRouteRoute._addFileChildren(mainMainProxiesRouteRouteChildren) interface mainMainRulesRouteRouteChildren { mainMainRulesIndexRoute: typeof mainMainRulesIndexRoute } const mainMainRulesRouteRouteChildren: mainMainRulesRouteRouteChildren = { mainMainRulesIndexRoute: mainMainRulesIndexRoute, } const mainMainRulesRouteRouteWithChildren = mainMainRulesRouteRoute._addFileChildren(mainMainRulesRouteRouteChildren) interface mainMainSettingsDebugRouteRouteChildren { mainMainSettingsDebugIndexRoute: typeof mainMainSettingsDebugIndexRoute } const mainMainSettingsDebugRouteRouteChildren: mainMainSettingsDebugRouteRouteChildren = { mainMainSettingsDebugIndexRoute: mainMainSettingsDebugIndexRoute, } const mainMainSettingsDebugRouteRouteWithChildren = mainMainSettingsDebugRouteRoute._addFileChildren( mainMainSettingsDebugRouteRouteChildren, ) interface mainMainSettingsRouteRouteChildren { mainMainSettingsAboutRouteRoute: typeof mainMainSettingsAboutRouteRoute mainMainSettingsClashRouteRoute: typeof mainMainSettingsClashRouteRoute mainMainSettingsDebugRouteRoute: typeof mainMainSettingsDebugRouteRouteWithChildren mainMainSettingsNyanpasuRouteRoute: typeof mainMainSettingsNyanpasuRouteRoute mainMainSettingsSystemRouteRoute: typeof mainMainSettingsSystemRouteRoute mainMainSettingsUserInterfaceRouteRoute: typeof mainMainSettingsUserInterfaceRouteRoute mainMainSettingsWebUiRouteRoute: typeof mainMainSettingsWebUiRouteRoute mainMainSettingsIndexRoute: typeof mainMainSettingsIndexRoute } const mainMainSettingsRouteRouteChildren: mainMainSettingsRouteRouteChildren = { mainMainSettingsAboutRouteRoute: mainMainSettingsAboutRouteRoute, mainMainSettingsClashRouteRoute: mainMainSettingsClashRouteRoute, mainMainSettingsDebugRouteRoute: mainMainSettingsDebugRouteRouteWithChildren, mainMainSettingsNyanpasuRouteRoute: mainMainSettingsNyanpasuRouteRoute, mainMainSettingsSystemRouteRoute: mainMainSettingsSystemRouteRoute, mainMainSettingsUserInterfaceRouteRoute: mainMainSettingsUserInterfaceRouteRoute, mainMainSettingsWebUiRouteRoute: mainMainSettingsWebUiRouteRoute, mainMainSettingsIndexRoute: mainMainSettingsIndexRoute, } const mainMainSettingsRouteRouteWithChildren = mainMainSettingsRouteRoute._addFileChildren( mainMainSettingsRouteRouteChildren, ) interface mainRouteRouteChildren { mainMainConnectionsRouteRoute: typeof mainMainConnectionsRouteRouteWithChildren mainMainDashboardRouteRoute: typeof mainMainDashboardRouteRoute mainMainLogsRouteRoute: typeof mainMainLogsRouteRouteWithChildren mainMainProfilesRouteRoute: typeof mainMainProfilesRouteRouteWithChildren mainMainProvidersRouteRoute: typeof mainMainProvidersRouteRouteWithChildren mainMainProxiesRouteRoute: typeof mainMainProxiesRouteRouteWithChildren mainMainRulesRouteRoute: typeof mainMainRulesRouteRouteWithChildren mainMainSettingsRouteRoute: typeof mainMainSettingsRouteRouteWithChildren mainMainIndexRoute: typeof mainMainIndexRoute } const mainRouteRouteChildren: mainRouteRouteChildren = { mainMainConnectionsRouteRoute: mainMainConnectionsRouteRouteWithChildren, mainMainDashboardRouteRoute: mainMainDashboardRouteRoute, mainMainLogsRouteRoute: mainMainLogsRouteRouteWithChildren, mainMainProfilesRouteRoute: mainMainProfilesRouteRouteWithChildren, mainMainProvidersRouteRoute: mainMainProvidersRouteRouteWithChildren, mainMainProxiesRouteRoute: mainMainProxiesRouteRouteWithChildren, mainMainRulesRouteRoute: mainMainRulesRouteRouteWithChildren, mainMainSettingsRouteRoute: mainMainSettingsRouteRouteWithChildren, mainMainIndexRoute: mainMainIndexRoute, } const mainRouteRouteWithChildren = mainRouteRoute._addFileChildren( mainRouteRouteChildren, ) interface editorEditorRouteRouteChildren { editorEditorIndexRoute: typeof editorEditorIndexRoute } const editorEditorRouteRouteChildren: editorEditorRouteRouteChildren = { editorEditorIndexRoute: editorEditorIndexRoute, } const editorEditorRouteRouteWithChildren = editorEditorRouteRoute._addFileChildren(editorEditorRouteRouteChildren) const rootRouteChildren: RootRouteChildren = { legacyRouteRoute: legacyRouteRouteWithChildren, mainRouteRoute: mainRouteRouteWithChildren, editorEditorRouteRoute: editorEditorRouteRouteWithChildren, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() ================================================ FILE: frontend/nyanpasu/src/services/i18n.ts ================================================ import i18n from 'i18next' import { initReactI18next } from 'react-i18next' import en from '../locales/en.json' import ru from '../locales/ru.json' import zhCN from '../locales/zh-CN.json' import zhTW from '../locales/zh-TW.json' const resources = { en: { translation: en }, ru: { translation: ru }, 'zh-CN': { translation: zhCN }, 'zh-TW': { translation: zhTW }, } i18n.use(initReactI18next).init({ resources, lng: 'en', interpolation: { escapeValue: false, }, }) ================================================ FILE: frontend/nyanpasu/src/services/monaco.ts ================================================ /* eslint-disable new-cap */ // features // langs import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js' import 'monaco-editor/esm/vs/basic-languages/lua/lua.contribution.js' import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution.js' import 'monaco-editor/esm/vs/editor/editor.all.js' import 'monaco-editor/esm/vs/editor/contrib/links/browser/links.js' // language services import * as monaco from 'monaco-editor' import 'monaco-editor/esm/vs/language/typescript/monaco.contribution.js' import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker' import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' // workers import yamlWorker from '@/utils/monaco-yaml.worker?worker' // others import { loader } from '@monaco-editor/react' self.MonacoEnvironment = { getWorker(_, label) { switch (label) { case 'json': return new jsonWorker() case 'typescript': case 'javascript': return new tsWorker() case 'yaml': return new yamlWorker() default: return new editorWorker() } }, } loader.config({ monaco }) loader .init() .then(() => { console.log('Monaco is ready') }) .catch((error) => { console.error('Monaco initialization failed', error) }) ================================================ FILE: frontend/nyanpasu/src/services/storage.ts ================================================ import { createJSONStorage } from 'jotai/utils' import { type AsyncStringStorage } from 'jotai/vanilla/utils/atomWithStorage' import { getStorageItem, removeStorageItem, setStorageItem, } from '@nyanpasu/interface' const subscribers: Map< string, Set<(newValue: string | null) => void> > = new Map() export function dispatchStorageValueChanged( key: string, newValue: string | null, ) { if (subscribers.has(key)) { const set = subscribers.get(key) set!.forEach((callback) => { callback(newValue) }) } } export const NyanpasuStorage = { getItem(key) { return getStorageItem(key) }, setItem(key, newValue) { return setStorageItem(key, newValue) }, removeItem(key) { return removeStorageItem(key) }, subscribe(key, callback) { if (!subscribers.has(key)) { subscribers.set(key, new Set()) } const set = subscribers.get(key) set!.add(callback) return () => { if (subscribers.has(key)) { const set = subscribers.get(key) set!.delete(callback) if (set!.size === 0) { subscribers.delete(key) } } } }, } satisfies AsyncStringStorage export const NyanpasuJSONStorage = createJSONStorage(() => NyanpasuStorage) ================================================ FILE: frontend/nyanpasu/src/services/types.d.ts ================================================ type Platform = | 'aix' | 'android' | 'darwin' | 'freebsd' | 'haiku' | 'linux' | 'openbsd' | 'sunos' | 'win32' | 'cygwin' | 'netbsd' /** * defines in `vite.config.ts` */ declare const WIN_PORTABLE: boolean declare const OS_PLATFORM: Platform ================================================ FILE: frontend/nyanpasu/src/store/clash.ts ================================================ import { atom } from 'jotai' import type { VergeConfig } from '@nyanpasu/interface' export const coreTypeAtom = atom>('mihomo') ================================================ FILE: frontend/nyanpasu/src/store/index.ts ================================================ import { atom } from 'jotai' import { atomWithStorage, createJSONStorage } from 'jotai/utils' import { SortType } from '@/components/proxies/utils' import { FileRouteTypes } from '@/route-tree.gen' import { NyanpasuStorage } from '@/services/storage' const atomWithLocalStorage = (key: string, initialValue: T) => { const getInitialValue = (): T => { const item = localStorage.getItem(key) return item ? JSON.parse(item) : initialValue } const baseAtom = atom(getInitialValue()) const derivedAtom = atom( (get) => get(baseAtom), (get, set, update: T | ((prev: T) => T)) => { const nextValue = typeof update === 'function' ? (update as (prev: T) => T)(get(baseAtom)) : update set(baseAtom, nextValue) localStorage.setItem(key, JSON.stringify(nextValue)) }, ) return derivedAtom } export const memorizedRoutePathAtom = atomWithStorage< FileRouteTypes['to'] | null >('memorizedRoutePathAtom', null, undefined, { getOnInit: true, }) export const proxyGroupAtom = atomWithLocalStorage<{ selector: number | null }>('proxyGroupAtom', { selector: 0, }) export const proxyGroupSortAtom = atomWithLocalStorage( 'proxyGroupSortAtom', SortType.Default, ) export const themeMode = atomWithLocalStorage<'light' | 'dark'>( 'themeMode', 'light', ) export const atomIsDrawer = atom() export const atomIsDrawerOnlyIcon = atomWithStorage( 'atomIsDrawerOnlyIcon', true, ) // save the state of each profile item loading export const atomLoadingCache = atom>({}) // save update state export const atomUpdateState = atom(false) interface IConnectionSetting { layout: 'table' | 'list' } export const atomConnectionSetting = atom({ layout: 'table', }) // TODO: generate default columns based on COLUMNS export const connectionTableColumnsAtom = atomWithStorage< Array<[string, boolean]> >( 'connections_table_columns', [ 'host', 'process', 'downloaded', 'uploaded', 'dl_speed', 'ul_speed', 'chains', 'rule', 'time', 'source', 'destination_ip', 'destination_asn', 'type', ].map((key) => [key, true]), createJSONStorage(() => NyanpasuStorage), ) // export const themeSchemeAtom = atom(null); ================================================ FILE: frontend/nyanpasu/src/store/proxies.ts ================================================ import { atomWithStorage } from 'jotai/utils' export const proxiesFilterAtom = atomWithStorage( 'proxiesFilterAtom', null, ) ================================================ FILE: frontend/nyanpasu/src/store/service.ts ================================================ import { atom } from 'jotai' export const serviceManualPromptDialogAtom = atom< 'install' | 'uninstall' | 'start' | 'stop' | null >(null) ================================================ FILE: frontend/nyanpasu/src/store/updater.ts ================================================ import { atom } from 'jotai' import { atomWithStorage } from 'jotai/utils' import { type Update } from '@tauri-apps/plugin-updater' export const UpdaterIgnoredAtom = atomWithStorage( 'updaterIgnored', null as string | null, ) export const UpdaterInstanceAtom = atom(null) ================================================ FILE: frontend/nyanpasu/src/utils/chain.ts ================================================ export function chains( ...handlers: Array<((event: T) => void) | undefined> ) { return (event: T) => { handlers.forEach((handler) => { if (handler) { handler(event) } }) } } ================================================ FILE: frontend/nyanpasu/src/utils/get-system.ts ================================================ import { getSystem } from '@nyanpasu/ui' export default getSystem ================================================ FILE: frontend/nyanpasu/src/utils/ignore-case.ts ================================================ // oxlint-disable typescript/no-explicit-any // Deep copy and change all keys to lowercase type TData = Record export default function ignoreCase(data: TData): TData { if (!data) return {} const newData = {} as TData Object.entries(data).forEach(([key, value]) => { newData[key.toLowerCase()] = JSON.parse(JSON.stringify(value)) }) return newData } ================================================ FILE: frontend/nyanpasu/src/utils/index.ts ================================================ // oxlint-disable typescript/no-explicit-any import { includes, isArray, isObject, isString, some } from 'lodash-es' import { EnvInfo } from '@nyanpasu/interface' /** * classNames filter out falsy values and join the rest with a space * @param classes - array of classes * @returns string of classes */ export function classNames(...classes: any[]) { return classes.filter(Boolean).join(' ') } export async function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } export const containsSearchTerm = (obj: any, term: string): boolean => { if (!obj || !term) return false if (isString(obj)) { return includes(obj.toLowerCase(), term.toLowerCase()) } if (isObject(obj) || isArray(obj)) { return some(obj, (value: any) => containsSearchTerm(value, term)) } return false } export function formatError(err: unknown): string { return `Error: ${err instanceof Error ? err.message : String(err)}` } export function formatEnvInfos(envs: EnvInfo) { let result = '----------- System -----------\n' result += `OS: ${envs.os}\n` result += `Arch: ${envs.arch}\n` result += `----------- Device -----------\n` for (const cpu of envs.device.cpu) { result += `CPU: ${cpu}\n` } result += `Memory: ${envs.device.memory}\n` result += `----------- Core -----------\n` for (const key in envs.core) { result += `${key}: \`${envs.core[key]}\`\n` } result += `----------- Build Info -----------\n` for (const k of Object.keys(envs.build_info) as string[]) { const key = k .split('_') .map((v: string) => v.charAt(0).toUpperCase() + v.slice(1)) .join(' ') // Fix linter error: explicitly type k as keyof typeof envs.build_info result += `${key}: ${envs.build_info[k as keyof typeof envs.build_info]}\n` } return result } ================================================ FILE: frontend/nyanpasu/src/utils/language.ts ================================================ import { defineCustomClientStrategy, locales } from '@/paraglide/runtime' export const languageOptions = { en: 'English', ru: 'Русский', 'zh-CN': '简体中文', 'zh-TW': '繁體中文', } export const languageQuirks: { [key: string]: { drawer: { minWidth: number itemClassNames?: string } } } = { en: { drawer: { minWidth: 240, }, }, ru: { drawer: { minWidth: 240, }, }, 'zh-CN': { drawer: { minWidth: 180, }, }, 'zh-TW': { drawer: { minWidth: 180, }, }, } export type Language = (typeof locales)[number] export const LANGUAGE_STORAGE_KEY = 'paraglide-language-cache' export const DEFAULT_LANGUAGE = 'en' // encode the language storage key to avoid special characters const CACHED_LANGUAGE_STORAGE_KEY = btoa(LANGUAGE_STORAGE_KEY) export const setCachedLanguage = (locale: Language) => { localStorage.setItem(CACHED_LANGUAGE_STORAGE_KEY, locale) } export const getCachedLanguage = () => { const value = localStorage.getItem(CACHED_LANGUAGE_STORAGE_KEY) return value && locales.includes(value as Language) ? (value as Language) : DEFAULT_LANGUAGE } defineCustomClientStrategy('custom-extension', { getLocale: () => { return getCachedLanguage() }, setLocale: (locale) => { setCachedLanguage(locale as Language) }, }) ================================================ FILE: frontend/nyanpasu/src/utils/monaco-yaml.worker.ts ================================================ // This file just to fix https://github.com/remcohaszing/monaco-yaml?tab=readme-ov-file#why-doesnt-it-work-with-vite import 'monaco-yaml/yaml.worker.js' ================================================ FILE: frontend/nyanpasu/src/utils/mui-theme.ts ================================================ // From https://github.com/RobinTail/merge-sx import type { SxProps } from '@mui/material' type PureSx = Exclude, ReadonlyArray> type SxAsArray = Array> export const mergeSxProps = ( ...styles: (SxProps | false | undefined)[] ): SxProps => { const capacitor: SxAsArray = [] for (const sx of styles) { if (sx) { if (Array.isArray(sx)) { for (const sub of sx as SxAsArray) { capacitor.push(sub) } } else { capacitor.push(sx as PureSx) } } } return capacitor } ================================================ FILE: frontend/nyanpasu/src/utils/mutation.ts ================================================ import { useCallback } from 'react' import { cache, mutate } from 'swr/_internal' export const useGlobalMutation = () => { return useCallback((swrKey, ...args) => { const matcher = typeof swrKey === 'function' ? swrKey : undefined if (matcher) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const keys = Array.from(cache.keys()).filter(matcher as any) keys.forEach((key) => mutate(key, ...args)) } else { mutate(swrKey, ...args) } }, []) as typeof mutate } ================================================ FILE: frontend/nyanpasu/src/utils/notification.ts ================================================ import { Notice } from '@/components/base' import { isPortable } from '@nyanpasu/interface' import { MessageDialogOptions, message as tauriMessage, } from '@tauri-apps/plugin-dialog' import { isPermissionGranted, Options, requestPermission, sendNotification, } from '@tauri-apps/plugin-notification' let permissionGranted: boolean | null = null let portable: boolean | null = null const checkPermission = async () => { if (permissionGranted == null) { permissionGranted = await isPermissionGranted() } if (!permissionGranted) { const permission = await requestPermission() permissionGranted = permission === 'granted' } return permissionGranted } export type NotificationOptions = { title: string body?: string type?: NotificationType } export enum NotificationType { Success = 'success', Info = 'info', // Warn = "warn", Error = 'error', } export const notification = async ({ title, body, type = NotificationType.Info, }: NotificationOptions) => { if (!title) { throw new Error('missing message argument!') } if (portable === null) { portable = await isPortable() } const permissionGranted = portable || (await checkPermission()) if (portable || !permissionGranted) { // fallback to mui notification Notice[type](`${title} ${body ? `: ${body}` : ''}`) // throw new Error("notification permission not granted!"); return } const options: Options = { title, } if (body) options.body = body sendNotification(options) } export const message = async ( value: string, options?: string | MessageDialogOptions | undefined, ) => { if (typeof options === 'object') { await tauriMessage(value, { ...options, title: options.title ? `Clash Nyanpasu - ${options.title}` : 'Clash Nyanpasu', }) } else { await tauriMessage(value, options) } } ================================================ FILE: frontend/nyanpasu/src/utils/parse-hotkey.ts ================================================ const KEY_MAP: Record = { '"': "'", ':': ';', '?': '/', '>': '.', '<': ',', '{': '[', '}': ']', '|': '\\', '!': '1', '@': '2', '#': '3', $: '4', '%': '5', '^': '6', '&': '7', '*': '8', '(': '9', ')': '0', '~': '`', } export const parseHotkey = (key: string) => { let temp = key.toUpperCase() if (temp.startsWith('ARROW')) { temp = temp.slice(5) } else if (temp.startsWith('DIGIT')) { temp = temp.slice(5) } else if (temp.startsWith('KEY')) { temp = temp.slice(3) } else if (temp.endsWith('LEFT')) { temp = temp.slice(0, -4) } else if (temp.endsWith('RIGHT')) { temp = temp.slice(0, -5) } switch (temp) { case 'CONTROL': return 'CTRL' case 'META': return 'CMD' case ' ': return 'SPACE' default: return KEY_MAP[temp] || temp } } ================================================ FILE: frontend/nyanpasu/src/utils/parse-traffic.ts ================================================ const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] const parseTraffic = (num?: string | number) => { if (typeof num !== 'number') { const tmp = Number(num) if (isNaN(tmp)) return ['NaN', ''] num = tmp } // 处理负数或零的情况 if (num <= 0) return ['0', 'B'] // 使用 Math.log 而不是 Math.log2 来提高精度 const exp = Math.min( Math.floor(Math.log(num) / Math.log(1024)), UNITS.length - 1, ) const dat = num / Math.pow(1024, exp) // 对于非常小的数字,确保至少显示一位小数 let ret: string if (dat < 1) { ret = dat.toPrecision(2) } else if (dat < 10) { ret = dat.toPrecision(3) } else { ret = dat >= 1000 ? dat.toFixed(0) : dat.toPrecision(3) } const unit = UNITS[exp] return [ret, unit] } export default parseTraffic ================================================ FILE: frontend/nyanpasu/src/utils/routes-utils.ts ================================================ import { Apps, Dashboard, DesignServices, GridView, Public, Settings, SettingsEthernet, SvgIconComponent, Terminal, } from '@mui/icons-material' const routes: { [key: string]: SvgIconComponent } = { dashboard: Dashboard, proxies: Public, profiles: GridView, connections: SettingsEthernet, rules: DesignServices, logs: Terminal, settings: Settings, providers: Apps, } export const getRoutes = () => { return Object.keys(routes).reduce( (acc, key) => { acc[key] = `/${key}` return acc }, {} as { [key: string]: string }, ) } export const getRoutesWithIcon = () => { return Object.keys(routes).reduce( (acc, key) => { acc[key] = { path: `/${key}`, icon: routes[key], } return acc }, {} as { [key: string]: { path: string; icon: SvgIconComponent } }, ) } ================================================ FILE: frontend/nyanpasu/src/utils/shiki.ts ================================================ import type { Highlighter } from 'shiki' import { getSingletonHighlighterCore } from 'shiki/core' import { createOnigurumaEngine } from 'shiki/engine/oniguruma' import minLight from 'shiki/themes/min-light.mjs' import nord from 'shiki/themes/nord.mjs' import getWasm from 'shiki/wasm' let shiki: Highlighter | null = null export async function getShikiSingleton() { if (!shiki) { shiki = (await getSingletonHighlighterCore({ engine: createOnigurumaEngine(getWasm), themes: [nord, minLight], langs: [() => import('shiki/langs/shell.mjs')], })) as Highlighter } return shiki } export async function formatAnsi(str: string) { const instance = await getShikiSingleton() return instance.codeToHtml(str, { lang: 'ansi', themes: { dark: 'nord', light: 'min-light', }, }) } ================================================ FILE: frontend/nyanpasu/src/utils/styled.ts ================================================ export function insertStyle(id: string, style: string) { removeStyle(id) const waitInsertStyle = document.createElement('style') waitInsertStyle.id = id waitInsertStyle.innerHTML = style document.head.appendChild(waitInsertStyle) return waitInsertStyle } export function removeStyle(id: string) { const originalElement = document.getElementById(id) if (originalElement) { document.head.removeChild(originalElement) } } ================================================ FILE: frontend/nyanpasu/tailwind.config.ts ================================================ import type { Config } from 'tailwindcss' import createPlugin from 'tailwindcss/plugin' import { MUI_BREAKPOINTS } from '@nyanpasu/ui/src/materialYou/themeConsts.mjs' const getMUIScreen = () => { const breakpoints = MUI_BREAKPOINTS.values as Record const result = {} as Record for (const key in breakpoints) { if (Object.prototype.hasOwnProperty.call(breakpoints, key)) { result[key] = `${breakpoints[key]}px` } } return result } /** @type {import('tailwindcss').Config} */ module.exports = { content: ['./src/**/*.{tsx,ts}', '../ui/**/*.{tsx,ts}'], darkMode: 'selector', theme: { extend: { maxHeight: { '1/8': 'calc(100vh / 8)', }, zIndex: { top: 100000, }, animation: { marquee: 'marquee 4s linear infinite', }, keyframes: { marquee: { '0%': { transform: 'translateX(100%)' }, '100%': { transform: 'translateX(-100%)' }, }, }, colors: { scroller: 'var(--scroller-color)', container: 'var(--background-color)', }, }, screen: getMUIScreen(), }, plugins: [ createPlugin(({ addBase }) => { addBase({ '.scrollbar-hidden::-webkit-scrollbar': { width: '0px', }, }) }), ], } satisfies Config ================================================ FILE: frontend/nyanpasu/tsconfig.json ================================================ { "compilerOptions": { "baseUrl": ".", "target": "ESNext", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ESNext"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "allowArbitraryExtensions": true, "strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "composite": true, "paths": { "@root/*": ["../../*"], "@/*": ["./src/*"], "~/*": ["./*"], }, "jsxImportSource": "@emotion/react", "types": ["unplugin-icons/types/react"], }, "include": ["src", "./auto-imports.d.ts"], "references": [ { "path": "../interface" }, { "path": "../ui" }, { "path": "./tsconfig.node.json" }, ], } ================================================ FILE: frontend/nyanpasu/tsconfig.node.json ================================================ { "include": ["vite.config.*", "tailwind.config.ts"], "compilerOptions": { "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, "strict": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "composite": true, "types": ["node", "vite/client"], "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true } } ================================================ FILE: frontend/nyanpasu/tsr.config.json ================================================ { "routeFileIgnorePrefix": "-", "routesDirectory": "./src/pages", "autoCodeSplitting": true } ================================================ FILE: frontend/nyanpasu/vite.config.ts ================================================ import path from 'node:path' import { NodePackageImporter } from 'sass-embedded' import AutoImport from 'unplugin-auto-import/vite' import IconsResolver from 'unplugin-icons/resolver' import Icons from 'unplugin-icons/vite' import { defineConfig, UserConfig } from 'vite' import { createHtmlPlugin } from 'vite-plugin-html' import sassDts from 'vite-plugin-sass-dts' import svgr from 'vite-plugin-svgr' import tsconfigPaths from 'vite-tsconfig-paths' import { paraglideVitePlugin } from '@inlang/paraglide-js' // import tailwindPlugin from '@tailwindcss/vite' // import react from "@vitejs/plugin-react"; import { tanstackRouter } from '@tanstack/router-plugin/vite' import legacy from '@vitejs/plugin-legacy' import react from '@vitejs/plugin-react-swc' const IS_NIGHTLY = process.env.NIGHTLY?.toLowerCase() === 'true' const builtinVars = () => { return { name: 'built-in-vars', transformIndexHtml(html: string) { return html.replace( /<\/head>/, ``, ) }, } } // https://vitejs.dev/config/ export default defineConfig(({ command, mode }) => { const isDev = command === 'serve' const config = { // root: "/", clearScreen: false, server: { port: 3000, watch: { ignored: ['**/*.scss.d.ts'], }, }, css: { preprocessorOptions: { scss: { api: 'modern-compiler', // @ts-expect-error fucking vite why embedded their own sass types definition???? importer: [ new NodePackageImporter(), // TODO: fix this when vite-sass-dts support it, or fix it when we use `@alias` // (...args: string[]) => { // if (args[0] !== '@/styles') { // return // } // return { // file: `${path.resolve(__dirname, './src/assets/styles')}`, // } // }, ], }, }, }, plugins: [ // tailwindPlugin(), tsconfigPaths(), legacy({ renderLegacyChunks: false, modernTargets: ['edge>=109', 'safari>=13'], modernPolyfills: true, additionalModernPolyfills: [ 'core-js/modules/es.object.has-own.js', 'core-js/modules/web.structured-clone.js', 'core-js/modules/es.array.at.js', ], }), createHtmlPlugin({ inject: { data: { title: 'Clash Nyanpasu', injectScript: mode === 'development' ? '' : '', }, }, }), builtinVars(), tanstackRouter({ target: 'react', autoCodeSplitting: true, routesDirectory: `src/pages`, generatedRouteTree: `src/route-tree.gen.ts`, routeFileIgnorePattern: '_modules', }), svgr(), react({ // babel: { // plugins: ["@emotion/babel-plugin"], // }, }), AutoImport({ resolvers: [ IconsResolver({ prefix: 'Icon', extension: 'jsx', }), ], }), Icons({ compiler: 'jsx', // or 'solid' }), sassDts({ esmExport: true }), paraglideVitePlugin({ project: './project.inlang', outdir: './src/paraglide', strategy: ['custom-extension'], }), ], resolve: { alias: { '@repo': path.resolve('../../'), '@nyanpasu/ui': path.resolve('../ui/src'), '@nyanpasu/interface': path.resolve('../interface/src'), }, }, optimizeDeps: { entries: ['./src/main.tsx'], include: ['@emotion/styled'], }, esbuild: { drop: isDev ? undefined : ['debugger'], pure: isDev || IS_NIGHTLY ? [] : ['console.log'], }, build: { outDir: '../../backend/tauri/tmp/dist', rollupOptions: { output: { manualChunks: { jsonWorker: [`monaco-editor/esm/vs/language/json/json.worker`], tsWorker: [`monaco-editor/esm/vs/language/typescript/ts.worker`], editorWorker: [`monaco-editor/esm/vs/editor/editor.worker`], yamlWorker: [`monaco-yaml/yaml.worker`], }, }, }, emptyOutDir: true, sourcemap: isDev || IS_NIGHTLY ? 'inline' : false, }, define: { OS_PLATFORM: `"${process.platform}"`, WIN_PORTABLE: !!process.env.VITE_WIN_PORTABLE, }, html: {}, } satisfies UserConfig // fucking vite why embedded their own sass types definition???? // oxlint-disable-next-line typescript/no-explicit-any return config as any as UserConfig }) ================================================ FILE: frontend/ui/package.json ================================================ { "name": "@nyanpasu/ui", "version": "2.0.0", "type": "module", "main": "./src/index.ts", "files": [ "dist", "src" ], "scripts": { "build": "vite build --sourcemap" }, "dependencies": { "@material/material-color-utilities": "0.4.0", "@mui/icons-material": "7.3.9", "@mui/lab": "7.0.0-beta.17", "@mui/material": "7.3.9", "@radix-ui/react-portal": "1.1.10", "@radix-ui/react-scroll-area": "1.2.10", "@tauri-apps/api": "2.10.1", "@types/d3": "7.4.3", "@types/react": "19.2.14", "@vitejs/plugin-react": "5.2.0", "ahooks": "3.9.6", "d3": "7.9.0", "framer-motion": "12.38.0", "react": "19.2.4", "react-dom": "19.2.4", "react-error-boundary": "6.0.0", "react-i18next": "15.7.4", "react-use": "17.6.0", "tailwindcss": "4.2.2", "vite": "7.3.1", "vite-tsconfig-paths": "6.1.1" }, "devDependencies": { "@emotion/react": "11.14.0", "@types/d3-interpolate-path": "2.0.3", "clsx": "2.1.1", "d3-interpolate-path": "2.3.0", "sass-embedded": "1.98.0", "tailwind-merge": "3.5.0", "typescript-plugin-css-modules": "5.2.0", "vite-plugin-dts": "4.5.4" } } ================================================ FILE: frontend/ui/src/chart/index.ts ================================================ export * from './sparkline' ================================================ FILE: frontend/ui/src/chart/sparkline.tsx ================================================ // oxlint-disable typescript/no-explicit-any import * as d3 from 'd3' import { interpolatePath } from 'd3-interpolate-path' import { CSSProperties, FC, useEffect, useMemo, useRef } from 'react' import { alpha, useColorScheme, useTheme } from '@mui/material' interface SparklineProps { data: number[] className?: string style?: CSSProperties visible?: boolean } export const Sparkline: FC = ({ data, className, style, visible = true, }) => { const theme = useTheme() const { mode } = useColorScheme() const lineColor = useMemo( () => mode === 'dark' ? alpha(theme.colorSchemes.light!.palette.primary.main, 0.7) : alpha(theme.colorSchemes.dark!.palette.primary.main, 0.7), [mode, theme], ) const areaColor = useMemo( () => mode === 'dark' ? alpha(theme.colorSchemes.light!.palette.primary.main, 0.1) : alpha(theme.colorSchemes.dark!.palette.primary.main, 0.1), [mode, theme], ) const svgRef = useRef(null) useEffect(() => { if (!svgRef.current) return const svg = d3.select(svgRef.current) const { width, height } = svg.node()?.getBoundingClientRect() ?? { width: 0, height: 0, } const maxHeight = () => { const dataRange = d3.max(data)! - d3.min(data)! if (dataRange / d3.max(data)! < 0.1) { return height * 0.65 } if (d3.max(data)) { return height * 0.35 } else { return height } } const xScale = d3 .scaleLinear() .domain([0, data.length - 1]) .range([0, width]) const yScale = d3 .scaleLinear() .domain([0, d3.max(data) ?? 0]) .range([height, maxHeight()]) const line = d3 .line() .x((d, i) => xScale(i)) .y((d) => yScale(d)) .curve(d3.curveCatmullRom.alpha(0.5)) const area = d3 .area() .x((d, i) => xScale(i)) .y0(height) .y1((d) => yScale(d)) .curve(d3.curveCatmullRom.alpha(0.5)) svg.selectAll('*').remove() svg .append('path') .datum(data) .attr('class', 'area') .attr('fill', areaColor) .attr('d', area) svg .append('path') .datum(data) .attr('class', 'line') .attr('fill', 'none') .attr('stroke', lineColor) .attr('stroke-width', 2) .attr('d', line) const updateChart = () => { // Skip animation if component is not visible to prevent performance issues if (!visible) { // Update without animation svg.select('.area').datum(data).attr('d', area) svg.select('.line').datum(data).attr('d', line) return } xScale.domain([0, data.length - 1]) yScale.domain([0, d3.max(data) ?? 0]) const t = svg.transition().duration(750).ease(d3.easeCubic) svg .select('.area') .datum(data) .transition(t as any) .attrTween('d', function (d) { const previous = d3.select(this).attr('d') const current = area(d) return interpolatePath(previous, current as string) }) svg .select('.line') .datum(data) .transition(t as any) .attrTween('d', function (d) { const previous = d3.select(this).attr('d') const current = line(d) return interpolatePath(previous, current as string) }) } updateChart() }, [data, lineColor, areaColor, visible]) return ( ) } ================================================ FILE: frontend/ui/src/hooks/get-system.ts ================================================ type Platform = | 'aix' | 'android' | 'darwin' | 'freebsd' | 'haiku' | 'linux' | 'openbsd' | 'sunos' | 'win32' | 'cygwin' | 'netbsd' declare const OS_PLATFORM: Platform | undefined // get the system os // according to UA export function getSystem() { const ua = typeof window === 'undefined' ? '' : window.navigator?.userAgent const platform = typeof OS_PLATFORM !== 'undefined' ? OS_PLATFORM : 'unknown' if (ua.includes('Mac OS X') || platform === 'darwin') return 'macos' if (/win64|win32/i.test(ua) || platform === 'win32') return 'windows' if (/linux/i.test(ua)) return 'linux' return 'unknown' } ================================================ FILE: frontend/ui/src/hooks/index.ts ================================================ export * from './use-breakpoint' export * from './use-click-position' export * from './get-system' ================================================ FILE: frontend/ui/src/hooks/use-breakpoint.ts ================================================ import { useAsyncEffect } from 'ahooks' import { RefObject, useEffect, useMemo, useState } from 'react' import { createBreakpoint } from 'react-use' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import { MUI_BREAKPOINTS } from '../materialYou/themeConsts.mjs' export type Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl' const breakpointsOrder: Breakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl'] const BREAKPOINT_VALUES = MUI_BREAKPOINTS.values as Record export const useBreakpoint = createBreakpoint( BREAKPOINT_VALUES, ) as () => Breakpoint type BreakpointEffectCallback = (currentBreakpoint: Breakpoint) => void export const useBreakpointEffect = (callback: BreakpointEffectCallback) => { const currentBreakpoint = useBreakpoint() useEffect(() => { callback(currentBreakpoint) }, [currentBreakpoint, callback]) } type BreakpointValues = Partial> export const useBreakpointValue = ( values: BreakpointValues, defaultValue?: T, ): T => { const currentBreakpoint = useBreakpoint() const calculateValue = (): T => { const value = values[currentBreakpoint] if (value !== undefined) { return value as T } const currentIndex = breakpointsOrder.indexOf(currentBreakpoint) for (let i = currentIndex; i >= 0; i--) { const fallbackValue = values[breakpointsOrder[i]] if (fallbackValue !== undefined) { return fallbackValue as T } } return defaultValue ?? (values[breakpointsOrder[0]] as T) } const [result, setResult] = useState(calculateValue) useAsyncEffect(async () => { const appWindow = getCurrentWebviewWindow() if (!(await appWindow.isMinimized())) { if (result !== calculateValue) { setResult(calculateValue) } } }, [currentBreakpoint, values, defaultValue]) return result } const getBreakpointFromWidth = (width: number): Breakpoint => { for (let i = breakpointsOrder.length - 1; i >= 0; i--) { const bp = breakpointsOrder[i] if (width >= BREAKPOINT_VALUES[bp]) { return bp } } return 'xs' } export const useContainerBreakpoint = ( containerRef: RefObject, ): Breakpoint => { const [breakpoint, setBreakpoint] = useState(() => { if (containerRef.current) { return getBreakpointFromWidth(containerRef.current.offsetWidth) } return 'md' }) useEffect(() => { const element = containerRef.current if (!element) { return } const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const width = entry.contentRect.width const newBreakpoint = getBreakpointFromWidth(width) setBreakpoint(newBreakpoint) } }) resizeObserver.observe(element) return () => { resizeObserver.disconnect() } }, [containerRef]) return breakpoint } export const useContainerBreakpointValue = ( containerRef: RefObject, values: BreakpointValues, defaultValue?: T, ): T => { const currentBreakpoint = useContainerBreakpoint(containerRef) const memoizedValue = useMemo(() => { const value = values[currentBreakpoint] if (value !== undefined) { return value as T } const currentIndex = breakpointsOrder.indexOf(currentBreakpoint) for (let i = currentIndex; i >= 0; i--) { const fallbackValue = values[breakpointsOrder[i]] if (fallbackValue !== undefined) { return fallbackValue as T } } return defaultValue ?? (values[breakpointsOrder[0]] as T) }, [currentBreakpoint, values, defaultValue]) return memoizedValue } ================================================ FILE: frontend/ui/src/hooks/use-click-position.ts ================================================ import { useSessionStorageState } from 'ahooks' import { useLayoutEffect } from 'react' export interface MousePosition { x: number y: number } export const useClickPosition = () => { const [mousePosition, setMousePosition] = useSessionStorageState< MousePosition | undefined >('use-click-position', { defaultValue: { x: 0, y: 0, }, }) useLayoutEffect(() => { const updateMousePosition = (ev: MouseEvent) => { setMousePosition({ x: ev.clientX, y: ev.clientY, }) } document.addEventListener('click', updateMousePosition, true) return () => { document.removeEventListener('click', updateMousePosition, true) } }, [setMousePosition]) return mousePosition } ================================================ FILE: frontend/ui/src/index.ts ================================================ if (typeof window === 'undefined') { // eslint-disable-next-line @typescript-eslint/no-explicit-any global.window = {} as any } export * from './chart' export * from './hooks' export * from './materialYou' export * from './utils' ================================================ FILE: frontend/ui/src/materialYou/components/baseCard/index.tsx ================================================ import { AnimatePresence, motion } from 'framer-motion' import { ReactNode } from 'react' import { cn } from '@/utils' import { Box, Card, CardContent, CircularProgress, Typography, } from '@mui/material' import style from './style.module.scss' export const BaseCard = ({ label, labelChildren, loading, children, }: { label?: string labelChildren?: ReactNode loading?: boolean children?: ReactNode }) => { return ( {label && ( {label} {labelChildren} )} {children} {loading && ( )} ) } ================================================ FILE: frontend/ui/src/materialYou/components/baseCard/style.module.d.scss.ts ================================================ declare const classNames: { readonly LoadingMask: 'LoadingMask' } export default classNames ================================================ FILE: frontend/ui/src/materialYou/components/baseCard/style.module.scss ================================================ .LoadingMask { position: absolute; top: 0; left: 0; z-index: 1; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; border-radius: 24px; backdrop-filter: blur(4px); } ================================================ FILE: frontend/ui/src/materialYou/components/baseCard/style.module.scss.d.ts ================================================ declare const classNames: { readonly LoadingMask: 'LoadingMask' } export default classNames ================================================ FILE: frontend/ui/src/materialYou/components/baseDialog/index.tsx ================================================ import { useLockFn } from 'ahooks' import useDebounceFn from 'ahooks/lib/useDebounceFn' import { AnimatePresence, motion } from 'framer-motion' import { CSSProperties, ReactNode, useEffect, useLayoutEffect, useState, } from 'react' import { useTranslation } from 'react-i18next' import { getSystem, useClickPosition } from '@/hooks' import { alpha, cn } from '@/utils' import { Box, Button, Divider } from '@mui/material' import { useColorScheme } from '@mui/material/styles' import * as Portal from '@radix-ui/react-portal' const OS = getSystem() export interface BaseDialogProps { title: ReactNode open: boolean close?: string ok?: string disabledOk?: boolean contentStyle?: CSSProperties children?: ReactNode loading?: boolean full?: boolean onOk?: () => void | Promise onClose?: () => void divider?: boolean } export const BaseDialog = ({ title, open, close, onClose, children, contentStyle, disabledOk, loading, full, onOk, ok, divider, }: BaseDialogProps) => { const { t } = useTranslation() const { mode } = useColorScheme() const [mounted, setMounted] = useState(false) const [offset, setOffset] = useState({ x: 0, y: 0, }) const [okLoading, setOkLoading] = useState(false) const [closeLoading, setCloseLoading] = useState(false) const { run: runMounted, cancel: cancelMounted } = useDebounceFn( () => setMounted(false), { wait: 300 }, ) const clickPosition = useClickPosition() const getClickPosition = () => clickPosition useLayoutEffect(() => { if (open) { setOffset({ x: getClickPosition()?.x ?? 0, y: getClickPosition()?.y ?? 0, }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) const handleClose = useLockFn(async () => { if (onClose) { if (onClose.constructor.name === 'AsyncFunction') { try { setCloseLoading(true) await onClose() } finally { setCloseLoading(false) } } else { onClose() } } runMounted() }) const handleOk = useLockFn(async () => { if (!onOk) return if (onOk.constructor.name === 'AsyncFunction') { try { setOkLoading(true) await onOk() } finally { setOkLoading(false) } } else { onOk() } }) useEffect(() => { if (open) { setMounted(true) cancelMounted() } else { handleClose() } }, [cancelMounted, handleClose, open]) return ( {mounted && ( {!full && ( ({ backgroundColor: alpha( theme.vars.palette.primary.main, 0.1, ), })), ]} animate={open ? 'open' : 'closed'} initial={{ opacity: 0 }} variants={{ open: { opacity: 1 }, closed: { opacity: 0 }, }} onClick={handleClose} /> )} ({ backgroundColor: theme.vars.palette.background.default, })} animate={open ? 'open' : 'closed'} initial={{ opacity: 0.3, scale: 0, x: offset.x - window.innerWidth / 2, y: offset.y - window.innerHeight / 2, translateX: '-50%', translateY: '-50%', }} variants={{ open: { opacity: 1, scale: 1, x: 0, y: 0, }, closed: { opacity: 0.3, scale: 0, x: offset.x - window.innerWidth / 2, y: offset.y - window.innerHeight / 2, }, }} transition={{ type: 'spring', bounce: 0, duration: 0.35, }} >
{title}
{divider && }
{children}
{divider && }
{onClose && ( )} {onOk && ( )}
)}
) } ================================================ FILE: frontend/ui/src/materialYou/components/basePage/baseErrorBoundary.tsx ================================================ import { ReactNode } from 'react' import { ErrorBoundary, FallbackProps } from 'react-error-boundary' function ErrorFallback({ error }: FallbackProps) { console.log(error) return (

Something went wrong:(

{error.message}
Error Stack
{error.stack}
) } interface Props { children?: ReactNode } export const BaseErrorBoundary = (props: Props) => { return ( {props.children} ) } ================================================ FILE: frontend/ui/src/materialYou/components/basePage/header.tsx ================================================ import { FC, memo, ReactNode } from 'react' export const Header: FC<{ title?: ReactNode; header?: ReactNode }> = memo( function Header({ title, header, }: { title?: ReactNode header?: ReactNode }) { return (

{title}

{header}
) }, ) export default Header ================================================ FILE: frontend/ui/src/materialYou/components/basePage/index.tsx ================================================ import { motion } from 'framer-motion' import { CSSProperties, FC, ReactNode, Ref, Suspense } from 'react' import { cn } from '@/utils' import * as ScrollArea from '@radix-ui/react-scroll-area' import { BaseErrorBoundary } from './baseErrorBoundary' import Header from './header' import './style.scss' interface BasePageProps { title?: ReactNode header?: ReactNode contentStyle?: CSSProperties sectionStyle?: CSSProperties full?: boolean viewportRef?: Ref children?: ReactNode } export const BasePage: FC = ({ title, header, contentStyle, sectionStyle, full, viewportRef, children, }) => { return (
div]:!block', full ?? 'p-6', )} ref={viewportRef} style={sectionStyle} > {children} {/* */}
) } export const ScrollAreaViewport = ScrollArea.Viewport ================================================ FILE: frontend/ui/src/materialYou/components/basePage/style.scss ================================================ .MDYBasePage { display: flex; flex-direction: column; width: 100%; height: 100%; > header { box-sizing: border-box; display: flex; align-items: end; justify-content: space-between; width: 100%; height: 72px; padding-bottom: 12px; margin: 0 auto; } .MDYBasePage-container { background-color: var(--background-color); .ScrollArea-Thumb { background-color: var(--scroller-color); } } } ================================================ FILE: frontend/ui/src/materialYou/components/expand/index.tsx ================================================ import { motion } from 'framer-motion' import { ReactNode } from 'react' /** * @example * * * @returns {React.JSX.Element} * React.JSX.Element * * `With motion support.` * * @author keiko233 * @copyright LibNyanpasu org. 2024 */ export const Expand = ({ open, children, }: { open: boolean children: ReactNode }): React.JSX.Element => { return ( {children} ) } ================================================ FILE: frontend/ui/src/materialYou/components/expandMore/index.tsx ================================================ import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import IconButton, { IconButtonProps } from '@mui/material/IconButton' import { useTheme } from '@mui/material/styles' interface ExpandMoreProps extends IconButtonProps { expand: boolean reverse?: boolean } /** * @example * setExpand(!expand)} /> * * `Built-in a small arrow icon.` * * @author keiko233 * @copyright LibNyanpasu org. 2024 */ export const ExpandMore = ({ expand, reverse, ...props }: ExpandMoreProps) => { const { transitions } = useTheme() return ( ) } ================================================ FILE: frontend/ui/src/materialYou/components/floatingButton/index.tsx ================================================ import { ReactNode } from 'react' import { alpha, cn } from '@/utils' import { Button, ButtonProps } from '@mui/material' export interface FloatingButtonProps extends ButtonProps { children: ReactNode className?: string } export const FloatingButton = ({ children, className, ...props }: FloatingButtonProps) => { return ( ) } ================================================ FILE: frontend/ui/src/materialYou/components/index.ts ================================================ export * from './baseCard' export * from './baseDialog' export * from './basePage' export * from './expand' export * from './expandMore' export * from './floatingButton' export * from './item' export * from './kbd' export * from './lazyImage' export * from './loadingButton' export * from './loadingSwitch' export * from './sidePage' ================================================ FILE: frontend/ui/src/materialYou/components/item/baseItem.tsx ================================================ import { FC, memo, ReactNode } from 'react' import { SxProps } from '@mui/material' import ListItem from '@mui/material/ListItem' import ListItemText from '@mui/material/ListItemText' export interface BaseItemProps { title: ReactNode children: ReactNode sxItem?: SxProps sxItemText?: SxProps } export const BaseItem: FC = memo(function BaseItem({ title, children, sxItem, sxItemText, }: BaseItemProps) { return ( {children} ) }) ================================================ FILE: frontend/ui/src/materialYou/components/item/index.ts ================================================ export * from './switchItem' export * from './menuItem' export * from './numberItem' export * from './textItem' ================================================ FILE: frontend/ui/src/materialYou/components/item/menuItem.tsx ================================================ import { MenuItem as MuiMenuItem, Select, SxProps } from '@mui/material' import { BaseItem } from './baseItem' type OptionValue = string | number | boolean export interface MenuItemProps { label: string options: Record selected: OptionValue onSelected: (value: OptionValue) => void selectSx?: SxProps disabled?: boolean } /** * @example * { console.log(value); }} selectSx={{ width: 100 }} /> * * @returns {React.JSX.Element} * React.JSX.Element * * `MenuItem extends MuiMenuItem. Support options api.` * * @author keiko233 * @copyright LibNyanpasu org. 2024 */ export const MenuItem = ({ label, options, selected, onSelected, selectSx, disabled, }: MenuItemProps) => { return ( ) } export default MenuItem ================================================ FILE: frontend/ui/src/materialYou/components/item/numberItem.tsx ================================================ import { ChangeEvent, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Done from '@mui/icons-material/Done' import { Box, Button, Divider, TextField, TextFieldProps, Typography, } from '@mui/material' import { Expand } from '../expand' import { BaseItem } from './baseItem' export interface NumberItemProps { label: string value: number checkEvent: (input: number) => boolean checkLabel: string onApply: (input: number) => void divider?: boolean textFieldProps?: TextFieldProps } /** * @example * input > 65535 || input < 1} checkLabel="Port must be between 1 and 65535." onApply={(value) => { setConfigs({ "mixed-port": value }); }} /> * * @returns {React.JSX.Element} * React.JSX.Element * * `NumberItem most use for port label.` * * @author keiko233 * @copyright LibNyanpasu org. 2024 */ export const NumberItem = ({ label, value, checkEvent, checkLabel, onApply, divider, textFieldProps, }: NumberItemProps) => { const { t } = useTranslation() const [changed, setChanged] = useState(false) const [input, setInput] = useState(null) const applyCheck = useMemo( () => checkEvent(input as number), [checkEvent, input], ) return ( <> ) => { setInput(Number(e.target.value)) setChanged(true) }} {...textFieldProps} /> {applyCheck && ( {checkLabel} )} {divider && } ) } ================================================ FILE: frontend/ui/src/materialYou/components/item/switchItem.tsx ================================================ import { ChangeEvent, useState } from 'react' import { SwitchProps } from '@mui/material' import LoadingSwitch from '../loadingSwitch' import { BaseItem } from './baseItem' interface Props extends SwitchProps { label: string onChange?: ( event: ChangeEvent, checked: boolean, ) => Promise | void } export const SwitchItem = ({ label, onChange, ...switchProps }: Props) => { const [loading, setLoading] = useState(false) const handleChange = async ( event: ChangeEvent, checked: boolean, ) => { if (onChange) { try { setLoading(true) await onChange(event, checked) } finally { setLoading(false) } } } return ( ) } ================================================ FILE: frontend/ui/src/materialYou/components/item/textItem.tsx ================================================ import { useState } from 'react' import { useTranslation } from 'react-i18next' import Done from '@mui/icons-material/Done' import Box from '@mui/material/Box' import Button from '@mui/material/Button' import ListItem from '@mui/material/ListItem' import TextField from '@mui/material/TextField' import { Expand } from '../expand' export interface TextItemProps { value: string label: string onApply: (value: string) => void applyLabel?: string placeholder?: string } export const TextItem = ({ value, label, onApply, applyLabel, placeholder, }: TextItemProps) => { const { t } = useTranslation() const [textString, setTextString] = useState(value) return ( <> setTextString(e.target.value)} placeholder={placeholder} /> ) } ================================================ FILE: frontend/ui/src/materialYou/components/kbd/index.module.d.scss.ts ================================================ declare const classNames: { readonly kbd: 'kbd' readonly dark: 'dark' } export default classNames ================================================ FILE: frontend/ui/src/materialYou/components/kbd/index.module.scss ================================================ .kbd { padding-right: 0.4em; padding-left: 0.4em; font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-weight: bold; white-space: nowrap; background-color: #edf2f7; border-color: #e2e8f0; border-style: solid; border-width: 1px; border-bottom-width: 3px; border-radius: 0.375rem; &.dark { background-color: rgb(255 255 255 / 6%); border-color: rgb(255 255 255 / 16%); } } ================================================ FILE: frontend/ui/src/materialYou/components/kbd/index.module.scss.d.ts ================================================ declare const classNames: { readonly kbd: 'kbd' readonly dark: 'dark' } export default classNames ================================================ FILE: frontend/ui/src/materialYou/components/kbd/index.tsx ================================================ import { cn } from '@/utils' import { useColorScheme } from '@mui/material' import styles from './index.module.scss' export type Props = React.DetailedHTMLProps< React.HTMLAttributes, HTMLElement > export function Kbd({ className, children, ...rest }: Props) { const { mode } = useColorScheme() return ( {children} ) } ================================================ FILE: frontend/ui/src/materialYou/components/lazyImage/index.tsx ================================================ import { useState } from 'react' import { cn } from '@/utils' export interface LazyImageProps extends React.ImgHTMLAttributes { loadingClassName?: string } export function LazyImage({ className, loadingClassName, ...others }: LazyImageProps) { const [loading, setLoading] = useState(true) return ( <>
setLoading(false)} className={cn(className, loading ? 'hidden' : 'inline-block')} /> ) } ================================================ FILE: frontend/ui/src/materialYou/components/loadingButton/index.tsx ================================================ import { useControllableValue } from 'ahooks' import { MouseEventHandler } from 'react' import { Button as MuiButton, ButtonProps as MuiButtonProps, } from '@mui/material' export interface LoadingButtonProps extends Omit { onClick?: MouseEventHandler } export const LoadingButton = ({ loading, onClick, ...props }: LoadingButtonProps) => { const [pending, setPending] = useControllableValue( { loading }, { defaultValue: false, }, ) const handleClick: MouseEventHandler = async (e) => { if (onClick) { setPending(true) try { await onClick(e) } catch (error) { console.error(error) } finally { setPending(false) } } } return } ================================================ FILE: frontend/ui/src/materialYou/components/loadingSwitch/index.tsx ================================================ import CircularProgress from '@mui/material/CircularProgress' import Switch, { SwitchProps } from '@mui/material/Switch' import style from './style.module.scss' interface LoadingSwitchProps extends SwitchProps { loading?: boolean } /** * @example * * * `Support loading status.` * * @author keiko233 * @copyright LibNyanpasu org. 2024 */ export const LoadingSwitch = ({ loading, checked, disabled, ...props }: LoadingSwitchProps) => { return (
{loading && ( )}
) } export default LoadingSwitch ================================================ FILE: frontend/ui/src/materialYou/components/loadingSwitch/style.module.d.scss.ts ================================================ declare const classNames: { readonly 'MDYSwitch-container': 'MDYSwitch-container' readonly CircularProgress: 'CircularProgress' readonly 'CircularProgress-checked': 'CircularProgress-checked' } export default classNames ================================================ FILE: frontend/ui/src/materialYou/components/loadingSwitch/style.module.scss ================================================ .MDYSwitch-container { position: relative; .CircularProgress { position: absolute; top: 8px; left: 8px; z-index: 1; cursor: not-allowed; } .CircularProgress-checked { position: absolute; top: 8px; right: 7px; z-index: 1; cursor: not-allowed; } } ================================================ FILE: frontend/ui/src/materialYou/components/loadingSwitch/style.module.scss.d.ts ================================================ declare const classNames: { readonly 'MDYSwitch-container': 'MDYSwitch-container' readonly CircularProgress: 'CircularProgress' readonly 'CircularProgress-checked': 'CircularProgress-checked' } export default classNames ================================================ FILE: frontend/ui/src/materialYou/components/sidePage/index.tsx ================================================ import { motion } from 'framer-motion' import { FC, ReactNode, Ref } from 'react' import { cn } from '@/utils' import * as ScrollArea from '@radix-ui/react-scroll-area' import { BaseErrorBoundary } from '../basePage/baseErrorBoundary' import Header from '../basePage/header' import style from './style.module.scss' interface Props { title?: ReactNode header?: ReactNode children?: ReactNode sideBar?: ReactNode side?: ReactNode sideClassName?: string portalRightRoot?: ReactNode noChildrenScroll?: boolean flexReverse?: boolean leftViewportRef?: Ref rightViewportRef?: Ref } export const SidePage: FC = ({ title, header, children, sideBar, side, sideClassName, portalRightRoot, flexReverse, leftViewportRef, rightViewportRef, }) => { const sideBarStyle = { height: sideBar ? 'calc(100% - 56px)' : undefined, } return (
{sideBar &&
{sideBar}
} div]:!block', sideClassName, )} style={sideBarStyle} ref={leftViewportRef} > {side}
{portalRightRoot} div]:!block')} ref={rightViewportRef} > {children}
) } ================================================ FILE: frontend/ui/src/materialYou/components/sidePage/style.module.d.scss.ts ================================================ declare const classNames: { readonly 'MDYSidePage-Main': 'MDYSidePage-Main' readonly 'MDYSidePage-Container': 'MDYSidePage-Container' readonly 'Container-common': 'Container-common' readonly 'ScrollArea-Thumb': 'ScrollArea-Thumb' } export default classNames ================================================ FILE: frontend/ui/src/materialYou/components/sidePage/style.module.scss ================================================ @reference "tailwindcss"; .MDYSidePage-Main { display: flex; flex-direction: column; width: 100%; height: 100%; > header { box-sizing: border-box; display: flex; flex: 0 0 64px; align-items: center; justify-content: space-between; width: 100%; margin: 0 auto; } .MDYSidePage-Container { height: 100%; overflow: hidden; } } .Container-common { @apply relative h-full overflow-hidden rounded-3xl; background-color: var(--background-color); } .ScrollArea-Thumb { background-color: var(--scroller-color); } ================================================ FILE: frontend/ui/src/materialYou/components/sidePage/style.module.scss.d.ts ================================================ declare const classNames: { readonly 'MDYSidePage-Main': 'MDYSidePage-Main' readonly 'MDYSidePage-Container': 'MDYSidePage-Container' readonly 'Container-common': 'Container-common' readonly 'ScrollArea-Thumb': 'ScrollArea-Thumb' } export default classNames ================================================ FILE: frontend/ui/src/materialYou/createTheme.ts ================================================ import { RecursivePartial } from '@/utils' import { argbFromHex, hexFromArgb, themeFromSourceColor, } from '@material/material-color-utilities' import { createTheme, Palette } from '@mui/material/styles' import { MuiButton, MuiCard, MuiCardContent, MuiDialog, MuiDialogActions, MuiDialogContent, MuiDialogTitle, MuiLinearProgress, MuiMenu, MuiPaper, MuiSwitch, MuiToggleButtonGroup, } from './themeComponents' import { MUI_BREAKPOINTS } from './themeConsts.mjs' export const createMDYTheme = (color: string, fontFamily?: string) => { const materialColor = themeFromSourceColor(argbFromHex(color)) const generatePalette = (mode: 'light' | 'dark') => { return { primary: { main: hexFromArgb(materialColor.schemes[mode].primary), }, secondary: { main: hexFromArgb(materialColor.schemes[mode].secondary), }, error: { main: hexFromArgb(materialColor.schemes[mode].error), }, text: { primary: hexFromArgb(materialColor.schemes[mode].onPrimaryContainer), secondary: hexFromArgb( materialColor.schemes[mode].onSecondaryContainer, ), }, } satisfies RecursivePartial } const colorSchemes = { light: { palette: generatePalette('light'), }, dark: { palette: generatePalette('dark'), }, } console.log(colorSchemes) const theme = createTheme( { cssVariables: { colorSchemeSelector: 'class', }, colorSchemes: { light: true, dark: true, }, typography: { fontFamily, }, components: { MuiButton, MuiToggleButtonGroup, MuiCard, MuiCardContent, MuiDialog, MuiDialogActions, MuiDialogContent, MuiDialogTitle, MuiLinearProgress, MuiMenu, MuiPaper, MuiSwitch, }, breakpoints: MUI_BREAKPOINTS, }, { colorSchemes, }, ) return theme } ================================================ FILE: frontend/ui/src/materialYou/index.ts ================================================ export * from './createTheme' export * from './components' ================================================ FILE: frontend/ui/src/materialYou/themeComponents/MuiButton.ts ================================================ import { Theme } from '@mui/material' import { Components } from '@mui/material/styles' export const MuiButton: Components['MuiButton'] = { styleOverrides: { root: { borderRadius: '48px', }, }, } ================================================ FILE: frontend/ui/src/materialYou/themeComponents/MuiCard.ts ================================================ import { Theme } from '@mui/material' import { Components } from '@mui/material/styles' export const MuiCard: Components['MuiCard'] = { defaultProps: { sx: { borderRadius: 6, elevation: 0, }, }, } ================================================ FILE: frontend/ui/src/materialYou/themeComponents/MuiCardContent.ts ================================================ import { Theme } from '@mui/material' import { Components } from '@mui/material/styles' export const MuiCardContent: Components['MuiCardContent'] = { defaultProps: { sx: { padding: 3, }, }, } ================================================ FILE: frontend/ui/src/materialYou/themeComponents/MuiDialog.ts ================================================ import { Theme } from '@mui/material' import { Components } from '@mui/material/styles' export const MuiDialog: Components['MuiDialog'] = { styleOverrides: { paper: { borderRadius: 24, }, }, } ================================================ FILE: frontend/ui/src/materialYou/themeComponents/MuiDialogActions.ts ================================================ import { Theme } from '@mui/material' import { Components } from '@mui/material/styles' export const MuiDialogActions: Components['MuiDialogActions'] = { styleOverrides: { root: { padding: 24, }, }, } ================================================ FILE: frontend/ui/src/materialYou/themeComponents/MuiDialogContent.ts ================================================ import { Theme } from '@mui/material' import { Components } from '@mui/material/styles' export const MuiDialogContent: Components['MuiDialogContent'] = { styleOverrides: { root: { padding: '0 24px', }, }, } ================================================ FILE: frontend/ui/src/materialYou/themeComponents/MuiDialogTitle.ts ================================================ import { Theme } from '@mui/material' import { Components } from '@mui/material/styles' export const MuiDialogTitle: Components['MuiDialogTitle'] = { styleOverrides: { root: { padding: 24, }, }, } ================================================ FILE: frontend/ui/src/materialYou/themeComponents/MuiLinearProgress.ts ================================================ import { Theme } from '@mui/material' import { Components } from '@mui/material/styles' export const MuiLinearProgress: Components['MuiLinearProgress'] = { styleOverrides: { root: { height: '8px', borderRadius: '8px', }, bar: { borderRadius: '8px', }, }, } ================================================ FILE: frontend/ui/src/materialYou/themeComponents/MuiMenu.ts ================================================ import { Theme } from '@mui/material' import { Components } from '@mui/material/styles' export const MuiMenu: Components['MuiMenu'] = { styleOverrides: { paper: ({ theme }) => ({ boxShadow: `${theme.shadows[8]} !important`, }), }, } ================================================ FILE: frontend/ui/src/materialYou/themeComponents/MuiPaper.ts ================================================ import { Theme } from '@mui/material' import { Components } from '@mui/material/styles' export const MuiPaper: Components['MuiPaper'] = { styleOverrides: { root: () => ({ boxShadow: 'none', }), }, } ================================================ FILE: frontend/ui/src/materialYou/themeComponents/MuiSwitch.ts ================================================ import { Theme } from '@mui/material' import { Components } from '@mui/material/styles' import type {} from '@mui/material/themeCssVarsAugmentation' export const MuiSwitch: Components['MuiSwitch'] = { styleOverrides: { root: ({ theme }) => ({ padding: 0, margin: 0, '& .Mui-checked': { '& .MuiSwitch-thumb': { color: theme.vars.palette.grey.A100, }, }, '&:has(.Mui-checked) .MuiSwitch-track::before': { opacity: 0, }, '&:has(.Mui-disabled) .MuiSwitch-track': { opacity: '0.5 !important', cursor: 'not-allowed', }, variants: [ { props: { size: 'medium', }, style: { height: 32, '& .MuiSwitch-switchBase': { padding: '6px', }, '& .MuiSwitch-thumb': { width: 14, height: 14, margin: 3, }, '& .Mui-checked': { '&.MuiSwitch-switchBase': { marginLeft: '6px', }, '& .MuiSwitch-thumb': { width: 24, height: 24, margin: -2, }, }, }, }, { props: { size: 'small', }, style: { height: 24, '& .MuiSwitch-switchBase': { padding: '3px', }, '& .MuiSwitch-thumb': { width: 12, height: 12, margin: 3, }, '& .Mui-checked': { '&.MuiSwitch-switchBase': { marginLeft: '1px', }, '& .MuiSwitch-thumb': { width: 17, height: 17, margin: 0, }, }, }, }, ], }), track: ({ theme }) => ({ borderRadius: '48px', backgroundColor: theme.vars.palette.grey.A200, opacity: `1 !important`, ...theme.applyStyles('dark', { backgroundColor: theme.vars.palette.grey.A700, opacity: `0.7 !important`, }), '&::before': { content: '""', border: `solid 2px ${theme.vars.palette.grey.A700}`, width: '100%', height: '100%', opacity: 1, position: 'absolute', borderRadius: 'inherit', boxSizing: 'border-box', transitionProperty: 'opacity, background-color', transitionTimingFunction: 'linear', transitionDuration: '100ms', }, }), thumb: ({ theme }) => ({ boxShadow: 'none', color: theme.vars.palette.grey.A700, ...theme.applyStyles('dark', { backgroundColor: theme.vars.palette.grey.A200, }), }), }, } ================================================ FILE: frontend/ui/src/materialYou/themeComponents/MuiToggleButtonGroup.ts ================================================ import { alpha, darken } from '@/utils/color-mix' import { Theme } from '@mui/material' import { Components } from '@mui/material/styles' export const MuiToggleButtonGroup: Components['MuiToggleButtonGroup'] = { styleOverrides: { grouped: ({ theme }) => theme.unstable_sx({ fontWeight: 700, height: '2.5em', padding: '0 1.25em', border: `1px solid ${darken(theme.vars.palette.primary.main, 0.09)}`, color: darken(theme.vars.palette.primary.main, 0.2), '&.MuiButton-contained.MuiButton-colorPrimary': { boxShadow: 'none', border: `1px solid ${theme.vars.palette.primary.mainChannel}`, backgroundColor: alpha(theme.vars.palette.primary.main, 0.2), color: theme.vars.palette.primary.main, '&::before': { content: 'none', }, '&:hover': { backgroundColor: alpha(theme.vars.palette.primary.main, 0.3), }, }, }), firstButton: ({ theme }) => theme.unstable_sx({ borderTopLeftRadius: 48, borderBottomLeftRadius: 48, '&.MuiButton-sizeSmall': { paddingLeft: '1.5em', }, '&.MuiButton-sizeMedium': { paddingLeft: '20px', }, '&.MuiButton-sizeLarge': { paddingLeft: '26px', }, }), lastButton: ({ theme }) => theme.unstable_sx({ borderTopRightRadius: 48, borderBottomRightRadius: 48, '&.MuiButton-sizeSmall': { paddingRight: '1.5em', }, '&.MuiButton-sizeMedium': { paddingRight: '20px', }, '&.MuiButton-sizeLarge': { paddingRight: '26px', }, }), }, } satisfies Components['MuiToggleButtonGroup'] ================================================ FILE: frontend/ui/src/materialYou/themeComponents/index.ts ================================================ export * from './MuiButton' export * from './MuiToggleButtonGroup' export * from './MuiPaper' export * from './MuiCard' export * from './MuiCardContent' export * from './MuiSwitch' export * from './MuiDialog' export * from './MuiDialogActions' export * from './MuiDialogContent' export * from './MuiDialogTitle' export * from './MuiLinearProgress' export * from './MuiMenu' ================================================ FILE: frontend/ui/src/materialYou/themeConsts.mjs ================================================ /** @type {import("@mui/material/styles").BreakpointsOptions} */ export const MUI_BREAKPOINTS = { values: { xs: 0, sm: 400, md: 800, lg: 1200, xl: 1600, }, } ================================================ FILE: frontend/ui/src/utils/cn.ts ================================================ import clsx, { type ClassValue } from 'clsx' import { twMerge } from 'tailwind-merge' export const cn = (...classes: ClassValue[]) => twMerge(clsx(...classes)) ================================================ FILE: frontend/ui/src/utils/color-mix.ts ================================================ export const alpha = (color: string, alpha: number) => { return `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(2)}%, transparent ${((1 - alpha) * 100).toFixed(2)}%)` } export const lighten = (color: string, alpha: number) => { return `color-mix(in lch, ${color} ${((1 - alpha) * 100).toFixed(2)}%, white ${(alpha * 100).toFixed(2)}%)` } export const darken = (color: string, alpha: number) => { return `color-mix(in lch, ${color} ${((1 - alpha) * 100).toFixed(2)}%, black ${(alpha * 100).toFixed(2)}%)` } ================================================ FILE: frontend/ui/src/utils/event.ts ================================================ export const cleanDeepClickEvent = ( e: Pick, ) => { e.preventDefault() e.stopPropagation() } ================================================ FILE: frontend/ui/src/utils/index.ts ================================================ export { cn } from './cn' export * from './event' export * from './ts-helper' export * from './color-mix' ================================================ FILE: frontend/ui/src/utils/ts-helper.ts ================================================ export type RecursivePartial = { [P in keyof T]?: T[P] extends (infer U)[] ? RecursivePartial[] : T[P] extends object | undefined ? RecursivePartial : T[P] } ================================================ FILE: frontend/ui/tsconfig.json ================================================ { "compilerOptions": { "baseUrl": ".", "target": "ESNext", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ESNext"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "allowArbitraryExtensions": true, "strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "jsxFactory": "React.createElement", "jsxFragmentFactory": "React.Fragment", "declaration": true, "composite": true, "paths": { "@/*": ["./src/*"], }, "plugins": [{ "name": "typescript-plugin-css-modules" }], "outDir": "./dist", }, "include": ["vite.config.ts", "src/"], } ================================================ FILE: frontend/ui/vite.config.ts ================================================ import { defineConfig } from 'vite' import dts from 'vite-plugin-dts' import tsconfigPaths from 'vite-tsconfig-paths' import react from '@vitejs/plugin-react' const needSourceMap = process.argv.includes('--sourcemap') export default defineConfig({ plugins: [ dts({ // rollupTypes: true, copyDtsFiles: true, staticImport: true, insertTypesEntry: true, compilerOptions: { // sourceMap: needSourceMap, declarationMap: needSourceMap, }, }), react(), tsconfigPaths(), ], build: { lib: { entry: 'src/index.ts', fileName: 'index', formats: ['es'], }, sourcemap: needSourceMap, rollupOptions: { external: ['react', 'react-dom', '@tauri-apps/api'], output: { globals: { react: 'React', 'react-dom': 'ReactDOM', OS_PLATFORM: 'OS_PLATFORM', }, }, }, }, }) ================================================ FILE: knip.config.ts ================================================ import { KnipConfig } from 'knip' export default { entry: [ 'frontend/nyanpasu/src/main.tsx', 'frontend/nyanpasu/src/pages/**/*.tsx', 'scripts/*.{js,ts}', ], project: ['frontend/**/*.{ts,js,jsx,tsx}', 'scripts/**/*.{js,ts}'], } satisfies KnipConfig ================================================ FILE: manifest/site/index.html ================================================ Clash Nyanpasu Manifest Site

Clash Nyanpasu Manifest Site

This is the manifest site for Clash Nyanpasu.

Stable updater channel with Github Proxy

Stable updater channel

Stable updater channel with fixed Webview and Github Proxy, only for Windows

Stable updater channel with fixed Webview, only for Windows

Nightly updater channel with Github Proxy

Nightly updater channel

Nightly updater channel with fixed Webview, only for Windows

Nightly updater channel with fixed Webview and Github Proxy, only for Windows

================================================ FILE: manifest/site/updater/.gitkeep ================================================ ================================================ FILE: manifest/version.json ================================================ { "manifest_version": 1, "latest": { "mihomo": "v1.19.21", "mihomo_alpha": "alpha-dd4eb63", "clash_rs": "v0.9.6", "clash_premium": "2023-09-05-gdcc8d87", "clash_rs_alpha": "0.9.6-alpha+sha.b17ba0a" }, "arch_template": { "mihomo": { "windows-i386": "mihomo-windows-386-{}.zip", "windows-x86_64": "mihomo-windows-amd64-v1-{}.zip", "windows-arm64": "mihomo-windows-arm64-{}.zip", "linux-aarch64": "mihomo-linux-arm64-{}.gz", "linux-amd64": "mihomo-linux-amd64-v1-{}.gz", "linux-i386": "mihomo-linux-386-{}.gz", "darwin-arm64": "mihomo-darwin-arm64-{}.gz", "darwin-x64": "mihomo-darwin-amd64-v1-{}.gz", "linux-armv7": "mihomo-linux-armv5-{}.gz", "linux-armv7hf": "mihomo-linux-armv7-{}.gz" }, "mihomo_alpha": { "windows-i386": "mihomo-windows-386-{}.zip", "windows-x86_64": "mihomo-windows-amd64-v1-{}.zip", "windows-arm64": "mihomo-windows-arm64-{}.zip", "linux-aarch64": "mihomo-linux-arm64-{}.gz", "linux-amd64": "mihomo-linux-amd64-v1-{}.gz", "linux-i386": "mihomo-linux-386-{}.gz", "darwin-arm64": "mihomo-darwin-arm64-{}.gz", "darwin-x64": "mihomo-darwin-amd64-v1-{}.gz", "linux-armv7": "mihomo-linux-armv5-{}.gz", "linux-armv7hf": "mihomo-linux-armv7-{}.gz" }, "clash_rs": { "windows-i386": "clash-rs-i686-pc-windows-msvc-static-crt.exe", "windows-x86_64": "clash-rs-x86_64-pc-windows-msvc.exe", "windows-arm64": "clash-rs-aarch64-pc-windows-msvc.exe", "linux-aarch64": "clash-rs-aarch64-unknown-linux-gnu", "linux-amd64": "clash-rs-x86_64-unknown-linux-gnu-static-crt", "linux-i386": "clash-rs-i686-unknown-linux-gnu", "darwin-arm64": "clash-rs-aarch64-apple-darwin", "darwin-x64": "clash-rs-x86_64-apple-darwin", "linux-armv7": "clash-rs-armv7-unknown-linux-gnueabi", "linux-armv7hf": "clash-rs-armv7-unknown-linux-gnueabihf" }, "clash_premium": { "windows-i386": "clash-windows-386-n{}.zip", "windows-x86_64": "clash-windows-amd64-n{}.zip", "windows-arm64": "clash-windows-arm64-n{}.zip", "linux-aarch64": "clash-linux-arm64-n{}.gz", "linux-amd64": "clash-linux-amd64-n{}.gz", "linux-i386": "clash-linux-386-n{}.gz", "darwin-arm64": "clash-darwin-arm64-n{}.gz", "darwin-x64": "clash-darwin-amd64-n{}.gz", "linux-armv7": "clash-linux-armv5-n{}.gz", "linux-armv7hf": "clash-linux-armv7-n{}.gz" }, "clash_rs_alpha": { "windows-i386": "clash-rs-i686-pc-windows-msvc-static-crt.exe", "windows-x86_64": "clash-rs-x86_64-pc-windows-msvc.exe", "windows-arm64": "clash-rs-aarch64-pc-windows-msvc.exe", "linux-aarch64": "clash-rs-aarch64-unknown-linux-gnu", "linux-amd64": "clash-rs-x86_64-unknown-linux-gnu-static-crt", "linux-i386": "clash-rs-i686-unknown-linux-gnu", "darwin-arm64": "clash-rs-aarch64-apple-darwin", "darwin-x64": "clash-rs-x86_64-apple-darwin", "linux-armv7": "clash-rs-armv7-unknown-linux-gnueabi", "linux-armv7hf": "clash-rs-armv7-unknown-linux-gnueabihf" } }, "updated_at": "2026-03-19T22:22:44.922Z" } ================================================ FILE: package.json ================================================ { "name": "@nyanpasu/monorepo", "version": "2.0.0", "repository": "https://github.com/libnyanpasu/clash-nyanpasu.git", "license": "GPL-3.0", "type": "module", "scripts": { "dev": "run-p tauri:dev", "dev:diff": "run-p tauri:diff", "build": "tauri build", "build:debug": "tauri build -f verge-dev deadlock-detection -d -c \"{ \\\"tauri\\\" : { \\\"updater\\\": { \\\"active\\\": false } }} \"", "build:nightly": "tauri build -f nightly -c ./backend/tauri/tauri.nightly.conf.json", "tauri": "tauri", "tauri:dev": "tauri dev -c ./backend/tauri/tauri.conf.json", "tauri:diff": "tauri dev -f verge-dev deadlock-detection -c ./backend/tauri/tauri.conf.json", "tauri:preview": "pnpm prepare:preview && tauri dev -f verge-dev deadlock-detection -c ./backend/tauri/tauri.preview.conf.json", "web:dev": "pnpm --filter=@nyanpasu/nyanpasu dev", "web:build": "pnpm --filter=@nyanpasu/nyanpasu build", "web:serve": "pnpm --filter=@nyanpasu/nyanpasu preview", "web:visualize": "pnpm --filter=@nyanpasu/nyanpasu bundle:visualize", "lint": "run-s lint:*", "lint:prettier": "prettier --check .", "lint:oxlint": "oxlint .", "lint:styles": "stylelint --cache --allow-empty-input \"**/*.{css,scss}\"", "lint:ts": "run-s lint:ts:*", "lint:ts:scripts": "tsc --noEmit --project ./scripts/tsconfig.json", "lint:ts:ui": "tsc --noEmit --project ./frontend/ui/tsconfig.json", "lint:ts:interface": "tsc --noEmit --project ./frontend/interface/tsconfig.json", "lint:ts:nyanpasu": "tsc --noEmit --project ./frontend/nyanpasu/tsconfig.json", "lint:clippy": "cargo clippy --manifest-path ./backend/Cargo.toml --all-targets --all-features", "lint:rustfmt": "cargo fmt --manifest-path ./backend/Cargo.toml --all -- --check", "knip": "knip", "test": "run-p test:*", "test:backend": "cargo test --manifest-path ./backend/Cargo.toml --all-features", "fmt": "run-p fmt:*", "fmt:backend": "cargo fmt --manifest-path ./backend/Cargo.toml --all", "fmt:prettier": "prettier --write .", "fmt:oxlint": "oxlint --fix .", "updater": "tsx scripts/updater.ts", "updater:nightly": "tsx scripts/updater-nightly.ts", "publish": "tsx scripts/publish.ts", "portable": "tsx scripts/portable.ts", "upload:osx-aarch64": "tsx scripts/osx-aarch64-upload.ts", "generate:git-info": "tsx scripts/generate-git-info.ts", "generate:manifest": "run-p generate:manifest:*", "generate:manifest:latest-version": "deno run -A scripts/deno/generate-latest-version.ts", "prepare": "husky", "prepare:nightly": "tsx scripts/prepare-nightly.ts", "prepare:release": "tsx scripts/prepare-release.ts", "prepare:preview": "tsx scripts/prepare-preview.ts", "prepare:check": "deno run -A scripts/deno/check.ts" }, "dependencies": { "@prettier/plugin-oxc": "0.1.3", "husky": "9.1.7", "lodash-es": "4.17.23" }, "devDependencies": { "@commitlint/cli": "20.5.0", "@commitlint/config-conventional": "20.5.0", "@ianvs/prettier-plugin-sort-imports": "4.7.1", "@tauri-apps/cli": "2.10.1", "@types/fs-extra": "11.0.4", "@types/lodash-es": "4.17.12", "@types/node": "24.11.0", "autoprefixer": "10.4.27", "conventional-changelog-conventionalcommits": "9.3.0", "cross-env": "10.1.0", "dedent": "1.7.2", "globals": "17.4.0", "knip": "5.88.1", "lint-staged": "16.4.0", "npm-run-all2": "8.0.4", "oxlint": "1.56.0", "postcss": "8.5.8", "postcss-html": "1.8.1", "postcss-import": "16.1.1", "postcss-scss": "4.0.9", "prettier": "3.8.1", "prettier-plugin-ember-template-tag": "2.1.3", "prettier-plugin-tailwindcss": "0.7.2", "prettier-plugin-toml": "2.0.6", "stylelint": "17.5.0", "stylelint-config-html": "1.1.0", "stylelint-config-recess-order": "7.7.0", "stylelint-config-standard": "40.0.0", "stylelint-declaration-block-no-ignored-properties": "3.0.0", "stylelint-order": "8.1.1", "stylelint-scss": "7.0.0", "tailwindcss": "4.2.2", "tsx": "4.21.0", "typescript": "5.9.3" }, "packageManager": "pnpm@10.32.1", "engines": { "node": "24.14.0" }, "pnpm": { "overrides": { "vite-plugin-monaco-editor": "npm:vite-plugin-monaco-editor-new@1.1.3", "material-react-table": "npm:@greenhat616/material-react-table@4.0.0" }, "onlyBuiltDependencies": [ "@swc/core", "@tailwindcss/oxide", "core-js", "esbuild", "meta-json-schema", "oxc-resolver" ] } } ================================================ FILE: pnpm-workspace.yaml ================================================ packages: - 'frontend/*' - 'scripts' ================================================ FILE: renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended", "default:automergeMinor", "default:prHourlyLimitNone", "default:preserveSemverRanges", "default:rebaseStalePrs", "group:monorepos" ], "packageRules": [ { "matchManagers": ["npm"], "rangeStrategy": "pin" }, { "matchManagers": ["cargo"], "rangeStrategy": "update-lockfile", "platformAutomerge": false }, { "groupName": "Oxc packages", "matchPackageNames": ["/oxc/"] }, { "groupName": "Bundler packages", "matchPackageNames": ["/vite/", "/unplugin/"] }, { "groupName": "Typescript packages", "matchPackageNames": ["/@types/", "/ts-/", "/tsx/", "/typescript/"] }, { "groupName": "Lint packages", "matchPackageNames": [ "/eslint/", "/prettier/", "/commitlint/", "/stylelint/", "/husky/", "/lint-staged/" ] }, { "groupName": "Tauri packages", "matchPackageNames": ["/tauri/"] }, { "groupName": "Windows packages", "matchPackageNames": ["/windows/", "/webview2-com/", "winreg"] }, { "groupName": "Object-C packages", "matchPackageNames": ["/objc2/"] }, { "groupName": "egui packages", "matchPackageNames": ["/egui/", "/eframe/"] }, { "groupName": "Testing packages", "matchPackageNames": ["/vitest/", "/cypress/", "/wdio/"] } ], "prConcurrentLimit": 30 } ================================================ FILE: rust-toolchain.toml ================================================ [toolchain] channel = "nightly" ================================================ FILE: scripts/.gitignore ================================================ !.vscode/settings.json !.vscode/ ================================================ FILE: scripts/.vscode/settings.json ================================================ { "deno.enable": true, "deno.enablePaths": ["./deno"] } ================================================ FILE: scripts/deno/README.md ================================================ # Deno scripts When we migrated all the scripts to Deno, let's move them to outer directory. ================================================ FILE: scripts/deno/build-cache.ts ================================================ import { parseArgs } from 'jsr:@std/cli@1/parse-args' import { exists } from 'jsr:@std/fs' import * as path from 'jsr:@std/path' import { downloadCache, listCacheKeys, uploadCache, } from './utils/cache-client.ts' import { consola } from './utils/logger.ts' // --- config --- const WORKSPACE_ROOT = path.join(import.meta.dirname!, '../..') const TARGET_DIR = path.join(WORKSPACE_ROOT, 'backend/target') const CARGO_LOCK_PATH = path.join(WORKSPACE_ROOT, 'backend/Cargo.lock') const TAR_EXCLUDE_PATTERNS = [ 'bundle', '*.exe', '*.dmg', '*.deb', '*.rpm', '*.AppImage', '*.msi', '*.nsis', ] // --- helpers --- function requireEnv(name: string): string { const value = Deno.env.get(name) if (!value) { consola.fatal(`${name} is required`) Deno.exit(1) } return value } async function computeCargoLockHash(): Promise { const content = await Deno.readFile(CARGO_LOCK_PATH) const hashBuffer = await crypto.subtle.digest('SHA-256', content) const hashArray = Array.from(new Uint8Array(hashBuffer)) const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') return hashHex.substring(0, 16) } function getCacheKey(os: string, arch: string, hash: string): string { return `nyanpasu-${os}-${arch}-${hash}` } function getFallbackPrefix(os: string, arch: string): string { return `nyanpasu-${os}-${arch}-` } async function createTarball(tarballPath: string): Promise { consola.info(`creating tarball from ${TARGET_DIR}...`) const excludeArgs = TAR_EXCLUDE_PATTERNS.flatMap((p) => ['--exclude', p]) const cmd = new Deno.Command('tar', { args: [ '--zstd', '-cf', tarballPath, ...excludeArgs, '-C', path.dirname(TARGET_DIR), path.basename(TARGET_DIR), ], stdout: 'inherit', stderr: 'inherit', }) const { code } = await cmd.output() if (code !== 0) { throw new Error(`tar creation failed with exit code ${code}`) } const stat = await Deno.stat(tarballPath) consola.success(`tarball created: ${tarballPath} (${stat.size} bytes)`) } async function extractTarball(tarballPath: string): Promise { consola.info(`extracting tarball to ${path.dirname(TARGET_DIR)}...`) const cmd = new Deno.Command('tar', { args: ['--zstd', '-xf', tarballPath, '-C', path.dirname(TARGET_DIR)], stdout: 'inherit', stderr: 'inherit', }) const { code } = await cmd.output() if (code !== 0) { throw new Error(`tar extraction failed with exit code ${code}`) } consola.success('tarball extracted successfully') } // --- commands --- async function save(os: string, arch: string): Promise { const token = requireEnv('FILE_SERVER_TOKEN') if (!(await exists(TARGET_DIR))) { consola.warn(`target directory does not exist: ${TARGET_DIR}`) return } const hash = await computeCargoLockHash() const key = getCacheKey(os, arch, hash) const tarballPath = path.join(Deno.makeTempDirSync(), `${key}.tar.zst`) try { await createTarball(tarballPath) await uploadCache(key, tarballPath, token) consola.success(`cache saved with key: ${key}`) } finally { try { await Deno.remove(tarballPath) } catch { // ignore cleanup errors } } } async function restore(os: string, arch: string): Promise { const token = requireEnv('FILE_SERVER_TOKEN') const hash = await computeCargoLockHash() const key = getCacheKey(os, arch, hash) const tarballPath = path.join(Deno.makeTempDirSync(), `${key}.tar.zst`) try { // try exact match first let hit = await downloadCache(key, tarballPath, token) if (!hit) { // fallback: find most recent cache with matching prefix const prefix = getFallbackPrefix(os, arch) const keys = await listCacheKeys(prefix, token) if (keys.length > 0) { const fallbackKey = keys[0] // server returns sorted by update time desc consola.info(`using fallback cache key: ${fallbackKey}`) hit = await downloadCache(fallbackKey, tarballPath, token) } } if (!hit) { consola.warn('no cache found, build will run from scratch') return } await extractTarball(tarballPath) consola.success('build cache restored successfully') } finally { try { await Deno.remove(tarballPath) } catch { // ignore cleanup errors } } } // --- main --- function main(): Promise { const args = parseArgs(Deno.args, { string: ['os', 'arch'], }) const subcommand = args._[0] as string | undefined const os = args.os const arch = args.arch if (!subcommand || !['save', 'restore'].includes(subcommand)) { consola.error( 'usage: build-cache.ts --os --arch ', ) Deno.exit(1) } if (!os || !arch) { consola.error('--os and --arch are required') Deno.exit(1) } if (subcommand === 'save') { return save(os, arch) } else { return restore(os, arch) } } main().catch((error) => { consola.fatal(error) Deno.exit(1) }) ================================================ FILE: scripts/deno/check.ts ================================================ // @ts-types="npm:@types/adm-zip" import { parseArgs } from 'jsr:@std/cli@1/parse-args' import { ensureDir, exists } from 'jsr:@std/fs' import * as path from 'jsr:@std/path' import AdmZip from 'npm:adm-zip' // @ts-types="npm:@types/figlet" import figlet from 'npm:figlet' import { colorize, consola } from './utils/logger.ts' // === Types === interface BinInfo { name: string targetFile: string exeFile: string tmpFile: string downloadURL: string } type SupportedArch = | 'windows-i386' | 'windows-x86_64' | 'windows-arm64' | 'linux-aarch64' | 'linux-amd64' | 'linux-i386' | 'linux-armv7' | 'linux-armv7hf' | 'darwin-arm64' | 'darwin-x64' type ArchMapping = Record interface VersionManifest { manifest_version: number latest: { mihomo: string mihomo_alpha: string clash_rs: string clash_premium: string clash_rs_alpha: string } arch_template: { mihomo: ArchMapping mihomo_alpha: ArchMapping clash_rs: ArchMapping clash_premium: ArchMapping clash_rs_alpha: ArchMapping } updated_at: string } interface ClashManifest { URL_PREFIX: string BACKUP_URL_PREFIX?: string BACKUP_LATEST_DATE?: string VERSION?: string VERSION_URL?: string ARCH_MAPPING: ArchMapping } // === Constants === const WORKSPACE_ROOT = path.join(import.meta.dirname!, '../..') const TAURI_APP_DIR = path.join(WORKSPACE_ROOT, 'backend/tauri') const TEMP_DIR = path.join(WORKSPACE_ROOT, 'node_modules/.verge') // === CLI Args === const args = parseArgs(Deno.args, { boolean: ['force'], string: ['arch', 'sidecar-host'], }) const FORCE = args.force const ARCH_OVERRIDE = args.arch // === Platform detection === // Deno.build.os: 'windows' | 'darwin' | 'linux' | ... // Map to Node-style for arch table compatibility const platform = Deno.build.os === 'windows' ? 'win32' : Deno.build.os // Deno.build.arch: 'x86_64' | 'aarch64' // Map to Node-style for arch table compatibility const DENO_ARCH_TO_NODE: Record = { x86_64: 'x64', aarch64: 'arm64', } const arch = ARCH_OVERRIDE ?? DENO_ARCH_TO_NODE[Deno.build.arch] ?? Deno.build.arch // === Sidecar Host === let SIDECAR_HOST = args['sidecar-host'] if (!SIDECAR_HOST) { const cmd = new Deno.Command('rustc', { args: ['-vV'], stdout: 'piped' }) const { stdout } = await cmd.output() const text = new TextDecoder().decode(stdout) SIDECAR_HOST = text.match(/host: (.+)/)?.[1]?.trim() } if (!SIDECAR_HOST) { consola.fatal(colorize`{red.bold SIDECAR_HOST} not found`) Deno.exit(1) } consola.debug(colorize`sidecar-host {yellow ${SIDECAR_HOST}}`) consola.debug(colorize`platform {yellow ${platform}}`) consola.debug(colorize`arch {yellow ${arch}}`) // === Arch Mapping === function mapArch(platform: string, arch: string): SupportedArch { const mapping: Partial> = { 'darwin-x64': 'darwin-x64', 'darwin-arm64': 'darwin-arm64', 'win32-x64': 'windows-x86_64', 'win32-ia32': 'windows-i386', 'win32-arm64': 'windows-arm64', 'linux-x64': 'linux-amd64', 'linux-ia32': 'linux-i386', 'linux-arm': 'linux-armv7hf', 'linux-arm64': 'linux-aarch64', 'linux-armel': 'linux-armv7', } const result = mapping[`${platform}-${arch}`] if (!result) { throw new Error(`Unsupported platform/architecture: ${platform}-${arch}`) } return result } // === Version Manifest === const versionManifest = JSON.parse( await Deno.readTextFile(path.join(WORKSPACE_ROOT, 'manifest/version.json')), ) as VersionManifest const CLASH_MANIFEST: ClashManifest = { URL_PREFIX: 'https://github.com/Dreamacro/clash/releases/download/premium/', BACKUP_URL_PREFIX: 'https://github.com/zhongfly/Clash-premium-backup/releases/download/', BACKUP_LATEST_DATE: versionManifest.latest.clash_premium, VERSION: versionManifest.latest.clash_premium, ARCH_MAPPING: versionManifest.arch_template.clash_premium as ArchMapping, } const CLASH_META_MANIFEST: ClashManifest = { URL_PREFIX: `https://github.com/MetaCubeX/mihomo/releases/download/${versionManifest.latest.mihomo}`, VERSION: versionManifest.latest.mihomo, ARCH_MAPPING: versionManifest.arch_template.mihomo as ArchMapping, } const CLASH_META_ALPHA_MANIFEST: ClashManifest = { VERSION_URL: 'https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha/version.txt', URL_PREFIX: 'https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha', ARCH_MAPPING: versionManifest.arch_template.mihomo_alpha as ArchMapping, } const CLASH_RS_MANIFEST: ClashManifest = { URL_PREFIX: 'https://github.com/Watfaq/clash-rs/releases/download/', VERSION: versionManifest.latest.clash_rs, ARCH_MAPPING: versionManifest.arch_template.clash_rs as ArchMapping, } const CLASH_RS_ALPHA_MANIFEST: ClashManifest = { VERSION_URL: 'https://github.com/Watfaq/clash-rs/releases/download/latest/version.txt', URL_PREFIX: 'https://github.com/Watfaq/clash-rs/releases/download/latest', ARCH_MAPPING: versionManifest.arch_template.clash_rs_alpha as ArchMapping, } // === Download === async function downloadFile(url: string, filePath: string): Promise { consola.debug(colorize`downloading {gray "${url.split('/').at(-1)}"}`) const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/octet-stream', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0', }, }) if (!response.ok) { throw new Error( `download failed: ${response.statusText} (${response.status})`, ) } const buffer = await response.arrayBuffer() await Deno.writeFile(filePath, new Uint8Array(buffer)) } // === Extract Helpers === async function extractZip( zipPath: string, destDir: string, name: string, ): Promise { const zip = new AdmZip(zipPath) const baseName = name .split('-') .filter((o: string) => o !== 'alpha') .join('-') let entryName: string | undefined for (const entry of zip.getEntries()) { consola.debug(colorize`"{green ${name}}" entry name ${entry.entryName}`) if ( (entry.entryName.includes(name) && entry.entryName.endsWith('.exe')) || (entry.entryName.includes(baseName) && entry.entryName.endsWith('.exe')) ) { entryName = entry.entryName } } zip.extractAllTo(destDir, true) if (!entryName) throw new Error('cannot find exe file in zip') return path.join(destDir, entryName) } async function extractTarGz( tarPath: string, destDir: string, name: string, ): Promise { const cmd = new Deno.Command('tar', { args: ['-xzf', tarPath, '-C', destDir], stdout: 'piped', stderr: 'piped', }) const { code, stderr } = await cmd.output() if (code !== 0) { throw new Error( `tar extraction failed: ${new TextDecoder().decode(stderr)}`, ) } } async function gunzipFile( inputPath: string, outputPath: string, ): Promise { const input = await Deno.open(inputPath, { read: true }) const output = await Deno.open(outputPath, { write: true, create: true }) await input.readable .pipeThrough(new DecompressionStream('gzip')) .pipeTo(output.writable) } // === Resource Resolution === async function resolveResource( binInfo: { file: string; downloadURL: string }, options?: { force?: boolean }, ): Promise { const { file, downloadURL } = binInfo const resDir = path.join(TAURI_APP_DIR, 'resources') const targetPath = path.join(resDir, file) if (!options?.force && (await exists(targetPath))) return await ensureDir(resDir) await downloadFile(downloadURL, targetPath) consola.success(colorize`resolve {green ${file}} finished`) } async function resolveSidecar( binInfo: BinInfo | Promise, options?: { force?: boolean }, ): Promise { const { name, targetFile, tmpFile, exeFile, downloadURL } = await binInfo const sidecarDir = path.join(TAURI_APP_DIR, 'sidecar') const sidecarPath = path.join(sidecarDir, targetFile) await ensureDir(sidecarDir) if (!options?.force && (await exists(sidecarPath))) return const tempDir = path.join(TEMP_DIR, name) const tempFile = path.join(tempDir, tmpFile) const tempExe = path.join(tempDir, exeFile) await ensureDir(tempDir) try { if (!(await exists(tempFile))) { await downloadFile(downloadURL, tempFile) } if (tmpFile.endsWith('.zip')) { const extractedExe = await extractZip(tempFile, tempDir, name) await Deno.rename(extractedExe, tempExe) await Deno.rename(tempExe, sidecarPath) } else if (tmpFile.endsWith('.tar.gz')) { await extractTarGz(tempFile, tempDir, name) await Deno.rename(tempExe, sidecarPath) } else if (tmpFile.endsWith('.gz')) { await gunzipFile(tempFile, sidecarPath) await Deno.chmod(sidecarPath, 0o755) } else { await Deno.rename(tempFile, sidecarPath) if (platform !== 'win32') { await Deno.chmod(sidecarPath, 0o755) } } consola.success(colorize`resolve {green ${name}} finished`) } catch (err) { try { await Deno.remove(sidecarPath) } catch { // ignore } throw err } finally { try { await Deno.remove(tempDir, { recursive: true }) } catch { // ignore } } } // === Binary Info Functions === function getClashBackupInfo(): BinInfo { const { ARCH_MAPPING, BACKUP_URL_PREFIX, BACKUP_LATEST_DATE } = CLASH_MANIFEST const archLabel = mapArch(platform, arch) const name = ARCH_MAPPING[archLabel].replace('{}', BACKUP_LATEST_DATE!) const isWin = platform === 'win32' return { name: 'clash', targetFile: `clash-${SIDECAR_HOST}${isWin ? '.exe' : ''}`, exeFile: `${name}${isWin ? '.exe' : ''}`, tmpFile: name, downloadURL: `${BACKUP_URL_PREFIX}${BACKUP_LATEST_DATE}/${name}`, } } function getClashMetaInfo(): BinInfo { const { ARCH_MAPPING, URL_PREFIX, VERSION } = CLASH_META_MANIFEST const archLabel = mapArch(platform, arch) const name = ARCH_MAPPING[archLabel].replace('{}', VERSION!) const isWin = platform === 'win32' return { name: 'mihomo', targetFile: `mihomo-${SIDECAR_HOST}${isWin ? '.exe' : ''}`, exeFile: `${name}${isWin ? '.exe' : ''}`, tmpFile: name, downloadURL: `${URL_PREFIX}/${name}`, } } async function getClashMetaAlphaInfo(): Promise { const { ARCH_MAPPING, URL_PREFIX, VERSION_URL } = CLASH_META_ALPHA_MANIFEST const resp = await fetch(VERSION_URL!) const version = (await resp.text()).trim() consola.debug(`mihomo-alpha version: ${version}`) const archLabel = mapArch(platform, arch) const name = ARCH_MAPPING[archLabel].replace('{}', version) const isWin = platform === 'win32' return { name: 'mihomo-alpha', targetFile: `mihomo-alpha-${SIDECAR_HOST}${isWin ? '.exe' : ''}`, exeFile: `${name}${isWin ? '.exe' : ''}`, tmpFile: name, downloadURL: `${URL_PREFIX}/${name}`, } } function getClashRustInfo(): BinInfo { const { ARCH_MAPPING, URL_PREFIX, VERSION } = CLASH_RS_MANIFEST const archLabel = mapArch(platform, arch) const name = ARCH_MAPPING[archLabel].replace('{}', VERSION!) const isWin = platform === 'win32' return { name: 'clash-rs', targetFile: `clash-rs-${SIDECAR_HOST}${isWin ? '.exe' : ''}`, exeFile: name, tmpFile: name, downloadURL: `${URL_PREFIX}${VERSION}/${name}`, } } async function getClashRustAlphaInfo(): Promise { const { ARCH_MAPPING, VERSION_URL, URL_PREFIX } = CLASH_RS_ALPHA_MANIFEST const resp = await fetch(VERSION_URL!) const version = (await resp.text()).trim() consola.debug(`clash-rs-alpha version: ${version}`) const archLabel = mapArch(platform, arch) const name = ARCH_MAPPING[archLabel].replace('{}', version) const isWin = platform === 'win32' return { name: 'clash-rs-alpha', targetFile: `clash-rs-alpha-${SIDECAR_HOST}${isWin ? '.exe' : ''}`, exeFile: name, tmpFile: name, downloadURL: `${URL_PREFIX}/${name}`, } } async function getNyanpasuServiceInfo(): Promise { const SERVICE_REPO = 'libnyanpasu/nyanpasu-service' const isWin = SIDECAR_HOST!.includes('windows') const urlExt = isWin ? 'zip' : 'tar.gz' const response = await fetch( `https://github.com/${SERVICE_REPO}/releases/latest`, { method: 'GET', redirect: 'manual' }, ) const location = response.headers.get('location') if (!location) throw new Error('Cannot find location from response header') const version = location.split('/').pop() if (!version) throw new Error('Cannot find tag from location') consola.debug(`nyanpasu-service version: ${version}`) const name = 'nyanpasu-service' return { name, targetFile: `${name}-${SIDECAR_HOST}${isWin ? '.exe' : ''}`, exeFile: `${name}${isWin ? '.exe' : ''}`, tmpFile: `${name}-${SIDECAR_HOST}.${urlExt}`, downloadURL: `https://github.com/${SERVICE_REPO}/releases/download/${version}/${name}-${SIDECAR_HOST}.${urlExt}`, } } async function resolveWintun(): Promise { if (platform !== 'win32') return const wintunArchMap: Record = { x64: 'amd64', ia32: 'x86', arm: 'arm', arm64: 'arm64', } const wintunArch = wintunArchMap[arch] if (!wintunArch) throw new Error(`unsupported arch ${arch}`) const url = 'https://www.wintun.net/builds/wintun-0.14.1.zip' const expectedHash = '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51' const tempDir = path.join(TEMP_DIR, 'wintun') const tempZip = path.join(tempDir, 'wintun.zip') const targetPath = path.join(TAURI_APP_DIR, 'resources', 'wintun.dll') if (!FORCE && (await exists(targetPath))) return await ensureDir(tempDir) if (!(await exists(tempZip))) { await downloadFile(url, tempZip) } // verify SHA-256 const fileData = await Deno.readFile(tempZip) const hashBuffer = await crypto.subtle.digest('SHA-256', fileData) const hashHex = Array.from(new Uint8Array(hashBuffer)) .map((b) => b.toString(16).padStart(2, '0')) .join('') if (hashHex !== expectedHash) { throw new Error(`wintun hash not match ${hashHex}`) } // extract const zip = new AdmZip(tempZip) zip.extractAllTo(tempDir, true) // recursively find wintun.dll for the target arch function findDlls(dir: string): string[] { const results: string[] = [] for (const entry of Deno.readDirSync(dir)) { const fullPath = path.join(dir, entry.name) if (entry.isDirectory) { results.push(...findDlls(fullPath)) } else if (entry.name === 'wintun.dll' && fullPath.includes(wintunArch)) { results.push(fullPath) } } return results } const dlls = findDlls(tempDir) const dll = dlls[0] if (!dll) throw new Error(`wintun not found for arch ${wintunArch}`) await ensureDir(path.dirname(targetPath)) await Deno.copyFile(dll, targetPath) await Deno.remove(tempDir, { recursive: true }) consola.success(colorize`resolve {green wintun.dll} finished`) } // === Task Runner === const tasks: Array<{ name: string func: () => Promise retry: number winOnly?: boolean }> = [ { name: 'clash', func: () => resolveSidecar(getClashBackupInfo(), { force: FORCE, }), retry: 5, }, { name: 'mihomo', func: () => resolveSidecar(getClashMetaInfo(), { force: FORCE }), retry: 5, }, { name: 'mihomo-alpha', func: () => resolveSidecar(getClashMetaAlphaInfo(), { force: FORCE }), retry: 5, }, { name: 'clash-rs', func: () => resolveSidecar(getClashRustInfo(), { force: FORCE }), retry: 5, }, { name: 'clash-rs-alpha', func: () => resolveSidecar(getClashRustAlphaInfo(), { force: FORCE }), retry: 5, }, { name: 'wintun', func: () => resolveWintun(), retry: 5, winOnly: true }, { name: 'nyanpasu-service', func: () => resolveSidecar(getNyanpasuServiceInfo(), { force: FORCE }), retry: 5, }, { name: 'mmdb', func: () => resolveResource( { file: 'Country.mmdb', downloadURL: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb', }, { force: FORCE }, ), retry: 5, }, { name: 'geoip', func: () => resolveResource( { file: 'geoip.dat', downloadURL: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat', }, { force: FORCE }, ), retry: 5, }, { name: 'geosite', func: () => resolveResource( { file: 'geosite.dat', downloadURL: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat', }, { force: FORCE }, ), retry: 5, }, { name: 'enableLoopback', func: () => resolveResource( { file: 'enableLoopback.exe', downloadURL: 'https://github.com/Kuingsmile/uwp-tool/releases/download/latest/enableLoopback.exe', }, { force: FORCE }, ), retry: 5, winOnly: true, }, ] async function runTask(): Promise { const task = tasks.shift() if (!task) return if (task.winOnly && platform !== 'win32') return runTask() for (let i = 0; i < task.retry; i++) { try { await task.func() break } catch (err) { consola.warn(`task::${task.name} try ${i} ==`, err) if (i === task.retry - 1) { consola.fatal(`task::${task.name} failed`, err) Deno.exit(1) } } } return runTask() } // === Main === consola.start('start check and download resources...') const concurrency = Math.ceil(navigator.hardwareConcurrency / 2) || 2 const jobs = Array.from({ length: concurrency }, () => runTask()) await Promise.all(jobs) console.log(figlet.textSync('Clash Nyanpasu', { whitespaceBreak: true })) consola.success('all resources download finished\n') consola.log(' next command:\n') consola.log(' pnpm dev - development') consola.log(' pnpm dev:diff - deadlock development (recommend)') ================================================ FILE: scripts/deno/deno.jsonc ================================================ { "tasks": { "check": { "description": "Check and download required sidecar binaries and resources", "command": "deno run -A check.ts", }, "upload-macos-updater": { "description": "Upload macOS updater to GitHub Releases", "command": "deno run -A upload-macos-updater.ts", }, "telegram-notify": { "description": "Send Telegram notification for releases and nightly builds", "command": "deno run -A telegram-notify.ts", }, "build-cache": { "description": "Save/restore build cache to file server", "command": "deno run -A build-cache.ts", }, }, } ================================================ FILE: scripts/deno/generate-latest-version.ts ================================================ import { ensureDir } from 'jsr:@std/fs' import * as path from 'jsr:@std/path' import { resolveClashPremium, resolveClashRs, resolveClashRsAlpha, resolveMihomo, resolveMihomoAlpha, type ArchMapping, } from './manifest.ts' import { colorize, consola } from './utils/logger.ts' // === Constants === const WORKSPACE_ROOT = path.join(import.meta.dirname!, '../..') const MANIFEST_DIR = path.join(WORKSPACE_ROOT, 'manifest') const MANIFEST_VERSION_PATH = path.join(MANIFEST_DIR, 'version.json') const MANIFEST_VERSION = 1 // === Types === type SupportedCore = | 'mihomo' | 'mihomo_alpha' | 'clash_rs' | 'clash_rs_alpha' | 'clash_premium' interface ManifestVersion { manifest_version: number latest: Record arch_template: Record updated_at?: string } // === Main === const resolvers = [ resolveMihomo, resolveMihomoAlpha, resolveClashRs, resolveClashPremium, resolveClashRsAlpha, ] consola.start(colorize`{cyan Resolving} latest versions`) const results = await Promise.all(resolvers.map((r) => r())) consola.success('Resolved latest versions') consola.start('Generating manifest') const manifest: ManifestVersion = { manifest_version: MANIFEST_VERSION, latest: {} as Record, arch_template: {} as Record, } for (const result of results) { manifest.latest[result.name as SupportedCore] = result.version manifest.arch_template[result.name as SupportedCore] = result.archMapping } await ensureDir(MANIFEST_DIR) // If no changes, skip writing manifest let previousManifest: Partial = {} try { previousManifest = JSON.parse(await Deno.readTextFile(MANIFEST_VERSION_PATH)) delete previousManifest.updated_at } catch { // file may not exist yet } if (JSON.stringify(previousManifest) === JSON.stringify(manifest)) { consola.success('No changes, skip writing manifest') Deno.exit(0) } manifest.updated_at = new Date().toISOString() consola.success('Generated manifest') await Deno.writeTextFile( MANIFEST_VERSION_PATH, JSON.stringify(manifest, null, 2) + '\n', ) consola.success('Manifest written') ================================================ FILE: scripts/deno/manifest.ts ================================================ import { consola } from './utils/logger.ts' // === Types === export type SupportedArch = | 'windows-i386' | 'windows-x86_64' | 'windows-arm64' | 'linux-aarch64' | 'linux-amd64' | 'linux-i386' | 'linux-armv7' | 'linux-armv7hf' | 'darwin-arm64' | 'darwin-x64' export type ArchMapping = Record export type LatestVersionResolver = Promise<{ name: string version: string archMapping: ArchMapping }> // === GitHub API helpers === const GITHUB_API_HEADERS = { Accept: 'application/vnd.github+json', 'User-Agent': 'clash-nyanpasu', } async function githubFetch(url: string): Promise { const resp = await fetch(url, { headers: GITHUB_API_HEADERS }) if (!resp.ok) { throw new Error( `GitHub API error: ${resp.statusText} (${resp.status}) — ${url}`, ) } return resp.json() as Promise } async function getLatestRelease(owner: string, repo: string): Promise { const data = await githubFetch<{ tag_name: string }>( `https://api.github.com/repos/${owner}/${repo}/releases/latest`, ) return data.tag_name } // === Resolvers === export const resolveMihomo = async (): LatestVersionResolver => { const version = await getLatestRelease('MetaCubeX', 'mihomo') consola.debug(`mihomo latest release: ${version}`) const archMapping: ArchMapping = { 'windows-i386': 'mihomo-windows-386-{}.zip', 'windows-x86_64': 'mihomo-windows-amd64-v1-{}.zip', 'windows-arm64': 'mihomo-windows-arm64-{}.zip', 'linux-aarch64': 'mihomo-linux-arm64-{}.gz', 'linux-amd64': 'mihomo-linux-amd64-v1-{}.gz', 'linux-i386': 'mihomo-linux-386-{}.gz', 'darwin-arm64': 'mihomo-darwin-arm64-{}.gz', 'darwin-x64': 'mihomo-darwin-amd64-v1-{}.gz', 'linux-armv7': 'mihomo-linux-armv5-{}.gz', 'linux-armv7hf': 'mihomo-linux-armv7-{}.gz', } return { name: 'mihomo', version, archMapping } } export const resolveMihomoAlpha = async (): LatestVersionResolver => { const resp = await fetch( 'https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha/version.txt', ) const alphaReleaseHash = (await resp.text()).trim() consola.debug(`mihomo alpha release: ${alphaReleaseHash}`) const archMapping: ArchMapping = { 'windows-i386': 'mihomo-windows-386-{}.zip', 'windows-x86_64': 'mihomo-windows-amd64-v1-{}.zip', 'windows-arm64': 'mihomo-windows-arm64-{}.zip', 'linux-aarch64': 'mihomo-linux-arm64-{}.gz', 'linux-amd64': 'mihomo-linux-amd64-v1-{}.gz', 'linux-i386': 'mihomo-linux-386-{}.gz', 'darwin-arm64': 'mihomo-darwin-arm64-{}.gz', 'darwin-x64': 'mihomo-darwin-amd64-v1-{}.gz', 'linux-armv7': 'mihomo-linux-armv5-{}.gz', 'linux-armv7hf': 'mihomo-linux-armv7-{}.gz', } return { name: 'mihomo_alpha', version: alphaReleaseHash, archMapping } } export const resolveClashRs = async (): LatestVersionResolver => { const version = await getLatestRelease('Watfaq', 'clash-rs') consola.debug(`clash-rs latest release: ${version}`) const archMapping: ArchMapping = { 'windows-i386': 'clash-rs-i686-pc-windows-msvc-static-crt.exe', 'windows-x86_64': 'clash-rs-x86_64-pc-windows-msvc.exe', 'windows-arm64': 'clash-rs-aarch64-pc-windows-msvc.exe', 'linux-aarch64': 'clash-rs-aarch64-unknown-linux-gnu', 'linux-amd64': 'clash-rs-x86_64-unknown-linux-gnu-static-crt', 'linux-i386': 'clash-rs-i686-unknown-linux-gnu', 'darwin-arm64': 'clash-rs-aarch64-apple-darwin', 'darwin-x64': 'clash-rs-x86_64-apple-darwin', 'linux-armv7': 'clash-rs-armv7-unknown-linux-gnueabi', 'linux-armv7hf': 'clash-rs-armv7-unknown-linux-gnueabihf', } return { name: 'clash_rs', version, archMapping } } export const resolveClashRsAlpha = async (): LatestVersionResolver => { // Fetch commit SHA for the "latest" pre-release tag and the stable base version in parallel const [ref, stableTag] = await Promise.all([ githubFetch<{ object: { type: string; sha: string; url: string } }>( 'https://api.github.com/repos/Watfaq/clash-rs/git/ref/tags/latest', ), getLatestRelease('Watfaq', 'clash-rs'), ]) // Dereference annotated tags to get the underlying commit SHA let commitSha = ref.object.sha if (ref.object.type === 'tag') { const tagObj = await githubFetch<{ object: { sha: string } }>( ref.object.url, ) commitSha = tagObj.object.sha } const shortSha = commitSha.substring(0, 7) const baseVersion = stableTag.replace(/^v/, '') const alphaVersion = `${baseVersion}-alpha+sha.${shortSha}` consola.debug(`clash-rs alpha latest release: ${alphaVersion}`) const archMapping: ArchMapping = { 'windows-i386': 'clash-rs-i686-pc-windows-msvc-static-crt.exe', 'windows-x86_64': 'clash-rs-x86_64-pc-windows-msvc.exe', 'windows-arm64': 'clash-rs-aarch64-pc-windows-msvc.exe', 'linux-aarch64': 'clash-rs-aarch64-unknown-linux-gnu', 'linux-amd64': 'clash-rs-x86_64-unknown-linux-gnu-static-crt', 'linux-i386': 'clash-rs-i686-unknown-linux-gnu', 'darwin-arm64': 'clash-rs-aarch64-apple-darwin', 'darwin-x64': 'clash-rs-x86_64-apple-darwin', 'linux-armv7': 'clash-rs-armv7-unknown-linux-gnueabi', 'linux-armv7hf': 'clash-rs-armv7-unknown-linux-gnueabihf', } return { name: 'clash_rs_alpha', version: alphaVersion, archMapping } } export const resolveClashPremium = async (): LatestVersionResolver => { const version = await getLatestRelease('zhongfly', 'Clash-premium-backup') consola.debug(`clash-premium latest release: ${version}`) const archMapping: ArchMapping = { 'windows-i386': 'clash-windows-386-n{}.zip', 'windows-x86_64': 'clash-windows-amd64-n{}.zip', 'windows-arm64': 'clash-windows-arm64-n{}.zip', 'linux-aarch64': 'clash-linux-arm64-n{}.gz', 'linux-amd64': 'clash-linux-amd64-n{}.gz', 'linux-i386': 'clash-linux-386-n{}.gz', 'darwin-arm64': 'clash-darwin-arm64-n{}.gz', 'darwin-x64': 'clash-darwin-amd64-n{}.gz', 'linux-armv7': 'clash-linux-armv5-n{}.gz', 'linux-armv7hf': 'clash-linux-armv7-n{}.gz', } return { name: 'clash_premium', version, archMapping } } ================================================ FILE: scripts/deno/telegram-notify.ts ================================================ import { retry } from 'jsr:@std/async@1/retry' import { format as formatBytes } from 'jsr:@std/fmt@1/bytes' import { ensureDir, exists } from 'jsr:@std/fs' import * as path from 'jsr:@std/path' import { Bot } from 'npm:grammy' import { UPLOAD_CONCURRENCY, uploadAllFiles, UploadResult, } from './utils/file-server.ts' import { consola } from './utils/logger.ts' // --- env helpers --- function requireEnv(name: string): string { const value = Deno.env.get(name) if (!value) { consola.fatal(`${name} is required`) Deno.exit(1) } return value } const nightlyBuild = Deno.args.includes('--nightly') const fromLocal = Deno.args.includes('--from-local') const TELEGRAM_TOKEN = requireEnv('TELEGRAM_TOKEN') const TELEGRAM_TO = requireEnv('TELEGRAM_TO') const TELEGRAM_TO_NIGHTLY = requireEnv('TELEGRAM_TO_NIGHTLY') const GITHUB_TOKEN = requireEnv('GITHUB_TOKEN') const FILE_SERVER_TOKEN = fromLocal ? '' : requireEnv('FILE_SERVER_TOKEN') const WORKFLOW_RUN_ID = Deno.env.get('WORKFLOW_RUN_ID') const UPLOAD_RESULTS_DIR = Deno.env.get('UPLOAD_RESULTS_DIR') || '.' // --- constants --- const WORKSPACE_ROOT = path.join(import.meta.dirname!, '../..') const TEMP_DIR = path.join(WORKSPACE_ROOT, 'node_modules/.verge') const repoInfo = { owner: 'libnyanpasu', repo: 'clash-nyanpasu' } as const const resourceFormats = [ '.exe', 'portable.zip', '.rpm', '.deb', '.dmg', '.AppImage', ] // --- helpers --- function isValidFormat(fileName: string): boolean { return resourceFormats.some((fmt) => fileName.endsWith(fmt)) } function getFileSize(filePath: string): string { const stat = Deno.statSync(filePath) return formatBytes(stat.size ?? 0) } function getGitShortHash(): string { const cmd = new Deno.Command('git', { args: ['rev-parse', '--short', 'HEAD'], stdout: 'piped', }) const { stdout } = cmd.outputSync() return new TextDecoder().decode(stdout).trim() } async function getVersion(): Promise { const pkgPath = path.join(WORKSPACE_ROOT, 'package.json') const pkg = JSON.parse(await Deno.readTextFile(pkgPath)) return pkg.version as string } async function downloadFile(url: string, destPath: string): Promise { consola.debug(`download "${url}" to "${destPath}"`) const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/octet-stream', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0', }, }) if (!response.ok) { throw new Error(`download failed: ${response.statusText}`) } const buffer = new Uint8Array(await response.arrayBuffer()) await Deno.writeFile(destPath, buffer) consola.success(`download finished "${url.split('/').at(-1)}"`) } interface GitHubAsset { name: string browser_download_url: string } interface GitHubRelease { assets: GitHubAsset[] } async function fetchRelease(): Promise { const { owner, repo } = repoInfo const url = nightlyBuild ? `https://api.github.com/repos/${owner}/${repo}/releases/tags/pre-release` : `https://api.github.com/repos/${owner}/${repo}/releases/latest` const resp = await fetch(url, { headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${GITHUB_TOKEN}`, 'X-GitHub-Api-Version': '2022-11-28', }, }) if (!resp.ok) { throw new Error(`GitHub API error: ${resp.status} ${resp.statusText}`) } return (await resp.json()) as GitHubRelease } async function readLocalUploadResults(dir: string): Promise { const results: UploadResult[] = [] try { for await (const entry of Deno.readDir(dir)) { if (entry.isDirectory) { const jsonPath = path.join(dir, entry.name, 'upload-results.json') try { const content = await Deno.readTextFile(jsonPath) const parsed = JSON.parse(content) as UploadResult[] results.push(...parsed) consola.success(`Loaded ${parsed.length} results from ${entry.name}`) } catch { // No upload-results.json in this subdirectory, skip } } } } catch (err) { consola.warn(`Could not read directory ${dir}: ${err}`) } return results } // --- platform grouping --- interface PlatformGroup { label: string filter: (filePath: string) => boolean } const platformGroups: PlatformGroup[] = [ { label: 'Windows', filter: (item) => !item.includes('fixed-webview') && (item.endsWith('.exe') || item.endsWith('portable.zip')), }, { label: 'macOS', filter: (item) => item.endsWith('.dmg'), }, { label: 'Linux', filter: (item) => (item.endsWith('.rpm') || item.endsWith('.deb') || item.endsWith('.AppImage')) && !item.includes('armel') && !item.includes('armhf'), }, { label: 'Linux (armv7)', filter: (item) => (item.endsWith('.rpm') || item.endsWith('.deb') || item.endsWith('.AppImage')) && (item.includes('armel') || item.includes('armhf')), }, ] // --- main --- async function main() { const bot = new Bot(TELEGRAM_TOKEN) const GIT_SHORT_HASH = getGitShortHash() const version = await getVersion() let uploadResults: UploadResult[] if (fromLocal) { consola.info( `Reading upload results from local directory: ${UPLOAD_RESULTS_DIR}`, ) uploadResults = await readLocalUploadResults(UPLOAD_RESULTS_DIR) consola.success(`Loaded ${uploadResults.length} total upload results`) } else { const release = await fetchRelease() const resourceMapping: string[] = [] const downloadTasks: Promise[] = [] for (const asset of release.assets) { if (isValidFormat(asset.name)) { const dest = path.join(TEMP_DIR, asset.name) resourceMapping.push(dest) downloadTasks.push( retry(() => downloadFile(asset.browser_download_url, dest), { maxAttempts: 5, }), ) } } try { await ensureDir(TEMP_DIR) await Promise.all(downloadTasks) } catch (error) { consola.error(error) throw new Error('Error during download tasks') } for (const item of resourceMapping) { consola.log( `found ${item}, size: ${getFileSize(item)}`, await exists(item), ) } const buildType = nightlyBuild ? 'nightly' : 'release' const folderPath = `${buildType}/${GIT_SHORT_HASH}` consola.start( `Uploading ${resourceMapping.length} files to file server (concurrency: ${UPLOAD_CONCURRENCY}, folder: ${folderPath})...`, ) uploadResults = await uploadAllFiles( resourceMapping, FILE_SERVER_TOKEN, folderPath, ) consola.success(`Uploaded ${uploadResults.length} files to file server`) } // build message with grouped download links const lines: string[] = [] if (!nightlyBuild) { lines.push( `**Clash Nyanpasu ${version} Released!**`, '', 'GitHub Release:', `https://github.com/libnyanpasu/clash-nyanpasu/releases/tag/v${version}`, ) } else { lines.push( `**Clash Nyanpasu Nightly Build \`${GIT_SHORT_HASH}\`**`, '', '⚠️ Could be unstable, use at your own risk.', ) if (WORKFLOW_RUN_ID) { lines.push( '', 'GitHub Actions:', `https://github.com/libnyanpasu/clash-nyanpasu/actions/runs/${WORKFLOW_RUN_ID}`, ) } } lines.push('', '--- Download Links ---') for (const group of platformGroups) { const groupFiles = uploadResults.filter((r) => group.filter(r.fileName)) if (groupFiles.length === 0) continue lines.push('', `**${group.label}:**`) for (const file of groupFiles) { lines.push(`- [${file.fileName}](${file.downloadUrl})`) } } const messageText = lines.join('\n') const chatId = nightlyBuild ? TELEGRAM_TO_NIGHTLY : TELEGRAM_TO await bot.api.sendMessage(chatId, messageText, { parse_mode: 'Markdown' }) consola.success('Sent telegram notification') } main().catch((error) => { consola.fatal(error) Deno.exit(1) }) ================================================ FILE: scripts/deno/upload-build-artifacts.ts ================================================ import * as path from 'jsr:@std/path' import { globby } from 'npm:globby' import { uploadAllFiles } from './utils/file-server.ts' import { consola } from './utils/logger.ts' function requireEnv(name: string): string { const value = Deno.env.get(name) if (!value) { consola.fatal(`${name} is required`) Deno.exit(1) } return value } const FILE_SERVER_TOKEN = requireEnv('FILE_SERVER_TOKEN') const FOLDER_PATH = requireEnv('FOLDER_PATH') const patterns = Deno.args if (patterns.length === 0) { consola.fatal('No file patterns provided as arguments') Deno.exit(1) } const WORKSPACE_ROOT = path.join(import.meta.dirname!, '../..') consola.info(`Searching for files matching: ${patterns.join(', ')}`) const files = await globby(patterns, { cwd: WORKSPACE_ROOT, absolute: true }) consola.info(`Found ${files.length} files:`) for (const f of files) { consola.info(` ${path.basename(f)}`) } const results = files.length > 0 ? await uploadAllFiles(files, FILE_SERVER_TOKEN, FOLDER_PATH) : [] const outputPath = path.join(WORKSPACE_ROOT, 'upload-results.json') await Deno.writeTextFile(outputPath, JSON.stringify(results, null, 2)) consola.success( `Upload complete. ${results.length} files uploaded. Results written to ${outputPath}`, ) ================================================ FILE: scripts/deno/upload-macos-updater.ts ================================================ import * as path from 'jsr:@std/path' import { globby } from 'npm:globby' import { consola } from './utils/logger.ts' const WORKSPACE_ROOT = path.join(import.meta.dirname!, '../..') consola.info(`WORKSPACE_ROOT: ${WORKSPACE_ROOT}`) const TARGET_ARCH = Deno.env.get('TARGET_ARCH') || Deno.build.arch const BACKEND_BUILD_DIR = path.join(WORKSPACE_ROOT, 'backend/target') const files = await globby(['**/*.tar.gz', '**/*.sig', '**/*.dmg'], { cwd: BACKEND_BUILD_DIR, }) for (let file of files) { file = path.join(BACKEND_BUILD_DIR, file) const p = path.parse(file) consola.info(`Found file: ${p.base}`) if (p.base.includes('.app')) { const components = p.base.split('.') const newName = components[0] + `.${TARGET_ARCH}.${components.slice(1).join('.')}` const newPath = path.join(p.dir, newName) consola.info(`Renaming ${file} to ${newPath}`) await Deno.rename(file, newPath) } } consola.success('Files renamed successfully') ================================================ FILE: scripts/deno/utils/cache-client.ts ================================================ import { format as formatBytes } from 'jsr:@std/fmt@1/bytes' import { writeAll } from 'jsr:@std/io@0.225/write-all' import { CHUNK_MULTIPLIER, performChunkedUpload } from './file-server.ts' import { consola } from './logger.ts' const CACHE_BASE_URL = 'https://file-server.elaina.moe/cache' // --- cache chunked upload types --- interface CacheInitResponse { uploadId: string key: string fileSize: number chunkSize: number expiresAt: number } interface CacheChunkResponse { done: boolean nextExpectedRanges?: string[] key?: string size?: number } // --- cache chunked upload functions --- async function initCacheUploadSession( key: string, fileSize: number, token: string, ): Promise { const resp = await fetch(`${CACHE_BASE_URL}/init`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ key, fileSize, chunkMultiplier: CHUNK_MULTIPLIER, }), }) if (!resp.ok) { const body = await resp.text() throw new Error( `cache upload init failed: ${resp.status} ${resp.statusText} - ${body}`, ) } return (await resp.json()) as CacheInitResponse } async function uploadCacheChunk( uploadId: string, chunk: Uint8Array, start: number, end: number, total: number, token: string, ): Promise { const resp = await fetch(`${CACHE_BASE_URL}/chunk`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'x-upload-id': uploadId, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Type': 'application/octet-stream', }, body: chunk as unknown as BodyInit, }) if (!resp.ok) { const body = await resp.text() throw new Error( `cache chunk upload failed: ${resp.status} ${resp.statusText} - ${body}`, ) } return (await resp.json()) as CacheChunkResponse } export async function uploadCache( key: string, filePath: string, token: string, ): Promise { const stat = await Deno.stat(filePath) const fileSize = stat.size consola.info( `uploading cache "${key}" (${formatBytes(fileSize)}) via chunked upload...`, ) const { uploadId, chunkSize } = await initCacheUploadSession( key, fileSize, token, ) await performChunkedUpload({ filePath, fileSize, uploadId, chunkSize, label: `cache "${key}"`, uploadChunkFn: (chunk, start, end, total) => uploadCacheChunk(uploadId, chunk, start, end, total, token), }) consola.success(`cache "${key}" uploaded successfully`) } export async function downloadCache( key: string, destPath: string, token: string, ): Promise { consola.info(`downloading cache "${key}"...`) const resp = await fetch(`${CACHE_BASE_URL}/${encodeURIComponent(key)}`, { method: 'GET', headers: { 'x-authorization': token, }, }) if (resp.status === 404) { consola.info(`cache miss for "${key}"`) return false } if (!resp.ok) { const body = await resp.text() throw new Error( `cache download failed: ${resp.status} ${resp.statusText} - ${body}`, ) } const totalSize = Number(resp.headers.get('Content-Length')) || 0 const reader = resp.body!.getReader() const dest = await Deno.open(destPath, { write: true, create: true, truncate: true, }) try { let received = 0 let lastLogTime = Date.now() let lastLogReceived = 0 while (true) { const { done, value } = await reader.read() if (done) break await writeAll(dest, value) received += value.byteLength const now = Date.now() const elapsed = now - lastLogTime if (elapsed >= 1000) { const speed = ((received - lastLogReceived) / elapsed) * 1000 lastLogTime = now lastLogReceived = received if (totalSize > 0) { const pct = Math.floor((received / totalSize) * 100) consola.info( ` cache "${key}": ${formatBytes(received)}/${formatBytes(totalSize)} (${pct}%) ${formatBytes(speed)}/s`, ) } else { consola.info( ` cache "${key}": ${formatBytes(received)} downloaded ${formatBytes(speed)}/s`, ) } } } } catch { try { dest.close() } catch { // already closed } throw new Error(`failed to write cache to "${destPath}"`) } dest.close() consola.success(`cache "${key}" downloaded to "${destPath}"`) return true } export async function listCacheKeys( prefix: string, token: string, ): Promise { consola.debug(`listing cache keys with prefix "${prefix}"...`) const resp = await fetch( `${CACHE_BASE_URL}?prefix=${encodeURIComponent(prefix)}`, { method: 'GET', headers: { 'x-authorization': token, }, }, ) if (!resp.ok) { const body = await resp.text() throw new Error( `cache list failed: ${resp.status} ${resp.statusText} - ${body}`, ) } const keys = (await resp.json()) as string[] consola.debug(`found ${keys.length} cache keys matching prefix "${prefix}"`) return keys } ================================================ FILE: scripts/deno/utils/file-server.ts ================================================ import { retry } from 'jsr:@std/async@1/retry' import { format as formatBytes } from 'jsr:@std/fmt@1/bytes' import * as path from 'jsr:@std/path' import { consola } from './logger.ts' // --- constants --- export const FILE_SERVER_UPLOAD_URL = 'https://file-server.elaina.moe/upload' export const FILE_SERVER_BIN_URL = 'https://file-server.elaina.moe/bin' export const UPLOAD_CONCURRENCY = 3 export const CHUNK_RETRY_ATTEMPTS = 5 export const CHUNK_MULTIPLIER = 32 // --- types --- export interface UploadResult { fileName: string downloadUrl: string } export interface InitResponse { uploadId: string chunkSize: number } export interface ChunkResponse { done: boolean file?: { id: string } } export interface ChunkedUploadOptions { filePath: string fileSize: number uploadId: string chunkSize: number label: string uploadChunkFn: ( chunk: Uint8Array, start: number, end: number, total: number, ) => Promise } // --- upload functions --- export async function initUploadSession( fileName: string, fileSize: number, mimeType: string | null, token: string, folderPath?: string, ): Promise { const body: Record = { filename: fileName, fileSize, mimeType, chunkMultiplier: CHUNK_MULTIPLIER, } if (folderPath) { body.folderPath = folderPath } const resp = await fetch(`${FILE_SERVER_UPLOAD_URL}/init`, { method: 'POST', headers: { 'x-authorization': token, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }) if (!resp.ok) { const body = await resp.text() throw new Error( `upload init failed: ${resp.status} ${resp.statusText} - ${body}`, ) } return (await resp.json()) as InitResponse } export async function uploadChunk( uploadId: string, chunk: Uint8Array, start: number, end: number, total: number, token: string, ): Promise { const resp = await fetch(`${FILE_SERVER_UPLOAD_URL}/chunk`, { method: 'POST', headers: { 'x-authorization': token, 'x-upload-id': uploadId, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Type': 'application/octet-stream', }, body: chunk as unknown as BodyInit, }) if (!resp.ok) { const body = await resp.text() throw new Error( `chunk upload failed: ${resp.status} ${resp.statusText} - ${body}`, ) } return (await resp.json()) as ChunkResponse } export async function performChunkedUpload( options: ChunkedUploadOptions, ): Promise { const { filePath, fileSize, uploadId, chunkSize, label, uploadChunkFn } = options consola.debug( `upload session created: uploadId=${uploadId}, chunkSize=${formatBytes(chunkSize)}`, ) const file = await Deno.open(filePath, { read: true }) try { let start = 0 let chunkIndex = 0 const totalChunks = Math.ceil(fileSize / chunkSize) let lastLogTime = Date.now() let lastLogUploaded = 0 while (start < fileSize) { const endExclusive = Math.min(start + chunkSize, fileSize) const size = endExclusive - start const buf = new Uint8Array(size) await file.seek(start, Deno.SeekMode.Start) let bytesRead = 0 while (bytesRead < size) { const n = await file.read(buf.subarray(bytesRead)) if (n === null) break bytesRead += n } const end = endExclusive - 1 chunkIndex++ const data = await retry( () => uploadChunkFn(buf.subarray(0, bytesRead), start, end, fileSize), { maxAttempts: CHUNK_RETRY_ATTEMPTS }, ) const now = Date.now() const elapsed = now - lastLogTime if (elapsed >= 1000 || data.done) { const speed = ((endExclusive - lastLogUploaded) / elapsed) * 1000 lastLogTime = now lastLogUploaded = endExclusive const pct = Math.floor((endExclusive / fileSize) * 100) consola.info( ` ${label} ${chunkIndex}/${totalChunks}: ${formatBytes(endExclusive)}/${formatBytes(fileSize)} (${pct}%) ${formatBytes(speed)}/s`, ) } if (data.done) { return data } start = endExclusive } } finally { file.close() } throw new Error(`Upload of ${label} ended unexpectedly without done=true`) } export async function uploadToFileServer( filePath: string, token: string, folderPath?: string, ): Promise { const fileName = path.basename(filePath) const stat = await Deno.stat(filePath) const fileSize = stat.size consola.info( `uploading ${fileName} (${formatBytes(fileSize)}) to file server${folderPath ? ` [folder: ${folderPath}]` : ''}...`, ) const { uploadId, chunkSize } = await initUploadSession( fileName, fileSize, null, token, folderPath, ) const data = await performChunkedUpload({ filePath, fileSize, uploadId, chunkSize, label: fileName, uploadChunkFn: (chunk, start, end, total) => uploadChunk(uploadId, chunk, start, end, total, token), }) const downloadUrl = `${FILE_SERVER_BIN_URL}/${data.file!.id}` consola.success(`uploaded ${fileName} -> ${downloadUrl}`) return { fileName, downloadUrl } } export async function uploadAllFiles( filePaths: string[], token: string, folderPath?: string, ): Promise { const results: UploadResult[] = [] const queue = [...filePaths] const inFlight: Promise[] = [] async function processNext(): Promise { while (queue.length > 0) { const filePath = queue.shift()! const result = await retry( () => uploadToFileServer(filePath, token, folderPath), { maxAttempts: CHUNK_RETRY_ATTEMPTS, }, ) results.push(result) } } const workers = Math.min(UPLOAD_CONCURRENCY, filePaths.length) for (let i = 0; i < workers; i++) { inFlight.push(processNext()) } await Promise.all(inFlight) return results } ================================================ FILE: scripts/deno/utils/logger.ts ================================================ import { createColorize } from 'npm:colorize-template' import { createConsola } from 'npm:consola' import pc from 'npm:picocolors' const logLevelStr = Deno.env.get('LOG_LEVEL') export const consola = createConsola({ level: logLevelStr ? Number.parseInt(logLevelStr) : 5, fancy: true, formatOptions: { colors: true, compact: false, date: true, }, }) export const colorize = createColorize({ ...pc, success: pc.green, error: pc.red, }) ================================================ FILE: scripts/generate-git-info.ts ================================================ import { execSync } from 'node:child_process' import fs from 'fs-extra' import { GIT_SUMMARY_INFO_PATH, TAURI_APP_TEMP_DIR } from './utils/env' import { consola } from './utils/logger' async function main() { const [hash, author, time] = execSync( "git show --pretty=format:'%H,%cn,%cI' --no-patch --no-notes", { cwd: process.cwd(), }, ) .toString() .replace(/'/g, '') .split(',') const summary = { hash, author, time, } consola.info(summary) if (!(await fs.exists(TAURI_APP_TEMP_DIR))) { await fs.mkdir(TAURI_APP_TEMP_DIR) } await fs.writeJSON(GIT_SUMMARY_INFO_PATH, summary, { spaces: 2 }) consola.success('Git summary info generated') } main().catch(consola.error) ================================================ FILE: scripts/generate-latest-version.ts ================================================ import fs from 'fs-extra' import { ManifestVersion, SupportedCore } from './types/index' import { MANIFEST_DIR, MANIFEST_VERSION_PATH } from './utils/env' import { consola } from './utils/logger' import { resolveClashPremium, resolveClashRs, resolveClashRsAlpha, resolveMihomo, resolveMihomoAlpha, } from './utils/manifest' const MANIFEST_VERSION = 1 export async function generateLatestVersion() { const resolvers = [ resolveMihomo, resolveMihomoAlpha, resolveClashRs, resolveClashPremium, resolveClashRsAlpha, ] consola.start('Resolving latest versions') const results = await Promise.all(resolvers.map((r) => r())) consola.success('Resolved latest versions') consola.start('Generating manifest') const manifest: ManifestVersion = { manifest_version: MANIFEST_VERSION, latest: {}, arch_template: {}, } as ManifestVersion for (const result of results) { manifest.latest[result.name as SupportedCore] = result.version manifest.arch_template[result.name as SupportedCore] = result.archMapping } await fs.ensureDir(MANIFEST_DIR) // If no changes, skip writing manifest const previousManifest = (await fs.readJSON(MANIFEST_VERSION_PATH)) || {} delete previousManifest.updated_at if (JSON.stringify(previousManifest) === JSON.stringify(manifest)) { consola.success('No changes, skip writing manifest') return } manifest.updated_at = new Date().toISOString() consola.success('Generated manifest') await fs.writeJSON(MANIFEST_VERSION_PATH, manifest, { spaces: 2 }) consola.success('Manifest written') } generateLatestVersion() ================================================ FILE: scripts/manifest/clash-meta.ts ================================================ import { ClashManifest } from 'types' import versionManifest from '../../manifest/version.json' export const CLASH_META_MANIFEST: ClashManifest = { URL_PREFIX: `https://github.com/MetaCubeX/mihomo/releases/download/${versionManifest.latest.mihomo}`, VERSION: versionManifest.latest.mihomo, ARCH_MAPPING: versionManifest.arch_template.mihomo, } export const CLASH_META_ALPHA_MANIFEST: ClashManifest = { VERSION_URL: 'https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha/version.txt', URL_PREFIX: 'https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha', VERSION: versionManifest.latest.mihomo_alpha, ARCH_MAPPING: versionManifest.arch_template.mihomo_alpha, } ================================================ FILE: scripts/manifest/clash-premium.ts ================================================ import { ClashManifest } from 'types' import versionManifest from '../../manifest/version.json' export const CLASH_MANIFEST: ClashManifest = { URL_PREFIX: 'https://github.com/Dreamacro/clash/releases/download/premium/', LATEST_DATE: '2023.08.17', STORAGE_PREFIX: 'https://release.dreamacro.workers.dev/', BACKUP_URL_PREFIX: 'https://github.com/zhongfly/Clash-premium-backup/releases/download/', BACKUP_LATEST_DATE: versionManifest.latest.clash_premium, VERSION: versionManifest.latest.clash_premium, ARCH_MAPPING: versionManifest.arch_template.clash_premium, } ================================================ FILE: scripts/manifest/clash-rs.ts ================================================ import { ClashManifest } from 'types' import versionManifest from '../../manifest/version.json' export const CLASH_RS_MANIFEST: ClashManifest = { URL_PREFIX: 'https://github.com/Watfaq/clash-rs/releases/download/', VERSION: versionManifest.latest.clash_rs, ARCH_MAPPING: versionManifest.arch_template.clash_rs, } export const CLASH_RS_ALPHA_MANIFEST: ClashManifest = { VERSION_URL: 'https://github.com/Watfaq/clash-rs/releases/download/latest/version.txt', URL_PREFIX: 'https://github.com/Watfaq/clash-rs/releases/download/latest', VERSION: versionManifest.latest.clash_rs_alpha, ARCH_MAPPING: versionManifest.arch_template.clash_rs_alpha, } ================================================ FILE: scripts/manifest/index.ts ================================================ import { CLASH_META_ALPHA_MANIFEST, CLASH_META_MANIFEST } from './clash-meta' import { CLASH_MANIFEST } from './clash-premium' import { CLASH_RS_MANIFEST } from './clash-rs' export const clashManifest = { premium: CLASH_MANIFEST, meta: CLASH_META_MANIFEST, metaAlpha: CLASH_META_ALPHA_MANIFEST, rs: CLASH_RS_MANIFEST, } ================================================ FILE: scripts/osx-aarch64-upload.ts ================================================ /** * Build and upload assets * for macOS(aarch) */ import path from 'node:path' import fs from 'fs-extra' import { context, getOctokit } from '@actions/github' import pkgJson from '../package.json' import { consola } from './utils/logger' async function resolve() { if (!process.env.GITHUB_TOKEN) { throw new Error('GITHUB_TOKEN is required') } if (!process.env.TAURI_SIGNING_PRIVATE_KEY) { throw new Error('TAURI_SIGNING_PRIVATE_KEY is required') } if (!process.env.TAURI_SIGNING_PRIVATE_KEY_PASSWORD) { throw new Error('TAURI_SIGNING_PRIVATE_KEY_PASSWORD is required') } const { version } = pkgJson const tag = process.env.TAG_NAME || `v${version}` consola.info(`Upload to tag ${tag}`) const cwd = process.cwd() const bundlePath = path.join( 'backend/target/aarch64-apple-darwin/release/bundle', ) const join = (p: string) => path.join(bundlePath, p) const appPathList = [ join('macos/Clash Nyanpasu.aarch64.app.tar.gz'), join('macos/Clash Nyanpasu.aarch64.app.tar.gz.sig'), ] for (const appPath of appPathList) { if (fs.pathExistsSync(appPath)) { fs.removeSync(appPath) } } fs.copyFileSync(join('macos/Clash Nyanpasu.app.tar.gz'), appPathList[0]) fs.copyFileSync(join('macos/Clash Nyanpasu.app.tar.gz.sig'), appPathList[1]) const options = { owner: context.repo.owner, repo: context.repo.repo } const github = getOctokit(process.env.GITHUB_TOKEN) const { data: release } = await github.rest.repos.getReleaseByTag({ ...options, tag, }) if (!release.id) throw new Error('failed to find the release') await uploadAssets(release.id, [ join(`dmg/Clash Nyanpasu_${version}_aarch64.dmg`), ...appPathList, ]) } // From tauri-apps/tauri-action // https://github.com/tauri-apps/tauri-action/blob/dev/packages/action/src/upload-release-assets.ts async function uploadAssets(releaseId: number, assets: string[]) { const GITHUB_TOKEN = process.env.GITHUB_TOKEN if (!GITHUB_TOKEN) { throw new Error('GITHUB_TOKEN is required') } const github = getOctokit(GITHUB_TOKEN) // Determine content-length for header to upload asset const contentLength = (filePath: string) => fs.statSync(filePath).size for (const assetPath of assets) { const headers = { 'content-type': 'application/zip', 'content-length': contentLength(assetPath), } const ext = path.extname(assetPath) const filename = path.basename(assetPath).replace(ext, '') const assetName = path.dirname(assetPath).includes(`target${path.sep}debug`) ? `${filename}-debug${ext}` : `${filename}${ext}` consola.start(`Uploading ${assetName}...`) try { await github.rest.repos.uploadReleaseAsset({ headers, name: assetName, // https://github.com/tauri-apps/tauri-action/pull/45 // @ts-expect-error error TS2322: Type 'Buffer' is not assignable to type 'string'. data: fs.readFileSync(assetPath), owner: context.repo.owner, repo: context.repo.repo, release_id: releaseId, }) consola.success(`Uploaded ${assetName}`) } catch (error) { throw new Error( `Failed to upload release asset: ${error instanceof Error ? error.message : error}`, ) } } } resolve() ================================================ FILE: scripts/package.json ================================================ { "name": "@nyanpasu/scripts", "type": "module", "version": "2.0.0", "dependencies": { "@actions/github": "6.0.1", "@types/figlet": "1.7.0", "@types/semver": "7.7.1", "figlet": "1.11.0", "filesize": "11.0.13", "p-retry": "7.1.1", "semver": "7.7.4", "zod": "4.3.6" }, "devDependencies": { "@octokit/types": "16.0.0", "@types/adm-zip": "0.5.7", "@types/yargs": "17.0.35", "adm-zip": "0.5.16", "colorize-template": "1.0.0", "consola": "3.4.2", "fs-extra": "11.3.4", "octokit": "5.0.5", "picocolors": "1.1.1", "tar": "7.5.12", "telegram": "2.26.22", "undici": "7.24.5", "yargs": "18.0.0" } } ================================================ FILE: scripts/portable.ts ================================================ import path from 'node:path' import AdmZip from 'adm-zip' import fs from 'fs-extra' import { context, getOctokit } from '@actions/github' import packageJson from '../package.json' import { TAURI_APP_DIR } from './utils/env' import { colorize, consola } from './utils/logger' const RUST_ARCH = process.env.RUST_ARCH || 'x86_64' const fixedWebview = process.argv.includes('--fixed-webview') /// Script for ci /// 打包绿色版/便携版 (only Windows) async function resolvePortable() { if (process.platform !== 'win32') return const buildDir = path.join( RUST_ARCH === 'x86_64' ? 'backend/target/release' : `backend/target/${RUST_ARCH}-pc-windows-msvc/release`, ) const configDir = path.join(buildDir, '.config') if (!(await fs.pathExists(buildDir))) { throw new Error('could not found the release dir') } await fs.ensureDir(configDir) await fs.createFile(path.join(configDir, 'PORTABLE')) const zip = new AdmZip() let mainEntryPath = path.join(buildDir, 'Clash Nyanpasu.exe') if (!(await fs.pathExists(mainEntryPath))) { mainEntryPath = path.join(buildDir, 'clash-nyanpasu.exe') } zip.addLocalFile(mainEntryPath) zip.addLocalFile(path.join(buildDir, 'clash.exe')) zip.addLocalFile(path.join(buildDir, 'mihomo.exe')) zip.addLocalFile(path.join(buildDir, 'mihomo-alpha.exe')) zip.addLocalFile(path.join(buildDir, 'nyanpasu-service.exe')) zip.addLocalFile(path.join(buildDir, 'clash-rs.exe')) zip.addLocalFile(path.join(buildDir, 'clash-rs-alpha.exe')) zip.addLocalFolder(path.join(buildDir, 'resources'), 'resources') if (fixedWebview) { const webviewPath = (await fs.readdir(TAURI_APP_DIR)).find((file) => file.includes('WebView2'), ) if (!webviewPath) { throw new Error('WebView2 runtime not found') } zip.addLocalFolder( path.join(TAURI_APP_DIR, webviewPath), path.basename(webviewPath), ) } zip.addLocalFolder(configDir, '.config') const { version } = packageJson const zipFile = `Clash.Nyanpasu_${version}_${RUST_ARCH}${fixedWebview ? '_fixed-webview' : ''}_portable.zip` zip.writeZip(zipFile) consola.success('create portable zip successfully') // push release assets if (process.env.GITHUB_TOKEN === undefined) { throw new Error('GITHUB_TOKEN is required') } const options = { owner: context.repo.owner, repo: context.repo.repo } const github = getOctokit(process.env.GITHUB_TOKEN) consola.info('upload to ', process.env.TAG_NAME || `v${version}`) const { data: release } = await github.rest.repos.getReleaseByTag({ ...options, tag: process.env.TAG_NAME || `v${version}`, }) consola.debug(colorize`releaseName: {green ${release.name}}`) await github.rest.repos.uploadReleaseAsset({ ...options, release_id: release.id, name: zipFile, // @ts-expect-error data is Buffer should work fine data: zip.toBuffer(), }) } resolvePortable().catch((err) => { consola.error(err) process.exit(1) }) ================================================ FILE: scripts/prepare-nightly.ts ================================================ import { execSync } from 'child_process' import path from 'node:path' import fs from 'fs-extra' import { merge } from 'lodash-es' import { cwd, TAURI_APP_DIR, TAURI_FIXED_WEBVIEW2_CONFIG_OVERRIDE_PATH, } from './utils/env' import { consola } from './utils/logger' const TAURI_DEV_APP_CONF_PATH = path.join( TAURI_APP_DIR, 'tauri.nightly.conf.json', ) const TAURI_APP_CONF = path.join(TAURI_APP_DIR, 'tauri.conf.json') const TAURI_DEV_APP_OVERRIDES_PATH = path.join( TAURI_APP_DIR, 'overrides/nightly.conf.json', ) const ROOT_PACKAGE_JSON_PATH = path.join(cwd, 'package.json') const NYANPASU_PACKAGE_JSON_PATH = path.join( cwd, 'frontend/nyanpasu/package.json', ) // blocked by https://github.com/tauri-apps/tauri/issues/8447 // const WXS_PATH = path.join(TAURI_APP_DIR, "templates", "nightly.wxs"); const isNSIS = process.argv.includes('--nsis') // only build nsis const isMSI = process.argv.includes('--msi') // only build msi const fixedWebview = process.argv.includes('--fixed-webview') const disableUpdater = process.argv.includes('--disable-updater') async function main() { consola.debug('Read config...') const tauriAppConf = await fs.readJSON(TAURI_APP_CONF) const tauriAppOverrides = await fs.readJSON(TAURI_DEV_APP_OVERRIDES_PATH) let tauriConf = merge(tauriAppConf, tauriAppOverrides) const packageJson = await fs.readJSON(NYANPASU_PACKAGE_JSON_PATH) const rootPackageJson = await fs.readJSON(ROOT_PACKAGE_JSON_PATH) // const wxsFile = await fs.readFile(WXS_PATH, "utf-8"); if (fixedWebview) { const fixedWebview2Config = await fs.readJSON( TAURI_FIXED_WEBVIEW2_CONFIG_OVERRIDE_PATH, ) const webviewPath = (await fs.readdir(TAURI_APP_DIR)).find((file) => file.includes('WebView2'), ) if (!webviewPath) { throw new Error('WebView2 runtime not found') } tauriConf = merge(tauriConf, fixedWebview2Config) delete tauriConf.bundle.windows.webviewInstallMode.silent tauriConf.bundle.windows.webviewInstallMode.path = `./${path.basename(webviewPath)}` tauriConf.plugins.updater.endpoints = tauriConf.plugins.updater.endpoints.map((o: string) => o.replace('update-', 'update-nightly-'), ) } if (isNSIS) { tauriConf.bundle.targets = ['nsis'] } if (disableUpdater) { tauriConf.bundle.createUpdaterArtifacts = false } consola.debug('Get current git short hash') const GIT_SHORT_HASH = execSync('git rev-parse --short HEAD') .toString() .trim() consola.debug(`Current git short hash: ${GIT_SHORT_HASH}`) const version = `${tauriConf.version}-alpha+${GIT_SHORT_HASH}` // blocked by https://github.com/tauri-apps/tauri/issues/8447 // 1. update wxs version // consola.debug("Write raw version to wxs"); // const modifiedWxsFile = wxsFile.replace( // "{{version}}", // tauriConf.package.version, // ); // await fs.writeFile(WXS_PATH, modifiedWxsFile); // consola.debug("wxs updated"); // 2. update tauri version consola.debug('Write tauri version to tauri.nightly.conf.json') if (!isNSIS && !isMSI) tauriConf.version = version await fs.writeJSON(TAURI_DEV_APP_CONF_PATH, tauriConf, { spaces: 2 }) consola.debug('tauri.nightly.conf.json updated') // 3. update package version consola.debug('Write tauri version to package.json') packageJson.version = version await fs.writeJSON(NYANPASU_PACKAGE_JSON_PATH, packageJson, { spaces: 2 }) rootPackageJson.version = version await fs.writeJSON(ROOT_PACKAGE_JSON_PATH, rootPackageJson, { spaces: 2 }) consola.debug('package.json updated') } main() ================================================ FILE: scripts/prepare-preview.ts ================================================ import path from 'path' import fs from 'fs-extra' import { TAURI_APP_DIR } from './utils/env' import { consola } from './utils/logger' const TAURI_APP_CONF = path.join(TAURI_APP_DIR, 'tauri.conf.json') const TAURI_PREVIEW_APP_CONF_PATH = path.join( TAURI_APP_DIR, 'tauri.preview.conf.json', ) const main = async () => { consola.debug('Read config...') const tauriAppConf = await fs.readJSON(TAURI_APP_CONF) tauriAppConf.build.devPath = tauriAppConf.build.distDir tauriAppConf.build.beforeDevCommand = tauriAppConf.build.beforeBuildCommand consola.debug('Write config...') await fs.writeJSON(TAURI_PREVIEW_APP_CONF_PATH, tauriAppConf, { spaces: 2, }) } main() ================================================ FILE: scripts/prepare-release.ts ================================================ import path from 'node:path' import fs from 'fs-extra' import { merge } from 'lodash-es' import { cwd, TAURI_APP_DIR, TAURI_FIXED_WEBVIEW2_CONFIG_OVERRIDE_PATH, } from './utils/env' import { consola } from './utils/logger' const TAURI_APP_CONF = path.join(TAURI_APP_DIR, 'tauri.conf.json') // TODO: define overrides // const TAURI_DEV_APP_OVERRIDES_PATH = path.join( // TAURI_APP_DIR, // "overrides/nightly.conf.json", // ); const PACKAGE_JSON_PATH = path.join(cwd, 'package.json') // blocked by https://github.com/tauri-apps/tauri/issues/8447 // const WXS_PATH = path.join(TAURI_APP_DIR, "templates", "nightly.wxs"); const isNSIS = process.argv.includes('--nsis') // only build nsis const fixedWebview = process.argv.includes('--fixed-webview') async function main() { consola.debug('Read config...') const tauriAppConf = await fs.readJSON(TAURI_APP_CONF) // const tauriAppOverrides = await fs.readJSON(TAURI_DEV_APP_OVERRIDES_PATH); // const tauriConf = merge(tauriAppConf, tauriAppOverrides); let tauriConf = tauriAppConf // const wxsFile = await fs.readFile(WXS_PATH, "utf-8"); // if (isNSIS) { // tauriConf.tauri.bundle.targets = ["nsis", "updater"]; // } if (fixedWebview) { const fixedWebview2Config = await fs.readJSON( TAURI_FIXED_WEBVIEW2_CONFIG_OVERRIDE_PATH, ) const webviewPath = (await fs.readdir(TAURI_APP_DIR)).find((file) => file.includes('WebView2'), ) if (!webviewPath) { throw new Error('WebView2 runtime not found') } tauriConf = merge(tauriConf, fixedWebview2Config) delete tauriConf.bundle.windows.webviewInstallMode.silent tauriConf.bundle.windows.webviewInstallMode.path = `./${path.basename(webviewPath)}` } consola.debug('Write tauri version to tauri.conf.json') await fs.writeJSON(TAURI_APP_CONF, tauriConf, { spaces: 2 }) consola.debug('tauri.conf.json updated') consola.debug('package.json updated') } main() ================================================ FILE: scripts/publish.ts ================================================ import path from 'node:path' import fs from 'fs-extra' import packageJson from '../package.json' import { cwd, TAURI_APP_DIR } from './utils/env' const MONO_REPO_PATHS = [ path.join(cwd, 'frontend/nyanpasu'), path.join(cwd, 'frontend/ui'), path.join(cwd, 'frontend/interface'), path.join(cwd, 'scripts'), ] // import { consola } from "./utils/logger"; const TAURI_APP_CONF_PATH = path.join(TAURI_APP_DIR, 'tauri.conf.json') const TAURI_NIGHTLY_APP_CONF_PATH = path.join( TAURI_APP_DIR, 'overrides/nightly.conf.json', ) const PACKAGE_JSON_PATH = path.join(cwd, 'package.json') // publish async function resolvePublish() { const flag = process.argv[2] ?? 'patch' const tauriJson = await fs.readJSON(TAURI_APP_CONF_PATH) const tauriNightlyJson = await fs.readJSON(TAURI_NIGHTLY_APP_CONF_PATH) let [a, b, c] = packageJson.version.split('.').map(Number) if (flag === 'major') { a += 1 b = 0 c = 0 } else if (flag === 'minor') { b += 1 c = 0 } else if (flag === 'patch') { c += 1 } else throw new Error(`invalid flag "${flag}"`) const nextVersion = `${a}.${b}.${c}` const nextNightlyVersion = `${a}.${b}.${c + 1}` packageJson.version = nextVersion tauriJson.version = nextVersion tauriNightlyJson.version = nextNightlyVersion // 发布更新前先写更新日志 // const nextTag = `v${nextVersion}`; // await resolveUpdateLog(nextTag); await fs.writeJSON(PACKAGE_JSON_PATH, packageJson, { spaces: 2, }) await fs.writeJSON(TAURI_APP_CONF_PATH, tauriJson, { spaces: 2, }) await fs.writeJSON(TAURI_NIGHTLY_APP_CONF_PATH, tauriNightlyJson, { spaces: 2, }) // overrides mono repo package.json for (const monoRepoPath of MONO_REPO_PATHS) { const monoRepoPackageJsonPath = path.join(monoRepoPath, 'package.json') const monoRepoPackageJson = await fs.readJSON(monoRepoPackageJsonPath) monoRepoPackageJson.version = nextVersion await fs.writeJSON(monoRepoPackageJsonPath, monoRepoPackageJson, { spaces: 2, }) } // execSync("git add ./package.json"); // execSync(`git add ${TAURI_APP_CONF_PATH}`); // execSync(`git commit -m "v${nextVersion}"`); // execSync(`git tag -a v${nextVersion} -m "v${nextVersion}"`); // execSync(`git push`); // execSync(`git push origin v${nextVersion}`); // consola.success(`Publish Successfully...`); console.log(nextVersion) } resolvePublish() ================================================ FILE: scripts/tsconfig.json ================================================ { "compilerOptions": { "baseUrl": "./", "target": "ESNext", "useDefineForClassFields": true, "lib": ["ESNext"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Node", "resolveJsonModule": true, "isolatedModules": true, "composite": true, }, "include": ["./"], "exclude": ["deno"], } ================================================ FILE: scripts/types/index.ts ================================================ import { ArchMapping } from 'utils/manifest' export interface ClashManifest { URL_PREFIX: string LATEST_DATE?: string STORAGE_PREFIX?: string BACKUP_URL_PREFIX?: string BACKUP_LATEST_DATE?: string VERSION?: string VERSION_URL?: string ARCH_MAPPING: ArchMapping } export interface BinInfo { name: string targetFile: string exeFile: string tmpFile: string downloadURL: string } export enum SupportedArch { WindowsX86_32 = 'windows-i386', WindowsX86_64 = 'windows-x86_64', WindowsArm64 = 'windows-arm64', LinuxAarch64 = 'linux-aarch64', LinuxAmd64 = 'linux-amd64', LinuxI386 = 'linux-i386', LinuxArmv7 = 'linux-armv7', LinuxArmv7hf = 'linux-armv7hf', DarwinArm64 = 'darwin-arm64', DarwinX64 = 'darwin-x64', } export enum SupportedCore { Mihomo = 'mihomo', MihomoAlpha = 'mihomo_alpha', ClashRs = 'clash_rs', ClashRsAlpha = 'clash_rs_alpha', ClashPremium = 'clash_premium', } export interface ManifestVersion { manifest_version: number latest: { [K in SupportedCore]: string } arch_template: { [K in SupportedCore]: ArchMapping } updated_at: string // ISO 8601 } ================================================ FILE: scripts/updatelog.ts ================================================ import path from 'path' import fs from 'fs-extra' import { cwd } from './utils/env' const UPDATE_LOG = 'UPDATELOG.md' // parse the UPDATELOG.md export async function resolveUpdateLog(tag: string) { const reTitle = /^## v[\d.]+/ const reEnd = /^---/ const file = path.join(cwd, UPDATE_LOG) if (!(await fs.pathExists(file))) { throw new Error('could not found UPDATELOG.md') } const data = await fs.readFile(file).then((d) => d.toString('utf8')) const map = {} as Record let p = '' data.split('\n').forEach((line) => { if (reTitle.test(line)) { p = line.slice(3).trim() if (!map[p]) { map[p] = [] } else { throw new Error(`Tag ${p} dup`) } } else if (reEnd.test(line)) { p = '' } else if (p) { map[p].push(line) } }) if (!map[tag]) { throw new Error(`could not found "${tag}" in UPDATELOG.md`) } return map[tag].join('\n').trim() } ================================================ FILE: scripts/updater-nightly.ts ================================================ import { execSync } from 'child_process' import fs from 'fs/promises' import path from 'path' import { camelCase, upperFirst } from 'lodash-es' import semver from 'semver' import { fetch } from 'undici' import yargs from 'yargs' import { hideBin } from 'yargs/helpers' import { z } from 'zod' import { context, getOctokit } from '@actions/github' import tauriNightly from '../backend/tauri/overrides/nightly.conf.json' import { getGithubUrl, getProxyAgent } from './utils' import { colorize, consola } from './utils/logger' const UPDATE_TAG_NAME = 'updater' const UPDATE_JSON_FILE = 'update-nightly.json' const UPDATE_JSON_PROXY = 'update-nightly-proxy.json' const UPDATE_FIXED_WEBVIEW_FILE = 'update-nightly-fixed-webview.json' const UPDATE_FIXED_WEBVIEW_PROXY = 'update-nightly-fixed-webview-proxy.json' const argv = yargs(hideBin(process.argv)) .option('fixed-webview', { type: 'boolean', default: false, }) .option('cache-path', { type: 'string', requiresArg: false, }) .help() .parseSync() /// generate update.json /// upload to update tag's release asset async function resolveUpdater() { if (process.env.GITHUB_TOKEN === undefined) { throw new Error('GITHUB_TOKEN is required') } consola.start('start to generate updater files') const options = { owner: context.repo.owner, repo: context.repo.repo, } const github = getOctokit(process.env.GITHUB_TOKEN) consola.debug('resolve latest pre-release files...') // latest pre-release tag const { data: latestPreRelease } = await github.rest.repos.getReleaseByTag({ ...options, tag: 'pre-release', }) let shortHash = '' const latestContent = latestPreRelease.assets.find( (o) => o.name === 'latest.json', ) // trying to get the short hash from the latest.json if (latestContent) { const schema = z.object({ version: z.string().min(1), }) const latest = schema.parse( await fetch(latestContent.browser_download_url, { dispatcher: getProxyAgent(), }).then((res) => res.json()), ) const version = semver.parse(latest.version) if (version && version.build.length > 0) { console.log(version) shortHash = version.build[0] } } if (!shortHash) { shortHash = await execSync(`git rev-parse --short pre-release`) .toString() .replace('\n', '') .replace('\r', '') .slice(0, 7) } consola.info(`latest pre-release short hash: ${shortHash}`) const updateData = { name: `v${tauriNightly.version}-alpha+${shortHash}`, notes: 'Nightly build. Full changes see commit history.', pub_date: new Date().toISOString(), platforms: { win64: { signature: '', url: '' }, // compatible with older formats linux: { signature: '', url: '' }, // compatible with older formats darwin: { signature: '', url: '' }, // compatible with older formats 'darwin-aarch64': { signature: '', url: '' }, 'darwin-intel': { signature: '', url: '' }, 'darwin-x86_64': { signature: '', url: '' }, 'linux-x86_64': { signature: '', url: '' }, // "linux-aarch64": { signature: "", url: "" }, // "linux-armv7": { signature: "", url: "" }, 'windows-x86_64': { signature: '', url: '' }, 'windows-i686': { signature: '', url: '' }, 'windows-aarch64': { signature: '', url: '' }, }, } const promises = latestPreRelease.assets.map(async (asset) => { const { name, browser_download_url: browserDownloadUrl } = asset function isMatch(name: string, extension: string, arch: string) { return ( name.endsWith(extension) && name.includes(arch) && (argv.fixedWebview ? name.includes('fixed-webview') : !name.includes('fixed-webview')) ) } // win64 url if (isMatch(name, '.nsis.zip', 'x64')) { updateData.platforms.win64.url = browserDownloadUrl updateData.platforms['windows-x86_64'].url = browserDownloadUrl } // win64 signature if (isMatch(name, '.nsis.zip.sig', 'x64')) { const sig = await getSignature(browserDownloadUrl) updateData.platforms.win64.signature = sig updateData.platforms['windows-x86_64'].signature = sig } // win32 url if (isMatch(name, '.nsis.zip', 'x86')) { updateData.platforms['windows-i686'].url = browserDownloadUrl } // win32 signature if (isMatch(name, '.nsis.zip.sig', 'x86')) { const sig = await getSignature(browserDownloadUrl) updateData.platforms['windows-i686'].signature = sig } // win arm64 url if (isMatch(name, '.nsis.zip', 'arm64')) { updateData.platforms['windows-aarch64'].url = browserDownloadUrl } // win arm64 signature if (isMatch(name, '.nsis.zip.sig', 'arm64')) { const sig = await getSignature(browserDownloadUrl) updateData.platforms['windows-aarch64'].signature = sig } // darwin url (intel) if (name.endsWith('.app.tar.gz') && !name.includes('aarch')) { updateData.platforms.darwin.url = browserDownloadUrl updateData.platforms['darwin-intel'].url = browserDownloadUrl updateData.platforms['darwin-x86_64'].url = browserDownloadUrl } // darwin signature (intel) if (name.endsWith('.app.tar.gz.sig') && !name.includes('aarch')) { const sig = await getSignature(browserDownloadUrl) updateData.platforms.darwin.signature = sig updateData.platforms['darwin-intel'].signature = sig updateData.platforms['darwin-x86_64'].signature = sig } // darwin url (aarch) if (name.endsWith('aarch64.app.tar.gz')) { updateData.platforms['darwin-aarch64'].url = browserDownloadUrl } // darwin signature (aarch) if (name.endsWith('aarch64.app.tar.gz.sig')) { const sig = await getSignature(browserDownloadUrl) updateData.platforms['darwin-aarch64'].signature = sig } // linux url if (name.endsWith('.AppImage.tar.gz')) { updateData.platforms.linux.url = browserDownloadUrl updateData.platforms['linux-x86_64'].url = browserDownloadUrl } // linux signature if (name.endsWith('.AppImage.tar.gz.sig')) { const sig = await getSignature(browserDownloadUrl) updateData.platforms.linux.signature = sig updateData.platforms['linux-x86_64'].signature = sig } }) await Promise.allSettled(promises) consola.info(updateData) consola.debug('generate updater metadata...') // maybe should test the signature as well // delete the null field Object.entries(updateData.platforms).forEach(([key, value]) => { if (!value.url) { throw new Error(`failed to parse release for "${key}"`) } }) // 生成一个代理github的更新文件 // 使用 https://hub.fastgit.xyz/ 做github资源的加速 const updateDataNew = JSON.parse( JSON.stringify(updateData), ) as typeof updateData Object.entries(updateDataNew.platforms).forEach(([key, value]) => { if (value.url) { updateDataNew.platforms[key as keyof typeof updateData.platforms].url = getGithubUrl(value.url) } else { consola.error(`updateDataNew.platforms.${key} is null`) } }) // update the update.json consola.debug('update updater files...') let updateRelease try { const { data } = await github.rest.repos.getReleaseByTag({ ...options, tag: UPDATE_TAG_NAME, }) updateRelease = data } catch (err) { consola.error(err) consola.error('failed to get release by tag, create one') const { data } = await github.rest.repos.createRelease({ ...options, tag_name: UPDATE_TAG_NAME, name: upperFirst(camelCase(UPDATE_TAG_NAME)), body: 'files for programs to check for updates', prerelease: true, }) updateRelease = data } // delete the old assets for (const asset of updateRelease.assets) { if ( argv.fixedWebview ? asset.name === UPDATE_FIXED_WEBVIEW_FILE : asset.name === UPDATE_JSON_FILE ) { await github.rest.repos.deleteReleaseAsset({ ...options, asset_id: asset.id, }) } if ( argv.fixedWebview ? asset.name === UPDATE_FIXED_WEBVIEW_PROXY : asset.name === UPDATE_JSON_PROXY ) { await github.rest.repos .deleteReleaseAsset({ ...options, asset_id: asset.id }) .catch((err) => { consola.error(err) }) // do not break the pipeline } } // upload new assets await github.rest.repos.uploadReleaseAsset({ ...options, release_id: updateRelease.id, name: argv.fixedWebview ? UPDATE_FIXED_WEBVIEW_FILE : UPDATE_JSON_FILE, data: JSON.stringify(updateData, null, 2), }) // cache the files if cache path is provided await saveToCache( argv.fixedWebview ? UPDATE_FIXED_WEBVIEW_FILE : UPDATE_JSON_FILE, JSON.stringify(updateData, null, 2), ) await github.rest.repos.uploadReleaseAsset({ ...options, release_id: updateRelease.id, name: argv.fixedWebview ? UPDATE_FIXED_WEBVIEW_PROXY : UPDATE_JSON_PROXY, data: JSON.stringify(updateDataNew, null, 2), }) // cache the proxy file if cache path is provided await saveToCache( argv.fixedWebview ? UPDATE_FIXED_WEBVIEW_PROXY : UPDATE_JSON_PROXY, JSON.stringify(updateDataNew, null, 2), ) consola.success('updater files updated') } async function saveToCache(fileName: string, content: string) { if (!argv.cachePath) return try { await fs.mkdir(argv.cachePath, { recursive: true }) const filePath = path.join(argv.cachePath, fileName) await fs.writeFile(filePath, content, 'utf-8') consola.success(colorize`cached file saved to: {gray.bold ${filePath}}`) } catch (err) { throw new Error(`Failed to save cache file: ${err}`) } } // get the signature file content async function getSignature(url: string) { const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/octet-stream' }, dispatcher: getProxyAgent(), }) return response.text() } resolveUpdater().catch((err) => { consola.fatal(err) process.exit(1) }) ================================================ FILE: scripts/updater.ts ================================================ import fs from 'fs/promises' import path from 'path' import { fetch } from 'undici' import yargs from 'yargs' import { hideBin } from 'yargs/helpers' import { context, getOctokit } from '@actions/github' import { resolveUpdateLog } from './updatelog' import { getGithubUrl, getProxyAgent } from './utils' import { colorize, consola } from './utils/logger' const UPDATE_TAG_NAME = 'updater' const UPDATE_JSON_FILE = 'update.json' const UPDATE_JSON_PROXY = 'update-proxy.json' const UPDATE_FIXED_WEBVIEW_FILE = 'update-fixed-webview.json' const UPDATE_FIXED_WEBVIEW_PROXY = 'update-fixed-webview-proxy.json' const UPDATE_RELEASE_BODY = process.env.RELEASE_BODY || '' const argv = yargs(hideBin(process.argv)) .option('fixed-webview', { type: 'boolean', default: false, }) .option('cache-path', { type: 'string', requiresArg: false, }) .help() .parseSync() /// generate update.json /// upload to update tag's release asset async function resolveUpdater() { if (process.env.GITHUB_TOKEN === undefined) { throw new Error('GITHUB_TOKEN is required') } const options = { owner: context.repo.owner, repo: context.repo.repo } const github = getOctokit(process.env.GITHUB_TOKEN) const { data: tags } = await github.rest.repos.listTags({ ...options, per_page: 10, page: 1, }) // get the latest publish tag const tag = tags.find((t) => t.name.startsWith('v')) if (!tag) throw new Error('could not found the latest tag') consola.debug(colorize`latest tag: {gray.bold ${tag.name}}`) const { data: latestRelease } = await github.rest.repos.getReleaseByTag({ ...options, tag: tag.name, }) let updateLog: string | null = null try { updateLog = await resolveUpdateLog(tag.name) } catch (err) { consola.error(err) } const updateData = { name: tag.name, notes: UPDATE_RELEASE_BODY || updateLog || latestRelease.body, pub_date: new Date().toISOString(), platforms: { win64: { signature: '', url: '' }, // compatible with older formats linux: { signature: '', url: '' }, // compatible with older formats darwin: { signature: '', url: '' }, // compatible with older formats 'darwin-aarch64': { signature: '', url: '' }, 'darwin-intel': { signature: '', url: '' }, 'darwin-x86_64': { signature: '', url: '' }, 'linux-x86_64': { signature: '', url: '' }, // "linux-aarch64": { signature: "", url: "" }, // "linux-armv7": { signature: "", url: "" }, 'windows-x86_64': { signature: '', url: '' }, 'windows-i686': { signature: '', url: '' }, 'windows-aarch64': { signature: '', url: '' }, }, } const promises = latestRelease.assets.map(async (asset) => { const { name, browser_download_url: browserDownloadUrl } = asset function isMatch(name: string, extension: string, arch: string) { return ( name.endsWith(extension) && name.includes(arch) && (argv.fixedWebview ? name.includes('fixed-webview') : !name.includes('fixed-webview')) ) } // win64 url if (isMatch(name, '.nsis.zip', 'x64')) { updateData.platforms.win64.url = browserDownloadUrl updateData.platforms['windows-x86_64'].url = browserDownloadUrl } // win64 signature if (isMatch(name, '.nsis.zip.sig', 'x64')) { const sig = await getSignature(browserDownloadUrl) updateData.platforms.win64.signature = sig updateData.platforms['windows-x86_64'].signature = sig } // win32 url if (isMatch(name, '.nsis.zip', 'x86')) { updateData.platforms['windows-i686'].url = browserDownloadUrl } // win32 signature if (isMatch(name, '.nsis.zip.sig', 'x86')) { const sig = await getSignature(browserDownloadUrl) updateData.platforms['windows-i686'].signature = sig } // win arm64 url if (isMatch(name, '.nsis.zip', 'arm64')) { updateData.platforms['windows-aarch64'].url = browserDownloadUrl } // win arm64 signature if (isMatch(name, '.nsis.zip.sig', 'arm64')) { const sig = await getSignature(browserDownloadUrl) updateData.platforms['windows-aarch64'].signature = sig } // darwin url (intel) if (name.endsWith('.app.tar.gz') && !name.includes('aarch')) { updateData.platforms.darwin.url = browserDownloadUrl updateData.platforms['darwin-intel'].url = browserDownloadUrl updateData.platforms['darwin-x86_64'].url = browserDownloadUrl } // darwin signature (intel) if (name.endsWith('.app.tar.gz.sig') && !name.includes('aarch')) { const sig = await getSignature(browserDownloadUrl) updateData.platforms.darwin.signature = sig updateData.platforms['darwin-intel'].signature = sig updateData.platforms['darwin-x86_64'].signature = sig } // darwin url (aarch) if (name.endsWith('aarch64.app.tar.gz')) { updateData.platforms['darwin-aarch64'].url = browserDownloadUrl } // darwin signature (aarch) if (name.endsWith('aarch64.app.tar.gz.sig')) { const sig = await getSignature(browserDownloadUrl) updateData.platforms['darwin-aarch64'].signature = sig } // linux url if (name.endsWith('.AppImage.tar.gz')) { updateData.platforms.linux.url = browserDownloadUrl updateData.platforms['linux-x86_64'].url = browserDownloadUrl } // linux signature if (name.endsWith('.AppImage.tar.gz.sig')) { const sig = await getSignature(browserDownloadUrl) updateData.platforms.linux.signature = sig updateData.platforms['linux-x86_64'].signature = sig } }) await Promise.allSettled(promises) consola.info(updateData) // maybe should test the signature as well // delete the null field Object.entries(updateData.platforms).forEach(([key, value]) => { if (!value.url) { consola.error(`failed to parse release for "${key}"`) delete updateData.platforms[key as keyof typeof updateData.platforms] } }) // 生成一个代理github的更新文件 // 使用 https://hub.fastgit.xyz/ 做github资源的加速 const updateDataNew = JSON.parse( JSON.stringify(updateData), ) as typeof updateData Object.entries(updateDataNew.platforms).forEach(([key, value]) => { if (value.url) { updateDataNew.platforms[key as keyof typeof updateData.platforms].url = getGithubUrl(value.url) } else { consola.error(`updateDataNew.platforms.${key} is null`) } }) // update the update.json const { data: updateRelease } = await github.rest.repos.getReleaseByTag({ ...options, tag: UPDATE_TAG_NAME, }) // delete the old assets for (const asset of updateRelease.assets) { if ( argv.fixedWebview ? asset.name === UPDATE_FIXED_WEBVIEW_FILE : asset.name === UPDATE_JSON_FILE ) { await github.rest.repos.deleteReleaseAsset({ ...options, asset_id: asset.id, }) } if ( argv.fixedWebview ? asset.name === UPDATE_FIXED_WEBVIEW_PROXY : asset.name === UPDATE_JSON_PROXY ) { await github.rest.repos .deleteReleaseAsset({ ...options, asset_id: asset.id }) .catch((err) => { consola.error(err) }) // do not break the pipeline } } // upload new assets await github.rest.repos.uploadReleaseAsset({ ...options, release_id: updateRelease.id, name: argv.fixedWebview ? UPDATE_FIXED_WEBVIEW_FILE : UPDATE_JSON_FILE, data: JSON.stringify(updateData, null, 2), }) // cache the files if cache path is provided await saveToCache( argv.fixedWebview ? UPDATE_FIXED_WEBVIEW_FILE : UPDATE_JSON_FILE, JSON.stringify(updateData, null, 2), ) await github.rest.repos.uploadReleaseAsset({ ...options, release_id: updateRelease.id, name: argv.fixedWebview ? UPDATE_FIXED_WEBVIEW_PROXY : UPDATE_JSON_PROXY, data: JSON.stringify(updateDataNew, null, 2), }) // cache the proxy file if cache path is provided await saveToCache( argv.fixedWebview ? UPDATE_FIXED_WEBVIEW_PROXY : UPDATE_JSON_PROXY, JSON.stringify(updateDataNew, null, 2), ) } async function saveToCache(fileName: string, content: string) { if (!argv.cachePath) return try { await fs.mkdir(argv.cachePath, { recursive: true }) const filePath = path.join(argv.cachePath, fileName) await fs.writeFile(filePath, content, 'utf-8') consola.success(colorize`cached file saved to: {gray.bold ${filePath}}`) } catch (err) { consola.error(`Failed to save cache file: ${err}`) } } // get the signature file content async function getSignature(url: string) { const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/octet-stream' }, dispatcher: getProxyAgent(), }) return response.text() } resolveUpdater().catch((err) => { consola.error(err) }) ================================================ FILE: scripts/utils/arch-check.ts ================================================ import { colorize, consola } from './logger' export const archCheck = (platform: string, arch: string) => { consola.debug(colorize`platform {yellow ${platform}}`) consola.debug(colorize`arch {yellow ${arch}}`) } ================================================ FILE: scripts/utils/consts.ts ================================================ import { execSync } from 'child_process' export const SIDECAR_HOST: string | undefined = process.argv.includes( '--sidecar-host', ) ? process.argv[process.argv.indexOf('--sidecar-host') + 1] : execSync('rustc -vV') .toString() ?.match(/(?<=host: ).+(?=\s*)/g)?.[0] ================================================ FILE: scripts/utils/download.ts ================================================ import { execSync } from 'child_process' import path from 'path' import zlib from 'zlib' import AdmZip from 'adm-zip' import fs from 'fs-extra' import * as tar from 'tar' import { BinInfo } from 'types' import { fetch, type RequestInit } from 'undici' import { getProxyAgent } from './' import { TAURI_APP_DIR, TEMP_DIR } from './env' import { colorize, consola } from './logger' /** * download sidecar and rename */ export const downloadFile = async (url: string, path: string) => { const options: Partial = {} const httpProxy = getProxyAgent() if (httpProxy) { options.dispatcher = httpProxy } consola.debug(colorize`download {gray "${url}"} to {gray "${path}"}`) const response = await fetch(url, { ...options, method: 'GET', headers: { 'Content-Type': 'application/octet-stream', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0', }, }) // check status code if (response.status !== 200) { throw new Error(`download failed: ${response.statusText}`) } const buffer = await response.arrayBuffer() await fs.writeFile(path, new Uint8Array(buffer)) consola.success(colorize`download finished {gray "${url.split('/').at(-1)}"}`) } export const resolveSidecar = async ( binInfo: PromiseLike | BinInfo, platform: string, option?: { force?: boolean }, ) => { const { name, targetFile, tmpFile, exeFile, downloadURL } = await binInfo consola.debug(colorize`resolve {cyan ${name}}...`) const sidecarDir = path.join(TAURI_APP_DIR, 'sidecar') const sidecarPath = path.join(sidecarDir, targetFile) await fs.mkdirp(sidecarDir) if (!option?.force && (await fs.pathExists(sidecarPath))) return const tempDir = path.join(TEMP_DIR, name) const tempFile = path.join(tempDir, tmpFile) const tempExe = path.join(tempDir, exeFile) await fs.mkdirp(tempDir) try { if (!(await fs.pathExists(tempFile))) { await downloadFile(downloadURL, tempFile) } if (tmpFile.endsWith('.zip')) { const zip = new AdmZip(tempFile) let entryName zip.getEntries().forEach((entry) => { consola.debug(colorize`"{green ${name}}" entry name ${entry.entryName}`) if ( (entry.entryName.includes(name) && entry.entryName.endsWith('.exe')) || (entry.entryName.includes( name .split('-') .filter((o) => o !== 'alpha') .join('-'), ) && entry.entryName.endsWith('.exe')) ) { entryName = entry.entryName } }) zip.extractAllTo(tempDir, true) if (!entryName) { throw new Error('cannot find exe file in zip') } await fs.rename(path.join(tempDir, entryName), tempExe) await fs.rename(tempExe, sidecarPath) consola.debug(colorize`{green "${name}"} unzip finished`) } else if (tmpFile.endsWith('.tar.gz')) { // decompress and untar the file await tar.x({ file: tempFile, cwd: tempDir, }) await fs.rename(tempExe, sidecarPath) consola.debug(colorize`{green "${name}"} untar finished`) } else if (tmpFile.endsWith('.gz')) { // gz const readStream = fs.createReadStream(tempFile) const writeStream = fs.createWriteStream(sidecarPath) await new Promise((resolve, reject) => { const onError = (error: Error) => { consola.error(colorize`"${name}" gz failed:`, error) reject(error) } readStream .pipe(zlib.createGunzip().on('error', onError)) .pipe(writeStream) .on('finish', () => { consola.debug(colorize`{green "${name}"} gunzip finished`) execSync(`chmod 755 ${sidecarPath}`) consola.debug(colorize`{green "${name}"}chmod binary finished`) resolve() }) .on('error', onError) }) } else { // Common Files await fs.rename(tempFile, sidecarPath) consola.info(colorize`{green "${name}"} rename finished`) if (platform !== 'win32') { execSync(`chmod 755 ${sidecarPath}`) consola.info(colorize`{green "${name}"} chmod binary finished`) } } consola.success(colorize`resolve {green ${name}} finished`) } catch (err) { // 需要删除文件 await fs.remove(sidecarPath) throw err } finally { // delete temp dir await fs.remove(tempDir) } } ================================================ FILE: scripts/utils/env.ts ================================================ import path from 'path' export const cwd = process.cwd() export const TAURI_APP_DIR = path.join(cwd, 'backend/tauri') export const TAURI_FIXED_WEBVIEW2_CONFIG_OVERRIDE_PATH = path.join( TAURI_APP_DIR, 'overrides/fixed-webview2.conf.json', ) export const MANIFEST_DIR = path.join(cwd, 'manifest') export const GITHUB_PROXY = 'https://gh-proxy.com/' export const GITHUB_TOKEN = process.env.GITHUB_TOKEN export const TEMP_DIR = path.join(cwd, 'node_modules/.verge') export const MANIFEST_VERSION_PATH = path.join(MANIFEST_DIR, 'version.json') export const TAURI_APP_TEMP_DIR = path.join(TAURI_APP_DIR, 'tmp') export const GIT_SUMMARY_INFO_PATH = path.join( TAURI_APP_TEMP_DIR, 'git-info.json', ) ================================================ FILE: scripts/utils/index.ts ================================================ import figlet from 'figlet' import { filesize } from 'filesize' import fs from 'fs-extra' import { ProxyAgent } from 'undici' import { GITHUB_PROXY } from './env' export const getGithubUrl = (url: string) => { return new URL(url.replace(/^https?:\/\//g, ''), GITHUB_PROXY).toString() } export const getFileSize = (path: string): string => { const stat = fs.statSync(path) return filesize(stat.size) } export const array2text = ( array: string[], type: 'newline' | 'space' = 'newline', ): string => { let result = '' const getSplit = () => { if (type === 'newline') { return '\n' } else if (type === 'space') { return ' ' } } array.forEach((value, index) => { if (index === array.length - 1) { result += value } else { result += value + getSplit() } }) return result } export const printNyanpasu = () => { const ascii = figlet.textSync('Clash Nyanpasu', { whitespaceBreak: true, }) console.log(ascii) } export const HTTP_PROXY = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy export function getProxyAgent() { if (HTTP_PROXY) { return new ProxyAgent(HTTP_PROXY) } return undefined } ================================================ FILE: scripts/utils/logger.ts ================================================ import { createColorize } from 'colorize-template' import { createConsola } from 'consola' import pc from 'picocolors' export const consola = createConsola({ level: process.env.LOG_LEVEL ? Number.parseInt(process.env.LOG_LEVEL) : 5, fancy: true, formatOptions: { colors: true, compact: false, date: true, }, }) export const colorize = createColorize({ ...pc, success: pc.green, error: pc.red, }) ================================================ FILE: scripts/utils/manifest.ts ================================================ import { fetch } from 'undici' import { SupportedArch } from '../types/index' import { getProxyAgent } from './' import { consola } from './logger' import { applyProxy, octokit } from './octokit' export type ArchMapping = { [key in SupportedArch]: string } export type NodeArch = NodeJS.Architecture | 'armel' // resolvers block export type LatestVersionResolver = Promise<{ name: string version: string archMapping: ArchMapping }> export const resolveMihomo = async (): LatestVersionResolver => { const latestRelease = await octokit.rest.repos.getLatestRelease( applyProxy({ owner: 'MetaCubeX', repo: 'mihomo', }), ) consola.debug(`mihomo latest release: ${latestRelease.data.tag_name}`) const archMapping: ArchMapping = { [SupportedArch.WindowsX86_32]: 'mihomo-windows-386-{}.zip', [SupportedArch.WindowsX86_64]: 'mihomo-windows-amd64-v1-{}.zip', [SupportedArch.WindowsArm64]: 'mihomo-windows-arm64-{}.zip', [SupportedArch.LinuxAarch64]: 'mihomo-linux-arm64-{}.gz', [SupportedArch.LinuxAmd64]: 'mihomo-linux-amd64-v1-{}.gz', [SupportedArch.LinuxI386]: 'mihomo-linux-386-{}.gz', [SupportedArch.DarwinArm64]: 'mihomo-darwin-arm64-{}.gz', [SupportedArch.DarwinX64]: 'mihomo-darwin-amd64-v1-{}.gz', [SupportedArch.LinuxArmv7]: 'mihomo-linux-armv5-{}.gz', [SupportedArch.LinuxArmv7hf]: 'mihomo-linux-armv7-{}.gz', } satisfies ArchMapping return { name: 'mihomo', version: latestRelease.data.tag_name, archMapping, } } export const resolveMihomoAlpha = async (): LatestVersionResolver => { const resp = await fetch( 'https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha/version.txt', { dispatcher: getProxyAgent() }, ) const alphaReleaseHash = (await resp.text()).trim() consola.debug(`mihomo alpha release: ${alphaReleaseHash}`) const archMapping: ArchMapping = { [SupportedArch.WindowsX86_32]: 'mihomo-windows-386-{}.zip', [SupportedArch.WindowsX86_64]: 'mihomo-windows-amd64-v1-{}.zip', [SupportedArch.WindowsArm64]: 'mihomo-windows-arm64-{}.zip', [SupportedArch.LinuxAarch64]: 'mihomo-linux-arm64-{}.gz', [SupportedArch.LinuxAmd64]: 'mihomo-linux-amd64-v1-{}.gz', [SupportedArch.LinuxI386]: 'mihomo-linux-386-{}.gz', [SupportedArch.DarwinArm64]: 'mihomo-darwin-arm64-{}.gz', [SupportedArch.DarwinX64]: 'mihomo-darwin-amd64-v1-{}.gz', [SupportedArch.LinuxArmv7]: 'mihomo-linux-armv5-{}.gz', [SupportedArch.LinuxArmv7hf]: 'mihomo-linux-armv7-{}.gz', } satisfies ArchMapping return { name: 'mihomo_alpha', version: alphaReleaseHash, archMapping, } } export const resolveClashRs = async (): LatestVersionResolver => { const latestRelease = await octokit.rest.repos.getLatestRelease( applyProxy({ owner: 'Watfaq', repo: 'clash-rs', }), ) consola.debug(`clash-rs latest release: ${latestRelease.data.tag_name}`) const archMapping: ArchMapping = { [SupportedArch.WindowsX86_32]: 'clash-i686-pc-windows-msvc-static-crt.exe', [SupportedArch.WindowsX86_64]: 'clash-x86_64-pc-windows-msvc.exe', [SupportedArch.WindowsArm64]: 'clash-aarch64-pc-windows-msvc.exe', [SupportedArch.LinuxAarch64]: 'clash-aarch64-unknown-linux-gnu', [SupportedArch.LinuxAmd64]: 'clash-x86_64-unknown-linux-gnu-static-crt', [SupportedArch.LinuxI386]: 'clash-i686-unknown-linux-gnu', [SupportedArch.DarwinArm64]: 'clash-aarch64-apple-darwin', [SupportedArch.DarwinX64]: 'clash-x86_64-apple-darwin', [SupportedArch.LinuxArmv7]: 'clash-armv7-unknown-linux-gnueabi', [SupportedArch.LinuxArmv7hf]: 'clash-armv7-unknown-linux-gnueabihf', } satisfies ArchMapping return { name: 'clash_rs', version: latestRelease.data.tag_name, archMapping, } } export const resolveClashRsAlpha = async (): LatestVersionResolver => { const resp = await fetch( 'https://github.com/Watfaq/clash-rs/releases/download/latest/version.txt', { dispatcher: getProxyAgent() }, ) const alphaVersion = resp.ok ? (await resp.text()).trim().split(' ').pop()! : 'latest' consola.debug(`clash-rs alpha latest release: ${alphaVersion}`) const archMapping: ArchMapping = { [SupportedArch.WindowsX86_32]: 'clash-rs-i686-pc-windows-msvc-static-crt.exe', [SupportedArch.WindowsX86_64]: 'clash-rs-x86_64-pc-windows-msvc.exe', [SupportedArch.WindowsArm64]: 'clash-rs-aarch64-pc-windows-msvc.exe', [SupportedArch.LinuxAarch64]: 'clash-rs-aarch64-unknown-linux-gnu', [SupportedArch.LinuxAmd64]: 'clash-rs-x86_64-unknown-linux-gnu-static-crt', [SupportedArch.LinuxI386]: 'clash-rs-i686-unknown-linux-gnu', [SupportedArch.DarwinArm64]: 'clash-rs-aarch64-apple-darwin', [SupportedArch.DarwinX64]: 'clash-rs-x86_64-apple-darwin', [SupportedArch.LinuxArmv7]: 'clash-rs-armv7-unknown-linux-gnueabi', [SupportedArch.LinuxArmv7hf]: 'clash-rs-armv7-unknown-linux-gnueabihf', } satisfies ArchMapping return { name: 'clash_rs_alpha', version: alphaVersion, archMapping, } } export const resolveClashPremium = async (): LatestVersionResolver => { const latestRelease = await octokit.rest.repos.getLatestRelease( applyProxy({ owner: 'zhongfly', repo: 'Clash-premium-backup', }), ) consola.debug(`clash-premium latest release: ${latestRelease.data.tag_name}`) const archMapping: ArchMapping = { [SupportedArch.WindowsX86_32]: 'clash-windows-386-n{}.zip', [SupportedArch.WindowsX86_64]: 'clash-windows-amd64-n{}.zip', [SupportedArch.WindowsArm64]: 'clash-windows-arm64-n{}.zip', [SupportedArch.LinuxAarch64]: 'clash-linux-arm64-n{}.gz', [SupportedArch.LinuxAmd64]: 'clash-linux-amd64-n{}.gz', [SupportedArch.LinuxI386]: 'clash-linux-386-n{}.gz', [SupportedArch.DarwinArm64]: 'clash-darwin-arm64-n{}.gz', [SupportedArch.DarwinX64]: 'clash-darwin-amd64-n{}.gz', [SupportedArch.LinuxArmv7]: 'clash-linux-armv5-n{}.gz', [SupportedArch.LinuxArmv7hf]: 'clash-linux-armv7-n{}.gz', } satisfies ArchMapping return { name: 'clash_premium', version: latestRelease.data.tag_name, archMapping, } } ================================================ FILE: scripts/utils/octokit.ts ================================================ import { Octokit } from 'octokit' import { ProxyAgent, fetch as undiciFetch } from 'undici' import { HTTP_PROXY } from './' const BASE_OPTIONS = { owner: 'libnyanpasu', repo: 'clash-nyanpasu', } export const fetcher = ( url: string, options: Parameters[1] = {}, ) => { return undiciFetch(url, { ...options, dispatcher: HTTP_PROXY ? new ProxyAgent(HTTP_PROXY) : undefined, }) } export const octokit = new Octokit(applyProxy(BASE_OPTIONS)) export function applyProxy(opts: ConstructorParameters[0]) { return { ...opts, request: { fetch: fetcher, }, auth: process.env.GITHUB_TOKEN || process.env.GH_TOKEN || undefined, } satisfies ConstructorParameters[0] } ================================================ FILE: scripts/utils/resolve.ts ================================================ import crypto from 'node:crypto' import path from 'path' import AdmZip from 'adm-zip' import fs from 'fs-extra' import { BinInfo } from '../types' import { downloadFile, resolveSidecar } from './download' import { TAURI_APP_DIR, TEMP_DIR } from './env' import { colorize, consola } from './logger' import { NodeArch } from './manifest' import { getClashBackupInfo, getClashMetaAlphaInfo, getClashMetaInfo, getClashRustAlphaInfo, getClashRustInfo, getNyanpasuServiceInfo, } from './resource' /** * download the file to the resources dir */ export const resolveResource = async ( binInfo: { file: string; downloadURL: string }, options?: { force?: boolean }, ) => { const { file, downloadURL } = binInfo const resDir = path.join(TAURI_APP_DIR, 'resources') const targetPath = path.join(resDir, file) if (!options?.force && (await fs.pathExists(targetPath))) return await fs.mkdirp(resDir) await downloadFile(downloadURL, targetPath) consola.success(colorize`resolve {green ${file}} finished`) } export class Resolve { private infoOption: { platform: NodeJS.Platform arch: NodeArch sidecarHost: string } constructor( private readonly options: { force?: boolean platform: NodeJS.Platform arch: NodeArch sidecarHost: string }, ) { this.infoOption = { platform: this.options.platform, arch: this.options.arch, sidecarHost: this.options.sidecarHost, } } /** * only Windows * get the wintun.dll (not required) */ public async wintun() { const { platform } = process let arch: string = this.options.arch || 'x64' if (platform !== 'win32') return switch (arch) { case 'x64': arch = 'amd64' break case 'ia32': arch = 'x86' break case 'arm': arch = 'arm' break case 'arm64': arch = 'arm64' break default: throw new Error(`unsupported arch ${arch}`) } const url = 'https://www.wintun.net/builds/wintun-0.14.1.zip' const hash = '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51' const tempDir = path.join(TEMP_DIR, 'wintun') const tempZip = path.join(tempDir, 'wintun.zip') // const wintunPath = path.join(tempDir, "wintun/bin/amd64/wintun.dll"); const targetPath = path.join(TAURI_APP_DIR, 'resources', 'wintun.dll') if (!this.options?.force && (await fs.pathExists(targetPath))) return await fs.mkdirp(tempDir) if (!(await fs.pathExists(tempZip))) { await downloadFile(url, tempZip) } // check hash const hashBuffer = await fs.readFile(tempZip) const sha256 = crypto.createHash('sha256') sha256.update(hashBuffer) const hashValue = sha256.digest('hex') if (hashValue !== hash) { throw new Error(`wintun. hash not match ${hashValue}`) } // unzip const zip = new AdmZip(tempZip) zip.extractAllTo(tempDir, true) // recursive list path for debug use const files = (await fs.readdir(tempDir, { recursive: true })).filter( (file) => file.includes('wintun.dll'), ) consola.debug(colorize`{green wintun} founded dlls: ${files}`) const file = files.find((file) => file.includes(arch)) if (!file) { throw new Error(`wintun. not found arch ${arch}`) } const wintunPath = path.join(tempDir, file.toString()) if (!(await fs.pathExists(wintunPath))) { throw new Error(`path not found "${wintunPath}"`) } // prepare resource dir await fs.mkdirp(path.dirname(targetPath)) await fs.copyFile(wintunPath, targetPath) await fs.remove(tempDir) consola.success(colorize`resolve {green wintun.dll} finished`) } public async service() { return await this.sidecar(getNyanpasuServiceInfo(this.infoOption)) } public mmdb() { return resolveResource({ file: 'Country.mmdb', downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb`, }) } public geosite() { return resolveResource({ file: 'geosite.dat', downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat`, }) } public geoip() { return resolveResource({ file: 'geoip.dat', downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat`, }) } public enableLoopback() { return resolveResource({ file: 'enableLoopback.exe', downloadURL: `https://github.com/Kuingsmile/uwp-tool/releases/download/latest/enableLoopback.exe`, }) } private sidecar(binInfo: BinInfo | PromiseLike) { return resolveSidecar(binInfo, this.options.platform, { force: this.options.force, }) } public async clash() { return await this.sidecar(getClashBackupInfo(this.infoOption)) } public async clashMeta() { return await this.sidecar(getClashMetaInfo(this.infoOption)) } public async clashMetaAlpha() { return await this.sidecar(getClashMetaAlphaInfo(this.infoOption)) } public async clashRust() { return await this.sidecar(getClashRustInfo(this.infoOption)) } public async clashRustAlpha() { return await this.sidecar(getClashRustAlphaInfo(this.infoOption)) } } ================================================ FILE: scripts/utils/resource.ts ================================================ // import { ArchMapping } from 'utils/manifest'; import { fetch, type RequestInit } from 'undici' import { CLASH_META_ALPHA_MANIFEST, CLASH_META_MANIFEST, } from '../manifest/clash-meta' import { CLASH_MANIFEST } from '../manifest/clash-premium' import { CLASH_RS_ALPHA_MANIFEST, CLASH_RS_MANIFEST, } from '../manifest/clash-rs' import { BinInfo, SupportedArch } from '../types' import { getProxyAgent } from './' import { SIDECAR_HOST } from './consts' import { consola } from './logger' const SERVICE_REPO = 'libnyanpasu/nyanpasu-service' type NodeArch = NodeJS.Architecture | 'armel' function mappingArch(platform: NodeJS.Platform, arch: NodeArch): SupportedArch { const label = `${platform}-${arch}` switch (label) { case 'darwin-x64': return SupportedArch.DarwinX64 case 'darwin-arm64': return SupportedArch.DarwinArm64 case 'win32-x64': return SupportedArch.WindowsX86_64 case 'win32-ia32': return SupportedArch.WindowsX86_32 case 'win32-arm64': return SupportedArch.WindowsArm64 case 'linux-x64': return SupportedArch.LinuxAmd64 case 'linux-ia32': return SupportedArch.LinuxI386 case 'linux-arm': return SupportedArch.LinuxArmv7hf case 'linux-arm64': return SupportedArch.LinuxAarch64 case 'linux-armel': return SupportedArch.LinuxArmv7 default: throw new Error('Unsupported platform/architecture: ' + label) } } export const getClashInfo = ({ platform, arch, sidecarHost, }: { platform: NodeJS.Platform arch: NodeArch sidecarHost?: string }): BinInfo => { const { ARCH_MAPPING, URL_PREFIX, LATEST_DATE } = CLASH_MANIFEST const archLabel = mappingArch(platform, arch) const name = ARCH_MAPPING[archLabel].replace('{}', LATEST_DATE as string) const isWin = platform === 'win32' const downloadURL = `${URL_PREFIX}${name}` const exeFile = `${name}${isWin ? '.exe' : ''}` const tmpFile = `${name}` const targetFile = `clash-${sidecarHost}${isWin ? '.exe' : ''}` return { name: 'clash', targetFile, exeFile, tmpFile, downloadURL, } } export const getClashBackupInfo = ({ platform, arch, sidecarHost, }: { platform: NodeJS.Platform arch: NodeArch sidecarHost?: string }): BinInfo => { const { ARCH_MAPPING, BACKUP_URL_PREFIX, BACKUP_LATEST_DATE } = CLASH_MANIFEST const archLabel = mappingArch(platform, arch) const name = ARCH_MAPPING[archLabel].replace( '{}', BACKUP_LATEST_DATE as string, ) const isWin = platform === 'win32' const downloadURL = `${BACKUP_URL_PREFIX}${BACKUP_LATEST_DATE}/${name}` const exeFile = `${name}${isWin ? '.exe' : ''}` const tmpFile = `${name}` const targetFile = `clash-${sidecarHost}${isWin ? '.exe' : ''}` return { name: 'clash', targetFile, exeFile, tmpFile, downloadURL, } } export const getClashMetaInfo = ({ platform, arch, sidecarHost, }: { platform: NodeJS.Platform arch: NodeArch sidecarHost?: string }): BinInfo => { const { ARCH_MAPPING, URL_PREFIX, VERSION } = CLASH_META_MANIFEST const archLabel = mappingArch(platform, arch) const name = ARCH_MAPPING[archLabel].replace('{}', VERSION as string) const isWin = platform === 'win32' const downloadURL = `${URL_PREFIX}/${name}` const exeFile = `${name}${isWin ? '.exe' : ''}` const tmpFile = `${name}` const targetFile = `mihomo-${sidecarHost}${isWin ? '.exe' : ''}` return { name: 'mihomo', targetFile, exeFile, tmpFile, downloadURL, } } export const getClashMetaAlphaInfo = async ({ platform, arch, sidecarHost, }: { platform: NodeJS.Platform arch: NodeArch sidecarHost?: string }): Promise => { const { ARCH_MAPPING, URL_PREFIX } = CLASH_META_ALPHA_MANIFEST const version = await getMetaAlphaLatestVersion() const archLabel = mappingArch(platform as NodeJS.Platform, arch as NodeArch) const name = ARCH_MAPPING[archLabel].replace('{}', version) const isWin = platform === 'win32' const downloadURL = `${URL_PREFIX}/${name}` const exeFile = `${name}${isWin ? '.exe' : ''}` const tmpFile = `${name}` const targetFile = `mihomo-alpha-${sidecarHost}${isWin ? '.exe' : ''}` return { name: 'mihomo-alpha', targetFile, exeFile, tmpFile, downloadURL, } } export const getClashRustInfo = ({ platform, arch, sidecarHost, }: { platform: string arch: string sidecarHost?: string }): BinInfo => { const { ARCH_MAPPING, URL_PREFIX, VERSION } = CLASH_RS_MANIFEST const archLabel = mappingArch(platform as NodeJS.Platform, arch as NodeArch) const name = ARCH_MAPPING[archLabel].replace('{}', VERSION as string) const isWin = platform === 'win32' const exeFile = `${name}` const downloadURL = `${URL_PREFIX}${VERSION}/${name}` const tmpFile = `${name}` const targetFile = `clash-rs-${sidecarHost}${isWin ? '.exe' : ''}` return { name: 'clash-rs', targetFile, exeFile, tmpFile, downloadURL, } } export const getClashRsAlphaLatestVersion = async () => { const { VERSION_URL } = CLASH_RS_ALPHA_MANIFEST try { const opts = {} as Partial const httpProxy = getProxyAgent() if (httpProxy) { opts.dispatcher = httpProxy } const response = await fetch(VERSION_URL!, { method: 'GET', ...opts, }) const v = (await response.text()).trim().split(' ').pop()! consola.info(`Clash Rs Alpha latest release version: ${v}`) return v.trim() } catch (error) { console.error('Error fetching latest release version:', error) process.exit(1) } } export const getClashRustAlphaInfo = async ({ platform, arch, sidecarHost, }: { platform: string arch: string sidecarHost?: string }): Promise => { const { ARCH_MAPPING, URL_PREFIX } = CLASH_RS_ALPHA_MANIFEST const version = await getClashRsAlphaLatestVersion() const archLabel = mappingArch(platform as NodeJS.Platform, arch as NodeArch) const name = ARCH_MAPPING[archLabel].replace('{}', version as string) const isWin = platform === 'win32' const exeFile = `${name}` const downloadURL = `${URL_PREFIX}/${name}` const tmpFile = `${name}` const targetFile = `clash-rs-alpha-${sidecarHost}${isWin ? '.exe' : ''}` return { name: 'clash-rs-alpha', targetFile, exeFile, tmpFile, downloadURL, } } export const getMetaAlphaLatestVersion = async () => { const { VERSION_URL } = CLASH_META_ALPHA_MANIFEST try { const opts = {} as Partial const httpProxy = getProxyAgent() if (httpProxy) { opts.dispatcher = httpProxy } const response = await fetch(VERSION_URL!, { method: 'GET', ...opts, }) const v = await response.text() consola.info(`Mihomo Alpha latest release version: ${v}`) return v.trim() } catch (error) { console.error('Error fetching latest release version:', error) process.exit(1) } } export const getNyanpasuServiceLatestVersion = async () => { try { const opts = {} as Partial const httpProxy = getProxyAgent() if (httpProxy) { opts.dispatcher = httpProxy } const url = new URL('https://github.com') url.pathname = `/${SERVICE_REPO}/releases/latest` const response = await fetch(url, { method: 'GET', redirect: 'manual', ...opts, }) const location = response.headers.get('location') if (!location) { throw new Error('Cannot find location from the response header') } const tag = location.split('/').pop() if (!tag) { throw new Error('Cannot find tag from the location') } consola.info(`Nyanpasu Service latest release version: ${tag}`) return tag.trim() } catch (error) { console.error('Error fetching latest release version:', error) process.exit(1) } } export const getNyanpasuServiceInfo = async ({ sidecarHost, }: { sidecarHost: string }): Promise => { const name = `nyanpasu-service` const isWin = SIDECAR_HOST?.includes('windows') const urlExt = isWin ? 'zip' : 'tar.gz' // first we had to get the latest tag const version = await getNyanpasuServiceLatestVersion() const downloadURL = `https://github.com/${SERVICE_REPO}/releases/download/${version}/${name}-${sidecarHost}.${urlExt}` const exeFile = `${name}${isWin ? '.exe' : ''}` const tmpFile = `${name}-${sidecarHost}.${urlExt}` const targetFile = `nyanpasu-service-${sidecarHost}${isWin ? '.exe' : ''}` return { name: 'nyanpasu-service', targetFile, exeFile, tmpFile, downloadURL, } } ================================================ FILE: scripts/utils/shell.ts ================================================ import { execSync } from 'child_process' export const GIT_SHORT_HASH = execSync('git rev-parse --short HEAD') .toString() .trim() ================================================ FILE: scripts/utils/telegram.ts ================================================ import { TelegramClient } from 'telegram' import { StringSession } from 'telegram/sessions' if (!process.env.TELEGRAM_API_ID) { throw new Error('TELEGRAM_API_ID is required') } const TELEGRAM_API_ID = Number(process.env.TELEGRAM_API_ID) if (!process.env.TELEGRAM_API_HASH) { throw new Error('TELEGRAM_API_ID is required') } const TELEGRAM_API_HASH = process.env.TELEGRAM_API_HASH export const client = new TelegramClient( new StringSession(''), TELEGRAM_API_ID, TELEGRAM_API_HASH, { connectionRetries: 5, }, ) ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "baseUrl": ".", "skipLibCheck": true, "esModuleInterop": true, "composite": true, "paths": { "@nyanpasu/ui/*": ["./frontend/ui/*"], "@nyanpasu/nyanpasu/*": ["./frontend/nyanpasu/*"], "@nyanpasu/interface/*": ["./frontend/interface/*"], }, }, "references": [ { "path": "./frontend/ui" }, { "path": "./frontend/nyanpasu" }, { "path": "./frontend/interface" }, { "path": "./scripts" }, ], }