Skip to content

Commit bcc07d1

Browse files
committed
feat(migrator): Add confirmed data reset
Allow users to clear OpenBitFun destination data before retrying migration. Require directory review and explicit confirmation, protect source and migrator directories, check active writers, and report partial failures. Reload migration state after reset and document the recovery workflow.
1 parent c3d81a4 commit bcc07d1

12 files changed

Lines changed: 806 additions & 8 deletions

File tree

‎src/apps/data-migrator/README.md‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,17 @@ If you have already launched OpenBitFun or run a migration, existing destination
9898

9999
If OpenBitFun contains no new data you need to keep, you can clear its destination data and retry:
100100

101-
1. Fully quit BitFun, OpenBitFun, and the migrator.
102-
2. Back up the following directories, then delete them. **This removes existing OpenBitFun settings, sessions, and other local data.**
103-
3. Run the migrator again, then launch OpenBitFun after migration finishes.
101+
1. Fully quit BitFun, OpenBitFun, their CLI/background writers, and other migrator instances.
102+
2. Open Data Migrator and check the destination locations. Under **Reset OpenBitFun data**, select **Review reset directories**.
103+
3. Save any data and migration logs/backups you need from the listed directories. **Reset permanently removes all their contents, including existing OpenBitFun settings, credentials, assistant workspaces, and sessions.** It does not create a backup.
104+
4. Type `RESET`, then select **Permanently reset OpenBitFun data**. If a directory cannot be fully removed, close applications using it and retry before migrating.
105+
5. Scan again and start a new migration. Launch OpenBitFun after migration finishes.
106+
107+
The reset preview uses the selected destination locations. For standard locations,
108+
it also covers platform Skills/SSH parent directories and Desktop WebView/UI data
109+
under `com.openbitfun.desktop`. It preserves the original BitFun source, the
110+
migrator's preferences, and ordinary project directories outside the listed roots.
111+
Unsafe overlaps and linked reset paths are rejected.
104112

105113
Windows destination directories:
106114

@@ -110,6 +118,12 @@ Windows destination directories:
110118
%LOCALAPPDATA%\OpenBitFun
111119
```
112120

121+
On macOS the standard roots are `~/.openbitfun` and
122+
`~/Library/Application Support/{openbitfun,OpenBitFun}`. On Linux they are
123+
`~/.openbitfun`, `~/.config/openbitfun`, and
124+
`~/.local/share/{openbitfun,OpenBitFun}` (or the corresponding XDG locations).
125+
Always review the actual paths shown by the tool before confirming.
126+
113127
**The issue persists after retrying**
114128

115129
Open a report in [GitHub Issues](https://github.com/GCWing/OpenBitFun/issues) or share your feedback in the OpenBitFun user WeChat group. Include:

‎src/apps/data-migrator/README.zh-CN.md‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,16 @@ Windows 需要 Microsoft Edge WebView2;macOS 使用系统 WebView,Linux 包
7474

7575
如果 OpenBitFun 中没有需要保留的新数据,可以清空目标数据后重新迁移:
7676

77-
1. 完全退出 BitFun、OpenBitFun 和迁移器。
78-
2. 备份以下目录,然后删除它们。**这会清除 OpenBitFun 的现有配置、会话及其他本地数据。**
79-
3. 重新运行迁移器,完成后再启动 OpenBitFun。
77+
1. 完全退出 BitFun、OpenBitFun、CLI 及后台写入进程,以及其他迁移器实例。
78+
2. 打开迁移器,检查目标位置,在“重置 OpenBitFun 数据”中点击“查看待重置目录”。
79+
3. 另存列表目录中需要保留的数据、迁移日志及备份。**重置会永久删除这些目录的全部内容,包括 OpenBitFun 配置、凭据、助理工作区和会话,不会自动备份。**
80+
4. 输入 `RESET`,点击“永久重置 OpenBitFun 数据”。如果部分目录未能完全删除,请关闭占用应用并重试,再进行迁移。
81+
5. 重新扫描并开始新迁移,完成后再启动 OpenBitFun。
82+
83+
重置预览以所选目标位置为准;使用标准位置时,还会包含系统 Skills/SSH 所属的
84+
OpenBitFun 目录,以及 `com.openbitfun.desktop` 下的桌面 WebView/UI 数据。
85+
旧 BitFun 来源、迁移器自身配置和列表之外的普通项目目录会保留。
86+
危险目录重叠、带符号链接或 junction 的重置路径会被拒绝。
8087

8188
Windows 目标目录:
8289

@@ -86,6 +93,12 @@ Windows 目标目录:
8693
%LOCALAPPDATA%\OpenBitFun
8794
```
8895

