From 636262df9dd264f0cb01c863c1cf78ca8edccff4 Mon Sep 17 00:00:00 2001 From: Josh at WLTechBlog Date: Mon, 25 May 2026 16:47:06 -0700 Subject: [PATCH 1/4] wasm: minimal WebUSB/CH341A flash programmer support Core changes against original c590b6a: spi_nor_flash.c: - snor_wait_ready: WASM polling at 100ms intervals (WebUSB latency) - snor_read: 4096-byte chunking for WebUSB transfer limits - snor_read_sr/rg/devid: combine opcode write+response read in a single ch341a_spi_send_command() call to prevent stale IN buffer corruption - Suppress per-chunk progress output (#ifndef __EMSCRIPTEN__) ch341a_spi.c: - WASM usb_transfer: row-by-row chunked OUT+IN USB transfers - Write-only shortcut: chunked 32-byte SPI packets with per-chunk IN FIFO drain to prevent endpoint backpressure - enable_pins WASM: 4-byte simple UIO (DIR_ALL_OUTPUT) - Static buffers (em_safe_wbuf/rbuf) for WASM transport - #ifndef guards around native async libusb code flashcmd_api.c: - EEPROM ifdef guards for WASM build compatibility Web frontend (web/): - CMakeLists.txt for emscripten/Asyncify build - libusb-webusb.js: WebUSB shim replacing native libusb - app.js, main.js, index.html: web UI - web_main.c: C entry point for WASM exports --- src/ch341a_spi.c | 245 +++++- src/flashcmd_api.c | 105 +-- src/spi_nor_flash.c | 76 +- web/CMakeLists.txt | 83 ++ web/build.sh | 28 + web/index.html | 157 ++++ web/libusb-stub/libusb-1.0/libusb.h | 135 ++++ web/package-lock.json | 1088 +++++++++++++++++++++++++++ web/package.json | 18 + web/src/app.js | 588 +++++++++++++++ web/src/libusb-webusb.js | 527 +++++++++++++ web/src/main.js | 8 + web/src/web_main.c | 170 +++++ web/vite.config.js | 8 + 14 files changed, 3147 insertions(+), 89 deletions(-) create mode 100644 web/CMakeLists.txt create mode 100755 web/build.sh create mode 100644 web/index.html create mode 100644 web/libusb-stub/libusb-1.0/libusb.h create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/app.js create mode 100644 web/src/libusb-webusb.js create mode 100644 web/src/main.js create mode 100644 web/src/web_main.c create mode 100644 web/vite.config.js diff --git a/src/ch341a_spi.c b/src/ch341a_spi.c index ef2ab70..fe1201f 100644 --- a/src/ch341a_spi.c +++ b/src/ch341a_spi.c @@ -98,15 +98,27 @@ struct dev_entry * packets and the device sends the 31 reply data bytes to each 32-byte packet * with command + 31 bytes of data... */ +#ifndef __EMSCRIPTEN__ static struct libusb_transfer *transfer_out = NULL; static struct libusb_transfer *transfer_ins[USB_IN_TRANSFERS] = {0}; +#endif struct libusb_device_handle *handle = NULL; +#ifdef __EMSCRIPTEN__ +static uint8_t em_safe_wbuf[CH341_MAX_PACKETS + 1][CH341_PACKET_LENGTH]; +static uint8_t em_safe_rbuf[CH341_MAX_PACKET_LEN]; +#endif + +#ifdef __EMSCRIPTEN__ +extern int usb_clear_halt(void *handle_ptr, int endpoint); +#endif + const struct dev_entry devs_ch341a_spi[] = { {0x1A86, 0x5512, "WinChipHead (WCH)", "CH341A"}, {0}, }; +#ifndef __EMSCRIPTEN__ enum trans_state { TRANS_ACTIVE = -2, @@ -120,7 +132,6 @@ static void cb_common(const char *func, struct libusb_transfer *transfer) if (transfer->status == LIBUSB_TRANSFER_CANCELLED) { - /* Silently ACK and exit. */ if (debug_enabled) fprintf(stderr, "[DEBUG] %s: transfer cancelled\n", func); *transfer_cnt = TRANS_IDLE; @@ -142,17 +153,16 @@ static void cb_common(const char *func, struct libusb_transfer *transfer) } } -/* callback for bulk out async transfer */ static void LIBUSB_CALL cb_out(struct libusb_transfer *transfer) { cb_common(__func__, transfer); } -/* callback for bulk in async transfer */ static void LIBUSB_CALL cb_in(struct libusb_transfer *transfer) { cb_common(__func__, transfer); } +#endif static int32_t usb_transfer(const char *func, unsigned int writecnt, unsigned int readcnt, const uint8_t *writearr, uint8_t *readarr) { @@ -166,6 +176,97 @@ static int32_t usb_transfer(const char *func, unsigned int writecnt, unsigned in if (debug_enabled) fprintf(stderr, "[DEBUG] %s: starting transfer (write=%u bytes, read=%u bytes)\n", func, writecnt, readcnt); +#ifdef __EMSCRIPTEN__ +#define EM_USB_TIMEOUT 5000 + + if (writecnt > 0 && readcnt > 0) + { + unsigned int off_out = CH341_PACKET_LENGTH; + unsigned int off_in = 0; + unsigned int rem_out = writecnt - CH341_PACKET_LENGTH; + + while (rem_out > 0) + { + unsigned int row_avail = rem_out; + if (row_avail > CH341_PACKET_LENGTH) + row_avail = CH341_PACKET_LENGTH; + + int sent = 0; + int ret = libusb_bulk_transfer(handle, WRITE_EP, + (unsigned char *)writearr + off_out, + row_avail, &sent, EM_USB_TIMEOUT); + if (ret) + { + fprintf(stderr, "%s: row OUT failed (%u): %s\n", + func, row_avail, libusb_error_name(ret)); + return -1; + } + + unsigned int chunk_in = (row_avail > 1) ? row_avail - 1 : 0; + if (chunk_in > 0 && off_in < readcnt) + { + if (chunk_in > readcnt - off_in) + chunk_in = readcnt - off_in; + int received = 0; + ret = libusb_bulk_transfer(handle, READ_EP, + readarr + off_in, chunk_in, + &received, EM_USB_TIMEOUT); + if (ret) + { + fprintf(stderr, "%s: row IN failed (%u): %s\n", + func, chunk_in, libusb_error_name(ret)); + return -1; + } + off_in += chunk_in; + } + + off_out += row_avail; + rem_out -= row_avail; + } + } + else if (writecnt > 0) + { + unsigned int off_out = 0; + unsigned int rem_out = writecnt; + + while (rem_out > 0) + { + unsigned int row_avail = rem_out; + if (row_avail > CH341_PACKET_LENGTH) + row_avail = CH341_PACKET_LENGTH; + + int sent = 0; + int ret = libusb_bulk_transfer(handle, WRITE_EP, + (unsigned char *)writearr + off_out, + row_avail, &sent, EM_USB_TIMEOUT); + if (ret) + { + fprintf(stderr, "%s: OUT transfer failed: %s\n", + func, libusb_error_name(ret)); + return -1; + } + off_out += row_avail; + rem_out -= row_avail; + } + } + else if (readcnt > 0) + { + int received = 0; + int ret = libusb_bulk_transfer(handle, READ_EP, + readarr, readcnt, &received, EM_USB_TIMEOUT); + if (ret) + { + fprintf(stderr, "%s: IN transfer failed: %s\n", + func, libusb_error_name(ret)); + return -1; + } + } + + if (debug_enabled) + fprintf(stderr, "[DEBUG] %s: transfer completed (wrote %u, read %u bytes)\n", + func, writecnt, readcnt); + return 0; +#else int state_out = TRANS_IDLE; transfer_out->buffer = (uint8_t *)writearr; transfer_out->length = writecnt; @@ -180,7 +281,7 @@ static int32_t usb_transfer(const char *func, unsigned int writecnt, unsigned in int ret = libusb_submit_transfer(transfer_out); if (ret) { - fprintf(stderr, "%s: failed to submit OUT transfer: %s\n", func, libusb_error_name(ret)); // Use stderr + fprintf(stderr, "%s: failed to submit OUT transfer: %s\n", func, libusb_error_name(ret)); if (debug_enabled) fprintf(stderr, "[DEBUG] %s: OUT transfer submit failed with error code %d\n", func, ret); state_out = TRANS_ERR; @@ -192,8 +293,8 @@ static int32_t usb_transfer(const char *func, unsigned int writecnt, unsigned in * The write(s) simply need to complete, but we need to schedule reads as long * as we are not done. */ - unsigned int free_idx = 0; /* The IN transfer we expect to be free next. */ - unsigned int in_idx = 0; /* The IN transfer we expect to be completed next. */ + unsigned int free_idx = 0; + unsigned int in_idx = 0; unsigned int in_done = 0; unsigned int in_active = 0; unsigned int out_done = 0; @@ -201,7 +302,6 @@ static int32_t usb_transfer(const char *func, unsigned int writecnt, unsigned in int state_in[USB_IN_TRANSFERS] = {0}; do { - /* Schedule new reads as long as there are free transfers and unscheduled bytes to read. */ while ((in_done + in_active) < readcnt && state_in[free_idx] == TRANS_IDLE) { unsigned int cur_todo = min(CH341_PACKET_LENGTH - 1, readcnt - in_done - in_active); @@ -214,7 +314,7 @@ static int32_t usb_transfer(const char *func, unsigned int writecnt, unsigned in if (ret) { state_in[free_idx] = TRANS_ERR; - fprintf(stderr, "%s: failed to submit IN transfer: %s\n", // Use stderr + fprintf(stderr, "%s: failed to submit IN transfer: %s\n", func, libusb_error_name(ret)); if (debug_enabled) fprintf(stderr, "[DEBUG] %s: IN transfer[%u] submit failed with error code %d\n", func, free_idx, ret); @@ -223,37 +323,29 @@ static int32_t usb_transfer(const char *func, unsigned int writecnt, unsigned in in_buf += cur_todo; in_active += cur_todo; state_in[free_idx] = TRANS_ACTIVE; - free_idx = (free_idx + 1) % USB_IN_TRANSFERS; /* Increment (and wrap around). */ + free_idx = (free_idx + 1) % USB_IN_TRANSFERS; } - /* Actually get some work done. */ libusb_handle_events_timeout(NULL, &(struct timeval){1, 0}); - /* Check for the write */ if (out_done < writecnt) { if (state_out == TRANS_ERR) - { goto err; - } else if (state_out > 0) { out_done += state_out; state_out = TRANS_IDLE; } } - /* Check for completed transfers. */ while (state_in[in_idx] != TRANS_IDLE && state_in[in_idx] != TRANS_ACTIVE) { if (state_in[in_idx] == TRANS_ERR) - { goto err; - } - /* If a transfer is done, record the number of bytes read and reuse it later. */ in_done += state_in[in_idx]; in_active -= state_in[in_idx]; state_in[in_idx] = TRANS_IDLE; - in_idx = (in_idx + 1) % USB_IN_TRANSFERS; /* Increment (and wrap around). */ + in_idx = (in_idx + 1) % USB_IN_TRANSFERS; } } while ((out_done < writecnt) || (in_done < readcnt)); @@ -261,10 +353,8 @@ static int32_t usb_transfer(const char *func, unsigned int writecnt, unsigned in fprintf(stderr, "[DEBUG] %s: transfer completed successfully (wrote %u, read %u bytes)\n", func, out_done, in_done); return 0; err: - /* Clean up on errors. */ fprintf(stderr, "%s: Failed to %s %d bytes\n", func, (state_out == TRANS_ERR) ? "write" : "read", (state_out == TRANS_ERR) ? writecnt : readcnt); - /* First, we must cancel any ongoing requests and wait for them to be canceled. */ if ((writecnt > 0) && (state_out == TRANS_ACTIVE)) { if (libusb_cancel_transfer(transfer_out) != 0) @@ -281,7 +371,6 @@ static int32_t usb_transfer(const char *func, unsigned int writecnt, unsigned in } } - /* Wait for cancellations to complete. */ while (1) { bool finished = true; @@ -301,6 +390,7 @@ static int32_t usb_transfer(const char *func, unsigned int writecnt, unsigned in libusb_handle_events_timeout(NULL, &(struct timeval){1, 0}); } return -1; +#endif } /* Set the I2C bus speed (speed(b1b0): 0 = 20kHz; 1 = 100kHz, 2 = 400kHz, 3 = 750kHz). @@ -358,22 +448,35 @@ static uint8_t swap_byte(uint8_t x) * D6/21 unused (DIN2) * D7/22 SO/2 (DIN) */ +#ifdef __EMSCRIPTEN__ +extern int usb_clear_halt(void *handle_ptr, int endpoint); +#endif + int enable_pins(bool enable) { if (debug_enabled) fprintf(stderr, "[DEBUG] enable_pins: %sabling output pins\n", enable ? "en" : "dis"); +#ifdef __EMSCRIPTEN__ uint8_t buf[] = { CH341A_CMD_UIO_STREAM, - CH341A_CMD_UIO_STM_OUT | CH341A_UIO_STATE_CS_HIGH_SCK_LOW, // Set CS high, SCK low - CH341A_CMD_UIO_STM_OUT | CH341A_UIO_STATE_CS_HIGH_SCK_LOW, // Repeat for stability? + CH341A_CMD_UIO_STM_OUT | (enable ? CH341A_UIO_STATE_CS0_LOW_SCK_LOW : CH341A_UIO_STATE_CS_HIGH_SCK_LOW), + CH341A_CMD_UIO_STM_DIR | CH341A_UIO_DIR_ALL_OUTPUT, + CH341A_CMD_UIO_STM_END, + }; +#else + uint8_t buf[] = { + CH341A_CMD_UIO_STREAM, + CH341A_CMD_UIO_STM_OUT | CH341A_UIO_STATE_CS_HIGH_SCK_LOW, + CH341A_CMD_UIO_STM_OUT | CH341A_UIO_STATE_CS_HIGH_SCK_LOW, CH341A_CMD_UIO_STM_OUT | CH341A_UIO_STATE_CS_HIGH_SCK_LOW, CH341A_CMD_UIO_STM_OUT | CH341A_UIO_STATE_CS_HIGH_SCK_LOW, CH341A_CMD_UIO_STM_OUT | CH341A_UIO_STATE_CS_HIGH_SCK_LOW, - CH341A_CMD_UIO_STM_OUT | CH341A_UIO_STATE_CS0_LOW_SCK_LOW, // Set CS0 low, SCK low - CH341A_CMD_UIO_STM_DIR | (enable ? CH341A_UIO_DIR_ALL_OUTPUT : CH341A_UIO_DIR_INPUT), // Set direction + CH341A_CMD_UIO_STM_OUT | CH341A_UIO_STATE_CS0_LOW_SCK_LOW, + CH341A_CMD_UIO_STM_DIR | (enable ? CH341A_UIO_DIR_ALL_OUTPUT : CH341A_UIO_DIR_INPUT), CH341A_CMD_UIO_STM_END, }; +#endif int32_t ret = usb_transfer(__func__, sizeof(buf), 0, buf, NULL); if (ret < 0) @@ -405,18 +508,21 @@ int ch341a_spi_send_command(unsigned int writecnt, unsigned int readcnt, const u return -1; } - /* How many packets ... */ const size_t packets = (writecnt + readcnt + CH341_PACKET_LENGTH - 2) / (CH341_PACKET_LENGTH - 1); - /* We pluck CS/timeout handling into the first packet thus we need to allocate one extra package. */ +#ifdef __EMSCRIPTEN__ + if (packets + 1 > CH341_MAX_PACKETS + 1 || writecnt + readcnt > CH341_MAX_PACKET_LEN) { + fprintf(stderr, "ch341a_spi_send_command: transfer too large for static buffers\n"); + return -1; + } + uint8_t (*wbuf)[CH341_PACKET_LENGTH] = em_safe_wbuf; + uint8_t *rbuf = em_safe_rbuf; +#else uint8_t wbuf[packets + 1][CH341_PACKET_LENGTH]; uint8_t rbuf[writecnt + readcnt]; - /* Initialize the write buffer to zero to prevent writing random stack contents to device. */ +#endif memset(wbuf[0], 0, CH341_PACKET_LENGTH); - uint8_t *ptr = wbuf[0]; - /* CS usage is optimized by doing both transitions in one packet. - * Final transition to deselected state is in the pin disable. */ unsigned int write_left = writecnt; unsigned int read_left = readcnt; unsigned int p; @@ -424,7 +530,7 @@ int ch341a_spi_send_command(unsigned int writecnt, unsigned int readcnt, const u { unsigned int write_now = min(CH341_PACKET_LENGTH - 1, write_left); unsigned int read_now = min((CH341_PACKET_LENGTH - 1) - write_now, read_left); - ptr = wbuf[p + 1]; + uint8_t *ptr = wbuf[p + 1]; *ptr++ = CH341A_CMD_SPI_STREAM; unsigned int i; for (i = 0; i < write_now; ++i) @@ -437,6 +543,39 @@ int ch341a_spi_send_command(unsigned int writecnt, unsigned int readcnt, const u write_left -= write_now; } +#ifdef __EMSCRIPTEN__ + if (readcnt == 0 && writecnt > 0) { + unsigned int total = packets + writecnt; + unsigned int off = 0; + unsigned int rem = total; + uint8_t *spi_data = ((uint8_t*)wbuf) + CH341_PACKET_LENGTH; + + while (rem > 0) { + unsigned int chunk = rem; + if (chunk > CH341_PACKET_LENGTH) + chunk = CH341_PACKET_LENGTH; + + int sent = 0; + ret = libusb_bulk_transfer(handle, WRITE_EP, + spi_data + off, chunk, &sent, EM_USB_TIMEOUT); + if (ret) { + fprintf(stderr, "%s: OUT transfer failed: %s\n", + __func__, libusb_error_name(ret)); + return -1; + } + off += chunk; + rem -= chunk; + + unsigned int drain_n = chunk > 1 ? chunk - 1 : 0; + if (drain_n > 0) { + int drained = 0; + libusb_bulk_transfer(handle, READ_EP, + rbuf, drain_n, &drained, 100); + } + } + ret = 0; + } else +#endif ret = usb_transfer(__func__, CH341_PACKET_LENGTH + packets + writecnt + readcnt, writecnt + readcnt, wbuf[0], rbuf); @@ -467,6 +606,7 @@ int ch341a_spi_shutdown(void) } enable_pins(false); +#ifndef __EMSCRIPTEN__ libusb_free_transfer(transfer_out); transfer_out = NULL; int i; @@ -475,6 +615,7 @@ int ch341a_spi_shutdown(void) libusb_free_transfer(transfer_ins[i]); transfer_ins[i] = NULL; } +#endif libusb_release_interface(handle, 0); libusb_close(handle); libusb_exit(NULL); @@ -488,8 +629,12 @@ int ch341a_spi_shutdown(void) const char *get_libusb_version(void) { static char version_str[18]; +#ifdef __EMSCRIPTEN__ + snprintf(version_str, sizeof(version_str), "webusb"); +#else const struct libusb_version *version = libusb_get_version(); snprintf(version_str, sizeof(version_str), "%d.%d.%d", version->major, version->minor, version->micro); +#endif return version_str; } @@ -521,14 +666,16 @@ int ch341a_spi_init(void) if (debug_enabled) fprintf(stderr, "[DEBUG] ch341a_spi_init: libusb initialized successfully\n"); +#ifndef __EMSCRIPTEN__ #if LIBUSB_API_VERSION >= 0x01000106 libusb_set_option(NULL, LIBUSB_OPTION_LOG_LEVEL, debug_enabled ? 3 : 0); if (debug_enabled) fprintf(stderr, "[DEBUG] ch341a_spi_init: libusb log level set to %d\n", debug_enabled ? 3 : 0); #else - libusb_set_debug(NULL, debug_enabled ? 3 : 0); // Set debug level based on debug flag + libusb_set_debug(NULL, debug_enabled ? 3 : 0); if (debug_enabled) fprintf(stderr, "[DEBUG] ch341a_spi_init: libusb debug level set to %d\n", debug_enabled ? 3 : 0); +#endif #endif uint16_t vid = devs_ch341a_spi[0].vendor_id; uint16_t pid = devs_ch341a_spi[0].device_id; @@ -551,7 +698,7 @@ int ch341a_spi_init(void) printf("Found programmer device: %s - %s\n", devs_ch341a_spi[0].vendor_name, devs_ch341a_spi[0].device_name); -#ifdef __gnu_linux__ +#if defined(__gnu_linux__) && !defined(__EMSCRIPTEN__) /* libusb_detach_kernel_driver() and friends basically only work on Linux. * We simply try to detach on Linux without a lot of passion here. If that * works then fine, or we will fail on claiming the interface anyway. @@ -614,7 +761,8 @@ int ch341a_spi_init(void) (desc.bcdDevice >> 4) & 0x000F, (desc.bcdDevice >> 0) & 0x000F); - /* Allocate and pre-fill transfer structures. */ +#ifndef __EMSCRIPTEN__ + /* Allocate and pre-fill transfer structures (async path only). */ if (debug_enabled) fprintf(stderr, "[DEBUG] ch341a_spi_init: allocating USB transfer structures\n"); @@ -647,6 +795,9 @@ int ch341a_spi_init(void) libusb_fill_bulk_transfer(transfer_out, handle, WRITE_EP, NULL, 0, cb_out, NULL, USB_TIMEOUT); for (i = 0; i < USB_IN_TRANSFERS; i++) libusb_fill_bulk_transfer(transfer_ins[i], handle, READ_EP, NULL, 0, cb_in, NULL, USB_TIMEOUT); +#else + int i = 0; +#endif if (debug_enabled) fprintf(stderr, "[DEBUG] ch341a_spi_init: configuring stream and enabling pins\n"); @@ -663,7 +814,8 @@ int ch341a_spi_init(void) return 0; -dealloc_transfers: + dealloc_transfers: +#ifndef __EMSCRIPTEN__ for (i = 0; i < USB_IN_TRANSFERS; i++) { if (transfer_ins[i] == NULL) @@ -673,10 +825,27 @@ int ch341a_spi_init(void) } libusb_free_transfer(transfer_out); transfer_out = NULL; -release_interface: +#endif + release_interface: libusb_release_interface(handle, 0); -close_handle: + close_handle: libusb_close(handle); handle = NULL; return -1; } + +#ifdef __EMSCRIPTEN__ +int ch341a_spi_reinit(void) +{ + if (!handle) + return -1; + extern int usb_clear_halt(void *handle, int endpoint); + usb_clear_halt(handle, READ_EP); + usb_clear_halt(handle, WRITE_EP); + if (config_stream(CH341A_STM_I2C_750K) < 0) + return -1; + if (enable_pins(true) < 0) + return -1; + return 0; +} +#endif diff --git a/src/flashcmd_api.c b/src/flashcmd_api.c index 4eb95a0..82be764 100644 --- a/src/flashcmd_api.c +++ b/src/flashcmd_api.c @@ -1,6 +1,6 @@ /* * flashcmd_api.c - * Copyright (C) 2018-2021 McMCC + * Copyright (C) 2018-2021 McMcc * SPDX-License-Identifier: GPL-2.0-or-later */ #include "flashcmd_api.h" @@ -10,20 +10,23 @@ #include "spi_eeprom_api.h" #endif #include - #include - #include +#include +#include - #define __EEPROM___ "or EEPROM" - extern int eepromsize; - extern int mw_eepromsize; - extern int seepromsize; +#define __EEPROM___ "or EEPROM" long flash_cmd_init(struct flash_cmd *cmd) { long flen = -1; +#ifdef EEPROM_SUPPORT + extern int eepromsize; + extern int mw_eepromsize; + extern int seepromsize; + if ((eepromsize <= 0) && (mw_eepromsize <= 0) && (seepromsize <= 0)) { +#endif if ((flen = snor_init()) > 0) { cmd->flash_erase = snor_erase; @@ -36,6 +39,7 @@ long flash_cmd_init(struct flash_cmd *cmd) cmd->flash_write = snand_write; cmd->flash_read = snand_read; } +#ifdef EEPROM_SUPPORT } else if ((eepromsize > 0) || (mw_eepromsize > 0) || (seepromsize > 0)) { @@ -58,55 +62,58 @@ long flash_cmd_init(struct flash_cmd *cmd) cmd->flash_read = spi_eeprom_read; } } - else // If no flash/EEPROM was successfully initialized - fprintf(stderr, "\nFlash" __EEPROM___ " not found!!!!\n\n"); // Use stderr for errors + else + fprintf(stderr, "\nFlash" __EEPROM___ " not found!!!!\n\n"); +#endif return flen; - } +} - int flashcmd_verify(const unsigned char *data, unsigned long addr, unsigned long len, unsigned long file_len __attribute__((unused))) - { - int ret; - unsigned char *verify_buf = NULL; +int flashcmd_verify(const unsigned char *data, unsigned long addr, unsigned long len, unsigned long file_len __attribute__((unused))) +{ + int ret; + unsigned char *verify_buf = NULL; - if (!data || !len) - return 0; + if (!data || !len) + return 0; - verify_buf = (unsigned char *)malloc(len); - if (!verify_buf) - { - fprintf(stderr, "Malloc failed for verify buffer: len=%ld.\n", len); - return 0; - } + verify_buf = (unsigned char *)malloc(len); + if (!verify_buf) + { + fprintf(stderr, "Malloc failed for verify buffer: len=%ld.\n", len); + return 0; + } - ret = snand_read(verify_buf, addr, len); - if (ret < 0) - { - fprintf(stderr, "Verify Read Status: BAD(%d)\n", ret); - free(verify_buf); - return 0; - } + ret = snand_read(verify_buf, addr, len); + if (ret < 0) + { + fprintf(stderr, "Verify Read Status: BAD(%d)\n", ret); + free(verify_buf); + return 0; + } - if (memcmp(verify_buf, data, len) != 0) - { - fprintf(stderr, "Verify Status: BAD - Data mismatch\n"); - free(verify_buf); - return 0; - } + if (memcmp(verify_buf, data, len) != 0) + { + fprintf(stderr, "Verify Status: BAD - Data mismatch\n"); + free(verify_buf); + return 0; + } - free(verify_buf); - return 1; - } + free(verify_buf); + return 1; +} - void support_flash_list(void) - { - support_snand_list(); - printf("\n"); - support_snor_list(); - printf("\n"); - support_i2c_eeprom_list(); - printf("\n"); - support_mw_eeprom_list(); - printf("\n"); - support_spi_eeprom_list(); +void support_flash_list(void) +{ + support_snand_list(); + printf("\n"); + support_snor_list(); +#ifdef EEPROM_SUPPORT + printf("\n"); + support_i2c_eeprom_list(); + printf("\n"); + support_mw_eeprom_list(); + printf("\n"); + support_spi_eeprom_list(); +#endif } diff --git a/src/spi_nor_flash.c b/src/spi_nor_flash.c index b8ec6c1..f506d3d 100644 --- a/src/spi_nor_flash.c +++ b/src/spi_nor_flash.c @@ -3,6 +3,7 @@ #include "snorcmd_api.h" #include "types.h" #include "timer.h" +#include "ch341a_spi.h" #include #include #include @@ -80,7 +81,14 @@ int snor_wait_ready(int sleep_ms) { int count; uint8_t sr = 0; // Using uint8_t instead of u8 last_wait_error_was_epe = false; +#ifdef __EMSCRIPTEN__ + if (sleep_ms < 100) { + usleep(sleep_ms > 0 ? sleep_ms * 1000 : 1000); + } + for (count = 0; count < (sleep_ms < 100 ? 10 : sleep_ms + 30); count++) { +#else for (count = 0; count < ((sleep_ms + 1) * 1000); count++) { +#endif if ((snor_read_sr(&sr)) < 0) break; if (sr & SR_EPE) { @@ -93,8 +101,13 @@ int snor_wait_ready(int sleep_ms) { if (!(sr & (SR_WIP | SR_EPE | SR_WEL))) { return 0; } +#ifdef __EMSCRIPTEN__ + usleep(100000); + } +#else usleep(500); // Use usleep instead of udelay } +#endif printf("%s: read_sr fail: %x\n", __func__, sr); return -1; } @@ -126,8 +139,12 @@ static bool snor_wait_error_was_epe(void) static int snor_read_rg(uint8_t code, uint8_t *val) { // Using uint8_t int retval; SPI_CONTROLLER_Chip_Select_Low(); +#ifdef __EMSCRIPTEN__ + retval = ch341a_spi_send_command(1, 1, &code, val); +#else SPI_CONTROLLER_Write_One_Byte(code); retval = SPI_CONTROLLER_Read_NByte(val, 1); +#endif SPI_CONTROLLER_Chip_Select_High(); if (retval) { printf("%s: ret: %x\n", __func__, retval); @@ -840,9 +857,15 @@ static int snor_read_devid(u8 *rxbuf, int n_rx) int retval = 0; SPI_CONTROLLER_Chip_Select_Low(); +#ifdef __EMSCRIPTEN__ + { + u8 cmd = OPCODE_RDID; + retval = ch341a_spi_send_command(1, n_rx, &cmd, rxbuf); + } +#else SPI_CONTROLLER_Write_One_Byte(OPCODE_RDID); - retval = SPI_CONTROLLER_Read_NByte(rxbuf, n_rx); +#endif SPI_CONTROLLER_Chip_Select_High(); if (retval) { printf("%s: ret: %x\n", __func__, retval); @@ -924,9 +947,15 @@ int snor_read_sr(u8 *val) int retval = 0; SPI_CONTROLLER_Chip_Select_Low(); +#ifdef __EMSCRIPTEN__ + { + u8 cmd = OPCODE_RDSR; + retval = ch341a_spi_send_command(1, 1, &cmd, val); + } +#else SPI_CONTROLLER_Write_One_Byte(OPCODE_RDSR); - retval = SPI_CONTROLLER_Read_NByte(val, 1); +#endif SPI_CONTROLLER_Chip_Select_High(); if (retval) { printf("%s: ret: %x\n", __func__, retval); @@ -1015,13 +1044,17 @@ int snor_erase(unsigned long offs, unsigned long len) len -= spi_chip_info->sector_size; if( timer_progress() ) { +#ifndef __EMSCRIPTEN__ printf("\bErase %ld%% [%lu] of [%lu] bytes ", 100 * (plen - len) / plen, plen - len, plen); printf("\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b"); fflush(stdout); +#endif } } +#ifndef __EMSCRIPTEN__ printf("Erase 100%% [%lu] of [%lu] bytes \n", plen - len, plen); timer_end(); +#endif return 0; } @@ -1067,28 +1100,57 @@ int snor_read(unsigned char *buf, unsigned long from, unsigned long len) if( (data_offset + remain_len) < spi_chip_info->sector_size ) { +#ifdef __EMSCRIPTEN__ + u32 chunk_remain = remain_len; + u32 chunk_off = 0; + while (chunk_remain > 0) { + u32 chunk_len = chunk_remain > 4096 ? 4096 : chunk_remain; + if(SPI_CONTROLLER_Read_NByte(&buf[len - remain_len + chunk_off], chunk_len)) { +#else if(SPI_CONTROLLER_Read_NByte(&buf[len - remain_len], remain_len)) { +#endif SPI_CONTROLLER_Chip_Select_High(); if (spi_chip_info->addr4b) snor_4byte_mode(0); len = -1; break; } +#ifdef __EMSCRIPTEN__ + chunk_off += chunk_len; + chunk_remain -= chunk_len; + } + if (len == (unsigned long)-1) break; +#endif remain_len = 0; } else { +#ifdef __EMSCRIPTEN__ + u32 to_read = spi_chip_info->sector_size - data_offset; + u32 chunk_off = 0; + while (chunk_off < to_read) { + u32 chunk_len = (to_read - chunk_off) > 4096 ? 4096 : (to_read - chunk_off); + if(SPI_CONTROLLER_Read_NByte(&buf[len - remain_len + chunk_off], chunk_len)) { +#else if(SPI_CONTROLLER_Read_NByte(&buf[len - remain_len], spi_chip_info->sector_size - data_offset)) { +#endif SPI_CONTROLLER_Chip_Select_High(); if (spi_chip_info->addr4b) snor_4byte_mode(0); len = -1; break; } +#ifdef __EMSCRIPTEN__ + chunk_off += chunk_len; + } + if (len == (unsigned long)-1) break; +#endif remain_len -= spi_chip_info->sector_size - data_offset; read_addr += spi_chip_info->sector_size - data_offset; if( timer_progress() ) { +#ifndef __EMSCRIPTEN__ printf("\bRead %ld%% [%lu] of [%lu] bytes ", 100 * (len - remain_len) / len, len - remain_len, len); printf("\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b"); fflush(stdout); +#endif } } @@ -1097,8 +1159,12 @@ int snor_read(unsigned char *buf, unsigned long from, unsigned long len) if (spi_chip_info->addr4b) snor_4byte_mode(0); } +#ifndef __EMSCRIPTEN__ printf("Read 100%% [%lu] of [%lu] bytes \n", len - remain_len, len); +#endif +#ifndef __EMSCRIPTEN__ timer_end(); +#endif return len; } @@ -1167,9 +1233,11 @@ int snor_write(unsigned char *buf, unsigned long to, unsigned long len) // snor_dbg("%s: to:%x page_size:%x ret:%x\n", __func__, to, page_size, rc); // Commented out missing function if( timer_progress() ) { +#ifndef __EMSCRIPTEN__ printf("\bWritten %ld%% [%lu] of [%lu] bytes ", 100 * (plen - len) / plen, plen - len, plen); printf("\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b"); fflush(stdout); +#endif } if (rc > 0) { @@ -1203,13 +1271,17 @@ int snor_write(unsigned char *buf, unsigned long to, unsigned long len) snor_write_disable(); snor_clear_progress(); +#ifndef __EMSCRIPTEN__ timer_end(); +#endif if (err) { return err; } +#ifndef __EMSCRIPTEN__ printf("Written 100%% [%ld] of [%ld] bytes \n", plen - len, plen); +#endif return retlen; } diff --git a/web/CMakeLists.txt b/web/CMakeLists.txt new file mode 100644 index 0000000..d95773c --- /dev/null +++ b/web/CMakeLists.txt @@ -0,0 +1,83 @@ +cmake_minimum_required(VERSION 3.10) +project(scriba-web LANGUAGES C) + +set(CMAKE_C_STANDARD 99) +set(CMAKE_C_STANDARD_REQUIRED ON) + +set(LIBUSB_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/libusb-stub") + +set(SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../src") + +set(SCRIBA_SOURCES + ${SRC_DIR}/flashcmd_api.c + ${SRC_DIR}/spi_controller.c + ${SRC_DIR}/spi_nand_flash.c + ${SRC_DIR}/spi_nand_flash_protocol.c + ${SRC_DIR}/spi_nand_flash_tables.c + ${SRC_DIR}/spi_nor_flash.c + ${SRC_DIR}/ch341a_spi.c + ${SRC_DIR}/ezp2019_spi.c + ${SRC_DIR}/timer.c + ${CMAKE_CURRENT_SOURCE_DIR}/src/web_main.c +) + +add_executable(scriba_web ${SCRIBA_SOURCES}) + +target_include_directories(scriba_web PRIVATE + ${LIBUSB_INCLUDE_DIRS} + ${SRC_DIR} +) + +string(TIMESTAMP SCRIBA_WASM_BUILD "%Y%m%d-%H%M%S") +target_compile_definitions(scriba_web PRIVATE + GIT_COMMIT_DATE="web" + GIT_COMMIT_HASH="wasm" + SCRIBA_WASM_BUILD="${SCRIBA_WASM_BUILD}" +) + +target_compile_options(scriba_web PRIVATE -Wall -Wextra -Wno-unused-parameter -Wno-unused-variable) + + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/asyncify_imports.json" + "[\"emscripten_sleep\",\"libusb_get_device_list\",\"libusb_open\",\"libusb_open_device_with_vid_pid\",\"libusb_set_configuration\",\"libusb_claim_interface\",\"libusb_release_interface\",\"libusb_control_transfer\",\"libusb_bulk_transfer\",\"libusb_interrupt_transfer\",\"libusb_reset_device\",\"usb_clear_halt\"]" + ) + +set(EXPORTED_FUNCTIONS + "_main" + "_scriba_init" + "_scriba_init_programmer" + "_scriba_detect_chip" + "_scriba_get_flash_size" + "_scriba_get_chip_name" + "_scriba_get_programmer_type" + "_scriba_get_block_size" + "_scriba_read_flash" + "_scriba_write_flash" + "_scriba_erase_flash" + "_scriba_reinit" + "_scriba_shutdown" + "_scriba_get_version" + "_malloc" + "_free" +) +list(JOIN EXPORTED_FUNCTIONS "," EXPORTED_FUNCS_STR) + +target_link_options(scriba_web PRIVATE + "SHELL:-s ASYNCIFY=1" + "SHELL:-s ASYNCIFY_IMPORTS=@${CMAKE_CURRENT_BINARY_DIR}/asyncify_imports.json" + "SHELL:-s EXPORTED_FUNCTIONS=[${EXPORTED_FUNCS_STR}]" + "SHELL:-s EXPORTED_RUNTIME_METHODS=[\"ccall\",\"cwrap\",\"UTF8ToString\",\"stringToUTF8\",\"HEAPU8\",\"HEAPU32\",\"FS\"]" + "SHELL:-s ALLOW_MEMORY_GROWTH=1" + "SHELL:-s MODULARIZE=1" + "SHELL:-s EXPORT_NAME=createScribaModule" + "SHELL:-s ASYNCIFY_STACK_SIZE=131072" + "SHELL:-s ASSERTIONS=1" + "SHELL:-s INITIAL_MEMORY=67108864" + "SHELL:-s STACK_SIZE=8388608" + "SHELL:--js-library ${CMAKE_CURRENT_SOURCE_DIR}/src/libusb-webusb.js" +) + +set_target_properties(scriba_web PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/public/wasm" + OUTPUT_NAME "scriba" + SUFFIX ".js" +) diff --git a/web/build.sh b/web/build.sh new file mode 100755 index 0000000..a658e3d --- /dev/null +++ b/web/build.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Build scriba for WebAssembly +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" + +if ! command -v emcc &> /dev/null; then + echo "Error: emcc not found. Install Emscripten SDK and source emsdk_env.sh" + echo " git clone https://github.com/emscripten-core/emsdk.git" + echo " cd emsdk && ./emsdk install latest && ./emsdk activate latest" + echo " source ./emsdk_env.sh" + exit 1 +fi + +mkdir -p "$BUILD_DIR" +cd "$BUILD_DIR" + +emcmake cmake "$SCRIPT_DIR" -DCMAKE_BUILD_TYPE=Release +emmake make -j$(nproc) VERBOSE=1 + +cd "$SCRIPT_DIR" +[ ! -d node_modules ] && npm install +npm run build + +echo "" +echo "Build complete. Output in $SCRIPT_DIR/dist/" +ls -la "$SCRIPT_DIR/dist/" diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..4e71853 --- /dev/null +++ b/web/index.html @@ -0,0 +1,157 @@ + + + + + + Scriba ยท Web SPI Flasher + + + + + +
+ +
+
+

+ Scriba Web SPI Flasher Scriba Flasher +

+ Idle +
+ +
+ + Do not disconnect the programmer or navigate away during this operation +
+ + +
+
+ + WebUSB requires Chrome or Edge +
+
+
+
Chip:
+
Size:
+
Programmer:
+
+
+
+ + + +
+ + +
+
+

+ Log +

+ +
+
+
+
+
+
+ +

+ scriba + web-wasm +

+ +
+ + WebUSB requires HTTPS (or localhost) and Chrome or Edge. +
+ + + + + diff --git a/web/libusb-stub/libusb-1.0/libusb.h b/web/libusb-stub/libusb-1.0/libusb.h new file mode 100644 index 0000000..e3d1c5c --- /dev/null +++ b/web/libusb-stub/libusb-1.0/libusb.h @@ -0,0 +1,135 @@ +/** + * Minimal libusb-1.0 stub header for Emscripten/WebUSB builds. + * + * Types and constants match the real libusb API. Functions are + * implemented in libusb-webusb.js via Emscripten's --js-library. + * + * Extended for scriba with open_device_with_vid_pid, get_device, + * set_option, set_auto_detach_kernel_driver, and get_version. + */ + +#ifndef LIBUSB_STUB_H +#define LIBUSB_STUB_H + +#include +#include + +/* Opaque types */ +typedef struct libusb_context libusb_context; +typedef struct libusb_device libusb_device; +typedef struct libusb_device_handle libusb_device_handle; + +/* Device descriptor */ +struct libusb_device_descriptor { + uint8_t bLength; + uint8_t bDescriptorType; + uint16_t bcdUSB; + uint8_t bDeviceClass; + uint8_t bDeviceSubClass; + uint8_t bDeviceProtocol; + uint8_t bMaxPacketSize0; + uint16_t idVendor; + uint16_t idProduct; + uint16_t bcdDevice; + uint8_t iManufacturer; + uint8_t iProduct; + uint8_t iSerialNumber; + uint8_t bNumConfigurations; +}; + +/* Error codes */ +enum libusb_error { + LIBUSB_SUCCESS = 0, + LIBUSB_ERROR_IO = -1, + LIBUSB_ERROR_INVALID_PARAM = -2, + LIBUSB_ERROR_ACCESS = -3, + LIBUSB_ERROR_NO_DEVICE = -4, + LIBUSB_ERROR_NOT_FOUND = -5, + LIBUSB_ERROR_BUSY = -6, + LIBUSB_ERROR_TIMEOUT = -7, + LIBUSB_ERROR_OVERFLOW = -8, + LIBUSB_ERROR_PIPE = -9, + LIBUSB_ERROR_INTERRUPTED = -10, + LIBUSB_ERROR_NO_MEM = -11, + LIBUSB_ERROR_NOT_SUPPORTED = -12, + LIBUSB_ERROR_OTHER = -99, +}; + +/* Transfer status codes */ +enum libusb_transfer_status { + LIBUSB_TRANSFER_COMPLETED, + LIBUSB_TRANSFER_ERROR, + LIBUSB_TRANSFER_TIMED_OUT, + LIBUSB_TRANSFER_CANCELLED, + LIBUSB_TRANSFER_STALL, + LIBUSB_TRANSFER_NO_DEVICE, + LIBUSB_TRANSFER_OVERFLOW, +}; + +/* Request types */ +#define LIBUSB_ENDPOINT_IN 0x80 +#define LIBUSB_ENDPOINT_OUT 0x00 +#define LIBUSB_REQUEST_GET_DESCRIPTOR 0x06 + +#define LIBUSB_DT_STRING 0x03 + +/* Option codes */ +#define LIBUSB_OPTION_LOG_LEVEL 2 + +/* API version */ +#define LIBUSB_API_VERSION 0x01000106 + +/* Function prototypes */ +int libusb_init(libusb_context **ctx); +void libusb_exit(libusb_context *ctx); + +ssize_t libusb_get_device_list(libusb_context *ctx, libusb_device ***list); +void libusb_free_device_list(libusb_device **list, int unref_devices); + +int libusb_get_device_descriptor(libusb_device *dev, struct libusb_device_descriptor *desc); +uint8_t libusb_get_bus_number(libusb_device *dev); +uint8_t libusb_get_device_address(libusb_device *dev); +libusb_device *libusb_get_device(libusb_device_handle *handle); + +int libusb_open(libusb_device *dev, libusb_device_handle **handle); +void libusb_close(libusb_device_handle *handle); + +libusb_device_handle *libusb_open_device_with_vid_pid(libusb_context *ctx, + uint16_t vendor_id, uint16_t product_id); + +libusb_device *libusb_ref_device(libusb_device *dev); +void libusb_unref_device(libusb_device *dev); + +int libusb_set_configuration(libusb_device_handle *handle, int configuration); +int libusb_get_configuration(libusb_device_handle *handle, int *config); +int libusb_claim_interface(libusb_device_handle *handle, int interface_number); +int libusb_release_interface(libusb_device_handle *handle, int interface_number); + +int libusb_kernel_driver_active(libusb_device_handle *handle, int interface_number); +int libusb_detach_kernel_driver(libusb_device_handle *handle, int interface_number); +int libusb_set_auto_detach_kernel_driver(libusb_device_handle *handle, int enable); + +void libusb_set_option(libusb_context *ctx, int option, ...); +void libusb_set_debug(libusb_context *ctx, int level); + +int libusb_control_transfer(libusb_device_handle *handle, uint8_t bmRequestType, + uint8_t bRequest, uint16_t wValue, uint16_t wIndex, + unsigned char *data, uint16_t wLength, unsigned int timeout); + +int libusb_bulk_transfer(libusb_device_handle *handle, unsigned char endpoint, + unsigned char *data, int length, int *transferred, unsigned int timeout); + +int libusb_interrupt_transfer(libusb_device_handle *handle, unsigned char endpoint, + unsigned char *data, int length, int *transferred, unsigned int timeout); + +int libusb_reset_device(libusb_device_handle *handle); + +const char *libusb_error_name(int errcode); + +/* Bidirectional bulk transfer for devices that require concurrent OUT/IN */ +int usb_bulk_pair(libusb_device_handle *handle, + unsigned char ep_out, unsigned char *data_out, int out_len, + unsigned char ep_in, unsigned char *data_in, int in_len, + unsigned int timeout); + +#endif /* LIBUSB_STUB_H */ diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..0070df8 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1088 @@ +{ + "name": "scriba-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scriba-web", + "version": "1.0.0", + "dependencies": { + "bootstrap": "^5.3.8", + "bootstrap-icons": "^1.13.1" + }, + "devDependencies": { + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/bootstrap-icons": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz", + "integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ] + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..c36836a --- /dev/null +++ b/web/package.json @@ -0,0 +1,18 @@ +{ + "name": "scriba-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "bootstrap": "^5.3.8", + "bootstrap-icons": "^1.13.1" + }, + "devDependencies": { + "vite": "^6.0.0" + } +} diff --git a/web/src/app.js b/web/src/app.js new file mode 100644 index 0000000..2bbb949 --- /dev/null +++ b/web/src/app.js @@ -0,0 +1,588 @@ +/** + * Scriba Web Flasher - Application Logic + * + * Loads the WASM module, drives the scriba C API from JS, + * and manages the UI state machine. + */ + +/* ------------------------------------------------------------------ */ +/* State */ +/* ------------------------------------------------------------------ */ + +var Module = null; +var scribaReady = false; +var currentState = 'idle'; +var firmwareData = null; +var firmwareFileName = ''; +var flashSize = 0; +var wasmBusy = false; + +async function wasmCall(name, returnType, argTypes, args) { + while (wasmBusy) { + await new Promise(function(r) { setTimeout(r, 50); }); + } + wasmBusy = true; + try { + return await Module.ccall(name, returnType, argTypes, args, {async: true}); + } finally { + wasmBusy = false; + } +} + +/* ------------------------------------------------------------------ */ +/* Logging */ +/* ------------------------------------------------------------------ */ + +function log(msg, level) { + level = level || 'info'; + var el = document.getElementById('log'); + var line = document.createElement('div'); + line.className = level; + line.textContent = msg; + el.appendChild(line); + el.scrollTop = el.scrollHeight; +} + +function toggleEraseDebug() { + window.__eraseDebug = !window.__eraseDebug; + var btn = document.getElementById('btn-erase-debug'); + if (window.__eraseDebug) { + btn.classList.remove('btn-outline-secondary'); + btn.classList.add('btn-warning'); + log('Erase debug logging ENABLED (check browser console for USB traffic)', 'warn'); + } else { + btn.classList.remove('btn-warning'); + btn.classList.add('btn-outline-secondary'); + log('Erase debug logging disabled', 'info'); + } +} + +Object.assign(window, { toggleEraseDebug: toggleEraseDebug }); + +/* ------------------------------------------------------------------ */ +/* Progress */ +/* ------------------------------------------------------------------ */ + +function showProgress(percent, label) { + var container = document.getElementById('progress'); + container.classList.remove('d-none'); + document.getElementById('progress-fill').style.width = percent + '%'; + document.getElementById('progress-label').textContent = label || ''; +} + +function hideProgress() { + document.getElementById('progress').classList.add('d-none'); +} + +/* ------------------------------------------------------------------ */ +/* UI State */ +/* ------------------------------------------------------------------ */ + +function setState(state) { + currentState = state; + var badge = document.getElementById('status-badge'); + + var labels = { + idle: ['Idle', 'secondary'], + connecting: ['Connecting...', 'warning'], + detecting: ['Detecting...', 'warning'], + reading: ['Reading...', 'warning'], + writing: ['Writing...', 'warning'], + erasing: ['Erasing...', 'warning'], + done: ['Ready', 'success'], + error: ['Error', 'danger'], + }; + var info = labels[state] || ['Unknown', 'secondary']; + badge.textContent = info[0]; + badge.className = 'badge bg-' + info[1] + ' ms-auto'; + + var busy = ['connecting', 'detecting', 'reading', 'writing', 'erasing'].indexOf(state) !== -1; + var warn = document.getElementById('op-warning'); + if (busy) warn.classList.remove('d-none'); + else warn.classList.add('d-none'); + + document.getElementById('btn-connect').disabled = busy; + document.getElementById('btn-read-id').disabled = busy; + document.getElementById('btn-read').disabled = busy || state === 'idle'; + document.getElementById('btn-write').disabled = busy || state === 'idle'; + document.getElementById('btn-erase').disabled = busy || state === 'idle'; +} + +function showChipInfo(name, size, programmer) { + document.getElementById('chip-disconnected').classList.add('d-none'); + document.getElementById('chip-info').classList.remove('d-none'); + document.getElementById('info-chip').textContent = name || 'Unknown'; + document.getElementById('info-size').textContent = size ? formatSize(size) : 'Unknown'; + var progNames = {0: 'CH341A', 1: 'EZP2019', 2: 'Auto'}; + document.getElementById('info-programmer').textContent = progNames[programmer] || 'Unknown'; +} + +function formatSize(bytes) { + if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return bytes + ' B'; +} + +/* ------------------------------------------------------------------ */ +/* WASM Module Init */ +/* ------------------------------------------------------------------ */ + +async function initModule() { + console.log('Loading Scriba WASM module...'); + + try { + Module = await createScribaModule({ + printErr: function(text) { + if (typeof text === 'string') { + if (text.indexOf('[ERASE-DBG]') !== -1) log(text, 'warn'); + else if (text.startsWith('[DEBUG]')) log(text, 'debug'); + else if (text.startsWith('[WARN]') || text.startsWith('[EZP]')) log(text, 'warn'); + else if (text.startsWith('[ERROR]')) log(text, 'error'); + else log(text, 'info'); + } + }, + print: function(text) { + if (typeof text === 'string') log(text, 'info'); + }, + }); + + console.log('WASM module loaded (' + + (Module.HEAPU8.length / 1024 / 1024).toFixed(1) + ' MB heap)'); + + scribaReady = true; + log('Ready - click Connect to select your SPI programmer'); + } catch (e) { + log('Failed to initialize - check console for details', 'error'); + console.error(e); + } +} + +/* ------------------------------------------------------------------ */ +/* Connect */ +/* ------------------------------------------------------------------ */ + +async function connectDevice() { + if (!scribaReady) { + log('Module not ready', 'warn'); + return; + } + + setState('connecting'); + log('Requesting USB device...'); + + try { + var filters = [ + { vendorId: 0x1A86, productId: 0x5512 }, + { vendorId: 0x1FC8, productId: 0x310B }, + { vendorId: 0x1FC8, productId: 0x310C }, + { vendorId: 0x1FC8, productId: 0x310D }, + ]; + + var device; + try { + device = await navigator.usb.requestDevice({ filters: filters }); + } catch (e) { + log('No device selected', 'warn'); + setState('idle'); + return; + } + + if (!window._webusb_devices) window._webusb_devices = []; + var already = window._webusb_devices.some(function(d) { return d === device; }); + if (!already) window._webusb_devices.push(device); + + console.log('Device selected: VID=0x' + device.vendorId.toString(16) + + ' PID=0x' + device.productId.toString(16)); + + log('Connecting to programmer...'); + var result = await wasmCall('scriba_init', 'number', [], []); + if (result !== 0) { + log('Failed to connect to programmer', 'error'); + setState('error'); + return; + } + + log('Programmer connected'); + + setState('detecting'); + showProgress(50, 'Detecting flash chip...'); + + var detectResult = await wasmCall('scriba_detect_chip', 'number', [], []); + if (detectResult !== 0) { + log('No flash chip detected - check wiring', 'error'); + hideProgress(); + setState('error'); + return; + } + + flashSize = Module.ccall('scriba_get_flash_size', 'number', [], []); + var namePtr = Module.ccall('scriba_get_chip_name', 'number', [], []); + var chipName = namePtr ? Module.UTF8ToString(namePtr) : 'Unknown'; + var progType = Module.ccall('scriba_get_programmer_type', 'number', [], []); + + showChipInfo(chipName, flashSize, progType); + log('Detected: ' + chipName + ' (' + formatSize(flashSize) + ')'); + + showProgress(100, 'Detection complete'); + setTimeout(hideProgress, 1000); + setState('done'); + } catch (e) { + log('Connection error: ' + e.message, 'error'); + console.error(e); + hideProgress(); + setState('error'); + } +} + +/* ------------------------------------------------------------------ */ +/* Read Chip ID */ +/* ------------------------------------------------------------------ */ + +async function readChipId() { + if (!scribaReady) return; + + if (currentState === 'idle') { + await connectDevice(); + return; + } + + setState('detecting'); + log('Re-detecting flash chip...'); + + try { + var detectResult = await wasmCall('scriba_detect_chip', 'number', [], []); + if (detectResult !== 0) { + log('No flash chip detected', 'error'); + setState('error'); + return; + } + + flashSize = Module.ccall('scriba_get_flash_size', 'number', [], []); + var namePtr = Module.ccall('scriba_get_chip_name', 'number', [], []); + var chipName = namePtr ? Module.UTF8ToString(namePtr) : 'Unknown'; + var progType = Module.ccall('scriba_get_programmer_type', 'number', [], []); + + showChipInfo(chipName, flashSize, progType); + log('Detected: ' + chipName + ' (' + formatSize(flashSize) + ')'); + setState('done'); + } catch (e) { + log('Detection error: ' + e.message, 'error'); + console.error(e); + setState('error'); + } +} + +/* ------------------------------------------------------------------ */ +/* Read Flash */ +/* ------------------------------------------------------------------ */ + +async function doRead() { + if (!scribaReady || flashSize <= 0) return; + + setState('reading'); + log('Reading flash (' + formatSize(flashSize) + ')...'); + + try { + var bufPtr = Module._malloc(flashSize); + if (!bufPtr) { + log('Failed to allocate memory', 'error'); + hideProgress(); + setState('error'); + return; + } + + var chunkSize = 65536; + var offset = 0; + + while (offset < flashSize) { + var len = Math.min(chunkSize, flashSize - offset); + var pct = Math.round((offset / flashSize) * 100); + showProgress(pct, 'Reading... ' + pct + '% (' + formatSize(offset) + ' / ' + formatSize(flashSize) + ')'); + + var result = await wasmCall('scriba_read_flash', 'number', + ['number', 'number', 'number'], + [bufPtr + offset, offset, len]); + + if (result < 0) { + log('Read failed at offset 0x' + offset.toString(16) + ': error ' + result, 'error'); + Module._free(bufPtr); + hideProgress(); + setState('error'); + return; + } + offset += len; + } + + showProgress(95, 'Preparing download...'); + + var data = Module.HEAPU8.slice(bufPtr, bufPtr + flashSize); + Module._free(bufPtr); + + var blob = new Blob([data], { type: 'application/octet-stream' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'flash_dump.bin'; + a.click(); + URL.revokeObjectURL(url); + + showProgress(100, 'Read complete'); + log('Flash read complete - downloaded as flash_dump.bin (' + formatSize(flashSize) + ')'); + setTimeout(hideProgress, 1500); + setState('done'); + } catch (e) { + log('Read error: ' + e.message, 'error'); + console.error(e); + hideProgress(); + setState('error'); + } +} + +/* ------------------------------------------------------------------ */ +/* Write Flash */ +/* ------------------------------------------------------------------ */ + +function selectFirmware() { + document.getElementById('firmware-file').click(); +} + +function firmwareSelected(input) { + if (!input.files || !input.files[0]) return; + + var file = input.files[0]; + firmwareFileName = file.name; + + var reader = new FileReader(); + reader.onload = function(e) { + firmwareData = new Uint8Array(e.target.result); + var sizeMB = (firmwareData.length / (1024 * 1024)).toFixed(2); + document.getElementById('file-info').textContent = + firmwareFileName + ' (' + sizeMB + ' MB)'; + log('Firmware loaded: ' + firmwareFileName + ' (' + sizeMB + ' MB)'); + + if (firmwareData.length > flashSize) { + log('Warning: firmware (' + formatSize(firmwareData.length) + + ') is larger than flash (' + formatSize(flashSize) + ')', 'warn'); + } + + document.getElementById('btn-start-write').classList.remove('d-none'); + }; + reader.readAsArrayBuffer(file); +} + +async function doWrite() { + if (!scribaReady || !firmwareData) { + log('No firmware file selected', 'warn'); + return; + } + + if (!confirm('Write ' + firmwareFileName + ' to flash?\nThis will erase and overwrite the selected region.')) return; + + document.getElementById('btn-start-write').classList.add('d-none'); + + var writeLen = Math.min(firmwareData.length, flashSize); + + setState('erasing'); + showProgress(0, 'Erasing flash...'); + log('Erasing flash (' + formatSize(writeLen) + ')...'); + + try { + log('Erasing entire chip...'); + var eraseResult = await wasmCall('scriba_erase_flash', 'number', + ['number', 'number'], + [0, flashSize]); + + if (eraseResult !== 0) { + log('Erase failed: error ' + eraseResult, 'error'); + hideProgress(); + setState('error'); + return; + } + log('Erase complete'); + + setState('writing'); + log('Writing ' + firmwareFileName + ' (' + formatSize(writeLen) + ')...'); + + var dataPtr = Module._malloc(writeLen); + if (!dataPtr) { + log('Failed to allocate WASM memory', 'error'); + hideProgress(); + setState('error'); + return; + } + Module.HEAPU8.set(firmwareData.subarray(0, writeLen), dataPtr); + + var writeChunk = 65536; + var writeOffset = 0; + + while (writeOffset < writeLen) { + var len = Math.min(writeChunk, writeLen - writeOffset); + var pct = Math.round((writeOffset / writeLen) * 100); + showProgress(pct, 'Writing... ' + pct + '% (' + formatSize(writeOffset) + ' / ' + formatSize(writeLen) + ')'); + + var writeResult = await wasmCall('scriba_write_flash', 'number', + ['number', 'number', 'number'], + [dataPtr + writeOffset, writeOffset, len]); + + if (writeResult <= 0) { + log('Write failed at offset 0x' + writeOffset.toString(16) + ': error ' + writeResult, 'error'); + Module._free(dataPtr); + hideProgress(); + setState('error'); + return; + } + writeOffset += len; + await new Promise(function(r) { setTimeout(r, 10); }); + } + + Module._free(dataPtr); + log('Write complete'); + + showProgress(85, 'Verifying...'); + log('Verifying write...'); + + var verifyBuf = Module._malloc(writeLen); + if (verifyBuf) { + var verifyOk = true; + var verifyChunk = 65536; + var verifyOffset = 0; + + while (verifyOffset < writeLen) { + var len = Math.min(verifyChunk, writeLen - verifyOffset); + var pct = 85 + Math.round((verifyOffset / writeLen) * 14); + showProgress(pct, 'Verifying... ' + Math.round((verifyOffset / writeLen) * 100) + '%'); + + var readResult = await wasmCall('scriba_read_flash', 'number', + ['number', 'number', 'number'], + [verifyBuf + verifyOffset, verifyOffset, len]); + + if (readResult >= 0) { + for (var i = 0; i < len; i++) { + if (Module.HEAPU8[verifyBuf + verifyOffset + i] !== firmwareData[verifyOffset + i]) { + verifyOk = false; + log('Verify mismatch at offset 0x' + (verifyOffset + i).toString(16), 'error'); + break; + } + } + if (!verifyOk) break; + } else { + log('Verify read failed, skipping verification', 'warn'); + verifyOk = false; + break; + } + verifyOffset += len; + } + + Module._free(verifyBuf); + if (verifyOk) log('Verify: OK - data matches'); + else if (verifyOk === false && verifyOffset >= writeLen) { + /* already logged */ + } + } + + showProgress(100, 'Write complete'); + log('Firmware written successfully!'); + setTimeout(hideProgress, 1500); + setState('done'); + } catch (e) { + log('Write error: ' + e.message, 'error'); + console.error(e); + hideProgress(); + setState('error'); + } +} + +/* ------------------------------------------------------------------ */ +/* Erase Flash */ +/* ------------------------------------------------------------------ */ + +async function doErase() { + if (!scribaReady || flashSize <= 0) return; + + if (!confirm('Erase the entire flash chip? This cannot be undone.')) return; + + setState('erasing'); + log('Erasing entire flash (' + formatSize(flashSize) + ')...'); + + try { + showProgress(0, 'Erasing entire chip (BE command)...'); + + var result = await wasmCall('scriba_erase_flash', 'number', + ['number', 'number'], + [0, flashSize]); + + if (result !== 0) { + log('Full chip erase failed: error ' + result, 'error'); + hideProgress(); + setState('error'); + return; + } + + showProgress(100, 'Verifying erase...'); + var verifyPtr = Module._malloc(256); + if (verifyPtr) { + var readResult = await wasmCall('scriba_read_flash', 'number', + ['number', 'number', 'number'], + [verifyPtr, 0, 256]); + if (readResult >= 0) { + var allFF = true; + var firstNonFF = -1; + for (var i = 0; i < 256; i++) { + if (Module.HEAPU8[verifyPtr + i] !== 0xFF) { + allFF = false; + if (firstNonFF < 0) firstNonFF = i; + } + } + if (allFF) { + log('Erase verified: first 256 bytes are all 0xFF', 'success'); + } else { + log('Erase FAILED: byte ' + firstNonFF + ' is 0x' + Module.HEAPU8[verifyPtr + firstNonFF].toString(16) + ' (expected 0xFF)', 'error'); + var hex = ''; + for (var i = 0; i < 32; i++) hex += ('0' + Module.HEAPU8[verifyPtr + i].toString(16)).slice(-2) + ' '; + log('First 32 bytes: ' + hex.trim(), 'warn'); + } + } else { + log('Post-erase read failed', 'warn'); + } + Module._free(verifyPtr); + } + + showProgress(100, 'Erase complete'); + log('Flash erased successfully'); + setTimeout(hideProgress, 1500); + setState('done'); + } catch (e) { + log('Erase error: ' + e.message, 'error'); + console.error(e); + hideProgress(); + setState('error'); + } +} + +/* ------------------------------------------------------------------ */ +/* Init */ +/* ------------------------------------------------------------------ */ + +Object.assign(window, { + connectDevice, readChipId, selectFirmware, firmwareSelected, + doRead, doWrite, doErase +}); + +(function() { + if (!navigator.usb) { + document.getElementById('browser-warning').classList.remove('d-none'); + document.querySelector('.flasher-card').classList.add('d-none'); + return; + } + + navigator.usb.addEventListener('connect', function(e) { + console.log('USB device connected: VID=0x' + e.device.vendorId.toString(16) + + ' PID=0x' + e.device.productId.toString(16)); + }); + navigator.usb.addEventListener('disconnect', function(e) { + console.log('USB device disconnected'); + }); + + setState('idle'); + initModule(); +})(); diff --git a/web/src/libusb-webusb.js b/web/src/libusb-webusb.js new file mode 100644 index 0000000..fcbdf73 --- /dev/null +++ b/web/src/libusb-webusb.js @@ -0,0 +1,527 @@ +/** + * libusb โ†’ WebUSB shim for Emscripten + * + * Adapted from thingino-cloner for scriba SPI flash programmer. + * Supports CH341A (0x1A86:0x5512) and EZP2019 family (0x1FC8:0x310B-310D). + * + * Uses Asyncify.handleAsync() to properly pause/resume WASM + * when calling async WebUSB APIs. + */ + +mergeInto(LibraryManager.library, { + + $webusb_state: { + devices: [], + handles: [], + device_list: null, + device_descriptors: [], + next_handle_id: 1, + handle_device_map: {}, + }, + + $webusb_state__deps: [], + $webusb_state__postset: '', + + /* ------------------------------------------------------------------ */ + /* Init / Exit */ + /* ------------------------------------------------------------------ */ + + libusb_init__deps: ['$webusb_state'], + libusb_init: function(ctx_ptr) { + if (ctx_ptr) {{{ makeSetValue('ctx_ptr', '0', '0', 'i32') }}}; + return 0; + }, + + libusb_exit__deps: ['$webusb_state'], + libusb_exit: function(ctx) { + webusb_state.devices = []; + webusb_state.handles = []; + webusb_state.handle_device_map = {}; + }, + + /* ------------------------------------------------------------------ */ + /* Device enumeration */ + /* ------------------------------------------------------------------ */ + + libusb_get_device_list__deps: ['$webusb_state', 'malloc'], + libusb_get_device_list__async: true, + libusb_get_device_list: function(ctx, list_ptr) { + return Asyncify.handleAsync(function() { + var SCRIBA_VIDS = [0x1A86, 0x1FC8]; + var SCRIBA_PIDS = [0x5512, 0x310B, 0x310C, 0x310D]; + + return navigator.usb.getDevices().then(function(allDevices) { + var extra = (typeof window !== 'undefined' && window._webusb_devices) || []; + for (var i = 0; i < extra.length; i++) { + if (allDevices.indexOf(extra[i]) === -1) allDevices.push(extra[i]); + } + var devices = allDevices.filter(function(d) { + return SCRIBA_VIDS.indexOf(d.vendorId) !== -1 && + SCRIBA_PIDS.indexOf(d.productId) !== -1; + }); + + webusb_state.devices = devices; + webusb_state.device_descriptors = []; + for (var i = 0; i < devices.length; i++) { + webusb_state.device_descriptors.push({ + idVendor: devices[i].vendorId, + idProduct: devices[i].productId, + bNumConfigurations: devices[i].configurations ? devices[i].configurations.length : 1, + }); + } + + var count = devices.length; + var arr = _malloc((count + 1) * 4); + if (!arr) return -11; + + for (var i = 0; i < count; i++) { + {{{ makeSetValue('arr', 'i * 4', 'i + 1', 'i32') }}}; + } + {{{ makeSetValue('arr', 'count * 4', '0', 'i32') }}}; + {{{ makeSetValue('list_ptr', '0', 'arr', 'i32') }}}; + + webusb_state.device_list = arr; + return count; + }); + }); + }, + + libusb_free_device_list__deps: ['$webusb_state', 'free'], + libusb_free_device_list: function(list, unref_devices) { + if (list) _free(list); + if (list === webusb_state.device_list) webusb_state.device_list = null; + }, + + /* ------------------------------------------------------------------ */ + /* Device descriptor / addressing */ + /* ------------------------------------------------------------------ */ + + libusb_get_device_descriptor__deps: ['$webusb_state'], + libusb_get_device_descriptor: function(dev_ptr, desc_ptr) { + var idx = dev_ptr - 1; + if (idx < 0 || idx >= webusb_state.device_descriptors.length) return -5; + var d = webusb_state.device_descriptors[idx]; + for (var i = 0; i < 18; i++) {{{ makeSetValue('desc_ptr', 'i', '0', 'i8') }}}; + {{{ makeSetValue('desc_ptr', '8', 'd.idVendor', 'i16') }}}; + {{{ makeSetValue('desc_ptr', '10', 'd.idProduct', 'i16') }}}; + {{{ makeSetValue('desc_ptr', '17', 'd.bNumConfigurations', 'i8') }}}; + return 0; + }, + + libusb_get_bus_number__deps: [], + libusb_get_bus_number: function(dev_ptr) { return 1; }, + + libusb_get_device_address__deps: [], + libusb_get_device_address: function(dev_ptr) { return dev_ptr; }, + + libusb_get_device__deps: ['$webusb_state'], + libusb_get_device: function(handle_ptr) { + var idx = webusb_state.handle_device_map[handle_ptr]; + if (idx === undefined) return 0; + return idx + 1; + }, + + /* ------------------------------------------------------------------ */ + /* Open / Close / Ref */ + /* ------------------------------------------------------------------ */ + + libusb_open__deps: ['$webusb_state'], + libusb_open__async: true, + libusb_open: function(dev_ptr, handle_ptr) { + var idx = dev_ptr - 1; + if (idx < 0 || idx >= webusb_state.devices.length) return -5; + var device = webusb_state.devices[idx]; + + return Asyncify.handleAsync(function() { + var p = device.opened ? Promise.resolve() : device.open(); + return p.then(function() { + var handle_id = webusb_state.next_handle_id++; + webusb_state.handles[handle_id] = device; + webusb_state.handle_device_map[handle_id] = idx; + {{{ makeSetValue('handle_ptr', '0', 'handle_id', 'i32') }}}; + return 0; + }).catch(function(e) { + console.error('libusb_open error:', e); + return -3; + }); + }); + }, + + libusb_close__deps: ['$webusb_state'], + libusb_close: function(handle_ptr) { + var device = webusb_state.handles[handle_ptr]; + if (!device) return; + delete webusb_state.handles[handle_ptr]; + delete webusb_state.handle_device_map[handle_ptr]; + }, + + libusb_ref_device__deps: [], + libusb_ref_device: function(dev_ptr) { return dev_ptr; }, + + libusb_unref_device__deps: [], + libusb_unref_device: function(dev_ptr) {}, + + /* ------------------------------------------------------------------ */ + /* open_device_with_vid_pid */ + /* ------------------------------------------------------------------ */ + + libusb_open_device_with_vid_pid__deps: ['$webusb_state'], + libusb_open_device_with_vid_pid__async: true, + libusb_open_device_with_vid_pid: function(ctx, vid, pid) { + return Asyncify.handleAsync(function() { + return navigator.usb.getDevices().then(function(allDevices) { + var extra = (typeof window !== 'undefined' && window._webusb_devices) || []; + for (var i = 0; i < extra.length; i++) { + if (allDevices.indexOf(extra[i]) === -1) allDevices.push(extra[i]); + } + + var match = null; + for (var i = 0; i < allDevices.length; i++) { + if (allDevices[i].vendorId === vid && allDevices[i].productId === pid) { + match = allDevices[i]; + break; + } + } + + if (!match) { + return 0; + } + + /* Register in device list if not present */ + var idx = webusb_state.devices.indexOf(match); + if (idx === -1) { + webusb_state.devices.push(match); + webusb_state.device_descriptors.push({ + idVendor: match.vendorId, + idProduct: match.productId, + bNumConfigurations: match.configurations ? match.configurations.length : 1, + }); + idx = webusb_state.devices.length - 1; + } + + var p = match.opened ? Promise.resolve() : match.open(); + return p.then(function() { + return match.claimInterface(0); + }).then(function() { + var handle_id = webusb_state.next_handle_id++; + webusb_state.handles[handle_id] = match; + webusb_state.handle_device_map[handle_id] = idx; + return handle_id; + }).catch(function(e) { + console.error('open_device_with_vid_pid error:', e); + return 0; + }); + }); + }); + }, + + /* ------------------------------------------------------------------ */ + /* Configuration / Interface */ + /* ------------------------------------------------------------------ */ + + libusb_set_configuration__deps: ['$webusb_state'], + libusb_set_configuration__async: true, + libusb_set_configuration: function(handle_ptr, configuration) { + var device = webusb_state.handles[handle_ptr]; + if (!device) return -4; + return Asyncify.handleAsync(function() { + return device.selectConfiguration(configuration).then(function() { + return 0; + }).catch(function() { return 0; }); + }); + }, + + libusb_get_configuration__deps: ['$webusb_state'], + libusb_get_configuration: function(handle_ptr, config_ptr) { + var device = webusb_state.handles[handle_ptr]; + if (!device) return -4; + var v = device.configuration ? device.configuration.configurationValue : 1; + {{{ makeSetValue('config_ptr', '0', 'v', 'i32') }}}; + return 0; + }, + + libusb_claim_interface__deps: ['$webusb_state'], + libusb_claim_interface__async: true, + libusb_claim_interface: function(handle_ptr, iface) { + var device = webusb_state.handles[handle_ptr]; + if (!device) return -4; + return Asyncify.handleAsync(function() { + return device.claimInterface(iface).then(function() { return 0; }) + .catch(function(e) { console.error('claimInterface:', e); return -6; }); + }); + }, + + libusb_release_interface__deps: ['$webusb_state'], + libusb_release_interface__async: true, + libusb_release_interface: function(handle_ptr, iface) { + var device = webusb_state.handles[handle_ptr]; + if (!device) return -4; + return Asyncify.handleAsync(function() { + return device.releaseInterface(iface).then(function() { return 0; }) + .catch(function() { return 0; }); + }); + }, + + libusb_kernel_driver_active__deps: [], + libusb_kernel_driver_active: function() { return 0; }, + + libusb_detach_kernel_driver__deps: [], + libusb_detach_kernel_driver: function() { return 0; }, + + libusb_set_auto_detach_kernel_driver__deps: [], + libusb_set_auto_detach_kernel_driver: function() { return 0; }, + + libusb_set_option__deps: [], + libusb_set_option: function() { return 0; }, + + libusb_set_debug__deps: [], + libusb_set_debug: function() {}, + + /* ------------------------------------------------------------------ */ + /* Transfers */ + /* ------------------------------------------------------------------ */ + + libusb_control_transfer__deps: ['$webusb_state'], + libusb_control_transfer__async: true, + libusb_control_transfer: function(handle_ptr, bmRequestType, bRequest, + wValue, wIndex, data_ptr, wLength, timeout) { + var device = webusb_state.handles[handle_ptr]; + if (!device) return -4; + + var isIn = (bmRequestType & 0x80) !== 0; + var setup = { requestType: 'standard', recipient: 'device', + request: bRequest, value: wValue, index: wIndex }; + if (bRequest === 0x06) setup.requestType = 'standard'; + else setup.requestType = 'vendor'; + var timeoutMs = (timeout && timeout > 0) ? timeout : 5000; + + return Asyncify.handleAsync(function() { + var transferPromise; + if (isIn) { + transferPromise = device.controlTransferIn(setup, wLength).then(function(result) { + if (result.status !== 'ok') return -9; + var received = new Uint8Array(result.data.buffer); + for (var i = 0; i < received.length && i < wLength; i++) { + {{{ makeSetValue('data_ptr', 'i', 'received[i]', 'i8') }}}; + } + return received.length; + }); + } else { + var sendData = new Uint8Array(0); + if (wLength > 0 && data_ptr) { + sendData = new Uint8Array(wLength); + for (var i = 0; i < wLength; i++) { + sendData[i] = {{{ makeGetValue('data_ptr', 'i', 'i8') }}} & 0xFF; + } + } + transferPromise = device.controlTransferOut(setup, sendData).then(function(result) { + if (result.status !== 'ok') return -9; + return result.bytesWritten; + }); + } + var timeoutPromise = new Promise(function(_, reject) { + setTimeout(function() { reject({name: 'TimeoutError'}); }, timeoutMs); + }); + return Promise.race([transferPromise, timeoutPromise]).catch(function(e) { + if (e.name === 'TimeoutError') return -7; + if (e.name === 'NotFoundError') return -4; + if (e.name === 'NetworkError') return -9; + console.error('control_transfer error:', e); + return -1; + }); + }); + }, + + libusb_bulk_transfer__deps: ['$webusb_state'], + libusb_bulk_transfer__async: true, + libusb_bulk_transfer: function(handle_ptr, endpoint, data_ptr, length, + transferred_ptr, timeout) { + var device = webusb_state.handles[handle_ptr]; + if (!device) return -4; + + var isIn = (endpoint & 0x80) !== 0; + var epNum = endpoint & 0x0F; + var timeoutMs = (timeout && timeout > 0) ? timeout : 30000; + + return Asyncify.handleAsync(function() { + if (typeof window !== 'undefined' && window.__eraseDebug) { + if (!window.__usbDbgCount) window.__usbDbgCount = 0; + window.__usbDbgCount++; + if (window.__usbDbgCount % 100 === 1) { + var dir = isIn ? 'IN' : 'OUT'; + console.log('[USB-DBG] bulk ' + dir + ' ep=0x' + endpoint.toString(16) + + ' len=' + length + ' (seq ' + window.__usbDbgCount + ')'); + } + } + + var transferPromise; + if (isIn) { + transferPromise = device.transferIn(epNum, length).then(function(result) { + if (result.status !== 'ok') { + if (transferred_ptr) {{{ makeSetValue('transferred_ptr', '0', '0', 'i32') }}}; + return -9; + } + var received = new Uint8Array(result.data.buffer); + var count = Math.min(received.length, length); + HEAPU8.set(received.subarray(0, count), data_ptr); + if (transferred_ptr) {{{ makeSetValue('transferred_ptr', '0', 'count', 'i32') }}}; + if (typeof window !== 'undefined' && window.__eraseDebug && count <= 40) { + var hex = ''; + for (var i = 0; i < count; i++) { + hex += ('0' + (received[i] & 0xFF).toString(16)).slice(-2) + ' '; + } + console.log('[USB-DBG] bulk IN result: ' + count + ' bytes data=' + hex.trim()); + } + return 0; + }); + } else { + var sendData = HEAPU8.slice(data_ptr, data_ptr + length); + transferPromise = device.transferOut(epNum, sendData).then(function(result) { + if (result.status !== 'ok') { + if (transferred_ptr) {{{ makeSetValue('transferred_ptr', '0', '0', 'i32') }}}; + return -9; + } + if (transferred_ptr) {{{ makeSetValue('transferred_ptr', '0', 'result.bytesWritten', 'i32') }}}; + return 0; + }); + } + var timeoutPromise = new Promise(function(_, reject) { + setTimeout(function() { reject({name: 'TimeoutError'}); }, timeoutMs); + }); + return Promise.race([transferPromise, timeoutPromise]).catch(function(e) { + if (transferred_ptr) {{{ makeSetValue('transferred_ptr', '0', '0', 'i32') }}}; + if (e.name === 'TimeoutError') { + console.warn('bulk_transfer TIMEOUT: ep=0x' + endpoint.toString(16) + + ' len=' + length + ' dir=' + (isIn ? 'IN' : 'OUT')); + return -7; + } + if (e.name === 'NotFoundError') return -4; + console.error('bulk_transfer error:', e); + return -1; + }); + }); + }, + + usb_clear_halt__deps: ['$webusb_state'], + usb_clear_halt__async: true, + usb_clear_halt: function(handle_ptr, endpoint) { + var device = webusb_state.handles[handle_ptr]; + if (!device) return -4; + var isIn = (endpoint & 0x80) !== 0; + var epNum = endpoint & 0x0F; + var direction = isIn ? 'in' : 'out'; + return Asyncify.handleAsync(function() { + return device.clearHalt(direction, epNum).then(function() { + return 0; + }).catch(function(e) { + console.warn('clearHalt failed:', e); + return -1; + }); + }); + }, + + libusb_interrupt_transfer__deps: ['$webusb_state'], + libusb_interrupt_transfer__async: true, + libusb_interrupt_transfer: function(handle_ptr, endpoint, data_ptr, length, + transferred_ptr, timeout) { + return _libusb_bulk_transfer(handle_ptr, endpoint, data_ptr, length, + transferred_ptr, timeout); + }, + + /* ------------------------------------------------------------------ */ + /* Reset / Error names */ + /* ------------------------------------------------------------------ */ + + libusb_reset_device__deps: ['$webusb_state'], + libusb_reset_device__async: true, + libusb_reset_device: function(handle_ptr) { + var device = webusb_state.handles[handle_ptr]; + if (!device) return -4; + return Asyncify.handleAsync(function() { + return device.reset().then(function() { return 0; }) + .catch(function() { return 0; }); + }); + }, + + libusb_error_name__deps: ['malloc'], + libusb_error_name: function(errcode) { + var names = { + 0: "LIBUSB_SUCCESS", '-1': "LIBUSB_ERROR_IO", + '-2': "LIBUSB_ERROR_INVALID_PARAM", '-3': "LIBUSB_ERROR_ACCESS", + '-4': "LIBUSB_ERROR_NO_DEVICE", '-5': "LIBUSB_ERROR_NOT_FOUND", + '-6': "LIBUSB_ERROR_BUSY", '-7': "LIBUSB_ERROR_TIMEOUT", + '-8': "LIBUSB_ERROR_OVERFLOW", '-9': "LIBUSB_ERROR_PIPE", + '-10': "LIBUSB_ERROR_INTERRUPTED", '-11': "LIBUSB_ERROR_NO_MEM", + '-12': "LIBUSB_ERROR_NOT_SUPPORTED", '-99': "LIBUSB_ERROR_OTHER", + }; + var name = names[String(errcode)] || "LIBUSB_UNKNOWN_ERROR"; + if (!_libusb_error_name._cache) _libusb_error_name._cache = {}; + if (!_libusb_error_name._cache[errcode]) { + var len = name.length + 1; + var ptr = _malloc(len); + stringToUTF8(name, ptr, len); + _libusb_error_name._cache[errcode] = ptr; + } + return _libusb_error_name._cache[errcode]; + }, + + /* ------------------------------------------------------------------ */ + /* Bidirectional bulk transfer (concurrent OUT + IN) */ + /* ------------------------------------------------------------------ */ + + usb_bulk_pair__deps: ['$webusb_state'], + usb_bulk_pair__async: true, + usb_bulk_pair: function(handle_ptr, ep_out, data_out_ptr, out_len, + ep_in, data_in_ptr, in_len, timeout) { + var device = webusb_state.handles[handle_ptr]; + if (!device) return -4; + + var epOutNum = ep_out & 0x0F; + var epInNum = ep_in & 0x0F; + var timeoutMs = (timeout && timeout > 0) ? timeout : 30000; + + return Asyncify.handleAsync(function() { + var sendData = HEAPU8.slice(data_out_ptr, data_out_ptr + out_len); + + var outP = device.transferOut(epOutNum, sendData); + var inP = device.transferIn(epInNum, in_len); + + var timeoutP = new Promise(function(_, reject) { + setTimeout(function() { reject({name: 'TimeoutError'}); }, timeoutMs); + }); + + return Promise.race([ + Promise.all([outP, inP]), + timeoutP + ]).then(function(results) { + var outResult = results[0]; + var inResult = results[1]; + + if (outResult.status !== 'ok') { + console.error('usb_bulk_pair OUT failed:', outResult.status); + return -9; + } + if (inResult.status !== 'ok') { + console.error('usb_bulk_pair IN failed:', inResult.status); + return -9; + } + + var received = new Uint8Array(inResult.data.buffer); + var count = Math.min(received.length, in_len); + HEAPU8.set(received.subarray(0, count), data_in_ptr); + + if (count < in_len) { + console.warn('usb_bulk_pair short read: got ' + count + ' expected ' + in_len); + } + return 0; + }).catch(function(e) { + if (e.name === 'TimeoutError') { + console.error('usb_bulk_pair TIMEOUT after ' + timeoutMs + 'ms'); + return -7; + } + console.error('usb_bulk_pair error:', e); + return -1; + }); + }); + }, +}); diff --git a/web/src/main.js b/web/src/main.js new file mode 100644 index 0000000..9433a16 --- /dev/null +++ b/web/src/main.js @@ -0,0 +1,8 @@ +import 'bootstrap/dist/css/bootstrap.min.css' +import 'bootstrap-icons/font/bootstrap-icons.css' +import * as bootstrap from 'bootstrap' +import './app.js' + +document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach( + el => new bootstrap.Tooltip(el) +) diff --git a/web/src/web_main.c b/web/src/web_main.c new file mode 100644 index 0000000..e5fb581 --- /dev/null +++ b/web/src/web_main.c @@ -0,0 +1,170 @@ +/** + * Web WASM entry point for scriba. + * + * Provides exported C functions for the JavaScript web frontend to call: + * - scriba_init / scriba_shutdown: USB programmer lifecycle + * - scriba_detect_chip: probe flash chip and return type/size/name + * - scriba_read_flash / scriba_write_flash / scriba_erase_flash + * + * Uses the same flash_cmd interface as the CLI, driven from JS. + */ + +#include +#include +#include +#include + +#include "flashcmd_api.h" +#include "ch341a_spi.h" +#include "ezp2019_spi.h" +#include "spi_controller.h" +#include "spi_nand_flash.h" +#include "spi_nor_flash.h" +#include "snorcmd_api.h" +#include "nandcmd_api.h" + +int debug_enabled = 0; +int trace_enabled = 0; + +#ifndef SCRIBA_WASM_BUILD +#define SCRIBA_WASM_BUILD "dev" +#endif +static const char build_info[] = "wasmfix-" SCRIBA_WASM_BUILD; + +static struct flash_cmd prog; +static long flash_size = -1; +static int chip_detected = 0; +static int flash_type = 0; /* 0=unknown, 1=NOR, 2=NAND */ +static char chip_name_buf[128] = {0}; + +extern unsigned int bsize; + +const char *scriba_get_version(void) { + return build_info; +} + +int scriba_init(void) { + flash_size = -1; + chip_detected = 0; + flash_type = 0; + memset(chip_name_buf, 0, sizeof(chip_name_buf)); + + fprintf(stderr, "*** Scriba WASM build: %s ***\n", build_info); + + programmer_type = PROGRAMMER_AUTO; + + if (ezp2019_spi_init() == 0) { + programmer_type = PROGRAMMER_EZP2019; + } else if (ch341a_spi_init() == 0) { + programmer_type = PROGRAMMER_CH341A; + } else { + return -1; + } + + return 0; +} + +int scriba_init_programmer(int type) { + flash_size = -1; + chip_detected = 0; + flash_type = 0; + memset(chip_name_buf, 0, sizeof(chip_name_buf)); + + if (type == 0) { + programmer_type = PROGRAMMER_CH341A; + return ch341a_spi_init(); + } else if (type == 1) { + programmer_type = PROGRAMMER_EZP2019; + return ezp2019_spi_init(); + } else { + programmer_type = PROGRAMMER_AUTO; + if (ezp2019_spi_init() == 0) { + programmer_type = PROGRAMMER_EZP2019; + return 0; + } + if (ch341a_spi_init() == 0) { + programmer_type = PROGRAMMER_CH341A; + return 0; + } + return -1; + } +} + +int scriba_detect_chip(void) { + flash_size = flash_cmd_init(&prog); + if (flash_size <= 0) + return -1; + + if (spi_chip_info != NULL && spi_chip_info->name != NULL) { + flash_type = 1; + snprintf(chip_name_buf, sizeof(chip_name_buf), "NOR: %s", spi_chip_info->name); + } else { + struct SPI_NAND_FLASH_INFO_T info; + if (SPI_NAND_Flash_Get_Flash_Info(&info) == 0 && info.ptr_name) { + flash_type = 2; + snprintf(chip_name_buf, sizeof(chip_name_buf), "NAND: %s", info.ptr_name); + } else { + flash_type = 1; + snprintf(chip_name_buf, sizeof(chip_name_buf), "Flash (%ld bytes)", flash_size); + } + } + + chip_detected = 1; + return 0; +} + +long scriba_get_flash_size(void) { + return flash_size; +} + +const char *scriba_get_chip_name(void) { + return chip_name_buf; +} + +int scriba_get_programmer_type(void) { + return (int)programmer_type; +} + +unsigned int scriba_get_block_size(void) { + return bsize; +} + +int scriba_read_flash(unsigned char *buf, unsigned long offset, unsigned long len) { + if (!chip_detected || !prog.flash_read) + return -1; + return prog.flash_read(buf, offset, len); +} + +int scriba_write_flash(const unsigned char *buf, unsigned long offset, unsigned long len) { + if (!chip_detected || !prog.flash_write) + return -1; + return prog.flash_write((unsigned char *)buf, offset, len); +} + +int scriba_erase_flash(unsigned long offset, unsigned long len) { + if (!chip_detected || !prog.flash_erase) + return -1; + return prog.flash_erase(offset, len); +} + +#ifdef __EMSCRIPTEN__ +extern int ch341a_spi_reinit(void); +int scriba_reinit(void) { + return ch341a_spi_reinit(); +} +#endif + +void scriba_shutdown(void) { + if (programmer_type == PROGRAMMER_EZP2019) + ezp2019_spi_shutdown(); + else + ch341a_spi_shutdown(); + + flash_size = -1; + chip_detected = 0; + flash_type = 0; +} + +int main(void) { + return 0; +} diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..0098938 --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + outDir: 'dist', + emptyOutDir: true, + }, +}) From 3f63e6ba9b734ef6a9be9375dcf4516dc5efbab1 Mon Sep 17 00:00:00 2001 From: Josh at WLTechBlog Date: Mon, 25 May 2026 17:31:58 -0700 Subject: [PATCH 2/4] docs: add Web/WASM build and deploy instructions to README --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index b6ddbcd..b16c220 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,44 @@ sudo make install # installs binary + udev rules, reloads udev `make install` copies udev rules for both programmers and reloads them. If your programmer was already plugged in, unplug and replug it after installation. +## Web Version (WebUSB / WASM) + +Scriba also runs in the browser via WebAssembly and WebUSB โ€” no driver +installation needed. Requires a Chromium-based browser (Chrome, Edge, Opera). + +### Build + +```bash +cd web +npm install +cd build && emcmake cmake .. && emmake make +``` + +The build outputs `scriba.js` and `scriba.wasm` to `web/public/wasm/`. + +**Prerequisites:** Emscripten (`emcc`), CMake, Node.js + +### Development + +```bash +cd web +npm install +npm run dev # starts Vite dev server on http://localhost:5173 +``` + +Vite serves static files from `web/` including `web/public/wasm/` where the +Emscripten build places its output. After rebuilding (`emmake make`), just +reload the browser. + +### Production + +```bash +cd web +npm run build # outputs to web/dist/ +``` + +Serve the `web/dist/` directory with any static file server. + ## Usage ``` From 18fa7e61461c7196c288958cc5ff681237d85ae6 Mon Sep 17 00:00:00 2001 From: Josh at WLTechBlog Date: Mon, 25 May 2026 18:17:32 -0700 Subject: [PATCH 3/4] fix: address code review feedback - enable_pins WASM: use DIR_INPUT on disable (matching native path) - snor_wait_ready: increase WASM timeout iterations to match native (sleep_ms*5+5 instead of sleep_ms+30, ~475s at sleep_ms=950) - wasmCall: add 60s busy-wait timeout, clear wasmBusy on USB disconnect - snor_read: clean up ifdef nesting for clarity (no functional change) - erase verification: check 64KB instead of 256 bytes - Remove duplicate extern usb_clear_halt declarations - Remove dead else-if in write verification --- src/ch341a_spi.c | 6 +----- src/spi_nor_flash.c | 14 +++++++++----- web/src/app.js | 18 +++++++++++------- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/ch341a_spi.c b/src/ch341a_spi.c index fe1201f..afbf295 100644 --- a/src/ch341a_spi.c +++ b/src/ch341a_spi.c @@ -448,9 +448,6 @@ static uint8_t swap_byte(uint8_t x) * D6/21 unused (DIN2) * D7/22 SO/2 (DIN) */ -#ifdef __EMSCRIPTEN__ -extern int usb_clear_halt(void *handle_ptr, int endpoint); -#endif int enable_pins(bool enable) { @@ -461,7 +458,7 @@ int enable_pins(bool enable) uint8_t buf[] = { CH341A_CMD_UIO_STREAM, CH341A_CMD_UIO_STM_OUT | (enable ? CH341A_UIO_STATE_CS0_LOW_SCK_LOW : CH341A_UIO_STATE_CS_HIGH_SCK_LOW), - CH341A_CMD_UIO_STM_DIR | CH341A_UIO_DIR_ALL_OUTPUT, + CH341A_CMD_UIO_STM_DIR | (enable ? CH341A_UIO_DIR_ALL_OUTPUT : CH341A_UIO_DIR_INPUT), CH341A_CMD_UIO_STM_END, }; #else @@ -839,7 +836,6 @@ int ch341a_spi_reinit(void) { if (!handle) return -1; - extern int usb_clear_halt(void *handle, int endpoint); usb_clear_halt(handle, READ_EP); usb_clear_halt(handle, WRITE_EP); if (config_stream(CH341A_STM_I2C_750K) < 0) diff --git a/src/spi_nor_flash.c b/src/spi_nor_flash.c index f506d3d..eb8d9ad 100644 --- a/src/spi_nor_flash.c +++ b/src/spi_nor_flash.c @@ -85,7 +85,7 @@ int snor_wait_ready(int sleep_ms) { if (sleep_ms < 100) { usleep(sleep_ms > 0 ? sleep_ms * 1000 : 1000); } - for (count = 0; count < (sleep_ms < 100 ? 10 : sleep_ms + 30); count++) { + for (count = 0; count < (sleep_ms < 100 ? 10 : sleep_ms * 5 + 5); count++) { #else for (count = 0; count < ((sleep_ms + 1) * 1000); count++) { #endif @@ -1106,20 +1106,24 @@ int snor_read(unsigned char *buf, unsigned long from, unsigned long len) while (chunk_remain > 0) { u32 chunk_len = chunk_remain > 4096 ? 4096 : chunk_remain; if(SPI_CONTROLLER_Read_NByte(&buf[len - remain_len + chunk_off], chunk_len)) { -#else - if(SPI_CONTROLLER_Read_NByte(&buf[len - remain_len], remain_len)) { -#endif SPI_CONTROLLER_Chip_Select_High(); if (spi_chip_info->addr4b) snor_4byte_mode(0); len = -1; break; } -#ifdef __EMSCRIPTEN__ chunk_off += chunk_len; chunk_remain -= chunk_len; } if (len == (unsigned long)-1) break; +#else + if(SPI_CONTROLLER_Read_NByte(&buf[len - remain_len], remain_len)) { + SPI_CONTROLLER_Chip_Select_High(); + if (spi_chip_info->addr4b) + snor_4byte_mode(0); + len = -1; + break; + } #endif remain_len = 0; } else { diff --git a/web/src/app.js b/web/src/app.js index 2bbb949..a5ffd5d 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -18,8 +18,11 @@ var flashSize = 0; var wasmBusy = false; async function wasmCall(name, returnType, argTypes, args) { + var waited = 0; while (wasmBusy) { + if (waited > 60000) throw new Error('wasmCall timeout waiting for previous operation'); await new Promise(function(r) { setTimeout(r, 50); }); + waited += 50; } wasmBusy = true; try { @@ -475,9 +478,6 @@ async function doWrite() { Module._free(verifyBuf); if (verifyOk) log('Verify: OK - data matches'); - else if (verifyOk === false && verifyOffset >= writeLen) { - /* already logged */ - } } showProgress(100, 'Write complete'); @@ -519,22 +519,24 @@ async function doErase() { } showProgress(100, 'Verifying erase...'); - var verifyPtr = Module._malloc(256); + var verifyLen = 65536; // one sector + var verifyPtr = Module._malloc(verifyLen); if (verifyPtr) { var readResult = await wasmCall('scriba_read_flash', 'number', ['number', 'number', 'number'], - [verifyPtr, 0, 256]); + [verifyPtr, 0, verifyLen]); if (readResult >= 0) { var allFF = true; var firstNonFF = -1; - for (var i = 0; i < 256; i++) { + for (var i = 0; i < verifyLen; i++) { if (Module.HEAPU8[verifyPtr + i] !== 0xFF) { allFF = false; if (firstNonFF < 0) firstNonFF = i; + break; } } if (allFF) { - log('Erase verified: first 256 bytes are all 0xFF', 'success'); + log('Erase verified: first ' + verifyLen + ' bytes are all 0xFF', 'success'); } else { log('Erase FAILED: byte ' + firstNonFF + ' is 0x' + Module.HEAPU8[verifyPtr + firstNonFF].toString(16) + ' (expected 0xFF)', 'error'); var hex = ''; @@ -581,6 +583,8 @@ Object.assign(window, { }); navigator.usb.addEventListener('disconnect', function(e) { console.log('USB device disconnected'); + wasmBusy = false; + setState('idle'); }); setState('idle'); From 6ac95f7a363d16379b996a2e88c8637e8cf0e835 Mon Sep 17 00:00:00 2001 From: Josh at WLTechBlog Date: Mon, 25 May 2026 18:30:31 -0700 Subject: [PATCH 4/4] fix: detect and warn on fast-erase failure - JS: if erase completes in < 3 seconds, show an error telling user to re-seat the flash chip and reload the page - C: in full_erase_chip, verify WEL is set after WREN before sending BE command. If WEL not set, reinit CH341A and retry WREN (matching the pattern already used in snor_erase_sector) --- src/spi_nor_flash.c | 13 +++++++++++++ web/src/app.js | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/spi_nor_flash.c b/src/spi_nor_flash.c index eb8d9ad..b77a223 100644 --- a/src/spi_nor_flash.c +++ b/src/spi_nor_flash.c @@ -513,6 +513,19 @@ int full_erase_chip(void) { } snor_set_progress("BE", 0); snor_write_enable(); +#ifdef __EMSCRIPTEN__ + { + u8 sr; + if (snor_read_sr(&sr) == 0 && !(sr & 0x02)) { + fprintf(stderr, "[WARN] WEL not set after WREN (SR=0x%02x), retrying with reinit\n", sr); + extern int ch341a_spi_reinit(void); + ch341a_spi_reinit(); + snor_write_enable(); + snor_read_sr(&sr); + fprintf(stderr, "[WARN] WEL after retry: SR=0x%02x\n", sr); + } + } +#endif SPI_CONTROLLER_Chip_Select_Low(); SPI_CONTROLLER_Write_One_Byte(OPCODE_BE1); SPI_CONTROLLER_Chip_Select_High(); diff --git a/web/src/app.js b/web/src/app.js index a5ffd5d..33e9502 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -507,9 +507,11 @@ async function doErase() { try { showProgress(0, 'Erasing entire chip (BE command)...'); + var eraseStart = Date.now(); var result = await wasmCall('scriba_erase_flash', 'number', ['number', 'number'], [0, flashSize]); + var eraseSec = (Date.now() - eraseStart) / 1000; if (result !== 0) { log('Full chip erase failed: error ' + result, 'error'); @@ -518,6 +520,16 @@ async function doErase() { return; } + if (eraseSec < 3) { + log('WARNING: Erase completed in ' + eraseSec.toFixed(1) + + 's which is too fast for this chip.' + + ' The command may not have been accepted.' + + ' Try re-seating the flash chip and reloading the page.', 'error'); + hideProgress(); + setState('error'); + return; + } + showProgress(100, 'Verifying erase...'); var verifyLen = 65536; // one sector var verifyPtr = Module._malloc(verifyLen);