448 lines
12 KiB
Dart
448 lines
12 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:yaml/yaml.dart';
|
|
|
|
String? _persistentSupportRootOverride;
|
|
|
|
void debugOverridePersistentSupportRoot(String? path) {
|
|
final trimmed = path?.trim() ?? '';
|
|
_persistentSupportRootOverride = trimmed.isEmpty
|
|
? null
|
|
: normalizeStoreDirectoryPath(trimmed);
|
|
}
|
|
|
|
String? defaultUserSettingsRootPath({
|
|
Map<String, String>? environment,
|
|
String? operatingSystem,
|
|
}) {
|
|
final env = environment ?? Platform.environment;
|
|
final os = operatingSystem ?? Platform.operatingSystem;
|
|
final home = env['HOME']?.trim() ?? '';
|
|
if (home.isEmpty) {
|
|
return null;
|
|
}
|
|
if (os == 'macos') {
|
|
return '$home/Library/Application Support/xworkmate';
|
|
}
|
|
if (os == 'linux') {
|
|
final xdgConfigHome = env['XDG_CONFIG_HOME']?.trim() ?? '';
|
|
if (xdgConfigHome.isNotEmpty) {
|
|
return '$xdgConfigHome/xworkmate';
|
|
}
|
|
return '$home/.config/xworkmate';
|
|
}
|
|
if (os == 'windows') {
|
|
final appData = env['APPDATA']?.trim() ?? '';
|
|
if (appData.isNotEmpty) {
|
|
return '$appData\\xworkmate';
|
|
}
|
|
}
|
|
return '$home/.xworkmate';
|
|
}
|
|
|
|
String? defaultUserSettingsFilePath({
|
|
Map<String, String>? environment,
|
|
String? operatingSystem,
|
|
}) {
|
|
final root = defaultUserSettingsRootPath(
|
|
environment: environment,
|
|
operatingSystem: operatingSystem,
|
|
);
|
|
if ((root ?? '').isEmpty) {
|
|
return null;
|
|
}
|
|
return '$root/config/settings.yaml';
|
|
}
|
|
|
|
enum PersistentStoreScope { settings, tasks, secrets, audit }
|
|
|
|
class PersistentWriteFailure {
|
|
const PersistentWriteFailure({
|
|
required this.scope,
|
|
required this.operation,
|
|
required this.message,
|
|
required this.timestampMs,
|
|
});
|
|
|
|
final PersistentStoreScope scope;
|
|
final String operation;
|
|
final String message;
|
|
final int timestampMs;
|
|
}
|
|
|
|
class PersistentWriteFailures {
|
|
const PersistentWriteFailures({
|
|
this.settings,
|
|
this.tasks,
|
|
this.secrets,
|
|
this.audit,
|
|
});
|
|
|
|
final PersistentWriteFailure? settings;
|
|
final PersistentWriteFailure? tasks;
|
|
final PersistentWriteFailure? secrets;
|
|
final PersistentWriteFailure? audit;
|
|
|
|
bool get hasFailures =>
|
|
settings != null || tasks != null || secrets != null || audit != null;
|
|
}
|
|
|
|
class StoreLayout {
|
|
const StoreLayout({
|
|
required this.rootDirectory,
|
|
required this.configDirectory,
|
|
required this.tasksDirectory,
|
|
required this.secretDirectory,
|
|
});
|
|
|
|
final Directory rootDirectory;
|
|
final Directory configDirectory;
|
|
final Directory tasksDirectory;
|
|
final Directory secretDirectory;
|
|
|
|
File get settingsFile => File('${configDirectory.path}/settings.yaml');
|
|
|
|
File get auditFile => File('${configDirectory.path}/secret-audit.json');
|
|
|
|
File get taskIndexFile => File('${tasksDirectory.path}/index.json');
|
|
|
|
File taskFileForSessionKey(String sessionKey) {
|
|
final encoded = encodeStableFileKey(sessionKey);
|
|
return File('${tasksDirectory.path}/$encoded.json');
|
|
}
|
|
|
|
File secretFileForKey(String key) {
|
|
final encoded = encodeStableFileKey(key);
|
|
return File('${secretDirectory.path}/$encoded.secret');
|
|
}
|
|
}
|
|
|
|
class StoreLayoutResolver {
|
|
StoreLayoutResolver({
|
|
Future<String?> Function()? appDataRootPathResolver,
|
|
Future<String?> Function()? secretRootPathResolver,
|
|
Future<String?> Function()? supportRootPathResolver,
|
|
}) : _appDataRootPathResolver = appDataRootPathResolver,
|
|
_secretRootPathResolver = secretRootPathResolver,
|
|
_supportRootPathResolver = supportRootPathResolver;
|
|
|
|
final Future<String?> Function()? _appDataRootPathResolver;
|
|
final Future<String?> Function()? _secretRootPathResolver;
|
|
final Future<String?> Function()? _supportRootPathResolver;
|
|
|
|
StoreLayout? _cached;
|
|
|
|
Future<StoreLayout> resolve() async {
|
|
final cached = _cached;
|
|
if (cached != null) {
|
|
return cached;
|
|
}
|
|
final supportRootPath =
|
|
await _resolvePath(_supportRootPathResolver) ??
|
|
await _defaultSupportRootPath();
|
|
if (supportRootPath == null) {
|
|
throw StateError('Persistent support root is unavailable.');
|
|
}
|
|
final appDataRootPath =
|
|
await _resolvePath(_appDataRootPathResolver) ?? supportRootPath;
|
|
final secretRootPath =
|
|
await _resolvePath(_secretRootPathResolver) ??
|
|
'$supportRootPath/secrets';
|
|
final rootDirectory = await ensureDirectory(
|
|
normalizeStoreDirectoryPath(appDataRootPath),
|
|
);
|
|
final configDirectory = await ensureDirectory(
|
|
'${rootDirectory.path}/config',
|
|
);
|
|
final tasksDirectory = await ensureDirectory('${rootDirectory.path}/tasks');
|
|
final secretDirectory = await ensureDirectory(
|
|
normalizeStoreDirectoryPath(secretRootPath),
|
|
);
|
|
await ensureOwnerOnlyDirectory(secretDirectory);
|
|
final layout = StoreLayout(
|
|
rootDirectory: rootDirectory,
|
|
configDirectory: configDirectory,
|
|
tasksDirectory: tasksDirectory,
|
|
secretDirectory: secretDirectory,
|
|
);
|
|
_cached = layout;
|
|
return layout;
|
|
}
|
|
|
|
Future<String?> _defaultSupportRootPath() async {
|
|
final override = _persistentSupportRootOverride;
|
|
if (override != null && override.isNotEmpty) {
|
|
return override;
|
|
}
|
|
if (Platform.isMacOS) {
|
|
final macUserRoot = defaultUserSettingsRootPath();
|
|
if ((macUserRoot ?? '').isNotEmpty) {
|
|
return macUserRoot;
|
|
}
|
|
}
|
|
try {
|
|
final supportDirectory = await getApplicationSupportDirectory();
|
|
return '${supportDirectory.path}/xworkmate';
|
|
} catch (error) {
|
|
debugPrint('Application support directory lookup failed: $error');
|
|
return defaultUserSettingsRootPath();
|
|
}
|
|
}
|
|
|
|
Future<String?> _resolvePath(Future<String?> Function()? resolver) async {
|
|
if (resolver == null) {
|
|
return null;
|
|
}
|
|
try {
|
|
final value = await resolver();
|
|
final trimmed = value?.trim() ?? '';
|
|
if (trimmed.isEmpty) {
|
|
return null;
|
|
}
|
|
return normalizeStoreDirectoryPath(trimmed);
|
|
} catch (error) {
|
|
debugPrint('Store layout path resolver failed: $error');
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
String normalizeStoreDirectoryPath(String path) {
|
|
final trimmed = path.trim();
|
|
if (trimmed.isEmpty) {
|
|
return trimmed;
|
|
}
|
|
final lower = trimmed.toLowerCase();
|
|
if (lower.endsWith('.sqlite') ||
|
|
lower.endsWith('.sqlite3') ||
|
|
lower.endsWith('.db') ||
|
|
lower.endsWith('.yaml') ||
|
|
lower.endsWith('.yml') ||
|
|
lower.endsWith('.json')) {
|
|
return File(trimmed).parent.path;
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
Future<Directory> ensureDirectory(String path) async {
|
|
final directory = Directory(path);
|
|
if (!await directory.exists()) {
|
|
await directory.create(recursive: true);
|
|
}
|
|
return directory;
|
|
}
|
|
|
|
Future<void> ensureOwnerOnlyDirectory(Directory directory) async {
|
|
if (!shouldApplyUnixOwnerOnlyPermissionsInternal()) {
|
|
return;
|
|
}
|
|
await _setUnixPermissions(directory.path, '700');
|
|
}
|
|
|
|
Future<void> ensureOwnerOnlyFile(File file) async {
|
|
if (!shouldApplyUnixOwnerOnlyPermissionsInternal()) {
|
|
return;
|
|
}
|
|
await _setUnixPermissions(file.path, '600');
|
|
}
|
|
|
|
bool shouldApplyUnixOwnerOnlyPermissionsInternal({String? operatingSystem}) {
|
|
final os = operatingSystem ?? Platform.operatingSystem;
|
|
return os == 'linux' || os == 'macos';
|
|
}
|
|
|
|
String encodeStableFileKey(String key) {
|
|
return base64Url.encode(utf8.encode(key)).replaceAll('=', '');
|
|
}
|
|
|
|
Future<void> atomicWriteString(
|
|
File file,
|
|
String contents, {
|
|
bool ownerOnly = false,
|
|
}) async {
|
|
if (!await file.parent.exists()) {
|
|
await file.parent.create(recursive: true);
|
|
}
|
|
final tempFile = File(
|
|
'${file.path}.tmp-${DateTime.now().microsecondsSinceEpoch}',
|
|
);
|
|
await tempFile.writeAsString(contents, flush: true);
|
|
if (ownerOnly) {
|
|
await ensureOwnerOnlyDirectory(file.parent);
|
|
await ensureOwnerOnlyFile(tempFile);
|
|
}
|
|
await tempFile.rename(file.path);
|
|
if (ownerOnly) {
|
|
await ensureOwnerOnlyFile(file);
|
|
}
|
|
}
|
|
|
|
Future<void> deleteIfExists(File file) async {
|
|
if (await file.exists()) {
|
|
await file.delete();
|
|
}
|
|
}
|
|
|
|
Object? decodeYamlDocument(String raw) {
|
|
final trimmed = raw.trim();
|
|
if (trimmed.isEmpty) {
|
|
return null;
|
|
}
|
|
try {
|
|
return _yamlToObject(loadYaml(trimmed));
|
|
} catch (error) {
|
|
debugPrint('YAML decode failed: $error');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Object? _yamlToObject(Object? value) {
|
|
if (value is YamlMap) {
|
|
return value.map(
|
|
(Object? key, Object? item) =>
|
|
MapEntry(key?.toString() ?? '', _yamlToObject(item)),
|
|
);
|
|
}
|
|
if (value is YamlList) {
|
|
return value.map(_yamlToObject).toList(growable: false);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
String encodeYamlDocument(Object? value) {
|
|
final buffer = StringBuffer('---\n');
|
|
_writeYamlValue(buffer, value, 0, listItem: false);
|
|
if (!buffer.toString().endsWith('\n')) {
|
|
buffer.writeln();
|
|
}
|
|
return buffer.toString();
|
|
}
|
|
|
|
Future<void> _setUnixPermissions(String path, String mode) async {
|
|
final result = await Process.run('chmod', <String>[mode, path]);
|
|
if (result.exitCode == 0) {
|
|
return;
|
|
}
|
|
throw ProcessException(
|
|
'chmod',
|
|
<String>[mode, path],
|
|
'${result.stderr}'.trim(),
|
|
result.exitCode,
|
|
);
|
|
}
|
|
|
|
void _writeYamlValue(
|
|
StringBuffer buffer,
|
|
Object? value,
|
|
int indent, {
|
|
required bool listItem,
|
|
}) {
|
|
final prefix = ' ' * indent;
|
|
if (value is Map) {
|
|
if (value.isEmpty) {
|
|
if (listItem) {
|
|
buffer.writeln('{}');
|
|
} else {
|
|
buffer.writeln('$prefix{}');
|
|
}
|
|
return;
|
|
}
|
|
if (listItem) {
|
|
buffer.writeln();
|
|
}
|
|
for (final entry in value.entries) {
|
|
final key = entry.key.toString();
|
|
final item = entry.value;
|
|
if (_isInlineYamlValue(item)) {
|
|
buffer.writeln('$prefix$key: ${_yamlInlineValue(item)}');
|
|
} else if (item is String && item.contains('\n')) {
|
|
buffer.writeln('$prefix$key: |-');
|
|
for (final line in item.split('\n')) {
|
|
buffer.writeln('${' ' * (indent + 1)}$line');
|
|
}
|
|
} else {
|
|
buffer.writeln('$prefix$key:');
|
|
_writeYamlValue(buffer, item, indent + 1, listItem: false);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (value is List) {
|
|
if (value.isEmpty) {
|
|
if (listItem) {
|
|
buffer.writeln('[]');
|
|
} else {
|
|
buffer.writeln('$prefix[]');
|
|
}
|
|
return;
|
|
}
|
|
if (listItem) {
|
|
buffer.writeln();
|
|
}
|
|
for (final item in value) {
|
|
if (_isInlineYamlValue(item)) {
|
|
buffer.writeln('$prefix- ${_yamlInlineValue(item)}');
|
|
} else if (item is String && item.contains('\n')) {
|
|
buffer.writeln('$prefix- |-');
|
|
for (final line in item.split('\n')) {
|
|
buffer.writeln('${' ' * (indent + 1)}$line');
|
|
}
|
|
} else {
|
|
buffer.writeln('$prefix-');
|
|
_writeYamlValue(buffer, item, indent + 1, listItem: false);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (listItem) {
|
|
buffer.writeln(_yamlInlineValue(value));
|
|
return;
|
|
}
|
|
buffer.writeln('$prefix${_yamlInlineValue(value)}');
|
|
}
|
|
|
|
bool _isInlineYamlValue(Object? value) {
|
|
if (value == null || value is bool || value is num) {
|
|
return true;
|
|
}
|
|
if (value is String) {
|
|
return !value.contains('\n');
|
|
}
|
|
if (value is List) {
|
|
return value.isEmpty;
|
|
}
|
|
if (value is Map) {
|
|
return value.isEmpty;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
String _yamlInlineValue(Object? value) {
|
|
if (value == null) {
|
|
return 'null';
|
|
}
|
|
if (value is bool || value is num) {
|
|
return value.toString();
|
|
}
|
|
if (value is List && value.isEmpty) {
|
|
return '[]';
|
|
}
|
|
if (value is Map && value.isEmpty) {
|
|
return '{}';
|
|
}
|
|
final stringValue = value.toString();
|
|
if (stringValue.isEmpty) {
|
|
return "''";
|
|
}
|
|
final safe = RegExp(r'^[A-Za-z0-9_./:@+%-]+$');
|
|
final reserved = <String>{'null', 'true', 'false', '~'};
|
|
if (safe.hasMatch(stringValue) && !reserved.contains(stringValue)) {
|
|
return stringValue;
|
|
}
|
|
final escaped = stringValue.replaceAll("'", "''");
|
|
return "'$escaped'";
|
|
}
|