#include "filesystem/HotReload.h" #include "common/AssetReloader.h" #include "common/Capability.h" #include "common/Module.h" #include "common/StartupTiming.h" #include "filesystem/Filesystem.h" #ifndef EVENGINE_WEBGPU #include #include #include #include #include #include #include #include #include #include #include #endif #include #if defined(EVENGINE_ANDROID) #include "android/android.h" #include #elif defined(EVENGINE_IOS) #include "ios/ios.h" #endif #include #include #include #include #include #include #include #include namespace eve::filesystem { namespace { std::string joinDir(const std::string &dir, const std::string &name) { if (dir.empty() || dir == ".") return name; if (dir.back() == '/' || dir.back() == '\\') return dir + name; return dir + "/" + name; } // --- Remote sync helpers --- #ifndef EVENGINE_WEBGPU std::string stripTrailingSlash(std::string s) { while (s.size() > 1 && s.back() == '/') s.pop_back(); return s; } // Path-escape for use inside a URL path segment (spaces, '#', '?', etc.). std::string urlPathEscape(const std::string &s) { std::string out; out.reserve(s.size() + 8); const char hex[] = "0123456789ABCDEF"; for (unsigned char c : s) { if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~' || c == '/') { out += static_cast(c); } else { out += '%'; out += hex[c >> 4]; out += hex[c & 0xF]; } } return out; } // HTTP GET; returns 200 response body or empty on failure. std::string httpGet(const std::string &url, int timeoutMs) { try { Poco::URI uri(url); Poco::Net::HTTPClientSession session(uri.getHost(), uri.getPort()); session.setTimeout(Poco::Timespan(timeoutMs / 1000, (timeoutMs % 1000) * 1000)); std::string path = uri.getPathAndQuery(); if (path.empty()) path = "/"; Poco::Net::HTTPRequest req(Poco::Net::HTTPRequest::HTTP_GET, path, Poco::Net::HTTPMessage::HTTP_1_1); req.setHost(uri.getHost()); std::ostream &os = session.sendRequest(req); (void)os; Poco::Net::HTTPResponse resp; std::istream &is = session.receiveResponse(resp); if (resp.getStatus() != Poco::Net::HTTPResponse::HTTP_OK) return {}; std::ostringstream oss; Poco::StreamCopier::copyStream(is, oss); return oss.str(); } catch (...) { return {}; } } bool writeFileBytes(const std::string &realPath, const std::string &data) { std::error_code ec; std::filesystem::path p(realPath); std::filesystem::create_directories(p.parent_path(), ec); std::ofstream ofs(realPath, std::ios::binary | std::ios::trunc); if (!ofs) return false; ofs.write(data.data(), static_cast(data.size())); return ofs.good(); } #endif // !EVENGINE_WEBGPU } // namespace Module_IMPL(HotReload, new HotReload()); HotReload::~HotReload() { stopRemoteSync(); } std::string HotReload::normalizePath(std::string path) { for (char &c : path) { if (c == '\\') c = '/'; } while (path.size() >= 2 && path[0] == '.' && path[1] == '/') path.erase(0, 2); while (path.size() > 1 && path.back() == '/') path.pop_back(); return path; } void HotReload::bind(std::string path, std::string kind) { path = normalizePath(std::move(path)); if (path.empty()) return; if (kind.empty()) kind = "auto"; bindings_[path] = kind; } void HotReload::unbind(std::string path) { bindings_.erase(normalizePath(std::move(path))); } bool HotReload::tryReload(std::string path) { const std::string norm = normalizePath(std::move(path)); if (norm.empty()) return false; std::string kind = "auto"; auto bit = bindings_.find(norm); if (bit != bindings_.end()) kind = bit->second; bool any = false; eve::cap::forEach([&](eve::caps::IAssetReloader *r) { // "auto" lets each reloader claim the path by extension. An explicit // kind targets exactly one reloader and ignores the extension, so a // config file with an unexpected suffix can still be bound by hand. const bool wanted = (kind == "auto") ? r->handlesPath(norm) : (kind == r->reloadKind()); if (wanted && r->reload(norm)) any = true; }); return any; } int HotReload::watchTree(std::string root) { StartupStage stage("hotreload: watchTree (recursive walk + register watches)"); auto *fs = Filesystem::create(); if (!fs) return 0; root = normalizePath(std::move(root)); if (root.empty()) root = "."; int added = 0; std::vector stack; stack.push_back(root == "." ? std::string(".") : root); while (!stack.empty()) { std::string dir = stack.back(); stack.pop_back(); if (fs->watch(dir)) ++added; std::vector items; try { if (dir == "." || dir.empty()) items = fs->getDirectoryItems(""); else items = fs->getDirectoryItems(dir); } catch (...) { continue; } for (const auto &name : items) { if (name.empty() || name == "." || name == "..") continue; const std::string child = (dir == "." || dir.empty()) ? name : joinDir(dir, name); Filesystem::Info info{}; if (!fs->getInfo(child, info)) continue; if (info.type == "directory") stack.push_back(child); } } return added; } // --- Remote hot reload (dev-server sync) --- // Poco (HTTP) is not available in the Emscripten/WebGPU build; the API is // stubbed there so scripts still compile, and startRemoteSync just fails. #ifndef EVENGINE_WEBGPU std::string HotReload::ensureHotDir() { if (!hotDir_.empty()) return hotDir_; std::string base; #if defined(EVENGINE_ANDROID) base = eve::android::getHotReloadDirectory(); #elif defined(EVENGINE_IOS) base = eve::ios::getHotReloadDirectory(); #else { auto *fs = Filesystem::create(); base = fs ? fs->getAppdataDirectory() : std::string("."); if (!base.empty() && base.back() == '/') base.pop_back(); base += "/EVE/hotreload"; } #endif if (base.empty()) base = "."; std::error_code ec; std::filesystem::create_directories(base, ec); hotDir_ = base; return hotDir_; } void HotReload::setRemoteHotDir(std::string dir) { if (syncStarted_) return; std::error_code ec; std::filesystem::create_directories(dir, ec); hotDir_ = std::move(dir); } bool HotReload::mountHotDir() { if (hotDirMounted_) return true; auto *fs = Filesystem::create(); if (!fs) return false; const std::string dir = ensureHotDir(); if (!fs->mountRealDirectory(dir, "/", /*appendToPath=*/false)) return false; hotDirMounted_ = true; return true; } bool HotReload::fetchManifest(std::vector &out) { const std::string url = stripTrailingSlash(syncUrl_) + "/manifest"; const std::string body = httpGet(url, 2000); if (body.empty()) return false; try { Poco::JSON::Parser parser; Poco::Dynamic::Var result = parser.parse(body); Poco::JSON::Array::Ptr arr = result.extract(); if (!arr) return false; for (size_t i = 0; i < arr->size(); ++i) { Poco::JSON::Object::Ptr o = arr->getObject(i); if (!o) continue; RemoteFile f; f.path = o->optValue("path", ""); if (f.path.empty()) continue; f.size = o->optValue("size", -1); f.mtime = o->optValue("mtime", -1); f.path = normalizePath(std::move(f.path)); if (!f.path.empty()) out.push_back(std::move(f)); } return true; } catch (...) { return false; } } bool HotReload::downloadFile(const std::string &relPath) { const std::string url = stripTrailingSlash(syncUrl_) + "/raw/" + urlPathEscape(relPath); const std::string body = httpGet(url, 5000); if (body.empty()) return false; const std::string real = joinDir(ensureHotDir(), relPath); return writeFileBytes(real, body); } std::map> HotReload::loadRecord() const { std::map> record; const std::string real = joinDir(hotDir_, ".eve-manifest.json"); std::ifstream ifs(real, std::ios::binary); if (!ifs) return record; std::ostringstream oss; oss << ifs.rdbuf(); try { Poco::JSON::Parser parser; Poco::Dynamic::Var result = parser.parse(oss.str()); Poco::JSON::Array::Ptr arr = result.extract(); if (!arr) return record; for (size_t i = 0; i < arr->size(); ++i) { Poco::JSON::Object::Ptr o = arr->getObject(i); if (!o) continue; const std::string path = o->optValue("path", ""); if (path.empty()) continue; record[path] = {o->optValue("size", -1), o->optValue("mtime", -1)}; } } catch (...) { } return record; } void HotReload::saveRecord(const std::map> &record) const { Poco::JSON::Array::Ptr arr = new Poco::JSON::Array(); for (const auto &kv : record) { Poco::JSON::Object::Ptr o = new Poco::JSON::Object(); o->set("path", kv.first); o->set("size", kv.second.first); o->set("mtime", kv.second.second); arr->add(o); } std::ostringstream oss; Poco::JSON::Stringifier::stringify(arr, oss, 0, 0); const std::string real = joinDir(hotDir_, ".eve-manifest.json"); writeFileBytes(real, oss.str()); } void HotReload::queueChange(std::string path) { std::lock_guard lock(syncMu_); changedQueue_.push_back(std::move(path)); } void HotReload::applyManifest(const std::vector &manifest) { auto record = loadRecord(); std::map seen; for (const auto &file : manifest) { seen[file.path] = true; auto it = record.find(file.path); if (it != record.end() && it->second.first == file.size && it->second.second == file.mtime) continue; // unchanged if (downloadFile(file.path)) { record[file.path] = {file.size, file.mtime}; queueChange(file.path); } } // Remove files that disappeared from the server. for (auto it = record.begin(); it != record.end();) { if (seen.find(it->first) != seen.end()) { ++it; continue; } const std::string real = joinDir(hotDir_, it->first); std::error_code ec; std::filesystem::remove(real, ec); queueChange(it->first); it = record.erase(it); } saveRecord(record); } void HotReload::syncLoop(int pollMs) { int failStreak = 0; while (syncRunning_.load()) { std::vector manifest; if (fetchManifest(manifest)) { failStreak = 0; { std::lock_guard l(syncMu_); syncStatus_ = "syncing"; } applyManifest(manifest); { std::lock_guard l(syncMu_); syncStatus_ = "synced"; } } else { ++failStreak; { std::lock_guard l(syncMu_); syncStatus_ = failStreak > 3 ? "error:unreachable" : "idle"; } } std::unique_lock l(syncMu_); syncCv_.wait_for(l, std::chrono::milliseconds(pollMs), [this]() { return !syncRunning_.load(); }); } { std::lock_guard l(syncMu_); syncStatus_ = "idle"; } } bool HotReload::startRemoteSync(std::string url, int pollMs) { std::lock_guard lock(syncMu_); if (syncStarted_) return false; url = stripTrailingSlash(std::move(url)); if (url.empty()) return false; if (url.rfind("http://", 0) != 0 && url.rfind("https://", 0) != 0) url = "http://" + url; syncUrl_ = std::move(url); syncStatus_ = "syncing"; syncStarted_ = true; hotDirMounted_ = false; // Mount the overlay dir from the calling (main) thread; PhysFS mount is not // guaranteed thread-safe, so never touch it from the sync thread. if (!mountHotDir()) { syncStarted_ = false; syncStatus_ = "error:mount"; return false; } syncRunning_.store(true); syncThread_ = std::thread([this, pollMs]() { syncLoop(pollMs); }); return true; } void HotReload::stopRemoteSync() { { std::lock_guard lock(syncMu_); if (!syncStarted_) return; syncRunning_.store(false); } syncCv_.notify_all(); if (syncThread_.joinable()) syncThread_.join(); std::lock_guard lock(syncMu_); syncStarted_ = false; changedQueue_.clear(); } bool HotReload::isRemoteSyncing() const { return syncRunning_.load(); } std::string HotReload::remoteSyncStatus() const { std::lock_guard lock(syncMu_); return syncStatus_; } std::string HotReload::pollRemoteChange() { std::lock_guard lock(syncMu_); if (changedQueue_.empty()) return {}; std::string p = std::move(changedQueue_.front()); changedQueue_.pop_front(); return p; } #endif // !EVENGINE_WEBGPU #ifdef EVENGINE_WEBGPU // WebGPU (browser) build: no Poco HTTP client / threads for remote sync. std::string HotReload::ensureHotDir() { return {}; } bool HotReload::mountHotDir() { return false; } bool HotReload::fetchManifest(std::vector &) { return false; } bool HotReload::downloadFile(const std::string &) { return false; } void HotReload::applyManifest(const std::vector &) {} std::map> HotReload::loadRecord() const { return {}; } void HotReload::saveRecord(const std::map> &) const {} void HotReload::syncLoop(int) {} void HotReload::queueChange(std::string) {} bool HotReload::startRemoteSync(std::string, int) { return false; } void HotReload::stopRemoteSync() {} void HotReload::setRemoteHotDir(std::string) {} bool HotReload::isRemoteSyncing() const { return false; } std::string HotReload::remoteSyncStatus() const { return "idle"; } std::string HotReload::pollRemoteChange() { return {}; } #endif // EVENGINE_WEBGPU void HotReload::expose(ssq::Table &table) { auto cls = table.addClass(name, HotReload::create, false); expose(cls); } void HotReload::expose(ssq::Class &cls) { cls.addFunc("getName", &HotReload::getName); cls.addFunc("bind", &HotReload::bind); cls.addFunc("unbind", &HotReload::unbind); cls.addFunc("tryReload", &HotReload::tryReload); cls.addFunc("watchTree", &HotReload::watchTree); cls.addFunc("startRemoteSync", &HotReload::startRemoteSync); cls.addFunc("stopRemoteSync", &HotReload::stopRemoteSync); cls.addFunc("isRemoteSyncing", &HotReload::isRemoteSyncing); cls.addFunc("remoteSyncStatus", &HotReload::remoteSyncStatus); cls.addFunc("pollRemoteChange", &HotReload::pollRemoteChange); } } // namespace eve::filesystem