Skip to content

Commit 09df4fa

Browse files
committed
Add a desktop update check against GitHub Releases
Desktop ships as raw zips with no update path, so somebody who downloaded v3.0.0 has no way to learn that v3.2.0 exists. Mobile is unaffected — the App Store and Play update it. UpdateController queries /releases/latest two seconds after first frame and at most once a day, and shows a banner above the page body when the tag parses to a newer version than package_info reports. Downloads open in the browser: nothing is downloaded, verified or installed by the app. Real one-click updates need Developer ID signing and notarization plus a Windows installer first — see docs/auto-update.md. The whole feature is gated on --dart-define=OV_CHANNEL=github, which only the desktop jobs in release.yml pass. The default is `store`, so a build that forgets the flag stays quiet rather than pointing App Store or Play users at a GitHub zip. Settings gains a manual "Check for updates" in About, next to the version it is about, plus an Updates card for the auto-check preference and un-skipping a passed-over release.
1 parent c1391dd commit 09df4fa

12 files changed

Lines changed: 1646 additions & 6 deletions

.github/workflows/release.yml

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ jobs:
8888
flutter pub get
8989
9090
- name: Build Linux release
91-
run: flutter build linux --release
91+
# OV_CHANNEL=github enables the in-app update check (UpdateChannel).
92+
# Only builds published here may point users at a GitHub Release zip;
93+
# store builds must stay on the default `store` channel.
94+
run: flutter build linux --release --dart-define=OV_CHANNEL=github
9295

9396
- name: Package
9497
run: |
@@ -143,7 +146,10 @@ jobs:
143146
flutter pub get
144147
145148
- name: Build Linux release
146-
run: flutter build linux --release
149+
# OV_CHANNEL=github enables the in-app update check (UpdateChannel).
150+
# Only builds published here may point users at a GitHub Release zip;
151+
# store builds must stay on the default `store` channel.
152+
run: flutter build linux --release --dart-define=OV_CHANNEL=github
147153

148154
- name: Package
149155
run: |
@@ -187,7 +193,7 @@ jobs:
187193
# can silence it here instead of patching the plugin. Remove once
188194
# permission_handler_windows moves to <coroutine>.
189195
CL: /D_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS
190-
run: flutter build windows --release
196+
run: flutter build windows --release --dart-define=OV_CHANNEL=github
191197

192198
- name: Package
193199
shell: pwsh
@@ -245,7 +251,10 @@ jobs:
245251
- name: Build macOS release
246252
run: |
247253
# Generate the ephemeral xcconfig / plugin registrant without building.
248-
flutter build macos --release --config-only
254+
# The --dart-define is baked into Flutter-Generated.xcconfig here, so
255+
# the xcodebuild step below inherits it.
256+
flutter build macos --release --config-only \
257+
--dart-define=OV_CHANNEL=github
249258
250259
# ARCHS + ONLY_ACTIVE_ARCH=NO force a universal (Intel + Apple Silicon)
251260
# binary; the pods (libserialport) build both slices from source.

