From ae0290b4fb778067108b3e88088272109a2f1544 Mon Sep 17 00:00:00 2001 From: Calvin Lee Date: Fri, 15 Dec 2023 22:28:13 +0000 Subject: [PATCH] Add utilities for different base directories This should be used to eventually fix dart-lang/sdk#41560. See https://github.com/dart-lang/sdk/issues/49166#issuecomment-1223943236 Test plan: ``` $ dart test 00:01 +16: All tests passed! ``` run `dart doc` and inspect docs for correctness. --- AUTHORS | 1 + CHANGELOG.md | 5 ++ lib/cli_util.dart | 191 +++++++++++++++++++++++++++++++++------- pubspec.yaml | 2 +- test/cli_util_test.dart | 41 +++++---- 5 files changed, 190 insertions(+), 50 deletions(-) diff --git a/AUTHORS b/AUTHORS index 7a6d1d9..64f2a7d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -4,3 +4,4 @@ # Name/Organization Google Inc. +Calvin Lee diff --git a/CHANGELOG.md b/CHANGELOG.md index c3cca22..8ea9cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.4.2 + +- Introduce `applicationCacheHome`, `applicationDataHome`, + `applicationRuntimeDir` and `applicationStateHome`. + ## 0.4.1 - Fix a broken link in the readme. diff --git a/lib/cli_util.dart b/lib/cli_util.dart index c7b867b..50e1e48 100644 --- a/lib/cli_util.dart +++ b/lib/cli_util.dart @@ -2,7 +2,23 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -/// Utilities to return the Dart SDK location. +/// Utilities for CLI programs written in dart. +/// +/// This library contains information for returning the location of the dart +/// SDK, and other directories that command-line applications may need to +/// access. This library aims follows best practices for each platform, honoring +/// the [XDG Base Directory Specification][1] on Linux and +/// [File System Basics][2] on Mac OS. +/// +/// Many functions require a `productName`, as data should be stored in a +/// directory unique to your application, as to not avoid clashes with other +/// programs on the same machine. For example, if you are writing a command-line +/// application named 'zinger' then `productName` on Linux could be `zinger`. On +/// MacOS, this should be your bundle identifier (for example, +/// `com.example.Zinger`). +/// +/// [1]: https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html +/// [2]: https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html#//apple_ref/doc/uid/TP40010672-CH2-SW1 library cli_util; import 'dart:async'; @@ -13,14 +29,34 @@ import 'package:path/path.dart' as path; /// Return the path to the current Dart SDK. String getSdkPath() => path.dirname(path.dirname(Platform.resolvedExecutable)); +// executable are alo mentioned in the XDG spec, but these do not have as well +// defined of locations on Windows, MacOS. +enum _BaseDirectory { cache, config, data, runtime, state } + +/// Get the user-specific application cache folder for the current platform. +/// +/// This is a location appropriate for storing non-essential files that may be +/// removed at any point. This method won't create the directory; It will merely +/// return the recommended location. +/// +/// The folder location depends on the platform: +/// * `%LOCALAPPDATA%\` on **Windows**, +/// * `$HOME/Library/Caches/` on **Mac OS**, +/// * `$XDG_CACHE_HOME/` on **Linux** +/// (if `$XDG_CACHE_HOME` is defined), and, +/// * `$HOME/.cache/` otherwise. +/// +/// Throws an [EnvironmentNotFoundException] if necessary environment variables +/// are undefined. +String applicationCacheHome(String productName) => + path.join(_baseDirectory(_BaseDirectory.cache), productName); + /// Get the user-specific application configuration folder for the current /// platform. /// /// This is a location appropriate for storing application specific -/// configuration for the current user. The [productName] should be unique to -/// avoid clashes with other applications on the same machine. This method won't -/// actually create the folder, merely return the recommended location for -/// storing user-specific application configuration. +/// configuration for the current user. This method won't create the directory; +/// It will merely return the recommended location. /// /// The folder location depends on the platform: /// * `%APPDATA%\` on **Windows**, @@ -29,54 +65,143 @@ String getSdkPath() => path.dirname(path.dirname(Platform.resolvedExecutable)); /// (if `$XDG_CONFIG_HOME` is defined), and, /// * `$HOME/.config/` otherwise. /// -/// This aims follows best practices for each platform, honoring the -/// [XDG Base Directory Specification][1] on Linux and [File System Basics][2] -/// on Mac OS. +/// Throws an [EnvironmentNotFoundException] if necessary environment variables +/// are undefined. +String applicationConfigHome(String productName) => + path.join(_baseDirectory(_BaseDirectory.config), productName); + +/// Get the user-specific application data folder for the current platform. /// -/// Throws an [EnvironmentNotFoundException] if `%APPDATA%` or `$HOME` is needed -/// but undefined. +/// This is a location appropriate for storing application specific +/// semi-permanent data for the current user. This method won't create the +/// directory; It will merely return the recommended location. /// -/// [1]: https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html -/// [2]: https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html#//apple_ref/doc/uid/TP40010672-CH2-SW1 -String applicationConfigHome(String productName) => - path.join(_configHome, productName); +/// The folder location depends on the platform: +/// * `%APPDATA%\` on **Windows**, +/// * `$HOME/Library/Application Support/` on **Mac OS**, +/// * `$XDG_DATA_HOME/` on **Linux** +/// (if `$XDG_DATA_HOME` is defined), and, +/// * `$HOME/.local/share/` otherwise. +/// +/// Throws an [EnvironmentNotFoundException] if necessary environment variables +/// are undefined. +String applicationDataHome(String productName) => + path.join(_baseDirectory(_BaseDirectory.data), productName); + +/// Get the runtime data folder for the current platform. +/// +/// This is a location appropriate for storing runtime data for the current +/// session. This method won't create the directory; It will merely return the +/// recommended location. +/// +/// The folder location depends on the platform: +/// * `%LOCALAPPDATA%\` on **Windows**, +/// * `$HOME/Library/Application Support/` on **Mac OS**, +/// * `$XDG_DATA_HOME/` on **Linux** +/// (if `$XDG_DATA_HOME` is defined), and, +/// * `$HOME/.local/share/` otherwise. +/// +/// Throws an [EnvironmentNotFoundException] if necessary environment variables +/// are undefined. +String applicationRuntimeDir(String productName) => + path.join(_baseDirectory(_BaseDirectory.runtime), productName); -String get _configHome { +/// Get the user-specific application state folder for the current platform. +/// +/// This is a location appropriate for storing application specific state +/// for the current user. This differs from [applicationDataHome] insomuch as it +/// should contain data which should persist restarts, but is not important +/// enough to be backed up. This method won't create the directory; +// It will merely return the recommended location. +/// +/// The folder location depends on the platform: +/// * `%APPDATA%\` on **Windows**, +/// * `$HOME/Library/Application Support/` on **Mac OS**, +/// * `$XDG_DATA_HOME/` on **Linux** +/// (if `$XDG_DATA_HOME` is defined), and, +/// * `$HOME/.local/share/` otherwise. +/// +/// Throws an [EnvironmentNotFoundException] if necessary environment variables +/// are undefined. +String applicationStateHome(String productName) => + path.join(_baseDirectory(_BaseDirectory.state), productName); + +String _baseDirectory(_BaseDirectory dir) { if (Platform.isWindows) { - final appdata = _env['APPDATA']; - if (appdata == null) { - throw EnvironmentNotFoundException( - 'Environment variable %APPDATA% is not defined!'); + switch (dir) { + case _BaseDirectory.config: + case _BaseDirectory.data: + return _fetchEnvRequired('APPDATA'); + case _BaseDirectory.cache: + case _BaseDirectory.runtime: + case _BaseDirectory.state: + return _fetchEnvRequired('LOCALAPPDATA'); } - return appdata; } if (Platform.isMacOS) { - return path.join(_home, 'Library', 'Application Support'); + switch (dir) { + case _BaseDirectory.config: + case _BaseDirectory.data: + case _BaseDirectory.state: + return path.join(_home, 'Library', 'Application Support'); + case _BaseDirectory.cache: + return path.join(_home, 'Library', 'Caches'); + case _BaseDirectory.runtime: + // https://stackoverflow.com/a/76799489 + return path.join(_home, 'Library', 'Caches', 'TemporaryItems'); + } } if (Platform.isLinux) { - final xdgConfigHome = _env['XDG_CONFIG_HOME']; - if (xdgConfigHome != null) { - return xdgConfigHome; + String xdgEnv; + switch (dir) { + case _BaseDirectory.config: + xdgEnv = 'XDG_CONFIG_HOME'; + break; + case _BaseDirectory.data: + xdgEnv = 'XDG_DATA_HOME'; + break; + case _BaseDirectory.state: + xdgEnv = 'XDG_STATE_HOME'; + break; + case _BaseDirectory.cache: + xdgEnv = 'XDG_CACHE_HOME'; + break; + case _BaseDirectory.runtime: + xdgEnv = 'XDG_RUNTIME_HOME'; + break; + } + final val = _env[xdgEnv]; + if (val != null) { + return val; } - // XDG Base Directory Specification says to use $HOME/.config/ when - // $XDG_CONFIG_HOME isn't defined. - return path.join(_home, '.config'); } // We have no guidelines, perhaps we should just do: $HOME/.config/ // same as XDG specification would specify as fallback. - return path.join(_home, '.config'); + switch (dir) { + case _BaseDirectory.runtime: + case _BaseDirectory.cache: + return path.join(_home, '.cache'); + case _BaseDirectory.config: + return path.join(_home, '.config'); + case _BaseDirectory.data: + return path.join(_home, '.local', 'share'); + case _BaseDirectory.state: + return path.join(_home, '.local', 'state'); + } } -String get _home { - final home = _env['HOME']; - if (home == null) { +String get _home => _fetchEnvRequired('HOME'); + +String _fetchEnvRequired(String name) { + final v = _env[name]; + if (v == null) { throw EnvironmentNotFoundException( - r'Environment variable $HOME is not defined!'); + 'Environment variable \$$name is not defined!'); } - return home; + return v; } class EnvironmentNotFoundException implements Exception { diff --git a/pubspec.yaml b/pubspec.yaml index b3e05ed..24aba5b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: cli_util -version: 0.4.1 +version: 0.4.2 description: A library to help in building Dart command-line apps. repository: https://github.com/dart-lang/cli_util diff --git a/test/cli_util_test.dart b/test/cli_util_test.dart index 349b629..b7aa66f 100644 --- a/test/cli_util_test.dart +++ b/test/cli_util_test.dart @@ -18,24 +18,33 @@ void defineTests() { }); }); - group('applicationConfigHome', () { - test('returns a non-empty string', () { - expect(applicationConfigHome('dart'), isNotEmpty); - }); + final functions = { + 'applicationCacheHome': applicationCacheHome, + 'applicationConfigHome': applicationConfigHome, + 'applicationDataHome': applicationDataHome, + 'applicationRuntimeDir': applicationRuntimeDir, + 'applicationStateHome': applicationStateHome, + }; + functions.forEach((name, fn) { + group(name, () { + test('returns a non-empty string', () { + expect(fn('dart'), isNotEmpty); + }); - test('has an ancestor folder that exists', () { - final path = p.split(applicationConfigHome('dart')); - // We expect that first two segments of the path exist. This is really - // just a dummy check that some part of the path exists. - expect(Directory(p.joinAll(path.take(2))).existsSync(), isTrue); - }); + test('has an ancestor folder that exists', () { + final path = p.split(fn('dart')); + // We expect that first two segments of the path exist. This is really + // just a dummy check that some part of the path exists. + expect(Directory(p.joinAll(path.take(2))).existsSync(), isTrue); + }); - test('empty environment throws exception', () async { - expect(() { - runZoned(() => applicationConfigHome('dart'), zoneValues: { - #environmentOverrides: {}, - }); - }, throwsA(isA())); + test('empty environment throws exception', () async { + expect(() { + runZoned(() => fn('dart'), zoneValues: { + #environmentOverrides: {}, + }); + }, throwsA(isA())); + }); }); }); }