Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fb4f64cb3 | |||
| 1f8e8981ce | |||
| a257d30dba | |||
| 97ee3b0667 |
@@ -0,0 +1,64 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* Service represents the notifications service
|
||||
* @module
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as application$0 from "../../application/models.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
/**
|
||||
* RemoveBadge removes the badge label from the application icon.
|
||||
*/
|
||||
export function RemoveBadge(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2374916939) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceName returns the name of the service.
|
||||
*/
|
||||
export function ServiceName(): Promise<string> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2428202016) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceShutdown is called when the service is unloaded.
|
||||
*/
|
||||
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3893755233) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceStartup is called when the service is loaded.
|
||||
*/
|
||||
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(4078800764, options) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* SetBadge sets the badge label on the application icon.
|
||||
*/
|
||||
export function SetBadge(label: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(784276339, label) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function SetCustomBadge(label: string, options: $models.Options): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3058653106, label, options) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
import * as BadgeService from "./badgeservice.js";
|
||||
export {
|
||||
BadgeService
|
||||
};
|
||||
|
||||
export * from "./models.js";
|
||||
@@ -0,0 +1,58 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import {Create as $Create} from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as color$0 from "../../../../../../../image/color/models.js";
|
||||
|
||||
export class Options {
|
||||
"TextColour": color$0.RGBA;
|
||||
"BackgroundColour": color$0.RGBA;
|
||||
"FontName": string;
|
||||
"FontSize": number;
|
||||
"SmallFontSize": number;
|
||||
|
||||
/** Creates a new Options instance. */
|
||||
constructor($$source: Partial<Options> = {}) {
|
||||
if (!("TextColour" in $$source)) {
|
||||
this["TextColour"] = (new color$0.RGBA());
|
||||
}
|
||||
if (!("BackgroundColour" in $$source)) {
|
||||
this["BackgroundColour"] = (new color$0.RGBA());
|
||||
}
|
||||
if (!("FontName" in $$source)) {
|
||||
this["FontName"] = "";
|
||||
}
|
||||
if (!("FontSize" in $$source)) {
|
||||
this["FontSize"] = 0;
|
||||
}
|
||||
if (!("SmallFontSize" in $$source)) {
|
||||
this["SmallFontSize"] = 0;
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Options instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): Options {
|
||||
const $$createField0_0 = $$createType0;
|
||||
const $$createField1_0 = $$createType0;
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
if ("TextColour" in $$parsedSource) {
|
||||
$$parsedSource["TextColour"] = $$createField0_0($$parsedSource["TextColour"]);
|
||||
}
|
||||
if ("BackgroundColour" in $$parsedSource) {
|
||||
$$parsedSource["BackgroundColour"] = $$createField1_0($$parsedSource["BackgroundColour"]);
|
||||
}
|
||||
return new Options($$parsedSource as Partial<Options>);
|
||||
}
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = color$0.RGBA.createFrom;
|
||||
@@ -0,0 +1,9 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
import * as NotificationService from "./notificationservice.js";
|
||||
export {
|
||||
NotificationService
|
||||
};
|
||||
|
||||
export * from "./models.js";
|
||||
@@ -0,0 +1,107 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import {Create as $Create} from "@wailsio/runtime";
|
||||
|
||||
/**
|
||||
* NotificationAction represents an action button for a notification.
|
||||
*/
|
||||
export class NotificationAction {
|
||||
"id"?: string;
|
||||
"title"?: string;
|
||||
|
||||
/**
|
||||
* (macOS-specific)
|
||||
*/
|
||||
"destructive"?: boolean;
|
||||
|
||||
/** Creates a new NotificationAction instance. */
|
||||
constructor($$source: Partial<NotificationAction> = {}) {
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new NotificationAction instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): NotificationAction {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new NotificationAction($$parsedSource as Partial<NotificationAction>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NotificationCategory groups actions for notifications.
|
||||
*/
|
||||
export class NotificationCategory {
|
||||
"id"?: string;
|
||||
"actions"?: NotificationAction[];
|
||||
"hasReplyField"?: boolean;
|
||||
"replyPlaceholder"?: string;
|
||||
"replyButtonTitle"?: string;
|
||||
|
||||
/** Creates a new NotificationCategory instance. */
|
||||
constructor($$source: Partial<NotificationCategory> = {}) {
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new NotificationCategory instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): NotificationCategory {
|
||||
const $$createField1_0 = $$createType1;
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
if ("actions" in $$parsedSource) {
|
||||
$$parsedSource["actions"] = $$createField1_0($$parsedSource["actions"]);
|
||||
}
|
||||
return new NotificationCategory($$parsedSource as Partial<NotificationCategory>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NotificationOptions contains configuration for a notification
|
||||
*/
|
||||
export class NotificationOptions {
|
||||
"id": string;
|
||||
"title": string;
|
||||
|
||||
/**
|
||||
* (macOS and Linux only)
|
||||
*/
|
||||
"subtitle"?: string;
|
||||
"body"?: string;
|
||||
"categoryId"?: string;
|
||||
"data"?: { [_: string]: any };
|
||||
|
||||
/** Creates a new NotificationOptions instance. */
|
||||
constructor($$source: Partial<NotificationOptions> = {}) {
|
||||
if (!("id" in $$source)) {
|
||||
this["id"] = "";
|
||||
}
|
||||
if (!("title" in $$source)) {
|
||||
this["title"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new NotificationOptions instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): NotificationOptions {
|
||||
const $$createField5_0 = $$createType2;
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
if ("data" in $$parsedSource) {
|
||||
$$parsedSource["data"] = $$createField5_0($$parsedSource["data"]);
|
||||
}
|
||||
return new NotificationOptions($$parsedSource as Partial<NotificationOptions>);
|
||||
}
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = NotificationAction.createFrom;
|
||||
const $$createType1 = $Create.Array($$createType0);
|
||||
const $$createType2 = $Create.Map($Create.Any, $Create.Any);
|
||||
@@ -0,0 +1,110 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* Service represents the notifications service
|
||||
* @module
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as application$0 from "../../application/models.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
export function CheckNotificationAuthorization(): Promise<boolean> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2216952893) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* OnNotificationResponse registers a callback function that will be called when
|
||||
* a notification response is received from the user.
|
||||
*/
|
||||
export function OnNotificationResponse(callback: any): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1642697808, callback) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function RegisterNotificationCategory(category: $models.NotificationCategory): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2917562919, category) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function RemoveAllDeliveredNotifications(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3956282340) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function RemoveAllPendingNotifications(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(108821341) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function RemoveDeliveredNotification(identifier: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(975691940, identifier) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function RemoveNotification(identifier: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3966653866, identifier) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function RemoveNotificationCategory(categoryID: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2032615554, categoryID) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function RemovePendingNotification(identifier: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3729049703, identifier) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public methods that delegate to the implementation.
|
||||
*/
|
||||
export function RequestNotificationAuthorization(): Promise<boolean> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3933442950) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function SendNotification(options: $models.NotificationOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3968228732, options) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function SendNotificationWithActions(options: $models.NotificationOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1886542847, options) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceName returns the name of the service.
|
||||
*/
|
||||
export function ServiceName(): Promise<string> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2704532675) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceShutdown is called when the service is unloaded.
|
||||
*/
|
||||
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2550195434) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceStartup is called when the service is loaded.
|
||||
*/
|
||||
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(4047820929, options) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
4
frontend/bindings/image/color/index.ts
Normal file
4
frontend/bindings/image/color/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export * from "./models.js";
|
||||
46
frontend/bindings/image/color/models.ts
Normal file
46
frontend/bindings/image/color/models.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import {Create as $Create} from "@wailsio/runtime";
|
||||
|
||||
/**
|
||||
* RGBA represents a traditional 32-bit alpha-premultiplied color, having 8
|
||||
* bits for each of red, green, blue and alpha.
|
||||
*
|
||||
* An alpha-premultiplied color component C has been scaled by alpha (A), so
|
||||
* has valid values 0 <= C <= A.
|
||||
*/
|
||||
export class RGBA {
|
||||
"R": number;
|
||||
"G": number;
|
||||
"B": number;
|
||||
"A": number;
|
||||
|
||||
/** Creates a new RGBA instance. */
|
||||
constructor($$source: Partial<RGBA> = {}) {
|
||||
if (!("R" in $$source)) {
|
||||
this["R"] = 0;
|
||||
}
|
||||
if (!("G" in $$source)) {
|
||||
this["G"] = 0;
|
||||
}
|
||||
if (!("B" in $$source)) {
|
||||
this["B"] = 0;
|
||||
}
|
||||
if (!("A" in $$source)) {
|
||||
this["A"] = 0;
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new RGBA instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): RGBA {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new RGBA($$parsedSource as Partial<RGBA>);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,14 @@ export function GetSettingsPath(): Promise<string> & { cancel(): void } {
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* MigrateConfig 执行配置迁移
|
||||
*/
|
||||
export function MigrateConfig(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(434292783) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ResetConfig 强制重置所有配置为默认值
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as MigrationService from "./migrationservice.js";
|
||||
import * as SelfUpdateService from "./selfupdateservice.js";
|
||||
import * as StartupService from "./startupservice.js";
|
||||
import * as SystemService from "./systemservice.js";
|
||||
import * as TestService from "./testservice.js";
|
||||
import * as ThemeService from "./themeservice.js";
|
||||
import * as TranslationService from "./translationservice.js";
|
||||
import * as TrayService from "./trayservice.js";
|
||||
@@ -31,6 +32,7 @@ export {
|
||||
SelfUpdateService,
|
||||
StartupService,
|
||||
SystemService,
|
||||
TestService,
|
||||
ThemeService,
|
||||
TranslationService,
|
||||
TrayService,
|
||||
|
||||
55
frontend/bindings/voidraft/internal/services/testservice.ts
Normal file
55
frontend/bindings/voidraft/internal/services/testservice.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* TestService 测试服务 - 仅在开发环境使用
|
||||
* @module
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
|
||||
|
||||
/**
|
||||
* ClearAll 清除所有测试状态
|
||||
*/
|
||||
export function ClearAll(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2179720854) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceStartup 服务启动时调用
|
||||
*/
|
||||
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(617408198, options) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* TestBadge 测试Badge功能
|
||||
*/
|
||||
export function TestBadge(text: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(4242952145, text) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* TestNotification 测试通知功能
|
||||
*/
|
||||
export function TestNotification(title: string, subtitle: string, body: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1697553289, title, subtitle, body) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* TestUpdateNotification 测试更新通知
|
||||
*/
|
||||
export function TestUpdateNotification(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3091730060) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
@@ -8,19 +8,11 @@ import KeyBindingsPage from '@/views/settings/pages/KeyBindingsPage.vue';
|
||||
import UpdatesPage from '@/views/settings/pages/UpdatesPage.vue';
|
||||
import ExtensionsPage from '@/views/settings/pages/ExtensionsPage.vue';
|
||||
import BackupPage from '@/views/settings/pages/BackupPage.vue';
|
||||
// 测试页面
|
||||
import TestPage from '@/views/settings/pages/TestPage.vue';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Editor',
|
||||
component: Editor
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
redirect: '/settings/general',
|
||||
component: Settings,
|
||||
children: [
|
||||
// 基础设置子路由
|
||||
const settingsChildren: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'general',
|
||||
name: 'SettingsGeneral',
|
||||
@@ -56,7 +48,29 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'SettingsBackup',
|
||||
component: BackupPage
|
||||
}
|
||||
]
|
||||
];
|
||||
|
||||
// 仅在开发环境添加测试页面路由
|
||||
if (import.meta.env.DEV) {
|
||||
settingsChildren.push({
|
||||
path: 'test',
|
||||
name: 'SettingsTest',
|
||||
component: TestPage
|
||||
});
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Editor',
|
||||
component: Editor
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
redirect: '/settings/general',
|
||||
component: Settings,
|
||||
children: settingsChildren
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ const navItems = [
|
||||
{ id: 'updates', icon: '🔄', route: '/settings/updates' }
|
||||
];
|
||||
|
||||
// 仅在开发环境添加测试页面导航
|
||||
if (import.meta.env.DEV) {
|
||||
navItems.push({ id: 'test', icon: '🧪', route: '/settings/test' });
|
||||
}
|
||||
|
||||
const activeNavItem = ref(route.path.split('/').pop() || 'general');
|
||||
|
||||
// 处理导航点击
|
||||
@@ -56,7 +61,7 @@ const goBackToEditor = async () => {
|
||||
@click="handleNavClick(item)"
|
||||
>
|
||||
<span class="nav-icon">{{ item.icon }}</span>
|
||||
<span class="nav-text">{{ t(`settings.${item.id}`) }}</span>
|
||||
<span class="nav-text">{{ item.id === 'test' ? 'Test' : t(`settings.${item.id}`) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-footer">
|
||||
|
||||
274
frontend/src/views/settings/pages/TestPage.vue
Normal file
274
frontend/src/views/settings/pages/TestPage.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<SettingSection title="Development Test Page">
|
||||
<div class="dev-description">
|
||||
This page is only available in development environment for testing notification and badge services.
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
<!-- Badge测试区域 -->
|
||||
<SettingSection title="Badge Service Test">
|
||||
<SettingItem title="Badge Text">
|
||||
<input
|
||||
v-model="badgeText"
|
||||
type="text"
|
||||
placeholder="Enter badge text (empty to remove)"
|
||||
class="select-input"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="Actions">
|
||||
<div class="button-group">
|
||||
<button @click="testBadge" class="test-button primary">
|
||||
Set Badge
|
||||
</button>
|
||||
<button @click="clearBadge" class="test-button">
|
||||
Clear Badge
|
||||
</button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<div v-if="badgeStatus" class="test-status" :class="badgeStatus.type">
|
||||
{{ badgeStatus.message }}
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
<!-- 通知测试区域 -->
|
||||
<SettingSection title="Notification Service Test">
|
||||
<SettingItem title="Title">
|
||||
<input
|
||||
v-model="notificationTitle"
|
||||
type="text"
|
||||
placeholder="Notification title"
|
||||
class="select-input"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="Subtitle">
|
||||
<input
|
||||
v-model="notificationSubtitle"
|
||||
type="text"
|
||||
placeholder="Notification subtitle"
|
||||
class="select-input"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="Body">
|
||||
<textarea
|
||||
v-model="notificationBody"
|
||||
placeholder="Notification body text"
|
||||
class="select-input textarea-input"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</SettingItem>
|
||||
<SettingItem title="Actions">
|
||||
<div class="button-group">
|
||||
<button @click="testNotification" class="test-button primary">
|
||||
Send Test Notification
|
||||
</button>
|
||||
<button @click="testUpdateNotification" class="test-button">
|
||||
Test Update Notification
|
||||
</button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<div v-if="notificationStatus" class="test-status" :class="notificationStatus.type">
|
||||
{{ notificationStatus.message }}
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
<!-- 清除所有测试状态 -->
|
||||
<SettingSection title="Cleanup">
|
||||
<SettingItem title="Clear All">
|
||||
<button @click="clearAll" class="test-button danger">
|
||||
Clear All Test States
|
||||
</button>
|
||||
</SettingItem>
|
||||
<div v-if="clearStatus" class="test-status" :class="clearStatus.type">
|
||||
{{ clearStatus.message }}
|
||||
</div>
|
||||
</SettingSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import * as TestService from '@/../bindings/voidraft/internal/services/testservice'
|
||||
import SettingSection from '../components/SettingSection.vue'
|
||||
import SettingItem from '../components/SettingItem.vue'
|
||||
|
||||
// Badge测试状态
|
||||
const badgeText = ref('')
|
||||
const badgeStatus = ref<{ type: string; message: string } | null>(null)
|
||||
|
||||
// 通知测试状态
|
||||
const notificationTitle = ref('')
|
||||
const notificationSubtitle = ref('')
|
||||
const notificationBody = ref('')
|
||||
const notificationStatus = ref<{ type: string; message: string } | null>(null)
|
||||
|
||||
// 清除状态
|
||||
const clearStatus = ref<{ type: string; message: string } | null>(null)
|
||||
|
||||
// 显示状态消息的辅助函数
|
||||
const showStatus = (statusRef: any, type: 'success' | 'error', message: string) => {
|
||||
statusRef.value = { type, message }
|
||||
setTimeout(() => {
|
||||
statusRef.value = null
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// 测试Badge功能
|
||||
const testBadge = async () => {
|
||||
try {
|
||||
await TestService.TestBadge(badgeText.value)
|
||||
showStatus(badgeStatus, 'success', `Badge ${badgeText.value ? 'set to: ' + badgeText.value : 'cleared'} successfully`)
|
||||
} catch (error: any) {
|
||||
showStatus(badgeStatus, 'error', `Failed to set badge: ${error.message || error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除Badge
|
||||
const clearBadge = async () => {
|
||||
try {
|
||||
await TestService.TestBadge('')
|
||||
badgeText.value = ''
|
||||
showStatus(badgeStatus, 'success', 'Badge cleared successfully')
|
||||
} catch (error: any) {
|
||||
showStatus(badgeStatus, 'error', `Failed to clear badge: ${error.message || error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试通知功能
|
||||
const testNotification = async () => {
|
||||
try {
|
||||
await TestService.TestNotification(
|
||||
notificationTitle.value,
|
||||
notificationSubtitle.value,
|
||||
notificationBody.value
|
||||
)
|
||||
showStatus(notificationStatus, 'success', 'Notification sent successfully')
|
||||
} catch (error: any) {
|
||||
showStatus(notificationStatus, 'error', `Failed to send notification: ${error.message || error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试更新通知
|
||||
const testUpdateNotification = async () => {
|
||||
try {
|
||||
await TestService.TestUpdateNotification()
|
||||
showStatus(notificationStatus, 'success', 'Update notification sent successfully (badge + notification)')
|
||||
} catch (error: any) {
|
||||
showStatus(notificationStatus, 'error', `Failed to send update notification: ${error.message || error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除所有测试状态
|
||||
const clearAll = async () => {
|
||||
try {
|
||||
await TestService.ClearAll()
|
||||
// 清空表单
|
||||
badgeText.value = ''
|
||||
notificationTitle.value = ''
|
||||
notificationSubtitle.value = ''
|
||||
notificationBody.value = ''
|
||||
showStatus(clearStatus, 'success', 'All test states cleared successfully')
|
||||
} catch (error: any) {
|
||||
showStatus(clearStatus, 'error', `Failed to clear test states: ${error.message || error}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
padding: 20px 0 20px 0;
|
||||
}
|
||||
|
||||
.dev-description {
|
||||
color: var(--settings-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--settings-input-border);
|
||||
border-radius: 4px;
|
||||
background-color: var(--settings-input-bg);
|
||||
color: var(--settings-text);
|
||||
font-size: 12px;
|
||||
width: 180px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
&.textarea-input {
|
||||
min-height: 60px;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.test-button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--settings-border);
|
||||
border-radius: 4px;
|
||||
background-color: var(--settings-card-bg);
|
||||
color: var(--settings-text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--settings-hover);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background-color: #4a9eff;
|
||||
color: white;
|
||||
border-color: #4a9eff;
|
||||
|
||||
&:hover {
|
||||
background-color: #3a8eef;
|
||||
border-color: #3a8eef;
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background-color: var(--text-danger);
|
||||
color: white;
|
||||
border-color: var(--text-danger);
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.test-status {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border: 1px solid;
|
||||
|
||||
&.success {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
&.error {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
border-color: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
2
go.mod
2
go.mod
@@ -21,6 +21,7 @@ require (
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.21.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
@@ -77,6 +78,7 @@ require (
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
|
||||
golang.org/x/image v0.24.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -2,6 +2,8 @@ code.gitea.io/sdk/gitea v0.21.0 h1:69n6oz6kEVHRo1+APQQyizkhrZrLsTLXey9142pfkD4=
|
||||
code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
@@ -178,6 +180,8 @@ golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
|
||||
@@ -53,7 +53,6 @@ func NewConfigMigrator(
|
||||
|
||||
// AutoMigrate automatically detects and migrates missing configuration fields
|
||||
func (cm *ConfigMigrator) AutoMigrate(defaultConfig interface{}, currentConfig *koanf.Koanf) (*MigrationResult, error) {
|
||||
// Load default config into temporary koanf instance
|
||||
defaultKoanf := koanf.New(".")
|
||||
if err := defaultKoanf.Load(structs.Provider(defaultConfig, "json"), nil); err != nil {
|
||||
return nil, fmt.Errorf("failed to load default config: %w", err)
|
||||
@@ -66,23 +65,20 @@ func (cm *ConfigMigrator) AutoMigrate(defaultConfig interface{}, currentConfig *
|
||||
result := &MigrationResult{
|
||||
MissingFields: missingFields,
|
||||
Migrated: len(missingFields) > 0,
|
||||
Description: fmt.Sprintf("Detected %d missing configuration fields", len(missingFields)),
|
||||
Description: fmt.Sprintf("Found %d missing fields", len(missingFields)),
|
||||
}
|
||||
|
||||
// If no missing fields, return early
|
||||
// No migration needed
|
||||
if !result.Migrated {
|
||||
cm.logger.Info("No missing configuration fields detected")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Only create backup if we actually need to migrate (has missing fields)
|
||||
if len(missingFields) > 0 {
|
||||
if backupPath, err := cm.createBackup(); err != nil {
|
||||
cm.logger.Error("Failed to create backup", "error", err)
|
||||
} else {
|
||||
// Create backup before migration
|
||||
backupPath, err := cm.createBackup()
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("backup creation failed: %w", err)
|
||||
}
|
||||
result.BackupPath = backupPath
|
||||
}
|
||||
}
|
||||
|
||||
// Merge missing fields from default config
|
||||
if err := cm.mergeDefaultFields(currentConfig, defaultKoanf, missingFields); err != nil {
|
||||
@@ -94,19 +90,25 @@ func (cm *ConfigMigrator) AutoMigrate(defaultConfig interface{}, currentConfig *
|
||||
return result, fmt.Errorf("failed to save updated config: %w", err)
|
||||
}
|
||||
|
||||
cm.logger.Info("Configuration migration completed successfully", "migratedFields", len(missingFields))
|
||||
// Clean up backup on success
|
||||
if backupPath != "" {
|
||||
if err := os.Remove(backupPath); err != nil {
|
||||
cm.logger.Error("Failed to remove backup", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// detectMissingFields detects missing configuration fields
|
||||
func (cm *ConfigMigrator) detectMissingFields(current, defaultConfig map[string]interface{}) []string {
|
||||
var missingFields []string
|
||||
cm.findMissingFieldsRecursive("", defaultConfig, current, &missingFields)
|
||||
return missingFields
|
||||
var missing []string
|
||||
cm.findMissing("", defaultConfig, current, &missing)
|
||||
return missing
|
||||
}
|
||||
|
||||
// findMissingFieldsRecursive recursively finds missing fields
|
||||
func (cm *ConfigMigrator) findMissingFieldsRecursive(prefix string, defaultMap, currentMap map[string]interface{}, missing *[]string) {
|
||||
// findMissing recursively finds missing fields
|
||||
func (cm *ConfigMigrator) findMissing(prefix string, defaultMap, currentMap map[string]interface{}, missing *[]string) {
|
||||
for key, defaultVal := range defaultMap {
|
||||
fullKey := key
|
||||
if prefix != "" {
|
||||
@@ -115,34 +117,38 @@ func (cm *ConfigMigrator) findMissingFieldsRecursive(prefix string, defaultMap,
|
||||
|
||||
currentVal, exists := currentMap[key]
|
||||
if !exists {
|
||||
// Field is completely missing
|
||||
// Field completely missing - add it
|
||||
*missing = append(*missing, fullKey)
|
||||
} else {
|
||||
// Check nested structures
|
||||
if defaultNestedMap, ok := defaultVal.(map[string]interface{}); ok {
|
||||
} else if defaultNestedMap, ok := defaultVal.(map[string]interface{}); ok {
|
||||
if currentNestedMap, ok := currentVal.(map[string]interface{}); ok {
|
||||
cm.findMissingFieldsRecursive(fullKey, defaultNestedMap, currentNestedMap, missing)
|
||||
} else {
|
||||
// Current value is not a map but default is, structure mismatch
|
||||
*missing = append(*missing, fullKey)
|
||||
}
|
||||
// Both are maps, recurse into them
|
||||
cm.findMissing(fullKey, defaultNestedMap, currentNestedMap, missing)
|
||||
}
|
||||
// Type mismatch: user has different type, don't recurse
|
||||
}
|
||||
// For non-map default values, field exists, preserve user's value
|
||||
}
|
||||
}
|
||||
|
||||
// mergeDefaultFields merges default values for missing fields into current config
|
||||
func (cm *ConfigMigrator) mergeDefaultFields(current, defaultConfig *koanf.Koanf, missingFields []string) error {
|
||||
actuallyMerged := 0
|
||||
|
||||
for _, field := range missingFields {
|
||||
defaultValue := defaultConfig.Get(field)
|
||||
if defaultValue != nil {
|
||||
if defaultConfig.Exists(field) {
|
||||
if defaultValue := defaultConfig.Get(field); defaultValue != nil {
|
||||
// Always set the field, even if it causes type conflicts
|
||||
// This allows configuration structure evolution during upgrades
|
||||
current.Set(field, defaultValue)
|
||||
cm.logger.Debug("Merged missing field", "field", field, "value", defaultValue)
|
||||
actuallyMerged++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update last modified timestamp
|
||||
// Update timestamp if we actually merged fields
|
||||
if actuallyMerged > 0 {
|
||||
current.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -165,7 +171,6 @@ func (cm *ConfigMigrator) createBackup() (string, error) {
|
||||
return "", fmt.Errorf("failed to create backup: %w", err)
|
||||
}
|
||||
|
||||
cm.logger.Info("Configuration backup created", "path", backupPath)
|
||||
return backupPath, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
jsonparser "github.com/knadh/koanf/parsers/json"
|
||||
"github.com/knadh/koanf/providers/file"
|
||||
"github.com/knadh/koanf/v2"
|
||||
@@ -158,10 +159,877 @@ func TestConfigMigrator_AutoMigrate(t *testing.T) {
|
||||
assert.True(t, k.Bool("newSection.enabled"), "newSection.enabled should be added with correct value")
|
||||
assert.Equal(t, "new section", k.String("newSection.value"), "newSection.value should be added with correct value")
|
||||
|
||||
// Check that backup was created
|
||||
// Check that backup was cleaned up after successful migration
|
||||
backupFiles, err := filepath.Glob(filepath.Join(tempDir, "*.backup.*"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list backup files: %v", err)
|
||||
}
|
||||
assert.Equal(t, 1, len(backupFiles), "One backup file should have been created")
|
||||
assert.Equal(t, 0, len(backupFiles), "Backup file should have been cleaned up after successful migration")
|
||||
}
|
||||
|
||||
// TestConfigMigrator_NoOverwrite tests that user configuration is never overwritten
|
||||
func TestConfigMigrator_NoOverwrite(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "config_migrator_no_overwrite_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create user config with custom values that differ from defaults
|
||||
userConfig := map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"name": "CustomAppName", // Different from default
|
||||
"version": "2.0.0", // Different from default
|
||||
"theme": "custom", // Different from default
|
||||
},
|
||||
"user": map[string]interface{}{
|
||||
"name": "Custom User", // Different from default
|
||||
"email": "custom@example.com", // Different from default
|
||||
"settings": map[string]interface{}{
|
||||
"autoSave": false, // Different from default
|
||||
"language": "zh", // Different from default
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create config file
|
||||
configPath := filepath.Join(tempDir, "config.json")
|
||||
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
|
||||
os.WriteFile(configPath, jsonData, 0644)
|
||||
|
||||
// Create migrator and load config
|
||||
logger := log.New()
|
||||
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||
k := koanf.New(".")
|
||||
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||
|
||||
// Create default config with different values
|
||||
defaultConfig := TestConfig{}
|
||||
defaultConfig.App.Name = "DefaultApp"
|
||||
defaultConfig.App.Version = "1.0.0"
|
||||
defaultConfig.App.Theme = "light"
|
||||
defaultConfig.User.Name = "Default User"
|
||||
defaultConfig.User.Email = "default@example.com"
|
||||
defaultConfig.User.Settings.AutoSave = true
|
||||
defaultConfig.User.Settings.Language = "en"
|
||||
defaultConfig.User.Settings.NewSetting = true // This should be added
|
||||
defaultConfig.User.Settings.NewSetting2 = "value" // This should be added
|
||||
defaultConfig.NewSection.Enabled = true // This should be added
|
||||
defaultConfig.NewSection.Value = "new section" // This should be added
|
||||
|
||||
// Run migration
|
||||
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, result.Migrated)
|
||||
|
||||
// Verify user values are preserved
|
||||
assert.Equal(t, "CustomAppName", k.String("app.name"), "User's app name should not be overwritten")
|
||||
assert.Equal(t, "2.0.0", k.String("app.version"), "User's version should not be overwritten")
|
||||
assert.Equal(t, "custom", k.String("app.theme"), "User's theme should not be overwritten")
|
||||
assert.Equal(t, "Custom User", k.String("user.name"), "User's name should not be overwritten")
|
||||
assert.Equal(t, "custom@example.com", k.String("user.email"), "User's email should not be overwritten")
|
||||
assert.False(t, k.Bool("user.settings.autoSave"), "User's autoSave should not be overwritten")
|
||||
assert.Equal(t, "zh", k.String("user.settings.language"), "User's language should not be overwritten")
|
||||
|
||||
// Verify missing fields were added with default values
|
||||
assert.True(t, k.Bool("user.settings.newSetting"), "Missing field should be added")
|
||||
assert.Equal(t, "value", k.String("user.settings.newSetting2"), "Missing field should be added")
|
||||
assert.True(t, k.Bool("newSection.enabled"), "Missing section should be added")
|
||||
assert.Equal(t, "new section", k.String("newSection.value"), "Missing section should be added")
|
||||
}
|
||||
|
||||
// TestConfigMigrator_TypeMismatch tests handling of type mismatches (config structure evolution)
|
||||
func TestConfigMigrator_TypeMismatch(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "config_migrator_type_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create user config where some fields have different types
|
||||
userConfig := map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"name": "TestApp",
|
||||
"version": "1.0.0",
|
||||
"theme": "dark",
|
||||
},
|
||||
"user": map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"email": "test@example.com",
|
||||
"settings": "simple_string", // This is a string, but default is an object
|
||||
},
|
||||
"newSection": 123, // This is a number, but default is an object
|
||||
}
|
||||
|
||||
// Create config file
|
||||
configPath := filepath.Join(tempDir, "config.json")
|
||||
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
|
||||
os.WriteFile(configPath, jsonData, 0644)
|
||||
|
||||
// Create migrator and load config
|
||||
logger := log.New()
|
||||
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||
k := koanf.New(".")
|
||||
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||
|
||||
// Create default config
|
||||
defaultConfig := TestConfig{}
|
||||
defaultConfig.App.Name = "TestApp"
|
||||
defaultConfig.App.Version = "1.0.0"
|
||||
defaultConfig.App.Theme = "dark"
|
||||
defaultConfig.User.Name = "Test User"
|
||||
defaultConfig.User.Email = "test@example.com"
|
||||
defaultConfig.User.Settings.AutoSave = true
|
||||
defaultConfig.User.Settings.Language = "en"
|
||||
defaultConfig.User.Settings.NewSetting = true
|
||||
defaultConfig.User.Settings.NewSetting2 = "value"
|
||||
defaultConfig.NewSection.Enabled = true
|
||||
defaultConfig.NewSection.Value = "new section"
|
||||
|
||||
// Run migration
|
||||
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Should detect missing fields and merge them, overriding type conflicts for config evolution
|
||||
assert.True(t, result.Migrated, "Migration should be performed")
|
||||
assert.Greater(t, len(result.MissingFields), 0, "Should detect missing fields with type mismatch")
|
||||
|
||||
// Verify that type-mismatched values are overwritten with new structure (config evolution)
|
||||
// This is important for software upgrades where config structure changes
|
||||
assert.NotEqual(t, "simple_string", k.String("user.settings"), "User's string should be overwritten with new structure")
|
||||
assert.NotEqual(t, int64(123), k.Int64("newSection"), "User's number should be overwritten with new structure")
|
||||
|
||||
// Verify new structure is properly applied
|
||||
assert.True(t, k.Bool("user.settings.autoSave"), "New settings structure should be applied")
|
||||
assert.Equal(t, "en", k.String("user.settings.language"), "New settings structure should be applied")
|
||||
assert.True(t, k.Bool("user.settings.newSetting"), "New settings structure should be applied")
|
||||
assert.Equal(t, "value", k.String("user.settings.newSetting2"), "New settings structure should be applied")
|
||||
assert.True(t, k.Bool("newSection.enabled"), "New section structure should be applied")
|
||||
assert.Equal(t, "new section", k.String("newSection.value"), "New section structure should be applied")
|
||||
}
|
||||
|
||||
// TestConfigMigrator_ConfigEvolution tests configuration structure evolution scenarios
|
||||
func TestConfigMigrator_ConfigEvolution(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "config_migrator_evolution_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Simulate old config format where "features" was a simple string
|
||||
// but new version expects it to be an object
|
||||
oldConfig := map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"name": "MyApp",
|
||||
"version": "1.0.0",
|
||||
"features": "plugin1,plugin2", // Old: simple comma-separated string
|
||||
},
|
||||
"database": "sqlite://data.db", // Old: simple string
|
||||
}
|
||||
|
||||
configPath := filepath.Join(tempDir, "config.json")
|
||||
jsonData, _ := json.MarshalIndent(oldConfig, "", " ")
|
||||
os.WriteFile(configPath, jsonData, 0644)
|
||||
|
||||
logger := log.New()
|
||||
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||
k := koanf.New(".")
|
||||
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||
|
||||
// New config format where "features" and "database" are objects
|
||||
type NewConfig struct {
|
||||
App struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Features struct {
|
||||
Enabled []string `json:"enabled"`
|
||||
Config string `json:"config"`
|
||||
} `json:"features"`
|
||||
} `json:"app"`
|
||||
Database struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
} `json:"database"`
|
||||
}
|
||||
|
||||
defaultConfig := NewConfig{}
|
||||
defaultConfig.App.Name = "DefaultApp" // Will be preserved from user
|
||||
defaultConfig.App.Version = "2.0.0" // Will be preserved from user
|
||||
defaultConfig.App.Features.Enabled = []string{"newFeature"}
|
||||
defaultConfig.App.Features.Config = "default.conf"
|
||||
defaultConfig.Database.Type = "postgresql"
|
||||
defaultConfig.Database.URL = "postgres://localhost:5432/db"
|
||||
|
||||
// Run migration
|
||||
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, result.Migrated)
|
||||
|
||||
// User's non-conflicting values should be preserved
|
||||
assert.Equal(t, "MyApp", k.String("app.name"), "User's app name should be preserved")
|
||||
assert.Equal(t, "1.0.0", k.String("app.version"), "User's version should be preserved")
|
||||
|
||||
// Conflicting structure should be evolved (old string → new object)
|
||||
assert.NotEqual(t, "plugin1,plugin2", k.String("app.features"), "Old features string should be replaced")
|
||||
assert.Equal(t, []string{"newFeature"}, k.Strings("app.features.enabled"), "New features structure should be applied")
|
||||
assert.Equal(t, "default.conf", k.String("app.features.config"), "New features structure should be applied")
|
||||
|
||||
assert.NotEqual(t, "sqlite://data.db", k.String("database"), "Old database string should be replaced")
|
||||
assert.Equal(t, "postgresql", k.String("database.type"), "New database structure should be applied")
|
||||
assert.Equal(t, "postgres://localhost:5432/db", k.String("database.url"), "New database structure should be applied")
|
||||
|
||||
t.Logf("Successfully evolved config structure, fields migrated: %d", len(result.MissingFields))
|
||||
}
|
||||
|
||||
// TestConfigMigrator_ComplexNested tests complex nested structure migration
|
||||
func TestConfigMigrator_ComplexNested(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "config_migrator_complex_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Complex user config with deep nesting
|
||||
userConfig := map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"name": "TestApp",
|
||||
"version": "1.0.0",
|
||||
"advanced": map[string]interface{}{
|
||||
"logging": map[string]interface{}{
|
||||
"level": "info",
|
||||
"file": "/var/log/app.log",
|
||||
// Missing: format, rotation
|
||||
},
|
||||
"performance": map[string]interface{}{
|
||||
"cache": true,
|
||||
// Missing: timeout, maxConnections
|
||||
},
|
||||
// Missing: security section
|
||||
},
|
||||
},
|
||||
"plugins": map[string]interface{}{
|
||||
"enabled": []string{"plugin1", "plugin2"},
|
||||
// Missing: config section
|
||||
},
|
||||
// Missing: monitoring section
|
||||
}
|
||||
|
||||
// Default config with additional nested fields
|
||||
type ComplexConfig struct {
|
||||
App struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Advanced struct {
|
||||
Logging struct {
|
||||
Level string `json:"level"`
|
||||
File string `json:"file"`
|
||||
Format string `json:"format"`
|
||||
Rotation bool `json:"rotation"`
|
||||
} `json:"logging"`
|
||||
Performance struct {
|
||||
Cache bool `json:"cache"`
|
||||
Timeout int `json:"timeout"`
|
||||
MaxConnections int `json:"maxConnections"`
|
||||
} `json:"performance"`
|
||||
Security struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
TokenType string `json:"tokenType"`
|
||||
ExpireTime int `json:"expireTime"`
|
||||
} `json:"security"`
|
||||
} `json:"advanced"`
|
||||
} `json:"app"`
|
||||
Plugins struct {
|
||||
Enabled []string `json:"enabled"`
|
||||
Config struct {
|
||||
LoadOrder []string `json:"loadOrder"`
|
||||
Settings map[string]string `json:"settings"`
|
||||
} `json:"config"`
|
||||
} `json:"plugins"`
|
||||
Monitoring struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Interval int `json:"interval"`
|
||||
} `json:"monitoring"`
|
||||
}
|
||||
|
||||
// Create config file
|
||||
configPath := filepath.Join(tempDir, "config.json")
|
||||
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
|
||||
os.WriteFile(configPath, jsonData, 0644)
|
||||
|
||||
// Create migrator and load config
|
||||
logger := log.New()
|
||||
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||
k := koanf.New(".")
|
||||
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||
|
||||
// Create complete default config
|
||||
defaultConfig := ComplexConfig{}
|
||||
defaultConfig.App.Name = "TestApp"
|
||||
defaultConfig.App.Version = "1.0.0"
|
||||
defaultConfig.App.Advanced.Logging.Level = "info"
|
||||
defaultConfig.App.Advanced.Logging.File = "/var/log/app.log"
|
||||
defaultConfig.App.Advanced.Logging.Format = "json"
|
||||
defaultConfig.App.Advanced.Logging.Rotation = true
|
||||
defaultConfig.App.Advanced.Performance.Cache = true
|
||||
defaultConfig.App.Advanced.Performance.Timeout = 30
|
||||
defaultConfig.App.Advanced.Performance.MaxConnections = 100
|
||||
defaultConfig.App.Advanced.Security.Enabled = true
|
||||
defaultConfig.App.Advanced.Security.TokenType = "JWT"
|
||||
defaultConfig.App.Advanced.Security.ExpireTime = 3600
|
||||
defaultConfig.Plugins.Enabled = []string{"plugin1", "plugin2"}
|
||||
defaultConfig.Plugins.Config.LoadOrder = []string{"plugin1", "plugin2"}
|
||||
defaultConfig.Plugins.Config.Settings = map[string]string{"key": "value"}
|
||||
defaultConfig.Monitoring.Enabled = true
|
||||
defaultConfig.Monitoring.Endpoint = "/metrics"
|
||||
defaultConfig.Monitoring.Interval = 60
|
||||
|
||||
// Run migration
|
||||
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, result.Migrated)
|
||||
|
||||
// Verify user values are preserved
|
||||
assert.Equal(t, "info", k.String("app.advanced.logging.level"))
|
||||
assert.Equal(t, "/var/log/app.log", k.String("app.advanced.logging.file"))
|
||||
assert.True(t, k.Bool("app.advanced.performance.cache"))
|
||||
|
||||
// Verify missing fields were added
|
||||
assert.Equal(t, "json", k.String("app.advanced.logging.format"))
|
||||
assert.True(t, k.Bool("app.advanced.logging.rotation"))
|
||||
assert.Equal(t, 30, k.Int("app.advanced.performance.timeout"))
|
||||
assert.Equal(t, 100, k.Int("app.advanced.performance.maxConnections"))
|
||||
assert.True(t, k.Bool("app.advanced.security.enabled"))
|
||||
assert.Equal(t, "JWT", k.String("app.advanced.security.tokenType"))
|
||||
assert.Equal(t, 3600, k.Int("app.advanced.security.expireTime"))
|
||||
assert.Equal(t, []string{"plugin1", "plugin2"}, k.Strings("plugins.config.loadOrder"))
|
||||
assert.True(t, k.Bool("monitoring.enabled"))
|
||||
assert.Equal(t, "/metrics", k.String("monitoring.endpoint"))
|
||||
assert.Equal(t, 60, k.Int("monitoring.interval"))
|
||||
|
||||
t.Logf("Detected missing fields: %v", result.MissingFields)
|
||||
// Should detect multiple missing fields
|
||||
assert.Greater(t, len(result.MissingFields), 5, "Should detect multiple missing fields")
|
||||
}
|
||||
|
||||
// TestConfigMigrator_MultipleMigrations tests running migration multiple times
|
||||
func TestConfigMigrator_MultipleMigrations(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "config_migrator_multiple_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create initial config
|
||||
configPath := createTestConfig(t, tempDir)
|
||||
logger := log.New()
|
||||
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||
|
||||
// Create default config
|
||||
defaultConfig := TestConfig{}
|
||||
defaultConfig.App.Name = "TestApp"
|
||||
defaultConfig.App.Version = "1.0.0"
|
||||
defaultConfig.App.Theme = "dark"
|
||||
defaultConfig.User.Name = "Test User"
|
||||
defaultConfig.User.Email = "test@example.com"
|
||||
defaultConfig.User.Settings.AutoSave = true
|
||||
defaultConfig.User.Settings.Language = "en"
|
||||
defaultConfig.User.Settings.NewSetting = true
|
||||
defaultConfig.User.Settings.NewSetting2 = "value"
|
||||
defaultConfig.NewSection.Enabled = true
|
||||
defaultConfig.NewSection.Value = "new section"
|
||||
|
||||
// First migration
|
||||
k1 := koanf.New(".")
|
||||
k1.Load(file.Provider(configPath), jsonparser.Parser())
|
||||
result1, err := migrator.AutoMigrate(defaultConfig, k1)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, result1.Migrated, "First migration should be performed")
|
||||
|
||||
// Second migration - should detect no missing fields
|
||||
k2 := koanf.New(".")
|
||||
k2.Load(file.Provider(configPath), jsonparser.Parser())
|
||||
result2, err := migrator.AutoMigrate(defaultConfig, k2)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, result2.Migrated, "Second migration should not be needed")
|
||||
assert.Equal(t, 0, len(result2.MissingFields), "No fields should be missing in second migration")
|
||||
}
|
||||
|
||||
// TestConfigMigrator_BackupHandling tests backup creation and cleanup
|
||||
func TestConfigMigrator_BackupHandling(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "config_migrator_backup_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
configPath := createTestConfig(t, tempDir)
|
||||
logger := log.New()
|
||||
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||
|
||||
// Create default config
|
||||
defaultConfig := TestConfig{}
|
||||
defaultConfig.App.Name = "TestApp"
|
||||
defaultConfig.App.Version = "1.0.0"
|
||||
defaultConfig.App.Theme = "dark"
|
||||
defaultConfig.User.Name = "Test User"
|
||||
defaultConfig.User.Email = "test@example.com"
|
||||
defaultConfig.User.Settings.AutoSave = true
|
||||
defaultConfig.User.Settings.Language = "en"
|
||||
defaultConfig.User.Settings.NewSetting = true
|
||||
defaultConfig.User.Settings.NewSetting2 = "value"
|
||||
defaultConfig.NewSection.Enabled = true
|
||||
defaultConfig.NewSection.Value = "new section"
|
||||
|
||||
k := koanf.New(".")
|
||||
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||
|
||||
// Run migration
|
||||
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, result.Migrated)
|
||||
|
||||
// Backup should be cleaned up after successful migration
|
||||
backupFiles, _ := filepath.Glob(filepath.Join(tempDir, "*.backup.*"))
|
||||
assert.Equal(t, 0, len(backupFiles), "Backup should be cleaned up after successful migration")
|
||||
}
|
||||
|
||||
// TestConfigMigrator_NoMigrationNeeded tests when no migration is needed
|
||||
func TestConfigMigrator_NoMigrationNeeded(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "config_migrator_no_migration_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create complete config (no missing fields)
|
||||
completeConfig := map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"name": "TestApp",
|
||||
"version": "1.0.0",
|
||||
"theme": "dark",
|
||||
},
|
||||
"user": map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"email": "test@example.com",
|
||||
"settings": map[string]interface{}{
|
||||
"autoSave": true,
|
||||
"language": "en",
|
||||
"newSetting": true,
|
||||
"newSetting2": "value",
|
||||
},
|
||||
},
|
||||
"newSection": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"value": "new section",
|
||||
},
|
||||
}
|
||||
|
||||
configPath := filepath.Join(tempDir, "config.json")
|
||||
jsonData, _ := json.MarshalIndent(completeConfig, "", " ")
|
||||
os.WriteFile(configPath, jsonData, 0644)
|
||||
|
||||
logger := log.New()
|
||||
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||
k := koanf.New(".")
|
||||
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||
|
||||
// Create matching default config
|
||||
defaultConfig := TestConfig{}
|
||||
defaultConfig.App.Name = "TestApp"
|
||||
defaultConfig.App.Version = "1.0.0"
|
||||
defaultConfig.App.Theme = "dark"
|
||||
defaultConfig.User.Name = "Test User"
|
||||
defaultConfig.User.Email = "test@example.com"
|
||||
defaultConfig.User.Settings.AutoSave = true
|
||||
defaultConfig.User.Settings.Language = "en"
|
||||
defaultConfig.User.Settings.NewSetting = true
|
||||
defaultConfig.User.Settings.NewSetting2 = "value"
|
||||
defaultConfig.NewSection.Enabled = true
|
||||
defaultConfig.NewSection.Value = "new section"
|
||||
|
||||
// Run migration
|
||||
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, result.Migrated, "No migration should be needed")
|
||||
assert.Equal(t, 0, len(result.MissingFields), "No fields should be missing")
|
||||
|
||||
// No backup should be created
|
||||
backupFiles, _ := filepath.Glob(filepath.Join(tempDir, "*.backup.*"))
|
||||
assert.Equal(t, 0, len(backupFiles), "No backup should be created when migration is not needed")
|
||||
}
|
||||
|
||||
// TestConfigMigrator_PartialOverride tests partial user override scenarios
|
||||
func TestConfigMigrator_PartialOverride(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "config_migrator_partial_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create user config with partial overrides
|
||||
userConfig := map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"name": "CustomApp",
|
||||
"version": "2.0.0", // User custom value
|
||||
// Missing: theme (should use default)
|
||||
},
|
||||
"user": map[string]interface{}{
|
||||
"name": "Custom User",
|
||||
"email": "custom@example.com",
|
||||
"settings": map[string]interface{}{
|
||||
"autoSave": false, // User custom value
|
||||
"language": "zh", // User custom value
|
||||
// Missing: newSetting, newSetting2 (should use defaults)
|
||||
},
|
||||
},
|
||||
// Missing: newSection (should use defaults)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(tempDir, "config.json")
|
||||
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
|
||||
os.WriteFile(configPath, jsonData, 0644)
|
||||
|
||||
logger := log.New()
|
||||
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||
k := koanf.New(".")
|
||||
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||
|
||||
// Create complete default config
|
||||
defaultConfig := TestConfig{}
|
||||
defaultConfig.App.Name = "DefaultApp"
|
||||
defaultConfig.App.Version = "1.0.0"
|
||||
defaultConfig.App.Theme = "light" // Should be added
|
||||
defaultConfig.User.Name = "Default User"
|
||||
defaultConfig.User.Email = "default@example.com"
|
||||
defaultConfig.User.Settings.AutoSave = true
|
||||
defaultConfig.User.Settings.Language = "en"
|
||||
defaultConfig.User.Settings.NewSetting = true // Should be added
|
||||
defaultConfig.User.Settings.NewSetting2 = "value" // Should be added
|
||||
defaultConfig.NewSection.Enabled = true // Should be added
|
||||
defaultConfig.NewSection.Value = "new section" // Should be added
|
||||
|
||||
// Run migration
|
||||
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, result.Migrated)
|
||||
|
||||
// Verify user values are preserved
|
||||
assert.Equal(t, "CustomApp", k.String("app.name"))
|
||||
assert.Equal(t, "2.0.0", k.String("app.version"))
|
||||
assert.Equal(t, "Custom User", k.String("user.name"))
|
||||
assert.Equal(t, "custom@example.com", k.String("user.email"))
|
||||
assert.False(t, k.Bool("user.settings.autoSave"))
|
||||
assert.Equal(t, "zh", k.String("user.settings.language"))
|
||||
|
||||
// Verify missing fields were added with defaults
|
||||
assert.Equal(t, "light", k.String("app.theme"))
|
||||
assert.True(t, k.Bool("user.settings.newSetting"))
|
||||
assert.Equal(t, "value", k.String("user.settings.newSetting2"))
|
||||
assert.True(t, k.Bool("newSection.enabled"))
|
||||
assert.Equal(t, "new section", k.String("newSection.value"))
|
||||
}
|
||||
|
||||
// TestConfigMigrator_ArrayMerge tests array and slice handling
|
||||
func TestConfigMigrator_ArrayMerge(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "config_migrator_array_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Config with arrays
|
||||
type ArrayConfig struct {
|
||||
Plugins struct {
|
||||
Enabled []string `json:"enabled"`
|
||||
Config struct {
|
||||
LoadOrder []string `json:"loadOrder"`
|
||||
Settings map[string]string `json:"settings"`
|
||||
} `json:"config"`
|
||||
} `json:"plugins"`
|
||||
Database struct {
|
||||
Hosts []string `json:"hosts"`
|
||||
Ports []int `json:"ports"`
|
||||
} `json:"database"`
|
||||
}
|
||||
|
||||
// User config with some arrays
|
||||
userConfig := map[string]interface{}{
|
||||
"plugins": map[string]interface{}{
|
||||
"enabled": []string{"plugin1", "plugin2"}, // User's plugin list
|
||||
// Missing: config section
|
||||
},
|
||||
// Missing: database section
|
||||
}
|
||||
|
||||
configPath := filepath.Join(tempDir, "config.json")
|
||||
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
|
||||
os.WriteFile(configPath, jsonData, 0644)
|
||||
|
||||
logger := log.New()
|
||||
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||
k := koanf.New(".")
|
||||
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||
|
||||
// Create default config with arrays
|
||||
defaultConfig := ArrayConfig{}
|
||||
defaultConfig.Plugins.Enabled = []string{"defaultPlugin1", "defaultPlugin2"}
|
||||
defaultConfig.Plugins.Config.LoadOrder = []string{"plugin1", "plugin2"}
|
||||
defaultConfig.Plugins.Config.Settings = map[string]string{"timeout": "30"}
|
||||
defaultConfig.Database.Hosts = []string{"localhost", "backup.host"}
|
||||
defaultConfig.Database.Ports = []int{5432, 5433}
|
||||
|
||||
// Run migration
|
||||
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, result.Migrated)
|
||||
|
||||
// User's array should be preserved
|
||||
assert.Equal(t, []string{"plugin1", "plugin2"}, k.Strings("plugins.enabled"))
|
||||
|
||||
// Missing arrays should be added from defaults
|
||||
assert.Equal(t, []string{"plugin1", "plugin2"}, k.Strings("plugins.config.loadOrder"))
|
||||
assert.Equal(t, []string{"localhost", "backup.host"}, k.Strings("database.hosts"))
|
||||
assert.Equal(t, []int{5432, 5433}, k.Ints("database.ports"))
|
||||
|
||||
expectedSettings := map[string]string{"timeout": "30"}
|
||||
assert.Equal(t, expectedSettings, k.StringMap("plugins.config.settings"))
|
||||
}
|
||||
|
||||
// TestConfigMigrator_DeepNesting tests very deep nested structures
|
||||
func TestConfigMigrator_DeepNesting(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "config_migrator_deep_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Deep nested config
|
||||
type DeepConfig struct {
|
||||
Level1 struct {
|
||||
Level2 struct {
|
||||
Level3 struct {
|
||||
Level4 struct {
|
||||
Level5 struct {
|
||||
Value string `json:"value"`
|
||||
Count int `json:"count"`
|
||||
} `json:"level5"`
|
||||
} `json:"level4"`
|
||||
} `json:"level3"`
|
||||
} `json:"level2"`
|
||||
} `json:"level1"`
|
||||
}
|
||||
|
||||
// User config with partial deep nesting
|
||||
userConfig := map[string]interface{}{
|
||||
"level1": map[string]interface{}{
|
||||
"level2": map[string]interface{}{
|
||||
"level3": map[string]interface{}{
|
||||
// Missing level4 completely
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
configPath := filepath.Join(tempDir, "config.json")
|
||||
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
|
||||
os.WriteFile(configPath, jsonData, 0644)
|
||||
|
||||
logger := log.New()
|
||||
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||
k := koanf.New(".")
|
||||
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||
|
||||
// Create default config with complete deep nesting
|
||||
defaultConfig := DeepConfig{}
|
||||
defaultConfig.Level1.Level2.Level3.Level4.Level5.Value = "deep_value"
|
||||
defaultConfig.Level1.Level2.Level3.Level4.Level5.Count = 42
|
||||
|
||||
// Run migration
|
||||
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, result.Migrated)
|
||||
|
||||
// Verify deep nested values were added
|
||||
assert.Equal(t, "deep_value", k.String("level1.level2.level3.level4.level5.value"))
|
||||
assert.Equal(t, 42, k.Int("level1.level2.level3.level4.level5.count"))
|
||||
|
||||
t.Logf("Missing fields: %v", result.MissingFields)
|
||||
assert.Equal(t, 2, len(result.MissingFields), "Should detect 2 missing deep nested fields")
|
||||
}
|
||||
|
||||
// TestConfigMigrator_EdgeCases tests various edge cases
|
||||
func TestConfigMigrator_EdgeCases(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "config_migrator_edge_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Edge case config with various data types
|
||||
userConfig := map[string]interface{}{
|
||||
"string_empty": "",
|
||||
"string_spaces": " ",
|
||||
"number_zero": 0,
|
||||
"number_float": 3.14,
|
||||
"bool_false": false,
|
||||
"array_empty": []interface{}{},
|
||||
"map_empty": map[string]interface{}{},
|
||||
"null_value": nil,
|
||||
}
|
||||
|
||||
configPath := filepath.Join(tempDir, "config.json")
|
||||
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
|
||||
os.WriteFile(configPath, jsonData, 0644)
|
||||
|
||||
logger := log.New()
|
||||
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||
k := koanf.New(".")
|
||||
k.Load(file.Provider(configPath), jsonparser.Parser())
|
||||
|
||||
// Default config with different values
|
||||
type EdgeConfig struct {
|
||||
StringEmpty string `json:"string_empty"`
|
||||
StringSpaces string `json:"string_spaces"`
|
||||
NumberZero int `json:"number_zero"`
|
||||
NumberFloat float64 `json:"number_float"`
|
||||
BoolFalse bool `json:"bool_false"`
|
||||
ArrayEmpty []string `json:"array_empty"`
|
||||
MapEmpty map[string]interface{} `json:"map_empty"`
|
||||
NullValue *string `json:"null_value"`
|
||||
NewField string `json:"new_field"` // This should be added
|
||||
}
|
||||
|
||||
defaultConfig := EdgeConfig{}
|
||||
defaultConfig.StringEmpty = "default_string"
|
||||
defaultConfig.StringSpaces = "default_spaces"
|
||||
defaultConfig.NumberZero = 42
|
||||
defaultConfig.NumberFloat = 2.71
|
||||
defaultConfig.BoolFalse = true
|
||||
defaultConfig.ArrayEmpty = []string{"default"}
|
||||
defaultConfig.MapEmpty = map[string]interface{}{"key": "value"}
|
||||
defaultValue := "default_null"
|
||||
defaultConfig.NullValue = &defaultValue
|
||||
defaultConfig.NewField = "new_field_value"
|
||||
|
||||
// Run migration
|
||||
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// All user edge case values should be preserved (they exist, even if empty/zero/false)
|
||||
assert.Equal(t, "", k.String("string_empty"))
|
||||
assert.Equal(t, " ", k.String("string_spaces"))
|
||||
assert.Equal(t, 0, k.Int("number_zero"))
|
||||
assert.Equal(t, 3.14, k.Float64("number_float"))
|
||||
assert.False(t, k.Bool("bool_false"))
|
||||
assert.Equal(t, []string{}, k.Strings("array_empty"))
|
||||
|
||||
// Only truly missing field should be added
|
||||
assert.Equal(t, "new_field_value", k.String("new_field"))
|
||||
|
||||
// Should detect 2 missing fields: new_field and map_empty.key
|
||||
// The user has an empty map, but default config has a key inside that map
|
||||
assert.Equal(t, 2, len(result.MissingFields), "Should detect 2 missing fields: new_field and map_empty.key")
|
||||
assert.Contains(t, result.MissingFields, "new_field")
|
||||
assert.Contains(t, result.MissingFields, "map_empty.key")
|
||||
|
||||
// Verify that the key was added to the empty map
|
||||
assert.Equal(t, "value", k.String("map_empty.key"))
|
||||
}
|
||||
|
||||
// TestConfigMigrator_ErrorHandling tests error conditions
|
||||
func TestConfigMigrator_ErrorHandling(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "config_migrator_error_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
logger := log.New()
|
||||
configPath := filepath.Join(tempDir, "nonexistent.json")
|
||||
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||
|
||||
// Test with empty koanf (no config file loaded)
|
||||
k := koanf.New(".")
|
||||
defaultConfig := TestConfig{}
|
||||
defaultConfig.App.Name = "TestApp"
|
||||
|
||||
// This should still work (creates config from scratch)
|
||||
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, result.Migrated)
|
||||
assert.Greater(t, len(result.MissingFields), 0)
|
||||
|
||||
// Test with corrupted config file
|
||||
corruptedPath := filepath.Join(tempDir, "corrupted.json")
|
||||
os.WriteFile(corruptedPath, []byte("{invalid json"), 0644)
|
||||
|
||||
k2 := koanf.New(".")
|
||||
// This should fail gracefully when trying to load the corrupted file
|
||||
err = k2.Load(file.Provider(corruptedPath), jsonparser.Parser())
|
||||
assert.Error(t, err, "Should fail to load corrupted JSON")
|
||||
}
|
||||
|
||||
// TestConfigMigrator_ConcurrentAccess tests concurrent migration access
|
||||
func TestConfigMigrator_ConcurrentAccess(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "config_migrator_concurrent_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
configPath := createTestConfig(t, tempDir)
|
||||
logger := log.New()
|
||||
|
||||
// Create multiple migrators
|
||||
numWorkers := 5
|
||||
results := make(chan *MigrationResult, numWorkers)
|
||||
errors := make(chan error, numWorkers)
|
||||
|
||||
defaultConfig := TestConfig{}
|
||||
defaultConfig.App.Name = "TestApp"
|
||||
defaultConfig.App.Version = "1.0.0"
|
||||
defaultConfig.App.Theme = "dark"
|
||||
defaultConfig.User.Name = "Test User"
|
||||
defaultConfig.User.Email = "test@example.com"
|
||||
defaultConfig.User.Settings.AutoSave = true
|
||||
defaultConfig.User.Settings.Language = "en"
|
||||
defaultConfig.User.Settings.NewSetting = true
|
||||
defaultConfig.User.Settings.NewSetting2 = "value"
|
||||
defaultConfig.NewSection.Enabled = true
|
||||
defaultConfig.NewSection.Value = "new section"
|
||||
|
||||
// Run concurrent migrations
|
||||
for i := 0; i < numWorkers; i++ {
|
||||
go func(workerID int) {
|
||||
// Each worker gets its own config path to avoid file conflicts
|
||||
workerConfigPath := filepath.Join(tempDir, fmt.Sprintf("config_%d.json", workerID))
|
||||
|
||||
// Copy the original config for this worker
|
||||
originalData, _ := os.ReadFile(configPath)
|
||||
os.WriteFile(workerConfigPath, originalData, 0644)
|
||||
|
||||
migrator := NewConfigMigrator(logger, tempDir, fmt.Sprintf("config_%d", workerID), workerConfigPath)
|
||||
k := koanf.New(".")
|
||||
k.Load(file.Provider(workerConfigPath), jsonparser.Parser())
|
||||
|
||||
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||
if err != nil {
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
results <- result
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Collect results
|
||||
for i := 0; i < numWorkers; i++ {
|
||||
select {
|
||||
case result := <-results:
|
||||
assert.True(t, result.Migrated, "Each worker should successfully migrate")
|
||||
assert.Equal(t, 4, len(result.MissingFields), "Each worker should detect same missing fields")
|
||||
case err := <-errors:
|
||||
t.Errorf("Worker failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,9 +109,6 @@ func (cs *ConfigService) initConfig() error {
|
||||
return cs.createDefaultConfig()
|
||||
}
|
||||
|
||||
// 检查并自动迁移配置
|
||||
cs.checkConfigMigration()
|
||||
|
||||
// 配置文件存在,直接加载现有配置
|
||||
cs.fileProvider = file.Provider(cs.settingsPath)
|
||||
if err := cs.koanf.Load(cs.fileProvider, jsonparser.Parser()); err != nil {
|
||||
@@ -121,8 +118,8 @@ func (cs *ConfigService) initConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkConfigMigration 检查配置迁移
|
||||
func (cs *ConfigService) checkConfigMigration() error {
|
||||
// MigrateConfig 执行配置迁移
|
||||
func (cs *ConfigService) MigrateConfig() error {
|
||||
if cs.configMigrator == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ package services
|
||||
#cgo CFLAGS: -I../lib
|
||||
#cgo LDFLAGS: -luser32
|
||||
#include "../lib/hotkey_windows.c"
|
||||
#include "hotkey_windows.h"
|
||||
#include "../lib/hotkey_windows.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/creativeprojects/go-selfupdate"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/badge"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/log"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
@@ -28,6 +30,8 @@ type SelfUpdateResult struct {
|
||||
type SelfUpdateService struct {
|
||||
logger *log.LogService
|
||||
configService *ConfigService
|
||||
badgeService *badge.BadgeService // 直接使用Wails原生badge服务
|
||||
notificationService *notifications.NotificationService // 通知服务
|
||||
config *models.AppConfig
|
||||
|
||||
// 状态管理
|
||||
@@ -35,7 +39,7 @@ type SelfUpdateService struct {
|
||||
}
|
||||
|
||||
// NewSelfUpdateService 创建自我更新服务实例
|
||||
func NewSelfUpdateService(configService *ConfigService, logger *log.LogService) (*SelfUpdateService, error) {
|
||||
func NewSelfUpdateService(configService *ConfigService, badgeService *badge.BadgeService, notificationService *notifications.NotificationService, logger *log.LogService) (*SelfUpdateService, error) {
|
||||
// 获取配置
|
||||
appConfig, err := configService.GetConfig()
|
||||
if err != nil {
|
||||
@@ -45,6 +49,8 @@ func NewSelfUpdateService(configService *ConfigService, logger *log.LogService)
|
||||
service := &SelfUpdateService{
|
||||
logger: logger,
|
||||
configService: configService,
|
||||
badgeService: badgeService,
|
||||
notificationService: notificationService,
|
||||
config: appConfig,
|
||||
isUpdating: false,
|
||||
}
|
||||
@@ -63,6 +69,7 @@ func (s *SelfUpdateService) CheckForUpdates(ctx context.Context) (*SelfUpdateRes
|
||||
// 首先尝试主要更新源
|
||||
primaryResult, err := s.checkSourceForUpdates(ctx, s.config.Updates.PrimarySource)
|
||||
if err == nil && primaryResult != nil {
|
||||
s.handleUpdateBadge(primaryResult)
|
||||
return primaryResult, nil
|
||||
}
|
||||
|
||||
@@ -71,9 +78,12 @@ func (s *SelfUpdateService) CheckForUpdates(ctx context.Context) (*SelfUpdateRes
|
||||
if backupErr != nil {
|
||||
// 如果备用源也失败,返回主要源的错误信息
|
||||
result.Error = fmt.Sprintf("Primary source error: %v; Backup source error: %v", err, backupErr)
|
||||
// 确保在检查失败时也调用handleUpdateBadge来清除可能存在的badge
|
||||
s.handleUpdateBadge(result)
|
||||
return result, errors.New(result.Error)
|
||||
}
|
||||
|
||||
s.handleUpdateBadge(backupResult)
|
||||
return backupResult, nil
|
||||
}
|
||||
|
||||
@@ -251,7 +261,6 @@ func (s *SelfUpdateService) ApplyUpdate(ctx context.Context) (*SelfUpdateResult,
|
||||
|
||||
// 检查是否有可用更新
|
||||
if !primaryRelease.GreaterThan(s.config.Updates.Version) {
|
||||
s.logger.Info("Current version is up to date, no need to apply update")
|
||||
result.LatestVersion = primaryRelease.Version()
|
||||
return result, nil
|
||||
}
|
||||
@@ -310,6 +319,18 @@ func (s *SelfUpdateService) ApplyUpdate(ctx context.Context) (*SelfUpdateResult,
|
||||
s.logger.Error("Failed to update config version", "error", err)
|
||||
}
|
||||
|
||||
// 执行配置迁移
|
||||
if err := s.configService.MigrateConfig(); err != nil {
|
||||
s.logger.Error("Failed to migrate config after update", "error", err)
|
||||
}
|
||||
|
||||
// 更新成功,移除badge
|
||||
if s.badgeService != nil {
|
||||
if err := s.badgeService.RemoveBadge(); err != nil {
|
||||
s.logger.Error("failed to remove update badge after successful update", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -365,7 +386,6 @@ func (s *SelfUpdateService) updateFromSource(ctx context.Context, sourceType mod
|
||||
}
|
||||
|
||||
// 尝试下载并应用更新,不设置超时
|
||||
s.logger.Info("Downloading update...", "source", sourceType)
|
||||
err = updater.UpdateTo(ctx, release, exe)
|
||||
|
||||
if err != nil {
|
||||
@@ -392,6 +412,13 @@ func (s *SelfUpdateService) updateFromSource(ctx context.Context, sourceType mod
|
||||
s.logger.Error("Failed to update config version", "error", err)
|
||||
}
|
||||
|
||||
// 更新成功,移除badge
|
||||
if s.badgeService != nil {
|
||||
if err := s.badgeService.RemoveBadge(); err != nil {
|
||||
s.logger.Error("failed to remove update badge after successful update", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -462,3 +489,63 @@ func (s *SelfUpdateService) cleanupBackup(backupPath string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleUpdateBadge 处理更新通知badge和通知
|
||||
func (s *SelfUpdateService) handleUpdateBadge(result *SelfUpdateResult) {
|
||||
if result != nil && result.HasUpdate {
|
||||
// 有更新时显示更新badge
|
||||
if s.badgeService != nil {
|
||||
if err := s.badgeService.SetBadge("●"); err != nil {
|
||||
s.logger.Error("failed to set update badge", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送简单通知
|
||||
s.sendUpdateNotification(result)
|
||||
} else {
|
||||
// 没有更新或出错时移除badge
|
||||
if s.badgeService != nil {
|
||||
if err := s.badgeService.RemoveBadge(); err != nil {
|
||||
s.logger.Error("failed to remove update badge", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendUpdateNotification 发送更新通知
|
||||
func (s *SelfUpdateService) sendUpdateNotification(result *SelfUpdateResult) {
|
||||
if s.notificationService == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查通知授权(macOS需要)
|
||||
authorized, err := s.notificationService.CheckNotificationAuthorization()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to check notification authorization", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !authorized {
|
||||
authorized, err = s.notificationService.RequestNotificationAuthorization()
|
||||
if err != nil || !authorized {
|
||||
s.logger.Error("Failed to get notification authorization", "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 构建简单通知内容
|
||||
title := "Voidraft Update Available"
|
||||
body := fmt.Sprintf("New version %s available (current: %s)", result.LatestVersion, result.CurrentVersion)
|
||||
|
||||
// 发送简单通知
|
||||
err = s.notificationService.SendNotification(notifications.NotificationOptions{
|
||||
ID: "update_available",
|
||||
Title: title,
|
||||
Subtitle: "New version available",
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to send notification", "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"voidraft/internal/models"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/badge"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/log"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
)
|
||||
|
||||
// ServiceManager 服务管理器,负责协调各个服务
|
||||
@@ -25,6 +27,9 @@ type ServiceManager struct {
|
||||
selfUpdateService *SelfUpdateService
|
||||
translationService *TranslationService
|
||||
themeService *ThemeService
|
||||
badgeService *badge.BadgeService
|
||||
notificationService *notifications.NotificationService
|
||||
testService *TestService // 测试服务(仅开发环境)
|
||||
BackupService *BackupService
|
||||
logger *log.LogService
|
||||
}
|
||||
@@ -34,6 +39,12 @@ func NewServiceManager() *ServiceManager {
|
||||
// 初始化日志服务
|
||||
logger := log.New()
|
||||
|
||||
// 初始化badge服务
|
||||
badgeService := badge.New()
|
||||
|
||||
// 初始化通知服务
|
||||
notificationService := notifications.New()
|
||||
|
||||
// 初始化配置服务
|
||||
configService := NewConfigService(logger)
|
||||
|
||||
@@ -77,7 +88,7 @@ func NewServiceManager() *ServiceManager {
|
||||
startupService := NewStartupService(configService, logger)
|
||||
|
||||
// 初始化自我更新服务
|
||||
selfUpdateService, err := NewSelfUpdateService(configService, logger)
|
||||
selfUpdateService, err := NewSelfUpdateService(configService, badgeService, notificationService, logger)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -91,6 +102,9 @@ func NewServiceManager() *ServiceManager {
|
||||
// 初始化备份服务
|
||||
backupService := NewBackupService(configService, databaseService, logger)
|
||||
|
||||
// 初始化测试服务(开发环境使用)
|
||||
testService := NewTestService(badgeService, notificationService, logger)
|
||||
|
||||
// 使用新的配置通知系统设置热键配置变更监听
|
||||
err = configService.SetHotkeyChangeCallback(func(enable bool, hotkey *models.HotkeyCombo) error {
|
||||
return hotkeyService.UpdateHotkey(enable, hotkey)
|
||||
@@ -140,6 +154,9 @@ func NewServiceManager() *ServiceManager {
|
||||
selfUpdateService: selfUpdateService,
|
||||
translationService: translationService,
|
||||
themeService: themeService,
|
||||
badgeService: badgeService,
|
||||
notificationService: notificationService,
|
||||
testService: testService,
|
||||
BackupService: backupService,
|
||||
logger: logger,
|
||||
}
|
||||
@@ -164,6 +181,9 @@ func (sm *ServiceManager) GetServices() []application.Service {
|
||||
application.NewService(sm.selfUpdateService),
|
||||
application.NewService(sm.translationService),
|
||||
application.NewService(sm.themeService),
|
||||
application.NewService(sm.badgeService),
|
||||
application.NewService(sm.notificationService),
|
||||
application.NewService(sm.testService), // 注册测试服务
|
||||
application.NewService(sm.BackupService),
|
||||
}
|
||||
return services
|
||||
@@ -243,3 +263,13 @@ func (sm *ServiceManager) GetThemeService() *ThemeService {
|
||||
func (sm *ServiceManager) GetWindowSnapService() *WindowSnapService {
|
||||
return sm.windowSnapService
|
||||
}
|
||||
|
||||
// GetBadgeService 获取badge服务实例
|
||||
func (sm *ServiceManager) GetBadgeService() *badge.BadgeService {
|
||||
return sm.badgeService
|
||||
}
|
||||
|
||||
// GetNotificationService 获取通知服务实例
|
||||
func (sm *ServiceManager) GetNotificationService() *notifications.NotificationService {
|
||||
return sm.notificationService
|
||||
}
|
||||
|
||||
139
internal/services/test_service.go
Normal file
139
internal/services/test_service.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/badge"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/log"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
)
|
||||
|
||||
// TestService 测试服务 - 仅在开发环境使用
|
||||
type TestService struct {
|
||||
logger *log.LogService
|
||||
badgeService *badge.BadgeService
|
||||
notificationService *notifications.NotificationService
|
||||
}
|
||||
|
||||
// NewTestService 创建测试服务实例
|
||||
func NewTestService(badgeService *badge.BadgeService, notificationService *notifications.NotificationService, logger *log.LogService) *TestService {
|
||||
if logger == nil {
|
||||
logger = log.New()
|
||||
}
|
||||
|
||||
return &TestService{
|
||||
logger: logger,
|
||||
badgeService: badgeService,
|
||||
notificationService: notificationService,
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceStartup 服务启动时调用
|
||||
func (ts *TestService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestBadge 测试Badge功能
|
||||
func (ts *TestService) TestBadge(text string) error {
|
||||
if ts.badgeService == nil {
|
||||
return fmt.Errorf("badge service not available")
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
// 如果文本为空,则移除badge
|
||||
err := ts.badgeService.RemoveBadge()
|
||||
if err != nil {
|
||||
ts.logger.Error("Failed to remove badge", "error", err)
|
||||
return err
|
||||
}
|
||||
ts.logger.Info("Badge removed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 设置badge
|
||||
err := ts.badgeService.SetBadge(text)
|
||||
if err != nil {
|
||||
ts.logger.Error("Failed to set badge", "text", text, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
ts.logger.Info("Badge set successfully", "text", text)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestNotification 测试通知功能
|
||||
func (ts *TestService) TestNotification(title, subtitle, body string) error {
|
||||
if ts.notificationService == nil {
|
||||
return fmt.Errorf("notification service not available")
|
||||
}
|
||||
|
||||
// 检查通知授权(macOS需要)
|
||||
authorized, err := ts.notificationService.CheckNotificationAuthorization()
|
||||
if err != nil {
|
||||
ts.logger.Error("Failed to check notification authorization", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if !authorized {
|
||||
authorized, err = ts.notificationService.RequestNotificationAuthorization()
|
||||
if err != nil || !authorized {
|
||||
ts.logger.Error("Failed to get notification authorization", "error", err)
|
||||
return fmt.Errorf("notification authorization denied")
|
||||
}
|
||||
}
|
||||
|
||||
// 使用默认值如果参数为空
|
||||
if title == "" {
|
||||
title = "Test Notification"
|
||||
}
|
||||
if subtitle == "" {
|
||||
subtitle = "Testing notification system"
|
||||
}
|
||||
if body == "" {
|
||||
body = "This is a test notification to verify the system is working correctly."
|
||||
}
|
||||
|
||||
// 发送测试通知
|
||||
err = ts.notificationService.SendNotification(notifications.NotificationOptions{
|
||||
ID: "test_notification",
|
||||
Title: title,
|
||||
Subtitle: subtitle,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
ts.logger.Error("Failed to send test notification", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
ts.logger.Info("Test notification sent successfully", "title", title)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestUpdateNotification 测试更新通知
|
||||
func (ts *TestService) TestUpdateNotification() error {
|
||||
// 设置badge
|
||||
if err := ts.TestBadge("●"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 发送更新通知
|
||||
return ts.TestNotification(
|
||||
"Voidraft Update Available",
|
||||
"New version available",
|
||||
"New version 1.2.3 available (current: 1.2.0)",
|
||||
)
|
||||
}
|
||||
|
||||
// ClearAll 清除所有测试状态
|
||||
func (ts *TestService) ClearAll() error {
|
||||
// 移除badge
|
||||
if err := ts.TestBadge(""); err != nil {
|
||||
ts.logger.Error("Failed to clear badge during cleanup", "error", err)
|
||||
}
|
||||
|
||||
ts.logger.Info("Test states cleared successfully")
|
||||
return nil
|
||||
}
|
||||
@@ -31,6 +31,19 @@ if errorlevel 1 (
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Sync remote tags
|
||||
echo [INFO] Syncing remote tags...
|
||||
echo [INFO] Deleting local tags...
|
||||
for /f "delims=" %%i in ('git tag -l') do git tag -d %%i >nul 2>&1
|
||||
|
||||
echo [INFO] Fetching remote tags...
|
||||
git fetch origin --prune >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [WARNING] Failed to fetch from remote, using local tags
|
||||
) else (
|
||||
echo [INFO] Remote tags synced successfully
|
||||
)
|
||||
|
||||
REM Get latest git tag
|
||||
git describe --abbrev=0 --tags > temp_tag.txt 2>nul
|
||||
if errorlevel 1 (
|
||||
|
||||
@@ -19,6 +19,18 @@ else
|
||||
echo "[ERROR] Not in a git repository"
|
||||
exit 1
|
||||
else
|
||||
# 同步远程标签
|
||||
echo "[INFO] Syncing remote tags..."
|
||||
echo "[INFO] Deleting local tags..."
|
||||
git tag -l | xargs git tag -d &> /dev/null
|
||||
|
||||
echo "[INFO] Fetching remote tags..."
|
||||
if git fetch origin --prune &> /dev/null; then
|
||||
echo "[INFO] Remote tags synced successfully"
|
||||
else
|
||||
echo "[WARNING] Failed to fetch from remote, using local tags"
|
||||
fi
|
||||
|
||||
# 获取最新的git标签
|
||||
LATEST_TAG=$(git describe --abbrev=0 --tags 2>/dev/null)
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
VERSION=1.3.5
|
||||
VERSION=1.3.6
|
||||
|
||||
Reference in New Issue
Block a user