feature/meldingen-hardening #6

Merged
brenno merged 5 commits from feature/meldingen-hardening into main 2026-06-11 20:40:08 +00:00
16 changed files with 346 additions and 91 deletions
Showing only changes of commit ee9e2bfc58 - Show all commits

View file

@ -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<String, dynamic>.from(s as Map)),
],
);
} catch (_) {
} catch (e, s) {
logError('ChartSpec.parse: decode chart JSON block', e, s);
return const ChartSpec();
}
}

View file

@ -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;

View file

@ -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<String, dynamic>.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) {

View file

@ -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<String, dynamic>.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;
}

View file

@ -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<FileSystemEntity> 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 `<subdir>/<bestandsnaam>` 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<int>, 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<int>;
// 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<String?> 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<int> 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);
}
}

View file

@ -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);
}
}

View file

@ -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<FileSystemEntity> 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),
);
}
}

View file

@ -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;
}
}

View file

@ -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:')) {
// 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<String, Object?>.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();

View file

@ -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<List<RecoverySnapshot>> loadAll() async {
@ -93,12 +98,15 @@ class RecoveryService {
try {
final data = jsonDecode(await entry.readAsString());
out.add(RecoverySnapshot.fromJson(Map<String, Object?>.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);
}
}
}

41
lib/utils/log.dart Normal file
View file

@ -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);
}

View file

@ -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<void> openExternalUrl(String url) async {
var u = url.trim();
if (u.isEmpty) return;
@ -11,11 +17,13 @@ Future<void> 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.
}
}

View file

@ -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<ImageCarouselPicker> {
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<ImageCarouselPicker> {
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<ImageCarouselPicker> {
// 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<String> remove})>[
@ -359,13 +364,13 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
// Keeper eerst, zodat zijn eigen tekst vooraan blijft staan.
final ordered = [entry.keeper, ...entry.remove];
final captions = <String?>[
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<ImageCarouselPicker> {
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<ImageCarouselPicker> {
: 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<bool?> _showDedupeDialog(
@ -719,11 +726,18 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
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<ImageCarouselPicker> {
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

View file

@ -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<SlideListPanel> {
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);

View file

@ -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<AudienceWindowApp> {
try {
final self = await WindowController.fromCurrentEngine();
await self.close();
} catch (_) {}
} catch (e) {
logWarning(
'_AudienceWindowAppState._onPresenterCall: close window',
e,
);
}
}
return null;
}

View file

@ -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<bool> _wakeLockEnabled() async {
try {
return await WakelockPlus.enabled;
} catch (_) {
} catch (e) {
logWarning('fullscreen_presenter._wakeLockEnabled: query failed', e);
return false;
}
}
@ -291,7 +298,8 @@ Future<bool> _wakeLockEnabled() async {
Future<void> _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<void> _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<FullscreenPresenter> {
}
_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<FullscreenPresenter> {
_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<FullscreenPresenter> {
);
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<FullscreenPresenter> {
// 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,