feat(menubar): manual "Check for updates" in the About box (T-47 P1)
Help → About gains a "Check for updates" button that fetches the latest GitHub release, semver-compares it to clideVersion, and shows the result inline: up-to-date, available (with a tappable link to the release notes), or a clear error. clide's first and only outbound HTTP call — a plain GET with no user data, run ONLY on this explicit tap, never on a launch path or a timer. So it's D-64-clean with no amendment; a background/periodic poll stays deferred (would need the narrow opt-in amendment first). The fetch is injectable so no test touches the network. compareSemver handles 2.3.10 > 2.3.9 and ranks pre-releases below their release. Closes T-492 (P1); the release-channel CI for downloadable signed packages is T-491, and download/ apply (P2/P3) depend on it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,24 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/clide.dart' show clideName, clideTagline, clideVersion, clideRepository, clideCommit, clideDate;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'licenses_loader.dart';
|
||||
import 'update_check.dart';
|
||||
|
||||
/// The Help → About dialog (T-48): clide identity + build info, plus the
|
||||
/// bundled-dependency licenses parsed from `assets/licenses.yaml`.
|
||||
class AboutDialog extends StatelessWidget {
|
||||
const AboutDialog({super.key, required this.onDismiss});
|
||||
const AboutDialog({super.key, required this.onDismiss, this.updateFetch});
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
/// Injected fetch for the update check, so widget tests never touch the
|
||||
/// network (T-47 P1). Production passes null → the real [githubGet].
|
||||
@visibleForTesting
|
||||
final GithubFetch? updateFetch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
@@ -52,6 +60,8 @@ class AboutDialog extends StatelessWidget {
|
||||
value: clideRepository,
|
||||
tokens: tokens,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_UpdateCheckRow(tokens: tokens, fetch: updateFetch),
|
||||
const SizedBox(height: 16),
|
||||
ClideText(
|
||||
ClideSettings.i18n.string(context, 'licenses.heading', namespace: 'builtin.menubar', placeholder: 'Bundled dependencies'),
|
||||
@@ -76,6 +86,89 @@ class AboutDialog extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Check for updates" button + inline status (T-47 P1). The check runs only on
|
||||
/// this explicit tap — never on launch, never on a timer (D-64 / POLICY.md).
|
||||
class _UpdateCheckRow extends StatefulWidget {
|
||||
const _UpdateCheckRow({required this.tokens, this.fetch});
|
||||
final SurfaceTokens tokens;
|
||||
final GithubFetch? fetch;
|
||||
|
||||
@override
|
||||
State<_UpdateCheckRow> createState() => _UpdateCheckRowState();
|
||||
}
|
||||
|
||||
class _UpdateCheckRowState extends State<_UpdateCheckRow> {
|
||||
UpdateCheckResult? _result;
|
||||
bool _checking = false;
|
||||
|
||||
Future<void> _check() async {
|
||||
setState(() {
|
||||
_checking = true;
|
||||
_result = null;
|
||||
});
|
||||
final r = await checkForUpdate(repositoryUrl: clideRepository, currentVersion: clideVersion, fetch: widget.fetch ?? githubGet);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_checking = false;
|
||||
_result = r;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _t(String key, String fallback) => ClideSettings.i18n.string(context, key, namespace: 'builtin.menubar', placeholder: fallback);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
ClideButton(label: _t('about.checkUpdates', 'Check for updates'), onPressed: _checking ? null : _check),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _status(context)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _status(BuildContext context) {
|
||||
final tokens = widget.tokens;
|
||||
if (_checking) return ClideText(_t('about.checking', 'Checking…'), fontSize: 12, color: tokens.globalTextMuted);
|
||||
switch (_result) {
|
||||
case null:
|
||||
return const SizedBox.shrink();
|
||||
case UpdateUpToDate():
|
||||
return ClideText(_t('about.upToDate', "You're on the latest version."), fontSize: 12, color: tokens.globalTextMuted);
|
||||
case UpdateAvailable(:final latest, :final url):
|
||||
return Semantics(
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
label: 'clide $latest available — release notes',
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: () => unawaited(ClideKernel.of(context).os.openURL(url)),
|
||||
builder: (ctx, hovered, _) => ClideText(
|
||||
ClideSettings.i18n.interpolated(
|
||||
context,
|
||||
'about.updateAvailable',
|
||||
namespace: 'builtin.menubar',
|
||||
placeholder: 'clide {version} is available — release notes',
|
||||
replacers: [I18nReplacer(from: '{version}', replace: latest)],
|
||||
),
|
||||
fontSize: 12,
|
||||
color: tokens.globalFocus,
|
||||
),
|
||||
),
|
||||
);
|
||||
case UpdateCheckFailed(:final message):
|
||||
return ClideText(
|
||||
'${_t('about.updateFailed', "Couldn't check for updates")} ($message)',
|
||||
fontSize: 12,
|
||||
color: tokens.statusError,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A label/value row in the build-info block.
|
||||
class _Kv extends StatelessWidget {
|
||||
const _Kv({required this.label, required this.value, required this.tokens});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/// Manual "check for updates" logic for the About dialog (T-47 P1, story T-46).
|
||||
///
|
||||
/// POLICY-sensitive: this is clide's ONLY outbound HTTP call, and it runs ONLY
|
||||
/// on explicit user action (the About-box button) — never on a launch path, never
|
||||
/// on a timer. It sends NO data about the user (a plain GET to the GitHub Releases
|
||||
/// API), so it doesn't offend D-64's no-telemetry commitment. A background/periodic
|
||||
/// poll would need a deliberate D-64 amendment first and is deferred.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
/// Injectable GET → response body (throws on failure). Lets the check run
|
||||
/// against a fake in tests so no widget test touches the network.
|
||||
typedef GithubFetch = Future<String> Function(Uri url);
|
||||
|
||||
sealed class UpdateCheckResult {
|
||||
const UpdateCheckResult();
|
||||
}
|
||||
|
||||
/// Already on (or ahead of) the latest published release.
|
||||
class UpdateUpToDate extends UpdateCheckResult {
|
||||
const UpdateUpToDate(this.current);
|
||||
final String current;
|
||||
}
|
||||
|
||||
/// A newer release is available.
|
||||
class UpdateAvailable extends UpdateCheckResult {
|
||||
const UpdateAvailable({required this.latest, required this.url});
|
||||
final String latest;
|
||||
final String url;
|
||||
}
|
||||
|
||||
/// The check couldn't complete (offline, API error, parse failure). The app is
|
||||
/// fully functional regardless — the failure is surfaced, never silent.
|
||||
class UpdateCheckFailed extends UpdateCheckResult {
|
||||
const UpdateCheckFailed(this.message);
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// clide's only outbound HTTP — a plain GET, identifying as `clide`, no body.
|
||||
Future<String> githubGet(Uri url) async {
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final req = await client.getUrl(url);
|
||||
req.headers.set(HttpHeaders.userAgentHeader, 'clide');
|
||||
req.headers.set(HttpHeaders.acceptHeader, 'application/vnd.github+json');
|
||||
final resp = await req.close();
|
||||
if (resp.statusCode != 200) throw HttpException('HTTP ${resp.statusCode}');
|
||||
return resp.transform(utf8.decoder).join();
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull `owner/repo` from a GitHub URL (`https://github.com/owner/repo[.git]`).
|
||||
({String owner, String repo})? parseGithubRepo(String repositoryUrl) {
|
||||
final m = RegExp(r'github\.com[/:]([^/]+)/([^/.\s]+)').firstMatch(repositoryUrl);
|
||||
return m == null ? null : (owner: m.group(1)!, repo: m.group(2)!);
|
||||
}
|
||||
|
||||
/// Fetch the latest GitHub Release for [repositoryUrl] and compare its version
|
||||
/// to [currentVersion]. Never throws — failures come back as [UpdateCheckFailed].
|
||||
Future<UpdateCheckResult> checkForUpdate({required String repositoryUrl, required String currentVersion, GithubFetch fetch = githubGet}) async {
|
||||
final gh = parseGithubRepo(repositoryUrl);
|
||||
if (gh == null) return const UpdateCheckFailed('unrecognized repository URL');
|
||||
try {
|
||||
final body = await fetch(Uri.parse('https://api.github.com/repos/${gh.owner}/${gh.repo}/releases/latest'));
|
||||
final json = jsonDecode(body) as Map<String, Object?>;
|
||||
final tag = (json['tag_name'] as String?)?.trim() ?? '';
|
||||
final latest = tag.startsWith('v') ? tag.substring(1) : tag;
|
||||
if (latest.isEmpty) return const UpdateCheckFailed('no release version found');
|
||||
final url = (json['html_url'] as String?) ?? repositoryUrl;
|
||||
return compareSemver(latest, currentVersion) > 0 ? UpdateAvailable(latest: latest, url: url) : UpdateUpToDate(currentVersion);
|
||||
} catch (e) {
|
||||
return UpdateCheckFailed('$e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal semver compare → -1/0/1 for a<b / a==b / a>b. Compares
|
||||
/// major.minor.patch numerically (so 2.3.10 > 2.3.9), and ranks a pre-release
|
||||
/// BELOW the same release (2.8.2-rc < 2.8.2). Missing components count as 0.
|
||||
int compareSemver(String a, String b) {
|
||||
(List<int>, String) parse(String v) {
|
||||
final dash = v.indexOf('-');
|
||||
final core = dash >= 0 ? v.substring(0, dash) : v;
|
||||
final pre = dash >= 0 ? v.substring(dash + 1) : '';
|
||||
final nums = [for (final p in core.split('.')) int.tryParse(p.trim()) ?? 0];
|
||||
while (nums.length < 3) {
|
||||
nums.add(0);
|
||||
}
|
||||
return (nums, pre);
|
||||
}
|
||||
|
||||
final (an, ap) = parse(a);
|
||||
final (bn, bp) = parse(b);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (an[i] != bn[i]) return an[i] < bn[i] ? -1 : 1;
|
||||
}
|
||||
if (ap.isEmpty && bp.isEmpty) return 0;
|
||||
if (ap.isEmpty) return 1; // release outranks a pre-release of the same core
|
||||
if (bp.isEmpty) return -1;
|
||||
return ap.compareTo(bp);
|
||||
}
|
||||
Reference in New Issue
Block a user