10#include <Poco/Exception.h>
11#include <Poco/JSON/Array.h>
12#include <Poco/JSON/Object.h>
13#include <Poco/JSON/Parser.h>
14#include <Poco/JSON/Stringifier.h>
15#include <Poco/Net/HTTPClientSession.h>
16#include <Poco/Net/HTTPRequest.h>
17#include <Poco/Net/HTTPResponse.h>
18#include <Poco/StreamCopier.h>
19#include <Poco/Timespan.h>
23#include <simplesquirrel/simplesquirrel.hpp>
25#if defined(EVENGINE_ANDROID)
26#include "android/android.h"
28#elif defined(EVENGINE_IOS)
39#include <system_error>
45std::string joinDir(
const std::string &
dir,
const std::string &
name) {
47 if (
dir.back() ==
'/' ||
dir.back() ==
'\\')
return dir +
name;
51std::filesystem::path pathFromUtf8(
const std::string &
text) {
52 const auto *data =
reinterpret_cast<const char8_t *
>(
text.data());
53 return std::filesystem::path(std::u8string_view(data,
text.size()));
56std::string pathToUtf8(
const std::filesystem::path &
p) {
57 auto u8 =
p.u8string();
58 return std::string(
reinterpret_cast<const char *
>(u8.data()), u8.size());
61bool looksAbsoluteOs(
const std::string &
path) {
62 if (
path.empty())
return false;
63 if (
path[0] ==
'/' ||
path[0] ==
'\\')
return true;
65 if (
path.size() >= 2 &&
path[1] ==
':')
return true;
70bool hasParentSegment(
const std::string &
path) {
71 if (
path ==
"..")
return true;
72 if (
path.size() >= 3 && (
path.compare(0, 3,
"../") == 0 ||
path.compare(0, 3,
"..\\") == 0))
74 return path.find(
"/..") != std::string::npos ||
path.find(
"\\..") != std::string::npos;
77bool skipWatchDirName(
const std::string &
name) {
78 return name ==
".git" ||
name ==
"build" ||
name ==
"third-party" ||
name ==
"node_modules" ||
79 name ==
".cursor" ||
name ==
".local-debug" ||
name ==
"CMakeFiles";
82std::string posixRelative(
const std::filesystem::path &
dir,
const std::filesystem::path &
root) {
84 auto rel = std::filesystem::relative(
dir,
root, ec);
86 std::string
s = rel.generic_string();
87 if (
s.empty())
return ".";
91bool isPathPrefix(
const std::filesystem::path &prefix,
const std::filesystem::path &full) {
93 auto a = std::filesystem::weakly_canonical(prefix, ec);
94 if (ec)
a = prefix.lexically_normal();
95 auto b = std::filesystem::weakly_canonical(full, ec);
96 if (ec)
b = full.lexically_normal();
97 std::string as =
a.generic_string();
98 std::string bs =
b.generic_string();
99 if (as.size() > bs.size())
return false;
101 for (
char &ch : as) ch = static_cast<char>(
std::tolower(static_cast<unsigned char>(ch)));
102 for (
char &ch : bs) ch = static_cast<char>(
std::tolower(static_cast<unsigned char>(ch)));
104 if (bs.compare(0, as.size(), as) != 0)
return false;
105 return bs.size() == as.size() || bs[as.size()] ==
'/';
108int watchTreeVfs(Filesystem *fs,
const std::string &
root) {
110 std::vector<std::string> stack;
111 stack.push_back(
root ==
"." ? std::string(
".") :
root);
113 while (!stack.empty()) {
114 std::string
dir = stack.back();
116 if (fs->watch(
dir)) ++added;
118 std::vector<std::string> items;
120 if (
dir ==
"." ||
dir.empty())
121 items = fs->getDirectoryItems(
"");
123 items = fs->getDirectoryItems(
dir);
128 for (
const auto &item : items) {
129 if (item.empty() || item ==
"." || item ==
"..")
continue;
130 const std::string
child = (
dir ==
"." ||
dir.empty()) ? item : joinDir(
dir, item);
131 Filesystem::Info
info{};
132 if (!fs->getInfo(child,
info))
continue;
133 if (
info.type ==
"directory") stack.push_back(child);
139int watchTreeOs(Filesystem *fs,
const std::filesystem::path &realRoot) {
141 if (!std::filesystem::is_directory(realRoot, ec))
return 0;
143 const auto cwd = pathFromUtf8(fs->getWorkingDirectory());
144 auto reportFor = [&](
const std::filesystem::path &
dir) -> std::string {
145 std::error_code canonEc;
146 auto d = std::filesystem::weakly_canonical(
dir, canonEc);
147 if (canonEc)
d =
dir;
148 auto c = std::filesystem::weakly_canonical(cwd, canonEc);
149 if (canonEc)
c = cwd;
150 if (isPathPrefix(
c,
d)) {
151 const std::string rel = posixRelative(
d,
c);
152 return rel.empty() ?
"." : rel;
154 const std::string rel = posixRelative(
d, realRoot);
155 return rel.empty() ?
"." : rel;
159 if (fs->watchRealDirectory(pathToUtf8(realRoot), reportFor(realRoot)).
ok()) ++added;
161 auto it = std::filesystem::recursive_directory_iterator(
162 realRoot, std::filesystem::directory_options::skip_permission_denied, ec);
163 const auto end = std::filesystem::recursive_directory_iterator();
164 for (; it !=
end; it.increment(ec)) {
169 std::error_code dirEc;
170 if (!it->is_directory(dirEc))
continue;
171 const auto name = it->path().filename().string();
172 if (
name.empty() ||
name ==
"." ||
name ==
".." || skipWatchDirName(
name)) {
173 it.disable_recursion_pending();
176 const auto &
dir = it->path();
179 if (isPathPrefix(realRoot, cwd)) {
180 const auto appsDir = cwd.parent_path();
181 if (isPathPrefix(appsDir,
dir) && !isPathPrefix(
dir, appsDir) && !isPathPrefix(cwd,
dir)) {
182 it.disable_recursion_pending();
186 const std::string report = reportFor(
dir);
187 if (report.empty() || report.find(
"..") != std::string::npos)
continue;
188 if (fs->watchRealDirectory(pathToUtf8(
dir), report).
ok()) ++added;
193bool resolveOsWatchRoot(Filesystem *fs,
const std::string &
root, std::filesystem::path &out) {
195 std::filesystem::path candidate;
197 candidate = pathFromUtf8(fs->getWorkingDirectory());
198 }
else if (looksAbsoluteOs(
root)) {
199 candidate = pathFromUtf8(
root);
201 candidate = pathFromUtf8(fs->getWorkingDirectory()) / pathFromUtf8(
root);
203 auto canonical = std::filesystem::weakly_canonical(candidate, ec);
204 if (ec)
return false;
205 if (!std::filesystem::is_directory(canonical, ec))
return false;
212#ifndef EVENGINE_WEBGPU
213std::string stripTrailingSlash(std::string
s) {
214 while (
s.size() > 1 &&
s.back() ==
'/')
s.pop_back();
219std::string urlPathEscape(
const std::string &
s) {
221 out.reserve(
s.size() + 8);
222 const char hex[] =
"0123456789ABCDEF";
223 for (
unsigned char c :
s) {
224 if (std::isalnum(
c) ||
c ==
'-' ||
c ==
'_' ||
c ==
'.' ||
c ==
'~' ||
c ==
'/') {
225 out +=
static_cast<char>(
c);
236std::string httpGet(
const std::string &url,
int timeoutMs) {
239 Poco::Net::HTTPClientSession
session(
uri.getHost(),
uri.getPort());
240 session.setTimeout(Poco::Timespan(timeoutMs / 1000, (timeoutMs % 1000) * 1000));
241 std::string
path =
uri.getPathAndQuery();
243 Poco::Net::HTTPRequest req(Poco::Net::HTTPRequest::HTTP_GET,
path,
244 Poco::Net::HTTPMessage::HTTP_1_1);
245 req.setHost(
uri.getHost());
246 std::ostream &os =
session.sendRequest(req);
248 Poco::Net::HTTPResponse resp;
249 std::istream &is =
session.receiveResponse(resp);
250 if (resp.getStatus() != Poco::Net::HTTPResponse::HTTP_OK)
return {};
251 std::ostringstream oss;
252 Poco::StreamCopier::copyStream(is, oss);
259bool writeFileBytes(
const std::string &realPath,
const std::string &data) {
261 std::filesystem::path
p(realPath);
262 std::filesystem::create_directories(
p.parent_path(), ec);
263 std::ofstream ofs(realPath, std::ios::binary | std::ios::trunc);
264 if (!ofs)
return false;
265 ofs.write(data.data(),
static_cast<std::streamsize
>(data.size()));
277 for (
char &
c :
path) {
278 if (
c ==
'\\')
c =
'/';
280 while (
path.size() >= 2 &&
path[0] ==
'.' &&
path[1] ==
'/')
path.erase(0, 2);
281 while (
path.size() > 1 &&
path.back() ==
'/')
path.pop_back();
287 if (
path.empty())
return;
298 if (norm.empty())
return false;
300 std::string
kind =
"auto";
301 auto bit = bindings_.find(norm);
302 if (bit != bindings_.end())
kind = bit->second;
309 const bool wanted = (
kind ==
"auto") ?
r->handlesPath(norm) : (
kind ==
r->reloadKind());
311 auto result =
r->reload(norm);
312 if (result.ok() && result.value()) any =
true;
318 StartupStage stage(
"hotreload: watchTree (recursive walk + register watches)");
319 auto *fs = Filesystem::create();
325 const bool vfsDir = (
root ==
"." ||
root ==
"./") ||
326 (!looksAbsoluteOs(
root) && !hasParentSegment(
root) && fs->getInfo(
root,
info) &&
327 info.type ==
"directory");
328 if (vfsDir)
return watchTreeVfs(fs,
root);
330 std::filesystem::path realRoot;
331 if (!resolveOsWatchRoot(fs,
root, realRoot))
return 0;
332 return watchTreeOs(fs, realRoot);
336 auto *fs = Filesystem::create();
337 if (!fs)
return false;
339 if (
path.empty())
return false;
342 if (fs->getInfo(
path,
info) &&
info.type ==
"directory") {
346 std::filesystem::path real;
347 if (!resolveOsWatchRoot(fs,
path, real))
return false;
348 watchTreeOs(fs, real);
356#ifndef EVENGINE_WEBGPU
358std::string HotReload::ensureHotDir() {
359 if (!hotDir_.empty())
return hotDir_;
362#if defined(EVENGINE_ANDROID)
363 base = eve::android::getHotReloadDirectory();
364#elif defined(EVENGINE_IOS)
365 base = eve::ios::getHotReloadDirectory();
368 auto *fs = Filesystem::create();
369 base = fs ? fs->getAppdataDirectory() : std::string(
".");
370 if (!base.empty() && base.back() ==
'/') base.pop_back();
371 base +=
"/EVE/hotreload";
374 if (base.empty()) base =
".";
376 std::filesystem::create_directories(base, ec);
382 if (syncStarted_)
return;
384 std::filesystem::create_directories(
dir, ec);
385 hotDir_ = std::move(
dir);
388bool HotReload::mountHotDir() {
389 if (hotDirMounted_)
return true;
390 auto *fs = Filesystem::create();
391 if (!fs)
return false;
392 const std::string
dir = ensureHotDir();
393 if (!fs->mountRealDirectory(
dir,
"/",
false))
return false;
394 hotDirMounted_ =
true;
398bool HotReload::fetchManifest(std::vector<RemoteFile> &out) {
399 const std::string url = stripTrailingSlash(syncUrl_) +
"/manifest";
400 const std::string
body = httpGet(url, 2000);
401 if (
body.empty())
return false;
404 Poco::JSON::Parser parser;
405 Poco::Dynamic::Var result = parser.parse(
body);
406 Poco::JSON::Array::Ptr arr = result.extract<Poco::JSON::Array::Ptr>();
407 if (!arr)
return false;
408 for (
size_t i = 0; i < arr->size(); ++i) {
409 Poco::JSON::Object::Ptr o = arr->getObject(
static_cast<unsigned int>(i));
412 f.path = o->optValue<std::string>(
"path",
"");
413 if (
f.path.empty())
continue;
414 f.size = o->optValue<int64_t>(
"size", -1);
415 f.mtime = o->optValue<int64_t>(
"mtime", -1);
417 if (!
f.path.empty()) out.push_back(std::move(
f));
425bool HotReload::downloadFile(
const std::string &relPath) {
426 const std::string url = stripTrailingSlash(syncUrl_) +
"/raw/" + urlPathEscape(relPath);
427 const std::string
body = httpGet(url, 5000);
428 if (
body.empty())
return false;
429 const std::string real = joinDir(ensureHotDir(), relPath);
430 return writeFileBytes(real,
body);
433std::map<std::string, std::pair<int64_t, int64_t>> HotReload::loadRecord()
const {
434 std::map<std::string, std::pair<int64_t, int64_t>> record;
435 const std::string real = joinDir(hotDir_,
".eve-manifest.json");
436 std::ifstream ifs(real, std::ios::binary);
437 if (!ifs)
return record;
438 std::ostringstream oss;
441 Poco::JSON::Parser parser;
442 Poco::Dynamic::Var result = parser.parse(oss.str());
443 Poco::JSON::Array::Ptr arr = result.extract<Poco::JSON::Array::Ptr>();
444 if (!arr)
return record;
445 for (
size_t i = 0; i < arr->size(); ++i) {
446 Poco::JSON::Object::Ptr o = arr->getObject(
static_cast<unsigned int>(i));
448 const std::string
path = o->optValue<std::string>(
"path",
"");
449 if (
path.empty())
continue;
450 record[
path] = {o->optValue<int64_t>(
"size", -1), o->optValue<int64_t>(
"mtime", -1)};
457void HotReload::saveRecord(
const std::map<std::string, std::pair<int64_t, int64_t>> &record)
const {
458 Poco::JSON::Array::Ptr arr =
new Poco::JSON::Array();
459 for (
const auto &kv : record) {
460 Poco::JSON::Object::Ptr o =
new Poco::JSON::Object();
461 o->set(
"path", kv.first);
462 o->set(
"size", kv.second.first);
463 o->set(
"mtime", kv.second.second);
466 std::ostringstream oss;
467 Poco::JSON::Stringifier::stringify(arr, oss, 0, 0);
468 const std::string real = joinDir(hotDir_,
".eve-manifest.json");
469 writeFileBytes(real, oss.str());
472void HotReload::queueChange(std::string
path) {
473 std::lock_guard<std::mutex> lock(syncMu_);
474 changedQueue_.push_back(std::move(
path));
477void HotReload::applyManifest(
const std::vector<RemoteFile> &manifest) {
478 auto record = loadRecord();
479 std::map<std::string, bool> seen;
480 for (
const auto &
file : manifest) {
481 seen[
file.path] =
true;
482 auto it = record.find(
file.path);
483 if (it != record.end() && it->second.first ==
file.size && it->second.second ==
file.mtime)
485 if (downloadFile(
file.path)) {
487 queueChange(
file.path);
491 for (
auto it = record.begin(); it != record.end();) {
492 if (seen.find(it->first) != seen.end()) {
496 const std::string real = joinDir(hotDir_, it->first);
498 std::filesystem::remove(real, ec);
499 queueChange(it->first);
500 it = record.erase(it);
505void HotReload::syncLoop(
int pollMs) {
507 while (syncRunning_.load()) {
508 std::vector<RemoteFile> manifest;
509 if (fetchManifest(manifest)) {
512 std::lock_guard<std::mutex> l(syncMu_);
513 syncStatus_ =
"syncing";
515 applyManifest(manifest);
517 std::lock_guard<std::mutex> l(syncMu_);
518 syncStatus_ =
"synced";
523 std::lock_guard<std::mutex> l(syncMu_);
524 syncStatus_ = failStreak > 3 ?
"error:unreachable" :
"idle";
528 std::unique_lock<std::mutex> l(syncMu_);
529 syncCv_.wait_for(l, std::chrono::milliseconds(pollMs), [
this]() {
return !syncRunning_.load(); });
532 std::lock_guard<std::mutex> l(syncMu_);
533 syncStatus_ =
"idle";
538 std::lock_guard<std::mutex> lock(syncMu_);
539 if (syncStarted_)
return false;
540 url = stripTrailingSlash(std::move(url));
541 if (url.empty())
return false;
542 if (url.rfind(
"http://", 0) != 0 && url.rfind(
"https://", 0) != 0) url =
"http://" + url;
544 syncUrl_ = std::move(url);
545 syncStatus_ =
"syncing";
547 hotDirMounted_ =
false;
550 if (!mountHotDir()) {
551 syncStarted_ =
false;
552 syncStatus_ =
"error:mount";
555 syncRunning_.store(
true);
556 syncThread_ = std::thread([
this, pollMs]() { syncLoop(pollMs); });
562 std::lock_guard<std::mutex> lock(syncMu_);
563 if (!syncStarted_)
return;
564 syncRunning_.store(
false);
566 syncCv_.notify_all();
567 if (syncThread_.joinable()) syncThread_.join();
568 std::lock_guard<std::mutex> lock(syncMu_);
569 syncStarted_ =
false;
570 changedQueue_.clear();
576 std::lock_guard<std::mutex> lock(syncMu_);
581 std::lock_guard<std::mutex> lock(syncMu_);
582 if (changedQueue_.empty())
return {};
583 std::string
p = std::move(changedQueue_.front());
584 changedQueue_.pop_front();
590#ifdef EVENGINE_WEBGPU
592std::string HotReload::ensureHotDir() {
return {}; }
593bool HotReload::mountHotDir() {
return false; }
594bool HotReload::fetchManifest(std::vector<RemoteFile> &) {
return false; }
595bool HotReload::downloadFile(
const std::string &) {
return false; }
596void HotReload::applyManifest(
const std::vector<RemoteFile> &) {}
597std::map<std::string, std::pair<int64_t, int64_t>> HotReload::loadRecord()
const {
return {}; }
598void HotReload::saveRecord(
const std::map<std::string, std::pair<int64_t, int64_t>> &)
const {}
599void HotReload::syncLoop(
int) {}
600void HotReload::queueChange(std::string) {}
609void HotReload::expose(ssq::Table &table) {
610 auto cls =
table.addClass(
name, HotReload::create,
false);
614void HotReload::expose(ssq::Class &
cls) {
#define Module_IMPL(ModuleName, newExpr)
std::optional< InteractionSession > session
virtual std::string getName() const =0
Returns the name.
EVENGINE_API_FOUNDATION public API.
Path→reload dispatcher for soft hot reload. Driven from load.nut via pollWatch → tryReload; also used...
void setRemoteHotDir(std::string dir)
Sets the remote hot dir.
int watchTree(std::string root=".")
Recursively watch root and all subdirectories. Virtual paths walk the VFS. .., ../…,...
std::string pollRemoteChange()
Polls remote change.
bool isRemoteSyncing() const
True when remote syncing.
bool tryReload(std::string path)
Offer a (normalized) path to the registered reloaders; true if any reloaded.
~HotReload() override
Hot reload.
bool watchNewDirectory(std::string path)
If path is a directory, recursively add watches for it and its current descendants.
void unbind(std::string path)
Unbinds unbind.
void stopRemoteSync()
Stops remote sync.
bool startRemoteSync(std::string url, int pollMs=1000)
Starts remote sync.
std::string remoteSyncStatus() const
Remote sync status.
void bind(std::string path, std::string kind="auto")
Pin a path to one reloader kind ("particle" / "tilemap" / "texture" / whatever a linked module regist...
static std::string normalizePath(std::string path)
Normalize path.
std::unordered_map< std::string, SkillDefinition > & table()
WidgetDesc child(std::string id, std::vector< WidgetDesc > children, float width, float height)
Scrollable child region with an explicit size.