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
|
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
|
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,
|
(never over a playing video), with its first changes. "What's new" lists
|
||||||
verifies, applies in place and restarts; "Later" hides it until the next
|
them all ([CHANGELOG.md](CHANGELOG.md)); the first start after an update
|
||||||
check; the install folder must be writable by you. The version you run is
|
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:
|
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
|
`./starsync-server --upgrade` (no display needed, so it works on a
|
||||||
server box too), `./starsync --upgrade`, or the shipped `update.sh` /
|
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 '../../app/app_graph.dart';
|
||||||
import '../../state/settings.dart';
|
import '../../state/settings.dart';
|
||||||
import '../../update/update_controller.dart';
|
import '../../update/update_controller.dart';
|
||||||
|
import '../../update/whats_new.dart';
|
||||||
|
import '../../widgets/whats_new_card.dart';
|
||||||
import '../../widgets/app_menu.dart';
|
import '../../widgets/app_menu.dart';
|
||||||
import '../../widgets/copy_open.dart';
|
import '../../widgets/copy_open.dart';
|
||||||
import 'settings_page.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 '
|
const uoscCredit = 'Player timeline and controls inspired by uosc by tomasklaen, LGPL-2.1 '
|
||||||
'(github.com/tomasklaen/uosc).';
|
'(github.com/tomasklaen/uosc).';
|
||||||
|
|
||||||
/// Name, version, update state, the source link and the logs (Settings →
|
/// Name, version, update state, What's new in this version, the source
|
||||||
/// About).
|
/// link and the logs (Settings → About).
|
||||||
class AboutPanel extends StatelessWidget {
|
class AboutPanel extends StatelessWidget {
|
||||||
final UpdateController? updates;
|
final UpdateController? updates;
|
||||||
const AboutPanel({super.key, this.updates});
|
final WhatsNew? whatsNew;
|
||||||
|
const AboutPanel({super.key, this.updates, this.whatsNew});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -36,6 +39,23 @@ class AboutPanel extends StatelessWidget {
|
|||||||
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
UpdateStatusLine(controller: updates),
|
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),
|
const SizedBox(height: kGap),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/update_controller.dart';
|
||||||
|
import '../../update/whats_new.dart';
|
||||||
import '../../widgets/update_banner.dart';
|
import '../../widgets/update_banner.dart';
|
||||||
|
import '../../widgets/whats_new_card.dart';
|
||||||
import 'ui.dart';
|
import 'ui.dart';
|
||||||
|
|
||||||
/// The band across the top of the window while an update is up for
|
/// 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
|
/// grabs: "Starsync 1.2.1 is ready", its first changes (or its one-line
|
||||||
/// Later (gone until the next check or start). The shell keeps it off
|
/// 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]).
|
/// the full player while a video is on ([AppShell]).
|
||||||
class UpdateBar extends StatelessWidget {
|
class UpdateBar extends StatelessWidget {
|
||||||
final UpdateController? controller;
|
final UpdateController? controller;
|
||||||
@@ -31,6 +34,8 @@ class UpdateBar extends StatelessWidget {
|
|||||||
final behind = isDevBuild(current) ? null : 'You are running $current';
|
final behind = isDevBuild(current) ? null : 'You are running $current';
|
||||||
final String title;
|
final String title;
|
||||||
String detail;
|
String detail;
|
||||||
|
// The offered version's first changes instead of its notes.
|
||||||
|
var preview = false;
|
||||||
switch (s.phase) {
|
switch (s.phase) {
|
||||||
case UpdatePhase.downloading:
|
case UpdatePhase.downloading:
|
||||||
title = 'Downloading $appName ${m.version}… ${((s.progress ?? 0) * 100).round()}%';
|
title = 'Downloading $appName ${m.version}… ${((s.progress ?? 0) * 100).round()}%';
|
||||||
@@ -51,9 +56,11 @@ class UpdateBar extends StatelessWidget {
|
|||||||
} else if (m.isRequired) {
|
} else if (m.isRequired) {
|
||||||
title = '$appName ${m.version} is required to join servers on it';
|
title = '$appName ${m.version} is required to join servers on it';
|
||||||
detail = m.notes;
|
detail = m.notes;
|
||||||
|
preview = m.changes != null;
|
||||||
} else {
|
} else {
|
||||||
title = '$appName ${m.version} is ready';
|
title = '$appName ${m.version} is ready';
|
||||||
detail = m.notes;
|
detail = m.notes;
|
||||||
|
preview = m.changes != null;
|
||||||
}
|
}
|
||||||
detail = [?behind, detail].where((t) => t.isNotEmpty).join(' · ');
|
detail = [?behind, detail].where((t) => t.isNotEmpty).join(' · ');
|
||||||
}
|
}
|
||||||
@@ -88,7 +95,9 @@ class UpdateBar extends StatelessWidget {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: fg, fontWeight: FontWeight.w600)),
|
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(
|
Tooltip(
|
||||||
message: detail,
|
message: detail,
|
||||||
waitDuration: const Duration(milliseconds: 600),
|
waitDuration: const Duration(milliseconds: 600),
|
||||||
@@ -102,6 +111,14 @@ class UpdateBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (!busy) ...[
|
if (!busy) ...[
|
||||||
const SizedBox(width: 12),
|
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)
|
if (!m.isRequired)
|
||||||
TextButton(
|
TextButton(
|
||||||
key: const ValueKey('update-later'),
|
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
|
/// The existing update card in a dialog; closes itself when the card
|
||||||
/// would render nothing (e.g. "Later").
|
/// would render nothing (e.g. "Later").
|
||||||
Future<void> showUpdateDialog(BuildContext context, {UpdateController? controller}) {
|
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/health_probe.dart';
|
||||||
import '../screens/home/media_picker.dart';
|
import '../screens/home/media_picker.dart';
|
||||||
import '../session/identity.dart';
|
import '../session/identity.dart';
|
||||||
|
import '../screens/home/ui.dart' show appName;
|
||||||
import '../screens/home/update_indicator.dart';
|
import '../screens/home/update_indicator.dart';
|
||||||
import '../app/app_graph.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../session/navigation.dart' show Place;
|
import '../session/navigation.dart' show Place;
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
import '../update/update_controller.dart';
|
import '../update/update_controller.dart';
|
||||||
|
import '../update/whats_new.dart';
|
||||||
|
import '../widgets/whats_new_card.dart';
|
||||||
import 'chat_panel.dart';
|
import 'chat_panel.dart';
|
||||||
import 'connect_cards.dart';
|
import 'connect_cards.dart';
|
||||||
import 'handoff.dart';
|
import 'handoff.dart';
|
||||||
@@ -52,6 +55,9 @@ import 'welcome.dart';
|
|||||||
class AppShell extends StatefulWidget {
|
class AppShell extends StatefulWidget {
|
||||||
final UpdateController? updates;
|
final UpdateController? updates;
|
||||||
final bool autoUpdateCheck;
|
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
|
/// Off in tests: the drag area, window buttons and fullscreen need
|
||||||
/// window_manager.
|
/// window_manager.
|
||||||
final bool windowControls;
|
final bool windowControls;
|
||||||
@@ -67,6 +73,7 @@ class AppShell extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
this.updates,
|
this.updates,
|
||||||
this.autoUpdateCheck = true,
|
this.autoUpdateCheck = true,
|
||||||
|
this.whatsNew,
|
||||||
this.windowControls = true,
|
this.windowControls = true,
|
||||||
this.healthGet = defaultHealthGet,
|
this.healthGet = defaultHealthGet,
|
||||||
this.jellyfin,
|
this.jellyfin,
|
||||||
@@ -158,6 +165,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
// Reads linked servers and checks their tokens in the background.
|
// Reads linked servers and checks their tokens in the background.
|
||||||
_jellyfin.ensureLoaded();
|
_jellyfin.ensureLoaded();
|
||||||
if (widget.autoUpdateCheck) _autoCheck();
|
if (widget.autoUpdateCheck) _autoCheck();
|
||||||
|
if (widget.whatsNew != null || widget.autoUpdateCheck) unawaited(_startWhatsNew());
|
||||||
HardwareKeyboard.instance.addHandler(_onKey);
|
HardwareKeyboard.instance.addHandler(_onKey);
|
||||||
final nav = _graph.navigation;
|
final nav = _graph.navigation;
|
||||||
final coord = _graph.coordinator;
|
final coord = _graph.coordinator;
|
||||||
@@ -241,6 +249,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
stop();
|
stop();
|
||||||
}
|
}
|
||||||
HardwareKeyboard.instance.removeHandler(_onKey);
|
HardwareKeyboard.instance.removeHandler(_onKey);
|
||||||
|
_whatsNewSource?.due.removeListener(_maybeWhatsNew);
|
||||||
_updateTimer?.cancel();
|
_updateTimer?.cancel();
|
||||||
_fade.dispose();
|
_fade.dispose();
|
||||||
_online.dispose();
|
_online.dispose();
|
||||||
@@ -276,6 +285,36 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
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() {
|
void _wentOnline() {
|
||||||
_unread = 0;
|
_unread = 0;
|
||||||
_feedSeen = _graph.coordinator.feedLog.peek().length;
|
_feedSeen = _graph.coordinator.feedLog.peek().length;
|
||||||
@@ -313,6 +352,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
}
|
}
|
||||||
if (!mounted || seq != _modeSeq) return;
|
if (!mounted || seq != _modeSeq) return;
|
||||||
setState(() => _shown = m);
|
setState(() => _shown = m);
|
||||||
|
if (m != PlayerMode.full) _later(_maybeWhatsNew);
|
||||||
await _fade.animateTo(1, duration: fadeThroughTime * (1 - fadeOutPart), curve: ease);
|
await _fade.animateTo(1, duration: fadeThroughTime * (1 - fadeOutPart), curve: ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ const keyAutoSitOut = 'autoSitOut';
|
|||||||
const _keyChatPopups = 'chatPopups';
|
const _keyChatPopups = 'chatPopups';
|
||||||
const _keyNotifyPeople = 'notifyPeople';
|
const _keyNotifyPeople = 'notifyPeople';
|
||||||
const _keyShareDiagnostics = 'shareDiagnostics';
|
const _keyShareDiagnostics = 'shareDiagnostics';
|
||||||
|
const _keyWhatsNewSeen = 'whatsNewSeen';
|
||||||
|
const _keyWhatsNewNotes = 'whatsNewNotes';
|
||||||
|
|
||||||
enum HwdecMode { auto, off, custom }
|
enum HwdecMode { auto, off, custom }
|
||||||
|
|
||||||
@@ -107,6 +109,13 @@ class AppSettings {
|
|||||||
/// `SfxKind` name -> `SoundPreset` name, per-sound overrides.
|
/// `SfxKind` name -> `SoundPreset` name, per-sound overrides.
|
||||||
final soundPresetFor = signal<Map<String, String>>(const {});
|
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
|
/// Video shaders: one choice, used on join and changed both here and
|
||||||
/// in the session (Ctrl+0..6, the shader button).
|
/// in the session (Ctrl+0..6, the shader button).
|
||||||
final shaderPreset = signal(ShaderPreset.off);
|
final shaderPreset = signal(ShaderPreset.off);
|
||||||
@@ -131,6 +140,7 @@ class AppSettings {
|
|||||||
Future<void> _loadNow() async {
|
Future<void> _loadNow() async {
|
||||||
try {
|
try {
|
||||||
final box = _box = await Hive.openBox(settingsBoxName);
|
final box = _box = await Hive.openBox(settingsBoxName);
|
||||||
|
freshInstall = box.isEmpty;
|
||||||
for (final line in await migrateSettings(box)) {
|
for (final line in await migrateSettings(box)) {
|
||||||
debugPrint(line);
|
debugPrint(line);
|
||||||
}
|
}
|
||||||
@@ -194,6 +204,10 @@ class AppSettings {
|
|||||||
shaderQuality.value =
|
shaderQuality.value =
|
||||||
ShaderQuality.values.where((q) => q.name == quality).firstOrNull ??
|
ShaderQuality.values.where((q) => q.name == quality).firstOrNull ??
|
||||||
ShaderQuality.hq;
|
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);
|
final files = box.get(_keyCustomShaders);
|
||||||
if (files is List) {
|
if (files is List) {
|
||||||
customShaders.value = List.unmodifiable(files.whereType<String>());
|
customShaders.value = List.unmodifiable(files.whereType<String>());
|
||||||
@@ -328,6 +342,16 @@ class AppSettings {
|
|||||||
_put(_keyCustomShaders, [...paths]);
|
_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 {
|
Future<void> _put(String key, Object value) async {
|
||||||
try {
|
try {
|
||||||
final box = _box ??
|
final box = _box ??
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:hive_ce_flutter/hive_flutter.dart';
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../platform/app_shutdown.dart';
|
import '../platform/app_shutdown.dart';
|
||||||
|
import 'whats_new.dart';
|
||||||
|
|
||||||
// In-app update flow behind the update bar (UpdateBar). Same code path as
|
// In-app update flow behind the update bar (UpdateBar). Same code path as
|
||||||
// `--upgrade` (core's updater, downloadAndApply); this adds state for the
|
// `--upgrade` (core's updater, downloadAndApply); this adds state for the
|
||||||
@@ -58,6 +59,9 @@ class UpdateController {
|
|||||||
final Directory? installRoot;
|
final Directory? installRoot;
|
||||||
final RestartFn restart;
|
final RestartFn restart;
|
||||||
final bool skipDevBuilds;
|
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(
|
UpdateController(
|
||||||
{this.currentVersion = appVersion,
|
{this.currentVersion = appVersion,
|
||||||
@@ -65,9 +69,14 @@ class UpdateController {
|
|||||||
this.executablePath,
|
this.executablePath,
|
||||||
this.installRoot,
|
this.installRoot,
|
||||||
this.restart = _closeAndRestart,
|
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));
|
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
|
/// Outcome of the last finished check, for Settings/About wording
|
||||||
/// ("Up to date", "Check failed: ..."); null until one finishes.
|
/// ("Up to date", "Check failed: ..."); null until one finishes.
|
||||||
final lastCheck = ValueNotifier<String?>(null);
|
final lastCheck = ValueNotifier<String?>(null);
|
||||||
@@ -108,6 +117,7 @@ class UpdateController {
|
|||||||
try {
|
try {
|
||||||
final m = await fetchLatestManifest(
|
final m = await fetchLatestManifest(
|
||||||
currentVersion: currentVersion, url: manifestUrl);
|
currentVersion: currentVersion, url: manifestUrl);
|
||||||
|
latest.value = m;
|
||||||
if (!isNewerVersion(currentVersion, m.version)) {
|
if (!isNewerVersion(currentVersion, m.version)) {
|
||||||
_log.info('up to date ($currentVersion, latest ${m.version})');
|
_log.info('up to date ($currentVersion, latest ${m.version})');
|
||||||
lastCheck.value = 'Up to date';
|
lastCheck.value = 'Up to date';
|
||||||
@@ -173,6 +183,11 @@ class UpdateController {
|
|||||||
_log.info('updated to ${m.version}, restarting');
|
_log.info('updated to ${m.version}, restarting');
|
||||||
status.value =
|
status.value =
|
||||||
UpdateStatus(UpdatePhase.restarting, manifest: m, canInstall: true);
|
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
|
// Started by an old name (1.2.x's restart, an old launcher): the new
|
||||||
// binary next to it.
|
// binary next to it.
|
||||||
await restart(relaunchExecutable(_exe), launchArgs);
|
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/session/session_coordinator.dart' show ToastItem;
|
||||||
import 'package:starsync/src/state/settings.dart';
|
import 'package:starsync/src/state/settings.dart';
|
||||||
import 'package:starsync/src/update/update_controller.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/invite.dart' show Invite;
|
||||||
import 'package:starsync/src/session/navigation.dart' show Place;
|
import 'package:starsync/src/session/navigation.dart' show Place;
|
||||||
@@ -88,7 +89,8 @@ void main() {
|
|||||||
session.notifier.clear();
|
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.physicalSize = size;
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
@@ -97,6 +99,7 @@ void main() {
|
|||||||
home: AppShell(
|
home: AppShell(
|
||||||
updates: updates ?? UpdateController(currentVersion: '0.0.0-dev'),
|
updates: updates ?? UpdateController(currentVersion: '0.0.0-dev'),
|
||||||
autoUpdateCheck: false,
|
autoUpdateCheck: false,
|
||||||
|
whatsNew: whatsNew,
|
||||||
windowControls: false,
|
windowControls: false,
|
||||||
videoBuilder: video ?? (context, controls) => const FakeVideo(),
|
videoBuilder: video ?? (context, controls) => const FakeVideo(),
|
||||||
// A real probe (network, yt-dlp) never answers on the test clock.
|
// 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', () {
|
group('party strip', () {
|
||||||
testWidgets('home, alone: a plain player\'s strip, Join a friend and Invite (once your server runs)',
|
testWidgets('home, alone: a plain player\'s strip, Join a friend and Invite (once your server runs)',
|
||||||
(tester) async {
|
(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
|
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
|
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
|
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`)
|
updates the whole install folder. Reads a manifest (`version`, `protocol`, `url`, optional `sha256`, `notes`,
|
||||||
from `updateManifestUrl`, compares versions, downloads the tarball, checks
|
optional `changes`) from `updateManifestUrl`, compares versions, downloads the tarball, checks
|
||||||
sha256 when present, then extracts into a staging dir inside the install
|
sha256 when present, then extracts into a staging dir inside the install
|
||||||
folder (same filesystem, so moves are renames; the folder must be
|
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,
|
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
|
(`apps/desktop/lib/src/update/`) runs the same code for the update bar
|
||||||
(`UpdateBar`, `screens/home/update_indicator.dart`): a band across the
|
(`UpdateBar`, `screens/home/update_indicator.dart`): a band across the
|
||||||
window under the strip while an update is available: "Starsync X.Y.Z is
|
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
|
ready", the first three changes (each with its kind's icon; the notes
|
||||||
"Update and restart" (download, apply, then the app close above with Hive
|
when the manifest has no `changes`), What's new, Later (hidden until the
|
||||||
closed and the new binary started), its progress in the band. A release
|
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
|
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
|
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
|
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
|
(`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.
|
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
|
## Decisions
|
||||||
|
|
||||||
- Desktop + mpv only, no web clients: exists because Jellyfin SyncPlay is bad.
|
- 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.
|
User pushes tags; CI does the rest.
|
||||||
|
|
||||||
1. `tool/release.sh [patch|minor|major] [--dry-run]`: derives the next
|
1. `tool/release.sh [patch|minor|major] [--dry-run]`: derives the next
|
||||||
version from the latest tag, bumps the three pubspecs in lockstep, writes
|
version from the latest tag, refuses when the [changelog](#changelog)'s
|
||||||
`updates/manifest.json` (per-platform `assets` URLs are predictable; `url`
|
Unreleased has no items (warns when it has no summary) and prints it,
|
||||||
stays the Linux tarball for builds <= 0.2.1), commits `Release vX.Y.Z`,
|
renames Unreleased to `X.Y.Z` under a fresh empty Unreleased, bumps the
|
||||||
makes an annotated tag.
|
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`.
|
2. Review, then `git push --follow-tags`.
|
||||||
3. `.gitea/workflows/release.yml` runs one job per platform: Linux builds
|
3. `.gitea/workflows/release.yml` runs one job per platform: Linux builds
|
||||||
with `tool/package_linux.sh`, Windows (runner `yuuka-windows`, host mode,
|
with `tool/package_linux.sh`, Windows (runner `yuuka-windows`, host mode,
|
||||||
PowerShell only; `tool/setup_flutter_windows.ps1` also adds Visual
|
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
|
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
|
`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.
|
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]
|
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
|
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
|
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
|
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
|
update from it ([Updater](ARCHITECTURE.md#updater)). A stable install
|
||||||
moves onto it once with the command the script prints (run with the app
|
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
|
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`
|
updater skips the check when absent. Workflows live in `.gitea/workflows`
|
||||||
(the forge reads `.gitea` before `.github`).
|
(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
|
## Debugging your server
|
||||||
|
|
||||||
The app's own server runs on its own isolate (`ServerIsolate`,
|
The app's own server runs on its own isolate (`ServerIsolate`,
|
||||||
|
|||||||
@@ -78,18 +78,50 @@ const _stagingDir = '.update-staging';
|
|||||||
const _backupDir = '.update-backup';
|
const _backupDir = '.update-backup';
|
||||||
const _downloadFile = '.update-download';
|
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 {
|
class UpdateManifest {
|
||||||
final String version;
|
final String version;
|
||||||
final int protocol;
|
final int protocol;
|
||||||
final String url;
|
final String url;
|
||||||
final String? sha256;
|
final String? sha256;
|
||||||
|
/// One line for every app (1.2.x and 1.3.x show only this).
|
||||||
final String notes;
|
final String notes;
|
||||||
|
/// Null in manifests from before the changelog (show [notes] then).
|
||||||
|
final ReleaseChanges? changes;
|
||||||
const UpdateManifest(
|
const UpdateManifest(
|
||||||
{required this.version,
|
{required this.version,
|
||||||
required this.protocol,
|
required this.protocol,
|
||||||
required this.url,
|
required this.url,
|
||||||
this.sha256,
|
this.sha256,
|
||||||
required this.notes});
|
required this.notes,
|
||||||
|
this.changes});
|
||||||
|
|
||||||
// `assets` maps platform → archive URL. Top-level `url` is the Linux
|
// `assets` maps platform → archive URL. Top-level `url` is the Linux
|
||||||
// tarball, kept for builds from before `assets` existed (<= 0.2.1).
|
// tarball, kept for builds from before `assets` existed (<= 0.2.1).
|
||||||
@@ -115,7 +147,8 @@ class UpdateManifest {
|
|||||||
protocol: protocol,
|
protocol: protocol,
|
||||||
url: url,
|
url: url,
|
||||||
sha256: sha is String && sha.isNotEmpty ? sha : null,
|
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.
|
// Newer wire protocol: this build can't join servers running it.
|
||||||
|
|||||||
@@ -190,6 +190,47 @@ void main() {
|
|||||||
isTrue);
|
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', () {
|
test('manifest picks the asset for this platform', () {
|
||||||
final json = {
|
final json = {
|
||||||
'version': '0.2.2',
|
'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.
|
# following it (1.2.0-dev.2, ...) until the release moves the pubspec.
|
||||||
# Token: RELEASE_TOKEN, else the file ~/.config/watch-together-release-token
|
# Token: RELEASE_TOKEN, else the file ~/.config/watch-together-release-token
|
||||||
# (a Gitea access token with repository read/write; keep the file 0600).
|
# (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.
|
# Prints the one-time command that moves a stable install onto test builds.
|
||||||
set -euo pipefail
|
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_URL="$BASE_URL/$(basename "$TARBALL")" \
|
||||||
MANIFEST_SHA="$(sha256sum "$TARBALL" | cut -d' ' -f1)" \
|
MANIFEST_SHA="$(sha256sum "$TARBALL" | cut -d' ' -f1)" \
|
||||||
MANIFEST_NOTES="Test build: $NOTES" \
|
MANIFEST_NOTES="Test build: $NOTES" \
|
||||||
|
MANIFEST_CHANGES="$(dart "$ROOT/tool/changelog.dart" json unreleased 2>/dev/null || echo '{}')" \
|
||||||
python3 -I - "$MANIFEST" <<'EOF'
|
python3 -I - "$MANIFEST" <<'EOF'
|
||||||
import json, os, sys
|
import json, os, sys
|
||||||
e = os.environ
|
e = os.environ
|
||||||
url = e['MANIFEST_URL']
|
url = e['MANIFEST_URL']
|
||||||
|
manifest = {'version': e['MANIFEST_VER'], 'protocol': int(e['MANIFEST_PROTO']),
|
||||||
|
'url': url, 'sha256': e['MANIFEST_SHA'], 'notes': e['MANIFEST_NOTES'],
|
||||||
|
'assets': {'linux-x64': url}}
|
||||||
|
# What's new in a test build: CHANGELOG.md's Unreleased, when it has items.
|
||||||
|
changes = json.loads(e['MANIFEST_CHANGES']).get('changes') or {}
|
||||||
|
if any(changes.values()):
|
||||||
|
manifest['changes'] = changes
|
||||||
with open(sys.argv[1], 'w') as f:
|
with open(sys.argv[1], 'w') as f:
|
||||||
json.dump({'version': e['MANIFEST_VER'], 'protocol': int(e['MANIFEST_PROTO']),
|
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||||
'url': url, 'sha256': e['MANIFEST_SHA'], 'notes': e['MANIFEST_NOTES'],
|
|
||||||
'assets': {'linux-x64': url}}, f, indent=2)
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
if [[ "$PUBLISH" != "1" ]]; then
|
if [[ "$PUBLISH" != "1" ]]; then
|
||||||
|
|||||||
+243
-3
@@ -1,6 +1,30 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
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:
|
args:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -17,6 +41,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.1"
|
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:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -33,6 +81,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
|
coverage:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: coverage
|
||||||
|
sha256: ff89e37c19355194e279d26dafe8e4bece46c9ea63a30be4dc68214f5bc290d3
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.16.0"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -41,6 +97,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
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:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -49,6 +145,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
io:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: io
|
||||||
|
sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
lints:
|
lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -65,6 +169,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
version: "1.3.0"
|
||||||
|
matcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: matcher
|
||||||
|
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.20"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -81,6 +193,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
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:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -89,6 +217,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
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:
|
relic:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -113,6 +265,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0"
|
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:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -151,14 +351,14 @@ packages:
|
|||||||
path: "../packages/core"
|
path: "../packages/core"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.1.0"
|
version: "1.3.2"
|
||||||
sync_relay_server:
|
sync_relay_server:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "../packages/server"
|
path: "../packages/server"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.1.0"
|
version: "1.3.2"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -167,6 +367,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.2"
|
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:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -183,6 +407,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.3.0"
|
version: "15.3.0"
|
||||||
|
watcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: watcher
|
||||||
|
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -207,6 +439,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
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:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -216,4 +456,4 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.4"
|
version: "3.1.4"
|
||||||
sdks:
|
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
|
web_socket_channel: ^3.0.0
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
lints: ^5.0.0
|
lints: ^5.0.0
|
||||||
|
test: ^1.25.0
|
||||||
+31
-14
@@ -1,8 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Cut a release without typing a version anywhere: derives the next
|
# Cut a release without typing a version anywhere: derives the next
|
||||||
# version (patch by default), bumps all three pubspecs in lockstep,
|
# version (patch by default), turns CHANGELOG.md's Unreleased into that
|
||||||
# writes updates/manifest.json (predictable asset URL — no CI roundtrip
|
# version (refuses when it has no items), bumps all three pubspecs in
|
||||||
# needed), commits, and tags. Review, then `git push --follow-tags` —
|
# 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).
|
# CI takes it from there (build, Forgejo release, asset upload).
|
||||||
# Usage: tool/release.sh [patch|minor|major] [--dry-run]
|
# Usage: tool/release.sh [patch|minor|major] [--dry-run]
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -41,9 +43,25 @@ if git -C "$ROOT" rev-parse "v$VER" >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
echo "release will be v$VER ($WHY)"
|
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
|
if [[ "$DRY" == "1" ]]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
dart "$ROOT/tool/changelog.dart" cut "$VER"
|
||||||
|
CHANGES="$(dart "$ROOT/tool/changelog.dart" json "$VER")"
|
||||||
for f in $PUBSPECS; do
|
for f in $PUBSPECS; do
|
||||||
sed -i -E "s/^version: .*/version: $VER/" "$ROOT/$f"
|
sed -i -E "s/^version: .*/version: $VER/" "$ROOT/$f"
|
||||||
done
|
done
|
||||||
@@ -52,15 +70,10 @@ ORIGIN="$(git -C "$ROOT" remote get-url origin)"
|
|||||||
SERVER="$(echo "$ORIGIN" | sed -E 's#(https?://[^/]+)/.*#\1#')"
|
SERVER="$(echo "$ORIGIN" | sed -E 's#(https?://[^/]+)/.*#\1#')"
|
||||||
REPO="$(echo "${ORIGIN%.git}" | sed -E 's#.*[:/]([^/]+/[^/]+)$#\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")"
|
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" \
|
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"
|
MANIFEST_BASE="$SERVER/$REPO/releases/download/v$VER/starsync-$VER"
|
||||||
python3 - <<'EOF'
|
python3 -I - <<'EOF'
|
||||||
import json, os
|
import json, os
|
||||||
path = os.environ['MANIFEST_FILE']
|
path = os.environ['MANIFEST_FILE']
|
||||||
try:
|
try:
|
||||||
@@ -69,6 +82,7 @@ try:
|
|||||||
except (FileNotFoundError, ValueError):
|
except (FileNotFoundError, ValueError):
|
||||||
manifest = {}
|
manifest = {}
|
||||||
base = os.environ['MANIFEST_BASE']
|
base = os.environ['MANIFEST_BASE']
|
||||||
|
log = json.loads(os.environ['MANIFEST_CHANGES'])
|
||||||
# Asset names must match tool/package_linux.sh / package_windows.ps1.
|
# Asset names must match tool/package_linux.sh / package_windows.ps1.
|
||||||
# `url` stays the Linux tarball for builds that predate `assets` (<= 0.2.1).
|
# `url` stays the Linux tarball for builds that predate `assets` (<= 0.2.1).
|
||||||
assets = {
|
assets = {
|
||||||
@@ -80,19 +94,22 @@ manifest.update({
|
|||||||
'protocol': int(os.environ['MANIFEST_PROTO']),
|
'protocol': int(os.environ['MANIFEST_PROTO']),
|
||||||
'url': assets['linux-x64'],
|
'url': assets['linux-x64'],
|
||||||
'assets': assets,
|
'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)
|
manifest.pop('sha256', None)
|
||||||
with open(path, 'w') as f:
|
with open(path, 'w') as f:
|
||||||
json.dump(manifest, f, indent=2)
|
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||||
f.write('\n')
|
f.write('\n')
|
||||||
EOF
|
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
|
if git -C "$ROOT" diff --cached --quiet; then
|
||||||
echo "everything already at $VER — tagging only"
|
echo "everything already at $VER — tagging only"
|
||||||
else
|
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
|
fi
|
||||||
git -C "$ROOT" tag -a -m "Release v$VER" "v$VER"
|
git -C "$ROOT" tag -a -m "Release v$VER" "v$VER"
|
||||||
echo "review with: git show v$VER — then: git push --follow-tags"
|
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,
|
// 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.
|
// 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
|
// 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>
|
// Usage: dart tool/release_publish.dart vX.Y.Z <archive>
|
||||||
// (run after tool/package_linux.sh or tool/package_windows.ps1)
|
// (run after tool/package_linux.sh or tool/package_windows.ps1)
|
||||||
// dart tool/release_publish.dart dev <archive> <manifest.json>
|
// dart tool/release_publish.dart dev <archive> <manifest.json>
|
||||||
@@ -13,6 +14,8 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'changelog.dart';
|
||||||
|
|
||||||
Future<void> main(List<String> args) async {
|
Future<void> main(List<String> args) async {
|
||||||
final dev = args.isNotEmpty && args[0] == 'dev';
|
final dev = args.isNotEmpty && args[0] == 'dev';
|
||||||
if (args.length != (dev ? 3 : 2)) {
|
if (args.length != (dev ? 3 : 2)) {
|
||||||
@@ -42,17 +45,7 @@ Future<void> main(List<String> args) async {
|
|||||||
final token = _token();
|
final token = _token();
|
||||||
final api = _apiBase(root);
|
final api = _apiBase(root);
|
||||||
|
|
||||||
final prev = _git(root, ['describe', '--tags', '--abbrev=0', '$tag^'],
|
final body = _changelog(root)?.section(ver)?.releaseBody(ver) ?? 'Starsync $ver.';
|
||||||
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 http = HttpClient();
|
final http = HttpClient();
|
||||||
try {
|
try {
|
||||||
@@ -63,7 +56,7 @@ Future<void> main(List<String> args) async {
|
|||||||
rel = await c.post('$api/releases', {
|
rel = await c.post('$api/releases', {
|
||||||
'tag_name': tag,
|
'tag_name': tag,
|
||||||
'name': tag,
|
'name': tag,
|
||||||
'body': 'Starsync $ver\n\n$notes',
|
'body': body,
|
||||||
'draft': false,
|
'draft': false,
|
||||||
'prerelease': false,
|
'prerelease': false,
|
||||||
});
|
});
|
||||||
@@ -130,6 +123,12 @@ Future<void> _publishDev(String root, List<File> files) async {
|
|||||||
}) ??
|
}) ??
|
||||||
_die('could not create or find release dev');
|
_die('could not create or find release dev');
|
||||||
final id = rel['id'];
|
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];
|
final old = [for (final a in (rel['assets'] as List? ?? const [])) if (a is Map) a];
|
||||||
Future<void> upload(File f) {
|
Future<void> upload(File f) {
|
||||||
final name = f.uri.pathSegments.last;
|
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) {
|
Never _die(String msg) {
|
||||||
stderr.writeln('error: $msg');
|
stderr.writeln('error: $msg');
|
||||||
exit(1);
|
exit(1);
|
||||||
@@ -203,6 +212,12 @@ class _Api {
|
|||||||
return jsonDecode(text) as Map<String, Object?>;
|
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 {
|
Future<void> delete(String url) async {
|
||||||
final (code, text) = await _send('DELETE', url);
|
final (code, text) = await _send('DELETE', url);
|
||||||
_check('DELETE', url, code, text);
|
_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