aboutsummaryrefslogtreecommitdiff
path: root/store
diff options
context:
space:
mode:
authorYuqian Yang <crupest@crupest.life>2025-02-28 23:13:39 +0800
committerYuqian Yang <crupest@crupest.life>2025-02-28 23:13:39 +0800
commita0f2f7e29035553562941f4046db88d707daf199 (patch)
treeb6b56019f89a2f3b1a6cffccba2849687ee61241 /store
parent346b5256b99e28cd5c67c03dca4be84e1c75f749 (diff)
downloadcrupest-a0f2f7e29035553562941f4046db88d707daf199.tar.gz
crupest-a0f2f7e29035553562941f4046db88d707daf199.tar.bz2
crupest-a0f2f7e29035553562941f4046db88d707daf199.zip
chore(store): move everything to store.
Diffstat (limited to 'store')
-rwxr-xr-xstore/assets/crupest-transparent.pngbin0 -> 12038 bytes
-rw-r--r--store/home/bash_profile1
-rw-r--r--store/home/bashrc8
-rwxr-xr-xstore/home/bin/neovide-listen43
-rw-r--r--store/home/config/halloy/config.toml20
-rw-r--r--store/home/config/nvim/.gitignore1
-rw-r--r--store/home/config/nvim/.luarc.json4
-rw-r--r--store/home/config/nvim/cspell.yaml13
-rw-r--r--store/home/config/nvim/init.lua63
-rw-r--r--store/home/config/nvim/lazy-lock.json20
-rw-r--r--store/home/config/nvim/lua/crupest/nvim/keymap.lua9
-rw-r--r--store/home/config/nvim/lua/crupest/nvim/lsp/c.lua25
-rw-r--r--store/home/config/nvim/lua/crupest/nvim/lsp/init.lua50
-rw-r--r--store/home/config/nvim/lua/crupest/nvim/lsp/lua.lua29
-rw-r--r--store/home/config/nvim/lua/crupest/nvim/plugins/cmp.lua31
-rw-r--r--store/home/config/nvim/lua/crupest/nvim/plugins/gitsigns.lua51
-rw-r--r--store/home/config/nvim/lua/crupest/nvim/plugins/init.lua12
-rw-r--r--store/home/config/nvim/lua/crupest/nvim/plugins/lint.lua82
-rw-r--r--store/home/config/nvim/lua/crupest/nvim/plugins/others.lua9
-rw-r--r--store/home/config/nvim/lua/crupest/nvim/plugins/snip.lua75
-rw-r--r--store/home/config/nvim/lua/crupest/nvim/plugins/telescope.lua11
-rw-r--r--store/home/config/nvim/lua/crupest/utils/find.lua101
-rw-r--r--store/home/config/nvim/lua/crupest/utils/nvim.lua12
-rw-r--r--store/home/config/nvim/lua/plugins.lua30
-rw-r--r--store/home/config/nvim/nvim-words.txt27
-rw-r--r--store/home/gitconfig10
-rw-r--r--store/misc/bruno/ComfyUI/Get Object Info.bru11
-rw-r--r--store/misc/bruno/ComfyUI/Get Prompt History.bru15
-rw-r--r--store/misc/bruno/ComfyUI/Post Prompt.bru124
-rw-r--r--store/misc/bruno/ComfyUI/Upload Image.bru18
-rw-r--r--store/misc/bruno/ComfyUI/View Image.bru19
-rw-r--r--store/misc/bruno/ComfyUI/bruno.json9
-rw-r--r--store/misc/bruno/ComfyUI/environments/ChimerAI ComfyUI Server.bru3
-rw-r--r--store/misc/magic/extend-script.js21
-rw-r--r--store/misc/magic/extend.yaml65
-rw-r--r--store/win/Microsoft.PowerShell_profile.ps135
-rw-r--r--store/win/crupest-winget.json104
-rw-r--r--store/win/neovide-listen.ps138
-rw-r--r--store/works/Crupest.SecretTool/.gitignore7
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool.sln30
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/.gitignore1
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/Config.cs95
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/Controller.cs113
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/Crupest.SecretTool.csproj34
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/FileWatcher.cs26
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/GeoDataManager.cs324
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/HostMatchConfig.cs123
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/Program.cs113
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/Properties/PublishProfiles/FolderProfile.pubxml13
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/Proxy.cs76
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/ProxyFile.cs31
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/Routing.cs155
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/SingConfigJsonObjects.cs20
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/StaticHosts.cs40
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/SurgeConfigGenerator.cs56
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/Template.cs231
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/ToolConfig.cs271
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/V4ConfigJsonObjects.cs25
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/V5ConfigJsonObjects.cs31
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/config.json.template63
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/config.v5.json.template55
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/hosts.txt2
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/proxy.txt50
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/sing-config.json.template45
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/sing-inbounds-mobile.json11
-rw-r--r--store/works/Crupest.SecretTool/Crupest.SecretTool/sing-inbounds-pc.json14
-rwxr-xr-xstore/works/Crupest.SecretTool/build-secret.bash41
-rw-r--r--store/works/Crupest.SecretTool/build-secret.ps125
-rwxr-xr-xstore/works/Crupest.SecretTool/tools/cru-proxy-edit12
-rwxr-xr-xstore/works/Crupest.SecretTool/tools/cru-proxy-log13
-rw-r--r--store/works/Crupest.SecretTool/tools/crupest-secret-tool.service8
-rw-r--r--store/works/Crupest.SecretTool/tools/crupest-secret-tool.xml49
-rw-r--r--store/works/Crupest.SecretTool/tools/life.crupest.secret-tool.plist18
-rw-r--r--store/works/README.md9
74 files changed, 3429 insertions, 0 deletions
diff --git a/store/assets/crupest-transparent.png b/store/assets/crupest-transparent.png
new file mode 100755
index 0000000..d890d8d
--- /dev/null
+++ b/store/assets/crupest-transparent.png
Binary files differ
diff --git a/store/home/bash_profile b/store/home/bash_profile
new file mode 100644
index 0000000..b65f405
--- /dev/null
+++ b/store/home/bash_profile
@@ -0,0 +1 @@
+export PATH="$HOME/.local/bin:$PATH"
diff --git a/store/home/bashrc b/store/home/bashrc
new file mode 100644
index 0000000..7d31f23
--- /dev/null
+++ b/store/home/bashrc
@@ -0,0 +1,8 @@
+set-proxy() {
+ export http_proxy="http://127.0.0.1:7897"
+ export https_proxy="http://127.0.0.1:7897"
+}
+
+unset-proxy() {
+ unset http_proxy https_proxy
+}
diff --git a/store/home/bin/neovide-listen b/store/home/bin/neovide-listen
new file mode 100755
index 0000000..2591842
--- /dev/null
+++ b/store/home/bin/neovide-listen
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+
+if [[ -z "$NVIM_SOCKET" ]]; then
+ NVIM_SOCKET="/tmp/nvimsocket"
+fi
+
+args=()
+MY_NEOVIM_PATH="$HOME/codes/neovim/build/bin/nvim"
+if [[ -e "$MY_NEOVIM_PATH" ]]; then
+ echo "Found my neovim at $MY_NEOVIM_PATH"
+ export VIMRUNTIME="$HOME/codes/neovim/runtime"
+ args=("${args[@]}" "--neovim-bin" "$MY_NEOVIM_PATH")
+fi
+
+listen_added=0
+for arg in "$@"; do
+ args=("${args[@]}" "$arg")
+ if [ "$arg" = '--' ]; then
+ args=("${args[@]}" "--listen" "$NVIM_SOCKET")
+ listen_added=1
+ fi
+done
+
+if [[ $listen_added = 0 ]]; then
+ args=("${args[@]}" "--" "--listen" "$NVIM_SOCKET")
+fi
+
+NEOVIDE_BIN=neovide
+MY_NEOVIDE_PATH="$HOME/codes/neovide/target/release/neovide"
+if [ -e "$MY_NEOVIDE_PATH" ]; then
+ echo "Found my neovide at $MY_NEOVIDE_PATH"
+ NEOVIDE_BIN="$MY_NEOVIDE_PATH"
+fi
+
+if which nvr > /dev/null; then
+ echo "Detected nvr, set git editor env"
+ export GIT_EDITOR='nvr -cc split --remote-wait'
+fi
+
+args=("$NEOVIDE_BIN" "${args[@]}")
+echo "Command is ${args[@]}"
+exec "${args[@]}"
+
diff --git a/store/home/config/halloy/config.toml b/store/home/config/halloy/config.toml
new file mode 100644
index 0000000..5d39f52
--- /dev/null
+++ b/store/home/config/halloy/config.toml
@@ -0,0 +1,20 @@
+tooltips = true
+
+[notifications]
+direct_message = { sound = "peck", show_toast = true }
+connected = { sound = "ring", show_toast = true }
+disconnected = { sound = "ring", show_toast = true }
+
+[notifications.highlight]
+sound = "dong"
+exclude = [ "NickServ" ]
+
+[servers.liberachat]
+nickname = "crupest"
+server = "irc.libera.chat"
+channels = [ "#hurd" ]
+use_tls = true
+
+[servers.liberachat.sasl.plain]
+username = "crupest"
+password = ...
diff --git a/store/home/config/nvim/.gitignore b/store/home/config/nvim/.gitignore
new file mode 100644
index 0000000..722d5e7
--- /dev/null
+++ b/store/home/config/nvim/.gitignore
@@ -0,0 +1 @@
+.vscode
diff --git a/store/home/config/nvim/.luarc.json b/store/home/config/nvim/.luarc.json
new file mode 100644
index 0000000..f704d01
--- /dev/null
+++ b/store/home/config/nvim/.luarc.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
+ "runtime.version": "LuaJIT"
+} \ No newline at end of file
diff --git a/store/home/config/nvim/cspell.yaml b/store/home/config/nvim/cspell.yaml
new file mode 100644
index 0000000..2a716e2
--- /dev/null
+++ b/store/home/config/nvim/cspell.yaml
@@ -0,0 +1,13 @@
+dictionaryDefinitions:
+ - name: nvim-words
+ path: './nvim-words.txt'
+ addWords: true
+
+dictionaries:
+ - nvim-words
+
+words:
+ - crupest
+
+ignorePaths:
+ - lazy-lock.json
diff --git a/store/home/config/nvim/init.lua b/store/home/config/nvim/init.lua
new file mode 100644
index 0000000..9de0b2c
--- /dev/null
+++ b/store/home/config/nvim/init.lua
@@ -0,0 +1,63 @@
+if vim.g.neovide then
+ -- spellchecker: disable-next-line
+ vim.opt.guifont = "FiraCode Nerd Font";
+ vim.g.neovide_window_blurred = true;
+ vim.g.neovide_transparency = 0.9;
+ vim.g.neovide_input_ime = false;
+ vim.g.neovide_cursor_animate_in_insert_mode = false
+ vim.g.neovide_cursor_vfx_mode = "pixiedust";
+ vim.g.neovide_input_macos_option_key_is_meta = 'only_left'
+end
+
+local is_win = vim.fn.has("win32") ~= 0
+
+-- spellchecker: disable
+if is_win then
+ vim.cmd([[
+ let &shell = executable('pwsh') ? 'pwsh' : 'powershell'
+ let &shellcmdflag = '-NoLogo -ExecutionPolicy RemoteSigned -Command [Console]::InputEncoding=[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new();$PSDefaultParameterValues[''Out-File:Encoding'']=''utf8'';Remove-Alias -Force -ErrorAction SilentlyContinue tee;'
+ let &shellredir = '2>&1 | %%{ "$_" } | Out-File %s; exit $LastExitCode'
+ let &shellpipe = '2>&1 | %%{ "$_" } | tee %s; exit $LastExitCode'
+ set shellquote= shellxquote=
+ ]])
+ vim.opt.completeslash = 'slash'
+end
+-- spellchecker: enable
+
+-- spellchecker: disable
+vim.opt.termguicolors = true;
+vim.opt.fileformats = "unix,dos";
+vim.opt.softtabstop = 4;
+vim.opt.shiftwidth = 4;
+vim.opt.expandtab = true;
+vim.opt.wrap = false;
+vim.opt.number = true;
+-- spellchecker: enable
+
+vim.g.load_doxygen_syntax = true;
+vim.g.doxygen_javadoc_autobrief = false;
+
+-- Init lazy.nvim
+local lazy_path = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
+if not vim.uv.fs_stat(lazy_path) then
+ vim.fn.system({
+ "git",
+ "clone",
+ "--filter=blob:none",
+ "https://github.com/folke/lazy.nvim.git",
+ "--branch=stable", -- latest stable release
+ lazy_path,
+ })
+end
+vim.opt.rtp:prepend(lazy_path)
+
+-- Use lazy.nvim
+require("lazy").setup("plugins")
+
+vim.cmd("colorscheme catppuccin-macchiato")
+
+require("crupest.nvim.lsp").setup()
+require("crupest.nvim.plugins").setup()
+require("crupest.nvim.keymap").setup()
+
+vim.cmd("autocmd FileType gitcommit,gitrebase,gitconfig set bufhidden=delete")
diff --git a/store/home/config/nvim/lazy-lock.json b/store/home/config/nvim/lazy-lock.json
new file mode 100644
index 0000000..3d08239
--- /dev/null
+++ b/store/home/config/nvim/lazy-lock.json
@@ -0,0 +1,20 @@
+{
+ "LuaSnip": { "branch": "master", "commit": "0f7bbce41ea152a94d12aea286f2ce98e63c0f58" },
+ "catppuccin": { "branch": "main", "commit": "faf15ab0201b564b6368ffa47b56feefc92ce3f4" },
+ "cmp-buffer": { "branch": "main", "commit": "3022dbc9166796b644a841a02de8dd1cc1d311fa" },
+ "cmp-cmdline": { "branch": "main", "commit": "d250c63aa13ead745e3a40f61fdd3470efde3923" },
+ "cmp-nvim-lsp": { "branch": "main", "commit": "39e2eda76828d88b773cc27a3f61d2ad782c922d" },
+ "cmp-path": { "branch": "main", "commit": "91ff86cd9c29299a64f968ebb45846c485725f23" },
+ "cmp_luasnip": { "branch": "master", "commit": "98d9cb5c2c38532bd9bdb481067b20fea8f32e90" },
+ "gitsigns.nvim": { "branch": "main", "commit": "5f808b5e4fef30bd8aca1b803b4e555da07fc412" },
+ "lazy.nvim": { "branch": "main", "commit": "56ead98e05bb37a4ec28930a54d836d033cf00f2" },
+ "lualine.nvim": { "branch": "master", "commit": "2a5bae925481f999263d6f5ed8361baef8df4f83" },
+ "nvim-autopairs": { "branch": "master", "commit": "b464658e9b880f463b9f7e6ccddd93fb0013f559" },
+ "nvim-cmp": { "branch": "main", "commit": "ed31156aa2cc14e3bc066c59357cc91536a2bc01" },
+ "nvim-lint": { "branch": "master", "commit": "6b46370d02cd001509a765591a3ffc481b538794" },
+ "nvim-lspconfig": { "branch": "master", "commit": "4ae9796c4e95ca84ec77946a9f9089b8f1a3eec9" },
+ "nvim-tree.lua": { "branch": "master", "commit": "ca7c4c33cac2ad66ec69d45e465379716ef0cc97" },
+ "nvim-web-devicons": { "branch": "master", "commit": "edbe0a65cfacbbfff6a4a1e98ddd60c28c560509" },
+ "plenary.nvim": { "branch": "master", "commit": "2d9b06177a975543726ce5c73fca176cedbffe9d" },
+ "telescope.nvim": { "branch": "master", "commit": "85922dde3767e01d42a08e750a773effbffaea3e" }
+}
diff --git a/store/home/config/nvim/lua/crupest/nvim/keymap.lua b/store/home/config/nvim/lua/crupest/nvim/keymap.lua
new file mode 100644
index 0000000..624c04c
--- /dev/null
+++ b/store/home/config/nvim/lua/crupest/nvim/keymap.lua
@@ -0,0 +1,9 @@
+local function setup()
+ vim.keymap.set("n", "<c-tab>", "<cmd>bnext<cr>")
+ vim.keymap.set("n", "<c-s-tab>", "<cmd>bNext<cr>")
+ vim.keymap.set("n", "<esc>", require("crupest.utils.nvim").close_float)
+end
+
+return {
+ setup = setup
+}
diff --git a/store/home/config/nvim/lua/crupest/nvim/lsp/c.lua b/store/home/config/nvim/lua/crupest/nvim/lsp/c.lua
new file mode 100644
index 0000000..bb1f6f7
--- /dev/null
+++ b/store/home/config/nvim/lua/crupest/nvim/lsp/c.lua
@@ -0,0 +1,25 @@
+local lspconfig = require("lspconfig")
+
+local brew_clangd_path = "/usr/local/opt/llvm/bin/clangd"
+
+local function setup()
+ local clangd = "clangd"
+
+ if vim.uv.fs_stat(brew_clangd_path) ~= nil then
+ clangd = brew_clangd_path
+ end
+
+ -- setup lsp clangd
+ lspconfig.clangd.setup {
+ cmd = { clangd },
+ on_attach = function(_, bufnr)
+ vim.keymap.set('n', '<space>s', "<cmd>ClangdSwitchSourceHeader<cr>", {
+ buffer = bufnr
+ })
+ end
+ }
+end
+
+return {
+ setup = setup
+}
diff --git a/store/home/config/nvim/lua/crupest/nvim/lsp/init.lua b/store/home/config/nvim/lua/crupest/nvim/lsp/init.lua
new file mode 100644
index 0000000..0fd29a3
--- /dev/null
+++ b/store/home/config/nvim/lua/crupest/nvim/lsp/init.lua
@@ -0,0 +1,50 @@
+local lspconfig = require("lspconfig")
+local cmp_nvim_lsp = require("cmp_nvim_lsp")
+local cmp_default_caps = cmp_nvim_lsp.default_capabilities()
+
+local lspconfig_default_caps = lspconfig.util.default_config.capabilities
+
+lspconfig.util.default_config = vim.tbl_extend(
+ "force",
+ lspconfig.util.default_config,
+ {
+ capabilities = vim.tbl_extend("force", lspconfig_default_caps, cmp_default_caps),
+ autostart = false,
+ })
+
+local function setup()
+ lspconfig.cmake.setup {}
+ lspconfig.bashls.setup {}
+ require("crupest.nvim.lsp.c").setup()
+ require("crupest.nvim.lsp.lua").setup()
+
+ -- Use LspAttach auto command to only map the following keys
+ -- after the language server attaches to the current buffer
+ vim.api.nvim_create_autocmd('LspAttach', {
+ group = vim.api.nvim_create_augroup('UserLspConfig', {}),
+ callback = function(ev)
+ -- Buffer local mappings.
+ -- See `:help vim.lsp.*` for documentation on any of the below functions
+ local opts = { buffer = ev.buf }
+ vim.keymap.set('n', 'gD', vim.lsp.buf.declaration, opts)
+ vim.keymap.set('n', 'gd', vim.lsp.buf.definition, opts)
+ vim.keymap.set('n', 'gi', vim.lsp.buf.implementation, opts)
+ vim.keymap.set('n', '<C-k>', vim.lsp.buf.signature_help, opts)
+ vim.keymap.set('n', '<space>wa', vim.lsp.buf.add_workspace_folder, opts)
+ vim.keymap.set('n', '<space>wr', vim.lsp.buf.remove_workspace_folder, opts)
+ vim.keymap.set('n', '<space>wl', function()
+ print(vim.inspect(vim.lsp.buf.list_workspace_folders()))
+ end, opts)
+ vim.keymap.set('n', '<space>D', vim.lsp.buf.type_definition, opts)
+ vim.keymap.set('n', '<space>rn', vim.lsp.buf.rename, opts)
+ vim.keymap.set({ 'n', 'v' }, '<space>ca', vim.lsp.buf.code_action, opts)
+ vim.keymap.set('n', 'gr', vim.lsp.buf.references, opts)
+ vim.keymap.set('n', '<space>f', vim.lsp.buf.format, opts)
+ end,
+ })
+end
+
+
+return {
+ setup = setup
+}
diff --git a/store/home/config/nvim/lua/crupest/nvim/lsp/lua.lua b/store/home/config/nvim/lua/crupest/nvim/lsp/lua.lua
new file mode 100644
index 0000000..93aa503
--- /dev/null
+++ b/store/home/config/nvim/lua/crupest/nvim/lsp/lua.lua
@@ -0,0 +1,29 @@
+local lspconfig = require("lspconfig")
+
+local function setup()
+ lspconfig.lua_ls.setup {
+ settings = {
+ Lua = {
+ runtime = {
+ version = "LuaJIT"
+ },
+ diagnostics = {
+ globals = { "vim" },
+ },
+ workspace = {
+ library = {
+ [vim.fn.expand "$VIMRUNTIME/lua"] = true,
+ [vim.fn.expand "$VIMRUNTIME/lua/vim/lsp"] = true,
+ [vim.fn.stdpath "data" .. "/lazy/lazy.nvim/lua/lazy"] = true,
+ },
+ maxPreload = 100000,
+ preloadFileSize = 10000,
+ },
+ },
+ },
+ }
+end
+
+return {
+ setup = setup
+}
diff --git a/store/home/config/nvim/lua/crupest/nvim/plugins/cmp.lua b/store/home/config/nvim/lua/crupest/nvim/plugins/cmp.lua
new file mode 100644
index 0000000..9b1d876
--- /dev/null
+++ b/store/home/config/nvim/lua/crupest/nvim/plugins/cmp.lua
@@ -0,0 +1,31 @@
+local function setup()
+ local cmp = require("cmp")
+ local luasnip = require("luasnip")
+
+ cmp.setup {
+ snippet = {
+ expand = function(args)
+ luasnip.lsp_expand(args.body)
+ end,
+ },
+ window = {
+ },
+ mapping = cmp.mapping.preset.insert({
+ ['<C-b>'] = cmp.mapping.scroll_docs(-4),
+ ['<C-f>'] = cmp.mapping.scroll_docs(4),
+ ['<C-Space>'] = cmp.mapping.complete(),
+ ['<C-e>'] = cmp.mapping.abort(),
+ ['<CR>'] = cmp.mapping.confirm({ select = true }), -- Accept currently selected item. Set `select` to `false` to only confirm explicitly selected items.
+ }),
+ sources = cmp.config.sources({
+ { name = 'nvim_lsp' },
+ { name = 'luasnip' },
+ }, {
+ { name = 'buffer' },
+ })
+ }
+end
+
+return {
+ setup = setup
+}
diff --git a/store/home/config/nvim/lua/crupest/nvim/plugins/gitsigns.lua b/store/home/config/nvim/lua/crupest/nvim/plugins/gitsigns.lua
new file mode 100644
index 0000000..220c91a
--- /dev/null
+++ b/store/home/config/nvim/lua/crupest/nvim/plugins/gitsigns.lua
@@ -0,0 +1,51 @@
+local function setup()
+ local gitsigns = require('gitsigns')
+ gitsigns.setup {
+ on_attach = function(bufnr)
+ local function map(mode, l, r, opts)
+ opts = opts or {}
+ opts.buffer = bufnr
+ vim.keymap.set(mode, l, r, opts)
+ end
+
+ -- Navigation
+ map('n', ']c', function()
+ if vim.wo.diff then
+ vim.cmd.normal({ ']c', bang = true })
+ else
+ gitsigns.nav_hunk('next')
+ end
+ end)
+
+ map('n', '[c', function()
+ if vim.wo.diff then
+ vim.cmd.normal({ '[c', bang = true })
+ else
+ gitsigns.nav_hunk('prev')
+ end
+ end)
+
+ -- Actions
+ map('n', '<leader>hs', gitsigns.stage_hunk)
+ map('n', '<leader>hr', gitsigns.reset_hunk)
+ map('v', '<leader>hs', function() gitsigns.stage_hunk { vim.fn.line('.'), vim.fn.line('v') } end)
+ map('v', '<leader>hr', function() gitsigns.reset_hunk { vim.fn.line('.'), vim.fn.line('v') } end)
+ map('n', '<leader>hS', gitsigns.stage_buffer)
+ map('n', '<leader>hu', gitsigns.undo_stage_hunk)
+ map('n', '<leader>hR', gitsigns.reset_buffer)
+ map('n', '<leader>hp', gitsigns.preview_hunk)
+ map('n', '<leader>hb', function() gitsigns.blame_line { full = true } end)
+ map('n', '<leader>tb', gitsigns.toggle_current_line_blame)
+ map('n', '<leader>hd', gitsigns.diffthis)
+ map('n', '<leader>hD', function() gitsigns.diffthis('~') end)
+ map('n', '<leader>td', gitsigns.toggle_deleted)
+
+ -- Text object
+ map({ 'o', 'x' }, 'ih', ':<C-U>Gitsigns select_hunk<CR>')
+ end
+ }
+end
+
+return {
+ setup = setup
+}
diff --git a/store/home/config/nvim/lua/crupest/nvim/plugins/init.lua b/store/home/config/nvim/lua/crupest/nvim/plugins/init.lua
new file mode 100644
index 0000000..24e0c2e
--- /dev/null
+++ b/store/home/config/nvim/lua/crupest/nvim/plugins/init.lua
@@ -0,0 +1,12 @@
+local function setup()
+ require("crupest.nvim.plugins.lint").setup()
+ require("crupest.nvim.plugins.snip").setup()
+ require("crupest.nvim.plugins.cmp").setup()
+ require("crupest.nvim.plugins.telescope").setup()
+ require("crupest.nvim.plugins.gitsigns").setup()
+ require("crupest.nvim.plugins.others").setup()
+end
+
+return {
+ setup = setup
+}
diff --git a/store/home/config/nvim/lua/crupest/nvim/plugins/lint.lua b/store/home/config/nvim/lua/crupest/nvim/plugins/lint.lua
new file mode 100644
index 0000000..5e348d6
--- /dev/null
+++ b/store/home/config/nvim/lua/crupest/nvim/plugins/lint.lua
@@ -0,0 +1,82 @@
+local lint = require("lint")
+
+local find = require('crupest.utils.find')
+local is_win = vim.fn.has("win32") ~= 0
+
+local cspell_config_patterns = {
+ ".cspell.json",
+ "cspell.json",
+ ".cSpell.json",
+ "cSpell.json",
+ "cspell.config.js",
+ "cspell.config.cjs",
+ "cspell.config.json",
+ "cspell.config.yaml",
+ "cspell.config.yml",
+ "cspell.yaml",
+ "cspell.yml",
+}
+
+--- @type FindExeForBufOpts[]
+local my_linters = {
+ {
+ name = "cspell",
+ places = { "node_modules", "global" },
+ config_files = cspell_config_patterns,
+ },
+}
+
+local function run(opt)
+ if not opt then
+ opt = {}
+ end
+
+ if not opt.buf then
+ opt.buf = 0
+ end
+
+ local linters = {}
+
+ for _, l in ipairs(my_linters) do
+ local linter = find.find_exe_for_buf(opt.buf, l)
+ if linter then table.insert(linters, linter) end
+ end
+
+
+ local linter_names = {}
+
+ for _, linter in ipairs(linters) do
+ table.insert(linter_names, linter.name)
+ require('lint.linters.' .. linter.name).cmd = linter.exe_path
+ end
+
+ lint.try_lint(linter_names)
+end
+
+local function setup()
+ if is_win then
+ for _, l in ipairs(my_linters) do
+ local name = l.name
+ local linter = require('lint.linters.' .. name)
+ if linter.cmd == 'cmd.exe' then
+ linter.cmd = linter.args[2]
+ end
+ table.remove(linter.args, 1)
+ table.remove(linter.args, 1)
+ end
+ end
+
+ vim.api.nvim_create_autocmd({ "BufWritePost" }, {
+ callback = function(opt)
+ run({
+ buf = opt.buffer
+ })
+ end,
+ })
+
+ vim.keymap.set('n', '<leader>lr', run)
+end
+
+return {
+ setup = setup,
+}
diff --git a/store/home/config/nvim/lua/crupest/nvim/plugins/others.lua b/store/home/config/nvim/lua/crupest/nvim/plugins/others.lua
new file mode 100644
index 0000000..2ef0d75
--- /dev/null
+++ b/store/home/config/nvim/lua/crupest/nvim/plugins/others.lua
@@ -0,0 +1,9 @@
+local function setup()
+ require('lualine').setup {}
+ require("nvim-tree").setup {}
+ require("nvim-autopairs").setup {}
+end
+
+return {
+ setup = setup
+}
diff --git a/store/home/config/nvim/lua/crupest/nvim/plugins/snip.lua b/store/home/config/nvim/lua/crupest/nvim/plugins/snip.lua
new file mode 100644
index 0000000..78ed2eb
--- /dev/null
+++ b/store/home/config/nvim/lua/crupest/nvim/plugins/snip.lua
@@ -0,0 +1,75 @@
+--- spellchecker: disable
+
+local luasnip = require("luasnip")
+
+local ls = luasnip
+-- some shorthands...
+local s = ls.snippet
+local sn = ls.snippet_node
+local t = ls.text_node
+local i = ls.insert_node
+local f = ls.function_node
+local c = ls.choice_node
+local d = ls.dynamic_node
+local r = ls.restore_node
+local l = require("luasnip.extras").lambda
+local rep = require("luasnip.extras").rep
+local p = require("luasnip.extras").partial
+local m = require("luasnip.extras").match
+local n = require("luasnip.extras").nonempty
+local dl = require("luasnip.extras").dynamic_lambda
+local fmt = require("luasnip.extras.fmt").fmt
+local fmta = require("luasnip.extras.fmt").fmta
+local types = require("luasnip.util.types")
+local conds = require("luasnip.extras.conditions")
+local conds_expand = require("luasnip.extras.conditions.expand")
+
+local function copy(args)
+ return args[1]
+end
+
+local function setup()
+ vim.keymap.set({ "i", "s" }, "<C-L>", function() luasnip.jump(1) end, { silent = true })
+ vim.keymap.set({ "i", "s" }, "<C-J>", function() luasnip.jump(-1) end, { silent = true })
+
+ vim.keymap.set({ "i", "s" }, "<C-E>", function()
+ if luasnip.choice_active() then
+ luasnip.change_choice(1)
+ end
+ end, { silent = true })
+
+ luasnip.add_snippets("cpp", {
+ s("cs", {
+ i(1, "classname"),
+ t("::"),
+ f(copy, 1),
+ t("("),
+ i(0),
+ t(") { }")
+ }),
+
+ s("ds", {
+ i(1, "classname"),
+ t("::~"),
+ f(copy, 1),
+ t("() { }")
+ }),
+
+ s("csds", {
+ i(1, "classname"),
+ t("::"),
+ f(copy, 1),
+ t("("),
+ i(0),
+ t({ ") { }", "", "" }),
+ f(copy, 1),
+ t("::~"),
+ f(copy, 1),
+ t("() { }")
+ }),
+ })
+end
+
+return {
+ setup = setup,
+}
diff --git a/store/home/config/nvim/lua/crupest/nvim/plugins/telescope.lua b/store/home/config/nvim/lua/crupest/nvim/plugins/telescope.lua
new file mode 100644
index 0000000..d68b7f2
--- /dev/null
+++ b/store/home/config/nvim/lua/crupest/nvim/plugins/telescope.lua
@@ -0,0 +1,11 @@
+local function setup()
+ local builtin = require('telescope.builtin')
+ vim.keymap.set('n', '<leader>ff', builtin.find_files, {})
+ vim.keymap.set('n', '<leader>fg', builtin.live_grep, {})
+ vim.keymap.set('n', '<leader>fb', builtin.buffers, {})
+ vim.keymap.set('n', '<leader>fh', builtin.help_tags, {})
+end
+
+return {
+ setup = setup
+}
diff --git a/store/home/config/nvim/lua/crupest/utils/find.lua b/store/home/config/nvim/lua/crupest/utils/find.lua
new file mode 100644
index 0000000..dd1f663
--- /dev/null
+++ b/store/home/config/nvim/lua/crupest/utils/find.lua
@@ -0,0 +1,101 @@
+local is_win = vim.fn.has("win32") ~= 0
+
+local M = {}
+
+local windows_exe_ext = { "exe", "bat", "cmd", "ps1" }
+
+--- Find real path (with ext) for an executable.
+--- @param dir string
+--- @param name string | string[]
+--- @return string | nil
+function M.find_exe_file(dir, name)
+ if type(name) == "string" then
+ name = { name }
+ end
+ for _, n in ipairs(name) do
+ if vim.uv.fs_stat(vim.fs.joinpath(dir, n)) ~= nil then
+ return n
+ end
+ if is_win then
+ for _, ext in ipairs(windows_exe_ext) do
+ if vim.uv.fs_stat(vim.fs.joinpath(dir, n .. "." .. ext)) ~= nil then
+ return n .. "." .. ext
+ end
+ end
+ end
+ end
+ return nil
+end
+
+--- Walk up until found an executable in node_modules.
+--- @param path string
+--- @param name string
+--- @return string | nil exe_path Path to the executable.
+function M.find_node_modules_exe(path, name)
+ local bin_dirs = vim.fs.find("node_modules/.bin", { path = path, upward = true, type = "directory" })
+ if #bin_dirs == 0 then return nil end
+ local exe = M.find_exe_file(bin_dirs[1], name)
+ return exe and vim.fs.joinpath(bin_dirs[1], exe)
+end
+
+--- Find executable in PATH.
+--- @param name string
+--- @return string | nil
+function M.find_global_exe(name)
+ local exe = vim.fn.exepath(name)
+ if exe == "" then return nil end
+ return exe
+end
+
+--- @alias ExePlace "node_modules" | "global"
+--- @param path string
+--- @param name string
+--- @param places ExePlace[]
+--- @return string | nil, ExePlace?
+function M.find_exe(path, name, places)
+ for _, place in ipairs(places) do
+ if place == "node_modules" then
+ local r = M.find_node_modules_exe(path, name)
+ if r then return r, "node_modules" end
+ end
+ if place == "global" then
+ local r = M.find_global_exe(name)
+ if r then return r, "global" end
+ end
+ end
+ return nil, nil
+end
+
+--- @alias FindExeForBufOpts { name: string, exe: string?, places: ExePlace[], config_files: string[]?, filetypes: string[]? }
+--- @alias FindExeForBufResult { name: string, file: string, exe: string, exe_path: string, place: ExePlace, config_file: string?, filetype: string? }
+--- @param buf number
+--- @param opts FindExeForBufOpts
+--- @return FindExeForBufResult | nil
+function M.find_exe_for_buf(buf, opts)
+ local r = {} --- @type FindExeForBufResult
+ r.name = opts.name
+ r.file = vim.api.nvim_buf_get_name(buf)
+ r.exe = opts.exe or opts.name
+
+ if opts.filetypes then
+ r.filetype = vim.api.nvim_get_option_value("filetype", { scope = "buffer", buf = buf })
+ if not vim.tbl_contains(opts.filetypes, r.filetype) then return nil end
+ end
+
+ if opts.config_files then
+ local config_file_list = vim.fs.find(opts.config_files, { path = r.file, upward = true })
+ if #config_file_list == 0 then return nil end
+ r.config_file = config_file_list[1]
+ end
+
+ local exe_path, place = M.find_exe(r.file, r.exe, opts.places)
+ if exe_path == nil then return nil end
+ r.exe_path = exe_path
+
+ --- @cast place ExePlace
+ r.place = place
+
+ return r
+end
+
+return M
diff --git a/store/home/config/nvim/lua/crupest/utils/nvim.lua b/store/home/config/nvim/lua/crupest/utils/nvim.lua
new file mode 100644
index 0000000..4477ecc
--- /dev/null
+++ b/store/home/config/nvim/lua/crupest/utils/nvim.lua
@@ -0,0 +1,12 @@
+local M = {}
+
+function M.close_float()
+ local wins = vim.api.nvim_list_wins()
+ for _, v in ipairs(wins) do
+ if vim.api.nvim_win_get_config(v).relative ~= '' then
+ vim.api.nvim_win_close(v, false)
+ end
+ end
+end
+
+return M
diff --git a/store/home/config/nvim/lua/plugins.lua b/store/home/config/nvim/lua/plugins.lua
new file mode 100644
index 0000000..b689559
--- /dev/null
+++ b/store/home/config/nvim/lua/plugins.lua
@@ -0,0 +1,30 @@
+-- spellchecker: disable
+return {
+ { "catppuccin/nvim", name = "catppuccin", priority = 1000 },
+ "neovim/nvim-lspconfig",
+ "L3MON4D3/LuaSnip",
+ "hrsh7th/nvim-cmp",
+ "hrsh7th/cmp-nvim-lsp",
+ "hrsh7th/cmp-buffer",
+ "hrsh7th/cmp-path",
+ "hrsh7th/cmp-cmdline",
+ "saadparwaiz1/cmp_luasnip",
+ {
+ "nvim-tree/nvim-tree.lua",
+ lazy = false,
+ dependencies = {
+ "nvim-tree/nvim-web-devicons",
+ },
+ },
+ {
+ "nvim-lualine/lualine.nvim",
+ dependencies = { 'nvim-tree/nvim-web-devicons' }
+ },
+ {
+ "nvim-telescope/telescope.nvim",
+ dependencies = { 'nvim-lua/plenary.nvim' }
+ },
+ "windwp/nvim-autopairs",
+ "mfussenegger/nvim-lint",
+ "lewis6991/gitsigns.nvim",
+}
diff --git a/store/home/config/nvim/nvim-words.txt b/store/home/config/nvim/nvim-words.txt
new file mode 100644
index 0000000..564904f
--- /dev/null
+++ b/store/home/config/nvim/nvim-words.txt
@@ -0,0 +1,27 @@
+nvim
+vimruntime
+bnext
+bufhidden
+exepath
+stdpath
+augroup
+autocmd
+autobrief
+autopairs
+keymap
+joinpath
+bufnr
+
+neovide
+pixiedust
+
+lspconfig
+clangd
+bashls
+
+catppuccin
+macchiato
+diffthis
+gitsigns
+lualine
+luasnip
diff --git a/store/home/gitconfig b/store/home/gitconfig
new file mode 100644
index 0000000..b343ab3
--- /dev/null
+++ b/store/home/gitconfig
@@ -0,0 +1,10 @@
+[user]
+ name = Yuqian Yang
+ email = crupest@crupest.life
+[core]
+ autocrlf = false
+ editor = vim
+[credential]
+ helper = /usr/lib/git-core/git-credential-libsecret
+[alias]
+ lg = log --decorate --oneline --graph
diff --git a/store/misc/bruno/ComfyUI/Get Object Info.bru b/store/misc/bruno/ComfyUI/Get Object Info.bru
new file mode 100644
index 0000000..d1a833c
--- /dev/null
+++ b/store/misc/bruno/ComfyUI/Get Object Info.bru
@@ -0,0 +1,11 @@
+meta {
+ name: Get Object Info
+ type: http
+ seq: 4
+}
+
+get {
+ url: {{BASE_URL}}/object_info
+ body: none
+ auth: none
+}
diff --git a/store/misc/bruno/ComfyUI/Get Prompt History.bru b/store/misc/bruno/ComfyUI/Get Prompt History.bru
new file mode 100644
index 0000000..2e26888
--- /dev/null
+++ b/store/misc/bruno/ComfyUI/Get Prompt History.bru
@@ -0,0 +1,15 @@
+meta {
+ name: Get Prompt History
+ type: http
+ seq: 6
+}
+
+get {
+ url: {{BASE_URL}}/history/{{prompt_id}}
+ body: none
+ auth: none
+}
+
+vars:pre-request {
+ prompt_id: 7e345a55-21c4-4bdc-9b34-add561775144
+}
diff --git a/store/misc/bruno/ComfyUI/Post Prompt.bru b/store/misc/bruno/ComfyUI/Post Prompt.bru
new file mode 100644
index 0000000..09bf89a
--- /dev/null
+++ b/store/misc/bruno/ComfyUI/Post Prompt.bru
@@ -0,0 +1,124 @@
+meta {
+ name: Post Prompt
+ type: http
+ seq: 5
+}
+
+post {
+ url: {{BASE_URL}}/prompt
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "client_id": "crupest",
+ "prompt": {
+ "3": {
+ "inputs": {
+ "seed": 156680208700286,
+ "steps": 20,
+ "cfg": 8,
+ "sampler_name": "euler",
+ "scheduler": "normal",
+ "denoise": 1,
+ "model": [
+ "4",
+ 0
+ ],
+ "positive": [
+ "6",
+ 0
+ ],
+ "negative": [
+ "7",
+ 0
+ ],
+ "latent_image": [
+ "5",
+ 0
+ ]
+ },
+ "class_type": "KSampler",
+ "_meta": {
+ "title": "KSampler"
+ }
+ },
+ "4": {
+ "inputs": {
+ "ckpt_name": "SUPIR/SUPIR-v0Q.ckpt"
+ },
+ "class_type": "CheckpointLoaderSimple",
+ "_meta": {
+ "title": "Load Checkpoint"
+ }
+ },
+ "5": {
+ "inputs": {
+ "width": 512,
+ "height": 512,
+ "batch_size": 1
+ },
+ "class_type": "EmptyLatentImage",
+ "_meta": {
+ "title": "Empty Latent Image"
+ }
+ },
+ "6": {
+ "inputs": {
+ "text": "beautiful scenery nature glass bottle landscape, , purple galaxy bottle,",
+ "clip": [
+ "4",
+ 1
+ ]
+ },
+ "class_type": "CLIPTextEncode",
+ "_meta": {
+ "title": "CLIP Text Encode (Prompt)"
+ }
+ },
+ "7": {
+ "inputs": {
+ "text": "text, watermark",
+ "clip": [
+ "4",
+ 1
+ ]
+ },
+ "class_type": "CLIPTextEncode",
+ "_meta": {
+ "title": "CLIP Text Encode (Prompt)"
+ }
+ },
+ "8": {
+ "inputs": {
+ "samples": [
+ "3",
+ 0
+ ],
+ "vae": [
+ "4",
+ 2
+ ]
+ },
+ "class_type": "VAEDecode",
+ "_meta": {
+ "title": "VAE Decode"
+ }
+ },
+ "9": {
+ "inputs": {
+ "filename_prefix": "ComfyUI",
+ "images": [
+ "8",
+ 0
+ ]
+ },
+ "class_type": "SaveImage",
+ "_meta": {
+ "title": "Save Image"
+ }
+ }
+ }
+ }
+}
diff --git a/store/misc/bruno/ComfyUI/Upload Image.bru b/store/misc/bruno/ComfyUI/Upload Image.bru
new file mode 100644
index 0000000..92b4aeb
--- /dev/null
+++ b/store/misc/bruno/ComfyUI/Upload Image.bru
@@ -0,0 +1,18 @@
+meta {
+ name: Upload Image
+ type: http
+ seq: 2
+}
+
+post {
+ url: {{BASE_URL}}/upload/image
+ body: multipartForm
+ auth: none
+}
+
+body:multipart-form {
+ overwrite: true
+ type: input
+ subfolder: crupest-test
+ image: @file(/Users/crupest/codes/crupest/assets/crupest-transparent.png)
+}
diff --git a/store/misc/bruno/ComfyUI/View Image.bru b/store/misc/bruno/ComfyUI/View Image.bru
new file mode 100644
index 0000000..395eccd
--- /dev/null
+++ b/store/misc/bruno/ComfyUI/View Image.bru
@@ -0,0 +1,19 @@
+meta {
+ name: View Image
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{BASE_URL}}/view?filename=crupest-transparent.png&type=input&subfolder=crupest-test&preview=jpeg;90&channel=rgb
+ body: none
+ auth: none
+}
+
+query {
+ filename: crupest-transparent.png
+ type: input
+ subfolder: crupest-test
+ preview: jpeg;90
+ channel: rgb
+}
diff --git a/store/misc/bruno/ComfyUI/bruno.json b/store/misc/bruno/ComfyUI/bruno.json
new file mode 100644
index 0000000..ee35540
--- /dev/null
+++ b/store/misc/bruno/ComfyUI/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "ComfyUI",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+} \ No newline at end of file
diff --git a/store/misc/bruno/ComfyUI/environments/ChimerAI ComfyUI Server.bru b/store/misc/bruno/ComfyUI/environments/ChimerAI ComfyUI Server.bru
new file mode 100644
index 0000000..480c8da
--- /dev/null
+++ b/store/misc/bruno/ComfyUI/environments/ChimerAI ComfyUI Server.bru
@@ -0,0 +1,3 @@
+vars:secret [
+ BASE_URL
+]
diff --git a/store/misc/magic/extend-script.js b/store/misc/magic/extend-script.js
new file mode 100644
index 0000000..519cee9
--- /dev/null
+++ b/store/misc/magic/extend-script.js
@@ -0,0 +1,21 @@
+// Define main function (script entry)
+
+function main(config, profileName) {
+ delete config.dns;
+ delete config.tun;
+ delete config.hosts;
+
+ delete config["cfw-latency-timeout"]
+ delete config["cfw-latency-url"]
+ delete config["cfw-conn-break-strategy"]
+
+ config["proxies"] = [config["crupest-proxy"], ...config["proxies"]]
+ delete config["crupest-proxy"]
+
+ select_proxy = { name: "node-select", type: "select", proxies: ["auto-select", ...config.proxies.map(p => p.name)] }
+ auto_select_proxy = config["crupest-auto-select"]
+ config["proxy-groups"] = [ select_proxy, auto_select_proxy ]
+ delete config["crupest-auto-select"]
+
+ return config;
+}
diff --git a/store/misc/magic/extend.yaml b/store/misc/magic/extend.yaml
new file mode 100644
index 0000000..3006f08
--- /dev/null
+++ b/store/misc/magic/extend.yaml
@@ -0,0 +1,65 @@
+# Profile Enhancement Merge Template for Clash Verge
+
+profile:
+ store-selected: true
+
+rules:
+ - "GEOSITE,github,node-select"
+ - "GEOSITE,google,node-select"
+ - "GEOSITE,youtube,node-select"
+ - "GEOSITE,twitter,node-select"
+ - "GEOSITE,facebook,node-select"
+ - "GEOSITE,discord,node-select"
+ - "GEOSITE,reddit,node-select"
+ - "GEOSITE,twitch,node-select"
+ - "GEOSITE,quora,node-select"
+ - "GEOSITE,telegram,node-select"
+ - "GEOSITE,imgur,node-select"
+ - "GEOSITE,stackexchange,node-select"
+ - "GEOSITE,onedrive,node-select"
+
+ - "GEOSITE,duckduckgo,node-select"
+ - "GEOSITE,wikimedia,node-select"
+ - "GEOSITE,gitbook,node-select"
+ - "GEOSITE,gitlab,node-select"
+ - "GEOSITE,creativecommons,node-select"
+ - "GEOSITE,archive,node-select"
+ - "GEOSITE,matrix,node-select"
+ - "GEOSITE,tor,node-select"
+
+ - "GEOSITE,python,node-select"
+ - "GEOSITE,ruby,node-select"
+ - "GEOSITE,rust,node-select"
+ - "GEOSITE,nodejs,node-select"
+ - "GEOSITE,npmjs,node-select"
+ - "GEOSITE,qt,node-select"
+ - "GEOSITE,docker,node-select"
+ - "GEOSITE,v2ray,node-select"
+ - "GEOSITE,homebrew,node-select"
+ - "GEOSITE,bootstrap,node-select"
+
+ - "GEOSITE,heroku,node-select"
+ - "GEOSITE,vercel,node-select"
+
+ - "GEOSITE,ieee,node-select"
+ - "GEOSITE,sci-hub,node-select"
+ - "GEOSITE,libgen,node-select"
+
+ - "DOMAIN-SUFFIX,gnu.org,node-select"
+ - "DOMAIN-SUFFIX,nongnu.org,node-select"
+ - "DOMAIN-SUFFIX,ietf.org,node-select"
+ - "DOMAIN-SUFFIX,packagist.org,node-select"
+ - "DOMAIN-SUFFIX,metacubex.one,node-select"
+ - "MATCH,DIRECT"
+
+crupest-proxy:
+ ...
+
+crupest-auto-select:
+ name: "auto-select"
+ type: url-test
+ interval: 1800
+ include-all-proxies: true
+ url: 'https://www.gstatic.com/generate_204'
+ filter: "日本|新加坡|香港|台湾|美国"
+ expected-status: 204
diff --git a/store/win/Microsoft.PowerShell_profile.ps1 b/store/win/Microsoft.PowerShell_profile.ps1
new file mode 100644
index 0000000..aeced5f
--- /dev/null
+++ b/store/win/Microsoft.PowerShell_profile.ps1
@@ -0,0 +1,35 @@
+function Use-VC {
+ param(
+ [Parameter()]
+ [ValidateSet('x64', 'x86')]
+ $Arch = 'x64'
+ )
+
+ if ($Arch -eq 'x86') {
+ $p = 'x86';
+ }
+ else {
+ $p = 'amd64'
+ }
+
+ cmd /c "`"$(vswhere.exe -format value -property installationPath)\VC\Auxiliary\Build\vcvars64.bat`" $p & set" |
+ ForEach-Object {
+ if ($_ -match '=') {
+ $v = $_ -split '='
+ Set-Item -Force -Path "ENV:\$($v[0])" -Value "$($v[1])"
+ }
+ }
+ Pop-Location
+ Write-Host "Visual Studio Command Prompt variables set." -ForegroundColor Yellow
+}
+
+
+function Set-Proxy {
+ $env:http_proxy = "http://127.0.0.1:2080"
+ $env:https_proxy = "http://127.0.0.1:2080"
+}
+
+function Reset-Proxy {
+ Remove-Item env:http_proxy
+ Remove-Item env:https_proxy
+}
diff --git a/store/win/crupest-winget.json b/store/win/crupest-winget.json
new file mode 100644
index 0000000..df2e7d9
--- /dev/null
+++ b/store/win/crupest-winget.json
@@ -0,0 +1,104 @@
+{
+ "$schema" : "https://aka.ms/winget-packages.schema.2.0.json",
+ "CreationDate" : "2024-10-31T18:34:15.174-00:00",
+ "Sources" :
+ [
+ {
+ "Packages" :
+ [
+ {
+ "PackageIdentifier" : "7zip.7zip"
+ },
+ {
+ "PackageIdentifier" : "Docker.DockerDesktop"
+ },
+ {
+ "PackageIdentifier" : "Git.Git"
+ },
+ {
+ "PackageIdentifier" : "Mozilla.Firefox"
+ },
+ {
+ "PackageIdentifier" : "Mozilla.Thunderbird"
+ },
+ {
+ "PackageIdentifier" : "VideoLAN.VLC"
+ },
+ {
+ "PackageIdentifier" : "vim.vim"
+ },
+ {
+ "PackageIdentifier" : "Neovim.Neovim"
+ },
+ {
+ "PackageIdentifier" : "OpenJS.NodeJS"
+ },
+ {
+ "PackageIdentifier" : "voidtools.Everything"
+ },
+ {
+ "PackageIdentifier" : "Neovide.Neovide"
+ },
+ {
+ "PackageIdentifier" : "Microsoft.PowerShell"
+ },
+ {
+ "PackageIdentifier" : "Kitware.CMake"
+ },
+ {
+ "PackageIdentifier" : "JetBrains.PyCharm.Community"
+ },
+ {
+ "PackageIdentifier" : "Tencent.QQ.NT"
+ },
+ {
+ "PackageIdentifier" : "Tencent.WeChat"
+ },
+ {
+ "PackageIdentifier" : "Python.Launcher"
+ },
+ {
+ "PackageIdentifier" : "NetEase.CloudMusic"
+ },
+ {
+ "PackageIdentifier" : "agalwood.Motrix"
+ },
+ {
+ "PackageIdentifier" : "BurntSushi.ripgrep.MSVC"
+ },
+ {
+ "PackageIdentifier" : "Microsoft.VisualStudio.Locator"
+ },
+ {
+ "PackageIdentifier" : "Ninja-build.Ninja"
+ },
+ {
+ "PackageIdentifier" : "Rufus.Rufus"
+ },
+ {
+ "PackageIdentifier" : "Rustlang.Rustup"
+ },
+ {
+ "PackageIdentifier" : "Python.Python.3.13"
+ },
+ {
+ "PackageIdentifier" : "Microsoft.PowerToys"
+ },
+ {
+ "PackageIdentifier" : "Microsoft.VisualStudioCode"
+ },
+ {
+ "PackageIdentifier" : "Microsoft.WinDbg"
+ }
+ ],
+ "SourceDetails" :
+ {
+ "Argument" : "https://cdn.winget.microsoft.com/cache",
+ "Identifier" : "Microsoft.Winget.Source_8wekyb3d8bbwe",
+ "Name" : "winget",
+ "Type" : "Microsoft.PreIndexed.Package"
+ }
+ }
+ ],
+ "WinGetVersion" : "1.9.2411-preview"
+} \ No newline at end of file
diff --git a/store/win/neovide-listen.ps1 b/store/win/neovide-listen.ps1
new file mode 100644
index 0000000..e84f3a2
--- /dev/null
+++ b/store/win/neovide-listen.ps1
@@ -0,0 +1,38 @@
+$env:NVIM_LISTEN_ADDRESS ??= "\\.\pipe\nvimsocket"
+
+$neovide_args = @()
+
+$MY_NEOVIM_PATH="$HOME/codes/neovim/build/bin/nvim.exe"
+if (Get-Item $MY_NEOVIM_PATH -ErrorAction Ignore) {
+ Write-Output "Found my neovim at $MY_NEOVIM_PATH."
+ $env:VIMRUNTIME="$HOME/codes/neovim/runtime"
+ $neovide_args += "--neovim-bin", "$MY_NEOVIM_PATH"
+}
+
+$listen_added = $false
+foreach ($arg in $args) {
+ $neovide_args += $arg
+ if ( $arg -eq '--') {
+ $neovide_args += "--listen", $env:NVIM_LISTEN_ADDRESS
+ $listen_added=$true
+ }
+}
+
+if (-not $listen_added) {
+ $neovide_args += "--", "--listen", $env:NVIM_LISTEN_ADDRESS
+}
+
+$neovide_bin = "neovide"
+$my_neovide_path = "$HOME/codes/neovide/target/release/neovide.exe"
+if (Get-Item $my_neovide_path -ErrorAction Ignore) {
+ Write-Output "Found my neovide at $my_neovide_path."
+ $neovide_bin = "$my_neovide_path"
+}
+
+if (Get-Command nvr -ErrorAction Ignore) {
+ Write-Output "Detected nvr, set git editor env."
+ $env:GIT_EDITOR = "nvr -cc split --remote-wait"
+}
+
+Write-Output "Command is $($neovide_args -join ' ')."
+Start-Process $neovide_bin -ArgumentList $neovide_args -Wait
diff --git a/store/works/Crupest.SecretTool/.gitignore b/store/works/Crupest.SecretTool/.gitignore
new file mode 100644
index 0000000..ac4d8a4
--- /dev/null
+++ b/store/works/Crupest.SecretTool/.gitignore
@@ -0,0 +1,7 @@
+.vs
+bin
+obj
+*.pubxml.user
+*.csproj.user
+
+publish
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool.sln b/store/works/Crupest.SecretTool/Crupest.SecretTool.sln
new file mode 100644
index 0000000..fde4347
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool.sln
@@ -0,0 +1,30 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.7.34024.191
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{F4C2CE80-CDF8-4B08-8912-D1F0F14196AD}"
+ ProjectSection(SolutionItems) = preProject
+ .gitignore = .gitignore
+ EndProjectSection
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Crupest.SecretTool", "Crupest.SecretTool\Crupest.SecretTool.csproj", "{D6335AE4-FD22-49CD-9624-37371F3B4F82}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {D6335AE4-FD22-49CD-9624-37371F3B4F82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D6335AE4-FD22-49CD-9624-37371F3B4F82}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D6335AE4-FD22-49CD-9624-37371F3B4F82}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D6335AE4-FD22-49CD-9624-37371F3B4F82}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {B1E8FD9C-9157-4F4E-8265-4B37F30EEC5E}
+ EndGlobalSection
+EndGlobal
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/.gitignore b/store/works/Crupest.SecretTool/Crupest.SecretTool/.gitignore
new file mode 100644
index 0000000..c936492
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/.gitignore
@@ -0,0 +1 @@
+vmess.txt
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/Config.cs b/store/works/Crupest.SecretTool/Crupest.SecretTool/Config.cs
new file mode 100644
index 0000000..ff58551
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/Config.cs
@@ -0,0 +1,95 @@
+namespace Crupest.SecretTool;
+
+public record ConfigItem(string Value, int LineNumber);
+
+public class DictionaryConfig(string configString, List<string>? requiredKeys = null)
+{
+ private static Dictionary<string, ConfigItem> Parse(string configString, List<string>? requiredKeys = null)
+ {
+ var config = new Dictionary<string, ConfigItem>();
+ var lines = configString.Split('\n');
+ int lineNumber = 1;
+
+ foreach (var line in lines)
+ {
+ var l = line;
+ var beginOfComment = l.IndexOf('#');
+ if (beginOfComment >= 0)
+ {
+ l = line[..beginOfComment];
+ }
+ l = l.Trim();
+ if (!string.IsNullOrEmpty(l))
+ {
+ var equalIndex = l.IndexOf('=');
+ if (equalIndex == -1)
+ {
+ throw new FormatException($"No '=' found in line {lineNumber}.");
+ }
+
+ config.Add(l[..equalIndex].Trim(), new ConfigItem(l[(equalIndex + 1)..].Trim(), lineNumber));
+ }
+
+ lineNumber++;
+ }
+
+ if (requiredKeys is not null)
+ {
+ foreach (var key in requiredKeys)
+ {
+ if (!config.ContainsKey(key))
+ {
+ throw new FormatException($"Required key '{key}' not found in config.");
+ }
+ }
+ }
+
+ return config;
+ }
+
+ public string ConfigString { get; } = configString;
+ public List<string>? RequiredKeys { get; } = requiredKeys;
+ public Dictionary<string, ConfigItem> Config { get; } = Parse(configString);
+ public ConfigItem GetItemCaseInsensitive(string key)
+ {
+ foreach (var (originalKey, value) in Config)
+ {
+ if (string.Equals(originalKey, key, StringComparison.OrdinalIgnoreCase))
+ {
+ return value;
+ }
+ }
+ throw new KeyNotFoundException($"Key '{key}' not found in config case-insensitively.");
+ }
+}
+
+public class ListConfig(string configString)
+{
+ private static List<ConfigItem> Parse(string configString)
+ {
+ var config = new List<ConfigItem>();
+ var lines = configString.Split('\n');
+ int lineNumber = 1;
+
+ foreach (var line in lines)
+ {
+ var l = line;
+ var beginOfComment = l.IndexOf('#');
+ if (beginOfComment >= 0)
+ {
+ l = line[..beginOfComment];
+ }
+ l = l.Trim();
+ if (!string.IsNullOrEmpty(l))
+ {
+ config.Add(new ConfigItem(l, lineNumber));
+ }
+ lineNumber++;
+ }
+
+ return config;
+ }
+
+ public string ConfigString { get; } = configString;
+ public List<ConfigItem> Config { get; } = Parse(configString);
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/Controller.cs b/store/works/Crupest.SecretTool/Crupest.SecretTool/Controller.cs
new file mode 100644
index 0000000..0803b01
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/Controller.cs
@@ -0,0 +1,113 @@
+using System.Diagnostics;
+
+namespace Crupest.SecretTool;
+
+public class Controller(string executablePath, string configPath, string? assetPath)
+{
+ public const string ToolAssetEnvironmentVariableName = "v2ray.location.asset";
+
+ public static string? FindExecutable(string contentDir, out bool isLocal, string? executableName = null)
+ {
+ isLocal = false;
+ executableName ??= "v2ray";
+
+ if (OperatingSystem.IsWindows())
+ {
+ executableName += ".exe";
+ }
+
+ var localToolPath = Path.Combine(contentDir, executableName);
+ if (File.Exists(localToolPath))
+ {
+ isLocal = true;
+ return localToolPath;
+ }
+
+ var paths = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator);
+ if (paths is not null)
+ {
+ foreach (var p in paths)
+ {
+ var toolPath = Path.Combine(p, executableName);
+ if (File.Exists(toolPath))
+ {
+ return toolPath;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ public string ExecutablePath { get; } = executablePath;
+ public string ConfigPath { get; } = configPath;
+ public string? AssetPath { get; } = assetPath;
+ public Process? CurrentProcess { get; private set; }
+
+ private Process CreateProcess()
+ {
+ var process = new Process();
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = ExecutablePath,
+ };
+ startInfo.ArgumentList.Add("run");
+ startInfo.ArgumentList.Add("-c");
+ startInfo.ArgumentList.Add(ConfigPath);
+ if (AssetPath is not null)
+ {
+ startInfo.EnvironmentVariables[ToolAssetEnvironmentVariableName] = AssetPath;
+ }
+
+ process.StartInfo = startInfo;
+ process.OutputDataReceived += (_, args) =>
+ {
+ Console.Out.Write(args.Data);
+ };
+ process.ErrorDataReceived += (_, args) =>
+ {
+ Console.Error.WriteLine(args.Data);
+ };
+
+ return process;
+ }
+
+ public void Stop()
+ {
+ if (CurrentProcess is not null)
+ {
+ CurrentProcess.Kill();
+ CurrentProcess.Dispose();
+ CurrentProcess = null;
+ Console.WriteLine("V2ray stopped.");
+ }
+ }
+
+ public void Start(bool stopOld = false)
+ {
+ if (stopOld) Stop();
+
+ if (CurrentProcess is null)
+ {
+ CurrentProcess = CreateProcess();
+ CurrentProcess.EnableRaisingEvents = true;
+ CurrentProcess.Exited += (_, _) =>
+ {
+ if (CurrentProcess.ExitCode != 0)
+ {
+ const string message = "V2ray exited with error.";
+ Console.Error.WriteLine(message);
+ throw new Exception(message);
+ }
+ };
+ CurrentProcess.Start();
+ Console.WriteLine("V2ray started.");
+ }
+ }
+
+ public void Restart()
+ {
+ Start(true);
+ }
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/Crupest.SecretTool.csproj b/store/works/Crupest.SecretTool/Crupest.SecretTool/Crupest.SecretTool.csproj
new file mode 100644
index 0000000..2502e74
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/Crupest.SecretTool.csproj
@@ -0,0 +1,34 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <OutputType>Exe</OutputType>
+ <TargetFramework>net8.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <None Update="config.json.template">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="proxy.txt">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="vmess.txt">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="hosts.txt">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="sing-config.json.template">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="sing-inbounds-mobile.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="sing-inbounds-pc.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ </ItemGroup>
+
+</Project>
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/FileWatcher.cs b/store/works/Crupest.SecretTool/Crupest.SecretTool/FileWatcher.cs
new file mode 100644
index 0000000..26e9231
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/FileWatcher.cs
@@ -0,0 +1,26 @@
+namespace Crupest.SecretTool;
+
+public class FileWatcher(string directory, List<string> fileNames)
+{
+ public string Directory { get; set; } = directory;
+ public List<string> FileNames { get; set; } = fileNames;
+
+ public delegate void OnChangedHandler();
+ public event OnChangedHandler? OnChanged;
+
+ public void Run()
+ {
+ var sourceWatcher = new FileSystemWatcher(Directory);
+ foreach (var fileName in FileNames)
+ {
+ sourceWatcher.Filters.Add(fileName);
+ }
+ sourceWatcher.NotifyFilter = NotifyFilters.LastWrite;
+
+ while (true)
+ {
+ var result = sourceWatcher.WaitForChanged(WatcherChangeTypes.Changed | WatcherChangeTypes.Created);
+ OnChanged?.Invoke();
+ }
+ }
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/GeoDataManager.cs b/store/works/Crupest.SecretTool/Crupest.SecretTool/GeoDataManager.cs
new file mode 100644
index 0000000..8f4c171
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/GeoDataManager.cs
@@ -0,0 +1,324 @@
+using System.IO.Compression;
+
+namespace Crupest.SecretTool;
+
+public interface IGeoSiteEntry
+{
+ bool IsInclude { get; }
+ string Value { get; }
+}
+
+public record GeoSiteIncludeEntry(string Value, string ContainingSite) : IGeoSiteEntry
+{
+ public bool IsInclude => true;
+}
+
+public record GeoSiteRuleEntry(HostMatchKind Kind, string Value, List<string> Attributes, string ContainingSite) : IGeoSiteEntry
+{
+ public bool IsInclude => false;
+
+ public RoutingRuleMatcher GetRoutingRuleMatcher() => new(Kind, Value);
+}
+
+public record GeoSite(string Name, List<IGeoSiteEntry> Entries)
+{
+ public static GeoSite Parse(string name, string str)
+ {
+ List<IGeoSiteEntry> entries = [];
+ var listConfig = new ListConfig(str);
+ foreach (var item in listConfig.Config)
+ {
+ var (value, line) = item;
+
+ if (value.StartsWith("include:"))
+ {
+ var include = value["include:".Length..].Trim();
+ if (include.Length == 0 || include.Contains(' '))
+ {
+ throw new FormatException($"Invalid geo site rule '{name}' in line {line}. Invalid include value.");
+ }
+ entries.Add(new GeoSiteIncludeEntry(include, name));
+ continue;
+ }
+
+ var segments = value.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ if (segments.Length > 2)
+ {
+ throw new FormatException($"Invalid geo site rule '{name}' in line {line}. More than one ':'.");
+ }
+
+ HostMatchKind kind;
+ if (segments.Length == 2)
+ {
+ kind = segments[0] switch
+ {
+ "domain" => kind = HostMatchKind.DomainSuffix,
+ "full" => kind = HostMatchKind.DomainFull,
+ "keyword" => kind = HostMatchKind.DomainKeyword,
+ "regexp" => kind = HostMatchKind.DomainRegex,
+ _ => throw new FormatException($"Invalid geo site rule '{name}' in line {line}. Unknown matcher.")
+ };
+ }
+ else
+ {
+ kind = HostMatchKind.DomainSuffix;
+ }
+
+ var domainSegments = segments[^1].Split('@', StringSplitOptions.TrimEntries);
+ var domain = domainSegments[0];
+ if (kind != HostMatchKind.DomainRegex && Uri.CheckHostName(domain) != UriHostNameType.Dns)
+ {
+ throw new FormatException($"Invalid geo site rule '{name}' in line {line}. Invalid domain.");
+ }
+
+ List<string> attributes = [];
+ foreach (var s in domainSegments[1..])
+ {
+ if (s.Length == 0)
+ {
+ throw new FormatException($"Invalid geo site rule '{name}' in line {line}. Empty attribute value.");
+ }
+ attributes.Add(s);
+ }
+
+ entries.Add(new GeoSiteRuleEntry(kind, domain, attributes, name));
+ }
+ return new GeoSite(name, entries);
+ }
+}
+
+public class GeoSiteData(string directory)
+{
+ private static List<GeoSite> Parse(string directory)
+ {
+ var sites = new List<GeoSite>();
+ foreach (var path in Directory.GetFileSystemEntries(directory))
+ {
+ var content = File.ReadAllText(path);
+ sites.Add(GeoSite.Parse(Path.GetFileName(path), content));
+ }
+ return sites;
+ }
+
+ public string DataDirectory { get; } = directory;
+
+ public List<GeoSite> Sites { get; } = Parse(directory);
+
+ public GeoSite? GetSite(string name)
+ {
+ return Sites.Where(s => s.Name == name).FirstOrDefault();
+ }
+
+ public List<GeoSiteRuleEntry> GetEntriesRecursive(List<string> sites,
+ List<HostMatchKind>? onlyMatcherKinds = null, List<string>? onlyAttributes = null)
+ {
+ List<GeoSiteRuleEntry> entries = [];
+ HashSet<string> visited = [];
+ HashSet<HostMatchKind>? kinds = onlyMatcherKinds?.ToHashSet();
+
+ void Visit(string site)
+ {
+ if (visited.Contains(site))
+ {
+ return;
+ }
+
+ visited.Add(site);
+ var siteData = GetSite(site);
+ if (siteData == null)
+ {
+ return;
+ }
+ foreach (var entry in siteData.Entries)
+ {
+ if (entry is GeoSiteIncludeEntry includeEntry)
+ {
+ Visit(includeEntry.Value);
+ }
+ else if (entry is GeoSiteRuleEntry geoSiteRuleEntry)
+ {
+ if (kinds != null && !kinds.Contains(geoSiteRuleEntry.Kind))
+ {
+ continue;
+ }
+
+ if (onlyAttributes != null && !geoSiteRuleEntry.Attributes.Intersect(onlyAttributes).Any())
+ {
+ continue;
+ }
+
+ entries.Add(geoSiteRuleEntry);
+ }
+ }
+ }
+
+ foreach (var s in sites)
+ {
+ Visit(s);
+ }
+
+ return entries;
+ }
+}
+
+public class GeoDataManager
+{
+ public const string GeoSiteFileName = "geosite.dat";
+ public const string GeoIpFileName = "geoip.dat";
+ public const string GeoIpCnFileName = "geoip-only-cn-private.dat";
+
+ public static class ToolGithub
+ {
+ public const string Organization = "v2fly";
+ public const string GeoSiteRepository = "domain-list-community";
+ public const string GeoIpRepository = "geoip";
+ public const string GeoSiteReleaseFilename = "dlc.dat";
+ public const string GeoIpReleaseFilename = "geoip.dat";
+ public const string GeoIpCnReleaseFilename = "geoip-only-cn-private.dat";
+ }
+
+ public static GeoDataManager Instance { get; } = new GeoDataManager();
+
+ public record GeoDataAsset(string Name, string FileName, string GithubUser, string GithubRepo, string GithubReleaseFileName);
+
+ public GeoDataManager()
+ {
+ Assets =
+ [
+ new("geosite", GeoSiteFileName, ToolGithub.Organization, ToolGithub.GeoSiteRepository, ToolGithub.GeoSiteReleaseFilename),
+ new("geoip", GeoIpFileName, ToolGithub.Organization, ToolGithub.GeoIpRepository, ToolGithub.GeoIpReleaseFilename),
+ new("geoip-cn", GeoIpCnFileName, ToolGithub.Organization, ToolGithub.GeoIpRepository, ToolGithub.GeoIpCnReleaseFilename),
+ ];
+ }
+
+ public List<GeoDataAsset> Assets { get; set; }
+
+ public GeoSiteData? GeoSiteData { get; set; }
+
+ public GeoSiteData GetOrCreateGeoSiteData(bool clean, bool silent)
+ {
+ if (GeoSiteData is not null) { return GeoSiteData; }
+ GeoSiteData = DownloadAndGenerateGeoSiteData(clean, silent);
+ return GeoSiteData;
+ }
+
+ private static string GetReleaseFileUrl(string user, string repo, string fileName)
+ {
+ return $"https://github.com/{user}/{repo}/releases/latest/download/{fileName}";
+ }
+
+ private static void GithubDownloadRelease(HttpClient httpClient, string user, string repo, string fileName, string outputPath, bool silent)
+ {
+ var url = GetReleaseFileUrl(user, repo, fileName);
+ if (!silent) Console.WriteLine($"Downloading {url} to {outputPath}");
+ using var responseStream = httpClient.GetStreamAsync(url).Result;
+ using var outputFileStream = File.OpenWrite(outputPath);
+ responseStream.CopyTo(outputFileStream);
+ }
+
+ public bool HasAllAssets(string directory, out List<string> missing)
+ {
+ missing = [];
+ foreach (var asset in Assets)
+ {
+ var assetPath = Path.Combine(directory, asset.FileName);
+ if (!File.Exists(assetPath))
+ {
+ missing.Add(asset.Name);
+ }
+ }
+ return missing.Count == 0;
+ }
+
+ public void Download(string outputDir, bool silent)
+ {
+ using var httpClient = new HttpClient();
+
+ foreach (var asset in Assets)
+ {
+ if (!silent)
+ {
+ Console.WriteLine($"Downloading {asset.Name}...");
+ }
+ GithubDownloadRelease(httpClient, asset.GithubUser, asset.GithubRepo, asset.GithubReleaseFileName, Path.Combine(outputDir, asset.FileName), silent);
+ if (!silent)
+ {
+ Console.WriteLine($"Downloaded {asset.Name}!");
+ }
+ }
+
+ if (!File.Exists(Program.RestartLabelFilePath))
+ {
+ File.Create(Program.RestartLabelFilePath);
+ }
+ else
+ {
+ File.SetLastWriteTime(Program.RestartLabelFilePath, DateTime.Now);
+ }
+ }
+
+ private static string GetGithubRepositoryArchiveUrl(string user, string repo)
+ {
+ return $"https://github.com/{user}/{repo}/archive/refs/heads/master.zip";
+ }
+
+ private static void GithubDownloadRepository(HttpClient httpClient, string user, string repo, string outputPath, bool silent)
+ {
+ var url = GetGithubRepositoryArchiveUrl(user, repo);
+ if (!silent) { Console.WriteLine($"Begin to download data from {url} to {outputPath}."); }
+ using var responseStream = httpClient.GetStreamAsync(url).Result;
+ using var outputFileStream = File.OpenWrite(outputPath);
+ responseStream.CopyTo(outputFileStream);
+ if (!silent) { Console.WriteLine("Succeeded to download."); }
+ }
+
+ private static void Unzip(string zipPath, string outputPath)
+ {
+ using var zip = ZipFile.OpenRead(zipPath) ?? throw new Exception($"Failed to open zip file {zipPath}");
+ zip.ExtractToDirectory(outputPath);
+ }
+
+ private static string DownloadAndExtractGeoDataRepository(bool cleanTempDirIfFailed, bool silent, out string tempDirectoryPath)
+ {
+ tempDirectoryPath = "";
+ const string zipFileName = "v2ray-geosite-master.zip";
+ using var httpClient = new HttpClient();
+ var tempDirectory = Directory.CreateTempSubdirectory(Program.Name);
+ tempDirectoryPath = tempDirectory.FullName;
+ try
+ {
+ var archivePath = Path.Combine(tempDirectoryPath, zipFileName);
+ var extractPath = Path.Combine(tempDirectoryPath, "repo");
+ GithubDownloadRepository(httpClient, ToolGithub.Organization, ToolGithub.GeoSiteRepository, archivePath, silent);
+ if (!silent) { Console.WriteLine($"Extract geo data to {extractPath}."); }
+ Directory.CreateDirectory(extractPath);
+ Unzip(archivePath, extractPath);
+ if (!silent) { Console.WriteLine($"Extraction done."); }
+ return Path.Join(extractPath, "domain-list-community-master");
+ }
+ catch (Exception)
+ {
+ if (cleanTempDirIfFailed)
+ {
+ Directory.Delete(tempDirectoryPath, true);
+ }
+ throw;
+ }
+ }
+
+ private static GeoSiteData DownloadAndGenerateGeoSiteData(bool clean, bool silent)
+ {
+ var repoDirectory = DownloadAndExtractGeoDataRepository(clean, silent, out var tempDirectoryPath);
+ try
+ {
+ return new GeoSiteData(Path.Join(repoDirectory, "data"));
+ }
+ finally
+ {
+ if (clean)
+ {
+ Directory.Delete(tempDirectoryPath, true);
+ }
+ }
+ }
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/HostMatchConfig.cs b/store/works/Crupest.SecretTool/Crupest.SecretTool/HostMatchConfig.cs
new file mode 100644
index 0000000..858333d
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/HostMatchConfig.cs
@@ -0,0 +1,123 @@
+namespace Crupest.SecretTool;
+
+public enum HostMatchKind
+{
+ DomainFull,
+ DomainSuffix,
+ DomainKeyword,
+ DomainRegex,
+ Ip,
+ GeoSite,
+ GeoIp,
+}
+
+public static class HostMatchKindExtensions
+{
+ public static bool IsDomain(this HostMatchKind kind)
+ {
+ return kind.IsNonRegexDomain() || kind == HostMatchKind.DomainRegex;
+ }
+
+ public static bool IsNonRegexDomain(this HostMatchKind kind)
+ {
+ return kind is HostMatchKind.DomainFull or HostMatchKind.DomainSuffix or HostMatchKind.DomainKeyword;
+ }
+
+
+ public static List<HostMatchKind> DomainMatchKinds { get; } = [HostMatchKind.DomainFull, HostMatchKind.DomainSuffix, HostMatchKind.DomainKeyword, HostMatchKind.DomainRegex];
+
+ public static List<HostMatchKind> NonRegexDomainMatchKinds { get; } = [HostMatchKind.DomainFull, HostMatchKind.DomainSuffix, HostMatchKind.DomainKeyword];
+
+ public static List<HostMatchKind> SupportedInSingRouteMatchKinds { get; } = [..DomainMatchKinds, HostMatchKind.Ip];
+
+ public static bool IsSupportedInSingRoute(this HostMatchKind kind) => SupportedInSingRouteMatchKinds.Contains(kind);
+}
+
+public record HostMatchConfigItem(HostMatchKind Kind, string MatchString, List<string> Values);
+
+public class HostMatchConfig(string configString, List<HostMatchKind> allowedMatchKinds, int minComponentCount = -1, int maxComponentCount = -1)
+{
+ private static List<HostMatchConfigItem> Parse(string configString, List<HostMatchKind> allowedMatchKinds, int minComponentCount = -1, int maxComponentCount = -1)
+ {
+ var items = new ListConfig(configString).Config;
+ var result = new List<HostMatchConfigItem>();
+
+ foreach (var item in items)
+ {
+ var lineNumber = item.LineNumber;
+ var line = item.Value;
+ var hasExplicitMatchKind = false;
+ var segments = line.Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList();
+
+ foreach (var matchKind in Enum.GetValues<HostMatchKind>())
+ {
+ var matchKindName = Enum.GetName(matchKind) ?? throw new Exception("No such match kind.");
+ if (segments[0] == matchKindName)
+ {
+ hasExplicitMatchKind = true;
+
+ if (segments.Count < 2)
+ {
+ throw new FormatException($"Explicit match item needs a value in line {lineNumber}.");
+ }
+ if (allowedMatchKinds.Contains(matchKind))
+ {
+ if (matchKind.IsNonRegexDomain() && Uri.CheckHostName(matchKindName) != UriHostNameType.Dns)
+ {
+ throw new FormatException($"Invalid domain format in line {lineNumber}.");
+ }
+
+ var components = segments[2..].ToList();
+ if (minComponentCount > 0 && components.Count < minComponentCount)
+ {
+ throw new FormatException($"Too few components in line {lineNumber}, at least {minComponentCount} required.");
+ }
+ if (maxComponentCount >= 0 && components.Count > maxComponentCount)
+ {
+ throw new FormatException($"Too many components in line {lineNumber}, only {maxComponentCount} allowed.");
+ }
+ result.Add(new HostMatchConfigItem(matchKind, segments[1], components));
+ }
+ else
+ {
+ throw new FormatException($"Match kind {matchKindName} is not allowed at line {lineNumber}.");
+ }
+ }
+ }
+
+ if (!hasExplicitMatchKind)
+ {
+ if (minComponentCount > 0 && segments.Count - 1 < minComponentCount)
+ {
+ throw new FormatException($"Too few components in line {lineNumber}, at least {minComponentCount} required.");
+ }
+ if (maxComponentCount >= 0 && segments.Count - 1 > maxComponentCount)
+ {
+ throw new FormatException($"Too many components in line {lineNumber}, only {maxComponentCount} allowed.");
+ }
+ result.Add(new HostMatchConfigItem(HostMatchKind.DomainSuffix, segments[0], segments.Count == 1 ? [] : segments[1..]));
+ }
+ }
+ return result;
+ }
+
+ public string ConfigString { get; } = configString;
+ public List<HostMatchKind> AllowedMatchKinds { get; } = allowedMatchKinds;
+ public int MinComponentCount { get; } = minComponentCount;
+ public int MaxComponentCount { get; } = maxComponentCount;
+ public List<HostMatchConfigItem> Items { get; } = Parse(configString, allowedMatchKinds, minComponentCount, maxComponentCount);
+}
+
+public class HostMatchConfigFile
+{
+ public HostMatchConfigFile(string path, List<HostMatchKind> allowedMatchKinds, int minComponentCount = -1, int maxComponentCount = -1)
+ {
+ Path = path;
+ FileContent = File.ReadAllText(path);
+ Config = new HostMatchConfig(FileContent, allowedMatchKinds, minComponentCount, maxComponentCount); ;
+ }
+
+ public string Path { get; }
+ public string FileContent { get; }
+ public HostMatchConfig Config { get; }
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/Program.cs b/store/works/Crupest.SecretTool/Crupest.SecretTool/Program.cs
new file mode 100644
index 0000000..18b1ac0
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/Program.cs
@@ -0,0 +1,113 @@
+using System.Reflection;
+
+namespace Crupest.SecretTool;
+
+public static class Program
+{
+ public static string Name { get; } = typeof(Program).Namespace ?? throw new Exception("Can't get the name of Crupest.SecretTool.");
+
+ public static string CrupestSecretToolDirectory { get; } =
+ Environment.GetEnvironmentVariable("CRUPEST_V2RAY_DIR") ??
+ Path.GetFullPath(Path.GetDirectoryName(
+ Assembly.GetExecutingAssembly().Location) ?? throw new Exception("Can't get the path of Crupest.SecretTool."));
+
+ private const string ConfigOutputFileName = "config.json";
+ private const string SurgeRuleSetChinaOutputFileName = "ChinaRuleSet.txt";
+ private const string SurgeRuleSetGlobalOutputFileName = "GlobalRuleSet.txt";
+
+ public const string RestartLabelFileName = "restart.label";
+ public static string RestartLabelFilePath { get; } = Path.Combine(CrupestSecretToolDirectory, RestartLabelFileName);
+
+ public static void RunToolAndWatchConfigChange()
+ {
+ var executablePath = Controller.FindExecutable(CrupestSecretToolDirectory, out var isLocal) ??
+ throw new Exception("Can't find v2ray executable either in Crupest.SecretTool directory or in PATH.");
+
+ string? assetsPath;
+ if (isLocal)
+ {
+ assetsPath = CrupestSecretToolDirectory;
+ var assetsComplete = GeoDataManager.Instance.HasAllAssets(CrupestSecretToolDirectory, out var missing);
+ if (!assetsComplete)
+ {
+ throw new Exception($"Missing assets: {string.Join(", ", missing)} in {CrupestSecretToolDirectory}. This v2ray is local. So only use assets in Crupest.SecretTool directory.");
+ }
+ }
+ else
+ {
+ assetsPath = CrupestSecretToolDirectory;
+ var assetsComplete = GeoDataManager.Instance.HasAllAssets(CrupestSecretToolDirectory, out var missing);
+ if (!assetsComplete)
+ {
+ Console.WriteLine($"Missing assets: {string.Join(", ", missing)} in {CrupestSecretToolDirectory}. This v2ray is global. So fallback to its own assets.");
+ assetsPath = null;
+ }
+ }
+
+ var controller = new Controller(executablePath, Path.Combine(CrupestSecretToolDirectory, ConfigOutputFileName), assetsPath);
+ var configFileWatcher = new FileWatcher(CrupestSecretToolDirectory,
+ [.. ToolConfig.ConfigFileNames, RestartLabelFileName]);
+
+ ToolConfig.FromDirectoryAndWriteToFile(CrupestSecretToolDirectory, Path.Join(CrupestSecretToolDirectory, ConfigOutputFileName));
+ controller.Start();
+
+ configFileWatcher.OnChanged += () =>
+ {
+ ToolConfig.FromDirectoryAndWriteToFile(CrupestSecretToolDirectory, Path.Join(CrupestSecretToolDirectory, ConfigOutputFileName));
+ controller.Restart();
+ };
+
+ configFileWatcher.Run();
+ }
+
+ public static void Main(string[] args)
+ {
+ if (args.Length != 0)
+ {
+ var verb = args[0].ToLower();
+ if (verb == "download-geodata" || verb == "dg")
+ {
+ if (args.Length != 1)
+ {
+ throw new Exception("Invalid command line arguments. download-geodata requires no arguments.");
+ }
+ GeoDataManager.Instance.Download(CrupestSecretToolDirectory, false);
+ return;
+ }
+ else if (verb == "generate-surge-rule-set" || verb == "gsr")
+ {
+ if (args.Length != 1)
+ {
+ throw new Exception("Invalid command line arguments. download-geodata requires no arguments.");
+ }
+ SurgeConfigGenerator.GenerateTo(
+ CrupestSecretToolDirectory,
+ Path.Join(CrupestSecretToolDirectory, SurgeRuleSetChinaOutputFileName),
+ Path.Join(CrupestSecretToolDirectory, SurgeRuleSetGlobalOutputFileName),
+ true, true
+ );
+ return;
+ }
+ else if (verb == "generate-sing-config" || verb == "gs")
+ {
+ if (args.Length != 2 || args[1].ToLower() is not ("pc" or "mobile"))
+ {
+ throw new Exception("Invalid command line arguments. generate-sing-config requires 1 argument. The argument must be either 'pc' or 'mobile'.");
+ }
+
+ var config = SingToolConfig.FromDirectory(CrupestSecretToolDirectory, args[1].ToLower() == "mobile", true, true);
+ Console.Out.WriteLine(config.ToSingConfigString());
+ return;
+ }
+ else if (verb == "generate" || verb == "g")
+ {
+ var config = ToolConfig.FromDirectory(CrupestSecretToolDirectory);
+ Console.Out.WriteLine(config.ToJsonStringV4());
+ return;
+ }
+ throw new Exception("Invalid command line arguments.");
+ }
+
+ RunToolAndWatchConfigChange();
+ }
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/Properties/PublishProfiles/FolderProfile.pubxml b/store/works/Crupest.SecretTool/Crupest.SecretTool/Properties/PublishProfiles/FolderProfile.pubxml
new file mode 100644
index 0000000..5fca454
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/Properties/PublishProfiles/FolderProfile.pubxml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+https://go.microsoft.com/fwlink/?LinkID=208121.
+-->
+<Project>
+ <PropertyGroup>
+ <Configuration>Release</Configuration>
+ <Platform>Any CPU</Platform>
+ <PublishDir>bin\Release\net8.0\publish\</PublishDir>
+ <PublishProtocol>FileSystem</PublishProtocol>
+ <_TargetId>Folder</_TargetId>
+ </PropertyGroup>
+</Project> \ No newline at end of file
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/Proxy.cs b/store/works/Crupest.SecretTool/Crupest.SecretTool/Proxy.cs
new file mode 100644
index 0000000..d2703ba
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/Proxy.cs
@@ -0,0 +1,76 @@
+namespace Crupest.SecretTool;
+
+public abstract class Proxy(string tag) : IV4ConfigObject, ISingConfigObject
+{
+ public string Tag { get; set; } = tag;
+
+ public abstract V4ConfigJsonObjects.Outbound ToJsonObjectV4();
+ public abstract SingConfigJsonObjects.OutboundBase ToJsonObjectSing();
+
+ object IV4ConfigObject.ToJsonObjectV4()
+ {
+ return ToJsonObjectV4();
+ }
+
+ object ISingConfigObject.ToJsonObjectSing()
+ {
+ return ToJsonObjectSing();
+ }
+}
+
+public class HttpProxy(string host, int port, string tag) : Proxy(tag)
+{
+ public string Host { get; set; } = host;
+ public int Port { get; set; } = port;
+
+ public override SingConfigJsonObjects.OutboundBase ToJsonObjectSing()
+ {
+ throw new NotImplementedException("Http proxy is not supported in sing now.");
+ }
+
+ public override V4ConfigJsonObjects.Outbound ToJsonObjectV4()
+ {
+ return new V4ConfigJsonObjects.Outbound(Tag, "http",
+ new V4ConfigJsonObjects.HttpOutboundSettings([new V4ConfigJsonObjects.HttpOutboundServer(Host, Port, [])]),
+ null
+ );
+ }
+}
+
+
+public class VmessProxy(string host, int port, string userId, string path, string tag) : Proxy(tag)
+{
+ public string Host { get; set; } = host;
+ public int Port { get; set; } = port;
+ public string Path { get; set; } = path;
+ public string UserId { get; set; } = userId;
+
+ public override SingConfigJsonObjects.OutboundBase ToJsonObjectSing()
+ {
+ return new SingConfigJsonObjects.VmessOutbound(Tag, Host, Port, UserId,
+ Transport: new SingConfigJsonObjects.V2rayWebsocketTransport(Path, new Dictionary<string, string> { { "Host", Host } }),
+ Tls: new SingConfigJsonObjects.OutboundTls(true));
+ }
+
+ public override V4ConfigJsonObjects.Outbound ToJsonObjectV4()
+ {
+ return new V4ConfigJsonObjects.Outbound(Tag, "vmess",
+ new V4ConfigJsonObjects.VmessOutboundSettings(
+ [new V4ConfigJsonObjects.VnextServer(Host, Port, [new V4ConfigJsonObjects.VnextServerUser(UserId, 0, "auto", 0)])]),
+ new V4ConfigJsonObjects.WsStreamSettings("ws", "tls", new V4ConfigJsonObjects.WsSettings(Path, new() { ["Host"] = Host }))
+ );
+ }
+
+ public static VmessProxy CreateFromConfigString(string configString, string tag)
+ {
+ var config = new DictionaryConfig(configString, ["host", "port", "userid", "path"]);
+ var portString = config.GetItemCaseInsensitive("port").Value;
+ if (!int.TryParse(portString, out var port) || port <= 0)
+ {
+ throw new FormatException($"Invalid port number: {portString}: not an integer or is a invalid number.");
+ }
+ return new VmessProxy(config.GetItemCaseInsensitive("host").Value, port,
+ config.GetItemCaseInsensitive("userid").Value, config.GetItemCaseInsensitive("path").Value, tag
+ );
+ }
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/ProxyFile.cs b/store/works/Crupest.SecretTool/Crupest.SecretTool/ProxyFile.cs
new file mode 100644
index 0000000..81698a3
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/ProxyFile.cs
@@ -0,0 +1,31 @@
+namespace Crupest.SecretTool;
+
+public class ProxyFile : HostMatchConfigFile
+{
+ public ProxyFile(string path) : base(path, [.. Enum.GetValues<HostMatchKind>()], maxComponentCount: 0)
+ {
+ RoutingRuleMatchers = Config.Items.Select(i => new RoutingRuleMatcher(i.Kind, i.MatchString)).ToList();
+ }
+
+ public List<RoutingRuleMatcher> RoutingRuleMatchers { get; }
+
+ public List<RoutingRuleMatcher> GetChinaRulesByGeoSite(GeoSiteData geoSiteData)
+ {
+ var geoSites = RoutingRuleMatchers.Where(m => m.MatchKind == HostMatchKind.GeoSite).Select(i => i.MatchString).ToList();
+ return geoSiteData.GetEntriesRecursive(geoSites, HostMatchKindExtensions.DomainMatchKinds, ["cn"]).Select(e => e.GetRoutingRuleMatcher()).ToList();
+ }
+
+ public List<RoutingRuleMatcher> GetRulesFlattenGeoSite(GeoSiteData geoSiteData, bool noCn = false)
+ {
+ var geoSites = RoutingRuleMatchers.Where(m => m.MatchKind == HostMatchKind.GeoSite).Select(i => i.MatchString).ToList();
+ var flattenGeoSiteRules = geoSiteData.GetEntriesRecursive(geoSites, HostMatchKindExtensions.DomainMatchKinds)
+ .Where(e => !noCn || !e.Attributes.Contains("cn"))
+ .Select(e => e.GetRoutingRuleMatcher())
+ .ToList();
+ var otherRules = RoutingRuleMatchers.Where(m => m.MatchKind != HostMatchKind.GeoSite).ToList();
+ return [
+ ..flattenGeoSiteRules,
+ ..otherRules
+ ];
+ }
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/Routing.cs b/store/works/Crupest.SecretTool/Crupest.SecretTool/Routing.cs
new file mode 100644
index 0000000..fdf1b93
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/Routing.cs
@@ -0,0 +1,155 @@
+namespace Crupest.SecretTool;
+
+public record RoutingRuleMatcher(HostMatchKind MatchKind, string MatchString)
+{
+ public RoutingRule ToRoutingRule(string OutboundTag) => new(MatchKind, MatchString, OutboundTag);
+}
+
+public record RoutingRule(HostMatchKind MatchKind, string MatchString, string OutboundTag) : IV4ConfigObject
+{
+ public string ToolConfigString => MatchKind switch
+ {
+ HostMatchKind.DomainFull => $"full:{MatchString}",
+ HostMatchKind.DomainSuffix => $"domain:{MatchString}",
+ HostMatchKind.DomainKeyword => MatchString,
+ HostMatchKind.DomainRegex => $"regexp:{MatchString}",
+ HostMatchKind.Ip => MatchString,
+ HostMatchKind.GeoSite => $"geosite:{MatchString}",
+ HostMatchKind.GeoIp => $"geoip:{MatchString}",
+ _ => throw new ArgumentException("Invalid matcher kind.")
+ };
+
+ public string ToolConfigStringSing => MatchKind.IsSupportedInSingRoute() ? MatchString : throw new ArgumentException("Unsupported matcher kind for sing.");
+
+ public static Dictionary<string, List<RoutingRule>> GroupByOutboundTag(List<RoutingRule> rules)
+ => rules.GroupBy(r => r.OutboundTag).Select(g => (g.Key, g.ToList())).ToDictionary();
+
+ public static Dictionary<HostMatchKind, List<RoutingRule>> GroupByMatchKind(List<RoutingRule> rules)
+ => rules.GroupBy(r => r.MatchKind).Select(g => (g.Key, g.ToList())).ToDictionary();
+
+ public static List<List<RoutingRule>> GroupByOutboundTagAndMatcherKind(List<RoutingRule> rules)
+ => GroupByOutboundTag(rules).Values.SelectMany((groupByTag) => GroupByMatchKind(groupByTag).Values).ToList();
+
+ public static SingConfigJsonObjects.RouteRule ListToJsonObjectSing(List<RoutingRule> rules)
+ {
+ if (rules.Count == 0)
+ {
+ throw new ArgumentException("Rule list is empty.");
+ }
+
+ var outboundTag = rules[0].OutboundTag;
+
+ if (rules.Any(r => !r.MatchKind.IsSupportedInSingRoute()))
+ {
+ throw new ArgumentException("Rules must have matcher kinds supported in sing.");
+ }
+
+ if (rules.Any(r => r.OutboundTag != outboundTag))
+ {
+ throw new ArgumentException("Rules must have the same outbound tag.");
+ }
+
+ return new SingConfigJsonObjects.RouteRule(Outbound: outboundTag,
+ Domain: rules.Where(r => r.MatchKind == HostMatchKind.DomainFull).Select(r => r.ToolConfigStringSing).ToList(),
+ DomainSuffix: rules.Where(r => r.MatchKind == HostMatchKind.DomainSuffix).Select(r => r.ToolConfigStringSing).ToList(),
+ DomainKeyword: rules.Where(r => r.MatchKind == HostMatchKind.DomainKeyword).Select(r => r.ToolConfigStringSing).ToList(),
+ DomainRegex: rules.Where(r => r.MatchKind == HostMatchKind.DomainRegex).Select(r => r.ToolConfigStringSing).ToList(),
+ IpCidr: rules.Where(r => r.MatchKind == HostMatchKind.Ip).Select(r => r.ToolConfigStringSing).ToList()
+ );
+ }
+
+ public static V4ConfigJsonObjects.RoutingRule ListToJsonObject(List<RoutingRule> rules)
+ {
+ if (rules.Count == 0)
+ {
+ throw new ArgumentException("Rule list is empty.");
+ }
+
+ var matchKind = rules[0].MatchKind;
+ var outboundTag = rules[0].OutboundTag;
+
+ if (rules.Any(r => r.OutboundTag != outboundTag) || rules.Any(r => r.MatchKind != matchKind))
+ {
+ throw new ArgumentException("Rules must have the same matcher kind and outbound tag.");
+ }
+
+ List<string> toolConfigList = rules.Select(r => r.ToolConfigString).ToList();
+
+ return new V4ConfigJsonObjects.RoutingRule(OutboundTag: outboundTag,
+ Ip: (matchKind is HostMatchKind.Ip or HostMatchKind.GeoIp) ? toolConfigList : null,
+ Domains: (matchKind.IsDomain() || matchKind == HostMatchKind.GeoSite) ? toolConfigList : null
+ );
+ }
+
+ public RoutingRule CloneGeositeWithCnAttribute(string outboundTag)
+ {
+ if (MatchKind is not HostMatchKind.GeoSite)
+ {
+ throw new ArgumentException("Matcher kind must be GeoSite.");
+ }
+
+ return new RoutingRule(HostMatchKind.GeoSite, $"{MatchString}@cn", outboundTag);
+ }
+
+ public RoutingRuleMatcher GetMatcher() => new(MatchKind, MatchString);
+
+ public V4ConfigJsonObjects.RoutingRule ToJsonObjectV4() => ListToJsonObject([this]);
+
+ object IV4ConfigObject.ToJsonObjectV4() => ToJsonObjectV4();
+}
+
+public record Routing(List<RoutingRule> Rules) : IV4ConfigObject, ISingConfigObject
+{
+ public List<RoutingRule> CreateGeositeCnDirectRules()
+ {
+ return Rules.Where(r => r.MatchKind is HostMatchKind.GeoSite)
+ .Select(r => r.CloneGeositeWithCnAttribute("direct")).ToList();
+ }
+
+ public SingConfigJsonObjects.Route ToJsonObjectSing()
+ {
+ List<SingConfigJsonObjects.RouteRule> ruleJsonObjects = [ new SingConfigJsonObjects.RouteRule(Outbound: "dns-out", Protocol: "dns")];
+ ruleJsonObjects.AddRange(RoutingRule.GroupByOutboundTag(Rules).Values.Select(RoutingRule.ListToJsonObjectSing));
+ return new SingConfigJsonObjects.Route(ruleJsonObjects);
+ }
+
+ public V4ConfigJsonObjects.Routing ToJsonObjectV4(string domainStrategy = "IpOnDemand", bool directGeositeCn = true)
+ {
+ List<V4ConfigJsonObjects.RoutingRule> ruleJsonObjects = [];
+
+ if (directGeositeCn)
+ {
+ ruleJsonObjects.Add(RoutingRule.ListToJsonObject(CreateGeositeCnDirectRules()));
+ }
+
+ ruleJsonObjects.AddRange(RoutingRule.GroupByOutboundTagAndMatcherKind(Rules).Select(RoutingRule.ListToJsonObject));
+
+ return new V4ConfigJsonObjects.Routing(ruleJsonObjects, domainStrategy);
+ }
+
+ object IV4ConfigObject.ToJsonObjectV4() => ToJsonObjectV4();
+
+ object ISingConfigObject.ToJsonObjectSing() => ToJsonObjectSing();
+
+ public static Routing FromProxyFile(ProxyFile proxyFile, string outboundTag)
+ {
+ return new Routing(
+ proxyFile.RoutingRuleMatchers.Select(m => m.ToRoutingRule(outboundTag)).ToList());
+ }
+
+ public static Routing FromProxyFileForSing(ProxyFile proxyFile, GeoSiteData geoSiteData, string outboundTag, string? directCnOutboundTag = null)
+ {
+ List<RoutingRule> rules = [];
+
+ if (directCnOutboundTag is not null)
+ {
+ rules.AddRange(proxyFile.GetChinaRulesByGeoSite(geoSiteData).Select(m => m.ToRoutingRule(directCnOutboundTag)).ToList());
+ }
+
+ rules.AddRange(proxyFile.GetRulesFlattenGeoSite(geoSiteData).Where(m => m.MatchKind.IsSupportedInSingRoute()).Select(m => m.ToRoutingRule(outboundTag)).ToList());
+
+ return new Routing(
+ rules
+ );
+ }
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/SingConfigJsonObjects.cs b/store/works/Crupest.SecretTool/Crupest.SecretTool/SingConfigJsonObjects.cs
new file mode 100644
index 0000000..56b5563
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/SingConfigJsonObjects.cs
@@ -0,0 +1,20 @@
+namespace Crupest.SecretTool;
+
+public static class SingConfigJsonObjects
+{
+ public interface IObject;
+
+ public record OutboundTls(bool Enabled);
+ public record V2rayTransportBase(string Type);
+ public record V2rayWebsocketTransport(string Path, Dictionary<string, string>? Headers = null) : V2rayTransportBase("ws");
+ public record OutboundBase(string Tag, string Type) : IObject;
+ public record VmessOutbound(string Tag, string Server, int ServerPort, string Uuid, string Security = "auto",
+ V2rayTransportBase? Transport = null, OutboundTls? Tls = null): OutboundBase(Tag, "vmess");
+
+ public record RouteRule(List<string>? Domain = null, List<string>? DomainSuffix = null, List<string>? DomainKeyword = null,
+ List<string>? DomainRegex = null, List<string>? IpCidr = null, List<string>? SourceIpCidr = null, string? Protocol = null,
+ List<int>? Port = null, List<int>? SourcePort = null, List<string>? PortRange = null, List<string>? SourcePortRange = null,
+ string? Network = null, List<string>? Inbound = null, string? Outbound = null) : IObject;
+
+ public record Route(List<RouteRule> Rules) : IObject;
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/StaticHosts.cs b/store/works/Crupest.SecretTool/Crupest.SecretTool/StaticHosts.cs
new file mode 100644
index 0000000..b112e1c
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/StaticHosts.cs
@@ -0,0 +1,40 @@
+namespace Crupest.SecretTool;
+
+public record StaticHostRule(HostMatchKind MatchKind, string MatchString, List<string> ResolveResult)
+{
+ public string AddressString()
+ {
+ return MatchKind switch
+ {
+ HostMatchKind.DomainFull => MatchString,
+ HostMatchKind.DomainSuffix => $"domain:{MatchString}",
+ HostMatchKind.DomainKeyword => $"keyword:{MatchString}",
+ HostMatchKind.DomainRegex => $"regexp:{MatchString}",
+ _ => throw new ArgumentOutOfRangeException($"Match kind {MatchKind} is not allowed in static host rule."),
+ };
+ }
+
+ public object ResolveResultToJsonObject()
+ {
+ return ResolveResult.Count == 1 ? ResolveResult[0] : ResolveResult;
+ }
+}
+
+public class StaticHosts(List<StaticHostRule> rules) : IV4ConfigObject
+{
+ public List<StaticHostRule> Rules { get; } = rules;
+
+ public Dictionary<string, object> ToJsonObjectV4() =>
+ Rules.ToDictionary(rule => rule.AddressString(), rule => rule.ResolveResultToJsonObject());
+
+ object IV4ConfigObject.ToJsonObjectV4()
+ {
+ return ToJsonObjectV4();
+ }
+
+ public static StaticHosts CreateFromHostMatchConfigString(string configString)
+ {
+ var config = new HostMatchConfig(configString, HostMatchKindExtensions.DomainMatchKinds, minComponentCount: 1);
+ return new StaticHosts(config.Items.Select(i => new StaticHostRule(i.Kind, i.MatchString, [.. i.Values])).ToList());
+ }
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/SurgeConfigGenerator.cs b/store/works/Crupest.SecretTool/Crupest.SecretTool/SurgeConfigGenerator.cs
new file mode 100644
index 0000000..8a57c9f
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/SurgeConfigGenerator.cs
@@ -0,0 +1,56 @@
+namespace Crupest.SecretTool;
+
+public class SurgeConfigGenerator(ProxyFile proxyFile, GeoSiteData geoData)
+{
+ public ProxyFile ProxyFile => proxyFile;
+ public GeoSiteData GeoData => geoData;
+
+ private static string ToSurgeRuleString(HostMatchKind kind, string value)
+ {
+ var ruleType = kind switch
+ {
+ HostMatchKind.DomainFull => "DOMAIN",
+ HostMatchKind.DomainSuffix => "DOMAIN-SUFFIX",
+ HostMatchKind.DomainKeyword => "DOMAIN-KEYWORD",
+ HostMatchKind.DomainRegex => "URL-REGEX",
+ _ => throw new Exception("Unacceptable matcher kind for Surge rule.")
+ };
+
+ return $"{ruleType},{value}";
+ }
+
+ public static string GenerateSurgeRuleSetString(List<RoutingRuleMatcher> rules)
+ {
+ return string.Join('\n', rules.Select(r => ToSurgeRuleString(r.MatchKind, r.MatchString)));
+ }
+
+ public string GenerateChinaRuleSet()
+ {
+ return GenerateSurgeRuleSetString(proxyFile.GetChinaRulesByGeoSite(GeoData));
+ }
+
+ public string GenerateGlobalRuleSet()
+ {
+ return GenerateSurgeRuleSetString(proxyFile.GetRulesFlattenGeoSite(geoData, true));
+ }
+
+ public static void GenerateTo(ProxyFile proxyFile, GeoSiteData geoSiteData, string cnPath, string globalPath, bool silent)
+ {
+ var generator = new SurgeConfigGenerator(proxyFile, geoSiteData);
+ File.WriteAllText(cnPath, generator.GenerateChinaRuleSet());
+ if (!silent) Console.WriteLine($"China rule set written to {cnPath}.");
+ File.WriteAllText(globalPath, generator.GenerateGlobalRuleSet());
+ if (!silent) Console.WriteLine($"Global rule set written to {globalPath}.");
+ }
+
+ public static void GenerateTo(string directory, string cnPath, string globalPath, bool clean, bool silent)
+ {
+ var geoSiteData = GeoDataManager.Instance.GetOrCreateGeoSiteData(clean, silent);
+ var proxyFile = new ProxyFile(Path.Combine(directory, ToolConfig.ProxyConfigFileName));
+ var generator = new SurgeConfigGenerator(proxyFile, geoSiteData);
+ File.WriteAllText(cnPath, generator.GenerateChinaRuleSet());
+ if (!silent) Console.WriteLine($"China rule set written to {cnPath}.");
+ File.WriteAllText(globalPath, generator.GenerateGlobalRuleSet());
+ if (!silent) Console.WriteLine($"Global rule set written to {globalPath}.");
+ }
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/Template.cs b/store/works/Crupest.SecretTool/Crupest.SecretTool/Template.cs
new file mode 100644
index 0000000..1fe91b1
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/Template.cs
@@ -0,0 +1,231 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+
+namespace Crupest.SecretTool;
+
+public class Template
+{
+ private enum ParseState
+ {
+ Text,
+ Dollar,
+ LeftBracket,
+ VariableName,
+ VariableNameFinish,
+ }
+
+ private interface ITemplateNode
+ {
+ string Render(Dictionary<string, string> values);
+ }
+
+ private class TextNode(string text) : ITemplateNode
+ {
+
+ public string Text { get; } = text;
+
+ public string Render(Dictionary<string, string> values)
+ {
+ return Text;
+ }
+ }
+
+ private class VariableNode(string variableName) : ITemplateNode
+ {
+ public string VariableName { get; } = variableName;
+
+ public string Render(Dictionary<string, string> values)
+ {
+ return values.GetValueOrDefault(VariableName) ?? "";
+ }
+ }
+
+ public Template(string templateString)
+ {
+ TemplateString = templateString;
+ Nodes = Parse(templateString);
+ VariableNames = Nodes.OfType<VariableNode>().Select(node => node.VariableName).ToList();
+ }
+
+ private static List<ITemplateNode> Parse(string templateString)
+ {
+ int lineNumber = 1;
+ int columnNumber = 0;
+ List<ITemplateNode> nodes = [];
+ ParseState state = ParseState.Text;
+ StringBuilder stringBuilder = new();
+
+ string GetPosition() => $"line {lineNumber} column{columnNumber}";
+
+ [DoesNotReturn]
+ void ReportInvalidState(string message)
+ {
+ throw new Exception($"Invalid state at {GetPosition()}: {message}");
+ }
+
+ [DoesNotReturn]
+ void ReportInvalidCharacter(char c)
+ {
+ throw new FormatException($"Unexpected '{c}' at {GetPosition()}.");
+ }
+
+ void FinishText()
+ {
+ if (state != ParseState.Text)
+ {
+ ReportInvalidState($"Can't call FinishText here.");
+ }
+
+ if (stringBuilder.Length > 0)
+ {
+ nodes.Add(new TextNode(stringBuilder.ToString()));
+ stringBuilder.Clear();
+ }
+ }
+
+ foreach (var c in templateString)
+ {
+ if (c == '\n')
+ {
+ lineNumber++;
+ columnNumber = 0;
+ }
+
+ columnNumber++;
+
+ switch (c)
+ {
+ case '$':
+ if (state == ParseState.Text)
+ {
+ FinishText();
+ state = ParseState.Dollar;
+ }
+ else if (state == ParseState.Dollar)
+ {
+ if (stringBuilder.Length > 0)
+ {
+ throw new Exception($"Invalid state at {GetPosition()}: when we meet the second '$', text builder should be empty.");
+ }
+ stringBuilder.Append(c);
+ state = ParseState.Text;
+ }
+ else
+ {
+ throw new FormatException($"Unexpected '$' at {GetPosition()}.");
+ }
+ break;
+ case '{':
+ if (state == ParseState.Text)
+ {
+ stringBuilder.Append(c);
+ }
+ else if (state == ParseState.Dollar)
+ {
+ state = ParseState.LeftBracket;
+ }
+ else
+ {
+ throw new Exception($"Unexpected '{{' at {GetPosition()}.");
+ }
+ break;
+ case '}':
+ if (state == ParseState.Text)
+ {
+ stringBuilder.Append(c);
+ state = ParseState.Text;
+ }
+ else if (state == ParseState.VariableName || state == ParseState.VariableNameFinish)
+ {
+ nodes.Add(new VariableNode(stringBuilder.ToString()));
+ stringBuilder.Clear();
+ state = ParseState.Text;
+ }
+ else
+ {
+ ReportInvalidCharacter(c);
+ }
+ break;
+ default:
+ if (state == ParseState.Dollar)
+ {
+ ReportInvalidCharacter(c);
+ }
+
+ if (char.IsWhiteSpace(c))
+ {
+ if (state == ParseState.LeftBracket || state == ParseState.VariableNameFinish)
+ {
+ continue;
+ }
+ else if (state == ParseState.Text)
+ {
+ stringBuilder.Append(c);
+ }
+ else if (state == ParseState.VariableName)
+ {
+ state = ParseState.VariableNameFinish;
+ }
+ else
+ {
+ ReportInvalidCharacter(c);
+ }
+ }
+ else
+ {
+ if (state == ParseState.Text)
+ {
+ stringBuilder.Append(c);
+ }
+ else if (state == ParseState.LeftBracket || state == ParseState.VariableName)
+ {
+ stringBuilder.Append(c);
+ state = ParseState.VariableName;
+ }
+ else
+ {
+ ReportInvalidCharacter(c);
+ }
+ }
+ break;
+ }
+ }
+
+ if (state == ParseState.Text)
+ {
+ FinishText();
+ }
+ else
+ {
+ throw new FormatException("Unexpected end of template string.");
+ }
+
+ return nodes;
+ }
+
+ public string TemplateString { get; }
+ private List<ITemplateNode> Nodes { get; set; }
+ public List<string> VariableNames { get; }
+
+ public string Generate(Dictionary<string, string> values, bool allowMissingVariable = false)
+ {
+ StringBuilder stringBuilder = new();
+ foreach (var node in Nodes)
+ {
+ if (node is TextNode textNode)
+ {
+ stringBuilder.Append(textNode.Text);
+ }
+ else if (node is VariableNode variableNode)
+ {
+ var hasValue = values.TryGetValue(variableNode.VariableName, out var value);
+ if (!hasValue && !allowMissingVariable)
+ {
+ throw new Exception($"Variable '{variableNode.VariableName}' is not set.");
+ }
+ stringBuilder.Append(hasValue ? value : string.Empty);
+ }
+ }
+ return stringBuilder.ToString();
+ }
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/ToolConfig.cs b/store/works/Crupest.SecretTool/Crupest.SecretTool/ToolConfig.cs
new file mode 100644
index 0000000..809fba1
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/ToolConfig.cs
@@ -0,0 +1,271 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Crupest.SecretTool;
+
+public interface IV4ConfigObject
+{
+ object ToJsonObjectV4();
+}
+
+public interface ISingConfigObject
+{
+ object ToJsonObjectSing();
+}
+
+public class ToolConfigBase(Template template, List<Proxy> proxies, Routing router)
+{
+ protected class JsonInterfaceConverter<Interface> : JsonConverter<Interface>
+ {
+ public override Interface Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options)
+ {
+ throw new NotImplementedException();
+ }
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ Interface value,
+ JsonSerializerOptions options)
+ {
+ JsonSerializer.Serialize(writer, value, typeof(object), options);
+ }
+ }
+
+ public const string VmessConfigFileName = "vmess.txt";
+ public const string ProxyConfigFileName = "proxy.txt";
+
+ public Template Template { get; set; } = template;
+ public List<Proxy> Proxies { get; set; } = proxies;
+ public Routing Routing { get; set; } = router;
+}
+
+public class ToolConfig(Template template, List<Proxy> proxies, Routing router, StaticHosts? hosts) : ToolConfigBase(template, proxies, router)
+{
+ public const string ConfigTemplateFileName = "config.json.template";
+ public const string HostsConfigFileName = "hosts.txt";
+
+ public static List<string> RequiredConfigFileNames { get; } = [ConfigTemplateFileName, VmessConfigFileName, ProxyConfigFileName];
+ public static List<string> ConfigFileNames { get; } = [ConfigTemplateFileName, VmessConfigFileName, ProxyConfigFileName, HostsConfigFileName];
+
+ private const string ProxyAnchor = "PROXY_ANCHOR";
+ private const string RoutingAnchor = "ROUTING_ANCHOR";
+ private const string HostsAnchor = "HOSTS_ANCHOR";
+
+ public const string AddCnAttributeToGeositeEnvironmentVariable = "CRUPEST_V2RAY_GEOSITE_USE_CN";
+
+ private static bool UseCnGeoSite => Environment.GetEnvironmentVariable(AddCnAttributeToGeositeEnvironmentVariable) switch
+ {
+ "0" or "false" or "off" or "disable" => false,
+ _ => true
+ };
+
+ public StaticHosts Hosts { get; set; } = hosts is null ? new StaticHosts([]) : hosts;
+
+ public string ToJsonStringV4(string domainStrategy = "IpOnDemand", bool directGeositeCn = true, bool pretty = true)
+ {
+ var jsonOptions = new JsonSerializerOptions(new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ });
+ // TODO: Make interface converter generic.
+ jsonOptions.Converters.Add(new JsonInterfaceConverter<V4ConfigJsonObjects.IOutboundSettings>());
+ jsonOptions.Converters.Add(new JsonInterfaceConverter<V4ConfigJsonObjects.IOutboundStreamSettings>());
+
+ var templateValues = new Dictionary<string, string>
+ {
+ [ProxyAnchor] = string.Join(',', Proxies.Select(p => JsonSerializer.Serialize(p.ToJsonObjectV4(), jsonOptions))),
+ [RoutingAnchor] = JsonSerializer.Serialize(Routing.ToJsonObjectV4(domainStrategy, directGeositeCn), jsonOptions),
+ [HostsAnchor] = JsonSerializer.Serialize(Hosts.ToJsonObjectV4(), jsonOptions),
+ };
+
+ var configString = Template.Generate(templateValues);
+
+ if (pretty)
+ {
+ var jsonOptionsPretty = new JsonSerializerOptions(jsonOptions)
+ {
+ WriteIndented = true,
+ };
+ return JsonSerializer.Serialize(JsonSerializer.Deserialize<object>(configString, jsonOptionsPretty), jsonOptionsPretty);
+ }
+ else
+ {
+ return configString;
+ }
+ }
+
+ public static ToolConfig FromFiles(string templatePath, string vmessPath, string proxyPath, string? hostsPath)
+ {
+ foreach (var path in new List<string>([templatePath, vmessPath, proxyPath]))
+ {
+ if (!File.Exists(path))
+ {
+ throw new FileNotFoundException($"Required config file not found: {path}.");
+ }
+ }
+
+ ProxyFile proxyFile = new(proxyPath);
+ string templateString, vmessString;
+ string? hostsString;
+
+ string file = "";
+ try
+ {
+ file = templatePath;
+ templateString = File.ReadAllText(templatePath);
+ file = vmessPath;
+ vmessString = File.ReadAllText(vmessPath);
+ hostsString = hostsPath is not null ? File.ReadAllText(hostsPath) : null;
+ }
+ catch (Exception e)
+ {
+ throw new Exception($"Error reading config file {file}.", e);
+ }
+
+ try
+ {
+ file = templatePath;
+ var template = new Template(templateString);
+ file = vmessPath;
+ var vmess = VmessProxy.CreateFromConfigString(vmessString, "proxy");
+ file = proxyPath;
+ var routing = Routing.FromProxyFile(proxyFile, "proxy");
+ file = hostsPath ?? "";
+ var hosts = hostsString is not null ? StaticHosts.CreateFromHostMatchConfigString(hostsString) : null;
+ return new ToolConfig(template, [vmess], routing, hosts);
+ }
+ catch (Exception e)
+ {
+ throw new Exception($"Error parsing config file {file}.", e);
+ }
+ }
+
+ public static ToolConfig FromDirectory(string directory)
+ {
+ return FromFiles(
+ Path.Join(directory, ConfigTemplateFileName),
+ Path.Join(directory, VmessConfigFileName),
+ Path.Join(directory, ProxyConfigFileName),
+ Path.Join(directory, HostsConfigFileName)
+ );
+ }
+
+ public static void FromDirectoryAndWriteToFile(string directory, string outputPath)
+ {
+ var config = FromDirectory(directory);
+ File.WriteAllText(outputPath, config.ToJsonStringV4());
+ }
+}
+
+public class SingToolConfig(Template template, List<Proxy> proxies, Routing router, string inboundsString) : ToolConfigBase(template, proxies, router)
+{
+
+ public const string ConfigTemplateFileName = "sing-config.json.template";
+ public const string ConfigInboundsPcFileName = "sing-inbounds-pc.json";
+ public const string ConfigInboundsMobileFileName = "sing-inbounds-mobile.json";
+
+ public static List<string> RequiredConfigFileNames { get; } = [ConfigTemplateFileName, VmessConfigFileName, ProxyConfigFileName, ConfigInboundsMobileFileName, ConfigInboundsPcFileName];
+
+ private const string ProxyAnchor = "PROXY_ANCHOR";
+ private const string RouteAnchor = "ROUTE_ANCHOR";
+ private const string InboundsAnchor = "INBOUNDS_ANCHOR";
+
+ public string InboundsString { get; } = inboundsString;
+
+ public string ToSingConfigString(bool pretty = true)
+ {
+ var jsonOptions = new JsonSerializerOptions(new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ });
+ // TODO: Make interface converter generic.
+ jsonOptions.Converters.Add(new JsonInterfaceConverter<SingConfigJsonObjects.OutboundBase>());
+ jsonOptions.Converters.Add(new JsonInterfaceConverter<SingConfigJsonObjects.V2rayTransportBase>());
+
+ var templateValues = new Dictionary<string, string>
+ {
+ [ProxyAnchor] = string.Join(',', Proxies.Select(p => JsonSerializer.Serialize(p.ToJsonObjectSing(), jsonOptions))),
+ [RouteAnchor] = JsonSerializer.Serialize(Routing.ToJsonObjectSing(), jsonOptions),
+ [InboundsAnchor] = InboundsString
+ };
+
+ var configString = Template.Generate(templateValues);
+
+ if (pretty)
+ {
+ var jsonOptionsPretty = new JsonSerializerOptions(jsonOptions)
+ {
+ WriteIndented = true,
+ };
+ return JsonSerializer.Serialize(JsonSerializer.Deserialize<object>(configString, jsonOptionsPretty), jsonOptionsPretty);
+ }
+ else
+ {
+ return configString;
+ }
+ }
+
+ public static SingToolConfig FromFiles(string templatePath, string vmessPath, string proxyPath, string inboundsPath, bool clean, bool silent)
+ {
+ foreach (var path in new List<string>([templatePath, vmessPath, proxyPath, inboundsPath]))
+ {
+ if (!File.Exists(path))
+ {
+ throw new FileNotFoundException($"Required config file not found: {path}.");
+ }
+ }
+
+ var geoSiteData = GeoDataManager.Instance.GetOrCreateGeoSiteData(clean, silent);
+
+ ProxyFile proxyFile = new(proxyPath);
+ string templateString, vmessString, inboundsString;
+
+ string file = "";
+ try
+ {
+ file = templatePath;
+ templateString = File.ReadAllText(templatePath);
+ file = vmessPath;
+ vmessString = File.ReadAllText(vmessPath);
+ file = inboundsPath;
+ inboundsString = File.ReadAllText(inboundsPath);
+ }
+ catch (Exception e)
+ {
+ throw new Exception($"Error reading config file {file}.", e);
+ }
+
+ try
+ {
+ file = templatePath;
+ var template = new Template(templateString);
+ file = vmessPath;
+ var vmess = VmessProxy.CreateFromConfigString(vmessString, "proxy-out");
+ file = proxyPath;
+ var routing = Routing.FromProxyFileForSing(proxyFile, geoSiteData, "proxy-out", "direct-out");
+ return new SingToolConfig(template, [vmess], routing, inboundsString);
+ }
+ catch (Exception e)
+ {
+ throw new Exception($"Error parsing config file {file}.", e);
+ }
+ }
+
+ public static SingToolConfig FromDirectory(string directory, bool isMobile, bool clean, bool silent)
+ {
+ return FromFiles(
+ Path.Join(directory, ConfigTemplateFileName),
+ Path.Join(directory, VmessConfigFileName),
+ Path.Join(directory, ProxyConfigFileName),
+ isMobile ? Path.Join(directory, ConfigInboundsMobileFileName) : Path.Join(directory, ConfigInboundsPcFileName),
+ clean, silent
+ );
+ }
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/V4ConfigJsonObjects.cs b/store/works/Crupest.SecretTool/Crupest.SecretTool/V4ConfigJsonObjects.cs
new file mode 100644
index 0000000..3e81dbb
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/V4ConfigJsonObjects.cs
@@ -0,0 +1,25 @@
+namespace Crupest.SecretTool;
+
+public static class V4ConfigJsonObjects
+{
+ public interface IObject;
+ public interface IOutboundSettings : IObject;
+ public interface IOutboundStreamSettings : IObject;
+
+ public record WsSettings(string Path, Dictionary<string, string> Headers) : IObject;
+ public record WsStreamSettings(string Network, string Security, WsSettings WsSettings) : IOutboundStreamSettings;
+ public record VnextServerUser(string Id, int AlterId, string Security, int Level) : IObject;
+ public record VnextServer(string Address, int Port, List<VnextServerUser> Users) : IObject;
+ public record VmessOutboundSettings(List<VnextServer> Vnext) : IOutboundSettings;
+ public record HttpOutboundUser(string User, string Pass) : IObject;
+ public record HttpOutboundServer(string Address, int Port, List<HttpOutboundUser> Users) : IObject;
+ public record HttpOutboundSettings(List<HttpOutboundServer> Servers) : IOutboundSettings;
+ public record Outbound(string Tag, string Protocol, IOutboundSettings Settings,
+ IOutboundStreamSettings? StreamSettings) : IObject;
+
+ public record RoutingRule(string DomainMatcher = "mph", string Type = "field", List<string>? Domains = null, List<string>? Ip = null,
+ string? Port = null, string? SourcePort = null, string? Network = null, List<string>? Source = null,
+ List<string>? User = null, List<string>? InboundTag = null, List<string>? Protocol = null, string? Attrs = null,
+ string? OutboundTag = null, string? BalancerTag = null) : IObject;
+ public record Routing(List<RoutingRule> Rules, string DomainStrategy = "IpOnDemand", string DomainMatcher = "mph") : IObject;
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/V5ConfigJsonObjects.cs b/store/works/Crupest.SecretTool/Crupest.SecretTool/V5ConfigJsonObjects.cs
new file mode 100644
index 0000000..a50e9be
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/V5ConfigJsonObjects.cs
@@ -0,0 +1,31 @@
+namespace Crupest.SecretTool;
+
+public static class V5ConfigJsonObjects
+{
+ public record OutboundObject(string Protocol, object Settings, string Tag, object? StreamSettings)
+ {
+ public static OutboundObject VmessViaWs(string tag, string address, int port, string uuid, string path)
+ {
+ return new OutboundObject("vmess", new VmessSettings(address, port, uuid), tag, StreamSettingsObject.Ws(path));
+ }
+
+ public static OutboundObject Http(string tag, string address, int port)
+ {
+ return new OutboundObject("http", new HttpSettingsObject(address, port), tag, null);
+ }
+ }
+
+ public record WsSettingsObject(string Path, Dictionary<string, string> Headers);
+
+ public record StreamSettingsObject(string Transport, object TransportSettings, string Security, object SecuritySettings)
+ {
+ public static StreamSettingsObject Ws(string path)
+ {
+ return new StreamSettingsObject("ws", new WsSettingsObject(path, new()), "tls", new());
+ }
+ }
+
+ public record VmessSettings(string Address, int Port, string Uuid);
+
+ public record HttpSettingsObject(string Address, int Port);
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/config.json.template b/store/works/Crupest.SecretTool/Crupest.SecretTool/config.json.template
new file mode 100644
index 0000000..424e996
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/config.json.template
@@ -0,0 +1,63 @@
+{
+ "log": {
+ "loglevel": "warning"
+ },
+ "inbounds": [
+ {
+ "port": 2081,
+ "listen": "127.0.0.1",
+ "tag": "socks-inbound",
+ "protocol": "socks",
+ "settings": {
+ "auth": "noauth"
+ }
+ },
+ {
+ "port": 2080,
+ "listen": "127.0.0.1",
+ "tag": "http-inbound",
+ "protocol": "http",
+ "settings": {
+ "auth": "noauth"
+ }
+ }
+ ],
+ "outbounds": [
+ {
+ "protocol": "freedom",
+ "settings": {},
+ "tag": "direct"
+ },
+ {
+ "protocol": "blackhole",
+ "settings": {},
+ "tag": "blocked"
+ },
+ ${PROXY_ANCHOR}
+ ],
+ "routing": ${ROUTING_ANCHOR},
+ "dns": {
+ "hosts": ${HOSTS_ANCHOR},
+ "servers": [
+ "https://doh.pub/dns-query",
+ "1.1.1.1",
+ "8.8.8.8",
+ "localhost"
+ ]
+ },
+ "policy": {
+ "levels": {
+ "0": {
+ "uplinkOnly": 0,
+ "downlinkOnly": 0
+ }
+ },
+ "system": {
+ "statsInboundUplink": false,
+ "statsInboundDownlink": false,
+ "statsOutboundUplink": false,
+ "statsOutboundDownlink": false
+ }
+ },
+ "other": {}
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/config.v5.json.template b/store/works/Crupest.SecretTool/Crupest.SecretTool/config.v5.json.template
new file mode 100644
index 0000000..01ccf7a
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/config.v5.json.template
@@ -0,0 +1,55 @@
+{
+ "log": {
+ "access": {
+ "type": "Console",
+ "level": "Info"
+ }
+ },
+ "dns": {
+ "nameServer": [{
+ "address": "https://doh.pub/dns-query"
+ }, {
+ "address": "1.1.1.1"
+ }, {
+ "address": "8.8.8.8"
+ }, {
+ "address": "localhost"
+ }],
+ "staticHosts": ${HOSTS_ANCHOR}
+ },
+ "inbounds": [{
+ {
+ "protocol": "socks",
+ "port": 2081,
+ "listen": "127.0.0.1",
+ "tag": "socks-inbound",
+ "settings": {
+ "auth": "noauth"
+ }
+ },
+ {
+ "protocol": "http",
+ "port": 2080,
+ "listen": "127.0.0.1",
+ "tag": "http-inbound",
+ "settings": {
+ "auth": "noauth"
+ }
+ }
+ }],
+ "outbounds": [
+ {
+ "protocol": "freedom",
+ "settings": {},
+ "tag": "direct"
+ },
+ {
+ "protocol": "blackhole",
+ "settings": {},
+ "tag": "blocked"
+ },
+ ${PROXY_ANCHOR}
+ ],
+ "router": ${ROUTER_ANCHOR}
+}
+
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/hosts.txt b/store/works/Crupest.SecretTool/Crupest.SecretTool/hosts.txt
new file mode 100644
index 0000000..88d5015
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/hosts.txt
@@ -0,0 +1,2 @@
+cdn.jsdelivr.net cdn.jsdelivr.net.cdn.cloudflare.net
+
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/proxy.txt b/store/works/Crupest.SecretTool/Crupest.SecretTool/proxy.txt
new file mode 100644
index 0000000..39800f9
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/proxy.txt
@@ -0,0 +1,50 @@
+GeoSite microsoft
+GeoSite google
+GeoSite youtube
+GeoSite x
+GeoSite facebook
+GeoSite discord
+GeoSite reddit
+GeoSite twitch
+GeoSite quora
+GeoSite telegram
+GeoSite imgur
+GeoSite stackexchange
+GeoSite medium
+
+GeoSite duckduckgo
+GeoSite wikimedia
+GeoSite gitbook
+GeoSite github
+GeoSite gitlab
+GeoSite sourceforge
+GeoSite creativecommons
+GeoSite archive
+GeoSite matrix
+GeoSite tor
+
+GeoSite python
+GeoSite ruby
+GeoSite rust
+GeoSite nodejs
+GeoSite npmjs
+GeoSite qt
+GeoSite docker
+GeoSite v2ray
+GeoSite homebrew
+
+GeoSite azure
+GeoSite akamai
+GeoSite aws
+GeoSite jsdelivr
+GeoSite fastly
+GeoSite heroku
+GeoSite bootstrap
+GeoSite vercel
+
+GeoSite ieee
+GeoSite sci-hub
+GeoSite libgen
+GeoSite z-library
+
+sagernet.org
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/sing-config.json.template b/store/works/Crupest.SecretTool/Crupest.SecretTool/sing-config.json.template
new file mode 100644
index 0000000..d7e55a0
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/sing-config.json.template
@@ -0,0 +1,45 @@
+{
+ "log": {
+ "disabled": false,
+ "level": "info",
+ "timestamp": true
+ },
+ "dns": {
+ "servers": [
+ {
+ "tag": "ali-doh",
+ "address": "https://dns.alidns.com/dns-query",
+ "address_resolver": "ali"
+ },
+ {
+ "tag": "ali",
+ "address": "223.5.5.5"
+ },
+ {
+ "tag": "cloudflare",
+ "address": "1.1.1.1"
+ },
+ {
+ "tag": "google",
+ "address": "8.8.8.8"
+ }
+ ]
+ },
+ "inbounds": ${INBOUNDS_ANCHOR},
+ "outbounds": [
+ {
+ "type": "direct",
+ "tag": "direct-out"
+ },
+ {
+ "type": "block",
+ "tag": "block-out"
+ },
+ {
+ "tag": "dns-out",
+ "type": "dns"
+ },
+ ${PROXY_ANCHOR}
+ ],
+ "route": ${ROUTE_ANCHOR}
+}
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/sing-inbounds-mobile.json b/store/works/Crupest.SecretTool/Crupest.SecretTool/sing-inbounds-mobile.json
new file mode 100644
index 0000000..5038c40
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/sing-inbounds-mobile.json
@@ -0,0 +1,11 @@
+[
+ {
+ "tag": "tun-in",
+ "type": "tun",
+ "auto_route": true,
+ "strict_route": true,
+ "address": [ "172.23.0.1/30", "fdfe:acbd:9876::1/126"],
+ "sniff": true,
+ "sniff_override_destination": true
+ }
+]
diff --git a/store/works/Crupest.SecretTool/Crupest.SecretTool/sing-inbounds-pc.json b/store/works/Crupest.SecretTool/Crupest.SecretTool/sing-inbounds-pc.json
new file mode 100644
index 0000000..956d751
--- /dev/null
+++ b/store/works/Crupest.SecretTool/Crupest.SecretTool/sing-inbounds-pc.json
@@ -0,0 +1,14 @@
+[
+ {
+ "tag": "http-in",
+ "type": "http",
+ "listen": "127.0.0.1",
+ "listen_port": 3080
+ },
+ {
+ "tag": "socks-in",
+ "type": "socks",
+ "listen": "127.0.0.1",
+ "listen_port": 3081
+ }
+] \ No newline at end of file
diff --git a/store/works/Crupest.SecretTool/build-secret.bash b/store/works/Crupest.SecretTool/build-secret.bash
new file mode 100755
index 0000000..8878049
--- /dev/null
+++ b/store/works/Crupest.SecretTool/build-secret.bash
@@ -0,0 +1,41 @@
+#! /usr/bin/env bash
+
+set -e
+
+function print_argument_error_message_and_exit() {
+ argument_error_message="You must specify exactly one argument, the build target (win-x64 | linux-x64 | osx-x64)."
+ echo "$argument_error_message"
+ exit 1
+}
+
+
+
+if [[ $# != 1 ]]; then
+ print_argument_error_message_and_exit
+fi
+
+case "$1" in
+ win-x64 | linux-x64 | osx-x64)
+ echo "Build target: $1"
+ ;;
+ *)
+ print_argument_error_message_and_exit
+ ;;
+esac
+
+secret_dir=$(realpath "$(dirname "$0")")
+
+echo "Secret dir: ${secret_dir}"
+
+echo "Check dotnet..."
+dotnet --version
+
+echo "Enter \"secret\" dir..."
+pushd "$secret_dir"
+
+echo "Begin to build..."
+dotnet publish Crupest.SecretTool -c Release -o "$secret_dir/publish" --sc -r "$1"
+
+popd
+
+echo "Finish!"
diff --git a/store/works/Crupest.SecretTool/build-secret.ps1 b/store/works/Crupest.SecretTool/build-secret.ps1
new file mode 100644
index 0000000..8aa7987
--- /dev/null
+++ b/store/works/Crupest.SecretTool/build-secret.ps1
@@ -0,0 +1,25 @@
+if ($args.Count -ne 1 || $args[0] -notmatch "^win-x64|linux-x64|osx-x64$")
+{
+ Write-Error "You must specify exactly one argument, the build target (win-x64 | linux-x64 | osx-x64)."
+ exit 1
+}
+
+Write-Output "Secret dir: $PSScriptRoot"
+
+Write-Output "Check dotnet..."
+dotnet --version
+if ($LASTEXITCODE -ne 0)
+{
+ Write-Error "dotnet not found."
+ exit 2
+}
+
+Write-Output "Enter `"secret`" dir..."
+Push-Location $PSScriptRoot
+
+Write-Output "Begin to build..."
+dotnet publish Crupest.SecretTool -c Release -o "$secret_dir/publish" --sc -r $args[0]
+
+Pop-Location
+
+Write-Host "Finish!" -ForegroundColor Green
diff --git a/store/works/Crupest.SecretTool/tools/cru-proxy-edit b/store/works/Crupest.SecretTool/tools/cru-proxy-edit
new file mode 100755
index 0000000..51a33e1
--- /dev/null
+++ b/store/works/Crupest.SecretTool/tools/cru-proxy-edit
@@ -0,0 +1,12 @@
+#! /usr/bin/env bash
+
+set -e
+
+p="$HOME/codes/crupest/tools/Crupest.SecretTool/publish/proxy.txt"
+
+if [[ ! -f "$p" ]]; then
+ echo "File $p does not exist!" >&2
+ exit 1
+fi
+
+exec vim "$p"
diff --git a/store/works/Crupest.SecretTool/tools/cru-proxy-log b/store/works/Crupest.SecretTool/tools/cru-proxy-log
new file mode 100755
index 0000000..6ec6ee1
--- /dev/null
+++ b/store/works/Crupest.SecretTool/tools/cru-proxy-log
@@ -0,0 +1,13 @@
+#! /usr/bin/env bash
+
+set -e
+
+if [[ -e /proc ]]; then
+ # I don't believe your system is Linux but there is no /proc.
+ exec journalctl --user -u crupest-secret-tool "$@"
+elif [[ "$(uname)" == "Darwin" ]]; then
+ exec less "$HOME/.local/state/Crupest.SecretTool/log"
+else
+ echo "Not supported on systems other than macOS and Linux now." >&2
+ exit 1
+fi
diff --git a/store/works/Crupest.SecretTool/tools/crupest-secret-tool.service b/store/works/Crupest.SecretTool/tools/crupest-secret-tool.service
new file mode 100644
index 0000000..df6d172
--- /dev/null
+++ b/store/works/Crupest.SecretTool/tools/crupest-secret-tool.service
@@ -0,0 +1,8 @@
+[Unit]
+Description=crupest v2ray service
+
+[Service]
+ExecStart=%h/.local/bin/Crupest.SecretTool
+
+[Install]
+WantedBy=default.target
diff --git a/store/works/Crupest.SecretTool/tools/crupest-secret-tool.xml b/store/works/Crupest.SecretTool/tools/crupest-secret-tool.xml
new file mode 100644
index 0000000..9b85f13
--- /dev/null
+++ b/store/works/Crupest.SecretTool/tools/crupest-secret-tool.xml
@@ -0,0 +1,49 @@
+<!--
+ MIT License
+
+ Copyright (c) 2008-2020 Kohsuke Kawaguchi, Sun Microsystems, Inc., CloudBees,
+ Inc., Oleg Nenashev and other contributors
+
+ 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.
+-->
+
+<!--
+ This is a sample configuration of the Windows Service Wrapper.
+ This configuration file should be placed near the WinSW executable, the name should be the same.
+ E.g. for myapp.exe the configuration file name should be myapp.xml
+
+ You can find more information about configuration options here:
+https://github.com/kohsuke/winsw/blob/master/doc/xmlConfigFile.md
+-->
+<service>
+ <id>crupest-secret-tool</id>
+ <name>Crupest Secret Tool</name>
+ <description>Crupest Secret Tool (powered by WinSW)</description>
+
+ <!-- Path to the executable, which should be started -->
+ <executable>%BASE%\Crupest.SecretTool.exe</executable>
+
+ <onfailure action="restart" delay="10 sec" />
+ <onfailure action="restart" delay="30 sec" />
+ <onfailure action="restart" delay="50 sec" />
+
+ <workingdirectory>%BASE%</workingdirectory>
+
+ <startmode>Automatic</startmode>
+</service> \ No newline at end of file
diff --git a/store/works/Crupest.SecretTool/tools/life.crupest.secret-tool.plist b/store/works/Crupest.SecretTool/tools/life.crupest.secret-tool.plist
new file mode 100644
index 0000000..bdfe490
--- /dev/null
+++ b/store/works/Crupest.SecretTool/tools/life.crupest.secret-tool.plist
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key>
+ <string>life.crupest.secret-tool</string>
+ <key>ProgramArguments</key>
+ <array>
+ <string>/Users/crupest/.local/bin/Crupest.SecretTool</string>
+ </array>
+ <key>KeepAlive</key>
+ <true/>
+ <key>StandardOutPath</key>
+ <string>/Users/crupest/.local/state/Crupest.SecretTool/log</string>
+ <key>StandardErrorPath</key>
+ <string>/Users/crupest/.local/state/Crupest.SecretTool/error</string>
+</dict>
+</plist>
diff --git a/store/works/README.md b/store/works/README.md
new file mode 100644
index 0000000..b280870
--- /dev/null
+++ b/store/works/README.md
@@ -0,0 +1,9 @@
+This directory contains some of my works that I have created. I moved them from individual repositories to here for easier management. Some works are stopped for its temporary purpose. Here are some of the descriptions of them.
+
+- `life`: Something I created in university time. Some for lecture tasks, some are small toys to solve some interesting problems. Maybe more will be added in future.
+
+- `solutions`: Answers to leetcode and acwing algorithms questions. Stopped now for a well-known reason. Maybe more will be added in future, too.
+
+- `teapot`: Answer to the pre-interview test for my first job. Dropped now definitely.
+
+- `ProxyChanger`: Written a long time ago to solve the proxy switching problem in Firefox for a also well-known problem.