diff --git a/README.md b/README.md index 54207a9..aafbaa8 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ then everyone (you included) uses Join. | `--[no-]yt-dlp` | on | read web pages (YouTube, X, …) with yt-dlp when the server fetches a link | | `--yt-dlp-dir` | next to the binary | writable folder for the server's yt-dlp (systemd: the unit's `StateDirectory`) | | `--yt-dlp-cookies` | none | cookies.txt or cookie-extension JSON export for that yt-dlp (sites that refuse guests); copied into the yt-dlp folder at start, removed from it when not given. **An account login**: keep it private, use a throwaway account for YouTube | +| `--log-dir` | `logs` next to `--config`, else next to the binary (systemd: the unit's `StateDirectory`) | folder for the log files (`server-YYYY-MM-DD.log`, kept 7 days; passwords, tokens and cookies left out) | | `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`, `ytDlpCookies`); replaces the flags | | `--upgrade` | | update the install folder to the latest release ([Install and update](#install-and-update)), then exit | | `--check-update` | | print whether a newer release exists, then exit | @@ -246,6 +247,10 @@ To run it as a service, unpack the tarball to `/opt/watch-together` and use [deploy/watch-together.service](deploy/watch-together.service) (copy it to `/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`). +Something not playing? Settings → About → **Copy diagnostics** puts the +app's recent log (passwords, tokens and cookies left out) on the +clipboard: paste it to whoever helps you, from both sides if you can. + ## Join Join in the strip. Servers you got into are listed first (up to 6), with diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart index 90f24e7..5be3e96 100644 --- a/apps/desktop/lib/main.dart +++ b/apps/desktop/lib/main.dart @@ -9,6 +9,7 @@ import 'package:window_manager/window_manager.dart'; import 'app.dart'; import 'src/playback/yt_dlp_app.dart'; +import 'src/platform/app_log.dart'; import 'src/platform/desktop_entry.dart'; import 'src/platform/window_memory.dart'; import 'src/screens/home/ui.dart'; @@ -41,6 +42,7 @@ Future main(List args) async { ), ); WidgetsFlutterBinding.ensureInitialized(); + await startAppLogging(); await Hive.initFlutter(); await AppSettings.instance.ensureLoaded(); await windowManager.ensureInitialized(); diff --git a/apps/desktop/lib/src/platform/app_log.dart b/apps/desktop/lib/src/platform/app_log.dart new file mode 100644 index 0000000..c318a17 --- /dev/null +++ b/apps/desktop/lib/src/platform/app_log.dart @@ -0,0 +1,98 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sync_relay_core/core.dart'; + +import '../playback/yt_dlp_app.dart'; +import '../screens/home/ui.dart' show appName, displayVersion; +import '../state/session_state.dart'; + +// The app's log on disk (/logs/app-YYYY-MM-DD.log; core's +// logging.dart writes, rotates and redacts it), debugPrint routed into it, +// and the "Copy diagnostics" text of Settings → About. + +final _appLog = createLogger('app'); + +/// The logs folder, once [startAppLogging] ran. +String? appLogDir; + +/// Opens the log files and routes `debugPrint` and uncaught errors into +/// the log. Needs Flutter's binding (path_provider). +Future startAppLogging() async { + try { + final base = await getApplicationSupportDirectory(); + final dir = '${base.path}${Platform.pathSeparator}logs'; + if (startLogFiles(dir) != null) appLogDir = dir; + } catch (e) { + _appLog.warning('no log files: $e'); + } + // Everything the app prints for diagnosis lands in the file too (the + // log's console output replaces the print). + debugPrint = (String? message, {int? wrapWidth}) { + if (message == null || message.isEmpty) return; + final bad = RegExp(r'fail|error|crash|exception', caseSensitive: false).hasMatch(message); + bad ? _appLog.warning(message) : _appLog.info(message); + }; + PlatformDispatcher.instance.onError = (error, stack) { + _appLog.severe('uncaught: $error', error, stack); + return false; + }; + _appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} ' + '${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}'); +} + +/// The text "Copy diagnostics" gives: versions, then the last [since] of +/// the app log (its hosted server's records included), already redacted. +Future diagnosticsText({Duration since = const Duration(hours: 6)}) async { + final b = StringBuffer() + ..writeln('$appName ${displayVersion()}') + ..writeln('OS: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}') + ..writeln('Dart: ${Platform.version.split(' ').first}'); + try { + final mpv = await SessionState.instance.coordinator.playback.mpvVersion().timeout(const Duration(seconds: 2)); + b.writeln('mpv: ${mpv ?? 'no player yet'}'); + } catch (_) { + b.writeln('mpv: unknown'); + } + try { + final y = await AppYtDlp.instance.ytDlp.timeout(const Duration(seconds: 2)); + final cookies = y.cookies.read(count: false); + b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, ' + 'cookies: ${cookies == null ? 'none' : 'set'}'); + } catch (_) { + b.writeln('yt-dlp: unknown'); + } + final files = logFiles; + b.writeln('Log: ${files?.currentPath ?? 'none (console only)'}'); + b.writeln('--- last ${since.inHours} h of the log ---'); + b.write(files?.recent(since) ?? ''); + return redactLog(b.toString()); +} + +/// Saves [text] next to the logs as `diagnostics-