feat: 统一 Commilitia Drop 全客户端命名与分发

This commit is contained in:
2026-07-31 22:56:10 +08:00
parent f7b0f04c9c
commit c480f0d1c2
77 changed files with 861 additions and 263 deletions
+8 -1
View File
@@ -68,7 +68,14 @@ func ResolveDeviceName() string {
if h, err := os.Hostname(); err == nil && h != "" {
return h
}
return "cdrop-desktop"
switch runtime.GOOS {
case "darwin":
return "Commilitia Drop (macOS)"
case "windows":
return "Commilitia Drop (Windows)"
default:
return "Commilitia Drop"
}
}
// DefaultConfig is what a fresh install gets: clipboard sync on, no autostart.
+1 -1
View File
@@ -43,7 +43,7 @@ func FetchOAuthConfig(ctx context.Context, apiBase string) (OAuthConfig, error)
app := c.App
if app == "" {
app = "cdrop"
app = "commilitia-drop"
}
cfg := OAuthConfig{BrokerURL: c.BrokerURL, App: app}
if cfg.BrokerURL == "" {
+6 -6
View File
@@ -17,7 +17,7 @@ func TestFetchOAuthConfig_Success(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]any{
"auth_mode": "prod",
"broker_url": "https://sso.example.net",
"broker_app": "cdrop",
"broker_app": "commilitia-drop",
})
}))
defer srv.Close()
@@ -30,13 +30,13 @@ func TestFetchOAuthConfig_Success(t *testing.T) {
if cfg.BrokerURL != "https://sso.example.net" {
t.Errorf("broker_url = %q", cfg.BrokerURL)
}
if cfg.App != "cdrop" {
t.Errorf("app = %q, want cdrop", cfg.App)
if cfg.App != "commilitia-drop" {
t.Errorf("app = %q, want commilitia-drop", cfg.App)
}
}
func TestFetchOAuthConfig_DefaultsApp(t *testing.T) {
// broker_app omitted → defaults to "cdrop".
// broker_app omitted → defaults to "commilitia-drop".
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{"broker_url": "https://sso.example.net"})
@@ -46,8 +46,8 @@ func TestFetchOAuthConfig_DefaultsApp(t *testing.T) {
if err != nil {
t.Fatalf("FetchOAuthConfig: %v", err)
}
if cfg.App != "cdrop" {
t.Errorf("app = %q, want default cdrop", cfg.App)
if cfg.App != "commilitia-drop" {
t.Errorf("app = %q, want default commilitia-drop", cfg.App)
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
)
// 代铸 (proxy-mint) on the desktop. The device-authorization flow yields a bootstrap
// machine token (scope app:cdrop, no meta) that proves the user's identity but is not a
// machine token (scope app:commilitia-drop, no meta) that proves the user's identity but is not a
// cdrop-managed device. This call exchanges it for a managed device session bound to this
// device's stable device_id, so the desktop joins cdrop's unified device list and is
// managed exactly like a browser — the same model, not a separate native-only track.
+2 -2
View File
@@ -46,7 +46,7 @@ func writeInto(dir, name string, data []byte) (string, error) {
}
safe := sanitizeFileName(name)
if safe == "" {
safe = "cdrop-download"
safe = "Commilitia Drop Download"
}
target := uniquePath(dir, safe)
if err := os.WriteFile(target, data, 0o644); err != nil {
@@ -248,7 +248,7 @@ func FinalizeStreamingDownload(sessionId, name string) (string, error) {
}
safe := sanitizeFileName(name)
if safe == "" {
safe = "cdrop-download"
safe = "Commilitia Drop Download"
}
target := uniquePath(d.dir, safe)
if err := os.Rename(d.tmp, target); err != nil {
+1 -1
View File
@@ -69,7 +69,7 @@ func HealIdentity(s *LoginResult, apiBase string) *LoginResult {
}
if changed {
if err := SaveSession(*s); err != nil {
slog.Warn("cdrop: heal identity save failed", "err", err)
slog.Warn("Commilitia Drop: heal identity save failed", "err", err)
}
}
return s
+36 -3
View File
@@ -10,8 +10,8 @@ import (
"strings"
)
// launchAgentLabel is the reverse-DNS label + plist filename for the per-user
// LaunchAgent that starts cdrop at login.
// launchAgentLabel is a stable internal identifier. The product name shown to
// users comes from the application metadata, not this LaunchAgent label.
const launchAgentLabel = "net.commilitia.cdrop"
func launchAgentPath() (string, error) {
@@ -71,7 +71,40 @@ func SetLaunchAtLogin(enabled bool) error {
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
return os.WriteFile(p, []byte(buildLaunchAgentPlist(launchAgentLabel, args)), 0o644)
if err := writeLaunchAgentAtomically(
p,
[]byte(buildLaunchAgentPlist(launchAgentLabel, args)),
); err != nil {
return err
}
return nil
}
func writeLaunchAgentAtomically(path string, contents []byte) error {
dir := filepath.Dir(path)
f, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*")
if err != nil {
return err
}
tempPath := f.Name()
defer os.Remove(tempPath)
if err := f.Chmod(0o644); err != nil {
f.Close()
return err
}
if _, err := f.Write(contents); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(tempPath, path)
}
// IsLaunchAtLoginEnabled reports whether the LaunchAgent plist is present.
+2 -2
View File
@@ -48,8 +48,8 @@ func TestSetLaunchAtLoginInstallsAndRemoves(t *testing.T) {
func TestAppBundlePath(t *testing.T) {
cases := map[string]string{
"/Apps/cdrop.app/Contents/MacOS/desktop": "/Apps/cdrop.app",
"/usr/local/bin/desktop": "",
"/Apps/Commilitia Drop.app/Contents/MacOS/Commilitia Drop": "/Apps/Commilitia Drop.app",
"/usr/local/bin/Commilitia Drop": "",
}
for in, want := range cases {
if got := appBundlePath(in); got != want {
+2 -2
View File
@@ -1,6 +1,6 @@
#import <Network/Network.h>
// cdropTriggerLocalNetwork:起一个对 _cdrop._tcp 的 Bonjour 浏览,触发 macOS 本地网络权限弹窗。
// cdropTriggerLocalNetwork:起一个对 _commilitia-drop._tcp 的 Bonjour 浏览,触发 macOS 本地网络权限弹窗。
// WKWebView 自身不会触发该权限请求,须由宿主 App 主动发起一次本地网络访问;授权后本进程内的
// WebRTC 才能收集 host / mDNS 候选实现同内网直连(见 localnetwork_darwin.go 注释)。浏览结果本身
// 不关心——「发起访问」这一动作即触发授权。保活单个 browser(静态全局,ARC 下持有),幂等。
@@ -11,7 +11,7 @@ void cdropTriggerLocalNetwork(void) {
return;
}
nw_browse_descriptor_t descriptor =
nw_browse_descriptor_create_bonjour_service("_cdrop._tcp", NULL);
nw_browse_descriptor_create_bonjour_service("_commilitia-drop._tcp", NULL);
nw_parameters_t parameters = nw_parameters_create();
nw_parameters_set_include_peer_to_peer(parameters, true);
+4
View File
@@ -13,6 +13,10 @@ import "C"
import "unsafe"
// InitializeNotifications is a no-op on macOS; the notification center is
// initialized lazily by the native framework.
func InitializeNotifications() {}
// Notify shows a native system notification.
//
// macOS uses UNUserNotificationCenter, which REQUIRES the app bundle to be
+3
View File
@@ -2,6 +2,9 @@
package platform
// InitializeNotifications is a no-op on unsupported desktop platforms.
func InitializeNotifications() {}
// Notify is a no-op on platforms without a native notification backend wired up.
// The desktop client targets macOS and Windows; this keeps the package building
// on other GOOS (e.g. a Linux `go vet` / CI pass).
+34 -9
View File
@@ -4,14 +4,18 @@ package platform
import (
"os"
"path/filepath"
"sync"
toast "git.sr.ht/~jackmordaunt/go-toast/v2"
"golang.org/x/sys/windows/registry"
)
const (
// toastAppID 是 Windows Action Center 里显示的应用标识,与 macOS bundle id 对齐。
toastAppID = "net.commilitia.cdrop"
// toastAppID 是稳定的内部 AppUserModelIDWindows 通知设置中的用户可见名称由
// toastDisplayName 单独写入 DisplayName,避免为了改显示名破坏系统身份。
toastAppID = "net.commilitia.cdrop"
toastDisplayName = "Commilitia Drop"
// toastGUID 固定不变——它把通知归属到注册表里的本应用条目;更换会让既有通知
// 失去归属。
toastGUID = "{c4d8e2a1-6b3f-4e7a-9c2d-1f5b8a0e3d6c}"
@@ -19,20 +23,41 @@ const (
var toastInit sync.Once
// Notify shows a native Windows toast. go-toast renders via the WinRT/COM path
// (PowerShell fallback when the AppID isn't registered), so it works without code
// signing — SmartScreen only gates the installer, not notifications. Best-effort:
// errors are swallowed. SetAppData registers the app identity once so the toast
// shows "cdrop" rather than the PowerShell host.
func Notify(title, body string) {
// InitializeNotifications registers the stable Windows notification identity
// and corrects its user-visible name. It is safe to call repeatedly.
func InitializeNotifications() {
toastInit.Do(func() {
data := toast.AppData{AppID: toastAppID, GUID: toastGUID}
if exe, err := os.Executable(); err == nil {
data.ActivationExe = exe
}
_ = toast.SetAppData(data)
})
// go-toast v2.0.3 没有独立 DisplayName 字段,会把 AppID 写进
// DisplayName,且已有值时不更新;在同一稳定键上显式覆盖显示名。
appKey := filepath.Join(
"SOFTWARE",
"Classes",
"AppUserModelId",
toastAppID,
)
if key, err := registry.OpenKey(
registry.CURRENT_USER,
appKey,
registry.SET_VALUE,
); err == nil {
_ = key.SetStringValue("DisplayName", toastDisplayName)
_ = key.Close()
}
})
}
// Notify shows a native Windows toast. go-toast renders via the WinRT/COM path
// (PowerShell fallback when the AppID isn't registered), so it works without code
// signing — SmartScreen only gates the installer, not notifications. Best-effort:
// errors are swallowed.
func Notify(title, body string) {
InitializeNotifications()
n := toast.Notification{
AppID: toastAppID,
Title: title,
+5 -5
View File
@@ -26,7 +26,7 @@ import (
// OAuthConfig carries the Auth Broker coordinates. BrokerURL is the broker's PUBLIC
// origin (e.g. https://sso.commilitia.net); App is this app's key in the broker apps
// registry ("cdrop"). The app's loopback redirect must be registered in the broker's
// registry ("commilitia-drop"). The app's loopback redirect must be registered in the broker's
// apps.json (redirect_uris) for the device flow to accept it.
type OAuthConfig struct {
BrokerURL string
@@ -138,7 +138,7 @@ func (f *Flow) Login(ctx context.Context) (*TokenResult, error) {
"state": {state},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"description": {"cdrop 桌面客户端"},
"description": {"Commilitia Drop"},
}.Encode()
f.openURL(authURL)
@@ -255,11 +255,11 @@ func randString(n int) (string, error) {
// 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。"
msg := "已登录,可关闭本页返回 Commilitia Drop。"
if !ok {
msg = "登录未完成,可关闭本页返回 cdrop 重试。"
msg = "登录未完成,可关闭本页返回 Commilitia Drop 重试。"
}
fmt.Fprintf(w, `<!doctype html><html lang="zh-Hans"><head><meta charset="utf-8"><title>cdrop</title></head>`+
fmt.Fprintf(w, `<!doctype html><html lang="zh-Hans"><head><meta charset="utf-8"><title>Commilitia Drop</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)
}
+6 -6
View File
@@ -55,7 +55,7 @@ func TestLogin_Success(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "sid-1",
"app": "cdrop",
"app": "commilitia-drop",
"access": "at-123",
"refresh": "rtk-456",
"access_expires": time.Now().Add(15 * time.Minute).Unix(),
@@ -63,7 +63,7 @@ func TestLogin_Success(t *testing.T) {
}))
defer brokerSrv.Close()
cfg := OAuthConfig{BrokerURL: brokerSrv.URL, App: "cdrop"}
cfg := OAuthConfig{BrokerURL: brokerSrv.URL, App: "commilitia-drop"}
// Fake browser: parse the /device/authorize URL, assert it carries app + PKCE,
// then GET the loopback redirect with a code + the same state (approved).
@@ -80,7 +80,7 @@ func TestLogin_Success(t *testing.T) {
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
t.Errorf("authorize request missing PKCE challenge: %v", q)
}
if q.Get("app") != "cdrop" {
if q.Get("app") != "commilitia-drop" {
t.Errorf("authorize app = %q", q.Get("app"))
}
cb := q.Get("redirect_uri") + "?code=auth-code-xyz&state=" + url.QueryEscape(q.Get("state"))
@@ -119,7 +119,7 @@ func TestLogin_Success(t *testing.T) {
}
func TestLogin_StateMismatch(t *testing.T) {
cfg := OAuthConfig{BrokerURL: "https://sso.example.net", App: "cdrop"}
cfg := OAuthConfig{BrokerURL: "https://sso.example.net", App: "commilitia-drop"}
openURL := func(authURL string) {
u, _ := url.Parse(authURL)
redirect := u.Query().Get("redirect_uri")
@@ -157,7 +157,7 @@ func TestRefresh_Success(t *testing.T) {
}))
defer brokerSrv.Close()
cfg := OAuthConfig{BrokerURL: brokerSrv.URL, App: "cdrop"}
cfg := OAuthConfig{BrokerURL: brokerSrv.URL, App: "commilitia-drop"}
tok, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), "old-rtk")
if err != nil {
t.Fatalf("Refresh: %v", err)
@@ -174,7 +174,7 @@ func TestRefresh_Success(t *testing.T) {
}
func TestRefresh_EmptyToken(t *testing.T) {
cfg := OAuthConfig{BrokerURL: "https://sso.example.net", App: "cdrop"}
cfg := OAuthConfig{BrokerURL: "https://sso.example.net", App: "commilitia-drop"}
if _, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), ""); err == nil {
t.Fatal("want error for empty refresh token")
}
+8 -4
View File
@@ -177,7 +177,11 @@ func SaveSession(res LoginResult) error {
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)
slog.Warn(
"Commilitia Drop: refresh_token kept in session file; OS secret store unavailable",
"err",
err,
)
rec.RefreshToken = res.RefreshToken
}
}
@@ -226,7 +230,7 @@ func LoadSession() (*LoginResult, error) {
// 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)
slog.Warn("Commilitia Drop: refresh_token migration to secret store failed", "err", err)
}
case rec.RefreshTokenEnc != "":
if rt, err := decryptToken(rec.RefreshTokenEnc); err == nil {
@@ -234,7 +238,7 @@ func LoadSession() (*LoginResult, error) {
} 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)
slog.Warn("Commilitia Drop: decrypt refresh_token failed; re-login will be required", "err", err)
}
}
return res, nil
@@ -252,7 +256,7 @@ func ClearSession() error {
}
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)
slog.Warn("Commilitia Drop: clear session key from secret store failed", "err", err)
}
return nil
}
+1 -1
View File
@@ -4,7 +4,7 @@ package platform
/*
#cgo darwin CFLAGS: -x objective-c -fobjc-arc
#cgo darwin LDFLAGS: -framework Cocoa
#cgo darwin LDFLAGS: -framework Cocoa -framework UniformTypeIdentifiers
#include <stdlib.h>
void cdropStatusBarInstall(const void *iconPNG, int iconLen, const char *title,