Improved settings

This commit is contained in:
2025-06-07 21:12:07 +08:00
parent 31addd5a20
commit 61f293ce6f
18 changed files with 1480 additions and 1098 deletions

View File

@@ -5,42 +5,6 @@
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import {Create as $Create} from "@wailsio/runtime"; import {Create as $Create} from "@wailsio/runtime";
/**
* A Duration represents the elapsed time between two instants
* as an int64 nanosecond count. The representation limits the
* largest representable duration to approximately 290 years.
*/
export enum Duration {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = 0,
minDuration = -9223372036854775808,
maxDuration = 9223372036854775807,
/**
* Common durations. There is no definition for units of Day or larger
* to avoid confusion across daylight savings time zone transitions.
*
* To count the number of units in a [Duration], divide:
*
* second := time.Second
* fmt.Print(int64(second/time.Millisecond)) // prints 1000
*
* To convert an integer number of units to a Duration, multiply:
*
* seconds := 10
* fmt.Print(time.Duration(seconds)*time.Second) // prints 10s
*/
Nanosecond = 1,
Microsecond = 1000,
Millisecond = 1000000,
Second = 1000000000,
Minute = 60000000000,
Hour = 3600000000000,
};
/** /**
* A Time represents an instant in time with nanosecond precision. * A Time represents an instant in time with nanosecond precision.
* *

View File

@@ -35,7 +35,7 @@ export function GetConfig(): Promise<models$0.AppConfig | null> & { cancel(): vo
} }
/** /**
* ResetConfig 置为默认配置 * ResetConfig 强制重置所有配置为默认
*/ */
export function ResetConfig(): Promise<void> & { cancel(): void } { export function ResetConfig(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3593047389) as any; let $resultPromise = $Call.ByID(3593047389) as any;

View File

@@ -51,10 +51,10 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
} }
/** /**
* SetProgressCallback 设置进度回调 * SetProgressBroadcaster 设置进度广播函数
*/ */
export function SetProgressCallback(callback: $models.MigrationProgressCallback): Promise<void> & { cancel(): void } { export function SetProgressBroadcaster(broadcaster: any): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(75752256, callback) as any; let $resultPromise = $Call.ByID(3244071921, broadcaster) as any;
return $resultPromise; return $resultPromise;
} }

View File

@@ -5,10 +5,6 @@
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import {Create as $Create} from "@wailsio/runtime"; import {Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as time$0 from "../../../time/models.js";
/** /**
* MemoryStats 内存统计信息 * MemoryStats 内存统计信息
*/ */
@@ -85,88 +81,24 @@ export class MigrationProgress {
*/ */
"status": MigrationStatus; "status": MigrationStatus;
/**
* 当前处理的文件
*/
"currentFile": string;
/**
* 已处理文件数
*/
"processedFiles": number;
/**
* 总文件数
*/
"totalFiles": number;
/**
* 已处理字节数
*/
"processedBytes": number;
/**
* 总字节数
*/
"totalBytes": number;
/** /**
* 进度百分比 (0-100) * 进度百分比 (0-100)
*/ */
"progress": number; "progress": number;
/**
* 状态消息
*/
"message": string;
/** /**
* 错误信息 * 错误信息
*/ */
"error"?: string; "error"?: string;
/**
* 开始时间
*/
"startTime": time$0.Time;
/**
* 估计剩余时间
*/
"estimatedTime": time$0.Duration;
/** Creates a new MigrationProgress instance. */ /** Creates a new MigrationProgress instance. */
constructor($$source: Partial<MigrationProgress> = {}) { constructor($$source: Partial<MigrationProgress> = {}) {
if (!("status" in $$source)) { if (!("status" in $$source)) {
this["status"] = ("" as MigrationStatus); this["status"] = ("" as MigrationStatus);
} }
if (!("currentFile" in $$source)) {
this["currentFile"] = "";
}
if (!("processedFiles" in $$source)) {
this["processedFiles"] = 0;
}
if (!("totalFiles" in $$source)) {
this["totalFiles"] = 0;
}
if (!("processedBytes" in $$source)) {
this["processedBytes"] = 0;
}
if (!("totalBytes" in $$source)) {
this["totalBytes"] = 0;
}
if (!("progress" in $$source)) { if (!("progress" in $$source)) {
this["progress"] = 0; this["progress"] = 0;
} }
if (!("message" in $$source)) {
this["message"] = "";
}
if (!("startTime" in $$source)) {
this["startTime"] = null;
}
if (!("estimatedTime" in $$source)) {
this["estimatedTime"] = (0 as time$0.Duration);
}
Object.assign(this, $$source); Object.assign(this, $$source);
} }
@@ -180,11 +112,6 @@ export class MigrationProgress {
} }
} }
/**
* MigrationProgressCallback 进度回调函数类型
*/
export type MigrationProgressCallback = any;
/** /**
* MigrationStatus 迁移状态 * MigrationStatus 迁移状态
*/ */
@@ -194,16 +121,6 @@ export enum MigrationStatus {
*/ */
$zero = "", $zero = "",
/**
* 空闲状态
*/
MigrationStatusIdle = "idle",
/**
* 准备中
*/
MigrationStatusPreparing = "preparing",
/** /**
* 迁移中 * 迁移中
*/ */
@@ -218,9 +135,4 @@ export enum MigrationStatus {
* 失败 * 失败
*/ */
MigrationStatusFailed = "failed", MigrationStatusFailed = "failed",
/**
* 取消
*/
MigrationStatusCancelled = "cancelled",
}; };

View File

