diff --git a/apps/desktop-client/package.json b/apps/desktop-client/package.json index f13d0ea..4b192c2 100644 --- a/apps/desktop-client/package.json +++ b/apps/desktop-client/package.json @@ -1,6 +1,6 @@ { "name": "@geo/desktop-client", - "version": "0.1.2", + "version": "0.1.1", "private": true, "description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。", "author": { diff --git a/apps/desktop-client/src/main/app-settings.ts b/apps/desktop-client/src/main/app-settings.ts index 6a75a51..d81a61a 100644 --- a/apps/desktop-client/src/main/app-settings.ts +++ b/apps/desktop-client/src/main/app-settings.ts @@ -19,7 +19,7 @@ interface DesktopAppInternalFlags { const DEFAULT_DESKTOP_APP_SETTINGS: DesktopAppSettings = { openAtLogin: false, keepRunningInBackground: true, - autoCleanStorageWeekly: false, + autoCleanStorageWeekly: true, } const DEFAULT_INTERNAL_FLAGS: DesktopAppInternalFlags = { diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index fe3f60a..bf2b78e 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -166,6 +166,7 @@ let loginWindow: ElectronBrowserWindow | null = null let settingsWindow: ElectronBrowserWindow | null = null let mainRendererContents: ElectronWebContents | null = null let quitReleaseInFlight = false +let quitAfterSaasLogoutPromise: Promise | null = null let mainWindowCreatePromise: Promise | null = null let loginWindowCreatePromise: Promise | null = null let settingsWindowCreatePromise: Promise | null = null @@ -516,6 +517,36 @@ function revealActiveWindowSafely(source: string): void { }) } +function timeout(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function quitAfterClearingSaasSession(): void { + if (quitAfterSaasLogoutPromise) { + return + } + + quitAfterSaasLogoutPromise = (async () => { + currentWindowMode = 'login' + persistWindowMode('login') + + // Only clear the Shengxintui SaaS session. Bound media/AI account browser + // partitions are intentionally preserved so the same account can reuse them + // after signing in again. + await Promise.race([clearRendererDesktopSessions(), timeout(1000)]) + await Promise.race([releaseRuntimeSession(), timeout(1500)]) + })() + .catch((error) => { + console.warn('[desktop-main] quit after clearing saas session failed', { + message: error instanceof Error ? error.message : String(error), + }) + }) + .finally(() => { + quitReleaseInFlight = true + app.quit() + }) +} + function windowFromIpcEvent(event: IpcMainInvokeEvent): ElectronBrowserWindow | null { const window = BrowserWindow.fromWebContents(event.sender) if (!window || window.isDestroyed()) { @@ -1190,9 +1221,14 @@ if (!hasSingleInstanceLock) { mainRendererContents.send('desktop:runtime-invalidated', event) }) await queueWindowModeSwitch(currentWindowMode) - initTray(() => { - revealActiveWindowSafely('tray') - }) + initTray( + () => { + revealActiveWindowSafely('tray') + }, + () => { + quitAfterClearingSaasSession() + }, + ) startDesktopHealthIndicator(() => currentMainWindow()) queueDesktopDeepLink(extractDesktopDeepLink(process.argv)) flushPendingDesktopDeepLinks() diff --git a/apps/desktop-client/src/main/tray.ts b/apps/desktop-client/src/main/tray.ts index 7c9a867..d75aeff 100644 --- a/apps/desktop-client/src/main/tray.ts +++ b/apps/desktop-client/src/main/tray.ts @@ -1,6 +1,6 @@ import { nativeImage } from 'electron/common' import type { Tray as ElectronTray } from 'electron/main' -import { Menu, Tray, app } from 'electron/main' +import { Menu, Tray } from 'electron/main' import { TRAY_DANGER_ICON_BASE64, TRAY_ICON_BASE64, TRAY_WINDOWS_ICON_BASE64 } from './brand-icons' @@ -84,7 +84,7 @@ export function updateTrayIssueIndicator(issueCount: number): void { tray.setToolTip(hasIssues ? `省心推 · ${safeCount} 个账号健康问题` : '省心推') } -export function initTray(onOpen: () => void): ElectronTray { +export function initTray(onOpen: () => void, onQuit: () => void): ElectronTray { if (tray) { return tray } @@ -95,7 +95,7 @@ export function initTray(onOpen: () => void): ElectronTray { Menu.buildFromTemplate([ { label: '打开省心推', click: () => onOpen() }, { type: 'separator' }, - { label: '退出省心推', click: () => app.quit() }, + { label: '退出省心推', click: () => onQuit() }, ]), ) tray.on('click', () => onOpen()) diff --git a/apps/desktop-client/src/renderer/views/SettingsView.vue b/apps/desktop-client/src/renderer/views/SettingsView.vue index 59b570f..1e37658 100644 --- a/apps/desktop-client/src/renderer/views/SettingsView.vue +++ b/apps/desktop-client/src/renderer/views/SettingsView.vue @@ -56,7 +56,7 @@ const activeSection = ref('general') const appSettings = ref({ openAtLogin: false, keepRunningInBackground: true, - autoCleanStorageWeekly: false, + autoCleanStorageWeekly: true, }) const serverBaseURL = ref(apiBaseURL.value) const settingsLoaded = ref(false) diff --git a/apps/ops-web/src/views/DesktopClientReleasesView.vue b/apps/ops-web/src/views/DesktopClientReleasesView.vue index 2b0a7dd..5d003af 100644 --- a/apps/ops-web/src/views/DesktopClientReleasesView.vue +++ b/apps/ops-web/src/views/DesktopClientReleasesView.vue @@ -623,6 +623,9 @@ function uploadButtonText(kind: 'installer' | 'updater') { if (isUploading && typeof progress === 'number' && progress < 100) { return `上传中 ${progress}%` } + if (isUploading && progress === 100) { + return '校验并写入 OSS...' + } if (isUploading) { return '写入 OSS...' } diff --git a/server/internal/shared/objectstorage/aliyun.go b/server/internal/shared/objectstorage/aliyun.go index 712c903..2adaddd 100644 --- a/server/internal/shared/objectstorage/aliyun.go +++ b/server/internal/shared/objectstorage/aliyun.go @@ -92,7 +92,7 @@ func (c *aliyunClient) PutReader( return fmt.Errorf("object reader is required") } - exists, err := c.client.IsBucketExist(c.bucket) + exists, err := c.bucketExists(ctx) if err != nil { c.logError(ctx, "aliyun bucket exists check failed", zap.String("bucket", c.bucket), @@ -102,7 +102,7 @@ func (c *aliyunClient) PutReader( return fmt.Errorf("check aliyun bucket: %w", err) } if !exists { - if err := c.client.CreateBucket(c.bucket); err != nil { + if err := c.client.CreateBucket(c.bucket, oss.WithContext(ctx)); err != nil { c.logError(ctx, "aliyun create bucket failed", zap.String("bucket", c.bucket), zap.String("object_key", objectKey), @@ -126,6 +126,7 @@ func (c *aliyunClient) PutReader( if err != nil { return err } + options = append(options, oss.WithContext(ctx)) if err := bucket.PutObject(objectKey, reader, options...); err != nil { c.logError(ctx, "aliyun put object failed", zap.String("bucket", c.bucket), @@ -144,6 +145,14 @@ func (c *aliyunClient) PutReader( return nil } +func (c *aliyunClient) bucketExists(ctx context.Context) (bool, error) { + listRes, err := c.client.ListBuckets(oss.Prefix(c.bucket), oss.MaxKeys(1), oss.WithContext(ctx)) + if err != nil { + return false, err + } + return len(listRes.Buckets) == 1 && listRes.Buckets[0].Name == c.bucket, nil +} + func aliyunPutObjectOptions(cfg config.ObjectStorageConfig, contentType string) ([]oss.Option, error) { options := []oss.Option{oss.ContentType(contentType)} acl := strings.ToLower(strings.TrimSpace(cfg.ObjectACL)) @@ -200,7 +209,7 @@ func (c *aliyunClient) GetBytes(ctx context.Context, objectKey string) ([]byte, return nil, fmt.Errorf("get aliyun bucket: %w", err) } - reader, err := bucket.GetObject(objectKey) + reader, err := bucket.GetObject(objectKey, oss.WithContext(ctx)) if err != nil { c.logError(ctx, "aliyun get object failed", zap.String("bucket", c.bucket), @@ -255,7 +264,7 @@ func (c *aliyunClient) Exists(ctx context.Context, objectKey string) (bool, erro return false, fmt.Errorf("get aliyun bucket: %w", err) } - exists, err := bucket.IsObjectExist(objectKey) + exists, err := bucket.IsObjectExist(objectKey, oss.WithContext(ctx)) if err != nil { c.logError(ctx, "aliyun stat object failed", zap.String("bucket", c.bucket), @@ -285,7 +294,7 @@ func (c *aliyunClient) Delete(ctx context.Context, objectKey string) error { return fmt.Errorf("get aliyun bucket: %w", err) } - if err := bucket.DeleteObject(objectKey); err != nil { + if err := bucket.DeleteObject(objectKey, oss.WithContext(ctx)); err != nil { c.logError(ctx, "aliyun delete object failed", zap.String("bucket", c.bucket), zap.String("object_key", objectKey), @@ -336,7 +345,7 @@ func (c *aliyunClient) Usage(ctx context.Context) (*UsageInfo, error) { if err := c.Validate(); err != nil { return nil, err } - stat, err := c.client.GetBucketStat(c.bucket) + stat, err := c.client.GetBucketStat(c.bucket, oss.WithContext(ctx)) if err != nil { c.logError(ctx, "aliyun get bucket stat failed", zap.String("bucket", c.bucket), @@ -382,6 +391,7 @@ func (c *aliyunClient) List(ctx context.Context, in ListInput) (*ListResult, err if startAfter := normalizeObjectKeyPath(in.StartAfter); startAfter != "" { options = append(options, oss.StartAfter(startAfter)) } + options = append(options, oss.WithContext(ctx)) result, err := bucket.ListObjectsV2(options...) if err != nil { @@ -426,7 +436,7 @@ func (c *aliyunClient) Stat(ctx context.Context, objectKey string) (*ObjectInfo, if err != nil { return nil, fmt.Errorf("get aliyun bucket: %w", err) } - header, err := bucket.GetObjectDetailedMeta(objectKey) + header, err := bucket.GetObjectDetailedMeta(objectKey, oss.WithContext(ctx)) if err != nil { c.logError(ctx, "aliyun stat object failed", zap.String("bucket", c.bucket), @@ -457,7 +467,7 @@ func (c *aliyunClient) Copy(ctx context.Context, sourceKey, destinationKey strin if err != nil { return fmt.Errorf("get aliyun bucket: %w", err) } - if _, err := bucket.CopyObject(sourceKey, destinationKey); err != nil { + if _, err := bucket.CopyObject(sourceKey, destinationKey, oss.WithContext(ctx)); err != nil { c.logError(ctx, "aliyun copy object failed", zap.String("bucket", c.bucket), zap.String("source_key", sourceKey), diff --git a/server/internal/shared/objectstorage/aliyun_test.go b/server/internal/shared/objectstorage/aliyun_test.go index 6635286..abdc999 100644 --- a/server/internal/shared/objectstorage/aliyun_test.go +++ b/server/internal/shared/objectstorage/aliyun_test.go @@ -1,7 +1,11 @@ package objectstorage import ( + "context" + "net/http" + "net/http/httptest" "testing" + "time" "github.com/geo-platform/tenant-api/internal/shared/config" ) @@ -34,6 +38,61 @@ func TestAliyunPutObjectOptionsRejectsUnsupportedObjectACL(t *testing.T) { } } +func TestAliyunExistsHonorsContextCancellation(t *testing.T) { + t.Parallel() + + requestStarted := make(chan struct{}) + blockRequest := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-requestStarted: + default: + close(requestStarted) + } + <-blockRequest + w.WriteHeader(http.StatusOK) + })) + defer func() { + close(blockRequest) + server.Close() + }() + + client := NewAliyunClient(config.ObjectStorageConfig{ + Provider: "aliyun", + Endpoint: server.URL, + AccessKey: "test-access-key", + SecretKey: "test-secret-key", + Bucket: "test-bucket", + Region: "cn-test", + }, nil) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + started := make(chan struct{}) + done := make(chan error, 1) + go func() { + close(started) + _, err := client.Exists(ctx, "desktop-client/releases/test.dmg") + done <- err + }() + <-started + + select { + case <-requestStarted: + case <-time.After(time.Second): + t.Fatal("expected aliyun request to reach the test server") + } + + select { + case err := <-done: + if err == nil { + t.Fatal("expected context cancellation error") + } + case <-time.After(2 * time.Second): + t.Fatal("aliyun Exists did not honor context cancellation") + } +} + func TestIsPublicReadACL(t *testing.T) { t.Parallel()