Makefile: defer the WSL capability probe so make run no longer cold-starts the WSL VM (use wsl -l -q for make all). filesystem: file() now fails when the file is missing so file_exists() returns false (eve run from a dir without config.nut no longer dies at dofile). Add temporary [startup] stage timers and EVE_BOOT_BENCH auto-exit to profile boot time.
468 lines
15 KiB
C++
468 lines
15 KiB
C++
#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 <Poco/Exception.h>
|
|
#include <Poco/JSON/Array.h>
|
|
#include <Poco/JSON/Object.h>
|
|
#include <Poco/JSON/Parser.h>
|
|
#include <Poco/JSON/Stringifier.h>
|
|
#include <Poco/Net/HTTPClientSession.h>
|
|
#include <Poco/Net/HTTPRequest.h>
|
|
#include <Poco/Net/HTTPResponse.h>
|
|
#include <Poco/StreamCopier.h>
|
|
#include <Poco/Timespan.h>
|
|
#include <Poco/URI.h>
|
|
#endif
|
|
|
|
#include <simplesquirrel/simplesquirrel.hpp>
|
|
|
|
#if defined(EVENGINE_ANDROID)
|
|
#include "android/android.h"
|
|
#include <SDL2/SDL.h>
|
|
#elif defined(EVENGINE_IOS)
|
|
#include "ios/ios.h"
|
|
#endif
|
|
|
|
#include <cctype>
|
|
#include <chrono>
|
|
#include <cstdio>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
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<char>(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<std::streamsize>(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>([&](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<std::string> 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<std::string> 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<RemoteFile> &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<Poco::JSON::Array::Ptr>();
|
|
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<std::string>("path", "");
|
|
if (f.path.empty()) continue;
|
|
f.size = o->optValue<int64_t>("size", -1);
|
|
f.mtime = o->optValue<int64_t>("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<std::string, std::pair<int64_t, int64_t>> HotReload::loadRecord() const {
|
|
std::map<std::string, std::pair<int64_t, int64_t>> 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<Poco::JSON::Array::Ptr>();
|
|
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<std::string>("path", "");
|
|
if (path.empty()) continue;
|
|
record[path] = {o->optValue<int64_t>("size", -1), o->optValue<int64_t>("mtime", -1)};
|
|
}
|
|
} catch (...) {
|
|
}
|
|
return record;
|
|
}
|
|
|
|
void HotReload::saveRecord(const std::map<std::string, std::pair<int64_t, int64_t>> &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<std::mutex> lock(syncMu_);
|
|
changedQueue_.push_back(std::move(path));
|
|
}
|
|
|
|
void HotReload::applyManifest(const std::vector<RemoteFile> &manifest) {
|
|
auto record = loadRecord();
|
|
std::map<std::string, bool> 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<RemoteFile> manifest;
|
|
if (fetchManifest(manifest)) {
|
|
failStreak = 0;
|
|
{
|
|
std::lock_guard<std::mutex> l(syncMu_);
|
|
syncStatus_ = "syncing";
|
|
}
|
|
applyManifest(manifest);
|
|
{
|
|
std::lock_guard<std::mutex> l(syncMu_);
|
|
syncStatus_ = "synced";
|
|
}
|
|
} else {
|
|
++failStreak;
|
|
{
|
|
std::lock_guard<std::mutex> l(syncMu_);
|
|
syncStatus_ = failStreak > 3 ? "error:unreachable" : "idle";
|
|
}
|
|
}
|
|
|
|
std::unique_lock<std::mutex> l(syncMu_);
|
|
syncCv_.wait_for(l, std::chrono::milliseconds(pollMs), [this]() { return !syncRunning_.load(); });
|
|
}
|
|
{
|
|
std::lock_guard<std::mutex> l(syncMu_);
|
|
syncStatus_ = "idle";
|
|
}
|
|
}
|
|
|
|
bool HotReload::startRemoteSync(std::string url, int pollMs) {
|
|
std::lock_guard<std::mutex> 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<std::mutex> lock(syncMu_);
|
|
if (!syncStarted_) return;
|
|
syncRunning_.store(false);
|
|
}
|
|
syncCv_.notify_all();
|
|
if (syncThread_.joinable()) syncThread_.join();
|
|
std::lock_guard<std::mutex> lock(syncMu_);
|
|
syncStarted_ = false;
|
|
changedQueue_.clear();
|
|
}
|
|
|
|
bool HotReload::isRemoteSyncing() const { return syncRunning_.load(); }
|
|
|
|
std::string HotReload::remoteSyncStatus() const {
|
|
std::lock_guard<std::mutex> lock(syncMu_);
|
|
return syncStatus_;
|
|
}
|
|
|
|
std::string HotReload::pollRemoteChange() {
|
|
std::lock_guard<std::mutex> 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<RemoteFile> &) { return false; }
|
|
bool HotReload::downloadFile(const std::string &) { return false; }
|
|
void HotReload::applyManifest(const std::vector<RemoteFile> &) {}
|
|
std::map<std::string, std::pair<int64_t, int64_t>> HotReload::loadRecord() const { return {}; }
|
|
void HotReload::saveRecord(const std::map<std::string, std::pair<int64_t, int64_t>> &) 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
|