Meldingen-hardening: verwerp-optie bij sluiten, classificatie-gate, presentatietimer #7

Merged
brenno merged 3 commits from feature/meldingen-hardening into main 2026-06-13 05:54:43 +00:00
13 changed files with 259 additions and 4 deletions
Showing only changes of commit f93417dc3c - Show all commits

View file

@ -41,6 +41,12 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
- **Per-slide TLP classification** — each slide can carry its own Traffic Light
Protocol level; slides classified stricter than the level the deck is shown at
are withheld when presenting and exporting.
- **Export release ceiling** — an optional maximum TLP level that may be
exported. When set, a deck classified *above* it cannot be exported in any
format; the gate is enforced at the single export chokepoint and fails closed
(no file is written when blocked, and the export dialog explains why).
Classifying a deck stays optional — the ceiling only stops decks that exceed
it, and it is off by default.
- **Dual-screen presenter** — on a second display the beamer shows the slide
while the laptop shows the presenter view (current/next slide, notes, timer),
kept in sync over method channels.

View file

@ -12,7 +12,7 @@ Built with Flutter for macOS, Windows, and Linux.
- **Source-code slides** — a "code sheet" with per-language syntax highlighting, stored as a fenced code block. Background, text colour and monospace font come from the style profile, with an optional syntax-colouring toggle (turn it off for a single-colour, CRT-terminal look). The code auto-sizes to fill the panel.
- **Charts** — bar, line, pie, and spider/radar charts rendered natively (preview, presenter, PDF, PPTX) and as self-contained SVG in the HTML export. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in the Markdown, with optional linking to a CSV kept in a tidy `data/` directory. Optional min/max draw reference lines (bar/line) or fix the scale (radar); legend hover highlights a series, and line tooltips pick the dot under the cursor.
- **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking. Free-Markdown slides render fenced code with syntax highlighting and `$…$` / `$$…$$` LaTeX math.
- **Traffic Light Protocol** — a deck-wide classification plus an optional **per-slide TLP level**; slides classified stricter than the level the deck is shown at are automatically withheld, both when presenting and exporting.
- **Traffic Light Protocol** — a deck-wide classification plus an optional **per-slide TLP level**; slides classified stricter than the level the deck is shown at are automatically withheld, both when presenting and exporting. An optional **export release ceiling** can block exporting any deck classified above a chosen level — enforced for every format, off by default.
- **Fullscreen presenter** — keyboard-driven navigation, presenter view, blank screen, auto-advance, and a slide-grid overview.
- **Dual-screen presenter** — when a second display is connected, the beamer shows the slide while the laptop shows the presenter view (current/next slide, notes, timer), kept in sync.
- **Annotation layer** — draw on slides while presenting (pen, highlighter, eraser, laser pointer). Kept as a separate layer that never touches the Marp Markdown, mirrored live to the beamer, and saved in a `.ink.json` sidecar.

View file

@ -38,6 +38,8 @@ OciDeck is an offline desktop application. Areas of particular interest:
- Importing presentations from a URL.
- The HTML export, which inlines third-party JavaScript (marked, highlight.js,
mermaid, MathJax) to render offline.
- The export classification gate (`ClassificationPolicy`) — any way to export a
deck classified above the configured release ceiling.
## Supported versions

View file

