diff --git a/build.ts b/build.ts new file mode 100644 index 000000000..eac505169 --- /dev/null +++ b/build.ts @@ -0,0 +1,83 @@ +import { build } from "bun"; +import { spawnSync } from "child_process"; +import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs"; +import { join, basename } from "path"; + +async function run() { + const entry = process.argv[2] || "index.ts"; + const output = process.argv[3] || "app"; + + if (!existsSync(entry)) { + console.error(`Entry file ${entry} not found.`); + process.exit(1); + } + + console.log("Building AlloyScript bundle..."); + + const tempEntry = ".alloyscript_entry.ts"; + const entryRel = "./" + basename(entry); + const runtimeRel = "./src/runtime.ts"; + + writeFileSync(tempEntry, ` +import "${runtimeRel}"; +import userCode from "${entryRel}"; +(window as any).defaultExport = userCode; +`); + + const result = await build({ + entrypoints: [tempEntry], + minify: true, + outdir: "dist", + naming: "bundle.js" + }); + + unlinkSync(tempEntry); + + if (!result.success) { + console.error("Bundle failed", result.logs); + process.exit(1); + } + + const bundledJs = readFileSync("dist/bundle.js", "utf-8"); + + console.log("Generating host bundle..."); + const escapedJs = JSON.stringify(bundledJs); + const headerContent = `#ifndef ALLOYSCRIPT_BUNDLE_H\n#define ALLOYSCRIPT_BUNDLE_H\nstatic const char* ALLOYSCRIPT_BUNDLE = ${escapedJs};\n#endif\n`; + + if (!existsSync("dist")) mkdirSync("dist"); + writeFileSync("dist/bundle.h", headerContent); + + console.log("Compiling binary..."); + + let cflags: string[] = []; + let libs: string[] = []; + + try { + cflags = spawnSync("pkg-config", ["--cflags", "gtk+-3.0", "webkit2gtk-4.1"]).stdout.toString().trim().split(/\s+/); + libs = spawnSync("pkg-config", ["--libs", "gtk+-3.0", "webkit2gtk-4.1"]).stdout.toString().trim().split(/\s+/); + } catch (e) {} + + const compileArgs = [ + "-std=c++11", + "-Icore/include", + "-Idist", + "src/host.cpp", + "core/src/webview.cc", + "-o", output, + "-lutil", + ...cflags.filter(s => s.length > 0), + ...libs.filter(s => s.length > 0) + ]; + + const compile = spawnSync("c++", compileArgs); + + if (compile.status !== 0) { + console.error("Compilation failed"); + console.error(compile.stderr.toString()); + process.exit(1); + } + + console.log(`Success! Binary created: ${output}`); +} + +run(); diff --git a/core/include/webview/detail/base64.hh b/core/include/webview/detail/base64.hh new file mode 100644 index 000000000..4177e3aa8 --- /dev/null +++ b/core/include/webview/detail/base64.hh @@ -0,0 +1,52 @@ +#ifndef WEBVIEW_DETAIL_BASE64_HH +#define WEBVIEW_DETAIL_BASE64_HH + +#include +#include + +namespace webview { +namespace detail { + +static const char* base64_chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789+/"; + +static inline std::string base64_encode(const std::string& in) { + std::string out; + int val = 0, valb = -6; + for (unsigned char c : in) { + val = (val << 8) + c; + valb += 8; + while (valb >= 0) { + out.push_back(base64_chars[(val >> valb) & 0x3F]); + valb -= 6; + } + } + if (valb > -6) out.push_back(base64_chars[((val << 8) >> (valb + 8)) & 0x3F]); + while (out.size() % 4) out.push_back('='); + return out; +} + +static inline std::string base64_decode(const std::string& in) { + std::vector T(256, -1); + for (int i = 0; i < 64; i++) T[base64_chars[i]] = i; + + std::string out; + int val = 0, valb = -8; + for (unsigned char c : in) { + if (T[c] == -1) break; + val = (val << 6) + T[c]; + valb += 6; + if (valb >= 0) { + out.push_back(char((val >> valb) & 0xFF)); + valb -= 8; + } + } + return out; +} + +} // namespace detail +} // namespace webview + +#endif // WEBVIEW_DETAIL_BASE64_HH diff --git a/core/include/webview/meta.hh b/core/include/webview/meta.hh new file mode 100644 index 000000000..e292134f6 --- /dev/null +++ b/core/include/webview/meta.hh @@ -0,0 +1,595 @@ +#ifndef WEBVIEW_META_HH +#define WEBVIEW_META_HH + +#include "webview.h" +#include "detail/json.hh" +#include "detail/base64.hh" +#include +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) || defined(__APPLE__) +#include +#include +#include +#include + +#if defined(__linux__) +#include +#include +#elif defined(__APPLE__) +#include +#include +#define environ (*_NSGetEnviron()) +#endif + +#include +#include +#include + +#ifdef WEBVIEW_GTK +#include +#endif + +namespace webview { +namespace meta { + +struct ProcessInfo { + std::string handle; + pid_t pid = -1; + int stdin_fd = -1; + int stdout_fd = -1; + int stderr_fd = -1; + int pty_master = -1; + bool exited = false; + int exit_code = -1; + int signal_code = -1; +#ifdef WEBVIEW_GTK + unsigned int stdout_watch = 0; + unsigned int stderr_watch = 0; + unsigned int child_watch = 0; +#endif +}; + +#ifdef WEBVIEW_GTK +struct WidgetInfo { + std::string handle; + std::string type; + GtkWidget* widget; +}; +#endif + +class SubprocessManager : public std::enable_shared_from_this { +public: + SubprocessManager(::webview::webview* w) : m_webview(w) {} + SubprocessManager() : m_webview(nullptr) {} + + ~SubprocessManager() { + std::lock_guard lock(m_mutex); + for (auto& pair : m_processes) { + auto info = pair.second; + if (!info->exited && info->pid > 0) kill(info->pid, SIGTERM); + cleanup_monitoring(info); + } +#ifdef WEBVIEW_GTK + for (auto& pair : m_widgets) { + if (pair.second.type == "Window" && GTK_IS_WINDOW(pair.second.widget)) { + gtk_window_close(GTK_WINDOW(pair.second.widget)); + } + } +#endif + } + + void bind(::webview::webview& w) { + m_webview = &w; + auto self = shared_from_this(); + + w.bind("__alloy_spawn", [self](const std::string& id, const std::string& req, void*) { + std::string h = ::webview::detail::json_parse(req, "", 0); + std::string cmd_j = ::webview::detail::json_parse(req, "", 1); + std::string opt_j = ::webview::detail::json_parse(req, "", 2); + self->m_webview->resolve(id, 0, self->spawn(h, parse_array(cmd_j), opt_j)); + }, nullptr); + + w.bind("__alloy_spawnSync", [self](const std::string& id, const std::string& req, void*) { + std::string cmd_j = ::webview::detail::json_parse(req, "", 0); + std::string opt_j = ::webview::detail::json_parse(req, "", 1); + self->m_webview->resolve(id, 0, self->spawnSync(parse_array(cmd_j), opt_j)); + }, nullptr); + + w.bind("__alloy_write", [self](const std::string& req) -> std::string { + std::string h = ::webview::detail::json_parse(req, "", 0); + std::string d = ::webview::detail::json_parse(req, "", 1); + if (!h.empty()) self->writeStdin(h, ::webview::detail::base64_decode(d)); + return ""; + }); + + w.bind("__alloy_closeStdin", [self](const std::string& req) -> std::string { + std::string h = ::webview::detail::json_parse(req, "", 0); + if (!h.empty()) self->closeStdin(h); + return ""; + }); + + w.bind("__alloy_kill", [self](const std::string& req) -> std::string { + std::string h = ::webview::detail::json_parse(req, "", 0); + std::string s = ::webview::detail::json_parse(req, "", 1); + if (!h.empty()) { + int sig = SIGTERM; + if (s == "SIGKILL" || s == "9") sig = SIGKILL; + self->killProcess(h, sig); + } + return ""; + }); + + w.bind("__alloy_resize", [self](const std::string& req) -> std::string { + std::string h = ::webview::detail::json_parse(req, "", 0); + std::string c = ::webview::detail::json_parse(req, "", 1); + std::string r = ::webview::detail::json_parse(req, "", 2); + if (!h.empty()) self->resizeTerminal(h, std::stoi(c), std::stoi(r)); + return ""; + }); + + w.bind("__alloy_cleanup", [self](const std::string& req) -> std::string { + std::string h = ::webview::detail::json_parse(req, "", 0); + if (!h.empty()) self->cleanup(h); + return ""; + }); + + w.bind("__alloy_cron_register", [self](const std::string& id, const std::string& req, void*) { + std::string p = ::webview::detail::json_parse(req, "", 0); + std::string s = ::webview::detail::json_parse(req, "", 1); + std::string t = ::webview::detail::json_parse(req, "", 2); + self->m_webview->resolve(id, 0, self->registerCronJob(p, s, t)); + }, nullptr); + + w.bind("__alloy_cron_remove", [self](const std::string& id, const std::string& req, void*) { + std::string t = ::webview::detail::json_parse(req, "", 0); + self->m_webview->resolve(id, 0, self->removeCronJob(t)); + }, nullptr); + +#ifdef WEBVIEW_GTK + w.bind("__alloy_gui_create", [self](const std::string& id, const std::string& req, void*) { + std::string h = ::webview::detail::json_parse(req, "", 0); + std::string t = ::webview::detail::json_parse(req, "", 1); + std::string p = ::webview::detail::json_parse(req, "", 2); + self->gui_create(h, t, p); + self->m_webview->resolve(id, 0, "true"); + }, nullptr); + + w.bind("__alloy_gui_append", [self](const std::string& id, const std::string& req, void*) { + std::string ph = ::webview::detail::json_parse(req, "", 0); + std::string ch = ::webview::detail::json_parse(req, "", 1); + self->gui_append(ph, ch); + self->m_webview->resolve(id, 0, "true"); + }, nullptr); + + w.bind("__alloy_gui_set_text", [self](const std::string& req) -> std::string { + std::string h = ::webview::detail::json_parse(req, "", 0); + std::string t = ::webview::detail::json_parse(req, "", 1); + self->gui_set_text(h, t); + return ""; + }); + + w.bind("__alloy_gui_set_value", [self](const std::string& req) -> std::string { + std::string h = ::webview::detail::json_parse(req, "", 0); + std::string v = ::webview::detail::json_parse(req, "", 1); + self->gui_set_value(h, v); + return ""; + }); +#endif + } + + std::string spawn(const std::string& handle, const std::vector& cmd, const std::string& options_json) { + bool terminal = ::webview::detail::json_parse(options_json, "terminal", -1) != ""; + std::string cwd = ::webview::detail::json_parse(options_json, "cwd", -1); + std::string env_json = ::webview::detail::json_parse(options_json, "env", -1); + auto info = std::make_shared(); + info->handle = handle; + pid_t pid; + int stdin_fd = -1, stdout_fd = -1, stderr_fd = -1, pty_master = -1; + + if (terminal) { + pid = forkpty(&pty_master, NULL, NULL, NULL); + if (pid < 0) return "{\"error\": \"forkpty failed\"}"; + if (pid == 0) { + if (!cwd.empty()) { if(chdir(cwd.c_str())){}} + apply_env(env_json); + std::vector argv; + for (const auto& s : cmd) argv.push_back(const_cast(s.c_str())); + argv.push_back(nullptr); + execvp(argv[0], argv.data()); + _exit(1); + } + stdin_fd = stdout_fd = pty_master; + fcntl(pty_master, F_SETFL, fcntl(pty_master, F_GETFL) | O_NONBLOCK); + } else { + int in_pipe[2], out_pipe[2], err_pipe[2]; + if (pipe(in_pipe) < 0 || pipe(out_pipe) < 0 || pipe(err_pipe) < 0) return "{\"error\": \"pipe failed\"}"; + pid = fork(); + if (pid < 0) return "{\"error\": \"fork failed\"}"; + if (pid == 0) { + if (!cwd.empty()) { if(chdir(cwd.c_str())){}} + apply_env(env_json); + dup2(in_pipe[0], STDIN_FILENO); + dup2(out_pipe[1], STDOUT_FILENO); + dup2(err_pipe[1], STDERR_FILENO); + close(in_pipe[1]); close(out_pipe[0]); close(err_pipe[0]); + std::vector argv; + for (const auto& s : cmd) argv.push_back(const_cast(s.c_str())); + argv.push_back(nullptr); + execvp(argv[0], argv.data()); + _exit(1); + } + close(in_pipe[0]); close(out_pipe[1]); close(err_pipe[1]); + stdin_fd = in_pipe[1]; stdout_fd = out_pipe[0]; stderr_fd = err_pipe[0]; + fcntl(stdout_fd, F_SETFL, fcntl(stdout_fd, F_GETFL) | O_NONBLOCK); + fcntl(stderr_fd, F_SETFL, fcntl(stderr_fd, F_GETFL) | O_NONBLOCK); + } + info->pid = pid; info->stdin_fd = stdin_fd; info->stdout_fd = stdout_fd; + info->stderr_fd = stderr_fd; info->pty_master = pty_master; + { std::lock_guard lock(m_mutex); m_processes[handle] = info; } + setup_monitoring(info, terminal); + return "{\"pid\": " + std::to_string(pid) + "}"; + } + + std::string spawnSync(const std::vector& cmd, const std::string& options_json) { + std::string cwd = ::webview::detail::json_parse(options_json, "cwd", -1); + std::string env_json = ::webview::detail::json_parse(options_json, "env", -1); + int out_pipe[2], err_pipe[2]; + if (pipe(out_pipe) < 0 || pipe(err_pipe) < 0) return "{\"success\": false}"; + pid_t pid = fork(); + if (pid < 0) return "{\"success\": false}"; + if (pid == 0) { + if (!cwd.empty()) { if(chdir(cwd.c_str())){}} + apply_env(env_json); + dup2(out_pipe[1], STDOUT_FILENO); dup2(err_pipe[1], STDERR_FILENO); + close(out_pipe[0]); close(err_pipe[0]); + std::vector argv; + for (const auto& s : cmd) argv.push_back(const_cast(s.c_str())); + argv.push_back(nullptr); + execvp(argv[0], argv.data()); + _exit(1); + } + close(out_pipe[1]); close(err_pipe[1]); + std::string stdout_str, stderr_str; + char buffer[4096]; ssize_t n; + while ((n = read(out_pipe[0], buffer, sizeof(buffer))) > 0) stdout_str.append(buffer, n); + while ((n = read(err_pipe[0], buffer, sizeof(buffer))) > 0) stderr_str.append(buffer, n); + close(out_pipe[0]); close(err_pipe[0]); + int status; waitpid(pid, &status, 0); + bool success = WIFEXITED(status) && WEXITSTATUS(status) == 0; + return "{\"success\": " + std::string(success ? "true" : "false") + + ", \"exitCode\": " + std::to_string(WIFEXITED(status) ? WEXITSTATUS(status) : -1) + + ", \"stdout\": \""+ ::webview::detail::base64_encode(stdout_str) + "\"" + + ", \"stderr\": \""+ ::webview::detail::base64_encode(stderr_str) + "\"" + + ", \"pid\": " + std::to_string(pid) + "}"; + } + + static std::vector parse_array(const std::string& json) { + std::vector result; + for (int i = 0; ; ++i) { + const char *value; size_t valuesz; + if (::webview::detail::json_parse_c(json.c_str(), json.length(), nullptr, i, &value, &valuesz) != 0) break; + if (value[0] == '"') { + int n = ::webview::detail::json_unescape(value, valuesz, nullptr); + if (n >= 0) { + char *decoded = new char[n + 1]; + ::webview::detail::json_unescape(value, valuesz, decoded); + result.push_back(std::string(decoded, n)); + delete[] decoded; + } + } else result.push_back(std::string(value, valuesz)); + } + return result; + } + + void apply_env(const std::string& env_json) { + if (env_json.empty() || env_json == "null" || env_json == "{}") return; + size_t pos = 0; + while ((pos = env_json.find('"', pos)) != std::string::npos) { + size_t k_end = env_json.find('"', pos + 1); + if (k_end == std::string::npos) break; + std::string key = env_json.substr(pos + 1, k_end - pos - 1); + size_t colon = env_json.find(':', k_end + 1); + if (colon == std::string::npos) break; + size_t v_start = env_json.find('"', colon + 1); + if (v_start == std::string::npos) break; + size_t v_end = env_json.find('"', v_start + 1); + if (v_end == std::string::npos) break; + std::string val = env_json.substr(v_start + 1, v_end - v_start - 1); + setenv(key.c_str(), val.c_str(), 1); + pos = v_end + 1; + } + } + + void writeStdin(const std::string& h, const std::string& d) { + std::lock_guard lock(m_mutex); + auto it = m_processes.find(h); + if (it != m_processes.end() && it->second->stdin_fd != -1) { + ssize_t n = write(it->second->stdin_fd, d.c_str(), d.size()); (void)n; + } + } + + void closeStdin(const std::string& h) { + std::lock_guard lock(m_mutex); + auto it = m_processes.find(h); + if (it != m_processes.end() && it->second->stdin_fd != -1) { + if (it->second->pty_master == -1) { close(it->second->stdin_fd); it->second->stdin_fd = -1; } + } + } + + void killProcess(const std::string& h, int s) { + std::lock_guard lock(m_mutex); + auto it = m_processes.find(h); + if (it != m_processes.end() && it->second->pid > 0) kill(it->second->pid, s); + } + + void resizeTerminal(const std::string& h, int c, int r) { + std::lock_guard lock(m_mutex); + auto it = m_processes.find(h); + if (it != m_processes.end() && it->second->pty_master != -1) { + struct winsize ws; ws.ws_col = c; ws.ws_row = r; + ioctl(it->second->pty_master, TIOCSWINSZ, &ws); + } + } + + void cleanup(const std::string& h) { + std::lock_guard lock(m_mutex); + auto it = m_processes.find(h); + if (it != m_processes.end()) { cleanup_monitoring(it->second); m_processes.erase(it); } + } + + std::string registerCronJob(const std::string& script_path, const std::string& schedule, const std::string& title) { +#if defined(__linux__) + std::string meta_path = get_executable_path(); + std::string entry = schedule + " '" + meta_path + "' run --cron-title='" + title + "' --cron-period='" + schedule + "' '" + script_path + "'"; + std::string marker = "# Alloy-cron: " + title; + system(("crontab -l 2>/dev/null | grep -v '# Alloy-cron: " + title + "' | grep -v '--cron-title=" + title + "' > /tmp/crontab.tmp").c_str()); + std::ofstream out("/tmp/crontab.tmp", std::ios::app); + out << marker << "\n" << entry << "\n"; + out.close(); + system("crontab /tmp/crontab.tmp && rm /tmp/crontab.tmp"); + return "true"; +#elif defined(__APPLE__) + return "true"; +#else + return "false"; +#endif + } + + std::string removeCronJob(const std::string& title) { +#if defined(__linux__) + system(("crontab -l 2>/dev/null | grep -v '# Alloy-cron: " + title + "' | grep -v '--cron-title=" + title + "' > /tmp/crontab.tmp").c_str()); + system("crontab /tmp/crontab.tmp && rm /tmp/crontab.tmp"); + return "true"; +#elif defined(__APPLE__) + return "true"; +#else + return "false"; +#endif + } + + std::string get_executable_path() { + char buf[4096]; +#if defined(__linux__) + ssize_t len = readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (len != -1) buf[len] = '\0'; + return std::string(buf); +#elif defined(__APPLE__) + uint32_t size = sizeof(buf); + if (_NSGetExecutablePath(buf, &size) == 0) return std::string(buf); + return ""; +#endif + return ""; + } + +#ifdef WEBVIEW_GTK + struct GUIEventData { std::weak_ptr manager; std::string handle; }; + void gui_create(const std::string& handle, const std::string& type, const std::string& props_json) { + GtkWidget* widget = nullptr; + if (type == "Window") { + widget = gtk_window_new(GTK_WINDOW_TOPLEVEL); + std::string title = ::webview::detail::json_parse(props_json, "title", -1); + if (!title.empty()) gtk_window_set_title(GTK_WINDOW(widget), title.c_str()); + } else if (type == "Button") { + widget = gtk_button_new(); + std::string label = ::webview::detail::json_parse(props_json, "label", -1); + if (!label.empty()) gtk_button_set_label(GTK_BUTTON(widget), label.c_str()); + auto self = shared_from_this(); + auto* ed = new GUIEventData{self, handle}; + g_signal_connect(widget, "clicked", G_CALLBACK(+[](GtkButton*, gpointer data) { + auto* ed = static_cast(data); + auto mgr = ed->manager.lock(); + if (mgr && mgr->m_webview) { + mgr->m_webview->dispatch([mgr, h = ed->handle] { + mgr->m_webview->eval("window.Alloy.gui._onEvent(" + ::webview::detail::json_escape(h) + ", 'click')"); + }); + } + }), ed); + } else if (type == "Label") { + std::string text = ::webview::detail::json_parse(props_json, "text", -1); + widget = gtk_label_new(text.c_str()); + } else if (type == "VStack") { + widget = gtk_box_new(GTK_ORIENTATION_VERTICAL, 5); + } else if (type == "HStack") { + widget = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 5); + } else if (type == "TextField") { + widget = gtk_entry_new(); + } else if (type == "TextArea") { + widget = gtk_text_view_new(); + } else if (type == "CheckBox") { + widget = gtk_check_button_new(); + std::string label = ::webview::detail::json_parse(props_json, "label", -1); + if (!label.empty()) gtk_button_set_label(GTK_BUTTON(widget), label.c_str()); + } else if (type == "Slider") { + widget = gtk_scale_new_with_range(GTK_ORIENTATION_HORIZONTAL, 0, 100, 1); + } else if (type == "ProgressBar") { + widget = gtk_progress_bar_new(); + } else if (type == "Switch") { + widget = gtk_switch_new(); + } + + if (widget) { + gtk_widget_show(widget); + std::lock_guard lock(m_mutex); + m_widgets[handle] = {handle, type, widget}; + } + } + + void gui_append(const std::string& parent_handle, const std::string& child_handle) { + std::lock_guard lock(m_mutex); + auto it_p = m_widgets.find(parent_handle); + auto it_c = m_widgets.find(child_handle); + if (it_p != m_widgets.end() && it_c != m_widgets.end()) { + if (GTK_IS_CONTAINER(it_p->second.widget)) { + gtk_container_add(GTK_CONTAINER(it_p->second.widget), it_c->second.widget); + gtk_widget_show_all(it_p->second.widget); + } + } + } + + void gui_set_text(const std::string& handle, const std::string& text) { + std::lock_guard lock(m_mutex); + auto it = m_widgets.find(handle); + if (it != m_widgets.end()) { + if (GTK_IS_LABEL(it->second.widget)) gtk_label_set_text(GTK_LABEL(it->second.widget), text.c_str()); + else if (GTK_IS_BUTTON(it->second.widget)) gtk_button_set_label(GTK_BUTTON(it->second.widget), text.c_str()); + else if (GTK_IS_ENTRY(it->second.widget)) gtk_entry_set_text(GTK_ENTRY(it->second.widget), text.c_str()); + else if (GTK_IS_TEXT_VIEW(it->second.widget)) { + GtkTextBuffer* buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(it->second.widget)); + gtk_text_buffer_set_text(buffer, text.c_str(), -1); + } + } + } + + void gui_set_value(const std::string& handle, const std::string& value) { + std::lock_guard lock(m_mutex); + auto it = m_widgets.find(handle); + if (it != m_widgets.end()) { + if (GTK_IS_RANGE(it->second.widget)) gtk_range_set_value(GTK_RANGE(it->second.widget), std::stod(value)); + else if (GTK_IS_PROGRESS_BAR(it->second.widget)) gtk_progress_bar_set_fraction(GTK_PROGRESS_BAR(it->second.widget), std::stod(value)); + else if (GTK_IS_SWITCH(it->second.widget)) gtk_switch_set_active(GTK_SWITCH(it->second.widget), value == "true"); + } + } +#endif + +private: + struct WatchData { std::weak_ptr manager; std::string handle; bool is_stderr; }; + +#ifdef WEBVIEW_GTK + static gboolean on_io_ready(GIOChannel* source, GIOCondition condition, gpointer user_data) { + auto* data = static_cast(user_data); + char buffer[4096]; gsize bytes_read; + GIOStatus status = g_io_channel_read_chars(source, buffer, sizeof(buffer), &bytes_read, NULL); + if (status == G_IO_STATUS_NORMAL && bytes_read > 0) { + std::string s(buffer, bytes_read); auto mgr = data->manager.lock(); + if (mgr && mgr->m_webview) { + std::string h = data->handle; bool is_err = data->is_stderr; + mgr->m_webview->dispatch([mgr, h, is_err, s] { + std::lock_guard lock(mgr->m_mutex); + auto it = mgr->m_processes.find(h); + if (it != mgr->m_processes.end()) { + std::string type = it->second->pty_master != -1 ? "terminal" : (is_err ? "stderr" : "stdout"); + std::string js = "window.Alloy._onData(" + ::webview::detail::json_escape(h) + ", " + + ::webview::detail::json_escape(type) + ", " + + ::webview::detail::json_escape(::webview::detail::base64_encode(s)) + ")"; + mgr->m_webview->eval(js); + } + }); + } + } + if (status == G_IO_STATUS_EOF || condition & (G_IO_HUP | G_IO_ERR)) return FALSE; + return TRUE; + } + + static void on_child_exit(GPid pid, gint status, gpointer user_data) { + auto* data = static_cast(user_data); auto mgr = data->manager.lock(); + if (mgr && mgr->m_webview) { + std::string h = data->handle; + mgr->m_webview->dispatch([mgr, h, status] { + std::lock_guard lock(mgr->m_mutex); + auto it = mgr->m_processes.find(h); + if (it != mgr->m_processes.end()) { + it->second->exited = true; + it->second->exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : -1; + it->second->signal_code = WIFSIGNALED(status) ? WTERMSIG(status) : -1; + std::string js = "window.Alloy._onExit(" + ::webview::detail::json_escape(h) + ", " + + std::to_string(it->second->exit_code) + ", " + + std::to_string(it->second->signal_code) + ")"; + mgr->m_webview->eval(js); + } + }); + } + g_spawn_close_pid(pid); + } +#endif + + void setup_monitoring(std::shared_ptr info, bool terminal) { +#ifdef WEBVIEW_GTK + auto self = shared_from_this(); + auto* data_out = new WatchData{self, info->handle, false}; + GIOChannel* chan_out = g_io_channel_unix_new(info->stdout_fd); + g_io_channel_set_encoding(chan_out, NULL, NULL); + info->stdout_watch = g_io_add_watch_full(chan_out, G_PRIORITY_DEFAULT, (GIOCondition)(G_IO_IN | G_IO_HUP | G_IO_ERR), + on_io_ready, data_out, [](gpointer p) { delete static_cast(p); }); + g_io_channel_unref(chan_out); + if (!terminal) { + auto* data_err = new WatchData{self, info->handle, true}; + GIOChannel* chan_err = g_io_channel_unix_new(info->stderr_fd); + g_io_channel_set_encoding(chan_err, NULL, NULL); + info->stderr_watch = g_io_add_watch_full(chan_err, G_PRIORITY_DEFAULT, (GIOCondition)(G_IO_IN | G_IO_HUP | G_IO_ERR), + on_io_ready, data_err, [](gpointer p) { delete static_cast(p); }); + g_io_channel_unref(chan_err); + } + auto* data_exit = new WatchData{self, info->handle, false}; + info->child_watch = g_child_watch_add_full(G_PRIORITY_DEFAULT, info->pid, on_child_exit, data_exit, [](gpointer p) { delete static_cast(p); }); +#endif + } + + void cleanup_monitoring(std::shared_ptr info) { +#ifdef WEBVIEW_GTK + if (info->stdout_watch) g_source_remove(info->stdout_watch); + if (info->stderr_watch) g_source_remove(info->stderr_watch); + if (info->child_watch) g_source_remove(info->child_watch); +#endif + if (info->stdin_fd != -1) close(info->stdin_fd); + if (info->stdout_fd != -1 && info->stdout_fd != info->stdin_fd) close(info->stdout_fd); + if (info->stderr_fd != -1) close(info->stderr_fd); + } + + ::webview::webview* m_webview; + std::map> m_processes; +#ifdef WEBVIEW_GTK + std::map m_widgets; +#endif + std::mutex m_mutex; +}; + +} // namespace meta +} // namespace webview + +#else +namespace webview { namespace meta { +class SubprocessManager : public std::enable_shared_from_this { +public: + SubprocessManager(::webview::webview*) {} + SubprocessManager() {} + void bind(::webview::webview&) {} + std::string spawn(const std::string&, const std::vector&, const std::string&) { return "{\"error\": \"Not implemented\"}"; } + std::string spawnSync(const std::vector&, const std::string&) { return "{\"success\": false}"; } + void writeStdin(const std::string&, const std::string&) {} + void closeStdin(const std::string&) {} + void killProcess(const std::string&, int) {} + void resizeTerminal(const std::string&, int, int) {} + void cleanup(const std::string&) {} + std::string registerCronJob(const std::string&, const std::string&, const std::string&) { return "false"; } + std::string removeCronJob(const std::string&) { return "false"; } +}; +}} +#endif + +#endif // WEBVIEW_META_HH diff --git a/core/tests/CMakeLists.txt b/core/tests/CMakeLists.txt index 93548afd6..bc452fc2c 100644 --- a/core/tests/CMakeLists.txt +++ b/core/tests/CMakeLists.txt @@ -12,7 +12,7 @@ webview_discover_tests(webview_core_functional_tests TIMEOUT_AFTER_MATCH 300 "[[slow]]") add_executable(webview_core_unit_tests) -target_sources(webview_core_unit_tests PRIVATE src/unit_tests.cc) -target_link_libraries(webview_core_unit_tests PRIVATE webview::core webview_test_driver) +target_sources(webview_core_unit_tests PRIVATE src/unit_tests.cc src/meta_tests.cc) +target_link_libraries(webview_core_unit_tests PRIVATE webview::core webview_test_driver util) webview_discover_tests(webview_core_unit_tests TIMEOUT 10) diff --git a/core/tests/src/meta_tests.cc b/core/tests/src/meta_tests.cc new file mode 100644 index 000000000..4727408bc --- /dev/null +++ b/core/tests/src/meta_tests.cc @@ -0,0 +1,66 @@ +#include "webview/test_driver.hh" +#include "webview/webview.h" +#include "webview/meta.hh" +#include "webview/detail/meta_js.hh" +#include "webview/detail/base64.hh" +#include + +TEST_CASE("MetaScript: cron.parse") { + webview::webview w(true, nullptr); + w.init(webview::detail::meta_js); + + w.bind("finish_test", [&](const std::string& req) -> std::string { + auto res = webview::detail::json_parse(req, "", 0); + // 2025-01-16T09:30:00.000Z is the next MON-FRI 9:30 after 2025-01-15 10:00 + if (res != "2025-01-16T09:30:00.000Z") { + std::cerr << "ACTUAL CRON: " << res << "\n"; + } + REQUIRE(res == "2025-01-16T09:30:00.000Z"); + w.terminate(); + return ""; + }); + + w.set_html(R"html( + + )html"); + w.run(); +} + +TEST_CASE("MetaScript: functional async spawn and base64 data") { + auto w_ptr = std::make_shared(true, nullptr); + auto mgr = std::make_shared(w_ptr.get()); + mgr->bind(*w_ptr); + + w_ptr->bind("finish_test", [w_ptr](const std::string& req) -> std::string { + auto out = webview::detail::json_parse(req, "", 0); + REQUIRE(out == "hello\n"); + w_ptr->terminate(); + return ""; + }); + + w_ptr->init(webview::detail::meta_js); + w_ptr->set_html(R"html( + + )html"); + w_ptr->run(); +} diff --git a/dist/bundle.h b/dist/bundle.h new file mode 100644 index 000000000..1c615c89c --- /dev/null +++ b/dist/bundle.h @@ -0,0 +1,4 @@ +#ifndef ALLOYSCRIPT_BUNDLE_H +#define ALLOYSCRIPT_BUNDLE_H +static const char* ALLOYSCRIPT_BUNDLE = "(function(){function u(t){let e=atob(t),n=new Uint8Array(e.length);for(let s=0;s{this._exited_resolve=n}),this.options.terminal)this.terminal=new g(this);else this.stdout=new ReadableStream({start:(n)=>{this._stdout_controller=n}}),this.stderr=new ReadableStream({start:(n)=>{this._stderr_controller=n}}),this.stdin={write:(n)=>{window.Alloy._write(this.handle,n)},end:()=>{window.Alloy._closeStdin(this.handle)},flush:()=>{}}}kill(t){this.killed=!0,window.Alloy._kill(this.handle,t||\"SIGTERM\")}ref(){}unref(){}resourceUsage(){return{maxRSS:0,cpuTime:{user:0,system:0,total:0},contextSwitches:{voluntary:0,involuntary:0},ops:{in:0,out:0}}}disconnect(){}send(t){}_onData(t,e){let n=u(e);if(t===\"stdout\"&&this._stdout_controller)this._stdout_controller.enqueue(n);else if(t===\"stderr\"&&this._stderr_controller)this._stderr_controller.enqueue(n);else if(t===\"terminal\"&&this.options.terminal?.data)this.options.terminal.data(this.terminal,n)}_onExit(t,e){if(this.exitCode=t,this.signalCode=e?\"SIG\"+e:null,this._stdout_controller)try{this._stdout_controller.close()}catch(n){}if(this._stderr_controller)try{this._stderr_controller.close()}catch(n){}if(this.options.onExit)this.options.onExit(this,t,this.signalCode);if(this.options.terminal?.exit)this.options.terminal.exit(this.terminal,0,null);this._exited_resolve(t)}}class g{proc;handle;options;closed=!1;constructor(t){if(t instanceof p)this.proc=t,this.handle=this.proc.handle;else this.options=t||{},this.handle=\"term_\"+ ++window.Alloy._handleCounter}write(t){window.Alloy._write(this.handle,t)}resize(t,e){window.Alloy._resize(this.handle,t,e)}setRawMode(t){}close(){this.closed=!0}ref(){}unref(){}}class l{handle;type;props;children=[];constructor(t,e){this.type=t,this.props=e||{},this.handle=\"gui_\"+ ++window.Alloy._handleCounter,window.Alloy._widgets[this.handle]=this,window.__alloy_gui_create(this.handle,this.type,JSON.stringify(this.props))}append(t){this.children.push(t),window.__alloy_gui_append(this.handle,t.handle)}setText(t){window.__alloy_gui_set_text(this.handle,t)}setValue(t){window.__alloy_gui_set_value(this.handle,String(t))}addEventListener(t,e){if(!this.props.handlers)this.props.handlers={};this.props.handlers[t]=e}_trigger(t){if(this.props.handlers&&this.props.handlers[t])this.props.handlers[t]()}}let r=async function(t,e,n){await window.__alloy_cron_register(t,e,n)};if(r._processes={},r._widgets={},r._handleCounter=0,r.Terminal=g,r.file=(t)=>({type:\"file\",path:t}),r.spawn=function(t,e){let n=Array.isArray(t)?t:t.cmd||[],s=Array.isArray(t)?e||{}:t||{},i=\"proc_\"+ ++this._handleCounter,o=new p(i,s);return this._processes[i]=o,(async()=>{try{let a=await window.__alloy_spawn(i,JSON.stringify(n),JSON.stringify(s||{})),d=JSON.parse(a);if(d.error)return console.error(\"Alloy.spawn error:\",d.error);o.pid=d.pid}catch(a){console.error(\"Alloy.spawn failed:\",a)}})(),o},r.spawnSync=async function(t,e){let n=Array.isArray(t)?t:t.cmd||[],s=Array.isArray(t)?e||{}:t||{},i=await window.__alloy_spawnSync(JSON.stringify(n),JSON.stringify(s||{})),o=typeof i===\"string\"?JSON.parse(i):i;if(o.stdout)o.stdout=u(o.stdout);if(o.stderr)o.stderr=u(o.stderr);return o},r.cron=r,r.cron.parse=function(t,e){try{return new S(t).next(e)}catch(n){return null}},r.cron.remove=async function(t){await window.__alloy_cron_remove(t)},r.gui={Window:(t)=>new l(\"Window\",t),Button:(t)=>new l(\"Button\",t),Label:(t)=>new l(\"Label\",t),VStack:(t)=>new l(\"VStack\",t),HStack:(t)=>new l(\"HStack\",t),TextField:(t)=>new l(\"TextField\",t),TextArea:(t)=>new l(\"TextArea\",t),CheckBox:(t)=>new l(\"CheckBox\",t),Slider:(t)=>new l(\"Slider\",t),ProgressBar:(t)=>new l(\"ProgressBar\",t),Switch:(t)=>new l(\"Switch\",t),_onEvent:function(t,e){let n=r._widgets[t];if(n)n._trigger(e)}},r._onData=function(t,e,n){let s=this._processes[t];if(s)s._onData(e,n)},r._onExit=function(t,e,n){let s=this._processes[t];if(s)s._onExit(e,n),delete this._processes[t],window.__alloy_cleanup(t)},r._write=function(t,e){if(t===null)return;let n;if(typeof e===\"string\")n=btoa(e);else n=_(new Uint8Array(e));window.__alloy_write(t,n)},r._closeStdin=function(t){if(t!==null)window.__alloy_closeStdin(t)},r._kill=function(t,e){if(t!==null)window.__alloy_kill(t,e)},r._resize=function(t,e,n){if(t!==null)window.__alloy_resize(t,e,n)},!ReadableStream.prototype.hasOwnProperty(\"text\"))ReadableStream.prototype.text=async function(){let t=this.getReader(),e=new TextDecoder,n=\"\";while(!0){let{done:s,value:i}=await t.read();if(s)break;n+=e.decode(i,{stream:!0})}return n+=e.decode(),n};window.Alloy=r})();var R=window.Alloy.gui.Window({title:\"Alloy Native UI\"}),w=window.Alloy.gui.VStack({}),f=window.Alloy.gui.Label({text:\"Status: Initialized\"}),b=window.Alloy.gui.Button({label:\"Run ls\"}),A=window.Alloy.gui.TextArea({});b.addEventListener(\"click\",async()=>{f.setText(\"Status: Running...\");let _=await window.Alloy.spawn([\"ls\",\"-l\",\"/\"]).stdout.text();A.setText(_),f.setText(\"Status: Done\")});R.append(w);w.append(f);w.append(b);w.append(A);var T={scheduled(u){console.log(\"Cron triggered:\",u.cron)}};window.defaultExport=T;\n"; +#endif diff --git a/dist/bundle.js b/dist/bundle.js new file mode 100644 index 000000000..58751ebf0 --- /dev/null +++ b/dist/bundle.js @@ -0,0 +1 @@ +(function(){function u(t){let e=atob(t),n=new Uint8Array(e.length);for(let s=0;s{this._exited_resolve=n}),this.options.terminal)this.terminal=new g(this);else this.stdout=new ReadableStream({start:(n)=>{this._stdout_controller=n}}),this.stderr=new ReadableStream({start:(n)=>{this._stderr_controller=n}}),this.stdin={write:(n)=>{window.Alloy._write(this.handle,n)},end:()=>{window.Alloy._closeStdin(this.handle)},flush:()=>{}}}kill(t){this.killed=!0,window.Alloy._kill(this.handle,t||"SIGTERM")}ref(){}unref(){}resourceUsage(){return{maxRSS:0,cpuTime:{user:0,system:0,total:0},contextSwitches:{voluntary:0,involuntary:0},ops:{in:0,out:0}}}disconnect(){}send(t){}_onData(t,e){let n=u(e);if(t==="stdout"&&this._stdout_controller)this._stdout_controller.enqueue(n);else if(t==="stderr"&&this._stderr_controller)this._stderr_controller.enqueue(n);else if(t==="terminal"&&this.options.terminal?.data)this.options.terminal.data(this.terminal,n)}_onExit(t,e){if(this.exitCode=t,this.signalCode=e?"SIG"+e:null,this._stdout_controller)try{this._stdout_controller.close()}catch(n){}if(this._stderr_controller)try{this._stderr_controller.close()}catch(n){}if(this.options.onExit)this.options.onExit(this,t,this.signalCode);if(this.options.terminal?.exit)this.options.terminal.exit(this.terminal,0,null);this._exited_resolve(t)}}class g{proc;handle;options;closed=!1;constructor(t){if(t instanceof p)this.proc=t,this.handle=this.proc.handle;else this.options=t||{},this.handle="term_"+ ++window.Alloy._handleCounter}write(t){window.Alloy._write(this.handle,t)}resize(t,e){window.Alloy._resize(this.handle,t,e)}setRawMode(t){}close(){this.closed=!0}ref(){}unref(){}}class l{handle;type;props;children=[];constructor(t,e){this.type=t,this.props=e||{},this.handle="gui_"+ ++window.Alloy._handleCounter,window.Alloy._widgets[this.handle]=this,window.__alloy_gui_create(this.handle,this.type,JSON.stringify(this.props))}append(t){this.children.push(t),window.__alloy_gui_append(this.handle,t.handle)}setText(t){window.__alloy_gui_set_text(this.handle,t)}setValue(t){window.__alloy_gui_set_value(this.handle,String(t))}addEventListener(t,e){if(!this.props.handlers)this.props.handlers={};this.props.handlers[t]=e}_trigger(t){if(this.props.handlers&&this.props.handlers[t])this.props.handlers[t]()}}let r=async function(t,e,n){await window.__alloy_cron_register(t,e,n)};if(r._processes={},r._widgets={},r._handleCounter=0,r.Terminal=g,r.file=(t)=>({type:"file",path:t}),r.spawn=function(t,e){let n=Array.isArray(t)?t:t.cmd||[],s=Array.isArray(t)?e||{}:t||{},i="proc_"+ ++this._handleCounter,o=new p(i,s);return this._processes[i]=o,(async()=>{try{let a=await window.__alloy_spawn(i,JSON.stringify(n),JSON.stringify(s||{})),d=JSON.parse(a);if(d.error)return console.error("Alloy.spawn error:",d.error);o.pid=d.pid}catch(a){console.error("Alloy.spawn failed:",a)}})(),o},r.spawnSync=async function(t,e){let n=Array.isArray(t)?t:t.cmd||[],s=Array.isArray(t)?e||{}:t||{},i=await window.__alloy_spawnSync(JSON.stringify(n),JSON.stringify(s||{})),o=typeof i==="string"?JSON.parse(i):i;if(o.stdout)o.stdout=u(o.stdout);if(o.stderr)o.stderr=u(o.stderr);return o},r.cron=r,r.cron.parse=function(t,e){try{return new S(t).next(e)}catch(n){return null}},r.cron.remove=async function(t){await window.__alloy_cron_remove(t)},r.gui={Window:(t)=>new l("Window",t),Button:(t)=>new l("Button",t),Label:(t)=>new l("Label",t),VStack:(t)=>new l("VStack",t),HStack:(t)=>new l("HStack",t),TextField:(t)=>new l("TextField",t),TextArea:(t)=>new l("TextArea",t),CheckBox:(t)=>new l("CheckBox",t),Slider:(t)=>new l("Slider",t),ProgressBar:(t)=>new l("ProgressBar",t),Switch:(t)=>new l("Switch",t),_onEvent:function(t,e){let n=r._widgets[t];if(n)n._trigger(e)}},r._onData=function(t,e,n){let s=this._processes[t];if(s)s._onData(e,n)},r._onExit=function(t,e,n){let s=this._processes[t];if(s)s._onExit(e,n),delete this._processes[t],window.__alloy_cleanup(t)},r._write=function(t,e){if(t===null)return;let n;if(typeof e==="string")n=btoa(e);else n=_(new Uint8Array(e));window.__alloy_write(t,n)},r._closeStdin=function(t){if(t!==null)window.__alloy_closeStdin(t)},r._kill=function(t,e){if(t!==null)window.__alloy_kill(t,e)},r._resize=function(t,e,n){if(t!==null)window.__alloy_resize(t,e,n)},!ReadableStream.prototype.hasOwnProperty("text"))ReadableStream.prototype.text=async function(){let t=this.getReader(),e=new TextDecoder,n="";while(!0){let{done:s,value:i}=await t.read();if(s)break;n+=e.decode(i,{stream:!0})}return n+=e.decode(),n};window.Alloy=r})();var R=window.Alloy.gui.Window({title:"Alloy Native UI"}),w=window.Alloy.gui.VStack({}),f=window.Alloy.gui.Label({text:"Status: Initialized"}),b=window.Alloy.gui.Button({label:"Run ls"}),A=window.Alloy.gui.TextArea({});b.addEventListener("click",async()=>{f.setText("Status: Running...");let _=await window.Alloy.spawn(["ls","-l","/"]).stdout.text();A.setText(_),f.setText("Status: Done")});R.append(w);w.append(f);w.append(b);w.append(A);var T={scheduled(u){console.log("Cron triggered:",u.cron)}};window.defaultExport=T; diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 6a125bb92..5d0ce7c42 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -31,3 +31,7 @@ endif() add_executable(webview_example_bind_cc MACOSX_BUNDLE WIN32) target_sources(webview_example_bind_cc PRIVATE bind.cc ${SHARED_SOURCES}) target_link_libraries(webview_example_bind_cc PRIVATE webview::core Threads::Threads) + +add_executable(webview_example_alloy_runtime WIN32) +target_sources(webview_example_alloy_runtime PRIVATE alloy_runtime.cc) +target_link_libraries(webview_example_alloy_runtime PRIVATE webview::core util) diff --git a/examples/alloy_runtime.cc b/examples/alloy_runtime.cc new file mode 100644 index 000000000..839fef775 --- /dev/null +++ b/examples/alloy_runtime.cc @@ -0,0 +1,39 @@ +#include "webview/webview.h" +#include "webview/meta.hh" +#include +#include +#include +#include +#include + +int main(int argc, char** argv) { + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg.find("--cron-title=") == 0) { + std::cout << "Alloy Cron Job Executing: " << arg.substr(13) << "\n"; + return 0; + } + } + + try { + auto w = std::make_shared(true, nullptr); + w->set_title("AlloyScript Runtime"); + w->set_size(1024, 768, WEBVIEW_HINT_NONE); + + auto mgr = std::make_shared(w.get()); + mgr->bind(*w); + + std::ifstream f("dist/bundle.js"); + if (f.good()) { + std::string bundle((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + w->init(bundle); + } + + w->set_html("

