344 lines
15 KiB
Dart
344 lines
15 KiB
Dart
import 'dart:ffi';
|
|
import 'dart:io';
|
|
|
|
import 'package:ffi/ffi.dart';
|
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
|
import 'package:path/path.dart' as p;
|
|
|
|
import '../../l10n/tr.dart';
|
|
|
|
/// 휴지통으로 보낸 결과 (94: "휴지통으로 보냈습니다" 라고 하고 실제로는 영구 삭제되는 일이 없게)
|
|
enum RecycleResult {
|
|
/// 휴지통에 들어갔다 (되살릴 수 있음)
|
|
recycled,
|
|
|
|
/// 휴지통에 들어가지 않고 지워졌다 (휴지통보다 큰 파일 - Windows 가 물었고 사용자가 지우기를 골랐음)
|
|
deletedPermanently,
|
|
|
|
/// 사용자가 Windows 의 경고 창에서 취소했다 (그대로 남음)
|
|
cancelled,
|
|
}
|
|
|
|
/// 시험용: 실제 휴지통 대신 쓸 것. 기본 전체 시험은 가짜로 흐름만 본다 - 시험이 도중에 끊기면 사용자 휴지통에
|
|
/// 흔적이 남기 때문 (실제 휴지통 시험은 JJ_TEST_REAL_RECYCLE=1 일 때만, test/support/fake_recycle_bin.dart).
|
|
abstract class RecycleBinBackend {
|
|
RecycleResult move(String path);
|
|
void restore(String original, {required DateTime since});
|
|
}
|
|
|
|
/// null 이면 실제 휴지통 (앱은 늘 null)
|
|
@visibleForTesting
|
|
RecycleBinBackend? recycleBinForTest;
|
|
|
|
/// 휴지통으로 보내기 (앱의 화면은 이것을 쓴다 - 시험에서 가짜로 바꿀 수 있게)
|
|
RecycleResult recycle(String path) => recycleBinForTest?.move(path) ?? moveToRecycleBin(path);
|
|
|
|
/// 휴지통에서 원래 자리로 (앱의 화면은 이것을 쓴다)
|
|
void restoreRecycled(String original, {required DateTime since}) {
|
|
final t = recycleBinForTest;
|
|
t == null ? restoreFromRecycleBin(original, since: since) : t.restore(original, since: since);
|
|
}
|
|
|
|
/// 이 경로가 있는 곳에 휴지통이 있는지 (94). Windows 의 휴지통은 고정 디스크에만 있다:
|
|
/// 네트워크 드라이브 (Z:) · \\NAS\공유 · USB 메모리 · CD 는 휴지통 없이 바로 지워진다.
|
|
bool hasRecycleBin(String path) {
|
|
if (!Platform.isWindows) return false;
|
|
if (path.startsWith(r'\\')) return false;
|
|
final m = RegExp(r'^([a-zA-Z]):').firstMatch(path);
|
|
if (m == null) return false;
|
|
final root = '${m[1]}:\\'.toNativeUtf16();
|
|
try {
|
|
final kernel32 = DynamicLibrary.open('kernel32.dll');
|
|
final getDriveType = kernel32.lookupFunction<Uint32 Function(Pointer<Utf16>), int Function(Pointer<Utf16>)>('GetDriveTypeW');
|
|
return getDriveType(root) == 3; // DRIVE_FIXED
|
|
} catch (_) {
|
|
return false;
|
|
} finally {
|
|
calloc.free(root);
|
|
}
|
|
}
|
|
|
|
/// 휴지통에 든 항목 수 (그 드라이브). 모르면 null
|
|
int? _binCount(String path) {
|
|
final m = RegExp(r'^([a-zA-Z]):').firstMatch(path);
|
|
if (m == null) return null;
|
|
final root = '${m[1]}:\\'.toNativeUtf16();
|
|
final info = calloc<_RbInfo>();
|
|
try {
|
|
final shell32 = DynamicLibrary.open('shell32.dll');
|
|
final query = shell32.lookupFunction<Int32 Function(Pointer<Utf16>, Pointer<_RbInfo>),
|
|
int Function(Pointer<Utf16>, Pointer<_RbInfo>)>('SHQueryRecycleBinW');
|
|
info.ref.cbSize = sizeOf<_RbInfo>();
|
|
return query(root, info) == 0 ? info.ref.numItems : null;
|
|
} catch (_) {
|
|
return null;
|
|
} finally {
|
|
calloc.free(root);
|
|
calloc.free(info);
|
|
}
|
|
}
|
|
|
|
/// 휴지통 정보 파일 ($I…) 에 적힌 원래 경로. Windows 10 이상 (판 2: 길이 + 경로) · 예전 (판 1: 260자 고정). 모르면 null
|
|
String? recycledInfoPath(List<int> bytes) {
|
|
if (bytes.length < 28) return null;
|
|
final version = bytes[0] | bytes[1] << 8;
|
|
int start, end;
|
|
if (version == 2) {
|
|
final n = bytes[24] | bytes[25] << 8 | bytes[26] << 16 | bytes[27] << 24;
|
|
start = 28;
|
|
end = start + n * 2;
|
|
} else if (version == 1) {
|
|
start = 24;
|
|
end = 24 + 520;
|
|
} else {
|
|
return null;
|
|
}
|
|
if (end > bytes.length) end = bytes.length - bytes.length % 2;
|
|
final units = <int>[];
|
|
for (var i = start; i + 1 < end; i += 2) {
|
|
final u = bytes[i] | bytes[i + 1] << 8;
|
|
if (u == 0) break;
|
|
units.add(u);
|
|
}
|
|
return String.fromCharCodes(units);
|
|
}
|
|
|
|
/// 그 드라이브 휴지통에 [original] 의 정보 파일이 [since] 뒤에 생겼는지. 휴지통 폴더를 읽을 수 없으면 null
|
|
bool? recycledInfoExists(String original, {required DateTime since}) {
|
|
final m = RegExp(r'^([a-zA-Z]):').firstMatch(original);
|
|
if (m == null) return null;
|
|
final bin = Directory('${m[1]}:\\\$Recycle.Bin');
|
|
var readable = false;
|
|
try {
|
|
for (final user in bin.listSync(followLinks: false).whereType<Directory>()) {
|
|
List<FileSystemEntity> items;
|
|
try {
|
|
items = user.listSync(followLinks: false); // 다른 사용자의 휴지통은 읽을 수 없다
|
|
} catch (_) {
|
|
continue;
|
|
}
|
|
readable = true;
|
|
for (final f in items.whereType<File>()) {
|
|
final name = f.uri.pathSegments.last;
|
|
if (!name.startsWith(r'$I')) continue;
|
|
try {
|
|
if (f.lastModifiedSync().isBefore(since)) continue;
|
|
final p = recycledInfoPath(f.readAsBytesSync());
|
|
if (p != null && p.toLowerCase() == original.toLowerCase()) return true;
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
return readable ? false : null;
|
|
}
|
|
|
|
/// 지금 사용자의 SID (예: S-1-5-21-…, 휴지통 폴더 이름). 모르면 null
|
|
String? currentUserSid() => _sid ??= () {
|
|
try {
|
|
// 전체 경로로 (PATH 에 Git 등의 다른 whoami 가 먼저 있을 수 있다)
|
|
final exe = '${Platform.environment['SystemRoot'] ?? r'C:\Windows'}\\System32\\whoami.exe';
|
|
final r = Process.runSync(exe, ['/user', '/fo', 'csv', '/nh']);
|
|
return RegExp(r'"(S-1-[0-9-]+)"').firstMatch('${r.stdout}')?.group(1);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}();
|
|
String? _sid;
|
|
|
|
/// 148: 방금 휴지통으로 보낸 [original] 을 원래 자리로 되돌린다 ([since] 뒤에 생긴 것 중 가장 새것).
|
|
/// 휴지통의 $R… (내용) 을 원래 경로로 옮기고 $I… (정보) 를 지운다. 원래 자리에 이미 무엇이 있으면 덮지 않고 실패.
|
|
/// 실패하면 [FileSystemException] (이유는 읽을 수 있는 말).
|
|
void restoreFromRecycleBin(String original, {required DateTime since}) {
|
|
final m = RegExp(r'^([a-zA-Z]):').firstMatch(original);
|
|
if (m == null) throw FileSystemException(tr('휴지통이 없는 곳입니다'), original);
|
|
if (FileSystemEntity.typeSync(original, followLinks: false) != FileSystemEntityType.notFound) {
|
|
throw FileSystemException(tr('원래 자리에 같은 이름이 이미 있습니다'), original);
|
|
}
|
|
// 원래 폴더가 그사이 지워졌으면 다시 만들지 않는다 (사용자가 지운 폴더를 몰래 되살리지 않게)
|
|
if (!Directory(p.dirname(original)).existsSync()) {
|
|
throw FileSystemException(tr('원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)'), original);
|
|
}
|
|
// 이 사용자 (SID) 의 휴지통 폴더에서만 찾는다 (관리자 권한으로 다른 사용자의 휴지통을 읽을 수 있어도)
|
|
final sid = currentUserSid();
|
|
if (sid == null) throw FileSystemException(tr('휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)'), original);
|
|
File? info;
|
|
DateTime? newest;
|
|
final bin = Directory('${m[1]}:\\\$Recycle.Bin');
|
|
try {
|
|
for (final user in [Directory('${bin.path}\\$sid')]) {
|
|
List<FileSystemEntity> items;
|
|
try {
|
|
items = user.listSync(followLinks: false);
|
|
} catch (_) {
|
|
continue;
|
|
}
|
|
for (final f in items.whereType<File>()) {
|
|
if (!f.uri.pathSegments.last.startsWith(r'$I')) continue;
|
|
try {
|
|
final t = f.lastModifiedSync();
|
|
if (t.isBefore(since) || (newest != null && t.isBefore(newest))) continue;
|
|
final was = recycledInfoPath(f.readAsBytesSync());
|
|
if (was != null && was.toLowerCase() == original.toLowerCase()) {
|
|
info = f;
|
|
newest = t;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
final i = info;
|
|
if (i == null) throw FileSystemException(tr('휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)'), original);
|
|
final dir = i.parent.path;
|
|
final content = '$dir\\\$R${i.uri.pathSegments.last.substring(2)}';
|
|
final type = FileSystemEntity.typeSync(content, followLinks: false);
|
|
if (type == FileSystemEntityType.notFound) throw FileSystemException(tr('휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)'), original);
|
|
if (type == FileSystemEntityType.directory) {
|
|
Directory(content).renameSync(original);
|
|
} else {
|
|
File(content).renameSync(original);
|
|
}
|
|
try {
|
|
i.deleteSync();
|
|
} catch (_) {}
|
|
}
|
|
|
|
/// 휴지통이 받을 수 있는 경로 길이 (MAX_PATH - 끝의 NUL)
|
|
const recycleMaxPath = 259;
|
|
|
|
/// [path] (폴더면 그 안까지) 에 휴지통이 받지 못하는 긴 경로가 있으면 그 경로, 없으면 null
|
|
String? longPathInside(String path) {
|
|
final full = File(path).absolute.path;
|
|
if (full.length > recycleMaxPath) return full;
|
|
if (FileSystemEntity.typeSync(full, followLinks: false) != FileSystemEntityType.directory) return null;
|
|
try {
|
|
for (final e in Directory(full).listSync(recursive: true, followLinks: false)) {
|
|
if (e.path.length > recycleMaxPath) return e.path;
|
|
}
|
|
} catch (_) {
|
|
// 긴 경로 때문에 목록을 다 읽지 못함 - 그것도 긴 경로가 있다는 뜻
|
|
return full;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// SHFileOperation 의 오류 번호를 사람이 읽을 말로 (98)
|
|
String recycleErrorText(int code) => switch (code) {
|
|
0x02 || 0x03 => tr('찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)'),
|
|
0x05 || 0x78 => tr('권한이 없습니다'),
|
|
0x20 || 0x21 => tr('다른 프로그램이 쓰고 있습니다'),
|
|
0x7C || 0x79 || 0x81 || 0xCE || 0x6F => tr('경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다'),
|
|
0x74 => tr('드라이브의 맨 위 폴더는 지울 수 없습니다'),
|
|
0x75 || 0x4C7 => tr('취소했습니다'),
|
|
0x82 || 0x83 || 0x84 || 0x85 || 0x86 || 0x87 || 0x88 => tr('읽기 전용 매체입니다'),
|
|
_ => trf('Windows 오류 0x{0}', [code.toRadixString(16)]),
|
|
};
|
|
|
|
/// 144: 경로가 너무 길어 휴지통이 받지 않는 것 (영구 삭제는 사용자가 따로 고른다). [path] 는 그 긴 경로
|
|
class LongPathException extends FileSystemException {
|
|
const LongPathException(super.message, super.path);
|
|
}
|
|
|
|
/// Windows 휴지통으로 보내기 (SHFileOperationW, FO_DELETE + FOF_ALLOWUNDO). 65: 탐색기의 기본 지우기.
|
|
/// 하나씩 보내 어느 것이 실패했는지 알 수 있게 한다. 실패하면 [FileSystemException] (이유는 읽을 수 있는 말).
|
|
/// 휴지통보다 커서 들어가지 않으면 Windows 가 묻게 하고 (FOF_WANTNUKEWARNING), 실제로 어떻게 됐는지 돌려준다 (94).
|
|
RecycleResult moveToRecycleBin(String path) {
|
|
if (!Platform.isWindows) throw FileSystemException(tr('휴지통이 없습니다'), path);
|
|
if (FileSystemEntity.typeSync(path, followLinks: false) == FileSystemEntityType.notFound) return RecycleResult.recycled;
|
|
// 휴지통은 260자 넘는 경로를 받지 않고, 그때 Windows 는 묻지 않고 영구 삭제해 버린다 (시험에서 확인).
|
|
// 그래서 보내기 전에 (폴더면 안의 것까지) 긴 경로가 있으면 지우지 않고 이유를 알린다 - 영구 삭제는 사용자가 따로 고른다.
|
|
final long = longPathInside(path);
|
|
if (long != null) throw LongPathException(recycleErrorText(0x7C), long);
|
|
final shell32 = DynamicLibrary.open('shell32.dll');
|
|
final op = shell32.lookupFunction<Int32 Function(Pointer<_ShFileOp>), int Function(Pointer<_ShFileOp>)>('SHFileOperationW');
|
|
final before = _binCount(path);
|
|
final full = File(path).absolute.path;
|
|
final started = DateTime.now().subtract(const Duration(seconds: 2));
|
|
// pFrom: 끝에 NUL 두 개 (여러 경로 목록 형식)
|
|
final units = [...File(path).absolute.path.codeUnits, 0, 0];
|
|
final from = calloc<Uint16>(units.length);
|
|
final s = calloc<_ShFileOp>();
|
|
try {
|
|
from.asTypedList(units.length).setAll(0, units);
|
|
s.ref
|
|
// 108: Windows 가 묻는 창 ("영구히 삭제할까요?") 이 앱 창 뒤에 숨지 않게 앱 창을 주인으로
|
|
..hwnd = _ownWindow()
|
|
..wFunc = 0x0003 // FO_DELETE
|
|
..pFrom = from
|
|
..pTo = nullptr
|
|
// FOF_SILENT | FOF_NOCONFIRMATION | FOF_ALLOWUNDO | FOF_NOERRORUI | FOF_WANTNUKEWARNING
|
|
// (휴지통에 못 넣는 것은 묻지 않고 지우지 않게: Windows 가 "영구히 삭제할까요?" 를 묻는다)
|
|
..fFlags = 0x0004 | 0x0010 | 0x0040 | 0x0400 | 0x4000
|
|
..fAnyOperationsAborted = 0
|
|
..hNameMappings = nullptr
|
|
..lpszProgressTitle = nullptr;
|
|
final r = op(s);
|
|
if (s.ref.fAnyOperationsAborted != 0 || r == 0x4C7) return RecycleResult.cancelled;
|
|
if (r != 0) throw FileSystemException(recycleErrorText(r), path);
|
|
if (FileSystemEntity.typeSync(path, followLinks: false) != FileSystemEntityType.notFound) {
|
|
return RecycleResult.cancelled;
|
|
}
|
|
// 휴지통 안에 이 파일의 정보 ($I…, 원래 경로가 적힘) 가 새로 생겼는지로 본다 (늦게 생길 수 있어 몇 번 다시 본다).
|
|
// 휴지통 폴더를 읽을 수 없으면 항목 수가 늘었는지로.
|
|
for (var k = 0; k < 5; k++) {
|
|
final found = recycledInfoExists(full, since: started);
|
|
if (found == true) return RecycleResult.recycled;
|
|
if (found == null) {
|
|
if (before == null) return RecycleResult.recycled;
|
|
final after = _binCount(path);
|
|
if (after == null || after > before) return RecycleResult.recycled;
|
|
}
|
|
sleep(const Duration(milliseconds: 100));
|
|
}
|
|
return RecycleResult.deletedPermanently;
|
|
} finally {
|
|
calloc.free(from);
|
|
calloc.free(s);
|
|
}
|
|
}
|
|
|
|
/// 이 앱의 앞 창 (지금 앞에 있는 창이 이 프로세스의 것이면). 아니면 없음
|
|
Pointer<Void> _ownWindow() {
|
|
try {
|
|
final user32 = DynamicLibrary.open('user32.dll');
|
|
final fg = user32.lookupFunction<Pointer<Void> Function(), Pointer<Void> Function()>('GetForegroundWindow')();
|
|
if (fg == nullptr) return nullptr;
|
|
final pidOf = user32.lookupFunction<Uint32 Function(Pointer<Void>, Pointer<Uint32>), int Function(Pointer<Void>, Pointer<Uint32>)>(
|
|
'GetWindowThreadProcessId');
|
|
final owner = calloc<Uint32>();
|
|
try {
|
|
pidOf(fg, owner);
|
|
return owner.value == pid ? fg : nullptr;
|
|
} finally {
|
|
calloc.free(owner);
|
|
}
|
|
} catch (_) {
|
|
return nullptr;
|
|
}
|
|
}
|
|
|
|
final class _ShFileOp extends Struct {
|
|
external Pointer<Void> hwnd;
|
|
@Uint32()
|
|
external int wFunc;
|
|
external Pointer<Uint16> pFrom;
|
|
external Pointer<Uint16> pTo;
|
|
@Uint16()
|
|
external int fFlags;
|
|
@Int32()
|
|
external int fAnyOperationsAborted;
|
|
external Pointer<Void> hNameMappings;
|
|
external Pointer<Uint16> lpszProgressTitle;
|
|
}
|
|
|
|
/// SHQUERYRBINFO
|
|
final class _RbInfo extends Struct {
|
|
@Uint32()
|
|
external int cbSize;
|
|
@Int64()
|
|
external int size;
|
|
@Int64()
|
|
external int numItems;
|
|
}
|