@@ -9,7 +9,6 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
MemoryMonitor: typeof import('./src/components/monitor/MemoryMonitor.vue')['default'] MemoryMonitor: typeof import('./src/components/monitor/MemoryMonitor.vue')['default']
MigrationProgress: typeof import('./src/components/migration/MigrationProgress.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
Toolbar: typeof import('./src/components/toolbar/Toolbar.vue')['default'] Toolbar: typeof import('./src/components/toolbar/Toolbar.vue')['default']

View File

@@ -1,540 +0,0 @@
<template>
<div v-if="visible" class="migration-overlay">
<div class="migration-modal">
<div class="migration-header">
<h3>{{ t('migration.title') }}</h3>
<div class="migration-status" :class="status">
{{ getStatusText() }}
</div>
</div>
<div class="migration-content">
<div class="progress-container">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${progress.progress || 0}%` }"
:class="status"
></div>
</div>
<div class="progress-text">
{{ Math.round(progress.progress || 0) }}%
</div>
</div>
<div class="migration-details">
<div v-if="progress.message" class="message">
{{ progress.message }}
</div>
<div v-if="progress.currentFile" class="current-file">
<span class="label">{{ t('migration.currentFile') }}:</span>
<span class="file-name">{{ progress.currentFile }}</span>
</div>
<div class="stats" v-if="progress.totalFiles > 0">
<div class="stat-item">
<span class="label">{{ t('migration.files') }}:</span>
<span class="value">{{ progress.processedFiles }} / {{ progress.totalFiles }}</span>
</div>
<div class="stat-item" v-if="progress.totalBytes > 0">
<span class="label">{{ t('migration.size') }}:</span>
<span class="value">{{ formatBytes(progress.processedBytes) }} / {{ formatBytes(progress.totalBytes) }}</span>
</div>
<div class="stat-item" v-if="progress.estimatedTime && status === 'migrating'">
<span class="label">{{ t('migration.timeRemaining') }}:</span>
<span class="value">{{ formatDuration(progress.estimatedTime) }}</span>
</div>
</div>
<div v-if="progress.error" class="error-message">
<span class="error-icon"></span>
{{ progress.error }}
</div>
</div>
</div>
<div class="migration-actions">
<button
v-if="status === 'completed'"
@click="handleComplete"
class="action-button success"
>
{{ t('migration.complete') }}
</button>
<button
v-if="status === 'failed'"
@click="handleRetry"
class="action-button retry"
>
{{ t('migration.retry') }}
</button>
<button
v-if="status === 'failed'"
@click="handleClose"
class="action-button secondary"
>
{{ t('migration.close') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { MigrationService } from '../../../bindings/voidraft/internal/services';
interface MigrationProgress {
status: string;
currentFile: string;
processedFiles: number;
totalFiles: number;
processedBytes: number;
totalBytes: number;
progress: number;
message: string;
error?: string;
startTime: string;
estimatedTime: number;
}
interface Props {
visible: boolean;
}
interface Emits {
(e: 'complete'): void;
(e: 'close'): void;
(e: 'retry'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const { t } = useI18n();
// 迁移进度状态
const progress = ref<MigrationProgress>({
status: 'idle',
currentFile: '',
processedFiles: 0,
totalFiles: 0,
processedBytes: 0,
totalBytes: 0,
progress: 0,
message: '',
startTime: '',
estimatedTime: 0
});
const status = computed(() => progress.value.status);
// 定时器用于轮询进度
let progressTimer: number | null = null;
// 轮询迁移进度
const pollProgress = async () => {
try {
const currentProgress = await MigrationService.GetProgress();
progress.value = currentProgress;
// 如果迁移完成或失败,停止轮询
if (currentProgress.status === 'completed' || currentProgress.status === 'failed' || currentProgress.status === 'cancelled') {
stopPolling();
}
} catch (error) {
console.error('Failed to get migration progress:', error);
}
};
// 开始轮询
const startPolling = () => {
if (progressTimer) return;
// 立即获取一次进度
pollProgress();
// 每500ms轮询一次
progressTimer = window.setInterval(pollProgress, 500);
};
// 停止轮询
const stopPolling = () => {
if (progressTimer) {
clearInterval(progressTimer);
progressTimer = null;
}
};
// 监听可见性变化
watch(() => props.visible, (visible) => {
if (visible) {
startPolling();
} else {
stopPolling();
}
});
// 组件挂载时开始轮询(如果可见)
onMounted(() => {
if (props.visible) {
startPolling();
}
});
// 组件卸载时停止轮询
onUnmounted(() => {
stopPolling();
});
// 获取状态文本
const getStatusText = () => {
switch (status.value) {
case 'preparing':
return t('migration.preparing');
case 'migrating':
return t('migration.migrating');
case 'completed':
return t('migration.completed');
case 'failed':
return t('migration.failed');
case 'cancelled':
return t('migration.cancelled');
default:
return t('migration.idle');
}
};
// 格式化字节数
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
};
// 格式化持续时间
const formatDuration = (nanoseconds: number): string => {
const seconds = Math.floor(nanoseconds / 1000000000);
if (seconds < 60) {
return `${seconds}`;
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
return `${minutes}分钟`;
} else {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}小时${minutes}分钟`;
}
};
// 处理完成
const handleComplete = () => {
emit('complete');
};
// 处理重试
const handleRetry = () => {
emit('retry');
};
// 处理关闭
const handleClose = () => {
emit('close');
};
</script>
<style scoped lang="scss">
.migration-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(4px);
}
.migration-modal {
background-color: #2a2a2a;
border-radius: 8px;
border: 1px solid #444444;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow: hidden;
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.migration-header {
padding: 20px 24px 16px;
border-bottom: 1px solid #444444;
h3 {
margin: 0 0 8px 0;
color: #e0e0e0;
font-size: 18px;
font-weight: 600;
}
.migration-status {
font-size: 14px;
font-weight: 500;
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
&.preparing {
color: #ffc107;
background-color: rgba(255, 193, 7, 0.1);
}
&.migrating {
color: #17a2b8;
background-color: rgba(23, 162, 184, 0.1);
}
&.completed {
color: #28a745;
background-color: rgba(40, 167, 69, 0.1);
}
&.failed {
color: #dc3545;
background-color: rgba(220, 53, 69, 0.1);
}
&.cancelled {
color: #6c757d;
background-color: rgba(108, 117, 125, 0.1);
}
}
}
.migration-content {
padding: 20px 24px;
max-height: 60vh;
overflow-y: auto;
}
.progress-container {
margin-bottom: 20px;
.progress-bar {
width: 100%;
height: 8px;
background-color: #444444;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
.progress-fill {
height: 100%;
transition: width 0.3s ease;
border-radius: 4px;
&.preparing {
background-color: #ffc107;
}
&.migrating {
background-color: #17a2b8;
background-image: linear-gradient(
45deg,
rgba(255, 255, 255, 0.1) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0.1) 75%,
transparent 75%,
transparent
);
background-size: 20px 20px;
animation: progressStripes 1s linear infinite;
}
&.completed {
background-color: #28a745;
}
&.failed {
background-color: #dc3545;
}
}
}
.progress-text {
text-align: center;
color: #b0b0b0;
font-size: 14px;
font-weight: 500;
}
}
@keyframes progressStripes {
0% {
background-position: 0 0;
}
100% {
background-position: 20px 0;
}
}
.migration-details {
.message {
color: #e0e0e0;
font-size: 14px;
margin-bottom: 12px;
padding: 8px 12px;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.current-file {
margin-bottom: 12px;
font-size: 13px;
.label {
color: #888888;
}
.file-name {
color: #e0e0e0;
font-family: 'Consolas', 'Courier New', monospace;
word-break: break-all;
}
}
.stats {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
.stat-item {
display: flex;
justify-content: space-between;
font-size: 13px;
.label {
color: #888888;
}
.value {
color: #e0e0e0;
font-weight: 500;
}
}
}
.error-message {
margin-top: 16px;
padding: 12px;
background-color: rgba(220, 53, 69, 0.1);
border: 1px solid rgba(220, 53, 69, 0.3);
border-radius: 4px;
color: #ff6b6b;
font-size: 13px;
display: flex;
align-items: flex-start;
gap: 8px;
.error-icon {
flex-shrink: 0;
}
}
}
.migration-actions {
padding: 16px 24px 20px;
border-top: 1px solid #444444;
display: flex;
justify-content: flex-end;
gap: 12px;
.action-button {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&.success {
background-color: #28a745;
color: white;
&:hover {
background-color: #218838;
}
}
&.retry {
background-color: #17a2b8;
color: white;
&:hover {
background-color: #138496;
}
}
&.secondary {
background-color: #6c757d;
color: white;
&:hover {
background-color: #5a6268;
}
}
&:active {
transform: translateY(1px);
}
}
}
/* 自定义滚动条 */
.migration-content::-webkit-scrollbar {
width: 6px;
}
.migration-content::-webkit-scrollbar-track {
background: #333333;
border-radius: 3px;
}
.migration-content::-webkit-scrollbar-thumb {
background: #666666;
border-radius: 3px;
&:hover {
background: #777777;
}
}
</style>

View File

@@ -0,0 +1,423 @@
import { ref, onUnmounted, reactive, computed, watch, nextTick } from 'vue';
// 基础WebSocket消息接口
interface WebSocketMessage<T = any> {
type: string;
data: T;
}
// 迁移进度接口(与后端保持一致)
interface MigrationProgress {
status: 'migrating' | 'completed' | 'failed';
progress: number; // 0-100
error?: string;
}
// 连接状态枚举
enum ConnectionState {
DISCONNECTED = 'disconnected',
CONNECTING = 'connecting',
CONNECTED = 'connected',
RECONNECTING = 'reconnecting',
ERROR = 'error'
}
// WebSocket配置选项
interface WebSocketOptions {
url?: string;
reconnectInterval?: number;
maxReconnectAttempts?: number;
debug?: boolean;
autoConnect?: boolean;
protocols?: string | string[];
heartbeat?: {
enabled: boolean;
interval: number;
message: string;
};
}
// 消息处理器类型
type MessageHandler<T = any> = (data: T) => void;
// 事件处理器映射
interface EventHandlers {
[messageType: string]: MessageHandler[];
}
// 连接事件类型
type ConnectionEventType = 'connect' | 'disconnect' | 'error' | 'reconnect';
type ConnectionEventHandler = (event?: Event | CloseEvent | ErrorEvent) => void;
export function useWebSocket(options: WebSocketOptions = {}) {
const {
url = 'ws://localhost:8899/ws/migration',
reconnectInterval = 3000,
maxReconnectAttempts = 10,
debug = false,
autoConnect = true,
protocols,
heartbeat = { enabled: false, interval: 30000, message: 'ping' }
} = options;
// === 状态管理 ===
const connectionState = ref<ConnectionState>(ConnectionState.DISCONNECTED);
const connectionError = ref<string | null>(null);
const reconnectAttempts = ref(0);
const lastMessage = ref<WebSocketMessage | null>(null);
const messageHistory = ref<WebSocketMessage[]>([]);
// 迁移进度状态(保持向后兼容)
const migrationProgress = reactive<MigrationProgress>({
status: 'completed',
progress: 0
});
// === 计算属性 ===
const isConnected = computed(() => connectionState.value === ConnectionState.CONNECTED);
const isConnecting = computed(() =>
connectionState.value === ConnectionState.CONNECTING ||
connectionState.value === ConnectionState.RECONNECTING
);
const canReconnect = computed(() => reconnectAttempts.value < maxReconnectAttempts);
// === 内部状态 ===
let ws: WebSocket | null = null;
let reconnectTimer: number | null = null;
let heartbeatTimer: number | null = null;
let isManualDisconnect = false;
// 事件处理器
const eventHandlers: EventHandlers = {};
const connectionEventHandlers: Map<ConnectionEventType, ConnectionEventHandler[]> = new Map();
// === 工具函数 ===
const log = (level: 'info' | 'warn' | 'error', message: string, ...args: any[]) => {
if (debug) {
console[level](`[WebSocket] ${message}`, ...args);
}
};
const clearTimers = () => {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
};
const updateConnectionState = (newState: ConnectionState, error?: string) => {
connectionState.value = newState;
connectionError.value = error || null;
log('info', `Connection state changed to: ${newState}`, error);
};
// === 事件系统 ===
const on = <T = any>(messageType: string, handler: MessageHandler<T>) => {
if (!eventHandlers[messageType]) {
eventHandlers[messageType] = [];
}
eventHandlers[messageType].push(handler as MessageHandler);
// 返回取消订阅函数
return () => off(messageType, handler);
};
const off = <T = any>(messageType: string, handler: MessageHandler<T>) => {
if (eventHandlers[messageType]) {
const index = eventHandlers[messageType].indexOf(handler as MessageHandler);
if (index > -1) {
eventHandlers[messageType].splice(index, 1);
}
}
};
const onConnection = (eventType: ConnectionEventType, handler: ConnectionEventHandler) => {
if (!connectionEventHandlers.has(eventType)) {
connectionEventHandlers.set(eventType, []);
}
connectionEventHandlers.get(eventType)!.push(handler);
return () => offConnection(eventType, handler);
};
const offConnection = (eventType: ConnectionEventType, handler: ConnectionEventHandler) => {
const handlers = connectionEventHandlers.get(eventType);
if (handlers) {
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
}
};
const emit = (eventType: ConnectionEventType, event?: Event | CloseEvent | ErrorEvent) => {
const handlers = connectionEventHandlers.get(eventType);
if (handlers) {
handlers.forEach(handler => {
try {
handler(event);
} catch (error) {
log('error', `Error in ${eventType} event handler:`, error);
}
});
}
};
// === 消息处理 ===
const handleMessage = (event: MessageEvent) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
log('info', 'Received message:', message);
// 更新消息历史
lastMessage.value = message;
messageHistory.value.push(message);
// 限制历史记录长度
if (messageHistory.value.length > 100) {
messageHistory.value.shift();
}
// 特殊处理迁移进度消息(保持向后兼容)
if (message.type === 'migration_progress') {
Object.assign(migrationProgress, message.data);
}
// 触发注册的处理器
const handlers = eventHandlers[message.type];
if (handlers) {
handlers.forEach(handler => {
try {
handler(message.data);
} catch (error) {
log('error', `Error in message handler for ${message.type}:`, error);
}
});
}
} catch (error) {
log('error', 'Failed to parse message:', error, event.data);
}
};
// === 心跳机制 ===
const startHeartbeat = () => {
if (!heartbeat.enabled || heartbeatTimer) return;
heartbeatTimer = window.setInterval(() => {
if (isConnected.value) {
send(heartbeat.message);
}
}, heartbeat.interval);
};
const stopHeartbeat = () => {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
};
// === 连接管理 ===
const connect = async (): Promise<void> => {
if (isConnecting.value || isConnected.value) {
log('warn', 'Already connecting or connected');
return;
}
updateConnectionState(ConnectionState.CONNECTING);
isManualDisconnect = false;
try {
log('info', 'Connecting to:', url);
ws = new WebSocket(url, protocols);
// 连接超时处理
const connectTimeout = setTimeout(() => {
if (ws && ws.readyState === WebSocket.CONNECTING) {
ws.close();
updateConnectionState(ConnectionState.ERROR, 'Connection timeout');
}
}, 10000);
ws.onopen = () => {
clearTimeout(connectTimeout);
log('info', 'Connected successfully');
updateConnectionState(ConnectionState.CONNECTED);
reconnectAttempts.value = 0;
clearTimers();
startHeartbeat();
emit('connect');
};
ws.onmessage = handleMessage;
ws.onclose = (event) => {
clearTimeout(connectTimeout);
stopHeartbeat();
log('info', 'Connection closed:', event.code, event.reason);
const wasConnected = connectionState.value === ConnectionState.CONNECTED;
updateConnectionState(ConnectionState.DISCONNECTED);
ws = null;
emit('disconnect', event);
// 自动重连逻辑
if (!isManualDisconnect && event.code !== 1000 && canReconnect.value) {
scheduleReconnect();
} else if (reconnectAttempts.value >= maxReconnectAttempts) {
updateConnectionState(ConnectionState.ERROR, 'Max reconnection attempts reached');
}
};
ws.onerror = (event) => {
clearTimeout(connectTimeout);
log('error', 'Connection error:', event);
updateConnectionState(ConnectionState.ERROR, 'WebSocket connection error');
emit('error', event);
};
} catch (error) {
log('error', 'Failed to create WebSocket:', error);
updateConnectionState(ConnectionState.ERROR, 'Failed to create WebSocket connection');
}
};
const disconnect = (code: number = 1000, reason: string = 'Manual disconnect') => {
isManualDisconnect = true;
clearTimers();
stopHeartbeat();
if (ws) {
log('info', 'Disconnecting manually');
ws.close(code, reason);
}
updateConnectionState(ConnectionState.DISCONNECTED);
};
const scheduleReconnect = () => {
if (!canReconnect.value || isManualDisconnect) return;
clearTimers();
reconnectAttempts.value++;
updateConnectionState(ConnectionState.RECONNECTING,
`Reconnecting... (${reconnectAttempts.value}/${maxReconnectAttempts})`);
log('info', `Scheduling reconnect attempt ${reconnectAttempts.value}/${maxReconnectAttempts} in ${reconnectInterval}ms`);
reconnectTimer = window.setTimeout(() => {
connect();
}, reconnectInterval);
emit('reconnect');
};
const reconnect = () => {
disconnect();
reconnectAttempts.value = 0;
nextTick(() => {
connect();
});
};
// === 消息发送 ===
const send = (message: any): boolean => {
if (!isConnected.value || !ws) {
log('warn', 'Cannot send message: not connected');
return false;
}
try {
const data = typeof message === 'string' ? message : JSON.stringify(message);
ws.send(data);
log('info', 'Sent message:', data);
return true;
} catch (error) {
log('error', 'Failed to send message:', error);
return false;
}
};
const sendMessage = <T = any>(type: string, data?: T): boolean => {
return send({ type, data });
};
// === 状态查询 ===
const getConnectionInfo = () => ({
state: connectionState.value,
error: connectionError.value,
reconnectAttempts: reconnectAttempts.value,
maxReconnectAttempts,
canReconnect: canReconnect.value,
url,
readyState: ws?.readyState,
protocol: ws?.protocol,
extensions: ws?.extensions
});
// === 初始化 ===
if (autoConnect) {
nextTick(() => {
connect();
});
}
// === 清理 ===
onUnmounted(() => {
disconnect();
});
// 监听连接状态变化,用于调试
if (debug) {
watch(connectionState, (newState, oldState) => {
log('info', `State transition: ${oldState} -> ${newState}`);
});
}
return {
// === 状态(只读) ===
connectionState: computed(() => connectionState.value),
isConnected,
isConnecting,
connectionError: computed(() => connectionError.value),
reconnectAttempts: computed(() => reconnectAttempts.value),
canReconnect,
lastMessage: computed(() => lastMessage.value),
messageHistory: computed(() => messageHistory.value),
// === 向后兼容的状态 ===
migrationProgress,
// === 连接控制 ===
connect,
disconnect,
reconnect,
// === 消息发送 ===
send,
sendMessage,
// === 事件系统 ===
on,
off,
onConnection,
offConnection,
// === 工具方法 ===
getConnectionInfo,
clearHistory: () => {
messageHistory.value = [];
lastMessage.value = null;
}
};
}
// 导出类型
export type { WebSocketMessage, MigrationProgress, WebSocketOptions, MessageHandler, ConnectionEventType };
export { ConnectionState };

