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)
44std::string joinDir(
const std::string &
dir,
const std::string &
name) {
46 if (
dir.back() ==
'/' ||
dir.back() ==
'\\')
return dir +
name;
52#ifndef EVENGINE_WEBGPU
53std::string stripTrailingSlash(std::string
s) {
54 while (
s.size() > 1 &&
s.back() ==
'/')
s.pop_back();
59std::string urlPathEscape(
const std::string &
s) {
61 out.reserve(
s.size() + 8);
62 const char hex[] =
"0123456789ABCDEF";
63 for (
unsigned char c :
s) {
64 if (std::isalnum(
c) ||
c ==
'-' ||
c ==
'_' ||
c ==
'.' ||
c ==
'~' ||
c ==
'/') {
65 out +=
static_cast<char>(
c);
76std::string httpGet(
const std::string &url,
int timeoutMs) {
79 Poco::Net::HTTPClientSession session(uri.getHost(), uri.getPort());
80 session.setTimeout(Poco::Timespan(timeoutMs / 1000, (timeoutMs % 1000) * 1000));
81 std::string path = uri.getPathAndQuery();
82 if (path.empty()) path =
"/";
83 Poco::Net::HTTPRequest req(Poco::Net::HTTPRequest::HTTP_GET, path,
84 Poco::Net::HTTPMessage::HTTP_1_1);
85 req.setHost(uri.getHost());
86 std::ostream &
os = session.sendRequest(req);
88 Poco::Net::HTTPResponse resp;
89 std::istream &is = session.receiveResponse(resp);
90 if (resp.getStatus() != Poco::Net::HTTPResponse::HTTP_OK)
return {};
91 std::ostringstream oss;
92 Poco::StreamCopier::copyStream(is, oss);
99bool writeFileBytes(
const std::string &realPath,
const std::string &data) {
101 std::filesystem::path
p(realPath);
102 std::filesystem::create_directories(
p.parent_path(), ec);
103 std::ofstream ofs(realPath, std::ios::binary | std::ios::trunc);
104 if (!ofs)
return false;
105 ofs.write(
data.data(),
static_cast<std::streamsize
>(
data.size()));
117 for (
char &
c : path) {
118 if (
c ==
'\\')
c =
'/';
120 while (path.size() >= 2 && path[0] ==
'.' && path[1] ==
'/') path.erase(0, 2);
121 while (path.size() > 1 && path.back() ==
'/') path.pop_back();
127 if (path.empty())
return;
129 bindings_[path] =
kind;
138 if (norm.empty())
return false;
140 std::string
kind =
"auto";
141 auto bit = bindings_.find(norm);
142 if (bit != bindings_.end())
kind = bit->second;
150 if (wanted && r->
reload(norm)) any =
true;
156 StartupStage stage(
"hotreload: watchTree (recursive walk + register watches)");
157 auto *fs = Filesystem::create();
160 if (root.empty()) root =
".";
163 std::vector<std::string> stack;
164 stack.push_back(root ==
"." ? std::string(
".") : root);
166 while (!stack.empty()) {
167 std::string
dir = stack.back();
169 if (fs->watch(
dir)) ++added;
171 std::vector<std::string> items;
173 if (
dir ==
"." ||
dir.empty())
174 items = fs->getDirectoryItems(
"");
176 items = fs->getDirectoryItems(
dir);
181 for (
const auto &
name : items) {
182 if (
name.empty() ||
name ==
"." ||
name ==
"..")
continue;
183 const std::string child = (
dir ==
"." ||
dir.empty()) ?
name : joinDir(
dir,
name);
185 if (!fs->getInfo(child, info))
continue;
186 if (info.type ==
"directory") stack.push_back(child);
196#ifndef EVENGINE_WEBGPU
198std::string HotReload::ensureHotDir() {
199 if (!hotDir_.empty())
return hotDir_;
202#if defined(EVENGINE_ANDROID)
203 base = eve::android::getHotReloadDirectory();
204#elif defined(EVENGINE_IOS)
205 base = eve::ios::getHotReloadDirectory();
208 auto *fs = Filesystem::create();
209 base = fs ? fs->getAppdataDirectory() : std::string(
".");
210 if (!base.empty() && base.back() ==
'/') base.pop_back();
211 base +=
"/EVE/hotreload";
214 if (base.empty()) base =
".";
216 std::filesystem::create_directories(base, ec);
222 if (syncStarted_)
return;
224 std::filesystem::create_directories(
dir, ec);
225 hotDir_ = std::move(
dir);
228bool HotReload::mountHotDir() {
229 if (hotDirMounted_)
return true;
230 auto *fs = Filesystem::create();
231 if (!fs)
return false;
232 const std::string
dir = ensureHotDir();
233 if (!fs->mountRealDirectory(
dir,
"/",
false))
return false;
234 hotDirMounted_ =
true;
238bool HotReload::fetchManifest(std::vector<RemoteFile> &out) {
239 const std::string url = stripTrailingSlash(syncUrl_) +
"/manifest";
240 const std::string
body = httpGet(url, 2000);
241 if (
body.empty())
return false;
244 Poco::JSON::Parser parser;
245 Poco::Dynamic::Var result = parser.parse(
body);
246 Poco::JSON::Array::Ptr arr = result.extract<Poco::JSON::Array::Ptr>();
247 if (!arr)
return false;
248 for (
size_t i = 0; i < arr->size(); ++i) {
249 Poco::JSON::Object::Ptr o = arr->getObject(i);
252 f.path = o->optValue<std::string>(
"path",
"");
253 if (
f.path.empty())
continue;
254 f.size = o->optValue<int64_t>(
"size", -1);
255 f.mtime = o->optValue<int64_t>(
"mtime", -1);
257 if (!
f.path.empty()) out.push_back(std::move(
f));
265bool HotReload::downloadFile(
const std::string &relPath) {
266 const std::string url = stripTrailingSlash(syncUrl_) +
"/raw/" + urlPathEscape(relPath);
267 const std::string
body = httpGet(url, 5000);
268 if (
body.empty())
return false;
269 const std::string real = joinDir(ensureHotDir(), relPath);
270 return writeFileBytes(real,
body);
273std::map<std::string, std::pair<int64_t, int64_t>> HotReload::loadRecord()
const {
274 std::map<std::string, std::pair<int64_t, int64_t>> record;
275 const std::string real = joinDir(hotDir_,
".eve-manifest.json");
276 std::ifstream ifs(real, std::ios::binary);
277 if (!ifs)
return record;
278 std::ostringstream oss;
281 Poco::JSON::Parser parser;
282 Poco::Dynamic::Var result = parser.parse(oss.str());
283 Poco::JSON::Array::Ptr arr = result.extract<Poco::JSON::Array::Ptr>();
284 if (!arr)
return record;
285 for (
size_t i = 0; i < arr->size(); ++i) {
286 Poco::JSON::Object::Ptr o = arr->getObject(i);
288 const std::string path = o->optValue<std::string>(
"path",
"");
289 if (path.empty())
continue;
290 record[path] = {o->optValue<int64_t>(
"size", -1), o->optValue<int64_t>(
"mtime", -1)};
297void HotReload::saveRecord(
const std::map<std::string, std::pair<int64_t, int64_t>> &record)
const {
298 Poco::JSON::Array::Ptr arr =
new Poco::JSON::Array();
299 for (
const auto &kv : record) {
300 Poco::JSON::Object::Ptr o =
new Poco::JSON::Object();
301 o->set(
"path", kv.first);
302 o->set(
"size", kv.second.first);
303 o->set(
"mtime", kv.second.second);
306 std::ostringstream oss;
307 Poco::JSON::Stringifier::stringify(arr, oss, 0, 0);
308 const std::string real = joinDir(hotDir_,
".eve-manifest.json");
309 writeFileBytes(real, oss.str());
312void HotReload::queueChange(std::string path) {
313 std::lock_guard<std::mutex> lock(syncMu_);
314 changedQueue_.push_back(std::move(path));
317void HotReload::applyManifest(
const std::vector<RemoteFile> &manifest) {
318 auto record = loadRecord();
319 std::map<std::string, bool> seen;
320 for (
const auto &
file : manifest) {
321 seen[
file.path] =
true;
322 auto it = record.find(
file.path);
323 if (it != record.end() && it->second.first ==
file.size && it->second.second ==
file.mtime)
325 if (downloadFile(
file.path)) {
327 queueChange(
file.path);
331 for (
auto it = record.begin(); it != record.end();) {
332 if (seen.find(it->first) != seen.end()) {
336 const std::string real = joinDir(hotDir_, it->first);
338 std::filesystem::remove(real, ec);
339 queueChange(it->first);
340 it = record.erase(it);
345void HotReload::syncLoop(
int pollMs) {
347 while (syncRunning_.load()) {
348 std::vector<RemoteFile> manifest;
349 if (fetchManifest(manifest)) {
352 std::lock_guard<std::mutex> l(syncMu_);
353 syncStatus_ =
"syncing";
355 applyManifest(manifest);
357 std::lock_guard<std::mutex> l(syncMu_);
358 syncStatus_ =
"synced";
363 std::lock_guard<std::mutex> l(syncMu_);
364 syncStatus_ = failStreak > 3 ?
"error:unreachable" :
"idle";
368 std::unique_lock<std::mutex> l(syncMu_);
369 syncCv_.wait_for(l, std::chrono::milliseconds(pollMs), [
this]() {
return !syncRunning_.load(); });
372 std::lock_guard<std::mutex> l(syncMu_);
373 syncStatus_ =
"idle";
378 std::lock_guard<std::mutex> lock(syncMu_);
379 if (syncStarted_)
return false;
380 url = stripTrailingSlash(std::move(url));
381 if (url.empty())
return false;
382 if (url.rfind(
"http://", 0) != 0 && url.rfind(
"https://", 0) != 0) url =
"http://" + url;
384 syncUrl_ = std::move(url);
385 syncStatus_ =
"syncing";
387 hotDirMounted_ =
false;
390 if (!mountHotDir()) {
391 syncStarted_ =
false;
392 syncStatus_ =
"error:mount";
395 syncRunning_.store(
true);
396 syncThread_ = std::thread([
this, pollMs]() { syncLoop(pollMs); });
402 std::lock_guard<std::mutex> lock(syncMu_);
403 if (!syncStarted_)
return;
404 syncRunning_.store(
false);
406 syncCv_.notify_all();
407 if (syncThread_.joinable()) syncThread_.join();
408 std::lock_guard<std::mutex> lock(syncMu_);
409 syncStarted_ =
false;
410 changedQueue_.clear();
416 std::lock_guard<std::mutex> lock(syncMu_);
421 std::lock_guard<std::mutex> lock(syncMu_);
422 if (changedQueue_.empty())
return {};
423 std::string
p = std::move(changedQueue_.front());
424 changedQueue_.pop_front();
430#ifdef EVENGINE_WEBGPU
432std::string HotReload::ensureHotDir() {
return {}; }
433bool HotReload::mountHotDir() {
return false; }
434bool HotReload::fetchManifest(std::vector<RemoteFile> &) {
return false; }
435bool HotReload::downloadFile(
const std::string &) {
return false; }
436void HotReload::applyManifest(
const std::vector<RemoteFile> &) {}
437std::map<std::string, std::pair<int64_t, int64_t>> HotReload::loadRecord()
const {
return {}; }
438void HotReload::saveRecord(
const std::map<std::string, std::pair<int64_t, int64_t>> &)
const {}
439void HotReload::syncLoop(
int) {}
440void HotReload::queueChange(std::string) {}
449void HotReload::expose(ssq::Table &table) {
450 auto cls = table.addClass(
name, HotReload::create,
false);
454void HotReload::expose(ssq::Class &
cls) {
#define Module_IMPL(ModuleName, newExpr)
virtual std::string getName() const =0
virtual const char * reloadKind() const =0
virtual bool handlesPath(const std::string &normPath) const =0
virtual bool reload(const std::string &normPath)
Path→reload dispatcher for soft hot reload. Driven from load.nut via pollWatch → tryReload; also used...
void setRemoteHotDir(std::string dir)
int watchTree(std::string root=".")
Recursively watch root and all subdirectories. Returns number of watches added.
std::string pollRemoteChange()
bool isRemoteSyncing() const
bool tryReload(std::string path)
Offer a (normalized) path to the registered reloaders; true if any reloaded.
void unbind(std::string path)
bool startRemoteSync(std::string url, int pollMs=1000)
std::string remoteSyncStatus() const
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)