feat: unified shell, diagnostics pack, native Office, sticky board
All checks were successful
CI / Windows build (push) Successful in 14m22s

Make Surface remote debugging and classroom workflows viable: always-on
structured logs with one-click zip export, a single AppShell chrome,
OOXML PPTX/DOCX annotation without LibreOffice, and a first-class sticky
board. Also drop spike/legacy ink widgets and tighten pen feel
(predictor, PenInfoHistory, page-tile layer).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 17:55:27 +08:00
parent 3cabc7e074
commit d346cc2670
49 changed files with 2883 additions and 2515 deletions

View File

@@ -0,0 +1,164 @@
// Global structured logging bus for BadNote.
//
// Always-on (unlike the old PDF-only DiagnosticLogger opt-in). Writes NDJSON
// lines to a rotating session file under the app documents directory so a
// Surface user can export a diagnostic pack without attaching a debugger.
import 'dart:async';
import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
enum LogLevel { trace, debug, info, warn, error }
/// Known subsystems — keep the set small so filters stay useful.
abstract final class LogSubsystem {
static const shell = 'shell';
static const ink = 'ink';
static const arbiter = 'arbiter';
static const penNative = 'pen_native';
static const pdf = 'pdf';
static const office = 'office';
static const board = 'board';
static const sync = 'sync';
static const diag = 'diag';
static const frame = 'frame';
}
class BadNoteLog {
BadNoteLog._();
static final BadNoteLog instance = BadNoteLog._();
final String sessionId = const Uuid().v4();
final List<Map<String, Object?>> _ring = <Map<String, Object?>>[];
static const int _ringCap = 4000;
File? _file;
Directory? _dir;
Timer? _flushTimer;
final List<String> _pending = <String>[];
bool _started = false;
LogLevel minLevel = LogLevel.debug;
/// Absolute path of the current session log, once [start] succeeds.
String? get path => _file?.path;
Directory? get directory => _dir;
Future<void> start() async {
if (_started) return;
_started = true;
try {
Directory base;
try {
base = await getApplicationDocumentsDirectory();
} catch (_) {
base = await getTemporaryDirectory();
}
_dir = Directory(
'${base.path}${Platform.pathSeparator}badnote_diagnostics',
);
if (!await _dir!.exists()) {
await _dir!.create(recursive: true);
}
final stamp = DateTime.now()
.toIso8601String()
.replaceAll(':', '-')
.replaceAll('.', '-');
_file = File(
'${_dir!.path}${Platform.pathSeparator}session_$stamp.ndjson',
);
await _file!.writeAsString(
'${jsonEncode({
'ts': DateTime.now().toIso8601String(),
'level': 'info',
'subsystem': LogSubsystem.diag,
'msg': 'session_start',
'sessionId': sessionId,
'platform': Platform.operatingSystem,
'osVersion': Platform.operatingSystemVersion,
})}\n',
flush: true,
);
_flushTimer = Timer.periodic(const Duration(seconds: 1), (_) => _flush());
info(LogSubsystem.diag, 'log file ready', fields: {'path': _file!.path});
} catch (e) {
// Logging must never crash the app.
developer.log('BadNoteLog start failed: $e', name: 'badnote');
}
}
void trace(String subsystem, String msg, {Map<String, Object?>? fields}) =>
_emit(LogLevel.trace, subsystem, msg, fields);
void debug(String subsystem, String msg, {Map<String, Object?>? fields}) =>
_emit(LogLevel.debug, subsystem, msg, fields);
void info(String subsystem, String msg, {Map<String, Object?>? fields}) =>
_emit(LogLevel.info, subsystem, msg, fields);
void warn(String subsystem, String msg, {Map<String, Object?>? fields}) =>
_emit(LogLevel.warn, subsystem, msg, fields);
void error(String subsystem, String msg, {Map<String, Object?>? fields}) =>
_emit(LogLevel.error, subsystem, msg, fields);
void _emit(
LogLevel level,
String subsystem,
String msg,
Map<String, Object?>? fields,
) {
if (level.index < minLevel.index) return;
final entry = <String, Object?>{
'ts': DateTime.now().toIso8601String(),
'level': level.name,
'subsystem': subsystem,
'msg': msg,
'sessionId': sessionId,
if (fields != null) ...fields,
};
_ring.add(entry);
if (_ring.length > _ringCap) {
_ring.removeRange(0, _ring.length - _ringCap);
}
final line = jsonEncode(entry);
developer.log(line, name: 'badnote.$subsystem');
if (_file != null) {
_pending.add(line);
if (_pending.length >= 200) {
unawaited(_flush());
}
}
}
Future<void> _flush() async {
final file = _file;
if (file == null || _pending.isEmpty) return;
final chunk = '${_pending.join('\n')}\n';
_pending.clear();
try {
await file.writeAsString(chunk, mode: FileMode.append, flush: true);
} catch (_) {}
}
/// Snapshot of the in-memory ring (newest last).
List<Map<String, Object?>> snapshotRing() =>
List<Map<String, Object?>>.unmodifiable(_ring);
Future<void> flush() => _flush();
Future<void> stop() async {
_flushTimer?.cancel();
_flushTimer = null;
await _flush();
}
}
/// Bridge for legacy call sites that still use plain strings.
void logLegacyInputLine(String line) {
BadNoteLog.instance.debug(LogSubsystem.penNative, line);
}

