cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。 - 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。 - 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。 - 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。 - 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。 架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
+372
@@ -0,0 +1,372 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/menu"
|
||||
"github.com/wailsapp/wails/v2/pkg/menu/keys"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"cdrop-desktop/platform"
|
||||
)
|
||||
|
||||
// loginResultFromToken decodes the identity out of the exchanged tokens and
|
||||
// assembles the payload the WebView store consumes. Shared by login + refresh.
|
||||
func loginResultFromToken(tok *platform.TokenResult) (*platform.LoginResult, error) {
|
||||
user, err := platform.UserFromToken(tok.IDToken, tok.AccessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &platform.LoginResult{
|
||||
AccessToken: tok.AccessToken,
|
||||
RefreshToken: tok.RefreshToken,
|
||||
ExpiresIn: tok.ExpiresIn,
|
||||
User: user,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
a.startClipboardSync(ctx)
|
||||
platform.InstallStatusBar(
|
||||
platform.StatusBarMenu{
|
||||
Title: "cdrop",
|
||||
Show: "显示主窗口",
|
||||
Settings: "设置…",
|
||||
Quit: "退出 cdrop",
|
||||
},
|
||||
// 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("退出 cdrop", keys.CmdOrCtrl("q"), func(*menu.CallbackData) {
|
||||
a.requestQuit()
|
||||
}))
|
||||
m.Append(menu.SubMenu("cdrop", 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())
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
tok, err := flow.Login(a.ctx)
|
||||
if err != nil {
|
||||
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
res, err := loginResultFromToken(tok)
|
||||
if err != nil {
|
||||
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
a.token = tok
|
||||
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
|
||||
}
|
||||
res, err := loginResultFromToken(tok)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 client);
|
||||
// otherwise it fetches /api/auth/config from the backend and reuses the web
|
||||
// app's client_id for the desktop's loopback PKCE flow (see desktop/PLAN.md §3).
|
||||
func (a *App) resolveOAuthConfig() (platform.OAuthConfig, error) {
|
||||
if env := oauthConfigFromEnv(); env.ClientID != "" {
|
||||
return env, nil
|
||||
}
|
||||
return platform.FetchOAuthConfig(a.ctx, a.apiBase)
|
||||
}
|
||||
|
||||
func oauthConfigFromEnv() platform.OAuthConfig {
|
||||
return platform.OAuthConfig{
|
||||
AuthorizeURL: os.Getenv("CDROP_OAUTH_AUTHORIZE_URL"),
|
||||
TokenURL: os.Getenv("CDROP_OAUTH_TOKEN_URL"),
|
||||
ClientID: os.Getenv("CDROP_OAUTH_CLIENT_ID"),
|
||||
Scopes: os.Getenv("CDROP_OAUTH_SCOPES"),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user