diff --git a/README.md b/README.md
index fc97ff1..0b98a01 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,12 @@ Windows 上的主路由 / 旁路由保活工具。旁路由连续检测失败时
手动启动时一定会弹出面板(登录任务用 `--minimized` 启动,只驻留托盘),不会再让人以为程序没启动而反复双击。
+### 放在哪个目录运行
+
+程序要改路由和 DNS,会以管理员身份运行,请把它放在自己的用户目录下,例如 `%LOCALAPPDATA%\Programs\GatewayKeepalive\`。
+
+不要从“低完整性”目录(沙箱、共享、被打了 Low 标签的目录,例如本机开发目录 `D:\dsh workspace` 下的产物)启动:Windows 会用低完整性令牌运行那里的 exe,它连自己的 `%LOCALAPPDATA%` 都写不了,登录自启就会停在“Access to the path … is denied”。判断办法是跑一次 `.\GatewayKeepalive.exe --logcheck`,看输出里的“数据目录 … 可写=True/False”和“实际目录”。程序也有兜底:首选日志目录写不了会依次退到 `%LOCALAPPDATA%Low`、`%TEMP%`、`%ProgramData%`,并在日志里写明“日志目录改为 …”,但数据目录(`session.json`、`settings.json`)必须是可写的用户目录。
+
- 左键图标:显示/隐藏面板;Esc 或点外部:收起面板。
- 自动:按连续阈值和冷却切换;直连/旁路由:固定模式,失败时提示。
- 暂停:继续监测,停止自动改网络。
diff --git a/docs/validation.md b/docs/validation.md
index b28259d..d106826 100644
--- a/docs/validation.md
+++ b/docs/validation.md
@@ -2,6 +2,29 @@
日期:2026-10-23(初版)、2026-10-24(第二轮修复)。环境:Windows 10/11 x64,开发机网卡 `以太网`(索引 16),IPv4 地址 `192.168.6.119/24`,主路由 `192.168.6.1`。
+## 第八轮修复(2026-10-24,重启后登录自启报“Access to the path … is denied”)
+
+症状:重启电脑后没有托盘图标,只弹一个对话框「程序无法继续:Access to the path 'C:\Users\emm\AppData\Local\GatewayKeepalive\logs\gateway-2026-10-24.log' is denied.」。
+
+排查与真因:
+
+- 计划任务本身没问题:`schtasks /Query /XML` 显示 `UserId=S-1-5-21-1629374972-835245509-3346384658-1001`、`LogonType=InteractiveToken`、`RunLevel=HighestAvailable`、动作 `GatewayKeepalive.exe --minimized`,触发器等登录用户。`schtasks /Run` 能复现同样的对话框。
+- 权限表也没问题:日志目录、日志文件、以及 `C:\Users\emm\AppData\Local` 三层 `icacls` 全都有 `DESKTOP\emm:(F)`,没有被拒绝的 ACE;同样的脚本用同一个账号可以写。
+- 真因在**所在目录的完整性级别**:`D:\dsh workspace` 被打了 Low 强制标签(`icacls` → `Mandatory Label\Low Mandatory Level:(OI)(CI)(NW)`),存在这里的 exe 会被 Windows 用**低完整性令牌**运行。低完整性进程写不了中完整性的 `%LOCALAPPDATA%`,于是第一步写日志就抛 `UnauthorizedAccessException`。把同一个 exe 复制到 `%LOCALAPPDATA%\Programs\GatewayKeepalive\` 再跑,`--logcheck` 立刻变成“数据目录 可写=True / 结论=写入成功”。
+- 也就是说:这不是提权问题、不是任务配置问题、不是 ACL 问题,而是“程序放在哪运行”。修了两头——给出正确的放置位置,并让程序不再因为写不了日志而整个起不来。
+
+修法:
+
+- `FileLog` 改成 fail-safe:写入先进缓冲区,按「首选目录 → `%LOCALAPPDATA%Low` → `%TEMP%` → `%ProgramData%`」依次尝试,任何一步失败都不抛异常;`ActiveDirectory` / `LastFailure` 暴露真实落点与失败原因,切换目录时在日志里写一行“日志目录改为 …”。
+- 启动时探测数据目录是否真的可写(建临时文件再删),自检行里带上「数据目录=… 可写=… 日志目录=…」;致命对话框不再只说一句错误,而是写出实际生效的日志目录与“程序可能放在低完整性目录里”的判断。
+- 新增 `--logcheck`:只用当前身份验证“能不能写日志/数据目录”,不起托盘、不提权,输出「程序路径 / 数据目录 可写 / 首选目录 / 实际目录 / 结论」,方便用户自查。
+
+本轮验证:
+
+- 新增两条回归测试:首选目录可写时就用首选且不记失败;首选目录不可用(被同名文件占住)时不抛异常、回退到可用目录并把失败原因写进 `LastFailure`。本地测试总数 47 → 49,全绿;探测离线测试 16/16;Release 构建 0 警告 0 错误。
+- 实测三种落点:`%LOCALAPPDATA%\Programs\GatewayKeepalive\GatewayKeepalive.exe --logcheck` → 可写=True、实际目录=`…\AppData\Local\GatewayKeepalive\logs`、写入成功;`artifacts\publish`(低完整性)下的同一 exe → 可写=False、实际目录=`…\AppData\LocalLow\GatewayKeepalive\logs`、写入成功(已回退),日志里确有该行。
+- **未验证**:桌面托盘与登录任务仍需把 exe 换到用户目录后由用户重启确认一次(本机命令行没有提权能力,不能改计划任务的动作路径)。
+
## 第七轮修复(2026-10-24,关闭登录自启必报错:按 HRESULT 而不是按异常类型判断)
症状:点「登录后自动启动」必弹「开机自启设置失败:找不到文件或目录:系统找不到指定的文件。(0x80070002) 已回滚到原设置。」,这个开关因此永远关不掉。
diff --git a/src/GatewayKeepalive.Core/Persistence.cs b/src/GatewayKeepalive.Core/Persistence.cs
index 6b4b6ee..d564bd1 100644
--- a/src/GatewayKeepalive.Core/Persistence.cs
+++ b/src/GatewayKeepalive.Core/Persistence.cs
@@ -35,21 +35,102 @@ public sealed class JsonStore(string directory)
public void Delete(string file) => File.Delete(Path.Combine(DirectoryPath, file));
}
+///
+/// 追加式日志。日志本身永远不能让程序停下:首选目录写不进去就换下一个候选目录,
+/// 并把失败原因(路径 + 异常类型 + Win32 错误码)记在 里,
+/// 由界面/诊断输出展示,而不是把异常抛给启动流程。
+///
public sealed class FileLog(string directory)
{
+ private const int PendingLimit = 200;
private readonly object _gate = new();
+ private readonly List _pending = [];
+ private string? _active;
+ private string? _failure;
+
+ /// 当前真正写入的日志目录(首选目录可用时就是构造参数)。
+ public string ActiveDirectory { get { lock (_gate) return _active ?? directory; } }
+
+ /// 最近一次写入失败的原因;全部候选都失败时才非空。
+ public string? LastFailure { get { lock (_gate) return _failure; } }
+
public void Write(string message)
{
+ var line = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}";
lock (_gate)
{
- Directory.CreateDirectory(directory);
- var today = DateTime.Now;
- File.AppendAllText(Path.Combine(directory, $"gateway-{today:yyyy-MM-dd}.log"), $"[{today:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}");
- foreach (var file in Directory.EnumerateFiles(directory, "gateway-*.log"))
- if (File.GetLastWriteTimeUtc(file) < DateTime.UtcNow.AddDays(-14))
- try { File.Delete(file); } catch (IOException) { }
+ _pending.Add(line);
+ if (_pending.Count > PendingLimit) _pending.RemoveRange(0, _pending.Count - PendingLimit);
+ TryFlush();
}
}
+
+ private void TryFlush()
+ {
+ var failures = new List(3);
+ foreach (var candidate in Candidates())
+ {
+ if (Append(candidate, _pending, out var error))
+ {
+ var switched = _active is not null && !string.Equals(_active, candidate, StringComparison.OrdinalIgnoreCase);
+ _active = candidate;
+ _pending.Clear();
+ _failure = failures.Count == 0 ? null : string.Join(";", failures);
+ if (switched)
+ Append(candidate, [$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] 日志目录改为 {candidate}{Environment.NewLine}"], out _);
+ Prune(candidate);
+ return;
+ }
+ failures.Add(error!);
+ }
+ _failure = string.Join(";", failures);
+ }
+
+ private IEnumerable Candidates()
+ {
+ yield return directory;
+ // 低完整性(低 IL)进程写不了 LOCALAPPDATA/TEMP,但 LocalLow 正是为它们准备的。
+ foreach (var low in LowIntegrityDirectories()) yield return low;
+ var temp = Environment.GetEnvironmentVariable("TEMP");
+ if (!string.IsNullOrWhiteSpace(temp)) yield return Path.Combine(temp, "GatewayKeepalive", "logs");
+ var common = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
+ if (!string.IsNullOrWhiteSpace(common)) yield return Path.Combine(common, "GatewayKeepalive", "logs");
+ }
+
+ private static IEnumerable LowIntegrityDirectories()
+ {
+ var profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ if (!string.IsNullOrWhiteSpace(profile)) yield return Path.Combine(profile, "AppData", "LocalLow", "GatewayKeepalive", "logs");
+ yield return Path.Combine(Path.GetTempPath(), "Low", "GatewayKeepalive", "logs");
+ }
+
+ private static bool Append(string target, List lines, out string? error)
+ {
+ error = null;
+ try
+ {
+ Directory.CreateDirectory(target);
+ var path = Path.Combine(target, $"gateway-{DateTime.Now:yyyy-MM-dd}.log");
+ File.AppendAllText(path, string.Concat(lines));
+ return true;
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or NotSupportedException or System.Security.SecurityException or ArgumentException)
+ {
+ error = $"{target}({exception.GetType().Name} 0x{exception.HResult:X8} {exception.Message.Trim()})";
+ return false;
+ }
+ }
+
+ private static void Prune(string target)
+ {
+ try
+ {
+ foreach (var file in Directory.EnumerateFiles(target, "gateway-*.log"))
+ if (File.GetLastWriteTimeUtc(file) < DateTime.UtcNow.AddDays(-14))
+ try { File.Delete(file); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { }
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException) { }
+ }
}
public sealed record SwitchJournal(NetworkSnapshot Before, NetworkSnapshot Intended, string Stage, DateTimeOffset StartedAt);
diff --git a/src/GatewayKeepalive.Tray/Program.cs b/src/GatewayKeepalive.Tray/Program.cs
index f6c8623..8672f6f 100644
--- a/src/GatewayKeepalive.Tray/Program.cs
+++ b/src/GatewayKeepalive.Tray/Program.cs
@@ -21,9 +21,29 @@ internal static class Program
return 0;
}
if (args.Any(x => x is "--diagnose" or "--dry-run" or "--probe")) return DiagnosticCommands.RunAsync(args).GetAwaiter().GetResult();
+ if (args.Contains("--logcheck"))
+ {
+ // 只验证“这个身份、这个时机能不能写日志”,不启动托盘、不申请提权。
+ using var who = WindowsIdentity.GetCurrent();
+ var target = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GatewayKeepalive", "logs");
+ var check = new FileLog(target);
+ check.Write($"日志自检:用户={who.Name} 管理员={new WindowsPrincipal(who).IsInRole(WindowsBuiltInRole.Administrator)}");
+ var dataDir = Path.GetDirectoryName(target)!;
+ var dataWritable = CanWrite(dataDir, out var dataError);
+ Console.WriteLine($"程序路径={Environment.ProcessPath}");
+ Console.WriteLine($"数据目录={dataDir} 可写={dataWritable}" + (dataWritable ? string.Empty : $" 原因={dataError}"));
+ var fellBack = !string.Equals(Path.GetFullPath(check.ActiveDirectory), Path.GetFullPath(target), StringComparison.OrdinalIgnoreCase);
+ var verdict = check.LastFailure is null
+ ? "写入成功(首选目录)"
+ : fellBack ? "写入成功(首选目录失败,已回退)" : "写入失败";
+ Console.WriteLine($"首选目录={target}");
+ Console.WriteLine($"实际目录={check.ActiveDirectory}");
+ Console.WriteLine($"结论={verdict}" + (check.LastFailure is null ? string.Empty : $";首选原因={check.LastFailure}"));
+ return check.LastFailure is null || fellBack ? 0 : 1;
+ }
if (args.Contains("--help"))
{
- MessageBox.Show("网关保活\n直接运行:托盘程序\n--minimized:仅驻留托盘\n--diagnose --output report.json:只读诊断\n--dry-run --adapter GUID:只生成切换计划\n--preview 目录:离屏界面预览\n控制台详细输出请使用 GatewayKeepalive.Cli。", "网关保活");
+ MessageBox.Show("网关保活\n直接运行:托盘程序\n--minimized:仅驻留托盘\n--logcheck:检查当前身份能否写日志(控制台输出)\n--diagnose --output report.json:只读诊断\n--dry-run --adapter GUID:只生成切换计划\n--preview 目录:离屏界面预览\n控制台详细输出请使用 GatewayKeepalive.Cli。", "网关保活");
return 0;
}
var backend = new WindowsNetworkBackend();
@@ -62,16 +82,30 @@ internal static class Program
var logger = new FileLog(Path.Combine(directory, "logs"));
try
{
+ var writable = CanWrite(directory, out var writeError);
+ logger.Write($"数据目录:{directory} 可写={writable}" + (writable ? string.Empty : $"({writeError})") +
+ (string.Equals(logger.ActiveDirectory, Path.Combine(directory, "logs"), StringComparison.OrdinalIgnoreCase) ? string.Empty : $" 日志改写至:{logger.ActiveDirectory}"));
+ if (!writable && !args.Contains("--minimized"))
+ {
+ // 只砸在交互式启动上;登录自启别弹窗,日志里有记录就够了。
+ MessageBox.Show(
+ "无法写入数据目录:" + directory + "\n原因:" + writeError +
+ "\n\n最常见的原因是程序放在了低完整性(沙箱)目录里,例如 D:\\dsh workspace 下的产物:" +
+ "Windows 会用低完整性令牌运行这里的 exe,于是写不进当前用户目录。\n\n" +
+ "处理办法:把 GatewayKeepalive.exe 复制到普通目录(例如 %LOCALAPPDATA%\\Programs\\GatewayKeepalive)后再运行," +
+ "然后在设置里重新开启一次开机自启。\n\n本次日志写到:" + logger.ActiveDirectory,
+ "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ }
var store = new JsonStore(directory);
var startup = new LoginStartup(logger.Write);
// 每次启动先留一行自检:出问题时光看日志就知道是不是提权/自启任务的问题。
try
{
- logger.Write($"启动自检:管理员={backend.IsAdministrator} 登录自启={startup.IsEnabled} 进程={Environment.ProcessPath} 参数={(args.Length == 0 ? "(无)" : string.Join(' ', args))}");
+ logger.Write($"启动自检:管理员={backend.IsAdministrator} 用户={identity.Name} 登录自启={startup.IsEnabled} 进程={Environment.ProcessPath} 参数={(args.Length == 0 ? "(无)" : string.Join(' ', args))} 日志目录={logger.ActiveDirectory}");
}
catch (Exception exception)
{
- logger.Write("启动自检失败:" + exception.Message);
+ logger.Write("启动自检失败:" + exception);
}
// 校正旧任务指向要连 COM、必要时还会拉起 schtasks,放到后台做,别让托盘图标准备好之前先卡住。
_ = Task.Run(() =>
@@ -104,13 +138,36 @@ internal static class Program
catch (Exception e)
{
try { logger.Write("启动/退出失败:" + e); } catch (Exception) { }
- MessageBox.Show("程序无法继续:" + e.Message + "\n配置与恢复记录保留在:\n" + directory, "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ var logNote = logger.LastFailure is null ? "" : "\n日志写入问题:" + logger.LastFailure + "\n实际日志目录:" + logger.ActiveDirectory;
+ MessageBox.Show("程序无法继续:" + e.Message + logNote + "\n配置与恢复记录保留在:\n" + directory, "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Error);
return 1;
}
}
private static void ShowExisting(TrayApplicationContext app) => app.ShowPanel();
+ ///
+ /// 判断数据目录是否真的能写。低完整性进程(例如 exe 落在沙箱目录里)会在
+ /// LOCALAPPDATA 上拿到 UnauthorizedAccessException,这不是配置问题,得说清楚。
+ ///
+ private static bool CanWrite(string directory, out string error)
+ {
+ try
+ {
+ Directory.CreateDirectory(directory);
+ var probe = Path.Combine(directory, $".write-probe-{Environment.ProcessId}");
+ File.WriteAllText(probe, "ok");
+ File.Delete(probe);
+ error = string.Empty;
+ return true;
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or NotSupportedException or System.Security.SecurityException)
+ {
+ error = $"{exception.GetType().Name} 0x{exception.HResult:X8} {exception.Message.Trim()}";
+ return false;
+ }
+ }
+
///
/// Single-file apps extract the WinForms theming manifest to the temp directory. If that
/// location is restricted, visual styles are decorative only for this fully custom-drawn UI;
diff --git a/tests/GatewayKeepalive.Tests/LogFallbackTests.cs b/tests/GatewayKeepalive.Tests/LogFallbackTests.cs
new file mode 100644
index 0000000..27c78e6
--- /dev/null
+++ b/tests/GatewayKeepalive.Tests/LogFallbackTests.cs
@@ -0,0 +1,43 @@
+using GatewayKeepalive.Core;
+
+namespace GatewayKeepalive.Tests;
+
+internal static class LogFallbackTests
+{
+ public static Task WritablePrimaryDirectoryIsUsedAsync()
+ {
+ using var temp = new TempDirectory();
+ var target = Path.Combine(temp.DirectoryPath, "logs");
+ var log = new FileLog(target);
+
+ log.Write("测试:首选目录可写");
+
+ Check.Equal(Path.GetFullPath(target), Path.GetFullPath(log.ActiveDirectory), "A writable primary directory must stay active");
+ Check.True(log.LastFailure is null, "No failure should be recorded when the primary directory works");
+ var file = Path.Combine(target, $"gateway-{DateTime.Now:yyyy-MM-dd}.log");
+ Check.True(File.Exists(file), "The log file should exist in the primary directory");
+ Check.Contains("测试:首选目录可写", File.ReadAllText(file), "The line should land in the primary log");
+ return Task.CompletedTask;
+ }
+
+ public static Task UnusablePrimaryDirectoryFallsBackAsync()
+ {
+ using var temp = new TempDirectory();
+ var blocked = Path.Combine(temp.DirectoryPath, "blocked");
+ File.WriteAllText(blocked, "a file, so no directory can be created here");
+ var log = new FileLog(blocked);
+
+ log.Write("测试:日志回退");
+
+ Check.True(
+ !string.Equals(Path.GetFullPath(blocked), Path.GetFullPath(log.ActiveDirectory), StringComparison.OrdinalIgnoreCase),
+ "An unusable primary directory must be replaced by a fallback");
+ Check.True(log.LastFailure is not null, "The unusable primary directory should be reported as a failure");
+ Check.Contains("blocked", log.LastFailure!, "The failure text should name the unusable directory");
+
+ var fallback = Path.Combine(log.ActiveDirectory, $"gateway-{DateTime.Now:yyyy-MM-dd}.log");
+ Check.True(File.Exists(fallback), "The line should be written to the fallback directory");
+ Check.Contains("测试:日志回退", File.ReadAllText(fallback), "The line should be present in the fallback log");
+ return Task.CompletedTask;
+ }
+}
diff --git a/tests/GatewayKeepalive.Tests/Program.cs b/tests/GatewayKeepalive.Tests/Program.cs
index 8922bc2..8ee5dbd 100644
--- a/tests/GatewayKeepalive.Tests/Program.cs
+++ b/tests/GatewayKeepalive.Tests/Program.cs
@@ -50,6 +50,8 @@ internal static class Program
("NetworkComparison: every owned route, DNS, and network field participates", NetworkComparisonTests.EachOwnedFieldParticipatesAsync),
("NetworkComparison: transaction recovery accepts only exact before/intended rows", NetworkComparisonTests.RecoveryRejectsForeignRowsAsync),
("JsonStore: round trip, backup, and failed serialization preserve the last complete file", JsonStoreTests.AtomicReplacementAsync),
+ ("FileLog: a writable primary directory is used and reports no failure", LogFallbackTests.WritablePrimaryDirectoryIsUsedAsync),
+ ("FileLog: an unusable primary directory falls back instead of throwing", LogFallbackTests.UnusablePrimaryDirectoryFallsBackAsync),
("Windows NativeLayout: x64 structure sizes and metric offsets", NativeLayoutTests.X64LayoutAsync),
("LoginStartup: logon task XML starts minimized with highest privileges", LoginStartupTests.LogonTaskXmlAsync),
("LoginStartup: missing-task detection follows HRESULT, not exception type", LoginStartupTests.TaskMissingDetectionAsync),