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:
2026-06-15 21:38:28 +08:00
commit f21fa5b5e8
239 changed files with 29010 additions and 0 deletions
+108
View File
@@ -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
}
+90
View File
@@ -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")
}
}
+104
View File
@@ -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)
}
+55
View File
@@ -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)
}
}
+151
View File
@@ -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[:])
}
+80
View File
@@ -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
}
+42
View File
@@ -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 }
+199
View File
@@ -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)
}
}
+67
View File
@@ -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
}
+56
View File
@@ -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
}
+69
View File
@@ -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")
}
}
+112
View File
@@ -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()
}
}
+104
View File
@@ -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 ""
}
+94
View File
@@ -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")
}
}
+117
View File
@@ -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(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&apos;",
)
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, "&amp;") || !strings.Contains(got, "&lt;") || !strings.Contains(got, "&quot;") {
t.Errorf("xmlEscape did not escape specials: %q", got)
}
}
+13
View File
@@ -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 }
+53
View File
@@ -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
}
+68
View File
@@ -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

+264
View File
@@ -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) // 43128 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 (AZ az 09 - _) 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)
}
+187
View File
@@ -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")
}
}
+55
View File
@@ -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
}
+145
View File
@@ -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://")
}
+258
View File
@@ -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
}
+197
View File
@@ -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)
}
}
+116
View File
@@ -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) }
+109
View File
@@ -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)
}
}
+23
View File
@@ -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
+80
View File
@@ -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()
}
}
+133
View File
@@ -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];
});
}
+19
View File
@@ -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) {}
+74
View File
@@ -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