docs/auto-update.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Auto-update — phase 1 (check and notify)
2+
3+
Written 2026-09-10. Implements the desktop update check; **phase 2 (true
4+
one-click update) is not built** — see the bottom of this file for what it
5+
needs.
6+
7+
## The shape of the problem
8+
9+
| Platform | How it updates | What OpenView does |
10+
| --- | --- | --- |
11+
| iOS / Android | App Store, Google Play | Nothing. The store updates it. |
12+
| Desktop (GitHub Release zips) | Nothing — the user must notice a new release | Checks daily, shows a banner, opens the download in a browser |
13+
14+
Desktop is the whole problem: `.github/workflows/release.yml` publishes raw
15+
zips, so somebody who downloaded v3.0.0 has no way to learn that v3.2.0 exists.
16+
17+
## What phase 1 does
18+
19+
1. Two seconds after first frame, [`UpdateController.checkAtStartup`](../lib/controllers/update_controller.dart)
20+
GETs `/repos/Protocentral/protocentral_openview/releases/latest`.
21+
2. If the tag parses to a version newer than `package_info_plus` reports, the
22+
[`UpdateBanner`](../lib/ui/widgets/update_banner.dart) appears above the page
23+
body with **What's new** / **Download** / dismiss.
24+
3. **Download** opens the matching release asset in the user's browser. Nothing
25+
is downloaded, verified, or installed by the app.
26+
27+
The same check runs on demand from Settings → About → **Check for updates**.
28+
29+
It never blocks startup, never throws out of the check, and stays silent when
30+
offline or rate-limited.
31+
32+
## The distribution-channel gate
33+
34+
`UpdateChannel.isSelfDistributed` is the master switch, and it is
35+
`String.fromEnvironment('OV_CHANNEL')`**`store` by default**, so a build that
36+
forgets the flag stays quiet rather than nagging App Store or Play users with a
37+
GitHub zip. Only the desktop jobs in `release.yml` pass
38+
`--dart-define=OV_CHANNEL=github`.
39+
40+
When it is off, the whole feature is absent: no check, no banner, and no
41+
Updates card in Settings. Future distro packages (Flathub, AUR) should stay on
42+
the default so the package manager keeps ownership of updating.
43+
44+
To exercise the flow locally:
45+
46+
```sh
47+
flutter run -d macos --dart-define=OV_CHANNEL=github
48+
```
49+
50+
Because the tag will match your own version, Settings → About will say "You are
51+
running the latest release" rather than showing a banner. To see the banner,
52+
temporarily lower `version:` in `pubspec.yaml`.
53+
54+
## Things that are easy to break
55+
56+
- **Asset names are duplicated.** `UpdateController.assetNameForCurrentPlatform`
57+
hard-codes the four zip names produced by the `Package` steps in
58+
`release.yml`. Rename one there without renaming it here and the dialog
59+
quietly degrades to the release page instead of a direct download.
60+
`test/update_controller_test.dart` pins the names but cannot see the workflow.
61+
- **Rate limit.** Unauthenticated GitHub API is 60 requests/hour *per IP*. One
62+
check per day per user is nowhere near it, but a shared lab NAT plus a tight
63+
loop would be — hence `lastUpdateCheck` in `openview_settings.json` and the
64+
24 h `_checkInterval`.
65+
- **`/releases/latest` excludes prereleases**, which is what we want: a
66+
`v3.3.0-rc1` tag must not nag stable users. Do not switch to `/releases`
67+
without re-adding that filter.
68+
- **Release notes are Markdown**, and OpenView has no Markdown renderer.
69+
`_ReleaseNotes` flattens headings, bullets and emphasis by hand and collapses
70+
PR URLs to `#123`. It is deliberately dumb — if the notes ever need real
71+
rendering, add `flutter_markdown` rather than growing that function.
72+
73+
## User controls
74+
75+
**Settings → About → Check for updates** is the manual check, sitting next to
76+
the version it is about — the familiar desktop "About → Check for Updates…"
77+
idiom. It ignores both the daily cadence and any skipped version (the user
78+
asked), and reports the same way: the release-notes dialog when there is
79+
something, a snackbar when there isn't. The line beside the button carries the
80+
standing state — last checked, up to date, or the error from the last attempt.
81+
82+
**Settings → Updates** carries the preferences only, so there is exactly one
83+
check button on the screen:
84+
85+
- **Check for updates automatically** — persisted as `autoCheckUpdates`.
86+
- **What's new in x.y.z** — reopens the dialog after a check has found
87+
something.
88+
- **Un-skip x.y.z** — clears `skippedUpdateVersion`.
89+
90+
Both are absent on store builds. Dismissing the banner with the × hides it for
91+
the session; **Skip this version** in the dialog suppresses it for good.
92+
93+
## Phase 2 — what real one-click updates need
94+
95+
The standard answer for desktop Flutter is
96+
[`auto_updater`](https://pub.dev/packages/auto_updater), which wraps **Sparkle**
97+
(macOS) and **WinSparkle** (Windows) and consumes an `appcast.xml` published
98+
alongside the release. Its prerequisites are not met today:
99+
100+
1. **macOS must be Developer ID signed and notarized.** The macOS job currently
101+
ad-hoc signs (`CODE_SIGN_IDENTITY=-`), which is also why Gatekeeper warns on
102+
first launch. Sparkle cannot install an update over an ad-hoc-signed app.
103+
The `AC_CERTIFICATE` / `APPLE_*` secrets for this already exist in the repo.
104+
2. **Windows needs an installer** (MSIX or Inno Setup), not a zip — WinSparkle
105+
updates by running one. Without an OV/EV code-signing cert, SmartScreen will
106+
flag every build.
107+
3. **Linux is not supported by `auto_updater` at all.** Linux stays on the
108+
phase-1 notify path, or moves to Flathub and lets the package manager own it.
109+
110+
Do the signing and notarization first — it fixes the Gatekeeper warning on its
111+
own merits, and everything else is blocked behind it.
112+
113+
Deliberately **not** on the roadmap: downloading and swapping the app bundle
114+
ourselves. macOS quarantine and Gatekeeper, Windows file locks on the running
115+
`.exe`, and hand-rolled signature verification are exactly what Sparkle and
116+
WinSparkle exist to handle.

lib/app.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import 'controllers/smp_controller.dart';
1212
import 'controllers/recordings_browser_controller.dart';
1313
import 'controllers/scan_controller.dart';
1414
import 'controllers/settings_controller.dart';
15+
import 'controllers/update_controller.dart';
1516
import 'theme/app_theme.dart';
1617
import 'transport/ble_service.dart';
1718
import 'transport/usb_serial_service.dart';
@@ -30,6 +31,7 @@ class OpenViewApp extends StatelessWidget {
3031
final RecordingsBrowserController recordingsBrowser;
3132
final DeveloperBleController developerBle;
3233
final SmpController smp;
34+
final UpdateController update;
3335

3436
const OpenViewApp({
3537
super.key,
@@ -44,6 +46,7 @@ class OpenViewApp extends StatelessWidget {
4446
required this.recordingsBrowser,
4547
required this.developerBle,
4648
required this.smp,
49+
required this.update,
4750
});
4851

4952
@override
@@ -63,6 +66,7 @@ class OpenViewApp extends StatelessWidget {
6366
ChangeNotifierProvider<DeveloperBleController>.value(
6467
value: developerBle),
6568
ChangeNotifierProvider<SmpController>.value(value: smp),
69+
ChangeNotifierProvider<UpdateController>.value(value: update),
6670
],
6771
child: Consumer<SettingsController>(
6872
builder: (_, settings, __) => MaterialApp.router(

lib/controllers/settings_controller.dart

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ class SettingsController extends ChangeNotifier {
2525
ThemeMode _themeMode = ThemeMode.dark;
2626
int _repaintHz = PlatformV3.isDesktop ? 60 : 30;
2727
String? _recordingDir; // null → default under Documents/
28+
bool _autoCheckUpdates = true;
29+
DateTime? _lastUpdateCheck;
30+
String? _skippedUpdateVersion;
2831

2932
ThemeMode get themeMode => _themeMode;
3033
int get repaintHz => _repaintHz;
@@ -33,6 +36,18 @@ class SettingsController extends ChangeNotifier {
3336
String? get recordingDirOverride => _recordingDir;
3437
bool get isDefaultRecordingDir => _recordingDir == null;
3538

39+
/// Whether OpenView may check GitHub Releases for a newer desktop build on
40+
/// startup. Only self-distributed builds ever check (see UpdateChannel),
41+
/// but the preference is stored for every platform so toggling it on a
42+
/// store build is harmless.
43+
bool get autoCheckUpdates => _autoCheckUpdates;
44+
45+
/// When the last update check completed, used to keep it to once a day.
46+
DateTime? get lastUpdateCheck => _lastUpdateCheck;
47+
48+
/// A release the user asked not to be reminded about, e.g. `3.3.0`.
49+
String? get skippedUpdateVersion => _skippedUpdateVersion;
50+
3651
File? _file;
3752

3853
Future<void> load() async {
@@ -49,6 +64,13 @@ class SettingsController extends ChangeNotifier {
4964
if (dirOverride != null && dirOverride.trim().isNotEmpty) {
5065
_recordingDir = dirOverride;
5166
}
67+
_autoCheckUpdates = json['autoCheckUpdates'] as bool? ?? _autoCheckUpdates;
68+
final lastCheck = json['lastUpdateCheck'] as String?;
69+
if (lastCheck != null) _lastUpdateCheck = DateTime.tryParse(lastCheck);
70+
final skipped = json['skippedUpdateVersion'] as String?;
71+
if (skipped != null && skipped.trim().isNotEmpty) {
72+
_skippedUpdateVersion = skipped;
73+
}
5274
} catch (_) {
5375
// Corrupt / unreadable settings — fall back to defaults silently.
5476
}
@@ -85,6 +107,29 @@ class SettingsController extends ChangeNotifier {
85107
await _save();
86108
}
87109

110+
Future<void> setAutoCheckUpdates(bool enabled) async {
111+
if (enabled == _autoCheckUpdates) return;
112+
_autoCheckUpdates = enabled;
113+
notifyListeners();
114+
await _save();
115+
}
116+
117+
/// Stamp the update check clock. Called after every completed check —
118+
/// including one that found nothing — so a quiet repo doesn't cause a
119+
/// request on every launch.
120+
Future<void> markUpdateChecked() async {
121+
_lastUpdateCheck = DateTime.now();
122+
await _save();
123+
}
124+
125+
/// Suppress reminders for [version] (pass null to clear).
126+
Future<void> setSkippedUpdateVersion(String? version) async {
127+
if (version == _skippedUpdateVersion) return;
128+
_skippedUpdateVersion = version;
129+
notifyListeners();
130+
await _save();
131+
}
132+
88133
/// Resolve the effective recordings directory (default or override).
89134
Future<Directory> recordingsDirectory() async {
90135
final override = _recordingDir;
@@ -103,6 +148,9 @@ class SettingsController extends ChangeNotifier {
103148
'themeMode': _themeMode.name,
104149
'repaintHz': _repaintHz,
105150
'recordingDir': _recordingDir,
151+
'autoCheckUpdates': _autoCheckUpdates,
152+
'lastUpdateCheck': _lastUpdateCheck?.toIso8601String(),
153+
'skippedUpdateVersion': _skippedUpdateVersion,
106154
}));
107155
} catch (_) {
108156
// Best-effort persistence; ignore write failures.

0 commit comments

Comments
 (0)