Skip to content

Commit d2e6a94

Browse files
committed
feat: guard against Isar data loss on app downgrade
Isar's delete_unopened_collections() silently destroys any collection present on disk but absent from the schema list passed to Isar.open(). Installing an older binary after a newer one has run triggers this — the older binary's schema list doesn't include collections added in the newer version (e.g. FrostWalletInfo, which stores non-recoverable FROST multisig participant state). Add a downgrade guard that reads hive_data_version before any Isar.open call and refuses to proceed if the stored version exceeds Constants.currentDataVersion: - Mobile (main.dart): bootstraps Isar+themes and calls runApp with a new DowngradeDetectedApp/DowngradeDetectedView blocking error screen. - Desktop (desktop_login_view.dart _checkDesktopMigrate): throws a descriptive Exception that surfaces as a FlushBar on the login screen. New file: lib/pages/downgrade_detected_view.dart (mirrors AlreadyRunningView)
1 parent 665e21c commit d2e6a94

3 files changed

Lines changed: 258 additions & 0 deletions

File tree

lib/main.dart

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import 'models/node_model.dart';
4242
import 'models/notification_model.dart';
4343
import 'models/trade_wallet_lookup.dart';
4444
import 'pages/already_running_view.dart';
45+
import 'pages/downgrade_detected_view.dart';
4546
import 'pages/campfire_migrate_view.dart';
4647
import 'pages/home_view/home_view.dart';
4748
import 'pages/intro_view.dart';
@@ -230,6 +231,47 @@ void main(List<String> args) async {
230231
}
231232
rethrow;
232233
}
234+
235+
// Guard against downgrade: if the on-disk data version is newer than this
236+
// binary knows about, Isar would silently delete any collection that is not
237+
// in this binary's schema list, causing irreversible wallet-data loss.
238+
// Instead, refuse to open and show a blocking error screen.
239+
final int storedDataVersion =
240+
DB.instance.get<dynamic>(
241+
boxName: DB.boxNameDBInfo,
242+
key: "hive_data_version",
243+
)
244+
as int? ??
245+
0;
246+
if (storedDataVersion > Constants.currentDataVersion) {
247+
Widget errorApp;
248+
try {
249+
await StackFileSystem.initThemesDir();
250+
await MainDB.instance.initMainDB();
251+
ThemeService.instance.init(MainDB.instance);
252+
errorApp = const ProviderScope(child: DowngradeDetectedApp());
253+
} catch (_) {
254+
errorApp = MaterialApp(
255+
debugShowCheckedModeBanner: false,
256+
theme: ThemeData(fontFamily: GoogleFonts.inter().fontFamily),
257+
home: Scaffold(
258+
body: Center(
259+
child: Padding(
260+
padding: const EdgeInsets.all(32),
261+
child: Text(
262+
'${AppConfig.appName} was previously run with a newer version. '
263+
'Please reinstall the latest version to avoid data loss.',
264+
textAlign: TextAlign.center,
265+
style: GoogleFonts.inter(fontSize: 16),
266+
),
267+
),
268+
),
269+
),
270+
);
271+
}
272+
runApp(errorApp);
273+
return;
274+
}
233275
await Prefs.instance.init();
234276

