M3 PrivilegedFs: 시스템 폴더 · 루트 읽기의 static 넷 · 전역 함수 둘을 주입으로

- core/shizuku.dart PrivilegedFs: Shizuku 뒤쪽 · 루트 뒤쪽 · 임시 폴더를 한 곳에서. 앱은 켤 때 PlatformServices.privileged 를 건다
  (Android 조립만 PrivilegedFs.device(앱 임시 폴더) 를 넘김, 원격 서버 목록처럼 한 번). 예전 ShizukuRemote.backend · rootBackend ·
  tempDir · SuRoot.start (static) 를 대신 - SuRoot 의 프로세스 시작은 생성자로 넘김
- shizukuSupported · rootSupported (전역 함수) 를 빼고 c.services.platform.isAndroid (M1 PlatformInfo) 로
- Android 에서 Shizuku · 루트 내용을 잠깐 받는 곳이 시스템 TEMP (앱 캐시) → 앱 임시 폴더 (앱 데이터/tmp, 켤 때 비움) - 둘 다 앱 전용
- 시험: privileged_fs_contract_test (넘기면 걸고 안 넘기면 그대로 · device 꼴). shizuku · root · 보관함 시험은 PrivilegedFs.use 로 2회 통과
- 사용자가 보게 될 동작: 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-25 16:46:49 +09:00
parent f1624759ba
commit 26d6b01752
11 changed files with 102 additions and 59 deletions

View File