View File

@@ -46,36 +46,12 @@ export default {
saveSuccess: 'Save settings updated', saveSuccess: 'Save settings updated',
saveFailed: 'Failed to update save settings' saveFailed: 'Failed to update save settings'
}, },
migration: {
inProgress: 'Migrating data to new path...',
success: 'Data migration completed',
failed: 'Data migration failed'
}
}, },
migration: { migration: {
title: 'Data Migration', started: 'Starting data migration',
preparing: 'Preparing',
migrating: 'Migrating', migrating: 'Migrating',
completed: 'Completed', completed: 'Migration Completed',
failed: 'Failed', failed: 'Migration Failed'
cancelled: 'Cancelled',
idle: 'Idle',
currentFile: 'Current File',
files: 'Files',
size: 'Size',
timeRemaining: 'Time Remaining',
complete: 'Complete',
retry: 'Retry',
close: 'Close',
migrationInProgress: 'Migrating data to new path...',
migrationCompleted: 'Data migration completed',
migrationFailed: 'Data migration failed',
recursiveCopyError: 'Target path cannot be a subdirectory of source path, this would cause infinite recursive copying',
targetNotDirectory: 'Target path exists but is not a directory',
targetNotEmpty: 'Target directory is not empty, cannot migrate',
compressing: 'Compressing source directory...',
extracting: 'Extracting to target location...',
cleaning: 'Cleaning up source directory...'
}, },
settings: { settings: {
title: 'Settings', title: 'Settings',
@@ -89,10 +65,11 @@ export default {
comingSoon: 'Coming Soon...', comingSoon: 'Coming Soon...',
save: 'Save', save: 'Save',
reset: 'Reset', reset: 'Reset',
cancel: 'Cancel',
dangerZone: 'Danger Zone', dangerZone: 'Danger Zone',
resetAllSettings: 'Reset All Settings', resetAllSettings: 'Reset All Settings',
resetDescription: 'This will restore all settings to their default values. This action cannot be undone.', resetDescription: 'This will restore all settings to their default values. This action cannot be undone.',
confirmReset: 'Are you sure you want to reset all settings? This action cannot be undone.', confirmReset: 'Click again to confirm reset',
globalHotkey: 'Global Keyboard Shortcuts', globalHotkey: 'Global Keyboard Shortcuts',
enableGlobalHotkey: 'Enable Global Hotkeys', enableGlobalHotkey: 'Enable Global Hotkeys',
window: 'Window/Application', window: 'Window/Application',

View File

@@ -46,36 +46,12 @@ export default {
saveSuccess: '保存设置已更新', saveSuccess: '保存设置已更新',
saveFailed: '保存设置更新失败' saveFailed: '保存设置更新失败'
}, },
migration: {
inProgress: '正在迁移数据到新路径...',
success: '数据迁移完成',
failed: '数据迁移失败'
}
}, },
migration: { migration: {
title: '数据迁移', started: '开始迁移数据',
preparing: '准备中', migrating: '迁移中',
migrating: '迁移', completed: '迁移已完成',
completed: '已完成', failed: '迁移失败'
failed: '失败',
cancelled: '已取消',
idle: '空闲',
currentFile: '当前文件',
files: '文件',
size: '大小',
timeRemaining: '剩余时间',
complete: '完成',
retry: '重试',
close: '关闭',
migrationInProgress: '正在迁移数据到新路径...',
migrationCompleted: '数据迁移完成',
migrationFailed: '数据迁移失败',
recursiveCopyError: '目标路径不能是源路径的子目录,这会导致无限递归复制',
targetNotDirectory: '目标路径存在但不是目录',
targetNotEmpty: '目标目录不为空,无法迁移',
compressing: '正在压缩源目录...',
extracting: '正在解压到目标位置...',
cleaning: '正在清理源目录...'
}, },
settings: { settings: {
title: '设置', title: '设置',
@@ -89,10 +65,11 @@ export default {
comingSoon: '即将推出...', comingSoon: '即将推出...',
save: '保存', save: '保存',
reset: '重置', reset: '重置',
cancel: '取消',
dangerZone: '危险操作', dangerZone: '危险操作',
resetAllSettings: '重置所有设置', resetAllSettings: '重置所有设置',
resetDescription: '这将恢复所有设置为默认值,此操作无法撤销', resetDescription: '这将恢复所有设置为默认值,此操作无法撤销',
confirmReset: '确定要重置所有设置吗?此操作无法撤销。', confirmReset: '再次点击确认重置',
globalHotkey: '全局键盘快捷键', globalHotkey: '全局键盘快捷键',
enableGlobalHotkey: '启用全局热键', enableGlobalHotkey: '启用全局热键',
window: '窗口/应用程序', window: '窗口/应用程序',

View File

@@ -270,8 +270,16 @@ export const useConfigStore = defineStore('config', () => {
state.isLoading = true; state.isLoading = true;
try { try {
// 调用后端重置配置
await safeCall(() => ConfigService.ResetConfig(), 'config.resetFailed', 'config.resetSuccess'); await safeCall(() => ConfigService.ResetConfig(), 'config.resetFailed', 'config.resetSuccess');
await safeCall(() => initConfig(), 'config.loadFailed', 'config.loadSuccess');
// 立即重新加载后端配置以确保前端状态同步
await safeCall(async () => {
const appConfig = await ConfigService.GetConfig();
if (appConfig) {
state.config = JSON.parse(JSON.stringify(appConfig)) as AppConfig;
}
}, 'config.loadFailed', 'config.loadSuccess');
} finally { } finally {
state.isLoading = false; state.isLoading = false;
} }

View File

@@ -1,21 +1,164 @@
<script setup lang="ts"> <script setup lang="ts">
import { useConfigStore } from '@/stores/configStore'; import {useConfigStore} from '@/stores/configStore';
import { useI18n } from 'vue-i18n'; import {useI18n} from 'vue-i18n';
import { computed, ref, onMounted } from 'vue'; import {computed, onUnmounted, ref, watch} from 'vue';
import SettingSection from '../components/SettingSection.vue'; import SettingSection from '../components/SettingSection.vue';
import SettingItem from '../components/SettingItem.vue'; import SettingItem from '../components/SettingItem.vue';
import ToggleSwitch from '../components/ToggleSwitch.vue'; import ToggleSwitch from '../components/ToggleSwitch.vue';
import MigrationProgress from '@/components/migration/MigrationProgress.vue'; import {useErrorHandler} from '@/utils/errorHandler';
import { useErrorHandler } from '@/utils/errorHandler'; import {DialogService, MigrationService} from '@/../bindings/voidraft/internal/services';
import { DialogService, MigrationService } from '@/../bindings/voidraft/internal/services'; import {useWebSocket} from '@/composables/useWebSocket';
import * as runtime from '@wailsio/runtime'; import * as runtime from '@wailsio/runtime';
const { t } = useI18n(); const {t} = useI18n();
const configStore = useConfigStore(); const configStore = useConfigStore();
const { safeCall } = useErrorHandler(); const {safeCall} = useErrorHandler();
// WebSocket连接
const {migrationProgress, isConnected, connectionState, connect, disconnect, on} = useWebSocket({
debug: true,
autoConnect: false // 手动控制连接
});
// 迁移消息链
interface MigrationMessage {
id: number;
content: string;
type: 'start' | 'progress' | 'success' | 'error';
timestamp: number;
}
const migrationMessages = ref<MigrationMessage[]>([]);
const showMessages = ref(false);
let messageIdCounter = 0;
let hideMessagesTimer: any = null;
// 添加迁移消息
const addMigrationMessage = (content: string, type: MigrationMessage['type']) => {
const message: MigrationMessage = {
id: ++messageIdCounter,
content,
type,
timestamp: Date.now()
};
migrationMessages.value.push(message);
showMessages.value = true;
};
// 清除所有消息
const clearMigrationMessages = () => {
migrationMessages.value = [];
showMessages.value = false;
};
// 监听连接状态变化
const connectionWatcher = computed(() => isConnected.value);
watch(connectionWatcher, (connected) => {
if (connected && isMigrating.value) {
// 如果连接成功且正在迁移,添加连接消息
if (!migrationMessages.value.some(msg => msg.content === '实时连接中')) {
addMigrationMessage('实时连接中', 'progress');
}
}
});
// 监听迁移进度变化
on('migration_progress', (data) => {
// 清除之前的隐藏定时器
if (hideMessagesTimer) {
clearTimeout(hideMessagesTimer);
hideMessagesTimer = null;
}
if (data.status === 'migrating') {
// 如果是第一次收到迁移状态,添加开始消息
if (migrationMessages.value.length === 0) {
addMigrationMessage(t('migration.started'), 'start');
}
// 如果还没有迁移中消息,添加迁移中消息
if (!migrationMessages.value.some(msg => msg.type === 'progress' && msg.content === t('migration.migrating'))) {
addMigrationMessage(t('migration.migrating'), 'progress');
}
} else if (data.status === 'completed') {
addMigrationMessage(t('migration.completed'), 'success');
// 3秒后断开连接
setTimeout(() => {
disconnect();
}, 3000);
// 5秒后开始逐个隐藏消息
hideMessagesTimer = setTimeout(() => {
hideMessagesSequentially();
}, 5000);
} else if (data.status === 'failed') {
const errorMsg = data.error || t('migration.failed');
addMigrationMessage(errorMsg, 'error');
// 3秒后断开连接
setTimeout(() => {
disconnect();
}, 3000);
// 8秒后开始逐个隐藏消息
hideMessagesTimer = setTimeout(() => {
hideMessagesSequentially();
}, 8000);
}
});
// 逐个隐藏消息
const hideMessagesSequentially = () => {
const hideNextMessage = () => {
if (migrationMessages.value.length > 0) {
migrationMessages.value.shift(); // 移除第一条消息
if (migrationMessages.value.length > 0) {
// 如果还有消息1秒后隐藏下一条
setTimeout(hideNextMessage, 1000);
} else {
// 所有消息都隐藏完了,同时隐藏进度条
showMessages.value = false;
}
}
};
hideNextMessage();
};
// 迁移状态 // 迁移状态
const showMigrationProgress = ref(false); const isMigrating = computed(() => migrationProgress.status === 'migrating');
const migrationComplete = computed(() => migrationProgress.status === 'completed');
const migrationFailed = computed(() => migrationProgress.status === 'failed');
// 进度条样式和宽度
const progressBarClass = computed(() => {
switch (migrationProgress.status) {
case 'migrating':
return 'migrating';
case 'completed':
return 'success';
case 'failed':
return 'error';
default:
return '';
}
});
const progressBarWidth = computed(() => {
if (isMigrating.value) {
return migrationProgress.progress + '%';
} else if (migrationComplete.value || migrationFailed.value) {
return '100%';
}
return '0%';
});
// 重置确认状态
const resetConfirmState = ref<'idle' | 'confirming'>('idle');
let resetConfirmTimer: any = null;
// 可选键列表 // 可选键列表
const keyOptions = [ const keyOptions = [
@@ -58,24 +201,39 @@ const selectedKey = computed(() => configStore.config.general.globalHotkey.key);
// 切换修饰键 // 切换修饰键
const toggleModifier = (key: 'ctrl' | 'shift' | 'alt' | 'win') => { const toggleModifier = (key: 'ctrl' | 'shift' | 'alt' | 'win') => {
const currentHotkey = configStore.config.general.globalHotkey; const currentHotkey = configStore.config.general.globalHotkey;
const newHotkey = { ...currentHotkey, [key]: !currentHotkey[key] }; const newHotkey = {...currentHotkey, [key]: !currentHotkey[key]};
configStore.setGlobalHotkey(newHotkey); configStore.setGlobalHotkey(newHotkey);
}; };
// 更新选择的键 // 更新选择的键
const updateSelectedKey = (event: Event) => { const updateSelectedKey = (event: Event) => {
const select = event.target as HTMLSelectElement; const select = event.target as HTMLSelectElement;
const newHotkey = { ...configStore.config.general.globalHotkey, key: select.value }; const newHotkey = {...configStore.config.general.globalHotkey, key: select.value};
configStore.setGlobalHotkey(newHotkey); configStore.setGlobalHotkey(newHotkey);
}; };
// 重置设置 // 重置设置
const resetSettings = async () => { const resetSettings = () => {
if (confirm(t('settings.confirmReset'))) { if (resetConfirmState.value === 'idle') {
await configStore.resetConfig(); // 第一次点击,进入确认状态
resetConfirmState.value = 'confirming';
// 3秒后自动返回idle状态
resetConfirmTimer = setTimeout(() => {
resetConfirmState.value = 'idle';
}, 3000);
} else if (resetConfirmState.value === 'confirming') {
// 第二次点击,执行重置
clearTimeout(resetConfirmTimer);
resetConfirmState.value = 'idle';
confirmReset();
} }
}; };
// 确认重置
const confirmReset = async () => {
await configStore.resetConfig();
};
// 计算热键预览文本 // 计算热键预览文本
const hotkeyPreview = computed(() => { const hotkeyPreview = computed(() => {
if (!enableGlobalHotkey.value) return ''; if (!enableGlobalHotkey.value) return '';
@@ -96,95 +254,89 @@ const currentDataPath = computed(() => configStore.config.general.dataPath);
// 选择数据存储目录 // 选择数据存储目录
const selectDataDirectory = async () => { const selectDataDirectory = async () => {
try { if (isMigrating.value) return;
const selectedPath = await DialogService.SelectDirectory(); const selectedPath = await DialogService.SelectDirectory();
if (selectedPath && selectedPath.trim() && selectedPath !== currentDataPath.value) { if (selectedPath && selectedPath.trim() && selectedPath !== currentDataPath.value) {
// 显示迁移进度对话框 // 清除之前的消息
showMigrationProgress.value = true; clearMigrationMessages();
// 连接WebSocket以接收迁移进度
await connect();
// 开始迁移 // 开始迁移
try {
await safeCall(async () => { await safeCall(async () => {
const oldPath = currentDataPath.value; const oldPath = currentDataPath.value;
const newPath = selectedPath.trim(); const newPath = selectedPath.trim();
// 先启动迁移
await MigrationService.MigrateDirectory(oldPath, newPath); await MigrationService.MigrateDirectory(oldPath, newPath);
// 迁移完成后更新配置
await configStore.setDataPath(newPath); await configStore.setDataPath(newPath);
}, 'migration.migrationFailed'); }, '');
}
} catch (error) { } catch (error) {
await safeCall(async () => { // 发生错误时清除消息
throw error; clearMigrationMessages();
}, 'settings.selectDirectoryFailed'); }
} }
}; };
// 处理迁移完成
const handleMigrationComplete = () => {
showMigrationProgress.value = false;
// 显示成功消息
safeCall(async () => {
// 空的成功操作,只为了显示成功消息
}, '', 'migration.migrationCompleted');
};
// 处理迁移关闭 // 清理定时器和WebSocket连接
const handleMigrationClose = () => { onUnmounted(() => {
showMigrationProgress.value = false; disconnect();
}; if (resetConfirmTimer) {
clearTimeout(resetConfirmTimer);
// 处理迁移重试 }
const handleMigrationRetry = () => { if (hideMessagesTimer) {
// 重新触发路径选择 clearTimeout(hideMessagesTimer);
showMigrationProgress.value = false; }
selectDataDirectory(); });
};
</script> </script>
<template> <template>
<div class="settings-page"> <div class="settings-page">
<SettingSection :title="t('settings.globalHotkey')"> <SettingSection :title="t('settings.globalHotkey')">
<SettingItem :title="t('settings.enableGlobalHotkey')"> <SettingItem :title="t('settings.enableGlobalHotkey')">
<ToggleSwitch v-model="enableGlobalHotkey" /> <ToggleSwitch v-model="enableGlobalHotkey"/>
</SettingItem> </SettingItem>
<div class="hotkey-selector" :class="{ 'disabled': !enableGlobalHotkey }"> <div class="hotkey-selector" :class="{ 'disabled': !enableGlobalHotkey }">
<div class="hotkey-modifiers"> <div class="hotkey-controls">
<label class="modifier-label" :class="{ active: modifierKeys.ctrl }" @click="toggleModifier('ctrl')"> <div class="hotkey-modifiers">
<input type="checkbox" :checked="modifierKeys.ctrl" class="hidden-checkbox" :disabled="!enableGlobalHotkey"> <label class="modifier-label" :class="{ active: modifierKeys.ctrl }" @click="toggleModifier('ctrl')">
<span class="modifier-key">Ctrl</span> <input type="checkbox" :checked="modifierKeys.ctrl" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
</label> <span class="modifier-key">Ctrl</span>
<label class="modifier-label" :class="{ active: modifierKeys.shift }" @click="toggleModifier('shift')"> </label>
<input type="checkbox" :checked="modifierKeys.shift" class="hidden-checkbox" :disabled="!enableGlobalHotkey"> <label class="modifier-label" :class="{ active: modifierKeys.shift }" @click="toggleModifier('shift')">
<span class="modifier-key">Shift</span> <input type="checkbox" :checked="modifierKeys.shift" class="hidden-checkbox"
</label> :disabled="!enableGlobalHotkey">
<label class="modifier-label" :class="{ active: modifierKeys.alt }" @click="toggleModifier('alt')"> <span class="modifier-key">Shift</span>
<input type="checkbox" :checked="modifierKeys.alt" class="hidden-checkbox" :disabled="!enableGlobalHotkey"> </label>
<span class="modifier-key">Alt</span> <label class="modifier-label" :class="{ active: modifierKeys.alt }" @click="toggleModifier('alt')">
</label> <input type="checkbox" :checked="modifierKeys.alt" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
<label class="modifier-label" :class="{ active: modifierKeys.win }" @click="toggleModifier('win')"> <span class="modifier-key">Alt</span>
<input type="checkbox" :checked="modifierKeys.win" class="hidden-checkbox" :disabled="!enableGlobalHotkey"> </label>
<span class="modifier-key">Win</span> <label class="modifier-label" :class="{ active: modifierKeys.win }" @click="toggleModifier('win')">
</label> <input type="checkbox" :checked="modifierKeys.win" class="hidden-checkbox" :disabled="!enableGlobalHotkey">
<span class="modifier-key">Win</span>
</label>
</div>
<select class="key-select" :value="selectedKey" @change="updateSelectedKey" :disabled="!enableGlobalHotkey">
<option v-for="key in keyOptions" :key="key" :value="key">{{ key }}</option>
</select>
</div> </div>
<select class="key-select" :value="selectedKey" @change="updateSelectedKey" :disabled="!enableGlobalHotkey"> <div class="hotkey-preview">
<option v-for="key in keyOptions" :key="key" :value="key">{{ key }}</option>
</select>
<div class="hotkey-preview" v-if="hotkeyPreview">
<span class="preview-label">预览</span> <span class="preview-label">预览</span>
<span class="preview-hotkey">{{ hotkeyPreview }}</span> <span class="preview-hotkey">{{ hotkeyPreview || '无' }}</span>
</div> </div>
</div> </div>
</SettingSection> </SettingSection>
<SettingSection :title="t('settings.window')"> <SettingSection :title="t('settings.window')">
<SettingItem :title="t('settings.alwaysOnTop')"> <SettingItem :title="t('settings.alwaysOnTop')">
<ToggleSwitch v-model="alwaysOnTop" /> <ToggleSwitch v-model="alwaysOnTop"/>
</SettingItem> </SettingItem>
</SettingSection> </SettingSection>
@@ -194,41 +346,61 @@ const handleMigrationRetry = () => {
<div class="setting-title">{{ t('settings.dataPath') }}</div> <div class="setting-title">{{ t('settings.dataPath') }}</div>
</div> </div>
<div class="data-path-controls"> <div class="data-path-controls">
<input <div class="path-input-container">
type="text" <input
:value="currentDataPath" type="text"
readonly :value="currentDataPath"
:placeholder="t('settings.clickToSelectPath')" readonly
class="path-display-input" :placeholder="t('settings.clickToSelectPath')"
@click="selectDataDirectory" class="path-display-input"
:title="t('settings.clickToSelectPath')" @click="selectDataDirectory"
/> :title="t('settings.clickToSelectPath')"
:disabled="isMigrating"
/>
<div
class="progress-bar"
:class="[
{ 'active': showMessages },
progressBarClass
]"
:style="{ width: progressBarWidth }"
></div>
</div>
<div class="migration-status-container">
<Transition name="fade-slide" mode="out-in">
<div v-if="showMessages" class="migration-messages">
<TransitionGroup name="message-list" tag="div">
<div v-for="message in migrationMessages" :key="message.id" class="migration-message" :class="message.type">
{{ message.content }}
</div>
</TransitionGroup>
</div>
</Transition>
</div>
</div> </div>
</div> </div>
</SettingSection> </SettingSection>
<SettingSection :title="t('settings.dangerZone')"> <SettingSection :title="t('settings.dangerZone')">
<div class="danger-zone"> <SettingItem :title="t('settings.resetAllSettings')" :description="t('settings.resetDescription')">
<div class="reset-section"> <button
<div class="reset-info"> class="reset-button"
<h4>{{ t('settings.resetAllSettings') }}</h4> :class="{ 'confirming': resetConfirmState === 'confirming' }"
<p>{{ t('settings.resetDescription') }}</p> @click="resetSettings"
</div> >
<button class="reset-button" @click="resetSettings"> <template v-if="resetConfirmState === 'idle'">
{{ t('settings.reset') }} {{ t('settings.reset') }}
</button> </template>
</div> <template v-else-if="resetConfirmState === 'confirming'">
</div> {{ t('settings.confirmReset') }}
</template>
</button>
</SettingItem>
</SettingSection> </SettingSection>
<!-- 迁移进度对话框 -->
<MigrationProgress
:visible="showMigrationProgress"
@complete="handleMigrationComplete"
@close="handleMigrationClose"
@retry="handleMigrationRetry"
/>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -238,16 +410,24 @@ const handleMigrationRetry = () => {
.hotkey-selector { .hotkey-selector {
padding: 15px 0 5px 20px; padding: 15px 0 5px 20px;
transition: all 0.3s ease;
&.disabled { &.disabled {
opacity: 0.5; opacity: 0.5;
pointer-events: none; pointer-events: none;
} }
.hotkey-controls {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.hotkey-modifiers { .hotkey-modifiers {
display: flex; display: flex;
gap: 8px; gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap; flex-wrap: wrap;
.modifier-label { .modifier-label {
@@ -298,7 +478,6 @@ const handleMigrationRetry = () => {
background-position: right 8px center; background-position: right 8px center;
background-size: 16px; background-size: 16px;
padding-right: 30px; padding-right: 30px;
margin-bottom: 12px;
&:focus { &:focus {
outline: none; outline: none;
@@ -366,92 +545,261 @@ const handleMigrationRetry = () => {
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
.path-input-container {
position: relative;
width: 100%;
max-width: 450px;
.path-display-input { .path-display-input {
width: 100%; width: 100%;
max-width: 450px;
box-sizing: border-box; box-sizing: border-box;
padding: 10px 12px; padding: 10px 12px;
background-color: #3a3a3a; background-color: #3a3a3a;
border: 1px solid #555555; border: 1px solid #555555;
border-radius: 4px; border-radius: 4px;
color: #e0e0e0; color: #e0e0e0;
font-size: 13px; font-size: 13px;
line-height: 1.2; line-height: 1.2;
transition: all 0.2s ease; transition: all 0.2s ease;
cursor: pointer; cursor: pointer;
&:hover {
border-color: #4a9eff;
background-color: #404040;
}
&:focus {
outline: none;
border-color: #4a9eff;
background-color: #404040;
}
&:hover:not(:disabled) {
border-color: #4a9eff;
background-color: #404040;
}
&:focus {
outline: none;
border-color: #4a9eff;
background-color: #404040;
}
&:disabled {
cursor: not-allowed;
opacity: 0.7;
}
&::placeholder { &::placeholder {
color: #888888; color: #888888;
} }
} }
.progress-bar {
position: absolute;
bottom: 0;
left: 0;
height: 2px;
background-color: #22c55e;
transition: width 0.3s ease;
border-radius: 0 0 4px 4px;
opacity: 0;
&.active {
opacity: 1;
}
} &.migrating {
background-color: #3b82f6;
}
.danger-zone { &.success {
padding: 20px; background-color: #22c55e;
background-color: rgba(220, 53, 69, 0.05);
border: 1px solid rgba(220, 53, 69, 0.2);
border-radius: 6px;
margin-top: 10px;
.reset-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
.reset-info {
flex: 1;
h4 {
margin: 0 0 6px 0;
color: #ff6b6b;
font-size: 14px;
font-weight: 600;
} }
p { &.error {
margin: 0; background-color: #ef4444;
color: #cccccc;
font-size: 13px;
line-height: 1.4;
}
}
.reset-button {
padding: 8px 16px;
background-color: #dc3545;
border: 1px solid #dc3545;
border-radius: 4px;
color: white;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
&:hover {
background-color: #c82333;
border-color: #bd2130;
}
&:active {
transform: translateY(1px);
} }
} }
} }
.migration-status-container {
margin-top: 8px;
min-height: 0;
overflow: hidden;
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
.migration-messages {
margin-bottom: 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.migration-message {
font-size: 11px;
padding: 2px 0;
display: flex;
align-items: center;
gap: 6px;
transform: translateY(0);
opacity: 1;
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
&::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
&.start {
color: #94a3b8;
&::before {
background-color: #94a3b8;
}
}
&.progress {
color: #3b82f6;
&::before {
background-color: #3b82f6;
animation: pulse-dot 1.5s infinite;
}
}
&.success {
color: #22c55e;
&::before {
background-color: #22c55e;
}
}
&.error {
color: #ef4444;
&::before {
background-color: #ef4444;
}
}
&.v-enter-from, &.v-leave-to {
opacity: 0;
transform: translateY(-8px);
}
}
&:empty {
min-height: 0;
margin-top: 0;
}
}
}
.reset-button {
padding: 8px 16px;
background-color: #dc3545;
border: 1px solid #dc3545;
border-radius: 4px;
color: white;
font-size: 13px;
cursor: pointer;
transition: all 0.3s ease;
min-width: 80px;
&:hover {
background-color: #c82333;
border-color: #bd2130;
}
&:active {
transform: translateY(1px);
}
&.confirming {
background-color: #ff4757;
border-color: #ff4757;
animation: pulse-button 1.5s infinite;
&:hover {
background-color: #ff3838;
border-color: #ff3838;
}
}
}
// 按钮脉冲动画
@keyframes pulse-button {
0% {
box-shadow: 0 0 0 0 rgba(255, 71, 87, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(255, 71, 87, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 71, 87, 0);
}
}
// 消息点脉冲动画
@keyframes pulse-dot {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.2);
}
}
// Vue Transition 动画
.fade-slide-enter-active {
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.fade-slide-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.6, 1);
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(-8px);
max-height: 0;
margin-top: 0;
margin-bottom: 0;
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-5px);
max-height: 0;
margin-top: 0;
margin-bottom: 0;
}
.fade-slide-enter-to,
.fade-slide-leave-from {
opacity: 1;
transform: translateY(0);
max-height: 150px;
}
// 消息列表动画
.message-list-enter-active {
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.message-list-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.6, 1);
}
.message-list-enter-from {
opacity: 0;
transform: translateX(-16px);
}
.message-list-leave-to {
opacity: 0;
transform: translateX(16px);
}
.message-list-move {
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
} }
</style> </style>

23
go.mod
View File

@@ -6,6 +6,8 @@ toolchain go1.24.2
require ( require (
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/gin-gonic/gin v1.10.1
github.com/lxzan/gws v1.8.9
github.com/spf13/viper v1.20.1 github.com/spf13/viper v1.20.1
github.com/wailsapp/wails/v3 v3.0.0-alpha.9 github.com/wailsapp/wails/v3 v3.0.0-alpha.9
) )
@@ -16,26 +18,43 @@ require (
github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect github.com/adrg/xdg v0.5.3 // indirect
github.com/bep/debounce v1.2.1 // indirect github.com/bep/debounce v1.2.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudflare/circl v1.6.1 // indirect github.com/cloudflare/circl v1.6.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/dolthub/maphash v0.1.0 // indirect
github.com/ebitengine/purego v0.8.4 // indirect github.com/ebitengine/purego v0.8.4 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-git/v5 v5.16.0 // indirect github.com/go-git/go-git/v5 v5.16.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.1 // indirect github.com/leaanthony/u v1.1.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lmittmann/tint v1.1.1 // indirect github.com/lmittmann/tint v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
@@ -49,15 +68,19 @@ require (
github.com/spf13/cast v1.9.2 // indirect github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/wailsapp/go-webview2 v1.0.21 // indirect github.com/wailsapp/go-webview2 v1.0.21 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.38.0 // indirect golang.org/x/crypto v0.38.0 // indirect
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
golang.org/x/net v0.40.0 // indirect golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect golang.org/x/text v0.25.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

64
go.sum
View File

@@ -13,13 +13,23 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
@@ -30,6 +40,12 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
@@ -42,22 +58,41 @@ github.com/go-git/go-git/v5 v5.16.0 h1:k3kuOEpkc0DeY7xlL6NaaNg39xdgQbtH5mwCafHO9
github.com/go-git/go-git/v5 v5.16.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= github.com/go-git/go-git/v5 v5.16.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -69,14 +104,23 @@ github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lmittmann/tint v1.1.1 h1:xmmGuinUsCSxWdwH1OqMUQ4tzQsq3BdjJLAAmVKJ9Dw= github.com/lmittmann/tint v1.1.1 h1:xmmGuinUsCSxWdwH1OqMUQ4tzQsq3BdjJLAAmVKJ9Dw=
github.com/lmittmann/tint v1.1.1/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/lmittmann/tint v1.1.1/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@@ -114,12 +158,23 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/wailsapp/go-webview2 v1.0.21 h1:k3dtoZU4KCoN/AEIbWiPln3P2661GtA2oEgA2Pb+maA= github.com/wailsapp/go-webview2 v1.0.21 h1:k3dtoZU4KCoN/AEIbWiPln3P2661GtA2oEgA2Pb+maA=
github.com/wailsapp/go-webview2 v1.0.21/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/go-webview2 v1.0.21/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
@@ -130,6 +185,9 @@ github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
@@ -147,6 +205,7 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
@@ -157,6 +216,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -167,5 +228,8 @@ gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -229,21 +229,48 @@ func (cs *ConfigService) Get(key string) interface{} {
return cs.viper.Get(key) return cs.viper.Get(key)
} }
// ResetConfig 置为默认配置 // ResetConfig 强制重置所有配置为默认
func (cs *ConfigService) ResetConfig() error { func (cs *ConfigService) ResetConfig() {
cs.mu.Lock() cs.mu.Lock()
defer cs.mu.Unlock() defer cs.mu.Unlock()
// 重新设置默认值 defaultConfig := models.NewDefaultAppConfig()
setDefaults(cs.viper)
// 使用 WriteConfig 写入配置文件(会触发文件监听) // 通用设置 - 批量设置到viper中
cs.viper.Set("general.always_on_top", defaultConfig.General.AlwaysOnTop)
cs.viper.Set("general.data_path", defaultConfig.General.DataPath)
cs.viper.Set("general.enable_global_hotkey", defaultConfig.General.EnableGlobalHotkey)
cs.viper.Set("general.global_hotkey.ctrl", defaultConfig.General.GlobalHotkey.Ctrl)
cs.viper.Set("general.global_hotkey.shift", defaultConfig.General.GlobalHotkey.Shift)
cs.viper.Set("general.global_hotkey.alt", defaultConfig.General.GlobalHotkey.Alt)
cs.viper.Set("general.global_hotkey.win", defaultConfig.General.GlobalHotkey.Win)
cs.viper.Set("general.global_hotkey.key", defaultConfig.General.GlobalHotkey.Key)
// 编辑设置 - 批量设置到viper中
cs.viper.Set("editing.font_size", defaultConfig.Editing.FontSize)
cs.viper.Set("editing.font_family", defaultConfig.Editing.FontFamily)
cs.viper.Set("editing.font_weight", defaultConfig.Editing.FontWeight)
cs.viper.Set("editing.line_height", defaultConfig.Editing.LineHeight)
cs.viper.Set("editing.enable_tab_indent", defaultConfig.Editing.EnableTabIndent)
cs.viper.Set("editing.tab_size", defaultConfig.Editing.TabSize)
cs.viper.Set("editing.tab_type", defaultConfig.Editing.TabType)
cs.viper.Set("editing.auto_save_delay", defaultConfig.Editing.AutoSaveDelay)
// 外观设置 - 批量设置到viper中
cs.viper.Set("appearance.language", defaultConfig.Appearance.Language)
// 元数据 - 批量设置到viper中
cs.viper.Set("metadata.version", defaultConfig.Metadata.Version)
cs.viper.Set("metadata.last_updated", time.Now())
// 一次性写入配置文件,触发配置变更通知
if err := cs.viper.WriteConfig(); err != nil { if err := cs.viper.WriteConfig(); err != nil {
return &ConfigError{Operation: "reset_config", Err: err} cs.logger.Error("Config: Failed to write config during reset", "error", err)
} else {
cs.logger.Info("Config: All settings have been reset to defaults")
// 手动触发配置变更检查,确保通知系统能感知到变更
cs.notificationService.CheckConfigChanges()
} }
cs.logger.Info("Config: Successfully reset to default configuration")
return nil
} }
// SetHotkeyChangeCallback 设置热键配置变更回调 // SetHotkeyChangeCallback 设置热键配置变更回调

View File

@@ -0,0 +1,156 @@
package services
import (
"context"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/wailsapp/wails/v3/pkg/services/log"
)
// HTTPService HTTP 服务
type HTTPService struct {
logger *log.LoggerService
server *http.Server
wsService *WebSocketService
}
// NewHTTPService 创建 HTTP 服务
func NewHTTPService(logger *log.LoggerService, wsService *WebSocketService) *HTTPService {
if logger == nil {
logger = log.New()
}
return &HTTPService{
logger: logger,
wsService: wsService,
}
}
// StartServer 启动 HTTP 服务器
func (hs *HTTPService) StartServer(port string) error {
// 设置 Gin 为发布模式
gin.SetMode(gin.ReleaseMode)
// 创建 Gin 路由器
router := gin.New()
// 添加中间件
router.Use(gin.Recovery())
router.Use(hs.corsMiddleware())
// 设置路由
hs.setupRoutes(router)
// 创建 HTTP 服务器
hs.server = &http.Server{
Addr: ":" + port,
Handler: router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20, // 1MB
}
hs.logger.Info("HTTP: Starting server", "port", port)
// 启动服务器
go func() {
if err := hs.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
hs.logger.Error("HTTP: Server failed to start", "error", err)
}
}()
return nil
}
// setupRoutes 设置路由
func (hs *HTTPService) setupRoutes(router *gin.Engine) {
// WebSocket 端点
router.GET("/ws/migration", hs.handleWebSocket)
// API 端点组
api := router.Group("/api")
{
api.GET("/health", hs.handleHealth)
api.GET("/ws/clients", hs.handleWSClients)
}
}
// handleWebSocket 处理 WebSocket 连接
func (hs *HTTPService) handleWebSocket(c *gin.Context) {
if hs.wsService == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "WebSocket service not available"})
return
}
hs.wsService.HandleUpgrade(c.Writer, c.Request)
}
// handleHealth 健康检查端点
func (hs *HTTPService) handleHealth(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"timestamp": time.Now().Unix(),
"service": "voidraft-http",
})
}
// handleWSClients 获取 WebSocket 客户端数量
func (hs *HTTPService) handleWSClients(c *gin.Context) {
if hs.wsService == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "WebSocket service not available"})
return
}
count := hs.wsService.GetConnectedClientsCount()
c.JSON(http.StatusOK, gin.H{
"connected_clients": count,
})
}
// corsMiddleware CORS 中间件
func (hs *HTTPService) corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
c.Header("Access-Control-Allow-Credentials", "true")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
// StopServer 停止 HTTP 服务器
func (hs *HTTPService) StopServer() error {
if hs.server == nil {
return nil
}
hs.logger.Info("HTTP: Stopping server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return hs.server.Shutdown(ctx)
}
// ServiceShutdown 服务关闭
func (hs *HTTPService) ServiceShutdown() error {
hs.logger.Info("HTTP: Service is shutting down...")
if err := hs.StopServer(); err != nil {
hs.logger.Error("HTTP: Failed to stop server", "error", err)
return err
}
hs.logger.Info("HTTP: Service shutdown completed")
return nil
}

View File

@@ -3,7 +3,6 @@ package services
import ( import (
"archive/zip" "archive/zip"
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -19,40 +18,26 @@ import (
type MigrationStatus string type MigrationStatus string
const ( const (
MigrationStatusIdle MigrationStatus = "idle" // 空闲状态
MigrationStatusPreparing MigrationStatus = "preparing" // 准备中
MigrationStatusMigrating MigrationStatus = "migrating" // 迁移中 MigrationStatusMigrating MigrationStatus = "migrating" // 迁移中
MigrationStatusCompleted MigrationStatus = "completed" // 完成 MigrationStatusCompleted MigrationStatus = "completed" // 完成
MigrationStatusFailed MigrationStatus = "failed" // 失败 MigrationStatusFailed MigrationStatus = "failed" // 失败
MigrationStatusCancelled MigrationStatus = "cancelled" // 取消
) )
// MigrationProgress 迁移进度信息 // MigrationProgress 迁移进度信息
type MigrationProgress struct { type MigrationProgress struct {
Status MigrationStatus `json:"status"` // 迁移状态 Status MigrationStatus `json:"status"` // 迁移状态
CurrentFile string `json:"currentFile"` // 当前处理的文件 Progress float64 `json:"progress"` // 进度百分比 (0-100)
ProcessedFiles int `json:"processedFiles"` // 已处理文件数 Error string `json:"error,omitempty"` // 错误信息
TotalFiles int `json:"totalFiles"` // 总文件数
ProcessedBytes int64 `json:"processedBytes"` // 已处理字节数
TotalBytes int64 `json:"totalBytes"` // 总字节数
Progress float64 `json:"progress"` // 进度百分比 (0-100)
Message string `json:"message"` // 状态消息
Error string `json:"error,omitempty"` // 错误信息
StartTime time.Time `json:"startTime"` // 开始时间
EstimatedTime time.Duration `json:"estimatedTime"` // 估计剩余时间
} }
// MigrationProgressCallback 进度回调函数类型
type MigrationProgressCallback func(progress MigrationProgress)
// MigrationService 迁移服务 // MigrationService 迁移服务
type MigrationService struct { type MigrationService struct {
logger *log.LoggerService logger *log.LoggerService
mu sync.RWMutex mu sync.RWMutex
currentProgress MigrationProgress currentProgress MigrationProgress
progressCallback MigrationProgressCallback cancelFunc context.CancelFunc
cancelFunc context.CancelFunc ctx context.Context
ctx context.Context progressBroadcaster func(MigrationProgress) // WebSocket广播函数
} }
// NewMigrationService 创建迁移服务 // NewMigrationService 创建迁移服务
@@ -64,18 +49,12 @@ func NewMigrationService(logger *log.LoggerService) *MigrationService {
return &MigrationService{ return &MigrationService{
logger: logger, logger: logger,
currentProgress: MigrationProgress{ currentProgress: MigrationProgress{
Status: MigrationStatusIdle, Status: MigrationStatusCompleted, // 初始状态为完成
Progress: 0,
}, },
} }
} }
// SetProgressCallback 设置进度回调
func (ms *MigrationService) SetProgressCallback(callback MigrationProgressCallback) {
ms.mu.Lock()
defer ms.mu.Unlock()
ms.progressCallback = callback
}
// GetProgress 获取当前进度 // GetProgress 获取当前进度
func (ms *MigrationService) GetProgress() MigrationProgress { func (ms *MigrationService) GetProgress() MigrationProgress {
ms.mu.RLock() ms.mu.RLock()
@@ -83,15 +62,15 @@ func (ms *MigrationService) GetProgress() MigrationProgress {
return ms.currentProgress return ms.currentProgress
} }
// updateProgress 更新进度并触发回调 // updateProgress 更新进度
func (ms *MigrationService) updateProgress(progress MigrationProgress) { func (ms *MigrationService) updateProgress(progress MigrationProgress) {
ms.mu.Lock() ms.mu.Lock()
ms.currentProgress = progress ms.currentProgress = progress
callback := ms.progressCallback
ms.mu.Unlock() ms.mu.Unlock()
if callback != nil { // 通过WebSocket广播进度
callback(progress) if ms.progressBroadcaster != nil {
ms.progressBroadcaster(progress)
} }
} }
@@ -111,22 +90,18 @@ func (ms *MigrationService) MigrateDirectory(srcPath, dstPath string) error {
ms.mu.Unlock() ms.mu.Unlock()
}() }()
ms.logger.Info("Migration: Starting directory migration", ms.logger.Info("Migration: Starting directory migration", "from", srcPath, "to", dstPath)
"from", srcPath,
"to", dstPath)
// 初始化进度 // 初始化进度
progress := MigrationProgress{ progress := MigrationProgress{
Status: MigrationStatusPreparing, Status: MigrationStatusMigrating,
Message: "Preparing migration...", Progress: 0,
StartTime: time.Now(),
} }
ms.updateProgress(progress) ms.updateProgress(progress)
// 检查源目录是否存在 // 检查源目录是否存在
if _, err := os.Stat(srcPath); os.IsNotExist(err) { if _, err := os.Stat(srcPath); os.IsNotExist(err) {
progress.Status = MigrationStatusCompleted progress.Status = MigrationStatusCompleted
progress.Message = "Source directory does not exist, skipping migration"
progress.Progress = 100 progress.Progress = 100
ms.updateProgress(progress) ms.updateProgress(progress)
return nil return nil
@@ -137,69 +112,38 @@ func (ms *MigrationService) MigrateDirectory(srcPath, dstPath string) error {
dstAbs, _ := filepath.Abs(dstPath) dstAbs, _ := filepath.Abs(dstPath)
if srcAbs == dstAbs { if srcAbs == dstAbs {
progress.Status = MigrationStatusCompleted progress.Status = MigrationStatusCompleted
progress.Message = "Paths are identical, no migration needed"
progress.Progress = 100 progress.Progress = 100
ms.updateProgress(progress) ms.updateProgress(progress)
return nil return nil
} }
// 检查目标路径是否是源路径的子目录,防止无限递归复制 // 检查目标路径是否是源路径的子目录
if ms.isSubDirectory(srcAbs, dstAbs) { if ms.isSubDirectory(srcAbs, dstAbs) {
progress.Status = MigrationStatusFailed progress.Status = MigrationStatusFailed
progress.Error = "Target path cannot be a subdirectory of source path, this would cause infinite recursive copying" progress.Error = "Target path cannot be a subdirectory of source path"
ms.updateProgress(progress) ms.updateProgress(progress)
return fmt.Errorf("target path cannot be a subdirectory of source path: src=%s, dst=%s", srcAbs, dstAbs) return fmt.Errorf("target path cannot be a subdirectory of source path")
} }
// 计算目录大小(用于显示进度)
totalFiles, totalBytes, err := ms.calculateDirectorySize(ctx, srcPath)
if err != nil {
progress.Status = MigrationStatusFailed
progress.Error = fmt.Sprintf("Failed to calculate directory size: %v", err)
ms.updateProgress(progress)
return err
}
progress.TotalFiles = totalFiles
progress.TotalBytes = totalBytes
progress.Status = MigrationStatusMigrating
progress.Message = "Starting atomic migration..."
ms.updateProgress(progress)
// 执行原子迁移 // 执行原子迁移
err = ms.atomicMove(ctx, srcPath, dstPath, &progress) err := ms.atomicMove(ctx, srcPath, dstPath, &progress)
if err != nil { if err != nil {
if errors.Is(ctx.Err(), context.Canceled) { progress.Status = MigrationStatusFailed
progress.Status = MigrationStatusCancelled progress.Error = err.Error()
progress.Error = "Migration cancelled"
} else {
progress.Status = MigrationStatusFailed
progress.Error = fmt.Sprintf("Migration failed: %v", err)
}
ms.updateProgress(progress) ms.updateProgress(progress)
return err return err
} }
// 迁移完成 // 迁移完成
progress.Status = MigrationStatusCompleted progress.Status = MigrationStatusCompleted
progress.Message = "Migration completed"
progress.Progress = 100 progress.Progress = 100
progress.ProcessedFiles = totalFiles
progress.ProcessedBytes = totalBytes
duration := time.Since(progress.StartTime)
ms.updateProgress(progress) ms.updateProgress(progress)
ms.logger.Info("Migration: Directory migration completed", ms.logger.Info("Migration: Directory migration completed", "from", srcPath, "to", dstPath)
"from", srcPath,
"to", dstPath,
"duration", duration,
"files", totalFiles,
"bytes", totalBytes)
return nil return nil
} }
// atomicMove 原子移动目录 - 使用压缩-移动-解压的方式 // atomicMove 原子移动目录
func (ms *MigrationService) atomicMove(ctx context.Context, srcPath, dstPath string, progress *MigrationProgress) error { func (ms *MigrationService) atomicMove(ctx context.Context, srcPath, dstPath string, progress *MigrationProgress) error {
// 检查是否取消 // 检查是否取消
select { select {
@@ -211,125 +155,98 @@ func (ms *MigrationService) atomicMove(ctx context.Context, srcPath, dstPath str
// 确保目标目录的父目录存在 // 确保目标目录的父目录存在
dstParent := filepath.Dir(dstPath) dstParent := filepath.Dir(dstPath)
if err := os.MkdirAll(dstParent, 0755); err != nil { if err := os.MkdirAll(dstParent, 0755); err != nil {
return fmt.Errorf("Failed to create target parent directory: %v", err) return fmt.Errorf("Failed to create target parent directory")
} }
// 检查目标路径情况 // 检查目标路径情况
if stat, err := os.Stat(dstPath); err == nil { if stat, err := os.Stat(dstPath); err == nil {
if !stat.IsDir() { if !stat.IsDir() {
return fmt.Errorf("Target path exists but is not a directory: %s", dstPath) return fmt.Errorf("Target path exists but is not a directory")
} }
// 检查目录是否为空
isEmpty, err := ms.isDirectoryEmpty(dstPath) isEmpty, err := ms.isDirectoryEmpty(dstPath)
if err != nil { if err != nil {
return fmt.Errorf("Failed to check if target directory is empty: %v", err) return fmt.Errorf("Failed to check target directory")
} }
if !isEmpty { if !isEmpty {
return fmt.Errorf("Target directory is not empty: %s", dstPath) return fmt.Errorf("Target directory is not empty")
} }
// 目录存在但为空,可以继续迁移
ms.logger.Info("Migration: Target directory exists but is empty, proceeding with migration")
} }
// 尝试直接重命名(如果在同一分区,这会很快) // 尝试直接重命名(如果在同一分区,这会很快)
progress.Message = "Attempting fast move..." progress.Progress = 20
ms.updateProgress(*progress) ms.updateProgress(*progress)
if err := os.Rename(srcPath, dstPath); err == nil { if err := os.Rename(srcPath, dstPath); err == nil {
// 重命名成功,这是最快的方式
ms.logger.Info("Migration: Fast rename successful") ms.logger.Info("Migration: Fast rename successful")
return nil return nil
} else {
ms.logger.Info("Migration: Fast rename failed, using copy method", "error", err)
} }
// 重命名失败(可能跨分区),使用原子压缩迁移 // 重命名失败,使用压缩迁移
progress.Message = "Starting atomic compress migration..." progress.Progress = 30
ms.updateProgress(*progress) ms.updateProgress(*progress)
return ms.atomicCompressMove(ctx, srcPath, dstPath, progress) return ms.atomicCompressMove(ctx, srcPath, dstPath, progress)
} }
// atomicCompressMove 原子压缩迁移 - 压缩、移动、解压、清理 // atomicCompressMove 原子压缩迁移
func (ms *MigrationService) atomicCompressMove(ctx context.Context, srcPath, dstPath string, progress *MigrationProgress) error { func (ms *MigrationService) atomicCompressMove(ctx context.Context, srcPath, dstPath string, progress *MigrationProgress) error {
// 创建临时压缩文件
tempDir := os.TempDir() tempDir := os.TempDir()
tempZipFile := filepath.Join(tempDir, fmt.Sprintf("voidraft_migration_%d.zip", time.Now().UnixNano())) tempZipFile := filepath.Join(tempDir, fmt.Sprintf("voidraft_migration_%d.zip", time.Now().UnixNano()))
// 确保临时文件在函数结束时被清理
defer func() { defer func() {
if err := os.Remove(tempZipFile); err != nil && !os.IsNotExist(err) { if err := os.Remove(tempZipFile); err != nil && !os.IsNotExist(err) {
ms.logger.Error("Migration: Failed to clean up temporary zip file", "file", tempZipFile, "error", err) ms.logger.Error("Migration: Failed to clean up temporary zip file", "error", err)
} }
}() }()
// 第一步: 压缩源目录 // 压缩源目录
progress.Message = "Compressing source directory..." progress.Progress = 40
progress.Progress = 10
ms.updateProgress(*progress) ms.updateProgress(*progress)
if err := ms.compressDirectory(ctx, srcPath, tempZipFile, progress); err != nil { if err := ms.compressDirectory(ctx, srcPath, tempZipFile); err != nil {
return fmt.Errorf("Failed to compress source directory: %v", err) return fmt.Errorf("Failed to compress source directory")
} }
// 检查是否取消 // 解压到目标位置
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// 第二步: 解压到目标位置
progress.Message = "Extracting to target location..."
progress.Progress = 70 progress.Progress = 70
ms.updateProgress(*progress) ms.updateProgress(*progress)
if err := ms.extractToDirectory(ctx, tempZipFile, dstPath, progress); err != nil { if err := ms.extractToDirectory(ctx, tempZipFile, dstPath); err != nil {
return fmt.Errorf("Failed to extract to target location: %v", err) return fmt.Errorf("Failed to extract to target location")
} }
// 检查是否取消 // 检查是否取消
select { select {
case <-ctx.Done(): case <-ctx.Done():
// 如果取消,需要清理已解压的目标目录
os.RemoveAll(dstPath) os.RemoveAll(dstPath)
return ctx.Err() return ctx.Err()
default: default:
} }
// 第三步: 删除源目录 // 删除源目录
progress.Message = "Cleaning up source directory..."
progress.Progress = 90 progress.Progress = 90
ms.updateProgress(*progress) ms.updateProgress(*progress)
if err := os.RemoveAll(srcPath); err != nil { if err := os.RemoveAll(srcPath); err != nil {
ms.logger.Error("Migration: Failed to remove source directory", "error", err) ms.logger.Error("Migration: Failed to remove source directory", "error", err)
// 不返回错误,因为迁移已经成功
} }
progress.Message = "Migration completed"
progress.Progress = 100
ms.updateProgress(*progress)
ms.logger.Info("Migration: Atomic compress-move completed successfully")
return nil return nil
} }
// compressDirectory 压缩目录到zip文件 // compressDirectory 压缩目录到zip文件
func (ms *MigrationService) compressDirectory(ctx context.Context, srcDir, zipFile string, progress *MigrationProgress) error { func (ms *MigrationService) compressDirectory(ctx context.Context, srcDir, zipFile string) error {
// 创建zip文件
zipWriter, err := os.Create(zipFile) zipWriter, err := os.Create(zipFile)
if err != nil { if err != nil {
return fmt.Errorf("Failed to create zip file: %v", err) return fmt.Errorf("Failed to create temporary file")
} }
defer zipWriter.Close() defer zipWriter.Close()
// 创建zip writer
zw := zip.NewWriter(zipWriter) zw := zip.NewWriter(zipWriter)
defer zw.Close() defer zw.Close()
// 遍历源目录并添加到zip
return filepath.Walk(srcDir, func(filePath string, info os.FileInfo, err error) error { return filepath.Walk(srcDir, func(filePath string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
@@ -342,31 +259,22 @@ func (ms *MigrationService) compressDirectory(ctx context.Context, srcDir, zipFi
default: default:
} }
// 计算相对路径
relPath, err := filepath.Rel(srcDir, filePath) relPath, err := filepath.Rel(srcDir, filePath)
if err != nil { if err != nil {
return err return err
} }
// 跳过根目录
if relPath == "." { if relPath == "." {
return nil return nil
} }
// 更新当前处理的文件
progress.CurrentFile = relPath
ms.updateProgress(*progress)
// 创建zip中的文件头
header, err := zip.FileInfoHeader(info) header, err := zip.FileInfoHeader(info)
if err != nil { if err != nil {
return err return err
} }
// 使用/作为路径分隔符zip标准
header.Name = strings.ReplaceAll(relPath, string(filepath.Separator), "/") header.Name = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
// 处理目录
if info.IsDir() { if info.IsDir() {
header.Name += "/" header.Name += "/"
header.Method = zip.Store header.Method = zip.Store
@@ -374,13 +282,11 @@ func (ms *MigrationService) compressDirectory(ctx context.Context, srcDir, zipFi
header.Method = zip.Deflate header.Method = zip.Deflate
} }
// 写入zip文件头
writer, err := zw.CreateHeader(header) writer, err := zw.CreateHeader(header)
if err != nil { if err != nil {
return err return err
} }
// 如果是文件,复制内容
if !info.IsDir() { if !info.IsDir() {
file, err := os.Open(filePath) file, err := os.Open(filePath)
if err != nil { if err != nil {
@@ -399,20 +305,17 @@ func (ms *MigrationService) compressDirectory(ctx context.Context, srcDir, zipFi
} }
// extractToDirectory 从zip文件解压到目录 // extractToDirectory 从zip文件解压到目录
func (ms *MigrationService) extractToDirectory(ctx context.Context, zipFile, dstDir string, progress *MigrationProgress) error { func (ms *MigrationService) extractToDirectory(ctx context.Context, zipFile, dstDir string) error {
// 打开zip文件
reader, err := zip.OpenReader(zipFile) reader, err := zip.OpenReader(zipFile)
if err != nil { if err != nil {
return fmt.Errorf("Failed to open zip file: %v", err) return fmt.Errorf("Failed to open temporary file")
} }
defer reader.Close() defer reader.Close()
// 确保目标目录存在
if err := os.MkdirAll(dstDir, 0755); err != nil { if err := os.MkdirAll(dstDir, 0755); err != nil {
return fmt.Errorf("Failed to create target directory: %v", err) return fmt.Errorf("Failed to create target directory")
} }
// 解压每个文件
for _, file := range reader.File { for _, file := range reader.File {
// 检查是否取消 // 检查是否取消
select { select {
@@ -421,19 +324,13 @@ func (ms *MigrationService) extractToDirectory(ctx context.Context, zipFile, dst
default: default:
} }
// 更新当前处理的文件
progress.CurrentFile = file.Name
ms.updateProgress(*progress)
// 构建目标文件路径
dstPath := filepath.Join(dstDir, file.Name) dstPath := filepath.Join(dstDir, file.Name)
// 安全检查防止zip slip攻击 // 安全检查防止zip slip攻击
if !strings.HasPrefix(dstPath, filepath.Clean(dstDir)+string(os.PathSeparator)) { if !strings.HasPrefix(dstPath, filepath.Clean(dstDir)+string(os.PathSeparator)) {
return fmt.Errorf("Invalid file path: %s", file.Name) return fmt.Errorf("Invalid file path in archive")
} }
// 处理目录
if file.FileInfo().IsDir() { if file.FileInfo().IsDir() {
if err := os.MkdirAll(dstPath, file.FileInfo().Mode()); err != nil { if err := os.MkdirAll(dstPath, file.FileInfo().Mode()); err != nil {
return err return err
@@ -441,12 +338,10 @@ func (ms *MigrationService) extractToDirectory(ctx context.Context, zipFile, dst
continue continue
} }
// 确保父目录存在
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
return err return err
} }
// 解压文件
if err := ms.extractFile(file, dstPath); err != nil { if err := ms.extractFile(file, dstPath); err != nil {
return err return err
} }
@@ -457,21 +352,18 @@ func (ms *MigrationService) extractToDirectory(ctx context.Context, zipFile, dst
// extractFile 解压单个文件 // extractFile 解压单个文件
func (ms *MigrationService) extractFile(file *zip.File, dstPath string) error { func (ms *MigrationService) extractFile(file *zip.File, dstPath string) error {
// 打开zip中的文件
srcFile, err := file.Open() srcFile, err := file.Open()
if err != nil { if err != nil {
return err return err
} }
defer srcFile.Close() defer srcFile.Close()
// 创建目标文件
dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, file.FileInfo().Mode()) dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, file.FileInfo().Mode())
if err != nil { if err != nil {
return err return err
} }
defer dstFile.Close() defer dstFile.Close()
// 复制文件内容
_, err = io.Copy(dstFile, srcFile) _, err = io.Copy(dstFile, srcFile)
return err return err
} }
@@ -484,56 +376,23 @@ func (ms *MigrationService) isDirectoryEmpty(dirPath string) (bool, error) {
} }
defer f.Close() defer f.Close()
// 尝试读取一个条目
_, err = f.Readdir(1) _, err = f.Readdir(1)
if err == io.EOF { if err == io.EOF {
// 目录为空
return true, nil return true, nil
} }
if err != nil { if err != nil {
return false, err return false, err
} }
// 目录不为空
return false, nil return false, nil
} }
// isSubDirectory 检查target是否是parent的子目录 // isSubDirectory 检查target是否是parent的子目录
func (ms *MigrationService) isSubDirectory(parent, target string) bool { func (ms *MigrationService) isSubDirectory(parent, target string) bool {
// 确保路径以分隔符结尾,以避免误判
parent = filepath.Clean(parent) + string(filepath.Separator) parent = filepath.Clean(parent) + string(filepath.Separator)
target = filepath.Clean(target) + string(filepath.Separator) target = filepath.Clean(target) + string(filepath.Separator)
// 检查target是否以parent开头
return len(target) > len(parent) && target[:len(parent)] == parent return len(target) > len(parent) && target[:len(parent)] == parent
} }
// calculateDirectorySize 计算目录大小和文件数
func (ms *MigrationService) calculateDirectorySize(ctx context.Context, dirPath string) (int, int64, error) {
var totalFiles int
var totalBytes int64
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// 检查是否取消
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if !info.IsDir() {
totalFiles++
totalBytes += info.Size()
}
return nil
})
return totalFiles, totalBytes, err
}
// CancelMigration 取消迁移 // CancelMigration 取消迁移
func (ms *MigrationService) CancelMigration() error { func (ms *MigrationService) CancelMigration() error {
ms.mu.Lock() ms.mu.Lock()
@@ -545,18 +404,22 @@ func (ms *MigrationService) CancelMigration() error {
return nil return nil
} }
return fmt.Errorf("no active migration to cancel") return fmt.Errorf("No active migration to cancel")
}
// SetProgressBroadcaster 设置进度广播函数
func (ms *MigrationService) SetProgressBroadcaster(broadcaster func(MigrationProgress)) {
ms.mu.Lock()
defer ms.mu.Unlock()
ms.progressBroadcaster = broadcaster
} }
// ServiceShutdown 服务关闭 // ServiceShutdown 服务关闭
func (ms *MigrationService) ServiceShutdown() error { func (ms *MigrationService) ServiceShutdown() error {
ms.logger.Info("Migration: Service is shutting down...") ms.logger.Info("Migration: Service is shutting down...")
// 取消正在进行的迁移
if err := ms.CancelMigration(); err != nil { if err := ms.CancelMigration(); err != nil {
ms.logger.Debug("Migration: No active migration to cancel during shutdown") ms.logger.Debug("Migration: No active migration to cancel during shutdown")
} }
ms.logger.Info("Migration: Service shutdown completed") ms.logger.Info("Migration: Service shutdown completed")
return nil return nil
} }

View File

@@ -15,6 +15,8 @@ type ServiceManager struct {
systemService *SystemService systemService *SystemService
hotkeyService *HotkeyService hotkeyService *HotkeyService
dialogService *DialogService dialogService *DialogService
websocketService *WebSocketService
httpService *HTTPService
logger *log.LoggerService logger *log.LoggerService
} }
@@ -41,8 +43,26 @@ func NewServiceManager() *ServiceManager {
// 初始化对话服务 // 初始化对话服务
dialogService := NewDialogService(logger) dialogService := NewDialogService(logger)
// 初始化 WebSocket 服务
websocketService := NewWebSocketService(logger)
// 初始化 HTTP 服务
httpService := NewHTTPService(logger, websocketService)
// 设置迁移服务的WebSocket广播
migrationService.SetProgressBroadcaster(func(progress MigrationProgress) {
websocketService.BroadcastMigrationProgress(progress)
})
// 启动 HTTP 服务器
err := httpService.StartServer("8899")
if err != nil {
logger.Error("Failed to start HTTP server", "error", err)
panic(err)
}
// 使用新的配置通知系统设置热键配置变更监听 // 使用新的配置通知系统设置热键配置变更监听
err := configService.SetHotkeyChangeCallback(func(enable bool, hotkey *models.HotkeyCombo) error { err = configService.SetHotkeyChangeCallback(func(enable bool, hotkey *models.HotkeyCombo) error {
return hotkeyService.UpdateHotkey(enable, hotkey) return hotkeyService.UpdateHotkey(enable, hotkey)
}) })
if err != nil { if err != nil {
@@ -50,7 +70,7 @@ func NewServiceManager() *ServiceManager {
panic(err) panic(err)
} }
// 设置数据路径变更监听 // 设置数据路径变更监听,处理配置重置和路径变更
err = configService.SetDataPathChangeCallback(func(oldPath, newPath string) error { err = configService.SetDataPathChangeCallback(func(oldPath, newPath string) error {
return documentService.OnDataPathChanged(oldPath, newPath) return documentService.OnDataPathChanged(oldPath, newPath)
}) })
@@ -73,6 +93,8 @@ func NewServiceManager() *ServiceManager {
systemService: systemService, systemService: systemService,
hotkeyService: hotkeyService, hotkeyService: hotkeyService,
dialogService: dialogService, dialogService: dialogService,
websocketService: websocketService,
httpService: httpService,
logger: logger, logger: logger,
} }
} }

View File

@@ -0,0 +1,159 @@
package services
import (
"encoding/json"
"net/http"
"sync"
"github.com/lxzan/gws"
"github.com/wailsapp/wails/v3/pkg/services/log"
)
// WebSocketService WebSocket 服务
type WebSocketService struct {
logger *log.LoggerService
upgrader *gws.Upgrader
clients map[*gws.Conn]bool
clientsMu sync.RWMutex
}
// NewWebSocketService 创建 WebSocket 服务
func NewWebSocketService(logger *log.LoggerService) *WebSocketService {
if logger == nil {
logger = log.New()
}
ws := &WebSocketService{
logger: logger,
clients: make(map[*gws.Conn]bool),
}
// 创建 WebSocket 升级器
ws.upgrader = gws.NewUpgrader(&WebSocketHandler{service: ws}, &gws.ServerOption{
ParallelEnabled: true,
Recovery: gws.Recovery,
PermessageDeflate: gws.PermessageDeflate{Enabled: true},
})
return ws
}
// WebSocketHandler WebSocket 事件处理器
type WebSocketHandler struct {
service *WebSocketService
}
// OnOpen 连接建立时调用
func (h *WebSocketHandler) OnOpen(socket *gws.Conn) {
h.service.logger.Info("WebSocket: Client connected")
h.service.clientsMu.Lock()
h.service.clients[socket] = true
h.service.clientsMu.Unlock()
}
// OnClose 连接关闭时调用
func (h *WebSocketHandler) OnClose(socket *gws.Conn, err error) {
h.service.logger.Info("WebSocket: Client disconnected", "error", err)
h.service.clientsMu.Lock()
delete(h.service.clients, socket)
h.service.clientsMu.Unlock()
}
// OnPing 收到 Ping 时调用
func (h *WebSocketHandler) OnPing(socket *gws.Conn, payload []byte) {
_ = socket.WritePong(nil)
}
// OnPong 收到 Pong 时调用
func (h *WebSocketHandler) OnPong(socket *gws.Conn, payload []byte) {
// Do nothing
}
// OnMessage 收到消息时调用
func (h *WebSocketHandler) OnMessage(socket *gws.Conn, message *gws.Message) {
defer message.Close()
h.service.logger.Debug("WebSocket: Received message", "message", string(message.Bytes()))
}
// HandleUpgrade 处理 WebSocket 升级请求
func (ws *WebSocketService) HandleUpgrade(w http.ResponseWriter, r *http.Request) {
socket, err := ws.upgrader.Upgrade(w, r)
if err != nil {
ws.logger.Error("WebSocket: Failed to upgrade connection", "error", err)
return
}
// 启动读取循环(必须在 goroutine 中运行以防止阻塞)
go func() {
socket.ReadLoop()
}()
}
// BroadcastMessage 广播消息给所有连接的客户端
func (ws *WebSocketService) BroadcastMessage(messageType string, data interface{}) {
message := map[string]interface{}{
"type": messageType,
"data": data,
}
jsonData, err := json.Marshal(message)
if err != nil {
ws.logger.Error("WebSocket: Failed to marshal message", "error", err)
return
}
ws.clientsMu.RLock()
clients := make([]*gws.Conn, 0, len(ws.clients))
for client := range ws.clients {
clients = append(clients, client)
}
ws.clientsMu.RUnlock()
// 使用广播器进行高效广播
broadcaster := gws.NewBroadcaster(gws.OpcodeText, jsonData)
defer broadcaster.Close()
for _, client := range clients {
if err := broadcaster.Broadcast(client); err != nil {
ws.logger.Error("WebSocket: Failed to broadcast to client", "error", err)
// 清理失效的连接
ws.clientsMu.Lock()
delete(ws.clients, client)
ws.clientsMu.Unlock()
}
}
ws.logger.Debug("WebSocket: Broadcasted message", "type", messageType, "clients", len(clients))
}
// BroadcastMigrationProgress 广播迁移进度
func (ws *WebSocketService) BroadcastMigrationProgress(progress MigrationProgress) {
ws.BroadcastMessage("migration_progress", progress)
}
// GetConnectedClientsCount 获取连接的客户端数量
func (ws *WebSocketService) GetConnectedClientsCount() int {
ws.clientsMu.RLock()
defer ws.clientsMu.RUnlock()
return len(ws.clients)
}
// ServiceShutdown 服务关闭
func (ws *WebSocketService) ServiceShutdown() error {
ws.logger.Info("WebSocket: Service is shutting down...")
// 关闭所有客户端连接
ws.clientsMu.Lock()
for client := range ws.clients {
_ = client.WriteClose(1000, []byte("Server shutting down"))
}
ws.clients = make(map[*gws.Conn]bool)
ws.clientsMu.Unlock()
ws.logger.Info("WebSocket: Service shutdown completed")
return nil
}