Changelog and What's new: CHANGELOG.md cut into each release, shown in the update band, a What's new card and once after updating

CHANGELOG.md keeps an Unreleased section (user-facing changes add a line in
the same commit); tool/release.sh cuts it into the version, the manifest
gets a structured "changes" field next to the one-line "notes" older
apps read, and the Gitea release text is that section. The app's update
band shows the top items, What's new opens a card (Added / Changed /
Fixed), and the first start after an update shows it once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-18 02:11:37 +03:00
1 parent ec0849eb01
commit 638a30e16c
22 files changed
+1637 -58

No files matched your search

+79
View File
@@ -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
+5 -3
View File
@@ -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` /
@@ -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: [
@@ -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<void> showUpdateDialog(BuildContext context, {UpdateController? controller}) {
+40
View File
@@ -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<AppShell> 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<AppShell> 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<AppShell> 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<void> _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<AppShell> 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);
}
+24
View File
@@ -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<Map<String, String>>(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<String, Object?>? 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<void> _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<String>());
@@ -328,6 +342,16 @@ class AppSettings {
_put(_keyCustomShaders, [...paths]);
}
Future<void> setWhatsNewSeen(String version) {
whatsNewSeen = version;
return _put(_keyWhatsNewSeen, version);
}
Future<void> setWhatsNewNotes(Map<String, Object?> notes) {
whatsNewNotes = notes;
return _put(_keyWhatsNewNotes, notes);
}
Future<void> _put(String key, Object value) async {
try {
final box = _box ??
@@ -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<void> 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<void> 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<UpdateManifest?>(null);
/// Outcome of the last finished check, for Settings/About wording
/// ("Up to date", "Check failed: ..."); null until one finishes.
final lastCheck = ValueNotifier<String?>(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);
+150
View File
@@ -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<String, Object?> 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<void> setSeen(String version);
/// The notes last known for an installed or current version.
ReleaseNotes? get notes;
Future<void> 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<void> setSeen(String version) => settings.setWhatsNewSeen(version);
@override
ReleaseNotes? get notes => ReleaseNotes.fromJson(settings.whatsNewNotes);
@override
Future<void> 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<void> setSeen(String version) async => seen = version;
@override
Future<void> 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<ReleaseNotes?>(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<void> 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;
}
}
@@ -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<String> 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<void> showWhatsNew(BuildContext context, ReleaseNotes notes, {String? overline, Widget? action}) =>
showPopover<void>(
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<WhatsNewCard> createState() => _WhatsNewCardState();
}
class _WhatsNewCardState extends State<WhatsNewCard> 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 = <Widget>[
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,
);
}
}
+23 -1
View File
@@ -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<void> pumpShell(WidgetTester tester, Size size, {UpdateController? updates, VideoViewBuilder? video}) async {
Future<void> 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 {
+178
View File
@@ -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<UpdateController> 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<Text>(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);
});
});
}
+28 -5
View File
@@ -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.
+29 -6
View File
@@ -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 <archive>`, 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 `<next>-dev.N` (N
one past the published one; `<next>` 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
<version|unreleased>`, `markdown <version|unreleased>`); 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`,
+35 -2
View File
@@ -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<String> added;
final List<String> changed;
final List<String> 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<String> 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<String, Object?> 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.
+41
View File
@@ -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<String, Object?>;
// Older apps read `(json['notes'] as String?)`: it must stay a string.
expect(json['notes'], isA<String>());
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',
+199
View File
@@ -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 <X.Y.Z|unreleased>
// (the manifest's {"notes": "...", "changes": {...}})
// dart tool/changelog.dart markdown <X.Y.Z|unreleased> [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<String, List<String>> items;
const ChangelogSection(this.title, this.summary, this.items);
List<String> 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<String, Object?> 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<ChangelogSection> 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 = <ChangelogSection>[];
String? title;
var summary = <String>[];
var items = <String, List<String>>{};
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<void> main(List<String> 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 <X.Y.Z|unreleased>\n'
' dart tool/changelog.dart markdown <X.Y.Z|unreleased> [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);
}
+11 -3
View File
@@ -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
+243 -3
View File
@@ -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"
+1
View File
@@ -13,3 +13,4 @@ dependencies:
web_socket_channel: ^3.0.0
dev_dependencies:
lints: ^5.0.0
test: ^1.25.0
+31 -14
View File
@@ -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"
+28 -13
View File
@@ -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 <archive>
// (run after tool/package_linux.sh or tool/package_windows.ps1)
// dart tool/release_publish.dart dev <archive> <manifest.json>
@@ -13,6 +14,8 @@
import 'dart:convert';
import 'dart:io';
import 'changelog.dart';
Future<void> main(List<String> args) async {
final dev = args.isNotEmpty && args[0] == 'dev';
if (args.length != (dev ? 3 : 2)) {
@@ -42,17 +45,7 @@ Future<void> main(List<String> 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<void> main(List<String> 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<void> _publishDev(String root, List<File> 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<void> upload(File f) {
final name = f.uri.pathSegments.last;
@@ -153,6 +152,16 @@ Future<void> _publishDev(String root, List<File> 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<String, Object?>;
}
Future<void> 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<void> delete(String url) async {
final (code, text) = await _send('DELETE', url);
_check('DELETE', url, code, text);
+85
View File
@@ -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': <String>[],
'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);
});
}