1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../app_info.dart';
import '../app_settings.dart';
import '../platform.dart';
import 'android_core.dart';
import 'core_api.dart';
import 'core_controller.dart';
import 'models.dart';
/// The release's APKs, one per processor type (scripts/build-android.ps1,
/// .github/workflows/build.yml); others get the universal one.
const _apkAbis = {'arm64-v8a', 'armeabi-v7a', 'x86_64'};
/// Where the Download button goes: the release's file for this copy of the
/// app, which the browser downloads at once (no page to choose from): on a
/// phone the APK for its processor; on Windows the installer for an
/// installed copy (its uninstaller is beside it) and the zip for a portable
/// one; on macOS the disk image; on Linux the archive for the processor.
Future<String> downloadUrl(Release release) async {
if (!release.url.contains('/releases/tag/')) return release.url;
final String name;
if (Platform.isAndroid) {
final abi = await AndroidCore.abi().catchError((Object _) => '');
name = apkName(abi);
} else if (Platform.isWindows) {
final dir = File(Platform.resolvedExecutable).parent.path;
final installed = File('$dir${Platform.pathSeparator}unins000.exe');
name = windowsName(installed: installed.existsSync());
} else if (Platform.isMacOS) {
name = 'macOS.dmg';
} else if (Platform.isLinux) {
name = linuxName(Platform.version);
} else {
return release.url;
}
return releaseFileUrl(release.url, name);
}
/// The APK's name part for processor [abi].
String apkName(String abi) =>
'Android-${_apkAbis.contains(abi) ? abi : 'universal'}.apk';
/// The Windows file's name part: the installer, or the portable zip.
String windowsName({required bool installed}) =>
installed ? 'Windows-Setup-x64.exe' : 'Windows-x64.zip';
/// The Linux archive's name part for the processor in Dart's
/// [platformVersion] ("... on "linux_arm64"").
String linuxName(String platformVersion) =>
'Linux-${platformVersion.contains('arm64') ? 'arm64' : 'amd64'}.tar.gz';
/// The file `Firefox-VPN-<version>-[name]` in the release whose page is
/// [releaseUrl] (".../releases/tag/v1.2.3"): its versioned name, so the
/// downloaded file says which version it is.
String releaseFileUrl(String releaseUrl, String name) {
final tag = releaseUrl.substring(releaseUrl.lastIndexOf('/') + 1);
final version = tag.replaceFirst(RegExp('^[vV]'), '');
return '${releaseUrl.replaceFirst('/releases/tag/', '/releases/download/')}'
'/Firefox-VPN-$version-$name';
}
/// The APK for processor [abi] in the release whose page is [releaseUrl].
String apkUrl(String releaseUrl, String abi) =>
releaseFileUrl(releaseUrl, apkName(abi));
/// Opens the release's download (see [downloadUrl]).
Future<void> openDownload(Release release) async =>
openUrl(await downloadUrl(release));
/// The local proxy for GitHub requests, on networks that block GitHub: the
/// VPN's own while it runs without a proxy login; null otherwise.
String? updateProxy(CoreStatus? status) {
final proxy = status?.proxy;
if (status?.state.phase != Phase.running || proxy == null) return null;
if (proxy.loginRequired) return null;
return CoreLocator.listenUri(proxy.listen).authority;
}
/// Looks for a newer release on GitHub every 5 days: the app looks a minute
/// after it starts, and then every hour, whether 5 days have passed since
/// the last look that worked. What it finds is kept in the settings, so the
/// home page shows it after a restart too; the computer also shows a
/// notification while the window is in the tray. The About page looks at
/// once, whenever asked.
///
/// The Windows app installs it itself (its core: core/internal/update):
/// Update downloads it through the VPN while it runs, and the app closes
/// and opens again as the new version. With automatic updates (on unless
/// turned off), a version found downloads in the background and installs
/// the next time the app starts. Elsewhere the notice opens the download.
abstract final class Updates {
/// How long a successful look holds.
static const every = Duration(days: 5);
/// The newer release, once one was found.
static final available = ValueNotifier<Release?>(null);
/// The app installs a downloaded version at its start (automatic
/// updates) and quits in a moment, so it does not connect first.
static final installingAtStart = ValueNotifier<bool>(false);
/// Why the app's last request to install failed, when the core could not
/// say it in its status.
static final failure = ValueNotifier<String?>(null);
/// The notice of what the last update did was closed.
static final resultSeen = ValueNotifier<bool>(false);
/// Quits the app (the desktop's Quit), once an install has started.
static Future<void> Function()? quitApp;
/// Whether the window is in the tray: the new version starts there too.
static bool Function() windowHidden = () => false;
static Timer? _first;
static Timer? _timer;
static CoreController? _core;
static AppSettings? _settings;
/// The app's arguments that its next start keeps (core/internal/update
/// AppArgs).
static List<String> _startArgs = const [];
/// The version the user asked to install: it installs once downloaded.
static String? _wanted;
/// Whether the first status since the start was looked at.
static bool _started = false;
/// The versions automatic updates asked to download in this run, so a
/// failed one waits for the next start.
static final _downloadsAsked = <String>{};
/// Starts looking. [args] are the app's own (`--autostart`, `--hidden`),
/// which a new version installed at the start keeps.
static void start(
CoreController core,
AppSettings settings, {
List<String> args = const [],
}) {
if (githubRepo.isEmpty || _timer != null) return;
_core = core;
_settings = settings;
_startArgs = [
for (final a in args)
if (a == '--autostart' || a == '--hidden') a,
];
// A version found earlier, unless this app is already that one.
if (newerVersion(settings.updateVersion, appVersion)) {
available.value = Release(settings.updateVersion, settings.updateUrl);
}
core.addListener(_onStatus);
_first = Timer(const Duration(minutes: 1), _due);
_timer = Timer.periodic(const Duration(hours: 1), (_) => _due());
}
/// Forgets everything, for the next test.
@visibleForTesting
static void reset() {
_first?.cancel();
_timer?.cancel();
_core?.removeListener(_onStatus);
_first = _timer = _core = _settings = _wanted = null;
_started = false;
_startArgs = const [];
_downloadsAsked.clear();
available.value = null;
installingAtStart.value = false;
failure.value = null;
resultSeen.value = false;
quitApp = null;
windowHidden = () => false;
}
/// Follows the core's update: a version downloaded before installs at
/// the start (automatic updates), the one the user asked for once it is
/// downloaded, and a version found downloads by itself.
static void _onStatus() {
final core = _core, settings = _settings;
final update = core?.status?.update;
if (core == null || settings == null || update == null) return;
if (!_started) {
_started = true;
// Not right after an update that failed (the old version started
// again): the next start tries again, so a lasting failure is no loop.
final justFailed = update.last != null && !update.last!.ok;
if (settings.autoUpdate &&
update.ready &&
!justFailed &&
newerVersion(update.version, appVersion)) {
installingAtStart.value = true;
unawaited(
_install(update.version, _startArgs).then((ok) {
if (!ok) installingAtStart.value = false;
}),
);
return;
}
}
final wanted = _wanted;
if (wanted != null && update.version == wanted) {
if (update.ready) {
_wanted = null;
unawaited(_install(wanted, _restartArgs()));
return;
}
if (update.failed) _wanted = null;
}
_downloadInBackground();
}
/// With automatic updates, downloads the version found (once per run).
static void _downloadInBackground() {
final core = _core, settings = _settings;
final release = available.value;
final update = core?.status?.update;
if (core == null || settings == null || release == null || update == null) {
return;
}
if (!settings.autoUpdate || !newerVersion(release.version, appVersion)) {
return;
}
if (update.downloading || update.installing) return;
if (update.ready && update.version == release.version) return;
if (!_downloadsAsked.add(release.version)) return;
unawaited(
core.downloadUpdate(release.version).catchError((Object _) {
// The core's status says why.
}),
);
}
/// The Update button: downloads [release] when needed, then installs it;
/// the app closes and opens again as the new version.
static Future<void> update(Release release) async {
final core = _core;
if (core == null) return;
failure.value = null;
final current = core.status?.update;
if (current != null &&
current.ready &&
current.version == release.version) {
await _install(release.version, _restartArgs());
return;
}
_wanted = release.version;
try {
await core.downloadUpdate(release.version);
} on Exception catch (e) {
_wanted = null;
failure.value = '$e';
}
}
/// The arguments of the next start after an install now: in the tray if
/// the window is there.
static List<String> _restartArgs() =>
windowHidden() ? const ['--hidden'] : const [];
/// Asks the core to install [version], then quits the app; false when the
/// core refused (the reason in [failure] or the core's status).
static Future<bool> _install(String version, List<String> args) async {
final core = _core;
if (core == null) return false;
try {
await core.installUpdate(version, pid, args);
} on Exception catch (e) {
failure.value = '$e';
return false;
}
await quitApp?.call();
return true;
}
static Future<void> _due() async {
final last = _settings?.updateCheckedAt;
if (last != null && DateTime.now().difference(last) < every) return;
try {
found(await checkForUpdate(proxy: updateProxy(_core?.status)));
} on Exception {
// No network, or GitHub not reachable: the next hour tries again.
}
}
/// The result of a look that worked, here or on the About page: a newer
/// [release], or null when this version is the newest.
static void found(Release? release) {
available.value = release;
_settings?.update(
updateCheckedAt: DateTime.now(),
updateVersion: release?.version ?? '',
updateUrl: release?.url ?? '',
);
_downloadInBackground();
}
}