diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9659229 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,79 @@ +# Changelog + +What changed in each Starsync version, newest first, in plain words. The +app shows a version's section when it offers the update and once after it +(`What's new`); the release page shows it too. How entries are added and +cut into a release: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#changelog). + +## Unreleased + +Links you put up are downloaded once for everyone, plus What's new and fixes for Windows, the relay and chat. + +### Added + +- What's new: the update band shows what changed, and after an update the app shows the full list once (also in Settings → About) + +### Changed + +- A link you put up is downloaded once: you and everyone else watch the same stream, so nobody drifts ahead or stalls alone, and sites like Bilibili are far less likely to block it + +### Fixed + +- Joining while something plays on Windows: the video opens again by itself when it was too early +- Relay: players no longer get stuck after a slow or stalled source +- Relay: X (Twitter) clips play through the relay +- Relay: gentler retries, so Bilibili stops blocking the server +- Relay: a source that refuses the old link (412) gets a fresh address and keeps playing +- Chat pop-ups stay away while the chat panel is open (the sound still plays) + +## 1.3.2 + +Fixes Windows closing by itself about 3 seconds after every start. + +### Fixed + +- Windows: the app no longer closes by itself about 3 seconds after starting (1.3.0 and 1.3.1) + +## 1.3.1 + +Joining another server no longer opens an empty player, a cleaner mini player and a softer Minimal progress line. + +### Changed + +- A cleaner mini player: just the picture, play and a plain progress bar +- Minimal controls: the progress line shown while the controls hide is softer and easier on the eyes + +### Fixed + +- Joining another server no longer opens an empty player + +## 1.3.0 + +The app is now called Starsync and was rebuilt inside for stability: a shared countdown to what plays next, honest watched marks, faster relay seeks and many fixes. Everyone must update to keep watching together. + +### Added + +- A shared countdown to what plays next, and anyone can cancel it +- The next episode of your local files continues by itself ("Continues with") +- System notifications while the window is in the background +- Your own server is always running: Invite your friends straight in +- See the server's version and everyone's app version +- The library matches your shows with AniList +- A new sound engine and new sounds + +### Changed + +- The app is now called Starsync (your settings and data come along) +- "Watched" counts only the time you actually watched (Jellyfin too) +- Joining no longer pauses everyone +- A dropped connection comes back as the same member, not a new one +- A server restart no longer loses what was playing +- Faster seeks over the relay + +### Fixed + +- The app no longer freezes on exit +- Shaders on Windows +- X11: video renders properly again (also with software rendering) +- Black video when joining mid-play on X11 +- Many stability fixes from the rebuild diff --git a/README.md b/README.md index a183903..662447b 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,11 @@ Documents folder) and leaves the originals where they were. Update from the app: the app looks for a new version at start and every few hours, and when one exists a band across the top of the window says so -(never over a playing video). "Update and restart" downloads, -verifies, applies in place and restarts; "Later" hides it until the next -check; the install folder must be writable by you. The version you run is +(never over a playing video), with its first changes. "What's new" lists +them all ([CHANGELOG.md](CHANGELOG.md)); the first start after an update +shows that list once, and Settings → About has it again. "Update and +restart" downloads, verifies, applies in place and restarts; "Later" hides +it until the next check; the install folder must be writable by you. The version you run is at the bottom of every Settings page (and in the Settings tab's tooltip). Settings has Check now and an auto-check toggle. From a terminal: `./starsync-server --upgrade` (no display needed, so it works on a server box too), `./starsync --upgrade`, or the shipped `update.sh` / diff --git a/apps/desktop/lib/src/screens/home/about_page.dart b/apps/desktop/lib/src/screens/home/about_page.dart index 5bc6f42..ac348da 100644 --- a/apps/desktop/lib/src/screens/home/about_page.dart +++ b/apps/desktop/lib/src/screens/home/about_page.dart @@ -6,6 +6,8 @@ import '../../shell/mark_problem.dart'; import '../../app/app_graph.dart'; import '../../state/settings.dart'; import '../../update/update_controller.dart'; +import '../../update/whats_new.dart'; +import '../../widgets/whats_new_card.dart'; import '../../widgets/app_menu.dart'; import '../../widgets/copy_open.dart'; import 'settings_page.dart'; @@ -16,11 +18,12 @@ import 'update_indicator.dart'; const uoscCredit = 'Player timeline and controls inspired by uosc by tomasklaen, LGPL-2.1 ' '(github.com/tomasklaen/uosc).'; -/// Name, version, update state, the source link and the logs (Settings → -/// About). +/// Name, version, update state, What's new in this version, the source +/// link and the logs (Settings → About). class AboutPanel extends StatelessWidget { final UpdateController? updates; - const AboutPanel({super.key, this.updates}); + final WhatsNew? whatsNew; + const AboutPanel({super.key, this.updates, this.whatsNew}); @override Widget build(BuildContext context) { @@ -36,6 +39,23 @@ class AboutPanel extends StatelessWidget { style: t.bodyMedium?.copyWith(color: Colors.grey)), const SizedBox(height: 4), UpdateStatusLine(controller: updates), + // This version's notes, once kept (the install, a check). + ValueListenableBuilder( + valueListenable: (updates ?? UpdateController.instance).latest, + builder: (context, _, _) { + final notes = (whatsNew ?? WhatsNew.instance).current; + if (notes == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 8), + child: FilledButton.tonalIcon( + key: const ValueKey('about-whats-new'), + onPressed: () => showWhatsNew(context, notes), + icon: const Icon(Icons.auto_awesome, size: 18), + label: Text("What's new in ${notes.version}"), + ), + ); + }, + ), const SizedBox(height: kGap), Row( children: [ diff --git a/apps/desktop/lib/src/screens/home/update_indicator.dart b/apps/desktop/lib/src/screens/home/update_indicator.dart index be1c924..59238f1 100644 --- a/apps/desktop/lib/src/screens/home/update_indicator.dart +++ b/apps/desktop/lib/src/screens/home/update_indicator.dart @@ -1,13 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:sync_relay_core/core.dart' show isDevBuild; +import 'package:sync_relay_core/core.dart' show UpdateManifest, isDevBuild; import '../../update/update_controller.dart'; +import '../../update/whats_new.dart'; import '../../widgets/update_banner.dart'; +import '../../widgets/whats_new_card.dart'; import 'ui.dart'; /// The band across the top of the window while an update is up for -/// grabs: "Starsync 1.2.1 is ready", its notes, Update and restart and -/// Later (gone until the next check or start). The shell keeps it off +/// grabs: "Starsync 1.2.1 is ready", its first changes (or its one-line +/// notes), What's new (the whole list, [showWhatsNew]), Update and restart +/// and Later (gone until the next check or start). The shell keeps it off /// the full player while a video is on ([AppShell]). class UpdateBar extends StatelessWidget { final UpdateController? controller; @@ -31,6 +34,8 @@ class UpdateBar extends StatelessWidget { final behind = isDevBuild(current) ? null : 'You are running $current'; final String title; String detail; + // The offered version's first changes instead of its notes. + var preview = false; switch (s.phase) { case UpdatePhase.downloading: title = 'Downloading $appName ${m.version}… ${((s.progress ?? 0) * 100).round()}%'; @@ -51,9 +56,11 @@ class UpdateBar extends StatelessWidget { } else if (m.isRequired) { title = '$appName ${m.version} is required to join servers on it'; detail = m.notes; + preview = m.changes != null; } else { title = '$appName ${m.version} is ready'; detail = m.notes; + preview = m.changes != null; } detail = [?behind, detail].where((t) => t.isNotEmpty).join(' · '); } @@ -88,7 +95,9 @@ class UpdateBar extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: fg, fontWeight: FontWeight.w600)), - if (detail.isNotEmpty) + if (preview) + WhatsNewPreview(changes: m.changes!, color: fg, lead: behind) + else if (detail.isNotEmpty) Tooltip( message: detail, waitDuration: const Duration(milliseconds: 600), @@ -102,6 +111,14 @@ class UpdateBar extends StatelessWidget { ), if (!busy) ...[ const SizedBox(width: 12), + if (m.changes != null || m.notes.trim().isNotEmpty) + TextButton.icon( + key: const ValueKey('update-whats-new'), + style: TextButton.styleFrom(foregroundColor: fg), + onPressed: () => _openWhatsNew(context, c, m, s.canInstall), + icon: const Icon(Icons.auto_awesome, size: 16), + label: const Text("What's new"), + ), if (!m.isRequired) TextButton( key: const ValueKey('update-later'), @@ -127,6 +144,27 @@ class UpdateBar extends StatelessWidget { } } +/// The offered version's card, with Update and restart when it installs here. +void _openWhatsNew(BuildContext context, UpdateController c, UpdateManifest m, bool canInstall) { + showWhatsNew( + context, + ReleaseNotes.of(m), + overline: canInstall ? '$appName · ready to install' : '$appName · new release', + action: canInstall + ? Builder( + builder: (context) => FilledButton( + key: const ValueKey('whats-new-install'), + onPressed: () { + Navigator.of(context).maybePop(); + c.install(); + }, + child: const Text('Update and restart'), + ), + ) + : null, + ); +} + /// The existing update card in a dialog; closes itself when the card /// would render nothing (e.g. "Later"). Future showUpdateDialog(BuildContext context, {UpdateController? controller}) { diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart index 8f62642..7027618 100644 --- a/apps/desktop/lib/src/shell/app_shell.dart +++ b/apps/desktop/lib/src/shell/app_shell.dart @@ -17,11 +17,14 @@ import '../session/app_events.dart' show ActionFailed, ScheduleResult; import '../screens/home/health_probe.dart'; import '../screens/home/media_picker.dart'; import '../session/identity.dart'; +import '../screens/home/ui.dart' show appName; import '../screens/home/update_indicator.dart'; import '../app/app_graph.dart'; import '../session/navigation.dart' show Place; import '../state/settings.dart'; import '../update/update_controller.dart'; +import '../update/whats_new.dart'; +import '../widgets/whats_new_card.dart'; import 'chat_panel.dart'; import 'connect_cards.dart'; import 'handoff.dart'; @@ -52,6 +55,9 @@ import 'welcome.dart'; class AppShell extends StatefulWidget { final UpdateController? updates; final bool autoUpdateCheck; + /// The card once after an update; defaults to [WhatsNew.instance] when + /// [autoUpdateCheck] is on (off in tests unless given). + final WhatsNew? whatsNew; /// Off in tests: the drag area, window buttons and fullscreen need /// window_manager. final bool windowControls; @@ -67,6 +73,7 @@ class AppShell extends StatefulWidget { super.key, this.updates, this.autoUpdateCheck = true, + this.whatsNew, this.windowControls = true, this.healthGet = defaultHealthGet, this.jellyfin, @@ -158,6 +165,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin // Reads linked servers and checks their tokens in the background. _jellyfin.ensureLoaded(); if (widget.autoUpdateCheck) _autoCheck(); + if (widget.whatsNew != null || widget.autoUpdateCheck) unawaited(_startWhatsNew()); HardwareKeyboard.instance.addHandler(_onKey); final nav = _graph.navigation; final coord = _graph.coordinator; @@ -241,6 +249,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin stop(); } HardwareKeyboard.instance.removeHandler(_onKey); + _whatsNewSource?.due.removeListener(_maybeWhatsNew); _updateTimer?.cancel(); _fade.dispose(); _online.dispose(); @@ -276,6 +285,36 @@ class AppShellState extends State with SingleTickerProviderStateMixin if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates); } + WhatsNew? _whatsNewSource; + bool _whatsNewOpen = false; + + /// What's new once after an update: due now (notes kept by the install) + /// or once a check reads this version's manifest. + Future _startWhatsNew() async { + await AppSettings.instance.ensureLoaded(); + if (!mounted) return; + final w = _whatsNewSource = widget.whatsNew ?? WhatsNew.instance; + w.due.addListener(_maybeWhatsNew); + // Not over the full player while a video is on: once it ends or minimises. + _stops.add(effect(() { + _watching.value; + untracked(() => _later(_maybeWhatsNew)); + })); + w.start(widget.updates ?? UpdateController.instance); + _maybeWhatsNew(); + } + + void _maybeWhatsNew() { + final w = _whatsNewSource; + final notes = w?.due.value; + if (w == null || notes == null || !mounted || _whatsNewOpen) return; + if (_shown == PlayerMode.full && _watching.peek()) return; + _whatsNewOpen = true; + w.shown(); + unawaited(showWhatsNew(context, notes, overline: '$appName · updated') + .whenComplete(() => _whatsNewOpen = false)); + } + void _wentOnline() { _unread = 0; _feedSeen = _graph.coordinator.feedLog.peek().length; @@ -313,6 +352,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin } if (!mounted || seq != _modeSeq) return; setState(() => _shown = m); + if (m != PlayerMode.full) _later(_maybeWhatsNew); await _fade.animateTo(1, duration: fadeThroughTime * (1 - fadeOutPart), curve: ease); } diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index 6484ebe..136bc26 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -44,6 +44,8 @@ const keyAutoSitOut = 'autoSitOut'; const _keyChatPopups = 'chatPopups'; const _keyNotifyPeople = 'notifyPeople'; const _keyShareDiagnostics = 'shareDiagnostics'; +const _keyWhatsNewSeen = 'whatsNewSeen'; +const _keyWhatsNewNotes = 'whatsNewNotes'; enum HwdecMode { auto, off, custom } @@ -107,6 +109,13 @@ class AppSettings { /// `SfxKind` name -> `SoundPreset` name, per-sound overrides. final soundPresetFor = signal>(const {}); + /// What's new (`update/whats_new.dart`): the version whose card was + /// shown, and the notes last kept (a version's `ReleaseNotes` as JSON). + String? whatsNewSeen; + Map? whatsNewNotes; + /// The box was empty when this run opened it: a new install. + bool freshInstall = false; + /// Video shaders: one choice, used on join and changed both here and /// in the session (Ctrl+0..6, the shader button). final shaderPreset = signal(ShaderPreset.off); @@ -131,6 +140,7 @@ class AppSettings { Future _loadNow() async { try { final box = _box = await Hive.openBox(settingsBoxName); + freshInstall = box.isEmpty; for (final line in await migrateSettings(box)) { debugPrint(line); } @@ -194,6 +204,10 @@ class AppSettings { shaderQuality.value = ShaderQuality.values.where((q) => q.name == quality).firstOrNull ?? ShaderQuality.hq; + final seen = box.get(_keyWhatsNewSeen); + if (seen is String) whatsNewSeen = seen; + final notes = box.get(_keyWhatsNewNotes); + if (notes is Map) whatsNewNotes = {for (final e in notes.entries) '${e.key}': e.value}; final files = box.get(_keyCustomShaders); if (files is List) { customShaders.value = List.unmodifiable(files.whereType()); @@ -328,6 +342,16 @@ class AppSettings { _put(_keyCustomShaders, [...paths]); } + Future setWhatsNewSeen(String version) { + whatsNewSeen = version; + return _put(_keyWhatsNewSeen, version); + } + + Future setWhatsNewNotes(Map notes) { + whatsNewNotes = notes; + return _put(_keyWhatsNewNotes, notes); + } + Future _put(String key, Object value) async { try { final box = _box ?? diff --git a/apps/desktop/lib/src/update/update_controller.dart b/apps/desktop/lib/src/update/update_controller.dart index 3822804..1077abf 100644 --- a/apps/desktop/lib/src/update/update_controller.dart +++ b/apps/desktop/lib/src/update/update_controller.dart @@ -5,6 +5,7 @@ import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:sync_relay_core/core.dart'; import '../platform/app_shutdown.dart'; +import 'whats_new.dart'; // In-app update flow behind the update bar (UpdateBar). Same code path as // `--upgrade` (core's updater, downloadAndApply); this adds state for the @@ -58,6 +59,9 @@ class UpdateController { final Directory? installRoot; final RestartFn restart; final bool skipDevBuilds; + /// Awaited after the apply, before [restart]: keeps the new version's + /// notes for its first start ([WhatsNew.installing]). + final Future Function(UpdateManifest manifest)? beforeRestart; UpdateController( {this.currentVersion = appVersion, @@ -65,9 +69,14 @@ class UpdateController { this.executablePath, this.installRoot, this.restart = _closeAndRestart, - this.skipDevBuilds = true}); + this.skipDevBuilds = true, + Future Function(UpdateManifest manifest)? beforeRestart}) + : beforeRestart = beforeRestart ?? WhatsNew.instance.installing; final status = ValueNotifier(const UpdateStatus(UpdatePhase.idle)); + /// The manifest of the last check that read one, newer or not (What's + /// new picks up this version's notes from it). + final latest = ValueNotifier(null); /// Outcome of the last finished check, for Settings/About wording /// ("Up to date", "Check failed: ..."); null until one finishes. final lastCheck = ValueNotifier(null); @@ -108,6 +117,7 @@ class UpdateController { try { final m = await fetchLatestManifest( currentVersion: currentVersion, url: manifestUrl); + latest.value = m; if (!isNewerVersion(currentVersion, m.version)) { _log.info('up to date ($currentVersion, latest ${m.version})'); lastCheck.value = 'Up to date'; @@ -173,6 +183,11 @@ class UpdateController { _log.info('updated to ${m.version}, restarting'); status.value = UpdateStatus(UpdatePhase.restarting, manifest: m, canInstall: true); + try { + await beforeRestart?.call(m); + } catch (e) { + _log.warning('keeping the notes of ${m.version}: $e'); + } // Started by an old name (1.2.x's restart, an old launcher): the new // binary next to it. await restart(relaunchExecutable(_exe), launchArgs); diff --git a/apps/desktop/lib/src/update/whats_new.dart b/apps/desktop/lib/src/update/whats_new.dart new file mode 100644 index 0000000..799dc70 --- /dev/null +++ b/apps/desktop/lib/src/update/whats_new.dart @@ -0,0 +1,150 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:sync_relay_core/core.dart'; + +import '../state/settings.dart'; +import 'update_controller.dart'; + +// "What's new in X.Y.Z" once after an update. The notes come from the +// manifest: the one installed (kept before the restart) or, for an update +// made another way (1.3.x's updater, `--upgrade`, by hand), the manifest of +// the first check that finds this very version. A fresh install shows +// nothing; Settings → About opens the card again. + +/// What the card shows for one version. +class ReleaseNotes { + final String version; + + /// The one-line notes (all a manifest from before the changelog has). + final String notes; + final ReleaseChanges? changes; + const ReleaseNotes({required this.version, this.notes = '', this.changes}); + + factory ReleaseNotes.of(UpdateManifest m) => ReleaseNotes(version: m.version, notes: m.notes, changes: m.changes); + + /// Null for anything unreadable. + static ReleaseNotes? fromJson(Object? json) { + if (json is! Map) return null; + final version = json['version']; + if (version is! String || version.isEmpty) return null; + final notes = json['notes']; + return ReleaseNotes( + version: version, + notes: notes is String ? notes : '', + changes: ReleaseChanges.fromJson(json['changes']), + ); + } + + Map toJson() => { + 'version': version, + 'notes': notes, + if (changes != null) 'changes': changes!.toJson(), + }; + + bool get isEmpty => notes.trim().isEmpty && (changes?.isEmpty ?? true); +} + +/// Where [WhatsNew] keeps its two values (settings box; tests: memory). +abstract class WhatsNewStore { + /// The version whose card was shown (or that was installed fresh). + String? get seen; + Future setSeen(String version); + + /// The notes last known for an installed or current version. + ReleaseNotes? get notes; + Future setNotes(ReleaseNotes notes); + + /// The settings box was empty at start: a fresh install. + bool get fresh; +} + +class SettingsWhatsNewStore implements WhatsNewStore { + final AppSettings settings; + SettingsWhatsNewStore([AppSettings? settings]) : settings = settings ?? AppSettings.instance; + @override + String? get seen => settings.whatsNewSeen; + @override + Future setSeen(String version) => settings.setWhatsNewSeen(version); + @override + ReleaseNotes? get notes => ReleaseNotes.fromJson(settings.whatsNewNotes); + @override + Future setNotes(ReleaseNotes notes) => settings.setWhatsNewNotes(notes.toJson()); + @override + bool get fresh => settings.freshInstall; +} + +class MemoryWhatsNewStore implements WhatsNewStore { + @override + String? seen; + @override + ReleaseNotes? notes; + @override + bool fresh; + MemoryWhatsNewStore({this.seen, this.notes, this.fresh = false}); + @override + Future setSeen(String version) async => seen = version; + @override + Future setNotes(ReleaseNotes notes) async => this.notes = notes; +} + +class WhatsNew { + static final instance = WhatsNew(); + + final String currentVersion; + + final WhatsNewStore store; + + WhatsNew({this.currentVersion = appVersion, WhatsNewStore? store}) : store = store ?? SettingsWhatsNewStore(); + + /// The card to show now (once); null when there is none. + final due = ValueNotifier(null); + UpdateController? _updates; + + /// At start, settings loaded: a pending card for this version, and + /// [updates]' checks from now on. + void start(UpdateController updates) { + if (!identical(_updates, updates)) { + _updates?.latest.removeListener(_onManifest); + _updates = updates..latest.addListener(_onManifest); + } + final s = store; + if (s.seen == currentVersion) return; + if (s.seen == null && s.fresh) { + // A new install: nothing changed for this person. + unawaited(s.setSeen(currentVersion)); + return; + } + final known = s.notes; + if (known != null && known.version == currentVersion && !known.isEmpty) due.value = known; + } + + void _onManifest() { + final m = _updates?.latest.value; + if (m != null) offer(ReleaseNotes.of(m)); + } + + /// Notes for some version (an update check's manifest). Kept when they + /// are this version's; due when its card was not shown yet. + void offer(ReleaseNotes n) { + if (n.version != currentVersion || n.isEmpty) return; + final s = store; + unawaited(s.setNotes(n)); + if (s.seen != currentVersion && !s.fresh) due.value = n; + } + + /// Before the restart into [m]: its notes for the first start. + Future installing(UpdateManifest m) => store.setNotes(ReleaseNotes.of(m)); + + /// The card was shown: never again for this version. + void shown() { + due.value = null; + unawaited(store.setSeen(currentVersion)); + } + + /// This version's notes, when known (Settings → About). + ReleaseNotes? get current { + final n = store.notes; + return n != null && n.version == currentVersion && !n.isEmpty ? n : null; + } +} diff --git a/apps/desktop/lib/src/widgets/whats_new_card.dart b/apps/desktop/lib/src/widgets/whats_new_card.dart new file mode 100644 index 0000000..f857021 --- /dev/null +++ b/apps/desktop/lib/src/widgets/whats_new_card.dart @@ -0,0 +1,326 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:sync_relay_core/core.dart'; + +import '../screens/home/ui.dart' show appName; +import '../shell/motion.dart'; +import '../update/whats_new.dart'; +import 'app_menu.dart'; + +// The "What's new" card: a version's changes (CHANGELOG.md, through the +// manifest's `changes`) as Added / Changed / Fixed sections, each with its +// icon and colour, on the app's glass. The update band opens it for the +// offered version, the first start after an update for the new one +// ([WhatsNew]), Settings → About for the running one. Notes without +// `changes` (older manifests) show as one paragraph. + +enum ChangeKind { + added('Added', Icons.auto_awesome_rounded, Icons.add_rounded, Color(0xFF7EE0A6)), + changed('Changed', Icons.tune_rounded, Icons.arrow_forward_rounded, Color(0xFF8DB8FF)), + fixed('Fixed', Icons.handyman_rounded, Icons.check_rounded, Color(0xFFFFC772)); + + final String label; + final IconData icon; + /// Before each item. + final IconData bullet; + + /// Reads on the dark glass. + final Color color; + const ChangeKind(this.label, this.icon, this.bullet, this.color); + + List of(ReleaseChanges c) => switch (this) { + ChangeKind.added => c.added, + ChangeKind.changed => c.changed, + ChangeKind.fixed => c.fixed, + }; +} + +/// The first [n] items, Added before Changed before Fixed. +List<(ChangeKind, String)> topChanges(ReleaseChanges c, int n) => [ + for (final k in ChangeKind.values) + for (final item in k.of(c)) (k, item), +].take(n).toList(); + +/// Opens the card over the window. [action]: a button beside Close (the +/// band's "Update and restart"). +Future showWhatsNew(BuildContext context, ReleaseNotes notes, {String? overline, Widget? action}) => + showPopover( + context, + at: Alignment.center, + dim: true, + child: WhatsNewCard(notes: notes, overline: overline, action: action), + ); + +class WhatsNewCard extends StatefulWidget { + final ReleaseNotes notes; + + /// The small line over the title (default: the app's name). + final String? overline; + final Widget? action; + + /// Default: closes the route. + final VoidCallback? onClose; + const WhatsNewCard({super.key, required this.notes, this.overline, this.action, this.onClose}); + + @override + State createState() => _WhatsNewCardState(); +} + +class _WhatsNewCardState extends State with SingleTickerProviderStateMixin { + late final _intro = AnimationController( + vsync: this, + duration: easeTime + Duration(milliseconds: 45 * math.min(_rows, 12)), + )..forward(); + + int get _rows => widget.notes.changes?.length ?? 1; + + @override + void dispose() { + _intro.dispose(); + super.dispose(); + } + + /// Row [i] of [_rows] slides up and fades in a little after the one above. + Widget _staggered(int i, Widget child) { + final start = math.min(i, 12) * 0.06; + final a = CurvedAnimation( + parent: _intro, + curve: Interval(math.min(start, 0.6), 1, curve: ease), + ); + return FadeTransition( + opacity: a, + child: SlideTransition( + position: Tween(begin: const Offset(0, 0.25), end: Offset.zero).animate(a), + child: child, + ), + ); + } + + void _close() => widget.onClose != null ? widget.onClose!() : Navigator.of(context).maybePop(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + const muted = Color(0xB3FFFFFF); + final n = widget.notes; + final changes = n.changes; + final screen = MediaQuery.sizeOf(context); + final maxHeight = math.min(620.0, screen.height - 64); + var row = 0; + final body = [ + if (changes == null) + _staggered(row++, Text(n.notes, style: const TextStyle(fontSize: 13.5, height: 1.45))) + else + for (final k in ChangeKind.values) + if (k.of(changes).isNotEmpty) ...[ + _staggered(row++, _SectionHead(kind: k, count: k.of(changes).length)), + const SizedBox(height: 6), + for (final item in k.of(changes)) _staggered(row++, _Item(kind: k, text: item)), + const SizedBox(height: 10), + ], + ]; + return AppGlass( + key: const ValueKey('whats-new-card'), + radius: 16, + child: ConstrainedBox( + constraints: BoxConstraints.tightFor(width: math.min(460.0, screen.width - 32)).copyWith(maxHeight: maxHeight), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 8, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [scheme.primary, scheme.tertiary.withValues(alpha: 0.85)], + ), + boxShadow: [BoxShadow(color: scheme.primary.withValues(alpha: 0.35), blurRadius: 14)], + ), + child: const SizedBox.square( + dimension: 40, + child: Icon(Icons.auto_awesome, color: Colors.white, size: 22), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + (widget.overline ?? appName).toUpperCase(), + style: TextStyle( + color: scheme.primary, + fontSize: 10.5, + fontWeight: FontWeight.w700, + letterSpacing: 1.4, + ), + ), + const SizedBox(height: 2), + Text( + "What's new in ${n.version}", + key: const ValueKey('whats-new-title'), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, height: 1.2), + ), + ], + ), + ), + IconButton( + tooltip: 'Close', + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, size: 18, color: muted), + onPressed: _close, + ), + ], + ), + ), + if (changes != null && n.notes.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(18, 10, 18, 0), + child: Text(n.notes, style: const TextStyle(color: muted, fontSize: 13, height: 1.4)), + ), + const SizedBox(height: 12), + const Divider(height: 1, thickness: 0.5, color: AppMenuStyle.border), + Flexible( + child: SingleChildScrollView( + key: const ValueKey('whats-new-list'), + padding: const EdgeInsets.fromLTRB(18, 14, 18, 4), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: body), + ), + ), + const Divider(height: 1, thickness: 0.5, color: AppMenuStyle.border), + Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (widget.action != null) ...[ + TextButton(key: const ValueKey('whats-new-close'), onPressed: _close, child: const Text('Close')), + const SizedBox(width: 8), + widget.action!, + ] else + FilledButton.tonal( + key: const ValueKey('whats-new-close'), + onPressed: _close, + child: const Text('Nice'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _SectionHead extends StatelessWidget { + final ChangeKind kind; + final int count; + const _SectionHead({required this.kind, required this.count}); + + @override + Widget build(BuildContext context) => Align( + alignment: AlignmentDirectional.centerStart, + child: DecoratedBox( + key: ValueKey('whats-new-${kind.name}'), + decoration: BoxDecoration( + color: kind.color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: kind.color.withValues(alpha: 0.28), width: 0.5), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 3, 10, 3), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(kind.icon, size: 14, color: kind.color), + const SizedBox(width: 6), + Text( + kind.label, + style: TextStyle(color: kind.color, fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.3), + ), + const SizedBox(width: 6), + Text('$count', style: TextStyle(color: kind.color.withValues(alpha: 0.7), fontSize: 11.5)), + ], + ), + ), + ), + ); +} + +class _Item extends StatelessWidget { + final ChangeKind kind; + final String text; + const _Item({required this.kind, required this.text}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2, left: 2), + child: DecoratedBox( + decoration: BoxDecoration(color: kind.color.withValues(alpha: 0.16), shape: BoxShape.circle), + child: SizedBox.square( + dimension: 16, + child: Icon( + kind.bullet, + size: 12, + color: kind.color, + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded(child: Text(text, style: const TextStyle(fontSize: 13.5, height: 1.4))), + ], + ), + ); +} + +/// The band's line: the first items with their kind's icon, in [color]. +class WhatsNewPreview extends StatelessWidget { + final ReleaseChanges changes; + final Color color; + final String? lead; + const WhatsNewPreview({super.key, required this.changes, required this.color, this.lead}); + + @override + Widget build(BuildContext context) { + final top = topChanges(changes, 3); + final style = TextStyle(color: color.withValues(alpha: 0.85), fontSize: 12); + return Text.rich( + key: const ValueKey('whats-new-preview'), + TextSpan( + children: [ + if (lead != null) TextSpan(text: '$lead · '), + for (final (i, (k, item)) in top.indexed) ...[ + if (i > 0) const TextSpan(text: ' '), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon(k.icon, size: 13, color: color.withValues(alpha: 0.85)), + ), + ), + TextSpan(text: item), + ], + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: style, + ); + } +} diff --git a/apps/desktop/test/shell_test.dart b/apps/desktop/test/shell_test.dart index 02635d8..34e2d6d 100644 --- a/apps/desktop/test/shell_test.dart +++ b/apps/desktop/test/shell_test.dart @@ -19,6 +19,7 @@ import 'package:starsync/src/shell/toasts.dart'; import 'package:starsync/src/session/session_coordinator.dart' show ToastItem; import 'package:starsync/src/state/settings.dart'; import 'package:starsync/src/update/update_controller.dart'; +import 'package:starsync/src/update/whats_new.dart'; import 'package:starsync/src/session/invite.dart' show Invite; import 'package:starsync/src/session/navigation.dart' show Place; @@ -88,7 +89,8 @@ void main() { session.notifier.clear(); }); - Future pumpShell(WidgetTester tester, Size size, {UpdateController? updates, VideoViewBuilder? video}) async { + Future pumpShell(WidgetTester tester, Size size, + {UpdateController? updates, VideoViewBuilder? video, WhatsNew? whatsNew}) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -97,6 +99,7 @@ void main() { home: AppShell( updates: updates ?? UpdateController(currentVersion: '0.0.0-dev'), autoUpdateCheck: false, + whatsNew: whatsNew, windowControls: false, videoBuilder: video ?? (context, controls) => const FakeVideo(), // A real probe (network, yt-dlp) never answers on the test clock. @@ -152,6 +155,25 @@ void main() { }); }); + group('what\'s new', () { + testWidgets('the first start after an update shows the card once', (tester) async { + final store = MemoryWhatsNewStore( + seen: '1.3.2', + notes: const ReleaseNotes(version: '1.3.3', changes: ReleaseChanges(fixed: ['Various bug fixes']))); + await pumpShell(tester, const Size(1280, 800), whatsNew: WhatsNew(currentVersion: '1.3.3', store: store)); + expect(find.text("What's new in 1.3.3"), findsOneWidget); + expect(find.text('STARSYNC · UPDATED'), findsOneWidget); + expect(store.seen, '1.3.3'); + await tester.tap(find.byKey(const ValueKey('whats-new-close'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('whats-new-card')), findsNothing); + // The next start: nothing. + await tester.pumpWidget(const SizedBox()); + await pumpShell(tester, const Size(1280, 800), whatsNew: WhatsNew(currentVersion: '1.3.3', store: store)); + expect(find.byKey(const ValueKey('whats-new-card')), findsNothing); + }); + }); + group('party strip', () { testWidgets('home, alone: a plain player\'s strip, Join a friend and Invite (once your server runs)', (tester) async { diff --git a/apps/desktop/test/whats_new_test.dart b/apps/desktop/test/whats_new_test.dart new file mode 100644 index 0000000..cf6d9aa --- /dev/null +++ b/apps/desktop/test/whats_new_test.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:starsync/src/screens/home/update_indicator.dart'; +import 'package:starsync/src/update/update_controller.dart'; +import 'package:starsync/src/update/whats_new.dart'; +import 'package:starsync/src/widgets/whats_new_card.dart'; + +const _changes = ReleaseChanges( + added: ['Danmaku style notifications', 'Bilibili style chat box'], + changed: ['A cleaner mini player'], + fixed: ['Optimized relay', 'Various bug fixes'], +); + +UpdateManifest _manifest({String version = '1.3.3', ReleaseChanges? changes = _changes, String notes = 'Chat, relay'}) => + UpdateManifest(version: version, protocol: 1, url: 'http://x/a.tgz', notes: notes, changes: changes); + +UpdateController _controller({String current = '1.3.2'}) => UpdateController( + currentVersion: current, + executablePath: '/nowhere/starsync', + restart: (_, _) async {}, + beforeRestart: (_) async {}); + +Widget _app(Widget child) => MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child)); + +void main() { + group('card', () { + testWidgets('sections in order with their items, the summary, Nice closes', (tester) async { + await tester.pumpWidget(_app(Builder( + builder: (context) => TextButton( + onPressed: () => showWhatsNew( + context, const ReleaseNotes(version: '1.3.3', notes: 'Chat, relay', changes: _changes)), + child: const Text('open'), + ), + ))); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('whats-new-card')), findsOneWidget); + expect(find.text("What's new in 1.3.3"), findsOneWidget); + expect(find.text('Chat, relay'), findsOneWidget); + final heads = [for (final k in ChangeKind.values) tester.getTopLeft(find.byKey(ValueKey('whats-new-${k.name}'))).dy]; + expect(heads, orderedEquals([...heads]..sort()), reason: 'Added, Changed, Fixed'); + for (final item in [..._changes.added, ..._changes.changed, ..._changes.fixed]) { + expect(find.text(item), findsOneWidget); + } + await tester.tap(find.byKey(const ValueKey('whats-new-close'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('whats-new-card')), findsNothing); + }); + + testWidgets('notes only (a manifest from before the changelog): one paragraph, no sections', (tester) async { + await tester.pumpWidget(_app(const Center( + child: WhatsNewCard(notes: ReleaseNotes(version: '1.3.2', notes: 'Fixes Windows closing by itself.'))))); + await tester.pumpAndSettle(); + expect(find.text('Fixes Windows closing by itself.'), findsOneWidget); + for (final k in ChangeKind.values) { + expect(find.byKey(ValueKey('whats-new-${k.name}')), findsNothing); + } + }); + + testWidgets('a long list scrolls inside a short window, no overflow', (tester) async { + tester.view.physicalSize = const Size(800, 420); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final many = ReleaseChanges(fixed: [for (var i = 0; i < 40; i++) 'Fix number $i']); + await tester.pumpWidget(_app(Center(child: WhatsNewCard(notes: ReleaseNotes(version: '2.0.0', changes: many))))); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byKey(const ValueKey('whats-new-card'))).height, lessThanOrEqualTo(420 - 64)); + await tester.drag(find.byKey(const ValueKey('whats-new-list')), const Offset(0, -2000)); + await tester.pumpAndSettle(); + expect(find.text('Fix number 39'), findsOneWidget); + }); + }); + + group('band', () { + Future pump(WidgetTester tester, UpdateManifest m) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = _controller(); + c.status.value = UpdateStatus(UpdatePhase.available, manifest: m, canInstall: true); + await tester.pumpWidget(_app(Column(children: [UpdateBar(controller: c)]))); + return c; + } + + testWidgets('with changes: the first items, What\'s new opens the card with Update and restart', (tester) async { + await pump(tester, _manifest()); + final preview = tester.widget(find.byKey(const ValueKey('whats-new-preview'))).textSpan!.toPlainText(); + expect(preview, contains('You are running 1.3.2')); + expect(preview, contains('Danmaku style notifications')); + expect(preview, contains('A cleaner mini player')); + expect(preview, isNot(contains('Optimized relay')), reason: 'three at most'); + expect(find.text('You are running 1.3.2 · Chat, relay'), findsNothing); + await tester.tap(find.byKey(const ValueKey('update-whats-new'))); + await tester.pumpAndSettle(); + expect(find.text("What's new in 1.3.3"), findsOneWidget); + expect(find.text('Various bug fixes'), findsOneWidget); + expect(find.byKey(const ValueKey('whats-new-install')), findsOneWidget); + }); + + testWidgets('without changes (old manifest): the notes line, What\'s new shows them', (tester) async { + await pump(tester, _manifest(changes: null)); + expect(find.byKey(const ValueKey('whats-new-preview')), findsNothing); + expect(find.text('You are running 1.3.2 · Chat, relay'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('update-whats-new'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('whats-new-card')), findsOneWidget); + expect(find.text('Chat, relay'), findsWidgets); + }); + + testWidgets('nothing to tell: no What\'s new button', (tester) async { + await pump(tester, _manifest(changes: null, notes: '')); + expect(find.byKey(const ValueKey('update-whats-new')), findsNothing); + expect(find.byKey(const ValueKey('update-install')), findsOneWidget); + }); + }); + + group('show once', () { + test('after an install: due at the first start, never again once shown', () async { + final store = MemoryWhatsNewStore(seen: '1.3.2'); + // The old version keeps the new one's notes before restarting. + await WhatsNew(currentVersion: '1.3.2', store: store).installing(_manifest()); + expect(store.notes!.version, '1.3.3'); + + final first = WhatsNew(currentVersion: '1.3.3', store: store)..start(_controller(current: '1.3.3')); + expect(first.due.value?.version, '1.3.3'); + expect(first.due.value?.changes?.fixed, _changes.fixed); + first.shown(); + expect(first.due.value, isNull); + expect(store.seen, '1.3.3'); + + final second = WhatsNew(currentVersion: '1.3.3', store: store)..start(_controller(current: '1.3.3')); + expect(second.due.value, isNull); + expect(second.current?.version, '1.3.3', reason: 'About still opens it'); + }); + + test('updated another way (1.3.x updater): the first check reading this version brings it', () { + final store = MemoryWhatsNewStore(); + final c = _controller(current: '1.3.3'); + final w = WhatsNew(currentVersion: '1.3.3', store: store)..start(c); + expect(w.due.value, isNull); + c.latest.value = _manifest(version: '1.3.4'); + expect(w.due.value, isNull, reason: 'another version'); + c.latest.value = _manifest(); + expect(w.due.value?.version, '1.3.3'); + expect(store.notes?.version, '1.3.3'); + }); + + test('a fresh install shows nothing, but About has the notes once known', () { + final store = MemoryWhatsNewStore(fresh: true); + final c = _controller(current: '1.3.3'); + final w = WhatsNew(currentVersion: '1.3.3', store: store)..start(c); + expect(store.seen, '1.3.3'); + c.latest.value = _manifest(); + expect(w.due.value, isNull); + expect(w.current?.version, '1.3.3'); + }); + + test('notes of another version or empty notes are never due', () { + final store = MemoryWhatsNewStore( + seen: '1.3.2', notes: const ReleaseNotes(version: '1.3.4', changes: _changes)); + expect((WhatsNew(currentVersion: '1.3.3', store: store)..start(_controller())).due.value, isNull); + final empty = MemoryWhatsNewStore(seen: '1.3.2', notes: const ReleaseNotes(version: '1.3.3')); + expect((WhatsNew(currentVersion: '1.3.3', store: empty)..start(_controller())).due.value, isNull); + }); + + test('kept notes survive a round trip through settings JSON', () { + final n = ReleaseNotes.of(_manifest()); + final back = ReleaseNotes.fromJson(n.toJson())!; + expect(back.version, '1.3.3'); + expect(back.notes, 'Chat, relay'); + expect(back.changes!.toJson(), _changes.toJson()); + expect(ReleaseNotes.fromJson({'notes': 'no version'}), isNull); + expect(ReleaseNotes.fromJson('junk'), isNull); + }); + }); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0db3aa7..bba577f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2540,8 +2540,8 @@ company `foxyz`, product `Starsync`; the window class is One implementation, `packages/core/lib/src/updater.dart` (pure Dart, tested in `packages/core/test/updater_test.dart`), with two front ends: the app and the dedicated server. Both binaries ship in the same archive, so either one -updates the whole install folder. Reads a manifest (`version`, `protocol`, `url`, optional `sha256`, `notes`) -from `updateManifestUrl`, compares versions, downloads the tarball, checks +updates the whole install folder. Reads a manifest (`version`, `protocol`, `url`, optional `sha256`, `notes`, +optional `changes`) from `updateManifestUrl`, compares versions, downloads the tarball, checks sha256 when present, then extracts into a staging dir inside the install folder (same filesystem, so moves are renames; the folder must be user-writable). Each top-level entry is moved aside and the new one moved in, @@ -2582,9 +2582,11 @@ does the same from the app binary. In the app, `UpdateController` (`apps/desktop/lib/src/update/`) runs the same code for the update bar (`UpdateBar`, `screens/home/update_indicator.dart`): a band across the window under the strip while an update is available: "Starsync X.Y.Z is -ready", the notes, Later (hidden until the next check or start) and -"Update and restart" (download, apply, then the app close above with Hive -closed and the new binary started), its progress in the band. A release +ready", the first three changes (each with its kind's icon; the notes +when the manifest has no `changes`), What's new, Later (hidden until the +next check or start) and "Update and restart" (download, apply, then the +app close above with Hive closed and the new binary started), its +progress in the band. A release that is behind also says which version it runs; a copy that is not a packaged install says to replace the folder by hand. The shell keeps the band off the full player while a video is on (not finished); it shows on @@ -2599,6 +2601,27 @@ prerelease (`tool/dev_release.sh`), and take the newer of the two (`fetchLatestManifest`); the release X.Y.Z outranks its test builds, so testers land back on stable. `0.0.0-dev` (a dev run) checks nothing. +**What's new.** The manifest's `notes` is one line (all 1.2.x and 1.3.x +read; they ignore other keys) and `changes` (`{"added": [...], "changed": +[...], "fixed": [...]}`, `ReleaseChanges`, read leniently: anything +malformed is no `changes`) the version's lists; both come from +`CHANGELOG.md` ([Changelog](DEVELOPMENT.md#changelog)). The card +(`WhatsNewCard`, `widgets/whats_new_card.dart`) shows them as Added / +Changed / Fixed sections, each with its icon and colour, on `AppGlass` in +a popover, the rows easing in one after another, the list scrolling in a +short window; notes alone show as one paragraph. Three ways open it: the +band's What's new (the offered version, with Update and restart), the +first start of a new version, and Settings → About ("What's new in X.Y.Z", +once this version's notes are known). The first start (`WhatsNew`, +`update/whats_new.dart`): before restarting, the update keeps the new +version's notes in settings (`UpdateController.beforeRestart`); an update +made otherwise (1.3.x's updater, `--upgrade`, by hand) gets them from the +first check whose manifest is this very version (`UpdateController.latest`). +The card shows once per version (`whatsNewSeen` in settings), never on a +fresh install (the settings box was empty), and not over the full player +while a video is on (it waits until the video ends or the player +leaves full). + ## Decisions - Desktop + mpv only, no web clients: exists because Jellyfin SyncPlay is bad. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 72c6b7a..745b830 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -254,17 +254,22 @@ Traps: User pushes tags; CI does the rest. 1. `tool/release.sh [patch|minor|major] [--dry-run]`: derives the next - version from the latest tag, bumps the three pubspecs in lockstep, writes - `updates/manifest.json` (per-platform `assets` URLs are predictable; `url` - stays the Linux tarball for builds <= 0.2.1), commits `Release vX.Y.Z`, - makes an annotated tag. + version from the latest tag, refuses when the [changelog](#changelog)'s + Unreleased has no items (warns when it has no summary) and prints it, + renames Unreleased to `X.Y.Z` under a fresh empty Unreleased, bumps the + three pubspecs in lockstep, writes `updates/manifest.json` (per-platform + `assets` URLs are predictable; `url` stays the Linux tarball for builds + <= 0.2.1; `notes` is the section's summary, `changes` its lists), commits + `Release vX.Y.Z` (with `CHANGELOG.md`), makes an annotated tag. + `--dry-run` stops after printing. 2. Review, then `git push --follow-tags`. 3. `.gitea/workflows/release.yml` runs one job per platform: Linux builds with `tool/package_linux.sh`, Windows (runner `yuuka-windows`, host mode, PowerShell only; `tool/setup_flutter_windows.ps1` also adds Visual Studio's C++ ATL component once, which the notifications plugin needs) with `tool/package_windows.ps1`. Each then runs `dart tool/release_publish.dart vX.Y.Z `, which creates the - release if the other job hasn't yet and uploads (or replaces) its asset. + release if the other job hasn't yet (its text: the version's changelog + section) and uploads (or replaces) its asset. By hand: package, then the same `dart tool/release_publish.dart` line. Test builds (Linux, no tag, no CI): `tool/dev_release.sh [--minor|--major] @@ -274,7 +279,8 @@ repository read/write) builds the working tree here as `-dev.N` (N one past the published one; `` the pubspec's next patch, or next minor / major when asked, and once such a line is published later runs follow it until a release moves the pubspec) and replaces the assets of the -`dev` prerelease with the tarball and its `manifest.json`. Dev builds +`dev` prerelease with the tarball and its `manifest.json` (`changes`: +the changelog's Unreleased, which also becomes the prerelease's text). Dev builds update from it ([Updater](ARCHITECTURE.md#updater)). A stable install moves onto it once with the command the script prints (run with the app open: it finds the install from the running process and copies the new @@ -285,6 +291,23 @@ Tokens come from the environment (`RELEASE_TOKEN`, or Actions-provided updater skips the check when absent. Workflows live in `.gitea/workflows` (the forge reads `.gitea` before `.github`). +### Changelog + +`CHANGELOG.md` is what people read: the update band's first items, the +What's new card ([Updater](ARCHITECTURE.md#updater)) and the release page. +A user-facing change adds a line to its `## Unreleased` section in the same +commit, under `### Added`, `### Changed` or `### Fixed` (no other +headings), as a `- ` item in plain words for the people watching, not +commit-speak ("Chat pop-ups stay away while the chat panel is open", not +"Notifier: suppress toasts when panel open"). Internal changes (tests, +refactors, docs, CI) get no line. The paragraph under the version heading +is an optional one-line summary: it becomes the manifest's `notes`, the +only thing 1.2.x/1.3.x apps show (without it, the first items); refresh it +before cutting. Versions carry no date (the tag and release page have it). +`tool/changelog.dart` reads and cuts the file (`cut X.Y.Z`, `json +`, `markdown `); its tests are +`tool/test/changelog_test.dart` (`cd tool && dart test`). + ## Debugging your server The app's own server runs on its own isolate (`ServerIsolate`, diff --git a/packages/core/lib/src/updater.dart b/packages/core/lib/src/updater.dart index 639fb80..3659ebb 100644 --- a/packages/core/lib/src/updater.dart +++ b/packages/core/lib/src/updater.dart @@ -78,18 +78,50 @@ const _stagingDir = '.update-staging'; const _backupDir = '.update-backup'; const _downloadFile = '.update-download'; +/// What a release changed, in the words of CHANGELOG.md: the manifest's +/// `changes` ({"added": [...], "changed": [...], "fixed": [...]}). +/// Manifests from before it have only `notes`. +class ReleaseChanges { + final List added; + final List changed; + final List fixed; + const ReleaseChanges({this.added = const [], this.changed = const [], this.fixed = const []}); + + /// Null for anything that is not a map, or one with no items; entries + /// that are not strings (or blank) are dropped, unknown keys ignored. + static ReleaseChanges? fromJson(Object? json) { + if (json is! Map) return null; + List list(String key) => List.unmodifiable([ + if (json[key] case final List l) + for (final item in l) + if (item is String && item.trim().isNotEmpty) item.trim(), + ]); + final c = ReleaseChanges(added: list('added'), changed: list('changed'), fixed: list('fixed')); + return c.isEmpty ? null : c; + } + + Map toJson() => {'added': added, 'changed': changed, 'fixed': fixed}; + + bool get isEmpty => added.isEmpty && changed.isEmpty && fixed.isEmpty; + int get length => added.length + changed.length + fixed.length; +} + class UpdateManifest { final String version; final int protocol; final String url; final String? sha256; + /// One line for every app (1.2.x and 1.3.x show only this). final String notes; + /// Null in manifests from before the changelog (show [notes] then). + final ReleaseChanges? changes; const UpdateManifest( {required this.version, required this.protocol, required this.url, this.sha256, - required this.notes}); + required this.notes, + this.changes}); // `assets` maps platform → archive URL. Top-level `url` is the Linux // tarball, kept for builds from before `assets` existed (<= 0.2.1). @@ -115,7 +147,8 @@ class UpdateManifest { protocol: protocol, url: url, sha256: sha is String && sha.isNotEmpty ? sha : null, - notes: (json['notes'] as String?) ?? ''); + notes: json['notes'] is String ? json['notes'] as String : '', + changes: ReleaseChanges.fromJson(json['changes'])); } // Newer wire protocol: this build can't join servers running it. diff --git a/packages/core/test/updater_test.dart b/packages/core/test/updater_test.dart index 324048a..88b5f46 100644 --- a/packages/core/test/updater_test.dart +++ b/packages/core/test/updater_test.dart @@ -190,6 +190,47 @@ void main() { isTrue); }); + test('manifest changes: structured lists, notes kept, old manifests without', () { + final base = { + 'version': '1.3.3', + 'protocol': RelayConstants.protoVersion, + 'url': 'https://x/y.tar.gz', + 'notes': 'One line for older apps.', + }; + final old = UpdateManifest.fromJson(base); + expect(old.changes, isNull); + expect(old.notes, 'One line for older apps.'); + + final m = UpdateManifest.fromJson({ + ...base, + 'changes': { + 'added': ['Danmaku style notifications', ' ', 7], + 'fixed': ['Various bug fixes'], + 'later': ['unknown kinds are ignored'], + }, + }); + expect(m.notes, 'One line for older apps.'); + expect(m.changes!.added, ['Danmaku style notifications']); + expect(m.changes!.changed, isEmpty); + expect(m.changes!.fixed, ['Various bug fixes']); + expect(m.changes!.length, 2); + expect(ReleaseChanges.fromJson(m.changes!.toJson())!.toJson(), m.changes!.toJson()); + + // Junk or empty `changes` (and a non-string `notes`) do not break the manifest. + for (final junk in [null, 'text', [], {}, {'added': 'no list'}]) { + expect(UpdateManifest.fromJson({...base, 'changes': junk}).changes, isNull); + } + expect(UpdateManifest.fromJson({...base, 'notes': 3}).notes, ''); + }); + + test('the repo manifest parses, and keeps a one-line string notes for 1.2.x/1.3.x', () { + final json = jsonDecode(File('../../updates/manifest.json').readAsStringSync()) as Map; + // Older apps read `(json['notes'] as String?)`: it must stay a string. + expect(json['notes'], isA()); + expect((json['notes'] as String).contains('\n'), isFalse); + expect(UpdateManifest.fromJson(json, platform: 'linux-x64').version, json['version']); + }); + test('manifest picks the asset for this platform', () { final json = { 'version': '0.2.2', diff --git a/tool/changelog.dart b/tool/changelog.dart new file mode 100644 index 0000000..154ba9d --- /dev/null +++ b/tool/changelog.dart @@ -0,0 +1,199 @@ +// CHANGELOG.md, read and cut for releases (docs/DEVELOPMENT.md, +// "Changelog"). The file: `## Unreleased`, then `## X.Y.Z` sections newest +// first; each has an optional one-paragraph summary and `### Added` / +// `### Changed` / `### Fixed` lists of `- ` items. +// Usage: dart tool/changelog.dart cut X.Y.Z +// (tool/release.sh: Unreleased becomes X.Y.Z, a fresh empty +// Unreleased on top; fails when Unreleased has no items) +// dart tool/changelog.dart json +// (the manifest's {"notes": "...", "changes": {...}}) +// dart tool/changelog.dart markdown [X.Y.Z] +// (the release page's body; the last argument names the version +// when it is not the section's own) +// dart: imports only — runs without `pub get` (tool/release_publish.dart +// imports it). +import 'dart:convert'; +import 'dart:io'; + +const unreleased = 'Unreleased'; +const kinds = ['Added', 'Changed', 'Fixed']; + +/// The empty section [cutRelease] puts on top. +const freshUnreleased = '## $unreleased\n\n### Added\n\n### Changed\n\n### Fixed\n'; + +class ChangelogSection { + /// `Unreleased` or a version. + final String title; + /// The paragraph under the heading, one line ('' when there is none). + final String summary; + /// [kinds] name -> items, in file order. + final Map> items; + const ChangelogSection(this.title, this.summary, this.items); + + List of(String kind) => items[kind] ?? const []; + bool get isEmpty => kinds.every((k) => of(k).isEmpty); + + /// One line for apps that read only `notes` (1.2.x, 1.3.x): the summary, + /// else the first items. + String get notes { + if (summary.isNotEmpty) return summary; + final all = [for (final k in kinds) ...of(k)]; + final head = all.take(3).join('; '); + return all.length > 3 ? '$head; and more' : head; + } + + Map toManifest() => { + 'notes': notes, + 'changes': {for (final k in kinds) k.toLowerCase(): of(k)}, + }; + + /// The section as written in CHANGELOG.md under [heading]. + String render([String? heading]) { + final b = StringBuffer('## ${heading ?? title}\n'); + if (summary.isNotEmpty) b.write('\n$summary\n'); + for (final k in kinds) { + if (of(k).isEmpty) continue; + b.write('\n### $k\n\n'); + for (final item in of(k)) { + b.write('- $item\n'); + } + } + return b.toString(); + } + + /// The release page's body. + String releaseBody(String version) { + final b = StringBuffer('## Starsync $version\n'); + if (summary.isNotEmpty) b.write('\n$summary\n'); + for (final k in kinds) { + if (of(k).isEmpty) continue; + b.write('\n### $k\n\n'); + for (final item in of(k)) { + b.write('- $item\n'); + } + } + b.write('\nIn the app: Update and restart in the band at the top, or Settings → Updates → Check now. ' + 'New here: download the archive for your system below.\n'); + return b.toString(); + } +} + +class Changelog { + /// Everything before the first `## ` heading. + final String preamble; + final List sections; + const Changelog(this.preamble, this.sections); + + /// [title] `unreleased` (any case) or a version; null when missing. + ChangelogSection? section(String title) { + final want = title.toLowerCase(); + for (final s in sections) { + if (s.title.toLowerCase() == want) return s; + } + return null; + } + + static Changelog parse(String text) { + final lines = const LineSplitter().convert(text); + final pre = StringBuffer(); + final sections = []; + String? title; + var summary = []; + var items = >{}; + String? kind; + void close() { + if (title != null) sections.add(ChangelogSection(title, summary.join(' ').trim(), items)); + } + + for (final (n, raw) in lines.indexed) { + final line = raw.trimRight(); + FormatException bad(String why) => FormatException('CHANGELOG.md line ${n + 1}: $why: "$line"'); + if (line.startsWith('## ')) { + close(); + title = line.substring(3).trim(); + if (title.toLowerCase() == unreleased.toLowerCase()) title = unreleased; + summary = []; + items = {}; + kind = null; + } else if (title == null) { + pre.writeln(raw); + } else if (line.startsWith('### ')) { + final name = line.substring(4).trim(); + kind = kinds.where((k) => k.toLowerCase() == name.toLowerCase()).firstOrNull ?? + (throw bad('a section is ${kinds.join(' / ')}')); + if (items.containsKey(kind)) throw bad('$kind twice'); + items[kind] = []; + } else if (RegExp(r'^[-+*] ').hasMatch(line)) { + if (kind == null) throw bad('an item needs ${kinds.join(' / ')} above it'); + items[kind]!.add(line.substring(2).trim()); + } else if (line.isEmpty) { + continue; + } else if (kind != null && raw.startsWith(' ') && items[kind]!.isNotEmpty) { + final list = items[kind]!; + list[list.length - 1] = '${list.last} ${line.trim()}'; + } else if (kind == null) { + summary.add(line.trim()); + } else { + throw bad('expected a "- " item'); + } + } + close(); + return Changelog(pre.toString(), sections); + } + + /// [version] takes Unreleased's items; a fresh, empty Unreleased goes on + /// top. Throws when Unreleased is missing or empty or [version] is there. + String cutRelease(String version) { + final next = section(unreleased); + if (next == null) throw StateError('CHANGELOG.md has no "## $unreleased" section'); + if (next.isEmpty) { + throw StateError('CHANGELOG.md: "## $unreleased" has no items — ' + 'add what changed for people before cutting $version'); + } + if (section(version) != null) throw StateError('CHANGELOG.md already has "## $version"'); + final b = StringBuffer(preamble.trimRight())..write('\n\n$freshUnreleased'); + for (final s in sections) { + b.write('\n${s.title == unreleased ? s.render(version) : s.render()}'); + } + return b.toString(); + } +} + +Future main(List args) async { + final markdown = args.length == 3 && args[0] == 'markdown'; + if ((args.length != 2 && !markdown) || !const ['cut', 'json', 'markdown'].contains(args[0])) { + _die('usage: dart tool/changelog.dart cut X.Y.Z\n' + ' dart tool/changelog.dart json \n' + ' dart tool/changelog.dart markdown [X.Y.Z]'); + } + final root = File.fromUri(Platform.script).parent.parent.path; + final file = File('$root/CHANGELOG.md'); + if (!file.existsSync()) _die('missing ${file.path}'); + final Changelog log; + try { + log = Changelog.parse(file.readAsStringSync()); + } on FormatException catch (e) { + _die(e.message); + } + final [cmd, which, ...rest] = args; + switch (cmd) { + case 'cut': + if (!RegExp(r'^\d+\.\d+\.\d+$').hasMatch(which)) _die('version must look like 1.3.3, got $which'); + try { + file.writeAsStringSync(log.cutRelease(which)); + } on StateError catch (e) { + _die(e.message); + } + stdout.writeln('CHANGELOG.md: $unreleased is now $which'); + case 'json': + stdout.writeln(jsonEncode((log.section(which) ?? _die('CHANGELOG.md has no "## $which"')).toManifest())); + case 'markdown': + final s = log.section(which) ?? _die('CHANGELOG.md has no "## $which"'); + stdout.write(s.releaseBody(rest.firstOrNull ?? which)); + } +} + +Never _die(String msg) { + stderr.writeln('error: $msg'); + exit(1); +} diff --git a/tool/dev_release.sh b/tool/dev_release.sh index ab7cfe9..286d755 100755 --- a/tool/dev_release.sh +++ b/tool/dev_release.sh @@ -10,6 +10,8 @@ # following it (1.2.0-dev.2, ...) until the release moves the pubspec. # Token: RELEASE_TOKEN, else the file ~/.config/watch-together-release-token # (a Gitea access token with repository read/write; keep the file 0600). +# The manifest's `changes` (What's new) and the dev release's page are +# CHANGELOG.md's Unreleased. # Prints the one-time command that moves a stable install onto test builds. set -euo pipefail @@ -66,14 +68,20 @@ MANIFEST_PROTO="$(sed -n 's/.*protoMax = \([0-9]*\).*/\1/p' "$ROOT/packages/core MANIFEST_URL="$BASE_URL/$(basename "$TARBALL")" \ MANIFEST_SHA="$(sha256sum "$TARBALL" | cut -d' ' -f1)" \ MANIFEST_NOTES="Test build: $NOTES" \ +MANIFEST_CHANGES="$(dart "$ROOT/tool/changelog.dart" json unreleased 2>/dev/null || echo '{}')" \ python3 -I - "$MANIFEST" <<'EOF' import json, os, sys e = os.environ url = e['MANIFEST_URL'] +manifest = {'version': e['MANIFEST_VER'], 'protocol': int(e['MANIFEST_PROTO']), + 'url': url, 'sha256': e['MANIFEST_SHA'], 'notes': e['MANIFEST_NOTES'], + 'assets': {'linux-x64': url}} +# What's new in a test build: CHANGELOG.md's Unreleased, when it has items. +changes = json.loads(e['MANIFEST_CHANGES']).get('changes') or {} +if any(changes.values()): + manifest['changes'] = changes with open(sys.argv[1], 'w') as f: - json.dump({'version': e['MANIFEST_VER'], 'protocol': int(e['MANIFEST_PROTO']), - 'url': url, 'sha256': e['MANIFEST_SHA'], 'notes': e['MANIFEST_NOTES'], - 'assets': {'linux-x64': url}}, f, indent=2) + json.dump(manifest, f, indent=2, ensure_ascii=False) EOF if [[ "$PUBLISH" != "1" ]]; then diff --git a/tool/pubspec.lock b/tool/pubspec.lock index 36ca436..bb220da 100644 --- a/tool/pubspec.lock +++ b/tool/pubspec.lock @@ -1,6 +1,30 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "48e48f73a431eb269eec66d243a63a07ed90ef9417a74047a3b98d52338626ad" + url: "https://pub.dev" + source: hosted + version: "109.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "6d377901d198536b648172974d9d4f277910502cad92193466869cac0ff34990" + url: "https://pub.dev" + source: hosted + version: "14.5.0" + archive: + dependency: transitive + description: + name: archive + sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe" + url: "https://pub.dev" + source: hosted + version: "4.5.2" args: dependency: "direct main" description: @@ -17,6 +41,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: "89b8801ba49f8b593fbf2d3e59efd0f566c46cd4ba6837ae2610c72b52a0f899" + url: "https://pub.dev" + source: hosted + version: "0.6.0" collection: dependency: transitive description: @@ -33,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: ff89e37c19355194e279d26dafe8e4bece46c9ea63a30be4dc68214f5bc290d3 + url: "https://pub.dev" + source: hosted + version: "1.16.0" crypto: dependency: transitive description: @@ -41,6 +97,46 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "404dbf9bce29beafbe9dcb19219447b6f7d49900cd42b8d3b7af102b24508e25" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: @@ -49,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" + url: "https://pub.dev" + source: hosted + version: "1.1.0" lints: dependency: "direct dev" description: @@ -65,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" meta: dependency: transitive description: @@ -81,6 +193,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" path: dependency: transitive description: @@ -89,6 +217,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" relic: dependency: "direct main" description: @@ -113,6 +265,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e75e9cf311d7fd712048038cc17c25199c38536c20330d53e7db058b543d5a48 + url: "https://pub.dev" + source: hosted + version: "1.4.3" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: "480504af9e5323899c5be26a4f6c2fc4437d50954d39842f0290f4ae733d57b4" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898" + url: "https://pub.dev" + source: hosted + version: "0.10.14" source_span: dependency: transitive description: @@ -151,14 +351,14 @@ packages: path: "../packages/core" relative: true source: path - version: "0.1.0" + version: "1.3.2" sync_relay_server: dependency: "direct main" description: path: "../packages/server" relative: true source: path - version: "0.1.0" + version: "1.3.2" term_glyph: dependency: transitive description: @@ -167,6 +367,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "44b2fccf4407bcdb5e105c064c5d6b5e2a640c87761f8b03cdce8cf532d98cfa" + url: "https://pub.dev" + source: hosted + version: "1.33.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "47481c58bb92d0b1d218f0510ff5647e768f8e1256d07077a151fb520923b51d" + url: "https://pub.dev" + source: hosted + version: "0.7.15" + test_core: + dependency: transitive + description: + name: test_core + sha256: "9dec4c178bd61493e1cd2060ffe701fcb61c43da2493e99590fb1b0cc82eb4af" + url: "https://pub.dev" + source: hosted + version: "0.6.21" typed_data: dependency: transitive description: @@ -183,6 +407,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" web: dependency: transitive description: @@ -207,6 +439,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" yaml: dependency: transitive description: @@ -216,4 +456,4 @@ packages: source: hosted version: "3.1.4" sdks: - dart: ">=3.8.0 <4.0.0" + dart: ">=3.11.0 <4.0.0" diff --git a/tool/pubspec.yaml b/tool/pubspec.yaml index c053833..7098b3d 100644 --- a/tool/pubspec.yaml +++ b/tool/pubspec.yaml @@ -13,3 +13,4 @@ dependencies: web_socket_channel: ^3.0.0 dev_dependencies: lints: ^5.0.0 + test: ^1.25.0 diff --git a/tool/release.sh b/tool/release.sh index 0c3dafe..09b68bf 100755 --- a/tool/release.sh +++ b/tool/release.sh @@ -1,8 +1,10 @@ #!/usr/bin/env bash # Cut a release without typing a version anywhere: derives the next -# version (patch by default), bumps all three pubspecs in lockstep, -# writes updates/manifest.json (predictable asset URL — no CI roundtrip -# needed), commits, and tags. Review, then `git push --follow-tags` — +# version (patch by default), turns CHANGELOG.md's Unreleased into that +# version (refuses when it has no items), bumps all three pubspecs in +# lockstep, writes updates/manifest.json (predictable asset URL — no CI +# roundtrip needed; `notes` and `changes` from the changelog), commits, +# and tags. Review, then `git push --follow-tags` — # CI takes it from there (build, Forgejo release, asset upload). # Usage: tool/release.sh [patch|minor|major] [--dry-run] set -euo pipefail @@ -41,9 +43,25 @@ if git -C "$ROOT" rev-parse "v$VER" >/dev/null 2>&1; then fi echo "release will be v$VER ($WHY)" +# What people read (the update band, What's new, the release page) is +# CHANGELOG.md's Unreleased: no items, no release. +CHANGES="$(dart "$ROOT/tool/changelog.dart" json unreleased)" +COUNT="$(python3 -I -c 'import json,sys; print(sum(map(len, json.loads(sys.argv[1])["changes"].values())))' "$CHANGES")" +if [[ "$COUNT" == "0" ]]; then + echo "error: CHANGELOG.md's Unreleased has no items — write what changed for people first" >&2 + exit 1 +fi +if [[ -z "$(sed -n '/^## Unreleased/,/^### /{/^#/d;p}' "$ROOT/CHANGELOG.md" | tr -d '[:space:]')" ]]; then + echo "warning: Unreleased has no summary line; older apps (which show only notes) get its first items" >&2 +fi +echo "changelog for v$VER:" +dart "$ROOT/tool/changelog.dart" markdown unreleased "$VER" | sed 's/^/ /' + if [[ "$DRY" == "1" ]]; then exit 0 fi +dart "$ROOT/tool/changelog.dart" cut "$VER" +CHANGES="$(dart "$ROOT/tool/changelog.dart" json "$VER")" for f in $PUBSPECS; do sed -i -E "s/^version: .*/version: $VER/" "$ROOT/$f" done @@ -52,15 +70,10 @@ ORIGIN="$(git -C "$ROOT" remote get-url origin)" SERVER="$(echo "$ORIGIN" | sed -E 's#(https?://[^/]+)/.*#\1#')" REPO="$(echo "${ORIGIN%.git}" | sed -E 's#.*[:/]([^/]+/[^/]+)$#\1#')" PROTO="$(sed -n 's/.*protoMax = \([0-9]*\).*/\1/p' "$ROOT/packages/core/lib/src/constants.dart")" -if [[ -n "$LATEST" && "$LATEST" != "v$VER" ]]; then - NOTES="$(git -C "$ROOT" log -n 20 "$LATEST..HEAD" --format='- %s')" -else - NOTES="Starsync $VER." -fi export MANIFEST_FILE="$ROOT/updates/manifest.json" MANIFEST_VER="$VER" \ - MANIFEST_PROTO="$PROTO" MANIFEST_NOTES="$NOTES" \ + MANIFEST_PROTO="$PROTO" MANIFEST_CHANGES="$CHANGES" \ MANIFEST_BASE="$SERVER/$REPO/releases/download/v$VER/starsync-$VER" -python3 - <<'EOF' +python3 -I - <<'EOF' import json, os path = os.environ['MANIFEST_FILE'] try: @@ -69,6 +82,7 @@ try: except (FileNotFoundError, ValueError): manifest = {} base = os.environ['MANIFEST_BASE'] +log = json.loads(os.environ['MANIFEST_CHANGES']) # Asset names must match tool/package_linux.sh / package_windows.ps1. # `url` stays the Linux tarball for builds that predate `assets` (<= 0.2.1). assets = { @@ -80,19 +94,22 @@ manifest.update({ 'protocol': int(os.environ['MANIFEST_PROTO']), 'url': assets['linux-x64'], 'assets': assets, - 'notes': os.environ['MANIFEST_NOTES'].splitlines()[0] if os.environ['MANIFEST_NOTES'] else '', + # One line: 1.2.x and 1.3.x apps show only this. + 'notes': ' '.join(log['notes'].split()) or 'Starsync ' + os.environ['MANIFEST_VER'], + # {"added": [...], "changed": [...], "fixed": [...]}: What's new. + 'changes': log['changes'], }) manifest.pop('sha256', None) with open(path, 'w') as f: - json.dump(manifest, f, indent=2) + json.dump(manifest, f, indent=2, ensure_ascii=False) f.write('\n') EOF -git -C "$ROOT" add $PUBSPECS updates/manifest.json +git -C "$ROOT" add $PUBSPECS updates/manifest.json CHANGELOG.md if git -C "$ROOT" diff --cached --quiet; then echo "everything already at $VER — tagging only" else - git -C "$ROOT" commit -m "Release v$VER" -- $PUBSPECS updates/manifest.json + git -C "$ROOT" commit -m "Release v$VER" -- $PUBSPECS updates/manifest.json CHANGELOG.md fi git -C "$ROOT" tag -a -m "Release v$VER" "v$VER" echo "review with: git show v$VER — then: git push --follow-tags" diff --git a/tool/release_publish.dart b/tool/release_publish.dart index 57eac2d..d1a92a6 100644 --- a/tool/release_publish.dart +++ b/tool/release_publish.dart @@ -3,7 +3,8 @@ // The Linux and Windows CI jobs both run this, possibly at the same time, // so a lost create race falls back to the release the other job made. // The manifest is written by tool/release.sh at release time (predictable -// asset URLs), so this never touches git history beyond reading notes. +// asset URLs). The release page's text is the version's CHANGELOG.md +// section (tool/changelog.dart). // Usage: dart tool/release_publish.dart vX.Y.Z // (run after tool/package_linux.sh or tool/package_windows.ps1) // dart tool/release_publish.dart dev @@ -13,6 +14,8 @@ import 'dart:convert'; import 'dart:io'; +import 'changelog.dart'; + Future main(List args) async { final dev = args.isNotEmpty && args[0] == 'dev'; if (args.length != (dev ? 3 : 2)) { @@ -42,17 +45,7 @@ Future main(List args) async { final token = _token(); final api = _apiBase(root); - final prev = _git(root, ['describe', '--tags', '--abbrev=0', '$tag^'], - allowFail: true); - final notes = prev.isEmpty - ? 'Starsync $ver.' - : _git(root, [ - 'log', - '$prev..$tag', - '--format=- %s', - '--invert-grep', - '--grep=^Release v' - ]).split('\n').take(20).join('\n'); + final body = _changelog(root)?.section(ver)?.releaseBody(ver) ?? 'Starsync $ver.'; final http = HttpClient(); try { @@ -63,7 +56,7 @@ Future main(List args) async { rel = await c.post('$api/releases', { 'tag_name': tag, 'name': tag, - 'body': 'Starsync $ver\n\n$notes', + 'body': body, 'draft': false, 'prerelease': false, }); @@ -130,6 +123,12 @@ Future _publishDev(String root, List files) async { }) ?? _die('could not create or find release dev'); final id = rel['id']; + // The page says what the build has: Unreleased. + final ver = jsonDecode(files.last.readAsStringSync())['version']; + final next = _changelog(root)?.section(unreleased); + if (next != null && !next.isEmpty) { + await c.patch('$api/releases/$id', {'body': 'Latest test build; dev builds update from here.\n\n${next.releaseBody('$ver')}'}); + } final old = [for (final a in (rel['assets'] as List? ?? const [])) if (a is Map) a]; Future upload(File f) { final name = f.uri.pathSegments.last; @@ -153,6 +152,16 @@ Future _publishDev(String root, List files) async { } } +Changelog? _changelog(String root) { + final f = File('$root/CHANGELOG.md'); + try { + return Changelog.parse(f.readAsStringSync()); + } on Object catch (e) { + stderr.writeln('warning: CHANGELOG.md not used: $e'); + return null; + } +} + Never _die(String msg) { stderr.writeln('error: $msg'); exit(1); @@ -203,6 +212,12 @@ class _Api { return jsonDecode(text) as Map; } + Future patch(String url, Object json) async { + final (code, text) = await _send('PATCH', url, + body: utf8.encode(jsonEncode(json)), type: 'application/json'); + _check('PATCH', url, code, text); + } + Future delete(String url) async { final (code, text) = await _send('DELETE', url); _check('DELETE', url, code, text); diff --git a/tool/test/changelog_test.dart b/tool/test/changelog_test.dart new file mode 100644 index 0000000..f2a79f9 --- /dev/null +++ b/tool/test/changelog_test.dart @@ -0,0 +1,85 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +import '../changelog.dart'; + +const _sample = '''# Changelog + +Intro text. + +## Unreleased + +Summary of the next one, +over two lines. + +### Added + +- Danmaku style notifications ++ Bilibili style chat box + that wraps + +### Fixed + +- Optimized relay + +## 1.0.0 + +### Fixed + +- Various bug fixes +'''; + +void main() { + test('parses sections, summaries, items and wrapped items', () { + final log = Changelog.parse(_sample); + expect(log.sections.map((s) => s.title), ['Unreleased', '1.0.0']); + final next = log.section('unreleased')!; + expect(next.summary, 'Summary of the next one, over two lines.'); + expect(next.of('Added'), ['Danmaku style notifications', 'Bilibili style chat box that wraps']); + expect(next.of('Changed'), isEmpty); + expect(next.of('Fixed'), ['Optimized relay']); + expect(next.toManifest(), { + 'notes': 'Summary of the next one, over two lines.', + 'changes': { + 'added': ['Danmaku style notifications', 'Bilibili style chat box that wraps'], + 'changed': [], + 'fixed': ['Optimized relay'], + }, + }); + // No summary: the first items. + expect(log.section('1.0.0')!.notes, 'Various bug fixes'); + }); + + test('cut: Unreleased becomes the version, a fresh empty one on top', () { + final cut = Changelog.parse(_sample).cutRelease('1.1.0'); + final log = Changelog.parse(cut); + expect(log.sections.map((s) => s.title), ['Unreleased', '1.1.0', '1.0.0']); + expect(log.section('Unreleased')!.isEmpty, isTrue); + expect(log.section('1.1.0')!.of('Added'), hasLength(2)); + expect(log.preamble, contains('Intro text.')); + expect(cut, contains(freshUnreleased)); + // Nothing new: no release. + expect(() => log.cutRelease('1.2.0'), throwsStateError); + expect(() => Changelog.parse(_sample).cutRelease('1.0.0'), throwsStateError); + }); + + test('rejects unknown kinds and stray text', () { + expect(() => Changelog.parse('## 1.0.0\n\n### Removed\n\n- x\n'), throwsFormatException); + expect(() => Changelog.parse('## 1.0.0\n\n- no kind\n'), throwsFormatException); + expect(() => Changelog.parse('## 1.0.0\n\n### Fixed\n\nprose\n'), throwsFormatException); + }); + + test('release body: heading, summary, lists', () { + final body = Changelog.parse(_sample).section('unreleased')!.releaseBody('1.1.0'); + expect(body, startsWith('## Starsync 1.1.0\n\nSummary of the next one')); + expect(body, contains('### Added\n\n- Danmaku style notifications\n')); + expect(body, isNot(contains('### Changed'))); + }); + + test("the repo's CHANGELOG.md parses and has an Unreleased section", () { + final log = Changelog.parse(File('../CHANGELOG.md').readAsStringSync()); + expect(log.sections.first.title, unreleased); + expect(log.section('1.3.0')!.of('Added'), isNotEmpty); + }); +}