fix: enable weekly storage cleanup
Desktop Client Build / Resolve Build Metadata (push) Successful in 32s
Frontend CI / Frontend (push) Successful in 2m6s
Backend CI / Backend (push) Successful in 16m11s
Desktop Client Build / Build Desktop Client (push) Successful in 23m50s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 37s
Desktop Client Build / Resolve Build Metadata (push) Successful in 32s
Frontend CI / Frontend (push) Successful in 2m6s
Backend CI / Backend (push) Successful in 16m11s
Desktop Client Build / Build Desktop Client (push) Successful in 23m50s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 37s
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@geo/desktop-client",
|
"name": "@geo/desktop-client",
|
||||||
"version": "0.1.2",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。",
|
"description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ interface DesktopAppInternalFlags {
|
|||||||
const DEFAULT_DESKTOP_APP_SETTINGS: DesktopAppSettings = {
|
const DEFAULT_DESKTOP_APP_SETTINGS: DesktopAppSettings = {
|
||||||
openAtLogin: false,
|
openAtLogin: false,
|
||||||
keepRunningInBackground: true,
|
keepRunningInBackground: true,
|
||||||
autoCleanStorageWeekly: false,
|
autoCleanStorageWeekly: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_INTERNAL_FLAGS: DesktopAppInternalFlags = {
|
const DEFAULT_INTERNAL_FLAGS: DesktopAppInternalFlags = {
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ let loginWindow: ElectronBrowserWindow | null = null
|
|||||||
let settingsWindow: ElectronBrowserWindow | null = null
|
let settingsWindow: ElectronBrowserWindow | null = null
|
||||||
let mainRendererContents: ElectronWebContents | null = null
|
let mainRendererContents: ElectronWebContents | null = null
|
||||||
let quitReleaseInFlight = false
|
let quitReleaseInFlight = false
|
||||||
|
let quitAfterSaasLogoutPromise: Promise<void> | null = null
|
||||||
let mainWindowCreatePromise: Promise<ElectronBrowserWindow> | null = null
|
let mainWindowCreatePromise: Promise<ElectronBrowserWindow> | null = null
|
||||||
let loginWindowCreatePromise: Promise<ElectronBrowserWindow> | null = null
|
let loginWindowCreatePromise: Promise<ElectronBrowserWindow> | null = null
|
||||||
let settingsWindowCreatePromise: Promise<ElectronBrowserWindow> | null = null
|
let settingsWindowCreatePromise: Promise<ElectronBrowserWindow> | null = null
|
||||||
@@ -516,6 +517,36 @@ function revealActiveWindowSafely(source: string): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timeout(ms: number): Promise<void> {
|
||||||
|
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 {
|
function windowFromIpcEvent(event: IpcMainInvokeEvent): ElectronBrowserWindow | null {
|
||||||
const window = BrowserWindow.fromWebContents(event.sender)
|
const window = BrowserWindow.fromWebContents(event.sender)
|
||||||
if (!window || window.isDestroyed()) {
|
if (!window || window.isDestroyed()) {
|
||||||
@@ -1190,9 +1221,14 @@ if (!hasSingleInstanceLock) {
|
|||||||
mainRendererContents.send('desktop:runtime-invalidated', event)
|
mainRendererContents.send('desktop:runtime-invalidated', event)
|
||||||
})
|
})
|
||||||
await queueWindowModeSwitch(currentWindowMode)
|
await queueWindowModeSwitch(currentWindowMode)
|
||||||
initTray(() => {
|
initTray(
|
||||||
revealActiveWindowSafely('tray')
|
() => {
|
||||||
})
|
revealActiveWindowSafely('tray')
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
quitAfterClearingSaasSession()
|
||||||
|
},
|
||||||
|
)
|
||||||
startDesktopHealthIndicator(() => currentMainWindow())
|
startDesktopHealthIndicator(() => currentMainWindow())
|
||||||
queueDesktopDeepLink(extractDesktopDeepLink(process.argv))
|
queueDesktopDeepLink(extractDesktopDeepLink(process.argv))
|
||||||
flushPendingDesktopDeepLinks()
|
flushPendingDesktopDeepLinks()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { nativeImage } from 'electron/common'
|
import { nativeImage } from 'electron/common'
|
||||||
import type { Tray as ElectronTray } from 'electron/main'
|
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'
|
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} 个账号健康问题` : '省心推')
|
tray.setToolTip(hasIssues ? `省心推 · ${safeCount} 个账号健康问题` : '省心推')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initTray(onOpen: () => void): ElectronTray {
|
export function initTray(onOpen: () => void, onQuit: () => void): ElectronTray {
|
||||||
if (tray) {
|
if (tray) {
|
||||||
return tray
|
return tray
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ export function initTray(onOpen: () => void): ElectronTray {
|
|||||||
Menu.buildFromTemplate([
|
Menu.buildFromTemplate([
|
||||||
{ label: '打开省心推', click: () => onOpen() },
|
{ label: '打开省心推', click: () => onOpen() },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ label: '退出省心推', click: () => app.quit() },
|
{ label: '退出省心推', click: () => onQuit() },
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
tray.on('click', () => onOpen())
|
tray.on('click', () => onOpen())
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const activeSection = ref<SectionKey>('general')
|
|||||||
const appSettings = ref<DesktopAppSettings>({
|
const appSettings = ref<DesktopAppSettings>({
|
||||||
openAtLogin: false,
|
openAtLogin: false,
|
||||||
keepRunningInBackground: true,
|
keepRunningInBackground: true,
|
||||||
autoCleanStorageWeekly: false,
|
autoCleanStorageWeekly: true,
|
||||||
})
|
})
|
||||||
const serverBaseURL = ref(apiBaseURL.value)
|
const serverBaseURL = ref(apiBaseURL.value)
|
||||||
const settingsLoaded = ref(false)
|
const settingsLoaded = ref(false)
|
||||||
|
|||||||
@@ -623,6 +623,9 @@ function uploadButtonText(kind: 'installer' | 'updater') {
|
|||||||
if (isUploading && typeof progress === 'number' && progress < 100) {
|
if (isUploading && typeof progress === 'number' && progress < 100) {
|
||||||
return `上传中 ${progress}%`
|
return `上传中 ${progress}%`
|
||||||
}
|
}
|
||||||
|
if (isUploading && progress === 100) {
|
||||||
|
return '校验并写入 OSS...'
|
||||||
|
}
|
||||||
if (isUploading) {
|
if (isUploading) {
|
||||||
return '写入 OSS...'
|
return '写入 OSS...'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func (c *aliyunClient) PutReader(
|
|||||||
return fmt.Errorf("object reader is required")
|
return fmt.Errorf("object reader is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
exists, err := c.client.IsBucketExist(c.bucket)
|
exists, err := c.bucketExists(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logError(ctx, "aliyun bucket exists check failed",
|
c.logError(ctx, "aliyun bucket exists check failed",
|
||||||
zap.String("bucket", c.bucket),
|
zap.String("bucket", c.bucket),
|
||||||
@@ -102,7 +102,7 @@ func (c *aliyunClient) PutReader(
|
|||||||
return fmt.Errorf("check aliyun bucket: %w", err)
|
return fmt.Errorf("check aliyun bucket: %w", err)
|
||||||
}
|
}
|
||||||
if !exists {
|
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",
|
c.logError(ctx, "aliyun create bucket failed",
|
||||||
zap.String("bucket", c.bucket),
|
zap.String("bucket", c.bucket),
|
||||||
zap.String("object_key", objectKey),
|
zap.String("object_key", objectKey),
|
||||||
@@ -126,6 +126,7 @@ func (c *aliyunClient) PutReader(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
options = append(options, oss.WithContext(ctx))
|
||||||
if err := bucket.PutObject(objectKey, reader, options...); err != nil {
|
if err := bucket.PutObject(objectKey, reader, options...); err != nil {
|
||||||
c.logError(ctx, "aliyun put object failed",
|
c.logError(ctx, "aliyun put object failed",
|
||||||
zap.String("bucket", c.bucket),
|
zap.String("bucket", c.bucket),
|
||||||
@@ -144,6 +145,14 @@ func (c *aliyunClient) PutReader(
|
|||||||
return nil
|
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) {
|
func aliyunPutObjectOptions(cfg config.ObjectStorageConfig, contentType string) ([]oss.Option, error) {
|
||||||
options := []oss.Option{oss.ContentType(contentType)}
|
options := []oss.Option{oss.ContentType(contentType)}
|
||||||
acl := strings.ToLower(strings.TrimSpace(cfg.ObjectACL))
|
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)
|
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 {
|
if err != nil {
|
||||||
c.logError(ctx, "aliyun get object failed",
|
c.logError(ctx, "aliyun get object failed",
|
||||||
zap.String("bucket", c.bucket),
|
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)
|
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 {
|
if err != nil {
|
||||||
c.logError(ctx, "aliyun stat object failed",
|
c.logError(ctx, "aliyun stat object failed",
|
||||||
zap.String("bucket", c.bucket),
|
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)
|
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",
|
c.logError(ctx, "aliyun delete object failed",
|
||||||
zap.String("bucket", c.bucket),
|
zap.String("bucket", c.bucket),
|
||||||
zap.String("object_key", objectKey),
|
zap.String("object_key", objectKey),
|
||||||
@@ -336,7 +345,7 @@ func (c *aliyunClient) Usage(ctx context.Context) (*UsageInfo, error) {
|
|||||||
if err := c.Validate(); err != nil {
|
if err := c.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
stat, err := c.client.GetBucketStat(c.bucket)
|
stat, err := c.client.GetBucketStat(c.bucket, oss.WithContext(ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logError(ctx, "aliyun get bucket stat failed",
|
c.logError(ctx, "aliyun get bucket stat failed",
|
||||||
zap.String("bucket", c.bucket),
|
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 != "" {
|
if startAfter := normalizeObjectKeyPath(in.StartAfter); startAfter != "" {
|
||||||
options = append(options, oss.StartAfter(startAfter))
|
options = append(options, oss.StartAfter(startAfter))
|
||||||
}
|
}
|
||||||
|
options = append(options, oss.WithContext(ctx))
|
||||||
|
|
||||||
result, err := bucket.ListObjectsV2(options...)
|
result, err := bucket.ListObjectsV2(options...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -426,7 +436,7 @@ func (c *aliyunClient) Stat(ctx context.Context, objectKey string) (*ObjectInfo,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get aliyun bucket: %w", err)
|
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 {
|
if err != nil {
|
||||||
c.logError(ctx, "aliyun stat object failed",
|
c.logError(ctx, "aliyun stat object failed",
|
||||||
zap.String("bucket", c.bucket),
|
zap.String("bucket", c.bucket),
|
||||||
@@ -457,7 +467,7 @@ func (c *aliyunClient) Copy(ctx context.Context, sourceKey, destinationKey strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get aliyun bucket: %w", err)
|
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",
|
c.logError(ctx, "aliyun copy object failed",
|
||||||
zap.String("bucket", c.bucket),
|
zap.String("bucket", c.bucket),
|
||||||
zap.String("source_key", sourceKey),
|
zap.String("source_key", sourceKey),
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package objectstorage
|
package objectstorage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
"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) {
|
func TestIsPublicReadACL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user