JJ_MKVMaker/lib/ui/wifi_share_page.dart

320 lines
14 KiB
Dart

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<void> Function(String exe, List<String> args) debugLaunch =
(exe, args) async => Process.start(exe, args, mode: ProcessStartMode.detached);
static Future<void> open(BuildContext context, AppController c, String folder) =>
Navigator.of(context).push(MaterialPageRoute(builder: (_) => WifiSharePage(c: c, folder: folder)));
@override
State<WifiSharePage> createState() => _WifiSharePageState();
}
class _WifiSharePageState extends State<WifiSharePage> {
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<void> _start() async {
setState(() {
_starting = true;
_error = null;
_lastReason = null;
});
try {
final mins = widget.c.settings.wifiShareMinutes;
final s = await WifiShare.start(widget.folder,
autoOff: mins > 0 ? Duration(minutes: mins) : null, tailscale: widget.c.settings.wifiShareTailscale);
await widget.c.updateSettings((x) => x.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<void> _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<Widget> _offView() {
final mins = widget.c.settings.wifiShareMinutes;
return [
Text(tr('공유할 폴더'), style: const 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<int>(
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.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.wifiShareTailscale,
onChanged: (v) async {
await widget.c.updateSettings((x) => x.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<Widget> _onView(WifiShare s) {
final hosts = s.addresses.isEmpty ? <String>[] : s.addresses;
final left = s.endsAt?.difference(DateTime.now());
return [
Row(children: [
const Icon(Icons.wifi_tethering, color: JjColors.success),
const SizedBox(width: 8),
Expanded(child: Text(tr('공유 중'), style: const 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: const TextStyle(color: JjColors.textDim, fontSize: 12)),
const SizedBox(height: 16),
Text(tr('접속 코드'), style: const 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: const 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: const 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: const TextStyle(color: JjColors.textDim, fontSize: 12)),
if (s.log.isEmpty)
Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Text(tr('아직 접속한 기기가 없습니다'), style: const 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 = JjColors.textDim}) => 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;
}