├── _images └── screenshot.png ├── frontend ├── src │ ├── langs │ │ ├── index.js │ │ └── en-us.json │ ├── assets │ │ ├── images │ │ │ ├── icon.png │ │ │ ├── logo.ico │ │ │ └── logo.png │ │ └── fonts │ │ │ ├── nunito-v16-latin-regular.woff2 │ │ │ └── OFL.txt │ ├── utils │ │ ├── platform.js │ │ ├── i18n.js │ │ ├── extra_theme.js │ │ ├── render.js │ │ ├── version.js │ │ ├── rgb.js │ │ └── theme.js │ ├── components │ │ ├── icons │ │ │ ├── WindowMin.vue │ │ │ ├── WindowMax.vue │ │ │ ├── WindowClose.vue │ │ │ └── WindowRestore.vue │ │ ├── common │ │ │ ├── OsIcon.vue │ │ │ ├── IconButton.vue │ │ │ ├── ResizeableWrapper.vue │ │ │ └── ToolbarControlWidget.vue │ │ ├── sidebar │ │ │ └── BrowserPane.vue │ │ └── content │ │ │ └── ContentPane.vue │ ├── styles │ │ ├── content.scss │ │ └── style.scss │ ├── style.css │ ├── main.js │ ├── App.vue │ ├── stores │ │ ├── tailscale.js │ │ └── preferences.js │ └── AppContent.vue ├── .prettierrc ├── index.html ├── README.md ├── package.json └── vite.config.js ├── backend ├── services │ ├── trayicons │ │ ├── active.png │ │ └── inactive.png │ ├── tray_service.go │ ├── system_service.go │ ├── preferences_service.go │ └── tailscale_service.go ├── types │ ├── js_resp.go │ ├── view_type.go │ ├── ts_types.go │ └── preferences.go ├── consts │ └── default_config.go ├── utils │ ├── constraints.go │ ├── string │ │ ├── key_convert.go │ │ ├── common.go │ │ └── any_convert.go │ ├── ts │ │ └── status.go │ └── slice │ │ └── slice_util.go └── storage │ ├── local_storage.go │ └── preferences.go ├── .gitignore ├── cattail.desktop ├── Makefile ├── wails.json ├── README.md ├── main.go ├── go.mod └── go.sum /_images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nerdyslacker/cattail/HEAD/_images/screenshot.png -------------------------------------------------------------------------------- /frontend/src/langs/index.js: -------------------------------------------------------------------------------- 1 | import en from './en-us' 2 | 3 | export const lang = { 4 | en, 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nerdyslacker/cattail/HEAD/frontend/src/assets/images/icon.png -------------------------------------------------------------------------------- /frontend/src/assets/images/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nerdyslacker/cattail/HEAD/frontend/src/assets/images/logo.ico -------------------------------------------------------------------------------- /frontend/src/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nerdyslacker/cattail/HEAD/frontend/src/assets/images/logo.png -------------------------------------------------------------------------------- /backend/services/trayicons/active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nerdyslacker/cattail/HEAD/backend/services/trayicons/active.png -------------------------------------------------------------------------------- /backend/services/trayicons/inactive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nerdyslacker/cattail/HEAD/backend/services/trayicons/inactive.png -------------------------------------------------------------------------------- /frontend/src/assets/fonts/nunito-v16-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nerdyslacker/cattail/HEAD/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 -------------------------------------------------------------------------------- /backend/types/js_resp.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type JSResp struct { 4 | Success bool `json:"success"` 5 | Msg string `json:"msg"` 6 | Data any `json:"data,omitempty"` 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | build/bin 3 | node_modules/ 4 | jspm_packages/ 5 | .env 6 | .env.development.local 7 | .env.test.local 8 | .env.production.local 9 | .env.local 10 | .npm 11 | wailsjs 12 | dist 13 | frontend/package.json.md5 -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 4, 4 | "singleQuote": true, 5 | "semi": false, 6 | "bracketSameLine": true, 7 | "endOfLine": "auto", 8 | "htmlWhitespaceSensitivity": "ignore" 9 | } 10 | -------------------------------------------------------------------------------- /cattail.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Cattail 3 | Comment=An unofficial Tailscale client. 4 | Exec=sh -c "$HOME/.local/bin/cattail" 5 | Icon=com.cattail 6 | Type=Application 7 | Categories=Network; 8 | SingleMainWindow=true 9 | X-GNOME-UsesNotifications=true 10 | -------------------------------------------------------------------------------- /frontend/src/utils/platform.js: -------------------------------------------------------------------------------- 1 | import { Environment } from 'wailsjs/runtime/runtime.js' 2 | 3 | let os = '' 4 | 5 | export async function loadEnvironment() { 6 | const env = await Environment() 7 | os = env.platform 8 | } 9 | 10 | export function isMacOS() { 11 | return os === 'darwin' 12 | } 13 | -------------------------------------------------------------------------------- /backend/consts/default_config.go: -------------------------------------------------------------------------------- 1 | package consts 2 | 3 | const DEFAULT_FONT_SIZE = 14 4 | const DEFAULT_ASIDE_WIDTH = 300 5 | const DEFAULT_WINDOW_WIDTH = 1024 6 | const DEFAULT_WINDOW_HEIGHT = 768 7 | const MIN_WINDOW_WIDTH = 960 8 | const MIN_WINDOW_HEIGHT = 640 9 | const DEFAULT_LOAD_SIZE = 10000 10 | const DEFAULT_SCAN_SIZE = 3000 11 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Cattail 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/utils/i18n.js: -------------------------------------------------------------------------------- 1 | import { createI18n } from 'vue-i18n' 2 | import { lang } from '@/langs/index.js' 3 | 4 | export const i18n = createI18n({ 5 | locale: 'en-us', 6 | fallbackLocale: 'en-us', 7 | globalInjection: true, 8 | legacy: false, 9 | messages: { 10 | ...lang, 11 | }, 12 | }) 13 | 14 | export const i18nGlobal = i18n.global 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: build 3 | 4 | build: 5 | wails build 6 | 7 | install: build 8 | mkdir -p ~/.local/share/applications ~/.local/share/icons/hicolor/256x256/apps ~/.local/bin/ 9 | cp build/bin/cattail ~/.local/bin 10 | cp cattail.desktop ~/.local/share/applications/ 11 | cp frontend/src/assets/images/icon.png ~/.local/share/icons/hicolor/256x256/apps/com.cattail.png 12 | -------------------------------------------------------------------------------- /wails.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://wails.io/schemas/config.v2.json", 3 | "name": "cattail", 4 | "outputfilename": "cattail", 5 | "frontend:install": "npm install", 6 | "frontend:build": "npm run build", 7 | "frontend:dev:watcher": "npm run dev", 8 | "frontend:dev:serverUrl": "auto", 9 | "author": { 10 | "name": "nerdyslacker", 11 | "email": "karyan40024@gmail.com" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/utils/constraints.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | type Hashable interface { 4 | ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | ~float32 | ~float64 | ~string 5 | } 6 | 7 | type SignedNumber interface { 8 | ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float32 | ~float64 9 | } 10 | 11 | type UnsignedNumber interface { 12 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 13 | } 14 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Vue 3 + Vite 2 | 3 | This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` 9 | 10 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /frontend/src/styles/content.scss: -------------------------------------------------------------------------------- 1 | .content-container { 2 | height: 100%; 3 | overflow: hidden; 4 | box-sizing: border-box; 5 | } 6 | 7 | .empty-content { 8 | height: 100%; 9 | justify-content: center; 10 | } 11 | 12 | .content-log { 13 | padding: 20px; 14 | } 15 | 16 | .content-value { 17 | user-select: text; 18 | cursor: text; 19 | } 20 | 21 | .tab-content { 22 | } 23 | 24 | :deep(.cmd-line) { 25 | word-wrap: break-word; 26 | white-space: pre-wrap; 27 | word-break: break-all; 28 | } 29 | -------------------------------------------------------------------------------- /backend/types/view_type.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | const FORMAT_RAW = "Raw" 4 | const FORMAT_JSON = "JSON" 5 | const FORMAT_UNICODE_JSON = "Unicode JSON" 6 | const FORMAT_YAML = "YAML" 7 | const FORMAT_XML = "XML" 8 | const FORMAT_HEX = "Hex" 9 | const FORMAT_BINARY = "Binary" 10 | 11 | const DECODE_NONE = "None" 12 | const DECODE_BASE64 = "Base64" 13 | const DECODE_GZIP = "GZip" 14 | const DECODE_DEFLATE = "Deflate" 15 | const DECODE_ZSTD = "ZStd" 16 | const DECODE_BROTLI = "Brotli" 17 | const DECODE_MSGPACK = "Msgpack" 18 | const DECODE_PHP = "PHP" 19 | const DECODE_PICKLE = "Pickle" 20 | -------------------------------------------------------------------------------- /frontend/src/components/icons/WindowMax.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /frontend/src/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | background-color: rgba(27, 38, 54, 1); 3 | text-align: center; 4 | color: white; 5 | } 6 | 7 | body { 8 | margin: 0; 9 | color: white; 10 | font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", 11 | "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 12 | sans-serif; 13 | } 14 | 15 | @font-face { 16 | font-family: "Nunito"; 17 | font-style: normal; 18 | font-weight: 400; 19 | src: local(""), 20 | url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2"); 21 | } 22 | 23 | #app { 24 | height: 100vh; 25 | text-align: center; 26 | } 27 | -------------------------------------------------------------------------------- /backend/utils/string/key_convert.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | import ( 4 | "strconv" 5 | ) 6 | 7 | // AnyToInt convert any value to int 8 | func AnyToInt(val any) (int, bool) { 9 | switch val.(type) { 10 | case string: 11 | num, err := strconv.Atoi(val.(string)) 12 | if err != nil { 13 | return 0, false 14 | } 15 | return num, true 16 | case float64: 17 | return int(val.(float64)), true 18 | case float32: 19 | return int(val.(float32)), true 20 | case int64: 21 | return int(val.(int64)), true 22 | case int32: 23 | return int(val.(int32)), true 24 | case int: 25 | return val.(int), true 26 | case bool: 27 | if val.(bool) { 28 | return 1, true 29 | } else { 30 | return 0, true 31 | } 32 | } 33 | return 0, false 34 | } 35 | -------------------------------------------------------------------------------- /backend/utils/ts/status.go: -------------------------------------------------------------------------------- 1 | package ts 2 | 3 | import ( 4 | "log/slog" 5 | "os/user" 6 | 7 | "tailscale.com/ipn" 8 | "tailscale.com/ipn/ipnstate" 9 | ) 10 | 11 | type Status struct { 12 | Status *ipnstate.Status 13 | Prefs *ipn.Prefs 14 | } 15 | 16 | func (s Status) Online() bool { 17 | return (s.Status != nil) && (s.Status.BackendState == ipn.Running.String()) 18 | } 19 | 20 | func (s Status) NeedsLogin() bool { 21 | return (s.Status != nil) && (s.Status.BackendState == ipn.NeedsLogin.String()) 22 | } 23 | 24 | func (s Status) OperatorIsCurrent() bool { 25 | current, err := user.Current() 26 | if err != nil { 27 | slog.Error("get current user", "err", err) 28 | return false 29 | } 30 | 31 | return s.Prefs.OperatorUser == current.Username 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/utils/extra_theme.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef ExtraTheme 3 | * @property {string} titleColor 4 | * @property {string} sidebarColor 5 | * @property {string} splitColor 6 | */ 7 | 8 | /** 9 | * 10 | * @type ExtraTheme 11 | */ 12 | export const extraLightTheme = { 13 | titleColor: '#F2F2F2', 14 | sidebarColor: '#F2F2F2', 15 | splitColor: '#DADADA', 16 | } 17 | 18 | /** 19 | * 20 | * @type ExtraTheme 21 | */ 22 | export const extraDarkTheme = { 23 | titleColor: '#18181C', 24 | sidebarColor: '#18181C', 25 | splitColor: '#474747', 26 | } 27 | 28 | /** 29 | * 30 | * @param {boolean} dark 31 | * @return ExtraTheme 32 | */ 33 | export const extraTheme = (dark) => { 34 | return dark ? extraDarkTheme : extraLightTheme 35 | } 36 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "dayjs": "^1.11.13", 13 | "lodash": "^4.17.21", 14 | "pinia": "^2.3.1", 15 | "sass": "^1.83.4", 16 | "vue": "^3.5.13", 17 | "vue-i18n": ">=11.0.1" 18 | }, 19 | "devDependencies": { 20 | "@vicons/fa": "^0.13.0", 21 | "@vitejs/plugin-vue": "^5.2.1", 22 | "humanize-duration": "^3.32.1", 23 | "naive-ui": "^2.41.0", 24 | "prettier": "^3.4.2", 25 | "unplugin-auto-import": "^19.0.0", 26 | "unplugin-icons": "^22.0.0", 27 | "unplugin-vue-components": "^28.0.0", 28 | "vite": ">=6.0.11" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/utils/render.js: -------------------------------------------------------------------------------- 1 | import { h } from 'vue' 2 | import { NIcon } from 'naive-ui' 3 | 4 | export function useRender() { 5 | return { 6 | /** 7 | * 8 | * @param {string|Object} icon 9 | * @param {{}} [props] 10 | * @return {VNode} 11 | */ 12 | renderIcon: (icon, props = {}) => { 13 | if (icon == null) { 14 | return undefined 15 | } 16 | return h(NIcon, null, { 17 | default: () => h(icon, props), 18 | }) 19 | }, 20 | 21 | /** 22 | * 23 | * @param {string} label 24 | * @param {{}} [props] 25 | * @return {VNode} 26 | */ 27 | renderLabel: (label, props = {}) => { 28 | return h('div', props, label) 29 | }, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /backend/utils/string/common.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | import ( 4 | "unicode" 5 | ) 6 | 7 | func ContainsBinary(str string) bool { 8 | //buf := []byte(str) 9 | //size := 0 10 | //for start := 0; start < len(buf); start += size { 11 | // var r rune 12 | // if r, size = utf8.DecodeRune(buf[start:]); r == utf8.RuneError { 13 | // return true 14 | // } 15 | //} 16 | rs := []rune(str) 17 | for _, r := range rs { 18 | if r == unicode.ReplacementChar { 19 | return true 20 | } 21 | if !unicode.IsPrint(r) && !unicode.IsSpace(r) { 22 | return true 23 | } 24 | } 25 | return false 26 | } 27 | 28 | func IsSameChar(str string) bool { 29 | if len(str) <= 0 { 30 | return false 31 | } 32 | 33 | rs := []rune(str) 34 | first := rs[0] 35 | for _, r := range rs { 36 | if r != first { 37 | return false 38 | } 39 | } 40 | 41 | return true 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/components/icons/WindowClose.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import AutoImport from 'unplugin-auto-import/vite' 4 | import Icons from 'unplugin-icons/vite' 5 | import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' 6 | import Components from 'unplugin-vue-components/vite' 7 | 8 | const rootPath = new URL('.', import.meta.url).pathname 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | plugins: [ 12 | vue(), 13 | AutoImport({ 14 | imports: [ 15 | { 16 | 'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'], 17 | }, 18 | ], 19 | }), 20 | Components({ 21 | resolvers: [NaiveUiResolver()], 22 | }), 23 | Icons(), 24 | ], 25 | resolve: { 26 | alias: { 27 | '@': rootPath + 'src', 28 | stores: rootPath + 'src/stores', 29 | wailsjs: rootPath + 'wailsjs', 30 | }, 31 | }, 32 | }) 33 | -------------------------------------------------------------------------------- /frontend/src/utils/version.js: -------------------------------------------------------------------------------- 1 | import { get, isEmpty, map, size, split, trimStart } from 'lodash' 2 | 3 | const toVerArr = (ver) => { 4 | const v = trimStart(ver, 'v') 5 | let vParts = split(v, '.') 6 | if (isEmpty(vParts)) { 7 | vParts = ['0'] 8 | } 9 | return map(vParts, (v) => { 10 | let vNum = parseInt(v) 11 | return isNaN(vNum) ? 0 : vNum 12 | }) 13 | } 14 | 15 | /** 16 | * compare two version strings 17 | * @param {string} v1 18 | * @param {string} v2 19 | * @return {number} 20 | */ 21 | export const compareVersion = (v1, v2) => { 22 | if (v1 !== v2) { 23 | const v1Nums = toVerArr(v1) 24 | const v2Nums = toVerArr(v2) 25 | const length = Math.max(size(v1Nums), size(v2Nums)) 26 | 27 | for (let i = 0; i < length; i++) { 28 | const num1 = get(v1Nums, i, 0) 29 | const num2 = get(v2Nums, i, 0) 30 | if (num1 !== num2) { 31 | return num1 > num2 ? 1 : -1 32 | } 33 | } 34 | } 35 | return 0 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/components/icons/WindowRestore.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /backend/types/ts_types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Namespace struct { 8 | Name string `json:"name"` 9 | Peers []Peer `json:"peers"` 10 | } 11 | 12 | type Peer struct { 13 | ID string `json:"id"` 14 | DNSName string `json:"dns_name"` 15 | Name string `json:"name"` 16 | ExitNode bool `json:"exit_node"` 17 | ExitNodeOption bool `json:"exit_node_option"` 18 | AllowLANAccess bool `json:"allow_lan_access"` 19 | AcceptRoutes bool `json:"accept_routes"` 20 | RunSSH bool `json:"run_ssh"` 21 | Online bool `json:"online"` 22 | OS string `json:"os"` 23 | Addrs []string `json:"addrs"` 24 | Routes []string `json:"routes"` // primary routes 25 | AdvertisedRoutes []string `json:"advertised_routes"` // advertised routes 26 | AllowedIPs []string `json:"allowed_ips"` 27 | IPs []string `json:"ips"` 28 | Created time.Time `json:"created_at"` 29 | LastSeen time.Time `json:"last_seen"` 30 | LastWrite time.Time `json:"last_write"` 31 | } 32 | 33 | type File struct { 34 | Name string `json:"name"` 35 | Size int64 `json:"size"` 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import { createPinia } from 'pinia' 2 | import { createApp, nextTick } from 'vue' 3 | import App from './App.vue' 4 | import './styles/style.scss' 5 | import dayjs from 'dayjs' 6 | import duration from 'dayjs/plugin/duration' 7 | import relativeTime from 'dayjs/plugin/relativeTime' 8 | import { i18n } from '@/utils/i18n.js' 9 | import usePreferencesStore from 'stores/preferences.js' 10 | import { loadEnvironment } from '@/utils/platform.js' 11 | 12 | dayjs.extend(duration) 13 | dayjs.extend(relativeTime) 14 | 15 | async function setupApp() { 16 | const app = createApp(App) 17 | app.use(i18n) 18 | app.use(createPinia()) 19 | 20 | await loadEnvironment() 21 | const prefStore = usePreferencesStore() 22 | await prefStore.loadPreferences() 23 | // app.config.errorHandler = (err, instance, info) => { 24 | // nextTick().then(() => { 25 | // try { 26 | // const content = err.toString() 27 | // $notification.error(content, { 28 | // title: i18n.global.t('common.error'), 29 | // meta: 'Please see console output for more detail', 30 | // }) 31 | // console.error(err) 32 | // } catch (e) {} 33 | // }) 34 | // } 35 | app.mount('#app') 36 | } 37 | 38 | setupApp() 39 | -------------------------------------------------------------------------------- /backend/storage/local_storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "os" 5 | "path" 6 | 7 | "github.com/vrischmann/userdir" 8 | ) 9 | 10 | // localStorage provides reading and writing application data to the user's 11 | // configuration directory. 12 | type localStorage struct { 13 | ConfPath string 14 | } 15 | 16 | // NewLocalStore returns a localStore instance. 17 | func NewLocalStore(filename string) *localStorage { 18 | return &localStorage{ 19 | ConfPath: path.Join(userdir.GetConfigHome(), "Cattail", filename), 20 | } 21 | } 22 | 23 | // Load reads the given file in the user's configuration directory and returns 24 | // its contents. 25 | func (l *localStorage) Load() ([]byte, error) { 26 | d, err := os.ReadFile(l.ConfPath) 27 | if err != nil { 28 | return nil, err 29 | } 30 | return d, err 31 | } 32 | 33 | // Store writes data to the user's configuration directory at the given 34 | // filename. 35 | func (l *localStorage) Store(data []byte) error { 36 | dir := path.Dir(l.ConfPath) 37 | if err := ensureDirExists(dir); err != nil { 38 | return err 39 | } 40 | if err := os.WriteFile(l.ConfPath, data, 0777); err != nil { 41 | return err 42 | } 43 | return nil 44 | } 45 | 46 | // ensureDirExists checks for the existence of the directory at the given path, 47 | // which is created if it does not exist. 48 | func ensureDirExists(path string) error { 49 | _, err := os.Stat(path) 50 | if os.IsNotExist(err) { 51 | if err = os.Mkdir(path, 0777); err != nil { 52 | return err 53 | } 54 | } 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /frontend/src/components/common/OsIcon.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | -------------------------------------------------------------------------------- /backend/services/tray_service.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | _ "embed" 5 | "runtime" 6 | 7 | "cattail/backend/utils/trayicons" 8 | 9 | "github.com/energye/systray" 10 | ) 11 | 12 | var ( 13 | //go:embed trayicons/active.png 14 | iconActive []byte 15 | 16 | //go:embed trayicons/inactive.png 17 | iconInactive []byte 18 | ) 19 | 20 | type trayService struct { 21 | statusMenuItem *systray.MenuItem 22 | showMenuItem *systray.MenuItem 23 | quitMenuItem *systray.MenuItem 24 | isWindowHidden bool 25 | } 26 | 27 | func TrayService(isOnline bool) *trayService { 28 | trayIcon := trayIcon(isOnline) 29 | systray.SetTemplateIcon(trayIcon, trayIcon) 30 | systray.SetIcon(trayIcon) 31 | systray.SetTitle("Cattail") 32 | systray.SetTooltip("Cattail") 33 | 34 | show := systray.AddMenuItem("Show", "") 35 | systray.AddSeparator() 36 | status := systray.AddMenuItem("", "") 37 | systray.AddSeparator() 38 | quit := systray.AddMenuItem("Quit", "") 39 | 40 | return &trayService{ 41 | statusMenuItem: status, 42 | showMenuItem: show, 43 | quitMenuItem: quit, 44 | isWindowHidden: true, 45 | } 46 | } 47 | 48 | var systrayExit = make(chan func(), 1) 49 | 50 | func (ts *trayService) Start(onReady func()) { 51 | start, stop := systray.RunWithExternalLoop(onReady, nil) 52 | select { 53 | case f := <-systrayExit: 54 | f() 55 | default: 56 | } 57 | 58 | start() 59 | systrayExit <- stop 60 | } 61 | 62 | func (ts *trayService) Stop() { 63 | select { 64 | case f := <-systrayExit: 65 | f() 66 | default: 67 | } 68 | } 69 | 70 | func (ts *trayService) ToggleStatusItem(enabled bool) { 71 | if enabled { 72 | ts.statusMenuItem.Check() 73 | ts.statusMenuItem.SetTitle("Stop") 74 | } else { 75 | ts.statusMenuItem.Uncheck() 76 | ts.statusMenuItem.SetTitle("Start") 77 | } 78 | } 79 | 80 | func (ts *trayService) setStatus(isOnline bool) { 81 | trayIcon := trayIcon(isOnline) 82 | systray.SetTemplateIcon(trayIcon, trayIcon) 83 | systray.SetIcon(trayIcon) 84 | } 85 | 86 | func trayIcon(isOnline bool) []byte { 87 | if runtime.GOOS == "windows" { 88 | iconActive = trayicons.Active 89 | iconInactive = trayicons.Inactive 90 | } 91 | 92 | if isOnline { 93 | return iconActive 94 | } 95 | 96 | return iconInactive 97 | } 98 | -------------------------------------------------------------------------------- /frontend/src/utils/rgb.js: -------------------------------------------------------------------------------- 1 | import { padStart, size, startsWith } from 'lodash' 2 | 3 | /** 4 | * @typedef {Object} RGB 5 | * @property {number} r 6 | * @property {number} g 7 | * @property {number} b 8 | * @property {number} [a] 9 | */ 10 | 11 | /** 12 | * parse hex color to rgb object 13 | * @param hex 14 | * @return {RGB} 15 | */ 16 | export function parseHexColor(hex) { 17 | if (size(hex) < 6) { 18 | return { r: 0, g: 0, b: 0 } 19 | } 20 | if (startsWith(hex, '#')) { 21 | hex = hex.slice(1) 22 | } 23 | const bigint = parseInt(hex, 16) 24 | const r = (bigint >> 16) & 255 25 | const g = (bigint >> 8) & 255 26 | const b = bigint & 255 27 | return { r, g, b } 28 | } 29 | 30 | /** 31 | * do gamma correction with an RGB object 32 | * @param {RGB} rgb 33 | * @param {Number} gamma 34 | * @return {RGB} 35 | */ 36 | export function hexGammaCorrection(rgb, gamma) { 37 | if (typeof rgb !== 'object') { 38 | return { r: 0, g: 0, b: 0 } 39 | } 40 | return { 41 | r: Math.max(0, Math.min(255, Math.round(rgb.r * gamma))), 42 | g: Math.max(0, Math.min(255, Math.round(rgb.g * gamma))), 43 | b: Math.max(0, Math.min(255, Math.round(rgb.b * gamma))), 44 | } 45 | } 46 | 47 | /** 48 | * mix two colors 49 | * @param rgba1 50 | * @param rgba2 51 | * @param weight 52 | * @return {{a: number, r: number, b: number, g: number}} 53 | */ 54 | export function mixColors(rgba1, rgba2, weight = 0.5) { 55 | if (rgba1.a === undefined) { 56 | rgba1.a = 255 57 | } 58 | if (rgba2.a === undefined) { 59 | rgba2.a = 255 60 | } 61 | return { 62 | r: Math.floor(rgba1.r * (1 - weight) + rgba2.r * weight), 63 | g: Math.floor(rgba1.g * (1 - weight) + rgba2.g * weight), 64 | b: Math.floor(rgba1.b * (1 - weight) + rgba2.b * weight), 65 | a: Math.floor(rgba1.a * (1 - weight) + rgba2.a * weight), 66 | } 67 | } 68 | 69 | /** 70 | * RGB object to hex color string 71 | * @param {RGB} rgb 72 | * @return {string} 73 | */ 74 | export function toHexColor(rgb) { 75 | return ( 76 | '#' + 77 | padStart(rgb.r.toString(16), 2, '0') + 78 | padStart(rgb.g.toString(16), 2, '0') + 79 | padStart(rgb.b.toString(16), 2, '0') 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |

Cattail

5 | 6 |
7 | 8 | [![License](https://img.shields.io/github/license/nerdyslacker/cattail)](https://github.com/nerdyslacker/cattail/blob/main/LICENSE) 9 | [![GitHub release](https://img.shields.io/github/release/nerdyslacker/cattail)](https://github.com/nerdyslacker/cattail/releases) 10 | ![GitHub All Releases](https://img.shields.io/github/downloads/nerdyslacker/cattail/total) 11 | [![GitHub stars](https://img.shields.io/github/stars/nerdyslacker/cattail)](https://github.com/nerdyslacker/cattail/stargazers) 12 | [![GitHub forks](https://img.shields.io/github/forks/nerdyslacker/cattail)](https://github.com/nerdyslacker/cattail/fork) 13 | 14 | Cattail is tailscale/headscale client for Linux and Windows developed using [Wails](https://wails.io) project and [Naive UI](https://www.naiveui.com) library. 15 | 16 |
17 | 18 | > :warning: This application is an independent project and is not affiliated with the official Tailscale project. Use it at your own risk. The developers are not responsible for any issues or damages that may arise from using this application. 19 | 20 | 21 | screenshot 22 | 23 | 24 | # Features 25 | 26 | - [x] Account switching 27 | - [x] Detailed peer information 28 | - [x] Tray menu for quick access 29 | - [x] Copying of IP addresses/DNS name 30 | - [ ] Pinging of peers 31 | - [ ] Set control URL 32 | - [ ] Adding tags 33 | - [x] Exit node management 34 | - [x] Allow LAN access 35 | - [x] Accept routes 36 | - [x] Run SSH 37 | - [x] Advertise routes 38 | - [x] Toggle tailscale status 39 | - [ ] Toggle taildrop status and change path 40 | - [x] Sending files 41 | - [x] Receiving files 42 | - [x] Notification on tailscale status change 43 | - [ ] Notification on peer addition/removal 44 | - [ ] Monitoring traffic 45 | 46 | # Installation 47 | 48 | ```bash 49 | go install github.com/wailsapp/wails/v2/cmd/wails@latest 50 | git clone https://github.com/nerdyslacker/cattail 51 | cd cattail 52 | make install 53 | ``` 54 | 55 | or download the binary from [Releases](https://github.com/nerdyslacker/cattail/releases) page. 56 | 57 | 58 | 59 | 60 | # Credits 61 | 62 | * [dgrr/tailscale-client](https://github.com/dgrr/tailscale-client) 63 | * [DeedleFake/trayscale](https://github.com/DeedleFake/trayscale) 64 | * [tiny-craft/tiny-rdm](https://github.com/tiny-craft/tiny-rdm) 65 | * [KSurzyn](https://github.com/KSurzyn) (for logo) 66 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /backend/services/system_service.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "cattail/backend/consts" 5 | "cattail/backend/types" 6 | "context" 7 | "github.com/wailsapp/wails/v2/pkg/runtime" 8 | runtime2 "runtime" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | type systemService struct { 14 | ctx context.Context 15 | appVersion string 16 | } 17 | 18 | var system *systemService 19 | var onceSystem sync.Once 20 | 21 | func System() *systemService { 22 | if system == nil { 23 | onceSystem.Do(func() { 24 | system = &systemService{ 25 | appVersion: "0.0.0", 26 | } 27 | go system.loopWindowEvent() 28 | }) 29 | } 30 | return system 31 | } 32 | 33 | func (s *systemService) Start(ctx context.Context, version string) { 34 | s.ctx = ctx 35 | s.appVersion = version 36 | 37 | // maximize the window if screen size is lower than the minimum window size 38 | if screen, err := runtime.ScreenGetAll(ctx); err == nil && len(screen) > 0 { 39 | for _, sc := range screen { 40 | if sc.IsCurrent { 41 | if sc.Size.Width < consts.MIN_WINDOW_WIDTH || sc.Size.Height < consts.MIN_WINDOW_HEIGHT { 42 | runtime.WindowMaximise(ctx) 43 | break 44 | } 45 | } 46 | } 47 | } 48 | } 49 | 50 | func (s *systemService) Info() (resp types.JSResp) { 51 | resp.Success = true 52 | resp.Data = struct { 53 | OS string `json:"os"` 54 | Arch string `json:"arch"` 55 | Version string `json:"version"` 56 | }{ 57 | OS: runtime2.GOOS, 58 | Arch: runtime2.GOARCH, 59 | Version: s.appVersion, 60 | } 61 | return 62 | } 63 | 64 | func (s *systemService) loopWindowEvent() { 65 | var fullscreen, maximised, minimised, normal bool 66 | var width, height int 67 | var dirty bool 68 | for { 69 | time.Sleep(300 * time.Millisecond) 70 | if s.ctx == nil { 71 | continue 72 | } 73 | 74 | dirty = false 75 | if f := runtime.WindowIsFullscreen(s.ctx); f != fullscreen { 76 | // full-screen switched 77 | fullscreen = f 78 | dirty = true 79 | } 80 | 81 | if w, h := runtime.WindowGetSize(s.ctx); w != width || h != height { 82 | // window size changed 83 | width, height = w, h 84 | dirty = true 85 | } 86 | 87 | if m := runtime.WindowIsMaximised(s.ctx); m != maximised { 88 | maximised = m 89 | dirty = true 90 | } 91 | 92 | if m := runtime.WindowIsMinimised(s.ctx); m != minimised { 93 | minimised = m 94 | dirty = true 95 | } 96 | 97 | if n := runtime.WindowIsNormal(s.ctx); n != normal { 98 | normal = n 99 | dirty = true 100 | } 101 | 102 | if dirty { 103 | runtime.EventsEmit(s.ctx, "window_changed", map[string]any{ 104 | "fullscreen": fullscreen, 105 | "width": width, 106 | "height": height, 107 | "maximised": maximised, 108 | "minimised": minimised, 109 | "normal": normal, 110 | }) 111 | 112 | if !fullscreen && !minimised { 113 | // save window size and position 114 | Preferences().SaveWindowSize(width, height, maximised) 115 | } 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /frontend/src/components/common/IconButton.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /backend/utils/string/any_convert.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | import ( 4 | sliceutil "cattail/backend/utils/slice" 5 | "encoding/json" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | func AnyToString(value interface{}, prefix string, layer int) (s string) { 12 | if value == nil { 13 | return 14 | } 15 | 16 | switch value.(type) { 17 | case float64: 18 | ft := value.(float64) 19 | s = strconv.FormatFloat(ft, 'f', -1, 64) 20 | case float32: 21 | ft := value.(float32) 22 | s = strconv.FormatFloat(float64(ft), 'f', -1, 64) 23 | case int: 24 | it := value.(int) 25 | s = strconv.Itoa(it) 26 | case uint: 27 | it := value.(uint) 28 | s = strconv.Itoa(int(it)) 29 | case int8: 30 | it := value.(int8) 31 | s = strconv.Itoa(int(it)) 32 | case uint8: 33 | it := value.(uint8) 34 | s = strconv.Itoa(int(it)) 35 | case int16: 36 | it := value.(int16) 37 | s = strconv.Itoa(int(it)) 38 | case uint16: 39 | it := value.(uint16) 40 | s = strconv.Itoa(int(it)) 41 | case int32: 42 | it := value.(int32) 43 | s = strconv.Itoa(int(it)) 44 | case uint32: 45 | it := value.(uint32) 46 | s = strconv.Itoa(int(it)) 47 | case int64: 48 | it := value.(int64) 49 | s = strconv.FormatInt(it, 10) 50 | case uint64: 51 | it := value.(uint64) 52 | s = strconv.FormatUint(it, 10) 53 | case string: 54 | if layer > 0 { 55 | s = "\"" + value.(string) + "\"" 56 | } else { 57 | s = value.(string) 58 | } 59 | case bool: 60 | val, _ := value.(bool) 61 | if val { 62 | s = "True" 63 | } else { 64 | s = "False" 65 | } 66 | case []byte: 67 | s = prefix + string(value.([]byte)) 68 | case []string: 69 | ss := value.([]string) 70 | anyStr := sliceutil.Map(ss, func(i int) string { 71 | str := AnyToString(ss[i], prefix, layer+1) 72 | return prefix + strconv.Itoa(i+1) + ") " + str 73 | }) 74 | s = prefix + sliceutil.JoinString(anyStr, "\r\n") 75 | case []any: 76 | as := value.([]any) 77 | anyItems := sliceutil.Map(as, func(i int) string { 78 | str := AnyToString(as[i], prefix, layer+1) 79 | return prefix + strconv.Itoa(i+1) + ") " + str 80 | }) 81 | s = sliceutil.JoinString(anyItems, "\r\n") 82 | case map[any]any: 83 | am := value.(map[any]any) 84 | var items []string 85 | index := 0 86 | for k, v := range am { 87 | kk := prefix + strconv.Itoa(index+1) + ") " + AnyToString(k, prefix, layer+1) 88 | vv := prefix + strconv.Itoa(index+2) + ") " + AnyToString(v, "\t", layer+1) 89 | if layer > 0 { 90 | indent := layer 91 | if index == 0 { 92 | indent -= 1 93 | } 94 | for i := 0; i < indent; i++ { 95 | vv = " " + vv 96 | } 97 | } 98 | index += 2 99 | items = append(items, kk, vv) 100 | } 101 | s = sliceutil.JoinString(items, "\r\n") 102 | default: 103 | b, _ := json.Marshal(value) 104 | s = prefix + string(b) 105 | } 106 | 107 | return 108 | } 109 | 110 | func SplitCmd(cmd string) []string { 111 | re := regexp.MustCompile(`'[^']+'|"[^"]+"|\S+`) 112 | args := re.FindAllString(cmd, -1) 113 | return sliceutil.FilterMap(args, func(i int) (string, bool) { 114 | arg := strings.Trim(args[i], "\"") 115 | arg = strings.Trim(arg, "'") 116 | if len(arg) <= 0 { 117 | return "", false 118 | } 119 | return arg, true 120 | }) 121 | } 122 | -------------------------------------------------------------------------------- /frontend/src/components/common/ResizeableWrapper.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 94 | 95 | 124 | -------------------------------------------------------------------------------- /frontend/src/utils/theme.js: -------------------------------------------------------------------------------- 1 | import { merge } from 'lodash' 2 | 3 | /** 4 | * 5 | * @type import('naive-ui').GlobalThemeOverrides 6 | */ 7 | export const themeOverrides = { 8 | common: { 9 | primaryColor: '#1f276c', 10 | primaryColorHover: '#5887be', 11 | primaryColorPressed: '#1f277d', 12 | primaryColorSuppl: '#5887be', 13 | borderRadius: '4px', 14 | borderRadiusSmall: '3px', 15 | heightMedium: '32px', 16 | lineHeight: 1.5, 17 | scrollbarWidth: '8px', 18 | tabColor: '#FFFFFF', 19 | }, 20 | Button: { 21 | heightMedium: '32px', 22 | paddingSmall: '0 8px', 23 | paddingMedium: '0 12px', 24 | }, 25 | Tag: { 26 | borderRadius: '4px', 27 | heightLarge: '32px', 28 | }, 29 | Input: { 30 | heightMedium: '32px', 31 | }, 32 | Tabs: { 33 | tabGapSmallCard: '2px', 34 | tabGapMediumCard: '2px', 35 | tabGapLargeCard: '2px', 36 | tabFontWeightActive: 450, 37 | }, 38 | Card: { 39 | colorEmbedded: '#FAFAFA', 40 | }, 41 | Form: { 42 | labelFontSizeTopSmall: '12px', 43 | labelFontSizeTopMedium: '13px', 44 | labelFontSizeTopLarge: '13px', 45 | labelHeightSmall: '18px', 46 | labelHeightMedium: '18px', 47 | labelHeightLarge: '18px', 48 | labelPaddingVertical: '0 0 5px 2px', 49 | feedbackHeightSmall: '18px', 50 | feedbackHeightMedium: '18px', 51 | feedbackHeightLarge: '20px', 52 | feedbackFontSizeSmall: '11px', 53 | feedbackFontSizeMedium: '12px', 54 | feedbackFontSizeLarge: '12px', 55 | labelTextColor: 'rgb(113,120,128)', 56 | labelFontWeight: '450', 57 | }, 58 | Radio: { 59 | buttonColorActive: '#1f276f', 60 | buttonTextColorActive: '#FFF', 61 | }, 62 | DataTable: { 63 | thPaddingSmall: '6px 8px', 64 | tdPaddingSmall: '6px 8px', 65 | }, 66 | Dropdown: { 67 | borderRadius: '5px', 68 | optionIconSizeMedium: '18px', 69 | padding: '6px 2px', 70 | optionColorHover: '#1f276c', 71 | optionTextColorHover: '#FFF', 72 | optionHeightMedium: '28px', 73 | }, 74 | Divider: { 75 | color: '#AAAAAB', 76 | }, 77 | } 78 | 79 | /** 80 | * 81 | * @type import('naive-ui').GlobalThemeOverrides 82 | */ 83 | const _darkThemeOverrides = { 84 | common: { 85 | primaryColor: '#eeb866', 86 | primaryColorHover: '#ce9b4e', 87 | primaryColorPressed: '#eeb869', 88 | primaryColorSuppl: '#ce9b4e', 89 | bodyColor: '#101014', 90 | tabColor: '#101014', //#18181C 91 | // borderColor: '#262629', 92 | }, 93 | Tree: { 94 | nodeTextColor: '#CECED0', 95 | }, 96 | Card: { 97 | // colorEmbedded: '#212121', 98 | }, 99 | Radio: { 100 | buttonColorActive: '#eeb867', 101 | buttonTextColorActive: '#FFF', 102 | }, 103 | Dropdown: { 104 | // color: '#272727', 105 | optionColorHover: '#eeb866', 106 | }, 107 | Popover: { 108 | // color: '#2C2C32', 109 | }, 110 | } 111 | 112 | export const darkThemeOverrides = merge({}, themeOverrides, _darkThemeOverrides) 113 | -------------------------------------------------------------------------------- /backend/storage/preferences.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "cattail/backend/consts" 5 | "cattail/backend/types" 6 | "fmt" 7 | "log" 8 | "reflect" 9 | "strings" 10 | "sync" 11 | 12 | "gopkg.in/yaml.v3" 13 | ) 14 | 15 | type PreferencesStorage struct { 16 | storage *localStorage 17 | mutex sync.Mutex 18 | } 19 | 20 | func NewPreferences() *PreferencesStorage { 21 | return &PreferencesStorage{ 22 | storage: NewLocalStore("preferences.yaml"), 23 | } 24 | } 25 | 26 | func (p *PreferencesStorage) DefaultPreferences() types.Preferences { 27 | return types.NewPreferences() 28 | } 29 | 30 | func (p *PreferencesStorage) getPreferences() (ret types.Preferences) { 31 | b, err := p.storage.Load() 32 | ret = p.DefaultPreferences() 33 | if err != nil { 34 | return 35 | } 36 | 37 | if err = yaml.Unmarshal(b, &ret); err != nil { 38 | ret = p.DefaultPreferences() 39 | return 40 | } 41 | return 42 | } 43 | 44 | // GetPreferences Get preferences from local 45 | func (p *PreferencesStorage) GetPreferences() (ret types.Preferences) { 46 | p.mutex.Lock() 47 | defer p.mutex.Unlock() 48 | 49 | ret = p.getPreferences() 50 | if ret.General.ScanSize <= 0 { 51 | ret.General.ScanSize = consts.DEFAULT_SCAN_SIZE 52 | } 53 | ret.Behavior.AsideWidth = max(ret.Behavior.AsideWidth, consts.DEFAULT_ASIDE_WIDTH) 54 | ret.Behavior.WindowWidth = max(ret.Behavior.WindowWidth, consts.MIN_WINDOW_WIDTH) 55 | ret.Behavior.WindowHeight = max(ret.Behavior.WindowHeight, consts.MIN_WINDOW_HEIGHT) 56 | return 57 | } 58 | 59 | func (p *PreferencesStorage) setPreferences(pf *types.Preferences, key string, value any) error { 60 | parts := strings.Split(key, ".") 61 | if len(parts) > 0 { 62 | var reflectValue reflect.Value 63 | if reflect.TypeOf(pf).Kind() == reflect.Ptr { 64 | reflectValue = reflect.ValueOf(pf).Elem() 65 | } else { 66 | reflectValue = reflect.ValueOf(pf) 67 | } 68 | for i, part := range parts { 69 | part = strings.ToUpper(part[:1]) + part[1:] 70 | reflectValue = reflectValue.FieldByName(part) 71 | if reflectValue.IsValid() { 72 | if i == len(parts)-1 { 73 | reflectValue.Set(reflect.ValueOf(value)) 74 | return nil 75 | } 76 | } else { 77 | break 78 | } 79 | } 80 | } 81 | 82 | return fmt.Errorf("invalid key path(%s)", key) 83 | } 84 | 85 | func (p *PreferencesStorage) savePreferences(pf *types.Preferences) error { 86 | b, err := yaml.Marshal(pf) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | if err = p.storage.Store(b); err != nil { 92 | return err 93 | } 94 | return nil 95 | } 96 | 97 | // SetPreferences replace preferences 98 | func (p *PreferencesStorage) SetPreferences(pf *types.Preferences) error { 99 | p.mutex.Lock() 100 | defer p.mutex.Unlock() 101 | 102 | return p.savePreferences(pf) 103 | } 104 | 105 | // UpdatePreferences update values by key paths, the key path use "." to indicate multiple level 106 | func (p *PreferencesStorage) UpdatePreferences(values map[string]any) error { 107 | p.mutex.Lock() 108 | defer p.mutex.Unlock() 109 | 110 | pf := p.getPreferences() 111 | for path, v := range values { 112 | if err := p.setPreferences(&pf, path, v); err != nil { 113 | return err 114 | } 115 | } 116 | log.Println("after save", pf) 117 | 118 | return p.savePreferences(&pf) 119 | } 120 | 121 | func (p *PreferencesStorage) RestoreDefault() types.Preferences { 122 | p.mutex.Lock() 123 | defer p.mutex.Unlock() 124 | 125 | pf := p.DefaultPreferences() 126 | p.savePreferences(&pf) 127 | return pf 128 | } 129 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "cattail/backend/consts" 5 | "cattail/backend/services" 6 | "context" 7 | "embed" 8 | "os" 9 | "runtime" 10 | 11 | "github.com/wailsapp/wails/v2" 12 | "github.com/wailsapp/wails/v2/pkg/options" 13 | "github.com/wailsapp/wails/v2/pkg/options/assetserver" 14 | "github.com/wailsapp/wails/v2/pkg/options/linux" 15 | "github.com/wailsapp/wails/v2/pkg/options/mac" 16 | "github.com/wailsapp/wails/v2/pkg/options/windows" 17 | runtime2 "github.com/wailsapp/wails/v2/pkg/runtime" 18 | ) 19 | 20 | //go:embed all:frontend/dist 21 | var assets embed.FS 22 | 23 | //go:embed build/appicon.png 24 | var icon []byte 25 | 26 | var version = "0.0.0" 27 | 28 | func main() { 29 | // Create an instance of the app structure 30 | tailSvc := services.TailScaleService() 31 | sysSvc := services.System() 32 | prefSvc := services.Preferences() 33 | prefSvc.SetAppVersion(version) 34 | windowWidth, windowHeight, maximised := prefSvc.GetWindowSize() 35 | windowStartState := options.Normal 36 | if maximised { 37 | windowStartState = options.Maximised 38 | } 39 | 40 | if runtime.GOOS == "linux" { 41 | _ = os.Setenv("GDK_BACKEND", "x11") 42 | } 43 | 44 | // Create application with options 45 | err := wails.Run(&options.App{ 46 | Title: "Cattail", 47 | Width: windowWidth, 48 | Height: windowHeight, 49 | MinWidth: consts.DEFAULT_WINDOW_WIDTH, 50 | MinHeight: consts.DEFAULT_WINDOW_HEIGHT, 51 | MaxWidth: consts.DEFAULT_WINDOW_WIDTH, 52 | MaxHeight: consts.DEFAULT_WINDOW_HEIGHT, 53 | WindowStartState: windowStartState, 54 | Frameless: runtime.GOOS != "darwin", 55 | EnableDefaultContextMenu: true, 56 | HideWindowOnClose: true, 57 | DisableResize: true, 58 | StartHidden: true, 59 | AssetServer: &assetserver.Options{ 60 | Assets: assets, 61 | }, 62 | BackgroundColour: options.NewRGBA(27, 38, 54, 0), 63 | OnStartup: func(ctx context.Context) { 64 | sysSvc.Start(ctx, version) 65 | tailSvc.Startup(ctx) 66 | }, 67 | OnDomReady: func(ctx context.Context) { 68 | x, y := prefSvc.GetWindowPosition(ctx) 69 | runtime2.WindowSetPosition(ctx, x, y) 70 | // runtime2.WindowShow(ctx) 71 | }, 72 | OnBeforeClose: func(ctx context.Context) (prevent bool) { 73 | x, y := runtime2.WindowGetPosition(ctx) 74 | prefSvc.SaveWindowPosition(x, y) 75 | return false 76 | }, 77 | SingleInstanceLock: &options.SingleInstanceLock{ 78 | UniqueId: "db1301ad-7f4f-4814-a513-f068c93a617b", 79 | OnSecondInstanceLaunch: tailSvc.OnSecondInstanceLaunch, 80 | }, 81 | Bind: []interface{}{ 82 | sysSvc, 83 | prefSvc, 84 | tailSvc, 85 | }, 86 | Mac: &mac.Options{ 87 | TitleBar: mac.TitleBarHiddenInset(), 88 | About: &mac.AboutInfo{ 89 | Title: "Cattail " + version, 90 | Message: "", 91 | Icon: icon, 92 | }, 93 | WebviewIsTransparent: false, 94 | WindowIsTranslucent: true, 95 | }, 96 | Windows: &windows.Options{ 97 | WebviewIsTransparent: true, 98 | WindowIsTranslucent: true, 99 | DisableFramelessWindowDecorations: true, 100 | }, 101 | Linux: &linux.Options{ 102 | ProgramName: "Cattail", 103 | Icon: icon, 104 | WebviewGpuPolicy: linux.WebviewGpuPolicyOnDemand, 105 | WindowIsTranslucent: true, 106 | }, 107 | }) 108 | 109 | if err != nil { 110 | println("Error:", err.Error()) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /frontend/src/components/common/ToolbarControlWidget.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 79 | 80 | 115 | -------------------------------------------------------------------------------- /backend/types/preferences.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "cattail/backend/consts" 4 | 5 | type Preferences struct { 6 | Behavior PreferencesBehavior `json:"behavior" yaml:"behavior"` 7 | General PreferencesGeneral `json:"general" yaml:"general"` 8 | Editor PreferencesEditor `json:"editor" yaml:"editor"` 9 | Cli PreferencesCli `json:"cli" yaml:"cli"` 10 | Decoder []PreferencesDecoder `json:"decoder" yaml:"decoder,omitempty"` 11 | } 12 | 13 | func NewPreferences() Preferences { 14 | return Preferences{ 15 | Behavior: PreferencesBehavior{ 16 | AsideWidth: consts.DEFAULT_ASIDE_WIDTH, 17 | WindowWidth: consts.DEFAULT_WINDOW_WIDTH, 18 | WindowHeight: consts.DEFAULT_WINDOW_HEIGHT, 19 | }, 20 | General: PreferencesGeneral{ 21 | Theme: "auto", 22 | Language: "auto", 23 | FontSize: consts.DEFAULT_FONT_SIZE, 24 | ScanSize: consts.DEFAULT_SCAN_SIZE, 25 | KeyIconStyle: 0, 26 | CheckUpdate: true, 27 | }, 28 | Editor: PreferencesEditor{ 29 | FontSize: consts.DEFAULT_FONT_SIZE, 30 | ShowLineNum: true, 31 | ShowFolding: true, 32 | DropText: true, 33 | Links: true, 34 | }, 35 | Cli: PreferencesCli{ 36 | FontSize: consts.DEFAULT_FONT_SIZE, 37 | CursorStyle: "block", 38 | }, 39 | Decoder: []PreferencesDecoder{}, 40 | } 41 | } 42 | 43 | type PreferencesBehavior struct { 44 | AsideWidth int `json:"asideWidth" yaml:"aside_width"` 45 | WindowWidth int `json:"windowWidth" yaml:"window_width"` 46 | WindowHeight int `json:"windowHeight" yaml:"window_height"` 47 | WindowMaximised bool `json:"windowMaximised" yaml:"window_maximised"` 48 | WindowPosX int `json:"windowPosX" yaml:"window_pos_x"` 49 | WindowPosY int `json:"windowPosY" yaml:"window_pos_y"` 50 | } 51 | 52 | type PreferencesGeneral struct { 53 | Theme string `json:"theme" yaml:"theme"` 54 | Language string `json:"language" yaml:"language"` 55 | Font string `json:"font" yaml:"font,omitempty"` 56 | FontFamily []string `json:"fontFamily" yaml:"font_family,omitempty"` 57 | FontSize int `json:"fontSize" yaml:"font_size"` 58 | ScanSize int `json:"scanSize" yaml:"scan_size"` 59 | KeyIconStyle int `json:"keyIconStyle" yaml:"key_icon_style"` 60 | UseSysProxy bool `json:"useSysProxy" yaml:"use_sys_proxy,omitempty"` 61 | UseSysProxyHttp bool `json:"useSysProxyHttp" yaml:"use_sys_proxy_http,omitempty"` 62 | CheckUpdate bool `json:"checkUpdate" yaml:"check_update"` 63 | SkipVersion string `json:"skipVersion" yaml:"skip_version,omitempty"` 64 | } 65 | 66 | type PreferencesEditor struct { 67 | Font string `json:"font" yaml:"font,omitempty"` 68 | FontFamily []string `json:"fontFamily" yaml:"font_family,omitempty"` 69 | FontSize int `json:"fontSize" yaml:"font_size"` 70 | ShowLineNum bool `json:"showLineNum" yaml:"show_line_num"` 71 | ShowFolding bool `json:"showFolding" yaml:"show_folding"` 72 | DropText bool `json:"dropText" yaml:"drop_text"` 73 | Links bool `json:"links" yaml:"links"` 74 | } 75 | 76 | type PreferencesCli struct { 77 | FontFamily []string `json:"fontFamily" yaml:"font_family,omitempty"` 78 | FontSize int `json:"fontSize" yaml:"font_size"` 79 | CursorStyle string `json:"cursorStyle" yaml:"cursor_style,omitempty"` 80 | } 81 | 82 | type PreferencesDecoder struct { 83 | Name string `json:"name" yaml:"name"` 84 | Enable bool `json:"enable" yaml:"enable"` 85 | Auto bool `json:"auto" yaml:"auto"` 86 | DecodePath string `json:"decodePath" yaml:"decode_path"` 87 | DecodeArgs []string `json:"decodeArgs" yaml:"decode_args,omitempty"` 88 | EncodePath string `json:"encodePath" yaml:"encode_path"` 89 | EncodeArgs []string `json:"encodeArgs" yaml:"encode_args,omitempty"` 90 | } 91 | -------------------------------------------------------------------------------- /frontend/src/styles/style.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | //--bg-color: #f8f8f8; 3 | //--bg-color-accent: #fff; 4 | //--bg-color-page: #f2f3f5; 5 | //--text-color-regular: #606266; 6 | //--border-color: #dcdfe6; 7 | --transition-duration-fast: 0.2s; 8 | --transition-function-ease-in-out-bezier: cubic-bezier(0.645, 0.045, 0.355, 1); 9 | } 10 | 11 | html { 12 | //text-align: center; 13 | cursor: default; 14 | -webkit-user-select: none; /* Chrome, Safari */ 15 | -moz-user-select: none; /* Firefox */ 16 | user-select: none; 17 | } 18 | 19 | body { 20 | margin: 0; 21 | padding: 0; 22 | background-color: #0000; 23 | line-height: 1.5; 24 | font-family: v-sans, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 25 | overflow: hidden; 26 | } 27 | 28 | @mixin bottom-shadow($transparent) { 29 | box-shadow: 0 5px 5px -5px rgba(0, 0, 0, $transparent); 30 | } 31 | 32 | @mixin top-shadow($transparent) { 33 | box-shadow: 0 -5px 5px -5px rgba(0, 0, 0, $transparent); 34 | } 35 | 36 | #app { 37 | height: 100vh; 38 | } 39 | 40 | .flex-box { 41 | display: flex; 42 | } 43 | 44 | .flex-box-v { 45 | @extend .flex-box; 46 | flex-direction: column; 47 | } 48 | 49 | .flex-box-h { 50 | @extend .flex-box; 51 | flex-direction: row; 52 | } 53 | 54 | .flex-item { 55 | flex: 0 0 auto; 56 | } 57 | 58 | .flex-item-expand { 59 | flex-grow: 1; 60 | } 61 | 62 | .justify-content-end { 63 | justify-content: flex-end!important; 64 | } 65 | 66 | .justify-content-between { 67 | justify-content: space-between!important; 68 | } 69 | 70 | .clickable { 71 | cursor: pointer; 72 | } 73 | 74 | .icon-btn { 75 | @extend .clickable; 76 | line-height: 100%; 77 | } 78 | 79 | .ellipsis { 80 | white-space: nowrap; 81 | overflow: hidden; 82 | text-overflow: ellipsis; 83 | } 84 | 85 | .fill-height { 86 | height: 100%; 87 | } 88 | 89 | .text-block { 90 | white-space: pre-line; 91 | } 92 | 93 | .content-wrapper { 94 | height: 100%; 95 | flex-grow: 1; 96 | overflow: hidden; 97 | gap: 5px; 98 | padding-top: 5px; 99 | //padding: 5px; 100 | box-sizing: border-box; 101 | position: relative; 102 | 103 | .tb2 { 104 | gap: 5px; 105 | justify-content: flex-end; 106 | align-items: center; 107 | } 108 | 109 | .value-wrapper { 110 | //border-top: v-bind('themeVars.borderColor') 1px solid; 111 | user-select: text; 112 | //height: 100%; 113 | box-sizing: border-box; 114 | } 115 | 116 | .value-item-part { 117 | padding: 0 5px; 118 | } 119 | 120 | .value-footer { 121 | @include top-shadow(0.1); 122 | align-items: center; 123 | gap: 0; 124 | padding: 3px 10px 3px 10px; 125 | height: 30px; 126 | } 127 | } 128 | 129 | .n-dynamic-input-item { 130 | align-items: center; 131 | gap: 10px; 132 | } 133 | 134 | .n-tree-node-content__text { 135 | @extend .ellipsis; 136 | } 137 | 138 | .context-menu-item { 139 | min-width: 100px; 140 | padding-right: 10px; 141 | } 142 | 143 | .nav-pane-container { 144 | overflow: hidden; 145 | 146 | .nav-pane-func { 147 | align-items: center; 148 | justify-content: center; 149 | gap: 3px; 150 | padding: 3px 8px; 151 | min-height: 30px; 152 | 153 | .nav-pane-func-btn { 154 | padding: 3px; 155 | border-radius: 3px; 156 | box-sizing: border-box; 157 | } 158 | } 159 | } 160 | 161 | .n-modal-mask { 162 | --wails-draggable: drag; 163 | } 164 | 165 | .n-tabs .n-tabs-nav { 166 | line-height: 1.3; 167 | } 168 | 169 | // animations 170 | .fade-enter-active, 171 | .fade-leave-active { 172 | transition: opacity 0.3s ease; 173 | } 174 | 175 | .fade-enter-from, 176 | .fade-leave-to { 177 | opacity: 0; 178 | } 179 | 180 | .auto-rotate { 181 | animation: rotate 2s linear infinite; 182 | } 183 | 184 | @keyframes rotate { 185 | 100% { 186 | transform: rotate(360deg); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /backend/services/preferences_service.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "cattail/backend/consts" 5 | storage2 "cattail/backend/storage" 6 | "cattail/backend/types" 7 | "context" 8 | "strings" 9 | "sync" 10 | 11 | runtime2 "github.com/wailsapp/wails/v2/pkg/runtime" 12 | ) 13 | 14 | type preferencesService struct { 15 | pref *storage2.PreferencesStorage 16 | clientVersion string 17 | } 18 | 19 | var preferences *preferencesService 20 | var oncePreferences sync.Once 21 | 22 | func Preferences() *preferencesService { 23 | if preferences == nil { 24 | oncePreferences.Do(func() { 25 | preferences = &preferencesService{ 26 | pref: storage2.NewPreferences(), 27 | clientVersion: "", 28 | } 29 | }) 30 | } 31 | return preferences 32 | } 33 | 34 | func (p *preferencesService) GetPreferences() (resp types.JSResp) { 35 | resp.Data = p.pref.GetPreferences() 36 | resp.Success = true 37 | return 38 | } 39 | 40 | func (p *preferencesService) SetPreferences(pf types.Preferences) (resp types.JSResp) { 41 | err := p.pref.SetPreferences(&pf) 42 | if err != nil { 43 | resp.Msg = err.Error() 44 | return 45 | } 46 | 47 | resp.Success = true 48 | return 49 | } 50 | 51 | func (p *preferencesService) UpdatePreferences(value map[string]any) (resp types.JSResp) { 52 | err := p.pref.UpdatePreferences(value) 53 | if err != nil { 54 | resp.Msg = err.Error() 55 | return 56 | } 57 | resp.Success = true 58 | return 59 | } 60 | 61 | func (p *preferencesService) RestorePreferences() (resp types.JSResp) { 62 | defaultPref := p.pref.RestoreDefault() 63 | resp.Data = map[string]any{ 64 | "pref": defaultPref, 65 | } 66 | resp.Success = true 67 | return 68 | } 69 | 70 | func (p *preferencesService) SetAppVersion(ver string) { 71 | if !strings.HasPrefix(ver, "v") { 72 | p.clientVersion = "v" + ver 73 | } else { 74 | p.clientVersion = ver 75 | } 76 | } 77 | 78 | func (p *preferencesService) GetAppVersion() (resp types.JSResp) { 79 | resp.Success = true 80 | resp.Data = map[string]any{ 81 | "version": p.clientVersion, 82 | } 83 | return 84 | } 85 | 86 | func (p *preferencesService) SaveWindowSize(width, height int, maximised bool) { 87 | if maximised { 88 | // do not update window size if maximised state 89 | p.UpdatePreferences(map[string]any{ 90 | "behavior.windowMaximised": true, 91 | }) 92 | } else if width >= consts.MIN_WINDOW_WIDTH && height >= consts.MIN_WINDOW_HEIGHT { 93 | p.UpdatePreferences(map[string]any{ 94 | "behavior.windowWidth": width, 95 | "behavior.windowHeight": height, 96 | "behavior.windowMaximised": false, 97 | }) 98 | } 99 | } 100 | 101 | func (p *preferencesService) GetWindowSize() (width, height int, maximised bool) { 102 | data := p.pref.GetPreferences() 103 | width, height, maximised = data.Behavior.WindowWidth, data.Behavior.WindowHeight, data.Behavior.WindowMaximised 104 | if width <= 0 { 105 | width = consts.DEFAULT_WINDOW_WIDTH 106 | } 107 | if height <= 0 { 108 | height = consts.DEFAULT_WINDOW_HEIGHT 109 | } 110 | return 111 | } 112 | 113 | func (p *preferencesService) GetWindowPosition(ctx context.Context) (x, y int) { 114 | data := p.pref.GetPreferences() 115 | x, y = data.Behavior.WindowPosX, data.Behavior.WindowPosY 116 | width, height := data.Behavior.WindowWidth, data.Behavior.WindowHeight 117 | var screenWidth, screenHeight int 118 | if screens, err := runtime2.ScreenGetAll(ctx); err == nil { 119 | for _, screen := range screens { 120 | if screen.IsCurrent { 121 | screenWidth, screenHeight = screen.Size.Width, screen.Size.Height 122 | break 123 | } 124 | } 125 | } 126 | if screenWidth <= 0 || screenHeight <= 0 { 127 | screenWidth, screenHeight = consts.DEFAULT_WINDOW_WIDTH, consts.DEFAULT_WINDOW_HEIGHT 128 | } 129 | if x <= 0 || x+width > screenWidth || y <= 0 || y+height > screenHeight { 130 | // out of screen, reset to center 131 | x, y = (screenWidth-width)/2, (screenHeight-height)/2 132 | } 133 | return 134 | } 135 | 136 | func (p *preferencesService) SaveWindowPosition(x, y int) { 137 | if x > 0 || y > 0 { 138 | p.UpdatePreferences(map[string]any{ 139 | "behavior.windowPosX": x, 140 | "behavior.windowPosY": y, 141 | }) 142 | } 143 | } 144 | 145 | func (p *preferencesService) GetScanSize() int { 146 | data := p.pref.GetPreferences() 147 | size := data.General.ScanSize 148 | if size <= 0 { 149 | size = consts.DEFAULT_SCAN_SIZE 150 | } 151 | return size 152 | } 153 | 154 | type latestRelease struct { 155 | Name string `json:"name"` 156 | TagName string `json:"tag_name"` 157 | Url string `json:"url"` 158 | HtmlUrl string `json:"html_url"` 159 | } 160 | -------------------------------------------------------------------------------- /frontend/src/assets/fonts/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com), 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module cattail 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/dgrr/tl v0.2.1 9 | github.com/energye/systray v1.0.2 10 | github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 11 | github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68 12 | github.com/wailsapp/wails/v2 v2.10.1 13 | golang.design/x/clipboard v0.7.0 14 | gopkg.in/yaml.v3 v3.0.1 15 | tailscale.com v1.82.5 16 | ) 17 | 18 | require ( 19 | filippo.io/edwards25519 v1.1.0 // indirect 20 | github.com/akutz/memconn v0.1.0 // indirect 21 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect 22 | github.com/bep/debounce v1.2.1 // indirect 23 | github.com/coder/websocket v1.8.13 // indirect 24 | github.com/coreos/go-iptables v0.8.0 // indirect 25 | github.com/dblohm7/wingoes v0.0.0-20240820181039-f2b84150679e // indirect 26 | github.com/fxamacker/cbor/v2 v2.8.0 // indirect 27 | github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1 // indirect 28 | github.com/go-ole/go-ole v1.3.0 // indirect 29 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect 30 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect 31 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 32 | github.com/google/go-cmp v0.7.0 // indirect 33 | github.com/google/nftables v0.3.0 // indirect 34 | github.com/google/uuid v1.6.0 // indirect 35 | github.com/gorilla/csrf v1.7.3 // indirect 36 | github.com/gorilla/securecookie v1.1.2 // indirect 37 | github.com/hdevalence/ed25519consensus v0.2.0 // indirect 38 | github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect 39 | github.com/jsimonetti/rtnetlink v1.4.2 // indirect 40 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 41 | github.com/labstack/echo/v4 v4.13.3 // indirect 42 | github.com/labstack/gommon v0.4.2 // indirect 43 | github.com/leaanthony/go-ansi-parser v1.6.1 // indirect 44 | github.com/leaanthony/gosod v1.0.4 // indirect 45 | github.com/leaanthony/slicer v1.6.0 // indirect 46 | github.com/leaanthony/u v1.1.1 // indirect 47 | github.com/mattn/go-colorable v0.1.14 // indirect 48 | github.com/mattn/go-isatty v0.0.20 // indirect 49 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect 50 | github.com/mdlayher/socket v0.5.1 // indirect 51 | github.com/miekg/dns v1.1.65 // indirect 52 | github.com/mitchellh/go-ps v1.0.0 // indirect 53 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect 54 | github.com/peterbourgon/ff/v3 v3.4.0 // indirect 55 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 56 | github.com/pkg/errors v0.9.1 // indirect 57 | github.com/rivo/uniseg v0.4.7 // indirect 58 | github.com/samber/lo v1.49.1 // indirect 59 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect 60 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect 61 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect 62 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect 63 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect 64 | github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect 65 | github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c // indirect 66 | github.com/tkrajina/go-reflector v0.5.8 // indirect 67 | github.com/toqueteos/webbrowser v1.2.0 // indirect 68 | github.com/valyala/bytebufferpool v1.0.0 // indirect 69 | github.com/valyala/fasttemplate v1.2.2 // indirect 70 | github.com/vishvananda/netns v0.0.5 // indirect 71 | github.com/wailsapp/go-webview2 v1.0.21 // indirect 72 | github.com/wailsapp/mimetype v1.4.1 // indirect 73 | github.com/x448/float16 v0.8.4 // indirect 74 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect 75 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect 76 | golang.org/x/crypto v0.37.0 // indirect 77 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 78 | golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 79 | golang.org/x/image v0.26.0 // indirect 80 | golang.org/x/mobile v0.0.0-20250408133729-978277e7eaf7 // indirect 81 | golang.org/x/mod v0.24.0 // indirect 82 | golang.org/x/net v0.39.0 // indirect 83 | golang.org/x/oauth2 v0.29.0 // indirect 84 | golang.org/x/sync v0.13.0 // indirect 85 | golang.org/x/sys v0.32.0 // indirect 86 | golang.org/x/text v0.24.0 // indirect 87 | golang.org/x/time v0.11.0 // indirect 88 | golang.org/x/tools v0.32.0 // indirect 89 | golang.zx2c4.com/wireguard/windows v0.5.3 // indirect 90 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect 91 | k8s.io/client-go v0.32.3 // indirect 92 | sigs.k8s.io/yaml v1.4.0 // indirect 93 | software.sslmate.com/src/go-pkcs12 v0.5.0 // indirect 94 | ) 95 | 96 | // replace github.com/wailsapp/wails/v2 v2.9.2 97 | -------------------------------------------------------------------------------- /frontend/src/stores/tailscale.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | // import { endsWith, get, isEmpty, map, now, size } from 'lodash' 3 | import { EventsEmit, EventsOnce } from 'wailsjs/runtime' 4 | import { 5 | Accounts, 6 | CopyClipboard, 7 | RemoveFile, 8 | AcceptFile, 9 | CurrentAccount, 10 | Namespaces, 11 | Files, 12 | Self, 13 | SetExitNode, 14 | SwitchTo, 15 | UploadFile, 16 | AdvertiseExitNode, 17 | AdvertiseRoutes, 18 | AllowLANAccess, 19 | AcceptRoutes, 20 | RunSSH, 21 | SetControlURL, 22 | Start, 23 | Stop, 24 | GetStatus, 25 | UpdateStatus, 26 | } from 'wailsjs/go/services/tailScaleService.js' 27 | // import humanizeDuration from 'humanize-duration' 28 | 29 | const useTailScaleStore = defineStore('tailScaleStore', { 30 | state: () => ({ 31 | account: '', 32 | otherAccounts: [], 33 | files: [], 34 | namespaces: null, 35 | self: {}, 36 | selectedPeer: null, 37 | appRunning: true, 38 | timer: null, 39 | }), 40 | actions: { 41 | async load() { 42 | if (!this.appRunning) { 43 | return 44 | } 45 | 46 | this.account = await CurrentAccount() 47 | this.otherAccounts = await Accounts() 48 | this.files = await Files() 49 | this.namespaces = await Namespaces() 50 | this.self = await Self() 51 | if (this.selectedPeer === null) { 52 | this.selectedPeer = this.self 53 | } else { 54 | this.namespaces.forEach((namespace) => { 55 | namespace.peers.forEach((peer) => { 56 | if (peer.dns_name === this.selectedPeer.dns_name) { 57 | this.selectedPeer = peer 58 | } 59 | }) 60 | }) 61 | } 62 | }, 63 | async start() { 64 | await Start() 65 | }, 66 | async stop() { 67 | await Stop() 68 | }, 69 | async getStatus() { 70 | return await GetStatus() 71 | }, 72 | async updateStatus(prevOnline) { 73 | return await UpdateStatus(prevOnline) 74 | }, 75 | async switchAccount(name) { 76 | await SwitchTo(name) 77 | }, 78 | async setExitNode(event) { 79 | event.target.disabled = true 80 | EventsOnce('exit_node_connect', () => { 81 | event.target.disabled = false 82 | }) 83 | await SetExitNode(this.selectedPeer.dns_name) 84 | }, 85 | async advertiseExitNode(event) { 86 | event.target.disabled = true 87 | EventsOnce('advertise_exit_node_done', async () => { 88 | event.target.disabled = false 89 | this.self = await Self() 90 | }) 91 | await AdvertiseExitNode(this.selectedPeer.dns_name) 92 | }, 93 | async advertiseRoutes(routes) { 94 | await AdvertiseRoutes(routes) 95 | }, 96 | async allowLANAccess(allow) { 97 | await AllowLANAccess(allow) 98 | }, 99 | async acceptRoutes(accept) { 100 | await AcceptRoutes(accept) 101 | }, 102 | async runSSH(run) { 103 | await RunSSH(run) 104 | }, 105 | async setControlURL(controlURL) { 106 | await SetControlURL(controlURL) 107 | }, 108 | async acceptFile(name) { 109 | await AcceptFile(name) 110 | }, 111 | async rejectFile(name) { 112 | await RemoveFile(name) 113 | }, 114 | async copyClipboard(name) { 115 | await CopyClipboard(name) 116 | }, 117 | async sendFile(name) { 118 | await UploadFile(name) 119 | }, 120 | dateDiff(ref) { 121 | const date = new Date(ref) 122 | const now = new Date() 123 | const res = (now - date) / 1000 124 | // const res = Math.round(now - date) 125 | // return humanizeDuration(res, { units: ["d"], round: true}) + ' ago' 126 | if (res < 3600) { 127 | return Math.round(res / 60) + ' minutes ago' 128 | } else if (res < 86400) { 129 | return Math.round(res / 3600) + ' hours ago' 130 | } else { 131 | return Math.round(res / 86400) + ' days ago' 132 | } 133 | }, 134 | humanFileSize(bytes, si = false, dp = 1) { 135 | const thresh = si ? 1000 : 1024 136 | 137 | if (Math.abs(bytes) < thresh) { 138 | return bytes + ' B' 139 | } 140 | 141 | const units = si 142 | ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] 143 | : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] 144 | let u = -1 145 | const r = 10 ** dp 146 | 147 | do { 148 | bytes /= thresh 149 | ++u 150 | } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1) 151 | 152 | return bytes.toFixed(dp) + ' ' + units[u] 153 | }, 154 | }, 155 | }) 156 | 157 | export default useTailScaleStore 158 | -------------------------------------------------------------------------------- /frontend/src/components/sidebar/BrowserPane.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 113 | 114 | -------------------------------------------------------------------------------- /frontend/src/stores/preferences.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { lang } from '@/langs/index.js' 3 | import { cloneDeep, findIndex, get, isEmpty, join, map, merge, pick, set, some, split } from 'lodash' 4 | import { 5 | GetPreferences, 6 | RestorePreferences, 7 | SetPreferences, 8 | } from 'wailsjs/go/services/preferencesService.js' 9 | import { BrowserOpenURL } from 'wailsjs/runtime/runtime.js' 10 | import { i18nGlobal } from '@/utils/i18n.js' 11 | import { enUS, NButton, NSpace, useOsTheme, zhCN } from 'naive-ui' 12 | import { h, nextTick } from 'vue' 13 | import { compareVersion } from '@/utils/version.js' 14 | 15 | const osTheme = useOsTheme() 16 | const usePreferencesStore = defineStore('preferences', { 17 | /** 18 | * @typedef {Object} Preferences 19 | * @property {Object} general 20 | * @property {Object} editor 21 | * @property {FontItem[]} fontList 22 | */ 23 | /** 24 | * 25 | * @returns {Preferences} 26 | */ 27 | state: () => ({ 28 | behavior: { 29 | asideWidth: 300, 30 | windowWidth: 0, 31 | windowHeight: 0, 32 | windowMaximised: false, 33 | }, 34 | general: { 35 | theme: 'auto', 36 | language: 'auto', 37 | font: '', 38 | fontFamily: [], 39 | fontSize: 14, 40 | scanSize: 3000, 41 | keyIconStyle: 0, 42 | useSysProxy: false, 43 | useSysProxyHttp: false, 44 | skipVersion: '', 45 | }, 46 | editor: { 47 | font: '', 48 | fontFamily: [], 49 | fontSize: 14, 50 | showLineNum: true, 51 | showFolding: true, 52 | dropText: true, 53 | links: true, 54 | }, 55 | cli: { 56 | fontFamily: [], 57 | fontSize: 14, 58 | cursorStyle: 'block', 59 | }, 60 | buildInDecoder: [], 61 | decoder: [], 62 | lastPref: {}, 63 | fontList: [], 64 | }), 65 | getters: { 66 | getSeparator() { 67 | return ':' 68 | }, 69 | 70 | themeOption() { 71 | return [ 72 | { 73 | value: 'light', 74 | label: 'preferences.general.theme_light', 75 | }, 76 | { 77 | value: 'dark', 78 | label: 'preferences.general.theme_dark', 79 | }, 80 | { 81 | value: 'auto', 82 | label: 'preferences.general.theme_auto', 83 | }, 84 | ] 85 | }, 86 | 87 | /** 88 | * all available language 89 | * @returns {{label: string, value: string}[]} 90 | */ 91 | langOption() { 92 | const options = Object.entries(lang).map(([key, value]) => ({ 93 | value: key, 94 | label: value['name'], 95 | })) 96 | options.splice(0, 0, { 97 | value: 'auto', 98 | label: 'preferences.general.system_lang', 99 | }) 100 | return options 101 | }, 102 | /** 103 | * get current language setting 104 | * @return {string} 105 | */ 106 | currentLanguage() { 107 | let lang = get(this.general, 'language', 'auto') 108 | if (lang === 'auto') { 109 | const systemLang = navigator.language || navigator.userLanguage 110 | lang = split(systemLang, '-')[0] 111 | } 112 | return lang || 'en' 113 | }, 114 | 115 | isDark() { 116 | const th = get(this.general, 'theme', 'auto') 117 | if (th !== 'auto') { 118 | return th === 'dark' 119 | } else { 120 | return osTheme.value === 'dark' 121 | } 122 | }, 123 | 124 | themeLocale() { 125 | const lang = this.currentLanguage 126 | switch (lang) { 127 | case 'zh': 128 | return zhCN 129 | default: 130 | return enUS 131 | } 132 | }, 133 | 134 | showLineNum() { 135 | return get(this.editor, 'showLineNum', true) 136 | }, 137 | 138 | showFolding() { 139 | return get(this.editor, 'showFolding', true) 140 | }, 141 | 142 | dropText() { 143 | return get(this.editor, 'dropText', true) 144 | }, 145 | 146 | editorLinks() { 147 | return get(this.editor, 'links', true) 148 | }, 149 | }, 150 | actions: { 151 | _applyPreferences(data) { 152 | for (const key in data) { 153 | set(this, key, data[key]) 154 | } 155 | }, 156 | 157 | /** 158 | * load preferences from local 159 | * @returns {Promise} 160 | */ 161 | async loadPreferences() { 162 | const { success, data } = await GetPreferences() 163 | if (success) { 164 | this.lastPref = cloneDeep(data) 165 | this._applyPreferences(data) 166 | // default value 167 | const showLineNum = get(data, 'editor.showLineNum') 168 | if (showLineNum === undefined) { 169 | set(data, 'editor.showLineNum', true) 170 | } 171 | const showFolding = get(data, 'editor.showFolding') 172 | if (showFolding === undefined) { 173 | set(data, 'editor.showFolding', true) 174 | } 175 | const dropText = get(data, 'editor.dropText') 176 | if (dropText === undefined) { 177 | set(data, 'editor.dropText', true) 178 | } 179 | const links = get(data, 'editor.links') 180 | if (links === undefined) { 181 | set(data, 'editor.links', true) 182 | } 183 | i18nGlobal.locale.value = this.currentLanguage 184 | } 185 | }, 186 | 187 | /** 188 | * save preferences to local 189 | * @returns {Promise} 190 | */ 191 | async savePreferences() { 192 | const pf = pick(this, ['behavior', 'general', 'editor', 'cli', 'decoder']) 193 | const { success, msg } = await SetPreferences(pf) 194 | return success === true 195 | }, 196 | 197 | /** 198 | * reset to last-loaded preferences 199 | * @returns {Promise} 200 | */ 201 | async resetToLastPreferences() { 202 | if (!isEmpty(this.lastPref)) { 203 | this._applyPreferences(this.lastPref) 204 | } 205 | }, 206 | 207 | /** 208 | * restore preferences to default 209 | * @returns {Promise} 210 | */ 211 | async restorePreferences() { 212 | const { success, data } = await RestorePreferences() 213 | if (success === true) { 214 | const { pref } = data 215 | this._applyPreferences(pref) 216 | return true 217 | } 218 | return false 219 | }, 220 | }, 221 | }) 222 | 223 | export default usePreferencesStore 224 | -------------------------------------------------------------------------------- /backend/utils/slice/slice_util.go: -------------------------------------------------------------------------------- 1 | package sliceutil 2 | 3 | import ( 4 | . "cattail/backend/utils" 5 | "sort" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | func Get[S ~[]T, T any](arr S, index int, defaultVal T) T { 11 | if index < 0 || index >= len(arr) { 12 | return defaultVal 13 | } 14 | return arr[index] 15 | } 16 | 17 | func Remove[S ~[]T, T any](arr S, index int) S { 18 | return append(arr[:index], arr[index+1:]...) 19 | } 20 | 21 | func RemoveIf[S ~[]T, T any](arr S, cond func(T) bool) S { 22 | l := len(arr) 23 | if l <= 0 { 24 | return arr 25 | } 26 | for i := l - 1; i >= 0; i-- { 27 | if cond(arr[i]) { 28 | arr = append(arr[:i], arr[i+1:]...) 29 | } 30 | } 31 | return arr 32 | } 33 | 34 | func RemoveRange[S ~[]T, T any](arr S, from, to int) S { 35 | return append(arr[:from], arr[to:]...) 36 | } 37 | 38 | func Find[S ~[]T, T any](arr S, matchFunc func(int) bool) (int, bool) { 39 | total := len(arr) 40 | for i := 0; i < total; i++ { 41 | if matchFunc(i) { 42 | return i, true 43 | } 44 | } 45 | return -1, false 46 | } 47 | 48 | func AnyMatch[S ~[]T, T any](arr S, matchFunc func(int) bool) bool { 49 | total := len(arr) 50 | if total > 0 { 51 | for i := 0; i < total; i++ { 52 | if matchFunc(i) { 53 | return true 54 | } 55 | } 56 | } 57 | return false 58 | } 59 | 60 | func AllMatch[S ~[]T, T any](arr S, matchFunc func(int) bool) bool { 61 | total := len(arr) 62 | for i := 0; i < total; i++ { 63 | if !matchFunc(i) { 64 | return false 65 | } 66 | } 67 | return true 68 | } 69 | 70 | func Equals[S ~[]T, T comparable](arr1, arr2 S) bool { 71 | if &arr1 == &arr2 { 72 | return true 73 | } 74 | 75 | len1, len2 := len(arr1), len(arr2) 76 | if len1 != len2 { 77 | return false 78 | } 79 | for i := 0; i < len1; i++ { 80 | if arr1[i] != arr2[i] { 81 | return false 82 | } 83 | } 84 | return true 85 | } 86 | 87 | func Contains[S ~[]T, T Hashable](arr S, elem T) bool { 88 | return AnyMatch(arr, func(idx int) bool { 89 | return arr[idx] == elem 90 | }) 91 | } 92 | 93 | func ContainsAny[S ~[]T, T Hashable](arr S, elems ...T) bool { 94 | for _, elem := range elems { 95 | if Contains(arr, elem) { 96 | return true 97 | } 98 | } 99 | return false 100 | } 101 | 102 | func ContainsAll[S ~[]T, T Hashable](arr S, elems ...T) bool { 103 | for _, elem := range elems { 104 | if !Contains(arr, elem) { 105 | return false 106 | } 107 | } 108 | return true 109 | } 110 | 111 | func Filter[S ~[]T, T any](arr S, filterFunc func(int) bool) []T { 112 | total := len(arr) 113 | var result []T 114 | for i := 0; i < total; i++ { 115 | if filterFunc(i) { 116 | result = append(result, arr[i]) 117 | } 118 | } 119 | return result 120 | } 121 | 122 | func Map[S ~[]T, T any, R any](arr S, mappingFunc func(int) R) []R { 123 | total := len(arr) 124 | result := make([]R, total) 125 | for i := 0; i < total; i++ { 126 | result[i] = mappingFunc(i) 127 | } 128 | return result 129 | } 130 | 131 | func FilterMap[S ~[]T, T any, R any](arr S, mappingFunc func(int) (R, bool)) []R { 132 | total := len(arr) 133 | result := make([]R, 0, total) 134 | var filter bool 135 | var mapItem R 136 | for i := 0; i < total; i++ { 137 | if mapItem, filter = mappingFunc(i); filter { 138 | result = append(result, mapItem) 139 | } 140 | } 141 | return result 142 | } 143 | 144 | func ToMap[S ~[]T, T any, K Hashable, V any](arr S, mappingFunc func(int) (K, V)) map[K]V { 145 | total := len(arr) 146 | result := map[K]V{} 147 | for i := 0; i < total; i++ { 148 | key, val := mappingFunc(i) 149 | result[key] = val 150 | } 151 | return result 152 | } 153 | 154 | func Flat[T any](arr [][]T) []T { 155 | total := len(arr) 156 | var result []T 157 | for i := 0; i < total; i++ { 158 | subTotal := len(arr[i]) 159 | for j := 0; j < subTotal; j++ { 160 | result = append(result, arr[i][j]) 161 | } 162 | } 163 | return result 164 | } 165 | 166 | func FlatMap[T any, R any](arr [][]T, mappingFunc func(int, int) R) []R { 167 | total := len(arr) 168 | var result []R 169 | for i := 0; i < total; i++ { 170 | subTotal := len(arr[i]) 171 | for j := 0; j < subTotal; j++ { 172 | result = append(result, mappingFunc(i, j)) 173 | } 174 | } 175 | return result 176 | } 177 | 178 | func FlatValueMap[T Hashable](arr [][]T) []T { 179 | return FlatMap(arr, func(i, j int) T { 180 | return arr[i][j] 181 | }) 182 | } 183 | 184 | func Reduce[S ~[]T, T any, R any](arr S, init R, reduceFunc func(R, T) R) R { 185 | result := init 186 | for _, item := range arr { 187 | result = reduceFunc(result, item) 188 | } 189 | return result 190 | } 191 | 192 | func Reverse[S ~[]T, T any](arr S) S { 193 | total := len(arr) 194 | for i := 0; i < total/2; i++ { 195 | arr[i], arr[total-i-1] = arr[total-i-1], arr[i] 196 | } 197 | return arr 198 | } 199 | 200 | func Join[S ~[]T, T any](arr S, sep string, toStringFunc func(int) string) string { 201 | total := len(arr) 202 | if total <= 0 { 203 | return "" 204 | } 205 | if total == 1 { 206 | return toStringFunc(0) 207 | } 208 | 209 | sb := strings.Builder{} 210 | for i := 0; i < total; i++ { 211 | if i != 0 { 212 | sb.WriteString(sep) 213 | } 214 | sb.WriteString(toStringFunc(i)) 215 | } 216 | return sb.String() 217 | } 218 | 219 | func JoinString(arr []string, sep string) string { 220 | return Join(arr, sep, func(idx int) string { 221 | return arr[idx] 222 | }) 223 | } 224 | 225 | func JoinInt(arr []int, sep string) string { 226 | return Join(arr, sep, func(idx int) string { 227 | return strconv.Itoa(arr[idx]) 228 | }) 229 | } 230 | 231 | func Unique[S ~[]T, T Hashable](arr S) S { 232 | result := make(S, 0, len(arr)) 233 | uniKeys := map[T]struct{}{} 234 | var exists bool 235 | for _, item := range arr { 236 | if _, exists = uniKeys[item]; !exists { 237 | uniKeys[item] = struct{}{} 238 | result = append(result, item) 239 | } 240 | } 241 | return result 242 | } 243 | 244 | func UniqueEx[S ~[]T, T any](arr S, toKeyFunc func(i int) string) S { 245 | result := make(S, 0, len(arr)) 246 | keyArr := Map(arr, toKeyFunc) 247 | uniKeys := map[string]struct{}{} 248 | var exists bool 249 | for i, item := range arr { 250 | if _, exists = uniKeys[keyArr[i]]; !exists { 251 | uniKeys[keyArr[i]] = struct{}{} 252 | result = append(result, item) 253 | } 254 | } 255 | return result 256 | } 257 | 258 | func Sort[S ~[]T, T Hashable](arr S) S { 259 | sort.Slice(arr, func(i, j int) bool { 260 | return arr[i] <= arr[j] 261 | }) 262 | return arr 263 | } 264 | 265 | func SortDesc[S ~[]T, T Hashable](arr S) S { 266 | sort.Slice(arr, func(i, j int) bool { 267 | return arr[i] > arr[j] 268 | }) 269 | return arr 270 | } 271 | 272 | func Union[S ~[]T, T Hashable](arr1 S, arr2 S) S { 273 | hashArr, compArr := arr1, arr2 274 | if len(arr1) < len(arr2) { 275 | hashArr, compArr = compArr, hashArr 276 | } 277 | hash := map[T]struct{}{} 278 | for _, item := range hashArr { 279 | hash[item] = struct{}{} 280 | } 281 | 282 | uniq := map[T]struct{}{} 283 | ret := make(S, 0, len(compArr)) 284 | exists := false 285 | for _, item := range compArr { 286 | if _, exists = hash[item]; exists { 287 | if _, exists = uniq[item]; !exists { 288 | ret = append(ret, item) 289 | uniq[item] = struct{}{} 290 | } 291 | } 292 | } 293 | return ret 294 | } 295 | 296 | func Exclude[S ~[]T, T Hashable](arr1 S, arr2 S) S { 297 | diff := make([]T, 0, len(arr1)) 298 | hash := map[T]struct{}{} 299 | for _, item := range arr2 { 300 | hash[item] = struct{}{} 301 | } 302 | 303 | for _, item := range arr1 { 304 | if _, exists := hash[item]; !exists { 305 | diff = append(diff, item) 306 | } 307 | } 308 | return diff 309 | } 310 | 311 | func PadLeft[S ~[]T, T any](arr S, val T, count int) S { 312 | prefix := make(S, count) 313 | for i := 0; i < count; i++ { 314 | prefix[i] = val 315 | } 316 | arr = append(prefix, arr...) 317 | return arr 318 | } 319 | 320 | func PadRight[S ~[]T, T any](arr S, val T, count int) S { 321 | for i := 0; i < count; i++ { 322 | arr = append(arr, val) 323 | } 324 | return arr 325 | } 326 | 327 | func RemoveLeft[S ~[]T, T comparable](arr S, val T) S { 328 | for len(arr) > 0 && arr[0] == val { 329 | arr = arr[1:] 330 | } 331 | return arr 332 | } 333 | 334 | func RemoveRight[S ~[]T, T comparable](arr S, val T) S { 335 | for { 336 | length := len(arr) 337 | if length > 0 && arr[length-1] == val { 338 | arr = arr[:length] 339 | } else { 340 | break 341 | } 342 | } 343 | return arr 344 | } 345 | 346 | func Count[S ~[]T, T any](arr S, filter func(int) bool) int { 347 | count := 0 348 | for i := range arr { 349 | if filter(i) { 350 | count += 1 351 | } 352 | } 353 | return count 354 | } 355 | 356 | func Group[S ~[]T, T any, K Hashable, R any](arr S, groupFunc func(int) (K, R)) map[K][]R { 357 | ret := map[K][]R{} 358 | for i := range arr { 359 | key, val := groupFunc(i) 360 | ret[key] = append(ret[key], val) 361 | } 362 | return ret 363 | } 364 | -------------------------------------------------------------------------------- /frontend/src/AppContent.vue: -------------------------------------------------------------------------------- 1 | 126 | 127 | 201 | 202 | 256 | -------------------------------------------------------------------------------- /frontend/src/components/content/ContentPane.vue: -------------------------------------------------------------------------------- 1 | 136 | 137 | 381 | 382 | 392 | 393 | -------------------------------------------------------------------------------- /frontend/src/langs/en-us.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "English", 3 | "common": { 4 | "confirm": "Confirm", 5 | "cancel": "Cancel", 6 | "success": "Success", 7 | "warning": "Warning", 8 | "error": "Error", 9 | "save": "Save", 10 | "update": "Update", 11 | "none": "None", 12 | "second": "Second(s)", 13 | "minute": "Minute(s)", 14 | "hour": "Hour(s)", 15 | "day": "Day(s)", 16 | "unit_day": "d", 17 | "unit_hour": "h", 18 | "unit_minute": "m", 19 | "unit_second": "s", 20 | "all": "All", 21 | "key": "Key", 22 | "value": "Value", 23 | "field": "Field", 24 | "score": "Score", 25 | "index": "Position" 26 | }, 27 | "preferences": { 28 | "name": "Preferences", 29 | "restore_defaults": "Restore Defaults", 30 | "font_tip": "Supports multi-selection. Manually input the font if it's not listed.", 31 | "general": { 32 | "name": "General", 33 | "theme": "Theme", 34 | "theme_light": "Light", 35 | "theme_dark": "Dark", 36 | "theme_auto": "Auto", 37 | "language": "Language", 38 | "system_lang": "Use System Language", 39 | "font": "Font", 40 | "font_tip": "Select or input font name", 41 | "font_size": "Font Size", 42 | "scan_size": "Default Size for SCAN", 43 | "key_icon_style": "Key Icon Style", 44 | "key_icon_style0": "Compact", 45 | "key_icon_style1": "Full Name", 46 | "key_icon_style2": "Dot", 47 | "key_icon_style3": "Common", 48 | "update": "Update", 49 | "auto_check_update": "Auto check for updates" 50 | }, 51 | "editor": { 52 | "name": "Editor", 53 | "show_linenum": "Show Line Numbers", 54 | "show_folding": "Enable Code Folding", 55 | "drop_text": "Allow Drag & Drop Text", 56 | "links": "Support Links" 57 | }, 58 | "cli": { 59 | "name": "Command Line", 60 | "cursor_style": "Cursor Style", 61 | "cursor_style_block": "Block", 62 | "cursor_style_underline": "Underline", 63 | "cursor_style_bar": "Bar" 64 | }, 65 | "decoder": { 66 | "name": "Custom Decoder", 67 | "new": "New Decoder", 68 | "decoder_name": "Name", 69 | "cmd_preview": "Preview", 70 | "status": "Status", 71 | "auto_enabled": "Auto Decoding Enabled", 72 | "help": "Help" 73 | } 74 | }, 75 | "interface": { 76 | "new_conn": "Add Connection", 77 | "new_group": "Add Group", 78 | "disconnect_all": "Disconnect All", 79 | "status": "Status", 80 | "filter": "Filter", 81 | "sort_conn": "Sort Connections", 82 | "new_conn_title": "New Connection", 83 | "open_db": "Open Database", 84 | "close_db": "Close Database", 85 | "filter_key": "Filter Keys", 86 | "disconnect": "Disconnect", 87 | "dup_conn": "Duplicate Connection", 88 | "remove_conn": "Remove Connection", 89 | "edit_conn": "Edit Connection", 90 | "edit_conn_group": "Edit Group", 91 | "rename_conn_group": "Rename Group", 92 | "remove_conn_group": "Remove Group", 93 | "import_conn": "Import Connections...", 94 | "export_conn": "Export Connections...", 95 | "ttl": "TTL", 96 | "forever": "Forever", 97 | "rename_key": "Rename Key", 98 | "delete_key": "Delete Key", 99 | "batch_delete_key": "Batch Delete Keys", 100 | "import_key": "Import Keys", 101 | "flush_db": "Flush Database", 102 | "check_mode": "Check Mode", 103 | "quit_check_mode": "Exit Check Mode", 104 | "delete_checked": "Delete Checked", 105 | "export_checked": "Export Checked", 106 | "ttl_checked": "Update TTL for Checked", 107 | "copy_value": "Copy Value", 108 | "edit_value": "Edit Value", 109 | "save_update": "Save Changes", 110 | "score_filter_tip": "Support operators:\n= equal\n!= not equal\n> greater than\n>= greater than or equal\n< less than\n<= less than or equal\ne.g. >3 for scores greater than 3", 111 | "add_row": "Insert Row", 112 | "edit_row": "Edit Row", 113 | "delete_row": "Delete Row", 114 | "fullscreen": "Full Screen", 115 | "offscreen": "Exit Full Screen", 116 | "pin_edit": "Pin (Stay open after save)", 117 | "unpin_edit": "Unpin", 118 | "search": "Search", 119 | "full_search": "Full Text Search", 120 | "full_search_result": "Content matched '{pattern}'", 121 | "filter_field": "Filter Field", 122 | "filter_value": "Filter Value", 123 | "length": "Length", 124 | "entries": "Entries", 125 | "memory_usage": "Memory Usage", 126 | "view_as": "View As", 127 | "decode_with": "Decode / Decompress", 128 | "custom_decoder": "New Custom Decoder", 129 | "reload": "Reload", 130 | "reload_disable": "Reload after fully loaded", 131 | "auto_refresh": "Auto Refresh", 132 | "refresh_interval": "Refresh Interval", 133 | "open_connection": "Open Connection", 134 | "copy_path": "Copy Path", 135 | "copy_key": "Copy Key", 136 | "save_value_succ": "Value Saved!", 137 | "copy_succ": "Copied to Clipboard!", 138 | "binary_key": "Binary Key Name", 139 | "remove_key": "Remove Key", 140 | "new_key": "New Key", 141 | "load_more": "Load More Keys", 142 | "load_all": "Load All Left Keys", 143 | "load_more_entries": "Load More", 144 | "load_all_entries": "Load All", 145 | "more_action": "More Actions", 146 | "nonexist_tab_content": "Selected key does not exist or none selected. Retry after refresh.", 147 | "empty_server_content": "Select and open a connection from the left panel", 148 | "empty_server_list": "No Redis server added", 149 | "action": "Action", 150 | "type": "Type", 151 | "cli_welcome": "Welcome to Tiny RDM Redis Console", 152 | "retrieving_version": "Checking for updates", 153 | "sub_tab": { 154 | "status": "Status", 155 | "key_detail": "Key Detail", 156 | "cli": "Console", 157 | "slow_log": "Slow Log", 158 | "cmd_monitor": "Monitor Commands", 159 | "pub_message": "Pub/Sub" 160 | } 161 | }, 162 | "ribbon": { 163 | "server": "Server", 164 | "browser": "Data Browser", 165 | "log": "Log", 166 | "wechat_official": "WeChat Official Account", 167 | "follow_x": "Follow \uD835\uDD4F", 168 | "github": "Github" 169 | }, 170 | "dialogue": { 171 | "close_confirm": "Close this connection ({name})?", 172 | "edit_close_confirm": "Please close relevant connections before editing. Continue?", 173 | "opening_connection": "Opening Connection...", 174 | "interrupt_connection": "Cancel", 175 | "remove_tip": "{type} \"{name}\" will be deleted", 176 | "remove_group_tip": "Group \"{name}\" and all its connections will be deleted", 177 | "rename_binary_key_fail": "Renaming binary key is not supported", 178 | "handle_succ": "Success!", 179 | "handle_cancel": "Operation canceled.", 180 | "reload_succ": "Reloaded!", 181 | "field_required": "This field is required", 182 | "spec_field_required": "\"{key}\" is required", 183 | "illegal_characters": "Contains illegal characters", 184 | "connection": { 185 | "new_title": "New Connection", 186 | "edit_title": "Edit Connection", 187 | "general": "General", 188 | "no_group": "No Group", 189 | "group": "Group", 190 | "conn_name": "Name", 191 | "addr": "Address", 192 | "usr": "Username", 193 | "pwd": "Password", 194 | "name_tip": "Connection name", 195 | "addr_tip": "Redis server address", 196 | "sock_tip": "Redis unix socket file", 197 | "usr_tip": "(Optional) Auth username", 198 | "pwd_tip": "(Optional) Auth password (Redis > 6.0)", 199 | "test": "Test Connection", 200 | "test_succ": "Successfully connected to Redis server", 201 | "test_fail": "Connection failed", 202 | "parse_url_clipboard": "Parse URL from Clipboard", 203 | "parse_pass": "Redis URL parsed: {url}", 204 | "parse_fail": "Failed to parse Redis URL: {reason}", 205 | "advn": { 206 | "title": "Advanced", 207 | "filter": "Default Key Filter", 208 | "filter_tip": "Pattern to filter loaded keys", 209 | "separator": "Key Separator", 210 | "separator_tip": "Separator for key path segments", 211 | "conn_timeout": "Connection Timeout", 212 | "exec_timeout": "Execution Timeout", 213 | "dbfilter_type": "Database Filter", 214 | "dbfilter_all": "Show All", 215 | "dbfilter_show": "Show Selected", 216 | "dbfilter_hide": "Hide Selected", 217 | "dbfilter_show_title": "Databases to Show", 218 | "dbfilter_hide_title": "Databases to Hide", 219 | "dbfilter_input": "Input Database Index", 220 | "dbfilter_input_tip": "Press Enter to confirm", 221 | "key_view": "Default Key View", 222 | "key_view_tree": "Tree View", 223 | "key_view_list": "List View", 224 | "load_size": "Keys Per Load", 225 | "mark_color": "Mark Color" 226 | }, 227 | "alias": { 228 | "title": "Database Alias", 229 | "db": "Input Database Index", 230 | "value": "Input Database Alias" 231 | }, 232 | "ssl": { 233 | "title": "SSL/TLS", 234 | "enable": "Enable SSL/TLS", 235 | "allow_insecure": "Allow Insecure", 236 | "sni": "Server Name (SNI)", 237 | "sni_tip": "(Optional) Server name", 238 | "cert_file": "Public Key File", 239 | "key_file": "Private Key File", 240 | "ca_file": "CA File", 241 | "cert_file_tip": "Public Key File in PEM format(Cert)", 242 | "key_file_tip": "Private Key File in PEM format(Key)", 243 | "ca_file_tip": "Certificate Authority File in PEM format(CA)" 244 | }, 245 | "ssh": { 246 | "enable": "Enable SSH Tunnel", 247 | "title": "SSH Tunnel", 248 | "login_type": "Login Type", 249 | "pkfile": "Private Key File", 250 | "passphrase": "Passphrase", 251 | "addr_tip": "SSH Server Address", 252 | "usr_tip": "SSH Username", 253 | "pwd_tip": "SSH Password", 254 | "pkfile_tip": "SSH private key file path", 255 | "passphrase_tip": "(Optional) Passphrase for private key" 256 | }, 257 | "sentinel": { 258 | "title": "Sentinel", 259 | "enable": "As Sentinel Node", 260 | "master": "Master Group Name", 261 | "auto_discover": "Auto Discover", 262 | "password": "Master Password", 263 | "username": "Master Username", 264 | "pwd_tip": "(Optional) Master auth username", 265 | "usr_tip": "(Optional) Master auth password (Redis > 6.0)" 266 | }, 267 | "cluster": { 268 | "title": "Cluster", 269 | "enable": "As Cluster Node" 270 | }, 271 | "proxy": { 272 | "title": "Proxy", 273 | "type_none": "No Proxy", 274 | "type_system": "System Proxy", 275 | "type_custom": "Manual Proxy", 276 | "host": "Hostname", 277 | "auth": "Proxy Authentication", 278 | "usr_tip": "Proxy auth username", 279 | "pwd_tip": "Proxy auth password" 280 | } 281 | }, 282 | "group": { 283 | "name": "Group Name", 284 | "rename": "Rename Group", 285 | "new": "New Group" 286 | }, 287 | "key": { 288 | "new": "New Key", 289 | "new_name": "New Key Name", 290 | "server": "Connection", 291 | "db_index": "Database Index", 292 | "key_expression": "Key Pattern", 293 | "affected_key": "Affected Keys", 294 | "show_affected_key": "Show Affected Keys", 295 | "confirm_delete_key": "Confirm delete {num} key(s)", 296 | "async_delete": "Async Execution", 297 | "async_delete_title": "Don't wait for result", 298 | "confirm_flush": "I know what I'm doing!", 299 | "confirm_flush_db": "Confirm flush database" 300 | }, 301 | "delete": { 302 | "success": "\"{key}\" deleted", 303 | "deleting": "Deleting", 304 | "doing": "Deleting key ({index}/{count})", 305 | "completed": "Deletion completed, {success} succeeded, {fail} failed" 306 | }, 307 | "field": { 308 | "new": "New Field", 309 | "new_item": "New Item", 310 | "conflict_handle": "On Field Conflict", 311 | "overwrite_field": "Overwrite", 312 | "ignore_field": "Ignore", 313 | "insert_type": "Insert Type", 314 | "append_item": "Append", 315 | "prepend_item": "Prepend", 316 | "enter_key": "Enter Key", 317 | "enter_value": "Enter Value", 318 | "enter_field": "Enter Field Name", 319 | "enter_elem": "Enter Element", 320 | "enter_member": "Enter Member", 321 | "enter_score": "Enter Score", 322 | "element": "Element", 323 | "reload_when_succ": "Reload immediately if success" 324 | }, 325 | "filter": { 326 | "set_key_filter": "Set Key Filter", 327 | "filter_pattern": "Pattern", 328 | "filter_pattern_tip": "* matches 0 or more chars, e.g. 'key*' \n? matches single char, e.g. 'key?'\n[] matches range, e.g. 'key[1-3]'\n\\ escapes special chars" 329 | }, 330 | "export": { 331 | "name": "Export Data", 332 | "export_expire_title": "Expiration", 333 | "export_expire": "Include Expiration", 334 | "export": "Export", 335 | "save_file": "Export Path", 336 | "save_file_tip": "Select path to save exported file", 337 | "exporting": "Exporting keys ({index}/{count})", 338 | "export_completed": "Export completed, {success} succeeded, {fail} failed" 339 | }, 340 | "import": { 341 | "name": "Import Data", 342 | "import_expire_title": "Expiration", 343 | "import": "Import", 344 | "reload": "Reload After Import", 345 | "open_csv_file": "Import File", 346 | "open_csv_file_tip": "Select file to import", 347 | "conflict_handle": "On Key Conflict", 348 | "conflict_overwrite": "Overwrite", 349 | "conflict_ignore": "Ignore", 350 | "ttl_include": "Import From File", 351 | "ttl_ignore": "Do Not Set", 352 | "ttl_custom": "Custom", 353 | "importing": "Importing keys imported/overwritten:{imported} conflict/failed:{conflict}", 354 | "import_completed": "Import completed, {success} succeeded, {ignored} ignored" 355 | }, 356 | "ttl": { 357 | "title": "Update TTL", 358 | "title_batch": "Batch Update TTL ({count})", 359 | "quick_set": "Quick Set", 360 | "success": "TTL updated for all keys" 361 | }, 362 | "decoder": { 363 | "name": "New Decoder/Encoder", 364 | "edit_name": "Edit Decoder/Encoder", 365 | "new": "New", 366 | "decoder": "Decoder", 367 | "encoder": "Encoder", 368 | "decoder_name": "Name", 369 | "auto": "Auto Decode", 370 | "decode_path": "Decoder Path", 371 | "encode_path": "Encoder Path", 372 | "path_help": "Path to executable, or cli alias like 'sh/php/python'", 373 | "args": "Arguments", 374 | "args_help": "Use [VALUE] as placeholder for encoding/decoding content. The content will be appended to the end if no placeholder is provided." 375 | }, 376 | "upgrade": { 377 | "title": "New Version Available", 378 | "new_version_tip": "New version {ver} available, download now?", 379 | "no_update": "You're up-to-date", 380 | "download_now": "Download Now", 381 | "later": "Later", 382 | "skip": "Skip This Version" 383 | }, 384 | "about": { 385 | "source": "Source Code", 386 | "website": "Official Website" 387 | } 388 | }, 389 | "menu": { 390 | "minimise": "Minimise", 391 | "maximise": "Maximise", 392 | "restore": "Restore", 393 | "close": "Close", 394 | "preferences": "Preferences", 395 | "help": "Help", 396 | "user_guide": "User Guide", 397 | "check_update": "Check for Updates...", 398 | "report_bug": "Report Bug", 399 | "about": "About" 400 | }, 401 | "log": { 402 | "title": "Launch Log", 403 | "filter_server": "Filter Server", 404 | "filter_keyword": "Filter Keyword", 405 | "clean_log": "Clean Log", 406 | "confirm_clean_log": "Confirm clean launch log", 407 | "exec_time": "Exec Time", 408 | "server": "Server", 409 | "cmd": "Command", 410 | "cost_time": "Cost", 411 | "refresh": "Refresh" 412 | }, 413 | "status": { 414 | "uptime": "Uptime", 415 | "connected_clients": "Clients", 416 | "total_keys": "Keys", 417 | "memory_used": "Memory", 418 | "server_info": "Server Info", 419 | "activity_status": "Activity", 420 | "act_cmd": "Commands/Sec", 421 | "act_network_input": "Network Input", 422 | "act_network_output": "Network Output", 423 | "client": { 424 | "title": "Client List", 425 | "addr": "Client Address", 426 | "age": "Age (sec)", 427 | "idle": "Idle (sec)", 428 | "db": "Database" 429 | } 430 | }, 431 | "slog": { 432 | "title": "Slow Log", 433 | "limit": "Limit", 434 | "filter": "Filter", 435 | "exec_time": "Time", 436 | "client": "Client", 437 | "cmd": "Command", 438 | "cost_time": "Cost" 439 | }, 440 | "monitor": { 441 | "title": "Monitor Commands", 442 | "actions": "Actions", 443 | "warning": "Command monitoring may cause server blocking, use with caution on production servers.", 444 | "start": "Start", 445 | "stop": "Stop", 446 | "search": "Search", 447 | "copy_log": "Copy Log", 448 | "save_log": "Save Log", 449 | "clean_log": "Clean Log", 450 | "always_show_last": "Always Show Latest" 451 | }, 452 | "pubsub": { 453 | "title": "Pub/Sub", 454 | "publish": "Publish", 455 | "subscribe": "Subscribe", 456 | "unsubscribe": "Unsubscribe", 457 | "clear": "Clear Messages", 458 | "time": "Time", 459 | "filter": "Filter", 460 | "channel": "Channel", 461 | "message": "Message", 462 | "receive_message": "Received {total} messages", 463 | "always_show_last": "Always Show Latest" 464 | } 465 | } 466 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= 4 | filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= 5 | github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= 6 | github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= 7 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= 8 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 9 | github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= 10 | github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= 11 | github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= 12 | github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= 13 | github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= 14 | github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= 15 | github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNsPRc= 16 | github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= 17 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 18 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/dblohm7/wingoes v0.0.0-20240820181039-f2b84150679e h1:L+XrFvD0vBIBm+Wf9sFN6aU395t7JROoai0qXZraA4U= 20 | github.com/dblohm7/wingoes v0.0.0-20240820181039-f2b84150679e/go.mod h1:SUxUaAK/0UG5lYyZR1L1nC4AaYYvSSYTWQSH3FPcxKU= 21 | github.com/dgrr/tl v0.2.1 h1:Md3LgAYTjLwn3cuO2HtZ8BWUvbqkfidFAlhtg9Wknxc= 22 | github.com/dgrr/tl v0.2.1/go.mod h1:Pha2zHjFh43RfV+YGXFEHmMwn1IBUTYI5XtpK4Ow51g= 23 | github.com/energye/systray v1.0.2 h1:63R4prQkANtpM2CIA4UrDCuwZFt+FiygG77JYCsNmXc= 24 | github.com/energye/systray v1.0.2/go.mod h1:sp7Q/q/I4/w5ebvpSuJVep71s9Bg7L9ZVp69gBASehM= 25 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 26 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 27 | github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= 28 | github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 29 | github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= 30 | github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= 31 | github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 h1:ygs9POGDQpQGLJPlq4+0LBUmMBNox1N4JSpw+OETcvI= 32 | github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4/go.mod h1:0W7dI87PvXJ1Sjs0QPvWXKcQmNERY77e8l7GFhZB/s4= 33 | github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1 h1:+VexzzkMLb1tnvpuQdGT/DicIRW7MN8ozsXqBMgp0Hk= 34 | github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= 35 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 36 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 37 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= 38 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10= 39 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 40 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 41 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= 42 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= 43 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 44 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 45 | github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= 46 | github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 47 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 48 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 49 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 50 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 51 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 52 | github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg= 53 | github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM= 54 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 55 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 56 | github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= 57 | github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= 58 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 59 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 60 | github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= 61 | github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= 62 | github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= 63 | github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= 64 | github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= 65 | github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= 66 | github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90= 67 | github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM= 68 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 69 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 70 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 71 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 72 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 73 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 74 | github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= 75 | github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= 76 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 77 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 78 | github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= 79 | github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= 80 | github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= 81 | github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= 82 | github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= 83 | github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= 84 | github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= 85 | github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= 86 | github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= 87 | github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= 88 | github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 89 | github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= 90 | github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 91 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 92 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 93 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 94 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 95 | github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= 96 | github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= 97 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= 98 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= 99 | github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= 100 | github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= 101 | github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc= 102 | github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck= 103 | github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= 104 | github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= 105 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 106 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 107 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= 108 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= 109 | github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc= 110 | github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ= 111 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= 112 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= 113 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 114 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 115 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 116 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 117 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 118 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 119 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 120 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 121 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 122 | github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= 123 | github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= 124 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= 125 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 126 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 127 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 128 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= 129 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= 130 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= 131 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= 132 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= 133 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= 134 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= 135 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= 136 | github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= 137 | github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= 138 | github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 h1:BcEJP2ewTIK2ZCsqgl6YGpuO6+oKqqag5HHb7ehljKw= 139 | github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= 140 | github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c h1:coVla7zpsycc+kA9NXpcvv2E4I7+ii6L5hZO2S6C3kw= 141 | github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg= 142 | github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= 143 | github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= 144 | github.com/toqueteos/webbrowser v1.2.0 h1:tVP/gpK69Fx+qMJKsLE7TD8LuGWPnEV71wBN9rrstGQ= 145 | github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM= 146 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 147 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 148 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 149 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 150 | github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= 151 | github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= 152 | github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= 153 | github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68 h1:Ah2/69Z24rwD6OByyOdpJDmttftz0FTF8Q4QZ/SF1E4= 154 | github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68/go.mod h1:EqKqAeKddSL9XSGnfXd/7iLncccKhR16HBKVva7ENw8= 155 | github.com/wailsapp/go-webview2 v1.0.21 h1:k3dtoZU4KCoN/AEIbWiPln3P2661GtA2oEgA2Pb+maA= 156 | github.com/wailsapp/go-webview2 v1.0.21/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= 157 | github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= 158 | github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= 159 | github.com/wailsapp/wails/v2 v2.10.1 h1:QWHvWMXII2nI/nXz77gpPG8P3ehl6zKe+u4su5BWIns= 160 | github.com/wailsapp/wails/v2 v2.10.1/go.mod h1:zrebnFV6MQf9kx8HI4iAv63vsR5v67oS7GTEZ7Pz1TY= 161 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 162 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 163 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= 164 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= 165 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= 166 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= 167 | golang.design/x/clipboard v0.7.0 h1:4Je8M/ys9AJumVnl8m+rZnIvstSnYj1fvzqYrU3TXvo= 168 | golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA6dnq2E= 169 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 170 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 171 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= 172 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= 173 | golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80= 174 | golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= 175 | golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= 176 | golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= 177 | golang.org/x/mobile v0.0.0-20250408133729-978277e7eaf7 h1:8MGTx39304caZ/OMsjPfuxUoDGI2tRas92F5x97tIYc= 178 | golang.org/x/mobile v0.0.0-20250408133729-978277e7eaf7/go.mod h1:ftACcHgQ7vaOnQbHOHvXt9Y6bEPHrs5Ovk67ClwrPJA= 179 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 180 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 181 | golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 182 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 183 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 184 | golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= 185 | golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 186 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 187 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 188 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 189 | golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 190 | golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 191 | golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 192 | golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 193 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 194 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 195 | golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 196 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 197 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 198 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 199 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 200 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 201 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 202 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 203 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 204 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 205 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 206 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 207 | golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= 208 | golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= 209 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= 210 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= 211 | golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= 212 | golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= 213 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 214 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= 215 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 216 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 217 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 218 | gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvsJ0ue6TRcEi2IUkv/F8k= 219 | gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM= 220 | howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= 221 | howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= 222 | k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= 223 | k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= 224 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 225 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 226 | software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M= 227 | software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= 228 | tailscale.com v1.82.5 h1:p5owmyPoPM1tFVHR3LjquFuLfpZLzafvhe5kjVavHtE= 229 | tailscale.com v1.82.5/go.mod h1:iU6kohVzG+bP0/5XjqBAnW8/6nSG/Du++bO+x7VJZD0= 230 | -------------------------------------------------------------------------------- /backend/services/tailscale_service.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/netip" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "cattail/backend/types" 16 | tsutils "cattail/backend/utils/ts" 17 | 18 | "github.com/dgrr/tl" 19 | "github.com/energye/systray" 20 | "github.com/gen2brain/beeep" 21 | "github.com/wailsapp/wails/v2/pkg/options" 22 | "github.com/wailsapp/wails/v2/pkg/runtime" 23 | "golang.design/x/clipboard" 24 | "tailscale.com/client/tailscale" 25 | "tailscale.com/client/tailscale/apitype" 26 | "tailscale.com/cmd/tailscale/cli" 27 | "tailscale.com/ipn" 28 | "tailscale.com/ipn/ipnstate" 29 | "tailscale.com/net/tsaddr" 30 | "tailscale.com/tailcfg" 31 | "tailscale.com/taildrop" 32 | "tailscale.com/types/key" 33 | ) 34 | 35 | type tailScaleService struct { 36 | ctx context.Context 37 | client tailscale.LocalClient 38 | fileMod chan struct{} 39 | initClipboard sync.Once 40 | traySvc *trayService 41 | } 42 | 43 | func TailScaleService() *tailScaleService { 44 | svc := &tailScaleService{} 45 | return svc 46 | } 47 | 48 | var iconPath = func() string { 49 | _, err := os.Stat("../../frontend/src/assets/images/icon.png") 50 | if err == nil { 51 | return "../../frontend/src/assets/images/icon.png" 52 | } else { 53 | home, _ := os.UserHomeDir() 54 | alterPath := filepath.Join(home, ".local", "share", "icons", "hicolor", "256x256", "apps", "com.cattail.png") 55 | 56 | _, err := os.Stat(alterPath) 57 | if err == nil { 58 | return alterPath 59 | } 60 | 61 | return "" 62 | } 63 | }() 64 | 65 | func Notify(format string, args ...interface{}) { 66 | beeep.Notify("Cattail", fmt.Sprintf(format, args...), iconPath) 67 | } 68 | 69 | func (tailSvc *tailScaleService) Startup(ctx context.Context) { 70 | tailSvc.ctx = ctx 71 | tailSvc.fileMod = make(chan struct{}, 1) 72 | 73 | Notify("Tailscale started") 74 | 75 | go tailSvc.watchFiles() 76 | go tailSvc.watchIPN() 77 | // go tailSvc.pingPeers() 78 | 79 | runtime.EventsOn(tailSvc.ctx, "file_upload", func(data ...interface{}) { 80 | fmt.Println(data) 81 | }) 82 | 83 | // Init tray 84 | go tailSvc.traySvc.Start(func() { tailSvc.initTray() }) 85 | 86 | // Refresh status 87 | tailSvc.Refresh() 88 | } 89 | 90 | func (tailSvc *tailScaleService) OnSecondInstanceLaunch(secondInstanceData options.SecondInstanceData) { 91 | secondInstanceArgs := secondInstanceData.Args 92 | 93 | runtime.WindowUnminimise(tailSvc.ctx) 94 | runtime.Show(tailSvc.ctx) 95 | go runtime.EventsEmit(tailSvc.ctx, "launchArgs", secondInstanceArgs) 96 | } 97 | 98 | func (tailSvc *tailScaleService) initTray() { 99 | online := tailSvc.GetStatus() 100 | 101 | if tailSvc.traySvc == nil { 102 | tailSvc.traySvc = TrayService(online) 103 | } 104 | 105 | tailSvc.setTrayActions() 106 | } 107 | 108 | func (tailSvc *tailScaleService) setTrayActions() { 109 | ts := tailSvc.traySvc 110 | ctx := tailSvc.ctx 111 | online := tailSvc.GetStatus() 112 | 113 | ts.ToggleStatusItem(online) 114 | 115 | ts.statusMenuItem.Click(func() { 116 | if ts.statusMenuItem.Checked() { 117 | ts.statusMenuItem.Uncheck() 118 | ts.statusMenuItem.SetTitle("Start") 119 | tailSvc.Stop() 120 | } else { 121 | ts.statusMenuItem.Check() 122 | ts.statusMenuItem.SetTitle("Stop") 123 | tailSvc.Start() 124 | } 125 | }) 126 | 127 | ts.showMenuItem.Click(func() { 128 | runtime.WindowShow(ctx) 129 | ts.isWindowHidden = false 130 | }) 131 | 132 | ts.quitMenuItem.Click(func() { 133 | ts.Stop() 134 | runtime.Quit(ctx) 135 | }) 136 | 137 | systray.SetOnClick(func(menu systray.IMenu) { 138 | if ts.isWindowHidden { 139 | runtime.WindowShow(ctx) 140 | ts.isWindowHidden = false 141 | } else { 142 | runtime.WindowHide(ctx) 143 | ts.isWindowHidden = true 144 | } 145 | }) 146 | } 147 | 148 | func (tailSvc *tailScaleService) PingPeers() { 149 | for { 150 | status, err := tailSvc.client.Status(tailSvc.ctx) 151 | if err != nil { 152 | log.Println("Getting client status", err) 153 | time.Sleep(time.Second * 10) 154 | continue 155 | } 156 | 157 | for _, nodeKey := range status.Peers() { 158 | peer := status.Peer[nodeKey] 159 | if len(peer.TailscaleIPs) == 0 { 160 | log.Printf("Peer %s doesn't have any IPs", peer.DNSName) 161 | continue 162 | } 163 | 164 | log.Printf("Pinging %s", peer.TailscaleIPs[0]) 165 | 166 | ctx, cancelFn := context.WithCancel(tailSvc.ctx) 167 | done := make(chan struct{}, 1) 168 | 169 | go func() { 170 | select { 171 | case <-done: 172 | case <-time.After(time.Second * 5): 173 | cancelFn() 174 | } 175 | }() 176 | 177 | res, err := tailSvc.client.Ping(ctx, peer.TailscaleIPs[0], tailcfg.PingICMP) 178 | if err != nil { 179 | log.Printf("Unable to ping %s: %s\n", peer.TailscaleIPs[0], err) 180 | } 181 | 182 | done <- struct{}{} 183 | 184 | log.Println("Ping result", res) 185 | } 186 | 187 | time.Sleep(time.Second * 30) 188 | } 189 | } 190 | 191 | func (tailSvc *tailScaleService) UploadFile(dnsName string) { 192 | status, err := tailSvc.client.Status(tailSvc.ctx) 193 | if err != nil { 194 | panic(err) 195 | } 196 | 197 | peers := status.Peers() 198 | 199 | i := tl.SearchFn(peers, func(nodeKey key.NodePublic) bool { 200 | peer := status.Peer[nodeKey] 201 | return peer.DNSName == dnsName 202 | }) 203 | if i == -1 { 204 | return 205 | } 206 | 207 | peer := status.Peer[peers[i]] 208 | 209 | filename, err := runtime.OpenFileDialog(tailSvc.ctx, runtime.OpenDialogOptions{ 210 | DefaultDirectory: func() string { 211 | dir, _ := os.UserHomeDir() 212 | return dir 213 | }(), 214 | }) 215 | if err != nil { 216 | panic(err) 217 | } 218 | 219 | if len(filename) == 0 { 220 | return 221 | } 222 | 223 | file, err := os.Open(filename) 224 | if err != nil { 225 | panic(err) 226 | } 227 | defer file.Close() 228 | 229 | stat, _ := file.Stat() 230 | 231 | err = tailSvc.client.PushFile(tailSvc.ctx, peer.ID, stat.Size(), stat.Name(), file) 232 | if err != nil { 233 | log.Printf("error uploading file to %s: %s\n", dnsName, err) 234 | } 235 | 236 | Notify("File %s sent to %s", stat.Name(), dnsName) 237 | } 238 | 239 | func (tailSvc *tailScaleService) AcceptFile(filename string) { 240 | dir, err := runtime.OpenDirectoryDialog(tailSvc.ctx, runtime.OpenDialogOptions{ 241 | DefaultDirectory: func() string { 242 | dir, _ := os.UserHomeDir() 243 | return dir 244 | }(), 245 | }) 246 | if err != nil { 247 | panic(err) 248 | } 249 | defer func() { 250 | tailSvc.RemoveFile(filename) 251 | }() 252 | 253 | r, _, err := tailSvc.client.GetWaitingFile(tailSvc.ctx, filename) 254 | if err != nil { 255 | panic(err) 256 | } 257 | defer r.Close() 258 | 259 | dstPath := filepath.Join(dir, filename) 260 | file, err := os.Create(dstPath) 261 | if err != nil { 262 | panic(err) 263 | } 264 | defer file.Close() 265 | 266 | _, _ = io.Copy(file, r) 267 | 268 | Notify("Downloaded %s to %s", filename, dstPath) 269 | } 270 | 271 | func (tailSvc *tailScaleService) RemoveFile(filename string) { 272 | log.Printf("Removing file %s\n", filename) 273 | 274 | err := tailSvc.client.DeleteWaitingFile(tailSvc.ctx, filename) 275 | if err != nil { 276 | log.Printf("Removing file: %s: %s\n", filename, err) 277 | } 278 | 279 | tailSvc.fileMod <- struct{}{} 280 | } 281 | 282 | func (tailSvc *tailScaleService) CurrentAccount() string { 283 | current, _, err := tailSvc.client.ProfileStatus(tailSvc.ctx) 284 | if err != nil { 285 | panic(err) 286 | } 287 | 288 | return current.Name 289 | } 290 | 291 | func (tailSvc *tailScaleService) SetExitNode(dnsName string) { 292 | status, err := tailSvc.client.Status(tailSvc.ctx) 293 | if err != nil { 294 | panic(err) 295 | } 296 | 297 | peers := status.Peers() 298 | 299 | i := tl.SearchFn(peers, func(nodeKey key.NodePublic) bool { 300 | peer := status.Peer[nodeKey] 301 | return peer.DNSName == dnsName 302 | }) 303 | if i == -1 { 304 | return 305 | } 306 | 307 | peer := status.Peer[peers[i]] 308 | 309 | prefs := &ipn.MaskedPrefs{ 310 | Prefs: ipn.Prefs{}, 311 | ExitNodeIPSet: true, 312 | ExitNodeIDSet: true, 313 | } 314 | 315 | if !peer.ExitNode { 316 | success := false 317 | ipsToTry := []string{ 318 | peer.DNSName, 319 | peer.HostName, 320 | } 321 | 322 | for _, ip := range peer.TailscaleIPs { 323 | ipsToTry = append(ipsToTry, ip.String()) 324 | } 325 | 326 | for _, host := range ipsToTry { 327 | log.Printf("Exit node as %s\n", host) 328 | 329 | err = prefs.SetExitNodeIP(host, status) 330 | if err != nil { 331 | log.Printf("Setting exit node as %s: %s\n", host, err) 332 | continue 333 | } 334 | 335 | success = true 336 | break 337 | } 338 | 339 | if !success { 340 | runtime.EventsEmit(tailSvc.ctx, "exit_node_connect") 341 | return 342 | } 343 | } 344 | 345 | _, err = tailSvc.client.EditPrefs(tailSvc.ctx, prefs) 346 | if err != nil { 347 | log.Println(err) 348 | } 349 | 350 | runtime.EventsEmit(tailSvc.ctx, "exit_node_connect") 351 | 352 | if peer.ExitNode { 353 | Notify("Removed exit node %s", peer.DNSName) 354 | } else { 355 | Notify("Using %s as exit node", peer.DNSName) 356 | } 357 | } 358 | 359 | func (tailSvc *tailScaleService) AdvertiseExitNode(dnsName string) { 360 | status, err := tailSvc.client.Status(tailSvc.ctx) 361 | if err != nil { 362 | panic(err) 363 | } 364 | 365 | if status.Self.DNSName != dnsName { 366 | return 367 | } 368 | 369 | curPrefs, err := tailSvc.client.GetPrefs(tailSvc.ctx) 370 | if err != nil { 371 | panic(err) 372 | } 373 | 374 | isAdvertise := curPrefs.AdvertisesExitNode() 375 | 376 | prefs := &ipn.MaskedPrefs{ 377 | Prefs: ipn.Prefs{ 378 | AdvertiseRoutes: append([]netip.Prefix{}, 379 | tsaddr.AllIPv4(), tsaddr.AllIPv4(), 380 | ), 381 | }, 382 | AdvertiseRoutesSet: true, 383 | } 384 | 385 | prefs.SetAdvertiseExitNode(!isAdvertise) 386 | 387 | // if current settings is advertise, then remove 388 | if isAdvertise { 389 | prefs.Prefs.AdvertiseRoutes = nil 390 | } 391 | 392 | _, err = tailSvc.client.EditPrefs(tailSvc.ctx, prefs) 393 | if err != nil { 394 | log.Println(err) 395 | } 396 | 397 | runtime.EventsEmit(tailSvc.ctx, "advertise_exit_node_done") 398 | 399 | if isAdvertise { 400 | Notify("Removed advertising node") 401 | } else { 402 | Notify("Advertising as exit node") 403 | } 404 | } 405 | 406 | func (tailSvc *tailScaleService) AdvertiseRoutes(routes string) error { 407 | curPrefs, err := tailSvc.client.GetPrefs(tailSvc.ctx) 408 | 409 | if err != nil { 410 | panic(err) 411 | } 412 | 413 | exit := curPrefs.AdvertisesExitNode() 414 | 415 | 416 | if strings.TrimSpace(routes) == "" { 417 | curPrefs.AdvertiseRoutes = nil 418 | } else { 419 | ipStrings := strings.Split(routes, ",") 420 | var prefixes []netip.Prefix 421 | for _, ipStr := range ipStrings { 422 | ipStr = strings.TrimSpace(ipStr) 423 | prefix, err := netip.ParsePrefix(ipStr) 424 | if err != nil { 425 | log.Println(err) 426 | return nil 427 | } 428 | prefixes = append(prefixes, prefix) 429 | } 430 | 431 | curPrefs.AdvertiseRoutes = prefixes 432 | } 433 | 434 | curPrefs.SetAdvertiseExitNode(exit) 435 | 436 | _, err = tailSvc.client.EditPrefs(tailSvc.ctx, &ipn.MaskedPrefs{ 437 | Prefs: *curPrefs, 438 | AdvertiseRoutesSet: true, 439 | }) 440 | 441 | if err != nil { 442 | log.Println(err) 443 | } 444 | 445 | return nil 446 | } 447 | 448 | func (tailSvc *tailScaleService) AllowLANAccess(allow bool) error { 449 | prefs := ipn.Prefs{ 450 | ExitNodeAllowLANAccess: allow, 451 | } 452 | 453 | _, err := tailSvc.client.EditPrefs(tailSvc.ctx, &ipn.MaskedPrefs{ 454 | Prefs: prefs, 455 | ExitNodeAllowLANAccessSet: true, 456 | }) 457 | 458 | if err != nil { 459 | log.Println(err) 460 | } 461 | 462 | if allow { 463 | Notify("LAN access has been granted.") 464 | } else { 465 | Notify("LAN access has been restricted.") 466 | } 467 | 468 | return nil 469 | } 470 | 471 | func (tailSvc *tailScaleService) AcceptRoutes(accept bool) error { 472 | prefs := ipn.Prefs{ 473 | RouteAll: accept, 474 | } 475 | 476 | _, err := tailSvc.client.EditPrefs(tailSvc.ctx, &ipn.MaskedPrefs{ 477 | Prefs: prefs, 478 | RouteAllSet: true, 479 | }) 480 | 481 | if err != nil { 482 | log.Println(err) 483 | } 484 | 485 | if accept { 486 | Notify("All routes acceptance is enabled.") 487 | } else { 488 | Notify("All routes acceptance is disabled.") 489 | } 490 | 491 | return nil 492 | } 493 | 494 | func (tailSvc *tailScaleService) RunSSH(run bool) error { 495 | prefs := ipn.Prefs{ 496 | RunSSH: run, 497 | } 498 | 499 | _, err := tailSvc.client.EditPrefs(tailSvc.ctx, &ipn.MaskedPrefs{ 500 | Prefs: prefs, 501 | RunSSHSet: true, 502 | }) 503 | 504 | if err != nil { 505 | log.Println(err) 506 | } 507 | 508 | if run { 509 | Notify("SSH access has been enabled.") 510 | } else { 511 | Notify("SSH access has been disabled.") 512 | } 513 | 514 | return nil 515 | } 516 | 517 | func (tailSvc *tailScaleService) SetControlURL(controlURL string) error { 518 | curPrefs, err := tailSvc.client.GetPrefs(tailSvc.ctx) 519 | 520 | if err != nil { 521 | panic(err) 522 | } 523 | 524 | curPrefs.ControlURL = controlURL 525 | 526 | err = tailSvc.client.Start(tailSvc.ctx, ipn.Options{ 527 | UpdatePrefs: curPrefs, 528 | }) 529 | 530 | if err != nil { 531 | log.Println(err) 532 | } 533 | 534 | return nil 535 | } 536 | 537 | func (tailSvc *tailScaleService) CopyClipboard(s string) { 538 | tailSvc.initClipboard.Do(func() { 539 | if err := clipboard.Init(); err != nil { 540 | panic(err) 541 | } 542 | }) 543 | log.Printf("Copying \"%s\" to the clipboard\n", s) 544 | clipboard.Write(clipboard.FmtText, []byte(s)) 545 | } 546 | 547 | func (tailSvc *tailScaleService) Accounts() []string { 548 | current, all, err := tailSvc.client.ProfileStatus(tailSvc.ctx) 549 | if err != nil { 550 | panic(err) 551 | } 552 | 553 | names := tl.Filter( 554 | tl.Map(all, func(profile ipn.LoginProfile) string { 555 | return profile.Name 556 | }), 557 | func(name string) bool { 558 | return name != current.Name 559 | }, 560 | ) 561 | 562 | return names 563 | } 564 | 565 | func (tailSvc *tailScaleService) Self() types.Peer { 566 | log.Printf("Requesting self") 567 | 568 | status, err := tailSvc.client.Status(tailSvc.ctx) 569 | if err != nil { 570 | log.Printf("Requesting self: %s\n", err) 571 | return types.Peer{} 572 | } 573 | 574 | curPrefs, err := tailSvc.client.GetPrefs(tailSvc.ctx) 575 | if err != nil { 576 | panic(err) 577 | } 578 | 579 | self := status.Self 580 | peer := convertPeer(self, curPrefs) 581 | 582 | peer.ExitNodeOption = curPrefs.AdvertisesExitNode() 583 | peer.AllowLANAccess = curPrefs.ExitNodeAllowLANAccess 584 | peer.AcceptRoutes = curPrefs.RouteAll 585 | peer.RunSSH = curPrefs.RunSSH 586 | 587 | return peer 588 | } 589 | 590 | func (tailSvc *tailScaleService) Files() []types.File { 591 | files, err := tailSvc.client.AwaitWaitingFiles(tailSvc.ctx, time.Second) 592 | if err != nil { 593 | if strings.Contains(err.Error(), taildrop.ErrNoTaildrop.Error()) { 594 | return nil 595 | } 596 | if tailSvc.ctx.Err() != nil { 597 | return nil 598 | } 599 | log.Println(err) 600 | return nil 601 | } 602 | 603 | return tl.Map(files, func(file apitype.WaitingFile) types.File { 604 | return types.File{ 605 | Name: file.Name, 606 | Size: file.Size, 607 | } 608 | }) 609 | } 610 | 611 | func (tailSvc *tailScaleService) Namespaces() []types.Namespace { 612 | status, err := tailSvc.client.Status(tailSvc.ctx) 613 | if err != nil { 614 | log.Printf("requesting instance: %s\n", err) 615 | return nil 616 | } 617 | 618 | curPrefs, err := tailSvc.client.GetPrefs(tailSvc.ctx) 619 | 620 | if err != nil { 621 | panic(err) 622 | } 623 | 624 | res := make([]types.Namespace, 0) 625 | 626 | for _, nodeKey := range status.Peers() { 627 | tsPeer := status.Peer[nodeKey] 628 | _, namespace := splitPeerNamespace(tsPeer.DNSName) 629 | 630 | peer := convertPeer(tsPeer, curPrefs) 631 | 632 | i := tl.SearchFn(res, func(a types.Namespace) bool { 633 | return namespace == a.Name 634 | }) 635 | if i == -1 { 636 | res = append(res, types.Namespace{ 637 | Name: namespace, 638 | Peers: []types.Peer{ 639 | peer, 640 | }, 641 | }) 642 | } else { 643 | res[i].Peers = append(res[i].Peers, peer) 644 | } 645 | } 646 | 647 | return res 648 | } 649 | 650 | func (tailSvc *tailScaleService) SwitchTo(account string) { 651 | current, all, err := tailSvc.client.ProfileStatus(tailSvc.ctx) 652 | if err != nil { 653 | panic(err) 654 | } 655 | 656 | if account == current.Name { 657 | return 658 | } 659 | 660 | all = tl.Filter(all, func(profile ipn.LoginProfile) bool { 661 | return profile.Name == account 662 | }) 663 | if len(all) == 0 { 664 | log.Printf("Profile %s not found\n", account) 665 | return 666 | } 667 | 668 | log.Printf("Profile %s", all[0].ID) 669 | tailSvc.client.SwitchProfile(tailSvc.ctx, all[0].ID) 670 | 671 | Notify("Switched to account: %s", account) 672 | } 673 | 674 | func (tailSvc *tailScaleService) GetStatus() bool { 675 | st, err := tailSvc.client.Status(tailSvc.ctx) 676 | 677 | if err != nil { 678 | return false 679 | } 680 | 681 | status := &tsutils.Status{ 682 | Status: st, 683 | } 684 | online := status.Online() 685 | 686 | return online 687 | } 688 | 689 | func (tailSvc *tailScaleService) UpdateStatus(previousOnlineStatus bool) bool { 690 | if tailSvc == nil { 691 | return false 692 | } 693 | 694 | if tailSvc.traySvc == nil { 695 | return false 696 | } 697 | 698 | online := tailSvc.GetStatus() 699 | 700 | if online != previousOnlineStatus { 701 | tailSvc.traySvc.setStatus(online) 702 | runtime.EventsEmit(tailSvc.ctx, "tailscale:status-changed", online) 703 | } 704 | 705 | tailSvc.traySvc.ToggleStatusItem(online) 706 | 707 | return online 708 | } 709 | 710 | func (tailSvc *tailScaleService) Start() error { 711 | st, err := tailSvc.client.Status(tailSvc.ctx) 712 | 713 | if err != nil { 714 | return err 715 | } 716 | 717 | status := &tsutils.Status{ 718 | Status: st, 719 | } 720 | 721 | if status.NeedsLogin() { 722 | result, err := runtime.MessageDialog(tailSvc.ctx, runtime.MessageDialogOptions{ 723 | Type: runtime.QuestionDialog, 724 | Title: "Login Required", 725 | Message: "Open a browser to authenticate with Tailscale?", 726 | DefaultButton: "Yes", 727 | CancelButton: "No", 728 | }) 729 | 730 | if err != nil { 731 | return err 732 | } 733 | 734 | if result == "Yes" { 735 | tailSvc.handleTailscaleLogin() 736 | } 737 | 738 | return nil 739 | } 740 | 741 | Notify("Tailscale started") 742 | return cli.Run([]string{"up"}) 743 | } 744 | 745 | func (tailSvc *tailScaleService) Stop() error { 746 | Notify("Tailscale stopped") 747 | return cli.Run([]string{"down"}) 748 | } 749 | 750 | func (tailSvc *tailScaleService) Refresh() { 751 | var previousStatus bool 752 | 753 | go func() { 754 | ticker := time.NewTicker(5 * time.Second) 755 | defer ticker.Stop() 756 | 757 | for { 758 | select { 759 | case <-ticker.C: 760 | currentStatus := tailSvc.UpdateStatus(previousStatus) 761 | previousStatus = currentStatus 762 | runtime.EventsOn(tailSvc.ctx, "wails:window:hide", func(optionalData ...interface{}) { 763 | tailSvc.traySvc.isWindowHidden = true 764 | }) 765 | case <-tailSvc.ctx.Done(): 766 | return 767 | } 768 | } 769 | }() 770 | } 771 | 772 | func (tailSvc *tailScaleService) watchFiles() { 773 | prevFiles := 0 774 | 775 | for { 776 | select { 777 | case <-time.After(time.Second * 10): 778 | case <-tailSvc.fileMod: 779 | } 780 | 781 | files, err := tailSvc.client.AwaitWaitingFiles(tailSvc.ctx, time.Second) 782 | if err != nil { 783 | if strings.Contains(err.Error(), taildrop.ErrNoTaildrop.Error()) { 784 | return 785 | } 786 | 787 | if tailSvc.ctx.Err() != nil { 788 | return 789 | } 790 | 791 | log.Println(err) 792 | } 793 | 794 | if len(files) != prevFiles { 795 | prevFiles = len(files) 796 | 797 | for _, file := range files { 798 | Notify("File %s available", file.Name) 799 | } 800 | 801 | runtime.EventsEmit(tailSvc.ctx, "update_files") 802 | } 803 | } 804 | } 805 | 806 | func (tailSvc *tailScaleService) watchIPN() { 807 | for { 808 | watcher, err := tailSvc.client.WatchIPNBus(tailSvc.ctx, 0) 809 | if err != nil { 810 | log.Printf("loading IPN bus watcher: %s\n", err) 811 | time.Sleep(time.Second) 812 | continue 813 | } 814 | 815 | for { 816 | not, err := watcher.Next() 817 | if err != nil { 818 | log.Printf("Watching IPN Bus: %s\n", err) 819 | break 820 | } 821 | 822 | if not.FilesWaiting != nil { 823 | tailSvc.fileMod <- struct{}{} 824 | } 825 | 826 | if not.State != nil { 827 | if *not.State == ipn.Running { 828 | runtime.EventsEmit(tailSvc.ctx, "app_running") 829 | } else { 830 | runtime.EventsEmit(tailSvc.ctx, "app_not_running") 831 | } 832 | } 833 | 834 | runtime.EventsEmit(tailSvc.ctx, "update_all") 835 | 836 | log.Printf("IPN bus update: %v\n", not) 837 | } 838 | } 839 | } 840 | 841 | func (tailSvc *tailScaleService) handleTailscaleLogin() error { 842 | // Use a channel to coordinate login and URL retrieval 843 | authURLChan := make(chan string, 1) 844 | errChan := make(chan error, 1) 845 | 846 | go func() { 847 | err := cli.Run([]string{"login"}) 848 | if err != nil { 849 | errChan <- err 850 | return 851 | } 852 | }() 853 | 854 | go func() { 855 | ticker := time.NewTicker(500 * time.Millisecond) 856 | defer ticker.Stop() 857 | 858 | timeout := time.After(30 * time.Second) 859 | 860 | for { 861 | select { 862 | case <-timeout: 863 | errChan <- fmt.Errorf("tailscale login timeout") 864 | return 865 | case <-ticker.C: 866 | st, err := tailSvc.client.Status(tailSvc.ctx) 867 | if err != nil { 868 | errChan <- err 869 | return 870 | } 871 | 872 | if st.AuthURL != "" { 873 | authURLChan <- st.AuthURL 874 | return 875 | } 876 | } 877 | } 878 | }() 879 | 880 | select { 881 | case authURL := <-authURLChan: 882 | runtime.BrowserOpenURL(tailSvc.ctx, authURL) 883 | return nil 884 | case err := <-errChan: 885 | return err 886 | } 887 | } 888 | 889 | func convertPeer(status *ipnstate.PeerStatus, prefs *ipn.Prefs) types.Peer { 890 | peerName, _ := splitPeerNamespace(status.DNSName) 891 | return types.Peer{ 892 | ID: string(status.ID), 893 | DNSName: status.DNSName, 894 | Name: peerName, 895 | ExitNode: status.ExitNode, 896 | ExitNodeOption: status.ExitNodeOption, 897 | Online: status.Online, 898 | OS: status.OS, 899 | Addrs: status.Addrs, 900 | Created: status.Created, 901 | LastSeen: status.LastSeen, 902 | LastWrite: status.LastWrite, 903 | Routes: func() []string { 904 | if status.PrimaryRoutes == nil { 905 | return nil 906 | } 907 | 908 | return tl.Map(status.PrimaryRoutes.AsSlice(), func(prefix netip.Prefix) string { 909 | return prefix.String() 910 | }) 911 | }(), 912 | IPs: tl.Map(status.TailscaleIPs, func(ip netip.Addr) string { 913 | return ip.String() 914 | }), 915 | AllowedIPs: func() []string { 916 | if status.AllowedIPs == nil { 917 | return nil 918 | } 919 | 920 | return tl.Map(status.AllowedIPs.AsSlice(), func(prefix netip.Prefix) string { 921 | return prefix.String() 922 | }) 923 | }(), 924 | AdvertisedRoutes: func() []string { 925 | if prefs.AdvertiseRoutes == nil { 926 | return nil 927 | } 928 | 929 | return tl.Map(prefs.AdvertiseRoutes, func(prefix netip.Prefix) string { 930 | return prefix.String() 931 | }) 932 | }(), 933 | } 934 | } 935 | 936 | func splitPeerNamespace(dnsName string) (peerName, namespace string) { 937 | names := strings.Split(dnsName, ".") 938 | namespace = strings.Join(names[1:], ".") 939 | peerName = names[0] 940 | return peerName, namespace 941 | } 942 | --------------------------------------------------------------------------------