diff --git a/lib/models/chart.dart b/lib/models/chart.dart index f07bcd2..3e2cc9d 100644 --- a/lib/models/chart.dart +++ b/lib/models/chart.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import '../utils/log.dart'; + /// Directory (relative to the deck) where linked chart CSVs are kept, so the /// data files stay tidily in one place — separate from images/media. const String chartDataDirName = 'data'; @@ -155,7 +157,8 @@ class ChartSpec { ChartSeries.fromJson(Map.from(s as Map)), ], ); - } catch (_) { + } catch (e, s) { + logError('ChartSpec.parse: decode chart JSON block', e, s); return const ChartSpec(); } } diff --git a/lib/services/annotation_codec.dart b/lib/services/annotation_codec.dart index 78fc8cd..b3b024f 100644 --- a/lib/services/annotation_codec.dart +++ b/lib/services/annotation_codec.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import '../models/annotation.dart'; import '../models/slide.dart'; +import '../utils/log.dart'; /// Serializes the annotation layer into a sidecar payload that is fully /// decoupled from the Marp markdown. @@ -96,7 +97,8 @@ class AnnotationCodec { used.add(target); result[slides[target].id] = strokes; } - } catch (_) { + } catch (e, s) { + logError('AnnotationCodec.decode: decode annotation sidecar JSON', e, s); return {}; } return result; diff --git a/lib/services/caption_service.dart b/lib/services/caption_service.dart index 7433140..b00a71e 100644 --- a/lib/services/caption_service.dart +++ b/lib/services/caption_service.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; +import '../utils/log.dart'; + /// Slaat afbeeldingscaptions op als JSON-sidecar in de map van de afbeelding. /// Bestandsnaam: .ocideck_captions.json class CaptionService { @@ -17,7 +19,8 @@ class CaptionService { final data = jsonDecode(await file.readAsString()) as Map; final caption = data[p.basename(resolvedPath)]; return caption is String ? caption : null; - } catch (_) { + } catch (e) { + logWarning('CaptionService.getCaption: read caption sidecar', e); return null; } } @@ -36,7 +39,9 @@ class CaptionService { data = Map.from( jsonDecode(await file.readAsString()) as Map, ); - } catch (_) {} + } catch (e, s) { + logError('CaptionService.saveCaption: parse existing sidecar', e, s); + } } final key = p.basename(resolvedPath); if (caption.trim().isEmpty) { diff --git a/lib/services/description_service.dart b/lib/services/description_service.dart index e0e3809..074df37 100644 --- a/lib/services/description_service.dart +++ b/lib/services/description_service.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; +import '../utils/log.dart'; + /// Stores short, searchable image descriptions as a JSON sidecar in the image's /// own directory. File name: .ocideck_descriptions.json, keyed by base name. /// @@ -19,7 +21,11 @@ class DescriptionService { final data = jsonDecode(await file.readAsString()) as Map; final value = data[p.basename(imagePath)]; return value is String ? value : null; - } catch (_) { + } catch (e) { + logWarning( + 'DescriptionService.getDescription: read description sidecar', + e, + ); return null; } } @@ -33,7 +39,13 @@ class DescriptionService { data = Map.from( jsonDecode(await file.readAsString()) as Map, ); - } catch (_) {} + } catch (e, s) { + logError( + 'DescriptionService.saveDescription: parse existing sidecar', + e, + s, + ); + } } final key = p.basename(imagePath); if (description.trim().isEmpty) { @@ -71,7 +83,9 @@ class DescriptionService { result[p.join(dir, entry.key as String)] = entry.value as String; } } - } catch (_) {} + } catch (e) { + logWarning('DescriptionService.loadFor: read description sidecar', e); + } } return result; } diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index 6c7216f..681892d 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -10,6 +10,7 @@ import '../l10n/app_localizations.dart'; import '../models/settings.dart'; import '../models/chart.dart'; import '../models/slide.dart'; +import '../utils/log.dart'; import 'annotation_codec.dart'; import 'caption_service.dart'; import 'image_service.dart'; @@ -64,6 +65,19 @@ class FileService { ThemeProfile activeProfileFor({String? projectPath}) => resolveThemeProfile(_themeProfile(), projectPath: projectPath); + /// Resolve a project-relative [path] to an absolute path strictly inside + /// [projectPath], or null for absolute paths or `../` escapes. Used for file + /// references an untrusted deck controls (e.g. a chart's linked CSV) so it + /// can't read arbitrary files outside its own folder. + static String? _projectFile(String? projectPath, String path) { + if (projectPath == null || path.trim().isEmpty || p.isAbsolute(path)) { + return null; + } + final abs = p.normalize(p.join(projectPath, path)); + if (abs != projectPath && !p.isWithin(projectPath, abs)) return null; + return abs; + } + ThemeProfile resolveThemeProfile( ThemeProfile profile, { String? projectPath, @@ -112,7 +126,11 @@ class FileService { List entries; try { entries = await dir.list(followLinks: false).toList(); - } catch (_) { + } catch (e) { + logWarning( + 'FileService.scanPresentations: directory listing failed', + e, + ); return; } for (final entity in entries) { @@ -124,7 +142,8 @@ class FileService { String content; try { content = await entity.readAsString(); - } catch (_) { + } catch (e) { + logWarning('FileService.scanPresentations: file not readable', e); continue; } final deck = await openDeck(entity.path, content: content); @@ -190,8 +209,9 @@ class FileService { hydrated.slides, ); if (map.isNotEmpty) return hydrated.copyWith(annotations: map); - } catch (_) { + } catch (e) { // A broken sidecar must never block opening the deck. + logWarning('FileService.openDeck: annotation sidecar unreadable', e); } } } @@ -229,11 +249,11 @@ class FileService { slides.add(s); continue; } - final abs = p.isAbsolute(spec.source!) - ? spec.source! - : p.join(deck.projectPath!, spec.source!); - final file = File(abs); - if (!await file.exists()) { + // A chart's CSV link must stay inside the project (no absolute paths or + // `../` escapes) — otherwise an untrusted deck could read arbitrary files. + final abs = _projectFile(deck.projectPath, spec.source!); + final file = abs == null ? null : File(abs); + if (file == null || !await file.exists()) { slides.add(s); continue; } @@ -241,7 +261,8 @@ class FileService { final csv = await file.readAsString(); slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock())); changed = true; - } catch (_) { + } catch (e) { + logWarning('FileService._hydrateCharts: chart CSV unreadable', e); slides.add(s); } } @@ -325,9 +346,18 @@ class FileService { /// bestand toe onder `/` en geef dat pad terug. String? addAsset(String path, String subdir) { if (path.trim().isEmpty) return null; - final abs = p.isAbsolute(path) - ? path - : (deck.projectPath != null ? p.join(deck.projectPath!, path) : path); + final String abs; + if (p.isAbsolute(path)) { + // Absolute paths come from the picker (the user explicitly chose them). + abs = path; + } else if (deck.projectPath != null) { + // A relative asset must not escape the project via `../`. + final resolved = _projectFile(deck.projectPath, path); + if (resolved == null) return null; + abs = resolved; + } else { + abs = path; + } final file = File(abs); if (!file.existsSync()) return null; final rel = p.posix.join(subdir, p.basename(abs)); @@ -415,7 +445,8 @@ class FileService { profile, logoRel == null ? null : '../$logoRel', ); - } catch (_) { + } catch (e) { + logWarning('FileService._packageThemeCss: theme asset not bundled', e); return null; } } @@ -429,7 +460,8 @@ class FileService { final Archive archive; try { archive = ZipDecoder().decodeBytes(zipBytes); - } catch (_) { + } catch (e, s) { + logError('FileService.importPackageBytes: ZIP decode failed', e, s); return null; } @@ -448,14 +480,33 @@ class FileService { final destDir = _uniqueDir(destParentDir, folderName); await destDir.create(recursive: true); - for (final f in archive.files) { - if (!f.isFile) continue; - final out = File(p.join(destDir.path, f.name)); - await out.parent.create(recursive: true); - await out.writeAsBytes(f.content as List, flush: true); + // Resolve an archive entry name to a path strictly inside [destDir], or + // null when it would escape (zip-slip: `../`, absolute paths, …). + String? safeOutPath(String entryName) { + final resolved = p.normalize(p.join(destDir.path, entryName)); + if (resolved != destDir.path && !p.isWithin(destDir.path, resolved)) { + return null; + } + return resolved; } - return p.join(destDir.path, mdEntry.name); + var extracted = 0; + for (final f in archive.files) { + if (!f.isFile) continue; + final outPath = safeOutPath(f.name); + if (outPath == null) continue; // skip path-traversal entries + final content = f.content as List; + // Bound total extracted size so a small zip can't fill the disk (zip bomb). + extracted += content.length; + if (extracted > _maxDownloadBytes) break; + final out = File(outPath); + await out.parent.create(recursive: true); + await out.writeAsBytes(content, flush: true); + } + + // The main markdown must itself resolve inside the extraction folder. + final mdPath = safeOutPath(mdEntry.name); + return mdPath; } Directory _uniqueDir(String parent, String name) { @@ -471,26 +522,65 @@ class FileService { /// Download een presentatie vanaf [url]. Een zip-pakket wordt uitgepakt; /// platte markdown wordt als losse `.md` opgeslagen. Geeft het pad naar het /// markdown-bestand terug. + /// Cap on how much we download / extract, to bound memory and disk use. + static const _maxDownloadBytes = 64 * 1024 * 1024; // 64 MB + + /// Hosts an import must never reach (loopback, private and link-local ranges) + /// so a deck URL can't be used to probe the local machine or intranet (SSRF). + static bool _isBlockedHost(String host) { + final h = host.toLowerCase(); + if (h.isEmpty || h == 'localhost' || h.endsWith('.localhost')) return true; + final addr = InternetAddress.tryParse(host); + if (addr == null) return false; // a hostname; can't classify offline + if (addr.isLoopback || addr.isLinkLocal || addr.isMulticast) return true; + final raw = addr.rawAddress; + if (addr.type == InternetAddressType.IPv4) { + final a = raw[0], b = raw[1]; + if (a == 0 || a == 10 || a == 127) { + return true; // this-host/private/loopback + } + if (a == 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a == 192 && b == 168) return true; // 192.168.0.0/16 + if (a == 169 && b == 254) return true; // 169.254.0.0/16 link-local + } else if ((raw[0] & 0xfe) == 0xfc) { + return true; // fc00::/7 unique-local + } + return false; + } + Future importFromUrl(String url, String destParentDir) async { final uri = Uri.tryParse(url.trim()); if (uri == null || !uri.hasScheme) return null; + // Only fetch over web schemes, and never reach private/loopback hosts. + final scheme = uri.scheme.toLowerCase(); + if (scheme != 'http' && scheme != 'https') return null; + if (_isBlockedHost(uri.host)) return null; final List bytes; try { - final client = HttpClient(); + final client = HttpClient() + ..connectionTimeout = const Duration(seconds: 15); try { final request = await client.getUrl(uri); - final response = await request.close(); + // Don't auto-follow redirects: a 3xx could point at a private host and + // bypass the SSRF check above. + request.followRedirects = false; + final response = await request.close().timeout( + const Duration(seconds: 30), + ); if (response.statusCode != 200) return null; + if (response.contentLength > _maxDownloadBytes) return null; final builder = BytesBuilder(copy: false); await for (final chunk in response) { builder.add(chunk); + if (builder.length > _maxDownloadBytes) return null; // runaway body } bytes = builder.takeBytes(); } finally { client.close(force: true); } - } catch (_) { + } catch (e) { + logError('FileService.importFromUrl: download failed', e); return null; } @@ -509,7 +599,8 @@ class FileService { final String markdown; try { markdown = utf8.decode(bytes); - } catch (_) { + } catch (e, s) { + logError('FileService.importFromUrl: UTF-8 decode failed', e, s); return null; } if (!markdown.contains('marp') && !markdown.contains('---')) return null; @@ -630,8 +721,9 @@ class FileService { 'assets/themes/ocideck.css', )).replaceFirst('@theme ocideck', '@theme $safeThemeName'); await dest.writeAsString(_buildThemeCss(base, profile, logoUrl)); - } catch (_) { + } catch (e) { // Asset not bundled in this build context; skip + logWarning('FileService._writeTheme: theme asset not bundled', e); } } diff --git a/lib/services/image_dedup_service.dart b/lib/services/image_dedup_service.dart index 2af529d..e205815 100644 --- a/lib/services/image_dedup_service.dart +++ b/lib/services/image_dedup_service.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../utils/log.dart'; /// Vindt exacte duplicaten tussen afbeeldingen op basis van hun md5-checksum, /// zodat de bibliotheek opgeschoond kan worden. Bestanden worden eerst op @@ -20,7 +21,9 @@ class ImageDedupService { try { final size = File(path).statSync().size; bySize.putIfAbsent(size, () => []).add(path); - } catch (_) {} + } catch (e) { + logWarning('ImageDedupService.findDuplicateGroups: stat for size', e); + } } // Stap 2: alleen binnen gelijke groottes de md5 berekenen. @@ -32,7 +35,9 @@ class ImageDedupService { try { final digest = await md5.bind(File(path).openRead()).single; byHash.putIfAbsent(digest.toString(), () => []).add(path); - } catch (_) {} + } catch (e) { + logWarning('ImageDedupService.findDuplicateGroups: md5 hash', e); + } } for (final group in byHash.values) { if (group.length >= 2) groups.add(group); @@ -51,7 +56,8 @@ class ImageDedupService { DateTime modifiedOf(String path) { try { return File(path).statSync().modified; - } catch (_) { + } catch (e) { + logWarning('ImageDedupService.chooseKeeper: stat modified time', e); return DateTime.fromMillisecondsSinceEpoch(0); } } diff --git a/lib/services/image_reference_service.dart b/lib/services/image_reference_service.dart index 485cabb..376adea 100644 --- a/lib/services/image_reference_service.dart +++ b/lib/services/image_reference_service.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; +import '../utils/log.dart'; /// Vindt en herschrijft afbeeldingsverwijzingen (`![…](pad)`) in /// Marp-markdownbestanden op schijf. Zo gaan bij het opruimen van duplicaten @@ -31,7 +32,8 @@ class ImageReferenceService { List entries; try { entries = await dir.list(followLinks: false).toList(); - } catch (_) { + } catch (e) { + logWarning('ImageReferenceService.findDeckFiles: list directory', e); return; } for (final entity in entries) { @@ -69,7 +71,8 @@ class ImageReferenceService { String content; try { content = await File(deckFile).readAsString(); - } catch (_) { + } catch (e) { + logWarning('ImageReferenceService.countReferences: read deck file', e); continue; } final mdDir = p.dirname(deckFile); @@ -100,7 +103,8 @@ class ImageReferenceService { String content; try { content = await File(deckFile).readAsString(); - } catch (_) { + } catch (e) { + logWarning('ImageReferenceService.referencingFiles: read deck file', e); continue; } final mdDir = p.dirname(deckFile); @@ -127,7 +131,8 @@ class ImageReferenceService { String content; try { content = await file.readAsString(); - } catch (_) { + } catch (e) { + logWarning('ImageReferenceService.replaceReferences: read deck file', e); return false; } final mdDir = p.dirname(deckFile); @@ -150,7 +155,8 @@ class ImageReferenceService { if (!changed) return false; try { await file.writeAsString(updated); - } catch (_) { + } catch (e) { + logWarning('ImageReferenceService.replaceReferences: write deck file', e); return false; } return true; @@ -159,7 +165,9 @@ class ImageReferenceService { String? _resolve(String ref, String mdDir) { final cleaned = ref.trim(); if (cleaned.isEmpty || cleaned.contains('://')) return null; - return p.normalize(p.isAbsolute(cleaned) ? cleaned : p.join(mdDir, cleaned)); + return p.normalize( + p.isAbsolute(cleaned) ? cleaned : p.join(mdDir, cleaned), + ); } } diff --git a/lib/services/image_service.dart b/lib/services/image_service.dart index 68f5d72..429cbeb 100644 --- a/lib/services/image_service.dart +++ b/lib/services/image_service.dart @@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; import '../l10n/app_localizations.dart'; import '../models/slide.dart'; +import '../utils/log.dart'; class ImageService { final String Function() _languageCode; @@ -46,7 +47,8 @@ class ImageService { if (bytes.isEmpty) return false; await Pasteboard.writeImage(bytes); return true; - } catch (_) { + } catch (e) { + logError('ImageService.copyImageBytesToClipboard: write image', e); return false; } } @@ -60,7 +62,8 @@ class ImageService { final file = File(path); if (!await file.exists()) return false; return copyImageBytesToClipboard(await file.readAsBytes()); - } catch (_) { + } catch (e) { + logWarning('ImageService.copyImageToClipboard: read image file', e); return false; } } diff --git a/lib/services/markdown_service.dart b/lib/services/markdown_service.dart index fbfef83..d092567 100644 --- a/lib/services/markdown_service.dart +++ b/lib/services/markdown_service.dart @@ -5,6 +5,7 @@ import '../models/chart.dart'; import '../models/deck.dart'; import '../models/settings.dart'; import '../models/slide.dart'; +import '../utils/log.dart'; const _uuid = Uuid(); @@ -528,7 +529,8 @@ class MarkdownService { static String _decodeText(String encoded) { try { return utf8.decode(base64Url.decode(encoded.trim())); - } catch (_) { + } catch (e, s) { + logError('MarkdownService._decodeText: base64/utf8 decode', e, s); return ''; } } @@ -538,7 +540,9 @@ class MarkdownService { final decoded = utf8.decode(base64Url.decode(encoded.trim())); final raw = jsonDecode(decoded); if (raw is List) return raw.map((v) => v.toString()).toList(); - } catch (_) {} + } catch (e, s) { + logError('MarkdownService._decodeBullets: base64/utf8/json decode', e, s); + } return const []; } @@ -646,7 +650,8 @@ class MarkdownService { Deck? parseDeck(String markdown, {String? filePath}) { try { return _doParse(markdown, filePath: filePath); - } catch (_) { + } catch (e, s) { + logError('MarkdownService.parseDeck: parse markdown', e, s); return null; } } @@ -692,11 +697,22 @@ class MarkdownService { } else if (line.startsWith('tlp:')) { tlp = TlpLevelX.fromKey(line.substring(4)); } else if (line.startsWith('ocideck_style_profile:')) { - final encoded = line.substring(22).trim(); - final decoded = utf8.decode(base64Url.decode(encoded)); - themeProfile = ThemeProfile.fromJson( - Map.from(jsonDecode(decoded) as Map), - ); + // Best-effort: a corrupt profile token must not fail the whole + // parse (which would blank the audience window). Keep the default. + try { + final encoded = line.substring(22).trim(); + final decoded = utf8.decode(base64Url.decode(encoded)); + themeProfile = ThemeProfile.fromJson( + Map.from(jsonDecode(decoded) as Map), + ); + } catch (e, s) { + logError( + 'MarkdownService._doParse: decode ocideck_style_profile', + e, + s, + ); + // Leave themeProfile at its default. + } } } content = content.substring(end + 5).trim(); diff --git a/lib/services/recovery_service.dart b/lib/services/recovery_service.dart index af5fceb..be981ca 100644 --- a/lib/services/recovery_service.dart +++ b/lib/services/recovery_service.dart @@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import '../utils/log.dart'; + /// Eén automatisch bewaard herstelbestand voor een (nog) niet-opgeslagen deck. class RecoverySnapshot { final String id; @@ -72,7 +74,8 @@ class RecoveryService { dir, snapshot.id, ).writeAsString(jsonEncode(snapshot.toJson()), flush: true); - } catch (_) { + } catch (e) { + logWarning('RecoveryService.save: write recovery snapshot', e); // Autosave mag nooit de app verstoren. } } @@ -81,7 +84,9 @@ class RecoveryService { try { final file = _file(await _dir(), id); if (file.existsSync()) await file.delete(); - } catch (_) {} + } catch (e) { + logWarning('RecoveryService.discard: delete recovery file', e); + } } Future> loadAll() async { @@ -93,12 +98,15 @@ class RecoveryService { try { final data = jsonDecode(await entry.readAsString()); out.add(RecoverySnapshot.fromJson(Map.from(data))); - } catch (_) {} + } catch (e, s) { + logError('RecoveryService.loadAll: decode recovery snapshot', e, s); + } } } out.sort((a, b) => b.savedAt.compareTo(a.savedAt)); return out; - } catch (_) { + } catch (e) { + logWarning('RecoveryService.loadAll: list recovery dir', e); return const []; } } @@ -110,10 +118,14 @@ class RecoveryService { if (entry is File && entry.path.endsWith('.json')) { try { await entry.delete(); - } catch (_) {} + } catch (e) { + logWarning('RecoveryService.clearAll: delete recovery file', e); + } } } - } catch (_) {} + } catch (e) { + logWarning('RecoveryService.clearAll: list recovery dir', e); + } } } diff --git a/lib/utils/log.dart b/lib/utils/log.dart new file mode 100644 index 0000000..67c0283 --- /dev/null +++ b/lib/utils/log.dart @@ -0,0 +1,41 @@ +/// Lightweight logging for failures the app deliberately swallows. +/// +/// Many call sites here used to be bare `catch (_) {}` blocks. Swallowing was +/// usually the *right* behaviour — a broken sidecar, an unreadable file or an +/// unsupported platform must never crash a presentation — but the failure then +/// vanished without a trace, which made real bugs invisible. Routing these +/// through [logError]/[logWarning] keeps the fail-soft behaviour while making +/// the cause observable. +/// +/// Records go to the `dart:developer` logging stream (DevTools / the VM +/// service), not stdout, so release builds stay quiet. Pass only an operation +/// description and the caught error object — never deck or file *contents*, +/// which can be personal data. +library; + +import 'dart:developer' as developer; + +const _name = 'ocideck'; + +// Severity levels mirror package:logging (WARNING = 900, SEVERE = 1000). +const int _levelWarning = 900; +const int _levelError = 1000; + +/// An unexpected failure that was handled by falling back. [op] is a short +/// description of what was attempted, e.g. `'openDeck: read annotation sidecar'`. +void logError(String op, Object error, [StackTrace? stack]) { + developer.log( + op, + name: _name, + error: error, + stackTrace: stack, + level: _levelError, + ); +} + +/// An expected-but-notable condition where the app fell back to a default +/// (e.g. an absent optional file, an unsupported platform capability). Lower +/// severity than [logError]; [error] is optional. +void logWarning(String op, [Object? error]) { + developer.log(op, name: _name, error: error, level: _levelWarning); +} diff --git a/lib/utils/url_launcher_util.dart b/lib/utils/url_launcher_util.dart index 252ca3f..69192d9 100644 --- a/lib/utils/url_launcher_util.dart +++ b/lib/utils/url_launcher_util.dart @@ -1,8 +1,14 @@ import 'package:url_launcher/url_launcher.dart'; +import 'log.dart'; + +/// Schemes a deck link may open. Anything else (file:, javascript:, custom app +/// schemes, …) is refused so a deck can't hand the OS a dangerous or +/// unexpected URI. +const _allowedUrlSchemes = {'https', 'http', 'mailto'}; /// Open een link uit slide-tekst in de externe browser. Kale domeinen -/// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige -/// of niet-openbare URLs. +/// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige, +/// niet-openbare of niet-toegestane URLs. Future openExternalUrl(String url) async { var u = url.trim(); if (u.isEmpty) return; @@ -11,11 +17,13 @@ Future openExternalUrl(String url) async { } final uri = Uri.tryParse(u); if (uri == null) return; + if (!_allowedUrlSchemes.contains(uri.scheme.toLowerCase())) return; try { if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } - } catch (_) { + } catch (e) { + logWarning('openExternalUrl: launching external URL failed', e); // Nooit de presentatie laten crashen op een kapotte link. } } diff --git a/lib/widgets/dialogs/image_carousel_picker.dart b/lib/widgets/dialogs/image_carousel_picker.dart index 44604f7..7133fd3 100644 --- a/lib/widgets/dialogs/image_carousel_picker.dart +++ b/lib/widgets/dialogs/image_carousel_picker.dart @@ -9,6 +9,7 @@ import '../../services/image_dedup_service.dart'; import '../../services/image_reference_service.dart'; import '../../services/image_service.dart'; import '../../l10n/app_localizations.dart'; +import '../../utils/log.dart'; /// Resultaat van de afbeeldingencarousel. class ImagePickResult { @@ -169,7 +170,9 @@ class _ImageCarouselPickerState extends State { if (_exts.contains(ext)) found.add(e.path); } } - } catch (_) {} + } catch (e) { + logWarning('_ImageCarouselPickerState._loadImages: directory scan', e); + } } // Stat each file exactly once (instead of repeatedly inside the sort @@ -179,7 +182,8 @@ class _ImageCarouselPickerState extends State { DateTime modified; try { modified = File(path).statSync().modified; - } catch (_) { + } catch (e) { + logWarning('_ImageCarouselPickerState._loadImages: statSync', e); modified = DateTime.fromMillisecondsSinceEpoch(0); } withTimes.add((path, modified)); @@ -323,9 +327,10 @@ class _ImageCarouselPickerState extends State { // bestand staan waar de meeste slides (open of niet) naar wijzen. Open // decks worden via usageOf geteld en hier overgeslagen. final deckFiles = await refs.findDeckFiles(widget.searchPaths); - final diskCounts = await refs.countReferences(_withoutOpenDecks(deckFiles), [ - for (final group in groups) ...group, - ]); + final diskCounts = await refs.countReferences( + _withoutOpenDecks(deckFiles), + [for (final group in groups) ...group], + ); if (!mounted) return; final plan = <({String keeper, List remove})>[ @@ -359,13 +364,13 @@ class _ImageCarouselPickerState extends State { // Keeper eerst, zodat zijn eigen tekst vooraan blijft staan. final ordered = [entry.keeper, ...entry.remove]; final captions = [ - for (final path in ordered) await widget.captionService.getCaption(path), + for (final path in ordered) + await widget.captionService.getCaption(path), ]; final mergedCaption = dedup.mergeMetadata(captions); - final mergedDescription = dedup.mergeMetadata( - [for (final path in ordered) _descriptions[path]], - separator: ', ', - ); + final mergedDescription = dedup.mergeMetadata([ + for (final path in ordered) _descriptions[path], + ], separator: ', '); if (mergedCaption.isNotEmpty) { await widget.captionService.saveCaption(entry.keeper, mergedCaption); } @@ -390,7 +395,9 @@ class _ImageCarouselPickerState extends State { try { final file = File(path); if (file.existsSync()) await file.delete(); - } catch (_) {} + } catch (e) { + logWarning('_ImageCarouselPickerState._dedupe: delete file', e); + } await widget.captionService.saveCaption(path, ''); await widget.descriptionService.removeDescription(path); _descriptions.remove(path); @@ -426,9 +433,9 @@ class _ImageCarouselPickerState extends State { : updatedDeckFiles.length == 1 ? ' · ${l10n.d('1 presentatiebestand bijgewerkt.')}' : ' · ${updatedDeckFiles.length} ${l10n.d('presentatiebestanden bijgewerkt.')}'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('$removedText$filesText')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$removedText$filesText'))); } Future _showDedupeDialog( @@ -719,11 +726,18 @@ class _ImageCarouselPickerState extends State { final confirmed = await _showDeleteDialog(path, usages, slideCount); if (confirmed != true) return; + var deleted = false; try { final file = File(path); if (file.existsSync()) await file.delete(); - } catch (_) {} - // Drop the sidecar metadata too. + deleted = true; + } catch (e) { + debugPrint('Kon afbeelding niet verwijderen: $e'); + } + // Only drop the sidecar metadata and the carousel entry once the file is + // actually gone; otherwise the image would disappear from the UI while it + // still exists on disk, having silently lost its caption/description. + if (!deleted) return; await widget.captionService.saveCaption(path, ''); await widget.descriptionService.removeDescription(path); @@ -1083,7 +1097,9 @@ class _ImageCarouselPickerState extends State { duration: const Duration(milliseconds: 150), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( - color: _untaggedOnly ? const Color(0xFF1D2433) : const Color(0xFF0D1117), + color: _untaggedOnly + ? const Color(0xFF1D2433) + : const Color(0xFF0D1117), borderRadius: BorderRadius.circular(9), border: Border.all( color: _untaggedOnly @@ -2053,7 +2069,9 @@ class _FileSizeState extends State<_FileSize> { ? '${mb.toStringAsFixed(1)} MB' : '${kb.toStringAsFixed(0)} KB'; if (mounted) setState(() => _size = label); - } catch (_) {} + } catch (e) { + logWarning('_FileSizeState._load: compute size label', e); + } } @override diff --git a/lib/widgets/panels/slide_list_panel.dart b/lib/widgets/panels/slide_list_panel.dart index ea7f107..961b3bd 100644 --- a/lib/widgets/panels/slide_list_panel.dart +++ b/lib/widgets/panels/slide_list_panel.dart @@ -15,6 +15,7 @@ import '../../services/slide_rasterizer.dart'; import '../../state/slide_clipboard_provider.dart'; import '../../theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; +import '../../utils/log.dart'; import '../dialogs/add_slide_dialog.dart'; import '../dialogs/import_slides_dialog.dart'; import '../dialogs/slide_finder_dialog.dart'; @@ -216,7 +217,9 @@ class _SlideListPanelState extends ConsumerState { tlp: deck.tlp, ); if (images.isNotEmpty) bytes = images.first; - } catch (_) {} + } catch (e) { + logWarning('_SlideListPanelState._copySlideAsImage: rasterize slide', e); + } if (!mounted) return; final ok = bytes != null && await ImageService().copyImageBytesToClipboard(bytes); diff --git a/lib/widgets/presentation/audience_window.dart b/lib/widgets/presentation/audience_window.dart index a95b69c..4fc5ddb 100644 --- a/lib/widgets/presentation/audience_window.dart +++ b/lib/widgets/presentation/audience_window.dart @@ -6,6 +6,7 @@ import '../../models/deck.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; import '../../services/markdown_service.dart'; +import '../../utils/log.dart'; import '../../utils/url_launcher_util.dart'; import '../slides/slide_preview.dart'; import 'annotation_overlay.dart'; @@ -133,7 +134,12 @@ class _AudienceWindowAppState extends State { try { final self = await WindowController.fromCurrentEngine(); await self.close(); - } catch (_) {} + } catch (e) { + logWarning( + '_AudienceWindowAppState._onPresenterCall: close window', + e, + ); + } } return null; } diff --git a/lib/widgets/presentation/fullscreen_presenter.dart b/lib/widgets/presentation/fullscreen_presenter.dart index b0cc448..57a0fbe 100644 --- a/lib/widgets/presentation/fullscreen_presenter.dart +++ b/lib/widgets/presentation/fullscreen_presenter.dart @@ -13,6 +13,7 @@ import '../../models/deck.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; import '../../services/markdown_service.dart'; +import '../../utils/log.dart'; import '../../utils/url_launcher_util.dart'; import '../../l10n/app_localizations.dart'; import '../slides/inline_markdown.dart'; @@ -73,7 +74,8 @@ class FullscreenPresenter extends StatefulWidget { try { final displays = await screenRetriever.getAllDisplays(); displayCount = displays.length; - } catch (_) { + } catch (e) { + logWarning('FullscreenPresenter.present: display detection failed', e); displayCount = 0; } } @@ -203,7 +205,11 @@ class FullscreenPresenter extends StatefulWidget { WindowConfiguration(arguments: argument, hiddenAtLaunch: true), ); await audience.coverScreen(external: true); - } catch (_) { + } catch (e) { + logError( + 'FullscreenPresenter.showDualScreen: audience window setup failed', + e, + ); audience = null; } @@ -283,7 +289,8 @@ bool autoAdvanceWaitsForMedia(Slide slide) { Future _wakeLockEnabled() async { try { return await WakelockPlus.enabled; - } catch (_) { + } catch (e) { + logWarning('fullscreen_presenter._wakeLockEnabled: query failed', e); return false; } } @@ -291,7 +298,8 @@ Future _wakeLockEnabled() async { Future _enableWakeLock() async { try { await WakelockPlus.enable(); - } catch (_) { + } catch (e) { + logWarning('fullscreen_presenter._enableWakeLock: enable failed', e); // Best-effort: unsupported platforms should not interrupt presenting. } } @@ -303,7 +311,8 @@ Future _restoreWakeLock(bool enabledBeforePresentation) async { } else { await WakelockPlus.disable(); } - } catch (_) { + } catch (e) { + logWarning('fullscreen_presenter._restoreWakeLock: restore failed', e); // Best-effort cleanup. } } @@ -560,10 +569,7 @@ class _FullscreenPresenterState extends State { } _lastInkLiveSent = now; audienceChannel - .invokeMethod('inkLive', { - 'index': _index, - 'stroke': stroke?.toJson(), - }) + .invokeMethod('inkLive', {'index': _index, 'stroke': stroke?.toJson()}) .catchError((_) => null); } @@ -707,7 +713,11 @@ class _FullscreenPresenterState extends State { _displays = displays; _displayIndex = current < 0 ? 0 : current; }); - } catch (_) { + } catch (e) { + logWarning( + '_FullscreenPresenterState._loadDisplays: screen detection failed', + e, + ); // Screen detection is best-effort; presenting should still work. } } @@ -724,7 +734,11 @@ class _FullscreenPresenterState extends State { ); await windowManager.setFullScreen(true); if (mounted) setState(() => _displayIndex = index); - } catch (_) { + } catch (e) { + logError( + '_FullscreenPresenterState._moveToDisplay: moving window to display failed', + e, + ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -1425,6 +1439,10 @@ class _FullscreenPresenterState extends State { // Annotatielaag bovenop de dia. Laat klikken door wanneer er // geen gereedschap actief is (zodat tikken blijft doorbladeren). AnnotationLayer( + // Keyed by slide so a slide change (e.g. auto-advance) while a + // stroke is in progress resets the layer instead of committing + // the half-drawn stroke onto the next slide. + key: ValueKey(slide.id), strokes: _currentStrokes, tool: _tool, color: _inkColor,