todo-app/lib/desktop/linux_system_probe.dart
Krzysztof kuhy Rudnicki 9d2aa1c178 Enforce the 250-line file cap, and split the first four files
Wires the shared gate (~/utils/scripts/check_file_length.sh) into a new
.pre-commit-config.yaml as a local hook, checking only the files in the
commit and failing rather than warning. No baseline, no allowlist.

The repo had a hand-written .git/hooks/pre-commit running two checks;
both are ported verbatim into the config rather than dropped, with the
version bump ordered last so it cannot rewrite pubspec.yaml for a commit
a gate is still about to reject. scripts/install_hooks.sh now installs
pre-commit (and removes the legacy hook) instead of writing its own.

.github/workflows/file-length.yml runs the same check on push, kept
separate from release-apk.yml so an over-long file fails lint rather than
blocking an APK release.

Splits the four cleanest violations, all behaviour-preserving:

  note_template.dart      370 -> 203 + parser (186)
  firebase_backend.dart   308 -> barrel (24) + project/store/client
  port_guard.dart         315 -> 232 + linux_system_probe (100)
  notes_list_screen.dart  698 -> 225 + 5 widget/nav files

Where a file had external callers, the original path stays as a barrel
that re-exports the split parts, so no call site changed.

Verified: 313 tests pass, coverage identical at 99.9% / 1922 lines
(per-file too -- notes_list's 281 covered lines are now 81+75+51+41+22+11),
flutter analyze clean, and a staged 260-line file makes git commit fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 16:01:24 +02:00

101 lines
3.1 KiB
Dart

/// The real-host [SystemProbe]: `ss` for the listener, `/proc` for the rest.
///
/// Split out of `port_guard.dart` for file size, and it is the natural seam:
/// everything here touches the live host, while `port_guard.dart` keeps the
/// pure decision logic that tests drive with a fake probe.
library;
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:todo/desktop/port_guard.dart';
/// NUL separates the arguments inside `/proc/<pid>/cmdline`.
final String _argumentSeparator = String.fromCharCode(0);
/// [SystemProbe] backed by `ss` and `/proc`.
///
/// The desktop wrapper only ever runs on Linux — the desktop app is the web
/// build served locally — so reading `/proc` directly is safe here.
class LinuxSystemProbe implements SystemProbe {
/// Creates the default host probe.
///
/// [ssExecutable] exists so a test can point at a missing binary and exercise
/// the "no `ss` on PATH" path.
const LinuxSystemProbe({this.ssExecutable = 'ss'});
/// Name of the socket-listing tool used to find the port owner.
final String ssExecutable;
@override
Future<int?> listenerPid(int port) async {
try {
final result = await Process.run(ssExecutable, ['-tlnp']);
final out = result.stdout;
return out is String ? parseListenerPid(out, port) : null;
} on ProcessException {
// No `ss` on PATH: report "unknown" and let the caller fail closed.
return null;
}
}
@override
Future<ProcessDescription?> describe(int pid) async {
final cmdline = readCmdline(pid);
if (cmdline == null) return null;
String exe;
try {
exe = Link('/proc/$pid/exe').targetSync();
} on FileSystemException {
// Unreadable for another user's process; the cmdline still identifies it.
exe = '';
}
return ProcessDescription(exeTarget: exe, cmdline: cmdline);
}
@override
Future<List<List<String>>> processCmdlines() async {
final cmdlines = <List<String>>[];
for (final entry in Directory('/proc').listSync()) {
final pid = int.tryParse(p.basename(entry.path));
if (pid == null) continue;
final cmdline = readCmdline(pid);
if (cmdline != null) cmdlines.add(cmdline);
}
return cmdlines;
}
@override
Future<bool> portInUse(int port) async {
try {
final socket = await ServerSocket.bind(
InternetAddress.loopbackIPv4,
port,
);
await socket.close();
return false;
} on SocketException {
return true;
}
}
@override
bool signal(int pid, ProcessSignal signal) => Process.killPid(pid, signal);
/// Reads `/proc/<pid>/cmdline` as an argument vector.
///
/// Returns null when the process is gone. Kernel threads yield an empty list,
/// and the trailing separator is dropped so exact-token matching holds.
static List<String>? readCmdline(int pid) {
try {
return File('/proc/$pid/cmdline')
.readAsStringSync()
.split(_argumentSeparator)
.where((token) => token.isNotEmpty)
.toList();
} on FileSystemException {
return null;
}
}
}