@ -15,9 +15,10 @@ are stored on disk, see [`FILE_FORMAT.md`](FILE_FORMAT.md).
```
lib/
models/ # Deck, Slide, Settings/ThemeProfile, Chart, Annotation
services/ # markdown, file, export, image, caption, description,
# image_dedup (md5 duplicates), image_reference (.md rewrites),
# recovery, rasterizer, marp_html, annotation_codec
services/ # markdown, file, export, classification_policy, image, caption,
# description, image_dedup (md5 duplicates),
# image_reference (.md rewrites), recovery, rasterizer,
# marp_html, annotation_codec
state/ # Riverpod providers: deck, editor, settings, tabs, clipboard
widgets/ # app shell, panels, dialogs, per-type editors, slides, presenter
l10n/ # AppLocalizations (8 languages)
@ -66,6 +67,15 @@ the key thing to understand before touching rendering:
**SVG in Dart** here (no JS chart library). Fidelity differs from the in-app
renderer by design.
Both worlds converge at one chokepoint: `services/export_service.dart`
(`ExportService.export()`) is the only place that writes an export, so the
**classification gate** lives there rather than in the export dialog. A
`ClassificationPolicy` enforces an optional *release ceiling* and refuses,
**fail-closed**, to export a deck classified above it — no format can bypass it.
The ceiling is stored in app settings (`maxReleaseExportTlpKey`, off by default);
the dialog also runs the same check up front so a blocked export is explained
before any work starts.
## Presenter
`widgets/presentation/fullscreen_presenter.dart` drives presenting:

View file

@ -114,6 +114,10 @@ A deck has an overall TLP level (shown as a marking on the slides). Each slide c
deck can be shown safely to audiences with different clearances. Order, least to
most restrictive: none < CLEAR < GREEN < AMBER < AMBER+STRICT < RED.
Classifying a deck is **optional**. As an extra guardrail, an organisation can
set a **release ceiling** — a maximum level that may leave the machine; see
*Exporting* below.
## Presenting
Start the fullscreen presenter from the toolbar. See
@ -148,6 +152,11 @@ Export to:
- **Portable package** (`.ocideck`) — a single zip with the Markdown and all
assets, to hand the whole deck to someone else.
**Release ceiling (optional).** When a maximum TLP level is configured, exporting
a deck classified *above* it is blocked for every format, and the export dialog
explains why. The ceiling is off by default and classifying a deck stays
optional — it only stops decks that exceed the configured level.
## Accessibility
OciDeck aims for WCAG 2.1 in the editor:

View file

@ -364,6 +364,12 @@ class AppSettings {
final String selectedAppAppearanceProfileName;
final List<String> recentFiles;
/// Optioneel vrijgaveplafond voor de classificatie-gate, opgeslagen als
/// TLP-sleutel (zie `TlpLevelX.key`). `null` = geen plafond, alles mag worden
/// geëxporteerd (standaard). Classificeren blijft optioneel; dit plafond
/// blokkeert alleen decks die er bovenuit zijn geclassificeerd.
final String? maxReleaseExportTlpKey;
/// Scale factor for all interface text (1.02.0), on top of the system
/// text scaling. The slide canvas itself is never scaled: slides are a
/// fixed 16:9 design surface. WCAG 1.4.4 asks for text resizing up to 200%.
@ -378,6 +384,7 @@ class AppSettings {
this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
this.selectedAppAppearanceProfileName = 'Basic',
this.recentFiles = const [],
this.maxReleaseExportTlpKey,
this.uiTextScale = 1.0,
});
@ -430,9 +437,11 @@ class AppSettings {
List<AppAppearanceProfile>? appAppearanceProfiles,
String? selectedAppAppearanceProfileName,
List<String>? recentFiles,
String? maxReleaseExportTlpKey,
double? uiTextScale,
bool clearHomeDirectory = false,
bool clearExportDirectory = false,
bool clearMaxReleaseExportTlp = false,
}) {
final nextProfiles = themeProfiles ?? this.themeProfiles;
return AppSettings(
@ -464,6 +473,9 @@ class AppSettings {
selectedAppAppearanceProfileName ??
this.selectedAppAppearanceProfileName,
recentFiles: recentFiles ?? this.recentFiles,
maxReleaseExportTlpKey: clearMaxReleaseExportTlp
? null
: (maxReleaseExportTlpKey ?? this.maxReleaseExportTlpKey),
uiTextScale: uiTextScale ?? this.uiTextScale,
);
}

View file

@ -0,0 +1,58 @@
import '../models/deck.dart';
/// Uitkomst van de export-gate: mag deze export door, en zo niet, waarom niet.
class ExportDecision {
/// Of de export is toegestaan.
final bool allowed;
/// Reden waarom de export geweigerd is (`null` wanneer toegestaan). Bedoeld om
/// 1-op-1 aan de gebruiker te tonen.
final String? reason;
const ExportDecision._(this.allowed, this.reason);
const ExportDecision.allow() : this._(true, null);
const ExportDecision.block(String reason) : this._(false, reason);
}
/// Centrale, pure beslisser voor classificatie bij export.
///
/// Classificeren is **optioneel**: een deck zonder TLP-niveau ([TlpLevel.none])
/// exporteert altijd. Maar zodra een organisatie een vrijgaveplafond instelt, is
/// dit de enige plek die bepaalt of een geclassificeerd deck naar buiten mag.
///
/// De gate hangt aan het export-chokepoint ([ExportService.export]), zodat geen
/// enkel formaat (PDF/PPTX/HTML) eromheen kan. Fail-closed: bij twijfel weigert
/// de gate in plaats van stilletjes te exporteren.
class ClassificationPolicy {
/// Hoogste TLP-niveau dat geëxporteerd mag worden het vrijgaveplafond.
///
/// `null` = geen plafond, alles mag (standaard). Een deck dat híérboven is
/// geclassificeerd wordt geweigerd in plaats van naar buiten gebracht. Let op:
/// een plafond van [TlpLevel.none] staat alléén ongeclassificeerde decks toe.
final TlpLevel? maxReleaseLevel;
const ClassificationPolicy({this.maxReleaseLevel});
/// Bouw het beleid uit de opgeslagen instelling: een TLP-sleutel (zie
/// [TlpLevelX.key]) of `null` wanneer er geen plafond is ingesteld.
factory ClassificationPolicy.fromKey(String? key) => ClassificationPolicy(
maxReleaseLevel: key == null ? null : TlpLevelX.fromKey(key),
);
/// Of er überhaupt een gate actief is.
bool get hasGate => maxReleaseLevel != null;
/// Beoordeel of een deck met niveau [deckLevel] geëxporteerd mag worden.
ExportDecision evaluate(TlpLevel deckLevel) {
final ceiling = maxReleaseLevel;
if (ceiling != null && deckLevel.index > ceiling.index) {
return ExportDecision.block(
'Export geblokkeerd door classificatiebeleid: dit deck is '
'${deckLevel.label}, hoger dan het toegestane vrijgaveniveau '
'${ceiling.label}.',
);
}
return const ExportDecision.allow();
}
}

View file

@ -8,7 +8,9 @@ import 'package:path/path.dart' as p;
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import '../models/deck.dart';
import '../models/settings.dart';
import 'classification_policy.dart';
import 'marp_html_service.dart';
enum ExportFormat { pdf, pptx, html }
@ -103,7 +105,17 @@ class ExportService {
List<String>? notes,
String? markdown,
ThemeProfile? themeProfile,
TlpLevel tlp = TlpLevel.none,
ClassificationPolicy policy = const ClassificationPolicy(),
}) async {
// Classificatie-gate. Dit is het enige chokepoint waar elk formaat
// (PDF/PPTX/HTML) doorheen moet, dus de handhaving zit hier en niet in de
// UI-laag: zo kan geen exportpad de gate omzeilen. Fail-closed bij een
// weigering wordt er niets gebouwd of weggeschreven.
final decision = policy.evaluate(tlp);
if (!decision.allowed) {
return ExportResult.fail(decision.reason!);
}
if (format == ExportFormat.html) {
if (markdown == null || markdown.trim().isEmpty) {
return ExportResult.fail('Geen inhoud om te exporteren.');

View file

@ -54,10 +54,25 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
? selectedAppearance
: 'Basic',
recentFiles: prefs.getStringList('recentFiles') ?? [],
maxReleaseExportTlpKey: prefs.getString('maxReleaseExportTlp'),
uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0),
);
}
/// Stel het vrijgaveplafond voor de export-gate in (een TLP-sleutel), of
/// `null` om de gate uit te zetten. Persisteert in hetzelfde prefs-domein.
Future<void> setMaxReleaseExportTlp(String? key) async {
state = key == null
? state.copyWith(clearMaxReleaseExportTlp: true)
: state.copyWith(maxReleaseExportTlpKey: key);
final prefs = await SharedPreferences.getInstance();
if (key == null) {
await prefs.remove('maxReleaseExportTlp');
} else {
await prefs.setString('maxReleaseExportTlp', key);
}
}
Future<void> setUiTextScale(double scale) async {
final clamped = scale.clamp(1.0, 2.0).toDouble();
state = state.copyWith(uiTextScale: clamped);

View file

@ -8,6 +8,7 @@ import '../models/deck.dart';
import '../models/slide.dart';
import '../services/caption_service.dart';
import '../services/description_service.dart';
import '../services/classification_policy.dart';
import '../services/export_service.dart';
import '../services/recovery_service.dart';
import '../state/deck_provider.dart';
@ -560,6 +561,9 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
projectPath: deck.projectPath,
exportService: widget.exportService,
tlp: deck.tlp,
policy: ClassificationPolicy.fromKey(
ref.read(settingsProvider).maxReleaseExportTlpKey,
),
exportDirectory: ref.read(settingsProvider).exportDirectory,
// Inline chart data so the HTML export can render charts standalone,
// even when a chart links an external CSV.

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../../models/deck.dart';
import '../../models/settings.dart';
import '../../models/slide.dart';
import '../../services/classification_policy.dart';
import '../../services/export_service.dart';
import '../../services/slide_rasterizer.dart';
import '../../l10n/app_localizations.dart';
@ -18,6 +19,9 @@ class ExportDialog extends StatefulWidget {
final ExportService exportService;
final TlpLevel tlp;
/// Classificatie-gate. Standaard geen plafond (alles mag).
final ClassificationPolicy policy;
/// Folder all exports are written to. Null = next to the source deck.
final String? exportDirectory;
@ -32,6 +36,7 @@ class ExportDialog extends StatefulWidget {
required this.projectPath,
required this.exportService,
this.tlp = TlpLevel.none,
this.policy = const ClassificationPolicy(),
this.exportDirectory,
this.markdown = '',
});
@ -44,6 +49,7 @@ class ExportDialog extends StatefulWidget {
required String? projectPath,
required ExportService exportService,
TlpLevel tlp = TlpLevel.none,
ClassificationPolicy policy = const ClassificationPolicy(),
String? exportDirectory,
String markdown = '',
}) {
@ -57,6 +63,7 @@ class ExportDialog extends StatefulWidget {
projectPath: projectPath,
exportService: exportService,
tlp: tlp,
policy: policy,
exportDirectory: exportDirectory,
markdown: markdown,
),
@ -131,6 +138,8 @@ class _ExportDialogState extends State<ExportDialog> {
notes: [for (final s in widget.slides) s.notes],
markdown: widget.markdown,
themeProfile: widget.themeProfile,
tlp: widget.tlp,
policy: widget.policy,
);
if (!mounted) return;
@ -231,6 +240,25 @@ class _ExportDialogState extends State<ExportDialog> {
);
}
// Pre-flight classificatie-gate: blokkeert de export al vóór een poging,
// zodat de gebruiker meteen de reden ziet. De service handhaaft dezelfde
// regel nog eens als backstop, dus dit is puur UX niet de beveiliging.
final decision = widget.policy.evaluate(widget.tlp);
if (!decision.allowed) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.block, color: Colors.red, size: 36),
const SizedBox(height: 12),
Text(
decision.reason!,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: Colors.red[800]),
),
],
);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,

View file

@ -0,0 +1,62 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/deck.dart';
import 'package:ocideck/services/classification_policy.dart';
void main() {
group('ClassificationPolicy', () {
test(
'without a ceiling every level is allowed (classificeren optioneel)',
() {
const policy = ClassificationPolicy();
expect(policy.hasGate, isFalse);
for (final level in TlpLevel.values) {
expect(policy.evaluate(level).allowed, isTrue);
}
},
);
test('a ceiling allows levels at or below it', () {
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber);
expect(policy.hasGate, isTrue);
for (final level in [
TlpLevel.none,
TlpLevel.clear,
TlpLevel.green,
TlpLevel.amber,
]) {
expect(policy.evaluate(level).allowed, isTrue, reason: level.name);
}
});
test('a ceiling blocks levels above it, with a clear reason', () {
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber);
for (final level in [TlpLevel.amberStrict, TlpLevel.red]) {
final decision = policy.evaluate(level);
expect(decision.allowed, isFalse, reason: level.name);
expect(decision.reason, contains(level.label));
expect(decision.reason, contains(TlpLevel.amber.label));
}
});
test('a ceiling of none only allows unclassified decks', () {
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.none);
expect(policy.evaluate(TlpLevel.none).allowed, isTrue);
expect(policy.evaluate(TlpLevel.clear).allowed, isFalse);
});
group('fromKey', () {
test('null key means no gate', () {
final policy = ClassificationPolicy.fromKey(null);
expect(policy.hasGate, isFalse);
expect(policy.evaluate(TlpLevel.red).allowed, isTrue);
});
test('a TLP key sets the ceiling', () {
final policy = ClassificationPolicy.fromKey(TlpLevel.green.key);
expect(policy.maxReleaseLevel, TlpLevel.green);
expect(policy.evaluate(TlpLevel.green).allowed, isTrue);
expect(policy.evaluate(TlpLevel.amber).allowed, isFalse);
});
});
});
}

View file

@ -5,6 +5,8 @@ import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:ocideck/models/deck.dart';
import 'package:ocideck/services/classification_policy.dart';
import 'package:ocideck/services/export_service.dart';
import 'package:ocideck/services/marp_html_service.dart';
import 'package:path/path.dart' as p;
@ -56,6 +58,41 @@ void main() {
String deckPath() => p.join(tmp.path, 'deck.md');
test(
'classificatie-gate blocks an over-classified export, writes nothing',
() async {
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.green);
final r = await service.export(
deckPath(),
ExportFormat.pdf,
[_png()],
tlp: TlpLevel.red,
policy: policy,
);
expect(r.success, isFalse);
expect(r.outputPath, isNull);
expect(r.error, contains('classificatiebeleid'));
// Fail-closed: no file may be produced when the gate refuses.
final produced = tmp.listSync().whereType<File>().where(
(f) => p.extension(f.path) == '.pdf',
);
expect(produced, isEmpty);
},
);
test('classificatie-gate allows an export at or below the ceiling', () async {
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber);
final r = await service.export(
deckPath(),
ExportFormat.pdf,
[_png()],
tlp: TlpLevel.green,
policy: policy,
);
expect(r.success, isTrue, reason: r.error);
});
test('exports a PDF that starts with the PDF magic header', () async {
final images = [_png(), _png()];
final r = await service.export(deckPath(), ExportFormat.pdf, images);