235277
await Logging.instance.initialize(
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/*
2+
* This file is part of Stack Wallet.
3+
*
4+
* Copyright (c) 2023 Cypher Stack
5+
* All Rights Reserved.
6+
* The code is distributed under GPLv3 license, see LICENSE file for details.
7+
* Generated by Cypher Stack on 2023-05-26
8+
*
9+
*/
10+
11+
import 'dart:io';
12+
13+
import 'package:flutter/material.dart';
14+
import 'package:flutter_riverpod/flutter_riverpod.dart';
15+
import 'package:flutter_svg/svg.dart';
16+
import 'package:google_fonts/google_fonts.dart';
17+
18+
import '../app_config.dart';
19+
import '../themes/stack_colors.dart';
20+
import '../themes/theme_providers.dart';
21+
import '../themes/theme_service.dart';
22+
import '../utilities/stack_file_system.dart';
23+
import '../utilities/text_styles.dart';
24+
import '../utilities/util.dart';
25+
import '../widgets/app_icon.dart';
26+
import '../widgets/background.dart';
27+
28+
/// Root app widget for the "downgrade detected" error path.
29+
///
30+
/// Shown when the on-disk Hive data version is higher than
31+
/// [Constants.currentDataVersion] in this binary, meaning the user installed
32+
/// an older build over a newer one. Opening Isar in this state would cause
33+
/// Isar to silently delete any collection introduced after this binary was
34+
/// built, resulting in irreversible data loss.
35+
///
36+
/// Mirrors the theme bootstrap performed by [AlreadyRunningApp] in
37+
/// already_running_view.dart. Requires Isar + ThemeService to already be
38+
/// initialized before [runApp] is called.
39+
class DowngradeDetectedApp extends ConsumerStatefulWidget {
40+
const DowngradeDetectedApp({super.key});
41+
42+
@override
43+
ConsumerState<DowngradeDetectedApp> createState() =>
44+
_DowngradeDetectedAppState();
45+
}
46+
47+
class _DowngradeDetectedAppState extends ConsumerState<DowngradeDetectedApp> {
48+
@override
49+
void initState() {
50+
super.initState();
51+
WidgetsBinding.instance.addPostFrameCallback((_) {
52+
ref.read(applicationThemesDirectoryPathProvider.notifier).state =
53+
StackFileSystem.themesDir!.path;
54+
ref.read(themeProvider.state).state = ref
55+
.read(pThemeService)
56+
.getTheme(themeId: "light")!;
57+
});
58+
}
59+
60+
@override
61+
Widget build(BuildContext context) {
62+
final colorScheme = ref.watch(colorProvider.state).state;
63+
return MaterialApp(
64+
debugShowCheckedModeBanner: false,
65+
title: AppConfig.appName,
66+
theme: ThemeData(
67+
extensions: [colorScheme],
68+
fontFamily: GoogleFonts.inter().fontFamily,
69+
splashColor: Colors.transparent,
70+
),
71+
home: const DowngradeDetectedView(),
72+
);
73+
}
74+
}
75+
76+
/// Error screen shown when this binary is older than the data it is trying to
77+
/// open.
78+
///
79+
/// The user must reinstall the newer version of ${AppConfig.appName} that
80+
/// originally wrote the data. Opening the database with the older binary
81+
/// would cause Isar to silently drop collections unknown to this version,
82+
/// leading to irreversible wallet-data loss.
83+
///
84+
/// Mirrors [AlreadyRunningView]'s layout: themed background, logo, app name
85+
/// heading, subtitle, then the error message.
86+
class DowngradeDetectedView extends ConsumerWidget {
87+
const DowngradeDetectedView({super.key});
88+
89+
static const _errorMessage =
90+
"A newer version of ${AppConfig.appName} previously wrote your data. "
91+
"Opening it with this older build would permanently delete wallet "
92+
"information. Please reinstall the latest version.";
93+
94+
@override
95+
Widget build(BuildContext context, WidgetRef ref) {
96+
final isDesktop = Util.isDesktop;
97+
final colors = Theme.of(context).extension<StackColors>()!;
98+
final stack = ref.watch(
99+
themeProvider.select((value) => value.assets.stack),
100+
);
101+
102+
return Background(
103+
child: Scaffold(
104+
backgroundColor: colors.background,
105+
body: SafeArea(
106+
child: Center(
107+
child: !isDesktop
108+
? Column(
109+
crossAxisAlignment: CrossAxisAlignment.center,
110+
children: [
111+
const Spacer(flex: 2),
112+
Padding(
113+
padding: const EdgeInsets.symmetric(horizontal: 16),
114+
child: ConstrainedBox(
115+
constraints: const BoxConstraints(maxWidth: 300),
116+
child: SizedBox(
117+
width: 266,
118+
height: 266,
119+
child: stack.endsWith(".png")
120+
? Image.file(File(stack))
121+
: SvgPicture.file(
122+
File(stack),
123+
width: 266,
124+
height: 266,
125+
),
126+
),
127+
),
128+
),
129+
const Spacer(flex: 1),
130+
Text(
131+
AppConfig.appName,
132+
textAlign: TextAlign.center,
133+
style: STextStyles.pageTitleH1(context),
134+
),
135+
const SizedBox(height: 8),
136+
Padding(
137+
padding: const EdgeInsets.symmetric(horizontal: 48),
138+
child: Text(
139+
AppConfig.shortDescriptionText,
140+
textAlign: TextAlign.center,
141+
style: STextStyles.subtitle(context),
142+
),
143+
),
144+
const Spacer(flex: 4),
145+
Padding(
146+
padding: const EdgeInsets.symmetric(
147+
horizontal: 16,
148+
vertical: 16,
149+
),
150+
child: Text(
151+
_errorMessage,
152+
textAlign: TextAlign.center,
153+
style: STextStyles.label(context),
154+
),
155+
),
156+
],
157+
)
158+
: SizedBox(
159+
width: 350,
160+
height: 540,
161+
child: Column(
162+
children: [
163+
const Spacer(flex: 2),
164+
const SizedBox(
165+
width: 130,
166+
height: 130,
167+
child: AppIcon(),
168+
),
169+
const Spacer(flex: 42),
170+
Text(
171+
AppConfig.appName,
172+
textAlign: TextAlign.center,
173+
style: STextStyles.pageTitleH1(
174+
context,
175+
).copyWith(fontSize: 40),
176+
),
177+
const Spacer(flex: 24),
178+
Text(
179+
AppConfig.shortDescriptionText,
180+
textAlign: TextAlign.center,
181+
style: STextStyles.subtitle(
182+
context,
183+
).copyWith(fontSize: 24),
184+
),
185+
const Spacer(flex: 42),
186+
Text(
187+
_errorMessage,
188+
textAlign: TextAlign.center,
189+
style: STextStyles.label(
190+
context,
191+
).copyWith(fontSize: 18),
192+
),
193+
const Spacer(flex: 65),
194+
],
195+
),
196+
),
197+
),
198+
),
199+
),
200+
);
201+
}
202+
}

lib/pages_desktop_specific/password/desktop_login_view.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,20 @@ class _DesktopLoginViewState extends ConsumerState<DesktopLoginView> {
6868
key: "hive_data_version",
6969
) as int? ??
7070
0;
71+
72+
// Guard against downgrade: if the stored version is newer than this
73+
// binary understands, opening Isar would silently delete any collection
74+
// unknown to this schema, causing irreversible data loss. Refuse to
75+
// proceed and let the login() catch block surface this to the user.
76+
if (dbVersion > Constants.currentDataVersion) {
77+
throw Exception(
78+
"${AppConfig.appName} was previously run with a newer version "
79+
"(data version $dbVersion, this binary knows up to "
80+
"${Constants.currentDataVersion}). "
81+
"Please reinstall the latest version to avoid data loss.",
82+
);
83+
}
84+
7185
if (dbVersion < Constants.currentDataVersion) {
7286
try {
7387
await DbVersionMigrator().migrate(

0 commit comments

Comments
 (0)