@_nazmiforreal/flutter-ota 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dart-src/packages/cli-tools/.dart_tool/package_config.json +496 -0
  2. package/dart-src/packages/cli-tools/.dart_tool/package_graph.json +743 -0
  3. package/dart-src/packages/cli-tools/lib/src/cli_base.dart +7 -6
  4. package/dart-src/packages/cli-tools/lib/src/commands/build.dart +46 -49
  5. package/dart-src/packages/cli-tools/lib/src/commands/bundle.dart +44 -23
  6. package/dart-src/packages/cli-tools/lib/src/commands/channel.dart +27 -17
  7. package/dart-src/packages/cli-tools/lib/src/commands/console.dart +11 -6
  8. package/dart-src/packages/cli-tools/lib/src/commands/deploy.dart +39 -22
  9. package/dart-src/packages/cli-tools/lib/src/commands/doctor.dart +22 -13
  10. package/dart-src/packages/cli-tools/lib/src/commands/init.dart +117 -36
  11. package/dart-src/packages/cli-tools/lib/src/commands/keys.dart +15 -9
  12. package/dart-src/packages/cli-tools/lib/src/commands/migrate.dart +14 -8
  13. package/dart-src/packages/cli-tools/lib/src/commands/rollback.dart +37 -12
  14. package/dart-src/packages/cli-tools/lib/src/config.dart +13 -3
  15. package/dart-src/packages/cli-tools/lib/src/operations.dart +45 -0
  16. package/dart-src/packages/cli-tools/lib/src/pack.dart +9 -0
  17. package/dart-src/packages/cli-tools/lib/src/ui/ui.dart +234 -0
  18. package/dart-src/packages/cli-tools/pubspec.lock +638 -0
  19. package/dart-src/packages/cli-tools/test/backends_test.dart +207 -0
  20. package/dart-src/packages/cli-tools/test/build_pack_test.dart +101 -0
  21. package/dart-src/packages/cli-tools/test/cli_test.dart +442 -0
  22. package/dart-src/packages/cli-tools/test/mocks/mock_aws_s3_client.dart +83 -0
  23. package/dart-src/packages/cli-tools/test/mocks/mock_d1_client.dart +256 -0
  24. package/dart-src/packages/cli-tools/test/mocks/mock_postgres_client.dart +347 -0
  25. package/dart-src/packages/cli-tools/test/standalone_backend_test.dart +315 -0
  26. package/dart-src/packages/core/test/rollout_test.dart +128 -0
  27. package/dart-src/packages/core/test/semver_test.dart +52 -0
  28. package/dart-src/packages/core/test/semver_vectors.json +1 -0
  29. package/dart-src/packages/core/test/types_test.dart +129 -0
  30. package/dart-src/packages/core/test/uuid_test.dart +38 -0
  31. package/dart-src/plugins/aws/test/aws_database_test.dart +167 -0
  32. package/dart-src/plugins/aws/test/aws_lambda_edge_storage_test.dart +190 -0
  33. package/dart-src/plugins/aws/test/aws_storage_test.dart +73 -0
  34. package/dart-src/plugins/aws/test/mock_aws_s3_client.dart +83 -0
  35. package/dart-src/plugins/cloudflare/test/cloudflare_worker_database_test.dart +122 -0
  36. package/dart-src/plugins/cloudflare/test/d1_bundle_mapper_test.dart +106 -0
  37. package/dart-src/plugins/cloudflare/test/d1_database_test.dart +190 -0
  38. package/dart-src/plugins/cloudflare/test/mock_d1_client.dart +256 -0
  39. package/dart-src/plugins/cloudflare/test/mock_r2_client.dart +83 -0
  40. package/dart-src/plugins/cloudflare/test/r2_storage_test.dart +77 -0
  41. package/dart-src/plugins/plugin-core/test/asset_storage_layout_test.dart +166 -0
  42. package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_store_test.dart +74 -0
  43. package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_test.dart +332 -0
  44. package/dart-src/plugins/plugin-core/test/create_blob_database_plugin_test.dart +711 -0
  45. package/dart-src/plugins/plugin-core/test/create_database_plugin_test.dart +294 -0
  46. package/dart-src/plugins/plugin-core/test/create_storage_plugin_test.dart +192 -0
  47. package/dart-src/plugins/plugin-core/test/generate_min_bundle_id_test.dart +51 -0
  48. package/dart-src/plugins/plugin-core/test/get_update_info_test.dart +1600 -0
  49. package/dart-src/plugins/plugin-core/test/helpers_test.dart +259 -0
  50. package/dart-src/plugins/plugin-core/test/query_bundles_test.dart +139 -0
  51. package/dart-src/plugins/plugin-core/test/request_update_bundle_state_test.dart +86 -0
  52. package/dart-src/plugins/plugin-core/test/uuidv7_test.dart +77 -0
  53. package/dart-src/plugins/postgres/test/mock_postgres_client.dart +297 -0
  54. package/dart-src/plugins/postgres/test/postgres_bundle_mapper_test.dart +140 -0
  55. package/dart-src/plugins/postgres/test/postgres_database_test.dart +181 -0
  56. package/dart-src/plugins/standalone/test/standalone_test.dart +264 -0
  57. package/dart-src/plugins/supabase/test/mock_supabase_client.dart +654 -0
  58. package/dart-src/plugins/supabase/test/supabase_bundle_mapper_test.dart +112 -0
  59. package/dart-src/plugins/supabase/test/supabase_database_test.dart +207 -0
  60. package/dart-src/plugins/supabase/test/supabase_storage_test.dart +175 -0
  61. package/package.json +1 -1
  62. package/scripts/vendor.sh +29 -0
