@_nazmiforreal/flutter-ota 0.1.0 → 0.1.2

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 (167) hide show
  1. package/bin/flutter-patcher.js +48 -39
  2. package/dart-src/packages/cli-tools/.gitkeep +0 -0
  3. package/dart-src/packages/cli-tools/bin/flutter_patcher.dart +9 -0
  4. package/dart-src/packages/cli-tools/lib/flutter_patcher_cli.dart +73 -0
  5. package/dart-src/packages/cli-tools/lib/src/backend.dart +162 -0
  6. package/dart-src/packages/cli-tools/lib/src/cli_base.dart +56 -0
  7. package/dart-src/packages/cli-tools/lib/src/commands/build.dart +99 -0
  8. package/dart-src/packages/cli-tools/lib/src/commands/bundle.dart +151 -0
  9. package/dart-src/packages/cli-tools/lib/src/commands/channel.dart +140 -0
  10. package/dart-src/packages/cli-tools/lib/src/commands/config_command.dart +113 -0
  11. package/dart-src/packages/cli-tools/lib/src/commands/console.dart +55 -0
  12. package/dart-src/packages/cli-tools/lib/src/commands/deploy.dart +95 -0
  13. package/dart-src/packages/cli-tools/lib/src/commands/doctor.dart +48 -0
  14. package/dart-src/packages/cli-tools/lib/src/commands/fingerprint.dart +29 -0
  15. package/dart-src/packages/cli-tools/lib/src/commands/init.dart +82 -0
  16. package/dart-src/packages/cli-tools/lib/src/commands/keys.dart +55 -0
  17. package/dart-src/packages/cli-tools/lib/src/commands/migrate.dart +132 -0
  18. package/dart-src/packages/cli-tools/lib/src/commands/rollback.dart +41 -0
  19. package/dart-src/packages/cli-tools/lib/src/config.dart +715 -0
  20. package/dart-src/packages/cli-tools/lib/src/operations.dart +206 -0
  21. package/dart-src/packages/cli-tools/lib/src/pack.dart +417 -0
  22. package/dart-src/packages/cli-tools/lib/src/sign.dart +60 -0
  23. package/dart-src/packages/cli-tools/lib/src/util.dart +86 -0
  24. package/dart-src/packages/cli-tools/migrations/cloudflare/0001_hot-updater_init.sql +16 -0
  25. package/dart-src/packages/cli-tools/migrations/cloudflare/0002_hot-updater_0.13.0.sql +8 -0
  26. package/dart-src/packages/cli-tools/migrations/cloudflare/0003_hot-updater_0.18.0.sql +38 -0
  27. package/dart-src/packages/cli-tools/migrations/cloudflare/0004_hot-updater_0.29.0.sql +11 -0
  28. package/dart-src/packages/cli-tools/migrations/cloudflare/0005_hot-updater_0.31.0.sql +24 -0
  29. package/dart-src/packages/cli-tools/migrations/cloudflare/bundles.sql +42 -0
  30. package/dart-src/packages/cli-tools/migrations/supabase/20250103114225_init.sql +228 -0
  31. package/dart-src/packages/cli-tools/migrations/supabase/20250314000000_hot-updater_0.13.0.sql +134 -0
  32. package/dart-src/packages/cli-tools/migrations/supabase/20250516000000_hot-updater_0.18.0.sql +194 -0
  33. package/dart-src/packages/cli-tools/migrations/supabase/20251014000000_hot-updater_0.21.0.sql +184 -0
  34. package/dart-src/packages/cli-tools/migrations/supabase/20260401000000_hot-updater_0.29.0.sql +503 -0
  35. package/dart-src/packages/cli-tools/migrations/supabase/20260414000000_hot-updater_0.30.0.sql +50 -0
  36. package/dart-src/packages/cli-tools/migrations/supabase/20260422000000_hot-updater_0.31.0.sql +20 -0
  37. package/dart-src/packages/cli-tools/migrations/supabase/20260520014100_hot-updater_rls.sql +48 -0
  38. package/dart-src/packages/cli-tools/pubspec.yaml +37 -0
  39. package/dart-src/packages/core/lib/flutter_patcher_core.dart +29 -0
  40. package/dart-src/packages/core/lib/src/app_update_info.dart +111 -0
  41. package/dart-src/packages/core/lib/src/bundle.dart +191 -0
  42. package/dart-src/packages/core/lib/src/bundle_artifacts.dart +39 -0
  43. package/dart-src/packages/core/lib/src/bundle_patch_artifact.dart +47 -0
  44. package/dart-src/packages/core/lib/src/changed_asset.dart +77 -0
  45. package/dart-src/packages/core/lib/src/get_bundles_args.dart +78 -0
  46. package/dart-src/packages/core/lib/src/metadata.dart +31 -0
  47. package/dart-src/packages/core/lib/src/patch_info.dart +284 -0
  48. package/dart-src/packages/core/lib/src/platform.dart +15 -0
  49. package/dart-src/packages/core/lib/src/rollout.dart +209 -0
  50. package/dart-src/packages/core/lib/src/semver.dart +70 -0
  51. package/dart-src/packages/core/lib/src/semver_range.dart +358 -0
  52. package/dart-src/packages/core/lib/src/semver_version.dart +94 -0
  53. package/dart-src/packages/core/lib/src/status.dart +24 -0
  54. package/dart-src/packages/core/lib/src/strategy.dart +10 -0
  55. package/dart-src/packages/core/lib/src/update_bundle_params.dart +30 -0
  56. package/dart-src/packages/core/lib/src/uuid.dart +45 -0
  57. package/dart-src/packages/core/pubspec.yaml +9 -0
  58. package/dart-src/packages/core/tool/dbg.dart +6 -0
  59. package/dart-src/plugins/aws/.gitkeep +0 -0
  60. package/dart-src/plugins/aws/lib/flutter_patcher_aws.dart +12 -0
  61. package/dart-src/plugins/aws/lib/src/aws_cloudfront_client.dart +207 -0
  62. package/dart-src/plugins/aws/lib/src/aws_cloudfront_signer.dart +130 -0
  63. package/dart-src/plugins/aws/lib/src/aws_config.dart +59 -0
  64. package/dart-src/plugins/aws/lib/src/aws_database.dart +198 -0
  65. package/dart-src/plugins/aws/lib/src/aws_lambda_edge_storage.dart +72 -0
  66. package/dart-src/plugins/aws/lib/src/aws_s3_client.dart +312 -0
  67. package/dart-src/plugins/aws/lib/src/aws_ssm_client.dart +125 -0
  68. package/dart-src/plugins/aws/lib/src/aws_storage.dart +20 -0
  69. package/dart-src/plugins/aws/lib/src/aws_storage_profile.dart +123 -0
  70. package/dart-src/plugins/aws/lib/src/with_cloudfront_signed_url.dart +136 -0
  71. package/dart-src/plugins/aws/pubspec.yaml +22 -0
  72. package/dart-src/plugins/cloudflare/.gitkeep +0 -0
  73. package/dart-src/plugins/cloudflare/lib/flutter_patcher_cloudflare.dart +18 -0
  74. package/dart-src/plugins/cloudflare/lib/src/cloudflare_worker_database.dart +29 -0
  75. package/dart-src/plugins/cloudflare/lib/src/d1_build_where.dart +93 -0
  76. package/dart-src/plugins/cloudflare/lib/src/d1_bundle_mapper.dart +131 -0
  77. package/dart-src/plugins/cloudflare/lib/src/d1_client.dart +66 -0
  78. package/dart-src/plugins/cloudflare/lib/src/d1_config.dart +30 -0
  79. package/dart-src/plugins/cloudflare/lib/src/d1_database.dart +16 -0
  80. package/dart-src/plugins/cloudflare/lib/src/d1_database_plugin.dart +314 -0
  81. package/dart-src/plugins/cloudflare/lib/src/r2_config.dart +50 -0
  82. package/dart-src/plugins/cloudflare/lib/src/r2_s3_client.dart +287 -0
  83. package/dart-src/plugins/cloudflare/lib/src/r2_storage.dart +23 -0
  84. package/dart-src/plugins/cloudflare/lib/src/r2_storage_profile.dart +110 -0
  85. package/dart-src/plugins/cloudflare/migrations/0001_hot-updater_init.sql +16 -0
  86. package/dart-src/plugins/cloudflare/migrations/0002_hot-updater_0.13.0.sql +8 -0
  87. package/dart-src/plugins/cloudflare/migrations/0003_hot-updater_0.18.0.sql +38 -0
  88. package/dart-src/plugins/cloudflare/migrations/0004_hot-updater_0.29.0.sql +11 -0
  89. package/dart-src/plugins/cloudflare/migrations/0005_hot-updater_0.31.0.sql +24 -0
  90. package/dart-src/plugins/cloudflare/pubspec.yaml +20 -0
  91. package/dart-src/plugins/cloudflare/sql/bundles.sql +42 -0
  92. package/dart-src/plugins/plugin-core/lib/flutter_patcher_plugin_core.dart +42 -0
  93. package/dart-src/plugins/plugin-core/lib/src/asset_storage_layout.dart +94 -0
  94. package/dart-src/plugins/plugin-core/lib/src/bundle_storage_layout.dart +44 -0
  95. package/dart-src/plugins/plugin-core/lib/src/bundle_unit_of_work.dart +259 -0
  96. package/dart-src/plugins/plugin-core/lib/src/bundle_unit_of_work_store.dart +20 -0
  97. package/dart-src/plugins/plugin-core/lib/src/calculate_pagination.dart +29 -0
  98. package/dart-src/plugins/plugin-core/lib/src/compression_format.dart +63 -0
  99. package/dart-src/plugins/plugin-core/lib/src/content_addressed_assets.dart +22 -0
  100. package/dart-src/plugins/plugin-core/lib/src/create_blob_database_plugin.dart +853 -0
  101. package/dart-src/plugins/plugin-core/lib/src/create_database_plugin.dart +525 -0
  102. package/dart-src/plugins/plugin-core/lib/src/create_storage_key_builder.dart +15 -0
  103. package/dart-src/plugins/plugin-core/lib/src/create_storage_plugin.dart +130 -0
  104. package/dart-src/plugins/plugin-core/lib/src/filter_compatible_app_versions.dart +18 -0
  105. package/dart-src/plugins/plugin-core/lib/src/generate_min_bundle_id.dart +19 -0
  106. package/dart-src/plugins/plugin-core/lib/src/get_update_info.dart +241 -0
  107. package/dart-src/plugins/plugin-core/lib/src/is_object.dart +5 -0
  108. package/dart-src/plugins/plugin-core/lib/src/legacy_asset_storage_layout.dart +9 -0
  109. package/dart-src/plugins/plugin-core/lib/src/paginate_bundles.dart +112 -0
  110. package/dart-src/plugins/plugin-core/lib/src/parse_storage_uri.dart +44 -0
  111. package/dart-src/plugins/plugin-core/lib/src/query_bundles.dart +70 -0
  112. package/dart-src/plugins/plugin-core/lib/src/request_update_bundle_state.dart +62 -0
  113. package/dart-src/plugins/plugin-core/lib/src/resolve_update_info_from_bundles.dart +43 -0
  114. package/dart-src/plugins/plugin-core/lib/src/storage_profile.dart +27 -0
  115. package/dart-src/plugins/plugin-core/lib/src/types.dart +454 -0
  116. package/dart-src/plugins/plugin-core/lib/src/uuidv7.dart +41 -0
  117. package/dart-src/plugins/plugin-core/pubspec.yaml +15 -0
  118. package/dart-src/plugins/postgres/.gitkeep +0 -0
  119. package/dart-src/plugins/postgres/lib/flutter_patcher_postgres.dart +17 -0
  120. package/dart-src/plugins/postgres/lib/src/postgres_bundle_mapper.dart +130 -0
  121. package/dart-src/plugins/postgres/lib/src/postgres_client.dart +90 -0
  122. package/dart-src/plugins/postgres/lib/src/postgres_config.dart +35 -0
  123. package/dart-src/plugins/postgres/lib/src/postgres_database.dart +302 -0
  124. package/dart-src/plugins/postgres/lib/src/postgres_get_update_info.dart +120 -0
  125. package/dart-src/plugins/postgres/lib/src/postgres_storage.dart +205 -0
  126. package/dart-src/plugins/postgres/lib/src/postgres_types.dart +132 -0
  127. package/dart-src/plugins/postgres/pubspec.yaml +18 -0
  128. package/dart-src/plugins/postgres/sql/bundles.sql +45 -0
  129. package/dart-src/plugins/postgres/sql/get_target_app_version_list.sql +21 -0
  130. package/dart-src/plugins/postgres/sql/get_update_info.spec.ts +171 -0
  131. package/dart-src/plugins/postgres/sql/get_update_info_by_app_version.sql +126 -0
  132. package/dart-src/plugins/postgres/sql/get_update_info_by_fingerprint_hash.sql +125 -0
  133. package/dart-src/plugins/postgres/sql/hash_user_id.sql +30 -0
  134. package/dart-src/plugins/postgres/sql/is_device_eligible.sql +237 -0
  135. package/dart-src/plugins/postgres/sql/prepareSql.ts +11 -0
  136. package/dart-src/plugins/standalone/.gitkeep +0 -0
  137. package/dart-src/plugins/standalone/lib/flutter_patcher_standalone.dart +12 -0
  138. package/dart-src/plugins/standalone/lib/src/standalone_config.dart +107 -0
  139. package/dart-src/plugins/standalone/lib/src/standalone_database.dart +233 -0
  140. package/dart-src/plugins/standalone/lib/src/standalone_storage.dart +175 -0
  141. package/dart-src/plugins/standalone/pubspec.yaml +18 -0
  142. package/dart-src/plugins/supabase/lib/edge.dart +10 -0
  143. package/dart-src/plugins/supabase/lib/flutter_patcher_supabase.dart +21 -0
  144. package/dart-src/plugins/supabase/lib/src/supabase_bundle_mapper.dart +129 -0
  145. package/dart-src/plugins/supabase/lib/src/supabase_client_adapter.dart +154 -0
  146. package/dart-src/plugins/supabase/lib/src/supabase_client_http.dart +516 -0
  147. package/dart-src/plugins/supabase/lib/src/supabase_config.dart +34 -0
  148. package/dart-src/plugins/supabase/lib/src/supabase_database.dart +422 -0
  149. package/dart-src/plugins/supabase/lib/src/supabase_edge_function_database.dart +32 -0
  150. package/dart-src/plugins/supabase/lib/src/supabase_edge_function_storage.dart +99 -0
  151. package/dart-src/plugins/supabase/lib/src/supabase_signed_url_batcher.dart +145 -0
  152. package/dart-src/plugins/supabase/lib/src/supabase_storage.dart +298 -0
  153. package/dart-src/plugins/supabase/lib/src/types.dart +130 -0
  154. package/dart-src/plugins/supabase/pubspec.yaml +19 -0
  155. package/dart-src/plugins/supabase/supabase/edge-functions/index.ts +44 -0
  156. package/dart-src/plugins/supabase/supabase/edge-functions/runtime.docker.integration.spec.ts +968 -0
  157. package/dart-src/plugins/supabase/supabase/migrations/20250103114225_init.sql +228 -0
  158. package/dart-src/plugins/supabase/supabase/migrations/20250314000000_hot-updater_0.13.0.sql +134 -0
  159. package/dart-src/plugins/supabase/supabase/migrations/20250516000000_hot-updater_0.18.0.sql +194 -0
  160. package/dart-src/plugins/supabase/supabase/migrations/20251014000000_hot-updater_0.21.0.sql +184 -0
  161. package/dart-src/plugins/supabase/supabase/migrations/20260401000000_hot-updater_0.29.0.sql +503 -0
  162. package/dart-src/plugins/supabase/supabase/migrations/20260414000000_hot-updater_0.30.0.sql +50 -0
  163. package/dart-src/plugins/supabase/supabase/migrations/20260422000000_hot-updater_0.31.0.sql +20 -0
  164. package/dart-src/plugins/supabase/supabase/migrations/20260520014100_hot-updater_rls.sql +48 -0
  165. package/package.json +2 -1
  166. package/scripts/postinstall.js +53 -25
  167. /package/bin/{flutter-patcher-linux → flutter-patcher-linux-x64} +0 -0