View File

@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'badnote_log.dart';
import 'diagnostic_export.dart';
import '../editor/canvas/input_diagnostics.dart';
import '../editor/input/diagnostic_logger.dart';
import '../editor/input/pen_input_service.dart';
/// Shared diagnostics chrome: overlay readout + export action.
/// Mount on any document surface (note / PDF / PPT / board).
class DiagnosticChrome extends StatefulWidget {
const DiagnosticChrome({
super.key,
required this.child,
this.initiallyVisible = false,
});
final Widget child;
final bool initiallyVisible;
@override
State<DiagnosticChrome> createState() => DiagnosticChromeState();
}
class DiagnosticChromeState extends State<DiagnosticChrome> {
late bool _visible = widget.initiallyVisible;
bool _exporting = false;
String? _lastExportPath;
bool get isVisible => _visible;
void toggle() {
setState(() {
_visible = !_visible;
if (_visible) {
DiagnosticLogger.instance.start();
InputDiagnostics.instance.reset();
} else {
DiagnosticLogger.instance.stop();
}
});
}
Future<void> exportPack() async {
if (_exporting) return;
setState(() => _exporting = true);
try {
final result = await DiagnosticExport.instance.exportPack();
if (!mounted) return;
setState(() => _lastExportPath = result.zipPath);
await Clipboard.setData(ClipboardData(text: result.zipPath));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'诊断包已导出 (${result.bytes} bytes)\n路径已复制到剪贴板',
),
duration: const Duration(seconds: 5),
),
);
} catch (e) {
BadNoteLog.instance.error(LogSubsystem.diag, 'export_failed', fields: {
'error': '$e',
});
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('导出失败: $e')),
);
} finally {
if (mounted) setState(() => _exporting = false);
}
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
widget.child,
if (_visible)
Positioned(
left: 8,
right: 8,
bottom: 8,
child: Material(
elevation: 6,
borderRadius: BorderRadius.circular(8),
color: Colors.black.withValues(alpha: 0.82),
child: Padding(
padding: const EdgeInsets.all(10),
child: DefaultTextStyle(
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontFamily: 'monospace',
height: 1.35,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ListenableBuilder(
listenable: InputDiagnostics.instance,
builder: (context, _) {
return Text(
'${InputDiagnostics.instance.summary()}\n'
'${PenInputService.instance.debugSummary}\n'
'log: ${BadNoteLog.instance.path ?? "(starting…)"}\n'
'session: ${BadNoteLog.instance.sessionId}'
'${_lastExportPath != null ? "\nlast zip: $_lastExportPath" : ""}',
);
},
),
const SizedBox(height: 8),
Row(
children: [
TextButton(
onPressed: () => InputDiagnostics.instance.reset(),
child: const Text('Reset',
style: TextStyle(color: Colors.white70)),
),
TextButton(
onPressed: _exporting ? null : exportPack,
child: Text(
_exporting ? 'Exporting…' : 'Export pack',
style: const TextStyle(color: Colors.lightGreenAccent),
),
),
TextButton(
onPressed: toggle,
child: const Text('Hide',
style: TextStyle(color: Colors.white54)),
),
],
),
],
),
),
),
),
),
],
);
}
}
/// Compact icon button for app bars / toolbars.
class DiagnosticToggleButton extends StatelessWidget {
const DiagnosticToggleButton({
super.key,
required this.onToggle,
required this.onExport,
});
final VoidCallback onToggle;
final VoidCallback onExport;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
tooltip: 'Diagnostics',
icon: const Icon(Icons.bug_report_outlined),
onSelected: (v) {
if (v == 'toggle') onToggle();
if (v == 'export') onExport();
},
itemBuilder: (context) => const [
PopupMenuItem(value: 'toggle', child: Text('Toggle overlay')),
PopupMenuItem(value: 'export', child: Text('Export diagnostic pack')),
],
);
}
}

