fix(windows): tolerate unavailable log files at startup

This commit is contained in:
kinowill 2026-10-22 20:05:25 +02:00
parent 69a310c78f
commit 04ca3ce53f
6 changed files with 187 additions and 21 deletions

View File

@ -0,0 +1,29 @@
import 'dart:io';
/// Best-effort file output for errors that have already been logged to console.
///
/// File errors must not escape into the same zone that reports app errors.
class FileLogWriter {
final Future<File?> _file;
FileLogWriter(Future<File> Function() getFile) : _file = _resolve(getFile);
static Future<File?> _resolve(Future<File> Function() getFile) async {
try {
return await getFile();
} catch (_) {
return null;
}
}
Future<void> write(String message) async {
final file = await _file;
if (file == null) return;
try {
await file.writeAsString(message, mode: FileMode.writeOnlyAppend);
} catch (_) {
// The console remains available if permissions change after startup.
}
}
}

View File

@ -11,6 +11,8 @@ import 'package:path_provider/path_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:logging/logging.dart' as logging;
import 'file_log_writer.dart';
final _loggingToLoggerLevel = {
logging.Level.ALL: Level.all,
logging.Level.FINEST: Level.trace,
@ -26,7 +28,7 @@ final _loggingToLoggerLevel = {
class AppLogger {
static late final Logger log;
static late final File logFile;
static FileLogWriter? _fileLogWriter;
static initialize(bool verbose) {
log = Logger(
@ -39,17 +41,15 @@ class AppLogger {
logging.hierarchicalLoggingEnabled = true;
logging.Logger('YoutubeExplode.StreamsClient')
..level = logging.Level.SEVERE
..onRecord.listen(
(record) {
log.log(
_loggingToLoggerLevel[record.level] ?? Level.info,
record.message,
error: record.error,
stackTrace: record.stackTrace,
time: record.time,
);
},
);
..onRecord.listen((record) {
log.log(
_loggingToLoggerLevel[record.level] ?? Level.info,
record.message,
error: record.error,
stackTrace: record.stackTrace,
time: record.time,
);
});
}
static R? runZoned<R>(R Function() body) {
@ -70,17 +70,14 @@ class AppLogger {
Isolate.current.addErrorListener(
RawReceivePort((pair) async {
final isolateError = pair as List<dynamic>;
reportError(
isolateError.first.toString(),
isolateError.last,
);
reportError(isolateError.first.toString(), isolateError.last);
}).sendPort,
);
}
_initInternalPackageLoggers();
getLogsPath().then((value) => logFile = value);
_fileLogWriter = FileLogWriter(getLogsPath);
return body();
},
@ -91,7 +88,11 @@ class AppLogger {
}
static Future<File> getLogsPath() async {
String dir = (await getApplicationDocumentsDirectory()).path;
// Documents can be protected by Windows Controlled Folder Access.
String dir = (kIsWindows
? await getApplicationSupportDirectory()
: await getApplicationDocumentsDirectory())
.path;
if (kIsAndroid) {
dir = (await getExternalStorageDirectory())?.path ?? "";
}
@ -119,11 +120,10 @@ class AppLogger {
log.e(message, error: error, stackTrace: stackTrace);
if (kReleaseMode) {
await logFile.writeAsString(
await _fileLogWriter?.write(
"[${DateTime.now()}]---------------------\n"
"$error\n$stackTrace\n"
"----------------------------------------\n",
mode: FileMode.writeOnlyAppend,
);
}
}

View File

@ -1767,7 +1767,7 @@ packages:
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
dependency: "direct dev"
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"

View File

@ -164,6 +164,7 @@ dev_dependencies:
sdk: flutter
integration_test:
sdk: flutter
path_provider_platform_interface: ^2.1.2
json_serializable: ^6.6.2
freezed: ^2.5.2
process_run: ^0.14.2

View File

@ -0,0 +1,87 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotube/services/logger/file_log_writer.dart';
void main() {
late Directory directory;
setUp(() async {
directory = await Directory.systemTemp.createTemp('spotube-log-test-');
});
tearDown(() async {
await directory.delete(recursive: true);
});
test('appends errors without replacing earlier entries', () async {
final file = File('${directory.path}/errors.log');
final writer = FileLogWriter(() async => file);
await writer.write('first\n');
await writer.write('second\n');
expect(await file.readAsString(), 'first\nsecond\n');
});
test('waits for file initialization before writing', () async {
final file = File('${directory.path}/errors.log');
final ready = Completer<File>();
final writer = FileLogWriter(() => ready.future);
final write = writer.write('startup error\n');
expect(await file.exists(), isFalse);
ready.complete(file);
await write;
expect(await file.readAsString(), 'startup error\n');
});
test('contains synchronous initialization errors', () async {
final writer = FileLogWriter(() {
throw const FileSystemException('Permission denied');
});
await expectLater(writer.write('error\n'), completes);
});
test(
'contains asynchronous initialization errors before any report',
() async {
final writer = FileLogWriter(() async {
throw const FileSystemException('Permission denied');
});
// Initialization must be safe even when no error report is being written.
await Future<void>.delayed(Duration.zero);
await expectLater(writer.write('error\n'), completes);
},
);
test(
'contains initialization failures with a pending error report',
() async {
final ready = Completer<File>();
final writer = FileLogWriter(() => ready.future);
final write = writer.write('startup error\n');
ready.completeError(const FileSystemException('Permission denied'));
await expectLater(write, completes);
},
);
test('contains write errors after successful initialization', () async {
final file = File('${directory.path}/errors.log');
final writer = FileLogWriter(() async => file);
await writer.write('first\n');
// A directory at the log path fails writes on every supported desktop OS.
await file.delete();
await Directory(file.path).create();
await expectLater(writer.write('second\n'), completes);
await expectLater(writer.write('third\n'), completes);
});
}

View File

@ -0,0 +1,49 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:spotube/services/logger/logger.dart';
class _ProtectedDocumentsPathProvider extends PathProviderPlatform {
final String supportPath;
int documentsRequests = 0;
_ProtectedDocumentsPathProvider(this.supportPath);
@override
Future<String?> getApplicationSupportPath() async => supportPath;
@override
Future<String?> getApplicationDocumentsPath() async {
documentsRequests++;
throw const FileSystemException('Documents access denied');
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'Windows logging does not require access to Documents',
() async {
final directory = await Directory.systemTemp.createTemp(
'spotube-path-test-',
);
final original = PathProviderPlatform.instance;
final provider = _ProtectedDocumentsPathProvider(directory.path);
PathProviderPlatform.instance = provider;
addTearDown(() async {
PathProviderPlatform.instance = original;
await directory.delete(recursive: true);
});
final file = await AppLogger.getLogsPath();
expect(file.path, join(directory.path, '.spotube_logs'));
expect(await file.exists(), isTrue);
expect(provider.documentsRequests, 0);
},
skip: !Platform.isWindows,
);
}