Files
Alex Cube 9e25f4de5d Значок: icon.ico вшивается в exe
csc собирался без /win32icon, поэтому у exe не было значка вовсе, а окно
пыталось сделать значок из icon.png через GetHicon — тот теряет альфу
и не даёт нужных размеров. Теперь многоразмерный icon.ico вшит в exe,
а окно берёт значок из себя же.
2026-08-26 16:51:47 +03:00

333 lines
12 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Окно с индикаторами вместо чёрной консоли.
//
// Запускает `node server.mjs` дочерним процессом, показывает его вывод,
// сворачивается в трей и красит значок по снимку из GET /api/state.
// Собирается компилятором из состава Windows — см. build.cmd, ставить нечего.
//
// Важное про снимок: state обновляет сторож, раз в watchdogSec секунд.
// Поэтому смотреть надо на поле checked, а не только на ready: иначе окно
// покажет «всё хорошо» через секунду после того, как браузер убили.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.IO;
using System.Net;
using System.Text;
using System.Threading;
using System.Web.Script.Serialization;
using System.Windows.Forms;
class Tray : Form
{
const string MUTEX = "deepseek-api-tray";
const string SHOW = "deepseek-api-tray-show";
static Mutex only;
static EventWaitHandle wake;
[STAThread]
static void Main()
{
bool first;
only = new Mutex(true, MUTEX, out first);
wake = new EventWaitHandle(false, EventResetMode.AutoReset, SHOW);
// Второй экземпляр не поднимает сервер (иначе EADDRINUSE), а просто
// разворачивает окно первого и уходит.
if (!first) { wake.Set(); return; }
Application.EnableVisualStyles();
Application.Run(new Tray());
}
readonly string here = AppDomain.CurrentDomain.BaseDirectory;
readonly TextBox log = new TextBox();
readonly NotifyIcon tray = new NotifyIcon();
readonly System.Windows.Forms.Timer poll = new System.Windows.Forms.Timer();
readonly JavaScriptSerializer json = new JavaScriptSerializer();
Process node;
int port;
string lastError;
bool quitting;
Tray()
{
port = ReadPort();
Text = "deepseek-api";
Width = 900; Height = 520;
StartPosition = FormStartPosition.CenterScreen;
log.Multiline = true;
log.ReadOnly = true;
log.ScrollBars = ScrollBars.Vertical;
log.Dock = DockStyle.Fill;
log.BackColor = Color.FromArgb(20, 20, 20);
log.ForeColor = Color.Gainsboro;
log.Font = new Font("Consolas", 9f);
log.BorderStyle = BorderStyle.None;
Controls.Add(log);
var menu = new ContextMenuStrip();
menu.Items.Add("Показать окно", null, (s, e) => Restore());
menu.Items.Add("Выход", null, (s, e) => { quitting = true; Close(); });
tray.ContextMenuStrip = menu;
tray.Visible = true;
tray.DoubleClick += (s, e) => Restore();
// Значок окна берём из самого exe: icon.ico вшит в него при сборке
// (/win32icon), поэтому рядом ничего лежать не обязано. Значок в трее
// всё равно рисуется цветом состояния, там картинке места нет.
try { Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath); }
catch { Icon = Dot(Color.Gray); }
Mark(Color.Gray, "запуск…");
poll.Interval = 1000;
poll.Tick += Look;
poll.Start();
// Второй экземпляр стучится через именованное событие; ждём его
// в фоне и разворачиваемся.
var waiter = new Thread(() =>
{
while (wake.WaitOne()) BeginInvoke((Action)Restore);
});
waiter.IsBackground = true;
waiter.Start();
Start();
}
// -------------------------------------------------------------- сервер
void Start()
{
var si = new ProcessStartInfo("node", "\"" + Path.Combine(here, "server.mjs") + "\"");
si.WorkingDirectory = here;
si.UseShellExecute = false;
si.CreateNoWindow = true;
si.RedirectStandardOutput = true;
si.RedirectStandardError = true;
si.StandardOutputEncoding = Encoding.UTF8;
si.StandardErrorEncoding = Encoding.UTF8;
// Труба stdin — поводок: закроется вместе с нами, и node уйдёт сам,
// даже если окно снимут диспетчером задач и OnFormClosed не сработает.
si.RedirectStandardInput = true;
si.EnvironmentVariables["DEEPSEEK_TRAY"] = "1";
node = new Process();
node.StartInfo = si;
node.EnableRaisingEvents = true;
node.OutputDataReceived += (s, e) => Say(e.Data);
node.ErrorDataReceived += (s, e) => Say(e.Data);
node.Exited += (s, e) => Say("[процесс node завершился]");
try
{
node.Start();
node.BeginOutputReadLine();
node.BeginErrorReadLine();
}
catch (Exception e)
{
Say("Не удалось запустить node: " + e.Message);
Say("Node должен быть в PATH. Проверка: node --version");
node = null;
}
}
bool Alive { get { return node != null && !node.HasExited; } }
void Say(string line)
{
if (line == null) return;
if (InvokeRequired) { BeginInvoke((Action<string>)Say, line); return; }
// Хвоста в четверть мегабайта хватает с запасом; дальше режем,
// иначе за сутки работы окно съест память на одном логе.
if (log.TextLength > 250000) log.Text = log.Text.Substring(log.TextLength / 2);
log.AppendText(line + Environment.NewLine);
}
// -------------------------------------------------------------- снимок
async void Look(object sender, EventArgs e)
{
if (!Alive) { Mark(Color.Gray, "node не запущен"); return; }
string body;
try
{
using (var web = new WebClient())
{
web.Encoding = Encoding.UTF8;
body = await web.DownloadStringTaskAsync(
"http://127.0.0.1:" + port + "/api/state");
}
}
catch
{
Mark(Color.Gold, "сервер не отвечает на порту " + port);
return;
}
Dictionary<string, object> s;
try { s = (Dictionary<string, object>)json.DeserializeObject(body); }
catch { Mark(Color.Gold, "непонятный ответ сервера"); return; }
bool browser = Flag(s, "browser"), ready = Flag(s, "ready"), busy = Flag(s, "busy");
int queue = Num(s, "queue"), seconds = Num(s, "seconds");
int watchdogSec = Num(s, "watchdogSec");
string error = Str(s, "error");
double stale = Age(Str(s, "checked"));
if (error != null && error != lastError)
tray.ShowBalloonTip(10000, "deepseek-api", error, ToolTipIcon.Warning);
lastError = error;
// Снимок обновляет сторож, поэтому он всегда чуть отстаёт. Три срока
// сторожа без проверки — уже не «чуть», верить такому нельзя.
bool fresh = watchdogSec <= 0 || (stale >= 0 && stale < watchdogSec * 3);
if (busy)
Mark(Color.DodgerBlue, "запрос идёт " + seconds + " с" + Waiting(queue));
else if (!fresh)
Mark(Color.Gray, "снимок устарел на " + (int)stale + " с — сторож молчит");
else if (error != null)
Mark(Color.Firebrick, error);
else if (ready)
Mark(Color.MediumSeaGreen, "готов, порт " + port + Waiting(queue));
else if (browser)
Mark(Color.Gold, "браузер есть, вкладка с чатом — нет");
else
Mark(Color.Firebrick, "браузер не отвечает");
}
static string Waiting(int queue)
{
return queue > 1 ? "; в очереди " + (queue - 1) : "";
}
// Возраст отметки времени в секундах; -1, если отметки нет.
static double Age(string iso)
{
DateTime t;
if (iso == null || !DateTime.TryParse(iso, null,
System.Globalization.DateTimeStyles.AdjustToUniversal |
System.Globalization.DateTimeStyles.AssumeUniversal, out t)) return -1;
return (DateTime.UtcNow - t).TotalSeconds;
}
static bool Flag(Dictionary<string, object> d, string k)
{
object v;
return d.TryGetValue(k, out v) && v is bool && (bool)v;
}
static int Num(Dictionary<string, object> d, string k)
{
object v;
if (!d.TryGetValue(k, out v) || v == null) return 0;
try { return Convert.ToInt32(v); } catch { return 0; }
}
static string Str(Dictionary<string, object> d, string k)
{
object v;
return d.TryGetValue(k, out v) ? v as string : null;
}
// --------------------------------------------------------------- значок
readonly Dictionary<int, Icon> icons = new Dictionary<int, Icon>();
Icon Dot(Color c)
{
Icon icon;
if (icons.TryGetValue(c.ToArgb(), out icon)) return icon;
using (var bmp = new Bitmap(16, 16))
{
using (var g = Graphics.FromImage(bmp))
{
g.SmoothingMode = SmoothingMode.AntiAlias;
using (var b = new SolidBrush(c)) g.FillEllipse(b, 1, 1, 14, 14);
using (var p = new Pen(Color.FromArgb(120, 0, 0, 0))) g.DrawEllipse(p, 1, 1, 14, 14);
}
icon = Icon.FromHandle(bmp.GetHicon());
}
icons[c.ToArgb()] = icon;
return icon;
}
void Mark(Color c, string hint)
{
tray.Icon = Dot(c);
// Подсказка трея обрывается на 63 символах — длинную ошибку режем сами.
string text = "deepseek-api: " + hint;
tray.Text = text.Length > 62 ? text.Substring(0, 59) + "..." : text;
Text = "deepseek-api — " + hint;
}
// ----------------------------------------------------------------- окно
void Restore()
{
Show();
WindowState = FormWindowState.Normal;
Activate();
}
protected override void OnResize(EventArgs e)
{
base.OnResize(e);
if (WindowState == FormWindowState.Minimized) Hide();
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
// Крестик прячет в трей; выход — только из меню значка. Иначе легко
// закрыть окно, думая, что сервис остановлен, а он остался работать.
if (!quitting && e.CloseReason == CloseReason.UserClosing)
{
e.Cancel = true;
Hide();
return;
}
base.OnFormClosing(e);
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
poll.Stop();
tray.Visible = false;
// Закрытая консоль иногда оставляла node висеть — теперь убиваем явно.
// Браузер не трогаем: он живёт своей жизнью и нужен человеку.
try { if (Alive) node.Kill(); } catch { }
base.OnFormClosed(e);
}
// ----------------------------------------------------------------- порт
int ReadPort()
{
int p = 11500;
foreach (var name in new[] { "config.json", "config.local.json" })
{
var path = Path.Combine(here, name);
if (!File.Exists(path)) continue;
try
{
var d = (Dictionary<string, object>)json.DeserializeObject(
File.ReadAllText(path, Encoding.UTF8));
if (d != null && d.ContainsKey("port")) p = Convert.ToInt32(d["port"]);
}
catch { }
}
var env = Environment.GetEnvironmentVariable("DEEPSEEK_API_PORT");
int fromEnv;
if (env != null && int.TryParse(env, out fromEnv)) p = fromEnv;
return p;
}
}