@@ -0,0 +1,314 @@
1
+ import 'dart:convert' show jsonEncode;
2
+
3
+ import 'package:flutter_patcher_core/flutter_patcher_core.dart'
4
+ show
5
+ AppVersionGetBundlesArgs,
6
+ Bundle,
7
+ FingerprintGetBundlesArgs,
8
+ GetBundlesArgs,
9
+ UpdateInfo,
10
+ UpdateStrategy;
11
+ import 'package:flutter_patcher_plugin_core/flutter_patcher_plugin_core.dart'
12
+ show
13
+ AbstractDatabasePlugin,
14
+ BundleChange,
15
+ BundleChangeOperation,
16
+ DatabaseBundleIdFilter,
17
+ DatabaseBundleQueryOptions,
18
+ DatabaseBundleQueryOrder,
19
+ DatabaseBundleQueryWhere,
20
+ Paginated,
21
+ calculatePagination,
22
+ filterCompatibleAppVersions,
23
+ resolveUpdateInfoFromBundles,
24
+ ResolveUpdateInfoFromBundlesOptions;
25
+
26
+ import 'd1_bundle_mapper.dart'
27
+ show
28
+ bundleToPatchRows,
29
+ defaultRolloutCohortCount,
30
+ transformRowToBundle;
31
+ import 'd1_build_where.dart' show buildWhereClause;
32
+ import 'd1_client.dart' show D1ClientLike;
33
+
34
+ /// Shared D1 database plugin logic used by both [d1Database] (REST API client)
35
+ /// and [cloudflareWorkerDatabase] (in-process D1 binding).
36
+ ///
37
+ /// Issues raw SQLite statements and resolves update information in-process via
38
+ /// [resolveUpdateInfoFromBundles] (no SQL stored procedures, unlike the
39
+ /// Supabase/Postgres backends).
40
+ class D1DatabasePlugin implements AbstractDatabasePlugin {
41
+ D1DatabasePlugin(this._client);
42
+
43
+ final D1ClientLike _client;
44
+
45
+ Future<Map<String, List<Map<String, dynamic>>>> _getPatchMap(
46
+ List<String> bundleIds,
47
+ ) async {
48
+ final patchMap = <String, List<Map<String, dynamic>>>{};
49
+ if (bundleIds.isEmpty) return patchMap;
50
+
51
+ final rows = await _client.query(
52
+ 'SELECT * FROM bundle_patches '
53
+ 'WHERE bundle_id IN (SELECT value FROM json_each(?)) '
54
+ 'ORDER BY order_index ASC, base_bundle_id ASC',
55
+ [jsonEncode(bundleIds)],
56
+ );
57
+
58
+ for (final row in rows) {
59
+ (patchMap[row['bundle_id'] as String] ??= []).add(row);
60
+ }
61
+ return patchMap;
62
+ }
63
+
64
+ Future<int> _getTotalCount(DatabaseBundleQueryWhere conditions) async {
65
+ final where = buildWhereClause(conditions);
66
+ final rows = await _client.query(
67
+ 'SELECT COUNT(*) as total FROM bundles${where.sql}',
68
+ where.params,
69
+ );
70
+ return (rows.firstOrNull?['total'] as num?)?.toInt() ?? 0;
71
+ }
72
+
73
+ Future<List<Bundle>> _getPaginatedBundles(
74
+ DatabaseBundleQueryWhere conditions,
75
+ int limit,
76
+ int offset,
77
+ DatabaseBundleQueryOrder? orderBy,
78
+ ) async {
79
+ final where = buildWhereClause(conditions);
80
+ final orderBySql =
81
+ orderBy?.direction == 'asc' ? 'ORDER BY id ASC' : 'ORDER BY id DESC';
82
+
83
+ final rows = await _client.query(
84
+ 'SELECT * FROM bundles ${where.sql} $orderBySql LIMIT ? OFFSET ?',
85
+ [...where.params, limit, offset],
86
+ );
87
+
88
+ final patchMap = await _getPatchMap(
89
+ rows.map((row) => row['id'] as String).toList(),
90
+ );
91
+ return rows
92
+ .map((row) => transformRowToBundle(row, patchMap[row['id']] ?? const []))
93
+ .toList();
94
+ }
95
+
96
+ Future<List<Bundle>> _queryBundlesForUpdateInfo(
97
+ DatabaseBundleQueryWhere conditions,
98
+ ) async {
99
+ final where = buildWhereClause(conditions);
100
+ final rows = await _client.query(
101
+ 'SELECT * FROM bundles${where.sql}',
102
+ where.params,
103
+ );
104
+ final patchMap = await _getPatchMap(
105
+ rows.map((row) => row['id'] as String).toList(),
106
+ );
107
+ return rows
108
+ .map((row) => transformRowToBundle(row, patchMap[row['id']] ?? const []))
109
+ .toList();
110
+ }
111
+
112
+ Future<List<String>> _getTargetAppVersionsForUpdateInfo({
113
+ required String channel,
114
+ required String platform,
115
+ required String minBundleId,
116
+ }) async {
117
+ final rows = await _client.query(
118
+ 'SELECT target_app_version FROM bundles '
119
+ 'WHERE channel = ? AND platform = ? AND enabled = 1 '
120
+ 'AND id >= ? AND target_app_version IS NOT NULL '
121
+ 'GROUP BY target_app_version',
122
+ [channel, platform, minBundleId],
123
+ );
124
+ return rows
125
+ .map((row) => row['target_app_version'] as String)
126
+ .toList();
127
+ }
128
+
129
+ @override
130
+ Future<UpdateInfo?> getUpdateInfo(GetBundlesArgs args) async {
131
+ final channel = args.channel;
132
+ final minBundleId = args.minBundleId;
133
+ final platform = args.platform.value;
134
+
135
+ if (args.updateStrategy == UpdateStrategy.appVersion) {
136
+ final appVersion = (args as AppVersionGetBundlesArgs).appVersion;
137
+ final targetAppVersions = await _getTargetAppVersionsForUpdateInfo(
138
+ channel: channel,
139
+ platform: platform,
140
+ minBundleId: minBundleId,
141
+ );
142
+ final compatibleAppVersions = filterCompatibleAppVersions(
143
+ targetAppVersions,
144
+ appVersion,
145
+ );
146
+ final bundles = compatibleAppVersions.isNotEmpty
147
+ ? await _queryBundlesForUpdateInfo(
148
+ DatabaseBundleQueryWhere(
149
+ enabled: true,
150
+ platform: args.platform,
151
+ channel: channel,
152
+ id: DatabaseBundleIdFilter(gte: minBundleId),
153
+ targetAppVersionIn: compatibleAppVersions,
154
+ ),
155
+ )
156
+ : const <Bundle>[];
157
+
158
+ return resolveUpdateInfoFromBundles(
159
+ ResolveUpdateInfoFromBundlesOptions(args: args, bundles: bundles),
160
+ );
161
+ }
162
+
163
+ final fingerprintHash = args is FingerprintGetBundlesArgs
164
+ ? args.fingerprintHash
165
+ : null;
166
+
167
+ final bundles = await _queryBundlesForUpdateInfo(
168
+ DatabaseBundleQueryWhere(
169
+ enabled: true,
170
+ platform: args.platform,
171
+ channel: channel,
172
+ id: DatabaseBundleIdFilter(gte: minBundleId),
173
+ fingerprintHash: fingerprintHash,
174
+ ),
175
+ );
176
+
177
+ return resolveUpdateInfoFromBundles(
178
+ ResolveUpdateInfoFromBundlesOptions(args: args, bundles: bundles),
179
+ );
180
+ }
181
+
182
+ @override
183
+ Future<Bundle?> getBundleById(String bundleId) async {
184
+ final rows = await _client.query(
185
+ 'SELECT * FROM bundles WHERE id = ? LIMIT 1',
186
+ [bundleId],
187
+ );
188
+ if (rows.isEmpty) return null;
189
+
190
+ final patchMap = await _getPatchMap([bundleId]);
191
+ return transformRowToBundle(rows.first, patchMap[bundleId] ?? const []);
192
+ }
193
+
194
+ @override
195
+ Future<Paginated<List<Bundle>>> getBundles(
196
+ DatabaseBundleQueryOptions options,
197
+ ) async {
198
+ final where = options.where ?? const DatabaseBundleQueryWhere();
199
+ final limit = options.limit;
200
+ final offset = options.offset ??
201
+ (options.page != null ? (options.page! - 1) * limit : 0);
202
+
203
+ final totalCount = await _getTotalCount(where);
204
+ final bundles = await _getPaginatedBundles(
205
+ where,
206
+ limit,
207
+ offset,
208
+ options.orderBy,
209
+ );
210
+
211
+ final pagination = calculatePagination(
212
+ totalCount,
213
+ limit: limit,
214
+ offset: offset,
215
+ );
216
+
217
+ return Paginated<List<Bundle>>(data: bundles, pagination: pagination);
218
+ }
219
+
220
+ @override
221
+ Future<List<String>> getChannels() async {
222
+ final rows = await _client.query(
223
+ 'SELECT channel FROM bundles GROUP BY channel',
224
+ );
225
+ return rows.map((row) => row['channel'] as String).toList();
226
+ }
227
+
228
+ @override
229
+ bool get supportsCursorPagination => false;
230
+
231
+ @override
232
+ Future<void> onUnmount() async {}
233
+
234
+ @override
235
+ Future<void> commitBundle({
236
+ required List<BundleChange> changedSets,
237
+ }) async {
238
+ if (changedSets.isEmpty) return;
239
+
240
+ for (final op in changedSets) {
241
+ if (op.operation == BundleChangeOperation.delete) {
242
+ await _client.query(
243
+ 'DELETE FROM bundle_patches WHERE bundle_id = ?',
244
+ [op.data.id],
245
+ );
246
+ await _client.query(
247
+ 'DELETE FROM bundle_patches WHERE base_bundle_id = ?',
248
+ [op.data.id],
249
+ );
250
+ await _client.query(
251
+ 'DELETE FROM bundles WHERE id = ?',
252
+ [op.data.id],
253
+ );
254
+ } else {
255
+ final bundle = op.data;
256
+ await _client.query(
257
+ 'INSERT OR REPLACE INTO bundles ('
258
+ 'id, channel, enabled, should_force_update, file_hash, '
259
+ 'git_commit_hash, message, platform, target_app_version, '
260
+ 'storage_uri, fingerprint_hash, metadata, manifest_storage_uri, '
261
+ 'manifest_file_hash, asset_base_storage_uri, rollout_cohort_count, '
262
+ 'target_cohorts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
263
+ [
264
+ bundle.id,
265
+ bundle.channel,
266
+ bundle.enabled ? 1 : 0,
267
+ bundle.shouldForceUpdate ? 1 : 0,
268
+ bundle.fileHash,
269
+ bundle.gitCommitHash,
270
+ bundle.message,
271
+ bundle.platform.value,
272
+ bundle.targetAppVersion,
273
+ bundle.storageUri,
274
+ bundle.fingerprintHash,
275
+ jsonEncode(
276
+ bundle.metadata?.toJson() ?? <String, dynamic>{},
277
+ ),
278
+ bundle.manifestStorageUri,
279
+ bundle.manifestFileHash,
280
+ bundle.assetBaseStorageUri,
281
+ bundle.rolloutCohortCount ?? defaultRolloutCohortCount,
282
+ bundle.targetCohorts != null
283
+ ? jsonEncode(bundle.targetCohorts)
284
+ : null,
285
+ ],
286
+ );
287
+
288
+ await _client.query(
289
+ 'DELETE FROM bundle_patches WHERE bundle_id = ?',
290
+ [bundle.id],
291
+ );
292
+
293
+ final patchRows = bundleToPatchRows(bundle);
294
+ for (final patchRow in patchRows) {
295
+ await _client.query(
296
+ 'INSERT OR REPLACE INTO bundle_patches ('
297
+ 'id, bundle_id, base_bundle_id, base_file_hash, '
298
+ 'patch_file_hash, patch_storage_uri, order_index) '
299
+ 'VALUES (?, ?, ?, ?, ?, ?, ?)',
300
+ [
301
+ patchRow['id'],
302
+ patchRow['bundle_id'],
303
+ patchRow['base_bundle_id'],
304
+ patchRow['base_file_hash'],
305
+ patchRow['patch_file_hash'],
306
+ patchRow['patch_storage_uri'],
307
+ patchRow['order_index'],
308
+ ],
309
+ );
310
+ }
311
+ }
312
+ }
313
+ }
314
+ }
@@ -0,0 +1,50 @@
1
+ import 'r2_s3_client.dart' show R2S3Client, R2S3ClientLike;
2
+
3
+ /// Configuration for the Cloudflare R2 S3-compatible storage plugin.
4
+ ///
5
+ /// Mirrors hot-updater's `R2S3StorageConfig` (a thin wrapper over the AWS S3
6
+ /// client config). Credentials are passed directly (access/secret keys) and
7
+ /// the client signs requests with AWS Signature V4 against the R2 endpoint.
8
+ class R2S3StorageConfig {
9
+ const R2S3StorageConfig({
10
+ required this.accountId,
11
+ required this.bucketName,
12
+ required this.accessKeyId,
13
+ required this.secretAccessKey,
14
+ this.basePath,
15
+ this.region = 'auto',
16
+ this.endpoint,
17
+ this.clientFactory,
18
+ });
19
+
20
+ /// Cloudflare account ID (used to build the R2 endpoint host).
21
+ final String accountId;
22
+
23
+ /// Target R2 bucket name.
24
+ final String bucketName;
25
+
26
+ /// S3 access key ID.
27
+ final String accessKeyId;
28
+
29
+ /// S3 secret access key.
30
+ final String secretAccessKey;
31
+
32
+ /// Optional base path prepended to all object keys.
33
+ final String? basePath;
34
+
35
+ /// R2 region (defaults to "auto").
36
+ final String region;
37
+
38
+ /// Override endpoint (defaults to `https://<accountId>.r2.cloudflarestorage.com`).
39
+ final String? endpoint;
40
+
41
+ /// Optional factory used to inject an [R2S3ClientLike] in tests.
42
+ final R2S3ClientFactory? clientFactory;
43
+ }
44
+
45
+ /// Builds an [R2S3ClientLike] for the given [R2S3StorageConfig].
46
+ typedef R2S3ClientFactory = R2S3ClientLike Function(R2S3StorageConfig config);
47
+
48
+ /// Resolves the client for a config, using the injected factory when present.
49
+ R2S3ClientLike resolveR2Client(R2S3StorageConfig config) =>
50
+ config.clientFactory?.call(config) ?? R2S3Client(config);
@@ -0,0 +1,287 @@
1
+ import 'dart:convert' show utf8;
2
+ import 'dart:io' show File;
3
+ import 'dart:typed_data' show Uint8List;
4
+
5
+ import 'package:crypto/crypto.dart' show Hmac, sha256;
6
+ import 'package:flutter_patcher_plugin_core/flutter_patcher_plugin_core.dart'
7
+ show StorageObject;
8
+ import 'package:http/http.dart' show Client, Request, Response;
9
+
10
+ import 'r2_config.dart' show R2S3StorageConfig;
11
+
12
+ /// A thin S3-compatible client for Cloudflare R2.
13
+ ///
14
+ /// Mirrors the subset of `@aws-sdk/client-s3` used by hot-updater's
15
+ /// `r2S3Storage.ts`: delete / put / head / get objects, listing, and
16
+ /// presigned GET URLs. Requests are signed with AWS Signature V4.
17
+ abstract class R2S3ClientLike {
18
+ const R2S3ClientLike();
19
+
20
+ Future<void> deleteObject(String key);
21
+
22
+ Future<void> putObject(String key, List<int> body, String contentType);
23
+
24
+ Future<bool> headObject(String key);
25
+
26
+ Future<List<int>> getObjectAsBytes(String key);
27
+
28
+ Future<String> getObjectAsString(String key);
29
+
30
+ Future<String> getSignedUrl(String key, {int expiresIn = 3600});
31
+
32
+ Future<List<StorageObject>> listObjects([String? prefix]);
33
+
34
+ Future<void> deleteObjects(List<String> keys);
35
+ }
36
+
37
+ /// Real R2 S3 client backed by `package:http` + AWS SigV4 signing.
38
+ class R2S3Client implements R2S3ClientLike {
39
+ R2S3Client(this.config, {Client? http}) : _http = http ?? Client();
40
+
41
+ final R2S3StorageConfig config;
42
+ final Client _http;
43
+
44
+ static const String _service = 'r2';
45
+
46
+ Uri _objectUri(String key) {
47
+ final host = config.endpoint ??
48
+ 'https://${config.accountId}.r2.cloudflarestorage.com';
49
+ return Uri.parse('$host/${config.bucketName}/$key');
50
+ }
51
+
52
+ @override
53
+ Future<void> deleteObject(String key) async {
54
+ final response = await _send('DELETE', _objectUri(key), body: null);
55
+ _assertStatus(response, [200, 204], 'deleteObject');
56
+ }
57
+
58
+ @override
59
+ Future<void> putObject(
60
+ String key,
61
+ List<int> body,
62
+ String contentType,
63
+ ) async {
64
+ final uri = _objectUri(key);
65
+ final response = await _send(
66
+ 'PUT',
67
+ uri,
68
+ body: body,
69
+ extraHeaders: {
70
+ 'Content-Type': contentType,
71
+ 'Cache-Control': 'max-age=31536000',
72
+ },
73
+ );
74
+ _assertStatus(response, [200], 'putObject');
75
+ }
76
+
77
+ @override
78
+ Future<bool> headObject(String key) async {
79
+ final uri = _objectUri(key);
80
+ final request = Request('HEAD', uri)..headers.addAll(_authHeaders('HEAD', uri, null));
81
+ final response = await _http.send(request);
82
+ if (response.statusCode == 404) return false;
83
+ if (response.statusCode < 200 || response.statusCode >= 300) {
84
+ throw Exception('R2 HeadObject failed (${response.statusCode})');
85
+ }
86
+ return true;
87
+ }
88
+
89
+ @override
90
+ Future<List<int>> getObjectAsBytes(String key) async {
91
+ final response = await _send('GET', _objectUri(key), body: null);
92
+ _assertStatus(response, [200], 'getObjectAsBytes');
93
+ return response.bodyBytes;
94
+ }
95
+
96
+ @override
97
+ Future<String> getObjectAsString(String key) async {
98
+ final bytes = await getObjectAsBytes(key);
99
+ return utf8.decode(bytes);
100
+ }
101
+
102
+ @override
103
+ Future<String> getSignedUrl(String key, {int expiresIn = 3600}) async {
104
+ final uri = _objectUri(key);
105
+ final now = DateTime.now().toUtc();
106
+ final amzDate = _amzDate(now);
107
+ final dateStamp = amzDate.substring(0, 8);
108
+ final scope = '$dateStamp/${config.region}/$_service/aws4_request';
109
+
110
+ final params = {
111
+ 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
112
+ 'X-Amz-Credential': '${config.accessKeyId}/$scope',
113
+ 'X-Amz-Date': amzDate,
114
+ 'X-Amz-Expires': '$expiresIn',
115
+ 'X-Amz-SignedHeaders': 'host',
116
+ };
117
+ final canonicalQuery = params.entries
118
+ .map((e) => '${_uriEncode(e.key)}=${_uriEncode(e.value)}')
119
+ .toList()
120
+ ..sort();
121
+ final queryString = canonicalQuery.join('&');
122
+
123
+ const payloadHash = 'UNSIGNED-PAYLOAD';
124
+ final canonicalHeaders = 'host:${uri.host}\n';
125
+ final canonicalRequest =
126
+ 'GET\n${uri.path}\n$queryString\n$canonicalHeaders\nhost\n$payloadHash';
127
+ final stringToSign = _stringToSign(
128
+ amzDate,
129
+ scope,
130
+ canonicalRequest,
131
+ );
132
+ final signingKey = _signingKey(dateStamp, config.region);
133
+ final signature = _toHex(_hmac(signingKey, stringToSign));
134
+ return '${uri.scheme}://${uri.host}${uri.path}?$queryString'
135
+ '&X-Amz-Signature=$signature';
136
+ }
137
+
138
+ @override
139
+ Future<List<StorageObject>> listObjects([String? prefix]) async {
140
+ final base = _objectUri('').replace(path: '/${config.bucketName}/');
141
+ final listUri = base.replace(
142
+ queryParameters: {
143
+ 'list-type': '2',
144
+ if (prefix != null && prefix.isNotEmpty) 'prefix': prefix,
145
+ },
146
+ );
147
+ final response = await _send('GET', listUri, body: null);
148
+ _assertStatus(response, [200], 'listObjects');
149
+ return _parseListObjects(response.body);
150
+ }
151
+
152
+ @override
153
+ Future<void> deleteObjects(List<String> keys) async {
154
+ for (final key in keys) {
155
+ await deleteObject(key);
156
+ }
157
+ }
158
+
159
+ // --- signing helpers -------------------------------------------------------
160
+
161
+ Map<String, String> _authHeaders(
162
+ String method,
163
+ Uri uri,
164
+ List<int>? body,
165
+ ) {
166
+ final now = DateTime.now().toUtc();
167
+ final amzDate = _amzDate(now);
168
+ final dateStamp = amzDate.substring(0, 8);
169
+ final scope = '$dateStamp/${config.region}/$_service/aws4_request';
170
+ final payloadHash = body == null ? _hashHex(const []) : _hashHex(body);
171
+
172
+ final canonicalHeaders = 'host:${uri.host}\n'
173
+ 'x-amz-content-sha256:$payloadHash\n'
174
+ 'x-amz-date:$amzDate\n';
175
+ const signedHeaders = 'host;x-amz-content-sha256;x-amz-date';
176
+
177
+ final canonicalRequest = '$method\n${uri.path}\n'
178
+ '\n$canonicalHeaders$signedHeaders\n$payloadHash';
179
+ final stringToSign = _stringToSign(amzDate, scope, canonicalRequest);
180
+ final signingKey = _signingKey(dateStamp, config.region);
181
+ final signature = _toHex(_hmac(signingKey, stringToSign));
182
+
183
+ return {
184
+ 'x-amz-date': amzDate,
185
+ 'x-amz-content-sha256': payloadHash,
186
+ 'Authorization':
187
+ 'AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/$scope, '
188
+ 'SignedHeaders=$signedHeaders, Signature=$signature',
189
+ };
190
+ }
191
+
192
+ Future<Response> _send(
193
+ String method,
194
+ Uri uri, {
195
+ required List<int>? body,
196
+ Map<String, String>? extraHeaders,
197
+ }) async {
198
+ final request = Request(method, uri);
199
+ request.headers.addAll(_authHeaders(method, uri, body));
200
+ if (extraHeaders != null) request.headers.addAll(extraHeaders);
201
+ if (body != null) request.bodyBytes = body;
202
+ final streamed = await _http.send(request);
203
+ return await Response.fromStream(streamed);
204
+ }
205
+
206
+ List<StorageObject> _parseListObjects(String xml) {
207
+ final objects = <StorageObject>[];
208
+ final contents = _allMatches(xml, RegExp(r'<Contents>([\s\S]*?)</Contents>'));
209
+ for (final block in contents) {
210
+ final key = _tag(block, 'Key');
211
+ final size = int.tryParse(_tag(block, 'Size') ?? '');
212
+ final lastModified = _tag(block, 'LastModified');
213
+ if (key == null) continue;
214
+ objects.add(
215
+ StorageObject(
216
+ key: key,
217
+ storageUri: 'r2://${config.bucketName}/$key',
218
+ size: size ?? 0,
219
+ lastModifiedAt: lastModified == null
220
+ ? null
221
+ : DateTime.tryParse(lastModified),
222
+ ),
223
+ );
224
+ }
225
+ return objects;
226
+ }
227
+
228
+ // --- low-level sigv4 primitives --------------------------------------------
229
+
230
+ List<int> _signingKey(String dateStamp, String region) {
231
+ var key = utf8.encode('AWS4${config.secretAccessKey}');
232
+ key = _hmac(key, dateStamp);
233
+ key = _hmac(key, region);
234
+ key = _hmac(key, _service);
235
+ key = _hmac(key, 'aws4_request');
236
+ return key;
237
+ }
238
+
239
+ String _stringToSign(String amzDate, String scope, String canonicalRequest) =>
240
+ 'AWS4-HMAC-SHA256\n$amzDate\n$scope\n${_hashHex(utf8.encode(canonicalRequest))}';
241
+
242
+ // --- static utilities ------------------------------------------------------
243
+
244
+ static String _amzDate(DateTime utc) =>
245
+ '${utc.toIso8601String().replaceAll('-', '').replaceAll(':', '').split('.').first}Z';
246
+
247
+ static String _uriEncode(String value) => Uri.encodeQueryComponent(value);
248
+
249
+ static Uint8List _hmac(List<int> key, String data) =>
250
+ Hmac(sha256, key).convert(utf8.encode(data)).bytes as Uint8List;
251
+
252
+ static String _hashHex(List<int> data) => _toHex(sha256.convert(data).bytes);
253
+
254
+ static String _toHex(List<int> bytes) =>
255
+ bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
256
+
257
+ static List<String> _allMatches(String input, RegExp regex) =>
258
+ regex.allMatches(input).map((m) => m.group(1)!).toList();
259
+
260
+ static String? _tag(String block, String name) {
261
+ final m = RegExp('<$name>([\\s\\S]*?)</$name>').firstMatch(block);
262
+ return m?.group(1);
263
+ }
264
+
265
+ static void _assertStatus(
266
+ Response response,
267
+ List<int> ok,
268
+ String operation,
269
+ ) {
270
+ if (!ok.contains(response.statusCode)) {
271
+ throw Exception(
272
+ 'R2 $operation failed (${response.statusCode}): ${response.body}',
273
+ );
274
+ }
275
+ }
276
+ }
277
+
278
+ /// Read a local file's bytes (node profile helper).
279
+ Future<List<int>> readFileBytes(String filePath) =>
280
+ File(filePath).readAsBytes();
281
+
282
+ /// Write bytes to a local file, creating parent directories.
283
+ Future<void> writeFileBytes(String filePath, List<int> bytes) async {
284
+ final file = File(filePath);
285
+ await file.parent.create(recursive: true);
286
+ await file.writeAsBytes(bytes);
287
+ }
@@ -0,0 +1,23 @@
1
+ import 'package:flutter_patcher_plugin_core/flutter_patcher_plugin_core.dart'
2
+ show StoragePlugin, createUniversalStoragePlugin;
3
+
4
+ import 'r2_config.dart' show R2S3StorageConfig;
5
+ import 'r2_storage_profile.dart'
6
+ show createS3RuntimeStorageProfile, createS3StorageProfile;
7
+
8
+ /// Cloudflare R2 storage plugin (faithful port of hot-updater's `r2Storage`).
9
+ ///
10
+ /// Uses the S3-compatible R2 API (AWS Signature V4). The node profile handles
11
+ /// upload/delete/download/list; the runtime profile serves presigned download
12
+ /// URLs and reads small text objects. Note: hot-updater also supports a
13
+ /// deprecated Wrangler-CLI based profile; only the S3-compatible profile is
14
+ /// ported here.
15
+ final StoragePlugin Function(R2S3StorageConfig config) r2Storage =
16
+ createUniversalStoragePlugin<R2S3StorageConfig>(
17
+ name: 'r2Storage',
18
+ supportedProtocol: 'r2',
19
+ factory: (config) => (
20
+ node: createS3StorageProfile(config),
21
+ runtime: createS3RuntimeStorageProfile(config),
22
+ ),
23
+ );