package main import ( "context" "encoding/base64" "errors" "fmt" "io" "os" "path/filepath" "github.com/wailsapp/wails/v2/pkg/menu" "github.com/wailsapp/wails/v2/pkg/menu/keys" "github.com/wailsapp/wails/v2/pkg/runtime" "cdrop-desktop/engine" "cdrop-desktop/platform" ) // App is the Wails application context bound to the WebView. type App struct { ctx context.Context apiBase string token *platform.TokenResult // in-memory copy; refresh_token persists in the OS secret store (see session.go) user platform.UserInfo // display identity from 代铸; preserved across refresh (machine tokens are nameless) quitting bool // set by the menu bar "Quit" so beforeClose allows the real exit startHidden bool // set by main when launched via autostart: come up to the menu bar, no window clipSource platform.Source clipMonitor *platform.Monitor // transfer 是原生 P2P 数据面(pion):取代 WebView 内 JS WebRTC,逃离 WebKit/WebView2 // 的 256KB rwnd 与渲染器节流。HTTP(信令 / 状态机)仍由 WebView 侧 JS 经事件桥发起, // 引擎只做纯 WebRTC + 文件 I/O。见 desktop/NATIVE-TRANSFER.md。 transfer *engine.Engine } // NewApp creates a new App application struct. func NewApp() *App { return &App{apiBase: envOr("CDROP_API_BASE", "https://drop.commilitia.net")} } // startup is called when the app starts. The context is saved so we can call // the runtime methods, and the menu bar item is installed (macOS; no-op // elsewhere). The app launches as a regular Dock app with the window visible. func (a *App) startup(ctx context.Context) { a.ctx = ctx // Load the persisted refresh_token into the Go process so refresh works after // a restart. The refresh_token stays here — it's never handed to the WebView. if s, err := platform.LoadSession(); err == nil && s != nil { a.token = &platform.TokenResult{ AccessToken: s.AccessToken, RefreshToken: s.RefreshToken, ExpiresIn: s.ExpiresIn, } a.user = s.User } // Keep the autostart entry in sync with this binary: when it's enabled, // re-apply so an entry written by an older build (no --hidden flag) or one // pointing at a since-moved bundle is refreshed to the current path + flags. // Idempotent and cheap — a single plist / registry write. if platform.IsLaunchAtLoginEnabled() { _ = platform.SetLaunchAtLogin(true) } // Migrate native notification metadata at startup so an old internal-name // entry does not remain visible until the first incoming notification. platform.InitializeNotifications() // 原生传输引擎:落地目录取当前配置(设置页改目录时经 SaveSettings 同步到引擎)。 a.transfer = engine.New(engine.Config{DownloadDir: platform.ResolveDownloadDir()}, &transferEvents{app: a}) a.startClipboardSync(ctx) // 触发 macOS 本地网络权限(macOS 15+ 隐私门):使本进程 WKWebView 的 WebRTC 能收集 host / // mDNS 候选、同内网走直连而非 prflx↔prflx 慢路径(见 platform.TriggerLocalNetwork)。其他平台空实现。 platform.TriggerLocalNetwork() platform.InstallStatusBar( platform.StatusBarMenu{ Title: "Commilitia Drop", Show: "显示主窗口", Settings: "设置…", Quit: "退出 Commilitia Drop", }, // The native menu-action callbacks fire on the AppKit main thread; calling // Wails runtime methods synchronously there can re-enter the main run loop // and crash. Hand off to a goroutine — Wails then dispatches to the main // thread safely. platform.StatusBarHandler{ OnShowWindow: func() { go a.revealWindow() }, OnOpenSettings: func() { go a.openSettings() }, OnQuit: func() { go a.requestQuit() }, }, ) // Autostart launch: the window already came up hidden (options.StartHidden). // On macOS also drop the Dock icon to accessory so only the menu bar item // remains — the same resting state as close-to-menu-bar. (Windows: this is a // no-op; the tray is installed above and the hidden window keeps the taskbar // clear. The menu bar item / tray is the way back in.) if a.startHidden { platform.SetDockVisible(false) } } // buildMenu returns the macOS application menu. Without it the standard // shortcuts (⌘C/⌘V/⌘X/⌘A/⌘Z and ⌘W/⌘M) don't work. // // Quit is a custom ⌘Q item rather than the role AppMenu's, because Wails routes // both the window close button and ⌘Q through the same OnBeforeClose("Q") // channel — indistinguishable there. The custom item sets the quitting flag // first, so OnBeforeClose lets ⌘Q quit while the close button hides. ⌘W likewise // hides (close-to-menu-bar), matching the lifecycle. func (a *App) buildMenu() *menu.Menu { m := menu.NewMenu() appSub := menu.NewMenu() appSub.Append(menu.Text("退出 Commilitia Drop", keys.CmdOrCtrl("q"), func(*menu.CallbackData) { a.requestQuit() })) m.Append(menu.SubMenu("Commilitia Drop", appSub)) // macOS renders the first menu as the app menu m.Append(menu.EditMenu()) // Undo/Redo/Cut/Copy/Paste/SelectAll window := menu.NewMenu() window.Append(menu.Text("最小化", keys.CmdOrCtrl("m"), func(*menu.CallbackData) { runtime.WindowMinimise(a.ctx) })) window.Append(menu.Text("关闭窗口", keys.CmdOrCtrl("w"), func(*menu.CallbackData) { a.hideWindow() })) m.Append(menu.SubMenu("窗口", window)) return m } // hideWindow tucks the window away to the menu bar (window hidden, Dock icon // gone). Shared by ⌘W and the window's close button (via beforeClose). func (a *App) hideWindow() { runtime.WindowHide(a.ctx) platform.SetDockVisible(false) } // beforeClose intercepts the window close. Instead of quitting, it hides the // window and drops to accessory (Dock icon gone, menu bar item remains the way // back in). The real exit only happens after the menu bar "Quit" sets quitting. func (a *App) beforeClose(_ context.Context) bool { if a.quitting { return false // allow the quit to proceed } a.hideWindow() return true // prevent the close } // revealWindow brings the main window back and restores the Dock icon. Bound to // the menu bar "Show" item. func (a *App) revealWindow() { if a.ctx == nil { return } platform.SetDockVisible(true) runtime.WindowShow(a.ctx) runtime.WindowUnminimise(a.ctx) } // openSettings reveals the window and asks the WebView to route to settings. func (a *App) openSettings() { a.revealWindow() runtime.EventsEmit(a.ctx, "menu:settings") } // requestQuit flags an intentional quit so beforeClose lets it through, then // asks Wails to terminate. func (a *App) requestQuit() { a.quitting = true runtime.Quit(a.ctx) } // startClipboardSync starts the native pasteboard watcher. Local copies are // emitted to the WebView as "clipboard:native"; the JS layer owns the upload // decision (auth + the user's sync toggle) and reuses the web app's existing // upload + dedup path. The download direction is driven by the WebView calling // ApplyRemoteClipboard. The shared Monitor enforces the loop guard across both // directions. func (a *App) startClipboardSync(ctx context.Context) { a.clipSource = platform.NewClipboardSource() a.clipMonitor = platform.NewMonitor(func(text string) { runtime.EventsEmit(a.ctx, "clipboard:native", text) }) go platform.Run(ctx, a.clipSource, a.clipMonitor) } // ApplyRemoteClipboard is bound to the WebView. It writes a remote clipboard // update to the local pasteboard (download direction). The Monitor arms its loop // guard before the write so the resulting change isn't re-uploaded, and skips // this device's own echoes. content is plain text at this stage. func (a *App) ApplyRemoteClipboard(content, sourceDevice string) { if a.clipMonitor == nil || a.clipSource == nil { return } a.clipMonitor.ApplyRemote(a.clipSource, platform.ClipboardContent{ Content: content, ContentType: "text/plain", SourceDevice: sourceDevice, }, a.DeviceName()) } // ShowNotification is bound to the WebView. The JS hub calls it when a // notify-worthy event (incoming file / message / transfer done|failed) arrives // while the window is NOT in the foreground — so the user gets a native system // notification instead of an in-app cue they'd miss. Foreground events stay // in-app: the JS gates on document focus/visibility, so this is only ever // invoked for the background case. Title/body arrive already localized by the // WebView (client-side i18n). macOS display additionally needs a signed bundle // (D6) — until then platform.Notify no-ops safely there; Windows shows now. func (a *App) ShowNotification(title, body string) { platform.Notify(title, body) } // StartLogin is bound to the WebView. It runs the loopback PKCE flow in the // background and reports the outcome via Wails events ("oauth:success" / // "oauth:error"). On success it persists the full session (with refresh_token) // Go-side and emits only the refresh-free SessionView to JS — the refresh_token // never enters the WebView (see the credential policy in session.go). func (a *App) StartLogin() { go func() { cfg, err := a.resolveOAuthConfig() if err != nil { runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()}) return } flow := platform.NewFlow(cfg, func(u string) { runtime.BrowserOpenURL(a.ctx, u) }) // Step 1: device-authorize → a bootstrap machine token (proves identity, no meta). boot, err := flow.Login(a.ctx) if err != nil { runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()}) return } // Step 2: 代铸 the bootstrap token into a cdrop-managed device session bound to this // device's stable device_id, so the desktop is managed exactly like a browser. The // device session's tokens supersede the bootstrap (which is discarded, left to expire). deviceID, err := platform.ResolveDeviceID() if err != nil { runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()}) return } ds, err := platform.MintDeviceSession(a.ctx, a.apiBase, boot.AccessToken, deviceID, platform.ResolveDeviceName(), platform.DeviceType()) if err != nil { runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()}) return } // Identity from the verified 代铸 response (X-Auth-Subject / X-Auth-Name / X-Auth-Avatar), // so the real display name and picture show rather than the subject UUID a nameless // machine token yields. user := platform.UserInfo{ID: ds.UserID, Name: ds.Name, Avatar: ds.Avatar} if user.Name == "" { user.Name = user.ID } res := &platform.LoginResult{ AccessToken: ds.AccessToken, RefreshToken: ds.RefreshToken, ExpiresIn: ds.ExpiresIn, User: user, } a.token = &platform.TokenResult{AccessToken: ds.AccessToken, RefreshToken: ds.RefreshToken, ExpiresIn: ds.ExpiresIn} a.user = user if err := platform.SaveSession(*res); err != nil { runtime.LogWarningf(a.ctx, "persist session failed: %v", err) } view := res.View() runtime.EventsEmit(a.ctx, "oauth:success", &view) }() } // Refresh is bound to the WebView and takes NO argument: the refresh_token never // crosses into JS, so Go uses its own in-memory copy. It mints a fresh // access_token (public-client loopback flow, no client_secret), persists the // rotated session, and returns only the refresh-free SessionView. The WebView // calls this when an API request comes back 401. func (a *App) Refresh() (*platform.SessionView, error) { if a.token == nil || a.token.RefreshToken == "" { return nil, errors.New("no refresh token") } cfg, err := a.resolveOAuthConfig() if err != nil { return nil, err } flow := platform.NewFlow(cfg, func(string) {}) tok, err := flow.Refresh(a.ctx, a.token.RefreshToken) if err != nil { return nil, err } if tok.RefreshToken == "" { tok.RefreshToken = a.token.RefreshToken // providers that don't rotate: keep the old one } // Preserve the identity established at login: the refreshed access token is a nameless // machine token (it carries meta, not the display name), so re-deriving identity from it // would regress the name to the subject UUID. The device session keeps its meta across // rotation, so the device stays the same managed device. res := &platform.LoginResult{ AccessToken: tok.AccessToken, RefreshToken: tok.RefreshToken, ExpiresIn: tok.ExpiresIn, User: a.user, } a.token = tok if err := platform.SaveSession(*res); err != nil { runtime.LogWarningf(a.ctx, "persist refreshed session failed: %v", err) } view := res.View() return &view, nil } // ClearSession is bound to the WebView; called on logout to drop the persisted // session file (and the Go-side token copy) so the next launch starts logged out. func (a *App) ClearSession() error { a.token = nil return platform.ClearSession() } // GetSettings is bound to the WebView. It returns the persisted desktop config, // with LaunchAtLogin reflecting the actual LaunchAgent state (the plist is the // source of truth — the user may have toggled it elsewhere). func (a *App) GetSettings() platform.DesktopConfig { cfg, _ := platform.LoadConfig() cfg.LaunchAtLogin = platform.IsLaunchAtLoginEnabled() return cfg } // SaveSettings is bound to the WebView. It persists the settings the page owns // and applies the launch-at-login side effect (install / remove the LaunchAgent). // // The page's DesktopConfig has no device_name field, so the incoming cfg always // carries DeviceName="". We merge onto the current config (preserving DeviceName, // which is owned by SetDeviceName) instead of overwriting — otherwise saving any // setting would wipe the persisted device name. Config is written before the // launch-agent side effect so its failure doesn't lose the other preferences. func (a *App) SaveSettings(cfg platform.DesktopConfig) error { cur, _ := platform.LoadConfig() cur.ClipboardSyncEnabled = cfg.ClipboardSyncEnabled cur.LaunchAtLogin = cfg.LaunchAtLogin cur.DownloadDir = cfg.DownloadDir if err := platform.SaveConfig(cur); err != nil { return err } // 让引擎的落地目录跟随设置变化(接收端直接写盘)。 if a.transfer != nil { a.transfer.SetDownloadDir(platform.ResolveDownloadDir()) } return platform.SetLaunchAtLogin(cur.LaunchAtLogin) } // DeviceName is bound to the WebView. Returns the persisted device name, or the // hostname when the user hasn't renamed it — so the desktop skips the per-device // naming step the browser shows. func (a *App) DeviceName() string { return platform.ResolveDeviceName() } // SetDeviceName is bound to the WebView; called when the user renames this device // so the name survives restart (the WebView's localStorage doesn't, on wails://). func (a *App) SetDeviceName(name string) error { cfg, _ := platform.LoadConfig() cfg.DeviceName = name return platform.SaveConfig(cfg) } // SaveDownload is bound to the WebView. The transfer pipeline hands a received // file's bytes (base64 over the bridge) to Go, which writes them into the user's // configured download directory (default ~/Downloads) without overwriting. The // absolute path is returned so the page can tell the user where it landed. func (a *App) SaveDownload(name string, data []byte) (string, error) { return platform.SaveDownload(platform.ResolveDownloadDir(), name, data) } // BeginDownload / AppendDownload / FinalizeDownload / AbortDownload are the // streaming counterpart of SaveDownload, bound to the WebView. The receiver's // hybrid sink keeps a file in memory until it crosses a cap, then spills the // overflow here so large files don't sit in the WebView heap (the wails:// no-OPFS // deadlock). Begin opens a temp file in the download dir, Append streams batches // (base64 over the bridge), Finalize renames it into place and returns the path, // Abort discards it on cancel / failure. func (a *App) BeginDownload(sessionId string) error { return platform.BeginStreamingDownload(platform.ResolveDownloadDir(), sessionId) } func (a *App) AppendDownload(sessionId string, data []byte) error { return platform.AppendStreamingDownload(sessionId, data) } func (a *App) FinalizeDownload(sessionId, name string) (string, error) { return platform.FinalizeStreamingDownload(sessionId, name) } func (a *App) AbortDownload(sessionId string) { platform.AbortStreamingDownload(sessionId) } // ChooseDownloadDir opens a native directory picker (title localised by the // caller) and returns the chosen path, or "" when the user cancels. The page // persists the result via SaveSettings. func (a *App) ChooseDownloadDir(title string) (string, error) { return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{Title: title}) } // EffectiveDownloadDir returns the directory downloads currently land in — the // configured override or the system default — for display in the settings page. func (a *App) EffectiveDownloadDir() string { return platform.ResolveDownloadDir() } // resolveOAuthConfig prefers explicit env overrides (dev / a pinned broker); // otherwise it fetches /api/auth/config from the backend for the broker's public URL, // then runs the broker device-authorization flow against it. func (a *App) resolveOAuthConfig() (platform.OAuthConfig, error) { if env := oauthConfigFromEnv(); env.BrokerURL != "" { return env, nil } return platform.FetchOAuthConfig(a.ctx, a.apiBase) } func oauthConfigFromEnv() platform.OAuthConfig { return platform.OAuthConfig{ BrokerURL: os.Getenv("CDROP_BROKER_URL"), App: envOr("CDROP_BROKER_APP", "commilitia-drop"), } } func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } // LoggedIn reports whether a token is currently held; bound so the WebView can // query auth state on startup. func (a *App) LoggedIn() bool { return a.token != nil } // Greet returns a greeting for the given name (scaffold demo, kept until the // shared web UI replaces the default frontend). func (a *App) Greet(name string) string { return fmt.Sprintf("Hello %s, It's show time!", name) } // --- 原生 P2P 数据面桥(绑定到 WebView) --- // // WebView 侧的 p2pBackend(Go-bridge 后端)调用这四个方法发起 / 接收 / 喂信令 / 取消; // 引擎经 transferEvents 反向 EventsEmit 进度 / 状态 / 出站信令 / 落盘路径。HTTP 全留 JS: // 出站信令由 JS POST /api/hub/signal,入站信令由 JS hub 转发进 P2PHandleSignal,状态机 // POST(/p2p、/done、/fail)由 JS 据 p2p:state 事件发起。iceServersJSON 由 JS 透传 // (与 web 同源,来自 /api/calls/credentials)。 // P2PStartOutgoing 作为发送端发起:filePath 为本机绝对路径(经 PickFileForSend 获得)。 func (a *App) P2PStartOutgoing(sessionID, peerName, filePath, iceServersJSON string) error { return a.transfer.StartOutgoing(sessionID, peerName, filePath, iceServersJSON) } // P2PStartIncoming 预备接收端,等对端 offer。 func (a *App) P2PStartIncoming(sessionID, peerName, iceServersJSON string) error { return a.transfer.StartIncoming(sessionID, peerName, iceServersJSON) } // P2PHandleSignal 把 JS hub 从 SSE 收到的入站信令喂给引擎(按对端设备名路由)。 func (a *App) P2PHandleSignal(fromPeer, payloadJSON string) error { return a.transfer.HandleSignal(fromPeer, payloadJSON) } // P2PCancel 拆除会话(取消 / 中继回退)。 func (a *App) P2PCancel(sessionID string) { a.transfer.Cancel(sessionID) } // PickedFile 是原生文件选择的结果,供发送端走原生数据面(Go 直接按路径读盘,不经浏览器 // File 的沙箱无路径 + 内存约束)。 type PickedFile struct { Path string `json:"path"` Name string `json:"name"` Size int64 `json:"size"` } // ReadFileSlice 读 path 的 [start,end) 字节并 base64 返回。仅供中继回退时 WebView 侧的 // FileSource.slice 用(原生 P2P 由引擎直接按路径读盘、不走此桥);故 base64 开销只在罕见 // 的 relay 路径上,不影响 P2P 主路径。镜像 iOS 的 readOutgoingSlice。 func (a *App) ReadFileSlice(path string, start, end int64) (string, error) { if start < 0 || end < start { return "", fmt.Errorf("bad range [%d,%d)", start, end) } f, err := os.Open(path) if err != nil { return "", err } defer f.Close() buf := make([]byte, end-start) n, err := f.ReadAt(buf, start) if err != nil && err != io.EOF { return "", err } return base64.StdEncoding.EncodeToString(buf[:n]), nil } // PickFileForSend 打开原生文件对话框,返回选中文件的绝对路径 + 名 + 大小;用户取消返回 nil。 func (a *App) PickFileForSend(title string) (*PickedFile, error) { path, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{Title: title}) if err != nil { return nil, err } if path == "" { return nil, nil // 用户取消 } fi, err := os.Stat(path) if err != nil { return nil, err } return &PickedFile{Path: path, Name: filepath.Base(path), Size: fi.Size()}, nil } // transferEvents 把引擎回调转成 Wails 事件,供 WebView 侧 p2pBackend 监听。回调可能在 pion // 任意 goroutine 触发;Wails 的 EventsEmit / Log* 可跨线程安全调用。 type transferEvents struct{ app *App } func (e *transferEvents) OnProgress(sessionID string, bytes int64) { runtime.EventsEmit(e.app.ctx, "p2p:progress", map[string]any{"sessionId": sessionID, "bytes": bytes}) } func (e *transferEvents) OnState(sessionID, state string) { runtime.EventsEmit(e.app.ctx, "p2p:state", map[string]any{"sessionId": sessionID, "state": state}) } func (e *transferEvents) OnSignal(sessionID, toPeer, payloadJSON string) { runtime.EventsEmit(e.app.ctx, "p2p:signal", map[string]any{"sessionId": sessionID, "to": toPeer, "payload": payloadJSON}) } func (e *transferEvents) OnSaved(sessionID, path string) { runtime.EventsEmit(e.app.ctx, "p2p:saved", map[string]any{"sessionId": sessionID, "path": path}) } func (e *transferEvents) OnIcePair(sessionID, local, remote string) { runtime.EventsEmit(e.app.ctx, "p2p:icepair", map[string]any{"sessionId": sessionID, "local": local, "remote": remote}) } func (e *transferEvents) OnLog(line string) { runtime.LogInfof(e.app.ctx, "p2p: %s", line) }