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:
1 parent
ec0849eb01
commit
638a30e16c
22 files changed
+1637
-58
No files matched your search
@@ -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
|
||||
@@ -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}) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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']
|
||||
with open(sys.argv[1], 'w') as f:
|
||||
json.dump({'version': e['MANIFEST_VER'], 'protocol': int(e['MANIFEST_PROTO']),
|
||||
manifest = {'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)
|
||||
'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(manifest, f, indent=2, ensure_ascii=False)
|
||||
EOF
|
||||
|
||||
if [[ "$PUBLISH" != "1" ]]; then
|
||||
|
||||
+243
-3
@@ -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"
|
||||
@@ -13,3 +13,4 @@ dependencies:
|
||||
web_socket_channel: ^3.0.0
|
||||
dev_dependencies:
|
||||
lints: ^5.0.0
|
||||
test: ^1.25.0
|
||||
+31
-14
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user