Skip to content
Merged
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@
аппаратным. Теперь после отказа XiControl спрашивает прошивку напрямую, есть ли у неё команда
порога, и делает вывод только по ответу «команды нет». Ошибочно выученный признак снимается
сам при следующем запуске (XIC-100).
- **Мелочи с код-ревью** (XIC-101):
- выключение «Режима питания Windows вместе с режимом» возвращает сбалансированный режим и
после перезапуска XiControl — раньше откат помнился только до выхода;
- на вкладке «Драйверы» «Нет доступа» к службе Xiaomi пишется, только если Windows и правда
отказала в доступе; прочие сбои — «Не прочиталось», без ложного совета про права;
- в журнале больше нет ложных ошибок: от оборванного по таймауту запроса к HTTP API и от
закрытия приложения с открытым «Монитором»;
- перед сном порог заряда пишется сразу, без лишнего чтения в последние миллисекунды;
- настройки, сохранённые одновременно из двух мест, больше не теряют одну из правок.

## [0.17.0] — 2026-10-11

Expand Down
5 changes: 5 additions & 0 deletions src/Config/AppConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,11 @@ public sealed class AppConfig
/// работает служба Xiaomi MiDeviceService, не пишем — она делает то же сама.</summary>
public bool PowerModeSync { get; set; } = false;

/// <summary>Что из режима питания Windows записали мы, по источнику (<c>ac</c>/<c>battery</c> →
/// GUID overlay). Выключая <see cref="PowerModeSync"/>, откатываем только это — и после
/// перезапуска тоже (XIC-101). «Сбалансированный» не храним; null — ничего нашего не стоит.</summary>
public Dictionary<string, Guid>? PowerOverlayWritten { get; set; }

/// <summary>Позиция окна «Монитор» (виджет перетаскивается мышью); null — по центру.</summary>
public int? MonitorX { get; set; }
public int? MonitorY { get; set; }
Expand Down
16 changes: 12 additions & 4 deletions src/Config/JsonConfigStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,22 @@ public AppConfig Load()
return cfg;
}

// Сохраняют и UI-поток, и пул (PowerModeSync, самолечение порога заряда, RestoreAfterBoot):
// две одновременные записи одного файла давали IOException, и одна правка терялась. Под
// замком каждая запись сериализует весь текущий конфиг — последняя несёт обе правки.
private readonly object _saveLock = new();

public void Save(AppConfig cfg)
{
try
lock (_saveLock)
{
Directory.CreateDirectory(_dir);
File.WriteAllText(FilePath, JsonSerializer.Serialize(cfg, JsonOpts));
try
{
Directory.CreateDirectory(_dir);
File.WriteAllText(FilePath, JsonSerializer.Serialize(cfg, JsonOpts));
}
catch (Exception ex) { Log.Ex("JsonConfigStore.Save", ex); /* не критично */ }
}
catch (Exception ex) { Log.Ex("JsonConfigStore.Save", ex); /* не критично */ }
}

