Monopoly — Portage moderne 0.1.0
Portage moderne C/C++23 du Monopoly de 1999
Loading...
Searching...
No Matches
OptionsHelpRuntime.cpp
Go to the documentation of this file.
2#include <SDL3/SDL.h>
3#include <algorithm>
4#include <array>
5#include <atomic>
6#include <thread>
7#include <chrono>
8#include <cstdint>
9#include <cstdio>
10#include <fstream>
11#include <iterator>
12#include <memory>
13#ifdef _WIN32
14#ifndef NOMINMAX
15#define NOMINMAX
16#endif
17#include <windows.h>
18#include <shellapi.h>
19#endif
20namespace monopoly::optionsui
21{
22 namespace
23 {
24 using HelpClock = std::chrono::steady_clock;
25
26 std::string pathUtf8(const std::filesystem::path& path)
27 {
28 const auto text = path.u8string();
29 return {reinterpret_cast<const char*>(text.data()), text.size()};
30 }
31
32#ifndef _WIN32
33 std::string fileUrl(const std::filesystem::path& path)
34 {
35 const auto generic = path.generic_u8string();
36 const std::string text(reinterpret_cast<const char*>(generic.data()), generic.size());
37 std::string url = text.starts_with("//") ? "file:" :
38 text.starts_with('/') ? "file://" : "file:///";
39 constexpr char hex[] = "0123456789ABCDEF";
40 for (const unsigned char byte : text)
41 {
42 if ((byte >= 'a' && byte <= 'z') || (byte >= 'A' && byte <= 'Z') ||
43 (byte >= '0' && byte <= '9') || byte == '/' || byte == ':' ||
44 byte == '-' || byte == '_' || byte == '.' || byte == '~')
45 url.push_back(static_cast<char>(byte));
46 else
47 {
48 url.push_back('%');
49 url.push_back(hex[byte >> 4]);
50 url.push_back(hex[byte & 15]);
51 }
52 }
53 return url;
54 }
55
56#endif
57#ifdef _WIN32
58 struct BrowserDispatch
59 {
60 std::atomic<bool> completed{false};
61 std::string error;
62 };
63 // Main-thread admission guard. Workers own only their result and path;
64 // cancellation/shutdown never joins a potentially stuck shell extension.
65 std::shared_ptr<BrowserDispatch> outstandingBrowserDispatch;
66
67 std::expected<void, std::string> openNativeDocument(const std::filesystem::path& document)
68 {
69 // Microsoft ShellExecuteExW requires a COM apartment for shell verbs.
70 // NOASYNC keeps the worker apartment alive until dispatch completes.
71 const auto initialized = CoInitializeEx(nullptr,
73 if (FAILED(initialized))
74 return std::unexpected("Cannot initialize Full Help browser dispatch: " +
75 std::to_string(static_cast<unsigned long>(initialized)));
76 SHELLEXECUTEINFOW request{};
77 request.cbSize = sizeof(request);
78 request.fMask = SEE_MASK_NOASYNC | SEE_MASK_FLAG_NO_UI;
79 request.lpVerb = L"open";
80 request.lpFile = document.c_str();
81 request.nShow = SW_SHOWNORMAL;
82 const bool opened = ShellExecuteExW(&request) != FALSE;
83 const auto error = opened ? ERROR_SUCCESS : GetLastError();
85 if (!opened) return std::unexpected("Cannot open Full Help in the default browser: " +
86 std::to_string(error));
87 return {};
88 }
89#endif
90
91 struct FullHelpJob
92 {
95#ifdef _WIN32
97#endif
98 std::filesystem::path directory;
99 std::filesystem::path document;
100 HelpClock::time_point deadline;
101 std::uintmax_t maximumHtmlBytes{};
102 std::uint32_t timeoutMilliseconds{};
103 std::function<std::expected<void, std::string>(const std::filesystem::path&)> openDocument;
104#ifdef _WIN32
105 std::shared_ptr<BrowserDispatch> browserDispatch;
106#endif
107 std::string diagnostics;
108 bool exited{};
110
112 {
113 if (process)
114 {
115 if (!exited)
116 {
118 SDL_WaitProcess(process, true, nullptr);
119 }
120#ifdef _WIN32
122#endif
124 }
125 // Delete only these two paths created by this job; never recurse
126 // over a resource root, the preference directory or other exports.
127 if (!retainDocument && !directory.empty())
128 {
129 std::error_code ignored;
130 std::filesystem::remove(document, ignored);
131 std::filesystem::remove(directory, ignored);
132 }
133 }
134
135 std::expected<void, std::string> drainErrors()
136 {
137 std::array<char, 2048> buffer{};
138 for (int read = 0; read < 8; ++read)
139 {
140 const auto bytes = SDL_ReadIO(errors, buffer.data(), buffer.size());
141 diagnostics.append(buffer.data(),
142 std::min(bytes, std::size_t{16384} - diagnostics.size()));
144 return std::unexpected(std::string("Full Help exporter pipe: ") + SDL_GetError());
145 if (bytes == 0) break;
146 }
147 return {};
148 }
149 };
150
151 std::unique_ptr<FullHelpJob> fullHelpJob;
152 std::uint64_t exportSerial{};
153
154 std::expected<void, std::string> createExportDirectory(FullHelpJob& job)
155 {
156 char* raw = SDL_GetPrefPath("Giscolab", "Monopoly");
157 if (!raw)
158 return std::unexpected(std::string("Full Help cache directory: ") + SDL_GetError());
159 const auto root = std::filesystem::u8path(raw) / "full-help";
160 SDL_free(raw);
161 std::error_code error;
162 std::filesystem::create_directories(root, error);
163 if (error) return std::unexpected("Cannot create Full Help cache: " + error.message());
164 const auto stamp = HelpClock::now().time_since_epoch().count();
165 for (unsigned attempt = 0; attempt < 100; ++attempt)
166 {
167 const auto candidate = root /
168 ("export-" + std::to_string(stamp) + "-" + std::to_string(++exportSerial));
169 if (std::filesystem::create_directory(candidate, error))
170 {
171 job.directory = candidate;
172 job.document = candidate / "contents.html";
173 return {};
174 }
175 if (error && error != std::errc::file_exists)
176 return std::unexpected("Cannot create Full Help export: " + error.message());
177 error.clear();
178 }
179 return std::unexpected("Cannot allocate a unique Full Help export directory");
180 }
181 }
182
183 std::expected<std::string, std::string> decodeQuickHelpText(std::string_view bytes)
184 {
185 // UDOpts reads qhelp files as bytes and converts them with mbstowcs.
186 // Their ten western-language assets are not UTF-8 (except ASCII 01/02).
187 // Use deterministic CP1252 on every host instead of its current locale.
188 constexpr std::array<std::uint16_t, 32> highControls{
189 0x20AC, 0, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021,
190 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0, 0x017D, 0,
191 0, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014,
192 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0, 0x017E, 0x0178};
193 std::string result;
194 result.reserve(bytes.size());
195 for (const unsigned char byte : bytes)
196 {
197 if (byte == 0)
198 return std::unexpected("retail quick-help text contains an embedded NUL");
199 const std::uint16_t code = byte >= 0x80 && byte <= 0x9F
200 ? highControls[byte - 0x80] : byte;
201 if (code == 0)
202 return std::unexpected("retail quick-help text contains an undefined Windows-1252 byte");
203 if (code < 0x80) result.push_back(static_cast<char>(code));
204 else if (code < 0x800)
205 {
206 result.push_back(static_cast<char>(0xC0 | (code >> 6)));
207 result.push_back(static_cast<char>(0x80 | (code & 0x3F)));
208 }
209 else
210 {
211 result.push_back(static_cast<char>(0xE0 | (code >> 12)));
212 result.push_back(static_cast<char>(0x80 | ((code >> 6) & 0x3F)));
213 result.push_back(static_cast<char>(0x80 | (code & 0x3F)));
214 }
215 }
216 return result;
217 }
218
219 std::string helpFileName(data::LanguageId language, bool fullHelp)
220 {
221 char name[32]{};
222 std::snprintf(name, sizeof(name), fullHelp ? "mono%02u.hlp" : "qhelp%02u.txt",
223 static_cast<unsigned>(language));
224 return name;
225 }
226
227 std::expected<void, std::string> openQuickHelp(
228 State& state, const data::ResourceSnapshot& resources)
229 {
230 const auto path = resources.paths().resolve(helpFileName(resources.context().language, false));
231 if (!path) return std::unexpected(path.error().detail);
232 std::ifstream file(*path, std::ios::binary);
233 if (!file) return std::unexpected("cannot open retail quick-help file");
234 std::string text((std::istreambuf_iterator<char>(file)), {});
235 if (file.bad()) return std::unexpected("cannot read retail quick-help file");
237 if (!decoded) return std::unexpected(decoded.error());
238 state.quickHelpText = std::move(*decoded);
239 state.quickHelpLines.clear();
240 state.quickHelpFirstLine = 0;
241 state.quickHelpPageOffsets.clear();
242 state.quickHelpPageIndex = 0;
243 state.quickHelpInitialPage = true;
244 state.quickHelpVisible = true;
245 return {};
246 }
247
248 std::expected<void, std::string> openFullHelp(
249 const data::ResourceSnapshot& resources, const FullHelpOptions& options)
250 {
251 if (fullHelpJob) return std::unexpected("A Full Help export is already running");
252#ifdef _WIN32
254 {
255 if (!outstandingBrowserDispatch->completed.load(std::memory_order_acquire))
256 return std::unexpected("A previous Full Help browser request is still pending");
258 }
259#endif
260 if (!options.timeoutMilliseconds || options.maximumHtmlBytes < 32)
261 return std::unexpected("Invalid Full Help export limits");
262 const auto path = resources.paths().resolve(helpFileName(resources.context().language, true));
263 if (!path) return std::unexpected(path.error().detail);
264
265 // Reject missing/truncated/non-HLP inputs before invoking any executable.
266 std::ifstream input(*path, std::ios::binary);
267 std::array<unsigned char, 4> signature{};
268 input.read(reinterpret_cast<char*>(signature.data()), signature.size());
269 if (!input || signature != std::array<unsigned char, 4>{0x3F, 0x5F, 0x03, 0x00})
270 return std::unexpected("Full Help resource is not a readable WinHelp HLP file");
271
272 auto job = std::make_unique<FullHelpJob>();
274 if (!created) return created;
275 job->deadline = HelpClock::now() + std::chrono::milliseconds(options.timeoutMilliseconds);
276 job->maximumHtmlBytes = options.maximumHtmlBytes;
277 job->timeoutMilliseconds = options.timeoutMilliseconds;
278 job->openDocument = options.openDocument;
279 std::string exporter = options.exporterExecutable;
280 if (exporter.empty())
281 {
282 const auto* configured = SDL_getenv("MONOPOLY_WINHLP");
283 exporter = configured && *configured ? configured : "winhlp";
284 }
285 if (exporter.find('\0') != std::string::npos)
286 return std::unexpected("Full Help exporter executable contains an embedded NUL");
287 const std::array<std::string, 6> arguments{
288 exporter, pathUtf8(*path), "--html", pathUtf8(job->document), "--images", "embed"};
289 std::array<const char*, 7> argv{};
290 for (std::size_t index = 0; index < arguments.size(); ++index)
292 const auto properties = SDL_CreateProperties();
293 if (!properties)
294 return std::unexpected(std::string("Full Help process properties: ") + SDL_GetError());
295 bool configured = SDL_SetPointerProperty(properties,
300#ifdef _WIN32
301 configured = configured && SDL_SetBooleanProperty(properties,
303#endif
304 if (configured) job->process = SDL_CreateProcessWithProperties(properties);
305 const std::string processError = job->process ? std::string{} : SDL_GetError();
306 SDL_DestroyProperties(properties);
307 if (!job->process)
308 return std::unexpected("Cannot start the Full Help exporter; install winhlp with HTML/image "
309 "support or set MONOPOLY_WINHLP to its executable: " + processError);
310#ifdef _WIN32
311 // Hidden SDL background processes report a masked exit status. Retain a
312 // query handle so an exporter failure cannot be mistaken for success.
315 job->statusHandle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, static_cast<DWORD>(pid));
316 if (!job->statusHandle)
317 return std::unexpected("Cannot retain Full Help exporter exit status: " +
318 std::to_string(GetLastError()));
319#endif
320 job->errors = static_cast<SDL_IOStream*>(SDL_GetPointerProperty(
322 if (!job->errors) return std::unexpected("Full Help exporter diagnostic pipe unavailable");
323 fullHelpJob = std::move(job);
324 return {};
325 }
326
327 std::optional<std::string> styleFullHelpHtml(std::string_view html, std::uintmax_t maximumBytes)
328 {
329 if(html.size()>maximumBytes || html.find('\0')!=std::string_view::npos) return {};
330 // Validate complete scalar UTF-8 without changing the export's bytes.
331 for(std::size_t i=0;i<html.size();)
332 {
333 const auto first=static_cast<unsigned char>(html[i++]);
334 if(first<0x80) continue;
335 unsigned count{},code{},minimum{};
336 if(first>=0xC2 && first<=0xDF){count=1;code=first&31;minimum=0x80;}
337 else if(first>=0xE0 && first<=0xEF){count=2;code=first&15;minimum=0x800;}
338 else if(first>=0xF0 && first<=0xF4){count=3;code=first&7;minimum=0x10000;}
339 else return {};
340 if(count>html.size()-i) return {};
341 while(count--)
342 {
343 const auto next=static_cast<unsigned char>(html[i++]);
344 if((next&0xC0)!=0x80) return {};
345 code=(code<<6)|(next&63);
346 }
347 if(code<minimum || code>0x10FFFF || (code>=0xD800 && code<=0xDFFF)) return {};
348 }
349 std::string lower(html);
350 std::transform(lower.begin(),lower.end(),lower.begin(),[](unsigned char c){return char(c>='A' && c<='Z'?c+32:c);});
351 const auto head=lower.find("<head>"),endHead=lower.find("</head>"),body=lower.find("<body>");
352 if(head==std::string::npos || endHead==std::string::npos || body==std::string::npos || head>=endHead || endHead>=body ||
353 lower.find("<!doctype html>")==std::string::npos || lower.find("<html")==std::string::npos || lower.find("</html>")==std::string::npos ||
354 lower.find("<meta charset=\"utf-8\">",head)>=endHead || lower.find("<nav>",body)==std::string::npos ||
355 lower.find("</nav>",body)==std::string::npos || lower.find("<section id=\"topic-",body)==std::string::npos ||
356 lower.find("</section>",body)==std::string::npos || lower.find("</body>",body)==std::string::npos ||
357 lower.find("monopoly-modern-help")!=std::string::npos) return {};
358 constexpr std::string_view theme=R"HELP(
359<meta name="viewport" content="width=device-width, initial-scale=1">
360<style id="monopoly-modern-help">
361html{background:#102e30;color:#203536;color-scheme:light;scroll-behavior:auto}
362body{font-family:"Segoe UI",Arial,sans-serif;max-width:72rem;margin:0 auto;padding:clamp(1rem,4vw,3.5rem);line-height:1.65}
363body>h1{color:#f4efdf;font-size:clamp(1.7rem,3vw,2.3rem);font-weight:600;letter-spacing:.02em;margin:0 0 1.4rem;padding:.2rem 0 .2rem 1rem;border-left:4px solid #bc9c59}
364nav{font-size:.95rem;line-height:1.35;background:#163c3e;border:1px solid #887344;border-radius:14px;padding:clamp(1rem,3vw,2rem);margin-bottom:2rem;box-shadow:0 12px 32px #0003}
365nav ul{columns:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.05rem 1.5rem;list-style:none;padding:0;margin:0}
366nav a{display:block;color:#f1e9d3;padding:.12rem .65rem;border-radius:5px;text-decoration:none;overflow-wrap:anywhere}
367nav a:hover{background:#244e4e;color:#fff}
368a{color:#215c60;text-underline-offset:.18em;overflow-wrap:anywhere}
369a:hover{color:#103d40}
370a:focus-visible{outline:3px solid #bc9c59;outline-offset:3px;border-radius:3px}
371section{background:#f5f0e2;border:1px solid #c2ad7a;border-radius:14px;padding:clamp(1rem,3vw,2rem);margin-top:2rem;box-shadow:0 12px 32px #0003;scroll-margin-top:1rem;overflow-wrap:anywhere}
372section:target{border-color:#d4b367}
373.topic-meta{color:#566665;font-size:.85rem}
374.nonscroll{background:#ebe3cf;border-left:3px solid #b79754;border-radius:5px;padding:.65rem 1rem}
375.f0,.f3,.f4,.f6,.f9{font-family:"Segoe UI",Arial,sans-serif}
376.f1,.f2,.f5,.f7,.f8{font-family:"Segoe UI",Arial,sans-serif}
377section p{line-height:1.65;margin-block:.65rem!important}
378table{max-width:100%;border-collapse:collapse}td{border-color:#baa97f;padding:.5rem .7rem}img{max-width:100%;height:auto}
379@media(max-width:640px){nav a{padding:.5rem .65rem}nav ul{grid-template-columns:1fr}body{padding:1rem}section,nav{border-radius:10px;padding:1rem}.f0{font-size:1.6rem}.f3{font-size:1.35rem}}
380@media print{html{background:white}body{max-width:none;padding:0}body>h1{color:#203536}nav,section{box-shadow:none;border-color:#999}a{color:inherit}}
381</style>
382)HELP";
383 if(theme.size()>maximumBytes-html.size()) return {};
384 std::string result(html);result.insert(endHead,theme);return result;
385 }
386
388
390
391 std::expected<bool, std::string> pollFullHelp()
392 {
393 if (!fullHelpJob) return false;
394 auto fail = [](std::string message) -> std::expected<bool, std::string>
395 {
396 fullHelpJob.reset();
397 return std::unexpected(std::move(message));
398 };
399 auto& job = *fullHelpJob;
400#ifdef _WIN32
401 if (job.browserDispatch)
402 {
403 if (!job.browserDispatch->completed.load(std::memory_order_acquire))
404 {
405 if (HelpClock::now() >= job.deadline)
406 return fail("Full Help browser dispatch timed out; the Windows request may still complete");
407 return false;
408 }
409 const auto error = job.browserDispatch->error;
411 fullHelpJob.reset();
412 if (!error.empty()) return std::unexpected(error);
413 return true;
414 }
415#endif
416 if (auto drained = job.drainErrors(); !drained) return fail(drained.error());
417 int exitCode{};
418 job.exited = SDL_WaitProcess(job.process, false, &exitCode);
419 std::error_code sizeError;
420 const auto size = std::filesystem::file_size(job.document, sizeError);
421 if (!sizeError && size > job.maximumHtmlBytes)
422 return fail("Full Help HTML exceeds the configured size limit");
423 if (!job.exited)
424 {
425 if (HelpClock::now() >= job.deadline)
426 return fail("Full Help export timed out: " + job.diagnostics);
427 return false;
428 }
429#ifdef _WIN32
431 if (!GetExitCodeProcess(job.statusHandle, &actualExitCode))
432 return fail("Cannot query Full Help exporter exit status: " + std::to_string(GetLastError()));
433 exitCode = static_cast<int>(actualExitCode);
434#endif
435 if (auto drained = job.drainErrors(); !drained) return fail(drained.error());
436 if (exitCode != 0)
437 return fail("Full Help export failed (exit " + std::to_string(exitCode) + "): " + job.diagnostics);
438 if (sizeError || size < 32)
439 return fail("Full Help exporter did not produce a readable HTML document");
440 // Bounded verification rejects an empty/unfinished output. Topic and image
441 // fidelity is qualified against real HLP assets, not inferred from this check.
442 std::ifstream file(job.document, std::ios::binary);
443 std::string html(static_cast<std::size_t>(size), '\0');
444 file.read(html.data(), static_cast<std::streamsize>(html.size()));
445 if (!file) return fail("Cannot read the completed Full Help HTML export");
446 file.close(); // Release the Windows file handle before atomic replacement.
447 const auto themed=styleFullHelpHtml(html,job.maximumHtmlBytes);
448 std::transform(html.begin(), html.end(), html.begin(), [](unsigned char ch)
449 {
450 return static_cast<char>(ch >= 'A' && ch <= 'Z' ? ch + ('a' - 'A') : ch);
451 });
452 if (html.find("<html") == std::string::npos || html.find("</html>") == std::string::npos)
453 return fail("Full Help exporter produced an incomplete HTML document");
454 if(themed)
455 {
456 auto temporary=job.document;temporary+=".theme.tmp";
457 std::ofstream output(temporary,std::ios::binary|std::ios::trunc);
458 output.write(themed->data(),static_cast<std::streamsize>(themed->size()));
459 output.close();
460 std::error_code error;
461 if(!output){std::filesystem::remove(temporary,error);return fail("Cannot write the themed Full Help export");}
462#ifdef _WIN32
464#else
465 std::filesystem::rename(temporary,job.document,error);
466 const bool replaced=!error;
467#endif
468 if(!replaced){std::filesystem::remove(temporary,error);return fail("Cannot publish the themed Full Help export");}
469 }
470#ifdef _WIN32
471 auto dispatch = std::make_shared<BrowserDispatch>();
472 try
473 {
474 // Never use SDL_OpenURL off-thread (SDL_misc.h: main thread only).
475 // All captures are owned; this worker cannot touch SDL/game lifetime.
476 std::thread([dispatch, document = job.document, opener = job.openDocument]()
477 {
478 try
479 {
480 const auto opened = opener ? opener(document) : openNativeDocument(document);
481 if (!opened) dispatch->error = opened.error();
482 }
483 catch (const std::exception& error) { dispatch->error = error.what(); }
484 catch (...) { dispatch->error = "Full Help browser dispatch failed"; }
485 dispatch->completed.store(true, std::memory_order_release);
486 }).detach();
487 }
488 catch (const std::exception& error)
489 {
490 return fail(std::string("Cannot start Full Help browser dispatch: ") + error.what());
491 }
492 // Preserve the file even on timeout/cancel: the OS may consume it later.
493 job.retainDocument = true;
494 job.browserDispatch = dispatch;
496 job.deadline = HelpClock::now() + std::chrono::milliseconds(job.timeoutMilliseconds);
497 return false;
498#else
499 const auto url = fileUrl(job.document);
500 const auto opened = job.openDocument ? job.openDocument(job.document) :
501 (SDL_OpenURL(url.c_str()) ? std::expected<void, std::string>{} :
502 std::expected<void, std::string>{std::unexpected(
503 std::string("Cannot open Full Help in the default browser: ") + SDL_GetError())});
504 if (!opened) return fail(opened.error());
505 job.retainDocument = true;
506 fullHelpJob.reset();
507 return true;
508#endif
509 }
510}
std::size_t count
const char * name
std::size_t root
std::string_view text
std::vector< std::byte > bytes
unsigned state
unsigned index
data::DataTag first
std::uintmax_t maximumHtmlBytes
bool exited
bool retainDocument
std::function< std::expected< void, std::string >(const std::filesystem::path &)> openDocument
SDL_Process * process
SDL_IOStream * errors
HelpClock::time_point deadline
std::filesystem::path directory
std::string diagnostics
std::filesystem::path document
std::uint32_t timeoutMilliseconds
std::string path
std::string error
Bytes input
std::deque< Bytes > output
int exitCode
void cancelFullHelp() noexcept
bool fullHelpPending() noexcept
std::expected< void, std::string > openQuickHelp(State &state, const data::ResourceSnapshot &resources)
std::expected< std::string, std::string > decodeQuickHelpText(std::string_view bytes)
std::optional< std::string > styleFullHelpHtml(std::string_view html, std::uintmax_t maximumBytes)
std::expected< bool, std::string > pollFullHelp()
std::expected< void, std::string > openFullHelp(const data::ResourceSnapshot &resources, const FullHelpOptions &options)
std::string helpFileName(data::LanguageId language, bool fullHelp)
std::function< std::expected< void, std::string >(const std::filesystem::path &)> openDocument