@@ -2,12 +2,17 @@ import 'dart:io';
2
2
 
3
3
  import 'package:args/args.dart';
4
4
  import 'package:args/command_runner.dart';
5
+ import 'package:flutter_patcher_cli/flutter_patcher_cli.dart';
5
6
 
6
- import '../backend.dart';
7
- import '../cli_base.dart';
8
- import '../config.dart';
7
+ import '../ui/ui.dart';
9
8
 
10
9
  /// `flutter_patcher init` — scaffold a project config file.
10
+ ///
11
+ /// Prompts (interactively, when attached to a terminal) for the credentials the
12
+ /// **selected** backend provider needs, defaulting each field to its
13
+ /// corresponding environment variable so CI runs can be non-interactive. The
14
+ /// result is saved as `.flutter_patcher.json` (cwd) or the global config when
15
+ /// `--global` is passed.
11
16
  class InitCommand extends Command<int> {
12
17
  InitCommand({this.config, this.backendOverride});
13
18
 
@@ -23,60 +28,136 @@ class InitCommand extends Command<int> {
23
28
 
24
29
  @override
25
30
  ArgParser get argParser => ArgParser()
26
- ..addOption('provider', defaultsTo: 'supabase', help: 'Backend provider.')
27
- ..addOption('url', help: 'Supabase project URL.')
28
- ..addOption('key', help: 'Supabase service role key.')
29
- ..addOption('anon-key', help: 'Supabase anon key.')
30
- ..addOption('bucket', defaultsTo: 'bundles', help: 'Storage bucket.')
31
- ..addOption('base-path', help: 'Storage base path.')
31
+ ..addOption('provider',
32
+ abbr: 'p', defaultsTo: 'supabase', help: 'Backend provider.')
32
33
  ..addOption('channel', defaultsTo: 'production', help: 'Default channel.')
33
34
  ..addOption('platform', defaultsTo: 'android', help: 'Default platform.')
34
35
  ..addOption('source', defaultsTo: './dist', help: 'Default deploy source.')
36
+ ..addFlag('global', help: 'Write to the global ~/.flutter_patcher config.')
35
37
  ..addFlag('force', abbr: 'f', help: 'Overwrite an existing config.');
36
38
 
37
- String? _prompt(String label, String? current) {
39
+ String? _p(String label, String? current) {
38
40
  if (!stdin.hasTerminal) return current;
39
- stdout.write('$label${current != null ? ' [$current]' : ''}: ');
41
+ final hint = current != null && current.isNotEmpty ? ' [$current]' : '';
42
+ stdout.write('$label$hint: ');
40
43
  final line = stdin.readLineSync();
41
- return (line == null || line.trim().isEmpty) ? current : line.trim();
44
+ final trimmed = (line ?? '').trim();
45
+ return trimmed.isEmpty ? current : trimmed;
42
46
  }
43
47
 
44
48
  @override
45
49
  Future<int> run() => runGuarded(() async {
46
- final file = configCandidates().first;
50
+ final file = argResults!['global'] as bool
51
+ ? configCandidates().last
52
+ : configCandidates().first;
47
53
  if (file.existsSync() && !(argResults!['force'] as bool)) {
48
- throw StateError(
54
+ throw PackException(
49
55
  'Config already exists at ${file.path}. Use --force to overwrite.',
56
+ 64,
50
57
  );
51
58
  }
52
59
 
53
60
  final provider = argResults!['provider'] as String;
54
- final url = _prompt('Supabase URL', argResults!['url'] as String?);
55
- final key = _prompt(
56
- 'Supabase service role key',
57
- argResults!['key'] as String?,
58
- );
59
- final anonKey = argResults!['anon-key'] as String?;
60
- final bucket = argResults!['bucket'] as String? ?? 'bundles';
61
- final basePath = argResults!['base-path'] as String?;
62
61
  final channel = argResults!['channel'] as String? ?? 'production';
63
62
  final platform = argResults!['platform'] as String? ?? 'android';
64
63
  final source = argResults!['source'] as String? ?? './dist';
64
+ final env = Platform.environment;
65
+
66
+ banner('init');
67
+
68
+ late FlutterPatcherConfig cfg;
69
+ switch (provider) {
70
+ case 'postgres':
71
+ cfg = FlutterPatcherConfig(
72
+ provider: provider,
73
+ channel: channel,
74
+ platform: platform,
75
+ source: source,
76
+ supabase: const SupabaseConfigJson(),
77
+ postgres: PostgresConfigJson(
78
+ host: _p('Postgres host', env['POSTGRES_HOST']),
79
+ port: _p('Postgres port', env['POSTGRES_PORT'] ?? '5432'),
80
+ database: _p('Postgres database', env['POSTGRES_DB'] ?? 'postgres'),
81
+ username: _p('Postgres username', env['POSTGRES_USER']),
82
+ password: _p('Postgres password', env['POSTGRES_PASSWORD']),
83
+ sslMode: _p('Postgres sslmode', env['POSTGRES_SSLMODE']),
84
+ basePath: _p('Storage base path', env['POSTGRES_BASE_PATH']),
85
+ servingBaseUrl:
86
+ _p('Serving base url', env['POSTGRES_SERVING_BASE_URL']),
87
+ ),
88
+ );
89
+ case 'cloudflare':
90
+ cfg = FlutterPatcherConfig(
91
+ provider: provider,
92
+ channel: channel,
93
+ platform: platform,
94
+ source: source,
95
+ supabase: const SupabaseConfigJson(),
96
+ cloudflare: CloudflareConfigJson(
97
+ accountId: _p('Cloudflare account id', env['CLOUDFLARE_ACCOUNT_ID']),
98
+ d1DatabaseId:
99
+ _p('Cloudflare D1 database id', env['CLOUDFLARE_D1_DATABASE_ID']),
100
+ apiToken: _p('Cloudflare API token', env['CLOUDFLARE_API_TOKEN']),
101
+ r2Bucket:
102
+ _p('R2 bucket', env['R2_BUCKET'] ?? 'bundles'),
103
+ r2AccessKeyId: _p('R2 access key id', env['R2_ACCESS_KEY_ID']),
104
+ r2SecretAccessKey:
105
+ _p('R2 secret access key', env['R2_SECRET_ACCESS_KEY']),
106
+ r2BasePath: _p('R2 base path', env['R2_BASE_PATH']),
107
+ ),
108
+ );
109
+ case 'aws':
110
+ cfg = FlutterPatcherConfig(
111
+ provider: provider,
112
+ channel: channel,
113
+ platform: platform,
114
+ source: source,
115
+ supabase: const SupabaseConfigJson(),
116
+ aws: AwsConfigJson(
117
+ bucket: _p('AWS bucket', env['AWS_BUCKET']),
118
+ region: _p('AWS region', env['AWS_REGION']),
119
+ accessKeyId: _p('AWS access key id', env['AWS_ACCESS_KEY_ID']),
120
+ secretAccessKey:
121
+ _p('AWS secret access key', env['AWS_SECRET_ACCESS_KEY']),
122
+ endpoint: _p('AWS endpoint', env['AWS_ENDPOINT']),
123
+ basePath: _p('AWS base path', env['AWS_BASE_PATH']),
124
+ sessionToken: _p('AWS session token', env['AWS_SESSION_TOKEN']),
125
+ ),
126
+ );
127
+ case 'standalone':
128
+ cfg = FlutterPatcherConfig(
129
+ provider: provider,
130
+ channel: channel,
131
+ platform: platform,
132
+ source: source,
133
+ supabase: const SupabaseConfigJson(),
134
+ standalone: StandaloneConfigJson(
135
+ baseUrl: _p('Standalone base url', env['STANDALONE_BASE_URL']),
136
+ ),
137
+ );
138
+ case 'supabase':
139
+ default:
140
+ cfg = FlutterPatcherConfig(
141
+ provider: 'supabase',
142
+ channel: channel,
143
+ platform: platform,
144
+ source: source,
145
+ supabase: SupabaseConfigJson(
146
+ url: _p('Supabase URL', env['SUPABASE_URL']),
147
+ serviceRoleKey:
148
+ _p('Supabase service role key', env['SUPABASE_SERVICE_ROLE_KEY']),
149
+ anonKey: _p('Supabase anon key', env['SUPABASE_ANON_KEY']),
150
+ bucket: _p('Storage bucket', env['SUPABASE_BUCKET'] ?? 'bundles'),
151
+ basePath: _p('Storage base path', env['SUPABASE_BASE_PATH']),
152
+ ),
153
+ );
154
+ }
65
155
 
66
- final cfg = FlutterPatcherConfig(
67
- provider: provider,
68
- supabase: SupabaseConfigJson(
69
- url: url,
70
- serviceRoleKey: key,
71
- anonKey: anonKey,
72
- bucket: bucket,
73
- basePath: basePath,
74
- ),
75
- channel: channel,
76
- platform: platform,
77
- source: source,
156
+ saveConfig(cfg, global: argResults!['global'] as bool);
157
+ step('Wrote ${file.path}');
158
+ stdout.writeln(
159
+ ' ${yellow('⚠')} Secrets are stored in plaintext. Restrict file '
160
+ 'permissions and use a secrets manager in production.',
78
161
  );
79
- saveConfig(cfg);
80
- stdout.writeln('Wrote ${file.path}');
81
162
  });
82
163
  }
@@ -1,5 +1,4 @@
1
1
  import 'dart:convert';
2
- import 'dart:io';
3
2
 
4
3
  import 'package:args/args.dart';
5
4
  import 'package:args/command_runner.dart';
@@ -8,6 +7,7 @@ import '../backend.dart';
8
7
  import '../cli_base.dart';
9
8
  import '../config.dart';
10
9
  import '../sign.dart';
10
+ import '../ui/ui.dart';
11
11
 
12
12
  /// `flutter_patcher keys` — generate an Ed25519 keypair for bundle signing.
13
13
  class KeysCommand extends Command<int> {
@@ -29,12 +29,19 @@ class KeysCommand extends Command<int> {
29
29
 
30
30
  @override
31
31
  Future<int> run() => runGuarded(() async {
32
- final (privateB64, publicB64) = await generateEd25519KeyPair();
33
- stdout.writeln('Private key (keep secret, use with `deploy --key`):');
34
- stdout.writeln(' $privateB64');
35
- stdout.writeln('');
36
- stdout.writeln('Public key (safe to embed / configure on device):');
37
- stdout.writeln(' $publicB64');
32
+ banner('keys');
33
+ final (privateB64, publicB64) = await spinner(
34
+ () => generateEd25519KeyPair(),
35
+ 'Generating Ed25519 keypair',
36
+ done: 'Keypair generated',
37
+ );
38
+ box('ed25519 keys', [
39
+ '${bold('private')} (keep secret — use with `deploy --key`):',
40
+ ' ${red(privateB64)}',
41
+ '',
42
+ '${bold('public')} (safe to embed / configure on device):',
43
+ ' ${green(publicB64)}',
44
+ ]);
38
45
  if (argResults!['save'] as bool) {
39
46
  final file = configCandidates().first;
40
47
  final json = file.existsSync() && file.readAsStringSync().trim().isNotEmpty
@@ -42,8 +49,7 @@ class KeysCommand extends Command<int> {
42
49
  : <String, dynamic>{};
43
50
  writePath(json, 'publicKey', publicB64);
44
51
  _save(json);
45
- stdout.writeln('');
46
- stdout.writeln('Saved public key to ${file.path}');
52
+ step('Saved public key to ${file.path}');
47
53
  }
48
54
  });
49
55
 
@@ -2,10 +2,11 @@ import 'dart:io';
2
2
 
3
3
  import 'package:args/args.dart';
4
4
  import 'package:args/command_runner.dart';
5
+ import 'package:flutter_patcher_cli/flutter_patcher_cli.dart';
5
6
  import 'package:path/path.dart' as p;
6
7
  import 'package:postgres/postgres.dart';
7
8
 
8
- import '../cli_base.dart';
9
+ import '../ui/ui.dart';
9
10
 
10
11
  /// `flutter_patcher migrate` — run backend SQL migrations.
11
12
  class MigrateCommand extends Command<int> {
@@ -63,6 +64,7 @@ class MigrateCommand extends Command<int> {
63
64
  }
64
65
 
65
66
  if (argResults!['dry-run'] as bool) {
67
+ banner('migrate · dry-run');
66
68
  for (final f in files) {
67
69
  stdout.writeln('--- ${p.basename(f.path)} ---');
68
70
  stdout.writeln(f.readAsStringSync());
@@ -73,11 +75,14 @@ class MigrateCommand extends Command<int> {
73
75
  final url = argResults!['database-url'] as String? ??
74
76
  Platform.environment['DATABASE_URL'];
75
77
  if (provider == 'cloudflare' || provider == 'aws') {
76
- stdout.writeln(
77
- 'Migrations for "$provider" are applied via its platform tooling '
78
- '(e.g. `wrangler d1 execute` for cloudflare, or the AWS console for '
79
- 'S3/DynamoDB). The SQL above shows the intended schema.',
80
- );
78
+ banner('migrate');
79
+ box('migrate', [
80
+ 'Migrations for "$provider" are applied via its platform tooling:',
81
+ ' cloudflare `wrangler d1 execute`',
82
+ ' • aws → AWS console (S3/DynamoDB)',
83
+ '',
84
+ 'The SQL files under ${dir} show the intended schema.',
85
+ ]);
81
86
  return;
82
87
  }
83
88
  if (url == null || url.isEmpty) {
@@ -94,6 +99,7 @@ class MigrateCommand extends Command<int> {
94
99
  ? uri.userInfo.split(':').last
95
100
  : null,
96
101
  );
102
+ banner('migrate');
97
103
  final conn = await Connection.open(
98
104
  endpoint,
99
105
  settings: ConnectionSettings(sslMode: SslMode.disable),
@@ -112,7 +118,7 @@ class MigrateCommand extends Command<int> {
112
118
  queryMode: QueryMode.simple,
113
119
  );
114
120
  if (existing.isNotEmpty) {
115
- stdout.writeln('skip $name (already applied)');
121
+ stdout.writeln(' ${dim('skip')} $name (already applied)');
116
122
  continue;
117
123
  }
118
124
  final statements = _splitStatements(file.readAsStringSync());
@@ -123,7 +129,7 @@ class MigrateCommand extends Command<int> {
123
129
  "INSERT INTO _flutter_patcher_migrations(name) VALUES ('$escaped')",
124
130
  queryMode: QueryMode.simple,
125
131
  );
126
- stdout.writeln('applied $name');
132
+ step('applied $name');
127
133
  }
128
134
  } finally {
129
135
  await conn.close();
@@ -1,14 +1,11 @@
1
- import 'dart:io';
2
-
3
1
  import 'package:args/args.dart';
4
2
  import 'package:args/command_runner.dart';
3
+ import 'package:flutter_patcher_cli/flutter_patcher_cli.dart';
4
+ import 'package:flutter_patcher_core/flutter_patcher_core.dart';
5
5
 
6
- import '../backend.dart';
7
- import '../cli_base.dart';
8
- import '../config.dart';
9
- import '../operations.dart';
6
+ import '../ui/ui.dart';
10
7
 
11
- /// `flutter_patcher rollback` — disable the latest enabled bundle on a channel.
8
+ /// `flutter_patcher rollback` — roll a channel back to a previous bundle.
12
9
  class RollbackCommand extends Command<int> {
13
10
  RollbackCommand({this.config, this.backendOverride});
14
11
 
@@ -20,22 +17,50 @@ class RollbackCommand extends Command<int> {
20
17
 
21
18
  @override
22
19
  String get description =>
23
- 'Disable the latest enabled bundle on a channel (roll back).';
20
+ 'Roll a channel back: disable the latest enabled bundle (or a specific --bundle-id).';
24
21
 
25
22
  @override
26
23
  ArgParser get argParser => ArgParser()
27
24
  ..addOption('backend', abbr: 'b', help: 'Backend provider.')
28
- ..addOption('channel', abbr: 'c', help: 'Channel to roll back.');
25
+ ..addOption('channel', abbr: 'c', help: 'Channel to roll back.')
26
+ ..addOption('bundle-id', help: 'Roll back to this specific bundle id.')
27
+ ..addOption('platform', abbr: 'p', help: 'Platform filter (e.g. android).');
29
28
 
30
29
  @override
31
30
  Future<int> run() => runGuarded(() async {
32
31
  final channel = argResults!['channel'] as String?;
33
32
  if (channel == null || channel.isEmpty) {
34
- throw StateError('Usage: flutter_patcher rollback --channel <channel>');
33
+ throw PackException(
34
+ 'Usage: flutter_patcher rollback --channel <channel> [--bundle-id <id>]',
35
+ 64,
36
+ );
35
37
  }
38
+ final bundleId = argResults!['bundle-id'] as String?;
39
+ final platformRaw = argResults!['platform'] as String?;
40
+ final platform =
41
+ platformRaw == null ? null : Platform.fromValue(platformRaw);
42
+
36
43
  final cfg = effectiveConfig(config ?? loadConfig(), argResults!);
37
44
  final backend = requireBackend(cfg, override: backendOverride);
38
- final id = await rollbackChannel(backend, channel);
39
- stdout.writeln('Rolled back channel "$channel" (disabled bundle $id)');
45
+
46
+ banner('rollback');
47
+ final (disabled, live) = await spinner(
48
+ () async {
49
+ if (bundleId != null && bundleId.isNotEmpty) {
50
+ return rollbackToBundle(backend, channel, bundleId,
51
+ platform: platform);
52
+ }
53
+ final id = await rollbackChannel(backend, channel);
54
+ final nowLive = (await getChannel(backend, channel))?.id ?? '';
55
+ return (<String>[id], nowLive);
56
+ },
57
+ 'Rolling back channel "$channel"',
58
+ done: 'Rolled back',
59
+ );
60
+ box('rollback', [
61
+ kv('channel', channel),
62
+ kv('now live', cyan(live)),
63
+ kv('disabled', disabled.isEmpty ? dim('(none)') : disabled.join(', ')),
64
+ ]);
40
65
  });
41
66
  }
@@ -161,13 +161,23 @@ FlutterPatcherConfig? loadConfig() {
161
161
  return null;
162
162
  }
163
163
 
164
- /// Persist a config to the project file (cwd).
165
- void saveConfig(FlutterPatcherConfig config) {
166
- final file = configCandidates().first;
164
+ /// Persist a config. By default writes the project file (cwd); pass
165
+ /// [global: true] to write the global `~/.flutter_patcher/config.json`.
166
+ void saveConfig(FlutterPatcherConfig config, {bool global = false}) {
167
+ final file = global ? configCandidates().last : configCandidates().first;
167
168
  file.parent.createSync(recursive: true);
168
169
  file.writeAsStringSync(
169
170
  const JsonEncoder.withIndent(' ').convert(config.toJson()),
170
171
  );
172
+ // Config may contain secrets (service-role keys, DB passwords, tokens). On
173
+ // POSIX systems tighten to owner-only read/write so they aren't world-readable.
174
+ if (!Platform.isWindows) {
175
+ try {
176
+ Process.runSync('chmod', ['600', file.path]);
177
+ } catch (_) {
178
+ // Non-fatal: chmod may be unavailable in some sandboxes.
179
+ }
180
+ }
171
181
  }
172
182
 
173
183
  /// Nested dot-path get/set helpers used by `config get/set`.
@@ -193,6 +193,51 @@ Future<String> rollbackChannel(Backend backend, String channel) async {
193
193
  return target.id;
194
194
  }
195
195
 
196
+ /// Roll back a channel to a specific [targetBundleId]: disable every enabled
197
+ /// bundle newer than it (by creation order) and ensure the target is enabled.
198
+ ///
199
+ /// Returns `(disabledBundleIds, liveBundleId)`.
200
+ Future<(List<String>, String)> rollbackToBundle(
201
+ Backend backend,
202
+ String channel,
203
+ String targetBundleId, {
204
+ Platform? platform,
205
+ }) async {
206
+ final res = await backend.db.getBundles(
207
+ DatabaseBundleQueryOptions(
208
+ where: DatabaseBundleQueryWhere(channel: channel, platform: platform),
209
+ orderBy: const DatabaseBundleQueryOrder(direction: 'desc'),
210
+ limit: 100,
211
+ ),
212
+ );
213
+ final bundles = res.data;
214
+ Bundle? target;
215
+ for (final b in bundles) {
216
+ if (b.id == targetBundleId) {
217
+ target = b;
218
+ break;
219
+ }
220
+ }
221
+ if (target == null) {
222
+ throw StateError(
223
+ 'Bundle "$targetBundleId" not found on channel "$channel".',
224
+ );
225
+ }
226
+ final targetIndex = bundles.indexOf(target);
227
+ final disabled = <String>[];
228
+ for (var i = 0; i < targetIndex; i++) {
229
+ if (bundles[i].enabled) {
230
+ disabled.add(bundles[i].id);
231
+ await backend.db.updateBundle(bundles[i].id, {'enabled': false});
232
+ }
233
+ }
234
+ if (!target.enabled) {
235
+ await backend.db.updateBundle(target.id, {'enabled': true});
236
+ }
237
+ await backend.db.commitBundle();
238
+ return (disabled, target.id);
239
+ }
240
+
196
241
  /// List channels configured on the backend.
197
242
  Future<List<String>> listChannels(Backend backend) => backend.db.getChannels();
198
243
 
@@ -42,6 +42,7 @@ Future<PackResult> packPatch({
42
42
  String? abi,
43
43
  List<String> requestedAssets = const [],
44
44
  required String out,
45
+ void Function(int done, int total, String label)? onProgress,
45
46
  }) async {
46
47
  final apkFile = File(apkPath);
47
48
  if (!apkFile.existsSync()) {
@@ -72,6 +73,7 @@ Future<PackResult> packPatch({
72
73
  version: version,
73
74
  targetVersionCode: targetVersionCode,
74
75
  requestedAssets: requestedAssets,
76
+ onProgress: onProgress,
75
77
  );
76
78
  return result;
77
79
  }
@@ -83,6 +85,7 @@ PackResult _writePatchPackage({
83
85
  required String version,
84
86
  required int targetVersionCode,
85
87
  required List<String> requestedAssets,
88
+ void Function(int done, int total, String label)? onProgress,
86
89
  }) {
87
90
  final patchFiles = <String, _PatchAssetFile>{};
88
91
  final operations = <Map<String, dynamic>>[];
@@ -169,9 +172,14 @@ PackResult _writePatchPackage({
169
172
 
170
173
  final package = Archive()
171
174
  ..addFile(_jsonArchiveFile('manifest.json', zipManifest));
175
+ final progressTotal = libs.length + 1;
176
+ onProgress?.call(0, progressTotal, 'libapp.so');
177
+ var progressDone = 0;
172
178
  for (final (currentAbi, soBytes) in libs) {
173
179
  package.addFile(
174
180
  ArchiveFile('lib/$currentAbi/libapp.so', soBytes.length, soBytes));
181
+ progressDone++;
182
+ onProgress?.call(progressDone, progressTotal, 'lib/$currentAbi/libapp.so');
175
183
  }
176
184
 
177
185
  if (requestedAssets.isNotEmpty) {
@@ -194,6 +202,7 @@ PackResult _writePatchPackage({
194
202
  final List<int> packageBytes = ZipEncoder().encode(package) ?? const <int>[];
195
203
  final outZip = File('${outDir.path}/patch.zip');
196
204
  outZip.writeAsBytesSync(packageBytes);
205
+ onProgress?.call(progressTotal, progressTotal, 'patch.zip');
197
206
 
198
207
  final payloadMd5 = md5.convert(packageBytes).toString();
199
208
  final outerManifest = <String, dynamic>{