/// <summary>Конфиг для первого старта (файла нет / повреждён): язык — по языку ОС.</summary>
Expand Down
4 changes: 3 additions & 1 deletion src/Localization/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -324,9 +324,11 @@
"settings.drivers.oem.base.desc": "Xiaomi's base service: Xiaomi account, privacy consents, telemetry, the first-run wizard and update hints for Xiaomi's own apps. It does not get in XiControl's way — disable it only if you want to.",
"settings.drivers.oem.base.off": "The service is disabled. XiControl does not need it; turn it back on if you use a Xiaomi account or Xiaomi's own apps.",
"settings.drivers.oem.base.missing": "The service was not found — XiControl does not need it, everything works without it.",
"settings.drivers.oem.noaccess": "Could not read the service state — see the log for details.",
"settings.drivers.oem.noaccess": "Windows won't let us read the service state: it is protected. How to disable it by hand — see \"About the service\".",
"settings.drivers.oem.unknown": "Could not read the service state — see the log (log.txt) for details.",
"settings.drivers.oem.v.missing": "Not installed",
"settings.drivers.oem.v.noaccess": "No access",
"settings.drivers.oem.v.unknown": "Not read",
"settings.drivers.oem.disable": "Disable",
"settings.drivers.oem.disable.confirm": "Really disable?",
"settings.drivers.oem.enable": "Enable",
Expand Down
4 changes: 3 additions & 1 deletion src/Localization/lang/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -324,9 +324,11 @@
"settings.drivers.oem.base.desc": "Базовая служба Xiaomi: аккаунт Xiaomi, согласия о приватности, телеметрия, мастер первого запуска и подсказки обновлений родных приложений. XiControl она не мешает — отключайте только по желанию.",
"settings.drivers.oem.base.off": "Служба отключена. XiControl она не нужна; включите её, если пользуетесь аккаунтом Xiaomi или родными приложениями Xiaomi.",
"settings.drivers.oem.base.missing": "Служба не найдена — XiControl она не нужна, всё работает без неё.",
"settings.drivers.oem.noaccess": "Не удалось прочитать состояние службы — подробности в журнале.",
"settings.drivers.oem.noaccess": "Windows не даёт прочитать состояние службы: она защищена. Как отключить её вручную — по ссылке «Подробнее о службе».",
"settings.drivers.oem.unknown": "Не удалось прочитать состояние службы — подробности в журнале (log.txt).",
"settings.drivers.oem.v.missing": "Не установлена",
"settings.drivers.oem.v.noaccess": "Нет доступа",
"settings.drivers.oem.v.unknown": "Не прочиталось",
"settings.drivers.oem.disable": "Отключить",
"settings.drivers.oem.disable.confirm": "Точно отключить?",
"settings.drivers.oem.enable": "Включить",
Expand Down
4 changes: 3 additions & 1 deletion src/Localization/lang/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -324,9 +324,11 @@
"settings.drivers.oem.base.desc": "小米基础服务:小米账号、隐私授权、遥测、首次启动向导以及小米自带应用的更新提示。它不会干扰 XiControl——是否禁用随你。",
"settings.drivers.oem.base.off": "该服务已禁用。XiControl 不需要它;如果你使用小米账号或小米自带的应用,可以重新启用。",
"settings.drivers.oem.base.missing": "未找到该服务——XiControl 不需要它,没有它一切照常工作。",
"settings.drivers.oem.noaccess": "无法读取服务状态——详情见日志。",
"settings.drivers.oem.noaccess": "Windows 不允许读取该服务的状态:它受保护。手动禁用的方法见“关于该服务”。",
"settings.drivers.oem.unknown": "无法读取服务状态——详情见日志(log.txt)。",
"settings.drivers.oem.v.missing": "未安装",
"settings.drivers.oem.v.noaccess": "无权访问",
"settings.drivers.oem.v.unknown": "未能读取",
"settings.drivers.oem.disable": "禁用",
"settings.drivers.oem.disable.confirm": "确定禁用?",
"settings.drivers.oem.enable": "启用",
Expand Down
4 changes: 3 additions & 1 deletion src/SystemIntegration/ChargeGuard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ public void Reapply(string reason, bool hurry = false)
int pct = _limitWanted();
if (pct >= 100) return;

int? now = _mifs.GetChargeLimit();
// Перед сном не спрашиваем: времени нет, а чтение — лишний вызов WMI ровно в этом окне
// (XIC-101). Риска нет: в спешке пишем одной командой без сброса в «выкл».
int? now = hurry ? null : _mifs.GetChargeLimit();
if (now == pct) return; // EC уже держит нужный порог — молча и не трогая

if (!_mifs.SetChargeLimit(pct, resetFirst: !hurry))
Expand Down
28 changes: 26 additions & 2 deletions src/SystemIntegration/HttpApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,34 @@ private async Task Serve(HttpListenerContext ctx, CancellationToken ct)
ctx.Response.StatusCode = code;
ctx.Response.ContentType = "application/json";
ctx.Response.ContentLength64 = buf.Length;
await ctx.Response.OutputStream.WriteAsync(buf, ct).AsTask().WaitAsync(ct).ConfigureAwait(false);
await Bounded(ctx.Response.OutputStream.WriteAsync(buf, ct).AsTask(), ct).ConfigureAwait(false);
ctx.Response.Close();
}

