CH-J SM Mobileمدیریت سرور از طریق SSH
منو
منبع منتشرشده

CH-J SM Mobile

پوشه‌ها و فایل‌های یک نسخهٔ مشخص از برنامه را مرور کنید.

دانلود ZIP منبع
CH-J / SM Mobile 175 فایل
sm-mobile-20261016T185235Z-8e93ecc1b59a افزوده‌شده: · ساخته‌شده:
viewers.dart افزوده‌شده: تاریخ ثبت نشده است 22.1 KB · 719 خط
کد دانلود فایل
1import 'dart:async';
2import 'dart:convert';
3import 'dart:io';
4import 'dart:math' as math;
5import 'package:cryptography/cryptography.dart';
6import 'package:flutter/material.dart';
7import 'package:flutter/services.dart';
8import 'package:flutter_svg/flutter_svg.dart';
9import 'package:intl/intl.dart' show DateFormat;
10import 'package:path_provider/path_provider.dart';
11import '../../core/app_controller.dart';
12import '../../core/failure.dart';
13import '../../l10n/strings.dart';
14import '../../models/server_profile.dart';
15import '../../services/editor/inspect.dart';
16import '../../services/editor/languages.dart';
17import '../../services/editor/sources.dart';
18import '../../services/metrics_format.dart';
19import '../../services/sftp_service.dart';
20import '../../services/ssh_service.dart';
21import '../../services/vault.dart';
22import '../common.dart';
23import '../export_sheet.dart';
24import 'editor_page.dart';
25import 'save_as.dart';
27/// Opens [source] with the right tool: the editor for text, a viewer for
28/// everything else. Nothing is executed: HTML and SVG are never run as
29/// pages, and documents are shown by the system viewer from a private
30/// temporary copy.
31Future<void> openSource(
32 BuildContext context,
33 AppController c,
34 FileSource source, {
35 Draft? draft,
36}) async {
37 unawaited(recordRecent(c, source));
38 final kind = draft != null ? FileKind.text : fileKind(source.name);
39 final page = kind == FileKind.text
40 ? EditorPage(c, source, draft: draft)
41 : ViewerPage(c, source, kind: kind);
42 await Navigator.push(context, MaterialPageRoute(builder: (_) => page));
45class ViewerPage extends StatefulWidget {
46 final AppController controller;
47 final FileSource source;
48 final FileKind kind;
49 const ViewerPage(
50 this.controller,
51 this.source, {
52 required this.kind,
53 super.key,
54 });
55 @override
56 State<ViewerPage> createState() => _ViewerPageState();
59class _ViewerPageState extends State<ViewerPage> {
60 Uint8List? bytes;
61 String? error;
62 late FileKind kind = widget.kind;
63 AppController get c => widget.controller;
65 @override
66 void initState() {
67 super.initState();
68 unawaited(_load());
69 }
71 Future<void> _load() async {
72 try {
73 final b = await widget.source.load(maxBytes: maxViewBytes);
74 if (!mounted) return;
75 setState(() {
76 bytes = b;
77 if (kind == FileKind.text) kind = fileKind(widget.source.name, head: b);
78 });
79 } catch (e) {
80 if (mounted) setState(() => error = failureCode(e));
81 }
82 }
84 void _asText() => Navigator.pushReplacement(
85 context,
86 MaterialPageRoute(
87 builder: (_) => EditorPage(c, widget.source, bytes: bytes),
88 ),
89 );
91 Future<String> _tempCopy() async {
92 final temp = await getTemporaryDirectory();
93 final dir = await Directory('${temp.path}/sm-transfer-${newId()}').create();
94 final file = File('${dir.path}/${safeName(widget.source.name)}');
95 await file.writeAsBytes(bytes!, flush: true);
96 return file.path;
97 }
99 /// The system viewer (Quick Look on iOS, an app the user picks on
100 /// Android) shows a private temporary copy; the copy is removed when the
101 /// Vault locks or the app restarts.
102 Future<void> _system() async {
103 try {
104 final path = await _tempCopy();
105 final shown = await c.withSystemDialog(
106 () => c.documents.preview(path, mime: mimeOf(widget.source.name)),
107 );
108 if (!shown && mounted) {
109 showFailure(context, const Failure('noViewer'));
110 }
111 } catch (e) {
112 if (mounted) showFailure(context, e);
113 }
114 }
116 Future<void> _share() async {
117 final box = context.findRenderObject() as RenderBox?;
118 final o = box?.localToGlobal(Offset.zero) ?? Offset.zero;
119 try {
120 await shareBytes(
121 c,
122 widget.source.name,
123 bytes!,
124 Rect.fromLTWH(o.dx, o.dy, 1, 1),
125 );
126 } catch (e) {
127 if (mounted) showFailure(context, e);
128 }
129 }
131 Future<void> _saveCopy() async {
132 try {
133 await saveAsDialog(context, c, name: widget.source.name, bytes: bytes!);
134 } catch (e) {
135 if (mounted) showFailure(context, e);
136 }
137 }
139 @override
140 Widget build(BuildContext context) {
141 final b = bytes;
142 return Scaffold(
143 appBar: AppBar(
144 title: Column(
145 crossAxisAlignment: CrossAxisAlignment.start,
146 children: [
147 Text(
148 widget.source.name,
149 maxLines: 1,
150 overflow: TextOverflow.ellipsis,
151 ),
152 Text(
153 widget.source.location,
154 maxLines: 1,
155 overflow: TextOverflow.ellipsis,
156 style: Theme.of(context).textTheme.bodySmall,
157 ),
158 ],
159 ),
160 actions: [
161 if (b != null)
162 PopupMenuButton<String>(
163 onSelected: (v) => switch (v) {
164 'text' => _asText(),
165 'system' => _system(),
166 'share' => _share(),
167 'save' => _saveCopy(),
168 _ => null,
169 },
170 itemBuilder: (ctx) => [
171 if (b.length <= maxEditableBytes)
172 PopupMenuItem(
173 value: 'text',
174 child: Text(ctx.tr('openAsText')),
175 ),
176 PopupMenuItem(
177 value: 'system',
178 child: Text(ctx.tr('openInSystemViewer')),
179 ),
180 PopupMenuItem(value: 'save', child: Text(ctx.tr('saveAs'))),
181 PopupMenuItem(value: 'share', child: Text(ctx.tr('shareFile'))),
182 ],
183 ),
184 ],
185 ),
186 body: SafeArea(
187 child: error != null
188 ? PageMessage(error!, icon: Icons.error_outline)
189 : b == null
190 ? const Center(child: CircularProgressIndicator())
191 : switch (kind) {
192 FileKind.image => _ImageView(b),
193 FileKind.svg => _SvgView(b, onSource: _asText),
194 FileKind.pdf when Platform.isAndroid => _PdfView(c, _tempCopy),
195 FileKind.pdf ||
196 FileKind.office ||
197 FileKind.audio ||
198 FileKind.video => _SystemView(kind, b.length, onOpen: _system),
199 FileKind.archive => _ArchiveView(widget.source.name, b),
200 FileKind.certificate => _CryptoView(b, onText: _asText),
201 FileKind.notebook => _NotebookView(b, onJson: _asText),
202 FileKind.binary ||
203 FileKind.text => _HexView(b, onText: _asText),
204 },
205 ),
206 );
207 }
210class _Info extends StatelessWidget {
211 final IconData icon;
212 final String title;
213 final String? detail;
214 final List<Widget> actions;
215 const _Info(this.icon, this.title, {this.detail, this.actions = const []});
216 @override
217 Widget build(BuildContext context) => Center(
218 child: SingleChildScrollView(
219 padding: const EdgeInsets.all(24),
220 child: ConstrainedBox(
221 constraints: const BoxConstraints(maxWidth: 520),
222 child: Column(
223 mainAxisSize: MainAxisSize.min,
224 children: [
225 Icon(icon, size: 56, color: Theme.of(context).colorScheme.primary),
226 const SizedBox(height: 16),
227 Text(
228 title,
229 textAlign: TextAlign.center,
230 style: Theme.of(context).textTheme.titleMedium,
231 ),
232 if (detail != null) ...[
233 const SizedBox(height: 8),
234 Text(detail!, textAlign: TextAlign.center),
235 ],
236 const SizedBox(height: 16),
237 Wrap(
238 spacing: 8,
239 runSpacing: 8,
240 alignment: WrapAlignment.center,
241 children: actions,
242 ),
243 ],
244 ),
245 ),
246 ),
247 );
250class _ImageView extends StatelessWidget {
251 final Uint8List bytes;
252 const _ImageView(this.bytes);
253 @override
254 Widget build(BuildContext context) => InteractiveViewer(
255 maxScale: 12,
256 child: Center(
257 child: Image.memory(
258 bytes,
259 filterQuality: FilterQuality.medium,
260 errorBuilder: (ctx, _, _) =>
261 _Info(Icons.broken_image_outlined, ctx.tr('imageUnsupported')),
262 ),
263 ),
264 );
267/// SVG is drawn as a picture: no scripts, no animation, no external
268/// resources, no links.
269class _SvgView extends StatelessWidget {
270 final Uint8List bytes;
271 final VoidCallback onSource;
272 const _SvgView(this.bytes, {required this.onSource});
273 @override
274 Widget build(BuildContext context) => Column(
275 children: [
276 MaterialBanner(
277 content: Text(context.tr('svgSafe')),
278 actions: [
279 TextButton(
280 onPressed: onSource,
281 child: Text(context.tr('openAsText')),
282 ),
283 ],
284 ),
285 Expanded(
286 child: InteractiveViewer(
287 maxScale: 12,
288 child: Center(
289 child: SvgPicture.memory(
290 bytes,
291 errorBuilder: (ctx, _, _) => _Info(
292 Icons.broken_image_outlined,
293 ctx.tr('imageUnsupported'),
294 ),
295 ),
296 ),
297 ),
298 ),
299 ],
300 );
303class _SystemView extends StatelessWidget {
304 final FileKind kind;
305 final int size;
306 final VoidCallback onOpen;
307 const _SystemView(this.kind, this.size, {required this.onOpen});
308 @override
309 Widget build(BuildContext context) => _Info(
310 switch (kind) {
311 FileKind.audio => Icons.audiotrack,
312 FileKind.video => Icons.movie_outlined,
313 FileKind.pdf => Icons.picture_as_pdf_outlined,
314 _ => Icons.description_outlined,
315 },
316 context.tr('systemViewerTitle'),
317 detail:
318 '${context.tr(Platform.isIOS ? 'systemViewerIos' : 'systemViewerAndroid')}\n'
319 '${context.tr('size')}: ${formatBytes(size, (v, d) => v.toStringAsFixed(d))}',
320 actions: [
321 FilledButton.icon(
322 onPressed: onOpen,
323 icon: const Icon(Icons.open_in_new),
324 label: Text(context.tr('openInSystemViewer')),
325 ),
326 ],
327 );
330/// Android: PDF pages rendered by the system PdfRenderer.
331class _PdfView extends StatefulWidget {
332 final AppController controller;
333 final Future<String> Function() copy;
334 const _PdfView(this.controller, this.copy);
335 @override
336 State<_PdfView> createState() => _PdfViewState();
339class _PdfViewState extends State<_PdfView> {
340 String? path, error;
341 int pages = 0;
342 final cache = <int, Future<Uint8List>>{};
343 @override
344 void initState() {
345 super.initState();
346 unawaited(() async {
347 try {
348 final p = await widget.copy();
349 final n = await widget.controller.documents.pdfPageCount(p);
350 if (mounted) {
351 setState(() {
352 path = p;
353 pages = n;
354 });
355 }
356 } catch (e) {
357 if (mounted) setState(() => error = failureCode(e));
358 }
359 }());
360 }
362 @override
363 Widget build(BuildContext context) {
364 if (error != null) return PageMessage(error!, icon: Icons.error_outline);
365 if (path == null) return const Center(child: CircularProgressIndicator());
366 return LayoutBuilder(
367 builder: (context, constraints) {
368 final width =
369 (constraints.maxWidth * MediaQuery.devicePixelRatioOf(context))
370 .clamp(400, 2400)
371 .round();
372 return InteractiveViewer(
373 maxScale: 6,
374 child: ListView.builder(
375 itemCount: pages,
376 itemBuilder: (context, i) => Padding(
377 padding: const EdgeInsets.all(8),
378 child: FutureBuilder<Uint8List>(
379 future: cache[i] ??= widget.controller.documents.pdfPage(
380 path!,
381 i,
382 width,
383 ),
384 builder: (context, snap) => snap.hasData
385 ? Image.memory(snap.data!)
386 : AspectRatio(
387 aspectRatio: 1 / 1.414,
388 child: Center(
389 child: snap.hasError
390 ? Text(context.tr('fileError'))
391 : const CircularProgressIndicator(),
392 ),
393 ),
394 ),
395 ),
396 ),
397 );
398 },
399 );
400 }
403class _ArchiveView extends StatelessWidget {
404 final String name;
405 final Uint8List bytes;
406 const _ArchiveView(this.name, this.bytes);
407 @override
408 Widget build(BuildContext context) {
409 final ArchiveListing listing;
410 try {
411 listing = listArchive(name, bytes);
412 } on ArchiveUnsupported catch (e) {
413 return _Info(
414 Icons.archive_outlined,
415 e.format == 'tooLarge'
416 ? context.tr('archiveTooLarge')
417 : '${context.tr('archiveUnsupported')} (${e.format})',
418 );
419 } catch (_) {
420 return _Info(Icons.archive_outlined, context.tr('archiveDamaged'));
421 }
422 String size(int v) => formatBytes(v, (x, d) => x.toStringAsFixed(d));
423 final date = DateFormat.yMd(
424 Localizations.localeOf(context).toString(),
425 ).add_Hm();
426 final files = listing.entries.where((e) => !e.directory).toList();
427 return Column(
428 crossAxisAlignment: CrossAxisAlignment.stretch,
429 children: [
430 ListTile(
431 title: Text(listing.format),
432 subtitle: Text(
433 '${files.length} ${context.tr('filesCount')} · '
434 '${size(files.fold(0, (a, e) => a + e.size))}'
435 '${listing.uncompressed == null ? '' : ' · ${context.tr('uncompressed')}: ${size(listing.uncompressed!)}'}'
436 '\n${context.tr('archiveListOnly')}',
437 ),
438 ),
439 const Divider(height: 1),
440 Expanded(
441 child: ListView.builder(
442 itemCount: listing.entries.length,
443 itemBuilder: (context, i) {
444 final e = listing.entries[i];
445 return ListTile(
446 dense: true,
447 leading: Icon(
448 e.directory
449 ? Icons.folder_outlined
450 : Icons.insert_drive_file_outlined,
451 ),
452 title: Text(e.name),
453 subtitle: e.directory
454 ? null
455 : Text(
456 [
457 size(e.size),
458 if (e.modified != null)
459 date.format(e.modified!.toLocal()),
460 ].join(' · '),
461 ),
462 );
463 },
464 ),
465 ),
466 ],
467 );
468 }
471class _CryptoView extends StatelessWidget {
472 final Uint8List bytes;
473 final VoidCallback onText;
474 const _CryptoView(this.bytes, {required this.onText});
475 @override
476 Widget build(BuildContext context) => FutureBuilder<List<CryptoItem>>(
477 future: inspectCrypto(bytes),
478 builder: (context, snap) {
479 if (!snap.hasData) {
480 return snap.hasError
481 ? _Info(Icons.key_off, context.tr('cryptoUnreadable'))
482 : const Center(child: CircularProgressIndicator());
483 }
484 final items = snap.data!;
485 return ListView(
486 padding: const EdgeInsets.all(16),
487 children: [
488 Text(
489 context.tr('cryptoNote'),
490 style: Theme.of(context).textTheme.bodySmall,
491 ),
492 const SizedBox(height: 8),
493 if (items.isEmpty) Text(context.tr('cryptoUnreadable')),
494 for (final item in items)
495 Card(
496 child: Padding(
497 padding: const EdgeInsets.all(16),
498 child: Column(
499 crossAxisAlignment: CrossAxisAlignment.start,
500 children: [
501 Row(
502 children: [
503 Icon(
504 item.secret ? Icons.vpn_key : Icons.verified_outlined,
505 ),
506 const SizedBox(width: 8),
507 Expanded(
508 child: Text(
509 item.type,
510 style: Theme.of(context).textTheme.titleMedium,
511 ),
512 ),
513 ],
514 ),
515 if (item.secret)
516 Padding(
517 padding: const EdgeInsets.only(top: 8),
518 child: Text(
519 context.tr('privateKeyHidden'),
520 style: TextStyle(
521 color: Theme.of(context).colorScheme.error,
522 ),
523 ),
524 ),
525 for (final MapEntry(:key, :value) in item.fields.entries)
526 Padding(
527 padding: const EdgeInsets.only(top: 6),
528 child: Column(
529 crossAxisAlignment: CrossAxisAlignment.start,
530 children: [
531 Text(
532 key,
533 style: Theme.of(context).textTheme.bodySmall,
534 ),
535 SelectableText(value),
536 ],
537 ),
538 ),
539 ],
540 ),
541 ),
542 ),
543 const SizedBox(height: 8),
544 OutlinedButton(
545 onPressed: onText,
546 child: Text(context.tr('openAsText')),
547 ),
548 ],
549 );
550 },
551 );
554class _NotebookView extends StatelessWidget {
555 final Uint8List bytes;
556 final VoidCallback onJson;
557 const _NotebookView(this.bytes, {required this.onJson});
558 @override
559 Widget build(BuildContext context) {
560 final List<NotebookCell> cells;
561 try {
562 cells = parseNotebook(
563 String.fromCharCodes(bytes).contains('Ã')
564 ? const Utf8Decoder(allowMalformed: true).convert(bytes)
565 : const Utf8Decoder(allowMalformed: true).convert(bytes),
566 );
567 } catch (_) {
568 return _Info(
569 Icons.data_object,
570 context.tr('notebookUnreadable'),
571 actions: [
572 FilledButton(
573 onPressed: onJson,
574 child: Text(context.tr('openAsText')),
575 ),
576 ],
577 );
578 }
579 final mono = TextStyle(
580 fontFamily: Platform.isIOS ? 'Menlo' : 'monospace',
581 fontSize: 13,
582 );
583 return ListView(
584 padding: const EdgeInsets.all(12),
585 children: [
586 Align(
587 alignment: AlignmentDirectional.centerEnd,
588 child: TextButton(
589 onPressed: onJson,
590 child: Text(context.tr('openAsText')),
591 ),
592 ),
593 for (final cell in cells)
594 Card(
595 child: Padding(
596 padding: const EdgeInsets.all(12),
597 child: Column(
598 crossAxisAlignment: CrossAxisAlignment.stretch,
599 children: [
600 Text(
601 cell.type,
602 style: Theme.of(context).textTheme.labelSmall,
603 ),
604 const SizedBox(height: 4),
605 SelectableText(
606 cell.source,
607 style: cell.type == 'code' ? mono : null,
608 ),
609 for (final o in cell.outputs) ...[
610 const Divider(),
611 SelectableText(o, style: mono),
612 ],
613 for (final img in cell.images) ...[
614 const Divider(),
615 Image.memory(
616 img,
617 errorBuilder: (_, _, _) => const SizedBox.shrink(),
618 ),
619 ],
620 ],
621 ),
622 ),
623 ),
624 ],
625 );
626 }
629/// Size, SHA-256 and a hexadecimal view of any file.
630class _HexView extends StatefulWidget {
631 final Uint8List bytes;
632 final VoidCallback onText;
633 const _HexView(this.bytes, {required this.onText});
634 @override
635 State<_HexView> createState() => _HexViewState();
638class _HexViewState extends State<_HexView> {
639 String? sha256;
640 @override
641 void initState() {
642 super.initState();
643 unawaited(() async {
644 final h = await Sha256().hash(widget.bytes);
645 if (mounted) {
646 setState(
647 () => sha256 = h.bytes
648 .map((b) => b.toRadixString(16).padLeft(2, '0'))
649 .join(),
650 );
651 }
652 }());
653 }
655 @override
656 Widget build(BuildContext context) {
657 final b = widget.bytes;
658 final rows = (b.length / 16).ceil();
659 final mono = TextStyle(
660 fontFamily: Platform.isIOS ? 'Menlo' : 'monospace',
661 fontSize: 12,
662 fontFeatures: const [FontFeature.tabularFigures()],
663 );
664 return Column(
665 crossAxisAlignment: CrossAxisAlignment.stretch,
666 children: [
667 ListTile(
668 title: Text(context.tr('binaryFile')),
669 subtitle: SelectableText(
670 '${context.tr('size')}: ${b.length} B\nSHA-256: ${sha256 ?? '…'}',
671 ),
672 trailing: b.length <= maxEditableBytes
673 ? TextButton(
674 onPressed: widget.onText,
675 child: Text(context.tr('openAsText')),
676 )
677 : null,
678 ),
679 const Divider(height: 1),
680 Expanded(
681 child: Scrollbar(
682 child: SingleChildScrollView(
683 scrollDirection: Axis.horizontal,
684 child: SizedBox(
685 width: 640,
686 child: ListView.builder(
687 itemCount: rows,
688 itemExtent: 20,
689 itemBuilder: (context, r) {
690 final start = r * 16, end = math.min(start + 16, b.length);
691 final hex = StringBuffer(), text = StringBuffer();
692 for (var i = start; i < start + 16; i++) {
693 if (i < end) {
694 hex.write('${b[i].toRadixString(16).padLeft(2, '0')} ');
695 text.writeCharCode(
696 b[i] >= 0x20 && b[i] < 0x7f ? b[i] : 0x2e,
697 );
698 } else {
699 hex.write(' ');
700 }
701 if (i == start + 7) hex.write(' ');
702 }
703 return Padding(
704 padding: const EdgeInsets.symmetric(horizontal: 12),
705 child: Text(
706 '${start.toRadixString(16).padLeft(8, '0')} $hex $text',
707 style: mono,
708 ),
709 );
710 },
711 ),
712 ),
713 ),
714 ),
715 ),
716 ],
717 );
718 }
SHA-2561e400e9d6a27416e05b6b75805e362c14893e6a8c20230a39126834a65f77fe7

CH-J Proprietary Software License 1.14 · کد منبع تحت CH-J Proprietary Software License 1.14 ارائه می‌شود. در دسترس بودن آن شرایط مجوز را تغییر نمی‌دهد و حق اضافه‌ای اعطا نمی‌کند.

SHA-256 بایگانی46e427a43e87af091004f36145590bda8fe90086dfb0f631afeb5029fb0b5655
فایل‌ها