diff --git a/pkgs/cli_util/CHANGELOG.md b/pkgs/cli_util/CHANGELOG.md index 9e77b8fe20..dc376fb076 100644 --- a/pkgs/cli_util/CHANGELOG.md +++ b/pkgs/cli_util/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.5.0-wip + +- Add `BaseDirectories` class and deprecate `applicationConfigHome`. + ## 0.4.2 - Add `sdkPath` getter, deprecate `getSdkPath` function. diff --git a/pkgs/cli_util/lib/cli_util.dart b/pkgs/cli_util/lib/cli_util.dart index e497d68b5b..39de8d4d68 100644 --- a/pkgs/cli_util/lib/cli_util.dart +++ b/pkgs/cli_util/lib/cli_util.dart @@ -10,6 +10,8 @@ import 'dart:io'; import 'package:path/path.dart' as path; +export 'src/base_directories.dart'; + /// The path to the current Dart SDK. String get sdkPath => path.dirname(path.dirname(Platform.resolvedExecutable)); @@ -41,6 +43,7 @@ String getSdkPath() => sdkPath; /// /// [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 +@Deprecated('Use BaseDirectories(tool: productName).configHome() instead.') String applicationConfigHome(String productName) => path.join(_configHome, productName); @@ -73,8 +76,8 @@ String _requireEnv(String name) => /// Exception thrown if a required environment entry does not exist. /// -/// Thrown by [applicationConfigHome] if an expected and required -/// platform specific environment entry is not available. +/// Thrown if an expected and required platform specific environment entry is +/// not available. class EnvironmentNotFoundException implements Exception { /// Name of environment entry which was needed, but not found. final String entryName; diff --git a/pkgs/cli_util/lib/src/base_directories.dart b/pkgs/cli_util/lib/src/base_directories.dart new file mode 100644 index 0000000000..a8f7304d91 --- /dev/null +++ b/pkgs/cli_util/lib/src/base_directories.dart @@ -0,0 +1,287 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// 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. + +import 'dart:io' show Platform; + +import 'package:path/path.dart' as path; + +import '../cli_util.dart'; + +/// The standard system paths for a Dart tool. +/// +/// These paths respects the following directory standards: +/// +/// - On Linux, the [XDG Base Directory +/// Specification](https://specifications.freedesktop.org/basedir-spec/latest/). +/// - On MacOS, the +/// [Library](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html) +/// directory. +/// - On Windows, `%APPDATA%` and `%LOCALAPPDATA%`. +/// +/// Note that [cacheHome], [configHome], [dataHome], [runtimeHome], and +/// [stateHome] may be overlapping or nested. +/// +/// Note that the directories won't be created, the methods merely return the +/// recommended locations. +final class BaseDirectories { + /// The name of the Dart tool. + /// + /// The name is used to provide a subdirectory inside the base directories. + /// + /// This should be a valid directory name on every operating system. The name + /// is typically camel-cased. For example: `"MyApp"`. + final String tool; + + /// The environment variables to use to determine the base directories. + /// + /// Defaults to [Platform.environment]. + final Map _environment; + + /// Constructs a [BaseDirectories] instance for the given [tool] name. + /// + /// The [environment] map, if provided, is used to determine the base + /// directories. If omitted, it defaults to using [Platform.environment]. + BaseDirectories( + this.tool, { + Map? environment, + }) : _environment = environment ?? Platform.environment; + + /// Path of the directory where the tool will place its caches. + /// + /// The cache may be purged by the operating system or user at any time. + /// Applications should be able to reconstruct any data stored here. If [tool] + /// cannot handle data being purged, use [runtimeHome] or [dataHome] instead. + /// + /// This is a location appropriate for storing non-essential files that may be + /// removed at any point. For example: intermediate compilation artifacts. + /// + /// The directory location depends on the current [Platform.operatingSystem]: + /// - on **Windows**: + /// - `%LOCALAPPDATA%\` + /// - on **Mac OS**: + /// - `$HOME/Library/Caches/` + /// - on **Linux**: + /// - `$XDG_CACHE_HOME/` if `$XDG_CACHE_HOME` is defined, and + /// - `$HOME/.cache/` otherwise. + /// + /// The directory won't be created, the method merely returns the recommended + /// location. + /// + /// On some platforms, this path may overlap with [runtimeHome] and + /// [stateHome]. + /// + /// Throws an [EnvironmentNotFoundException] if a necessary environment + /// variable is undefined. + late final String cacheHome = + path.join(_baseDirectory(_XdgBaseDirectoryKind.cache)!, tool); + + /// Path of the directory where the tool will place its configuration. + /// + /// The configuration may be synchronized across devices by the OS and may + /// survive application removal. + /// + /// This is a location appropriate for storing application specific + /// configuration for the current user. + /// + /// The directory location depends on the current [Platform.operatingSystem] + /// and what file types are stored: + /// - on **Windows**: + /// - `%APPDATA%\` + /// - on **Mac OS**: + /// - `$HOME/Library/Application Support/` + /// - on **Linux**: + /// - `$XDG_CONFIG_HOME/` if `$XDG_CONFIG_HOME` is defined, and + /// - `$HOME/.config/` otherwise. + /// + /// The directory won't be created, the method merely returns the recommended + /// location. + /// + /// On some platforms, this path may overlap with [dataHome]. + /// + /// Throws an [EnvironmentNotFoundException] if a necessary environment + /// variable is undefined. + late final String configHome = + path.join(_baseDirectory(_XdgBaseDirectoryKind.config)!, tool); + + /// Path of the directory where the tool will place its user data. + /// + /// The data may be backed up and synchronized to other devices by the + /// operating system. For large data use [stateHome] instead. + /// + /// This is a location appropriate for storing application specific + /// data for the current user. For example: documents created by the user. + /// + /// The directory location depends on the current [Platform.operatingSystem]: + /// - on **Windows**: + /// - `%APPDATA%\` + /// - on **Mac OS**: + /// - `$HOME/Library/Application Support/` + /// - on **Linux**: + /// - `$XDG_DATA_HOME/` if `$XDG_DATA_HOME` is defined, and + /// - `$HOME/.local/share/` otherwise. + /// + /// The directory won't be created, the method merely returns the recommended + /// location. + /// + /// On some platforms, this path may overlap with [configHome] and + /// [stateHome]. + /// + /// Throws an [EnvironmentNotFoundException] if a necessary environment + /// variable is undefined. + late final String dataHome = + path.join(_baseDirectory(_XdgBaseDirectoryKind.data)!, tool); + + /// Path of the directory where the tool will place its runtime data. + /// + /// The runtime data may be deleted in between user logins by the OS. For data + /// that needs to persist between sessions, use [stateHome] instead. + /// + /// This is a location appropriate for storing runtime data for the current + /// session. For example: undo history. + /// + /// This directory might be undefined on Linux, in such case a warning should + /// be printed and a suitable fallback (such as a temporary directory) should + /// be used. + /// + /// The directory location depends on the current [Platform.operatingSystem]: + /// - on **Windows**: + /// - `%LOCALAPPDATA%\` + /// - on **Mac OS**: + /// - `$HOME/Library/Caches/TemporaryItems/` + /// - on **Linux**: + /// - `$XDG_RUNTIME_HOME/` if `$XDG_RUNTIME_HOME` is defined, and + /// - `null` otherwise. + /// + /// The directory won't be created, the method merely returns the recommended + /// location. + /// + /// On some platforms, this path may overlap [cacheHome] and [stateHome] or be + /// nested in [cacheHome]. + /// + /// Throws an [EnvironmentNotFoundException] if a necessary environment + /// variable is undefined. + late final String? runtimeHome = + _join(_baseDirectory(_XdgBaseDirectoryKind.runtime), tool); + + /// Path of the directory where the tool will place its state. + /// + /// The state directory is likely not backed up or synchronized accross + /// devices by the OS. For data that may be backed up and synchronized, use + /// [dataHome] instead. + /// + /// This is a location appropriate for storing data which is either not + /// important enougn, not small enough, or not portable enough to store in + /// [dataHome]. For example: logs and indices. + /// + /// The directory location depends on the current [Platform.operatingSystem]: + /// - on **Windows**: + /// - `%LOCALAPPDATA%\` + /// - on **Mac OS**: + /// - `$HOME/Library/Application Support/` + /// - on **Linux**: + /// - `$XDG_STATE_HOME/` if `$XDG_STATE_HOME` is defined, and + /// - `$HOME/.local/state/` otherwise. + /// + /// The directory won't be created, the method merely returns the recommended + /// location. + /// + /// On some platforms, this path may overlap with [cacheHome], and + /// [runtimeHome]. + /// + /// Throws an [EnvironmentNotFoundException] if a necessary environment + /// variable is undefined. + late final String stateHome = + path.join(_baseDirectory(_XdgBaseDirectoryKind.state)!, tool); + + String? _baseDirectory(_XdgBaseDirectoryKind directoryKind) { + if (Platform.isWindows) { + return _baseDirectoryWindows(directoryKind); + } + if (Platform.isMacOS) { + return _baseDirectoryMacOs(directoryKind); + } + if (Platform.isLinux) { + return _baseDirectoryLinux(directoryKind); + } + throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}'); + } + + String _baseDirectoryWindows(_XdgBaseDirectoryKind dir) => switch (dir) { + _XdgBaseDirectoryKind.config || + _XdgBaseDirectoryKind.data => + _requireEnv('APPDATA'), + _XdgBaseDirectoryKind.cache || + _XdgBaseDirectoryKind.runtime || + _XdgBaseDirectoryKind.state => + _requireEnv('LOCALAPPDATA'), + }; + + String _baseDirectoryMacOs(_XdgBaseDirectoryKind dir) => switch (dir) { + _XdgBaseDirectoryKind.config || + // `$HOME/Library/Preferences/` may only contain `.plist` files, so use + // `Application Support` instead. + _XdgBaseDirectoryKind.data || + _XdgBaseDirectoryKind.state => + path.join(_home, 'Library', 'Application Support'), + _XdgBaseDirectoryKind.cache => path.join(_home, 'Library', 'Caches'), + _XdgBaseDirectoryKind.runtime => + // https://stackoverflow.com/a/76799489 + path.join(_home, 'Library', 'Caches', 'TemporaryItems'), + }; + + String? _baseDirectoryLinux(_XdgBaseDirectoryKind dir) { + if (Platform.isLinux) { + final xdgEnv = switch (dir) { + _XdgBaseDirectoryKind.config => 'XDG_CONFIG_HOME', + _XdgBaseDirectoryKind.data => 'XDG_DATA_HOME', + _XdgBaseDirectoryKind.state => 'XDG_STATE_HOME', + _XdgBaseDirectoryKind.cache => 'XDG_CACHE_HOME', + _XdgBaseDirectoryKind.runtime => 'XDG_RUNTIME_DIR', + }; + final envVar = _environment[xdgEnv]; + if (envVar != null) { + return envVar; + } + } + + switch (dir) { + case _XdgBaseDirectoryKind.runtime: + // Applications should chose a different directory and print a warning. + return null; + case _XdgBaseDirectoryKind.cache: + return path.join(_home, '.cache'); + case _XdgBaseDirectoryKind.config: + return path.join(_home, '.config'); + case _XdgBaseDirectoryKind.data: + return path.join(_home, '.local', 'share'); + case _XdgBaseDirectoryKind.state: + return path.join(_home, '.local', 'state'); + } + } + + String get _home => _requireEnv('HOME'); + + String _requireEnv(String name) => + _environment[name] ?? (throw EnvironmentNotFoundException(name)); +} + +/// A kind from the XDG base directory specification for Linux. +/// +/// MacOS and Windows have less kinds. +enum _XdgBaseDirectoryKind { + cache, + config, + data, + // Executables are also mentioned in the XDG spec, but these do not have as + // well defined of locations on Windows and MacOS. + runtime, + state, +} + +String? _join(String? part1, String? part2) { + if (part1 == null || part2 == null) { + return null; + } + return path.join(part1, part2); +} diff --git a/pkgs/cli_util/pubspec.yaml b/pkgs/cli_util/pubspec.yaml index ab879dbd09..e885bff9bd 100644 --- a/pkgs/cli_util/pubspec.yaml +++ b/pkgs/cli_util/pubspec.yaml @@ -1,5 +1,5 @@ name: cli_util -version: 0.4.2 +version: 0.5.0-wip description: A library to help in building Dart command-line apps. repository: https://github.com/dart-lang/tools/tree/main/pkgs/cli_util issue_tracker: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Acli_util diff --git a/pkgs/cli_util/test/base_directories_test.dart b/pkgs/cli_util/test/base_directories_test.dart new file mode 100644 index 0000000000..4acda8afb1 --- /dev/null +++ b/pkgs/cli_util/test/base_directories_test.dart @@ -0,0 +1,50 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// 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. + +import 'dart:io'; + +import 'package:cli_util/cli_util.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + final baseDirectories = BaseDirectories('my_app'); + + test('returns a non-empty string', () { + expect(baseDirectories.cacheHome, isNotEmpty); + expect(baseDirectories.configHome, isNotEmpty); + expect(baseDirectories.dataHome, isNotEmpty); + expect(baseDirectories.runtimeHome, isNotEmpty); + expect(baseDirectories.stateHome, isNotEmpty); + }); + + test('has an ancestor folder that exists', () { + void expectAncestorExists(String? path) { + if (path == null) { + // runtimeHome may be undefined on Linux. + return; + } + // We expect that first two segments of the path exist. This is really + // just a dummy check that some part of the path exists. + final ancestorPath = p.joinAll(p.split(path).take(2)); + expect( + Directory(ancestorPath).existsSync(), + isTrue, + ); + } + + expectAncestorExists(baseDirectories.cacheHome); + expectAncestorExists(baseDirectories.configHome); + expectAncestorExists(baseDirectories.dataHome); + expectAncestorExists(baseDirectories.runtimeHome); + expectAncestorExists(baseDirectories.stateHome); + }); + + test('empty environment throws exception', () async { + expect( + () => BaseDirectories('Dart', environment: {}).configHome, + throwsA(isA()), + ); + }); +} diff --git a/pkgs/cli_util/test/cli_util_test.dart b/pkgs/cli_util/test/cli_util_test.dart index e16bc593f0..5a2e4b2e1d 100644 --- a/pkgs/cli_util/test/cli_util_test.dart +++ b/pkgs/cli_util/test/cli_util_test.dart @@ -2,6 +2,8 @@ // 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. +// ignore_for_file: deprecated_member_use_from_same_package + import 'dart:async'; import 'dart:io';