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:
@@ -0,0 +1,108 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ClipboardMaxBytes mirrors the backend's CDROP_CLIPBOARD_MAX_BYTES (default
|
||||
// 64 KiB). Uploads larger than this are rejected client-side, matching the web.
|
||||
const ClipboardMaxBytes = 64 * 1024
|
||||
|
||||
// Client is an authenticated client for the cdrop backend API. The access token
|
||||
// is held here in Go (never in the WebView); deviceName identifies this device
|
||||
// as the source of clipboard writes via the X-Device-Name header.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
deviceName string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient builds a Client. baseURL is the backend origin, e.g.
|
||||
// https://drop.commilitia.net.
|
||||
func NewClient(baseURL, accessToken, deviceName string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: accessToken,
|
||||
deviceName: deviceName,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// ClipboardContent is the cloud clipboard state. UpdatedAt is the server's
|
||||
// authoritative timestamp (also the ETag); 0 means never written.
|
||||
type ClipboardContent struct {
|
||||
Content string `json:"content"`
|
||||
ContentType string `json:"content_type"`
|
||||
SourceDevice string `json:"source_device"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (c *Client) authReq(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.deviceName != "" {
|
||||
req.Header.Set("X-Device-Name", c.deviceName)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// Clipboard fetches the current cloud clipboard content.
|
||||
func (c *Client) Clipboard(ctx context.Context) (ClipboardContent, error) {
|
||||
req, err := c.authReq(ctx, http.MethodGet, "/api/clipboard", nil)
|
||||
if err != nil {
|
||||
return ClipboardContent{}, fmt.Errorf("api: build clipboard request: %w", err)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return ClipboardContent{}, fmt.Errorf("api: clipboard request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return ClipboardContent{}, fmt.Errorf("api: GET clipboard status %d", resp.StatusCode)
|
||||
}
|
||||
var cc ClipboardContent
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cc); err != nil {
|
||||
return ClipboardContent{}, fmt.Errorf("api: decode clipboard: %w", err)
|
||||
}
|
||||
return cc, nil
|
||||
}
|
||||
|
||||
// PutClipboard uploads plain text to the cloud clipboard.
|
||||
func (c *Client) PutClipboard(ctx context.Context, text string) error {
|
||||
if len(text) > ClipboardMaxBytes {
|
||||
return fmt.Errorf("api: clipboard content %d bytes exceeds limit %d", len(text), ClipboardMaxBytes)
|
||||
}
|
||||
payload, err := json.Marshal(map[string]string{
|
||||
"content": text,
|
||||
"content_type": "text/plain",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("api: marshal clipboard: %w", err)
|
||||
}
|
||||
req, err := c.authReq(ctx, http.MethodPut, "/api/clipboard", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("api: build clipboard request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("api: clipboard upload: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("api: PUT clipboard status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPutClipboard(t *testing.T) {
|
||||
var gotAuth, gotDevice, gotCT string
|
||||
var gotBody map[string]string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut || r.URL.Path != "/api/clipboard" {
|
||||
t.Errorf("method/path = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotDevice = r.Header.Get("X-Device-Name")
|
||||
gotCT = r.Header.Get("Content-Type")
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "tok-abc", "my-mac")
|
||||
if err := c.PutClipboard(context.Background(), "hello world"); err != nil {
|
||||
t.Fatalf("PutClipboard: %v", err)
|
||||
}
|
||||
if gotAuth != "Bearer tok-abc" {
|
||||
t.Errorf("Authorization = %q, want Bearer tok-abc", gotAuth)
|
||||
}
|
||||
if gotDevice != "my-mac" {
|
||||
t.Errorf("X-Device-Name = %q, want my-mac", gotDevice)
|
||||
}
|
||||
if gotCT != "application/json" {
|
||||
t.Errorf("Content-Type = %q", gotCT)
|
||||
}
|
||||
if gotBody["content"] != "hello world" || gotBody["content_type"] != "text/plain" {
|
||||
t.Errorf("body = %v", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutClipboard_TooLarge(t *testing.T) {
|
||||
c := NewClient("https://example", "tok", "dev")
|
||||
big := strings.Repeat("x", ClipboardMaxBytes+1)
|
||||
if err := c.PutClipboard(context.Background(), big); err == nil {
|
||||
t.Fatal("want error for oversized content, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboard_Get(t *testing.T) {
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(ClipboardContent{
|
||||
Content: "from cloud",
|
||||
ContentType: "text/plain",
|
||||
SourceDevice: "other-device",
|
||||
UpdatedAt: 1700000000,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "tok-xyz", "my-mac")
|
||||
cc, err := c.Clipboard(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Clipboard: %v", err)
|
||||
}
|
||||
if gotAuth != "Bearer tok-xyz" {
|
||||
t.Errorf("Authorization = %q", gotAuth)
|
||||
}
|
||||
if cc.Content != "from cloud" || cc.SourceDevice != "other-device" || cc.UpdatedAt != 1700000000 {
|
||||
t.Errorf("clipboard = %+v", cc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboard_Unauthorized(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "bad", "dev")
|
||||
if _, err := c.Clipboard(context.Background()); err == nil {
|
||||
t.Fatal("want error for 401, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DesktopConfig is the desktop-only preference set, persisted as JSON under the
|
||||
// user config dir. json tags are the contract with the WebView settings page.
|
||||
type DesktopConfig struct {
|
||||
ClipboardSyncEnabled bool `json:"clipboard_sync_enabled"`
|
||||
LaunchAtLogin bool `json:"launch_at_login"`
|
||||
DeviceName string `json:"device_name"` // empty = use the hostname
|
||||
DownloadDir string `json:"download_dir"` // empty = system Downloads dir
|
||||
}
|
||||
|
||||
// ResolveDeviceName returns the persisted device name, or the hostname when the
|
||||
// user hasn't renamed it. Persisting it Go-side is required because the WebView's
|
||||
// localStorage doesn't survive restart on the wails:// scheme (see session.go).
|
||||
func ResolveDeviceName() string {
|
||||
cfg, _ := LoadConfig()
|
||||
if cfg.DeviceName != "" {
|
||||
return cfg.DeviceName
|
||||
}
|
||||
if h, err := os.Hostname(); err == nil && h != "" {
|
||||
return h
|
||||
}
|
||||
return "cdrop-desktop"
|
||||
}
|
||||
|
||||
// DefaultConfig is what a fresh install gets: clipboard sync on, no autostart.
|
||||
func DefaultConfig() DesktopConfig {
|
||||
return DesktopConfig{ClipboardSyncEnabled: true, LaunchAtLogin: false}
|
||||
}
|
||||
|
||||
// ResolveDownloadDir returns the directory received files land in: the user's
|
||||
// configured override, or the system Downloads dir when unset.
|
||||
func ResolveDownloadDir() string {
|
||||
cfg, _ := LoadConfig()
|
||||
if cfg.DownloadDir != "" {
|
||||
return cfg.DownloadDir
|
||||
}
|
||||
return DefaultDownloadDir()
|
||||
}
|
||||
|
||||
// DefaultDownloadDir is ~/Downloads (the OS default download folder; its path is
|
||||
// stable across locales on macOS/Windows). Falls back to the cwd if the home
|
||||
// dir can't be resolved.
|
||||
func DefaultDownloadDir() string {
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
return filepath.Join(home, "Downloads")
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
||||
// configPath is ~/Library/Application Support/cdrop/config.json on macOS
|
||||
// (os.UserConfigDir resolves the per-OS base).
|
||||
func configPath() (string, error) {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "cdrop", "config.json"), nil
|
||||
}
|
||||
|
||||
// LoadConfig reads the persisted config, falling back to defaults when the file
|
||||
// is absent or unreadable so the app always has a usable preference set.
|
||||
func LoadConfig() (DesktopConfig, error) {
|
||||
p, err := configPath()
|
||||
if err != nil {
|
||||
return DefaultConfig(), err
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return DefaultConfig(), err
|
||||
}
|
||||
cfg := DefaultConfig()
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return DefaultConfig(), err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SaveConfig writes the config atomically-ish (mkdir + write) under the user
|
||||
// config dir.
|
||||
func SaveConfig(cfg DesktopConfig) error {
|
||||
p, err := configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, data, 0o644)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigRoundTrip(t *testing.T) {
|
||||
// Redirect the user config dir into a temp HOME so the test never touches
|
||||
// the real ~/Library/Application Support.
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
// Absent file → defaults.
|
||||
got, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig (absent): %v", err)
|
||||
}
|
||||
if got != DefaultConfig() {
|
||||
t.Errorf("absent config should be defaults, got %+v", got)
|
||||
}
|
||||
|
||||
want := DesktopConfig{ClipboardSyncEnabled: false, LaunchAtLogin: true}
|
||||
if err := SaveConfig(want); err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
got, err = LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("round-trip: got %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigToleratesGarbage(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
p, err := configPath()
|
||||
if err != nil {
|
||||
t.Fatalf("configPath: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := LoadConfig()
|
||||
if err == nil {
|
||||
t.Error("expected an error for malformed json")
|
||||
}
|
||||
if got != DefaultConfig() {
|
||||
t.Errorf("malformed config should fall back to defaults, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ClipboardEvent is one observed change of the local clipboard.
|
||||
type ClipboardEvent struct {
|
||||
Text string
|
||||
// Sensitive is set by the platform Source when the pasteboard carries a
|
||||
// privacy marker — on macOS org.nspasteboard.ConcealedType / TransientType /
|
||||
// AutoGeneratedType or a known password-manager UTI; on Windows the
|
||||
// ExcludeClipboardContentFromMonitorProcessing / CanUploadToCloudClipboard
|
||||
// formats. Sensitive events are never uploaded.
|
||||
Sensitive bool
|
||||
}
|
||||
|
||||
// Sink receives clipboard text that the policy decided should be uploaded.
|
||||
type Sink func(text string)
|
||||
|
||||
// Source is the platform-specific clipboard backend: it watches for local
|
||||
// changes and can write text back. Implementations live in clipboard_darwin.go
|
||||
// / clipboard_windows.go. The upstream golang.design/x/clipboard library only
|
||||
// surfaces the changed text (no change-count, no pasteboard types), so the
|
||||
// Sensitive flag and the upload policy below are this package's responsibility,
|
||||
// not the library's.
|
||||
type Source interface {
|
||||
// Watch emits an event on every observed local clipboard change until ctx
|
||||
// is cancelled, then closes the channel. The implementation sets Sensitive
|
||||
// from the pasteboard's privacy markers.
|
||||
Watch(ctx context.Context) <-chan ClipboardEvent
|
||||
// Write replaces the local clipboard text.
|
||||
Write(text string) error
|
||||
}
|
||||
|
||||
// Monitor applies cdrop's upload policy on top of a raw clipboard event stream:
|
||||
// it drops sensitive content, de-duplicates repeats, and suppresses the echo of
|
||||
// our own writes (the loop guard, PLAN.md R4). Everything here is pure and
|
||||
// host-independent, so it is unit-tested without a real clipboard; the
|
||||
// platform Source produces the raw events and performs the OS writes.
|
||||
//
|
||||
// Debounce of rapid successive copies is intentionally left to the upload
|
||||
// callback (wrap it with github.com/bep/debounce in the app wiring) so the
|
||||
// policy stays free of timers and trivially testable.
|
||||
type Monitor struct {
|
||||
upload Sink
|
||||
|
||||
mu sync.Mutex
|
||||
lastHash string // hash of the last text we accepted (dedup)
|
||||
selfHash string // hash of the text we most recently wrote ourselves (loop guard)
|
||||
}
|
||||
|
||||
// NewMonitor builds a Monitor that forwards accepted text to upload.
|
||||
func NewMonitor(upload Sink) *Monitor {
|
||||
return &Monitor{upload: upload}
|
||||
}
|
||||
|
||||
// Observe runs the policy for one raw event. It returns true when the event was
|
||||
// accepted and forwarded to the sink, false when it was filtered out. Safe to
|
||||
// call concurrently with ApplyRemote — the watch goroutine and the download
|
||||
// path share one Monitor.
|
||||
func (m *Monitor) Observe(ev ClipboardEvent) bool {
|
||||
if ev.Sensitive || ev.Text == "" {
|
||||
return false
|
||||
}
|
||||
h := hashText(ev.Text)
|
||||
|
||||
m.mu.Lock()
|
||||
if h == m.selfHash {
|
||||
// Echo of our own write: consume it once (don't re-upload) and disarm,
|
||||
// so a genuine later re-copy of the same text still passes.
|
||||
m.selfHash = ""
|
||||
m.lastHash = h
|
||||
m.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
if h == m.lastHash {
|
||||
m.mu.Unlock()
|
||||
return false // unchanged content re-observed
|
||||
}
|
||||
m.lastHash = h
|
||||
m.mu.Unlock()
|
||||
|
||||
m.upload(ev.Text) // outside the lock: the sink may block (network / IPC)
|
||||
return true
|
||||
}
|
||||
|
||||
// WillWrite must be called immediately before the Source writes text to the
|
||||
// local clipboard, so the resulting change notification (carrying the same
|
||||
// text) is recognised as our own echo and not re-uploaded.
|
||||
func (m *Monitor) WillWrite(text string) {
|
||||
m.mu.Lock()
|
||||
m.selfHash = hashText(text)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// ApplyRemote writes a remote clipboard change to the local clipboard (the
|
||||
// download direction), skipping our own uploads echoed back by the server and
|
||||
// content we already hold. It arms the loop guard before writing, so the
|
||||
// Source's resulting change event is not re-uploaded. Returns true if it wrote.
|
||||
func (m *Monitor) ApplyRemote(src Source, cc ClipboardContent, selfDevice string) bool {
|
||||
if cc.Content == "" {
|
||||
return false
|
||||
}
|
||||
if selfDevice != "" && cc.SourceDevice == selfDevice {
|
||||
return false // our own upload, echoed back by the server
|
||||
}
|
||||
h := hashText(cc.Content)
|
||||
|
||||
m.mu.Lock()
|
||||
if h == m.lastHash {
|
||||
m.mu.Unlock()
|
||||
return false // already the current local content
|
||||
}
|
||||
m.selfHash = h // arm loop guard inline (avoid re-locking via WillWrite)
|
||||
m.mu.Unlock()
|
||||
|
||||
if err := src.Write(cc.Content); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.lastHash = h
|
||||
m.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
// Run drives src's events through m until ctx is cancelled or the source closes.
|
||||
// It is the glue the app uses; all policy decisions live in Monitor.
|
||||
func Run(ctx context.Context, src Source, m *Monitor) {
|
||||
ch := src.Watch(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ev, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
m.Observe(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hashText(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build darwin
|
||||
|
||||
package platform
|
||||
|
||||
/*
|
||||
#cgo darwin CFLAGS: -x objective-c -fobjc-arc
|
||||
#cgo darwin LDFLAGS: -framework Cocoa
|
||||
#include <stdlib.h>
|
||||
|
||||
long cdropPasteboardChangeCount(void);
|
||||
char *cdropPasteboardReadText(int *sensitive);
|
||||
void cdropPasteboardWriteText(const char *text);
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// pasteboardPollInterval is how often the watcher samples the change counter.
|
||||
// 400ms keeps copies feeling instant without busy-polling.
|
||||
const pasteboardPollInterval = 400 * time.Millisecond
|
||||
|
||||
// darwinClipboard is the macOS Source: it polls NSPasteboard's changeCount for
|
||||
// local copies and writes plain text back. NSPasteboard read/write off the main
|
||||
// thread is the same approach the common clipboard libraries take.
|
||||
type darwinClipboard struct {
|
||||
poll time.Duration
|
||||
}
|
||||
|
||||
// NewClipboardSource returns the platform clipboard Source (macOS).
|
||||
func NewClipboardSource() Source {
|
||||
return &darwinClipboard{poll: pasteboardPollInterval}
|
||||
}
|
||||
|
||||
func (d *darwinClipboard) Watch(ctx context.Context) <-chan ClipboardEvent {
|
||||
ch := make(chan ClipboardEvent)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
last := C.cdropPasteboardChangeCount()
|
||||
ticker := time.NewTicker(d.poll)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
cc := C.cdropPasteboardChangeCount()
|
||||
if cc == last {
|
||||
continue
|
||||
}
|
||||
last = cc
|
||||
|
||||
var sensitive C.int
|
||||
cstr := C.cdropPasteboardReadText(&sensitive)
|
||||
if cstr == nil {
|
||||
continue // non-text payload (image / file) — ignore
|
||||
}
|
||||
text := C.GoString(cstr)
|
||||
C.free(unsafe.Pointer(cstr))
|
||||
|
||||
select {
|
||||
case ch <- ClipboardEvent{Text: text, Sensitive: sensitive != 0}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (d *darwinClipboard) Write(text string) error {
|
||||
c := C.CString(text)
|
||||
defer C.free(unsafe.Pointer(c))
|
||||
C.cdropPasteboardWriteText(c)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
// cdropPasteboardChangeCount returns the general pasteboard's monotonic change
|
||||
// counter. Polling it is the cheap way to detect a copy without a callback API.
|
||||
long cdropPasteboardChangeCount(void) {
|
||||
return (long)[[NSPasteboard generalPasteboard] changeCount];
|
||||
}
|
||||
|
||||
// cdropPasteboardReadText returns a malloc'd UTF-8 copy of the pasteboard's
|
||||
// plain-text payload (caller frees), or NULL when there is no text (image /
|
||||
// file only). It sets *sensitive to 1 when a privacy marker type is present —
|
||||
// org.nspasteboard.{Concealed,Transient,AutoGenerated}Type — so the upload
|
||||
// policy can skip it. (Most password managers set no marker; this is a
|
||||
// best-effort secondary guard behind the server's short TTL.)
|
||||
char *cdropPasteboardReadText(int *sensitive) {
|
||||
NSPasteboard *pb = [NSPasteboard generalPasteboard];
|
||||
*sensitive = 0;
|
||||
for (NSString *type in pb.types) {
|
||||
if ([type isEqualToString:@"org.nspasteboard.ConcealedType"] ||
|
||||
[type isEqualToString:@"org.nspasteboard.TransientType"] ||
|
||||
[type isEqualToString:@"org.nspasteboard.AutoGeneratedType"]) {
|
||||
*sensitive = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
NSString *s = [pb stringForType:NSPasteboardTypeString];
|
||||
if (s == nil) { return NULL; }
|
||||
const char *utf8 = [s UTF8String];
|
||||
if (utf8 == NULL) { return NULL; }
|
||||
return strdup(utf8);
|
||||
}
|
||||
|
||||
// cdropPasteboardWriteText replaces the pasteboard's contents with plain text.
|
||||
void cdropPasteboardWriteText(const char *text) {
|
||||
NSString *s = [NSString stringWithUTF8String:text];
|
||||
if (s == nil) { return; }
|
||||
NSPasteboard *pb = [NSPasteboard generalPasteboard];
|
||||
[pb clearContents];
|
||||
[pb setString:s forType:NSPasteboardTypeString];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package platform
|
||||
|
||||
import "context"
|
||||
|
||||
// noopClipboard is the non-darwin Source: it never emits and silently drops
|
||||
// writes. Windows/Linux native clipboard backends land when those platforms are
|
||||
// targeted; until then the rest of the sync wiring compiles and stays inert.
|
||||
type noopClipboard struct{}
|
||||
|
||||
// NewClipboardSource returns an inert Source outside macOS.
|
||||
func NewClipboardSource() Source { return noopClipboard{} }
|
||||
|
||||
func (noopClipboard) Watch(ctx context.Context) <-chan ClipboardEvent {
|
||||
ch := make(chan ClipboardEvent)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (noopClipboard) Write(string) error { return nil }
|
||||
@@ -0,0 +1,199 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// collect records everything the monitor decides to upload.
|
||||
func newCollector() (*[]string, Sink) {
|
||||
var got []string
|
||||
return &got, func(text string) { got = append(got, text) }
|
||||
}
|
||||
|
||||
func TestMonitor_UploadsNewText(t *testing.T) {
|
||||
got, sink := newCollector()
|
||||
m := NewMonitor(sink)
|
||||
|
||||
if !m.Observe(ClipboardEvent{Text: "hello"}) {
|
||||
t.Error("first new text should be accepted")
|
||||
}
|
||||
if len(*got) != 1 || (*got)[0] != "hello" {
|
||||
t.Errorf("uploads = %v, want [hello]", *got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitor_DropsSensitive(t *testing.T) {
|
||||
got, sink := newCollector()
|
||||
m := NewMonitor(sink)
|
||||
|
||||
if m.Observe(ClipboardEvent{Text: "hunter2", Sensitive: true}) {
|
||||
t.Error("sensitive content must not be accepted")
|
||||
}
|
||||
if len(*got) != 0 {
|
||||
t.Errorf("uploads = %v, want none", *got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitor_DropsEmpty(t *testing.T) {
|
||||
got, sink := newCollector()
|
||||
m := NewMonitor(sink)
|
||||
if m.Observe(ClipboardEvent{Text: ""}) {
|
||||
t.Error("empty text must not be accepted")
|
||||
}
|
||||
if len(*got) != 0 {
|
||||
t.Errorf("uploads = %v, want none", *got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitor_DedupsRepeats(t *testing.T) {
|
||||
got, sink := newCollector()
|
||||
m := NewMonitor(sink)
|
||||
|
||||
m.Observe(ClipboardEvent{Text: "same"})
|
||||
if m.Observe(ClipboardEvent{Text: "same"}) {
|
||||
t.Error("identical repeat should be dropped")
|
||||
}
|
||||
if len(*got) != 1 {
|
||||
t.Errorf("uploads = %v, want one", *got)
|
||||
}
|
||||
// a different value re-arms uploads
|
||||
if !m.Observe(ClipboardEvent{Text: "other"}) {
|
||||
t.Error("changed text should be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitor_LoopGuardSuppressesOwnEcho(t *testing.T) {
|
||||
got, sink := newCollector()
|
||||
m := NewMonitor(sink)
|
||||
|
||||
// We write "from-cloud" locally, then the OS reports that same change.
|
||||
m.WillWrite("from-cloud")
|
||||
if m.Observe(ClipboardEvent{Text: "from-cloud"}) {
|
||||
t.Error("our own write echo must not be re-uploaded")
|
||||
}
|
||||
if len(*got) != 0 {
|
||||
t.Errorf("uploads = %v, want none (echo suppressed)", *got)
|
||||
}
|
||||
|
||||
// A genuinely new copy afterwards still uploads.
|
||||
if !m.Observe(ClipboardEvent{Text: "typed-by-user"}) {
|
||||
t.Error("subsequent real change should be accepted")
|
||||
}
|
||||
if len(*got) != 1 || (*got)[0] != "typed-by-user" {
|
||||
t.Errorf("uploads = %v, want [typed-by-user]", *got)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeSource feeds a fixed list of events into Watch then closes.
|
||||
type fakeSource struct {
|
||||
events []ClipboardEvent
|
||||
}
|
||||
|
||||
func (f *fakeSource) Watch(ctx context.Context) <-chan ClipboardEvent {
|
||||
ch := make(chan ClipboardEvent)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
for _, ev := range f.events {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ch <- ev:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (f *fakeSource) Write(string) error { return nil }
|
||||
|
||||
func TestRun_DrivesSourceThroughMonitor(t *testing.T) {
|
||||
got, sink := newCollector()
|
||||
m := NewMonitor(sink)
|
||||
src := &fakeSource{events: []ClipboardEvent{
|
||||
{Text: "a"},
|
||||
{Text: "secret", Sensitive: true},
|
||||
{Text: "a"}, // dedup
|
||||
{Text: "b"},
|
||||
}}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
Run(ctx, src, m)
|
||||
|
||||
want := []string{"a", "b"}
|
||||
if len(*got) != len(want) || (*got)[0] != "a" || (*got)[1] != "b" {
|
||||
t.Errorf("uploads = %v, want %v", *got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// recordingSource records Write calls; Watch is unused for the download tests.
|
||||
type recordingSource struct {
|
||||
writes []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *recordingSource) Watch(context.Context) <-chan ClipboardEvent {
|
||||
ch := make(chan ClipboardEvent)
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
func (r *recordingSource) Write(text string) error {
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
r.writes = append(r.writes, text)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestApplyRemote_WritesNewContent(t *testing.T) {
|
||||
src := &recordingSource{}
|
||||
m := NewMonitor(func(string) {})
|
||||
if !m.ApplyRemote(src, ClipboardContent{Content: "hi", SourceDevice: "other"}, "me") {
|
||||
t.Error("new remote content should be written locally")
|
||||
}
|
||||
if len(src.writes) != 1 || src.writes[0] != "hi" {
|
||||
t.Errorf("writes = %v, want [hi]", src.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRemote_SkipsSelfSource(t *testing.T) {
|
||||
src := &recordingSource{}
|
||||
m := NewMonitor(func(string) {})
|
||||
if m.ApplyRemote(src, ClipboardContent{Content: "hi", SourceDevice: "me"}, "me") {
|
||||
t.Error("our own echoed upload must be skipped")
|
||||
}
|
||||
if len(src.writes) != 0 {
|
||||
t.Errorf("writes = %v, want none", src.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRemote_SkipsEmptyAndCurrent(t *testing.T) {
|
||||
src := &recordingSource{}
|
||||
m := NewMonitor(func(string) {})
|
||||
if m.ApplyRemote(src, ClipboardContent{Content: "", SourceDevice: "other"}, "me") {
|
||||
t.Error("empty remote content must be skipped")
|
||||
}
|
||||
m.Observe(ClipboardEvent{Text: "X"}) // lastHash now = hash(X)
|
||||
if m.ApplyRemote(src, ClipboardContent{Content: "X", SourceDevice: "other"}, "me") {
|
||||
t.Error("content equal to current must be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
// The full loop guard across both directions: apply a remote change, then the
|
||||
// Source's resulting local change event must not be re-uploaded.
|
||||
func TestApplyRemote_LoopGuardAcrossObserve(t *testing.T) {
|
||||
src := &recordingSource{}
|
||||
var uploads []string
|
||||
m := NewMonitor(func(text string) { uploads = append(uploads, text) })
|
||||
|
||||
m.ApplyRemote(src, ClipboardContent{Content: "cloud", SourceDevice: "other"}, "me")
|
||||
if m.Observe(ClipboardEvent{Text: "cloud"}) {
|
||||
t.Error("echo of an applied-remote write must be suppressed")
|
||||
}
|
||||
if len(uploads) != 0 {
|
||||
t.Errorf("uploads = %v, want none", uploads)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//go:build windows
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
// windowsClipboard is the Windows Source backed by golang.design/x/clipboard
|
||||
// (pure syscall, no cgo): it watches the clipboard for local copies via
|
||||
// GetClipboardSequenceNumber polling and writes plain text back.
|
||||
type windowsClipboard struct {
|
||||
ready bool
|
||||
}
|
||||
|
||||
// NewClipboardSource returns the platform clipboard Source (Windows). If the
|
||||
// clipboard can't be initialised it degrades to an inert source so the rest of
|
||||
// the sync wiring still runs.
|
||||
func NewClipboardSource() Source {
|
||||
return &windowsClipboard{ready: clipboard.Init() == nil}
|
||||
}
|
||||
|
||||
func (c *windowsClipboard) Watch(ctx context.Context) <-chan ClipboardEvent {
|
||||
out := make(chan ClipboardEvent)
|
||||
go func() {
|
||||
defer close(out)
|
||||
if !c.ready {
|
||||
<-ctx.Done()
|
||||
return
|
||||
}
|
||||
changes := clipboard.Watch(ctx, clipboard.FmtText)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case data, ok := <-changes:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if data.Format != clipboard.FmtText || len(data.Bytes) == 0 {
|
||||
continue
|
||||
}
|
||||
// golang.design/x/clipboard surfaces only the text — the Windows
|
||||
// privacy formats (ExcludeClipboardContentFromMonitorProcessing /
|
||||
// CanUploadToCloudClipboard) aren't exposed, so Sensitive stays
|
||||
// false and the server's 5-min TTL is the backstop. A raw
|
||||
// EnumClipboardFormats check is a possible follow-up.
|
||||
select {
|
||||
case out <- ClipboardEvent{Text: string(data.Bytes)}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *windowsClipboard) Write(text string) error {
|
||||
if !c.ready {
|
||||
return nil
|
||||
}
|
||||
clipboard.Write(clipboard.FmtText, []byte(text))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FetchOAuthConfig pulls the provider coordinates from the cdrop backend's
|
||||
// /api/auth/config. The desktop reuses the same OAuth client as the web app
|
||||
// (the client_id published there) and runs its own loopback PKCE flow against
|
||||
// the provider, instead of the browser's in-page redirect + /api/auth/exchange
|
||||
// proxy. apiBase is the backend origin, e.g. https://drop.commilitia.net.
|
||||
func FetchOAuthConfig(ctx context.Context, apiBase string) (OAuthConfig, error) {
|
||||
endpoint := strings.TrimRight(apiBase, "/") + "/api/auth/config"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return OAuthConfig{}, fmt.Errorf("oauth: build config request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return OAuthConfig{}, fmt.Errorf("oauth: fetch config: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return OAuthConfig{}, fmt.Errorf("oauth: config endpoint status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var c struct {
|
||||
AuthorizeURL string `json:"authorize_url"`
|
||||
TokenURL string `json:"token_url"`
|
||||
ClientID string `json:"client_id"`
|
||||
Scopes string `json:"scopes"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&c); err != nil {
|
||||
return OAuthConfig{}, fmt.Errorf("oauth: decode config: %w", err)
|
||||
}
|
||||
|
||||
cfg := OAuthConfig{
|
||||
AuthorizeURL: c.AuthorizeURL,
|
||||
TokenURL: c.TokenURL,
|
||||
ClientID: c.ClientID,
|
||||
Scopes: c.Scopes,
|
||||
}
|
||||
if cfg.AuthorizeURL == "" || cfg.TokenURL == "" || cfg.ClientID == "" {
|
||||
return OAuthConfig{}, errors.New("oauth: backend config missing authorize_url / token_url / client_id")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchOAuthConfig_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/auth/config" {
|
||||
t.Errorf("path = %s, want /api/auth/config", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"auth_mode": "prod",
|
||||
"authorize_url": "https://casdoor.example/login/oauth/authorize",
|
||||
"token_url": "https://casdoor.example/api/login/oauth/access_token",
|
||||
"client_id": "web-client-id",
|
||||
"redirect_uri": "https://drop.example/oauth/callback",
|
||||
"scopes": "openid profile groups",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// trailing slash on apiBase must be tolerated
|
||||
cfg, err := FetchOAuthConfig(context.Background(), srv.URL+"/")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchOAuthConfig: %v", err)
|
||||
}
|
||||
if cfg.ClientID != "web-client-id" {
|
||||
t.Errorf("client_id = %q, want web-client-id (reused web client)", cfg.ClientID)
|
||||
}
|
||||
if cfg.TokenURL != "https://casdoor.example/api/login/oauth/access_token" {
|
||||
t.Errorf("token_url = %q", cfg.TokenURL)
|
||||
}
|
||||
if cfg.Scopes != "openid profile groups" {
|
||||
t.Errorf("scopes = %q", cfg.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchOAuthConfig_Incomplete(t *testing.T) {
|
||||
// backend missing token_url / client_id (e.g. dev mode) must be rejected.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"auth_mode": "dev",
|
||||
"authorize_url": "https://casdoor.example/login/oauth/authorize",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := FetchOAuthConfig(context.Background(), srv.URL); err == nil {
|
||||
t.Fatal("want error for incomplete backend config, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchOAuthConfig_BadStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := FetchOAuthConfig(context.Background(), srv.URL); err == nil {
|
||||
t.Fatal("want error for 500 status, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SaveDownload writes a received file's bytes into dir (empty → system Downloads),
|
||||
// returning the absolute path written. The name comes from a transfer peer (U2,
|
||||
// untrusted), so it is sanitized to a bare filename before use — no path
|
||||
// components survive, so a crafted name like "../../x" cannot escape dir. On a
|
||||
// name collision a " (n)" suffix is appended rather than overwriting. After a
|
||||
// successful write the file is best-effort revealed in the OS file manager.
|
||||
//
|
||||
// If the configured dir is unwritable (deleted, no permission, read-only), the
|
||||
// write is retried into the system Downloads dir before giving up — the WebView
|
||||
// has no usable download fallback (WKWebView ignores blob a[download]), so a
|
||||
// received file must never be silently lost to a bad setting. The returned path
|
||||
// reflects where the file actually landed, so the UI can show it.
|
||||
func SaveDownload(dir, name string, data []byte) (string, error) {
|
||||
if dir == "" {
|
||||
dir = DefaultDownloadDir()
|
||||
}
|
||||
path, err := writeInto(dir, name, data)
|
||||
if err == nil {
|
||||
return path, nil
|
||||
}
|
||||
if def := DefaultDownloadDir(); def != dir {
|
||||
if p, ferr := writeInto(def, name, data); ferr == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", err // report the original (configured-dir) failure
|
||||
}
|
||||
|
||||
// writeInto sanitizes the name, resolves a non-colliding path under dir, writes
|
||||
// the bytes, and reveals the result. Returns the absolute path written.
|
||||
func writeInto(dir, name string, data []byte) (string, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
safe := sanitizeFileName(name)
|
||||
if safe == "" {
|
||||
safe = "cdrop-download"
|
||||
}
|
||||
target := uniquePath(dir, safe)
|
||||
if err := os.WriteFile(target, data, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
revealInFileManager(target)
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// sanitizeFileName reduces a peer-supplied name to a single safe filename:
|
||||
// directory components are dropped (path-traversal defense), control chars are
|
||||
// removed, and characters reserved by common filesystems are replaced with "_".
|
||||
func sanitizeFileName(name string) string {
|
||||
name = filepath.Base(filepath.FromSlash(name)) // strip any directory parts
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "." || name == ".." {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r < 0x20:
|
||||
continue // control characters
|
||||
case strings.ContainsRune(`/\:*?"<>|`, r):
|
||||
b.WriteRune('_') // filesystem-reserved
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := strings.TrimSpace(b.String())
|
||||
if len(out) > 200 {
|
||||
out = strings.TrimSpace(out[:200])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uniquePath returns dir/name, or dir/"name (n).ext" with the lowest n that does
|
||||
// not yet exist, so a repeated transfer never silently overwrites a prior file.
|
||||
func uniquePath(dir, name string) string {
|
||||
target := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(target); os.IsNotExist(err) {
|
||||
return target
|
||||
}
|
||||
ext := filepath.Ext(name)
|
||||
stem := strings.TrimSuffix(name, ext)
|
||||
for i := 1; ; i += 1 {
|
||||
cand := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", stem, i, ext))
|
||||
if _, err := os.Stat(cand); os.IsNotExist(err) {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// revealInFileManager opens the OS file manager with the saved file selected.
|
||||
// Best-effort: the save already succeeded, so any error here is ignored.
|
||||
func revealInFileManager(path string) {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
_ = exec.Command("open", "-R", path).Start()
|
||||
case "windows":
|
||||
_ = exec.Command("explorer", "/select,"+path).Start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UserInfo is the display identity the WebView store needs (mirrors the web
|
||||
// app's User shape: id / name / avatar).
|
||||
type UserInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
// LoginResult is the full result Go keeps internally: tokens + identity. The
|
||||
// refresh_token never crosses into the WebView — see SessionView and the
|
||||
// credential policy in session.go.
|
||||
type LoginResult struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User UserInfo `json:"user"`
|
||||
}
|
||||
|
||||
// SessionView is the refresh-token-free projection handed to the WebView (login
|
||||
// emit, refresh return, boot injection). The refresh_token — the long-lived
|
||||
// secret — is deliberately omitted: it lives only in the Go process and the
|
||||
// on-disk session, never in JS or the injected HTML.
|
||||
type SessionView struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User UserInfo `json:"user"`
|
||||
}
|
||||
|
||||
// View projects a LoginResult to the refresh-free SessionView.
|
||||
func (r LoginResult) View() SessionView {
|
||||
return SessionView{AccessToken: r.AccessToken, ExpiresIn: r.ExpiresIn, User: r.User}
|
||||
}
|
||||
|
||||
// UserFromToken extracts the display identity from OIDC tokens WITHOUT verifying
|
||||
// the signature. This mirrors the backend's extractUser (internal/httpapi/auth.go)
|
||||
// exactly: prefer the id_token's richer claims, fall back to the access_token;
|
||||
// name priority preferred_username → name → email → sub; avatar avatar → picture.
|
||||
//
|
||||
// Skipping verification is safe for the same reason it is on the backend: the
|
||||
// auth middleware verifies the access_token on every API call via JWKS, so any
|
||||
// tamper surfaces there. These claims only drive local display.
|
||||
func UserFromToken(idToken, accessToken string) (UserInfo, error) {
|
||||
pick := idToken
|
||||
if pick == "" {
|
||||
pick = accessToken
|
||||
}
|
||||
claims, err := decodeJWTClaims(pick)
|
||||
if err != nil {
|
||||
return UserInfo{}, err
|
||||
}
|
||||
|
||||
u := UserInfo{ID: claimString(claims, "sub")}
|
||||
for _, k := range []string{"preferred_username", "name", "email"} {
|
||||
if v := claimString(claims, k); v != "" {
|
||||
u.Name = v
|
||||
break
|
||||
}
|
||||
}
|
||||
if u.Name == "" {
|
||||
u.Name = u.ID
|
||||
}
|
||||
for _, k := range []string{"avatar", "picture"} {
|
||||
if v := claimString(claims, k); v != "" {
|
||||
u.Avatar = v
|
||||
break
|
||||
}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// decodeJWTClaims base64url-decodes the JWT payload segment and unmarshals it.
|
||||
// No signature check (see UserFromToken).
|
||||
func decodeJWTClaims(token string) (map[string]any, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return nil, errors.New("oauth: token is not a JWT")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: decode jwt payload: %w", err)
|
||||
}
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return nil, fmt.Errorf("oauth: parse jwt claims: %w", err)
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func claimString(m map[string]any, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// makeJWT builds a syntactically valid (unsigned) JWT carrying the given claims.
|
||||
// UserFromToken never verifies the signature, so a dummy header + sig suffice.
|
||||
func makeJWT(claims map[string]any) string {
|
||||
payload, _ := json.Marshal(claims)
|
||||
seg := base64.RawURLEncoding.EncodeToString(payload)
|
||||
return "eyJhbGciOiJSUzI1NiJ9." + seg + ".sig"
|
||||
}
|
||||
|
||||
func TestUserFromTokenPrefersIDToken(t *testing.T) {
|
||||
idTok := makeJWT(map[string]any{"sub": "u-1", "preferred_username": "alice"})
|
||||
accessTok := makeJWT(map[string]any{"sub": "u-1", "name": "bob"})
|
||||
|
||||
u, err := UserFromToken(idTok, accessTok)
|
||||
if err != nil {
|
||||
t.Fatalf("UserFromToken: %v", err)
|
||||
}
|
||||
if u.ID != "u-1" {
|
||||
t.Errorf("id: got %q, want u-1", u.ID)
|
||||
}
|
||||
if u.Name != "alice" {
|
||||
t.Errorf("name should come from id_token preferred_username, got %q", u.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFromTokenNamePriority(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
claims map[string]any
|
||||
want string
|
||||
}{
|
||||
{"preferred_username wins", map[string]any{"sub": "s", "preferred_username": "p", "name": "n", "email": "e"}, "p"},
|
||||
{"name when no preferred", map[string]any{"sub": "s", "name": "n", "email": "e"}, "n"},
|
||||
{"email when no name", map[string]any{"sub": "s", "email": "e@x"}, "e@x"},
|
||||
{"sub as last resort", map[string]any{"sub": "s"}, "s"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
u, err := UserFromToken(makeJWT(c.claims), "")
|
||||
if err != nil {
|
||||
t.Fatalf("UserFromToken: %v", err)
|
||||
}
|
||||
if u.Name != c.want {
|
||||
t.Errorf("name: got %q, want %q", u.Name, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFromTokenAvatarPriority(t *testing.T) {
|
||||
u, _ := UserFromToken(makeJWT(map[string]any{
|
||||
"sub": "s", "avatar": "a.png", "picture": "p.png",
|
||||
}), "")
|
||||
if u.Avatar != "a.png" {
|
||||
t.Errorf("avatar should prefer 'avatar' claim, got %q", u.Avatar)
|
||||
}
|
||||
|
||||
u2, _ := UserFromToken(makeJWT(map[string]any{"sub": "s", "picture": "p.png"}), "")
|
||||
if u2.Avatar != "p.png" {
|
||||
t.Errorf("avatar should fall back to 'picture', got %q", u2.Avatar)
|
||||
}
|
||||
|
||||
u3, _ := UserFromToken(makeJWT(map[string]any{"sub": "s"}), "")
|
||||
if u3.Avatar != "" {
|
||||
t.Errorf("avatar should be empty when no claim, got %q", u3.Avatar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFromTokenFallsBackToAccessToken(t *testing.T) {
|
||||
accessTok := makeJWT(map[string]any{"sub": "u-9", "name": "carol"})
|
||||
u, err := UserFromToken("", accessTok)
|
||||
if err != nil {
|
||||
t.Fatalf("UserFromToken: %v", err)
|
||||
}
|
||||
if u.ID != "u-9" || u.Name != "carol" {
|
||||
t.Errorf("fallback to access_token failed: %+v", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFromTokenRejectsGarbage(t *testing.T) {
|
||||
if _, err := UserFromToken("not-a-jwt", ""); err == nil {
|
||||
t.Error("expected error for non-JWT token")
|
||||
}
|
||||
if _, err := UserFromToken("a.!!!notbase64!!!.c", ""); err == nil {
|
||||
t.Error("expected error for undecodable payload")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//go:build darwin
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// launchAgentLabel is the reverse-DNS label + plist filename for the per-user
|
||||
// LaunchAgent that starts cdrop at login.
|
||||
const launchAgentLabel = "net.commilitia.cdrop"
|
||||
|
||||
func launchAgentPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, "Library", "LaunchAgents", launchAgentLabel+".plist"), nil
|
||||
}
|
||||
|
||||
// launchTarget returns the program the LaunchAgent should run. For a packaged
|
||||
// .app it launches the bundle via `open` (proper LaunchServices activation);
|
||||
// in a bare dev binary it runs the executable directly. Either way it appends
|
||||
// --hidden so a login launch comes up to the menu bar with no window (see
|
||||
// launchedHidden in main); `open --args` forwards the rest as the app's argv.
|
||||
func launchTarget() ([]string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
if bundle := appBundlePath(exe); bundle != "" {
|
||||
return []string{"/usr/bin/open", bundle, "--args", "--hidden"}, nil
|
||||
}
|
||||
return []string{exe, "--hidden"}, nil
|
||||
}
|
||||
|
||||
// appBundlePath returns the enclosing .app path if exe lives inside one
|
||||
// (…/Foo.app/Contents/MacOS/binary), else "".
|
||||
func appBundlePath(exe string) string {
|
||||
const marker = ".app/Contents/MacOS/"
|
||||
if i := strings.LastIndex(exe, marker); i != -1 {
|
||||
return exe[:i+len(".app")]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetLaunchAtLogin installs or removes the LaunchAgent plist. Removing a missing
|
||||
// agent is a no-op success.
|
||||
func SetLaunchAtLogin(enabled bool) error {
|
||||
p, err := launchAgentPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled {
|
||||
if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
args, err := launchTarget()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, []byte(buildLaunchAgentPlist(launchAgentLabel, args)), 0o644)
|
||||
}
|
||||
|
||||
// IsLaunchAtLoginEnabled reports whether the LaunchAgent plist is present.
|
||||
func IsLaunchAtLoginEnabled() bool {
|
||||
p, err := launchAgentPath()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func buildLaunchAgentPlist(label string, programArgs []string) string {
|
||||
var args strings.Builder
|
||||
for _, a := range programArgs {
|
||||
fmt.Fprintf(&args, "\n <string>%s</string>", xmlEscape(a))
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>%s</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>%s
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
`, xmlEscape(label), args.String())
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
r := strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
`"`, """,
|
||||
"'", "'",
|
||||
)
|
||||
return r.Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build darwin
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetLaunchAtLoginInstallsAndRemoves(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
if IsLaunchAtLoginEnabled() {
|
||||
t.Fatal("should start disabled")
|
||||
}
|
||||
|
||||
if err := SetLaunchAtLogin(true); err != nil {
|
||||
t.Fatalf("enable: %v", err)
|
||||
}
|
||||
if !IsLaunchAtLoginEnabled() {
|
||||
t.Error("should be enabled after install")
|
||||
}
|
||||
|
||||
p, _ := launchAgentPath()
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("read plist: %v", err)
|
||||
}
|
||||
plist := string(data)
|
||||
if !strings.Contains(plist, "<key>Label</key>") ||
|
||||
!strings.Contains(plist, launchAgentLabel) ||
|
||||
!strings.Contains(plist, "<key>RunAtLoad</key>") {
|
||||
t.Errorf("plist missing expected keys:\n%s", plist)
|
||||
}
|
||||
|
||||
// Disabling removes the file; removing again is a no-op success.
|
||||
if err := SetLaunchAtLogin(false); err != nil {
|
||||
t.Fatalf("disable: %v", err)
|
||||
}
|
||||
if IsLaunchAtLoginEnabled() {
|
||||
t.Error("should be disabled after removal")
|
||||
}
|
||||
if err := SetLaunchAtLogin(false); err != nil {
|
||||
t.Errorf("removing an absent agent should be a no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppBundlePath(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/Apps/cdrop.app/Contents/MacOS/desktop": "/Apps/cdrop.app",
|
||||
"/usr/local/bin/desktop": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := appBundlePath(in); got != want {
|
||||
t.Errorf("appBundlePath(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestXMLEscape(t *testing.T) {
|
||||
got := xmlEscape(`a&b<c>"d"`)
|
||||
if !strings.Contains(got, "&") || !strings.Contains(got, "<") || !strings.Contains(got, """) {
|
||||
t.Errorf("xmlEscape did not escape specials: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package platform
|
||||
|
||||
// Launch-at-login on Windows/Linux uses different mechanisms (registry Run key /
|
||||
// XDG autostart .desktop) and lands with those platform targets. Stubbed so the
|
||||
// settings wiring compiles everywhere.
|
||||
|
||||
// SetLaunchAtLogin is a no-op outside macOS.
|
||||
func SetLaunchAtLogin(bool) error { return nil }
|
||||
|
||||
// IsLaunchAtLoginEnabled always reports false outside macOS.
|
||||
func IsLaunchAtLoginEnabled() bool { return false }
|
||||
@@ -0,0 +1,53 @@
|
||||
//go:build windows
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
// runKeyPath is the per-user autostart key — entries here launch at sign-in
|
||||
// without elevation (HKLM would need admin).
|
||||
runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run`
|
||||
runValueName = "Commilitia Drop"
|
||||
)
|
||||
|
||||
// SetLaunchAtLogin adds or removes the per-user Run registry entry that starts
|
||||
// the app at sign-in. The value is the quoted executable path (so a Program
|
||||
// Files path with spaces stays one argument) followed by --hidden, so a login
|
||||
// launch comes up to the tray with no window (see launchedHidden in main).
|
||||
func SetLaunchAtLogin(enabled bool) error {
|
||||
k, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer k.Close()
|
||||
|
||||
if !enabled {
|
||||
if err := k.DeleteValue(runValueName); err != nil && !errors.Is(err, registry.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return k.SetStringValue(runValueName, `"`+exe+`" --hidden`)
|
||||
}
|
||||
|
||||
// IsLaunchAtLoginEnabled reports whether the Run entry is present.
|
||||
func IsLaunchAtLoginEnabled() bool {
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer k.Close()
|
||||
_, _, err = k.GetStringValue(runValueName)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// StartLocalAPIServer runs the /api reverse proxy on a loopback HTTP listener and
|
||||
// returns its base URL (e.g. http://127.0.0.1:54321).
|
||||
//
|
||||
// Why: on Windows, Wails' custom-scheme AssetServer buffers a handler's response
|
||||
// until the handler RETURNS, so a long-lived SSE (/api/hub/events, whose handler
|
||||
// never returns) never reaches the WebView — the device list never loads and the
|
||||
// status sticks on "connecting". A real loopback HTTP origin is served by
|
||||
// WebView2's normal network stack, which streams response bodies incrementally.
|
||||
//
|
||||
// The page reaches this via the injected api_base; since its origin differs from
|
||||
// the listener, CORS is added. No credentials/cookies are used (auth is a Bearer
|
||||
// header), so a permissive policy is safe. macOS (WKWebView) streams the custom
|
||||
// scheme fine and doesn't use this.
|
||||
func StartLocalAPIServer(apiBase string) (string, error) {
|
||||
proxy, err := NewAPIProxy(apiBase)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("local api: listen: %w", err)
|
||||
}
|
||||
srv := &http.Server{Handler: corsWrap(proxy)}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
return "http://" + ln.Addr().String(), nil
|
||||
}
|
||||
|
||||
// corsWrap adds permissive CORS so the WebView page (a different origin than this
|
||||
// loopback listener) can fetch /api, and answers the preflight. Includes the
|
||||
// Private Network Access ack since the page origin is also loopback.
|
||||
func corsWrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
origin = "*"
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
h.Add("Vary", "Origin")
|
||||
h.Add("Vary", "Access-Control-Request-Headers")
|
||||
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
// Echo the requested headers so every client header is allowed without
|
||||
// maintaining a list — the app sends Authorization, X-Device-Name,
|
||||
// X-Device-Type, and conditional headers like If-None-Match (clipboard
|
||||
// caching). Missing one fails the preflight as a "Failed to fetch".
|
||||
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
|
||||
h.Set("Access-Control-Allow-Headers", reqHeaders)
|
||||
} else {
|
||||
h.Set("Access-Control-Allow-Headers",
|
||||
"Authorization, Content-Type, X-Device-Name, X-Device-Type, X-Dev-User, If-None-Match")
|
||||
}
|
||||
h.Set("Access-Control-Allow-Private-Network", "true")
|
||||
h.Set("Access-Control-Max-Age", "600")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,264 @@
|
||||
// Package platform implements cdrop desktop's native capabilities — the Go
|
||||
// shell that the shared web UI drives over Wails bindings.
|
||||
//
|
||||
// This file owns the OAuth login flow: an RFC 8252 loopback redirect against
|
||||
// Casdoor with PKCE as a public client (no secret). The authorization code
|
||||
// exchange happens here in Go on purpose, so the PKCE verifier and the
|
||||
// resulting tokens never enter the WebView / JS context.
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OAuthConfig carries the provider coordinates. For Casdoor the token endpoint
|
||||
// is /api/login/oauth/access_token (non-standard) and authorize is
|
||||
// /login/oauth/authorize; the desktop registers as its own public client.
|
||||
type OAuthConfig struct {
|
||||
AuthorizeURL string
|
||||
TokenURL string
|
||||
ClientID string
|
||||
Scopes string // space-separated; empty defaults to "openid profile groups"
|
||||
}
|
||||
|
||||
// TokenResult is the subset of the token response the shell keeps. IDToken is
|
||||
// captured so the identity can be decoded from the richer id_token claims (it's
|
||||
// present when the openid scope is granted — our default scopes include it).
|
||||
type TokenResult struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// Flow runs one interactive login. openURL is injected so production uses
|
||||
// Wails' runtime.BrowserOpenURL while tests substitute a fake user-agent.
|
||||
type Flow struct {
|
||||
cfg OAuthConfig
|
||||
openURL func(string)
|
||||
client *http.Client
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewFlow builds a Flow with sane defaults: a 30s HTTP client for the token
|
||||
// exchange and a 3-minute ceiling on the whole interactive login.
|
||||
func NewFlow(cfg OAuthConfig, openURL func(string)) *Flow {
|
||||
return &Flow{
|
||||
cfg: cfg,
|
||||
openURL: openURL,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
timeout: 3 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// Login performs the loopback PKCE flow and returns the exchanged tokens.
|
||||
//
|
||||
// Steps: generate verifier/challenge/state → bind an ephemeral 127.0.0.1
|
||||
// listener → open the system browser at the authorize URL → wait for the
|
||||
// browser to hit the loopback /callback with the code → validate state →
|
||||
// exchange code+verifier at the token endpoint. The verifier never leaves Go.
|
||||
func (f *Flow) Login(ctx context.Context) (*TokenResult, error) {
|
||||
if f.cfg.AuthorizeURL == "" || f.cfg.TokenURL == "" || f.cfg.ClientID == "" {
|
||||
return nil, errors.New("oauth: incomplete config (authorize_url / token_url / client_id)")
|
||||
}
|
||||
|
||||
verifier, challenge, err := newPKCE()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: pkce: %w", err)
|
||||
}
|
||||
state, err := randString(24)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: state: %w", err)
|
||||
}
|
||||
|
||||
// RFC 8252 §7.3: bind the IPv4 loopback literal (not "localhost") on an
|
||||
// OS-assigned ephemeral port.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: loopback listen: %w", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", ln.Addr().(*net.TCPAddr).Port)
|
||||
|
||||
type cbResult struct {
|
||||
code string
|
||||
err error
|
||||
}
|
||||
resCh := make(chan cbResult, 1)
|
||||
send := func(r cbResult) {
|
||||
// non-blocking: only the first callback wins, later ones are dropped.
|
||||
select {
|
||||
case resCh <- r:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
if e := q.Get("error"); e != "" {
|
||||
writeClosePage(w, false)
|
||||
send(cbResult{err: fmt.Errorf("oauth: authorization denied: %s", e)})
|
||||
return
|
||||
}
|
||||
if q.Get("state") != state {
|
||||
writeClosePage(w, false)
|
||||
send(cbResult{err: errors.New("oauth: state mismatch (possible CSRF)")})
|
||||
return
|
||||
}
|
||||
code := q.Get("code")
|
||||
if code == "" {
|
||||
writeClosePage(w, false)
|
||||
send(cbResult{err: errors.New("oauth: callback missing code")})
|
||||
return
|
||||
}
|
||||
writeClosePage(w, true)
|
||||
send(cbResult{code: code})
|
||||
})
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
defer srv.Close()
|
||||
|
||||
authURL := f.cfg.AuthorizeURL + "?" + url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {f.cfg.ClientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
"scope": {f.scopes()},
|
||||
"state": {state},
|
||||
"code_challenge": {challenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
}.Encode()
|
||||
f.openURL(authURL)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(f.timeout):
|
||||
return nil, errors.New("oauth: login timed out")
|
||||
case res := <-resCh:
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
return f.exchange(ctx, res.code, verifier, redirectURI)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Flow) scopes() string {
|
||||
if s := strings.TrimSpace(f.cfg.Scopes); s != "" {
|
||||
return s
|
||||
}
|
||||
return "openid profile groups"
|
||||
}
|
||||
|
||||
// exchange trades the authorization code + PKCE verifier for tokens. Casdoor
|
||||
// accepts form-encoded public-client requests (no client_secret). authorize and
|
||||
// token must carry the identical redirect_uri, so we pass the same value.
|
||||
func (f *Flow) exchange(ctx context.Context, code, verifier, redirectURI string) (*TokenResult, error) {
|
||||
return f.postToken(ctx, url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"code_verifier": {verifier},
|
||||
"client_id": {f.cfg.ClientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
})
|
||||
}
|
||||
|
||||
// Refresh exchanges a refresh_token for a fresh token (public client, no
|
||||
// secret). Call it when the access token nears expiry; the access token never
|
||||
// leaves Go, so refresh is driven from the shell, not the WebView.
|
||||
func (f *Flow) Refresh(ctx context.Context, refreshToken string) (*TokenResult, error) {
|
||||
if f.cfg.TokenURL == "" || f.cfg.ClientID == "" {
|
||||
return nil, errors.New("oauth: incomplete config (token_url / client_id)")
|
||||
}
|
||||
if refreshToken == "" {
|
||||
return nil, errors.New("oauth: empty refresh token")
|
||||
}
|
||||
return f.postToken(ctx, url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {refreshToken},
|
||||
"client_id": {f.cfg.ClientID},
|
||||
"scope": {f.scopes()},
|
||||
})
|
||||
}
|
||||
|
||||
// postToken POSTs a form-encoded request to the token endpoint and decodes the
|
||||
// response. Shared by the authorization-code exchange and refresh.
|
||||
func (f *Flow) postToken(ctx context.Context, form url.Values) (*TokenResult, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.cfg.TokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: build token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: token request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("oauth: token endpoint status %d", resp.StatusCode)
|
||||
}
|
||||
var tok TokenResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
|
||||
return nil, fmt.Errorf("oauth: decode token: %w", err)
|
||||
}
|
||||
if tok.AccessToken == "" {
|
||||
return nil, errors.New("oauth: token response missing access_token")
|
||||
}
|
||||
return &tok, nil
|
||||
}
|
||||
|
||||
// --- PKCE (RFC 7636) ---
|
||||
|
||||
func newPKCE() (verifier, challenge string, err error) {
|
||||
verifier, err = randString(64) // 43–128 chars required; 64 is comfortable
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
challenge = base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
return verifier, challenge, nil
|
||||
}
|
||||
|
||||
// randString returns n characters of base64url-encoded crypto-random data. The
|
||||
// base64url alphabet (A–Z a–z 0–9 - _) is a subset of the PKCE unreserved set,
|
||||
// so the output is a valid code_verifier / state.
|
||||
func randString(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := base64.RawURLEncoding.EncodeToString(b)
|
||||
if len(s) > n {
|
||||
s = s[:n]
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// writeClosePage is what the browser tab lands on after the redirect. The tab
|
||||
// usually can't be closed by script (it wasn't script-opened), so the text is
|
||||
// the real affordance; the close attempt is best-effort.
|
||||
func writeClosePage(w http.ResponseWriter, ok bool) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
msg := "已登录,可关闭本页返回 cdrop。"
|
||||
if !ok {
|
||||
msg = "登录未完成,可关闭本页返回 cdrop 重试。"
|
||||
}
|
||||
fmt.Fprintf(w, `<!doctype html><html lang="zh-Hans"><head><meta charset="utf-8"><title>cdrop</title></head>`+
|
||||
`<body style="font-family:system-ui,sans-serif;text-align:center;margin-top:20vh">`+
|
||||
`<p>%s</p><script>setTimeout(function(){window.close();},800);</script></body></html>`, msg)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewPKCE(t *testing.T) {
|
||||
v, c, err := newPKCE()
|
||||
if err != nil {
|
||||
t.Fatalf("newPKCE: %v", err)
|
||||
}
|
||||
if len(v) != 64 {
|
||||
t.Errorf("verifier length = %d, want 64", len(v))
|
||||
}
|
||||
sum := sha256.Sum256([]byte(v))
|
||||
want := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
if c != want {
|
||||
t.Errorf("challenge = %q, want S256(verifier) = %q", c, want)
|
||||
}
|
||||
// two calls must differ
|
||||
v2, _, _ := newPKCE()
|
||||
if v == v2 {
|
||||
t.Error("two verifiers collided")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogin_Success drives the whole loopback flow with a fake Casdoor token
|
||||
// endpoint and a fake browser, with no real network or prod dependency.
|
||||
func TestLogin_Success(t *testing.T) {
|
||||
var gotForm url.Values
|
||||
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("token endpoint ParseForm: %v", err)
|
||||
}
|
||||
gotForm = r.PostForm
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "at-123",
|
||||
"refresh_token": "rt-456",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}))
|
||||
defer tokenSrv.Close()
|
||||
|
||||
cfg := OAuthConfig{
|
||||
AuthorizeURL: "https://casdoor.example/login/oauth/authorize",
|
||||
TokenURL: tokenSrv.URL,
|
||||
ClientID: "cdrop-desktop",
|
||||
}
|
||||
|
||||
// Fake browser: parse the authorize URL, assert it carries PKCE, then GET
|
||||
// the loopback redirect with a code + the same state (authorized).
|
||||
openURL := func(authURL string) {
|
||||
u, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
t.Errorf("parse authURL: %v", err)
|
||||
return
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
|
||||
t.Errorf("authorize request missing PKCE challenge: %v", q)
|
||||
}
|
||||
if q.Get("client_id") != "cdrop-desktop" {
|
||||
t.Errorf("authorize client_id = %q", q.Get("client_id"))
|
||||
}
|
||||
cb := q.Get("redirect_uri") + "?code=auth-code-xyz&state=" + url.QueryEscape(q.Get("state"))
|
||||
resp, err := http.Get(cb)
|
||||
if err != nil {
|
||||
t.Errorf("callback GET: %v", err)
|
||||
return
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
tok, err := NewFlow(cfg, openURL).Login(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Login: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "at-123" {
|
||||
t.Errorf("access_token = %q, want at-123", tok.AccessToken)
|
||||
}
|
||||
if tok.RefreshToken != "rt-456" {
|
||||
t.Errorf("refresh_token = %q, want rt-456", tok.RefreshToken)
|
||||
}
|
||||
if tok.ExpiresIn != 3600 {
|
||||
t.Errorf("expires_in = %d, want 3600", tok.ExpiresIn)
|
||||
}
|
||||
|
||||
// The exchange must carry the code + PKCE verifier and, as a public client,
|
||||
// no client_secret.
|
||||
if gotForm.Get("grant_type") != "authorization_code" {
|
||||
t.Errorf("grant_type = %q", gotForm.Get("grant_type"))
|
||||
}
|
||||
if gotForm.Get("code") != "auth-code-xyz" {
|
||||
t.Errorf("code = %q", gotForm.Get("code"))
|
||||
}
|
||||
if gotForm.Get("code_verifier") == "" {
|
||||
t.Error("token exchange missing code_verifier")
|
||||
}
|
||||
if gotForm.Get("client_secret") != "" {
|
||||
t.Error("public client must not send client_secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_StateMismatch(t *testing.T) {
|
||||
cfg := OAuthConfig{
|
||||
AuthorizeURL: "https://casdoor.example/login/oauth/authorize",
|
||||
TokenURL: "https://casdoor.example/token",
|
||||
ClientID: "cdrop-desktop",
|
||||
}
|
||||
openURL := func(authURL string) {
|
||||
u, _ := url.Parse(authURL)
|
||||
redirect := u.Query().Get("redirect_uri")
|
||||
resp, err := http.Get(redirect + "?code=c&state=WRONG-STATE")
|
||||
if err == nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}
|
||||
_, err := NewFlow(cfg, openURL).Login(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "state mismatch") {
|
||||
t.Fatalf("want state mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_IncompleteConfig(t *testing.T) {
|
||||
_, err := NewFlow(OAuthConfig{ClientID: "only-id"}, func(string) {}).Login(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("want error for incomplete config, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh_Success(t *testing.T) {
|
||||
var gotForm url.Values
|
||||
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
gotForm = r.PostForm
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "new-at",
|
||||
"refresh_token": "new-rt",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer tokenSrv.Close()
|
||||
|
||||
cfg := OAuthConfig{
|
||||
AuthorizeURL: "https://casdoor.example/login/oauth/authorize",
|
||||
TokenURL: tokenSrv.URL,
|
||||
ClientID: "cdrop-desktop",
|
||||
}
|
||||
tok, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), "old-rt")
|
||||
if err != nil {
|
||||
t.Fatalf("Refresh: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "new-at" {
|
||||
t.Errorf("access_token = %q, want new-at", tok.AccessToken)
|
||||
}
|
||||
if gotForm.Get("grant_type") != "refresh_token" {
|
||||
t.Errorf("grant_type = %q", gotForm.Get("grant_type"))
|
||||
}
|
||||
if gotForm.Get("refresh_token") != "old-rt" {
|
||||
t.Errorf("refresh_token = %q", gotForm.Get("refresh_token"))
|
||||
}
|
||||
if gotForm.Get("client_secret") != "" {
|
||||
t.Error("public client must not send client_secret on refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh_EmptyToken(t *testing.T) {
|
||||
cfg := OAuthConfig{
|
||||
AuthorizeURL: "https://x/authorize",
|
||||
TokenURL: "https://x/token",
|
||||
ClientID: "c",
|
||||
}
|
||||
if _, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), ""); err == nil {
|
||||
t.Fatal("want error for empty refresh token")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NewAPIProxy returns an http.Handler suitable for Wails' AssetServer.Handler.
|
||||
// It reverse-proxies every /api/* request to the remote cdrop backend so the
|
||||
// embedded web UI keeps using same-origin relative URLs — no CORS, no API-base
|
||||
// rewrite, no backend change.
|
||||
//
|
||||
// Why a proxy instead of cross-origin fetch: the WebView serves the bundle from
|
||||
// a custom scheme, so a relative /api request never reaches the backend, and a
|
||||
// cross-origin fetch to https://drop.commilitia.net would need server-side CORS
|
||||
// the backend doesn't send. Proxying keeps the request same-origin end to end.
|
||||
//
|
||||
// SSE (/api/hub/events) streams correctly: FlushInterval=-1 flushes each chunk,
|
||||
// and the darwin WebView ResponseWriter forwards every Write to the
|
||||
// WKURLSchemeTask immediately (didReceiveData per write), so frames arrive live.
|
||||
//
|
||||
// apiBase is the backend origin, e.g. https://drop.commilitia.net. Requests
|
||||
// that are not under /api/ get a 404 — desktop navigation is client-side, so the
|
||||
// asset FS already answers "/" and deep-link misses don't happen in normal use.
|
||||
func NewAPIProxy(apiBase string) (http.Handler, error) {
|
||||
target, err := url.Parse(apiBase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("proxy: parse api base %q: %w", apiBase, err)
|
||||
}
|
||||
if target.Scheme == "" || target.Host == "" {
|
||||
return nil, fmt.Errorf("proxy: api base %q missing scheme or host", apiBase)
|
||||
}
|
||||
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
base := proxy.Director
|
||||
proxy.Director = func(r *http.Request) {
|
||||
base(r)
|
||||
// The backend is vhosted behind Caddy; the Host header drives TLS SNI
|
||||
// and request routing, so it must be the backend host, not the WebView's
|
||||
// synthetic origin.
|
||||
r.Host = target.Host
|
||||
}
|
||||
proxy.FlushInterval = -1 // immediate flush — SSE frames / streamed relay bodies
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
proxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProxyForwardsAPIWithBackendHost(t *testing.T) {
|
||||
var gotPath, gotHost, gotAuth string
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotHost = r.Host
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, "clip-body")
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
proxy, err := NewAPIProxy(backend.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPIProxy: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/clipboard", nil)
|
||||
req.Header.Set("Authorization", "Bearer tok-123")
|
||||
rec := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want 200", rec.Code)
|
||||
}
|
||||
if gotPath != "/api/clipboard" {
|
||||
t.Errorf("backend path: got %q", gotPath)
|
||||
}
|
||||
// Host must be rewritten to the backend (TLS SNI + vhost routing), not the
|
||||
// WebView's synthetic origin.
|
||||
if gotHost != backendHost(backend.URL) {
|
||||
t.Errorf("backend Host header: got %q, want %q", gotHost, backendHost(backend.URL))
|
||||
}
|
||||
if gotAuth != "Bearer tok-123" {
|
||||
t.Errorf("Authorization not forwarded: got %q", gotAuth)
|
||||
}
|
||||
if body := rec.Body.String(); body != "clip-body" {
|
||||
t.Errorf("body: got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyNonAPIReturns404(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
t.Error("backend should not be hit for non-/api path")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
proxy, err := NewAPIProxy(backend.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPIProxy: %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/settings", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("non-/api status: got %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyForwardsPUT(t *testing.T) {
|
||||
var gotMethod, gotBody string
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
buf := make([]byte, r.ContentLength)
|
||||
_, _ = r.Body.Read(buf)
|
||||
gotBody = string(buf)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
proxy, _ := NewAPIProxy(backend.URL)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/clipboard", strings.NewReader("hello"))
|
||||
rec := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(rec, req)
|
||||
|
||||
if gotMethod != http.MethodPut {
|
||||
t.Errorf("method: got %q", gotMethod)
|
||||
}
|
||||
if gotBody != "hello" {
|
||||
t.Errorf("body: got %q", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyStreamsSSE checks that the proxy relays an event-stream incrementally
|
||||
// rather than buffering until the backend closes the connection. The backend
|
||||
// holds the connection open after the first frame; the client must see that
|
||||
// frame before the deadline.
|
||||
func TestProxyStreamsSSE(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, "event: clipboard:update\ndata: {}\n\n")
|
||||
w.(http.Flusher).Flush()
|
||||
<-release // keep the stream open like a real SSE channel
|
||||
}))
|
||||
defer backend.Close()
|
||||
defer close(release)
|
||||
|
||||
proxy, _ := NewAPIProxy(backend.URL)
|
||||
srv := httptest.NewServer(proxy)
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/api/hub/events", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET sse: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
line, _ := bufio.NewReader(resp.Body).ReadString('\n')
|
||||
done <- line
|
||||
}()
|
||||
|
||||
select {
|
||||
case line := <-done:
|
||||
if !strings.Contains(line, "clipboard:update") {
|
||||
t.Errorf("first streamed frame: got %q", line)
|
||||
}
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
t.Fatal("SSE frame did not arrive incrementally (proxy buffered the stream)")
|
||||
}
|
||||
}
|
||||
|
||||
func backendHost(rawURL string) string {
|
||||
// httptest URLs are http://host:port — strip the scheme.
|
||||
return strings.TrimPrefix(rawURL, "http://")
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
keyring "github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// Session persistence lives in a Go-owned file, NOT WebView storage: Wails serves
|
||||
// the UI from the wails:// custom scheme, and WKWebView does not persist
|
||||
// localStorage / sessionStorage for custom-scheme origins, so any in-page store
|
||||
// is wiped on every restart. The persisted session is injected back into the
|
||||
// page at load time (see SessionInjectMiddleware) so it's available synchronously
|
||||
// without a binding round-trip.
|
||||
//
|
||||
// Credential policy (consistent with the browser): the refresh_token is the
|
||||
// long-lived secret and is never exposed to the WebView/JS context. It lives only
|
||||
// in the Go process and at rest; the WebView only ever receives the short-lived
|
||||
// access_token (via SessionView). Refresh is Go-internal — the WebView calls
|
||||
// Refresh() with no argument and Go uses its own copy.
|
||||
//
|
||||
// At rest the refresh_token is encrypted, not plaintext (R2). It is the one
|
||||
// credential that lets a holder mint fresh access tokens indefinitely, so
|
||||
// same-user malware must not be able to read it off disk. We can't store it in
|
||||
// the OS secret store directly: Casdoor issues JWT refresh_tokens that run to
|
||||
// ~15 KB, far past go-keyring's per-item limits (macOS ~3 KB command, Windows
|
||||
// 2560 B). Instead we keep a 32-byte AES key in the secret store (tiny, no size
|
||||
// issue on any platform) and write the AES-256-GCM ciphertext to the 0600 file.
|
||||
// Reading the file yields only ciphertext; decrypting it needs the key, which is
|
||||
// protected exactly as a directly-stored token would be — same posture, no size
|
||||
// cap. The key goes through go-keyring's system tooling rather than this app's
|
||||
// code signature, so an ad-hoc / per-build re-sign doesn't invalidate it.
|
||||
//
|
||||
// The short-lived access_token and display identity stay as plaintext in the
|
||||
// file: the access_token is already handed to the WebView and expires fast, so
|
||||
// it is not the asset R2 protects.
|
||||
|
||||
const (
|
||||
keyringService = "cdrop"
|
||||
// keyringKeyAccount holds the 32-byte AES key (base64) that encrypts the
|
||||
// refresh_token at rest — NOT the token itself.
|
||||
keyringKeyAccount = "session_key"
|
||||
)
|
||||
|
||||
// persistedSession is the on-disk shape. RefreshTokenEnc holds
|
||||
// base64(nonce||ciphertext) under AES-256-GCM. RefreshToken is only ever set on
|
||||
// a legacy file (written before R2) or as a degraded fallback when the secret
|
||||
// store is unavailable; the happy path leaves it empty.
|
||||
type persistedSession struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshTokenEnc string `json:"refresh_token_enc,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User UserInfo `json:"user"`
|
||||
}
|
||||
|
||||
// sessionPath is ~/Library/Application Support/cdrop/session.json on macOS.
|
||||
func sessionPath() (string, error) {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "cdrop", "session.json"), nil
|
||||
}
|
||||
|
||||
// loadOrCreateKey returns the AES-256 key from the OS secret store, minting and
|
||||
// storing a fresh random one on first use.
|
||||
func loadOrCreateKey() ([]byte, error) {
|
||||
if enc, err := keyring.Get(keyringService, keyringKeyAccount); err == nil {
|
||||
if key, derr := base64.StdEncoding.DecodeString(enc); derr == nil && len(key) == 32 {
|
||||
return key, nil
|
||||
}
|
||||
// A corrupt / wrong-sized value is unusable; fall through and regenerate.
|
||||
// (Any ciphertext encrypted under the old value becomes undecryptable —
|
||||
// the user re-logs in, which is acceptable for a corrupt store.)
|
||||
} else if !errors.Is(err, keyring.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := keyring.Set(keyringService, keyringKeyAccount, base64.StdEncoding.EncodeToString(key)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
// encryptToken seals the refresh_token under the secret-store key, returning
|
||||
// base64(nonce||ciphertext).
|
||||
func encryptToken(plaintext string) (string, error) {
|
||||
key, err := loadOrCreateKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
// decryptToken opens a refresh_token ciphertext using the secret-store key. It
|
||||
// never mints a key: a missing key means the ciphertext is unrecoverable (the
|
||||
// caller falls back to requiring a fresh login).
|
||||
func decryptToken(enc string) (string, error) {
|
||||
keyB64, err := keyring.Get(keyringService, keyringKeyAccount)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(keyB64)
|
||||
if err != nil || len(key) != 32 {
|
||||
return "", errors.New("session: malformed key in secret store")
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(enc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return "", errors.New("session: ciphertext too short")
|
||||
}
|
||||
nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
|
||||
plaintext, err := gcm.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// SaveSession persists the login session. The refresh_token is encrypted at rest
|
||||
// under a key held in the OS secret store; everything else (short-lived
|
||||
// access_token + identity) is plaintext in the 0600 file. If the secret store is
|
||||
// unavailable (e.g. a headless Linux box with no Secret Service), we fall back to
|
||||
// writing the refresh_token in plaintext so login still works — logging the
|
||||
// downgrade rather than failing auth outright.
|
||||
func SaveSession(res LoginResult) error {
|
||||
p, err := sessionPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rec := persistedSession{
|
||||
AccessToken: res.AccessToken,
|
||||
ExpiresIn: res.ExpiresIn,
|
||||
User: res.User,
|
||||
}
|
||||
if res.RefreshToken != "" {
|
||||
if enc, err := encryptToken(res.RefreshToken); err == nil {
|
||||
rec.RefreshTokenEnc = enc
|
||||
} else {
|
||||
slog.Warn("cdrop: refresh_token kept in session file; OS secret store unavailable", "err", err)
|
||||
rec.RefreshToken = res.RefreshToken
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, data, 0o600)
|
||||
}
|
||||
|
||||
// LoadSession returns the persisted session, or nil when none is stored / the
|
||||
// file is unreadable or empty. The refresh_token is decrypted from the file
|
||||
// using the secret-store key. A legacy file that still embeds the plaintext
|
||||
// refresh_token (written before R2, or by the fallback above) is re-encrypted on
|
||||
// first load and stripped of its plaintext.
|
||||
func LoadSession() (*LoginResult, error) {
|
||||
p, err := sessionPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rec persistedSession
|
||||
if err := json.Unmarshal(data, &rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rec.AccessToken == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
res := &LoginResult{
|
||||
AccessToken: rec.AccessToken,
|
||||
ExpiresIn: rec.ExpiresIn,
|
||||
User: rec.User,
|
||||
}
|
||||
switch {
|
||||
case rec.RefreshToken != "":
|
||||
// Plaintext on disk (legacy or fallback): keep it in memory and re-save,
|
||||
// which encrypts it and strips the plaintext (no-ops if the store is
|
||||
// still unavailable, leaving the file as-is).
|
||||
res.RefreshToken = rec.RefreshToken
|
||||
if err := SaveSession(*res); err != nil {
|
||||
slog.Warn("cdrop: refresh_token migration to secret store failed", "err", err)
|
||||
}
|
||||
case rec.RefreshTokenEnc != "":
|
||||
if rt, err := decryptToken(rec.RefreshTokenEnc); err == nil {
|
||||
res.RefreshToken = rt
|
||||
} else {
|
||||
// Key gone / ciphertext corrupt: drop to an access-token-only session;
|
||||
// the app will require a fresh login once the access_token lapses.
|
||||
slog.Warn("cdrop: decrypt refresh_token failed; re-login will be required", "err", err)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ClearSession removes the persisted session (logout): both the file and the
|
||||
// encryption key held in the OS secret store. Absent entries are a no-op.
|
||||
func ClearSession() error {
|
||||
p, err := sessionPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := keyring.Delete(keyringService, keyringKeyAccount); err != nil &&
|
||||
!errors.Is(err, keyring.ErrNotFound) {
|
||||
slog.Warn("cdrop: clear session key from secret store failed", "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
keyring "github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// withTempSession isolates session storage: an in-memory keyring mock (so tests
|
||||
// never touch the real Keychain / Credential Manager) plus a temp HOME so
|
||||
// sessionPath resolves under t.TempDir().
|
||||
func withTempSession(t *testing.T) {
|
||||
t.Helper()
|
||||
keyring.MockInit()
|
||||
dir := t.TempDir()
|
||||
// os.UserConfigDir honours XDG_CONFIG_HOME on Linux and HOME on macOS.
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
t.Setenv("HOME", dir)
|
||||
}
|
||||
|
||||
// readSessionFile returns the parsed on-disk record plus its raw bytes.
|
||||
func readSessionFile(t *testing.T) (persistedSession, []byte) {
|
||||
t.Helper()
|
||||
p, err := sessionPath()
|
||||
if err != nil {
|
||||
t.Fatalf("sessionPath: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return persistedSession{}, nil
|
||||
}
|
||||
t.Fatalf("read session file: %v", err)
|
||||
}
|
||||
var rec persistedSession
|
||||
if err := json.Unmarshal(data, &rec); err != nil {
|
||||
t.Fatalf("unmarshal session file: %v", err)
|
||||
}
|
||||
return rec, data
|
||||
}
|
||||
|
||||
// The refresh_token must be encrypted at rest (R2): the plaintext never appears
|
||||
// in the file, the keyring holds only the small AES key, and the round-trip
|
||||
// returns the token in memory.
|
||||
func TestSaveSession_RefreshTokenEncryptedAtRest(t *testing.T) {
|
||||
withTempSession(t)
|
||||
|
||||
res := LoginResult{
|
||||
AccessToken: "access-abc",
|
||||
RefreshToken: "refresh-xyz-secret",
|
||||
ExpiresIn: 3600,
|
||||
User: UserInfo{ID: "u1", Name: "Tester"},
|
||||
}
|
||||
if err := SaveSession(res); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
rec, raw := readSessionFile(t)
|
||||
if rec.RefreshToken != "" {
|
||||
t.Errorf("plaintext refresh_token must not be in the file, got %q", rec.RefreshToken)
|
||||
}
|
||||
if rec.RefreshTokenEnc == "" {
|
||||
t.Error("refresh_token_enc must be present")
|
||||
}
|
||||
if strings.Contains(string(raw), "refresh-xyz-secret") {
|
||||
t.Error("the plaintext token must not appear anywhere in the file bytes")
|
||||
}
|
||||
// The keyring holds the AES key, not the token.
|
||||
if k, err := keyring.Get(keyringService, keyringKeyAccount); err != nil || k == "" {
|
||||
t.Errorf("keyring must hold the session key: %q err %v", k, err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSession()
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("load: %v (nil=%v)", err, loaded == nil)
|
||||
}
|
||||
if loaded.RefreshToken != "refresh-xyz-secret" || loaded.AccessToken != "access-abc" {
|
||||
t.Errorf("loaded tokens: rt=%q at=%q", loaded.RefreshToken, loaded.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
// A large JWT refresh_token (Casdoor issues ~15 KB) must round-trip — this is
|
||||
// exactly the case go-keyring can't store directly (its per-item limit is a few
|
||||
// KB), and the reason the token is encrypted into the file instead.
|
||||
func TestSaveSession_LargeTokenRoundTrips(t *testing.T) {
|
||||
withTempSession(t)
|
||||
|
||||
big := strings.Repeat("a", 16000)
|
||||
res := LoginResult{AccessToken: "acc", RefreshToken: big, ExpiresIn: 3600, User: UserInfo{ID: "u"}}
|
||||
if err := SaveSession(res); err != nil {
|
||||
t.Fatalf("save large token: %v", err)
|
||||
}
|
||||
rec, raw := readSessionFile(t)
|
||||
if rec.RefreshToken != "" {
|
||||
t.Error("large token must not be stored in plaintext")
|
||||
}
|
||||
if strings.Contains(string(raw), big) {
|
||||
t.Error("large plaintext token must not appear in the file bytes")
|
||||
}
|
||||
loaded, err := LoadSession()
|
||||
if err != nil || loaded == nil || loaded.RefreshToken != big {
|
||||
t.Fatalf("large token did not round-trip: err %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A legacy file with a plaintext refresh_token (written before R2) must be
|
||||
// re-encrypted on first load and stripped of its plaintext.
|
||||
func TestLoadSession_MigratesLegacyPlaintext(t *testing.T) {
|
||||
withTempSession(t)
|
||||
|
||||
p, _ := sessionPath()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
// Seed a legacy file: plaintext refresh_token, no ciphertext field.
|
||||
data, _ := json.Marshal(persistedSession{
|
||||
AccessToken: "access-abc",
|
||||
RefreshToken: "legacy-plain",
|
||||
ExpiresIn: 3600,
|
||||
User: UserInfo{ID: "u1"},
|
||||
})
|
||||
if err := os.WriteFile(p, data, 0o600); err != nil {
|
||||
t.Fatalf("seed legacy file: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSession()
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if loaded.RefreshToken != "legacy-plain" {
|
||||
t.Errorf("migrated session must carry the token in memory, got %q", loaded.RefreshToken)
|
||||
}
|
||||
rec, raw := readSessionFile(t)
|
||||
if rec.RefreshToken != "" {
|
||||
t.Error("plaintext must be stripped from disk after migration")
|
||||
}
|
||||
if rec.RefreshTokenEnc == "" || strings.Contains(string(raw), "legacy-plain") {
|
||||
t.Error("migrated token must be encrypted in the file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRoundTripAndClear(t *testing.T) {
|
||||
withTempSession(t)
|
||||
|
||||
if got, err := LoadSession(); err != nil || got != nil {
|
||||
t.Fatalf("absent session should be nil; got %+v err %v", got, err)
|
||||
}
|
||||
|
||||
want := LoginResult{
|
||||
AccessToken: "acc",
|
||||
RefreshToken: "ref",
|
||||
ExpiresIn: 3600,
|
||||
User: UserInfo{ID: "u-1", Name: "alice", Avatar: "a.png"},
|
||||
}
|
||||
if err := SaveSession(want); err != nil {
|
||||
t.Fatalf("SaveSession: %v", err)
|
||||
}
|
||||
got, err := LoadSession()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
if got == nil || *got != want {
|
||||
t.Errorf("round-trip: got %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
if err := ClearSession(); err != nil {
|
||||
t.Fatalf("ClearSession: %v", err)
|
||||
}
|
||||
if got, _ := LoadSession(); got != nil {
|
||||
t.Error("session should be gone after ClearSession")
|
||||
}
|
||||
// The key is dropped too — no orphaned secret-store entry.
|
||||
if _, err := keyring.Get(keyringService, keyringKeyAccount); err != keyring.ErrNotFound {
|
||||
t.Errorf("session key should be gone after clear, got err %v", err)
|
||||
}
|
||||
if err := ClearSession(); err != nil {
|
||||
t.Errorf("ClearSession on absent should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionIgnoresEmptyToken(t *testing.T) {
|
||||
withTempSession(t)
|
||||
if err := SaveSession(LoginResult{RefreshToken: "ref"}); err != nil {
|
||||
t.Fatalf("SaveSession: %v", err)
|
||||
}
|
||||
got, err := LoadSession()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("empty-access-token session should load as nil, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
)
|
||||
|
||||
// bootPayload is the state injected into the page at load so the WebView store
|
||||
// can hydrate synchronously: the restored session (refresh-free — see
|
||||
// SessionView), the device name (both of which the wails:// scheme can't persist
|
||||
// in WebView storage), and the API base the page should fetch from.
|
||||
type bootPayload struct {
|
||||
Session *SessionView `json:"session"`
|
||||
DeviceName string `json:"device_name"`
|
||||
// APIBase points the page at a loopback HTTP origin for /api on Windows,
|
||||
// where the custom-scheme AssetServer buffers streaming responses (so SSE
|
||||
// never arrives). Empty on macOS — the page then uses same-origin relative
|
||||
// /api through the custom scheme, which streams fine there.
|
||||
APIBase string `json:"api_base,omitempty"`
|
||||
// DeviceType is the native client kind (macos / windows / linux); the page
|
||||
// forwards it as X-Device-Type so the backend lists it correctly instead of
|
||||
// defaulting every device to "browser".
|
||||
DeviceType string `json:"device_type,omitempty"`
|
||||
}
|
||||
|
||||
// SessionInjectMiddleware injects the boot state into the served index.html as
|
||||
// `window.__CDROP_BOOT__`, so the WebView store hydrates synchronously at startup
|
||||
// — no WebView storage (which doesn't survive restart for the wails:// scheme)
|
||||
// and no binding round-trip (window.go isn't ready that early). Read once at app
|
||||
// launch. device_name + api_base are injected always (needed even logged out);
|
||||
// the session is included only when one was restored.
|
||||
//
|
||||
// Only the top-level HTML document is rewritten; assets and the /api proxy
|
||||
// (including the SSE stream) pass straight through untouched. The JSON is
|
||||
// produced by encoding/json with default HTML escaping, so a display name
|
||||
// containing "</script>" can't break out of the inline script.
|
||||
func SessionInjectMiddleware(session *LoginResult, deviceName, apiBase, deviceType string) assetserver.Middleware {
|
||||
boot := bootPayload{DeviceName: deviceName, APIBase: apiBase, DeviceType: deviceType}
|
||||
if session != nil {
|
||||
view := session.View() // strip the refresh_token before it ever reaches the page
|
||||
boot.Session = &view
|
||||
}
|
||||
payload, _ := json.Marshal(boot)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || !isDocumentRequest(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Force a full 200 (never a 304): strip conditional headers so
|
||||
// http.ServeContent always returns the whole document for us to
|
||||
// inject. Without this, a WebView that cached the first (logged-out)
|
||||
// page could send If-Modified-Since on the next launch, get a 304 with
|
||||
// an empty body, reuse its stale page, and the session wouldn't restore.
|
||||
r.Header.Del("If-Modified-Since")
|
||||
r.Header.Del("If-None-Match")
|
||||
r.Header.Del("If-Range")
|
||||
|
||||
rec := &captureWriter{header: http.Header{}}
|
||||
next.ServeHTTP(rec, r)
|
||||
body := rec.buf.Bytes()
|
||||
|
||||
if idx := bytes.Index(body, []byte("</head>")); idx != -1 {
|
||||
script := []byte("<script>window.__CDROP_BOOT__=" + string(payload) + ";</script>")
|
||||
out := make([]byte, 0, len(body)+len(script))
|
||||
out = append(out, body[:idx]...)
|
||||
out = append(out, script...)
|
||||
out = append(out, body[idx:]...)
|
||||
body = out
|
||||
}
|
||||
|
||||
h := w.Header()
|
||||
for k, vs := range rec.header {
|
||||
for _, v := range vs {
|
||||
h.Add(k, v)
|
||||
}
|
||||
}
|
||||
// Never let the WebView cache the document — the injected session
|
||||
// changes between launches, so a cached copy must not be reused.
|
||||
h.Del("Last-Modified")
|
||||
h.Del("ETag")
|
||||
h.Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
h.Set("Pragma", "no-cache")
|
||||
h.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
code := rec.code
|
||||
if code == 0 {
|
||||
code = http.StatusOK
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
_, _ = w.Write(body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isDocumentRequest matches the top-level HTML document the WebView loads.
|
||||
func isDocumentRequest(path string) bool {
|
||||
return path == "" || path == "/" || path == "/index.html"
|
||||
}
|
||||
|
||||
// captureWriter buffers a handler's response so the middleware can rewrite the
|
||||
// HTML body before forwarding it. Only used for the small index.html document.
|
||||
type captureWriter struct {
|
||||
header http.Header
|
||||
buf bytes.Buffer
|
||||
code int
|
||||
}
|
||||
|
||||
func (c *captureWriter) Header() http.Header { return c.header }
|
||||
func (c *captureWriter) WriteHeader(code int) { c.code = code }
|
||||
func (c *captureWriter) Write(b []byte) (int, error) { return c.buf.Write(b) }
|
||||
@@ -0,0 +1,109 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const htmlDoc = "<html><head><title>cdrop</title></head><body></body></html>"
|
||||
|
||||
func htmlHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(htmlDoc))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionInjectIntoDocument(t *testing.T) {
|
||||
session := &LoginResult{
|
||||
AccessToken: "acc-token",
|
||||
RefreshToken: "ref-token",
|
||||
User: UserInfo{ID: "u-1", Name: "alice"},
|
||||
}
|
||||
h := SessionInjectMiddleware(session, "my-host", "", "macos")(htmlHandler())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
body := rec.Body.String()
|
||||
|
||||
if !strings.Contains(body, "window.__CDROP_BOOT__") {
|
||||
t.Fatalf("boot global not injected:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"access_token":"acc-token"`) {
|
||||
t.Errorf("access token not in injected payload:\n%s", body)
|
||||
}
|
||||
// Credential policy: the refresh_token must NEVER reach the page.
|
||||
if strings.Contains(body, "ref-token") || strings.Contains(body, "refresh_token") {
|
||||
t.Errorf("refresh_token must not be injected into the page:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"device_name":"my-host"`) {
|
||||
t.Errorf("device name not in injected payload:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"device_type":"macos"`) {
|
||||
t.Errorf("device type not in injected payload:\n%s", body)
|
||||
}
|
||||
// Injected before </head> so it runs before the deferred module bundle.
|
||||
if strings.Index(body, "__CDROP_BOOT__") > strings.Index(body, "</head>") {
|
||||
t.Error("injection must precede </head>")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInjectPassesThroughNonDocument(t *testing.T) {
|
||||
apiBody := "streamed-api-body"
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(apiBody))
|
||||
})
|
||||
h := SessionInjectMiddleware(&LoginResult{AccessToken: "x"}, "h", "", "")(next)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/hub/events", nil))
|
||||
if rec.Body.String() != apiBody {
|
||||
t.Errorf("non-document request must pass through unchanged, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInjectNilSessionStillInjectsDeviceBoot(t *testing.T) {
|
||||
// Logged out, the boot state is still injected — the page needs the device
|
||||
// name / api_base / device_type before login — but with a null session, and
|
||||
// no token of any kind leaks.
|
||||
h := SessionInjectMiddleware(nil, "my-host", "http://127.0.0.1:5000", "windows")(htmlHandler())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
body := rec.Body.String()
|
||||
|
||||
if !strings.Contains(body, "window.__CDROP_BOOT__") {
|
||||
t.Fatalf("boot global must be injected even when logged out:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"session":null`) {
|
||||
t.Errorf("logged-out boot must carry a null session:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"device_name":"my-host"`) ||
|
||||
!strings.Contains(body, `"device_type":"windows"`) ||
|
||||
!strings.Contains(body, `"api_base":"http://127.0.0.1:5000"`) {
|
||||
t.Errorf("device boot fields not injected:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "access_token") {
|
||||
t.Errorf("no token must appear when logged out:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInjectEscapesScriptBreakout(t *testing.T) {
|
||||
// A display name containing </script> must not break out of the inline tag.
|
||||
session := &LoginResult{AccessToken: "acc", User: UserInfo{Name: "</script><b>x"}}
|
||||
h := SessionInjectMiddleware(session, "h", "", "")(htmlHandler())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
body := rec.Body.String()
|
||||
|
||||
// The literal closing tag from the name must NOT appear verbatim; encoding/json
|
||||
// HTML-escapes < / > to < / > by default.
|
||||
if strings.Contains(body, "</script><b>x") {
|
||||
t.Errorf("script breakout not escaped:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `</script>`) {
|
||||
t.Errorf("expected HTML-escaped name in payload:\n%s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package platform
|
||||
|
||||
// StatusBarHandler carries the three menu-bar actions as closures so the App can
|
||||
// supply them without exposing extra Wails-bound methods on its struct.
|
||||
type StatusBarHandler struct {
|
||||
OnShowWindow func()
|
||||
OnOpenSettings func()
|
||||
OnQuit func()
|
||||
}
|
||||
|
||||
// StatusBarMenu is the set of localized labels for the menu bar item + its menu.
|
||||
// The icon itself is the embedded brand mascot (see statusbar_darwin); Title is
|
||||
// only the accessibility label + text fallback when the image can't be loaded.
|
||||
type StatusBarMenu struct {
|
||||
Title string // accessibility label + text fallback if the icon fails to load
|
||||
Show string
|
||||
Settings string
|
||||
Quit string
|
||||
}
|
||||
|
||||
// statusBarHandler holds the active closures; set by InstallStatusBar and read
|
||||
// by the native menu-action callbacks (darwin) — a no-op elsewhere.
|
||||
var statusBarHandler StatusBarHandler
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build darwin
|
||||
|
||||
package platform
|
||||
|
||||
/*
|
||||
#cgo darwin CFLAGS: -x objective-c -fobjc-arc
|
||||
#cgo darwin LDFLAGS: -framework Cocoa
|
||||
#include <stdlib.h>
|
||||
|
||||
void cdropStatusBarInstall(const void *iconPNG, int iconLen, const char *title,
|
||||
const char *showLabel, const char *settingsLabel,
|
||||
const char *quitLabel);
|
||||
void cdropSetActivationPolicy(int policy);
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// menuBarIconPNG 是嵌入二进制的品牌吉祥物头像(avatar-head,256px)。菜单栏图标
|
||||
// 用彩色图而非 SF Symbol 模板图——彩色更有品牌辨识度(代价是不随明暗自适应)。
|
||||
// 256px 提供高清 rep,运行时按菜单栏 thickness(逻辑点)显示,retina 下采样保持清晰。
|
||||
//
|
||||
//go:embed menubar-icon.png
|
||||
var menuBarIconPNG []byte
|
||||
|
||||
// InstallStatusBar creates the menu bar (NSStatusBar) item with a Show /
|
||||
// Settings / Quit menu. Safe to call from any goroutine — the native work is
|
||||
// dispatched to the main thread. Idempotent on the native side.
|
||||
func InstallStatusBar(m StatusBarMenu, h StatusBarHandler) {
|
||||
statusBarHandler = h
|
||||
|
||||
ct := C.CString(m.Title)
|
||||
cs := C.CString(m.Show)
|
||||
cg := C.CString(m.Settings)
|
||||
cq := C.CString(m.Quit)
|
||||
defer C.free(unsafe.Pointer(ct))
|
||||
defer C.free(unsafe.Pointer(cs))
|
||||
defer C.free(unsafe.Pointer(cg))
|
||||
defer C.free(unsafe.Pointer(cq))
|
||||
|
||||
// 把 PNG 字节复制进 C 缓冲;ObjC 侧在 dispatch 前同步拷进 NSData,故调用返回后即可释放。
|
||||
cIcon := C.CBytes(menuBarIconPNG)
|
||||
defer C.free(cIcon)
|
||||
|
||||
C.cdropStatusBarInstall(cIcon, C.int(len(menuBarIconPNG)), ct, cs, cg, cq)
|
||||
}
|
||||
|
||||
// SetDockVisible toggles the Dock icon: true → regular (icon shown), false →
|
||||
// accessory (Dock hidden, only the menu bar item remains). Main-thread safe.
|
||||
func SetDockVisible(visible bool) {
|
||||
p := C.int(1) // accessory
|
||||
if visible {
|
||||
p = C.int(0) // regular
|
||||
}
|
||||
C.cdropSetActivationPolicy(p)
|
||||
}
|
||||
|
||||
//export cdropOnShowWindow
|
||||
func cdropOnShowWindow() {
|
||||
if statusBarHandler.OnShowWindow != nil {
|
||||
statusBarHandler.OnShowWindow()
|
||||
}
|
||||
}
|
||||
|
||||
//export cdropOnOpenSettings
|
||||
func cdropOnOpenSettings() {
|
||||
if statusBarHandler.OnOpenSettings != nil {
|
||||
statusBarHandler.OnOpenSettings()
|
||||
}
|
||||
}
|
||||
|
||||
//export cdropOnQuit
|
||||
func cdropOnQuit() {
|
||||
if statusBarHandler.OnQuit != nil {
|
||||
statusBarHandler.OnQuit()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//go:build darwin
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#include "_cgo_export.h"
|
||||
|
||||
// CdropStatusBarController owns the menu bar item and routes the three menu
|
||||
// actions back into Go. It is deliberately NOT the NSApplication delegate —
|
||||
// Wails owns that, and stealing it would break Wails' window / runtime / custom
|
||||
// scheme handling. A status item can be created independently after launch, so
|
||||
// we never touch the delegate.
|
||||
@interface CdropStatusBarController : NSObject
|
||||
@property (strong) NSStatusItem *item;
|
||||
@property (strong) NSMenu *menu;
|
||||
- (void)showWindow:(id)sender;
|
||||
- (void)openSettings:(id)sender;
|
||||
- (void)quit:(id)sender;
|
||||
- (void)statusItemClicked:(id)sender;
|
||||
@end
|
||||
|
||||
@implementation CdropStatusBarController
|
||||
- (void)showWindow:(id)sender { cdropOnShowWindow(); }
|
||||
- (void)openSettings:(id)sender { cdropOnOpenSettings(); }
|
||||
- (void)quit:(id)sender { cdropOnQuit(); }
|
||||
|
||||
// showMenu pops the menu now. Temporarily attaching item.menu and performClick
|
||||
// shows it and BLOCKS until dismissed (menu tracking is modal); detaching after
|
||||
// keeps left-clicks flowing through statusItemClicked: (so left-click opens the
|
||||
// window directly instead of popping the menu).
|
||||
- (void)showMenu {
|
||||
self.item.menu = self.menu;
|
||||
[self.item.button performClick:nil];
|
||||
self.item.menu = nil;
|
||||
}
|
||||
|
||||
// statusItemClicked fires on left/right mouse-up. Click routing (the Windows tray
|
||||
// should mirror this once implemented):
|
||||
// - left-click → open the main window
|
||||
// - right-click / control-click → menu (Show / Settings / Quit)
|
||||
// We route clicks manually instead of setting item.menu — item.menu would pop the
|
||||
// menu on left-click too, leaving no way for left-click to open the window.
|
||||
- (void)statusItemClicked:(id)sender {
|
||||
NSEvent *e = [NSApp currentEvent];
|
||||
BOOL secondary = (e.type == NSEventTypeRightMouseUp) ||
|
||||
((e.modifierFlags & NSEventModifierFlagControl) != 0);
|
||||
if (secondary) {
|
||||
[self showMenu]; // right-click / control-click → menu
|
||||
} else {
|
||||
cdropOnShowWindow(); // left-click → open the main window
|
||||
}
|
||||
}
|
||||
@end
|
||||
|
||||
// File-scope strong reference (ARC) keeps the controller and its status item
|
||||
// alive for the process lifetime.
|
||||
static CdropStatusBarController *gController = nil;
|
||||
|
||||
// cdropStatusBarInstall creates the menu bar item + its menu on the main thread.
|
||||
// The icon is the embedded brand mascot (a colored, NON-template image — keeps
|
||||
// brand color instead of tinting to the menu bar). It's displayed at the menu
|
||||
// bar thickness (logical points); the high-res (256px) source rep keeps it crisp
|
||||
// on retina. If decoding fails it falls back to the text title. The PNG bytes are
|
||||
// copied into NSData synchronously here (before the async block) so Go can free
|
||||
// its C buffer right after this call. Labels arrive as UTF-8 from Go so this
|
||||
// source stays ASCII-only. Idempotent.
|
||||
void cdropStatusBarInstall(const void *iconPNG, int iconLen, const char *title,
|
||||
const char *showLabel, const char *settingsLabel,
|
||||
const char *quitLabel) {
|
||||
NSData *iconData = (iconPNG != NULL && iconLen > 0)
|
||||
? [NSData dataWithBytes:iconPNG length:(NSUInteger)iconLen]
|
||||
: nil;
|
||||
NSString *t = [NSString stringWithUTF8String:title];
|
||||
NSString *sl = [NSString stringWithUTF8String:showLabel];
|
||||
NSString *gl = [NSString stringWithUTF8String:settingsLabel];
|
||||
NSString *ql = [NSString stringWithUTF8String:quitLabel];
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (gController != nil) { return; }
|
||||
gController = [[CdropStatusBarController alloc] init];
|
||||
|
||||
NSStatusItem *item = [[NSStatusBar systemStatusBar]
|
||||
statusItemWithLength:NSVariableStatusItemLength];
|
||||
|
||||
NSImage *icon = (iconData != nil) ? [[NSImage alloc] initWithData:iconData] : nil;
|
||||
if (icon != nil) {
|
||||
CGFloat th = [[NSStatusBar systemStatusBar] thickness];
|
||||
[icon setSize:NSMakeSize(th, th)]; // fill bar height; retina uses 256px rep
|
||||
icon.template = NO; // keep brand color, don't tint
|
||||
item.button.image = icon;
|
||||
item.button.accessibilityLabel = t; // image has no text → a11y label
|
||||
} else {
|
||||
item.button.title = t;
|
||||
}
|
||||
|
||||
NSMenu *menu = [[NSMenu alloc] init];
|
||||
|
||||
NSMenuItem *show = [[NSMenuItem alloc] initWithTitle:sl
|
||||
action:@selector(showWindow:) keyEquivalent:@""];
|
||||
show.target = gController;
|
||||
[menu addItem:show];
|
||||
|
||||
NSMenuItem *settings = [[NSMenuItem alloc] initWithTitle:gl
|
||||
action:@selector(openSettings:) keyEquivalent:@""];
|
||||
settings.target = gController;
|
||||
[menu addItem:settings];
|
||||
|
||||
[menu addItem:[NSMenuItem separatorItem]];
|
||||
|
||||
NSMenuItem *quit = [[NSMenuItem alloc] initWithTitle:ql
|
||||
action:@selector(quit:) keyEquivalent:@""];
|
||||
quit.target = gController;
|
||||
[menu addItem:quit];
|
||||
|
||||
// Don't set item.menu (that auto-pops on mouse-down, killing double-click
|
||||
// detection). Store the menu and route clicks through statusItemClicked:.
|
||||
gController.item = item;
|
||||
gController.menu = menu;
|
||||
item.button.target = gController;
|
||||
item.button.action = @selector(statusItemClicked:);
|
||||
[item.button sendActionOn:(NSEventMaskLeftMouseUp | NSEventMaskRightMouseUp)];
|
||||
});
|
||||
}
|
||||
|
||||
// cdropSetActivationPolicy flips the Dock presence on the main thread.
|
||||
// policy: 0 = regular (Dock icon shown), 1 = accessory (Dock hidden, menu bar
|
||||
// item only).
|
||||
void cdropSetActivationPolicy(int policy) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSApplicationActivationPolicy p =
|
||||
(policy == 1) ? NSApplicationActivationPolicyAccessory
|
||||
: NSApplicationActivationPolicyRegular;
|
||||
[NSApp setActivationPolicy:p];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package platform
|
||||
|
||||
// Non-darwin stubs. The menu bar item + Dock activation policy are macOS
|
||||
// concepts; Windows/Linux equivalents (tray + window-to-tray) land when those
|
||||
// platforms are targeted. Kept as no-ops so cross-platform builds compile.
|
||||
//
|
||||
// 当实现 Windows 托盘时,交互须与 macOS 对齐(见 statusbar_darwin.m):
|
||||
// 左键单击 → 打开主窗口;右键 → 菜单(显示主窗 / 设置 / 退出)。托盘图标用同一
|
||||
// 彩色品牌图(menubar-icon.png)。
|
||||
|
||||
// InstallStatusBar is a no-op outside macOS.
|
||||
func InstallStatusBar(_ StatusBarMenu, h StatusBarHandler) {
|
||||
statusBarHandler = h
|
||||
}
|
||||
|
||||
// SetDockVisible is a no-op outside macOS.
|
||||
func SetDockVisible(_ bool) {}
|
||||
@@ -0,0 +1,74 @@
|
||||
//go:build windows
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"runtime"
|
||||
|
||||
"fyne.io/systray"
|
||||
)
|
||||
|
||||
// trayIconICO is the brand mascot as a Windows .ico, embedded in the binary and
|
||||
// shown in the notification area.
|
||||
//
|
||||
//go:embed tray-icon.ico
|
||||
var trayIconICO []byte
|
||||
|
||||
// InstallStatusBar installs the Windows system-tray icon, mirroring the macOS
|
||||
// menu bar: left-click (primary tap) opens the main window, right-click shows
|
||||
// the Show / Settings / Quit menu. systray owns a Win32 message loop, so it runs
|
||||
// on its own OS-locked goroutine; the menu-item click channels are drained in a
|
||||
// second goroutine. The handler closures already hand work to a goroutine before
|
||||
// touching the Wails runtime, so invoking them from here is safe.
|
||||
func InstallStatusBar(m StatusBarMenu, h StatusBarHandler) {
|
||||
statusBarHandler = h
|
||||
go func() {
|
||||
// systray creates a window + GetMessage loop on this thread; the window
|
||||
// is thread-affine, so the goroutine must not migrate.
|
||||
runtime.LockOSThread()
|
||||
systray.Run(func() { trayOnReady(m) }, nil)
|
||||
}()
|
||||
}
|
||||
|
||||
func trayOnReady(m StatusBarMenu) {
|
||||
systray.SetIcon(trayIconICO)
|
||||
systray.SetTitle(m.Title)
|
||||
systray.SetTooltip(m.Title)
|
||||
|
||||
// Left-click → open the main window (matches the macOS convention). Right-
|
||||
// click falls through to the menu below (Windows default).
|
||||
systray.SetOnTapped(func() {
|
||||
if statusBarHandler.OnShowWindow != nil {
|
||||
statusBarHandler.OnShowWindow()
|
||||
}
|
||||
})
|
||||
|
||||
show := systray.AddMenuItem(m.Show, "")
|
||||
settings := systray.AddMenuItem(m.Settings, "")
|
||||
quit := systray.AddMenuItem(m.Quit, "")
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-show.ClickedCh:
|
||||
if statusBarHandler.OnShowWindow != nil {
|
||||
statusBarHandler.OnShowWindow()
|
||||
}
|
||||
case <-settings.ClickedCh:
|
||||
if statusBarHandler.OnOpenSettings != nil {
|
||||
statusBarHandler.OnOpenSettings()
|
||||
}
|
||||
case <-quit.ClickedCh:
|
||||
if statusBarHandler.OnQuit != nil {
|
||||
statusBarHandler.OnQuit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// SetDockVisible is a no-op on Windows: the app lifecycle already hides/shows the
|
||||
// window (Wails WindowHide/WindowShow), which removes/adds the taskbar button, so
|
||||
// there is no separate Dock-style activation policy to toggle.
|
||||
func SetDockVisible(_ bool) {}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Reference in New Issue
Block a user