Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions apps/rgsm-gui/src-tauri/src/ipc_handler.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use crate::{quick_actions, sound};
use rgsm_core::backup::{CreatedBy, ExtraBackupItem, Game, GameDraft, GameSnapshots};
use rgsm_core::cloud_sync::{
self, BatchSyncItemStatus, BatchSyncReport, CancelCloudSyncResult, CloudSyncSessionConfig,
CloudSyncTaskManager, ConflictResolution, ConflictResolutionOutcome, SyncGameOutcome,
self, BatchSyncItemStatus, BatchSyncReport, CancelCloudSyncResult, CloudBackendCheckReport,
CloudSyncSessionConfig, CloudSyncTaskManager, ConflictResolution, ConflictResolutionOutcome,
SyncGameOutcome,
};
use rgsm_core::config::{Config, QuickActionSoundPreferences, get_backup_path, get_config};
use rgsm_core::device::{Device, get_current_device_id};
Expand Down Expand Up @@ -506,12 +507,25 @@ pub async fn open_extra_backup_folder(game: Game) -> Result<bool, String> {
pub async fn check_cloud_backend(
session: CloudSyncSessionConfig,
app_handle: AppHandle,
) -> Result<(), String> {
) -> Result<CloudBackendCheckReport, String> {
info!(target:"rgsm::ipc", "Checking cloud backend: {:?}", session.backend.clone().sanitize());
match svc(&app_handle).check_cloud_backend(&session).await {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep IPC command as thin delegation

AGENTS.md specifically says apps/rgsm-gui/src-tauri/src/ipc_handler.rs is a thin export layer and commands should stay 1-3 lines while delegating to services/domain modules. This change moves outcome-specific control flow and logging into the IPC command, so future backend-check behavior is now split between the service and GUI export layer; please move this reporting/logging decision into a service/domain helper and keep the command as a small delegation wrapper.

Useful? React with 👍 / 👎.

Ok(_) => {
info!(target:"rgsm::ipc", "Successfully checked cloud backend: {:?}", session.backend.sanitize());
Ok(())
Ok(report) => {
if report.is_usable() {
info!(
target:"rgsm::ipc",
"Checked cloud backend with outcome {:?}: {:?}",
report.outcome,
session.backend.sanitize()
);
} else {
warn!(
target:"rgsm::ipc",
"Cloud backend check reported unusable backend: {:?}",
session.backend.sanitize()
);
}
Ok(report)
}
Err(e) => {
error!(target:"rgsm::ipc", "Failed to check cloud backend: {:?}", e);
Expand Down
17 changes: 14 additions & 3 deletions apps/rgsm-gui/src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ async openExtraBackupFolder(game: Game) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any };
}
},
async checkCloudBackend(session: CloudSyncSessionConfig) : Promise<Result<null, string>> {
async checkCloudBackend(session: CloudSyncSessionConfig) : Promise<Result<CloudBackendCheckReport, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("check_cloud_backend", { session }) };
} catch (e) {
Expand Down Expand Up @@ -565,6 +565,11 @@ export type BatchSyncItemStatus = "success" | "cancelled" | { failed: string }
export type BatchSyncReport = { config: BatchSyncItemReport; games: BatchSyncItemReport[] }
export type BuildInfo = { version: string; git_hash: string }
export type CancelCloudSyncResult = "cancelled" | "no_active_operations"
export type CloudBackendCheckItem = { step: CloudBackendCheckStep; status: CloudBackendCheckItemStatus; critical: boolean; message: string | null }
export type CloudBackendCheckItemStatus = "passed" | "warning" | "failed"
export type CloudBackendCheckOutcome = "available" | "degraded" | "unavailable"
export type CloudBackendCheckReport = { outcome: CloudBackendCheckOutcome; items: CloudBackendCheckItem[] }
export type CloudBackendCheckStep = "prepare_backend" | "list_files" | "write_file" | "read_file" | "verify_content" | "delete_file"
export type CloudSettings = {
/**
* 同步间隔,单位分钟,为0则不自动同步
Expand Down Expand Up @@ -629,7 +634,13 @@ devices?: Partial<{ [key in string]: Device }> }
/**
* What the user chose to do when a conflict is detected.
*/
export type ConflictResolution = "keep_local" | "accept_remote" | "fork" | "cancelled"
export type ConflictResolution = "keep_local" | "accept_remote" |
/**
* Preserve both local and remote branches without merging.
*
* TODO: implement branch selection and upload semantics for this git-like fork workflow.
*/
"fork" | "cancelled"
export type ConflictResolutionOutcome = "cancelled" | "kept_local" | "accepted_remote"
/**
* Tracks how a snapshot was created.
Expand Down Expand Up @@ -903,7 +914,7 @@ export type SaveUnitDraft = { id?: number | null; unit_type: SaveUnitType; paths
*/
export type SaveUnitType = "File" | "Folder" |
/**
* Windows Registry key tree (stored as `registry.json` inside the archive).
* Windows Registry key tree (stored as `registry.reg` inside new archives).
*/
"WinRegistry"
export type Settings = { prompt_when_not_described?: boolean; extra_backup_when_apply?: boolean; show_edit_button?: boolean; prompt_when_auto_backup?: boolean; exit_to_tray?: boolean; cloud_settings?: CloudSettings; locale?: string; default_delete_before_apply?: boolean; default_expend_favorites_tree?: boolean; home_page?: string; log_to_file?: boolean; add_new_to_favorites?: boolean; vn_scan_dirs?: string[]; save_list_expand_behavior?: SaveListExpandBehavior; save_list_last_expanded?: boolean; max_auto_backup_count?: number;
Expand Down
247 changes: 247 additions & 0 deletions apps/rgsm-gui/src/components/BackendCheckResult.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
<script setup lang="ts">
import { computed, type Component } from 'vue';
import {
CircleCheckFilled,
CircleCloseFilled,
InfoFilled,
WarningFilled,
} from '@element-plus/icons-vue';
import { $t } from '../i18n';
import type { CloudBackendCheckReport } from '../bindings';

type CheckOutcome = CloudBackendCheckReport['outcome'];
type CheckItem = CloudBackendCheckReport['items'][number];
type TagType = 'success' | 'warning' | 'danger' | 'info';

const props = defineProps<{
report: CloudBackendCheckReport;
}>();

const OUTCOME_META: Record<
CheckOutcome,
{ icon: Component; type: TagType; titleKey: string; descKey: string; badgeKey: string }
> = {
available: {
icon: CircleCheckFilled,
type: 'success',
titleKey: 'sync_settings.check_result.available_title',
descKey: 'sync_settings.check_result.available_desc',
badgeKey: 'sync_settings.check_result.available_badge',
},
degraded: {
icon: WarningFilled,
type: 'warning',
titleKey: 'sync_settings.check_result.degraded_title',
descKey: 'sync_settings.check_result.degraded_desc',
badgeKey: 'sync_settings.check_result.degraded_badge',
},
unavailable: {
icon: CircleCloseFilled,
type: 'danger',
titleKey: 'sync_settings.check_result.unavailable_title',
descKey: 'sync_settings.check_result.unavailable_desc',
badgeKey: 'sync_settings.check_result.unavailable_badge',
},
};

const STEP_LABELS: Record<CheckItem['step'], string> = {
prepare_backend: 'sync_settings.check_result.steps.prepare_backend',
list_files: 'sync_settings.check_result.steps.list_files',
write_file: 'sync_settings.check_result.steps.write_file',
read_file: 'sync_settings.check_result.steps.read_file',
verify_content: 'sync_settings.check_result.steps.verify_content',
delete_file: 'sync_settings.check_result.steps.delete_file',
};

const outcomeMeta = computed(() => OUTCOME_META[props.report.outcome]);

function itemIcon(item: CheckItem) {
if (item.status === 'passed') return CircleCheckFilled;
if (item.status === 'warning') return WarningFilled;
return CircleCloseFilled;
}

function itemTagType(item: CheckItem): TagType {
if (item.status === 'passed') return 'success';
if (item.status === 'warning') return 'warning';
return 'danger';
}

function itemKindLabel(item: CheckItem) {
return item.critical
? $t('sync_settings.check_result.required')
: $t('sync_settings.check_result.optional');
}
</script>

<template>
<section class="backend-check-result" :class="`is-${report.outcome}`" aria-live="polite">
<header class="check-summary">
<span class="summary-icon" :class="`is-${report.outcome}`">
<ElIcon><component :is="outcomeMeta.icon" /></ElIcon>
</span>
<div class="summary-copy">
<div class="summary-title">{{ $t(outcomeMeta.titleKey) }}</div>
<div class="summary-desc">{{ $t(outcomeMeta.descKey) }}</div>
</div>
<ElTag size="small" :type="outcomeMeta.type" effect="light" round>
{{ $t(outcomeMeta.badgeKey) }}
</ElTag>
</header>

<div class="check-items">
<ElTooltip
v-for="item in report.items"
:key="item.step"
:content="item.message ?? ''"
:disabled="!item.message"
placement="top-start"
:show-after="250"
>
<div class="check-item" :class="`is-${item.status}`">
<span class="item-icon">
<ElIcon><component :is="itemIcon(item)" /></ElIcon>
</span>
<span class="item-label">{{ $t(STEP_LABELS[item.step]) }}</span>
<ElTag size="small" :type="itemTagType(item)" effect="plain" round>
{{ itemKindLabel(item) }}
</ElTag>
<ElIcon v-if="item.message" class="detail-icon"><InfoFilled /></ElIcon>
</div>
</ElTooltip>
</div>
</section>
</template>

<style scoped>
.backend-check-result {
width: min(100%, 520px);
padding: 12px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: var(--el-bg-color);
box-shadow: inset 3px 0 0 var(--el-border-color);
}

.backend-check-result.is-available {
border-color: var(--el-color-success-light-5);
box-shadow: inset 3px 0 0 var(--el-color-success);
}

.backend-check-result.is-degraded {
border-color: var(--el-color-warning-light-5);
box-shadow: inset 3px 0 0 var(--el-color-warning);
}

.backend-check-result.is-unavailable {
border-color: var(--el-color-danger-light-5);
box-shadow: inset 3px 0 0 var(--el-color-danger);
}

.check-summary {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
}

.summary-icon,
.item-icon {
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
color: var(--el-text-color-primary);
}

.summary-icon.is-available {
color: var(--el-color-success);
background: var(--el-color-success-light-9);
}

.summary-icon.is-degraded {
color: var(--el-color-warning);
background: var(--el-color-warning-light-9);
}

.summary-icon.is-unavailable {
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
}

.summary-copy {
min-width: 0;
}

.summary-title {
color: var(--el-text-color-primary);
font-size: 0.94rem;
font-weight: 600;
line-height: 1.3;
}

.summary-desc {
margin-top: 2px;
color: var(--el-text-color-secondary);
font-size: 0.82rem;
line-height: 1.4;
}

.check-items {
display: grid;
gap: 6px;
margin-top: 12px;
}

.check-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 6px 8px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background: var(--el-fill-color-blank);
}

.check-item.is-passed .item-icon {
color: var(--el-color-success);
}

.check-item.is-warning .item-icon {
color: var(--el-color-warning);
}

.check-item.is-failed .item-icon {
color: var(--el-color-danger);
}

.item-label {
min-width: 0;
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 0.84rem;
text-overflow: ellipsis;
white-space: nowrap;
}

.detail-icon {
color: var(--el-text-color-secondary);
}

@media (max-width: 640px) {
.check-summary,
.check-item {
grid-template-columns: auto minmax(0, 1fr);
}

.check-summary :deep(.el-tag),
.check-item :deep(.el-tag),
.detail-icon {
justify-self: start;
grid-column: 2;
}
}
</style>
Loading