Repository navigation
Expand file tree
/
Copy pathOpenCodeRelocator.cs
More file actions
3272 lines (2907 loc) · 133 KB
/
Copy pathOpenCodeRelocator.cs
File metadata and controls
3272 lines (2907 loc) · 133 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
using System.Windows.Forms;
namespace OpenCodeRelocator
{
// ================================================================ 基础数据
internal static class Paths
{
public static readonly string LocalAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
public static readonly string RoamingAppData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
public static readonly string UserProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
public static string App = Path.Combine(LocalAppData, @"Programs\@openchamberelectron");
public static string Roaming = Path.Combine(RoamingAppData, "OpenChamber");
public static string Config = Path.Combine(UserProfile, @".config\openchamber");
public static string UpdaterCache = Path.Combine(LocalAppData, @"@openchamberelectron-updater");
// OpenCode 官方桌面端
public static string OpenCodeApp = Path.Combine(LocalAppData, @"Programs\@opencodedesktop");
public static string OpenCodeRoaming = Path.Combine(RoamingAppData, "ai.opencode.desktop");
public static string OpenCodeConfig = Path.Combine(UserProfile, @".config\opencode");
public static string OpenCodeShare = Path.Combine(UserProfile, @".local\share\opencode");
public static string OpenCodeCache = Path.Combine(LocalAppData, @"@opencodedesktop-updater");
public static string Data = Path.Combine(LocalAppData, "OpenCodeRelocator");
public static string JournalFile { get { return Path.Combine(Data, "journal.json"); } }
}
// 一个可迁移的应用:目录集合、进程名、记录文件都各自独立
internal sealed class AppProfile
{
public string Key;
public string Name; // 完整名,如 OpenCode 桌面端
public string ShortName; // 状态胶囊用
public string Folder; // 默认目标文件夹名
public string App;
public string Roaming;
public string Config;
public string Share; // 可选:会话/凭据等第二份数据
public string UpdaterCache;
public string ExeName;
public string JournalFile;
}
internal sealed class TreeInfo
{
public int Files;
public long Bytes;
public int Links; // 目录联接/符号链接数量:复制时必须原样保留,校验时要对上
}
internal sealed class JournalEntry
{
public string Name { get; set; }
public string Source { get; set; }
public string Target { get; set; }
public string Backup { get; set; }
public string MovedAt { get; set; }
public string State { get; set; } // pending=已改名备份但联接未建好,done=已完成
}
internal sealed class MigItem
{
public string Key;
public string Name;
public string Subdir;
public string Source;
}
internal delegate void Logger(string message, int level);
// ================================================================ 迁移逻辑
internal static class Ops
{
public const int Info = 0;
public const int Ok = 1;
public const int Warn = 2;
public const int Error = 3;
public static string FormatSize(long bytes)
{
if (bytes >= 1024L * 1024 * 1024) return string.Format("{0:N2} GB", bytes / 1073741824.0);
if (bytes >= 1024L * 1024) return string.Format("{0:N1} MB", bytes / 1048576.0);
if (bytes >= 1024L) return string.Format("{0:N0} KB", bytes / 1024.0);
return bytes + " B";
}
private static string LongPath(string path)
{
if (path.StartsWith(@"\\?\")) return path;
if (path.StartsWith(@"\\")) return @"\\?\UNC\" + path.Substring(2);
if (path.Length > 3) path = path.TrimEnd('\\');
if (path.EndsWith(":")) path += "\\";
return @"\\?\" + path;
}
// 目录统计:直接走 Win32,避免 .NET Framework 对 \\?\ 前缀的非法字符检查,
// 同时跳过重解析点,防止联接被重复统计或形成环。
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct WIN32_FIND_DATA
{
public uint dwFileAttributes;
public uint ftCreationTimeLow;
public uint ftCreationTimeHigh;
public uint ftLastAccessTimeLow;
public uint ftLastAccessTimeHigh;
public uint ftLastWriteTimeLow;
public uint ftLastWriteTimeHigh;
public uint nFileSizeHigh;
public uint nFileSizeLow;
public uint dwReserved0;
public uint dwReserved1;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string cFileName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)] public string cAlternateFileName;
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr FindFirstFileW(string pattern, out WIN32_FIND_DATA data);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool FindNextFileW(IntPtr handle, out WIN32_FIND_DATA data);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool FindClose(IntPtr handle);
private const uint FILE_ATTRIBUTE_DIRECTORY = 0x10;
private const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x400;
public static TreeInfo GetTreeInfo(string path)
{
var info = new TreeInfo();
if (!Directory.Exists(path)) return info;
var pending = new Stack<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
pending.Push(path);
seen.Add(path);
while (pending.Count > 0)
{
string dir = pending.Pop();
WIN32_FIND_DATA data;
IntPtr handle = FindFirstFileW(LongPath(dir) + @"\*", out data);
if (handle == new IntPtr(-1)) continue;
try
{
do
{
string name = data.cFileName;
if (string.IsNullOrEmpty(name) || name == "." || name == "..") continue;
if ((data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0)
{
// 目录联接/符号链接:只计数不进去,避免成环;复制时按链接原样搬走
if ((data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { info.Links++; continue; }
string sub = Path.Combine(dir, name);
if (seen.Add(sub)) pending.Push(sub);
}
else
{
info.Files++;
info.Bytes += ((long)data.nFileSizeHigh << 32) + data.nFileSizeLow;
}
}
while (FindNextFileW(handle, out data));
}
finally
{
FindClose(handle);
}
}
return info;
}
public static long GetFreeSpace(string anyPath)
{
try
{
string root = Path.GetPathRoot(Path.GetFullPath(anyPath));
if (string.IsNullOrEmpty(root)) return -1;
return new DriveInfo(root).AvailableFreeSpace;
}
catch { return -1; }
}
public static bool IsJunction(string path)
{
try
{
if (!Directory.Exists(path)) return false;
return (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
}
catch { return false; }
}
public static void OpenFolder(string path)
{
try
{
if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) return;
var psi = new ProcessStartInfo("explorer.exe", "\"" + path + "\"");
psi.UseShellExecute = true;
Process.Start(psi);
}
catch { }
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr CreateFileW(string name, uint access, uint share, IntPtr sec, uint disp, uint flags, IntPtr templ);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool DeviceIoControl(IntPtr handle, uint code, IntPtr inBuf, int inSize,
IntPtr outBuf, int outSize, out int returned, IntPtr overlapped);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr handle);
public static string JunctionTarget(string path)
{
const uint GENERIC_READ = 0x80000000;
const uint SHARE_ALL = 7;
const uint OPEN_EXISTING = 3;
const uint BACKUP_SEMANTICS = 0x02000000;
const uint OPEN_REPARSE_POINT = 0x00200000;
const uint FSCTL_GET_REPARSE_POINT = 0x000900A8;
const int IO_REPARSE_TAG_MOUNT_POINT = unchecked((int)0xA0000003);
IntPtr handle = CreateFileW(path, GENERIC_READ, SHARE_ALL, IntPtr.Zero, OPEN_EXISTING,
BACKUP_SEMANTICS | OPEN_REPARSE_POINT, IntPtr.Zero);
if (handle == new IntPtr(-1)) return null;
IntPtr buffer = IntPtr.Zero;
try
{
buffer = Marshal.AllocHGlobal(16384);
int returned;
if (!DeviceIoControl(handle, FSCTL_GET_REPARSE_POINT, IntPtr.Zero, 0, buffer, 16384, out returned, IntPtr.Zero))
return null;
if (Marshal.ReadInt32(buffer, 0) != IO_REPARSE_TAG_MOUNT_POINT) return null;
int subOffset = Marshal.ReadInt16(buffer, 8);
int subLength = Marshal.ReadInt16(buffer, 10);
string name = Marshal.PtrToStringUni(new IntPtr(buffer.ToInt64() + 16 + subOffset), subLength / 2);
if (name != null && name.StartsWith(@"\??\")) name = name.Substring(4);
return name;
}
catch { return null; }
finally
{
if (buffer != IntPtr.Zero) Marshal.FreeHGlobal(buffer);
CloseHandle(handle);
}
}
public static void CreateJunction(string link, string target)
{
Run("cmd.exe", "/c mklink /J \"" + link + "\" \"" + target + "\"", "创建 junction 失败");
}
public static void RemoveJunction(string link)
{
if (!IsJunction(link)) return;
string output;
int code = RunProcess("cmd.exe", "/c rmdir \"" + link + "\"", out output);
if (code != 0 && Directory.Exists(link))
throw new Exception("删除 junction 失败:" + output.Trim());
}
// 读 stdout/stderr 必须同时进行,否则管道写满会互相等死,迁移会永久卡住
private static int RunProcess(string file, string args, out string output)
{
var psi = new ProcessStartInfo(file, args);
psi.UseShellExecute = false;
psi.CreateNoWindow = true;
psi.RedirectStandardOutput = true;
psi.RedirectStandardError = true;
using (Process p = Process.Start(psi))
{
var buffer = new StringBuilder();
var sync = new object();
p.OutputDataReceived += delegate(object sender, DataReceivedEventArgs e)
{
if (e.Data == null) return;
lock (sync) buffer.AppendLine(e.Data);
};
p.ErrorDataReceived += delegate(object sender, DataReceivedEventArgs e)
{
if (e.Data == null) return;
lock (sync) buffer.AppendLine(e.Data);
};
p.BeginOutputReadLine();
p.BeginErrorReadLine();
p.WaitForExit();
p.WaitForExit(); // 第二次调用确保异步输出已经收完
lock (sync) output = buffer.ToString();
return p.ExitCode;
}
}
private static void Run(string file, string args, string errorText)
{
string output;
int code = RunProcess(file, args, out output);
if (code != 0) throw new Exception(errorText + ":" + output.Trim());
}
public static void CopyTree(string source, string target)
{
Directory.CreateDirectory(target);
// /SJ /SL:目录联接与符号链接按链接本身复制(不跟随、不丢弃),
// 和 GetTreeInfo 的统计口径一致;复制后还会比对链接数量,防止悄悄丢东西
string args = "/E /SJ /SL /COPY:DAT /DCOPY:DAT /R:1 /W:1 /NFL /NDL /NJH /NJS /MT:8 \"" + source + "\" \"" + target + "\"";
string output;
int code = RunProcess("robocopy.exe", args, out output);
if (code >= 8)
{
string tail = output.Length > 700 ? output.Substring(output.Length - 700) : output;
throw new Exception("robocopy 失败 (exit " + code + "):" + tail.Trim());
}
}
public static void ForceDeleteDir(string dir)
{
if (!Directory.Exists(dir)) return;
try
{
Directory.Delete(dir, true);
return;
}
catch { }
// 长路径或只读文件的兜底:先清属性,再用 rd /s /q
try
{
var pending = new Stack<string>();
pending.Push(dir);
while (pending.Count > 0)
{
string current = pending.Pop();
WIN32_FIND_DATA data;
IntPtr handle = FindFirstFileW(LongPath(current) + @"\*", out data);
if (handle == new IntPtr(-1)) continue;
try
{
do
{
string name = data.cFileName;
if (name == "." || name == "..") continue;
string full = Path.Combine(current, name);
if ((data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0)
{
if ((data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0) pending.Push(full);
}
else
{
try { File.SetAttributes(full, FileAttributes.Normal); }
catch { }
}
}
while (FindNextFileW(handle, out data));
}
finally
{
FindClose(handle);
}
}
}
catch { }
string rdOutput;
RunProcess("cmd.exe", "/c rd /s /q \"" + dir + "\"", out rdOutput);
if (Directory.Exists(dir) && GetTreeInfo(dir).Files > 0)
throw new Exception("部分文件无法删除:" + dir);
}
public static void MoveDir(string source, string target)
{
try { Directory.Move(source, target); }
catch (IOException)
{
CopyTree(source, target);
var a = GetTreeInfo(source);
var b = GetTreeInfo(target);
if (a.Files != b.Files || a.Bytes != b.Bytes)
throw new Exception("跨盘搬迁校验不一致:" + a.Files + " / " + b.Files);
ForceDeleteDir(source);
}
}
public static List<AppProfile> Profiles()
{
var list = new List<AppProfile>();
list.Add(new AppProfile
{
Key = "openchamber",
Name = "OpenChamber",
ShortName = "OpenChamber",
Folder = "OpenChamber",
App = Paths.App,
Roaming = Paths.Roaming,
Config = Paths.Config,
UpdaterCache = Paths.UpdaterCache,
ExeName = "OpenChamber.exe",
JournalFile = Path.Combine(Paths.Data, "journal.json")
});
list.Add(new AppProfile
{
Key = "opencode",
Name = "OpenCode 桌面端",
ShortName = "OpenCode",
Folder = "OpenCode",
App = Paths.OpenCodeApp,
Roaming = Paths.OpenCodeRoaming,
Config = Paths.OpenCodeConfig,
Share = Paths.OpenCodeShare,
UpdaterCache = Paths.OpenCodeCache,
ExeName = "OpenCode.exe",
JournalFile = Path.Combine(Paths.Data, "journal-opencode.json")
});
return list;
}
private static bool Under(string path, string dir)
{
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(dir)) return false;
return path.StartsWith(dir.TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase);
}
// 比 Process.MainModule 快得多,也不会因为权限不足拖慢整轮扫描
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool QueryFullProcessImageNameW(IntPtr handle, uint flags, StringBuilder name, ref int size);
private static string ProcessPath(int pid)
{
IntPtr handle = OpenProcess(0x1000, false, pid); // PROCESS_QUERY_LIMITED_INFORMATION
if (handle == IntPtr.Zero) return null;
try
{
var buffer = new StringBuilder(1024);
int size = buffer.Capacity;
if (!QueryFullProcessImageNameW(handle, 0, buffer, ref size)) return null;
return buffer.ToString(0, size);
}
catch { return null; }
finally { CloseHandle(handle); }
}
// 进程镜像路径可能已经被系统解析成联接的真实目标,两个位置都要认
private static List<string> AppRoots(AppProfile profile)
{
var roots = new List<string>();
AddRoot(roots, profile.App);
AddRoot(roots, profile.Roaming);
AddRoot(roots, profile.Config);
AddRoot(roots, profile.Share);
return roots;
}
private static void AddRoot(List<string> roots, string path)
{
if (string.IsNullOrEmpty(path)) return;
roots.Add(path.TrimEnd('\\'));
try
{
if (IsJunction(path))
{
string target = JunctionTarget(path);
if (!string.IsNullOrEmpty(target)) roots.Add(target.TrimEnd('\\'));
}
}
catch { }
}
private static bool UnderAny(string file, List<string> roots)
{
if (string.IsNullOrEmpty(file)) return false;
foreach (string root in roots)
if (Under(file, root)) return true;
return false;
}
public static List<Process> FindAppProcesses(AppProfile profile)
{
List<string> roots = AppRoots(profile);
var result = new List<Process>();
foreach (Process p in Process.GetProcesses())
{
string file = null;
try { file = ProcessPath(p.Id); }
catch { }
// 路径读不到就不当作目标进程,避免误杀同名程序
bool hit = !string.IsNullOrEmpty(file) && UnderAny(file, roots);
if (hit) result.Add(p);
else p.Dispose();
}
return result;
}
private static List<string> GuiRoots(AppProfile profile)
{
var roots = new List<string>();
AddRoot(roots, profile.App);
return roots;
}
// 只统计图形界面进程:后台服务(如 opencode-cli)不代表应用开着
public static Dictionary<string, bool> GuiRunningStates(List<AppProfile> list)
{
var result = new Dictionary<string, bool>();
var roots = new Dictionary<string, List<string>>();
foreach (AppProfile p in list)
{
result[p.Key] = false;
roots[p.Key] = GuiRoots(p);
}
foreach (Process proc in Process.GetProcesses())
{
try
{
string file = null;
try { file = ProcessPath(proc.Id); }
catch { }
if (string.IsNullOrEmpty(file)) continue;
string baseName;
try { baseName = Path.GetFileName(file); }
catch { continue; }
foreach (AppProfile p in list)
{
if (result[p.Key]) continue;
if (!string.Equals(baseName, p.ExeName, StringComparison.OrdinalIgnoreCase)) continue;
if (UnderAny(file, roots[p.Key])) result[p.Key] = true;
}
}
finally
{
// Process 对象会一直占着句柄,及时释放
try { proc.Dispose(); } catch { }
}
}
return result;
}
public static bool IsGuiRunning(AppProfile profile)
{
var list = new List<AppProfile>();
list.Add(profile);
bool value;
return GuiRunningStates(list).TryGetValue(profile.Key, out value) && value;
}
public static bool IsAppRunning(AppProfile profile)
{
return IsGuiRunning(profile);
}
public static void StopApp(AppProfile profile, Logger log)
{
List<Process> procs = FindAppProcesses(profile);
if (procs.Count == 0) { log(profile.ShortName + " 未在运行", Info); return; }
var pending = new List<int>();
foreach (Process p in procs)
{
int pid = p.Id;
string name = "?";
try { name = p.ProcessName; } catch { }
try { p.Kill(); log("已结束进程 " + name + " (PID " + pid + ")", Info); pending.Add(pid); }
catch (Exception ex) { log("无法结束 PID " + pid + ":" + ex.Message, Warn); }
finally { try { p.Dispose(); } catch { } }
}
// 只复查刚结束的那几个 PID,不要每 250ms 就全量重扫一遍
for (int i = 0; i < 24; i++)
{
System.Threading.Thread.Sleep(250);
if (!AnyAlive(pending)) { log(profile.ShortName + " 已关闭", Ok); return; }
}
throw new Exception(profile.ShortName + " 进程仍在运行,请手动退出后重试。");
}
private static bool AnyAlive(List<int> pids)
{
foreach (int pid in pids)
{
try
{
using (Process p = Process.GetProcessById(pid)) { if (!p.HasExited) return true; }
}
catch { }
}
return false;
}
public static void StartApp(AppProfile profile)
{
string exe = Path.Combine(profile.App, profile.ExeName);
if (!File.Exists(exe)) throw new Exception("找不到 " + exe);
Process.Start(exe);
}
public static string LastJournalError;
public static List<JournalEntry> LoadJournal(AppProfile profile)
{
LastJournalError = null;
try
{
if (!File.Exists(profile.JournalFile)) return new List<JournalEntry>();
string json = File.ReadAllText(profile.JournalFile, Encoding.UTF8);
if (string.IsNullOrWhiteSpace(json)) return new List<JournalEntry>();
var list = new JavaScriptSerializer().Deserialize<List<JournalEntry>>(json);
return list ?? new List<JournalEntry>();
}
catch (Exception ex)
{
LastJournalError = profile.JournalFile + " 解析失败:" + ex.Message;
return new List<JournalEntry>();
}
}
public static void SaveJournal(AppProfile profile, List<JournalEntry> entries)
{
string dir = Path.GetDirectoryName(profile.JournalFile);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
// 先写临时文件再原子替换:中途断电不会留下半截 json
string tmp = profile.JournalFile + ".tmp";
File.WriteAllText(tmp, new JavaScriptSerializer().Serialize(entries), new UTF8Encoding(true));
try
{
if (File.Exists(profile.JournalFile)) File.Replace(tmp, profile.JournalFile, null);
else File.Move(tmp, profile.JournalFile);
}
catch
{
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
File.WriteAllText(profile.JournalFile, new JavaScriptSerializer().Serialize(entries), new UTF8Encoding(true));
}
}
public static void ClearJournal(AppProfile profile)
{
try { if (File.Exists(profile.JournalFile)) File.Delete(profile.JournalFile); }
catch { }
}
public static List<MigItem> AllItems(AppProfile profile)
{
var list = new List<MigItem>();
bool opencode = profile.Key == "opencode";
list.Add(new MigItem { Key = "app", Name = "程序目录", Subdir = "app", Source = profile.App });
list.Add(new MigItem { Key = "roaming", Name = "应用数据", Subdir = @"data\roaming", Source = profile.Roaming });
list.Add(new MigItem
{
Key = "config",
Name = opencode ? "配置" : "配置与会话",
Subdir = @"data\config",
Source = profile.Config
});
if (!string.IsNullOrEmpty(profile.Share))
{
list.Add(new MigItem { Key = "share", Name = "会话与登录", Subdir = @"data\share", Source = profile.Share });
}
return list;
}
public static bool SamePath(string a, string b)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
return string.Equals(a.TrimEnd('\\'), b.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
}
private static bool IsInside(string parent, string child)
{
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(child)) return false;
parent = parent.TrimEnd('\\');
return child.TrimEnd('\\').StartsWith(parent + "\\", StringComparison.OrdinalIgnoreCase);
}
// 目标目录校验(纯逻辑,不弹窗,方便测试)
public static bool ValidateTargetPath(string text, List<string> sources, out string root, out string error)
{
root = (text ?? "").Trim().TrimEnd('\\');
error = null;
if (root.Length == 0) { error = "请先填写目标目录,例如 E:\\Apps\\OpenChamber"; return false; }
if (root.Length == 2 && root[1] == ':') { error = "请写完整路径,例如 E:\\Apps\\OpenChamber"; return false; }
if (!Path.IsPathRooted(root)) { error = "目标必须是绝对路径,例如 E:\\Apps\\OpenChamber"; return false; }
try { root = Path.GetFullPath(root); }
catch { error = "目标路径无效。"; return false; }
string driveRoot = Path.GetPathRoot(root);
try
{
var drive = new DriveInfo(driveRoot);
if (!drive.IsReady) { error = "目标磁盘不可用(没有插盘或未就绪):" + driveRoot; return false; }
// 目录联接只能指向本地卷:固定盘或移动盘都行,网络盘/光驱不行
if (drive.DriveType != DriveType.Fixed && drive.DriveType != DriveType.Removable)
{
error = "目标必须是本地磁盘(当前为 " + drive.DriveType + "):" + driveRoot;
return false;
}
}
catch (Exception ex)
{
error = "无法访问目标磁盘:" + driveRoot + "(" + ex.Message + ")";
return false;
}
// 目录联接要求 NTFS/FAT32 以外的文件系统其实不支持,先查清楚
string fs = VolumeFileSystem(driveRoot);
if (!string.IsNullOrEmpty(fs) &&
!string.Equals(fs, "NTFS", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(fs, "ReFS", StringComparison.OrdinalIgnoreCase))
{
error = "目标磁盘必须是 NTFS 格式(当前是 " + fs + "),目录联接无法建立:" + driveRoot;
return false;
}
if (sources != null)
{
foreach (string source in sources)
{
if (string.IsNullOrEmpty(source)) continue;
if (SamePath(source, root))
{
error = "目标目录和源目录是同一个地方,换一个位置:\r\n" + root;
return false;
}
if (IsInside(source, root) || IsInside(root, source))
{
error = "目标目录不能与源目录互相包含:\r\n" + root;
return false;
}
}
}
return true;
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool GetVolumeInformation(string root, StringBuilder volumeName, int volumeNameSize,
out int volumeSerial, out int maxComponentLength, out int fileSystemFlags,
StringBuilder fileSystemName, int fileSystemNameSize);
public static string VolumeFileSystem(string rootPath)
{
try
{
var name = new StringBuilder(261);
var fs = new StringBuilder(261);
int serial, maxLength, flags;
if (!GetVolumeInformation(rootPath, name, name.Capacity, out serial, out maxLength, out flags, fs, fs.Capacity))
return null;
return fs.ToString();
}
catch { return null; }
}
// 备份目录名带时间戳,撞名时自动换一个,避免 Directory.Move 直接失败
private static string UniqueBackupPath(string source)
{
string stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss");
string candidate = source + ".moved-" + stamp;
int n = 1;
while (Directory.Exists(candidate) || File.Exists(candidate))
{
if (n > 50)
{
candidate = source + ".moved-" + stamp + "-" + Guid.NewGuid().ToString("N").Substring(0, 6);
break;
}
candidate = source + ".moved-" + stamp + "-" + n;
n++;
}
return candidate;
}
// 「迁移」与「再次迁移」共用:已经迁移过的(源路径是 junction)直接从当前位置搬走,
// 不需要先回滚,也不会重复占用空间。
public static void MigrateOne(MigItem item, string targetRoot, bool keepBackup, List<JournalEntry> journal, Logger log, AppProfile profile)
{
string source = item.Source;
string target = Path.Combine(targetRoot, item.Subdir);
log(item.Name + " · " + target, Info);
if (!Directory.Exists(source)) throw new Exception("源目录不存在:" + source);
bool again = IsJunction(source);
string current = again ? JunctionTarget(source) : source;
if (again)
{
if (string.IsNullOrEmpty(current) || !Directory.Exists(current))
throw new Exception("目录联接已失效,找不到当前数据位置;请先用「回滚」还原。");
if (SamePath(current, target))
{
log("· 位置没有变化,已跳过", Warn);
return;
}
log("· 已迁移过,从当前位置搬到新位置:" + current, Info);
}
if (IsInside(current, target) || IsInside(target, current))
throw new Exception("新旧位置不能互相包含:\r\n" + current + "\r\n" + target);
var info = GetTreeInfo(current);
log(string.Format("· 复制 {0},共 {1} 个文件{2}", FormatSize(info.Bytes), info.Files,
info.Links > 0 ? "、" + info.Links + " 个链接" : ""), Info);
if (TargetOccupied(target))
throw new Exception("目标目录里已有内容,请换一个空目录:" + target);
// 同盘迁移时源和目标会同时存在,需要接近两倍空间
bool sameVolume = SameVolume(current, target);
long free = GetFreeSpace(target);
if (free > 0)
{
long need = sameVolume ? (long)(info.Bytes * 2.1) : (long)(info.Bytes * 1.1);
if (free < need)
throw new Exception(string.Format("目标磁盘空间不足:需要约 {0},可用 {1}",
FormatSize(sameVolume ? info.Bytes * 2 : info.Bytes), FormatSize(free)));
}
string backup = null;
bool journalPending = false;
try
{
CopyTree(current, target);
var copied = GetTreeInfo(target);
if (copied.Files != info.Files || copied.Bytes != info.Bytes || copied.Links != info.Links)
{
throw new Exception(string.Format("复制校验不一致:源 {0} 文件 / {1}{2},目标 {3} 文件 / {4}{5}(源未改动)",
info.Files, FormatSize(info.Bytes), info.Links > 0 ? " / " + info.Links + " 链接" : "",
copied.Files, FormatSize(copied.Bytes), copied.Links > 0 ? " / " + copied.Links + " 链接" : ""));
}
log(string.Format("· 校验通过:{0} 个文件,{1}", copied.Files, FormatSize(copied.Bytes)), Ok);
if (again) RemoveJunction(source);
else
{
backup = UniqueBackupPath(source);
// 先落一条 pending 记录:中途断电或被杀,也能从 journal 找到 .moved-* 备份
UpsertJournal(profile, journal, new JournalEntry
{
Name = item.Name,
Source = source,
Target = target,
Backup = backup,
State = "pending",
MovedAt = DateTime.Now.ToString("o")
});
journalPending = true;
Directory.Move(source, backup);
}
try
{
CreateJunction(source, target);
if (!IsJunction(source)) throw new Exception("junction 创建后无法识别");
var linked = GetTreeInfo(source);
if (linked.Files != info.Files)
throw new Exception(string.Format("通过 junction 读到的文件数不一致:{0} vs {1}", linked.Files, info.Files));
log("· 已建立目录联接并校验", Ok);
}
catch (Exception ex)
{
log("· 失败,正在回滚:" + ex.Message, Warn);
bool restored = false;
try { if (IsJunction(source)) RemoveJunction(source); }
catch { }
try
{
if (!Directory.Exists(source))
{
if (again) { CreateJunction(source, current); restored = true; }
else if (backup != null && Directory.Exists(backup)) { Directory.Move(backup, source); restored = true; }
}
else restored = true;
}
catch (Exception rex)
{
log("· 回滚失败,原路径现在不可用:" + rex.Message, Error);
log("· 数据仍在 " + (backup ?? current) + ",请手动移回 " + source, Error);
}
if (!restored) throw new Exception("迁移失败且原路径没能自动恢复:" + ex.Message);
throw;
}
UpsertJournal(profile, journal, new JournalEntry
{
Name = item.Name,
Source = source,
Target = target,
Backup = backup,
State = "done",
MovedAt = DateTime.Now.ToString("o")
});
journalPending = false;
string old = again ? current : backup;
if (keepBackup)
{
log((again ? "· 旧位置保留在 " : "· 旧副本保留在 ") + old, Info);
}
else
{
try
{
ForceDeleteDir(old);
log(again ? "· 旧位置的副本已删除,空间已释放" : "· 旧副本已删除,原盘空间已释放", Ok);
}
catch (Exception ex)
{
log("· 旧位置未能完全删除(" + ex.Message + "),可手动删除:" + old, Warn);
}
}
}
catch
{
// 中途失败:源恢复了就清掉目标副本;源没恢复就把副本留着,别两边都丢
try
{
bool sourceUsesTarget = IsJunction(source) && SamePath(JunctionTarget(source), target);
if (!sourceUsesTarget)
{
if (Directory.Exists(source))
{
try { ForceDeleteDir(target); } catch { }
if (journalPending) RemoveJournal(profile, journal, source);
}
else
{
log("· 目标副本保留在 " + target + "(原路径尚未恢复,先别删)", Warn);
}
}
}
catch { }
throw;
}
}
private static void UpsertJournal(AppProfile profile, List<JournalEntry> journal, JournalEntry entry)
{
for (int i = journal.Count - 1; i >= 0; i--)
if (SamePath(journal[i].Source, entry.Source)) journal.RemoveAt(i);
journal.Add(entry);
SaveJournal(profile, journal);
}
private static void RemoveJournal(AppProfile profile, List<JournalEntry> journal, string source)
{
bool changed = false;
for (int i = journal.Count - 1; i >= 0; i--)
if (SamePath(journal[i].Source, source)) { journal.RemoveAt(i); changed = true; }
if (changed) SaveJournal(profile, journal);
}
// 目标目录里已经有内容(哪怕只是空目录)就不要再往里合并
public static bool TargetOccupied(string dir)
{
if (!Directory.Exists(dir)) return false;
try
{
foreach (string entry in Directory.EnumerateFileSystemEntries(dir)) return true;
}
catch { }
return false;
}
public static bool SameVolume(string a, string b)
{
try
{
string ra = Path.GetPathRoot(Path.GetFullPath(a));
string rb = Path.GetPathRoot(Path.GetFullPath(b));
return !string.IsNullOrEmpty(ra) && string.Equals(ra, rb, StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}