## 一、探针不可用 ≠ 没在跑(把上一轮糊回去的洞补上) installer 在 ccb6c3f 里给 `process` 加了 `probe_available/probe_error`,把 「查不了」与「没在跑」分开了(人话输出已经用上);但 JSON 消费方没跟上: `ResidentStatus.fromJson` 只读 `running`/`pids`,于是 Windows(无 pgrep)上 `running=false + registered=true` → 卡片一本正经显示 **"已安装但没起来"(橙色告警)**, 而真相是"一次都没查过"。这比"没有卡片"糟得多:它看起来**很确定**,用户会 照着它去重装一个已经装好的东西。 - `fromJson` 读 `probe_available`,**只有明确 false 且没观测到进程**才降级为 `ResidentState.unknown`(中性灰)+ detail 写明原因与"这不等于没在运行" - `probe_available` 做成**三态**(`bool?`):缺字段(老二进制/老快照)→ 不看该判据。 若把"缺字段"当 false,所有老机器都会被判成"未知"——那是用另一种方式骗人 - 正面观测优先:`running=true`/有 pid 时仍判 running(探针元数据不压过证据) ## 二、采集端实际读到的配置:诚实标注,不做同步 macOS GUI 是真沙箱(容器内 `ConfigStore`),常驻采集端由 launchd 拉起、读真实 家目录 —— 磁盘上**两份 config 同时活着**:界面里改网关地址只改容器那份,采集端 看到的还是旧的,两边都不报错。三条同步路本轮都不通(沙箱无权写容器外 / 沙箱子 进程继承沙箱 / 让 agent 读容器会给主路径加上"必须装过 GUI"的依赖),故只做标注: - 卡片显示「采集端当前读到:<server_url> · <device_id>」(来自 `status --json`) - 与界面输入不一致时明说不一致,并给出下一步(点一次「重新安装并启动」) - 配置面板加一句:上面两项只对「界面内采集」生效,常驻采集读的是安装器那份 - 未安装/问不到时不显示这一行(没有"采集端读到的配置"这回事,不能凭空造) ## 三、`log.path` 的漂移种子写进注释 `/tmp/<label>.err.log` 在 Rust 的 `macos_launchagent_plist` 与 Dart 的 `statusSnapshot()` 各写一份,今天"逐字相同"只是碰巧。已在 `logPath` 的文档里 写明正解:让 `AutoStartPlan` 返回 `stderr_log_path`,Dart 只读不算。 ## 四、顺带补上 label 守卫(等 ccb6c3f 落地后才敢提) `⑥` 组:字面量只许出现在 `lib/host/autostart_label.dart`,两个消费者必须引用 `kDefaultAutostartLabel`。 ## 验证 - `flutter test` → **556/0**(新增 7 条:接缝 6 + 卡片 5,另含 label 守卫 2) - `dart analyze lib bin` → No issues found - 变异自证: · 把 `fromJson` 里 `probeAvailable == false` 分支短路 → `⑦` 两条 ★ 报红(其余仍绿),恢复即绿 · 把卡片的 `if (mismatch)` 短路 → 「与界面输入不一致」报红,恢复即绿
449 lines
18 KiB
Dart
449 lines
18 KiB
Dart
/// 常驻采集的**状态与动作** —— GUI 与 `pmon-install` CLI 之间的唯一接缝
|
||
///
|
||
/// ── 为什么 GUI 不自己做 launchctl / plist 那一套 ──────────────────────
|
||
/// 1. **物理限制**:macOS 沙箱 GUI 写不了 `~/Library/LaunchAgents/`,自启注册
|
||
/// 必须由非沙箱进程完成 —— 那个进程就是 `pmon-install`(见其文件头)。
|
||
/// 2. **单一真相源**:自启产物内容由 Rust 核心生成、落盘与启停由 CLI 执行。
|
||
/// GUI 若自己 stat 文件、自己 pgrep 进程,迟早与 CLI 说的不一致,
|
||
/// 用户会看到"卡片说在跑、CLI 说没起"这种两张嘴。
|
||
/// 故本文件只做三件事:**找到 CLI、跑它、把 `status --json` 解析成类型**。
|
||
/// 判据(四类:自启注册态 / 进程 / 配置 / 日志)只有一处,在
|
||
/// `bin/pmon_install.dart` 的 `statusSnapshot()` 里。
|
||
///
|
||
/// ── 为什么状态要解析成枚举而不是直接显示 JSON ────────────────────────
|
||
/// 界面需要"一句话 + 一个颜色 + 一个该做的动作"。把 JSON 直接铺到界面上,
|
||
/// 等于让 UI 层自己解释字段 —— 那就是第二份判据。这里做**唯一一次**解释,
|
||
/// 且做成纯函数([ResidentStatus.fromJson]),便于单测三态。
|
||
library;
|
||
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:path/path.dart' as p;
|
||
|
||
import 'autostart_label.dart';
|
||
import 'install_paths.dart';
|
||
|
||
/// CLI 执行器(可注入:测试不该真的去跑进程)
|
||
typedef ProcessRunner = Future<ProcessResult> Function(
|
||
String executable,
|
||
List<String> arguments, {
|
||
String? workingDirectory,
|
||
});
|
||
|
||
/// 常驻采集的三种可显示状态(+ 一种"问不出来")
|
||
enum ResidentState {
|
||
/// 采集进程在跑(自启已注册或手动起的都算)
|
||
running,
|
||
|
||
/// 装了但没起来:落位文件/自启项在,进程不在
|
||
installedNotRunning,
|
||
|
||
/// 没装:既没有自启项,也没落位文件
|
||
notInstalled,
|
||
|
||
/// 问不到状态(CLI 缺失、输出不是 JSON、执行失败)—— 界面必须如实说"不知道"
|
||
unknown,
|
||
}
|
||
|
||
/// `pmon-install status --json` 的类型化视图
|
||
class ResidentStatus {
|
||
const ResidentStatus({
|
||
required this.state,
|
||
this.pids = const [],
|
||
this.autostartRegistered = false,
|
||
this.autostartLoaded = false,
|
||
this.unitPath,
|
||
this.configExists = false,
|
||
this.configPath = '',
|
||
this.logPath = '',
|
||
this.logExists = false,
|
||
this.agentPath = '',
|
||
this.agentExists = false,
|
||
this.probeAvailable,
|
||
this.probeError = '',
|
||
this.serverUrl = '',
|
||
this.deviceId = '',
|
||
this.detail,
|
||
});
|
||
|
||
final ResidentState state;
|
||
final List<int> pids;
|
||
|
||
/// 自启项(plist/systemd unit/注册表项)是否已注册
|
||
final bool autostartRegistered;
|
||
|
||
/// 自启项是否已**加载**(launchd 已接管 / systemd 已 enable)
|
||
final bool autostartLoaded;
|
||
|
||
/// 自启产物路径(Windows 走注册表时为 null)
|
||
final String? unitPath;
|
||
|
||
final bool configExists;
|
||
final String configPath;
|
||
|
||
/// 采集端错误日志路径(launchd 的 `StandardErrorPath`)。
|
||
///
|
||
/// ⚠️ **这条事实目前在 Rust 与 Dart 各写了一份**:`rust-core/src/keepalive.rs`
|
||
/// 的 `macos_launchagent_plist` 把 `StandardErrorPath` 写成 `/tmp/<label>.err.log`,
|
||
/// `bin/pmon_install.dart` 的 `statusSnapshot()` 又自己拼一遍。今天两边**逐字
|
||
/// 相同**,但"两边碰巧一样"正是漂移的前兆 —— 改一处,status 就指向一个不存在
|
||
/// 的日志文件,而两边都不会报错。
|
||
///
|
||
/// 正解(未做,要动 Rust 结构 + FFI 出口):让 `AutoStartPlan` 多返回
|
||
/// `stderr_log_path`,Dart 侧**只读不算** —— 即改为读
|
||
/// `AutoStartPlan.stderr_log_path`,**勿再在本地拼 `/tmp/<label>.err.log`**。
|
||
final String logPath;
|
||
final bool logExists;
|
||
|
||
/// 落位的采集端可执行文件
|
||
final String agentPath;
|
||
final bool agentExists;
|
||
|
||
/// 采集端进程探针**是否可用**(`process.probe_available`)。
|
||
///
|
||
/// 三态而非布尔:`null` 表示这个 CLI 版本压根没报这个字段(老二进制/老快照)。
|
||
/// 只有明确 `false` 才允许把结论降级为 [ResidentState.unknown] ——
|
||
/// 老 CLI 没这个字段时若按"不可用"处理,会把所有老机器的正常状态打成"未知",
|
||
/// 那就是用另一种方式骗人。
|
||
final bool? probeAvailable;
|
||
|
||
/// 探针不可用的原因(`process.probe_error`),只在 [probeAvailable] 为 false 时有意义
|
||
final String probeError;
|
||
|
||
/// 采集端**实际读到的**网关地址(`config.server_url`,即安装器那份配置)
|
||
///
|
||
/// 为什么不让界面拿自己那份配置显示:macOS GUI 是**真沙箱**,它读写的
|
||
/// `ConfigStore` 在应用容器里;而常驻采集端由 launchd 拉起、读真实家目录。
|
||
/// 两份都活着、各说各话 —— 显示"采集端实际读到什么"才是真的。
|
||
final String serverUrl;
|
||
|
||
/// 采集端**实际读到的**设备标识(`config.device_id`)
|
||
final String deviceId;
|
||
|
||
/// 诊断说明(未知态时说明为什么问不出来)
|
||
final String? detail;
|
||
|
||
static const unknown = ResidentStatus(
|
||
state: ResidentState.unknown,
|
||
detail: '拿不到常驻采集状态',
|
||
);
|
||
|
||
/// 解析 `status --json` 的输出。
|
||
///
|
||
/// 容错原则:**字段缺了不能崩,也不能假装知道**。缺关键字段时退化为
|
||
/// [ResidentState.unknown] 并把原因写进 detail —— 界面宁可说"看不清",
|
||
/// 也不要猜一个"在跑/没跑"骗人。
|
||
static ResidentStatus fromJson(Map<String, dynamic> j) {
|
||
final process = _map(j['process']);
|
||
final autostart = _map(j['autostart']);
|
||
final config = _map(j['config']);
|
||
final log = _map(j['log']);
|
||
final installed = _map(j['installed']);
|
||
|
||
final rawPids = (process['pids'] as List?) ?? const [];
|
||
final pids = rawPids
|
||
.map((e) => e is num ? e.toInt() : int.tryParse('$e'))
|
||
.whereType<int>()
|
||
.toList();
|
||
|
||
// `probe_available` 是三态:缺字段(老 CLI)→ null,不看这个判据。
|
||
final rawProbe = process['probe_available'];
|
||
final probeAvailable = rawProbe is bool ? rawProbe : null;
|
||
final probeError = (process['probe_error'] as String?) ?? '';
|
||
|
||
final running = process['running'] == true || pids.isNotEmpty;
|
||
final registered = autostart['registered'] == true;
|
||
final loaded = autostart['loaded'] == true;
|
||
final agentExists = installed['agent_exists'] == true;
|
||
|
||
final ResidentState state;
|
||
// 探针不可用 ≠ 没在跑
|
||
String? resolutionDetail;
|
||
if (running) {
|
||
// 正面观察到的"在跑"永远优先:探针能给出 pid,就说明它至少是能用的
|
||
state = ResidentState.running;
|
||
} else if (probeAvailable == false) {
|
||
// ★「查不了」不是「没在跑」:本平台没有探针(Windows 暂无)时,
|
||
// `running` 只能是 false —— 若照旧往下判,会得出"已安装但没起来"这个
|
||
// **看起来很确定、实际没查过**的结论,卡片还会染成告警色。
|
||
// 本项目的规矩是"不知道"优于"错的答案",故降级为 unknown(中性灰)。
|
||
state = ResidentState.unknown;
|
||
resolutionDetail = '本平台无法判定采集端进程'
|
||
'(${probeError.isEmpty ? '缺少可用的进程探针' : probeError}),'
|
||
'这不等于"没在运行"';
|
||
} else if (registered || agentExists) {
|
||
state = ResidentState.installedNotRunning;
|
||
} else {
|
||
state = ResidentState.notInstalled;
|
||
}
|
||
|
||
return ResidentStatus(
|
||
state: state,
|
||
pids: pids,
|
||
autostartRegistered: registered,
|
||
autostartLoaded: loaded,
|
||
unitPath: autostart['unit_path'] as String?,
|
||
configExists: config['exists'] == true,
|
||
configPath: (config['path'] as String?) ?? '',
|
||
logPath: (log['path'] as String?) ?? '',
|
||
logExists: log['exists'] == true,
|
||
agentPath: (installed['agent_path'] as String?) ?? '',
|
||
agentExists: agentExists,
|
||
probeAvailable: probeAvailable,
|
||
probeError: probeError,
|
||
serverUrl: (config['server_url'] as String?) ?? '',
|
||
deviceId: (config['device_id'] as String?) ?? '',
|
||
detail: resolutionDetail,
|
||
);
|
||
}
|
||
|
||
static Map<String, dynamic> _map(Object? v) =>
|
||
v is Map ? Map<String, dynamic>.from(v) : const <String, dynamic>{};
|
||
}
|
||
|
||
/// 一次动作(安装/卸载)的结果。
|
||
///
|
||
/// 为什么带 `message` 而不是只给 bool:P0-6 已让 installer 返回非零并打印
|
||
/// **哪条命令失败**,失败时把那段原文回显给用户,比"安装失败"有用得多。
|
||
class ResidentActionResult {
|
||
const ResidentActionResult({
|
||
required this.ok,
|
||
required this.message,
|
||
this.exitCode = 0,
|
||
});
|
||
|
||
final bool ok;
|
||
final String message;
|
||
final int exitCode;
|
||
|
||
static const okNoOutput = ResidentActionResult(ok: true, message: '');
|
||
}
|
||
|
||
/// 卡片依赖的最小接口(测试用假实现替换,不必真跑进程)
|
||
abstract class ResidentOps {
|
||
Future<ResidentStatus> status();
|
||
|
||
Future<ResidentActionResult> install({
|
||
required String serverUrl,
|
||
required String deviceId,
|
||
});
|
||
|
||
Future<ResidentActionResult> uninstall();
|
||
}
|
||
|
||
/// 真实现:`Process.run` 调 `pmon-install` CLI
|
||
class ResidentService implements ResidentOps {
|
||
ResidentService({
|
||
ProcessRunner? runner,
|
||
String? cliPath,
|
||
String? cliSource,
|
||
String? projectDir,
|
||
// label 与安装器共用同一份(见 host/autostart_label.dart):漂移的后果是
|
||
// "明明装了、卡片却说未注册" —— 两边都正常,只是问的不是同一个自启项。
|
||
this.label = kDefaultAutostartLabel,
|
||
}) : _run = runner ?? _defaultRunner,
|
||
_cliPathOverride = cliPath,
|
||
_cliSourceOverride = cliSource,
|
||
_projectDirOverride = projectDir;
|
||
|
||
final ProcessRunner _run;
|
||
final String? _cliPathOverride;
|
||
final String? _cliSourceOverride;
|
||
final String? _projectDirOverride;
|
||
final String label;
|
||
|
||
static Future<ProcessResult> _defaultRunner(
|
||
String executable,
|
||
List<String> arguments, {
|
||
String? workingDirectory,
|
||
}) =>
|
||
Process.run(executable, arguments, workingDirectory: workingDirectory);
|
||
|
||
/// CLI 的落位(**从安装路径派生,不新写一份**)。
|
||
///
|
||
/// 安装器把可执行文件统一落在 [installBinDir](见 app_paths/install_paths),
|
||
/// 所以装好的 CLI 就在它旁边。
|
||
String get installedCliPath => p.join(installBinDir, _cliFileName);
|
||
|
||
String get _cliFileName => Platform.isWindows ? 'pmon-install.exe' : 'pmon-install';
|
||
|
||
/// 解析出"怎么调这个 CLI":`[exe, ...前置参数]`(**按优先级**返回全部候选)。
|
||
///
|
||
/// 三级回落(每一级都写清为什么存在,避免后人凭直觉删):
|
||
/// 1. 装好的 `pmon-install` —— 终端用户的实际形态;
|
||
/// 2. 开发树 `build/pmon-install` —— 开发机按 README 编译后的形态;
|
||
/// 3. `dart run bin/pmon_install.dart` —— 没编译也能用(开发/排障兜底),
|
||
/// 代价是慢(每次要 JIT 编译一遍)。
|
||
///
|
||
/// ⚠️ 为什么是"候选列表"而不是"第一个存在的就用"(真机实测驱动):
|
||
/// 开发机上 `build/pmon-install` 往往是**几天前编译的旧二进制**。实测它
|
||
/// 不支持 `status --json`,旧实现会打印一行**配置 JSON**:
|
||
/// `{"server_url":...,"device_id":...}`
|
||
/// 于是"取最后一个能解析的 JSON"会抓到那一行,解析成"未安装、路径全空" ——
|
||
/// 卡片**静默显示错误状态**,而不是"这个 CLI 太旧"。这正是"形状对、内容错"。
|
||
/// 故 [status] 会按候选顺序逐个试,**只接受形状正确的快照**(见 [looksLikeSnapshot])。
|
||
List<List<String>> resolveCliCandidates() {
|
||
if (_cliPathOverride != null) {
|
||
return [
|
||
[_cliPathOverride]
|
||
];
|
||
}
|
||
final proj = _projectDirOverride ?? Directory.current.path;
|
||
final out = <List<String>>[];
|
||
for (final c in [installedCliPath, p.join(proj, 'build', _cliFileName)]) {
|
||
if (File(c).existsSync()) out.add([c]);
|
||
}
|
||
final entry = p.join(proj, 'bin', 'pmon_install.dart');
|
||
if (File(entry).existsSync()) {
|
||
out.add([_cliSourceOverride ?? 'dart', 'run', entry]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/// 第一个候选(安装/卸载用:那两个动作没有"形状不合规"这回事,
|
||
/// 失败原因原样回显就够;状态查询才需要逐个试)
|
||
List<String>? resolveCli() {
|
||
final all = resolveCliCandidates();
|
||
return all.isEmpty ? null : all.first;
|
||
}
|
||
|
||
@override
|
||
Future<ResidentStatus> status() async {
|
||
final candidates = resolveCliCandidates();
|
||
if (candidates.isEmpty) {
|
||
return const ResidentStatus(
|
||
state: ResidentState.unknown,
|
||
detail: '未找到 pmon-install(先执行 `dart compile exe bin/pmon_install.dart '
|
||
'-o build/pmon-install`,或直接使用已安装的版本)',
|
||
);
|
||
}
|
||
|
||
// 逐个候选试:**只接受形状正确的快照**。第一个不合规的(很可能是几天前
|
||
// 编译的旧二进制)不能直接把结果当真 —— 那会静默显示错误状态(见
|
||
// resolveCliCandidates 的说明)。全都不合规时,把最后一个的原因报出去。
|
||
ResidentStatus? rejected;
|
||
for (final cli in candidates) {
|
||
final st = await _statusOnce(cli);
|
||
if (st.state != ResidentState.unknown) return st;
|
||
rejected = st;
|
||
}
|
||
return rejected ??
|
||
const ResidentStatus(
|
||
state: ResidentState.unknown, detail: '拿不到常驻采集状态');
|
||
}
|
||
|
||
Future<ResidentStatus> _statusOnce(List<String> cli) async {
|
||
final r = await _run(cli.first, [...cli.skip(1), 'status', '--json', '--label', label]);
|
||
if (r.exitCode != 0) {
|
||
return ResidentStatus(
|
||
state: ResidentState.unknown,
|
||
detail: 'pmon-install status 失败(exit ${r.exitCode},${cli.first}):'
|
||
'${_tail(r.stderr)}',
|
||
);
|
||
}
|
||
final raw = (r.stdout as String).trim();
|
||
final decoded = _extractJson(raw);
|
||
if (decoded == null) {
|
||
return ResidentStatus(
|
||
state: ResidentState.unknown,
|
||
detail: '这个 pmon-install 不支持 `status --json`(${cli.first});'
|
||
'请重新编译:dart compile exe bin/pmon_install.dart -o build/pmon-install。'
|
||
'输出前 120 字:${raw.length > 120 ? raw.substring(0, 120) : raw}',
|
||
);
|
||
}
|
||
return ResidentStatus.fromJson(decoded);
|
||
}
|
||
|
||
/// 一份输出"像不像 status 快照"—— 形状校验,不是有 `{` 就算。
|
||
///
|
||
/// 真机血案:旧版 CLI 的 `status` 会打印一行**配置 JSON**
|
||
/// (`{"server_url":...,"device_id":...}`)。若只判"能解析成 Map",
|
||
/// 就会把配置当成状态快照 → 三个路径全空、state 落到"未安装",
|
||
/// 而卡片会一本正经地显示"尚未安装"(用户明明装过)。
|
||
/// 判据:必须同时有 `autostart` 与 `process` 两段(它们只出现在状态快照里)。
|
||
static bool looksLikeSnapshot(Map<String, dynamic> m) =>
|
||
m['autostart'] is Map && m['process'] is Map;
|
||
|
||
/// 从 CLI 的 stdout 里抠出**状态快照** JSON。
|
||
///
|
||
/// 两条真机教训(都实测过,别简化回去):
|
||
/// 1. 不能假设"最后一行以 `{` 开头":`dart run` 会把 `Running build hooks...`
|
||
/// **写在同一行**(`Running build hooks...{"label":"com.pmon..."}`);
|
||
/// 2. 不能"能解析成 Map 就收":旧版 CLI 的 `status` 会打印一行**配置 JSON**,
|
||
/// 那也会解析成功,但内容是错的(三个路径全空、state 落到"未安装")。
|
||
/// 故只接受 [looksLikeSnapshot] 认可的(含 `autostart` + `process` 两段)。
|
||
static Map<String, dynamic>? _extractJson(String raw) {
|
||
final lines = raw.split('\n');
|
||
for (var i = lines.length - 1; i >= 0; i--) {
|
||
final line = lines[i];
|
||
final start = line.indexOf('{');
|
||
if (start < 0) continue;
|
||
try {
|
||
final v = jsonDecode(line.substring(start));
|
||
if (v is Map<String, dynamic> && looksLikeSnapshot(v)) return v;
|
||
} catch (_) {
|
||
// 这一行不是完整 JSON,继续往前找
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
@override
|
||
Future<ResidentActionResult> install({
|
||
required String serverUrl,
|
||
required String deviceId,
|
||
}) =>
|
||
_action(['install'], extraArgs: [
|
||
if (serverUrl.isNotEmpty) ...['--server', serverUrl],
|
||
if (deviceId.isNotEmpty) ...['--device-id', deviceId],
|
||
]);
|
||
|
||
@override
|
||
Future<ResidentActionResult> uninstall() => _action(['uninstall']);
|
||
|
||
Future<ResidentActionResult> _action(
|
||
List<String> subcommand, {
|
||
List<String> extraArgs = const [],
|
||
}) async {
|
||
final cli = resolveCli();
|
||
if (cli == null) {
|
||
return const ResidentActionResult(
|
||
ok: false,
|
||
message: '未找到 pmon-install:先执行 '
|
||
'`dart compile exe bin/pmon_install.dart -o build/pmon-install`,'
|
||
'或把编译产物放到安装目录的 bin/ 下。',
|
||
);
|
||
}
|
||
final args = [...cli.skip(1), ...subcommand, '--label', label, ...extraArgs];
|
||
try {
|
||
final r = await _run(cli.first, args);
|
||
if (r.exitCode == 0) {
|
||
return ResidentActionResult(
|
||
ok: true,
|
||
message: _tail(r.stdout),
|
||
);
|
||
}
|
||
// 失败必须带**具体原因**:installer 会把"哪条命令失败"打到 stderr,
|
||
// 只回一句"安装失败"等于把最有用的一行丢掉。
|
||
final err = _tail(r.stderr);
|
||
final out = _tail(r.stdout);
|
||
return ResidentActionResult(
|
||
ok: false,
|
||
exitCode: r.exitCode,
|
||
message: err.isNotEmpty ? err : (out.isNotEmpty ? out : '命令失败,但没有输出'),
|
||
);
|
||
} catch (e) {
|
||
return ResidentActionResult(ok: false, message: '无法执行 $cli:$e');
|
||
}
|
||
}
|
||
|
||
/// 取输出的**最后几行**(安装器的失败原因通常在末尾,前面的都是过程日志)
|
||
static String _tail(Object? raw, {int lines = 4}) {
|
||
final s = (raw as String?)?.trim() ?? '';
|
||
if (s.isEmpty) return '';
|
||
final all = s.split('\n').map((l) => l.trimRight()).where((l) => l.isNotEmpty).toList();
|
||
return (all.length <= lines ? all : all.sublist(all.length - lines)).join('\n');
|
||
}
|
||
}
|