Skip to content

Commit 8eb7a39

Browse files
committed
fix: make Drive sync recovery cross-platform
1 parent fb83de9 commit 8eb7a39

15 files changed

Lines changed: 656 additions & 58 deletions

docs/guides/google-drive-sync.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,20 @@ for recovery.
3333
Keep an exported backup file somewhere safe. A Drive copy cannot be
3434
decrypted without its passphrase.
3535

36+
## If you forget the passphrase
37+
38+
There is no recovery key or encryption backdoor. If the Drive file is your
39+
only remaining copy, it cannot be decrypted without the old passphrase.
40+
41+
If Admincraft still has the server profiles on this device, open
42+
**Settings → Data & Sync → Forgot passphrase?** and choose **Replace Drive
43+
copy**. Confirm a new passphrase and Admincraft will encrypt the local profiles
44+
again, overwrite the inaccessible Drive file, and resume sync. This does not
45+
recover anything that existed only in the old Drive file.
46+
47+
Choose **Disconnect Drive** instead if you want to stop sync without replacing
48+
the remote file. The profiles on this device are kept.
49+
3650
## Web app notes
3751

3852
Drive sync works in the web app, but sign-in is tied to the exact site origin.
@@ -42,6 +56,18 @@ and third-party sign-in are allowed for the Admincraft site.
4256
Profiles in the browser still depend on site storage. Drive sync or an
4357
encrypted backup protects them if browser data is cleared.
4458

59+
### Profiles created with a self-signed certificate
60+
61+
Drive preserves a certificate configured on Android or desktop, but a browser
62+
cannot use that certificate for pinning. When such a profile is downloaded,
63+
Admincraft keeps it unchanged for native devices, stops the browser from
64+
attempting an incompatible connection, and explains the required change.
65+
66+
Use **Public address, trusted certificate** with a hostname and certificate the
67+
browser already trusts. If the browser reaches the same Minecraft server
68+
through a different address, keep the self-signed profile for native apps and
69+
add a second browser-compatible server profile. Admincraft will sync both.
70+
4571
## Troubleshooting
4672

4773
| What Admincraft shows | What to do |
@@ -52,6 +78,7 @@ encrypted backup protects them if browser data is cleared.
5278
| **No Admincraft configuration exists** | Choose **Upload this device** on the device that has the profiles first. |
5379
| **Sign-in was cancelled** | Try again and finish the Google account prompt. |
5480
| **Could not decrypt** | Check that the passphrase exactly matches the one used for the Drive copy. |
81+
| **Passphrase forgotten** | Use **Forgot passphrase?** to replace Drive from a device that still has the profiles, or disconnect while keeping its local profiles. A Drive-only copy cannot be recovered. |
5582

5683
If you maintain the build, the
5784
[developer OAuth setup](../development/google-drive-oauth.md) explains origins,

docs/guides/web-app.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,17 @@ The private tailnet address still works in the Windows and Android builds, which
2828

2929
## Self-signed certificates
3030

31-
Web pages cannot install or pin a certificate for a WebSocket connection. The **Self-signed certificate** option is therefore hidden in the web app.
31+
Web pages cannot install or pin a certificate for a WebSocket connection. The
32+
**Self-signed certificate** option is therefore not offered for new web
33+
profiles. A synced native profile still shows its real mode, disabled, so the
34+
web app does not silently reinterpret it as a public certificate.
35+
36+
If Drive sync brings in a profile that was configured with a self-signed
37+
certificate on Android or desktop, Admincraft preserves it but does not try to
38+
connect with it in the browser. Switch that profile to **Public certificate**
39+
only when the same hostname presents a certificate the browser already trusts.
40+
If the browser needs another hostname or port, add a separate browser profile
41+
and keep the native self-signed profile unchanged.
3242

3343
If the browser or operating system already trusts the endpoint certificate, use **Public certificate**. Otherwise use the Windows or Android build, or place the WebSocket behind a publicly trusted TLS endpoint.
3444

