import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:qr/qr.dart'; import '../app/app_controller.dart'; import '../core/wifi_share.dart'; import '../l10n/tr.dart'; import 'theme.dart'; /// 89: Wi-Fi 공유 화면 - 고른 폴더 하나를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용). /// 켜기 전: 폴더 · 저절로 끄는 시간 · 안내 · [켜기]. 켠 뒤: 6자리 코드 (크게) · 주소 · QR · 남은 시간 [+30분] · [끄기] · 접속 기록. /// 사용자 결정 (10/9): 기본 꺼짐 · 읽기 전용 · 비밀번호 (켤 때마다 새 코드). 화면을 닫아도 공유는 켜진 채 (탐색기 위 줄 · 알림으로 보임) class WifiSharePage extends StatefulWidget { final AppController c; /// 공유할 폴더 (이미 켜져 있으면 그 공유를 보인다) final String folder; const WifiSharePage({super.key, required this.c, required this.folder}); /// 시험: Windows 설정 · 제어판 열기를 바꾼다 @visibleForTesting static Future Function(String exe, List args) debugLaunch = (exe, args) async => Process.start(exe, args, mode: ProcessStartMode.detached); static Future open(BuildContext context, AppController c, String folder) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => WifiSharePage(c: c, folder: folder))); @override State createState() => _WifiSharePageState(); } class _WifiSharePageState extends State { WifiShare? _s = WifiShare.instance; Timer? _tick; String? _error; String? _lastReason; bool _starting = false; @override void initState() { super.initState(); _s?.addListener(_changed); // 남은 시간 표시 _tick = Timer.periodic(const Duration(seconds: 15), (_) { if (mounted) setState(() {}); }); } @override void dispose() { _tick?.cancel(); _s?.removeListener(_changed); super.dispose(); } void _changed() { if (!mounted) return; final s = _s; if (s != null && !s.running) { _lastReason = s.stopReason; s.removeListener(_changed); _s = null; } setState(() {}); } Future _start() async { setState(() { _starting = true; _error = null; _lastReason = null; }); try { final mins = widget.c.settings.tools.wifiShareMinutes; final s = await WifiShare.start(widget.folder, autoOff: mins > 0 ? Duration(minutes: mins) : null, tailscale: widget.c.settings.tools.wifiShareTailscale); await widget.c.updateSettings((x) => x.tools.wifiShareFolder = widget.folder); _s?.removeListener(_changed); _s = s..addListener(_changed); // 작업 기록에는 켜고 끈 것만 (코드 · 주소 · 접속한 곳은 남기지 않는다) widget.c.note(tr('Wi-Fi 공유를 켰습니다')); } catch (e) { _error = trf('켜지 못했습니다: {0}', [e is SocketException ? e.message : '$e']); } finally { if (mounted) setState(() => _starting = false); } } Future _stop() async { await _s?.stop(); widget.c.note(tr('Wi-Fi 공유를 껐습니다')); } @override Widget build(BuildContext context) { final s = _s; return Scaffold( appBar: AppBar(title: Text(tr('Wi-Fi 공유'))), body: SafeArea( child: ListView( padding: const EdgeInsets.all(16), children: s == null ? _offView() : _onView(s), ), ), ); } List _offView() { final mins = widget.c.settings.tools.wifiShareMinutes; return [ Text(tr('공유할 폴더'), style: TextStyle(color: JjColors.textDim, fontSize: 12)), const SizedBox(height: 4), SelectableText(widget.folder, style: const TextStyle(fontSize: 15)), const SizedBox(height: 16), Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 12, runSpacing: 4, children: [ Text(tr('저절로 끄기')), DropdownButton( value: mins, items: [ for (final m in const [10, 30, 60, 120]) DropdownMenuItem(value: m, child: Text(trf('{0}분 뒤', [m]))), DropdownMenuItem(value: 0, child: Text(tr('끄지 않음 (직접 끔)'))), ], onChanged: (v) async { if (v == null) return; await widget.c.updateSettings((x) => x.tools.wifiShareMinutes = v); if (mounted) setState(() {}); }, ), ]), // 보안 검토 3 (관리자 결정 10/18): Tailscale 주소에서도 열지 (처음 켬) SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(tr('Tailscale 에서도 열기')), subtitle: Text(tr('내 Tailscale 망 (tailnet) 의 기기도 이 주소로 볼 수 있습니다. 끄면 같은 Wi-Fi · 유선 망에서만')), value: widget.c.settings.tools.wifiShareTailscale, onChanged: (v) async { await widget.c.updateSettings((x) => x.tools.wifiShareTailscale = v); if (mounted) setState(() {}); }, ), const SizedBox(height: 12), _note(Icons.lock_outline, tr('켤 때마다 6자리 코드를 새로 만듭니다. 주소와 코드를 아는 같은 Wi-Fi 의 기기만 이 폴더를 볼 수 있습니다.')), _note(Icons.visibility_outlined, tr('읽기 전용입니다: 보기와 받기만 되고, 올리기 · 지우기 · 이름 바꾸기는 되지 않습니다. 이 폴더 밖과 링크는 열리지 않습니다.')), if (Platform.isWindows) ...[ _note(Icons.shield_outlined, tr('켜면 Windows 방화벽 창이 뜰 수 있습니다: "개인 네트워크" 에 체크하고 [허용] 을 누르세요. "공용 네트워크" 는 체크하지 않는 것이 안전합니다.')), _firewallHelp(), ], if (_lastReason != null) _note(Icons.info_outline, _lastReason!, color: JjColors.danger), if (_error != null) _note(Icons.error_outline, _error!, color: JjColors.danger), const SizedBox(height: 16), Align( alignment: Alignment.centerLeft, child: FilledButton.icon( onPressed: _starting ? null : _start, icon: const Icon(Icons.wifi_tethering), label: Text(tr('켜기')), ), ), ]; } List _onView(WifiShare s) { final hosts = s.addresses.isEmpty ? [] : s.addresses; final left = s.endsAt?.difference(DateTime.now()); return [ Row(children: [ Icon(Icons.wifi_tethering, color: JjColors.success), const SizedBox(width: 8), Expanded(child: Text(tr('공유 중'), style: TextStyle(fontSize: 16, color: JjColors.success))), FilledButton.icon( style: FilledButton.styleFrom(backgroundColor: JjColors.danger), onPressed: _stop, icon: const Icon(Icons.stop), label: Text(tr('끄기')), ), ]), const SizedBox(height: 8), SelectableText(s.root, style: TextStyle(color: JjColors.textDim, fontSize: 12)), const SizedBox(height: 16), Text(tr('접속 코드'), style: TextStyle(color: JjColors.textDim, fontSize: 12)), SelectableText(s.code, style: const TextStyle(fontSize: 40, letterSpacing: 8, fontWeight: FontWeight.w600)), const SizedBox(height: 12), if (hosts.isEmpty) _note(Icons.wifi_off, tr('이 기기의 Wi-Fi 주소를 찾지 못했습니다. Wi-Fi 에 연결돼 있는지 확인하세요.'), color: JjColors.danger) else ...[ Text(tr('다른 기기의 브라우저에서 이 주소를 여세요'), style: TextStyle(color: JjColors.textDim, fontSize: 12)), for (final h in hosts) Row(children: [ Expanded(child: SelectableText('http://$h:${s.port}', style: const TextStyle(fontSize: 16))), IconButton( tooltip: tr('주소 복사'), icon: const Icon(Icons.copy, size: 18), onPressed: () => Clipboard.setData(ClipboardData(text: 'http://$h:${s.port}')), ), ]), const SizedBox(height: 8), Text(tr('또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)'), style: TextStyle(color: JjColors.textDim, fontSize: 12)), const SizedBox(height: 8), Center(child: QrView(data: s.urlWithCode(hosts.first), size: 200)), ], // 관리자 지시 (10/19): 공유 중에도 다른 기기에서 안 열릴 때 볼 곳 if (Platform.isWindows) _firewallHelp(), const SizedBox(height: 12), Row(children: [ Expanded( child: Text(left == null ? tr('저절로 끄지 않습니다 - 다 쓰면 [끄기] 를 누르세요') : trf('{0}분 뒤에 저절로 꺼집니다', [(left.inSeconds / 60).ceil().clamp(0, 9999)])), ), if (left != null) TextButton(onPressed: () => s.extend(const Duration(minutes: 30)), child: Text(tr('+30분'))), ]), const Divider(height: 32), Text(trf('접속 기록 ({0}) - 이 화면에만 보이고 저장하지 않습니다', [s.log.length]), style: TextStyle(color: JjColors.textDim, fontSize: 12)), if (s.log.isEmpty) Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Text(tr('아직 접속한 기기가 없습니다'), style: TextStyle(color: JjColors.textDim))) else for (final a in s.log.take(50)) Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: Text( '${_hm(a.at)} ${a.ip} ${a.status} ${Uri.decodeComponent(a.path)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: a.status >= 400 ? JjColors.danger : JjColors.text), ), ), ]; } static String _hm(DateTime t) => '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}'; /// 관리자 지시 (10/19): Windows 방화벽이 막았을 때 "개인 네트워크 허용" 하는 법 (펼쳐 봄) · 그 화면을 바로 여는 버튼 Widget _firewallHelp() => ExpansionTile( tilePadding: EdgeInsets.zero, childrenPadding: const EdgeInsets.only(left: 8, bottom: 8), leading: const Icon(Icons.help_outline, size: 20), title: Text(tr('다른 기기에서 열리지 않으면 (Windows 방화벽)'), style: const TextStyle(fontSize: 14)), expandedCrossAxisAlignment: CrossAxisAlignment.start, children: [ _note(Icons.looks_one_outlined, tr('이 PC 가 연결한 망이 "개인 네트워크" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.')), Padding( padding: const EdgeInsets.only(left: 26), child: TextButton.icon( onPressed: () => WifiSharePage.debugLaunch('explorer.exe', ['ms-settings:network-status']), icon: const Icon(Icons.open_in_new, size: 16), label: Text(tr('네트워크 설정 열기')), ), ), _note(Icons.looks_two_outlined, tr('방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 "개인" 에 체크 > [확인].')), Padding( padding: const EdgeInsets.only(left: 26), child: TextButton.icon( onPressed: () => WifiSharePage.debugLaunch('control.exe', ['/name', 'Microsoft.WindowsFirewall', '/page', 'pageConfigureApps']), icon: const Icon(Icons.open_in_new, size: 16), label: Text(tr('방화벽 허용 앱 열기')), ), ), _note(Icons.looks_3_outlined, tr('목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 "개인" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.')), _note(Icons.info_outline, tr('회사 · 학교 PC 는 관리자가 방화벽을 막아 두어 바꿀 수 없을 수 있습니다.')), ], ); Widget _note(IconData icon, String text, {Color? color}) => _noteIn(icon, text, color ?? JjColors.textDim); Widget _noteIn(IconData icon, String text, Color color) => Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, size: 18, color: color), const SizedBox(width: 8), Expanded(child: Text(text, style: TextStyle(color: color == JjColors.textDim ? JjColors.text : color, fontSize: 13))), ]), ); } /// 89: QR (흰 바탕 · 검은 칸, 둘레 여백 4칸 - 휴대폰 카메라가 읽게) class QrView extends StatelessWidget { final String data; final double size; const QrView({super.key, required this.data, this.size = 200}); @override Widget build(BuildContext context) { final img = QrImage(QrCode.fromData(data: data, errorCorrectLevel: QrErrorCorrectLevel.M)); return Semantics( label: tr('접속 QR'), child: CustomPaint(size: Size.square(size), painter: _QrPainter(img)), ); } } class _QrPainter extends CustomPainter { final QrImage img; _QrPainter(this.img); @override void paint(Canvas canvas, Size size) { const quiet = 4; final n = img.moduleCount + quiet * 2; final cell = size.width / n; canvas.drawRect(Offset.zero & size, Paint()..color = Colors.white); final dark = Paint()..color = Colors.black; for (var r = 0; r < img.moduleCount; r++) { for (var col = 0; col < img.moduleCount; col++) { if (img.isDark(r, col)) { canvas.drawRect(Rect.fromLTWH((col + quiet) * cell, (r + quiet) * cell, cell + 0.5, cell + 0.5), dark); } } } } @override bool shouldRepaint(_QrPainter old) => old.img != img; }