W JjField 1: 비밀 칸 6곳을 SecretField 하나로 + 캡처 흔들림 (check_shot 37·65·94 · sshkey) 을 시험 쪽에서 고정
- lib/ui/secret_field.dart: 비밀번호 · API 키 · 키 암호 칸 (처음 늘 가림, 눈 단추로 켜고 끄기, 자동 고침 · 추천 끔, 바깥 가리기로 칸 여럿을 단추 하나로). WebDAV · FTP · SFTP · SMB · AI 서비스 · 압축이 부르기만 한다 - 눈 단추 모양 · 툴팁을 하나로 (AI 창의 채운 24 아이콘 → 다른 곳과 같은 테두리 18, 툴팁 '보이기/가리기'), 쓰지 않게 된 사전 글 '비밀번호 보기/숨기기' 를 en · ja · zh 에서 뺌 - 시험: secret_field_contract_test (처음 가림 · 켜고 끄기 · 칸 여럿 · 300/412 · 배율 2 넘침 없음) - 시험 쪽 고정: testFixedTempDir · pinTestTimes (폴더 이름 · 파일 · 폴더 시각), 시험 SFTP 서버 fixedPort · fixedHostKey Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>modular
parent
2eb5f335db
commit
36069a8707
|
|
@ -1609,8 +1609,6 @@
|
|||
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "Passwords and the subtitle API key aren't backed up, so please enter them again. Auto Backup runs about once a day, so changes made after it may be missing.",
|
||||
"비밀번호 기억 (안전 저장소)": "Remember passwords (secure storage)",
|
||||
"비밀번호 다시 넣기": "Enter password again",
|
||||
"비밀번호 보기": "Show password",
|
||||
"비밀번호 숨기기": "Hide password",
|
||||
"비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "Can't preview a password-protected archive - use [Extract]",
|
||||
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "Some files are password-protected (you'll be asked when extracting)",
|
||||
"비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "This file is password-protected - use [Extract]",
|
||||
|
|
|
|||
|
|
@ -1609,8 +1609,6 @@
|
|||
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。",
|
||||
"비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)",
|
||||
"비밀번호 다시 넣기": "パスワードをもう一度入力",
|
||||
"비밀번호 보기": "パスワードを表示",
|
||||
"비밀번호 숨기기": "パスワードを隠す",
|
||||
"비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "パスワード付きアーカイブの中はプレビューできません - [展開] で展開してください",
|
||||
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "パスワード付きのファイルがあります (展開時に確認します)",
|
||||
"비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "パスワード付きのファイルです - [展開] で展開してください",
|
||||
|
|
|
|||
|
|
@ -1609,8 +1609,6 @@
|
|||
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。",
|
||||
"비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)",
|
||||
"비밀번호 다시 넣기": "再次输入密码",
|
||||
"비밀번호 보기": "显示密码",
|
||||
"비밀번호 숨기기": "隐藏密码",
|
||||
"비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "无法预览有密码的压缩包 - 请用 [解压]",
|
||||
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "部分文件设有密码 (解压时会询问)",
|
||||
"비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "此文件有密码 - 请用 [解压]",
|
||||
|
|
|
|||
|
|
@ -49,3 +49,34 @@ void deleteTestTemp(Directory dir) {
|
|||
}
|
||||
stderr.writeln('시험 임시 폴더를 지우지 못함: $path');
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/24): 캡처 비교가 흔들리지 않게 이름이 늘 같은 시험 폴더 ([testTempDir] 는 끝에 임의 글자가 붙어 경로 막대 · 공유 경로가 실행마다 달랐다).
|
||||
/// 있으면 지우고 새로 만들고, 시험 끝 (실패해도) 에 지운다. [base] 는 [testTempDir] 와 같다. 반드시 시험 안에서 부른다
|
||||
Directory testFixedTempDir(String name, {String? base}) {
|
||||
final root = base == null || base.isEmpty ? Directory.systemTemp : (Directory(base)..createSync(recursive: true));
|
||||
final dir = Directory('${root.path}${Platform.pathSeparator}$name');
|
||||
deleteTestTemp(dir);
|
||||
dir.createSync(recursive: true);
|
||||
addTearDown(() => deleteTestTemp(dir));
|
||||
if (base == null || base.isEmpty) expectInTaggedTemp(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/// 캡처용: [dir] 아래 파일 · 폴더의 바뀐 시각을 [at] 으로 고정 (목록의 날짜 칸이 실행마다 같게).
|
||||
/// Dart 는 폴더 시각을 못 바꿔 폴더는 PowerShell 로 (Windows 만). 긴 경로는 \\?\ 로
|
||||
void pinTestTimes(Directory dir, {DateTime? at}) {
|
||||
final when = at ?? DateTime(2026, 10, 22, 9);
|
||||
final path = dir.absolute.path;
|
||||
final long = Platform.isWindows && !path.startsWith(r'\\') ? Directory('\\\\?\\$path') : dir;
|
||||
for (final f in long.listSync(recursive: true).whereType<File>()) {
|
||||
f.setLastModifiedSync(when);
|
||||
}
|
||||
if (!Platform.isWindows) return;
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
final stamp = '${when.year}-${two(when.month)}-${two(when.day)} ${two(when.hour)}:${two(when.minute)}';
|
||||
Process.runSync('powershell', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
"Get-ChildItem -LiteralPath '$path' -Recurse -Directory -Force | ForEach-Object { \$_.LastWriteTime = [datetime]'$stamp' }",
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,11 +34,9 @@ void main() {
|
|||
// ⑦ 밝은 화면 캡처: JJ_SHOT_THEME=light (JJ_SHOT_DIR 를 따로 두어 어두운 것과 견줌)
|
||||
if (Platform.environment['JJ_SHOT_THEME'] == 'light') JjColors.use(Brightness.light);
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots');
|
||||
// 시험 폴더: 항목이 아주 많은 시스템 임시 폴더 대신 (JJ_SHOT_TMP, 없으면 시스템 임시 폴더)
|
||||
Directory tempDir(String prefix) {
|
||||
final base = Platform.environment['JJ_SHOT_TMP'];
|
||||
return testTempDir(prefix, base: base);
|
||||
}
|
||||
// 시험 폴더: 항목이 아주 많은 시스템 임시 폴더 대신 (JJ_SHOT_TMP, 없으면 시스템 임시 폴더).
|
||||
// 관리자 지시 (10/24): 이름은 늘 같게 (경로 막대 · 공유 경로가 실행마다 달라 37 · 65 · 94 캡처가 흔들렸다) - 파일 · 폴더 시각은 [pinTestTimes]
|
||||
Directory tempDir(String name) => testFixedTempDir(name, base: Platform.environment['JJ_SHOT_TMP']);
|
||||
final key = GlobalKey();
|
||||
const sizes = [('narrow', Size(700, 900)), ('wide', Size(1280, 800))];
|
||||
|
||||
|
|
@ -103,7 +101,7 @@ void main() {
|
|||
}
|
||||
|
||||
testWidgets('65 · 94 · 98: 지우기 - 휴지통 · 영구 삭제 · 휴지통 없는 위치 · 오류 문구', (t) async {
|
||||
final tmp = tempDir('jj_shot_del_');
|
||||
final tmp = tempDir('jj_shot_del_fixed');
|
||||
final started = DateTime.now();
|
||||
// 휴지통: 기본은 가짜 (화면 흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 (끝에 시험 항목만 지움)
|
||||
final device = TestExplorerDevice();
|
||||
|
|
@ -113,6 +111,7 @@ void main() {
|
|||
final dir = Directory(p.join(tmp.path, tag))..createSync();
|
||||
File(p.join(dir.path, 'recycle_me_$tag.txt')).writeAsStringSync('x');
|
||||
File(p.join(dir.path, 'keep_$tag.txt')).writeAsStringSync('x');
|
||||
pinTestTimes(dir);
|
||||
final c = controller();
|
||||
c.settings
|
||||
..explorer.style = 'xplore' // 이 캡처는 X-plore 모양 (E6 부터 Windows 처음 값은 Total Commander - 이름 · 확장자가 다른 칸)
|
||||
|
|
@ -142,6 +141,7 @@ void main() {
|
|||
final unc = '\\\\localhost\\${tmp.path[0]}\$${tmp.path.substring(2)}';
|
||||
final share = Directory(p.join(unc, 'share'))..createSync();
|
||||
File(p.join(share.path, 'on_share.txt')).writeAsStringSync('x');
|
||||
pinTestTimes(share);
|
||||
for (final (tag, size) in sizes) {
|
||||
final c = controller();
|
||||
c.settings
|
||||
|
|
@ -206,10 +206,11 @@ void main() {
|
|||
});
|
||||
|
||||
testWidgets('37: 경로 입력 - 폴더로 가기 · \\\\NAS 만 · 없는 경로', (t) async {
|
||||
final tmp = tempDir('jj_shot_path_');
|
||||
final tmp = tempDir('jj_shot_path_fixed');
|
||||
try {
|
||||
final target = Directory(p.join(tmp.path, '가고 싶은 폴더'))..createSync();
|
||||
File(p.join(target.path, 'inside.txt')).writeAsStringSync('x');
|
||||
pinTestTimes(tmp);
|
||||
for (final (tag, size) in sizes) {
|
||||
final c = controller();
|
||||
c.settings
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ void main() {
|
|||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_sshkeyshot_');
|
||||
// 관리자 지시 (10/24): 폴더 이름 · 포트 · 서버 키 지문을 실행마다 같게 (캡처가 흔들리지 않게 - 시험 쪽에서만)
|
||||
final tmp = testFixedTempDir('jj_sshkeyshot_fixed');
|
||||
final root = Directory(p.join(tmp.path, 'root'))..createSync();
|
||||
Future<String> gen(String name, String pass) async {
|
||||
final f = p.join(tmp.path, name);
|
||||
|
|
@ -57,7 +58,7 @@ void main() {
|
|||
await t.runAsync(() async {
|
||||
keyA = await gen('a', 'kp');
|
||||
keyB = await gen('b', '');
|
||||
server = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'), password: null, pubkey: '$keyA.pub');
|
||||
server = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'), password: null, pubkey: '$keyA.pub', fixedPort: 42022, fixedHostKey: true);
|
||||
});
|
||||
addTearDown(() => t.runAsync(server.stop));
|
||||
final ppk = File(p.join(tmp.path, 'key.ppk'))
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import 'setting_tile.dart';
|
|||
import 'theme.dart';
|
||||
import '../app/ai_upscale.dart' show deviceMemTotalMb, upscaleMemoryLines;
|
||||
import 'notice_band.dart';
|
||||
import 'secret_field.dart';
|
||||
|
||||
/// 기기 안에서 그리는 데 아직 받지 않은 파일 (Windows 는 실행 파일까지, TAESD 포함) - AI 그림 화면 · 받기 화면이 같은 값을 보인다 (139)
|
||||
List<AiFile> aiMissingFiles(AiStore store) => [
|
||||
|
|
@ -476,7 +477,6 @@ Future<void> editAiService(BuildContext context, AppController c, {AiService? ol
|
|||
final model = TextEditingController(text: old?.model ?? '');
|
||||
final key = TextEditingController(text: old?.apiKey ?? '');
|
||||
var kind = old?.kind ?? 'a1111';
|
||||
var hide = true;
|
||||
final saved = await showDialog<AiService>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
|
|
@ -517,17 +517,11 @@ Future<void> editAiService(BuildContext context, AppController c, {AiService? ol
|
|||
helperMaxLines: 3,
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: key,
|
||||
obscureText: hide,
|
||||
decoration: InputDecoration(
|
||||
// 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다
|
||||
labelText: kind == 'a1111' ? tr('API 키 또는 아이디:비밀번호') : tr('API 키'),
|
||||
helperText: tr('넣지 않아도 됩니다'),
|
||||
helperMaxLines: 3,
|
||||
suffixIcon: IconButton(icon: Icon(hide ? Icons.visibility : Icons.visibility_off), onPressed: () => set(() => hide = !hide)),
|
||||
),
|
||||
),
|
||||
// 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다
|
||||
SecretField(
|
||||
controller: key,
|
||||
label: kind == 'a1111' ? tr('API 키 또는 아이디:비밀번호') : tr('API 키'),
|
||||
helper: tr('넣지 않아도 됩니다')),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import '../core/vfs.dart';
|
|||
import '../l10n/tr.dart';
|
||||
import 'app_actions.dart' show isCompact;
|
||||
import 'theme.dart';
|
||||
import 'secret_field.dart';
|
||||
|
||||
/// 86: 동봉한 7-Zip. 없으면 null
|
||||
/// - Windows: 실행 파일 옆 7zip\7z.exe (개발 중에는 third_party 의 것)
|
||||
|
|
@ -354,37 +355,24 @@ Future<String?> createZipFlow(BuildContext context, AppController c, List<String
|
|||
Text(trf('만들 파일: {0}', [p.basename(target())]), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
Text(tr('링크 · junction 은 넣지 않습니다'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
// 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다 - "선택" 은 아래 도움 글 (비우면 보통 ZIP) 이 알린다
|
||||
SecretField(
|
||||
controller: pass,
|
||||
obscureText: !showPass,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
decoration: InputDecoration(
|
||||
// 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다 - "선택" 은 아래 도움 글 (비우면 보통 ZIP) 이 알린다
|
||||
labelText: tr('비밀번호'),
|
||||
helperText: tr('넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP'),
|
||||
helperMaxLines: 3,
|
||||
isDense: true,
|
||||
suffixIcon: IconButton(
|
||||
tooltip: showPass ? tr('비밀번호 숨기기') : tr('비밀번호 보기'),
|
||||
icon: Icon(showPass ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18),
|
||||
onPressed: () => set(() => showPass = !showPass),
|
||||
),
|
||||
),
|
||||
label: tr('비밀번호'),
|
||||
helper: tr('넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP'),
|
||||
isDense: true,
|
||||
hidden: !showPass,
|
||||
onToggle: () => set(() => showPass = !showPass),
|
||||
onChanged: (_) => set(() {}),
|
||||
),
|
||||
if (pass.text.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
SecretField(
|
||||
controller: pass2,
|
||||
obscureText: !showPass,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('비밀번호 다시 넣기'),
|
||||
isDense: true,
|
||||
errorText: pass2.text.isNotEmpty && pass2.text != pass.text ? tr('두 비밀번호가 다릅니다') : null,
|
||||
),
|
||||
label: tr('비밀번호 다시 넣기'),
|
||||
isDense: true,
|
||||
hidden: !showPass,
|
||||
errorText: pass2.text.isNotEmpty && pass2.text != pass.text ? tr('두 비밀번호가 다릅니다') : null,
|
||||
onChanged: (_) => set(() {}),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'setting_tile.dart';
|
|||
import 'theme.dart';
|
||||
import 'webdav_settings.dart' show davServerUsers;
|
||||
import 'notice_band.dart';
|
||||
import 'secret_field.dart';
|
||||
|
||||
/// l: 환경 설정 > 파일 탐색기 > FTP · FTPS: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). SFTP 와 같은 틀.
|
||||
/// 서버마다 파일 탐색기 · Rsync 화면 위쪽 (WebDAV 옆) 에 탭이 생긴다. 평문 FTP 는 목록 · 탭 · 창에 "암호화 안 됨" 을 늘 보인다
|
||||
|
|
@ -139,7 +140,6 @@ Future<FtpServer?> editFtpServer(BuildContext context, AppController c, {FtpServ
|
|||
final root = TextEditingController(text: old?.root ?? '');
|
||||
var security = old?.security ?? 'explicit';
|
||||
var fingerprint = old?.certFingerprint ?? '';
|
||||
var hide = true;
|
||||
String? result;
|
||||
var passed = false;
|
||||
var testing = false;
|
||||
|
|
@ -246,18 +246,7 @@ Future<FtpServer?> editFtpServer(BuildContext context, AppController c, {FtpServ
|
|||
if (security == 'none')
|
||||
NoticeBand(icon: Icons.lock_open, color: JjColors.danger, margin: EdgeInsets.zero, title: tr('평문 FTP: 암호화 안 됨'), body: tr('평문 FTP 는 아이디 · 비밀번호 · 파일이 암호화되지 않은 채 오갑니다. 같은 망의 다른 사람이 볼 수 있습니다. 서버가 FTPS 를 못 할 때만, 믿을 수 있는 집 안 망에서만 쓰세요.')),
|
||||
TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디 (비우면 익명)'))),
|
||||
TextField(
|
||||
controller: pass,
|
||||
obscureText: hide,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('비밀번호'),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: hide ? tr('보이기') : tr('가리기'),
|
||||
icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18),
|
||||
onPressed: () => set(() => hide = !hide),
|
||||
),
|
||||
),
|
||||
),
|
||||
SecretField(controller: pass, label: tr('비밀번호')),
|
||||
TextField(
|
||||
controller: root,
|
||||
decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('비우면 로그인한 곳 · 예: /volume1/video · 탭의 맨 위가 됩니다'), helperMaxLines: 3),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
/// 약속 (W 공용 위젯, 설계 7-2 JjField - 비밀 칸): 비밀번호 · API 키 · 키 암호 칸 하나.
|
||||
/// 입력: 글 칸 · 칸 이름 (짧게) · 도움 글 (칸 아래, 3줄까지) · 오류 글 · 가리기 (스스로 들거나 [hidden] · [onToggle] 로 바깥에서).
|
||||
/// 출력: 가린 글 칸 + 눈 단추 (가렸으면 "보이기", 보이면 "가리기" - 아이콘 · 크기 · 툴팁이 어디서나 같음). 자동 고침 · 추천은 늘 끔.
|
||||
/// 오류 없음. 쓰는 곳: WebDAV · FTP · SFTP · SMB 서버 설정 · AI 서비스 · 압축 비밀번호. 화면은 부르기만 한다
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/tr.dart';
|
||||
|
||||
class SecretField extends StatefulWidget {
|
||||
const SecretField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.label,
|
||||
this.helper,
|
||||
this.errorText,
|
||||
this.isDense = false,
|
||||
this.onChanged,
|
||||
this.hidden,
|
||||
this.onToggle,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final String? helper;
|
||||
final String? errorText;
|
||||
final bool isDense;
|
||||
final ValueChanged<String>? onChanged;
|
||||
|
||||
/// 바깥에서 가리기를 정할 때 (눈 단추 하나로 칸 여럿 - 비밀번호와 확인 칸 등). null 이면 칸이 스스로 든다
|
||||
final bool? hidden;
|
||||
|
||||
/// [hidden] 을 바깥에서 줄 때의 눈 단추 (null 이면 이 칸에는 단추 없음)
|
||||
final VoidCallback? onToggle;
|
||||
|
||||
@override
|
||||
State<SecretField> createState() => _SecretFieldState();
|
||||
}
|
||||
|
||||
class _SecretFieldState extends State<SecretField> {
|
||||
bool _hidden = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final outside = widget.hidden != null;
|
||||
final hidden = widget.hidden ?? _hidden;
|
||||
final toggle = outside ? widget.onToggle : () => setState(() => _hidden = !_hidden);
|
||||
return TextField(
|
||||
controller: widget.controller,
|
||||
obscureText: hidden,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
onChanged: widget.onChanged,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.label,
|
||||
helperText: widget.helper,
|
||||
helperMaxLines: 3,
|
||||
errorText: widget.errorText,
|
||||
isDense: widget.isDense,
|
||||
suffixIcon: toggle == null
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: hidden ? tr('보이기') : tr('가리기'),
|
||||
icon: Icon(hidden ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18),
|
||||
onPressed: toggle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import 'confirm.dart';
|
|||
import 'setting_tile.dart';
|
||||
import 'theme.dart';
|
||||
import 'webdav_settings.dart' show davServerUsers;
|
||||
import 'secret_field.dart';
|
||||
|
||||
/// 88: 환경 설정 > 파일 탐색기 > SFTP: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인).
|
||||
/// 서버마다 파일 탐색기 · Rsync 화면 위쪽 (WebDAV 옆) 에 탭이 생긴다.
|
||||
|
|
@ -274,21 +275,14 @@ Future<SftpServer?> editSftpServer(BuildContext context, AppController c, {SftpS
|
|||
),
|
||||
]),
|
||||
TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디'))),
|
||||
TextField(
|
||||
controller: pass,
|
||||
obscureText: hide,
|
||||
decoration: InputDecoration(
|
||||
// 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다
|
||||
labelText: tr('비밀번호'),
|
||||
helperText: tr('개인 키만 쓰면 비웁니다'),
|
||||
helperMaxLines: 3,
|
||||
suffixIcon: IconButton(
|
||||
tooltip: hide ? tr('보이기') : tr('가리기'),
|
||||
icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18),
|
||||
onPressed: () => set(() => hide = !hide),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다.
|
||||
// 눈 단추 하나가 비밀번호 · 개인 키 암호를 함께 보이고 가린다
|
||||
SecretField(
|
||||
controller: pass,
|
||||
label: tr('비밀번호'),
|
||||
helper: tr('개인 키만 쓰면 비웁니다'),
|
||||
hidden: hide,
|
||||
onToggle: () => set(() => hide = !hide)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||
OutlinedButton.icon(
|
||||
|
|
@ -302,11 +296,7 @@ Future<SftpServer?> editSftpServer(BuildContext context, AppController c, {SftpS
|
|||
],
|
||||
]),
|
||||
if (key.isNotEmpty)
|
||||
TextField(
|
||||
controller: passphrase,
|
||||
obscureText: hide,
|
||||
decoration: InputDecoration(labelText: tr('개인 키 암호'), helperText: tr('없으면 비웁니다'), helperMaxLines: 3),
|
||||
),
|
||||
SecretField(controller: passphrase, label: tr('개인 키 암호'), helper: tr('없으면 비웁니다'), hidden: hide),
|
||||
TextField(
|
||||
controller: root,
|
||||
decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('비우면 홈 폴더 · 예: /volume1/video · 탭의 맨 위가 됩니다'), helperMaxLines: 3),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'confirm.dart';
|
|||
import 'setting_tile.dart';
|
||||
import 'theme.dart';
|
||||
import 'webdav_settings.dart' show davServerUsers;
|
||||
import 'secret_field.dart';
|
||||
|
||||
/// 88: 환경 설정 > 파일 탐색기 > SMB (Android 만): 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인).
|
||||
/// Windows 는 운영체제가 \\서버\공유 를 연다 (경로 입력 · 네트워크 드라이브) - 사용자 결정 88-1.
|
||||
|
|
@ -124,7 +125,6 @@ Future<SmbServer?> editSmbServer(BuildContext context, AppController c, {SmbServ
|
|||
final user = TextEditingController(text: old?.user ?? '');
|
||||
final domain = TextEditingController(text: old?.domain ?? '');
|
||||
final pass = TextEditingController(text: old?.password ?? '');
|
||||
var hide = true;
|
||||
String? result;
|
||||
String? warning;
|
||||
var passed = false;
|
||||
|
|
@ -229,18 +229,7 @@ Future<SmbServer?> editSmbServer(BuildContext context, AppController c, {SmbServ
|
|||
),
|
||||
TextField(controller: root, decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('공유 안의 폴더 · 비우면 공유의 맨 위'), helperMaxLines: 3)),
|
||||
TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디 (비우면 게스트)'))),
|
||||
TextField(
|
||||
controller: pass,
|
||||
obscureText: hide,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('비밀번호'),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: hide ? tr('보이기') : tr('가리기'),
|
||||
icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18),
|
||||
onPressed: () => set(() => hide = !hide),
|
||||
),
|
||||
),
|
||||
),
|
||||
SecretField(controller: pass, label: tr('비밀번호')),
|
||||
TextField(controller: domain, decoration: InputDecoration(labelText: tr('도메인 · 작업 그룹'), helperText: tr('보통 비워 둡니다'), helperMaxLines: 3)),
|
||||
const SizedBox(height: 8),
|
||||
if (testing) const LinearProgressIndicator(),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'confirm.dart';
|
|||
import 'file_error.dart';
|
||||
import 'theme.dart';
|
||||
import 'setting_tile.dart';
|
||||
import 'secret_field.dart';
|
||||
|
||||
/// 환경 설정 > 파일 탐색기 > WebDAV: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인).
|
||||
/// 서버마다 파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆에 탭이 생긴다.
|
||||
|
|
@ -106,7 +107,6 @@ Future<DavServer?> editDavServer(BuildContext context, AppController c, {DavServ
|
|||
final user = TextEditingController(text: old?.user ?? '');
|
||||
final pass = TextEditingController(text: old?.password ?? '');
|
||||
var insecure = old?.insecure ?? false;
|
||||
var hide = true;
|
||||
String? result; // 연결 확인 결과
|
||||
var toHttps = false; // 서버가 https 로 옮기라고 함 → [https 로 바꾸기]
|
||||
var passed = false;
|
||||
|
|
@ -185,17 +185,7 @@ Future<DavServer?> editDavServer(BuildContext context, AppController c, {DavServ
|
|||
]),
|
||||
),
|
||||
TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디'))),
|
||||
TextField(
|
||||
controller: pass,
|
||||
obscureText: hide,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('비밀번호'),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18),
|
||||
onPressed: () => set(() => hide = !hide),
|
||||
),
|
||||
),
|
||||
),
|
||||
SecretField(controller: pass, label: tr('비밀번호')),
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: insecure,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/ui/secret_field.dart';
|
||||
|
||||
/// W 공용 위젯 약속 (SecretField): 처음은 늘 가림 · 눈 단추는 눌러 켜고 끄기 (누르는 동안만이 아님) · 자동 고침 · 추천 끔 ·
|
||||
/// 바깥 가리기 하나로 칸 여럿 · 좁은 폭 · 큰 글자에서 넘치지 않음. 위젯 하나에 대해 한 번만 둔다 (설계 7-2)
|
||||
void main() {
|
||||
TextField field(WidgetTester t, int i) => t.widget<TextField>(find.byType(TextField).at(i));
|
||||
EditableText edit(WidgetTester t, int i) => t.widget<EditableText>(find.byType(EditableText).at(i));
|
||||
|
||||
Future<void> show(WidgetTester t, Widget child, {double width = 412, double scale = 1}) async {
|
||||
t.view.physicalSize = Size(width, 900);
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.pumpWidget(MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: MediaQueryData(size: Size(width, 900), textScaler: TextScaler.linear(scale)),
|
||||
child: Scaffold(body: Column(children: [child])),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
testWidgets('스스로 들기: 처음 가림 → 누르면 보임 (손을 떼도 그대로) → 다시 누르면 가림', (t) async {
|
||||
final c = TextEditingController(text: 'pw');
|
||||
await show(t, SecretField(controller: c, label: '비밀번호'));
|
||||
expect(field(t, 0).obscureText, isTrue);
|
||||
expect(field(t, 0).autocorrect, isFalse);
|
||||
expect(field(t, 0).enableSuggestions, isFalse);
|
||||
expect(find.byTooltip('보이기'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.visibility_outlined), findsOneWidget);
|
||||
|
||||
await t.tap(find.byType(IconButton));
|
||||
await t.pump();
|
||||
expect(field(t, 0).obscureText, isFalse, reason: '누른 뒤 손을 떼도 보임');
|
||||
expect(find.byTooltip('가리기'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.visibility_off_outlined), findsOneWidget);
|
||||
|
||||
await t.tap(find.byType(IconButton));
|
||||
await t.pump();
|
||||
expect(field(t, 0).obscureText, isTrue);
|
||||
expect(edit(t, 0).controller.text, 'pw', reason: '보이기 · 가리기는 글을 바꾸지 않음');
|
||||
});
|
||||
|
||||
testWidgets('바깥 가리기: 눈 단추 하나가 두 칸을 함께, 단추 없는 칸은 따름', (t) async {
|
||||
var hidden = true;
|
||||
await show(
|
||||
t,
|
||||
StatefulBuilder(
|
||||
builder: (ctx, set) => Column(children: [
|
||||
SecretField(controller: TextEditingController(), label: 'a', hidden: hidden, onToggle: () => set(() => hidden = !hidden)),
|
||||
SecretField(controller: TextEditingController(), label: 'b', hidden: hidden),
|
||||
]),
|
||||
));
|
||||
expect(find.byType(IconButton), findsOneWidget, reason: '단추는 첫 칸에만');
|
||||
expect([field(t, 0).obscureText, field(t, 1).obscureText], [true, true]);
|
||||
await t.tap(find.byType(IconButton));
|
||||
await t.pump();
|
||||
expect([field(t, 0).obscureText, field(t, 1).obscureText], [false, false]);
|
||||
});
|
||||
|
||||
testWidgets('오류 글 · 도움 글이 칸 아래에 보임', (t) async {
|
||||
await show(t, SecretField(controller: TextEditingController(), label: '다시 넣기', helper: '없으면 비웁니다', errorText: '두 비밀번호가 다릅니다'));
|
||||
expect(find.text('두 비밀번호가 다릅니다'), findsOneWidget);
|
||||
});
|
||||
|
||||
for (final width in [300.0, 412.0]) {
|
||||
for (final scale in [1.0, 2.0]) {
|
||||
testWidgets('폭 $width · 배율 $scale: 긴 이름 · 도움 글도 넘치지 않음', (t) async {
|
||||
await show(
|
||||
t,
|
||||
SecretField(
|
||||
controller: TextEditingController(),
|
||||
label: 'API key or username:password',
|
||||
helper: 'If set, encrypts with AES-256. Leave empty for a plain ZIP archive file'),
|
||||
width: width,
|
||||
scale: scale);
|
||||
expect(t.takeException(), isNull);
|
||||
expect(t.getRect(find.byType(IconButton)).right, lessThanOrEqualTo(width));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,9 @@ class SftpTestServer {
|
|||
String? pubkey,
|
||||
String keytype = 'ed25519',
|
||||
bool legacyOnly = false,
|
||||
// 캡처 시험 (관리자 10/24): 포트 · 호스트 키를 실행마다 같게 (창의 포트 칸 · 지문 글이 흔들리지 않게). 0 이면 빈 포트
|
||||
int fixedPort = 0,
|
||||
bool fixedHostKey = false,
|
||||
}) async {
|
||||
final pr = await Process.start(sftpTestPython, [
|
||||
'tool/sftp_test_server.py',
|
||||
|
|
@ -38,6 +41,8 @@ class SftpTestServer {
|
|||
if (password != null) ...['--password', password],
|
||||
if (pubkey != null) ...['--pubkey', pubkey],
|
||||
if (legacyOnly) '--legacy-only',
|
||||
if (fixedPort != 0) ...['--port', '$fixedPort'],
|
||||
if (fixedHostKey) '--fixed-hostkey',
|
||||
]);
|
||||
final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream();
|
||||
pr.stderr.drain<void>();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
- 첫 줄에 "PORT <번호>" 를 찍는다 (port 0 이면 빈 포트).
|
||||
- --legacy-only: 호스트 키 서명을 ssh-rsa (SHA-1) 만 (오래된 서버 흉내, RSA 호스트 키일 때).
|
||||
- 호스트 키 파일이 없으면 만든다 (--keytype ed25519 · rsa).
|
||||
- --fixed-hostkey: 실행마다 같은 ed25519 호스트 키 (캡처의 지문 글이 흔들리지 않게 - 시험 전용, 파일에 남기지 않음).
|
||||
paramiko 3.5.1 이 필요하다 (tools\\sftp_test_py 의 venv: python -m pip install "paramiko==3.5.1").
|
||||
5.0 은 ssh-rsa (SHA-1) · SHA-1 키 교환을 빼서 --legacy-only (오래된 서버 흉내) 가 아무 방식도 내놓지 못한다.
|
||||
"""
|
||||
|
|
@ -202,6 +203,17 @@ def _load_or_make_key(path, keytype):
|
|||
return paramiko.Ed25519Key(filename=path)
|
||||
|
||||
|
||||
def _fixed_key():
|
||||
# 정한 글에서 만든 ed25519 키 - 캡처 시험이 실행마다 같은 지문을 보게 (시험 서버 전용)
|
||||
import hashlib
|
||||
import io
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
pk = ed25519.Ed25519PrivateKey.from_private_bytes(hashlib.sha256(b'jj-mkvmaker sftp shot host key').digest())
|
||||
pem = pk.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, serialization.NoEncryption())
|
||||
return paramiko.Ed25519Key(file_obj=io.StringIO(pem.decode()))
|
||||
|
||||
|
||||
def _public_key(path):
|
||||
text = open(path, encoding='ascii').read().split()
|
||||
kind, data = text[0], text[1]
|
||||
|
|
@ -218,9 +230,10 @@ def main():
|
|||
ap.add_argument('--pubkey')
|
||||
ap.add_argument('--legacy-only', action='store_true')
|
||||
ap.add_argument('--port', type=int, default=0)
|
||||
ap.add_argument('--fixed-hostkey', action='store_true')
|
||||
a = ap.parse_args()
|
||||
_Sftp.ROOT = os.path.abspath(a.root)
|
||||
host_key = _load_or_make_key(a.hostkey, a.keytype)
|
||||
host_key = _fixed_key() if a.fixed_hostkey else _load_or_make_key(a.hostkey, a.keytype)
|
||||
pub = _public_key(a.pubkey) if a.pubkey else None
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
|
|
|
|||
Loading…
Reference in New Issue