diff --git a/app.go b/app.go index cf5c17d..e95c74f 100644 --- a/app.go +++ b/app.go @@ -7,6 +7,9 @@ import ( "os" "path/filepath" + "github.com/go-toast/toast" + "github.com/wailsapp/wails/v2/pkg/runtime" + "trae-switch/internal/cert" "trae-switch/internal/config" "trae-switch/internal/hosts" @@ -265,3 +268,41 @@ func (a *App) shutdown(ctx context.Context) { } } } + +func (a *App) ShowWindow() { + runtime.WindowShow(a.ctx) + runtime.WindowSetAlwaysOnTop(a.ctx, false) +} + +func (a *App) HideWindow() { + runtime.WindowHide(a.ctx) +} + +func (a *App) HideWindowWithNotification() { + runtime.WindowHide(a.ctx) + + notification := toast.Notification{ + AppID: "Trae Switch", + Title: "Trae Switch", + Message: "程序将在后台运行,点击托盘图标恢复窗口", + Icon: "", // No icon path needed for basic notification + } + if err := notification.Push(); err != nil { + log.Printf("Failed to send notification: %v", err) + } +} + +func (a *App) QuitApp() { + if a.IsProxyRunning() { + a.StopProxy() + } + runtime.Quit(a.ctx) +} + +func (a *App) SetTrayMode(enabled bool) error { + return config.SetTrayMode(enabled) +} + +func (a *App) GetTrayMode() bool { + return config.GetTrayMode() +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 1a51aee..c426b32 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte' import Button from './components/ui/Button.svelte' import Card from './components/ui/Card.svelte' + import Switch from './components/ui/Switch.svelte' let status = { runningAsAdmin: false, @@ -21,6 +22,7 @@ let error = '' let success = '' let theme = 'dark' + let trayMode = false let showProviderModal = false let editingProviderIndex = -1 @@ -59,8 +61,30 @@ await refreshStatus() await loadProviders() + await loadTrayMode() }) + async function loadTrayMode() { + try { + trayMode = await window.go.main.App.GetTrayMode() + } catch (e) { + console.error('Failed to load tray mode:', e) + } + } + + async function toggleTrayMode() { + try { + await window.go.main.App.SetTrayMode(trayMode) + if (trayMode) { + showSuccess('常驻任务栏已开启') + } else { + showSuccess('常驻任务栏已关闭') + } + } catch (e) { + showError(e.message || String(e)) + } + } + function toggleTheme() { const currentIndex = themes.findIndex((item) => item.value === theme) const nextTheme = themes[(currentIndex + 1) % themes.length].value @@ -439,6 +463,23 @@

系统配置

+
+
+ {#if trayMode} + + {:else} + + {/if} +
+
常驻任务栏
+
+ {trayMode ? '已开启' : '已关闭'} +
+
+
+ { trayMode = !trayMode; toggleTrayMode() }} /> +
+
diff --git a/frontend/src/components/ui/Switch.svelte b/frontend/src/components/ui/Switch.svelte index 4463c59..5809bc4 100644 --- a/frontend/src/components/ui/Switch.svelte +++ b/frontend/src/components/ui/Switch.svelte @@ -1,12 +1,17 @@ diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 494c690..0fd7887 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -11,6 +11,12 @@ export function GetProviders():Promise>>; export function GetStatus():Promise>; +export function GetTrayMode():Promise; + +export function HideWindow():Promise; + +export function HideWindowWithNotification():Promise; + export function InstallCertificate():Promise; export function IsCertificateInstalled():Promise; @@ -25,12 +31,18 @@ export function QuickStart():Promise; export function QuickStop():Promise; +export function QuitApp():Promise; + export function RestoreHosts():Promise; export function SetActiveProvider(arg1:number):Promise; export function SetHosts():Promise; +export function SetTrayMode(arg1:boolean):Promise; + +export function ShowWindow():Promise; + export function StartProxy():Promise; export function StopProxy():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index a8474d3..fef7e21 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -22,6 +22,18 @@ export function GetStatus() { return window['go']['main']['App']['GetStatus'](); } +export function GetTrayMode() { + return window['go']['main']['App']['GetTrayMode'](); +} + +export function HideWindow() { + return window['go']['main']['App']['HideWindow'](); +} + +export function HideWindowWithNotification() { + return window['go']['main']['App']['HideWindowWithNotification'](); +} + export function InstallCertificate() { return window['go']['main']['App']['InstallCertificate'](); } @@ -50,6 +62,10 @@ export function QuickStop() { return window['go']['main']['App']['QuickStop'](); } +export function QuitApp() { + return window['go']['main']['App']['QuitApp'](); +} + export function RestoreHosts() { return window['go']['main']['App']['RestoreHosts'](); } @@ -62,6 +78,14 @@ export function SetHosts() { return window['go']['main']['App']['SetHosts'](); } +export function SetTrayMode(arg1) { + return window['go']['main']['App']['SetTrayMode'](arg1); +} + +export function ShowWindow() { + return window['go']['main']['App']['ShowWindow'](); +} + export function StartProxy() { return window['go']['main']['App']['StartProxy'](); } diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts index 4445dac..3bbea84 100644 --- a/frontend/wailsjs/runtime/runtime.d.ts +++ b/frontend/wailsjs/runtime/runtime.d.ts @@ -246,4 +246,85 @@ export function OnFileDropOff() :void export function CanResolveFilePaths(): boolean; // Resolves file paths for an array of files -export function ResolveFilePaths(files: File[]): void \ No newline at end of file +export function ResolveFilePaths(files: File[]): void + +// Notification types +export interface NotificationOptions { + id: string; + title: string; + subtitle?: string; // macOS and Linux only + body?: string; + categoryId?: string; + data?: { [key: string]: any }; +} + +export interface NotificationAction { + id?: string; + title?: string; + destructive?: boolean; // macOS-specific +} + +export interface NotificationCategory { + id?: string; + actions?: NotificationAction[]; + hasReplyField?: boolean; + replyPlaceholder?: string; + replyButtonTitle?: string; +} + +// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications) +// Initializes the notification service for the application. +// This must be called before sending any notifications. +export function InitializeNotifications(): Promise; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise; + +// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions) +// Sends a notification with action buttons. Requires a registered category. +export function SendNotificationWithActions(options: NotificationOptions): Promise; + +// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory) +// Registers a notification category that can be used with SendNotificationWithActions. +export function RegisterNotificationCategory(category: NotificationCategory): Promise; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise; + +// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification) +// Removes a notification by its identifier (cross-platform convenience function). +export function RemoveNotification(identifier: string): Promise; \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js index 7cb89d7..556621e 100644 --- a/frontend/wailsjs/runtime/runtime.js +++ b/frontend/wailsjs/runtime/runtime.js @@ -239,4 +239,60 @@ export function CanResolveFilePaths() { export function ResolveFilePaths(files) { return window.runtime.ResolveFilePaths(files); +} + +export function InitializeNotifications() { + return window.runtime.InitializeNotifications(); +} + +export function CleanupNotifications() { + return window.runtime.CleanupNotifications(); +} + +export function IsNotificationAvailable() { + return window.runtime.IsNotificationAvailable(); +} + +export function RequestNotificationAuthorization() { + return window.runtime.RequestNotificationAuthorization(); +} + +export function CheckNotificationAuthorization() { + return window.runtime.CheckNotificationAuthorization(); +} + +export function SendNotification(options) { + return window.runtime.SendNotification(options); +} + +export function SendNotificationWithActions(options) { + return window.runtime.SendNotificationWithActions(options); +} + +export function RegisterNotificationCategory(category) { + return window.runtime.RegisterNotificationCategory(category); +} + +export function RemoveNotificationCategory(categoryId) { + return window.runtime.RemoveNotificationCategory(categoryId); +} + +export function RemoveAllPendingNotifications() { + return window.runtime.RemoveAllPendingNotifications(); +} + +export function RemovePendingNotification(identifier) { + return window.runtime.RemovePendingNotification(identifier); +} + +export function RemoveAllDeliveredNotifications() { + return window.runtime.RemoveAllDeliveredNotifications(); +} + +export function RemoveDeliveredNotification(identifier) { + return window.runtime.RemoveDeliveredNotification(identifier); +} + +export function RemoveNotification(identifier) { + return window.runtime.RemoveNotification(identifier); } \ No newline at end of file diff --git a/go.mod b/go.mod index e28feeb..a682681 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,11 @@ go 1.22.0 toolchain go1.24.1 -require github.com/wailsapp/wails/v2 v2.11.0 +require ( + github.com/energye/systray v1.0.3 + github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 + github.com/wailsapp/wails/v2 v2.11.0 +) require ( github.com/bep/debounce v1.2.1 // indirect @@ -21,6 +25,7 @@ require ( github.com/leaanthony/u v1.1.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/go.sum b/go.sum index e3658ec..8d6b9c8 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,12 @@ github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/energye/systray v1.0.3 h1:XnyjJCeRU5z00bpNOic2fGTKz/7yHZMZjWiGIVXDS+4= +github.com/energye/systray v1.0.3/go.mod h1:HelKhC3PXwv3ryDxbuQqV+7kAxAYNzE5cfdrerGOZTc= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= +github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -34,6 +38,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/internal/config/config.go b/internal/config/config.go index 1cbf2e4..feb8ee6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,7 @@ type Provider struct { type Config struct { Providers []Provider `json:"providers"` ActiveProvider int `json:"active_provider"` + TrayMode bool `json:"tray_mode"` mu sync.RWMutex } @@ -157,3 +158,22 @@ func GetModels() []string { } return provider.Models } + +func GetTrayMode() bool { + if cfg == nil { + return false + } + cfg.mu.RLock() + defer cfg.mu.RUnlock() + return cfg.TrayMode +} + +func SetTrayMode(enabled bool) error { + if cfg == nil { + return nil + } + cfg.mu.Lock() + defer cfg.mu.Unlock() + cfg.TrayMode = enabled + return cfg.Save() +} diff --git a/internal/elevate/elevate.go b/internal/elevate/elevate.go new file mode 100644 index 0000000..a090253 --- /dev/null +++ b/internal/elevate/elevate.go @@ -0,0 +1,32 @@ +//go:build windows + +package elevate + +import ( + "os" + "syscall" + "unsafe" +) + +const SW_SHOWNORMAL = 1 + +func Elevate() error { + exePath, err := os.Executable() + if err != nil { + exePath = os.Args[0] + } + + ret, _, _ := syscall.NewLazyDLL("shell32.dll").NewProc("ShellExecuteW").Call( + 0, + uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("runas"))), + uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(exePath))), + 0, + 0, + SW_SHOWNORMAL, + ) + + if ret <= 32 { + return os.NewSyscallError("ShellExecute", syscall.Errno(ret)) + } + return nil +} \ No newline at end of file diff --git a/internal/elevate/elevate_stub.go b/internal/elevate/elevate_stub.go new file mode 100644 index 0000000..207fc65 --- /dev/null +++ b/internal/elevate/elevate_stub.go @@ -0,0 +1,11 @@ +//go:build !windows + +package elevate + +import ( + "errors" +) + +func Elevate() error { + return errors.New("elevation not supported on this platform") +} \ No newline at end of file diff --git a/internal/tray/icon.ico b/internal/tray/icon.ico new file mode 100644 index 0000000..bfa0690 Binary files /dev/null and b/internal/tray/icon.ico differ diff --git a/internal/tray/tray.go b/internal/tray/tray.go new file mode 100644 index 0000000..844d27c --- /dev/null +++ b/internal/tray/tray.go @@ -0,0 +1,79 @@ +package tray + +import ( + _ "embed" + "log" + + "github.com/energye/systray" +) + +//go:embed icon.ico +var iconData []byte + +var ( + showWindowCallback func() + quitCallback func() + running bool +) + +func SetCallbacks(onShow func(), onQuit func()) { + showWindowCallback = onShow + quitCallback = onQuit +} + +func Start() { + if running { + return + } + running = true + go systray.Run(onReady, onExit) +} + +func onReady() { + systray.SetIcon(iconData) + systray.SetTitle("Trae Switch") + systray.SetTooltip("Trae Switch - 点击显示窗口") + + systray.SetOnClick(func(menu systray.IMenu) { + if showWindowCallback != nil { + showWindowCallback() + } + }) + + systray.SetOnDClick(func(menu systray.IMenu) { + if showWindowCallback != nil { + showWindowCallback() + } + }) + + systray.SetOnRClick(func(menu systray.IMenu) { + menu.ShowMenu() + }) + + mShow := systray.AddMenuItem("显示主窗口", "显示应用主窗口") + mQuit := systray.AddMenuItem("退出程序", "退出 Trae Switch") + + mShow.Click(func() { + if showWindowCallback != nil { + showWindowCallback() + } + }) + + mQuit.Click(func() { + if quitCallback != nil { + quitCallback() + } + systray.Quit() + }) + + log.Println("System tray ready") +} + +func onExit() { + running = false + log.Println("System tray exited") +} + +func Quit() { + systray.Quit() +} \ No newline at end of file diff --git a/main.go b/main.go index 8c29cc4..51f55a9 100644 --- a/main.go +++ b/main.go @@ -1,20 +1,44 @@ package main import ( + "context" "embed" + "os" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/windows" + + "trae-switch/internal/config" + "trae-switch/internal/elevate" + "trae-switch/internal/tray" + "trae-switch/internal/truststore" ) //go:embed all:frontend/dist var assets embed.FS func main() { + if !truststore.IsRunningAsAdmin() { + if err := elevate.Elevate(); err == nil { + os.Exit(0) + } + } + app := NewApp() + tray.SetCallbacks( + func() { app.ShowWindow() }, + func() { app.QuitApp() }, + ) + + tray.Start() + + if _, err := config.Load(); err != nil { + println("Failed to load config: " + err.Error()) + } + err := wails.Run(&options.App{ Title: "Trae Switch", Width: 500, @@ -23,6 +47,13 @@ func main() { BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, OnStartup: app.startup, OnShutdown: app.shutdown, + OnBeforeClose: func(ctx context.Context) bool { + if config.GetTrayMode() { + app.HideWindowWithNotification() + return true + } + return false + }, Bind: []interface{}{ app, }, @@ -39,4 +70,6 @@ func main() { if err != nil { println("Error: " + err.Error()) } + + tray.Quit() }