View File

@@ -0,0 +1,138 @@
// Build a zip diagnostic pack the user can hand back for remote debugging.
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../editor/canvas/input_diagnostics.dart';
import '../editor/input/pen_input_service.dart';
import 'badnote_log.dart';
import 'frame_sampler.dart';
import 'pen_event_ring.dart';
class DiagnosticExportResult {
DiagnosticExportResult({required this.zipPath, required this.bytes});
final String zipPath;
final int bytes;
}
class DiagnosticExport {
DiagnosticExport._();
static final DiagnosticExport instance = DiagnosticExport._();
/// Flush logs and write a zip under Documents/badnote_diagnostics/.
Future<DiagnosticExportResult> exportPack({
Duration penWindow = const Duration(minutes: 5),
}) async {
final log = BadNoteLog.instance;
await log.flush();
final meta = <String, Object?>{
'exportedAt': DateTime.now().toIso8601String(),
'sessionId': log.sessionId,
'platform': Platform.operatingSystem,
'osVersion': Platform.operatingSystemVersion,
'localHostname': Platform.localHostname,
'numberOfProcessors': Platform.numberOfProcessors,
'flutter': {
'foundationDebug': kDebugMode,
'foundationProfile': kProfileMode,
'foundationRelease': kReleaseMode,
},
'penNative': PenInputService.instance.debugSummary,
'penActive': PenInputService.instance.isActive,
'zoom': InputDiagnostics.instance.summary(),
'frames': FrameSampler.instance.summary(),
'instructions':
'Reproduce the issue for ~3 minutes with diagnostics on, then share this zip.',
};
final archive = Archive();
void addText(String name, String body) {
final bytes = utf8.encode(body);
archive.addFile(ArchiveFile(name, bytes.length, bytes));
}
addText('meta.json', const JsonEncoder.withIndent(' ').convert(meta));
addText(
'pen_events.json',
const JsonEncoder.withIndent(' ').convert(
PenEventRing.instance.toJsonList(window: penWindow),
),
);
addText(
'frame_samples.json',
const JsonEncoder.withIndent(' ').convert(FrameSampler.instance.toJsonList()),
);
addText(
'log_ring.json',
const JsonEncoder.withIndent(' ').convert(log.snapshotRing()),
);
// Include on-disk session NDJSON if present.
final sessionPath = log.path;
if (sessionPath != null) {
try {
final f = File(sessionPath);
if (await f.exists()) {
final bytes = await f.readAsBytes();
archive.addFile(
ArchiveFile('session.ndjson', bytes.length, bytes),
);
}
} catch (_) {}
}
// Legacy input log if it exists alongside.
try {
Directory dir;
try {
dir = await getApplicationDocumentsDirectory();
} catch (_) {
dir = await getTemporaryDirectory();
}
final legacy = File(
'${dir.path}${Platform.pathSeparator}badnote_input_log.txt',
);
if (await legacy.exists()) {
final bytes = await legacy.readAsBytes();
archive.addFile(
ArchiveFile('legacy_input_log.txt', bytes.length, bytes),
);
}
} catch (_) {}
final encoded = ZipEncoder().encode(archive);
if (encoded.isEmpty) {
throw StateError('Failed to encode diagnostic zip');
}
Directory outDir = log.directory ??
Directory(
'${(await getApplicationDocumentsDirectory()).path}'
'${Platform.pathSeparator}badnote_diagnostics',
);
if (!await outDir.exists()) {
await outDir.create(recursive: true);
}
final stamp = DateTime.now()
.toIso8601String()
.replaceAll(':', '-')
.replaceAll('.', '-');
final zipPath =
'${outDir.path}${Platform.pathSeparator}badnote_diag_$stamp.zip';
await File(zipPath).writeAsBytes(encoded, flush: true);
BadNoteLog.instance.info(
LogSubsystem.diag,
'export_pack',
fields: {'path': zipPath, 'bytes': encoded.length},
);
return DiagnosticExportResult(zipPath: zipPath, bytes: encoded.length);
}
}

View File

