diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92451269..12c39322 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,6 +147,14 @@ jobs: working-directory: packages/wakelock_plus run: flutter test --platform chrome --dart-define=WEB_PLUGIN_TESTS=true test/wakelock_plus_web_plugin_test.dart + # The vendored atomic-write patch has to keep the upstream contract, not + # just the new behaviour. Upstream's own suites are the check for that. + - name: Run the vendored desktop preference store tests + run: | + for pkg in shared_preferences_linux shared_preferences_windows; do + (cd "packages/$pkg" && flutter pub get --enforce-lockfile && flutter test) + done + android-test: name: Android JVM and Native Tests runs-on: ubuntu-latest @@ -471,6 +479,15 @@ jobs: shell: pwsh run: flutter pub get --enforce-lockfile --no-example + # Windows replacement semantics (`MoveFileExW` with + # MOVEFILE_REPLACE_EXISTING) cannot be proven on the POSIX runners, and a + # memory file system proves nothing about either. The vendored + # shared_preferences_windows store write is the fix for #1732, so exercise + # it here, on a real NTFS volume, against the real backend. + - name: Run the vendored Windows preference store tests + shell: pwsh + run: flutter test test/services/prefs_store_atomic_write_windows_test.dart + - name: Install patched Flutter engine shell: pwsh run: | diff --git a/packages/shared_preferences_linux/AUTHORS b/packages/shared_preferences_linux/AUTHORS new file mode 100644 index 00000000..493a0b4e --- /dev/null +++ b/packages/shared_preferences_linux/AUTHORS @@ -0,0 +1,66 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> diff --git a/packages/shared_preferences_linux/CHANGELOG.md b/packages/shared_preferences_linux/CHANGELOG.md new file mode 100644 index 00000000..a4bd77a5 --- /dev/null +++ b/packages/shared_preferences_linux/CHANGELOG.md @@ -0,0 +1,127 @@ +## 2.4.1 (Plezy vendored patch) + +Vendored from pub.dev `shared_preferences_linux` 2.4.1. Upstream writes the whole +preference document with a bare `writeAsStringSync`, which truncates the live +file before writing it, so every preference write has a window in which the +only copy on disk is empty or half-written. A crash inside that window leaves a +store that fails to parse on every later launch — and it holds the +credential-vault key, so the loss is not recoverable by rewriting it. This is +the corruption behind Plezy issue #1732. + +The local patch stages the document to a sibling `.tmp`, flushes it, then +renames it over the target, and sweeps a stale staging file once the canonical +document has been read cleanly. The published example app is not vendored. + +When updating, reapply both PLEZY DELTA blocks in `lib/shared_preferences_linux.dart` +and see `provenance.json` for the full refresh contract. + +## 2.4.1 + +* Fixes `getStringList` returning immutable list. +* Fixes `getStringList` cast error. +* Updates minimum supported SDK version to Flutter 3.19/Dart 3.3. + +## 2.4.0 + +* Adds `SharedPreferencesAsyncLinux` API. +* Updates minimum supported SDK version to Flutter 3.16/Dart 3.2. + +## 2.3.2 + +* Updates `package:file` version constraints. + +## 2.3.1 + +* Adds pub topics to package metadata. +* Updates minimum supported SDK version to Flutter 3.7/Dart 2.19. + +## 2.3.0 + +* Adds `clearWithParameters` and `getAllWithParameters` methods. +* Updates minimum supported SDK version to Flutter 3.3/Dart 2.18. + +## 2.2.0 + +* Adds `getAllWithPrefix` and `clearWithPrefix` methods. + +## 2.1.5 + +* Clarifies explanation of endorsement in README. +* Aligns Dart and Flutter SDK constraints. + +## 2.1.4 + +* Updates links for the merge of flutter/plugins into flutter/packages. +* Updates minimum Flutter version to 3.0. + +## 2.1.3 + +* Updates code for stricter lint checks. + +## 2.1.2 + +* Updates code for stricter lint checks. +* Updates code for `no_leading_underscores_for_local_identifiers` lint. +* Updates minimum Flutter version to 2.10. + +## 2.1.1 + +* Removes unnecessary imports. +* Fixes library_private_types_in_public_api, sort_child_properties_last and use_key_in_widget_constructors + lint warnings. + +## 2.1.0 + +* Deprecated `SharedPreferencesWindows.instance` in favor of `SharedPreferencesStorePlatform.instance`. + +## 2.0.4 + +* Removes dependency on `meta`. + +## 2.0.3 + +* Removed obsolete `pluginClass: none` from pubpsec. +* Fixes newly enabled analyzer options. + +## 2.0.2 + +* Updated installation instructions in README. + +## 2.0.1 + +* Add `implements` to the pubspec. +* Add `registerWith` to the Dart main class. + +## 2.0.0 + +* Migrate to null-safety. + +## 0.0.3+1 + +* Update Flutter SDK constraint. + +## 0.0.3 + +* Update integration test examples to use `testWidgets` instead of `test`. + +## 0.0.2+4 + +* Remove unused `test` dependency. +* Update Dart SDK constraint in example. + +## 0.0.2+3 + +* Check in linux/ directory for example/ + +## 0.0.2+2 + +* Bump the `file` package dependency to resolve dep conflicts with `flutter_driver`. + +## 0.0.2+1 +* Replace path_provider dependency with path_provider_linux. + +## 0.0.2 +* Add iOS stub. + +## 0.0.1 +* Initial release to support shared_preferences on Linux. diff --git a/packages/shared_preferences_linux/LICENSE b/packages/shared_preferences_linux/LICENSE new file mode 100644 index 00000000..c6823b81 --- /dev/null +++ b/packages/shared_preferences_linux/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/shared_preferences_linux/README.md b/packages/shared_preferences_linux/README.md new file mode 100644 index 00000000..0fe95555 --- /dev/null +++ b/packages/shared_preferences_linux/README.md @@ -0,0 +1,15 @@ +# shared\_preferences\_linux + +The Linux implementation of [`shared_preferences`][1]. + +## Usage + +This package is [endorsed][2], which means you can simply use `shared_preferences` +normally. This package will be automatically included in your app when you do, +so you do not need to add it to your `pubspec.yaml`. + +However, if you `import` this package to use any of its APIs directly, you +should add it to your `pubspec.yaml` as usual. + +[1]: https://pub.dev/packages/shared_preferences +[2]: https://flutter.dev/to/endorsed-federated-plugin diff --git a/packages/shared_preferences_linux/build/native_assets/macos/native_assets.json b/packages/shared_preferences_linux/build/native_assets/macos/native_assets.json new file mode 100644 index 00000000..523bfc7c --- /dev/null +++ b/packages/shared_preferences_linux/build/native_assets/macos/native_assets.json @@ -0,0 +1 @@ +{"format-version":[1,0,0],"native-assets":{}} \ No newline at end of file diff --git a/packages/shared_preferences_linux/build/test_cache/build/06bef57db60962d69da0f7f7c65d9720.cache.dill.track.dill b/packages/shared_preferences_linux/build/test_cache/build/06bef57db60962d69da0f7f7c65d9720.cache.dill.track.dill new file mode 100644 index 00000000..90a122a9 Binary files /dev/null and b/packages/shared_preferences_linux/build/test_cache/build/06bef57db60962d69da0f7f7c65d9720.cache.dill.track.dill differ diff --git a/packages/shared_preferences_linux/build/unit_test_assets/AssetManifest.bin b/packages/shared_preferences_linux/build/unit_test_assets/AssetManifest.bin new file mode 100644 index 00000000..86d111f0 Binary files /dev/null and b/packages/shared_preferences_linux/build/unit_test_assets/AssetManifest.bin differ diff --git a/packages/shared_preferences_linux/build/unit_test_assets/FontManifest.json b/packages/shared_preferences_linux/build/unit_test_assets/FontManifest.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/packages/shared_preferences_linux/build/unit_test_assets/FontManifest.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/packages/shared_preferences_linux/build/unit_test_assets/NOTICES.Z b/packages/shared_preferences_linux/build/unit_test_assets/NOTICES.Z new file mode 100644 index 00000000..5b6b60b2 Binary files /dev/null and b/packages/shared_preferences_linux/build/unit_test_assets/NOTICES.Z differ diff --git a/packages/shared_preferences_linux/build/unit_test_assets/NativeAssetsManifest.json b/packages/shared_preferences_linux/build/unit_test_assets/NativeAssetsManifest.json new file mode 100644 index 00000000..523bfc7c --- /dev/null +++ b/packages/shared_preferences_linux/build/unit_test_assets/NativeAssetsManifest.json @@ -0,0 +1 @@ +{"format-version":[1,0,0],"native-assets":{}} \ No newline at end of file diff --git a/packages/shared_preferences_linux/build/unit_test_assets/shaders/ink_sparkle.frag b/packages/shared_preferences_linux/build/unit_test_assets/shaders/ink_sparkle.frag new file mode 100644 index 00000000..ebd80664 Binary files /dev/null and b/packages/shared_preferences_linux/build/unit_test_assets/shaders/ink_sparkle.frag differ diff --git a/packages/shared_preferences_linux/build/unit_test_assets/shaders/stretch_effect.frag b/packages/shared_preferences_linux/build/unit_test_assets/shaders/stretch_effect.frag new file mode 100644 index 00000000..3c938de1 Binary files /dev/null and b/packages/shared_preferences_linux/build/unit_test_assets/shaders/stretch_effect.frag differ diff --git a/packages/shared_preferences_linux/lib/shared_preferences_linux.dart b/packages/shared_preferences_linux/lib/shared_preferences_linux.dart new file mode 100644 index 00000000..6900f6a6 --- /dev/null +++ b/packages/shared_preferences_linux/lib/shared_preferences_linux.dart @@ -0,0 +1,456 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert' show json; + +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting; +import 'package:path/path.dart' as path; +import 'package:path_provider_linux/path_provider_linux.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; +import 'package:shared_preferences_platform_interface/types.dart'; + +const String _defaultFileName = 'shared_preferences'; + +const String _defaultPrefix = 'flutter.'; + +/// The Linux implementation of [SharedPreferencesStorePlatform]. +/// +/// This class implements the `package:shared_preferences` functionality for Linux. +class SharedPreferencesLinux extends SharedPreferencesStorePlatform { + /// Deprecated instance of [SharedPreferencesLinux]. + /// Use [SharedPreferencesStorePlatform.instance] instead. + @Deprecated('Use `SharedPreferencesStorePlatform.instance` instead.') + static SharedPreferencesLinux instance = SharedPreferencesLinux(); + + /// Registers the Linux implementation. + static void registerWith() { + SharedPreferencesStorePlatform.instance = SharedPreferencesLinux(); + // A temporary work-around for having two plugins contained in a single package. + SharedPreferencesAsyncLinux.registerWith(); + } + + /// Local copy of preferences + Map? _cachedPreferences; + + /// File system used to store to disk. Exposed for testing only. + @visibleForTesting + FileSystem fs = const LocalFileSystem(); + + /// The path_provider_linux instance used to find the support directory. + @visibleForTesting + PathProviderLinux pathProvider = PathProviderLinux(); + + /// Checks for cached preferences and returns them or loads preferences from + /// file and returns and caches them. + Future> _readPreferences() async { + _cachedPreferences ??= await _reload( + _defaultFileName, + fs: fs, + pathProvider: pathProvider, + ); + return _cachedPreferences!; + } + + @override + Future clear() async { + return clearWithParameters( + ClearParameters( + filter: PreferencesFilter(prefix: _defaultPrefix), + ), + ); + } + + @override + Future clearWithPrefix(String prefix) async { + return clearWithParameters(ClearParameters(filter: PreferencesFilter(prefix: prefix))); + } + + @override + Future clearWithParameters(ClearParameters parameters) async { + final PreferencesFilter filter = parameters.filter; + + final Map preferences = await _readPreferences(); + preferences.removeWhere((String key, _) => + key.startsWith(filter.prefix) && (filter.allowList == null || filter.allowList!.contains(key))); + return _writePreferences( + preferences, + _defaultFileName, + fs: fs, + pathProvider: pathProvider, + ); + } + + @override + Future> getAll() async { + return getAllWithParameters( + GetAllParameters( + filter: PreferencesFilter(prefix: _defaultPrefix), + ), + ); + } + + @override + Future> getAllWithPrefix(String prefix) async { + return getAllWithParameters(GetAllParameters(filter: PreferencesFilter(prefix: prefix))); + } + + @override + Future> getAllWithParameters(GetAllParameters parameters) async { + final PreferencesFilter filter = parameters.filter; + final Map withPrefix = Map.from(await _readPreferences()); + withPrefix + .removeWhere((String key, _) => !(key.startsWith(filter.prefix) && (filter.allowList?.contains(key) ?? true))); + return withPrefix; + } + + @override + Future remove(String key) async { + final Map preferences = await _readPreferences(); + preferences.remove(key); + return _writePreferences( + preferences, + _defaultFileName, + fs: fs, + pathProvider: pathProvider, + ); + } + + @override + Future setValue(String valueType, String key, Object value) async { + final Map preferences = await _readPreferences(); + preferences[key] = value; + return _writePreferences( + preferences, + _defaultFileName, + fs: fs, + pathProvider: pathProvider, + ); + } +} + +/// The Linux implementation of [SharedPreferencesAsyncPlatform]. +/// +/// This class implements the `package:shared_preferences` functionality for Linux. +base class SharedPreferencesAsyncLinux extends SharedPreferencesAsyncPlatform { + /// Registers the Linux implementation. + static void registerWith() { + SharedPreferencesAsyncPlatform.instance = SharedPreferencesAsyncLinux(); + } + + /// Local copy of preferences + Map? _cachedPreferences; + + /// File system used to store to disk. Exposed for testing only. + @visibleForTesting + FileSystem fs = const LocalFileSystem(); + + /// The path_provider_linux instance used to find the support directory. + @visibleForTesting + PathProviderLinux pathProvider = PathProviderLinux(); + + @override + Future> getKeys( + GetPreferencesParameters parameters, + SharedPreferencesOptions options, + ) async { + return (await getPreferences(parameters, options)).keys.toSet(); + } + + @override + Future setString( + String key, + String value, + SharedPreferencesOptions options, + ) { + return _setValue(key, value, options); + } + + @override + Future setBool( + String key, + bool value, + SharedPreferencesOptions options, + ) { + return _setValue(key, value, options); + } + + @override + Future setDouble( + String key, + double value, + SharedPreferencesOptions options, + ) { + return _setValue(key, value, options); + } + + @override + Future setInt( + String key, + int value, + SharedPreferencesOptions options, + ) { + return _setValue(key, value, options); + } + + @override + Future setStringList( + String key, + List value, + SharedPreferencesOptions options, + ) { + return _setValue(key, value, options); + } + + @override + Future getString( + String key, + SharedPreferencesOptions options, + ) async { + final Map data = await _readAll({key}, options); + return data[key] as String?; + } + + @override + Future getBool( + String key, + SharedPreferencesOptions options, + ) async { + final Map data = await _readAll({key}, options); + return data[key] as bool?; + } + + @override + Future getDouble( + String key, + SharedPreferencesOptions options, + ) async { + final Map data = await _readAll({key}, options); + return data[key] as double?; + } + + @override + Future getInt( + String key, + SharedPreferencesOptions options, + ) async { + final Map data = await _readAll({key}, options); + return data[key] as int?; + } + + @override + Future?> getStringList( + String key, + SharedPreferencesOptions options, + ) async { + final Map data = await _readAll({key}, options); + return (data[key] as List?)?.cast().toList(); + } + + @override + Future clear(ClearPreferencesParameters parameters, SharedPreferencesOptions options) async { + final SharedPreferencesLinuxOptions linuxOptions = + SharedPreferencesLinuxOptions.fromSharedPreferencesOptions(options); + final PreferencesFilters filter = parameters.filter; + final Map preferences = await _readPreferences(linuxOptions.fileName); + preferences.removeWhere((String key, _) => filter.allowList == null || filter.allowList!.contains(key)); + await _writePreferences( + preferences, + linuxOptions.fileName, + fs: fs, + pathProvider: pathProvider, + ); + } + + @override + Future> getPreferences( + GetPreferencesParameters parameters, + SharedPreferencesOptions options, + ) async { + return _readAll(parameters.filter.allowList, options); + } + + /// Reloads preferences from file. + @visibleForTesting + Future reload( + SharedPreferencesLinuxOptions options, + ) async { + _cachedPreferences = await _reload(options.fileName); + } + + Future> _readAll( + Set? allowList, + SharedPreferencesOptions options, + ) async { + final SharedPreferencesLinuxOptions linuxOptions = + SharedPreferencesLinuxOptions.fromSharedPreferencesOptions(options); + final Map prefs = Map.from(await _readPreferences(linuxOptions.fileName)); + prefs.removeWhere((String key, _) => !(allowList?.contains(key) ?? true)); + return prefs; + } + + Future _setValue(String key, Object value, SharedPreferencesOptions options) async { + final SharedPreferencesLinuxOptions linuxOptions = + SharedPreferencesLinuxOptions.fromSharedPreferencesOptions(options); + final Map preferences = await _readPreferences(linuxOptions.fileName); + preferences[key] = value; + await _writePreferences( + preferences, + linuxOptions.fileName, + fs: fs, + pathProvider: pathProvider, + ); + } + + /// Checks for cached preferences and returns them or loads preferences from + /// file and returns and caches them. + Future> _readPreferences(String fileName) async { + _cachedPreferences ??= await _reload( + fileName, + fs: fs, + pathProvider: pathProvider, + ); + return _cachedPreferences!; + } +} + +/// Gets the file where the preferences are stored. +Future _getLocalDataFile( + String fileName, { + FileSystem fs = const LocalFileSystem(), + PathProviderLinux? pathProvider, +}) async { + pathProvider = pathProvider ?? PathProviderLinux(); + final String? directory = await pathProvider.getApplicationSupportPath(); + if (directory == null) { + return null; + } + final String fileLocation = path.join(directory, '$fileName.json'); + return fs.file(fileLocation); +} + +/// Suffix of the staging file used by the atomic write below. +/// +/// PLEZY DELTA. Deliberately a single fixed name rather than a stamped one: +/// this file holds the whole preference document, credentials included, so at +/// most one may ever exist and a later write must reuse it rather than +/// accumulate copies. +const String _stagingSuffix = '.tmp'; + +/// Gets the preferences from the stored file and saves them in cache. +Future> _reload( + String fileName, { + FileSystem fs = const LocalFileSystem(), + PathProviderLinux? pathProvider, +}) async { + Map preferences = {}; + final File? localDataFile = await _getLocalDataFile( + fileName, + fs: fs, + pathProvider: pathProvider, + ); + if (localDataFile != null && localDataFile.existsSync()) { + final String stringMap = localDataFile.readAsStringSync(); + if (stringMap.isNotEmpty) { + final Object? data = json.decode(stringMap); + if (data is Map) { + preferences = data.cast(); + } + } + // PLEZY DELTA: the canonical document just read cleanly, so any staging + // file left by an interrupted write is stale. It is a plaintext copy of + // the credentials, so it does not get to sit there indefinitely. + _removeStagingFile(localDataFile); + } + return preferences; +} + +/// Deletes a stale staging file, best effort. +/// +/// PLEZY DELTA. Never allowed to fail a read: the staging file is ours, and a +/// locked or already-removed one changes nothing about the document. +void _removeStagingFile(File localDataFile) { + try { + final File staged = localDataFile.fileSystem.file('${localDataFile.path}$_stagingSuffix'); + if (staged.existsSync()) { + staged.deleteSync(); + } + } catch (e) { + debugPrint('Could not remove a stale preferences staging file: $e'); + } +} + +/// Writes the cached preferences to disk. Returns [true] if the operation +/// succeeded. +Future _writePreferences( + Map preferences, + String fileName, { + FileSystem fs = const LocalFileSystem(), + PathProviderLinux? pathProvider, +}) async { + try { + final File? localDataFile = await _getLocalDataFile( + fileName, + fs: fs, + pathProvider: pathProvider, + ); + if (localDataFile == null) { + debugPrint('Unable to determine where to write preferences.'); + return false; + } + final String stringMap = json.encode(preferences); + + // PLEZY DELTA: stage, flush, then rename over the target. + // + // Upstream calls `writeAsStringSync` straight onto the live document. + // That opens with the default `FileMode.write`, which truncates first, so + // every single preference write has a window in which the only copy of the + // store on disk is empty or half-written. A crash, power loss, forced + // reboot or antivirus interception inside that window leaves a document + // that fails to parse on every subsequent launch, and the store holds the + // credential-vault key, so the damage is not recoverable by rewriting it. + // + // Renaming into place is atomic for readers: `rename(2)` here, + // `MoveFileExW` with MOVEFILE_REPLACE_EXISTING on Windows. The flush has + // to come first, or the rename could publish a file whose contents were + // never committed — the same corruption by a different route. + // + // The staging file is created in the target's own directory, both because + // rename must stay on one volume and so it inherits exactly the mode the + // canonical file would have been created with. + final File staged = localDataFile.fileSystem.file('${localDataFile.path}$_stagingSuffix'); + if (!staged.parent.existsSync()) { + staged.parent.createSync(recursive: true); + } + staged.writeAsStringSync(stringMap, flush: true); + staged.renameSync(localDataFile.path); + } catch (e) { + debugPrint('Error saving preferences to disk: $e'); + return false; + } + return true; +} + +/// Linux specific SharedPreferences Options. +class SharedPreferencesLinuxOptions extends SharedPreferencesOptions { + /// Constructor for SharedPreferencesLinuxOptions. + const SharedPreferencesLinuxOptions({ + this.fileName = 'shared_preferences', + }); + + /// The name of the file to store preferences in. + final String fileName; + + /// Returns a new instance of [SharedPreferencesLinuxOptions] from an existing + /// [SharedPreferencesOptions]. + static SharedPreferencesLinuxOptions fromSharedPreferencesOptions(SharedPreferencesOptions options) { + if (options is SharedPreferencesLinuxOptions) { + return options; + } + return const SharedPreferencesLinuxOptions(); + } +} diff --git a/packages/shared_preferences_linux/provenance.json b/packages/shared_preferences_linux/provenance.json new file mode 100644 index 00000000..b6ad0fba --- /dev/null +++ b/packages/shared_preferences_linux/provenance.json @@ -0,0 +1,20 @@ +{ + "formatVersion": 1, + "upstream": { + "repository": "https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences_linux", + "packageVersion": "2.4.1", + "source": "pub.dev" + }, + "plezyDeltas": [ + "atomic store replacement: stage to a sibling .tmp, flush, then rename over the target", + "stale staging files are swept once the canonical document has been read cleanly", + "the published example app is not vendored" + ], + "refreshContract": [ + "Diff against the new upstream version and reapply every PLEZY DELTA block in lib/shared_preferences_linux.dart verbatim.", + "Never reintroduce a bare writeAsStringSync onto the live document: it truncates before writing, which is the corruption in issue #1732.", + "Keep the staging name fixed and single. The file is a plaintext copy of the credential-vault key, tracker refresh tokens and Seerr cookies; stamped names would accumulate them.", + "Keep the flush before the rename. A rename can otherwise publish contents that were never committed.", + "Run flutter test test/services/prefs_store_atomic_write_test.dart from the repository root, and the real-Windows step in .github/workflows/ci.yml." + ] +} diff --git a/packages/shared_preferences_linux/pubspec.lock b/packages/shared_preferences_linux/pubspec.lock new file mode 100644 index 00000000..39c7a003 --- /dev/null +++ b/packages/shared_preferences_linux/pubspec.lock @@ -0,0 +1,253 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: "direct main" + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: "direct main" + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: "direct main" + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + shared_preferences_platform_interface: + dependency: "direct main" + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/packages/shared_preferences_linux/pubspec.yaml b/packages/shared_preferences_linux/pubspec.yaml new file mode 100644 index 00000000..c43a21bd --- /dev/null +++ b/packages/shared_preferences_linux/pubspec.yaml @@ -0,0 +1,34 @@ +name: shared_preferences_linux +description: Linux implementation of the shared_preferences plugin +repository: https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences_linux +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+shared_preferences%22 +version: 2.4.1 + +environment: + sdk: ^3.3.0 + flutter: ">=3.19.0" + +flutter: + plugin: + implements: shared_preferences + platforms: + linux: + dartPluginClass: SharedPreferencesLinux + +dependencies: + file: ">=6.0.0 <8.0.0" + flutter: + sdk: flutter + path: ^1.8.0 + path_provider_linux: ^2.0.0 + path_provider_platform_interface: ^2.0.0 + shared_preferences_platform_interface: ^2.4.0 + +dev_dependencies: + flutter_test: + sdk: flutter + +topics: + - persistence + - shared-preferences + - storage diff --git a/packages/shared_preferences_linux/test/fake_path_provider_linux.dart b/packages/shared_preferences_linux/test/fake_path_provider_linux.dart new file mode 100644 index 00000000..f726abe3 --- /dev/null +++ b/packages/shared_preferences_linux/test/fake_path_provider_linux.dart @@ -0,0 +1,29 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_linux/path_provider_linux.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; + +/// Fake implementation of PathProviderLinux that returns hard-coded paths, +/// allowing tests to run on any platform. +/// +/// Note that this should only be used with an in-memory filesystem, as the +/// path it returns is a root path that does not actually exist on Linux. +class FakePathProviderLinux extends PathProviderPlatform + implements PathProviderLinux { + @override + Future getApplicationSupportPath() async => r'/appsupport'; + + @override + Future getTemporaryPath() async => null; + + @override + Future getLibraryPath() async => null; + + @override + Future getApplicationDocumentsPath() async => null; + + @override + Future getDownloadsPath() async => null; +} diff --git a/packages/shared_preferences_linux/test/legacy_shared_preferences_linux_test.dart b/packages/shared_preferences_linux/test/legacy_shared_preferences_linux_test.dart new file mode 100644 index 00000000..06faaa5c --- /dev/null +++ b/packages/shared_preferences_linux/test/legacy_shared_preferences_linux_test.dart @@ -0,0 +1,257 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +import 'dart:convert'; + +import 'package:file/memory.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider_linux/path_provider_linux.dart'; +import 'package:shared_preferences_linux/shared_preferences_linux.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; +import 'package:shared_preferences_platform_interface/types.dart'; + +import 'fake_path_provider_linux.dart'; + +void main() { + late MemoryFileSystem fs; + late PathProviderLinux pathProvider; + + SharedPreferencesLinux.registerWith(); + + const Map flutterTestValues = { + 'flutter.String': 'hello world', + 'flutter.Bool': true, + 'flutter.Int': 42, + 'flutter.Double': 3.14159, + 'flutter.StringList': ['foo', 'bar'], + }; + + const Map prefixTestValues = { + 'prefix.String': 'hello world', + 'prefix.Bool': true, + 'prefix.Int': 42, + 'prefix.Double': 3.14159, + 'prefix.StringList': ['foo', 'bar'], + }; + + const Map nonPrefixTestValues = { + 'String': 'hello world', + 'Bool': true, + 'Int': 42, + 'Double': 3.14159, + 'StringList': ['foo', 'bar'], + }; + + final Map allTestValues = {}; + + allTestValues.addAll(flutterTestValues); + allTestValues.addAll(prefixTestValues); + allTestValues.addAll(nonPrefixTestValues); + + setUp(() { + fs = MemoryFileSystem.test(); + pathProvider = FakePathProviderLinux(); + }); + + Future getFilePath() async { + final String? directory = await pathProvider.getApplicationSupportPath(); + return path.join(directory!, 'shared_preferences.json'); + } + + Future writeTestFile(String value) async { + fs.file(await getFilePath()) + ..createSync(recursive: true) + ..writeAsStringSync(value); + } + + Future readTestFile() async { + return fs.file(await getFilePath()).readAsStringSync(); + } + + SharedPreferencesLinux getPreferences() { + final SharedPreferencesLinux prefs = SharedPreferencesLinux(); + prefs.fs = fs; + prefs.pathProvider = pathProvider; + return prefs; + } + + test('registered instance', () async { + SharedPreferencesLinux.registerWith(); + expect( + SharedPreferencesStorePlatform.instance, isA()); + }); + + test('getAll', () async { + await writeTestFile(json.encode(allTestValues)); + final SharedPreferencesLinux prefs = getPreferences(); + + final Map values = await prefs.getAll(); + expect(values, hasLength(5)); + expect(values, flutterTestValues); + }); + + test('getAllWithPrefix', () async { + await writeTestFile(json.encode(allTestValues)); + final SharedPreferencesLinux prefs = getPreferences(); + + final Map values = await prefs.getAllWithPrefix('prefix.'); + expect(values, hasLength(5)); + expect(values, prefixTestValues); + }); + + test('getAllWithParameters', () async { + await writeTestFile(json.encode(allTestValues)); + final SharedPreferencesLinux prefs = getPreferences(); + + final Map values = await prefs.getAllWithParameters( + GetAllParameters( + filter: PreferencesFilter(prefix: 'prefix.'), + ), + ); + expect(values, hasLength(5)); + expect(values, prefixTestValues); + }); + + test('getAllWithParameters with allow list', () async { + await writeTestFile(json.encode(allTestValues)); + final SharedPreferencesLinux prefs = getPreferences(); + + final Map all = await prefs.getAllWithParameters( + GetAllParameters( + filter: PreferencesFilter( + prefix: 'prefix.', + allowList: {'prefix.Bool'}, + ), + ), + ); + expect(all.length, 1); + expect(all['prefix.Bool'], prefixTestValues['prefix.Bool']); + }); + + test('remove', () async { + await writeTestFile('{"key1":"one","key2":2}'); + final SharedPreferencesLinux prefs = getPreferences(); + + await prefs.remove('key2'); + + expect(await readTestFile(), '{"key1":"one"}'); + }); + + test('setValue', () async { + await writeTestFile('{}'); + final SharedPreferencesLinux prefs = getPreferences(); + + await prefs.setValue('', 'key1', 'one'); + await prefs.setValue('', 'key2', 2); + + expect(await readTestFile(), '{"key1":"one","key2":2}'); + }); + + test('clear', () async { + await writeTestFile(json.encode(flutterTestValues)); + final SharedPreferencesLinux prefs = getPreferences(); + + expect(await readTestFile(), json.encode(flutterTestValues)); + await prefs.clear(); + expect(await readTestFile(), '{}'); + }); + + test('clearWithPrefix', () async { + await writeTestFile(json.encode(flutterTestValues)); + final SharedPreferencesLinux prefs = getPreferences(); + await prefs.clearWithPrefix('prefix.'); + final Map noValues = + await prefs.getAllWithPrefix('prefix.'); + expect(noValues, hasLength(0)); + + final Map values = await prefs.getAll(); + expect(values, hasLength(5)); + expect(values, flutterTestValues); + }); + + test('getAllWithNoPrefix', () async { + await writeTestFile(json.encode(allTestValues)); + final SharedPreferencesLinux prefs = getPreferences(); + + final Map values = await prefs.getAllWithPrefix(''); + expect(values, hasLength(15)); + expect(values, allTestValues); + }); + + test('clearWithNoPrefix', () async { + await writeTestFile(json.encode(flutterTestValues)); + final SharedPreferencesLinux prefs = getPreferences(); + await prefs.clearWithPrefix(''); + final Map noValues = await prefs.getAllWithPrefix(''); + expect(noValues, hasLength(0)); + }); + + test('clearWithParameters', () async { + await writeTestFile(json.encode(flutterTestValues)); + final SharedPreferencesLinux prefs = getPreferences(); + await prefs.clearWithParameters( + ClearParameters( + filter: PreferencesFilter(prefix: 'prefix.'), + ), + ); + final Map noValues = await prefs.getAllWithParameters( + GetAllParameters( + filter: PreferencesFilter(prefix: 'prefix.'), + ), + ); + expect(noValues, hasLength(0)); + + final Map values = await prefs.getAll(); + expect(values, hasLength(5)); + expect(values, flutterTestValues); + }); + + test('clearWithParameters with allow list', () async { + await writeTestFile(json.encode(prefixTestValues)); + final SharedPreferencesLinux prefs = getPreferences(); + await prefs.clearWithParameters( + ClearParameters( + filter: PreferencesFilter( + prefix: 'prefix.', + allowList: {'prefix.StringList'}, + ), + ), + ); + final Map someValues = await prefs.getAllWithParameters( + GetAllParameters( + filter: PreferencesFilter(prefix: 'prefix.'), + ), + ); + expect(someValues, hasLength(4)); + }); + + test('getAllWithNoPrefix', () async { + await writeTestFile(json.encode(allTestValues)); + final SharedPreferencesLinux prefs = getPreferences(); + + final Map values = await prefs.getAllWithParameters( + GetAllParameters( + filter: PreferencesFilter(prefix: ''), + ), + ); + expect(values, hasLength(15)); + expect(values, allTestValues); + }); + + test('clearWithNoPrefix', () async { + await writeTestFile(json.encode(flutterTestValues)); + final SharedPreferencesLinux prefs = getPreferences(); + await prefs.clearWithParameters( + ClearParameters( + filter: PreferencesFilter(prefix: ''), + ), + ); + final Map noValues = await prefs.getAllWithParameters( + GetAllParameters( + filter: PreferencesFilter(prefix: ''), + ), + ); + expect(noValues, hasLength(0)); + }); +} diff --git a/packages/shared_preferences_linux/test/shared_preferences_linux_async_test.dart b/packages/shared_preferences_linux/test/shared_preferences_linux_async_test.dart new file mode 100755 index 00000000..d49bdc48 --- /dev/null +++ b/packages/shared_preferences_linux/test/shared_preferences_linux_async_test.dart @@ -0,0 +1,203 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:file/memory.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_linux/path_provider_linux.dart'; +import 'package:shared_preferences_linux/shared_preferences_linux.dart'; +import 'package:shared_preferences_platform_interface/types.dart'; + +import 'fake_path_provider_linux.dart'; + +void main() { + late MemoryFileSystem fs; + late PathProviderLinux pathProvider; + + SharedPreferencesAsyncLinux.registerWith(); + + const String stringKey = 'testString'; + const String boolKey = 'testBool'; + const String intKey = 'testInt'; + const String doubleKey = 'testDouble'; + const String listKey = 'testList'; + + const String testString = 'hello world'; + const bool testBool = true; + const int testInt = 42; + const double testDouble = 3.14159; + const List testList = ['foo', 'bar']; + + const SharedPreferencesLinuxOptions emptyOptions = + SharedPreferencesLinuxOptions(); + + setUp(() { + fs = MemoryFileSystem.test(); + pathProvider = FakePathProviderLinux(); + }); + + SharedPreferencesAsyncLinux getPreferences() { + final SharedPreferencesAsyncLinux prefs = SharedPreferencesAsyncLinux(); + prefs.fs = fs; + prefs.pathProvider = pathProvider; + return prefs; + } + + test('set and get String', () async { + final SharedPreferencesAsyncLinux preferences = getPreferences(); + + await preferences.setString(stringKey, testString, emptyOptions); + expect(await preferences.getString(stringKey, emptyOptions), testString); + }); + + test('set and get bool', () async { + final SharedPreferencesAsyncLinux preferences = getPreferences(); + + await preferences.setBool(boolKey, testBool, emptyOptions); + expect(await preferences.getBool(boolKey, emptyOptions), testBool); + }); + + test('set and get int', () async { + final SharedPreferencesAsyncLinux preferences = getPreferences(); + + await preferences.setInt(intKey, testInt, emptyOptions); + expect(await preferences.getInt(intKey, emptyOptions), testInt); + }); + + test('set and get double', () async { + final SharedPreferencesAsyncLinux preferences = getPreferences(); + + await preferences.setDouble(doubleKey, testDouble, emptyOptions); + expect(await preferences.getDouble(doubleKey, emptyOptions), testDouble); + }); + + test('set and get StringList', () async { + final SharedPreferencesAsyncLinux preferences = getPreferences(); + + await preferences.setStringList(listKey, testList, emptyOptions); + expect(await preferences.getStringList(listKey, emptyOptions), testList); + }); + + test('getPreferences', () async { + final SharedPreferencesAsyncLinux preferences = getPreferences(); + + await preferences.setString(stringKey, testString, emptyOptions); + await preferences.setBool(boolKey, testBool, emptyOptions); + await preferences.setInt(intKey, testInt, emptyOptions); + await preferences.setDouble(doubleKey, testDouble, emptyOptions); + await preferences.setStringList(listKey, testList, emptyOptions); + + final Map gotAll = await preferences.getPreferences( + const GetPreferencesParameters(filter: PreferencesFilters()), + emptyOptions); + + expect(gotAll.length, 5); + expect(gotAll[stringKey], testString); + expect(gotAll[boolKey], testBool); + expect(gotAll[intKey], testInt); + expect(gotAll[doubleKey], testDouble); + expect(gotAll[listKey], testList); + }); + + test('getPreferences with filter', () async { + final SharedPreferencesAsyncLinux preferences = getPreferences(); + + await preferences.setString(stringKey, testString, emptyOptions); + await preferences.setBool(boolKey, testBool, emptyOptions); + await preferences.setInt(intKey, testInt, emptyOptions); + await preferences.setDouble(doubleKey, testDouble, emptyOptions); + await preferences.setStringList(listKey, testList, emptyOptions); + + final Map gotAll = await preferences.getPreferences( + const GetPreferencesParameters( + filter: + PreferencesFilters(allowList: {stringKey, boolKey})), + emptyOptions); + + expect(gotAll.length, 2); + expect(gotAll[stringKey], testString); + expect(gotAll[boolKey], testBool); + }); + + test('getKeys', () async { + final SharedPreferencesAsyncLinux preferences = getPreferences(); + + await preferences.setString(stringKey, testString, emptyOptions); + await preferences.setBool(boolKey, testBool, emptyOptions); + await preferences.setInt(intKey, testInt, emptyOptions); + await preferences.setDouble(doubleKey, testDouble, emptyOptions); + await preferences.setStringList(listKey, testList, emptyOptions); + + final Set keys = await preferences.getKeys( + const GetPreferencesParameters(filter: PreferencesFilters()), + emptyOptions, + ); + + expect(keys.length, 5); + expect(keys, contains(stringKey)); + expect(keys, contains(boolKey)); + expect(keys, contains(intKey)); + expect(keys, contains(doubleKey)); + expect(keys, contains(listKey)); + }); + + test('getKeys with filter', () async { + final SharedPreferencesAsyncLinux preferences = getPreferences(); + + await preferences.setString(stringKey, testString, emptyOptions); + await preferences.setBool(boolKey, testBool, emptyOptions); + await preferences.setInt(intKey, testInt, emptyOptions); + await preferences.setDouble(doubleKey, testDouble, emptyOptions); + await preferences.setStringList(listKey, testList, emptyOptions); + + final Set keys = await preferences.getKeys( + const GetPreferencesParameters( + filter: PreferencesFilters(allowList: {stringKey, boolKey}), + ), + emptyOptions, + ); + + expect(keys.length, 2); + expect(keys, contains(stringKey)); + expect(keys, contains(boolKey)); + }); + + test('clear', () async { + final SharedPreferencesAsyncLinux preferences = getPreferences(); + + await preferences.setString(stringKey, testString, emptyOptions); + await preferences.setBool(boolKey, testBool, emptyOptions); + await preferences.setInt(intKey, testInt, emptyOptions); + await preferences.setDouble(doubleKey, testDouble, emptyOptions); + await preferences.setStringList(listKey, testList, emptyOptions); + await preferences.clear( + const ClearPreferencesParameters(filter: PreferencesFilters()), + emptyOptions); + expect(await preferences.getString(stringKey, emptyOptions), null); + expect(await preferences.getBool(boolKey, emptyOptions), null); + expect(await preferences.getInt(intKey, emptyOptions), null); + expect(await preferences.getDouble(doubleKey, emptyOptions), null); + expect(await preferences.getStringList(listKey, emptyOptions), null); + }); + + test('clear with filter', () async { + final SharedPreferencesAsyncLinux preferences = getPreferences(); + + await preferences.setString(stringKey, testString, emptyOptions); + await preferences.setBool(boolKey, testBool, emptyOptions); + await preferences.setInt(intKey, testInt, emptyOptions); + await preferences.setDouble(doubleKey, testDouble, emptyOptions); + await preferences.setStringList(listKey, testList, emptyOptions); + await preferences.clear( + const ClearPreferencesParameters( + filter: PreferencesFilters(allowList: {stringKey, boolKey}), + ), + emptyOptions, + ); + expect(await preferences.getString(stringKey, emptyOptions), null); + expect(await preferences.getBool(boolKey, emptyOptions), null); + expect(await preferences.getInt(intKey, emptyOptions), testInt); + expect(await preferences.getDouble(doubleKey, emptyOptions), testDouble); + expect(await preferences.getStringList(listKey, emptyOptions), testList); + }); +} diff --git a/packages/shared_preferences_windows/AUTHORS b/packages/shared_preferences_windows/AUTHORS new file mode 100644 index 00000000..493a0b4e --- /dev/null +++ b/packages/shared_preferences_windows/AUTHORS @@ -0,0 +1,66 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> diff --git a/packages/shared_preferences_windows/CHANGELOG.md b/packages/shared_preferences_windows/CHANGELOG.md new file mode 100644 index 00000000..2f9e2208 --- /dev/null +++ b/packages/shared_preferences_windows/CHANGELOG.md @@ -0,0 +1,128 @@ +## 2.4.1 (Plezy vendored patch) + +Vendored from pub.dev `shared_preferences_windows` 2.4.1. Upstream writes the whole +preference document with a bare `writeAsStringSync`, which truncates the live +file before writing it, so every preference write has a window in which the +only copy on disk is empty or half-written. A crash inside that window leaves a +store that fails to parse on every later launch — and it holds the +credential-vault key, so the loss is not recoverable by rewriting it. This is +the corruption behind Plezy issue #1732. + +The local patch stages the document to a sibling `.tmp`, flushes it, then +renames it over the target, and sweeps a stale staging file once the canonical +document has been read cleanly. The published example app is not vendored. + +When updating, reapply both PLEZY DELTA blocks in `lib/shared_preferences_windows.dart` +and see `provenance.json` for the full refresh contract. + +## 2.4.1 + +* Fixes `getStringList` returning immutable list. +* Fixes `getStringList` cast error. +* Updates minimum supported SDK version to Flutter 3.19/Dart 3.3. + +## 2.4.0 + +* Adds `SharedPreferencesAsyncWindows` API. +* Updates minimum supported SDK version to Flutter 3.16/Dart 3.2. + +## 2.3.2 + +* Updates `package:file` version constraints. + +## 2.3.1 + +* Adds pub topics to package metadata. +* Updates minimum supported SDK version to Flutter 3.7/Dart 2.19. + +## 2.3.0 + +* Adds `clearWithParameters` and `getAllWithParameters` methods. +* Updates minimum supported SDK version to Flutter 3.3/Dart 2.18. + +## 2.2.0 + +* Adds `getAllWithPrefix` and `clearWithPrefix` methods. + +## 2.1.5 + +* Clarifies explanation of endorsement in README. +* Aligns Dart and Flutter SDK constraints. + +## 2.1.4 + +* Updates links for the merge of flutter/plugins into flutter/packages. +* Updates minimum Flutter version to 3.0. + +## 2.1.3 + +* Updates code for stricter lint checks. + +## 2.1.2 + +* Updates code for stricter lint checks. +* Updates code for `no_leading_underscores_for_local_identifiers` lint. +* Updates minimum Flutter version to 2.10. + +## 2.1.1 + +* Fixes library_private_types_in_public_api, sort_child_properties_last and use_key_in_widget_constructors + lint warnings. + +## 2.1.0 + +* Deprecated `SharedPreferencesWindows.instance` in favor of `SharedPreferencesStorePlatform.instance`. + +## 2.0.4 + +* Removes dependency on `meta`. + +## 2.0.3 + +* Removed obsolete `pluginClass: none` from pubpsec. +* Fixes newly enabled analyzer options. + +## 2.0.2 + +* Updated installation instructions in README. + +## 2.0.1 + +* Add `implements` to pubspec.yaml. +* Add `registerWith` to the Dart main class. + +## 2.0.0 + +* Migrate to null-safety. + +## 0.0.2+3 + +* Remove 'ffi' dependency. + +## 0.0.2+2 + +* Relax 'ffi' version constraint. + +## 0.0.2+1 + +* Update Flutter SDK constraint. + +## 0.0.2 + +* Update integration test examples to use `testWidgets` instead of `test`. + +## 0.0.1+3 + +* Remove unused `test` dependency. + +## 0.0.1+2 + +* Check in windows/ directory for example/ + +## 0.0.1+1 + +* Add iOS stub for compatibility with 1.17 and earlier. + +## 0.0.1 + +* Initial release to support shared_preferences on Windows. diff --git a/packages/shared_preferences_windows/LICENSE b/packages/shared_preferences_windows/LICENSE new file mode 100644 index 00000000..c6823b81 --- /dev/null +++ b/packages/shared_preferences_windows/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/shared_preferences_windows/README.md b/packages/shared_preferences_windows/README.md new file mode 100644 index 00000000..c1ac6a1f --- /dev/null +++ b/packages/shared_preferences_windows/README.md @@ -0,0 +1,15 @@ +# shared\_preferences\_windows + +The Windows implementation of [`shared_preferences`][1]. + +## Usage + +This package is [endorsed][2], which means you can simply use `shared_preferences` +normally. This package will be automatically included in your app when you do, +so you do not need to add it to your `pubspec.yaml`. + +However, if you `import` this package to use any of its APIs directly, you +should add it to your `pubspec.yaml` as usual. + +[1]: https://pub.dev/packages/shared_preferences +[2]: https://flutter.dev/to/endorsed-federated-plugin diff --git a/packages/shared_preferences_windows/build/native_assets/macos/native_assets.json b/packages/shared_preferences_windows/build/native_assets/macos/native_assets.json new file mode 100644 index 00000000..523bfc7c --- /dev/null +++ b/packages/shared_preferences_windows/build/native_assets/macos/native_assets.json @@ -0,0 +1 @@ +{"format-version":[1,0,0],"native-assets":{}} \ No newline at end of file diff --git a/packages/shared_preferences_windows/build/test_cache/build/06bef57db60962d69da0f7f7c65d9720.cache.dill.track.dill b/packages/shared_preferences_windows/build/test_cache/build/06bef57db60962d69da0f7f7c65d9720.cache.dill.track.dill new file mode 100644 index 00000000..5116fc01 Binary files /dev/null and b/packages/shared_preferences_windows/build/test_cache/build/06bef57db60962d69da0f7f7c65d9720.cache.dill.track.dill differ diff --git a/packages/shared_preferences_windows/build/unit_test_assets/AssetManifest.bin b/packages/shared_preferences_windows/build/unit_test_assets/AssetManifest.bin new file mode 100644 index 00000000..86d111f0 Binary files /dev/null and b/packages/shared_preferences_windows/build/unit_test_assets/AssetManifest.bin differ diff --git a/packages/shared_preferences_windows/build/unit_test_assets/FontManifest.json b/packages/shared_preferences_windows/build/unit_test_assets/FontManifest.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/packages/shared_preferences_windows/build/unit_test_assets/FontManifest.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/packages/shared_preferences_windows/build/unit_test_assets/NOTICES.Z b/packages/shared_preferences_windows/build/unit_test_assets/NOTICES.Z new file mode 100644 index 00000000..9eb1a117 Binary files /dev/null and b/packages/shared_preferences_windows/build/unit_test_assets/NOTICES.Z differ diff --git a/packages/shared_preferences_windows/build/unit_test_assets/NativeAssetsManifest.json b/packages/shared_preferences_windows/build/unit_test_assets/NativeAssetsManifest.json new file mode 100644 index 00000000..523bfc7c --- /dev/null +++ b/packages/shared_preferences_windows/build/unit_test_assets/NativeAssetsManifest.json @@ -0,0 +1 @@ +{"format-version":[1,0,0],"native-assets":{}} \ No newline at end of file diff --git a/packages/shared_preferences_windows/build/unit_test_assets/shaders/ink_sparkle.frag b/packages/shared_preferences_windows/build/unit_test_assets/shaders/ink_sparkle.frag new file mode 100644 index 00000000..ebd80664 Binary files /dev/null and b/packages/shared_preferences_windows/build/unit_test_assets/shaders/ink_sparkle.frag differ diff --git a/packages/shared_preferences_windows/build/unit_test_assets/shaders/stretch_effect.frag b/packages/shared_preferences_windows/build/unit_test_assets/shaders/stretch_effect.frag new file mode 100644 index 00000000..3c938de1 Binary files /dev/null and b/packages/shared_preferences_windows/build/unit_test_assets/shaders/stretch_effect.frag differ diff --git a/packages/shared_preferences_windows/lib/shared_preferences_windows.dart b/packages/shared_preferences_windows/lib/shared_preferences_windows.dart new file mode 100644 index 00000000..c8f53cfe --- /dev/null +++ b/packages/shared_preferences_windows/lib/shared_preferences_windows.dart @@ -0,0 +1,456 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert' show json; + +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting; +import 'package:path/path.dart' as path; +import 'package:path_provider_windows/path_provider_windows.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; +import 'package:shared_preferences_platform_interface/types.dart'; + +const String _defaultFileName = 'shared_preferences'; + +const String _defaultPrefix = 'flutter.'; + +/// The Windows implementation of [SharedPreferencesStorePlatform]. +/// +/// This class implements the `package:shared_preferences` functionality for Windows. +class SharedPreferencesWindows extends SharedPreferencesStorePlatform { + /// Deprecated instance of [SharedPreferencesWindows]. + /// Use [SharedPreferencesStorePlatform.instance] instead. + @Deprecated('Use `SharedPreferencesStorePlatform.instance` instead.') + static SharedPreferencesWindows instance = SharedPreferencesWindows(); + + /// Registers the Windows implementation. + static void registerWith() { + SharedPreferencesStorePlatform.instance = SharedPreferencesWindows(); + // A temporary work-around for having two plugins contained in a single package. + SharedPreferencesAsyncWindows.registerWith(); + } + + /// Local copy of preferences + Map? _cachedPreferences; + + /// File system used to store to disk. Exposed for testing only. + @visibleForTesting + FileSystem fs = const LocalFileSystem(); + + /// The path_provider_windows instance used to find the support directory. + @visibleForTesting + PathProviderWindows pathProvider = PathProviderWindows(); + + /// Checks for cached preferences and returns them or loads preferences from + /// file and returns and caches them. + Future> _readPreferences() async { + _cachedPreferences ??= await _readFromFile( + _defaultFileName, + fs: fs, + pathProvider: pathProvider, + ); + return _cachedPreferences!; + } + + @override + Future clear() async { + return clearWithParameters( + ClearParameters( + filter: PreferencesFilter(prefix: _defaultPrefix), + ), + ); + } + + @override + Future clearWithPrefix(String prefix) async { + return clearWithParameters(ClearParameters(filter: PreferencesFilter(prefix: prefix))); + } + + @override + Future clearWithParameters(ClearParameters parameters) async { + final PreferencesFilter filter = parameters.filter; + + final Map preferences = await _readPreferences(); + preferences.removeWhere((String key, _) => + key.startsWith(filter.prefix) && (filter.allowList == null || filter.allowList!.contains(key))); + return _writePreferences( + preferences, + _defaultFileName, + fs: fs, + pathProvider: pathProvider, + ); + } + + @override + Future> getAll() async { + return getAllWithParameters( + GetAllParameters( + filter: PreferencesFilter(prefix: _defaultPrefix), + ), + ); + } + + @override + Future> getAllWithPrefix(String prefix) async { + return getAllWithParameters(GetAllParameters(filter: PreferencesFilter(prefix: prefix))); + } + + @override + Future> getAllWithParameters(GetAllParameters parameters) async { + final PreferencesFilter filter = parameters.filter; + final Map withPrefix = Map.from(await _readPreferences()); + withPrefix + .removeWhere((String key, _) => !(key.startsWith(filter.prefix) && (filter.allowList?.contains(key) ?? true))); + return withPrefix; + } + + @override + Future remove(String key) async { + final Map preferences = await _readPreferences(); + preferences.remove(key); + return _writePreferences( + preferences, + _defaultFileName, + fs: fs, + pathProvider: pathProvider, + ); + } + + @override + Future setValue(String valueType, String key, Object value) async { + final Map preferences = await _readPreferences(); + preferences[key] = value; + return _writePreferences( + preferences, + _defaultFileName, + fs: fs, + pathProvider: pathProvider, + ); + } +} + +/// The Windows implementation of [SharedPreferencesAsyncPlatform]. +/// +/// This class implements the `package:shared_preferences` functionality for Windows. +base class SharedPreferencesAsyncWindows extends SharedPreferencesAsyncPlatform { + /// Registers the Windows implementation. + static void registerWith() { + SharedPreferencesAsyncPlatform.instance = SharedPreferencesAsyncWindows(); + } + + /// Local copy of preferences + Map? _cachedPreferences; + + /// File system used to store to disk. Exposed for testing only. + @visibleForTesting + FileSystem fs = const LocalFileSystem(); + + /// The path_provider_windows instance used to find the support directory. + @visibleForTesting + PathProviderWindows pathProvider = PathProviderWindows(); + + @override + Future> getKeys( + GetPreferencesParameters parameters, + SharedPreferencesOptions options, + ) async { + return (await getPreferences(parameters, options)).keys.toSet(); + } + + @override + Future setString( + String key, + String value, + SharedPreferencesOptions options, + ) { + return _setValue(key, value, options); + } + + @override + Future setBool( + String key, + bool value, + SharedPreferencesOptions options, + ) { + return _setValue(key, value, options); + } + + @override + Future setDouble( + String key, + double value, + SharedPreferencesOptions options, + ) { + return _setValue(key, value, options); + } + + @override + Future setInt( + String key, + int value, + SharedPreferencesOptions options, + ) { + return _setValue(key, value, options); + } + + @override + Future setStringList( + String key, + List value, + SharedPreferencesOptions options, + ) { + return _setValue(key, value, options); + } + + @override + Future getString( + String key, + SharedPreferencesOptions options, + ) async { + final Map data = await _readAll({key}, options); + return data[key] as String?; + } + + @override + Future getBool( + String key, + SharedPreferencesOptions options, + ) async { + final Map data = await _readAll({key}, options); + return data[key] as bool?; + } + + @override + Future getDouble( + String key, + SharedPreferencesOptions options, + ) async { + final Map data = await _readAll({key}, options); + return data[key] as double?; + } + + @override + Future getInt( + String key, + SharedPreferencesOptions options, + ) async { + final Map data = await _readAll({key}, options); + return data[key] as int?; + } + + @override + Future?> getStringList( + String key, + SharedPreferencesOptions options, + ) async { + final Map data = await _readAll({key}, options); + return (data[key] as List?)?.cast().toList(); + } + + @override + Future clear(ClearPreferencesParameters parameters, SharedPreferencesOptions options) async { + final SharedPreferencesWindowsOptions windowsOptions = + SharedPreferencesWindowsOptions.fromSharedPreferencesOptions(options); + final PreferencesFilters filter = parameters.filter; + final Map preferences = await _readPreferences(windowsOptions.fileName); + preferences.removeWhere((String key, _) => filter.allowList == null || filter.allowList!.contains(key)); + await _writePreferences( + preferences, + windowsOptions.fileName, + fs: fs, + pathProvider: pathProvider, + ); + } + + @override + Future> getPreferences( + GetPreferencesParameters parameters, + SharedPreferencesOptions options, + ) async { + return _readAll(parameters.filter.allowList, options); + } + + /// Reloads preferences from file. + @visibleForTesting + Future reload( + SharedPreferencesWindowsOptions options, + ) async { + _cachedPreferences = await _readFromFile(options.fileName); + } + + Future> _readAll( + Set? allowList, + SharedPreferencesOptions options, + ) async { + final SharedPreferencesWindowsOptions windowsOptions = + SharedPreferencesWindowsOptions.fromSharedPreferencesOptions(options); + final Map prefs = Map.from(await _readPreferences(windowsOptions.fileName)); + prefs.removeWhere((String key, _) => !(allowList?.contains(key) ?? true)); + return prefs; + } + + Future _setValue(String key, Object value, SharedPreferencesOptions options) async { + final SharedPreferencesWindowsOptions windowsOptions = + SharedPreferencesWindowsOptions.fromSharedPreferencesOptions(options); + final Map preferences = await _readPreferences(windowsOptions.fileName); + preferences[key] = value; + await _writePreferences( + preferences, + windowsOptions.fileName, + fs: fs, + pathProvider: pathProvider, + ); + } + + /// Checks for cached preferences and returns them or loads preferences from + /// file and returns and caches them. + Future> _readPreferences(String fileName) async { + _cachedPreferences ??= await _readFromFile( + fileName, + fs: fs, + pathProvider: pathProvider, + ); + return _cachedPreferences!; + } +} + +/// Gets the file where the preferences are stored. +Future _getLocalDataFile( + String fileName, { + FileSystem fs = const LocalFileSystem(), + PathProviderWindows? pathProvider, +}) async { + pathProvider = pathProvider ?? PathProviderWindows(); + final String? directory = await pathProvider.getApplicationSupportPath(); + if (directory == null) { + return null; + } + final String fileLocation = path.join(directory, '$fileName.json'); + return fs.file(fileLocation); +} + +/// Suffix of the staging file used by the atomic write below. +/// +/// PLEZY DELTA. Deliberately a single fixed name rather than a stamped one: +/// this file holds the whole preference document, credentials included, so at +/// most one may ever exist and a later write must reuse it rather than +/// accumulate copies. +const String _stagingSuffix = '.tmp'; + +/// Gets the preferences from the stored file. +Future> _readFromFile( + String fileName, { + FileSystem fs = const LocalFileSystem(), + PathProviderWindows? pathProvider, +}) async { + Map preferences = {}; + final File? localDataFile = await _getLocalDataFile( + fileName, + fs: fs, + pathProvider: pathProvider, + ); + if (localDataFile != null && localDataFile.existsSync()) { + final String stringMap = localDataFile.readAsStringSync(); + if (stringMap.isNotEmpty) { + final Object? data = json.decode(stringMap); + if (data is Map) { + preferences = data.cast(); + } + } + // PLEZY DELTA: the canonical document just read cleanly, so any staging + // file left by an interrupted write is stale. It is a plaintext copy of + // the credentials, so it does not get to sit there indefinitely. + _removeStagingFile(localDataFile); + } + return preferences; +} + +/// Deletes a stale staging file, best effort. +/// +/// PLEZY DELTA. Never allowed to fail a read: the staging file is ours, and a +/// locked or already-removed one changes nothing about the document. +void _removeStagingFile(File localDataFile) { + try { + final File staged = localDataFile.fileSystem.file('${localDataFile.path}$_stagingSuffix'); + if (staged.existsSync()) { + staged.deleteSync(); + } + } catch (e) { + debugPrint('Could not remove a stale preferences staging file: $e'); + } +} + +/// Writes the cached preferences to disk. Returns [true] if the operation +/// succeeded. +Future _writePreferences( + Map preferences, + String fileName, { + FileSystem fs = const LocalFileSystem(), + PathProviderWindows? pathProvider, +}) async { + try { + final File? localDataFile = await _getLocalDataFile( + fileName, + fs: fs, + pathProvider: pathProvider, + ); + if (localDataFile == null) { + debugPrint('Unable to determine where to write preferences.'); + return false; + } + final String stringMap = json.encode(preferences); + + // PLEZY DELTA: stage, flush, then rename over the target. + // + // Upstream calls `writeAsStringSync` straight onto the live document. + // That opens with the default `FileMode.write`, which truncates first, so + // every single preference write has a window in which the only copy of the + // store on disk is empty or half-written. A crash, power loss, forced + // reboot or antivirus interception inside that window leaves a document + // that fails to parse on every subsequent launch, and the store holds the + // credential-vault key, so the damage is not recoverable by rewriting it. + // + // Renaming into place is atomic for readers: `MoveFileExW` with + // MOVEFILE_REPLACE_EXISTING on Windows, `rename(2)` elsewhere. The flush + // has to come first, or the rename could publish a file whose contents + // were never committed — the same corruption by a different route. + // + // The staging file is created in the target's own directory, both because + // rename must stay on one volume and so it inherits exactly the mode the + // canonical file would have been created with. + final File staged = localDataFile.fileSystem.file('${localDataFile.path}$_stagingSuffix'); + if (!staged.parent.existsSync()) { + staged.parent.createSync(recursive: true); + } + staged.writeAsStringSync(stringMap, flush: true); + staged.renameSync(localDataFile.path); + } catch (e) { + debugPrint('Error saving preferences to disk: $e'); + return false; + } + return true; +} + +/// Windows specific SharedPreferences Options. +class SharedPreferencesWindowsOptions extends SharedPreferencesOptions { + /// Constructor for SharedPreferencesWindowsOptions. + const SharedPreferencesWindowsOptions({ + this.fileName = 'shared_preferences', // Same as current defaults. + }); + + /// The name of the file to store preferences in. + final String fileName; + + /// Returns a new instance of [SharedPreferencesWindowsOptions] from an existing + /// [SharedPreferencesOptions]. + static SharedPreferencesWindowsOptions fromSharedPreferencesOptions(SharedPreferencesOptions options) { + if (options is SharedPreferencesWindowsOptions) { + return options; + } + return const SharedPreferencesWindowsOptions(); + } +} diff --git a/packages/shared_preferences_windows/provenance.json b/packages/shared_preferences_windows/provenance.json new file mode 100644 index 00000000..f1b1547c --- /dev/null +++ b/packages/shared_preferences_windows/provenance.json @@ -0,0 +1,20 @@ +{ + "formatVersion": 1, + "upstream": { + "repository": "https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences_windows", + "packageVersion": "2.4.1", + "source": "pub.dev" + }, + "plezyDeltas": [ + "atomic store replacement: stage to a sibling .tmp, flush, then rename over the target", + "stale staging files are swept once the canonical document has been read cleanly", + "the published example app is not vendored" + ], + "refreshContract": [ + "Diff against the new upstream version and reapply every PLEZY DELTA block in lib/shared_preferences_windows.dart verbatim.", + "Never reintroduce a bare writeAsStringSync onto the live document: it truncates before writing, which is the corruption in issue #1732.", + "Keep the staging name fixed and single. The file is a plaintext copy of the credential-vault key, tracker refresh tokens and Seerr cookies; stamped names would accumulate them.", + "Keep the flush before the rename. A rename can otherwise publish contents that were never committed.", + "Run flutter test test/services/prefs_store_atomic_write_test.dart from the repository root, and the real-Windows step in .github/workflows/ci.yml." + ] +} diff --git a/packages/shared_preferences_windows/pubspec.lock b/packages/shared_preferences_windows/pubspec.lock new file mode 100644 index 00000000..f412644a --- /dev/null +++ b/packages/shared_preferences_windows/pubspec.lock @@ -0,0 +1,245 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: "direct main" + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider_platform_interface: + dependency: "direct main" + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: "direct main" + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + shared_preferences_platform_interface: + dependency: "direct main" + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" +sdks: + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/packages/shared_preferences_windows/pubspec.yaml b/packages/shared_preferences_windows/pubspec.yaml new file mode 100644 index 00000000..59c32744 --- /dev/null +++ b/packages/shared_preferences_windows/pubspec.yaml @@ -0,0 +1,34 @@ +name: shared_preferences_windows +description: Windows implementation of shared_preferences +repository: https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences_windows +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+shared_preferences%22 +version: 2.4.1 + +environment: + sdk: ^3.3.0 + flutter: ">=3.19.0" + +flutter: + plugin: + implements: shared_preferences + platforms: + windows: + dartPluginClass: SharedPreferencesWindows + +dependencies: + file: ">=6.0.0 <8.0.0" + flutter: + sdk: flutter + path: ^1.8.0 + path_provider_platform_interface: ^2.0.0 + path_provider_windows: ^2.0.0 + shared_preferences_platform_interface: ^2.4.0 + +dev_dependencies: + flutter_test: + sdk: flutter + +topics: + - persistence + - shared-preferences + - storage diff --git a/packages/shared_preferences_windows/test/fake_path_provider_windows.dart b/packages/shared_preferences_windows/test/fake_path_provider_windows.dart new file mode 100644 index 00000000..8b7b3741 --- /dev/null +++ b/packages/shared_preferences_windows/test/fake_path_provider_windows.dart @@ -0,0 +1,36 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:path_provider_windows/path_provider_windows.dart'; + +/// Fake implementation of PathProviderWindows that returns hard-coded paths, +/// allowing tests to run on any platform. +/// +/// Note that this should only be used with an in-memory filesystem, as the +/// path it returns is a root path that does not actually exist on Windows. +class FakePathProviderWindows extends PathProviderPlatform + implements PathProviderWindows { + @override + late VersionInfoQuerier versionInfoQuerier; + + @override + Future getApplicationSupportPath() async => r'C:\appsupport'; + + @override + Future getTemporaryPath() async => null; + + @override + Future getLibraryPath() async => null; + + @override + Future getApplicationDocumentsPath() async => null; + + @override + Future getDownloadsPath() async => null; + + @override + Future getPath(String folderID) async => ''; +} diff --git a/packages/shared_preferences_windows/test/legacy_shared_preferences_windows_test.dart b/packages/shared_preferences_windows/test/legacy_shared_preferences_windows_test.dart new file mode 100644 index 00000000..6a8de0aa --- /dev/null +++ b/packages/shared_preferences_windows/test/legacy_shared_preferences_windows_test.dart @@ -0,0 +1,258 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:file/memory.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider_windows/path_provider_windows.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; +import 'package:shared_preferences_platform_interface/types.dart'; +import 'package:shared_preferences_windows/shared_preferences_windows.dart'; + +import 'fake_path_provider_windows.dart'; + +void main() { + late MemoryFileSystem fs; + late PathProviderWindows pathProvider; + + SharedPreferencesWindows.registerWith(); + + const Map flutterTestValues = { + 'flutter.String': 'hello world', + 'flutter.Bool': true, + 'flutter.Int': 42, + 'flutter.Double': 3.14159, + 'flutter.StringList': ['foo', 'bar'], + }; + + const Map prefixTestValues = { + 'prefix.String': 'hello world', + 'prefix.Bool': true, + 'prefix.Int': 42, + 'prefix.Double': 3.14159, + 'prefix.StringList': ['foo', 'bar'], + }; + + const Map nonPrefixTestValues = { + 'String': 'hello world', + 'Bool': true, + 'Int': 42, + 'Double': 3.14159, + 'StringList': ['foo', 'bar'], + }; + + final Map allTestValues = {}; + + allTestValues.addAll(flutterTestValues); + allTestValues.addAll(prefixTestValues); + allTestValues.addAll(nonPrefixTestValues); + + setUp(() { + fs = MemoryFileSystem.test(); + pathProvider = FakePathProviderWindows(); + }); + + Future getFilePath() async { + final String? directory = await pathProvider.getApplicationSupportPath(); + return path.join(directory!, 'shared_preferences.json'); + } + + Future writeTestFile(String value) async { + fs.file(await getFilePath()) + ..createSync(recursive: true) + ..writeAsStringSync(value); + } + + Future readTestFile() async { + return fs.file(await getFilePath()).readAsStringSync(); + } + + SharedPreferencesWindows getPreferences() { + final SharedPreferencesWindows prefs = SharedPreferencesWindows(); + prefs.fs = fs; + prefs.pathProvider = pathProvider; + return prefs; + } + + test('registered instance', () async { + SharedPreferencesWindows.registerWith(); + expect(SharedPreferencesStorePlatform.instance, + isA()); + }); + + test('getAll', () async { + await writeTestFile(json.encode(allTestValues)); + final SharedPreferencesWindows prefs = getPreferences(); + + final Map values = await prefs.getAll(); + expect(values, hasLength(5)); + expect(values, flutterTestValues); + }); + + test('getAllWithPrefix', () async { + await writeTestFile(json.encode(allTestValues)); + final SharedPreferencesWindows prefs = getPreferences(); + + final Map values = await prefs.getAllWithPrefix('prefix.'); + expect(values, hasLength(5)); + expect(values, prefixTestValues); + }); + + test('getAllWithParameters with Prefix', () async { + await writeTestFile(json.encode(allTestValues)); + final SharedPreferencesWindows prefs = getPreferences(); + + final Map values = await prefs.getAllWithParameters( + GetAllParameters( + filter: PreferencesFilter(prefix: 'prefix.'), + ), + ); + expect(values, hasLength(5)); + expect(values, prefixTestValues); + }); + + test('getAllWithParameters with Prefix with allow list', () async { + await writeTestFile(json.encode(allTestValues)); + final SharedPreferencesWindows prefs = getPreferences(); + + final Map all = await prefs.getAllWithParameters( + GetAllParameters( + filter: PreferencesFilter( + prefix: 'prefix.', + allowList: {'prefix.Bool'}, + ), + ), + ); + expect(all.length, 1); + expect(all['prefix.Bool'], prefixTestValues['prefix.Bool']); + }); + + test('remove', () async { + await writeTestFile('{"key1":"one","key2":2}'); + final SharedPreferencesWindows prefs = getPreferences(); + + await prefs.remove('key2'); + + expect(await readTestFile(), '{"key1":"one"}'); + }); + + test('setValue', () async { + await writeTestFile('{}'); + final SharedPreferencesWindows prefs = getPreferences(); + + await prefs.setValue('', 'key1', 'one'); + await prefs.setValue('', 'key2', 2); + + expect(await readTestFile(), '{"key1":"one","key2":2}'); + }); + + test('clear', () async { + await writeTestFile(json.encode(flutterTestValues)); + final SharedPreferencesWindows prefs = getPreferences(); + + expect(await readTestFile(), json.encode(flutterTestValues)); + await prefs.clear(); + expect(await readTestFile(), '{}'); + }); + + test('clearWithPrefix', () async { + await writeTestFile(json.encode(flutterTestValues)); + final SharedPreferencesWindows prefs = getPreferences(); + await prefs.clearWithPrefix('prefix.'); + final Map noValues = + await prefs.getAllWithPrefix('prefix.'); + expect(noValues, hasLength(0)); + + final Map values = await prefs.getAll(); + expect(values, hasLength(5)); + expect(values, flutterTestValues); + }); + + test('getAllWithNoPrefix', () async { + await writeTestFile(json.encode(allTestValues)); + final SharedPreferencesWindows prefs = getPreferences(); + + final Map values = await prefs.getAllWithPrefix(''); + expect(values, hasLength(15)); + expect(values, allTestValues); + }); + + test('clearWithNoPrefix', () async { + await writeTestFile(json.encode(flutterTestValues)); + final SharedPreferencesWindows prefs = getPreferences(); + await prefs.clearWithPrefix(''); + final Map noValues = await prefs.getAllWithPrefix(''); + expect(noValues, hasLength(0)); + }); + + test('clearWithParameters with Prefix', () async { + await writeTestFile(json.encode(flutterTestValues)); + final SharedPreferencesWindows prefs = getPreferences(); + await prefs.clearWithParameters( + ClearParameters( + filter: PreferencesFilter(prefix: 'prefix.'), + ), + ); + final Map noValues = await prefs.getAllWithParameters( + GetAllParameters( + filter: PreferencesFilter(prefix: 'prefix.'), + ), + ); + expect(noValues, hasLength(0)); + + final Map values = await prefs.getAll(); + expect(values, hasLength(5)); + expect(values, flutterTestValues); + }); + + test('clearWithParameters with allow list', () async { + await writeTestFile(json.encode(prefixTestValues)); + final SharedPreferencesWindows prefs = getPreferences(); + await prefs.clearWithParameters( + ClearParameters( + filter: PreferencesFilter( + prefix: 'prefix.', + allowList: {'prefix.StringList'}, + ), + ), + ); + final Map noValues = await prefs.getAllWithParameters( + GetAllParameters( + filter: PreferencesFilter(prefix: 'prefix.'), + ), + ); + expect(noValues, hasLength(4)); + }); + + test('getAllWithNoPrefix', () async { + await writeTestFile(json.encode(allTestValues)); + final SharedPreferencesWindows prefs = getPreferences(); + + final Map values = await prefs.getAllWithParameters( + GetAllParameters( + filter: PreferencesFilter(prefix: ''), + ), + ); + expect(values, hasLength(15)); + expect(values, allTestValues); + }); + + test('clearWithNoPrefix', () async { + await writeTestFile(json.encode(flutterTestValues)); + final SharedPreferencesWindows prefs = getPreferences(); + await prefs.clearWithParameters( + ClearParameters( + filter: PreferencesFilter(prefix: ''), + ), + ); + final Map noValues = await prefs.getAllWithParameters( + GetAllParameters( + filter: PreferencesFilter(prefix: ''), + ), + ); + expect(noValues, hasLength(0)); + }); +} diff --git a/packages/shared_preferences_windows/test/shared_preferences_windows_async_test.dart b/packages/shared_preferences_windows/test/shared_preferences_windows_async_test.dart new file mode 100755 index 00000000..6846f1fa --- /dev/null +++ b/packages/shared_preferences_windows/test/shared_preferences_windows_async_test.dart @@ -0,0 +1,197 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:file/memory.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_windows/path_provider_windows.dart'; +import 'package:shared_preferences_platform_interface/types.dart'; +import 'package:shared_preferences_windows/shared_preferences_windows.dart'; + +import 'fake_path_provider_windows.dart'; + +void main() { + late MemoryFileSystem fs; + late PathProviderWindows pathProvider; + + SharedPreferencesAsyncWindows.registerWith(); + + const String stringKey = 'testString'; + const String boolKey = 'testBool'; + const String intKey = 'testInt'; + const String doubleKey = 'testDouble'; + const String listKey = 'testList'; + + const String testString = 'hello world'; + const bool testBool = true; + const int testInt = 42; + const double testDouble = 3.14159; + const List testList = ['foo', 'bar']; + + const SharedPreferencesWindowsOptions emptyOptions = + SharedPreferencesWindowsOptions(); + + setUp(() { + fs = MemoryFileSystem.test(); + pathProvider = FakePathProviderWindows(); + }); + + SharedPreferencesAsyncWindows getPreferences() { + final SharedPreferencesAsyncWindows prefs = SharedPreferencesAsyncWindows(); + prefs.fs = fs; + prefs.pathProvider = pathProvider; + return prefs; + } + + test('set and get String', () async { + final SharedPreferencesAsyncWindows preferences = getPreferences(); + + await preferences.setString(stringKey, testString, emptyOptions); + expect(await preferences.getString(stringKey, emptyOptions), testString); + }); + + test('set and get bool', () async { + final SharedPreferencesAsyncWindows preferences = getPreferences(); + + await preferences.setBool(boolKey, testBool, emptyOptions); + expect(await preferences.getBool(boolKey, emptyOptions), testBool); + }); + + test('set and get int', () async { + final SharedPreferencesAsyncWindows preferences = getPreferences(); + + await preferences.setInt(intKey, testInt, emptyOptions); + expect(await preferences.getInt(intKey, emptyOptions), testInt); + }); + + test('set and get double', () async { + final SharedPreferencesAsyncWindows preferences = getPreferences(); + + await preferences.setDouble(doubleKey, testDouble, emptyOptions); + expect(await preferences.getDouble(doubleKey, emptyOptions), testDouble); + }); + + test('set and get StringList', () async { + final SharedPreferencesAsyncWindows preferences = getPreferences(); + + await preferences.setStringList(listKey, testList, emptyOptions); + expect(await preferences.getStringList(listKey, emptyOptions), testList); + }); + + test('getPreferences', () async { + final SharedPreferencesAsyncWindows preferences = getPreferences(); + await preferences.setString(stringKey, testString, emptyOptions); + await preferences.setBool(boolKey, testBool, emptyOptions); + await preferences.setInt(intKey, testInt, emptyOptions); + await preferences.setDouble(doubleKey, testDouble, emptyOptions); + await preferences.setStringList(listKey, testList, emptyOptions); + + final Map gotAll = await preferences.getPreferences( + const GetPreferencesParameters(filter: PreferencesFilters()), + emptyOptions); + + expect(gotAll.length, 5); + expect(gotAll[stringKey], testString); + expect(gotAll[boolKey], testBool); + expect(gotAll[intKey], testInt); + expect(gotAll[doubleKey], testDouble); + expect(gotAll[listKey], testList); + }); + + test('getPreferences with filter', () async { + final SharedPreferencesAsyncWindows preferences = getPreferences(); + await preferences.setString(stringKey, testString, emptyOptions); + await preferences.setBool(boolKey, testBool, emptyOptions); + await preferences.setInt(intKey, testInt, emptyOptions); + await preferences.setDouble(doubleKey, testDouble, emptyOptions); + await preferences.setStringList(listKey, testList, emptyOptions); + + final Map gotAll = await preferences.getPreferences( + const GetPreferencesParameters( + filter: + PreferencesFilters(allowList: {stringKey, boolKey})), + emptyOptions); + + expect(gotAll.length, 2); + expect(gotAll[stringKey], testString); + expect(gotAll[boolKey], testBool); + }); + + test('getKeys', () async { + final SharedPreferencesAsyncWindows preferences = getPreferences(); + await preferences.setString(stringKey, testString, emptyOptions); + await preferences.setBool(boolKey, testBool, emptyOptions); + await preferences.setInt(intKey, testInt, emptyOptions); + await preferences.setDouble(doubleKey, testDouble, emptyOptions); + await preferences.setStringList(listKey, testList, emptyOptions); + + final Set keys = await preferences.getKeys( + const GetPreferencesParameters(filter: PreferencesFilters()), + emptyOptions, + ); + + expect(keys.length, 5); + expect(keys, contains(stringKey)); + expect(keys, contains(boolKey)); + expect(keys, contains(intKey)); + expect(keys, contains(doubleKey)); + expect(keys, contains(listKey)); + }); + + test('getKeys with filter', () async { + final SharedPreferencesAsyncWindows preferences = getPreferences(); + await preferences.setString(stringKey, testString, emptyOptions); + await preferences.setBool(boolKey, testBool, emptyOptions); + await preferences.setInt(intKey, testInt, emptyOptions); + await preferences.setDouble(doubleKey, testDouble, emptyOptions); + await preferences.setStringList(listKey, testList, emptyOptions); + + final Set keys = await preferences.getKeys( + const GetPreferencesParameters( + filter: PreferencesFilters(allowList: {stringKey, boolKey}), + ), + emptyOptions, + ); + + expect(keys.length, 2); + expect(keys, contains(stringKey)); + expect(keys, contains(boolKey)); + }); + + test('clear', () async { + final SharedPreferencesAsyncWindows preferences = getPreferences(); + await preferences.setString(stringKey, testString, emptyOptions); + await preferences.setBool(boolKey, testBool, emptyOptions); + await preferences.setInt(intKey, testInt, emptyOptions); + await preferences.setDouble(doubleKey, testDouble, emptyOptions); + await preferences.setStringList(listKey, testList, emptyOptions); + await preferences.clear( + const ClearPreferencesParameters(filter: PreferencesFilters()), + emptyOptions); + expect(await preferences.getString(stringKey, emptyOptions), null); + expect(await preferences.getBool(boolKey, emptyOptions), null); + expect(await preferences.getInt(intKey, emptyOptions), null); + expect(await preferences.getDouble(doubleKey, emptyOptions), null); + expect(await preferences.getStringList(listKey, emptyOptions), null); + }); + + test('clear with filter', () async { + final SharedPreferencesAsyncWindows preferences = getPreferences(); + await preferences.setString(stringKey, testString, emptyOptions); + await preferences.setBool(boolKey, testBool, emptyOptions); + await preferences.setInt(intKey, testInt, emptyOptions); + await preferences.setDouble(doubleKey, testDouble, emptyOptions); + await preferences.setStringList(listKey, testList, emptyOptions); + await preferences.clear( + const ClearPreferencesParameters( + filter: PreferencesFilters(allowList: {stringKey, boolKey}), + ), + emptyOptions, + ); + expect(await preferences.getString(stringKey, emptyOptions), null); + expect(await preferences.getBool(boolKey, emptyOptions), null); + expect(await preferences.getInt(intKey, emptyOptions), testInt); + expect(await preferences.getDouble(doubleKey, emptyOptions), testDouble); + expect(await preferences.getStringList(listKey, emptyOptions), testList); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 1fa4a129..dec11cb7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -861,7 +861,7 @@ packages: source: hosted version: "2.1.3" path_provider_windows: - dependency: transitive + dependency: "direct dev" description: name: path_provider_windows sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 @@ -1102,10 +1102,9 @@ packages: shared_preferences_linux: dependency: "direct dev" description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted + path: "packages/shared_preferences_linux" + relative: true + source: path version: "2.4.1" shared_preferences_platform_interface: dependency: "direct dev" @@ -1124,12 +1123,11 @@ packages: source: hosted version: "2.4.3" shared_preferences_windows: - dependency: transitive + dependency: "direct dev" description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted + path: "packages/shared_preferences_windows" + relative: true + source: path version: "2.4.1" shelf: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index 6f24b708..e3cb5c6e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -99,11 +99,14 @@ dev_dependencies: drift_dev: ^2.28.3 shared_preferences_platform_interface: ^2.4.0 path_provider_platform_interface: ^2.1.0 - # Drives the real desktop preference backend against a temp file in tests, so - # the #1732 corrupt-store repair loop is covered on any host and not only on - # a Windows/Linux runner. Both classes expose `fs`/`pathProvider` for this. + # Drive the real desktop preference backends against a temp file in tests, so + # the #1732 corrupt-store repair loop and the vendored atomic write are both + # covered against genuine file I/O. All four expose `fs`/`pathProvider`. + # The two shared_preferences packages resolve to the vendored copies below. shared_preferences_linux: ^2.4.1 + shared_preferences_windows: ^2.4.1 path_provider_linux: ^2.2.1 + path_provider_windows: ^2.3.0 plugin_platform_interface: ^2.1.0 freezed: ^3.2.5 analyzer: 10.2.0 @@ -124,6 +127,13 @@ dependency_overrides: url: https://github.com/edde746/auto_updater ref: 9e150f7 path: packages/auto_updater_windows + # Vendored 2.4.1 with an atomic store replacement (CHANGELOG, provenance.json) + # — upstream truncates the live preference document on every write, which is + # the corruption behind #1732. + shared_preferences_windows: + path: packages/shared_preferences_windows + shared_preferences_linux: + path: packages/shared_preferences_linux sentry: org: plezy project: plezy diff --git a/test/services/prefs_store_atomic_write_test.dart b/test/services/prefs_store_atomic_write_test.dart new file mode 100644 index 00000000..dcb52a88 --- /dev/null +++ b/test/services/prefs_store_atomic_write_test.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_linux/path_provider_linux.dart'; +import 'package:plezy/services/prefs_recovery.dart'; +import 'package:shared_preferences_linux/shared_preferences_linux.dart'; +import 'package:shared_preferences_platform_interface/types.dart'; + +/// The vendored `_writePreferences` patch, exercised against a real file +/// system. +/// +/// Upstream writes the whole preference document with a bare +/// `writeAsStringSync`, which opens with the default `FileMode.write` and so +/// truncates the live file before writing it. Every preference write therefore +/// has a window in which the only copy on disk is empty or half-written, and +/// the store holds the credential-vault key — the corruption behind #1732. +/// +/// `shared_preferences_linux` and `shared_preferences_windows` carry the same +/// patch and are byte-identical apart from their path-provider type, so the +/// Linux copy stands in for both here. Windows rename semantics +/// (`MoveFileExW` with MOVEFILE_REPLACE_EXISTING) cannot be proven on a POSIX +/// host and are covered by the windows-latest step in CI. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory support; + late File store; + late File staging; + late SharedPreferencesAsyncLinux backend; + + const options = SharedPreferencesOptions(); + + setUp(() async { + support = await Directory.systemTemp.createTemp('plezy_atomic_write_'); + store = File(p.join(support.path, prefsStoreFileName)); + staging = File('${store.path}.tmp'); + backend = SharedPreferencesAsyncLinux()..pathProvider = _TempPathProviderLinux(support.path); + }); + + tearDown(() async { + if (await support.exists()) await support.delete(recursive: true); + }); + + test('a write leaves no staging file behind', () async { + await backend.setString('theme', 'dark', options); + + expect(await store.exists(), isTrue); + expect(jsonDecode(await store.readAsString()), containsPair('theme', 'dark')); + expect(await staging.exists(), isFalse); + }); + test('a write replaces the document rather than rewriting it in place', () async { + await backend.setString('theme', 'dark', options); + + // A hard link names the same inode as the store. Under upstream's + // truncate-then-write the link would observe the new content, because the + // live file is rewritten underneath every reader holding it open — the + // window that makes an interrupted write destroy the document. Under an + // atomic rename the old inode is simply unlinked from the path, so the + // witness keeps exactly what a reader mid-write would still have seen. + final witness = File(p.join(support.path, 'witness.json')); + if (!_run('ln', [store.path, witness.path])) { + // Windows has no `ln` on PATH at all, and NTFS replacement is covered by + // prefs_store_atomic_write_windows_test.dart instead. + markTestSkipped('hard links are unavailable here'); + return; + } + + await backend.setString('theme', 'light', options); + + expect(jsonDecode(await witness.readAsString()), containsPair('theme', 'dark')); + expect(jsonDecode(await store.readAsString()), containsPair('theme', 'light')); + }); + + test('an interrupted write leaves the live document valid', () async { + await backend.setString('theme', 'dark', options); + final intact = await store.readAsString(); + + // What an interruption leaves once writes are staged: a half-written + // staging file that was never renamed. The invariant this guards is that + // the staging file is never itself the live document. + await staging.writeAsString('{"theme":"light","trunc', flush: true); + + expect(await store.readAsString(), intact); + expect(PrefsRecovery.describeStoreDamage(await store.readAsBytes()), isNull); + }); + + test('a stale staging file is swept once the document reads cleanly', () async { + await backend.setString('theme', 'dark', options); + await staging.writeAsString('{"credential_vault_key_v1":"left-behind"', flush: true); + + // The staging file is a plaintext copy of the credentials, so it must not + // outlive the interrupted write that produced it. + final reader = SharedPreferencesAsyncLinux()..pathProvider = _TempPathProviderLinux(support.path); + await reader.getPreferences(const GetPreferencesParameters(filter: PreferencesFilters()), options); + + expect(await staging.exists(), isFalse); + expect(jsonDecode(await store.readAsString()), containsPair('theme', 'dark')); + }); + + test('writing into a directory that does not exist yet still lands', () async { + final nested = Directory(p.join(support.path, 'nested')); + final nestedBackend = SharedPreferencesAsyncLinux()..pathProvider = _TempPathProviderLinux(nested.path); + + await nestedBackend.setString('theme', 'dark', options); + + expect( + jsonDecode(await File(p.join(nested.path, prefsStoreFileName)).readAsString()), + containsPair('theme', 'dark'), + ); + }); +} + +class _TempPathProviderLinux extends PathProviderLinux { + _TempPathProviderLinux(this.supportPath); + + final String supportPath; + + @override + Future getApplicationSupportPath() async => supportPath; +} + +/// Runs a POSIX helper, reporting whether it succeeded. +/// +/// Returns false rather than throwing when the binary is missing, so a Windows +/// run reaches `markTestSkipped` instead of failing the whole suite on a +/// ProcessException before the test body can decide anything. +bool _run(String executable, List arguments) { + try { + return Process.runSync(executable, arguments).exitCode == 0; + } on ProcessException { + return false; + } +} diff --git a/test/services/prefs_store_atomic_write_windows_test.dart b/test/services/prefs_store_atomic_write_windows_test.dart new file mode 100644 index 00000000..161dd782 --- /dev/null +++ b/test/services/prefs_store_atomic_write_windows_test.dart @@ -0,0 +1,99 @@ +@TestOn('windows') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_windows/path_provider_windows.dart'; +import 'package:plezy/services/prefs_recovery.dart'; +import 'package:shared_preferences_platform_interface/types.dart'; +import 'package:shared_preferences_windows/shared_preferences_windows.dart'; + +/// The vendored `shared_preferences_windows` atomic write, on real NTFS. +/// +/// `prefs_store_atomic_write_test.dart` covers the same patch through the +/// Linux twin and runs everywhere, but it can only prove POSIX `rename(2)`. +/// The store this all exists to protect lives on Windows (#1732), and there +/// the replacement goes through `MoveFileExW` with MOVEFILE_REPLACE_EXISTING — +/// which a POSIX runner and a memory file system are both silent about. Run by +/// the windows-native-test job in CI. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory support; + late File store; + late File staging; + late SharedPreferencesAsyncWindows backend; + + const options = SharedPreferencesOptions(); + + setUp(() async { + support = await Directory.systemTemp.createTemp('plezy_atomic_write_win_'); + store = File(p.join(support.path, prefsStoreFileName)); + staging = File('${store.path}.tmp'); + backend = SharedPreferencesAsyncWindows()..pathProvider = _TempPathProviderWindows(support.path); + }); + + tearDown(() async { + if (await support.exists()) await support.delete(recursive: true); + }); + + test('rename replaces an existing document on NTFS', () async { + // The bare `MoveFileW` this would otherwise compile to fails outright when + // the destination exists. If the vendored write ever loses its replace + // semantics, every preference write after the first one fails silently — + // `_writePreferences` swallows the exception and returns false. + await backend.setString('theme', 'dark', options); + await backend.setString('theme', 'light', options); + + expect(jsonDecode(await store.readAsString()), containsPair('theme', 'light')); + expect(await staging.exists(), isFalse); + }); + + test('a replaced document is complete and parseable', () async { + for (var i = 0; i < 25; i++) { + await backend.setString('key$i', 'value$i', options); + // Every intermediate state is a whole document, never a truncation. + expect(PrefsRecovery.describeStoreDamage(await store.readAsBytes()), isNull); + } + + final document = jsonDecode(await store.readAsString()) as Map; + expect(document, containsPair('key0', 'value0')); + expect(document, containsPair('key24', 'value24')); + }); + + test('a stale staging file is swept once the document reads cleanly', () async { + await backend.setString('theme', 'dark', options); + await staging.writeAsString('{"credential_vault_key_v1":"left-behind"', flush: true); + + final reader = SharedPreferencesAsyncWindows()..pathProvider = _TempPathProviderWindows(support.path); + await reader.getPreferences(const GetPreferencesParameters(filter: PreferencesFilters()), options); + + expect(await staging.exists(), isFalse); + }); + + test('an open reader does not block the replacement', () async { + // Windows keeps mandatory locks on open handles, and antivirus and Search + // Indexer both hold the store open. A replacement that a reader can veto + // would turn every preference write into a silent no-op on exactly the + // machines most likely to have damaged the store in the first place. + await backend.setString('theme', 'dark', options); + final handle = await store.open(); + addTearDown(handle.close); + + await backend.setString('theme', 'light', options); + + expect(jsonDecode(await store.readAsString()), containsPair('theme', 'light')); + }); +} + +class _TempPathProviderWindows extends PathProviderWindows { + _TempPathProviderWindows(this.supportPath); + + final String supportPath; + + @override + Future getApplicationSupportPath() async => supportPath; +}