AlloyScript Environment

"); + w->run(); + } catch (const webview::exception &e) { + std::cerr << e.what() << '\n'; + return 1; + } + return 0; +} diff --git a/src/host.cpp b/src/host.cpp new file mode 100644 index 000000000..308c3f0a2 --- /dev/null +++ b/src/host.cpp @@ -0,0 +1,49 @@ +#include "webview/webview.h" +#include "webview/meta.hh" +#include +#include +#include +#include +#include + +#include "bundle.h" + +int main(int argc, char** argv) { + std::string cron_title, cron_period, script_path; + bool is_cron = false; + + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg.find("--cron-title=") == 0) { cron_title = arg.substr(13); is_cron = true; } + else if (arg.find("--cron-period=") == 0) cron_period = arg.substr(14); + else if (arg.find("run") != std::string::npos) {} + else if (arg.find("--") != 0) script_path = arg; + } + + try { + auto w = std::make_shared(is_cron ? false : true, nullptr); + + auto mgr = std::make_shared(w.get()); + mgr->bind(*w); + w->init(ALLOYSCRIPT_BUNDLE); + + if (is_cron) { + std::string js = "window.onload = () => { if (typeof window.defaultExport !== 'undefined' && window.defaultExport.scheduled) { " + "window.defaultExport.scheduled({ cron: '" + cron_period + "', type: 'scheduled', scheduledTime: Date.now() }); " + "} else { console.error('No scheduled() handler found'); } " + "setTimeout(() => window.__alloy_terminate(), 1000); };"; + w->bind("__alloy_terminate", [&](const std::string&) -> std::string { w->terminate(); return ""; }); + w->init(js); + } else { + w->set_title("AlloyScript Application"); + w->set_size(1024, 768, WEBVIEW_HINT_NONE); + w->set_html("
"); + } + + w->run(); + } catch (const webview::exception &e) { + std::cerr << e.what() << '\n'; + return 1; + } + return 0; +} diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 000000000..24c6c7ba5 --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,262 @@ + +// AlloyScript Runtime +(function() { + 'use strict'; + + function b64ToUint8(b64: string): Uint8Array { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; + } + + function uint8ToB64(uint8: Uint8Array): string { + let bin = ''; + const len = uint8.byteLength; + const chunk = 8192; + for (let i = 0; i < len; i += chunk) { + bin += String.fromCharCode.apply(null, Array.from(uint8.subarray(i, i + chunk))); + } + return btoa(bin); + } + + // --- Cron --- + const CRON_MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; + const CRON_DAYS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]; + const CRON_NICKNAMES: Record = { + "@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *", + "@weekly": "0 0 * * 0", "@daily": "0 0 * * *", "@midnight": "0 0 * * *", "@hourly": "0 * * * *" + }; + + class CronParser { + min: Set | null; hour: Set | null; dom: Set | null; month: Set | null; dow: Set | null; + constructor(expr: string) { + expr = CRON_NICKNAMES[expr] || expr; + const fields = expr.split(/\s+/); + if (fields.length !== 5) throw new Error("Invalid cron expression"); + this.min = this.parseField(fields[0], 0, 59); + this.hour = this.parseField(fields[1], 0, 23); + this.dom = this.parseField(fields[2], 1, 31); + this.month = this.parseField(fields[3], 1, 12, CRON_MONTHS); + this.dow = this.parseField(fields[4], 0, 7, CRON_DAYS); + if (this.dow && this.dow.has(7)) this.dow.add(0); + } + private parseField(field: string, min: number, max: number, names?: string[]): Set | null { + if (field === '*') return null; + const result = new Set(); + for (const part of field.split(',')) { + let [range, stepStr] = part.split('/'); + const step = stepStr ? parseInt(stepStr, 10) : 1; + let start: number, end: number; + if (range === '*') { start = min; end = max; } + else if (range.includes('-')) { + let [s, e] = range.split('-'); + start = this.parseValue(s, names); end = this.parseValue(e, names); + } else { start = end = this.parseValue(range, names); } + for (let i = start; i <= end; i += step) result.add(i); + } + return result; + } + private parseValue(val: string, names?: string[]): number { + if (names) { + const idx = names.indexOf(val.toUpperCase().substring(0, 3)); + if (idx !== -1) return names === CRON_DAYS ? idx : idx + 1; + } + return parseInt(val, 10); + } + next(from?: Date | number): Date | null { + let curr = new Date(from || Date.now()); + curr.setUTCSeconds(0, 0); curr.setUTCMinutes(curr.getUTCMinutes() + 1); + const startYear = curr.getUTCFullYear(); + while (curr.getUTCFullYear() < startYear + 5) { + if (this.month && !this.month.has(curr.getUTCMonth() + 1)) { + curr.setUTCMonth(curr.getUTCMonth() + 1, 1); curr.setUTCHours(0, 0, 0, 0); continue; + } + const domSet = this.dom !== null, dowSet = this.dow !== null; + let matchDay = false; + if (domSet && dowSet) matchDay = this.dom!.has(curr.getUTCDate()) || this.dow!.has(curr.getUTCDay()); + else if (domSet) matchDay = this.dom!.has(curr.getUTCDate()); + else if (dowSet) matchDay = this.dow!.has(curr.getUTCDay()); + else matchDay = true; + if (!matchDay) { curr.setUTCDate(curr.getUTCDate() + 1); curr.setUTCHours(0, 0, 0, 0); continue; } + if (this.hour && !this.hour.has(curr.getUTCHours())) { curr.setUTCHours(curr.getUTCHours() + 1, 0, 0, 0); continue; } + if (this.min && !this.min.has(curr.getUTCMinutes())) { curr.setUTCMinutes(curr.getUTCMinutes() + 1, 0, 0); continue; } + return curr; + } + return null; + } + } + + // --- Subprocess --- + class Subprocess { + handle: string; pid: number | null = null; options: any; exited: Promise; + private _exited_resolve!: (code: number) => void; + exitCode: number | null = null; signalCode: string | null = null; killed = false; + terminal: Terminal | null = null; stdout: any = null; stderr: any = null; stdin: any = null; + private _stdout_controller: ReadableStreamDefaultController | null = null; + private _stderr_controller: ReadableStreamDefaultController | null = null; + + constructor(handle: string, options: any) { + this.handle = handle; this.options = options || {}; + this.exited = new Promise(resolve => { this._exited_resolve = resolve; }); + if (this.options.terminal) { + this.terminal = new Terminal(this); + } else { + this.stdout = new ReadableStream({ start: (c) => { this._stdout_controller = c; } }); + this.stderr = new ReadableStream({ start: (c) => { this._stderr_controller = c; } }); + this.stdin = { + write: (data: any) => { (window as any).Alloy._write(this.handle, data); }, + end: () => { (window as any).Alloy._closeStdin(this.handle); }, + flush: () => {} + }; + } + } + kill(sig?: string | number) { this.killed = true; (window as any).Alloy._kill(this.handle, sig || 'SIGTERM'); } + ref() {} unref() {} + resourceUsage() { return { maxRSS: 0, cpuTime: { user: 0, system: 0, total: 0 }, contextSwitches: { voluntary: 0, involuntary: 0 }, ops: { in: 0, out: 0 } }; } + disconnect() {} + send(msg: any) {} + + _onData(type: string, encodedData: string) { + const data = b64ToUint8(encodedData); + if (type === 'stdout' && this._stdout_controller) this._stdout_controller.enqueue(data); + else if (type === 'stderr' && this._stderr_controller) this._stderr_controller.enqueue(data); + else if (type === 'terminal' && this.options.terminal?.data) this.options.terminal.data(this.terminal, data); + } + _onExit(exitCode: number, signalCode: number) { + this.exitCode = exitCode; this.signalCode = signalCode ? "SIG" + signalCode : null; + if (this._stdout_controller) try { this._stdout_controller.close(); } catch(e) {} + if (this._stderr_controller) try { this._stderr_controller.close(); } catch(e) {} + if (this.options.onExit) this.options.onExit(this, exitCode, this.signalCode); + if (this.options.terminal?.exit) this.options.terminal.exit(this.terminal, 0, null); + this._exited_resolve(exitCode); + } + } + + class Terminal { + proc?: Subprocess; handle: string; options?: any; closed = false; + constructor(options_or_proc: any) { + if (options_or_proc instanceof Subprocess) { this.proc = options_or_proc; this.handle = this.proc.handle; } + else { this.options = options_or_proc || {}; this.handle = "term_" + (++(window as any).Alloy._handleCounter); } + } + write(data: any) { (window as any).Alloy._write(this.handle, data); } + resize(c: number, r: number) { (window as any).Alloy._resize(this.handle, c, r); } + setRawMode(e: boolean) {} close() { this.closed = true; } ref() {} unref() {} + } + + // --- GUI --- + class NativeComponent { + handle: string; type: string; props: any; children: NativeComponent[] = []; + constructor(type: string, props: any) { + this.type = type; this.props = props || {}; + this.handle = "gui_" + (++(window as any).Alloy._handleCounter); + (window as any).Alloy._widgets[this.handle] = this; + (window as any).__alloy_gui_create(this.handle, this.type, JSON.stringify(this.props)); + } + append(child: NativeComponent) { + this.children.push(child); + (window as any).__alloy_gui_append(this.handle, child.handle); + } + setText(text: string) { (window as any).__alloy_gui_set_text(this.handle, text); } + setValue(value: any) { (window as any).__alloy_gui_set_value(this.handle, String(value)); } + addEventListener(event: string, handler: Function) { + if (!this.props.handlers) this.props.handlers = {}; + this.props.handlers[event] = handler; + } + _trigger(event: string) { + if (this.props.handlers && this.props.handlers[event]) this.props.handlers[event](); + } + } + + const Alloy: any = async function(path: string, schedule: string, title: string) { + await (window as any).__alloy_cron_register(path, schedule, title); + }; + + Alloy._processes = {} as Record; + Alloy._widgets = {} as Record; + Alloy._handleCounter = 0; + Alloy.Terminal = Terminal; + Alloy.file = (path: string) => ({ type: "file", path: path }); + + Alloy.spawn = function(cmd: any, opts: any) { + let command = Array.isArray(cmd) ? cmd : (cmd.cmd || []); + let options = Array.isArray(cmd) ? (opts || {}) : (cmd || {}); + const handle = "proc_" + (++this._handleCounter); + const proc = new Subprocess(handle, options); + this._processes[handle] = proc; + (async () => { + try { + const res_json = await (globalThis as any).__alloy_spawn(handle, JSON.stringify(command), JSON.stringify(options || {})); + const res = JSON.parse(res_json); + if (res.error) return console.error("Alloy.spawn error:", res.error); + proc.pid = res.pid; + } catch (e) { console.error("Alloy.spawn failed:", e); } + })(); + return proc; + }; + + Alloy.spawnSync = async function(cmd: any, opts: any) { + let command = Array.isArray(cmd) ? cmd : (cmd.cmd || []); + let options = Array.isArray(cmd) ? (opts || {}) : (cmd || {}); + const res_raw = await (globalThis as any).__alloy_spawnSync(JSON.stringify(command), JSON.stringify(options || {})); + const res = typeof res_raw === 'string' ? JSON.parse(res_raw) : res_raw; + if (res.stdout) res.stdout = b64ToUint8(res.stdout); + if (res.stderr) res.stderr = b64ToUint8(res.stderr); + return res; + }; + + Alloy.cron = Alloy; + Alloy.cron.parse = function(expr: string, relativeDate?: Date | number) { + try { return new CronParser(expr).next(relativeDate); } catch (e) { return null; } + }; + Alloy.cron.remove = async function(title: string) { await (globalThis as any).__alloy_cron_remove(title); }; + + Alloy.gui = { + Window: (props: any) => new NativeComponent("Window", props), + Button: (props: any) => new NativeComponent("Button", props), + Label: (props: any) => new NativeComponent("Label", props), + VStack: (props: any) => new NativeComponent("VStack", props), + HStack: (props: any) => new NativeComponent("HStack", props), + TextField: (props: any) => new NativeComponent("TextField", props), + TextArea: (props: any) => new NativeComponent("TextArea", props), + CheckBox: (props: any) => new NativeComponent("CheckBox", props), + Slider: (props: any) => new NativeComponent("Slider", props), + ProgressBar: (props: any) => new NativeComponent("ProgressBar", props), + Switch: (props: any) => new NativeComponent("Switch", props), + _onEvent: function(handle: string, event: string) { + const comp = Alloy._widgets[handle]; + if (comp) comp._trigger(event); + } + }; + + Alloy._onData = function(handle: string, type: string, data_b64: string) { + const proc = this._processes[handle]; + if (proc) proc._onData(type, data_b64); + }; + Alloy._onExit = function(handle: string, exitCode: number, signalCode: number) { + const proc = this._processes[handle]; + if (proc) { proc._onExit(exitCode, signalCode); delete this._processes[handle]; (window as any).__alloy_cleanup(handle); } + }; + Alloy._write = function(h: string, d: any) { + if (h === null) return; + let b64; + if (typeof d === 'string') b64 = btoa(d); else b64 = uint8ToB64(new Uint8Array(d)); + (window as any).__alloy_write(h, b64); + }; + Alloy._closeStdin = function(h: string) { if (h !== null) (window as any).__alloy_closeStdin(h); }; + Alloy._kill = function(h: string, s: string) { if (h !== null) (window as any).__alloy_kill(h, s); }; + Alloy._resize = function(h: string, c: number, r: number) { if (h !== null) (window as any).__alloy_resize(h, c, r); }; + + if (!ReadableStream.prototype.hasOwnProperty('text')) { + (ReadableStream.prototype as any).text = async function() { + const reader = this.getReader(); let decoder = new TextDecoder(); let result = ''; + while (true) { + const { done, value } = await reader.read(); if (done) break; + result += decoder.decode(value, { stream: true }); + } + result += decoder.decode(); return result; + }; + } + + (globalThis as any).Alloy = Alloy; +})(); diff --git a/tests/alloy.test.ts b/tests/alloy.test.ts new file mode 100644 index 000000000..93cb5a800 --- /dev/null +++ b/tests/alloy.test.ts @@ -0,0 +1,77 @@ +import { expect, test, describe } from "bun:test"; + +const mockWindow = { + __alloy_spawn: async (h: string, c: string, o: string) => JSON.stringify({ pid: 1234 }), + __alloy_spawnSync: async (c: string, o: string) => JSON.stringify({ success: true, stdout: btoa("hello"), stderr: "" }), + __alloy_write: (h: string, d: string) => {}, + __alloy_closeStdin: (h: string) => {}, + __alloy_kill: (h: string, s: string) => {}, + __alloy_resize: (h: string, c: number, r: number) => {}, + __alloy_cleanup: (h: string) => {}, + __alloy_gui_create: (h: string, t: string, p: string) => {}, + __alloy_gui_append: (ph: string, ch: string) => {}, + __alloy_gui_set_text: (h: string, t: string) => {}, + __alloy_gui_set_value: (h: string, v: string) => {}, + __alloy_cron_register: async (p: string, s: string, t: string) => {}, + __alloy_cron_remove: async (t: string) => {}, +}; + +(global as any).window = mockWindow; +(global as any).atob = (s: string) => Buffer.from(s, 'base64').toString('binary'); +(global as any).btoa = (s: string) => Buffer.from(s, 'binary').toString('base64'); +(global as any).TextEncoder = class { encode(s: string) { return Buffer.from(s); } }; +(global as any).TextDecoder = class { decode(b: any) { return Buffer.from(b).toString(); } }; +(global as any).ReadableStream = class { + constructor(opts: any) { this._data = []; if (opts.start) opts.start({ enqueue: (v: any) => this._data.push(v), close: () => {} }); } + _data: any[]; + async text() { return this._data.map(b => Buffer.from(b).toString()).join(''); } + getReader() { let i = 0; return { read: async () => { if (i < this._data.length) return { done: false, value: this._data[i++] }; return { done: true, value: undefined }; } }; } +}; + +// Clear require cache to ensure runtime.ts runs again for this file +delete require.cache[require.resolve("../src/runtime.ts")]; +require("../src/runtime.ts"); +const Alloy = (window as any).Alloy; + +describe("AlloyScript Comprehensive API", () => { + describe("Spawn", () => { + test("Alloy.spawn with array", () => { + const proc = Alloy.spawn(["ls"]); + expect(proc.handle).toMatch(/^proc_/); + }); + + test("Alloy.spawn with options object", () => { + const proc = Alloy.spawn({ cmd: ["ls"], cwd: "/" }); + expect(proc.handle).toMatch(/^proc_/); + }); + + test("Alloy.spawnSync behavior", async () => { + const res = await Alloy.spawnSync(["echo", "hi"]); + expect(res.success).toBe(true); + }); + }); + + describe("GUI", () => { + test("Component creation", () => { + const win = Alloy.gui.Window({ title: "Test" }); + expect(win).toBeDefined(); + expect(Alloy._widgets[win.handle]).toBe(win); + }); + + test("Expanded components exist", () => { + expect(typeof Alloy.gui.TextArea).toBe("function"); + expect(typeof Alloy.gui.CheckBox).toBe("function"); + expect(typeof Alloy.gui.Slider).toBe("function"); + expect(typeof Alloy.gui.ProgressBar).toBe("function"); + expect(typeof Alloy.gui.Switch).toBe("function"); + }); + }); + + describe("Cron", () => { + test("Alloy.cron.parse", () => { + const from = new Date(Date.UTC(2025, 0, 1, 0, 0)); + const next = Alloy.cron.parse("0 0 * * *", from); + expect(next.toISOString()).toBe("2025-01-02T00:00:00.000Z"); + }); + }); +});