/// <summary>
/// Ждать операцию ввода-вывода не дольше <paramref name="ct"/>. Поток http.sys отмену не
/// слушает, поэтому по дедлайну мы бросаем ожидание, а сама операция остаётся висеть и потом
/// падает (сокет закрыл вызывающий). Без наблюдателя её исключение всплыло бы в
/// UnobservedTaskException и легло в log.txt «необработанным» — побочный эффект дедлайна,
/// а не сбой (XIC-101). Поэтому брошенную операцию наблюдаем и глотаем.
/// </summary>
internal static async Task<T> Bounded<T>(Task<T> io, CancellationToken ct)
{
try { return await io.WaitAsync(ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (!io.IsCompleted) { Observe(io); throw; }
}

/// <inheritdoc cref="Bounded{T}(Task{T}, CancellationToken)"/>
internal static async Task Bounded(Task io, CancellationToken ct)
{
try { await io.WaitAsync(ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (!io.IsCompleted) { Observe(io); throw; }
}

private static void Observe(Task io) => _ = io.ContinueWith(static t => _ = t.Exception,
CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);

internal enum BodyError { None, TooLarge, BadUtf8 }

/// <summary>
Expand All @@ -115,7 +139,7 @@ internal enum BodyError { None, TooLarge, BadUtf8 }
int count = 0;
while (count < buf.Length)
{
int read = await input.ReadAsync(buf.AsMemory(count), ct).AsTask().WaitAsync(ct).ConfigureAwait(false);
int read = await Bounded(input.ReadAsync(buf.AsMemory(count), ct).AsTask(), ct).ConfigureAwait(false);
if (read == 0) break;
count += read;
}
Expand Down
6 changes: 6 additions & 0 deletions src/SystemIntegration/LatestValue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ public T Fresh(long maxAgeMs, T stale)
return _at != long.MinValue && _now() - _at <= maxAgeMs ? _latest : stale;
}

/// <summary>Чтение в полёте (или завершённая задача, если его нет). Владелец источника ждёт
/// его перед тем, как освободить то, что читает <c>read</c>: иначе чтение на пуле падает на
/// уже освобождённом объекте и пишет в журнал ложный ObjectDisposedException (XIC-101).
/// Не бросает — ReadOnce исключения глотает.</summary>
public Task Pending { get { lock (_gate) return _pending ?? Task.CompletedTask; } }

/// <summary>Хоть одно чтение уже завершилось удачно.</summary>
public bool HasValue { get { lock (_gate) return _at != long.MinValue; } }

Expand Down
13 changes: 10 additions & 3 deletions src/SystemIntegration/OemService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@

namespace XiControl.SystemIntegration;

/// <summary>Что видно о службе Xiaomi MiDeviceService.</summary>
public enum OemServiceState { NotInstalled, Running, Stopped, Disabled, NoAccess }
/// <summary>Что видно о службе Xiaomi MiDeviceService. NoAccess — Windows отказала в доступе;
/// Unknown — прочие сбои чтения (SCM недоступен, RPC), их с правами не путаем (XIC-101).</summary>
public enum OemServiceState { NotInstalled, Running, Stopped, Disabled, NoAccess, Unknown }

/// <summary>Тип запуска службы (то, что запоминаем перед отключением и возвращаем потом).</summary>
public enum OemStartType { Unknown, Auto, DelayedAuto, Manual, Disabled }
Expand Down Expand Up @@ -59,7 +60,9 @@ public static OemServiceInfo Read(string name)
catch (Exception ex)
{
Log.Ex("OemService.Read " + name, ex);
return new(OemServiceState.NoAccess, OemStartType.Unknown);
// «нет доступа» — только когда Windows так и сказала: иначе человек зря пойдёт
// перезапускать от администратора, хотя причина в недоступном SCM (XIC-101)
return new(StateOnFailure(ex), OemStartType.Unknown);
}
}

Expand Down Expand Up @@ -107,6 +110,10 @@ internal static OemStartType RestoreTarget(string? saved) =>
internal static bool ShouldRemember(OemStartType current) =>
current is OemStartType.Auto or OemStartType.DelayedAuto or OemStartType.Manual;

/// <summary>Чем показать сбой чтения: «нет доступа» — только если Windows так и сказала.</summary>
internal static OemServiceState StateOnFailure(Exception ex) =>
ex is Win32Exception { NativeErrorCode: ERROR_ACCESS_DENIED } ? OemServiceState.NoAccess : OemServiceState.Unknown;

/// <summary>Состояние для показа: работающая — «запущена» при любом типе запуска (отключение
/// могло не остановить её), стоящая с Disabled — «отключена».</summary>
internal static OemServiceState StateOf(uint scmState, OemStartType start) => scmState switch
Expand Down
41 changes: 34 additions & 7 deletions src/SystemIntegration/PowerModeSync.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,15 @@
private readonly IPowerOverlay _overlay;
private readonly Func<OemServiceState> _service;

// Что записали мы, по источнику, — в конфиге (AppConfig.PowerOverlayWritten), а не в памяти:
// выключая опцию, откатываем только своё, и это должно пережить перезапуск (XIC-101).
// Выбор человека в параметрах Windows не трогаем. Читается и пишется только под замком.
private readonly object _lock = new();
// что записали мы, по источнику (true — сеть): выключая опцию, откатываем только своё,
// выбор человека в параметрах Windows не трогаем
private readonly Dictionary<bool, Guid> _written = [];
private Skip _lastSkip = Skip.None;

public PowerModeSync(AppConfig cfg, IMifsClient mifs, IPowerEvents power)
: this(cfg, mifs, power, NativeOverlay.Instance,
() => OemService.Read(OemService.DeviceService).State) { }

Check failure on line 56 in src/SystemIntegration/PowerModeSync.cs

View workflow job for this annotation

GitHub Actions / build-test

Fix whitespace formatting. Replace 1 characters with '\r\n\s\s\s\s'.

internal PowerModeSync(AppConfig cfg, IMifsClient mifs, IPowerEvents power,
IPowerOverlay overlay, Func<OemServiceState> service)
Expand Down Expand Up @@ -121,7 +121,7 @@
{
if (_overlay.SetActive(target))
{
_written[online] = target;
Remember(online, target);
Log.Write($"PowerModeSync: {(online ? "сеть" : "батарея")} → {Name(target)}");
}
else skip = Skip.Unavailable;
Expand All @@ -142,10 +142,10 @@
try
{
bool online = _power.IsOnline;
if (!_written.TryGetValue(online, out var w)) return;
if (_cfg.PowerOverlayWritten?.TryGetValue(Key(online), out var w) != true) return;
if (ShouldUndo(w, Actual()) && _overlay.SetActive(Balanced))
Log.Write($"PowerModeSync: выключено, {(online ? "сеть" : "батарея")} → сбалансированный");
_written.Remove(online);
Remember(online, Balanced); // своё откатили (или его уже сменил человек) — забыть
}
catch (Exception ex) { Log.Ex("PowerModeSync.Undo", ex); }
}
Expand All @@ -156,7 +156,34 @@
if (mode is not (PowerModes.Resume or PowerModes.StatusChange)) return;
// включено — сверить новый источник; выключено — доделать откат, если он остался на нём
if (_cfg.PowerModeSync) Request();
else if (_written.Count > 0) Undo();
else
{
bool pending;
lock (_lock) pending = _cfg.PowerOverlayWritten is { Count: > 0 };
if (pending) Undo();
}
}

private static string Key(bool online) => online ? "ac" : "battery";

/// <summary>Запомнить, что стоит нашей записью на источнике. «Сбалансированный» откатывать
/// не нужно — его не храним. Звать под замком. Конфиг пишем только при изменении.</summary>
private void Remember(bool online, Guid overlay)
{
var written = _cfg.PowerOverlayWritten;
bool known = written?.ContainsKey(Key(online)) == true;
if (overlay == Balanced)
{
if (!known) return;
written!.Remove(Key(online));
if (written.Count == 0) _cfg.PowerOverlayWritten = null;
}
else
{
if (known && written![Key(online)] == overlay) return;
(_cfg.PowerOverlayWritten ??= [])[Key(online)] = overlay;
}
_cfg.Save();
}

private PerfMode? Mode()
Expand Down
4 changes: 4 additions & 0 deletions src/Ui/MonitorForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,10 @@ protected override void Dispose(bool disposing)
_tip.Dispose();
_compactMenu?.Dispose();
_battery?.Dispose();
// Чтения на пуле (температура, ватты) могут ещё идти над источниками ниже: дать им
// закончить, не дольше 500 мс — зависший провайдер не держит закрытие (XIC-101).
// Тик уже остановлен — новых чтений не будет.
Task.WhenAll(_tempWmi.Pending, _batteryWmi.Pending, _adapterWmi.Pending).Wait(500);
_tempSrc.Dispose();
_powerDraw.Dispose();
_gpuTel.Dispose();
Expand Down
Loading
Loading