Files
Commilitia-Drop/desktop/app.go
T

543 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 侧的 p2pBackendGo-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)
}