载入中...
搜索中...
未找到
HotReload.cpp
浏览该文件的文档.
2
4#include "common/Capability.h"
5#include "common/Module.h"
8
9#ifndef EVENGINE_WEBGPU
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>
20#include <Poco/URI.h>
21#endif
22
23#include <simplesquirrel/simplesquirrel.hpp>
24
25#if defined(EVENGINE_ANDROID)
26#include "android/android.h"
27#include <SDL2/SDL.h>
28#elif defined(EVENGINE_IOS)
29#include "ios/ios.h"
30#endif
31
32#include <cctype>
33#include <chrono>
34#include <cstdio>
35#include <filesystem>
36#include <fstream>
37#include <sstream>
38#include <string>
39#include <vector>
40
41namespace eve::filesystem {
42namespace {
43
44std::string joinDir(const std::string &dir, const std::string &name) {
45 if (dir.empty() || dir == ".") return name;
46 if (dir.back() == '/' || dir.back() == '\\') return dir + name;
47 return dir + "/" + name;
48}
49
50// --- Remote sync helpers ---
51
52#ifndef EVENGINE_WEBGPU
53std::string stripTrailingSlash(std::string s) {
54 while (s.size() > 1 && s.back() == '/') s.pop_back();
55 return s;
56}
57
58// Path-escape for use inside a URL path segment (spaces, '#', '?', etc.).
59std::string urlPathEscape(const std::string &s) {
60 std::string out;
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);
66 } else {
67 out += '%';
68 out += hex[c >> 4];
69 out += hex[c & 0xF];
70 }
71 }
72 return out;
73}
74
75// HTTP GET; returns 200 response body or empty on failure.
76std::string httpGet(const std::string &url, int timeoutMs) {
77 try {
78 Poco::URI uri(url);
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);
87 (void)os;
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);
93 return oss.str();
94 } catch (...) {
95 return {};
96 }
97}
98
99bool writeFileBytes(const std::string &realPath, const std::string &data) {
100 std::error_code ec;
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()));
106 return ofs.good();
107}
108#endif // !EVENGINE_WEBGPU
109
110} // namespace
111
113
115
116std::string HotReload::normalizePath(std::string path) {
117 for (char &c : path) {
118 if (c == '\\') c = '/';
119 }
120 while (path.size() >= 2 && path[0] == '.' && path[1] == '/') path.erase(0, 2);
121 while (path.size() > 1 && path.back() == '/') path.pop_back();
122 return path;
123}
124
125void HotReload::bind(std::string path, std::string kind) {
126 path = normalizePath(std::move(path));
127 if (path.empty()) return;
128 if (kind.empty()) kind = "auto";
129 bindings_[path] = kind;
130}
131
132void HotReload::unbind(std::string path) {
133 bindings_.erase(normalizePath(std::move(path)));
134}
135
136bool HotReload::tryReload(std::string path) {
137 const std::string norm = normalizePath(std::move(path));
138 if (norm.empty()) return false;
139
140 std::string kind = "auto";
141 auto bit = bindings_.find(norm);
142 if (bit != bindings_.end()) kind = bit->second;
143
144 bool any = false;
146 // "auto" lets each reloader claim the path by extension. An explicit
147 // kind targets exactly one reloader and ignores the extension, so a
148 // config file with an unexpected suffix can still be bound by hand.
149 const bool wanted = (kind == "auto") ? r->handlesPath(norm) : (kind == r->reloadKind());
150 if (wanted && r->reload(norm)) any = true;
151 });
152 return any;
153}
154
155int HotReload::watchTree(std::string root) {
156 StartupStage stage("hotreload: watchTree (recursive walk + register watches)");
157 auto *fs = Filesystem::create();
158 if (!fs) return 0;
159 root = normalizePath(std::move(root));
160 if (root.empty()) root = ".";
161
162 int added = 0;
163 std::vector<std::string> stack;
164 stack.push_back(root == "." ? std::string(".") : root);
165
166 while (!stack.empty()) {
167 std::string dir = stack.back();
168 stack.pop_back();
169 if (fs->watch(dir)) ++added;
170
171 std::vector<std::string> items;
172 try {
173 if (dir == "." || dir.empty())
174 items = fs->getDirectoryItems("");
175 else
176 items = fs->getDirectoryItems(dir);
177 } catch (...) {
178 continue;
179 }
180
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);
184 Filesystem::Info info{};
185 if (!fs->getInfo(child, info)) continue;
186 if (info.type == "directory") stack.push_back(child);
187 }
188 }
189 return added;
190}
191
192// --- Remote hot reload (dev-server sync) ---
193// Poco (HTTP) is not available in the Emscripten/WebGPU build; the API is
194// stubbed there so scripts still compile, and startRemoteSync just fails.
195
196#ifndef EVENGINE_WEBGPU
197
198std::string HotReload::ensureHotDir() {
199 if (!hotDir_.empty()) return hotDir_;
200
201 std::string base;
202#if defined(EVENGINE_ANDROID)
203 base = eve::android::getHotReloadDirectory();
204#elif defined(EVENGINE_IOS)
205 base = eve::ios::getHotReloadDirectory();
206#else
207 {
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";
212 }
213#endif
214 if (base.empty()) base = ".";
215 std::error_code ec;
216 std::filesystem::create_directories(base, ec);
217 hotDir_ = base;
218 return hotDir_;
219}
220
222 if (syncStarted_) return;
223 std::error_code ec;
224 std::filesystem::create_directories(dir, ec);
225 hotDir_ = std::move(dir);
226}
227
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, "/", /*appendToPath=*/false)) return false;
234 hotDirMounted_ = true;
235 return true;
236}
237
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;
242
243 try {
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);
250 if (!o) continue;
251 RemoteFile f;
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);
256 f.path = normalizePath(std::move(f.path));
257 if (!f.path.empty()) out.push_back(std::move(f));
258 }
259 return true;
260 } catch (...) {
261 return false;
262 }
263}
264
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);
271}
272
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;
279 oss << ifs.rdbuf();
280 try {
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);
287 if (!o) continue;
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)};
291 }
292 } catch (...) {
293 }
294 return record;
295}
296
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);
304 arr->add(o);
305 }
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());
310}
311
312void HotReload::queueChange(std::string path) {
313 std::lock_guard<std::mutex> lock(syncMu_);
314 changedQueue_.push_back(std::move(path));
315}
316
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)
324 continue; // unchanged
325 if (downloadFile(file.path)) {
326 record[file.path] = {file.size, file.mtime};
327 queueChange(file.path);
328 }
329 }
330 // Remove files that disappeared from the server.
331 for (auto it = record.begin(); it != record.end();) {
332 if (seen.find(it->first) != seen.end()) {
333 ++it;
334 continue;
335 }
336 const std::string real = joinDir(hotDir_, it->first);
337 std::error_code ec;
338 std::filesystem::remove(real, ec);
339 queueChange(it->first);
340 it = record.erase(it);
341 }
342 saveRecord(record);
343}
344
345void HotReload::syncLoop(int pollMs) {
346 int failStreak = 0;
347 while (syncRunning_.load()) {
348 std::vector<RemoteFile> manifest;
349 if (fetchManifest(manifest)) {
350 failStreak = 0;
351 {
352 std::lock_guard<std::mutex> l(syncMu_);
353 syncStatus_ = "syncing";
354 }
355 applyManifest(manifest);
356 {
357 std::lock_guard<std::mutex> l(syncMu_);
358 syncStatus_ = "synced";
359 }
360 } else {
361 ++failStreak;
362 {
363 std::lock_guard<std::mutex> l(syncMu_);
364 syncStatus_ = failStreak > 3 ? "error:unreachable" : "idle";
365 }
366 }
367
368 std::unique_lock<std::mutex> l(syncMu_);
369 syncCv_.wait_for(l, std::chrono::milliseconds(pollMs), [this]() { return !syncRunning_.load(); });
370 }
371 {
372 std::lock_guard<std::mutex> l(syncMu_);
373 syncStatus_ = "idle";
374 }
375}
376
377bool HotReload::startRemoteSync(std::string url, int pollMs) {
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;
383
384 syncUrl_ = std::move(url);
385 syncStatus_ = "syncing";
386 syncStarted_ = true;
387 hotDirMounted_ = false;
388 // Mount the overlay dir from the calling (main) thread; PhysFS mount is not
389 // guaranteed thread-safe, so never touch it from the sync thread.
390 if (!mountHotDir()) {
391 syncStarted_ = false;
392 syncStatus_ = "error:mount";
393 return false;
394 }
395 syncRunning_.store(true);
396 syncThread_ = std::thread([this, pollMs]() { syncLoop(pollMs); });
397 return true;
398}
399
401 {
402 std::lock_guard<std::mutex> lock(syncMu_);
403 if (!syncStarted_) return;
404 syncRunning_.store(false);
405 }
406 syncCv_.notify_all();
407 if (syncThread_.joinable()) syncThread_.join();
408 std::lock_guard<std::mutex> lock(syncMu_);
409 syncStarted_ = false;
410 changedQueue_.clear();
411}
412
413bool HotReload::isRemoteSyncing() const { return syncRunning_.load(); }
414
415std::string HotReload::remoteSyncStatus() const {
416 std::lock_guard<std::mutex> lock(syncMu_);
417 return syncStatus_;
418}
419
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();
425 return p;
426}
427
428#endif // !EVENGINE_WEBGPU
429
430#ifdef EVENGINE_WEBGPU
431// WebGPU (browser) build: no Poco HTTP client / threads for remote sync.
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) {}
441bool HotReload::startRemoteSync(std::string, int) { return false; }
443void HotReload::setRemoteHotDir(std::string) {}
444bool HotReload::isRemoteSyncing() const { return false; }
445std::string HotReload::remoteSyncStatus() const { return "idle"; }
446std::string HotReload::pollRemoteChange() { return {}; }
447#endif // EVENGINE_WEBGPU
448
449void HotReload::expose(ssq::Table &table) {
450 auto cls = table.addClass(name, HotReload::create, false);
451 expose(cls);
452}
453
454void HotReload::expose(ssq::Class &cls) {
455 cls.addFunc("getName", &HotReload::getName);
456 cls.addFunc("bind", &HotReload::bind);
457 cls.addFunc("unbind", &HotReload::unbind);
458 cls.addFunc("tryReload", &HotReload::tryReload);
459 cls.addFunc("watchTree", &HotReload::watchTree);
460 cls.addFunc("startRemoteSync", &HotReload::startRemoteSync);
461 cls.addFunc("stopRemoteSync", &HotReload::stopRemoteSync);
462 cls.addFunc("isRemoteSyncing", &HotReload::isRemoteSyncing);
463 cls.addFunc("remoteSyncStatus", &HotReload::remoteSyncStatus);
464 cls.addFunc("pollRemoteChange", &HotReload::pollRemoteChange);
465}
466
467} // namespace eve::filesystem
Tok kind
HSQOBJECT cls
Definition ECS.cpp:21
filesystem::File * file
JobFunc body
std::ostringstream & os
uint32_t c
#define Module_IMPL(ModuleName, newExpr)
Definition Module.h:24
float f
glm::vec4 p[6]
Light2D::Data * data
const char * name
Definition RockMesh.cpp:21
V3 dir
Definition TreeMesh.cpp:121
uint32_t s
Definition Weather.cpp:28
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...
Definition HotReload.h:34
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 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)
I * query()
Definition Capability.h:77