lib/controllers/connection_controller.dart

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:async';
33
import 'package:admincraft/models/connection_status.dart';
44
import 'package:admincraft/models/model.dart';
55
import 'package:admincraft/services/connection_failure.dart';
6+
import 'package:admincraft/services/connection_platform_capabilities.dart';
67
import 'package:admincraft/services/connection_service.dart';
78
import 'package:admincraft/services/retry_policy.dart';
89
import 'package:admincraft/utils/toast_utils.dart';
@@ -18,6 +19,7 @@ class ConnectionController with ChangeNotifier, WidgetsBindingObserver {
1819
static const int maxRetries = 3;
1920

2021
final ConnectionService connectionService;
22+
final ConnectionPlatformCapabilities capabilities;
2123
ConnectionStatus get status => connectionService.status;
2224

2325
int _retries = 0;
@@ -32,8 +34,10 @@ class ConnectionController with ChangeNotifier, WidgetsBindingObserver {
3234
/// only flashing a toast that may be missed.
3335
ConnectionFailure? lastFailure;
3436

35-
ConnectionController({ConnectionService? connectionService})
36-
: connectionService = connectionService ?? ConnectionService() {
37+
ConnectionController({
38+
ConnectionService? connectionService,
39+
this.capabilities = currentConnectionPlatformCapabilities,
40+
}) : connectionService = connectionService ?? ConnectionService() {
3741
this.connectionService.onConnectionLost = _handleConnectionLost;
3842
WidgetsBinding.instance.addObserver(this);
3943
}
@@ -54,6 +58,14 @@ class ConnectionController with ChangeNotifier, WidgetsBindingObserver {
5458
return;
5559
}
5660

61+
final platformFailure = compatibilityFailure(model);
62+
if (platformFailure != null) {
63+
_keepConnected = false;
64+
lastFailure = platformFailure;
65+
notifyListeners();
66+
return;
67+
}
68+
5769
if (status == ConnectionStatus.connected) {
5870
ToastUtils.showToastError('Already connected.');
5971
return;
@@ -84,6 +96,9 @@ class ConnectionController with ChangeNotifier, WidgetsBindingObserver {
8496
}
8597
}
8698

99+
ConnectionFailure? compatibilityFailure(Model model) =>
100+
capabilities.failureFor(model.connectionSecurity, model.minecraftEdition);
101+
87102
/// Reports the failure, and schedules another attempt when [decideRetry]
88103
/// says one is worth making.
89104
void _reportAndMaybeRetry(Model model, ConnectionFailure failure) {

lib/controllers/google_drive_sync_controller.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,16 @@ class GoogleDriveSyncController with ChangeNotifier {
198198
});
199199
}
200200

201+
/// Replaces an inaccessible Drive copy with the profiles on this device.
202+
///
203+
/// Encryption intentionally has no recovery key. This is therefore a reset,
204+
/// not a way to decrypt the previous copy: the caller must warn the user
205+
/// before overwriting it.
206+
Future<void> replaceDriveCopyWithNewPassphrase(
207+
Model model,
208+
String passphrase,
209+
) => enableWithUpload(model, passphrase);
210+
201211
Future<void> enableWithDownload(Model model, String passphrase) async {
202212
await _run(() async {
203213
final api = await _api(interactive: true);
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import 'package:admincraft/models/connection_security.dart';
2+
import 'package:admincraft/models/minecraft_edition.dart';
3+
import 'package:admincraft/services/connection_failure.dart';
4+
import 'package:admincraft/services/rcon_client.dart' as rcon;
5+
import 'package:admincraft/services/websocket_connector.dart' as websocket;
6+
7+
/// Connection features the current platform can implement.
8+
///
9+
/// Profiles are synced unchanged between devices, but the transports are not
10+
/// interchangeable: browsers cannot pin a certificate or open a raw RCON
11+
/// socket. Keeping this decision outside the profile prevents a web download
12+
/// from silently rewriting a configuration that still works on Android.
13+
class ConnectionPlatformCapabilities {
14+
final bool supportsCustomCertificates;
15+
final bool supportsDirectRcon;
16+
17+
const ConnectionPlatformCapabilities({
18+
required this.supportsCustomCertificates,
19+
required this.supportsDirectRcon,
20+
});
21+
22+
bool supports(ConnectionSecurity security, MinecraftEdition edition) {
23+
if (security.requiresCertificate) return supportsCustomCertificates;
24+
if (security.isDirectRcon) {
25+
return supportsDirectRcon && edition == MinecraftEdition.java;
26+
}
27+
return true;
28+
}
29+
30+
String? unsupportedMessage(
31+
ConnectionSecurity security,
32+
MinecraftEdition edition,
33+
) {
34+
if (supports(security, edition)) return null;
35+
if (security.requiresCertificate) {
36+
return 'Web browsers cannot use the self-signed certificate stored in '
37+
'this synced profile. Keep this profile for Android or desktop, or '
38+
'switch to Public certificate with a hostname the browser trusts. '
39+
'If the browser uses a different address, add a separate server '
40+
'profile for it.';
41+
}
42+
if (security.isDirectRcon) {
43+
return 'Web browsers cannot open a Direct RCON connection. Keep this '
44+
'profile for a native app, or add a browser profile that connects '
45+
'through the Admincraft WebSocket bridge.';
46+
}
47+
return 'This connection type is not available on this platform.';
48+
}
49+
50+
ConnectionFailure? failureFor(
51+
ConnectionSecurity security,
52+
MinecraftEdition edition,
53+
) {
54+
final message = unsupportedMessage(security, edition);
55+
return message == null
56+
? null
57+
: ConnectionFailure(ConnectionFailureKind.unsupported, message);
58+
}
59+
}
60+
61+
const currentConnectionPlatformCapabilities = ConnectionPlatformCapabilities(
62+
supportsCustomCertificates: websocket.supportsCustomCertificate,
63+
supportsDirectRcon: rcon.supportsDirectRcon,
64+
);

lib/views/data_sync_view.dart

Lines changed: 94 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import 'package:flutter/material.dart';
1313
import 'package:flutter/services.dart';
1414
import 'package:provider/provider.dart';
1515

16+
enum _PassphraseRecoveryAction { replaceDriveCopy, disconnect }
17+
1618
class DataSyncView extends StatefulWidget {
1719
final Future<void> Function() onServersChanged;
1820

@@ -111,7 +113,8 @@ class _DataSyncViewState extends State<DataSyncView> {
111113
final servers = await ConfigTransfer.import(blob, passphrase);
112114
if (servers.isEmpty) {
113115
throw const ConfigTransferException(
114-
'This backup does not contain any servers.');
116+
'This backup does not contain any servers.',
117+
);
115118
}
116119
if (!mounted) return;
117120
final confirmed = await DialogUtils.confirmAction(
@@ -182,6 +185,66 @@ class _DataSyncViewState extends State<DataSyncView> {
182185
);
183186
}
184187

188+
Future<void> _recoverDrivePassphrase() async {
189+
final action = await showDialog<_PassphraseRecoveryAction>(
190+
context: context,
191+
builder: (dialogContext) => AlertDialog(
192+
icon: const Icon(Icons.key_off_outlined),
193+
title: const Text('Forgot the sync passphrase?'),
194+
content: const Text(
195+
'The existing encrypted Drive copy cannot be decrypted without its '
196+
'old passphrase.\n\nIf this device still has the server profiles you '
197+
'want, you can replace the Drive copy and protect it with a new '
198+
'passphrase. Otherwise, disconnect Drive and keep the profiles on '
199+
'this device.\n\nIf Drive is your only remaining copy, it cannot be '
200+
'recovered.',
201+
),
202+
actions: [
203+
TextButton(
204+
onPressed: () => Navigator.pop(dialogContext),
205+
child: const Text('Cancel'),
206+
),
207+
TextButton(
208+
onPressed: () => Navigator.pop(
209+
dialogContext,
210+
_PassphraseRecoveryAction.disconnect,
211+
),
212+
child: const Text('Disconnect Drive'),
213+
),
214+
FilledButton(
215+
onPressed: () => Navigator.pop(
216+
dialogContext,
217+
_PassphraseRecoveryAction.replaceDriveCopy,
218+
),
219+
child: const Text('Replace Drive copy'),
220+
),
221+
],
222+
),
223+
);
224+
if (action == null || !mounted) return;
225+
226+
if (action == _PassphraseRecoveryAction.disconnect) {
227+
await _drive.disconnect();
228+
ToastUtils.showToastSuccess(
229+
'Google Drive disconnected. Local server profiles were kept.',
230+
);
231+
return;
232+
}
233+
234+
final passphrase = await DialogUtils.promptForPassphrase(
235+
context,
236+
title: 'Set a new sync passphrase',
237+
message:
238+
'The profiles currently on this device will replace the inaccessible Drive copy.',
239+
confirm: true,
240+
);
241+
if (passphrase == null || !mounted) return;
242+
await _driveAction(
243+
() => _drive.replaceDriveCopyWithNewPassphrase(_model, passphrase),
244+
'Google Drive was replaced and protected with the new passphrase.',
245+
);
246+
}
247+
185248
Future<void> _driveAction(
186249
Future<void> Function() action,
187250
String success,
@@ -241,10 +304,10 @@ class _DataSyncViewState extends State<DataSyncView> {
241304
final subtitle = !drive.configured
242305
? 'This build has no Google OAuth client IDs. Add them at build time to enable Drive.'
243306
: !drive.signedIn
244-
? 'Sign in to keep the same encrypted server profiles on every device.'
245-
: drive.automaticSyncEnabled
246-
? '${account == null ? 'Connected to Google Drive' : 'Connected as $account'} · ${_lastSyncLabel(drive.lastSyncAt)}'
247-
: '${account ?? 'Google account connected'} · Choose which copy to use first.';
307+
? 'Sign in to keep the same encrypted server profiles on every device.'
308+
: drive.automaticSyncEnabled
309+
? '${account == null ? 'Connected to Google Drive' : 'Connected as $account'} · ${_lastSyncLabel(drive.lastSyncAt)}'
310+
: '${account ?? 'Google account connected'} · Choose which copy to use first.';
248311

249312
final actions = <Widget>[];
250313
if (drive.configured && !drive.signedIn) {
@@ -271,6 +334,11 @@ class _DataSyncViewState extends State<DataSyncView> {
271334
icon: const Icon(Icons.cloud_download_outlined),
272335
label: const Text('Use Drive copy'),
273336
),
337+
TextButton.icon(
338+
onPressed: drive.busy ? null : _recoverDrivePassphrase,
339+
icon: const Icon(Icons.key_off_outlined),
340+
label: const Text('Forgot passphrase?'),
341+
),
274342
TextButton(
275343
onPressed: drive.busy ? null : drive.disconnect,
276344
child: const Text('Sign out'),
@@ -282,9 +350,9 @@ class _DataSyncViewState extends State<DataSyncView> {
282350
onPressed: drive.busy
283351
? null
284352
: () => _driveAction(
285-
() => drive.syncNow(_model),
286-
'Google Drive is up to date.',
287-
),
353+
() => drive.syncNow(_model),
354+
'Google Drive is up to date.',
355+
),
288356
icon: const Icon(Icons.sync),
289357
label: const Text('Sync now'),
290358
),
@@ -298,6 +366,11 @@ class _DataSyncViewState extends State<DataSyncView> {
298366
icon: const Icon(Icons.cloud_download_outlined),
299367
label: const Text('Download'),
300368
),
369+
TextButton.icon(
370+
onPressed: drive.busy ? null : _recoverDrivePassphrase,
371+
icon: const Icon(Icons.key_off_outlined),
372+
label: const Text('Forgot passphrase?'),
373+
),
301374
TextButton(
302375
onPressed: drive.busy ? null : drive.disconnect,
303376
child: const Text('Disconnect'),
@@ -316,8 +389,8 @@ class _DataSyncViewState extends State<DataSyncView> {
316389
final status = !drive.configured
317390
? const Chip(label: Text('Setup required'))
318391
: drive.automaticSyncEnabled
319-
? const Chip(label: Text('Automatic'))
320-
: null;
392+
? const Chip(label: Text('Automatic'))
393+
: null;
321394
final icon = Icon(
322395
Icons.cloud_sync_outlined,
323396
size: 30,
@@ -408,8 +481,10 @@ class _DataSyncViewState extends State<DataSyncView> {
408481
child: Column(
409482
crossAxisAlignment: CrossAxisAlignment.start,
410483
children: [
411-
Text('Data & Sync',
412-
style: Theme.of(context).textTheme.headlineMedium),
484+
Text(
485+
'Data & Sync',
486+
style: Theme.of(context).textTheme.headlineMedium,
487+
),
413488
const SizedBox(height: 4),
414489
Text(
415490
'Move or protect every saved server profile.',
@@ -440,8 +515,9 @@ class _DataSyncViewState extends State<DataSyncView> {
440515
'Copy an encrypted configuration and paste it on another device.',
441516
actions: [
442517
FilledButton.icon(
443-
onPressed:
444-
busy ? null : () => _export(toClipboard: true),
518+
onPressed: busy
519+
? null
520+
: () => _export(toClipboard: true),
445521
icon: const Icon(Icons.copy),
446522
label: const Text('Copy config'),
447523
),
@@ -459,8 +535,9 @@ class _DataSyncViewState extends State<DataSyncView> {
459535
'Keep a portable encrypted backup for offline transfer or recovery.',
460536
actions: [
461537
FilledButton.icon(
462-
onPressed:
463-
busy ? null : () => _export(toClipboard: false),
538+
onPressed: busy
539+
? null
540+
: () => _export(toClipboard: false),
464541
icon: const Icon(Icons.save_alt),
465542
label: const Text('Export file'),
466543
),
@@ -501,7 +578,7 @@ class _DataSyncViewState extends State<DataSyncView> {
501578
leading: Icon(Icons.lock_outline),
502579
title: Text('Your passphrase is never stored in the backup'),
503580
subtitle: Text(
504-
'Anyone importing it must know the passphrase you chose during export.',
581+
'There is no recovery key. Keep the passphrase or an accessible copy of your server profiles.',
505582
),
506583
),
507584
),

0 commit comments

Comments
 (0)