package app import ( "context" "crypto/sha256" "crypto/x509" "encoding/hex" "encoding/pem" "io/fs" "net" "os" goruntime "runtime" "strconv" "strings" "time" "cursor/internal/ads" "cursor/internal/appdata" serverconfig "cursor/internal/backend/server/config" "cursor/internal/buildinfo" "cursor/internal/cursor" "cursor/internal/historymetrics" "github.com/leaanthony/u" bridge "cursor/internal/bridge" "cursor/internal/certs" "cursor/internal/logger" "cursor/internal/mitm" "cursor/internal/netproxy" "cursor/internal/updater" "github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/events" ) const ( // appName 表示当前模块中的 appName 状态值。 appName = "Cursor助手" // adRefreshInterval 表示后台广告拉取间隔。 adRefreshInterval = 3 * time.Minute // disableWebViewSandboxEnv allows affected VDI users to opt out of the WebView2 sandbox. disableWebViewSandboxEnv = "CURSOR_BYOK_DISABLE_WEBVIEW_SANDBOX" ) // EmbeddedResources 定义了当前模块中的 EmbeddedResources 类型。 type EmbeddedResources struct { // Assets 表示当前声明中的 Assets。 Assets fs.FS // AppIcon 表示当前声明中的 AppIcon。 AppIcon []byte // TrayIcon 表示当前声明中的 TrayIcon。 TrayIcon []byte } // init 用于处理与 init 相关的逻辑。 func init() { application.RegisterEvent[bridge.ProxyState]("proxy:state") application.RegisterEvent[bridge.UserConfig]("user-config:changed") application.RegisterEvent[bridge.ModelAdapterTestResultsPayload]("model-adapter-test:updated") application.RegisterEvent[bridge.AdRuntime](ads.EventUpdated) application.RegisterEvent[updater.StatePayload](updater.EventState) application.RegisterEvent[updater.ProgressPayload](updater.EventProgress) application.RegisterEvent[updater.ReadyPayload](updater.EventReady) application.RegisterEvent[updater.ErrorPayload](updater.EventError) } // Run 用于处理与 Run 相关的逻辑。 func Run(resources EmbeddedResources) error { logger.Init() netproxy.InstallDefaultTransport() if err := appdata.EnsureAssistantHome(); err != nil { return err } certManager, caCertPEM, err := certs.LoadOrCreateManager(appdata.CACertFilePath(), appdata.CAKeyFilePath()) if err != nil { return err } logCAInfo(caCertPEM) defaultBackendBaseURL := "http://" + serverconfig.DefaultBackendListenAddr proxyServer, err := mitm.NewProxyServer(serverconfig.DefaultProxyListenAddr, defaultBackendBaseURL, "", "", certManager) if err != nil { return err } proxyService := bridge.NewProxyService(proxyServer, certManager, caCertPEM) adAssetBaseURL := defaultBackendBaseURL if cfg, err := proxyService.LoadUserConfig(); err == nil { adAssetBaseURL = browserReachableLoopbackBaseURL(cfg.BackendListenAddr) } metricsService := bridge.NewMetricsService() windowService := bridge.NewWindowService() adCore := ads.NewService(ads.Options{ StoreRoot: appdata.AdsRootPath(), HTTPClient: netproxy.NewHTTPClient(30 * time.Second), AppVersion: buildinfo.CurrentVersion(), AssetBaseURL: adAssetBaseURL + ads.RoutePrefix, DeviceID: cursor.GetDeviceID, Metrics: func(context.Context) (ads.MetricsSnapshot, error) { if err := appdata.EnsureAssistantHome(); err != nil { return ads.MetricsSnapshot{}, err } summary, err := historymetrics.LoadUsageSummary(appdata.UsageFilePath()) if err != nil { return ads.MetricsSnapshot{}, err } return ads.MetricsSnapshot{ TurnsTotal: summary.TurnsTotal, RequestTokensTotal: summary.RequestTokensTotal, PromptTokensTotal: summary.PromptTokensTotal, CacheReadTokens: summary.CacheReadTokens, CacheWriteTokens: summary.CacheWriteTokens, }, nil }, ProviderCount: func(context.Context) (int, error) { cfg, err := proxyService.LoadUserConfig() if err != nil { return 0, err } return len(cfg.ModelAdapters), nil }, }) adService := bridge.NewAdService(adCore) var updateManager *updater.Manager var mainWindow *application.WebviewWindow adRefreshCtx, stopAdRefresh := context.WithCancel(context.Background()) app := application.New(application.Options{ Name: appName, Description: appName, Services: []application.Service{ application.NewService(proxyService), application.NewService(metricsService), application.NewService(windowService), application.NewService(adService), }, Assets: application.AssetOptions{ Handler: application.AssetFileServerFS(resources.Assets), }, Windows: application.WindowsOptions{ AdditionalBrowserArgs: windowsAdditionalBrowserArgs(), }, Mac: application.MacOptions{ ActivationPolicy: application.ActivationPolicyAccessory, ApplicationShouldTerminateAfterLastWindowClosed: false, }, OnShutdown: func() { stopAdRefresh() if updateManager != nil { updateManager.Shutdown() } proxyService.ShutdownForQuit() }, SingleInstance: &application.SingleInstanceOptions{ UniqueID: "com.cursor-assistant.single-instance", OnSecondInstanceLaunch: func(data application.SecondInstanceData) { logger.Infof("检测到实例请求,已忽略") // 不激活窗口,避免干扰用户工作 }, }, }) refreshAdAssetBaseURL := func() bool { state := proxyService.GetState() backendListenAddr := strings.TrimSpace(state.BackendListenAddr) if backendListenAddr == "" { backendListenAddr = serverconfig.DefaultBackendListenAddr } return adCore.SetAssetBaseURL(browserReachableLoopbackBaseURL(backendListenAddr) + ads.RoutePrefix) } refreshAdRuntime := func() { runtimeState, err := adCore.GetRuntime(context.Background()) if err != nil { return } app.Event.Emit(ads.EventUpdated, runtimeState) } refreshAd := func(ctx context.Context) { if ctx == nil { ctx = context.Background() } runtimeState, changed, err := adCore.Refresh(ctx) if err != nil || !changed { return } app.Event.Emit(ads.EventUpdated, runtimeState) } refreshAdAsync := func() { go func() { refreshAd(context.Background()) }() } startAdRefreshLoop := func(ctx context.Context) { go func() { refreshAd(ctx) ticker := time.NewTicker(adRefreshInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: refreshAd(ctx) } } }() } updateManager = updater.NewManager(app) windowService.SetApp(app) windowService.SetUpdater(updateManager) mainWindow = app.Window.NewWithOptions(application.WebviewWindowOptions{ Title: appName, Width: 700, Height: 530, MinWidth: 700, MinHeight: 530, DisableResize: false, Frameless: goruntime.GOOS == "windows", URL: "/", Hidden: false, HideOnEscape: false, MinimiseButtonState: application.ButtonEnabled, MaximiseButtonState: application.ButtonEnabled, CloseButtonState: application.ButtonEnabled, BackgroundColour: application.RGBA{Red: 25, Green: 25, Blue: 25, Alpha: 255}, Mac: application.MacWindow{ Backdrop: application.MacBackdropLiquidGlass, DisableShadow: false, TitleBar: application.MacTitleBar{ AppearsTransparent: true, Hide: false, HideTitle: true, FullSizeContent: true, UseToolbar: false, HideToolbarSeparator: true, }, WebviewPreferences: application.MacWebviewPreferences{ FullscreenEnabled: u.True, TextInteractionEnabled: u.True, AllowsBackForwardNavigationGestures: u.False, }, }, Windows: application.WindowsWindow{ HiddenOnTaskbar: false, }, }) window := mainWindow window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { window.Hide() e.Cancel() }) window.RegisterHook(events.Common.WindowFocus, func(e *application.WindowEvent) { refreshAdAsync() }) showMainWindow := func() { window.Show().Focus() } toggleMainWindow := func() { if window.IsVisible() { window.Hide() return } showMainWindow() } systray := app.SystemTray.New() menu := app.Menu.New() statusItem := menu.Add("状态:未启动").SetEnabled(false) menu.AddSeparator() startItem := menu.Add("启动服务") stopItem := menu.Add("停止服务") updateItem := menu.Add("检查更新").OnClick(func(ctx *application.Context) { updateManager.CheckNow(true) }) menu.AddSeparator() showItem := menu.Add("显示窗口").OnClick(func(ctx *application.Context) { showMainWindow() }) hideItem := menu.Add("隐藏窗口").OnClick(func(ctx *application.Context) { window.Hide() }) menu.AddSeparator() quitItem := menu.Add("退出").OnClick(func(ctx *application.Context) { proxyService.ShutdownForQuit() app.Quit() }) var currentLocale = "zh-CN" updateTrayLabels := func(locale string) { currentLocale = locale state := proxyService.GetState() if state.Running { if locale == "en-US" { statusItem.SetLabel("Status: Running") } else if locale == "ja-JP" { statusItem.SetLabel("状態:実行中") } else if locale == "ru-RU" { statusItem.SetLabel("Статус: запущено") } else { statusItem.SetLabel("状态:运行中") } } else { if locale == "en-US" { statusItem.SetLabel("Status: Not Started") } else if locale == "ja-JP" { statusItem.SetLabel("状態:未起動") } else if locale == "ru-RU" { statusItem.SetLabel("Статус: не запущено") } else { statusItem.SetLabel("状态:未启动") } } if locale == "en-US" { startItem.SetLabel("Start Service") stopItem.SetLabel("Stop Service") updateItem.SetLabel("Check for Updates") showItem.SetLabel("Show Window") hideItem.SetLabel("Hide Window") quitItem.SetLabel("Exit") } else if locale == "ja-JP" { startItem.SetLabel("サービス起動") stopItem.SetLabel("サービス停止") updateItem.SetLabel("アップデートを確認") showItem.SetLabel("ウィンドウを表示") hideItem.SetLabel("ウィンドウを非表示") quitItem.SetLabel("終了") } else if locale == "ru-RU" { startItem.SetLabel("Запустить сервис") stopItem.SetLabel("Остановить сервис") updateItem.SetLabel("Проверить обновления") showItem.SetLabel("Показать окно") hideItem.SetLabel("Скрыть окно") quitItem.SetLabel("Выход") } else { startItem.SetLabel("启动服务") stopItem.SetLabel("停止服务") updateItem.SetLabel("检查更新") showItem.SetLabel("显示窗口") hideItem.SetLabel("隐藏窗口") quitItem.SetLabel("退出") } } refreshTray := func() { state := proxyService.GetState() if state.Running { startItem.SetEnabled(false) stopItem.SetEnabled(true) } else { startItem.SetEnabled(true) stopItem.SetEnabled(false) } updateTrayLabels(currentLocale) if refreshAdAssetBaseURL() { refreshAdRuntime() } } app.Event.On("locale:changed", func(e *application.CustomEvent) { if locale, ok := e.Data.(string); ok { updateTrayLabels(locale) } }) app.Event.On("proxy:state", func(event *application.CustomEvent) { refreshTray() }) app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(event *application.ApplicationEvent) { logger.Infof("应用版本:v%s", buildinfo.CurrentVersion()) updateManager.Start() startAdRefreshLoop(adRefreshCtx) go func() { logger.Infof("application started, begin auto start service in background") if _, err := proxyService.StartProxy(); err != nil { logger.Errorf("自动启动服务失败: %v", err) } else { state := proxyService.GetState() if refreshAdAssetBaseURL() { refreshAdRuntime() } logger.Infof("代理已自动启动: %s", state.ProxyListenAddr) } }() }) startItem.OnClick(func(ctx *application.Context) { if _, err := proxyService.StartProxy(); err != nil { logger.Errorf("启动服务失败: %v", err) } else if refreshAdAssetBaseURL() { refreshAdRuntime() } refreshTray() }) stopItem.OnClick(func(ctx *application.Context) { if _, err := proxyService.StopProxy(); err != nil { logger.Errorf("停止服务失败: %v", err) } refreshTray() }) if len(resources.AppIcon) > 0 { switch goruntime.GOOS { case "darwin": systray.SetTemplateIcon(resources.TrayIcon) case "windows": systray.SetIcon(resources.AppIcon) default: systray.SetIcon(resources.TrayIcon) } } systray.SetTooltip(appName) systray.OnClick(toggleMainWindow).SetMenu(menu) refreshTray() return app.Run() } func windowsAdditionalBrowserArgs() []string { disableSandbox, err := strconv.ParseBool(strings.TrimSpace(os.Getenv(disableWebViewSandboxEnv))) if err != nil || !disableSandbox { return nil } return []string{"--no-sandbox"} } func browserReachableLoopbackBaseURL(listenAddr string) string { host, port, err := net.SplitHostPort(strings.TrimSpace(listenAddr)) if err != nil || strings.TrimSpace(port) == "" { return "http://" + serverconfig.DefaultBackendListenAddr } host = strings.TrimSpace(host) if host == "" || host == "0.0.0.0" || host == "::" || host == "[::]" { host = "127.0.0.1" } return "http://" + net.JoinHostPort(host, port) } // logCAInfo 记录当前安装专属 CA 的公开信息。 func logCAInfo(certPEM []byte) { if len(certPEM) == 0 { logger.Errorf("installation CA is empty") return } cert, err := parseCert(certPEM) if err != nil { logger.Errorf("parse installation CA failed: %v", err) return } sum := sha256.Sum256(cert.Raw) logger.Infof( "installation CA loaded: sha256=%s subject=%s valid=%s~%s", strings.ToUpper(hex.EncodeToString(sum[:])), cert.Subject.String(), cert.NotBefore.Format(time.RFC3339), cert.NotAfter.Format(time.RFC3339), ) } // parseCert 解析 DER 或 PEM 编码的证书。 func parseCert(data []byte) (*x509.Certificate, error) { if block, _ := pem.Decode(data); block != nil { return x509.ParseCertificate(block.Bytes) } return x509.ParseCertificate(data) }