@@ -0,0 +1,99 @@
// Frame / hitch sampler for diagnostic packs.
import 'badnote_log.dart';
class FrameSample {
FrameSample({
required this.at,
required this.label,
required this.ms,
this.dropped = false,
});
final DateTime at;
final String label;
final double ms;
final bool dropped;
Map<String, Object?> toJson() => {
'at': at.toIso8601String(),
'label': label,
'ms': ms,
'dropped': dropped,
};
}
class FrameSampler {
FrameSampler._();
static final FrameSampler instance = FrameSampler._();
static const int capacity = 500;
final List<FrameSample> _samples = <FrameSample>[];
int overBudget = 0;
int total = 0;
/// Budget for a single frame at 60fps.
static const double budgetMs = 16.6;
void record(String label, double ms, {bool dropped = false}) {
total++;
final over = ms > budgetMs;
if (over) overBudget++;
final sample = FrameSample(
at: DateTime.now(),
label: label,
ms: ms,
dropped: dropped || over,
);
_samples.add(sample);
if (_samples.length > capacity) {
_samples.removeRange(0, _samples.length - capacity);
}
if (over || dropped) {
BadNoteLog.instance.warn(
LogSubsystem.frame,
'slow_frame',
fields: {'label': label, 'ms': ms, 'dropped': dropped},
);
}
}
void recordZoom({
required double rawScale,
required bool scaleDrop,
required bool focalDrop,
required double focalJumpPx,
}) {
record(
'zoom',
scaleDrop || focalDrop ? budgetMs + 1 : 8,
dropped: scaleDrop || focalDrop,
);
BadNoteLog.instance.debug(
LogSubsystem.frame,
'zoom',
fields: {
'rawScale': rawScale,
'scaleDrop': scaleDrop,
'focalDrop': focalDrop,
'focalJumpPx': focalJumpPx,
},
);
}
List<Map<String, Object?>> toJsonList() =>
_samples.map((s) => s.toJson()).toList(growable: false);
Map<String, Object?> summary() => {
'total': total,
'overBudget': overBudget,
'budgetMs': budgetMs,
'recent': toJsonList(),
};
void reset() {
_samples.clear();
overBudget = 0;
total = 0;
}
}

View File

@@ -0,0 +1,121 @@
// Rolling ring of recent pen / arbiter events for diagnostic export.
class PenEventRecord {
PenEventRecord({
required this.at,
required this.kind,
required this.pointerId,
this.pressure,
this.tiltX,
this.tiltY,
this.barrel = false,
this.eraser = false,
this.inverted = false,
this.decision,
this.note,
});
final DateTime at;
final String kind; // down|move|up|hw|arbiter
final int pointerId;
final double? pressure;
final double? tiltX;
final double? tiltY;
final bool barrel;
final bool eraser;
final bool inverted;
final String? decision; // draw|pan|reject
final String? note;
Map<String, Object?> toJson() => {
'at': at.toIso8601String(),
'kind': kind,
'pointerId': pointerId,
if (pressure != null) 'pressure': pressure,
if (tiltX != null) 'tiltX': tiltX,
if (tiltY != null) 'tiltY': tiltY,
'barrel': barrel,
'eraser': eraser,
'inverted': inverted,
if (decision != null) 'decision': decision,
if (note != null) 'note': note,
};
}
class PenEventRing {
PenEventRing._();
static final PenEventRing instance = PenEventRing._();
static const int capacity = 2000;
final List<PenEventRecord> _events = <PenEventRecord>[];
void add(PenEventRecord event) {
_events.add(event);
if (_events.length > capacity) {
_events.removeRange(0, _events.length - capacity);
}
}
void recordPointer({
required String kind,
required int pointerId,
required String deviceKind,
double? pressure,
String? decision,
String? note,
}) {
add(PenEventRecord(
at: DateTime.now(),
kind: kind,
pointerId: pointerId,
pressure: pressure,
decision: decision,
note: note ?? deviceKind,
));
}
void recordHardware({
required bool barrel,
required bool eraser,
required bool inverted,
required double tiltX,
required double tiltY,
}) {
add(PenEventRecord(
at: DateTime.now(),
kind: 'hw',
pointerId: -1,
barrel: barrel,
eraser: eraser,
inverted: inverted,
tiltX: tiltX,
tiltY: tiltY,
));
}
void recordArbiter({
required int activeCount,
required String deviceKind,
required bool draw,
required bool fingerDrawing,
}) {
add(PenEventRecord(
at: DateTime.now(),
kind: 'arbiter',
pointerId: -1,
decision: draw ? 'draw' : 'pan',
note: 'count=$activeCount kind=$deviceKind finger=$fingerDrawing',
));
}
List<PenEventRecord> recent({Duration? window}) {
if (window == null) return List.unmodifiable(_events);
final cut = DateTime.now().subtract(window);
return _events.where((e) => e.at.isAfter(cut)).toList(growable: false);
}
List<Map<String, Object?>> toJsonList({Duration? window}) =>
recent(window: window).map((e) => e.toJson()).toList(growable: false);
void clear() => _events.clear();
}