96+
macOS 标准目录为 `~/.openbitfun` 和
97+
`~/Library/Application Support/{openbitfun,OpenBitFun}`;Linux 为
98+
`~/.openbitfun`、`~/.config/openbitfun` 和
99+
`~/.local/share/{openbitfun,OpenBitFun}`(或对应的 XDG 目录)。
100+
确认前请以迁移器显示的实际路径为准。
101+
89102
**重试后仍然异常**
90103

91104
请在 [GitHub Issues](https://github.com/GCWing/OpenBitFun/issues) 提交问题,或在 OpenBitFun 用户微信群中反馈,并提供:

‎src/apps/data-migrator/src/app_state.rs‎

Lines changed: 246 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use openbitfun_legacy_migration::{
22
atomic_write_json, blocking_writer_processes, export_failure_diagnostics, list_tasks,
33
load_task, probe_legacy_source, save_task, CancellationToken, LegacyMigrationError,
44
LegacyMigrationResult, MigrationEngine, MigrationLayout, MigrationRoots, NoCrashInjection,
5-
ProbeLimits, SavedMigrationTask, WriterProcess,
5+
ProbeLimits, ResetDirectory, SavedMigrationTask, TargetResetResult, WriterProcess,
66
};
77
use openbitfun_legacy_migration_adapters::adapters_for_groups;
88
use openbitfun_product_domains::legacy_migration::{
@@ -175,6 +175,16 @@ pub(crate) struct MigratorView {
175175
pub can_execute: bool,
176176
pub recovery: bool,
177177
pub error: Option<CommandError>,
178+
pub reset_preview: Option<ResetPreviewView>,
179+
pub reset_result: Option<TargetResetResult>,
180+
pub resetting: bool,
181+
}
182+
183+
#[derive(Debug, Clone, Serialize)]
184+
#[serde(rename_all = "camelCase")]
185+
pub(crate) struct ResetPreviewView {
186+
pub confirmation_id: String,
187+
pub directories: Vec<ResetDirectory>,
178188
}
179189

180190
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -201,6 +211,9 @@ struct MigratorSession {
201211
running: bool,
202212
error: Option<CommandError>,
203213
cancellation: CancellationToken,
214+
reset_preview: Option<ResetPreviewView>,
215+
reset_result: Option<TargetResetResult>,
216+
resetting: bool,
204217
}
205218

206219
#[derive(Clone)]
@@ -263,6 +276,9 @@ impl MigratorCoordinator {
263276
blockers: Vec::new(),
264277
running: false,
265278
cancellation: CancellationToken::default(),
279+
reset_preview: None,
280+
reset_result: None,
281+
resetting: false,
266282
})),
267283
}
268284
}
@@ -313,6 +329,8 @@ impl MigratorCoordinator {
313329
session.progress = None;
314330
session.recovery = true;
315331
session.error = None;
332+
session.reset_preview = None;
333+
session.reset_result = None;
316334
Ok(snapshot(&session))
317335
}
318336

@@ -321,6 +339,112 @@ impl MigratorCoordinator {
321339
snapshot(&session)
322340
}
323341