@ -30,6 +30,7 @@ PlatformServices shotServices() {
diskSpace: (_) async => (107374182400, 536870912000),
clock: r.clock,
vault: r.vault,
privileged: r.privileged,
updater: r.updater,
usage: r.usage == null ? null : const FixedUsage(),
models: r.models,

View File

@ -12,6 +12,7 @@ import '../core/srt.dart';
import '../core/subtitle_search.dart';
import '../services/subtitle_provider.dart';
import '../services/media_tool.dart';
import '../core/shizuku.dart' show PrivilegedFs;
import '../services/platform_services.dart';
import '../services/system_usage.dart';
import 'settings.dart';
@ -42,7 +43,10 @@ class AppController extends ChangeNotifier {
final SettingsStore? settingsStore;
AppSettings settings = AppSettings();
AppController(this.services, {this.settingsStore});
AppController(this.services, {this.settingsStore}) {
// 설계 M3: 시스템 폴더 · 루트 읽기는 앱 조립이 넘긴 것으로 (넘기지 않으면 그대로)
if (services.privileged != null) PrivilegedFs.use(services.privileged!);
}
/// 종료 전 확인 (다운로드 중이면 목록 확인). main 에서 연결, 업데이트 설치 때 사용
Future<bool> Function()? confirmQuit;

View File

@ -17,9 +17,6 @@ const shizukuServer = 'sys';
bool isShizuku(String path) => path.startsWith(shizukuScheme);
/// 시스템 폴더 보기를 쓰는 기기 (Android - 시험에서 바꿈)
bool Function() shizukuSupported = () => Platform.isAndroid;
/// 탐색기 탭이 처음 여는 곳 (Android 11 부터 막힌 Android/data · obb 가 있는 폴더)
String shizukuHome(String storageRoot) => '$shizukuScheme$shizukuServer${storageRoot.replaceAll('\\', '/')}/Android';
@ -29,8 +26,7 @@ const rootServer = 'root';
bool isRootPath(String path) => path.startsWith('$shizukuScheme$rootServer/') || path == '$shizukuScheme$rootServer';
/// 루트 보기를 쓰는 기기 (Android - 시험에서 바꿈)
bool Function() rootSupported = () => Platform.isAndroid;
// 시스템 폴더 · 루트 보기를 쓰는 기기인지는 PlatformInfo.isAndroid (c.services.platform - 예전 전역 함수 shizukuSupported · rootSupported)
/// 루트 탭이 처음 여는 곳 (관리자 3a 결정 10/21: "/" - 다른 앱의 개인 자료가 있는 /data 를 고르지 않았는데 펼치지 않게)
const rootHome = '$shizukuScheme$rootServer/';
@ -49,8 +45,9 @@ String shellCatScript(String path) => 'cat -- ${shellPath(path)}';
/// su 로 (루팅한 기기). 이번 실행에서 허락을 받았는지만 기억한다 (허락 · 거절은 Magisk 같은 su 앱이 기억)
class SuRoot implements ShizukuBackend {
/// 프로세스 시작 (시험에서 바꿈)
static Future<Process> Function(String exe, List<String> args) start = (exe, args) => Process.start(exe, args);
/// 프로세스 시작 (처음 값 = 실제 Process.start - 시험은 가짜 su 를 넘긴다)
final Future<Process> Function(String exe, List<String> args) start;
SuRoot({Future<Process> Function(String exe, List<String> args)? start}) : start = start ?? Process.start;
static bool _granted = false;
@ -187,23 +184,34 @@ class ShizukuReadOnlyException extends FileSystemException {
return (parts[0].trim() == 'directory', size, DateTime.fromMillisecondsSinceEpoch(secs * 1000), name);
}
/// [RemoteClient] 로 탐색기 · 복사 (꺼내기) · 보기가 그대로 쓰게. rel = 기기 절대 경로
/// 약속 (M3 PrivilegedFs): 시스템 폴더 (Shizuku) · 루트 (su) 읽기의 뒤쪽 둘과 내용을 잠깐 받아 둘 폴더 - 한 곳에서 정한다.
/// [device] = 실제 (AndroidShizuku · su, 임시는 넘긴 폴더). 앱은 켤 때 PlatformServices.privileged 를 [use] 로 건다 (원격 서버 목록처럼 한 번).
/// 시험은 [use] 로 가짜 뒤쪽을 걸고 끝에 되돌린다. 예전의 ShizukuRemote.backend · rootBackend · tempDir · SuRoot.start (따로 바꾸던 static 넷) 를 대신한다
class PrivilegedFs {
final ShizukuBackend shizuku;
final ShizukuBackend root;
/// 내용을 잠깐 받아 둘 폴더
final Future<String> Function() tempDir;
const PrivilegedFs({required this.shizuku, required this.root, required this.tempDir});
factory PrivilegedFs.device(Future<String> Function() tempDir) => PrivilegedFs(shizuku: AndroidShizuku(), root: SuRoot(), tempDir: tempDir);
/// 지금 쓰는 것 (처음 값 = 실제 뒤쪽 · 시스템 임시 폴더)
static PrivilegedFs current = PrivilegedFs.device(() async => Directory.systemTemp.path);
static void use(PrivilegedFs fs) => current = fs;
}
/// [RemoteClient] 로 탐색기 · 복사 (꺼내기) · 보기가 그대로 쓰게. rel = 기기 절대 경로. 뒤쪽 · 임시 폴더는 [PrivilegedFs.current]
class ShizukuRemote implements RemoteClient {
static ShizukuBackend backend = AndroidShizuku();
/// 루트 (su) 쪽 - 시험에서 바꿈
static ShizukuBackend rootBackend = SuRoot();
/// 내용을 잠깐 받아 둘 앱 전용 폴더 (시험에서 바꿈)
static Future<String> Function() tempDir = () async => Directory.systemTemp.path;
static final instance = ShizukuRemote();
static final root = ShizukuRemote(isRoot: true);
/// 루트 (shz://root/…) 인지 - 아니면 Shizuku (shz://sys/…)
final bool isRoot;
ShizukuRemote({this.isRoot = false});
ShizukuBackend get _b => isRoot ? rootBackend : backend;
ShizukuBackend get _b => isRoot ? PrivilegedFs.current.root : PrivilegedFs.current.shizuku;
String _abs(String rel) => rel.isEmpty ? '/' : rel;
@ -230,7 +238,7 @@ class ShizukuRemote implements RemoteClient {
/// 앱 전용 임시 폴더에 받아 두고 읽은 뒤 지운다
@override
Future<Stream<List<int>>> openRead(String rel) async {
final dir = Directory(p.join(await tempDir(), 'jj_shz_${DateTime.now().microsecondsSinceEpoch}'));
final dir = Directory(p.join(await PrivilegedFs.current.tempDir(), 'jj_shz_${DateTime.now().microsecondsSinceEpoch}'));
await dir.create(recursive: true);
final f = File(p.join(dir.path, 'f'));
try {

View File

@ -7,6 +7,7 @@ import '../platform/common/media_kit_player.dart';
import '../app/ai_upscale.dart' show AiUpscaler;
import '../app/settings.dart';
import '../app/vault_location.dart';
import '../core/shizuku.dart' show PrivilegedFs;
import '../core/download_detect.dart';
import '../core/lan_scan.dart' show LanHost;
import '../platform/common/nllb_translator.dart';
@ -83,6 +84,10 @@ class PlatformServices {
/// 손으로 조립하면 처음 값은 앱 임시 폴더 아래 ([VaultLocation.under] - 사용자 보관함 · 앱 데이터를 건드리지 않음)
final VaultLocation vault;
/// 시스템 폴더 (Shizuku) · 루트 (su) 읽기 (설계 M3). 있으면 AppController 가 켤 때 PrivilegedFs.use 로 건다 - Android 조립만 넘기고,
/// 없으면 PrivilegedFs.current 를 그대로 둔다 (시험이 건 가짜를 덮지 않게)
final PrivilegedFs? privileged;
/// 이 기기 (Android · Windows · 메모리) - 기기마다 다르게 도는 곳은 이것을 본다 (M1, 처음 값 = 이 기기)
final PlatformInfo platform;
@ -108,6 +113,7 @@ class PlatformServices {
AppShell? shell,
PlatformInfo? platform,
VaultLocation? vault,
this.privileged,
}) : models = models ?? ModelStore(),
vault = vault ?? VaultLocation.under(storage.tempDirectory),
platform = platform ?? PlatformInfo.host,
@ -159,6 +165,7 @@ class PlatformServices {
final storage = AndroidStorageService();
return PlatformServices(
vault: VaultLocation.device,
privileged: PrivilegedFs.device(storage.tempDirectory),
mediaTool: FfmpegKitMediaTool(),
storage: storage,
updater: AndroidUpdater(storage),

View File

@ -99,12 +99,12 @@ class ExplorerAccess extends ChangeNotifier {
rootWarned = true;
}
try {
final st = await ShizukuRemote.rootBackend.status();
final st = await PrivilegedFs.current.root.status();
if (!st.running) {
ctl.host.snack(tr('이 기기에는 su 가 없습니다 - 루팅한 기기에서만 쓸 수 있습니다'));
return;
}
if (!st.granted && !await ShizukuRemote.rootBackend.request()) {
if (!st.granted && !await PrivilegedFs.current.root.request()) {
ctl.host.snack(tr('루트 허락이 없어 볼 수 없습니다 (su 앱에서 이 앱을 허락하세요)'));
return;
}
@ -140,12 +140,12 @@ class ExplorerAccess extends ChangeNotifier {
shizukuWarned = true;
}
try {
final st = await ShizukuRemote.backend.status();
final st = await PrivilegedFs.current.shizuku.status();
if (!st.running) {
ctl.host.snack(tr('Shizuku 가 켜져 있지 않습니다 - Shizuku 앱을 설치하고 (무선 디버깅 · adb 로) 시작한 뒤 다시 누르세요'));
return;
}
if (!st.granted && !await ShizukuRemote.backend.request()) {
if (!st.granted && !await PrivilegedFs.current.shizuku.request()) {
ctl.host.snack(tr('Shizuku 허락이 없어 볼 수 없습니다'));
return;
}

View File

@ -92,8 +92,8 @@ class ExplorerController extends ChangeNotifier {
for (final s in c.settings.remote.smbServers) ('$smbScheme${s.id}/', s.label),
// l: 평문 FTP 는 이름 앞에 ⚠
for (final s in c.settings.remote.ftpServers) ('$ftpScheme${s.id}/', s.tls ? s.label : '⚠ ${s.label}'),
if (c.settings.tools.shizukuEnabled && shizukuSupported()) ('$shizukuScheme$shizukuServer/', tr('시스템 (읽기 전용)')),
if (c.settings.tools.rootEnabled && rootSupported()) (rootHome, tr('루트 (읽기 전용)')),
if (c.settings.tools.shizukuEnabled && c.services.platform.isAndroid) ('$shizukuScheme$shizukuServer/', tr('시스템 (읽기 전용)')),
if (c.settings.tools.rootEnabled && c.services.platform.isAndroid) (rootHome, tr('루트 (읽기 전용)')),
];
String volumeOf(String path) => vol.volumeOf(path, local);

View File

@ -11,7 +11,6 @@ import '../smb_settings.dart';
import '../webdav_settings.dart';
import '../explorer_look.dart' show ExplorerStyle;
import '../explorer_keys_page.dart';
import '../../core/shizuku.dart' show rootSupported, shizukuSupported;
import '../theme.dart';
import '../../l10n/tr.dart';
import '../setting_tile.dart';
@ -90,7 +89,7 @@ class _FilesSettingsSectionState extends State<FilesSettingsSection> {
),
),
// X-plore 비교 5-2 (사용자 결정 10/21): 시스템 폴더 보기 (Shizuku, 읽기 전용) - Android, 켤 때마다 경고
if (shizukuSupported())
if (c.services.platform.isAndroid)
SettingTile(
title: Text(tr('시스템 폴더 보기 (Shizuku · 읽기 전용)')),
subtitle: Text(tr('Shizuku 앱이 있으면 Android/data · obb 처럼 막힌 폴더를 읽기만 합니다. 탐색기 위 탭 [시스템 (읽기 전용)]')),
@ -110,7 +109,7 @@ class _FilesSettingsSectionState extends State<FilesSettingsSection> {
),
),
// X-plore 비교 5-2 (관리자 결정 10/21): 루트 폴더 보기 (su, 읽기 전용) - Shizuku 와 같은 틀
if (rootSupported())
if (c.services.platform.isAndroid)
SettingTile(
title: Text(tr('루트 폴더 보기 (su · 읽기 전용)')),
subtitle: Text(tr('루팅한 기기에서 su 로 시스템 · 다른 앱의 폴더를 읽기만 합니다. 탐색기 위 탭 [루트 (읽기 전용)]')),

View File

@ -0,0 +1,35 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/core/shizuku.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
class _Back implements ShizukuBackend {
_Back(this.name);
final String name;
@override
dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i);
}
/// 약속 (M3 PrivilegedFs): 앱 조립이 privileged 를 넘기면 AppController 가 켤 때 건다 · 넘기지 않으면 지금 것을 그대로 둔다 (시험의 가짜를 덮지 않음) ·
/// ShizukuRemote 는 지금 건 뒤쪽 (시스템 · 루트) 을 쓴다
void main() {
final was = PrivilegedFs.current;
tearDown(() => PrivilegedFs.use(was));
test('privileged 를 넘기면 켤 때 걸고, 안 넘기면 그대로', () {
final a = PrivilegedFs(shizuku: _Back('a'), root: _Back('ar'), tempDir: () async => 'A');
PrivilegedFs.use(a);
AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
expect(identical(PrivilegedFs.current, a), isTrue, reason: '안 넘기면 그대로');
final b = PrivilegedFs(shizuku: _Back('b'), root: _Back('br'), tempDir: () async => 'B');
AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), privileged: b));
expect(identical(PrivilegedFs.current, b), isTrue, reason: '넘기면 건다');
});
test('device: Shizuku 는 AndroidShizuku, 루트는 SuRoot (실제 Process.start), 임시는 넘긴 폴더', () async {
final d = PrivilegedFs.device(() async => 'T');
expect([d.shizuku is AndroidShizuku, d.root is SuRoot, await d.tempDir()], [true, true, 'T']);
});
}

View File

@ -14,6 +14,7 @@ import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/settings_page.dart';
import 'package:path/path.dart' as p;
import 'support/test_explorer_device.dart';
import 'package:jj_mkvmaker/services/platform_info.dart';
/// X-plore 비교 5-2 루트 폴더 보기 (su, 읽기 전용 - 관리자 결정 10/21: Shizuku 와 같은 틀, 처음 여는 곳 "/")
class _Proc implements Process {
@ -56,25 +57,17 @@ void main() {
setUp(() => device = TestExplorerDevice());
late Directory tmp;
late _Su su;
final wasStart = SuRoot.start;
final wasRoot = ShizukuRemote.rootBackend;
final wasTemp = ShizukuRemote.tempDir;
final wasSupported = rootSupported;
final was = PrivilegedFs.current;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_root_');
su = _Su();
SuRoot.start = su.start;
SuRoot.forgetGrant();
ShizukuRemote.rootBackend = SuRoot();
ShizukuRemote.tempDir = () async => tmp.path;
rootSupported = () => true;
// 설계 M3: 실제 SuRoot 에 가짜 su 프로세스만 넘기고, PrivilegedFs 하나로 건다 (Shizuku 쪽은 그대로)
PrivilegedFs.use(PrivilegedFs(shizuku: was.shizuku, root: SuRoot(start: su.start), tempDir: () async => tmp.path));
ExplorerPage.rootWarnedThisRun = false;
});
tearDown(() {
SuRoot.start = wasStart;
ShizukuRemote.rootBackend = wasRoot;
ShizukuRemote.tempDir = wasTemp;
rootSupported = wasSupported;
PrivilegedFs.use(was);
tmp.deleteSync(recursive: true);
});
@ -90,7 +83,7 @@ void main() {
});
test('su: 있는지 (묻지 않음) → 허락 (id -u = 0) · 목록 · 정보 · 내용 (끝 코드가 0 이 아니면 반쪽 파일을 지우고 오류) · 거절 · su 없음', () async {
final r = SuRoot();
final r = SuRoot(start: su.start); // 가짜 su 프로세스 (설계 M3 - 예전 static start 대신 넘김)
expect(await r.status(), (running: true, granted: false));
expect(su.calls, ['sh command -v su'], reason: '있는지만 볼 때는 허락 창을 띄우지 않음');
expect(await r.request(), isTrue);
@ -127,7 +120,9 @@ void main() {
});
AppController ctl({bool on = true}) {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
// 시스템 폴더 · 루트 탭은 Android 에서만 (설계 M3 - 예전 전역 함수 대신 PlatformInfo 주입)
final c = AppController(
PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), platform: PlatformInfo.android));
c.settings.explorer.style = 'xplore'; // E6: 이 시험은 X-plore 모양 (트리) 을 본다 - Windows 처음 값은 Total Commander
c.settings
..tools.rootEnabled = on

View File

@ -12,6 +12,7 @@ import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/settings_page.dart';
import 'package:path/path.dart' as p;
import 'support/test_explorer_device.dart';
import 'package:jj_mkvmaker/services/platform_info.dart';
/// X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용 · 처음 끔 · 경고 두 번 - 관리자 결정 10/21)
class _Fake implements ShizukuBackend {
@ -51,21 +52,16 @@ void main() {
setUp(() => device = TestExplorerDevice());
late Directory tmp;
late _Fake fake;
final wasBackend = ShizukuRemote.backend;
final wasTemp = ShizukuRemote.tempDir;
final wasSupported = shizukuSupported;
final was = PrivilegedFs.current;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_shz_');
fake = _Fake();
ShizukuRemote.backend = fake;
ShizukuRemote.tempDir = () async => tmp.path;
shizukuSupported = () => true;
// 설계 M3: 가짜 Shizuku 뒤쪽 · 시험 임시 폴더를 PrivilegedFs 하나로 건다 (루트 쪽은 그대로)
PrivilegedFs.use(PrivilegedFs(shizuku: fake, root: was.root, tempDir: () async => tmp.path));
ExplorerPage.shizukuWarnedThisRun = false;
});
tearDown(() {
ShizukuRemote.backend = wasBackend;
ShizukuRemote.tempDir = wasTemp;
shizukuSupported = wasSupported;
PrivilegedFs.use(was);
tmp.deleteSync(recursive: true);
});
@ -110,7 +106,9 @@ void main() {
});
AppController ctl({bool on = true}) {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
// 시스템 폴더 · 루트 탭은 Android 에서만 (설계 M3 - 예전 전역 함수 대신 PlatformInfo 주입)
final c = AppController(
PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), platform: PlatformInfo.android));
c.settings.explorer.style = 'xplore'; // E6: 이 시험은 X-plore 모양 (트리) 을 본다 - Windows 처음 값은 Total Commander
c.settings
..tools.shizukuEnabled = on

View File

@ -346,13 +346,9 @@ void main() {
addTearDown(t.view.reset);
final viewRoot = Directory(p.join(tmp.path, 'viewroot')); // where.viewRoot
final remote = _Remote({'/r/remote.txt': 'from nas'});
final wasBackend = ShizukuRemote.backend, wasTemp = ShizukuRemote.tempDir;
ShizukuRemote.backend = remote;
ShizukuRemote.tempDir = () async => tmp.path;
addTearDown(() {
ShizukuRemote.backend = wasBackend;
ShizukuRemote.tempDir = wasTemp;
});
final was = PrivilegedFs.current;
PrivilegedFs.use(PrivilegedFs(shizuku: remote, root: was.root, tempDir: () async => tmp.path));
addTearDown(() => PrivilegedFs.use(was));
final album = Directory(p.join(tmp.path, 'album'));
File(p.join(album.path, 'a.jpg'))
..createSync(recursive: true)