igneum/app/windows/host.cpp
igneum-josh 83a293f2c9 Igneum Miner UI 2: six sections behind a rail (Mine, Prove, Rewards, Node, Updates, Settings)
The one long page becomes a rail with six sections, a thin top bar and the status strip under it; the setup
screens and the key sheet stay. Mine: one big start/stop, the numbers, one row per GPU with its switch, hash
rate, temperature and power. Prove: the switch with plain words, the counts, the verifier, the pinned ids.
Rewards: the address with one Copy, the key backup card, another address. Node: the plain lines, the consensus
digest, the next switch. Updates: the version, the jobs. Settings: one switch or slider per setting with one line
of help. Every function that existed is placed, none removed.

Engine (three small additions, each with a unit test): node.consensus_digest from the node's own line,
node.consensus_switches from the override file, proving.program_id and aggregator_id from the host's --mode id.
Hosts: the window minimum is 900 x 600 on both. UI tests: view.test.mjs (10) joins notices and update-card in CI.
Proof: docs/plans/miner-ui-2.md and the 19 screenshots under docs/plans/miner-ui-2/, taken from a build of this
tree running on its own port against the devnet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 20:06:27 +01:00

461 lines
20 KiB
C++

// Igneum Miner for Windows: the window around the engine. One C++ file, built on the PC by BUILD-APP.bat
// (MSVC, the WebView2 SDK from NuGet, static loader). It starts igneum-app.exe --wrapper next to it, reads
// "URL ..." / "STATE {...}" / "EXIT" from its stdout, shows the URL in a WebView2 control, keeps a tray icon with the
// state, and on quit writes "quit" to the engine's stdin and waits for EXIT (miners stop first, then the node).
// Closing the window hides it to the tray; the miner keeps running. If the WebView2 runtime is missing the dashboard
// opens in the default browser instead and the window says so. 4 October 2026.
//
// Untested on a real PC at the time of writing (written on a Mac): BUILD-APP.bat is the first run.
#define WIN32_LEAN_AND_MEAN
#ifndef UNICODE
#define UNICODE
#endif
#ifndef _UNICODE
#define _UNICODE
#endif
#include <windows.h>
#include <shellapi.h>
#include <wrl.h>
#include <string>
#include <vector>
#include <thread>
#include <mutex>
#include "WebView2.h"
#include "version.h"
using namespace Microsoft::WRL;
#define WM_ENGINE_LINE (WM_APP + 1)
#define WM_TRAY (WM_APP + 2)
#define ID_TRAY_OPEN 1001
#define ID_TRAY_PAUSE 1002
#define ID_TRAY_QUIT 1003
#define ID_QUIT_TIMER 7
#define IDI_APP 1
static HWND g_hwnd = nullptr;
static HANDLE g_engine = nullptr, g_engineIn = nullptr, g_engineOut = nullptr;
static ComPtr<ICoreWebView2Controller> g_controller;
static ComPtr<ICoreWebView2> g_webview;
static std::wstring g_url, g_status = L"starting the engine";
static bool g_paused = false, g_quitting = false, g_exited = false, g_webviewOk = false, g_hintShown = false;
static NOTIFYICONDATAW g_nid = {};
static std::wstring g_trayTitle = L"Igneum Miner";
static ULONGLONG g_quitStarted = 0;
static std::wstring widen(const std::string& s) {
if (s.empty()) return L"";
int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0);
std::wstring w(n, 0);
MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), &w[0], n);
return w;
}
static std::wstring exeDir() {
wchar_t buf[MAX_PATH];
GetModuleFileNameW(nullptr, buf, MAX_PATH);
std::wstring p(buf);
size_t i = p.find_last_of(L"\\/");
return i == std::wstring::npos ? L"." : p.substr(0, i);
}
static void sendEngine(const char* line) {
if (!g_engineIn) return;
DWORD w = 0;
WriteFile(g_engineIn, line, (DWORD)strlen(line), &w, nullptr);
WriteFile(g_engineIn, "\n", 1, &w, nullptr);
}
static void setTray(const std::wstring& tip) {
g_trayTitle = tip;
wcsncpy_s(g_nid.szTip, tip.c_str(), _TRUNCATE);
Shell_NotifyIconW(NIM_MODIFY, &g_nid);
}
static void repaintStatus() {
InvalidateRect(g_hwnd, nullptr, TRUE);
}
// A tiny JSON number/string/bool reader for the STATE line (flat object, known keys).
static std::string jsonField(const std::string& j, const char* key) {
std::string k = std::string("\"") + key + "\":";
size_t i = j.find(k);
if (i == std::string::npos) return "";
i += k.size();
while (i < j.size() && j[i] == ' ') i++;
if (i < j.size() && j[i] == '"') {
size_t e = j.find('"', i + 1);
return e == std::string::npos ? "" : j.substr(i + 1, e - i - 1);
}
size_t e = i;
while (e < j.size() && j[e] != ',' && j[e] != '}') e++;
return j.substr(i, e - i);
}
static void applyState(const std::string& j) {
std::string mining = jsonField(j, "mining"), node = jsonField(j, "node"), phase = jsonField(j, "phase");
g_paused = jsonField(j, "paused") == "true";
double hash = atof(jsonField(j, "hash_total").c_str());
std::string blocks = jsonField(j, "blocks"), accepted = jsonField(j, "accepted_total");
wchar_t tip[128];
if (jsonField(j, "quitting") == "true") swprintf_s(tip, L"Igneum Miner: stopping");
else if (phase != "dashboard") swprintf_s(tip, L"Igneum Miner: set up");
else if (mining == "mining") swprintf_s(tip, L"Igneum Miner: %.1f MH/s, %S blocks found, node %S", hash, accepted.c_str(), node.c_str());
else if (mining == "paused") swprintf_s(tip, L"Igneum Miner: paused, node %S", node.c_str());
else swprintf_s(tip, L"Igneum Miner: %S, node %S (%S blocks)", mining.c_str(), node.c_str(), blocks.c_str());
setTray(tip);
}
// The engine asks for an elevated step (the NVIDIA power cap): this process has a UI context, so the UAC prompt shows.
// Runs cmd /c <line> as administrator, waits, and answers on the engine's stdin.
static void runElevated(std::wstring line) {
std::thread([line] {
std::wstring params = L"/c " + line;
wchar_t sysdir[MAX_PATH];
GetSystemDirectoryW(sysdir, MAX_PATH);
std::wstring cmdExe = std::wstring(sysdir) + L"\\cmd.exe"; // the absolute path, never a bare name (R4.3.3)
SHELLEXECUTEINFOW sei = { sizeof(sei) };
sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI;
sei.lpVerb = L"runas";
sei.lpFile = cmdExe.c_str();
sei.lpParameters = params.c_str();
sei.nShow = SW_HIDE;
if (!ShellExecuteExW(&sei) || !sei.hProcess) {
DWORD err = GetLastError();
sendEngine(err == ERROR_CANCELLED ? "elevated fail: the administrator prompt was cancelled" : "elevated fail: could not start the elevated step");
return;
}
WaitForSingleObject(sei.hProcess, 120000);
DWORD code = 1;
GetExitCodeProcess(sei.hProcess, &code);
CloseHandle(sei.hProcess);
if (code == 0) sendEngine("elevated ok");
else { char buf[64]; sprintf_s(buf, "elevated fail: exit code %lu", code); sendEngine(buf); }
}).detach();
}
static void openInBrowser(const std::wstring& url) {
ShellExecuteW(nullptr, L"open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
}
static void navigate() {
if (g_webview && !g_url.empty()) g_webview->Navigate((g_url + L"?host=windows").c_str());
}
static void initWebView() {
std::wstring data = L"";
wchar_t* local = nullptr;
size_t len = 0;
if (_wdupenv_s(&local, &len, L"LOCALAPPDATA") == 0 && local) { data = std::wstring(local) + L"\\igneum\\webview2"; free(local); }
HRESULT hr = CreateCoreWebView2EnvironmentWithOptions(nullptr, data.empty() ? nullptr : data.c_str(), nullptr,
Callback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>([](HRESULT result, ICoreWebView2Environment* env) -> HRESULT {
if (FAILED(result) || !env) {
g_status = L"The WebView2 runtime is not installed. The dashboard opens in your browser; install the runtime from microsoft.com for the app window.";
repaintStatus();
if (!g_url.empty()) { openInBrowser(g_url); g_hintShown = true; }
return S_OK;
}
env->CreateCoreWebView2Controller(g_hwnd, Callback<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>([](HRESULT result, ICoreWebView2Controller* controller) -> HRESULT {
if (FAILED(result) || !controller) {
g_status = L"The app window could not start WebView2. The dashboard opens in your browser.";
repaintStatus();
if (!g_url.empty()) { openInBrowser(g_url); g_hintShown = true; }
return S_OK;
}
g_controller = controller;
g_controller->get_CoreWebView2(&g_webview);
g_webviewOk = true;
ComPtr<ICoreWebView2Settings> settings;
if (g_webview && SUCCEEDED(g_webview->get_Settings(&settings)) && settings) {
settings->put_AreDefaultContextMenusEnabled(FALSE);
settings->put_IsStatusBarEnabled(FALSE);
settings->put_AreDevToolsEnabled(FALSE);
}
// links off 127.0.0.1 open in the default browser
g_webview->add_NewWindowRequested(Callback<ICoreWebView2NewWindowRequestedEventHandler>([](ICoreWebView2*, ICoreWebView2NewWindowRequestedEventArgs* args) -> HRESULT {
LPWSTR uri = nullptr;
if (SUCCEEDED(args->get_Uri(&uri)) && uri) { openInBrowser(uri); CoTaskMemFree(uri); }
args->put_Handled(TRUE);
return S_OK;
}).Get(), nullptr);
g_webview->add_NavigationStarting(Callback<ICoreWebView2NavigationStartingEventHandler>([](ICoreWebView2*, ICoreWebView2NavigationStartingEventArgs* args) -> HRESULT {
LPWSTR uri = nullptr;
if (SUCCEEDED(args->get_Uri(&uri)) && uri) {
std::wstring u(uri);
CoTaskMemFree(uri);
if (u.rfind(L"http", 0) == 0 && u.find(L"127.0.0.1") == std::wstring::npos) { args->put_Cancel(TRUE); openInBrowser(u); }
}
return S_OK;
}).Get(), nullptr);
RECT rc; GetClientRect(g_hwnd, &rc);
g_controller->put_Bounds(rc);
COREWEBVIEW2_COLOR dark = { 255, 12, 12, 14 };
ComPtr<ICoreWebView2Controller2> c2;
if (SUCCEEDED(g_controller.As(&c2)) && c2) c2->put_DefaultBackgroundColor(dark);
g_status = L"";
repaintStatus();
navigate();
return S_OK;
}).Get());
return S_OK;
}).Get());
if (FAILED(hr)) {
g_status = L"The WebView2 runtime is not installed. The dashboard opens in your browser; install the runtime from microsoft.com for the app window.";
repaintStatus();
if (!g_url.empty()) { openInBrowser(g_url); g_hintShown = true; }
}
}
static bool startEngine() {
SECURITY_ATTRIBUTES sa = { sizeof(sa), nullptr, TRUE };
HANDLE outR, outW, inR, inW;
if (!CreatePipe(&outR, &outW, &sa, 0) || !CreatePipe(&inR, &inW, &sa, 0)) return false;
SetHandleInformation(outR, HANDLE_FLAG_INHERIT, 0);
SetHandleInformation(inW, HANDLE_FLAG_INHERIT, 0);
STARTUPINFOW si = { sizeof(si) };
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdOutput = outW;
si.hStdError = GetStdHandle(STD_ERROR_HANDLE);
si.hStdInput = inR;
PROCESS_INFORMATION pi = {};
std::wstring exe = exeDir() + L"\\igneum-app.exe";
std::wstring cmd = L"\"" + exe + L"\" --wrapper";
std::vector<wchar_t> buf(cmd.begin(), cmd.end());
buf.push_back(0);
BOOL ok = CreateProcessW(exe.c_str(), buf.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, exeDir().c_str(), &si, &pi);
CloseHandle(outW);
CloseHandle(inR);
if (!ok) { CloseHandle(outR); CloseHandle(inW); return false; }
CloseHandle(pi.hThread);
g_engine = pi.hProcess;
g_engineIn = inW;
g_engineOut = outR;
std::thread([] {
std::string acc;
char chunk[4096];
DWORD n;
while (ReadFile(g_engineOut, chunk, sizeof(chunk), &n, nullptr) && n > 0) {
acc.append(chunk, n);
size_t nl;
while ((nl = acc.find('\n')) != std::string::npos) {
std::string line = acc.substr(0, nl);
acc.erase(0, nl + 1);
if (!line.empty() && line.back() == '\r') line.pop_back();
PostMessageW(g_hwnd, WM_ENGINE_LINE, 0, (LPARAM) new std::string(line));
}
}
PostMessageW(g_hwnd, WM_ENGINE_LINE, 1, 0);
}).detach();
return true;
}
static void showTrayMenu() {
HMENU m = CreatePopupMenu();
AppendMenuW(m, MF_STRING, ID_TRAY_OPEN, L"Open Igneum Miner");
AppendMenuW(m, MF_STRING, ID_TRAY_PAUSE, g_paused ? L"Resume mining" : L"Pause mining");
AppendMenuW(m, MF_SEPARATOR, 0, nullptr);
AppendMenuW(m, MF_STRING | MF_GRAYED, 0, g_trayTitle.c_str());
AppendMenuW(m, MF_SEPARATOR, 0, nullptr);
AppendMenuW(m, MF_STRING, ID_TRAY_QUIT, L"Quit Igneum Miner");
POINT pt; GetCursorPos(&pt);
SetForegroundWindow(g_hwnd);
TrackPopupMenu(m, TPM_RIGHTBUTTON | TPM_BOTTOMALIGN, pt.x, pt.y, 0, g_hwnd, nullptr);
DestroyMenu(m);
}
static void beginQuit() {
if (g_quitting) return;
g_quitting = true;
g_quitStarted = GetTickCount64();
g_status = L"Stopping: the miner first, then the node";
setTray(L"Igneum Miner: stopping");
if (g_webview) g_webview->ExecuteScript(L"document.getElementById('pill-text').textContent='stopping'", nullptr);
if (g_engine && !g_exited) {
sendEngine("quit");
SetTimer(g_hwnd, ID_QUIT_TIMER, 500, nullptr);
} else {
DestroyWindow(g_hwnd);
}
}
static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
switch (msg) {
case WM_SIZE:
if (g_controller) { RECT rc; GetClientRect(hwnd, &rc); g_controller->put_Bounds(rc); }
return 0;
case WM_GETMINMAXINFO: // the dashboard lays out from 900 x 600 up (app/igneum-app/ui); the Mac window says the same
((MINMAXINFO*)lp)->ptMinTrackSize.x = 900; ((MINMAXINFO*)lp)->ptMinTrackSize.y = 600;
return 0;
case WM_ENGINE_LINE: {
if (wp == 1) {
g_exited = true;
if (g_quitting) { DestroyWindow(hwnd); return 0; }
g_status = L"The engine stopped. Close this window and open Igneum Miner again.";
setTray(L"Igneum Miner: stopped");
repaintStatus();
return 0;
}
std::string* line = (std::string*)lp;
if (line->rfind("URL ", 0) == 0) {
g_url = widen(line->substr(4));
if (g_webviewOk) navigate();
else if (!g_webview && g_status.find(L"WebView2") != std::wstring::npos && !g_hintShown) { openInBrowser(g_url); g_hintShown = true; }
} else if (line->rfind("STATE ", 0) == 0) {
applyState(line->substr(6));
} else if (line->rfind("ELEVATE ", 0) == 0) {
runElevated(widen(line->substr(8)));
} else if (line->rfind("FATAL ", 0) == 0) {
g_status = widen(line->substr(6));
repaintStatus();
MessageBoxW(hwnd, g_status.c_str(), L"Igneum Miner", MB_OK | MB_ICONERROR);
} else if (*line == "EXIT") {
g_exited = true;
if (g_quitting) DestroyWindow(hwnd);
}
delete line;
return 0;
}
case WM_TIMER:
if (wp == ID_QUIT_TIMER) {
DWORD code = 0;
bool gone = !g_engine || (GetExitCodeProcess(g_engine, &code) && code != STILL_ACTIVE);
if (gone || g_exited || GetTickCount64() - g_quitStarted > 45000) {
if (!gone && g_engine) TerminateProcess(g_engine, 1);
KillTimer(hwnd, ID_QUIT_TIMER);
DestroyWindow(hwnd);
}
}
return 0;
case WM_TRAY:
if (lp == WM_LBUTTONUP || lp == WM_LBUTTONDBLCLK) { ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); }
else if (lp == WM_RBUTTONUP || lp == WM_CONTEXTMENU) showTrayMenu();
return 0;
case WM_COMMAND:
switch (LOWORD(wp)) {
case ID_TRAY_OPEN: ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); return 0;
case ID_TRAY_PAUSE: sendEngine(g_paused ? "resume" : "pause"); return 0;
case ID_TRAY_QUIT: beginQuit(); return 0;
}
return 0;
case WM_CLOSE:
// hide to the tray; the miner keeps running
ShowWindow(hwnd, SW_HIDE);
if (!g_hintShown) {
g_nid.uFlags |= NIF_INFO;
wcscpy_s(g_nid.szInfoTitle, L"Igneum Miner keeps mining");
wcscpy_s(g_nid.szInfo, L"The window is in the tray. Right-click the icon to pause or quit.");
g_nid.dwInfoFlags = NIIF_INFO;
Shell_NotifyIconW(NIM_MODIFY, &g_nid);
g_nid.uFlags &= ~NIF_INFO;
g_hintShown = true;
}
return 0;
case WM_PAINT: {
PAINTSTRUCT ps;
HDC dc = BeginPaint(hwnd, &ps);
RECT rc; GetClientRect(hwnd, &rc);
HBRUSH bg = CreateSolidBrush(RGB(12, 12, 14));
FillRect(dc, &rc, bg);
DeleteObject(bg);
if (!g_status.empty()) {
SetBkMode(dc, TRANSPARENT);
SetTextColor(dc, RGB(154, 154, 158));
HFONT f = CreateFontW(-15, 0, 0, 0, FW_NORMAL, 0, 0, 0, DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, L"Segoe UI");
HFONT old = (HFONT)SelectObject(dc, f);
RECT tr = rc; tr.left += 40; tr.right -= 40;
DrawTextW(dc, g_status.c_str(), -1, &tr, DT_CENTER | DT_VCENTER | DT_WORDBREAK | DT_NOPREFIX | (g_webviewOk ? DT_BOTTOM : DT_VCENTER));
SelectObject(dc, old);
DeleteObject(f);
}
EndPaint(hwnd, &ps);
return 0;
}
case WM_DESTROY:
Shell_NotifyIconW(NIM_DELETE, &g_nid);
PostQuitMessage(0);
return 0;
}
return DefWindowProcW(hwnd, msg, wp, lp);
}
// --version and --help print one line and exit, for the CI smoke run and support scripts. A windows-subsystem exe has
// no console of its own: the text goes to the inherited stdout when there is one (a pipe or a file), else to the
// parent's console.
static bool handleCliFlags() {
int argc = 0;
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
if (!argv) return false;
std::wstring text;
for (int i = 1; i < argc; i++) {
std::wstring a = argv[i];
if (a == L"--version" || a == L"-V") text = L"Igneum Miner " IGNEUM_HOST_VERSION_STR L" (window host)\r\n";
else if (a == L"--help" || a == L"-h" || a == L"/?")
text = L"Igneum Miner " IGNEUM_HOST_VERSION_STR L" (window host)\r\n"
L"Usage: \"Igneum Miner.exe\" [--version | --help]\r\n"
L"Without flags it starts igneum-app.exe --wrapper next to it and shows the dashboard in a WebView2 window.\r\n";
}
LocalFree(argv);
if (text.empty()) return false;
HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE);
if (out == nullptr || out == INVALID_HANDLE_VALUE) {
if (AttachConsole(ATTACH_PARENT_PROCESS)) out = GetStdHandle(STD_OUTPUT_HANDLE);
}
if (out && out != INVALID_HANDLE_VALUE) {
int n = WideCharToMultiByte(CP_UTF8, 0, text.c_str(), (int)text.size(), nullptr, 0, nullptr, nullptr);
std::string utf8(n, 0);
WideCharToMultiByte(CP_UTF8, 0, text.c_str(), (int)text.size(), &utf8[0], n, nullptr, nullptr);
DWORD written = 0;
WriteFile(out, utf8.data(), (DWORD)utf8.size(), &written, nullptr);
}
return true;
}
int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) {
if (handleCliFlags()) return 0;
HANDLE once = CreateMutexW(nullptr, TRUE, L"Local\\IgneumMinerWindow");
if (GetLastError() == ERROR_ALREADY_EXISTS) {
HWND other = FindWindowW(L"IgneumMinerWindow", nullptr);
if (other) { ShowWindow(other, SW_SHOW); SetForegroundWindow(other); }
return 0;
}
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
WNDCLASSW wc = {};
wc.lpfnWndProc = WndProc;
wc.hInstance = hInst;
wc.lpszClassName = L"IgneumMinerWindow";
wc.hIcon = LoadIconW(hInst, MAKEINTRESOURCEW(IDI_APP));
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
wc.hbrBackground = CreateSolidBrush(RGB(12, 12, 14));
RegisterClassW(&wc);
int w = 1120, h = 820;
int sx = (GetSystemMetrics(SM_CXSCREEN) - w) / 2, sy = (GetSystemMetrics(SM_CYSCREEN) - h) / 2;
g_hwnd = CreateWindowExW(0, wc.lpszClassName, L"Igneum Miner", WS_OVERLAPPEDWINDOW, sx, sy, w, h, nullptr, nullptr, hInst, nullptr);
ShowWindow(g_hwnd, SW_SHOW);
g_nid.cbSize = sizeof(g_nid);
g_nid.hWnd = g_hwnd;
g_nid.uID = 1;
g_nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
g_nid.uCallbackMessage = WM_TRAY;
g_nid.hIcon = (HICON)LoadImageW(hInst, MAKEINTRESOURCEW(IDI_APP), IMAGE_ICON, 16, 16, LR_DEFAULTCOLOR);
wcscpy_s(g_nid.szTip, L"Igneum Miner: starting");
Shell_NotifyIconW(NIM_ADD, &g_nid);
if (!startEngine()) {
g_status = L"igneum-app.exe is missing next to this program. Run the installer again.";
repaintStatus();
MessageBoxW(g_hwnd, g_status.c_str(), L"Igneum Miner", MB_OK | MB_ICONERROR);
}
initWebView();
MSG msg;
while (GetMessageW(&msg, nullptr, 0, 0)) {
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
if (g_engine) { CloseHandle(g_engine); }
CloseHandle(once);
CoUninitialize();
return 0;
}