342+
pub(crate) fn preview_reset(&self) -> Result<MigratorView, CommandError> {
343+
let mut session = self.lock();
344+
if session.running {
345+
return Err(CommandError::operation_in_progress());
346+
}
347+
let protected = reset_protected_directories(&session.settings_path)?;
348+
let directories = openbitfun_legacy_migration::plan_target_reset(&session.roots, &protected)
349+
.map_err(|_| CommandError::new("unsafe_reset_locations", "Reset refused: a destination overlaps source data, a protected directory, or a linked path. Check the selected locations.", false))?;
350+
session.reset_preview = Some(ResetPreviewView {
351+
confirmation_id: uuid::Uuid::new_v4().to_string(),
352+
directories,
353+
});
354+
session.reset_result = None;
355+
session.error = None;
356+
Ok(snapshot(&session))
357+
}
358+
359+
pub(crate) fn start_reset(
360+
&self,
361+
confirmation_id: String,
362+
confirmation: String,
363+
) -> Result<MigratorView, CommandError> {
364+
let (roots, preview, protected) = {
365+
let mut session = self.lock();
366+
if session.running {
367+
return Err(CommandError::operation_in_progress());
368+
}
369+
let preview = session
370+
.reset_preview
371+
.clone()
372+
.filter(|preview| preview.confirmation_id == confirmation_id)
373+
.ok_or_else(|| {
374+
CommandError::new(
375+
"reset_confirmation_required",
376+
"Review the reset directories again before continuing.",
377+
true,
378+
)
379+
})?;
380+
if confirmation != "RESET" {
381+
return Err(CommandError::new(
382+
"reset_confirmation_required",
383+
"Type RESET to confirm permanent deletion of OpenBitFun data.",
384+
true,
385+
));
386+
}
387+
let protected = reset_protected_directories(&session.settings_path)?;
388+
session.running = true;
389+
session.resetting = true;
390+
session.progress = None;
391+
session.error = None;
392+
session.reset_result = None;
393+
session.reset_preview = None;
394+
(session.roots.clone(), preview, protected)
395+
};
396+
let coordinator = self.clone();
397+
self.spawn_worker("openbitfun-data-reset", move || {
398+
coordinator.reset_background(roots, preview, protected);
399+
})
400+
}
401+
402+
fn reset_background(
403+
&self,
404+
roots: MigrationRoots,
405+
preview: ResetPreviewView,
406+
protected: Vec<PathBuf>,
407+
) {
408+
let mut blockers = Vec::new();
409+
let result = (|| {
410+
blockers = openbitfun_legacy_migration::blocking_writer_processes_for_product(
411+
0,
412+
&["openbitfun-data-migrator"],
413+
)
414+
.map_err(|error| CommandError::from_legacy(&error))?;
415+
if !blockers.is_empty() {
416+
return Err(CommandError::new("reset_writers_running", "Close BitFun, OpenBitFun, their CLI/background writers, and other Data Migrator instances, then retry reset.", true));
417+
}
418+
openbitfun_legacy_migration::reset_target_data(&roots, &preview.directories, &protected)
419+
.map_err(|_| CommandError::new("reset_scope_changed", "Reset stopped because the directories failed revalidation. Review the paths again; some data may already have been removed.", true))
420+
})();
421+
self.finish_reset(roots, result, blockers);
422+
}
423+
424+
fn finish_reset(
425+
&self,
426+
roots: MigrationRoots,
427+
result: Result<TargetResetResult, CommandError>,
428+
blockers: Vec<WriterProcess>,
429+
) {
430+
let mut session = self.lock();
431+
// Reload even after failure: a partial deletion invalidates old plans.
432+
let replacement = Self::bootstrap_with(roots, session.settings_path.clone());
433+
std::mem::swap(&mut *session, &mut *replacement.lock());
434+
session.blockers = blockers;
435+
match result {
436+
Ok(result) => {
437+
if !result.failed.is_empty() {
438+
session.error = Some(CommandError::new("reset_incomplete", "Some OpenBitFun directories could not be fully removed. Close applications using them, then review and retry reset before migrating.", true));
439+
}
440+
session.reset_result = Some(result);
441+
}
442+
Err(error) => {
443+
session.error = Some(error);
444+
}
445+
}
446+
}
447+
324448
pub(crate) fn export_diagnostics(&self) -> Result<DiagnosticsExportView, CommandError> {
325449
let session = self.lock();
326450
if session.running {
@@ -559,6 +683,9 @@ impl MigratorCoordinator {
559683

560684
pub(crate) fn cancel(&self) -> MigratorView {
561685
let mut session = self.lock();
686+
if session.resetting {
687+
return snapshot(&session);
688+
}
562689
session.cancellation.cancel();
563690
if let Some(progress) = &mut session.progress {
564691
progress.code = if progress.safe_to_cancel {
@@ -600,6 +727,8 @@ impl MigratorCoordinator {
600727
session.cancellation = CancellationToken::default();
601728
session.running = true;
602729
session.error = None;
730+
session.reset_preview = None;
731+
session.reset_result = None;
603732
session.progress = Some(MigrationProgressEvent {
604733
run_id: session.request.run_id.clone(),
605734
phase: MigrationPhase::Scan,
@@ -628,6 +757,7 @@ impl MigratorCoordinator {
628757
{
629758
let mut session = self.lock();
630759
session.running = false;
760+
session.resetting = false;
631761
let error = CommandError::worker_failed();
632762
session.error = Some(error.clone());
633763
return Err(error);
@@ -781,6 +911,34 @@ fn writer_processes() -> LegacyMigrationResult<Vec<WriterProcess>> {
781911
blocking_writer_processes(0)
782912
}
783913

914+
fn reset_protected_directories(settings: &std::path::Path) -> Result<Vec<PathBuf>, CommandError> {
915+
let settings_root = settings.parent().ok_or_else(|| {
916+
CommandError::new(
917+
"unsafe_reset_locations",
918+
"The migrator configuration directory is unavailable.",
919+
false,
920+
)
921+
})?;
922+
let executable = std::env::current_exe().map_err(|_| {
923+
CommandError::new(
924+
"unsafe_reset_locations",
925+
"The migrator installation directory is unavailable.",
926+
false,
927+
)
928+
})?;
929+
let executable_root = executable.parent().ok_or_else(|| {
930+
CommandError::new(
931+
"unsafe_reset_locations",
932+
"The migrator installation directory is unavailable.",
933+
false,
934+
)
935+
})?;
936+
Ok(vec![
937+
settings_root.to_path_buf(),
938+
executable_root.to_path_buf(),
939+
])
940+
}
941+
784942
fn validate_selection(selection: &MigrationSelection) -> Result<(), CommandError> {
785943
if selection.groups.is_empty() {
786944
return Err(CommandError::new(
@@ -829,6 +987,9 @@ fn snapshot(session: &MigratorSession) -> MigratorView {
829987
running: session.running,
830988
recovery: session.recovery,
831989
error: session.error.clone(),
990+
reset_preview: session.reset_preview.clone(),
991+
reset_result: session.reset_result.clone(),
992+
resetting: session.resetting,
832993
}
833994
}
834995

@@ -949,6 +1110,90 @@ mod tests {
9491110
assert!(!settings.exists());
9501111
}
9511112

1113+
#[test]
1114+
fn reset_requires_fresh_confirmation_and_excludes_running_operations() {
1115+
let temp = tempfile::tempdir().unwrap();
1116+
let root = fs::canonicalize(temp.path()).unwrap();
1117+
let roots = fixture_roots(&root);
1118+
fs::create_dir_all(&roots.target_user_root).unwrap();
1119+
let keep = roots.target_user_root.join("keep");
1120+
fs::write(&keep, "target").unwrap();
1121+
let coordinator =
1122+
MigratorCoordinator::bootstrap_with(roots, root.join("tool/locations.json"));
1123+
assert!(coordinator
1124+
.start_reset("stale".into(), "RESET".into())
1125+
.is_err());
1126+
let preview = coordinator.preview_reset().unwrap().reset_preview.unwrap();
1127+
assert!(coordinator
1128+
.start_reset(preview.confirmation_id.clone(), "reset".into())
1129+
.is_err());
1130+
coordinator.preview_reset().unwrap();
1131+
assert!(coordinator
1132+
.start_reset(preview.confirmation_id, "RESET".into())
1133+
.is_err());
1134+
coordinator.lock().running = true;
1135+
assert!(coordinator.preview_reset().is_err());
1136+
assert!(coordinator
1137+
.start_reset("stale".into(), "RESET".into())
1138+
.is_err());
1139+
assert_eq!(fs::read_to_string(keep).unwrap(), "target");
1140+
}
1141+
1142+
#[test]
1143+
fn reset_reloads_history_after_success_or_partial_failure_and_preserves_preferences() {
1144+
let temp = tempfile::tempdir().unwrap();
1145+
let root = fs::canonicalize(temp.path()).unwrap();
1146+
let roots = fixture_roots(&root);
1147+
let settings = root.join("tool/locations.json");
1148+
atomic_write_json(&settings, &roots).unwrap();
1149+
let coordinator = MigratorCoordinator::bootstrap_with(roots.clone(), settings.clone());
1150+
fs::create_dir_all(&roots.target_user_root).unwrap();
1151+
fs::write(roots.target_user_root.join("keep"), "target").unwrap();
1152+
let preview = coordinator.preview_reset().unwrap().reset_preview.unwrap();
1153+
let protected = reset_protected_directories(&settings).unwrap();
1154+
let result = openbitfun_legacy_migration::reset_target_data(
1155+
&roots,
1156+
&preview.directories,
1157+
&protected,
1158+
)
1159+
.unwrap();
1160+
coordinator.finish_reset(roots.clone(), Ok(result), Vec::new());
1161+
let view = coordinator.snapshot();
1162+
assert!(view.reset_result.unwrap().failed.is_empty());
1163+
assert!(view.plan.is_none());
1164+
assert!(view.report.is_none());
1165+
assert!(view.saved_tasks.is_empty());
1166+
assert!(view.reset_preview.is_none());
1167+
assert!(!view.running && !view.resetting);
1168+
assert!(settings.exists());
1169+
assert!(!roots.target_user_root.exists());
1170+
1171+
coordinator.lock().running = true;
1172+
coordinator.lock().resetting = true;
1173+
coordinator.finish_reset(
1174+
roots.clone(),
1175+
Ok(TargetResetResult {
1176+
failed: vec![roots.target_home_root.clone()],
1177+
..Default::default()
1178+
}),
1179+
Vec::new(),
1180+
);
1181+
let view = coordinator.snapshot();
1182+
assert_eq!(view.error.unwrap().code, "reset_incomplete");
1183+
assert!(!view.running && !view.resetting);
1184+
assert!(view.reset_preview.is_none());
1185+
coordinator.finish_reset(
1186+
roots,
1187+
Err(CommandError::new("reset_scope_changed", "Changed", true)),
1188+
Vec::new(),
1189+
);
1190+
assert_eq!(
1191+
coordinator.snapshot().error.unwrap().code,
1192+
"reset_scope_changed"
1193+
);
1194+
assert!(settings.exists());
1195+
}
1196+
9521197
#[test]
9531198
fn unsupported_target_is_rejected_before_scan_or_any_writes() {
9541199
let temp = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)