backend-infra-engineer: Post v0.3.9-hotfix7 snapshot (build cleanup)
This commit is contained in:
@@ -1,64 +1,18 @@
|
||||
#ifndef YAZE_APP_PLATFORM_APP_DELEGATE_H
|
||||
#define YAZE_APP_PLATFORM_APP_DELEGATE_H
|
||||
#ifndef YAZE_APP_PLATFORM_APP_DELEGATE_H_
|
||||
#define YAZE_APP_PLATFORM_APP_DELEGATE_H_
|
||||
|
||||
#include "app/application.h"
|
||||
|
||||
#if defined(__APPLE__) && defined(__MACH__)
|
||||
/* Apple OSX and iOS (Darwin). */
|
||||
#import <CoreText/CoreText.h>
|
||||
#include <TargetConditionals.h>
|
||||
|
||||
#if TARGET_IPHONE_SIMULATOR == 1 || TARGET_OS_IPHONE == 1
|
||||
/* iOS in Xcode simulator */
|
||||
#import <PencilKit/PencilKit.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@interface AppDelegate : UIResponder <UIApplicationDelegate,
|
||||
UIDocumentPickerDelegate,
|
||||
UITabBarControllerDelegate,
|
||||
PKCanvasViewDelegate>
|
||||
@property(strong, nonatomic) UIWindow* window;
|
||||
|
||||
@property UIDocumentPickerViewController* documentPicker;
|
||||
@property(nonatomic, copy) void (^completionHandler)(NSString* selectedFile);
|
||||
- (void)PresentDocumentPickerWithCompletionHandler:
|
||||
(void (^)(NSString* selectedFile))completionHandler;
|
||||
|
||||
// TODO: Setup a tab bar controller for multiple yaze instances
|
||||
@property(nonatomic) UITabBarController* tabBarController;
|
||||
|
||||
// TODO: Setup a font picker for the text editor and display settings
|
||||
@property(nonatomic) UIFontPickerViewController* fontPicker;
|
||||
|
||||
// TODO: Setup the pencil kit for drawing
|
||||
@property PKToolPicker* toolPicker;
|
||||
@property PKCanvasView* canvasView;
|
||||
|
||||
// TODO: Setup the file manager for file operations
|
||||
@property NSFileManager* fileManager;
|
||||
|
||||
@end
|
||||
|
||||
#elif TARGET_OS_MAC == 1
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Initialize the Cocoa application.
|
||||
*/
|
||||
// Initialize Cocoa Application Delegate
|
||||
void yaze_initialize_cocoa();
|
||||
|
||||
/**
|
||||
* @brief Run the Cocoa application delegate.
|
||||
*/
|
||||
int yaze_run_cocoa_app_delegate(const char* filename);
|
||||
// Run the main loop with Cocoa App Delegate
|
||||
int yaze_run_cocoa_app_delegate(const yaze::AppConfig& config);
|
||||
}
|
||||
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
#endif
|
||||
|
||||
#endif // TARGET_OS_MAC
|
||||
|
||||
#endif // defined(__APPLE__) && defined(__MACH__)
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_APP_DELEGATE_H
|
||||
#endif // YAZE_APP_PLATFORM_APP_DELEGATE_H_
|
||||
|
||||
@@ -7,14 +7,12 @@
|
||||
|
||||
#import "app/platform/app_delegate.h"
|
||||
#import "app/controller.h"
|
||||
#import "app/application.h"
|
||||
#import "util/file_util.h"
|
||||
#import "app/editor/editor.h"
|
||||
#import "app/rom.h"
|
||||
#include <span>
|
||||
#import "rom/rom.h"
|
||||
#include <vector>
|
||||
|
||||
using std::span;
|
||||
|
||||
#if defined(__APPLE__) && defined(__MACH__)
|
||||
/* Apple OSX and iOS (Darwin). */
|
||||
#include <TargetConditionals.h>
|
||||
@@ -30,16 +28,10 @@ using std::span;
|
||||
|
||||
@interface AppDelegate : NSObject <NSApplicationDelegate>
|
||||
- (void)setupMenus;
|
||||
// - (void)changeApplicationIcon;
|
||||
@end
|
||||
|
||||
@implementation AppDelegate
|
||||
|
||||
// - (void)changeApplicationIcon {
|
||||
// NSImage *newIcon = [NSImage imageNamed:@"newIcon"];
|
||||
// [NSApp setApplicationIconImage:newIcon];
|
||||
// }
|
||||
|
||||
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
|
||||
[self setupMenus];
|
||||
|
||||
@@ -62,7 +54,7 @@ using std::span;
|
||||
keyEquivalent:@"o"];
|
||||
[fileMenu addItem:openItem];
|
||||
|
||||
// Open Recent
|
||||
// Open Recent (System handled usually, but we can add our own if needed)
|
||||
NSMenu *openRecentMenu = [[NSMenu alloc] initWithTitle:@"Open Recent"];
|
||||
NSMenuItem *openRecentMenuItem = [[NSMenuItem alloc] initWithTitle:@"Open Recent"
|
||||
action:nil
|
||||
@@ -70,179 +62,146 @@ using std::span;
|
||||
[openRecentMenuItem setSubmenu:openRecentMenu];
|
||||
[fileMenu addItem:openRecentMenuItem];
|
||||
|
||||
// Add a separator
|
||||
[fileMenu addItem:[NSMenuItem separatorItem]];
|
||||
|
||||
// Save
|
||||
NSMenuItem *saveItem = [[NSMenuItem alloc] initWithTitle:@"Save" action:nil keyEquivalent:@"s"];
|
||||
NSMenuItem *saveItem = [[NSMenuItem alloc] initWithTitle:@"Save"
|
||||
action:@selector(saveAction:)
|
||||
keyEquivalent:@"s"];
|
||||
[fileMenu addItem:saveItem];
|
||||
|
||||
// Save As
|
||||
NSMenuItem *saveAsItem = [[NSMenuItem alloc] initWithTitle:@"Save As..."
|
||||
action:@selector(saveAsAction:)
|
||||
keyEquivalent:@"S"];
|
||||
[fileMenu addItem:saveAsItem];
|
||||
|
||||
// Separator
|
||||
[fileMenu addItem:[NSMenuItem separatorItem]];
|
||||
|
||||
// Options submenu
|
||||
NSMenu *optionsMenu = [[NSMenu alloc] initWithTitle:@"Options"];
|
||||
NSMenuItem *optionsMenuItem = [[NSMenuItem alloc] initWithTitle:@"Options"
|
||||
action:nil
|
||||
keyEquivalent:@""];
|
||||
[optionsMenuItem setSubmenu:optionsMenu];
|
||||
|
||||
// Flag checkmark field
|
||||
NSMenuItem *flagItem = [[NSMenuItem alloc] initWithTitle:@"Flag"
|
||||
action:@selector(toggleFlagAction:)
|
||||
keyEquivalent:@""];
|
||||
[flagItem setTarget:self];
|
||||
[flagItem setState:NSControlStateValueOff];
|
||||
[optionsMenu addItem:flagItem];
|
||||
[fileMenu addItem:optionsMenuItem];
|
||||
|
||||
[mainMenu insertItem:fileMenuItem atIndex:1];
|
||||
}
|
||||
|
||||
// Edit Menu
|
||||
NSMenuItem *editMenuItem = [mainMenu itemWithTitle:@"Edit"];
|
||||
if (!editMenuItem) {
|
||||
NSMenu *editMenu = [[NSMenu alloc] initWithTitle:@"Edit"];
|
||||
editMenuItem = [[NSMenuItem alloc] initWithTitle:@"Edit" action:nil keyEquivalent:@""];
|
||||
[editMenuItem setSubmenu:editMenu];
|
||||
|
||||
NSMenuItem *undoItem = [[NSMenuItem alloc] initWithTitle:@"Undo" action:nil keyEquivalent:@"z"];
|
||||
|
||||
NSMenuItem *undoItem = [[NSMenuItem alloc] initWithTitle:@"Undo"
|
||||
action:@selector(undoAction:)
|
||||
keyEquivalent:@"z"];
|
||||
[editMenu addItem:undoItem];
|
||||
|
||||
NSMenuItem *redoItem = [[NSMenuItem alloc] initWithTitle:@"Redo" action:nil keyEquivalent:@"Z"];
|
||||
|
||||
NSMenuItem *redoItem = [[NSMenuItem alloc] initWithTitle:@"Redo"
|
||||
action:@selector(redoAction:)
|
||||
keyEquivalent:@"Z"];
|
||||
[editMenu addItem:redoItem];
|
||||
|
||||
// Add a separator
|
||||
[editMenu addItem:[NSMenuItem separatorItem]];
|
||||
|
||||
NSMenuItem *cutItem = [[NSMenuItem alloc] initWithTitle:@"Cut"
|
||||
action:@selector(cutAction:)
|
||||
keyEquivalent:@"x"];
|
||||
[editMenu addItem:cutItem];
|
||||
|
||||
NSMenuItem *copyItem = [[NSMenuItem alloc] initWithTitle:@"Copy" action:nil keyEquivalent:@"c"];
|
||||
[editMenu addItem:copyItem];
|
||||
|
||||
NSMenuItem *pasteItem = [[NSMenuItem alloc] initWithTitle:@"Paste"
|
||||
action:nil
|
||||
keyEquivalent:@"v"];
|
||||
|
||||
[editMenu addItem:pasteItem];
|
||||
|
||||
// Add a separator
|
||||
[editMenu addItem:[NSMenuItem separatorItem]];
|
||||
|
||||
NSMenuItem *selectAllItem = [[NSMenuItem alloc] initWithTitle:@"Select All"
|
||||
action:nil
|
||||
keyEquivalent:@"a"];
|
||||
|
||||
[editMenu addItem:selectAllItem];
|
||||
|
||||
// System-handled copy/paste usually works if we don't override,
|
||||
// but we might want to wire them to our internal clipboard if needed.
|
||||
// For now, let SDL handle keyboard events for copy/paste in ImGui.
|
||||
|
||||
[mainMenu insertItem:editMenuItem atIndex:2];
|
||||
}
|
||||
|
||||
// View Menu
|
||||
NSMenuItem *viewMenuItem = [mainMenu itemWithTitle:@"View"];
|
||||
if (!viewMenuItem) {
|
||||
NSMenu *viewMenu = [[NSMenu alloc] initWithTitle:@"View"];
|
||||
viewMenuItem = [[NSMenuItem alloc] initWithTitle:@"View" action:nil keyEquivalent:@""];
|
||||
[viewMenuItem setSubmenu:viewMenu];
|
||||
|
||||
// Emulator view button
|
||||
NSMenuItem *emulatorViewItem = [[NSMenuItem alloc] initWithTitle:@"Emulator View"
|
||||
action:nil
|
||||
keyEquivalent:@"1"];
|
||||
|
||||
[viewMenu addItem:emulatorViewItem];
|
||||
|
||||
// Hex Editor View
|
||||
NSMenuItem *hexEditorViewItem = [[NSMenuItem alloc] initWithTitle:@"Hex Editor View"
|
||||
action:nil
|
||||
keyEquivalent:@"2"];
|
||||
|
||||
[viewMenu addItem:hexEditorViewItem];
|
||||
|
||||
// Disassembly view button
|
||||
NSMenuItem *disassemblyViewItem = [[NSMenuItem alloc] initWithTitle:@"Disassembly View"
|
||||
action:nil
|
||||
keyEquivalent:@"3"];
|
||||
|
||||
[viewMenu addItem:disassemblyViewItem];
|
||||
|
||||
// Memory view button
|
||||
NSMenuItem *memoryViewItem = [[NSMenuItem alloc] initWithTitle:@"Memory View"
|
||||
action:nil
|
||||
keyEquivalent:@"4"];
|
||||
|
||||
[viewMenu addItem:memoryViewItem];
|
||||
|
||||
// Add a separator
|
||||
[viewMenu addItem:[NSMenuItem separatorItem]];
|
||||
|
||||
// Toggle fullscreen button
|
||||
NSMenuItem *toggleFullscreenItem = [[NSMenuItem alloc] initWithTitle:@"Toggle Fullscreen"
|
||||
action:nil
|
||||
action:@selector(toggleFullscreenAction:)
|
||||
keyEquivalent:@"f"];
|
||||
|
||||
[viewMenu addItem:toggleFullscreenItem];
|
||||
|
||||
[mainMenu insertItem:viewMenuItem atIndex:3];
|
||||
}
|
||||
|
||||
NSMenuItem *helpMenuItem = [mainMenu itemWithTitle:@"Help"];
|
||||
if (!helpMenuItem) {
|
||||
NSMenu *helpMenu = [[NSMenu alloc] initWithTitle:@"Help"];
|
||||
helpMenuItem = [[NSMenuItem alloc] initWithTitle:@"Help" action:nil keyEquivalent:@""];
|
||||
[helpMenuItem setSubmenu:helpMenu];
|
||||
|
||||
// URL to online documentation
|
||||
NSMenuItem *documentationItem = [[NSMenuItem alloc] initWithTitle:@"Documentation"
|
||||
action:nil
|
||||
keyEquivalent:@"?"];
|
||||
[helpMenu addItem:documentationItem];
|
||||
|
||||
[mainMenu insertItem:helpMenuItem atIndex:4];
|
||||
}
|
||||
}
|
||||
|
||||
// Action method for the New menu item
|
||||
- (void)newFileAction:(id)sender {
|
||||
NSLog(@"New File action triggered");
|
||||
}
|
||||
|
||||
- (void)toggleFlagAction:(id)sender {
|
||||
NSMenuItem *flagItem = (NSMenuItem *)sender;
|
||||
if ([flagItem state] == NSControlStateValueOff) {
|
||||
[flagItem setState:NSControlStateValueOn];
|
||||
} else {
|
||||
[flagItem setState:NSControlStateValueOff];
|
||||
}
|
||||
}
|
||||
// ============================================================================
|
||||
// Menu Actions
|
||||
// ============================================================================
|
||||
|
||||
- (void)openFileAction:(id)sender {
|
||||
// TODO: Re-implmenent this without the SharedRom singleton
|
||||
// if (!yaze::SharedRom::shared_rom_
|
||||
// ->LoadFromFile(yaze::util::FileDialogWrapper::ShowOpenFileDialog())
|
||||
// .ok()) {
|
||||
// NSAlert *alert = [[NSAlert alloc] init];
|
||||
// [alert setMessageText:@"Error"];
|
||||
// [alert setInformativeText:@"Failed to load file."];
|
||||
// [alert addButtonWithTitle:@"OK"];
|
||||
// [alert runModal];
|
||||
// }
|
||||
// Use our internal file dialog via Application -> Controller -> EditorManager
|
||||
// Or trigger the native dialog here and pass the path back.
|
||||
// Since we have ImGui dialogs, we might prefer those, but native is nice on macOS.
|
||||
// For now, let's just trigger the LoadRom logic which opens the dialog.
|
||||
auto& app = yaze::Application::Instance();
|
||||
if (app.IsReady() && app.GetController()) {
|
||||
if (auto* manager = app.GetController()->editor_manager()) {
|
||||
(void)manager->LoadRom();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)cutAction:(id)sender {
|
||||
// TODO: Implement
|
||||
- (void)saveAction:(id)sender {
|
||||
auto& app = yaze::Application::Instance();
|
||||
if (app.IsReady() && app.GetController()) {
|
||||
if (auto* manager = app.GetController()->editor_manager()) {
|
||||
(void)manager->SaveRom();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)openRecentFileAction:(id)sender {
|
||||
NSLog(@"Open Recent File action triggered");
|
||||
- (void)saveAsAction:(id)sender {
|
||||
// Trigger Save As logic
|
||||
// Manager->SaveRomAs("") usually triggers dialog
|
||||
auto& app = yaze::Application::Instance();
|
||||
if (app.IsReady() && app.GetController()) {
|
||||
if (auto* manager = app.GetController()->editor_manager()) {
|
||||
// We need a method to trigger Save As dialog from manager,
|
||||
// usually passing empty string does it or there's a specific method.
|
||||
// EditorManager::SaveRomAs(string) saves immediately.
|
||||
// We might need to expose a method to show the dialog.
|
||||
// For now, let's assume we can use the file dialog wrapper from C++ side.
|
||||
(void)manager->SaveRomAs(""); // This might fail if empty string isn't handled as "ask user"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)undoAction:(id)sender {
|
||||
// Route to active editor
|
||||
auto& app = yaze::Application::Instance();
|
||||
if (app.IsReady() && app.GetController()) {
|
||||
if (auto* manager = app.GetController()->editor_manager()) {
|
||||
// manager->card_registry().TriggerUndo(); // If we exposed TriggerUndo
|
||||
// Or directly:
|
||||
if (auto* current = manager->GetCurrentEditor()) {
|
||||
(void)current->Undo();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)redoAction:(id)sender {
|
||||
auto& app = yaze::Application::Instance();
|
||||
if (app.IsReady() && app.GetController()) {
|
||||
if (auto* manager = app.GetController()->editor_manager()) {
|
||||
if (auto* current = manager->GetCurrentEditor()) {
|
||||
(void)current->Redo();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)toggleFullscreenAction:(id)sender {
|
||||
// Toggle fullscreen on the window
|
||||
// SDL usually handles this, but we can trigger it via SDL_SetWindowFullscreen
|
||||
// Accessing window via Application -> Controller -> Window
|
||||
// Use SDL backend logic
|
||||
// For now, rely on the View menu item shortcut that ImGui might catch,
|
||||
// or implement proper toggling in Controller.
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
extern "C" void yaze_initialize_cococa() {
|
||||
extern "C" void yaze_initialize_cocoa() {
|
||||
@autoreleasepool {
|
||||
AppDelegate *delegate = [[AppDelegate alloc] init];
|
||||
[NSApplication sharedApplication];
|
||||
@@ -251,25 +210,27 @@ extern "C" void yaze_initialize_cococa() {
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" int yaze_run_cocoa_app_delegate(const char *filename) {
|
||||
yaze_initialize_cococa();
|
||||
auto controller = std::make_unique<yaze::Controller>();
|
||||
EXIT_IF_ERROR(controller->OnEntry(filename));
|
||||
while (controller->IsActive()) {
|
||||
extern "C" int yaze_run_cocoa_app_delegate(const yaze::AppConfig& config) {
|
||||
yaze_initialize_cocoa();
|
||||
|
||||
// Initialize the Application singleton with the provided config
|
||||
// This will create the Controller and the SDL Window
|
||||
yaze::Application::Instance().Initialize(config);
|
||||
|
||||
// Main loop
|
||||
// We continue to run our own loop rather than [NSApp run]
|
||||
// because we're driving SDL/ImGui manually.
|
||||
// SDL's event polling works fine with Cocoa in this setup.
|
||||
|
||||
auto& app = yaze::Application::Instance();
|
||||
|
||||
while (app.IsReady() && app.GetController()->IsActive()) {
|
||||
@autoreleasepool {
|
||||
controller->OnInput();
|
||||
if (auto status = controller->OnLoad(); !status.ok()) {
|
||||
NSAlert *alert = [[NSAlert alloc] init];
|
||||
[alert setMessageText:@"Error"];
|
||||
[alert setInformativeText:[NSString stringWithUTF8String:status.message().data()]];
|
||||
[alert addButtonWithTitle:@"OK"];
|
||||
[alert runModal];
|
||||
break;
|
||||
}
|
||||
controller->DoRender();
|
||||
app.Tick();
|
||||
}
|
||||
}
|
||||
controller->OnExit();
|
||||
|
||||
app.Shutdown();
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
60
src/app/platform/file_dialog_web.cc
Normal file
60
src/app/platform/file_dialog_web.cc
Normal file
@@ -0,0 +1,60 @@
|
||||
#include "util/file_util.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
#include <emscripten.h>
|
||||
#endif
|
||||
|
||||
namespace yaze {
|
||||
namespace util {
|
||||
|
||||
// Web implementation of FileDialogWrapper
|
||||
// Triggers the existing file input element in the HTML
|
||||
|
||||
std::string FileDialogWrapper::ShowOpenFileDialog() {
|
||||
#ifdef __EMSCRIPTEN__
|
||||
// Trigger the existing file input element
|
||||
// The file input handler in app.js will handle the file selection
|
||||
// and call LoadRomFromWeb, which will update the ROM
|
||||
EM_ASM({
|
||||
var romInput = document.getElementById('rom-input');
|
||||
if (romInput) {
|
||||
romInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Return empty string - the actual loading happens asynchronously
|
||||
// via the file input handler which calls LoadRomFromWeb
|
||||
return "";
|
||||
#else
|
||||
return "";
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string FileDialogWrapper::ShowOpenFolderDialog() {
|
||||
// Folder picking not supported on web in the same way
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string FileDialogWrapper::ShowSaveFileDialog(
|
||||
const std::string& default_name, const std::string& default_extension) {
|
||||
// TODO(web): Implement download trigger via JS
|
||||
return "";
|
||||
}
|
||||
|
||||
std::vector<std::string> FileDialogWrapper::GetSubdirectoriesInFolder(
|
||||
const std::string& folder_path) {
|
||||
// Emscripten's VFS might support this if mounted
|
||||
return {};
|
||||
}
|
||||
|
||||
std::vector<std::string> FileDialogWrapper::GetFilesInFolder(
|
||||
const std::string& folder_path) {
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace util
|
||||
} // namespace yaze
|
||||
|
||||
@@ -130,6 +130,42 @@ absl::Status ReloadPackageFont(const FontConfig& config) {
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
absl::Status LoadFontFromMemory(const std::string& name,
|
||||
const std::string& data, float size_pixels) {
|
||||
ImGuiIO& imgui_io = ImGui::GetIO();
|
||||
|
||||
// ImGui takes ownership of the data and will free it
|
||||
void* font_data = ImGui::MemAlloc(data.size());
|
||||
if (!font_data) {
|
||||
return absl::InternalError("Failed to allocate memory for font data");
|
||||
}
|
||||
std::memcpy(font_data, data.data(), data.size());
|
||||
|
||||
ImFontConfig config;
|
||||
std::strncpy(config.Name, name.c_str(), sizeof(config.Name) - 1);
|
||||
config.Name[sizeof(config.Name) - 1] = 0;
|
||||
|
||||
if (!imgui_io.Fonts->AddFontFromMemoryTTF(font_data,
|
||||
static_cast<int>(data.size()),
|
||||
size_pixels, &config)) {
|
||||
ImGui::MemFree(font_data);
|
||||
return absl::InternalError("Failed to load font from memory");
|
||||
}
|
||||
|
||||
// We also need to add icons and Japanese characters to this new font
|
||||
// Note: This is a simplified version of AddIconFont/AddJapaneseFont that
|
||||
// works with the current font being added (since we can't easily merge into
|
||||
// a font that's just been added without rebuilding atlas immediately)
|
||||
// For now, we'll just load the base font. Merging requires more complex logic.
|
||||
|
||||
// Important: We must rebuild the font atlas!
|
||||
// This is usually done by the backend, but since we changed fonts at runtime...
|
||||
// Ideally, this should be done before NewFrame().
|
||||
// If called during a frame, changes won't appear until texture is rebuilt.
|
||||
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
#ifdef __linux__
|
||||
void LoadSystemFonts() {
|
||||
// Load Linux System Fonts into ImGui
|
||||
|
||||
@@ -25,6 +25,9 @@ absl::Status LoadPackageFonts();
|
||||
|
||||
absl::Status ReloadPackageFont(const FontConfig& config);
|
||||
|
||||
absl::Status LoadFontFromMemory(const std::string& name,
|
||||
const std::string& data, float size_pixels);
|
||||
|
||||
void LoadSystemFonts();
|
||||
|
||||
} // namespace yaze
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "app/gfx/backend/irenderer.h"
|
||||
#include "app/platform/sdl_compat.h"
|
||||
|
||||
// Forward declarations to avoid SDL header dependency in interface
|
||||
struct SDL_Window;
|
||||
@@ -28,7 +29,7 @@ struct WindowConfig {
|
||||
bool resizable = true;
|
||||
bool maximized = false;
|
||||
bool fullscreen = false;
|
||||
bool high_dpi = true;
|
||||
bool high_dpi = false; // Disabled by default - causes issues on macOS Retina with SDL_Renderer
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -83,6 +84,10 @@ struct WindowEvent {
|
||||
|
||||
// Drop file data
|
||||
std::string dropped_file;
|
||||
|
||||
// Native event copy (SDL2/SDL3). Only valid when has_native_event is true.
|
||||
bool has_native_event = false;
|
||||
SDL_Event native_event{};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -226,6 +231,12 @@ class IWindowBackend {
|
||||
*/
|
||||
virtual void NewImGuiFrame() = 0;
|
||||
|
||||
/**
|
||||
* @brief Render ImGui draw data (and viewports if enabled)
|
||||
* @param renderer The renderer to use for drawing (needed to get backend renderer)
|
||||
*/
|
||||
virtual void RenderImGui(gfx::IRenderer* renderer) = 0;
|
||||
|
||||
// =========================================================================
|
||||
// Audio Support (Legacy compatibility)
|
||||
// =========================================================================
|
||||
|
||||
@@ -17,11 +17,16 @@
|
||||
#include "util/log.h"
|
||||
|
||||
namespace yaze {
|
||||
|
||||
// Forward reference to the global resize flag defined in window.cc
|
||||
namespace core {
|
||||
extern bool g_window_is_resizing;
|
||||
}
|
||||
|
||||
namespace platform {
|
||||
|
||||
// Global flag for window resize state (used by emulator to pause)
|
||||
// This maintains compatibility with the legacy window.cc
|
||||
extern bool g_window_is_resizing;
|
||||
// Alias to core's resize flag for compatibility
|
||||
#define g_window_is_resizing yaze::core::g_window_is_resizing
|
||||
|
||||
SDL2WindowBackend::~SDL2WindowBackend() {
|
||||
if (initialized_) {
|
||||
@@ -150,6 +155,8 @@ bool SDL2WindowBackend::PollEvent(WindowEvent& out_event) {
|
||||
|
||||
// Convert to platform-agnostic event
|
||||
out_event = ConvertSDL2Event(sdl_event);
|
||||
out_event.has_native_event = true;
|
||||
out_event.native_event = sdl_event;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -375,6 +382,15 @@ absl::Status SDL2WindowBackend::InitializeImGui(gfx::IRenderer* renderer) {
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
|
||||
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
|
||||
// Note: ViewportsEnable is intentionally NOT set for SDL2 + SDL_Renderer
|
||||
// It causes scaling issues on macOS Retina displays
|
||||
|
||||
// Ensure macOS-style behavior (Cmd acts as Ctrl for shortcuts)
|
||||
// ImGui should set this automatically based on __APPLE__, but force it to be safe
|
||||
#ifdef __APPLE__
|
||||
io.ConfigMacOSXBehaviors = true;
|
||||
LOG_INFO("SDL2WindowBackend", "Enabled ConfigMacOSXBehaviors for macOS");
|
||||
#endif
|
||||
|
||||
// Initialize ImGui backends
|
||||
SDL_Renderer* sdl_renderer =
|
||||
@@ -426,10 +442,34 @@ void SDL2WindowBackend::NewImGuiFrame() {
|
||||
|
||||
ImGui_ImplSDLRenderer2_NewFrame();
|
||||
ImGui_ImplSDL2_NewFrame();
|
||||
// ImGui_ImplSDL2_NewFrame() automatically handles DisplaySize and
|
||||
// DisplayFramebufferScale via ImGui_ImplSDL2_GetWindowSizeAndFramebufferScale()
|
||||
// which uses SDL_GetRendererOutputSize() when renderer is available.
|
||||
}
|
||||
|
||||
// Define the global variable for backward compatibility
|
||||
bool g_window_is_resizing = false;
|
||||
void SDL2WindowBackend::RenderImGui(gfx::IRenderer* renderer) {
|
||||
if (!imgui_initialized_) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Finalize ImGui frame and render draw data
|
||||
ImGui::Render();
|
||||
|
||||
if (renderer) {
|
||||
SDL_Renderer* sdl_renderer =
|
||||
static_cast<SDL_Renderer*>(renderer->GetBackendRenderer());
|
||||
if (sdl_renderer) {
|
||||
ImGui_ImplSDLRenderer2_RenderDrawData(ImGui::GetDrawData(), sdl_renderer);
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-viewport support
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) {
|
||||
ImGui::UpdatePlatformWindows();
|
||||
ImGui::RenderPlatformWindowsDefault();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
@@ -52,6 +52,7 @@ class SDL2WindowBackend : public IWindowBackend {
|
||||
absl::Status InitializeImGui(gfx::IRenderer* renderer) override;
|
||||
void ShutdownImGui() override;
|
||||
void NewImGuiFrame() override;
|
||||
void RenderImGui(gfx::IRenderer* renderer) override;
|
||||
|
||||
uint32_t GetAudioDevice() const override { return audio_device_; }
|
||||
std::shared_ptr<int16_t> GetAudioBuffer() const override {
|
||||
|
||||
@@ -18,10 +18,16 @@
|
||||
#include "util/log.h"
|
||||
|
||||
namespace yaze {
|
||||
|
||||
// Forward reference to the global resize flag defined in window.cc
|
||||
namespace core {
|
||||
extern bool g_window_is_resizing;
|
||||
}
|
||||
|
||||
namespace platform {
|
||||
|
||||
// Global flag for window resize state (used by emulator to pause)
|
||||
extern bool g_window_is_resizing;
|
||||
// Alias to core's resize flag for compatibility
|
||||
#define g_window_is_resizing yaze::core::g_window_is_resizing
|
||||
|
||||
SDL3WindowBackend::~SDL3WindowBackend() {
|
||||
if (initialized_) {
|
||||
@@ -148,6 +154,8 @@ bool SDL3WindowBackend::PollEvent(WindowEvent& out_event) {
|
||||
|
||||
// Convert to platform-agnostic event
|
||||
out_event = ConvertSDL3Event(sdl_event);
|
||||
out_event.has_native_event = true;
|
||||
out_event.native_event = sdl_event;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -396,6 +404,12 @@ absl::Status SDL3WindowBackend::InitializeImGui(gfx::IRenderer* renderer) {
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
|
||||
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
|
||||
io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable;
|
||||
|
||||
// Ensure macOS-style behavior (Cmd acts as Ctrl for shortcuts)
|
||||
#ifdef __APPLE__
|
||||
io.ConfigMacOSXBehaviors = true;
|
||||
#endif
|
||||
|
||||
// Initialize ImGui backends for SDL3
|
||||
SDL_Renderer* sdl_renderer =
|
||||
@@ -450,6 +464,30 @@ void SDL3WindowBackend::NewImGuiFrame() {
|
||||
ImGui_ImplSDL3_NewFrame();
|
||||
}
|
||||
|
||||
void SDL3WindowBackend::RenderImGui(gfx::IRenderer* renderer) {
|
||||
if (!imgui_initialized_) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Finalize ImGui frame and render draw data
|
||||
ImGui::Render();
|
||||
|
||||
if (renderer) {
|
||||
SDL_Renderer* sdl_renderer =
|
||||
static_cast<SDL_Renderer*>(renderer->GetBackendRenderer());
|
||||
if (sdl_renderer) {
|
||||
ImGui_ImplSDLRenderer3_RenderDrawData(ImGui::GetDrawData(), sdl_renderer);
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-viewport support
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) {
|
||||
ImGui::UpdatePlatformWindows();
|
||||
ImGui::RenderPlatformWindowsDefault();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ class SDL3WindowBackend : public IWindowBackend {
|
||||
absl::Status InitializeImGui(gfx::IRenderer* renderer) override;
|
||||
void ShutdownImGui() override;
|
||||
void NewImGuiFrame() override;
|
||||
void RenderImGui(gfx::IRenderer* renderer) override;
|
||||
|
||||
uint32_t GetAudioDevice() const override { return 0; } // SDL3 uses streams
|
||||
std::shared_ptr<int16_t> GetAudioBuffer() const override {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
#ifdef YAZE_USE_SDL3
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3/SDL_gamepad.h>
|
||||
#else
|
||||
#include <SDL.h>
|
||||
#endif
|
||||
@@ -64,6 +65,44 @@ constexpr auto kEventControllerDeviceAdded = SDL_CONTROLLERDEVICEADDED;
|
||||
constexpr auto kEventControllerDeviceRemoved = SDL_CONTROLLERDEVICEREMOVED;
|
||||
#endif
|
||||
|
||||
// ============================================================================
|
||||
// Key Constants
|
||||
// ============================================================================
|
||||
|
||||
#ifdef YAZE_USE_SDL3
|
||||
#include <SDL3/SDL_keycode.h>
|
||||
|
||||
constexpr auto kKeyA = 'a';
|
||||
constexpr auto kKeyB = 'b';
|
||||
constexpr auto kKeyC = 'c';
|
||||
constexpr auto kKeyD = 'd';
|
||||
constexpr auto kKeyS = 's';
|
||||
constexpr auto kKeyX = 'x';
|
||||
constexpr auto kKeyY = 'y';
|
||||
constexpr auto kKeyZ = 'z';
|
||||
constexpr auto kKeyReturn = SDLK_RETURN;
|
||||
constexpr auto kKeyRShift = SDLK_RSHIFT;
|
||||
constexpr auto kKeyUp = SDLK_UP;
|
||||
constexpr auto kKeyDown = SDLK_DOWN;
|
||||
constexpr auto kKeyLeft = SDLK_LEFT;
|
||||
constexpr auto kKeyRight = SDLK_RIGHT;
|
||||
#else
|
||||
constexpr auto kKeyA = SDLK_a;
|
||||
constexpr auto kKeyB = SDLK_b;
|
||||
constexpr auto kKeyC = SDLK_c;
|
||||
constexpr auto kKeyD = SDLK_d;
|
||||
constexpr auto kKeyS = SDLK_s;
|
||||
constexpr auto kKeyX = SDLK_x;
|
||||
constexpr auto kKeyY = SDLK_y;
|
||||
constexpr auto kKeyZ = SDLK_z;
|
||||
constexpr auto kKeyReturn = SDLK_RETURN;
|
||||
constexpr auto kKeyRShift = SDLK_RSHIFT;
|
||||
constexpr auto kKeyUp = SDLK_UP;
|
||||
constexpr auto kKeyDown = SDLK_DOWN;
|
||||
constexpr auto kKeyLeft = SDLK_LEFT;
|
||||
constexpr auto kKeyRight = SDLK_RIGHT;
|
||||
#endif
|
||||
|
||||
// ============================================================================
|
||||
// Keyboard Helpers
|
||||
// ============================================================================
|
||||
@@ -81,6 +120,19 @@ inline SDL_Keycode GetKeyFromEvent(const SDL_Event& event) {
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get scancode from keycode
|
||||
* @param key The keycode
|
||||
* @return The corresponding scancode
|
||||
*/
|
||||
inline SDL_Scancode GetScancodeFromKey(SDL_Keycode key) {
|
||||
#ifdef YAZE_USE_SDL3
|
||||
return SDL_GetScancodeFromKey(key, nullptr);
|
||||
#else
|
||||
return SDL_GetScancodeFromKey(key);
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if a key is pressed using the keyboard state
|
||||
* @param state The keyboard state from SDL_GetKeyboardState
|
||||
@@ -311,12 +363,68 @@ inline SDL_Surface* ConvertSurfaceFormat(SDL_Surface* surface, uint32_t format,
|
||||
if (!surface) return nullptr;
|
||||
#ifdef YAZE_USE_SDL3
|
||||
(void)flags; // SDL3 removed flags parameter
|
||||
return SDL_ConvertSurface(surface, format);
|
||||
return SDL_ConvertSurface(surface, static_cast<SDL_PixelFormat>(format));
|
||||
#else
|
||||
return SDL_ConvertSurfaceFormat(surface, format, flags);
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the palette attached to a surface.
|
||||
*/
|
||||
inline SDL_Palette* GetSurfacePalette(SDL_Surface* surface) {
|
||||
if (!surface) return nullptr;
|
||||
#ifdef YAZE_USE_SDL3
|
||||
return SDL_GetSurfacePalette(surface);
|
||||
#else
|
||||
return surface->format ? surface->format->palette : nullptr;
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the pixel format of a surface as Uint32.
|
||||
*
|
||||
* Note: SDL2 uses Uint32 for pixel format, SDL3 uses SDL_PixelFormat enum.
|
||||
* This function returns Uint32 for cross-version compatibility.
|
||||
*/
|
||||
inline Uint32 GetSurfaceFormat(SDL_Surface* surface) {
|
||||
#ifdef YAZE_USE_SDL3
|
||||
return surface ? static_cast<Uint32>(surface->format) : SDL_PIXELFORMAT_UNKNOWN;
|
||||
#else
|
||||
return (surface && surface->format) ? surface->format->format
|
||||
: SDL_PIXELFORMAT_UNKNOWN;
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Map an RGB color to the surface's pixel format.
|
||||
*/
|
||||
inline Uint32 MapRGB(SDL_Surface* surface, Uint8 r, Uint8 g, Uint8 b) {
|
||||
if (!surface) return 0;
|
||||
#ifdef YAZE_USE_SDL3
|
||||
const SDL_PixelFormatDetails* details =
|
||||
SDL_GetPixelFormatDetails(surface->format);
|
||||
if (!details) return 0;
|
||||
SDL_Palette* palette = SDL_GetSurfacePalette(surface);
|
||||
return SDL_MapRGB(details, palette, r, g, b);
|
||||
#else
|
||||
return SDL_MapRGB(surface->format, r, g, b);
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Create a surface using the appropriate API.
|
||||
*/
|
||||
inline SDL_Surface* CreateSurface(int width, int height, int depth,
|
||||
uint32_t format) {
|
||||
#ifdef YAZE_USE_SDL3
|
||||
(void)depth; // SDL3 infers depth from format
|
||||
return SDL_CreateSurface(width, height, static_cast<SDL_PixelFormat>(format));
|
||||
#else
|
||||
return SDL_CreateRGBSurfaceWithFormat(0, width, height, depth, format);
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get bits per pixel from a surface.
|
||||
*
|
||||
@@ -334,6 +442,62 @@ inline int GetSurfaceBitsPerPixel(SDL_Surface* surface) {
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Ensure the surface has a proper 256-color palette for indexed formats.
|
||||
*
|
||||
* For 8-bit indexed surfaces, this creates and attaches a 256-color palette
|
||||
* if one doesn't exist or if the existing palette is too small.
|
||||
*
|
||||
* @param surface The surface to check/fix
|
||||
* @return true if surface now has a valid 256-color palette, false on error
|
||||
*/
|
||||
inline bool EnsureSurfacePalette256(SDL_Surface* surface) {
|
||||
if (!surface) return false;
|
||||
|
||||
SDL_Palette* existing = GetSurfacePalette(surface);
|
||||
if (existing && existing->ncolors >= 256) {
|
||||
return true; // Already has proper palette
|
||||
}
|
||||
|
||||
// Check if this is an indexed format that needs a palette
|
||||
int bpp = GetSurfaceBitsPerPixel(surface);
|
||||
if (bpp != 8) {
|
||||
return true; // Not an indexed format, no palette needed
|
||||
}
|
||||
|
||||
// Create a new 256-color palette (SDL2: SDL_AllocPalette, SDL3: SDL_CreatePalette)
|
||||
#ifdef YAZE_USE_SDL3
|
||||
SDL_Palette* new_palette = SDL_CreatePalette(256);
|
||||
#else
|
||||
SDL_Palette* new_palette = SDL_AllocPalette(256);
|
||||
#endif
|
||||
if (!new_palette) {
|
||||
SDL_Log("Warning: Failed to create 256-color palette: %s", SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialize with grayscale as a safe default
|
||||
SDL_Color colors[256];
|
||||
for (int i = 0; i < 256; i++) {
|
||||
colors[i].r = colors[i].g = colors[i].b = static_cast<Uint8>(i);
|
||||
colors[i].a = 255;
|
||||
}
|
||||
SDL_SetPaletteColors(new_palette, colors, 0, 256);
|
||||
|
||||
// Attach to surface
|
||||
if (SDL_SetSurfacePalette(surface, new_palette) != 0) {
|
||||
SDL_Log("Warning: Failed to set surface palette: %s", SDL_GetError());
|
||||
#ifdef YAZE_USE_SDL3
|
||||
SDL_DestroyPalette(new_palette);
|
||||
#else
|
||||
SDL_FreePalette(new_palette);
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get bytes per pixel from a surface.
|
||||
*
|
||||
@@ -504,6 +668,90 @@ inline uint32_t GetDefaultInitFlags() {
|
||||
#endif
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Surface Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Create an RGB surface
|
||||
*/
|
||||
inline SDL_Surface* CreateRGBSurface(Uint32 flags, int width, int height, int depth,
|
||||
Uint32 Rmask, Uint32 Gmask, Uint32 Bmask,
|
||||
Uint32 Amask) {
|
||||
#ifdef YAZE_USE_SDL3
|
||||
// SDL3 uses SDL_CreateSurface with pixel format
|
||||
return SDL_CreateSurface(width, height,
|
||||
SDL_GetPixelFormatForMasks(depth, Rmask, Gmask, Bmask, Amask));
|
||||
#else
|
||||
return SDL_CreateRGBSurface(flags, width, height, depth, Rmask, Gmask, Bmask,
|
||||
Amask);
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Destroy a surface
|
||||
*/
|
||||
inline void DestroySurface(SDL_Surface* surface) {
|
||||
#ifdef YAZE_USE_SDL3
|
||||
SDL_DestroySurface(surface);
|
||||
#else
|
||||
SDL_FreeSurface(surface);
|
||||
#endif
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Renderer Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Get renderer output size
|
||||
*/
|
||||
inline int GetRendererOutputSize(SDL_Renderer* renderer, int* w, int* h) {
|
||||
#ifdef YAZE_USE_SDL3
|
||||
return SDL_GetCurrentRenderOutputSize(renderer, w, h);
|
||||
#else
|
||||
return SDL_GetRendererOutputSize(renderer, w, h);
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Read pixels from renderer to a surface
|
||||
*
|
||||
* SDL3: Returns a new surface
|
||||
* SDL2: We manually create surface and read into it to match SDL3 behavior
|
||||
*/
|
||||
inline SDL_Surface* ReadPixelsToSurface(SDL_Renderer* renderer, int width,
|
||||
int height, const SDL_Rect* rect) {
|
||||
#ifdef YAZE_USE_SDL3
|
||||
return SDL_RenderReadPixels(renderer, rect);
|
||||
#else
|
||||
// Create surface to read into (ARGB8888 to match typical screenshot needs)
|
||||
SDL_Surface* surface = SDL_CreateRGBSurface(0, width, height, 32, 0x00FF0000,
|
||||
0x0000FF00, 0x000000FF, 0xFF000000);
|
||||
if (!surface) return nullptr;
|
||||
|
||||
if (SDL_RenderReadPixels(renderer, rect, SDL_PIXELFORMAT_ARGB8888,
|
||||
surface->pixels, surface->pitch) != 0) {
|
||||
SDL_FreeSurface(surface);
|
||||
return nullptr;
|
||||
}
|
||||
return surface;
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @brief Load a BMP file
|
||||
*/
|
||||
inline SDL_Surface* LoadBMP(const char* file) {
|
||||
#ifdef YAZE_USE_SDL3
|
||||
return SDL_LoadBMP(file);
|
||||
#else
|
||||
return SDL_LoadBMP(file);
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
|
||||
@@ -84,6 +84,33 @@ class TimingManager {
|
||||
frame_count_ = 0;
|
||||
fps_ = 0.0f;
|
||||
last_delta_time_ = 0.0f;
|
||||
frame_start_time_ = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Mark the start of a new frame for budget tracking
|
||||
*/
|
||||
void BeginFrame() {
|
||||
frame_start_time_ = SDL_GetPerformanceCounter();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get elapsed time within the current frame in milliseconds
|
||||
* @return Milliseconds since BeginFrame() was called
|
||||
*/
|
||||
float GetFrameElapsedMs() const {
|
||||
if (frame_start_time_ == 0) return 0.0f;
|
||||
uint64_t current = SDL_GetPerformanceCounter();
|
||||
return ((current - frame_start_time_) * 1000.0f) / static_cast<float>(frequency_);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get remaining frame budget in milliseconds (targeting 60fps)
|
||||
* @return Milliseconds remaining before frame deadline
|
||||
*/
|
||||
float GetFrameBudgetRemainingMs() const {
|
||||
constexpr float kTargetFrameTimeMs = 16.67f; // 60fps target
|
||||
return kTargetFrameTimeMs - GetFrameElapsedMs();
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -100,6 +127,7 @@ class TimingManager {
|
||||
uint64_t frequency_;
|
||||
uint64_t first_time_;
|
||||
uint64_t last_time_;
|
||||
uint64_t frame_start_time_ = 0;
|
||||
float accumulated_time_;
|
||||
uint32_t frame_count_;
|
||||
float fps_;
|
||||
|
||||
16
src/app/platform/wasm/wasm_async_guard.cc
Normal file
16
src/app/platform/wasm/wasm_async_guard.cc
Normal file
@@ -0,0 +1,16 @@
|
||||
// clang-format off
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_async_guard.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
// Static member initialization
|
||||
std::atomic<bool> WasmAsyncGuard::in_async_op_{false};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
// clang-format on
|
||||
98
src/app/platform/wasm/wasm_async_guard.h
Normal file
98
src/app/platform/wasm/wasm_async_guard.h
Normal file
@@ -0,0 +1,98 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_WASM_ASYNC_GUARD_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_WASM_ASYNC_GUARD_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <atomic>
|
||||
|
||||
#include <emscripten.h>
|
||||
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* @class WasmAsyncGuard
|
||||
* @brief Global guard to prevent concurrent Asyncify operations
|
||||
*
|
||||
* Emscripten's Asyncify only supports one async operation at a time.
|
||||
* This guard provides C++ side tracking to complement the JavaScript
|
||||
* async queue (async_queue.js).
|
||||
*
|
||||
* Usage:
|
||||
* // At the start of any function that calls Asyncify-wrapped code:
|
||||
* WasmAsyncGuard::ScopedGuard guard;
|
||||
* if (!guard.acquired()) {
|
||||
* // Another async operation is in progress - the JS queue will handle it
|
||||
* }
|
||||
* // ... call async operations
|
||||
*/
|
||||
class WasmAsyncGuard {
|
||||
public:
|
||||
/**
|
||||
* @brief Try to acquire the async operation lock
|
||||
* @return true if acquired, false if another operation is in progress
|
||||
*/
|
||||
static bool TryAcquire() {
|
||||
bool expected = false;
|
||||
return in_async_op_.compare_exchange_strong(expected, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Release the async operation lock
|
||||
*/
|
||||
static void Release() { in_async_op_.store(false); }
|
||||
|
||||
/**
|
||||
* @brief Check if an async operation is in progress
|
||||
* @return true if an operation is currently running
|
||||
*/
|
||||
static bool IsInProgress() { return in_async_op_.load(); }
|
||||
|
||||
/**
|
||||
* @class ScopedGuard
|
||||
* @brief RAII wrapper for async operation tracking
|
||||
*
|
||||
* Automatically acquires the guard on construction and releases on
|
||||
* destruction. Logs a warning if another operation was already in progress.
|
||||
*/
|
||||
class ScopedGuard {
|
||||
public:
|
||||
ScopedGuard() : acquired_(TryAcquire()) {
|
||||
if (!acquired_) {
|
||||
emscripten_log(
|
||||
EM_LOG_WARN,
|
||||
"[WasmAsyncGuard] Async operation already in progress, "
|
||||
"request will be queued by JS async queue");
|
||||
}
|
||||
}
|
||||
|
||||
~ScopedGuard() {
|
||||
if (acquired_) {
|
||||
Release();
|
||||
}
|
||||
}
|
||||
|
||||
// Non-copyable
|
||||
ScopedGuard(const ScopedGuard&) = delete;
|
||||
ScopedGuard& operator=(const ScopedGuard&) = delete;
|
||||
|
||||
/**
|
||||
* @brief Check if this guard acquired the lock
|
||||
* @return true if this guard holds the lock
|
||||
*/
|
||||
bool acquired() const { return acquired_; }
|
||||
|
||||
private:
|
||||
bool acquired_;
|
||||
};
|
||||
|
||||
private:
|
||||
static std::atomic<bool> in_async_op_;
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_WASM_ASYNC_GUARD_H_
|
||||
579
src/app/platform/wasm/wasm_autosave.cc
Normal file
579
src/app/platform/wasm/wasm_autosave.cc
Normal file
@@ -0,0 +1,579 @@
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_autosave.h"
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/html5.h> // For emscripten_set_timeout/clear_timeout
|
||||
#include <chrono>
|
||||
#include <ctime>
|
||||
|
||||
#include "absl/strings/str_format.h"
|
||||
#include "app/platform/wasm/wasm_storage.h"
|
||||
#include "app/platform/wasm/wasm_config.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
using ::yaze::app::platform::WasmConfig;
|
||||
|
||||
// Static member initialization
|
||||
bool AutoSaveManager::emergency_save_triggered_ = false;
|
||||
|
||||
// JavaScript event handler registration using EM_JS
|
||||
EM_JS(void, register_beforeunload_handler, (), {
|
||||
// Store handler references for cleanup
|
||||
if (!window._yazeAutoSaveHandlers) {
|
||||
window._yazeAutoSaveHandlers = {};
|
||||
}
|
||||
|
||||
// Register beforeunload event handler
|
||||
window._yazeAutoSaveHandlers.beforeunload = function(event) {
|
||||
// Call the C++ emergency save function
|
||||
if (Module._yazeEmergencySave) {
|
||||
Module._yazeEmergencySave();
|
||||
}
|
||||
|
||||
// Some browsers require returnValue to be set
|
||||
event.returnValue = 'You have unsaved changes. Are you sure you want to leave?';
|
||||
return event.returnValue;
|
||||
};
|
||||
window.addEventListener('beforeunload', window._yazeAutoSaveHandlers.beforeunload);
|
||||
|
||||
// Register visibilitychange event for when tab becomes hidden
|
||||
window._yazeAutoSaveHandlers.visibilitychange = function() {
|
||||
if (document.hidden && Module._yazeEmergencySave) {
|
||||
// Save when tab becomes hidden (user switches tabs or minimizes)
|
||||
Module._yazeEmergencySave();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', window._yazeAutoSaveHandlers.visibilitychange);
|
||||
|
||||
// Register pagehide event as backup
|
||||
window._yazeAutoSaveHandlers.pagehide = function(event) {
|
||||
if (Module._yazeEmergencySave) {
|
||||
Module._yazeEmergencySave();
|
||||
}
|
||||
};
|
||||
window.addEventListener('pagehide', window._yazeAutoSaveHandlers.pagehide);
|
||||
|
||||
console.log('AutoSave event handlers registered');
|
||||
});
|
||||
|
||||
EM_JS(void, unregister_beforeunload_handler, (), {
|
||||
// Remove all event listeners using stored references
|
||||
if (window._yazeAutoSaveHandlers) {
|
||||
if (window._yazeAutoSaveHandlers.beforeunload) {
|
||||
window.removeEventListener('beforeunload', window._yazeAutoSaveHandlers.beforeunload);
|
||||
}
|
||||
if (window._yazeAutoSaveHandlers.visibilitychange) {
|
||||
document.removeEventListener('visibilitychange', window._yazeAutoSaveHandlers.visibilitychange);
|
||||
}
|
||||
if (window._yazeAutoSaveHandlers.pagehide) {
|
||||
window.removeEventListener('pagehide', window._yazeAutoSaveHandlers.pagehide);
|
||||
}
|
||||
window._yazeAutoSaveHandlers = null;
|
||||
}
|
||||
console.log('AutoSave event handlers unregistered');
|
||||
});
|
||||
|
||||
EM_JS(void, set_recovery_flag, (int has_recovery), {
|
||||
try {
|
||||
if (has_recovery) {
|
||||
sessionStorage.setItem('yaze_has_recovery', 'true');
|
||||
} else {
|
||||
sessionStorage.removeItem('yaze_has_recovery');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to set recovery flag:', e);
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(int, get_recovery_flag, (), {
|
||||
try {
|
||||
return sessionStorage.getItem('yaze_has_recovery') === 'true' ? 1 : 0;
|
||||
} catch (e) {
|
||||
console.error('Failed to get recovery flag:', e);
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// C functions exposed to JavaScript for emergency save and recovery
|
||||
extern "C" {
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void yazeEmergencySave() {
|
||||
if (!AutoSaveManager::emergency_save_triggered_) {
|
||||
AutoSaveManager::emergency_save_triggered_ = true;
|
||||
AutoSaveManager::Instance().EmergencySave();
|
||||
}
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
int yazeRecoverSession() {
|
||||
auto status = AutoSaveManager::Instance().RecoverLastSession();
|
||||
if (status.ok()) {
|
||||
emscripten_log(EM_LOG_INFO, "Session recovery successful");
|
||||
return 1;
|
||||
} else {
|
||||
emscripten_log(EM_LOG_WARN, "Session recovery failed: %s",
|
||||
std::string(status.message()).c_str());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
int yazeHasRecoveryData() {
|
||||
return AutoSaveManager::Instance().HasRecoveryData() ? 1 : 0;
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void yazeClearRecoveryData() {
|
||||
AutoSaveManager::Instance().ClearRecoveryData();
|
||||
}
|
||||
}
|
||||
|
||||
// AutoSaveManager implementation
|
||||
|
||||
AutoSaveManager::AutoSaveManager()
|
||||
: interval_seconds_(app::platform::WasmConfig::Get().autosave.interval_seconds),
|
||||
enabled_(true),
|
||||
running_(false),
|
||||
timer_id_(-1),
|
||||
save_count_(0),
|
||||
error_count_(0),
|
||||
recovery_count_(0),
|
||||
event_handlers_initialized_(false) {
|
||||
// Set the JavaScript function pointer
|
||||
EM_ASM({
|
||||
Module._yazeEmergencySave = Module.cwrap('yazeEmergencySave', null, []);
|
||||
});
|
||||
}
|
||||
|
||||
AutoSaveManager::~AutoSaveManager() {
|
||||
Stop();
|
||||
CleanupEventHandlers();
|
||||
}
|
||||
|
||||
AutoSaveManager& AutoSaveManager::Instance() {
|
||||
static AutoSaveManager instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
void AutoSaveManager::InitializeEventHandlers() {
|
||||
if (!event_handlers_initialized_) {
|
||||
register_beforeunload_handler();
|
||||
event_handlers_initialized_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
void AutoSaveManager::CleanupEventHandlers() {
|
||||
if (event_handlers_initialized_) {
|
||||
unregister_beforeunload_handler();
|
||||
event_handlers_initialized_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
void AutoSaveManager::TimerCallback(void* user_data) {
|
||||
AutoSaveManager* manager = static_cast<AutoSaveManager*>(user_data);
|
||||
if (manager && manager->IsRunning()) {
|
||||
auto status = manager->PerformSave();
|
||||
if (!status.ok()) {
|
||||
emscripten_log(EM_LOG_WARN, "Auto-save failed: %s",
|
||||
status.ToString().c_str());
|
||||
}
|
||||
|
||||
// Reschedule the timer
|
||||
if (manager->IsRunning()) {
|
||||
manager->timer_id_ = emscripten_set_timeout(
|
||||
TimerCallback, manager->interval_seconds_ * 1000, manager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
absl::Status AutoSaveManager::Start(int interval_seconds) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
if (running_) {
|
||||
return absl::AlreadyExistsError("Auto-save is already running");
|
||||
}
|
||||
|
||||
interval_seconds_ = interval_seconds;
|
||||
InitializeEventHandlers();
|
||||
|
||||
// Start the timer
|
||||
timer_id_ = emscripten_set_timeout(
|
||||
TimerCallback, interval_seconds_ * 1000, this);
|
||||
|
||||
running_ = true;
|
||||
emscripten_log(EM_LOG_INFO, "Auto-save started with %d second interval",
|
||||
interval_seconds);
|
||||
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
absl::Status AutoSaveManager::Stop() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
if (!running_) {
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
// Cancel the timer
|
||||
if (timer_id_ >= 0) {
|
||||
emscripten_clear_timeout(timer_id_);
|
||||
timer_id_ = -1;
|
||||
}
|
||||
|
||||
running_ = false;
|
||||
emscripten_log(EM_LOG_INFO, "Auto-save stopped");
|
||||
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
bool AutoSaveManager::IsRunning() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return running_;
|
||||
}
|
||||
|
||||
void AutoSaveManager::RegisterComponent(const std::string& component_id,
|
||||
SaveCallback save_fn,
|
||||
RestoreCallback restore_fn) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
components_[component_id] = {save_fn, restore_fn};
|
||||
emscripten_log(EM_LOG_INFO, "Registered component for auto-save: %s",
|
||||
component_id.c_str());
|
||||
}
|
||||
|
||||
void AutoSaveManager::UnregisterComponent(const std::string& component_id) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
components_.erase(component_id);
|
||||
emscripten_log(EM_LOG_INFO, "Unregistered component from auto-save: %s",
|
||||
component_id.c_str());
|
||||
}
|
||||
|
||||
absl::Status AutoSaveManager::SaveNow() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return PerformSave();
|
||||
}
|
||||
|
||||
nlohmann::json AutoSaveManager::CollectComponentData() {
|
||||
nlohmann::json data = nlohmann::json::object();
|
||||
|
||||
for (const auto& [id, component] : components_) {
|
||||
try {
|
||||
if (component.save_fn) {
|
||||
data[id] = component.save_fn();
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
emscripten_log(EM_LOG_ERROR, "Failed to save component %s: %s",
|
||||
id.c_str(), e.what());
|
||||
error_count_++;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
absl::Status AutoSaveManager::PerformSave() {
|
||||
nlohmann::json save_data = nlohmann::json::object();
|
||||
|
||||
// Collect data from all registered components
|
||||
save_data["components"] = CollectComponentData();
|
||||
|
||||
// Add metadata
|
||||
auto now = std::chrono::system_clock::now();
|
||||
save_data["timestamp"] = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
now.time_since_epoch()).count();
|
||||
save_data["version"] = 1;
|
||||
|
||||
// Save to storage
|
||||
auto status = SaveToStorage(save_data);
|
||||
if (status.ok()) {
|
||||
last_save_time_ = now;
|
||||
save_count_++;
|
||||
set_recovery_flag(1); // Mark that recovery data is available
|
||||
} else {
|
||||
error_count_++;
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
absl::Status AutoSaveManager::SaveToStorage(const nlohmann::json& data) {
|
||||
try {
|
||||
// Convert JSON to string
|
||||
std::string json_str = data.dump();
|
||||
|
||||
// Save to IndexedDB via WasmStorage
|
||||
auto status = WasmStorage::SaveProject(kAutoSaveDataKey, json_str);
|
||||
if (!status.ok()) {
|
||||
return status;
|
||||
}
|
||||
|
||||
// Save metadata separately for quick access
|
||||
nlohmann::json meta;
|
||||
meta["timestamp"] = data["timestamp"];
|
||||
meta["component_count"] = data["components"].size();
|
||||
meta["save_count"] = save_count_;
|
||||
|
||||
return WasmStorage::SaveProject(kAutoSaveMetaKey, meta.dump());
|
||||
} catch (const std::exception& e) {
|
||||
return absl::InternalError(
|
||||
absl::StrFormat("Failed to save auto-save data: %s", e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
absl::StatusOr<nlohmann::json> AutoSaveManager::LoadFromStorage() {
|
||||
auto result = WasmStorage::LoadProject(kAutoSaveDataKey);
|
||||
if (!result.ok()) {
|
||||
return result.status();
|
||||
}
|
||||
|
||||
try {
|
||||
return nlohmann::json::parse(*result);
|
||||
} catch (const std::exception& e) {
|
||||
return absl::InvalidArgumentError(
|
||||
absl::StrFormat("Failed to parse auto-save data: %s", e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
void AutoSaveManager::EmergencySave() {
|
||||
// Use try_lock to avoid blocking - emergency save should be fast
|
||||
// If we can't get the lock, another operation is in progress
|
||||
std::unique_lock<std::mutex> lock(mutex_, std::try_to_lock);
|
||||
|
||||
try {
|
||||
nlohmann::json emergency_data = nlohmann::json::object();
|
||||
emergency_data["emergency"] = true;
|
||||
|
||||
// Only collect component data if we got the lock
|
||||
if (lock.owns_lock()) {
|
||||
emergency_data["components"] = CollectComponentData();
|
||||
} else {
|
||||
// Can't safely access components, save empty state marker
|
||||
emergency_data["components"] = nlohmann::json::object();
|
||||
emergency_data["incomplete"] = true;
|
||||
}
|
||||
|
||||
emergency_data["timestamp"] =
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch()).count();
|
||||
|
||||
// Use synchronous localStorage for emergency save (faster than IndexedDB)
|
||||
EM_ASM({
|
||||
try {
|
||||
const data = UTF8ToString($0);
|
||||
localStorage.setItem('yaze_emergency_save', data);
|
||||
console.log('Emergency save completed');
|
||||
} catch (e) {
|
||||
console.error('Emergency save failed:', e);
|
||||
}
|
||||
}, emergency_data.dump().c_str());
|
||||
|
||||
set_recovery_flag(1);
|
||||
} catch (...) {
|
||||
// Silently fail - we're in emergency mode
|
||||
}
|
||||
}
|
||||
|
||||
bool AutoSaveManager::HasRecoveryData() {
|
||||
// Check both sessionStorage flag and actual data
|
||||
if (get_recovery_flag() == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify data actually exists
|
||||
auto meta = WasmStorage::LoadProject(kAutoSaveMetaKey);
|
||||
if (meta.ok()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for emergency save data
|
||||
int has_emergency = EM_ASM_INT({
|
||||
return localStorage.getItem('yaze_emergency_save') !== null ? 1 : 0;
|
||||
});
|
||||
|
||||
return has_emergency == 1;
|
||||
}
|
||||
|
||||
absl::StatusOr<nlohmann::json> AutoSaveManager::GetRecoveryInfo() {
|
||||
// First try to get regular auto-save metadata
|
||||
auto meta_result = WasmStorage::LoadProject(kAutoSaveMetaKey);
|
||||
if (meta_result.ok()) {
|
||||
try {
|
||||
return nlohmann::json::parse(*meta_result);
|
||||
} catch (...) {
|
||||
// Continue to check emergency save
|
||||
}
|
||||
}
|
||||
|
||||
// Check for emergency save
|
||||
char* emergency_data = nullptr;
|
||||
EM_ASM({
|
||||
const data = localStorage.getItem('yaze_emergency_save');
|
||||
if (data) {
|
||||
const len = lengthBytesUTF8(data) + 1;
|
||||
const ptr = _malloc(len);
|
||||
stringToUTF8(data, ptr, len);
|
||||
setValue($0, ptr, 'i32');
|
||||
}
|
||||
}, &emergency_data);
|
||||
|
||||
if (emergency_data) {
|
||||
try {
|
||||
nlohmann::json data = nlohmann::json::parse(emergency_data);
|
||||
free(emergency_data);
|
||||
|
||||
nlohmann::json info;
|
||||
info["type"] = "emergency";
|
||||
info["timestamp"] = data["timestamp"];
|
||||
info["component_count"] = data["components"].size();
|
||||
return info;
|
||||
} catch (const std::exception& e) {
|
||||
free(emergency_data);
|
||||
return absl::InvalidArgumentError("Failed to parse emergency save data");
|
||||
}
|
||||
}
|
||||
|
||||
return absl::NotFoundError("No recovery data found");
|
||||
}
|
||||
|
||||
absl::Status AutoSaveManager::RecoverLastSession() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
// First try regular auto-save data
|
||||
auto data_result = LoadFromStorage();
|
||||
if (data_result.ok()) {
|
||||
const auto& data = *data_result;
|
||||
if (data.contains("components") && data["components"].is_object()) {
|
||||
for (const auto& [id, component_data] : data["components"].items()) {
|
||||
auto it = components_.find(id);
|
||||
if (it != components_.end() && it->second.restore_fn) {
|
||||
try {
|
||||
it->second.restore_fn(component_data);
|
||||
emscripten_log(EM_LOG_INFO, "Restored component: %s", id.c_str());
|
||||
} catch (const std::exception& e) {
|
||||
emscripten_log(EM_LOG_ERROR, "Failed to restore component %s: %s",
|
||||
id.c_str(), e.what());
|
||||
}
|
||||
}
|
||||
}
|
||||
recovery_count_++;
|
||||
set_recovery_flag(0); // Clear recovery flag
|
||||
return absl::OkStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// Try emergency save data
|
||||
char* emergency_data = nullptr;
|
||||
EM_ASM({
|
||||
const data = localStorage.getItem('yaze_emergency_save');
|
||||
if (data) {
|
||||
const len = lengthBytesUTF8(data) + 1;
|
||||
const ptr = _malloc(len);
|
||||
stringToUTF8(data, ptr, len);
|
||||
setValue($0, ptr, 'i32');
|
||||
}
|
||||
}, &emergency_data);
|
||||
|
||||
if (emergency_data) {
|
||||
try {
|
||||
nlohmann::json data = nlohmann::json::parse(emergency_data);
|
||||
free(emergency_data);
|
||||
|
||||
if (data.contains("components") && data["components"].is_object()) {
|
||||
for (const auto& [id, component_data] : data["components"].items()) {
|
||||
auto it = components_.find(id);
|
||||
if (it != components_.end() && it->second.restore_fn) {
|
||||
try {
|
||||
it->second.restore_fn(component_data);
|
||||
emscripten_log(EM_LOG_INFO, "Restored component from emergency save: %s",
|
||||
id.c_str());
|
||||
} catch (const std::exception& e) {
|
||||
emscripten_log(EM_LOG_ERROR, "Failed to restore component %s: %s",
|
||||
id.c_str(), e.what());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear emergency save data
|
||||
EM_ASM({
|
||||
localStorage.removeItem('yaze_emergency_save');
|
||||
});
|
||||
|
||||
recovery_count_++;
|
||||
set_recovery_flag(0); // Clear recovery flag
|
||||
return absl::OkStatus();
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
return absl::InvalidArgumentError(
|
||||
absl::StrFormat("Failed to recover session: %s", e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
return absl::NotFoundError("No recovery data available");
|
||||
}
|
||||
|
||||
absl::Status AutoSaveManager::ClearRecoveryData() {
|
||||
// Clear regular auto-save data
|
||||
WasmStorage::DeleteProject(kAutoSaveDataKey);
|
||||
WasmStorage::DeleteProject(kAutoSaveMetaKey);
|
||||
|
||||
// Clear emergency save data
|
||||
EM_ASM({
|
||||
localStorage.removeItem('yaze_emergency_save');
|
||||
});
|
||||
|
||||
// Clear recovery flag
|
||||
set_recovery_flag(0);
|
||||
|
||||
emscripten_log(EM_LOG_INFO, "Recovery data cleared");
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
void AutoSaveManager::SetInterval(int seconds) {
|
||||
bool was_running = IsRunning();
|
||||
if (was_running) {
|
||||
Stop();
|
||||
}
|
||||
|
||||
interval_seconds_ = seconds;
|
||||
|
||||
if (was_running && enabled_) {
|
||||
Start(seconds);
|
||||
}
|
||||
}
|
||||
|
||||
void AutoSaveManager::SetEnabled(bool enabled) {
|
||||
enabled_ = enabled;
|
||||
if (!enabled && IsRunning()) {
|
||||
Stop();
|
||||
} else if (enabled && !IsRunning()) {
|
||||
Start(interval_seconds_);
|
||||
}
|
||||
}
|
||||
|
||||
nlohmann::json AutoSaveManager::GetStatistics() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
nlohmann::json stats;
|
||||
stats["save_count"] = save_count_;
|
||||
stats["error_count"] = error_count_;
|
||||
stats["recovery_count"] = recovery_count_;
|
||||
stats["components_registered"] = components_.size();
|
||||
stats["is_running"] = running_;
|
||||
stats["interval_seconds"] = interval_seconds_;
|
||||
stats["enabled"] = enabled_;
|
||||
|
||||
if (last_save_time_.time_since_epoch().count() > 0) {
|
||||
stats["last_save_timestamp"] =
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
last_save_time_.time_since_epoch()).count();
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
209
src/app/platform/wasm/wasm_autosave.h
Normal file
209
src/app/platform/wasm/wasm_autosave.h
Normal file
@@ -0,0 +1,209 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_AUTOSAVE_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_AUTOSAVE_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/status/statusor.h"
|
||||
#include "nlohmann/json.hpp"
|
||||
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* @class AutoSaveManager
|
||||
* @brief Manages automatic saving and crash recovery for WASM builds
|
||||
*
|
||||
* This class provides periodic auto-save functionality, emergency save on
|
||||
* page unload, and session recovery after crashes or unexpected closures.
|
||||
* It integrates with WasmStorage for data persistence and uses browser
|
||||
* event handlers for lifecycle management.
|
||||
*/
|
||||
class AutoSaveManager {
|
||||
public:
|
||||
// Callback types
|
||||
using SaveCallback = std::function<nlohmann::json()>;
|
||||
using RestoreCallback = std::function<void(const nlohmann::json&)>;
|
||||
|
||||
/**
|
||||
* @brief Get the singleton instance of AutoSaveManager
|
||||
* @return Reference to the AutoSaveManager instance
|
||||
*/
|
||||
static AutoSaveManager& Instance();
|
||||
|
||||
/**
|
||||
* @brief Start periodic auto-save
|
||||
* @param interval_seconds Interval between saves (default 60 seconds)
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
absl::Status Start(int interval_seconds = 60);
|
||||
|
||||
/**
|
||||
* @brief Stop auto-save
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
absl::Status Stop();
|
||||
|
||||
/**
|
||||
* @brief Check if auto-save is currently running
|
||||
* @return true if auto-save is active
|
||||
*/
|
||||
bool IsRunning() const;
|
||||
|
||||
/**
|
||||
* @brief Register a component for auto-save
|
||||
* @param component_id Unique identifier for the component
|
||||
* @param save_fn Function that returns JSON data to save
|
||||
* @param restore_fn Function that accepts JSON data to restore
|
||||
*/
|
||||
void RegisterComponent(const std::string& component_id,
|
||||
SaveCallback save_fn,
|
||||
RestoreCallback restore_fn);
|
||||
|
||||
/**
|
||||
* @brief Unregister a component from auto-save
|
||||
* @param component_id Component identifier to unregister
|
||||
*/
|
||||
void UnregisterComponent(const std::string& component_id);
|
||||
|
||||
/**
|
||||
* @brief Manually trigger a save of all registered components
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
absl::Status SaveNow();
|
||||
|
||||
/**
|
||||
* @brief Save data immediately (called on page unload)
|
||||
* @note This is called automatically by the browser event handler
|
||||
*/
|
||||
void EmergencySave();
|
||||
|
||||
/**
|
||||
* @brief Check if there's recovery data available
|
||||
* @return true if recovery data exists
|
||||
*/
|
||||
bool HasRecoveryData();
|
||||
|
||||
/**
|
||||
* @brief Get information about available recovery data
|
||||
* @return JSON object with recovery metadata (timestamp, components, etc.)
|
||||
*/
|
||||
absl::StatusOr<nlohmann::json> GetRecoveryInfo();
|
||||
|
||||
/**
|
||||
* @brief Recover the last saved session
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
absl::Status RecoverLastSession();
|
||||
|
||||
/**
|
||||
* @brief Clear all recovery data
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
absl::Status ClearRecoveryData();
|
||||
|
||||
/**
|
||||
* @brief Set the auto-save interval
|
||||
* @param seconds New interval in seconds
|
||||
* @note Restarts auto-save if currently running
|
||||
*/
|
||||
void SetInterval(int seconds);
|
||||
|
||||
/**
|
||||
* @brief Get the current auto-save interval
|
||||
* @return Interval in seconds
|
||||
*/
|
||||
int GetInterval() const { return interval_seconds_; }
|
||||
|
||||
/**
|
||||
* @brief Enable or disable auto-save
|
||||
* @param enabled true to enable, false to disable
|
||||
*/
|
||||
void SetEnabled(bool enabled);
|
||||
|
||||
/**
|
||||
* @brief Check if auto-save is enabled
|
||||
* @return true if enabled
|
||||
*/
|
||||
bool IsEnabled() const { return enabled_; }
|
||||
|
||||
/**
|
||||
* @brief Get the last auto-save timestamp
|
||||
* @return Timestamp of last successful save
|
||||
*/
|
||||
std::chrono::system_clock::time_point GetLastSaveTime() const {
|
||||
return last_save_time_;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get statistics about auto-save operations
|
||||
* @return JSON object with save count, error count, etc.
|
||||
*/
|
||||
nlohmann::json GetStatistics() const;
|
||||
|
||||
private:
|
||||
// Private constructor for singleton
|
||||
AutoSaveManager();
|
||||
~AutoSaveManager();
|
||||
|
||||
// Delete copy and move constructors
|
||||
AutoSaveManager(const AutoSaveManager&) = delete;
|
||||
AutoSaveManager& operator=(const AutoSaveManager&) = delete;
|
||||
AutoSaveManager(AutoSaveManager&&) = delete;
|
||||
AutoSaveManager& operator=(AutoSaveManager&&) = delete;
|
||||
|
||||
// Component registration
|
||||
struct Component {
|
||||
SaveCallback save_fn;
|
||||
RestoreCallback restore_fn;
|
||||
};
|
||||
|
||||
// Internal methods
|
||||
void InitializeEventHandlers();
|
||||
void CleanupEventHandlers();
|
||||
static void TimerCallback(void* user_data);
|
||||
absl::Status PerformSave();
|
||||
nlohmann::json CollectComponentData();
|
||||
absl::Status SaveToStorage(const nlohmann::json& data);
|
||||
absl::StatusOr<nlohmann::json> LoadFromStorage();
|
||||
|
||||
// Storage keys
|
||||
static constexpr const char* kAutoSaveDataKey = "yaze_autosave_data";
|
||||
static constexpr const char* kAutoSaveMetaKey = "yaze_autosave_meta";
|
||||
static constexpr const char* kRecoveryFlagKey = "yaze_has_recovery";
|
||||
|
||||
// Member variables
|
||||
mutable std::mutex mutex_;
|
||||
std::unordered_map<std::string, Component> components_;
|
||||
int interval_seconds_;
|
||||
bool enabled_;
|
||||
bool running_;
|
||||
int timer_id_;
|
||||
std::chrono::system_clock::time_point last_save_time_;
|
||||
|
||||
// Statistics
|
||||
size_t save_count_;
|
||||
size_t error_count_;
|
||||
size_t recovery_count_;
|
||||
|
||||
// Event handler registration state
|
||||
bool event_handlers_initialized_;
|
||||
|
||||
public:
|
||||
// Must be public for emergency save callback access
|
||||
static bool emergency_save_triggered_;
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_AUTOSAVE_H_
|
||||
272
src/app/platform/wasm/wasm_bootstrap.cc
Normal file
272
src/app/platform/wasm/wasm_bootstrap.cc
Normal file
@@ -0,0 +1,272 @@
|
||||
#include "app/platform/wasm/wasm_bootstrap.h"
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <fstream>
|
||||
#include <algorithm>
|
||||
#include <mutex>
|
||||
#include <queue>
|
||||
#include <vector>
|
||||
#include "app/platform/wasm/wasm_config.h"
|
||||
#include "app/platform/wasm/wasm_drop_handler.h"
|
||||
#include "util/log.h"
|
||||
|
||||
namespace {
|
||||
|
||||
bool g_filesystem_ready = false;
|
||||
std::function<void(std::string)> g_rom_load_handler;
|
||||
std::queue<std::string> g_pending_rom_loads;
|
||||
std::mutex g_rom_load_mutex;
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" {
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void SetFileSystemReady() {
|
||||
g_filesystem_ready = true;
|
||||
LOG_INFO("Wasm", "Filesystem sync complete.");
|
||||
|
||||
// Notify JS that FS is ready
|
||||
EM_ASM({
|
||||
if (Module.onFileSystemReady) {
|
||||
Module.onFileSystemReady();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void SyncFilesystem() {
|
||||
// Sync all IDBFS mounts to IndexedDB for persistence
|
||||
EM_ASM({
|
||||
if (typeof FS !== 'undefined' && FS.syncfs) {
|
||||
FS.syncfs(false, function(err) {
|
||||
if (err) {
|
||||
console.error('[WASM] Failed to sync filesystem:', err);
|
||||
} else {
|
||||
console.log('[WASM] Filesystem synced successfully');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void LoadRomFromWeb(const char* filename) {
|
||||
if (!filename) {
|
||||
LOG_ERROR("Wasm", "LoadRomFromWeb called with null filename");
|
||||
return;
|
||||
}
|
||||
|
||||
std::string path(filename);
|
||||
|
||||
// Validate path is not empty
|
||||
if (path.empty()) {
|
||||
LOG_ERROR("Wasm", "LoadRomFromWeb called with empty filename");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate path doesn't contain path traversal
|
||||
if (path.find("..") != std::string::npos) {
|
||||
LOG_ERROR("Wasm", "LoadRomFromWeb: path traversal not allowed: %s", filename);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate reasonable path length (max 512 chars)
|
||||
if (path.length() > 512) {
|
||||
LOG_ERROR("Wasm", "LoadRomFromWeb: path too long (%zu chars)", path.length());
|
||||
return;
|
||||
}
|
||||
|
||||
yaze::app::wasm::TriggerRomLoad(path);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
||||
EM_JS(void, MountFilesystems, (), {
|
||||
// Create all required directories
|
||||
var directories = [
|
||||
'/roms', // ROM files (IDBFS - persistent for session restore)
|
||||
'/saves', // Save files (IDBFS - persistent)
|
||||
'/config', // Configuration files (IDBFS - persistent)
|
||||
'/projects', // Project files (IDBFS - persistent)
|
||||
'/prompts', // Agent prompts (IDBFS - persistent)
|
||||
'/recent', // Recent files metadata (IDBFS - persistent)
|
||||
'/temp' // Temporary files (MEMFS - non-persistent)
|
||||
];
|
||||
|
||||
directories.forEach(function(dir) {
|
||||
try {
|
||||
FS.mkdir(dir);
|
||||
} catch (e) {
|
||||
// Directory may already exist
|
||||
if (e.code !== 'EEXIST') {
|
||||
console.warn("Failed to create directory " + dir + ": " + e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Mount MEMFS for temporary files only
|
||||
FS.mount(MEMFS, {}, '/temp');
|
||||
|
||||
// Check if IDBFS is available (try multiple ways to access it)
|
||||
var idbfs = null;
|
||||
if (typeof IDBFS !== 'undefined') {
|
||||
idbfs = IDBFS;
|
||||
} else if (typeof Module !== 'undefined' && typeof Module.IDBFS !== 'undefined') {
|
||||
idbfs = Module.IDBFS;
|
||||
} else if (typeof FS !== 'undefined' && typeof FS.filesystems !== 'undefined' && FS.filesystems.IDBFS) {
|
||||
idbfs = FS.filesystems.IDBFS;
|
||||
}
|
||||
|
||||
// Persistent directories to mount with IDBFS
|
||||
var persistentDirs = ['/roms', '/saves', '/config', '/projects', '/prompts', '/recent'];
|
||||
var mountedCount = 0;
|
||||
var totalToMount = persistentDirs.length;
|
||||
|
||||
if (idbfs !== null) {
|
||||
persistentDirs.forEach(function(dir) {
|
||||
try {
|
||||
FS.mount(idbfs, {}, dir);
|
||||
mountedCount++;
|
||||
} catch (e) {
|
||||
console.error("Error mounting IDBFS for " + dir + ": " + e);
|
||||
// Fallback to MEMFS for this directory
|
||||
try {
|
||||
FS.mount(MEMFS, {}, dir);
|
||||
} catch (e2) {
|
||||
// May already be mounted
|
||||
}
|
||||
mountedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// Sync all IDBFS mounts from IndexedDB to memory
|
||||
FS.syncfs(true, function(err) {
|
||||
if (err) {
|
||||
console.error("Failed to sync IDBFS: " + err);
|
||||
} else {
|
||||
console.log("IDBFS synced successfully");
|
||||
}
|
||||
// Signal C++ that we are ready regardless of success/fail
|
||||
Module._SetFileSystemReady();
|
||||
});
|
||||
} else {
|
||||
// Fallback to MEMFS if IDBFS is not available
|
||||
console.warn("IDBFS not available, using MEMFS for all directories (no persistence)");
|
||||
persistentDirs.forEach(function(dir) {
|
||||
try {
|
||||
FS.mount(MEMFS, {}, dir);
|
||||
} catch (e) {
|
||||
// May already be mounted
|
||||
}
|
||||
});
|
||||
Module._SetFileSystemReady();
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(void, SetupYazeGlobalApi, (), {
|
||||
if (typeof Module === 'undefined') return;
|
||||
|
||||
// Initialize global API for agents/automation
|
||||
window.yazeApp = {
|
||||
execute: function(cmd) {
|
||||
if (Module.executeCommand) {
|
||||
return Module.executeCommand(cmd);
|
||||
}
|
||||
return "Error: bindings not ready";
|
||||
},
|
||||
|
||||
getState: function() {
|
||||
if (Module.getFullDebugState) {
|
||||
try { return JSON.parse(Module.getFullDebugState()); } catch(e) { return {}; }
|
||||
}
|
||||
return {};
|
||||
},
|
||||
|
||||
getEditorState: function() {
|
||||
if (Module.getEditorState) {
|
||||
try { return JSON.parse(Module.getEditorState()); } catch(e) { return {}; }
|
||||
}
|
||||
return {};
|
||||
},
|
||||
|
||||
loadRom: function(filename) {
|
||||
return this.execute("rom load " + filename);
|
||||
}
|
||||
};
|
||||
|
||||
console.log("[yaze] window.yazeApp API initialized for agents");
|
||||
});
|
||||
|
||||
namespace yaze::app::wasm {
|
||||
|
||||
bool IsFileSystemReady() {
|
||||
return g_filesystem_ready;
|
||||
}
|
||||
|
||||
void SetRomLoadHandler(std::function<void(std::string)> handler) {
|
||||
std::lock_guard<std::mutex> lock(g_rom_load_mutex);
|
||||
g_rom_load_handler = handler;
|
||||
|
||||
// Flush all pending ROM loads
|
||||
while (!g_pending_rom_loads.empty()) {
|
||||
std::string path = g_pending_rom_loads.front();
|
||||
g_pending_rom_loads.pop();
|
||||
LOG_INFO("Wasm", "Flushing pending ROM load: %s", path.c_str());
|
||||
handler(path);
|
||||
}
|
||||
}
|
||||
|
||||
void TriggerRomLoad(const std::string& path) {
|
||||
std::lock_guard<std::mutex> lock(g_rom_load_mutex);
|
||||
if (g_rom_load_handler) {
|
||||
g_rom_load_handler(path);
|
||||
} else {
|
||||
LOG_INFO("Wasm", "Queuing ROM load (handler not ready): %s", path.c_str());
|
||||
g_pending_rom_loads.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
void InitializeWasmPlatform() {
|
||||
// Load WASM configuration from JavaScript
|
||||
app::platform::WasmConfig::Get().LoadFromJavaScript();
|
||||
|
||||
// Setup global API
|
||||
SetupYazeGlobalApi();
|
||||
|
||||
// Initialize drop handler for Drag & Drop support
|
||||
auto& drop_handler = yaze::platform::WasmDropHandler::GetInstance();
|
||||
drop_handler.Initialize("",
|
||||
[](const std::string& filename, const std::vector<uint8_t>& data) {
|
||||
// Determine file type from extension
|
||||
std::string ext = filename.substr(filename.find_last_of(".") + 1);
|
||||
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
|
||||
|
||||
if (ext == "sfc" || ext == "smc" || ext == "zip") {
|
||||
// Write to MEMFS and load
|
||||
std::string path = "/roms/" + filename;
|
||||
std::ofstream file(path, std::ios::binary);
|
||||
file.write(reinterpret_cast<const char*>(data.data()), data.size());
|
||||
file.close();
|
||||
|
||||
LOG_INFO("Wasm", "Wrote dropped ROM to %s (%zu bytes)", path.c_str(), data.size());
|
||||
LoadRomFromWeb(path.c_str());
|
||||
}
|
||||
else if (ext == "pal" || ext == "tpl") {
|
||||
LOG_INFO("Wasm", "Palette drop detected: %s. Feature pending UI integration.", filename.c_str());
|
||||
}
|
||||
},
|
||||
[](const std::string& error) {
|
||||
LOG_ERROR("Wasm", "Drop Handler Error: %s", error.c_str());
|
||||
}
|
||||
);
|
||||
|
||||
// Initialize filesystems asynchronously
|
||||
MountFilesystems();
|
||||
}
|
||||
|
||||
} // namespace yaze::app::wasm
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
39
src/app/platform/wasm/wasm_bootstrap.h
Normal file
39
src/app/platform/wasm/wasm_bootstrap.h
Normal file
@@ -0,0 +1,39 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_WASM_BOOTSTRAP_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_WASM_BOOTSTRAP_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <string>
|
||||
#include <functional>
|
||||
|
||||
namespace yaze::app::wasm {
|
||||
|
||||
/**
|
||||
* @brief Initialize the WASM platform layer.
|
||||
*
|
||||
* Sets up filesystem mounts, drag-and-drop handlers, and JS configuration.
|
||||
* Call this early in main().
|
||||
*/
|
||||
void InitializeWasmPlatform();
|
||||
|
||||
/**
|
||||
* @brief Check if the asynchronous filesystem sync is complete.
|
||||
* @return true if filesystems are mounted and ready.
|
||||
*/
|
||||
bool IsFileSystemReady();
|
||||
|
||||
/**
|
||||
* @brief Register a callback for when an external source (JS, Drop) requests a ROM load.
|
||||
*/
|
||||
void SetRomLoadHandler(std::function<void(std::string)> handler);
|
||||
|
||||
/**
|
||||
* @brief Trigger a ROM load from C++ code (e.g. terminal bridge).
|
||||
*/
|
||||
void TriggerRomLoad(const std::string& path);
|
||||
|
||||
} // namespace yaze::app::wasm
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_WASM_BOOTSTRAP_H_
|
||||
13
src/app/platform/wasm/wasm_browser_storage.cc
Normal file
13
src/app/platform/wasm/wasm_browser_storage.cc
Normal file
@@ -0,0 +1,13 @@
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_browser_storage.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
// Intentionally empty: stub implementation lives in the header.
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
172
src/app/platform/wasm/wasm_browser_storage.h
Normal file
172
src/app/platform/wasm/wasm_browser_storage.h
Normal file
@@ -0,0 +1,172 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_BROWSER_STORAGE_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_BROWSER_STORAGE_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/status/statusor.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* Stubbed browser storage for WASM builds.
|
||||
*
|
||||
* Key/secret storage in the browser is intentionally disabled to avoid leaking
|
||||
* model/API credentials in page-visible storage. All methods return
|
||||
* Unimplemented/NotFound and should not be used for sensitive data.
|
||||
*/
|
||||
class WasmBrowserStorage {
|
||||
public:
|
||||
enum class StorageType { kSession, kLocal };
|
||||
|
||||
static absl::Status StoreApiKey(const std::string&, const std::string&,
|
||||
StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Browser storage disabled for security");
|
||||
}
|
||||
|
||||
static absl::StatusOr<std::string> RetrieveApiKey(
|
||||
const std::string&, StorageType = StorageType::kSession) {
|
||||
return absl::NotFoundError("Browser storage disabled");
|
||||
}
|
||||
|
||||
static absl::Status ClearApiKey(const std::string&,
|
||||
StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Browser storage disabled for security");
|
||||
}
|
||||
|
||||
static bool HasApiKey(const std::string&,
|
||||
StorageType = StorageType::kSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
static absl::Status StoreSecret(const std::string&, const std::string&,
|
||||
StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Browser storage disabled for security");
|
||||
}
|
||||
|
||||
static absl::StatusOr<std::string> RetrieveSecret(
|
||||
const std::string&, StorageType = StorageType::kSession) {
|
||||
return absl::NotFoundError("Browser storage disabled");
|
||||
}
|
||||
|
||||
static absl::Status ClearSecret(const std::string&,
|
||||
StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Browser storage disabled for security");
|
||||
}
|
||||
|
||||
static std::vector<std::string> ListStoredApiKeys(
|
||||
StorageType = StorageType::kSession) {
|
||||
return {};
|
||||
}
|
||||
|
||||
static absl::Status ClearAllApiKeys(StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Browser storage disabled for security");
|
||||
}
|
||||
|
||||
struct StorageQuota {
|
||||
size_t used_bytes = 0;
|
||||
size_t available_bytes = 0;
|
||||
};
|
||||
|
||||
static bool IsStorageAvailable() { return false; }
|
||||
|
||||
static absl::StatusOr<StorageQuota> GetStorageQuota(
|
||||
StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Browser storage disabled for security");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#else // !__EMSCRIPTEN__
|
||||
|
||||
// Stub for non-WASM builds
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/status/statusor.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* Non-WASM stub for WasmBrowserStorage.
|
||||
* All methods return Unimplemented/NotFound as browser storage is not available.
|
||||
*/
|
||||
class WasmBrowserStorage {
|
||||
public:
|
||||
enum class StorageType { kSession, kLocal };
|
||||
|
||||
static absl::Status StoreApiKey(const std::string&, const std::string&,
|
||||
StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Browser storage requires WASM build");
|
||||
}
|
||||
|
||||
static absl::StatusOr<std::string> RetrieveApiKey(
|
||||
const std::string&, StorageType = StorageType::kSession) {
|
||||
return absl::NotFoundError("Browser storage requires WASM build");
|
||||
}
|
||||
|
||||
static absl::Status ClearApiKey(const std::string&,
|
||||
StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Browser storage requires WASM build");
|
||||
}
|
||||
|
||||
static bool HasApiKey(const std::string&,
|
||||
StorageType = StorageType::kSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
static absl::Status StoreSecret(const std::string&, const std::string&,
|
||||
StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Browser storage requires WASM build");
|
||||
}
|
||||
|
||||
static absl::StatusOr<std::string> RetrieveSecret(
|
||||
const std::string&, StorageType = StorageType::kSession) {
|
||||
return absl::NotFoundError("Browser storage requires WASM build");
|
||||
}
|
||||
|
||||
static absl::Status ClearSecret(const std::string&,
|
||||
StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Browser storage requires WASM build");
|
||||
}
|
||||
|
||||
static std::vector<std::string> ListStoredApiKeys(
|
||||
StorageType = StorageType::kSession) {
|
||||
return {};
|
||||
}
|
||||
|
||||
static absl::Status ClearAllApiKeys(StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Browser storage requires WASM build");
|
||||
}
|
||||
|
||||
struct StorageQuota {
|
||||
size_t used_bytes = 0;
|
||||
size_t available_bytes = 0;
|
||||
};
|
||||
|
||||
static bool IsStorageAvailable() { return false; }
|
||||
|
||||
static absl::StatusOr<StorageQuota> GetStorageQuota(
|
||||
StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Browser storage requires WASM build");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_BROWSER_STORAGE_H_
|
||||
991
src/app/platform/wasm/wasm_collaboration.cc
Normal file
991
src/app/platform/wasm/wasm_collaboration.cc
Normal file
@@ -0,0 +1,991 @@
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_collaboration.h"
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/bind.h>
|
||||
#include <emscripten/val.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
|
||||
#include "absl/strings/str_format.h"
|
||||
#include "nlohmann/json.hpp"
|
||||
#include "app/platform/wasm/wasm_config.h"
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
// clang-format off
|
||||
EM_JS(double, GetCurrentTime, (), {
|
||||
return Date.now() / 1000.0;
|
||||
});
|
||||
|
||||
EM_JS(void, ConsoleLog, (const char* message), {
|
||||
console.log('[WasmCollaboration] ' + UTF8ToString(message));
|
||||
});
|
||||
|
||||
EM_JS(void, ConsoleError, (const char* message), {
|
||||
console.error('[WasmCollaboration] ' + UTF8ToString(message));
|
||||
});
|
||||
|
||||
EM_JS(void, UpdateCollaborationUI, (const char* type, const char* data), {
|
||||
if (typeof window.updateCollaborationUI === 'function') {
|
||||
window.updateCollaborationUI(UTF8ToString(type), UTF8ToString(data));
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(char*, GenerateRandomRoomCode, (), {
|
||||
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
var result = '';
|
||||
for (var i = 0; i < 6; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
var lengthBytes = lengthBytesUTF8(result) + 1;
|
||||
var stringOnWasmHeap = _malloc(lengthBytes);
|
||||
stringToUTF8(result, stringOnWasmHeap, lengthBytes);
|
||||
return stringOnWasmHeap;
|
||||
});
|
||||
|
||||
EM_JS(char*, GetCollaborationServerUrl, (), {
|
||||
// Check for configuration in order of precedence:
|
||||
// 1. window.YAZE_CONFIG.collaborationServerUrl
|
||||
// 2. Environment variable via meta tag
|
||||
// 3. Default empty (disabled)
|
||||
var url = '';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
if (window.YAZE_CONFIG && window.YAZE_CONFIG.collaborationServerUrl) {
|
||||
url = window.YAZE_CONFIG.collaborationServerUrl;
|
||||
} else {
|
||||
// Check for meta tag configuration
|
||||
var meta = document.querySelector('meta[name="yaze-collab-server"]');
|
||||
if (meta && meta.content) {
|
||||
url = meta.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (url.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var lengthBytes = lengthBytesUTF8(url) + 1;
|
||||
var stringOnWasmHeap = _malloc(lengthBytes);
|
||||
stringToUTF8(url, stringOnWasmHeap, lengthBytes);
|
||||
return stringOnWasmHeap;
|
||||
});
|
||||
// clang-format on
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
namespace {
|
||||
|
||||
// Color palette for user cursors
|
||||
const std::vector<std::string> kUserColors = {
|
||||
"#FF6B6B", // Red
|
||||
"#4ECDC4", // Teal
|
||||
"#45B7D1", // Blue
|
||||
"#96CEB4", // Green
|
||||
"#FFEAA7", // Yellow
|
||||
"#DDA0DD", // Plum
|
||||
"#98D8C8", // Mint
|
||||
"#F7DC6F", // Gold
|
||||
};
|
||||
|
||||
WasmCollaboration& GetInstance() {
|
||||
static WasmCollaboration instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WasmCollaboration& GetWasmCollaborationInstance() { return GetInstance(); }
|
||||
|
||||
WasmCollaboration::WasmCollaboration() {
|
||||
user_id_ = GenerateUserId();
|
||||
user_color_ = GenerateUserColor();
|
||||
websocket_ = std::make_unique<net::EmscriptenWebSocket>();
|
||||
|
||||
// Try to initialize from config automatically
|
||||
InitializeFromConfig();
|
||||
}
|
||||
|
||||
WasmCollaboration::~WasmCollaboration() {
|
||||
if (is_connected_) {
|
||||
LeaveSession();
|
||||
}
|
||||
}
|
||||
|
||||
void WasmCollaboration::InitializeFromConfig() {
|
||||
char* url = GetCollaborationServerUrl();
|
||||
if (url != nullptr) {
|
||||
websocket_url_ = std::string(url);
|
||||
free(url);
|
||||
ConsoleLog(("Collaboration server configured: " + websocket_url_).c_str());
|
||||
} else {
|
||||
ConsoleLog("Collaboration server not configured. Set window.YAZE_CONFIG.collaborationServerUrl or add <meta name=\"yaze-collab-server\" content=\"wss://...\"> to enable.");
|
||||
}
|
||||
}
|
||||
|
||||
absl::StatusOr<std::string> WasmCollaboration::CreateSession(
|
||||
const std::string& session_name, const std::string& username,
|
||||
const std::string& password) {
|
||||
if (is_connected_ || connection_state_ == ConnectionState::Connecting) {
|
||||
return absl::FailedPreconditionError("Already connected or connecting to a session");
|
||||
}
|
||||
|
||||
if (!IsConfigured()) {
|
||||
return absl::FailedPreconditionError(
|
||||
"Collaboration server not configured. Set window.YAZE_CONFIG.collaborationServerUrl "
|
||||
"or call SetWebSocketUrl() before creating a session.");
|
||||
}
|
||||
|
||||
// Generate room code
|
||||
char* room_code_ptr = GenerateRandomRoomCode();
|
||||
room_code_ = std::string(room_code_ptr);
|
||||
free(room_code_ptr);
|
||||
|
||||
session_name_ = session_name;
|
||||
username_ = username;
|
||||
stored_password_ = password;
|
||||
should_reconnect_ = true; // Enable auto-reconnect for this session
|
||||
|
||||
UpdateConnectionState(ConnectionState::Connecting, "Creating session...");
|
||||
|
||||
// Connect to WebSocket server
|
||||
auto status = websocket_->Connect(websocket_url_);
|
||||
if (!status.ok()) {
|
||||
return status;
|
||||
}
|
||||
|
||||
// Set up WebSocket callbacks
|
||||
websocket_->OnOpen([this, password]() {
|
||||
ConsoleLog("WebSocket connected, creating session");
|
||||
is_connected_ = true;
|
||||
UpdateConnectionState(ConnectionState::Connected, "Connected");
|
||||
|
||||
// Add self to users list
|
||||
User self_user;
|
||||
self_user.id = user_id_;
|
||||
self_user.name = username_;
|
||||
self_user.color = user_color_;
|
||||
self_user.is_active = true;
|
||||
self_user.last_activity = GetCurrentTime();
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(users_mutex_);
|
||||
users_[user_id_] = self_user;
|
||||
}
|
||||
|
||||
// Send create session message
|
||||
json msg;
|
||||
msg["type"] = "create";
|
||||
msg["room"] = room_code_;
|
||||
msg["name"] = session_name_;
|
||||
msg["user"] = username_;
|
||||
msg["user_id"] = user_id_;
|
||||
msg["color"] = user_color_;
|
||||
if (!password.empty()) {
|
||||
msg["password"] = password;
|
||||
}
|
||||
|
||||
auto send_status = websocket_->Send(msg.dump());
|
||||
if (!send_status.ok()) {
|
||||
ConsoleError("Failed to send create message");
|
||||
}
|
||||
|
||||
if (status_callback_) {
|
||||
status_callback_(true, "Session created");
|
||||
}
|
||||
UpdateCollaborationUI("session_created", room_code_.c_str());
|
||||
});
|
||||
|
||||
websocket_->OnMessage([this](const std::string& message) {
|
||||
HandleMessage(message);
|
||||
});
|
||||
|
||||
websocket_->OnClose([this](int code, const std::string& reason) {
|
||||
is_connected_ = false;
|
||||
ConsoleLog(absl::StrFormat("WebSocket closed: %s (code: %d)", reason, code).c_str());
|
||||
|
||||
// Initiate reconnection if enabled
|
||||
if (should_reconnect_) {
|
||||
InitiateReconnection();
|
||||
} else {
|
||||
UpdateConnectionState(ConnectionState::Disconnected, absl::StrFormat("Disconnected: %s", reason));
|
||||
}
|
||||
|
||||
if (status_callback_) {
|
||||
status_callback_(false, absl::StrFormat("Disconnected: %s", reason));
|
||||
}
|
||||
UpdateCollaborationUI("disconnected", "");
|
||||
});
|
||||
|
||||
websocket_->OnError([this](const std::string& error) {
|
||||
ConsoleError(error.c_str());
|
||||
is_connected_ = false;
|
||||
|
||||
// Initiate reconnection on error
|
||||
if (should_reconnect_) {
|
||||
InitiateReconnection();
|
||||
} else {
|
||||
UpdateConnectionState(ConnectionState::Disconnected, error);
|
||||
}
|
||||
|
||||
if (status_callback_) {
|
||||
status_callback_(false, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Note: is_connected_ will be set to true in OnOpen callback when connection is established
|
||||
// For now, mark as "connecting" state by returning the room code
|
||||
// The actual connected state is confirmed in HandleMessage when create_response is received
|
||||
|
||||
UpdateCollaborationUI("session_creating", room_code_.c_str());
|
||||
return room_code_;
|
||||
}
|
||||
|
||||
absl::Status WasmCollaboration::JoinSession(const std::string& room_code,
|
||||
const std::string& username,
|
||||
const std::string& password) {
|
||||
if (is_connected_ || connection_state_ == ConnectionState::Connecting) {
|
||||
return absl::FailedPreconditionError("Already connected or connecting to a session");
|
||||
}
|
||||
|
||||
if (!IsConfigured()) {
|
||||
return absl::FailedPreconditionError(
|
||||
"Collaboration server not configured. Set window.YAZE_CONFIG.collaborationServerUrl "
|
||||
"or call SetWebSocketUrl() before joining a session.");
|
||||
}
|
||||
|
||||
room_code_ = room_code;
|
||||
username_ = username;
|
||||
stored_password_ = password;
|
||||
should_reconnect_ = true; // Enable auto-reconnect for this session
|
||||
|
||||
UpdateConnectionState(ConnectionState::Connecting, "Joining session...");
|
||||
|
||||
// Connect to WebSocket server
|
||||
auto status = websocket_->Connect(websocket_url_);
|
||||
if (!status.ok()) {
|
||||
return status;
|
||||
}
|
||||
|
||||
// Set up WebSocket callbacks
|
||||
websocket_->OnOpen([this, password]() {
|
||||
ConsoleLog("WebSocket connected, joining session");
|
||||
is_connected_ = true;
|
||||
UpdateConnectionState(ConnectionState::Connected, "Connected");
|
||||
|
||||
// Send join session message
|
||||
json msg;
|
||||
msg["type"] = "join";
|
||||
msg["room"] = room_code_;
|
||||
msg["user"] = username_;
|
||||
msg["user_id"] = user_id_;
|
||||
msg["color"] = user_color_;
|
||||
if (!password.empty()) {
|
||||
msg["password"] = password;
|
||||
}
|
||||
|
||||
auto send_status = websocket_->Send(msg.dump());
|
||||
if (!send_status.ok()) {
|
||||
ConsoleError("Failed to send join message");
|
||||
}
|
||||
|
||||
if (status_callback_) {
|
||||
status_callback_(true, "Joined session");
|
||||
}
|
||||
UpdateCollaborationUI("session_joined", room_code_.c_str());
|
||||
});
|
||||
|
||||
websocket_->OnMessage([this](const std::string& message) {
|
||||
HandleMessage(message);
|
||||
});
|
||||
|
||||
websocket_->OnClose([this](int code, const std::string& reason) {
|
||||
is_connected_ = false;
|
||||
ConsoleLog(absl::StrFormat("WebSocket closed: %s (code: %d)", reason, code).c_str());
|
||||
|
||||
// Initiate reconnection if enabled
|
||||
if (should_reconnect_) {
|
||||
InitiateReconnection();
|
||||
} else {
|
||||
UpdateConnectionState(ConnectionState::Disconnected, absl::StrFormat("Disconnected: %s", reason));
|
||||
}
|
||||
|
||||
if (status_callback_) {
|
||||
status_callback_(false, absl::StrFormat("Disconnected: %s", reason));
|
||||
}
|
||||
UpdateCollaborationUI("disconnected", "");
|
||||
});
|
||||
|
||||
websocket_->OnError([this](const std::string& error) {
|
||||
ConsoleError(error.c_str());
|
||||
is_connected_ = false;
|
||||
|
||||
// Initiate reconnection on error
|
||||
if (should_reconnect_) {
|
||||
InitiateReconnection();
|
||||
} else {
|
||||
UpdateConnectionState(ConnectionState::Disconnected, error);
|
||||
}
|
||||
|
||||
if (status_callback_) {
|
||||
status_callback_(false, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Note: is_connected_ will be set in OnOpen callback
|
||||
UpdateCollaborationUI("session_joining", room_code_.c_str());
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
absl::Status WasmCollaboration::LeaveSession() {
|
||||
if (!is_connected_ && connection_state_ != ConnectionState::Connecting &&
|
||||
connection_state_ != ConnectionState::Reconnecting) {
|
||||
return absl::FailedPreconditionError("Not connected to a session");
|
||||
}
|
||||
|
||||
// Disable auto-reconnect when explicitly leaving
|
||||
should_reconnect_ = false;
|
||||
|
||||
// Send leave message if connected
|
||||
if (is_connected_) {
|
||||
json msg;
|
||||
msg["type"] = "leave";
|
||||
msg["room"] = room_code_;
|
||||
msg["user_id"] = user_id_;
|
||||
|
||||
auto status = websocket_->Send(msg.dump());
|
||||
if (!status.ok()) {
|
||||
ConsoleError("Failed to send leave message");
|
||||
}
|
||||
}
|
||||
|
||||
// Close WebSocket connection
|
||||
if (websocket_) {
|
||||
websocket_->Close();
|
||||
}
|
||||
is_connected_ = false;
|
||||
UpdateConnectionState(ConnectionState::Disconnected, "Left session");
|
||||
|
||||
// Clear state
|
||||
room_code_.clear();
|
||||
session_name_.clear();
|
||||
stored_password_.clear();
|
||||
ResetReconnectionState();
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(users_mutex_);
|
||||
users_.clear();
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(cursors_mutex_);
|
||||
cursors_.clear();
|
||||
}
|
||||
|
||||
if (status_callback_) {
|
||||
status_callback_(false, "Left session");
|
||||
}
|
||||
|
||||
UpdateCollaborationUI("session_left", "");
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
absl::Status WasmCollaboration::BroadcastChange(
|
||||
uint32_t offset, const std::vector<uint8_t>& old_data,
|
||||
const std::vector<uint8_t>& new_data) {
|
||||
size_t max_size = WasmConfig::Get().collaboration.max_change_size_bytes;
|
||||
if (old_data.size() > max_size || new_data.size() > max_size) {
|
||||
return absl::InvalidArgumentError(
|
||||
absl::StrFormat("Change size exceeds maximum of %d bytes", max_size));
|
||||
}
|
||||
|
||||
// Create change message
|
||||
json msg;
|
||||
msg["type"] = "change";
|
||||
msg["room"] = room_code_;
|
||||
msg["user_id"] = user_id_;
|
||||
msg["offset"] = offset;
|
||||
msg["old_data"] = old_data;
|
||||
msg["new_data"] = new_data;
|
||||
msg["timestamp"] = GetCurrentTime();
|
||||
|
||||
std::string message = msg.dump();
|
||||
|
||||
// If disconnected, queue the message for later
|
||||
if (!is_connected_) {
|
||||
if (connection_state_ == ConnectionState::Reconnecting) {
|
||||
QueueMessageWhileDisconnected(message);
|
||||
return absl::OkStatus(); // Queued successfully
|
||||
} else {
|
||||
return absl::FailedPreconditionError("Not connected to a session");
|
||||
}
|
||||
}
|
||||
|
||||
auto status = websocket_->Send(message);
|
||||
if (!status.ok()) {
|
||||
// Try to queue on send failure
|
||||
if (connection_state_ == ConnectionState::Reconnecting) {
|
||||
QueueMessageWhileDisconnected(message);
|
||||
return absl::OkStatus();
|
||||
}
|
||||
return absl::InternalError("Failed to send change");
|
||||
}
|
||||
|
||||
UpdateUserActivity(user_id_);
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
absl::Status WasmCollaboration::SendCursorPosition(
|
||||
const std::string& editor_type, int x, int y, int map_id) {
|
||||
// Don't queue cursor updates during reconnection - they're transient
|
||||
if (!is_connected_) {
|
||||
if (connection_state_ == ConnectionState::Reconnecting) {
|
||||
return absl::OkStatus(); // Silently drop during reconnection
|
||||
}
|
||||
return absl::FailedPreconditionError("Not connected to a session");
|
||||
}
|
||||
|
||||
// Rate limit cursor updates
|
||||
double now = GetCurrentTime();
|
||||
double cursor_interval = WasmConfig::Get().collaboration.cursor_send_interval_seconds;
|
||||
if (now - last_cursor_send_ < cursor_interval) {
|
||||
return absl::OkStatus(); // Silently skip
|
||||
}
|
||||
last_cursor_send_ = now;
|
||||
|
||||
// Send cursor update
|
||||
json msg;
|
||||
msg["type"] = "cursor";
|
||||
msg["room"] = room_code_;
|
||||
msg["user_id"] = user_id_;
|
||||
msg["editor"] = editor_type;
|
||||
msg["x"] = x;
|
||||
msg["y"] = y;
|
||||
if (map_id >= 0) {
|
||||
msg["map_id"] = map_id;
|
||||
}
|
||||
|
||||
auto status = websocket_->Send(msg.dump());
|
||||
if (!status.ok()) {
|
||||
// Don't fail on cursor send errors during reconnection
|
||||
if (connection_state_ == ConnectionState::Reconnecting) {
|
||||
return absl::OkStatus();
|
||||
}
|
||||
return absl::InternalError("Failed to send cursor position");
|
||||
}
|
||||
|
||||
UpdateUserActivity(user_id_);
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
std::vector<WasmCollaboration::User> WasmCollaboration::GetConnectedUsers() const {
|
||||
std::lock_guard<std::mutex> lock(users_mutex_);
|
||||
std::vector<User> result;
|
||||
for (const auto& [id, user] : users_) {
|
||||
if (user.is_active) {
|
||||
result.push_back(user);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool WasmCollaboration::IsConnected() const {
|
||||
return is_connected_ && websocket_ && websocket_->IsConnected();
|
||||
}
|
||||
|
||||
void WasmCollaboration::ProcessPendingChanges() {
|
||||
std::vector<ChangeEvent> changes_to_apply;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(changes_mutex_);
|
||||
changes_to_apply = std::move(pending_changes_);
|
||||
pending_changes_.clear();
|
||||
}
|
||||
|
||||
for (const auto& change : changes_to_apply) {
|
||||
if (IsChangeValid(change)) {
|
||||
ApplyRemoteChange(change);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for user timeouts
|
||||
CheckUserTimeouts();
|
||||
}
|
||||
|
||||
void WasmCollaboration::HandleMessage(const std::string& message) {
|
||||
try {
|
||||
json msg = json::parse(message);
|
||||
std::string type = msg["type"];
|
||||
|
||||
if (type == "create_response") {
|
||||
// Session created successfully
|
||||
if (msg["success"]) {
|
||||
session_name_ = msg["session_name"];
|
||||
ConsoleLog("Session created successfully");
|
||||
} else {
|
||||
ConsoleError(msg["error"].get<std::string>().c_str());
|
||||
is_connected_ = false;
|
||||
}
|
||||
} else if (type == "join_response") {
|
||||
// Joined session successfully
|
||||
if (msg["success"]) {
|
||||
session_name_ = msg["session_name"];
|
||||
ConsoleLog("Joined session successfully");
|
||||
} else {
|
||||
ConsoleError(msg["error"].get<std::string>().c_str());
|
||||
is_connected_ = false;
|
||||
}
|
||||
} else if (type == "users") {
|
||||
// User list update
|
||||
std::lock_guard<std::mutex> lock(users_mutex_);
|
||||
users_.clear();
|
||||
|
||||
for (const auto& user_data : msg["list"]) {
|
||||
User user;
|
||||
user.id = user_data["id"];
|
||||
user.name = user_data["name"];
|
||||
user.color = user_data["color"];
|
||||
user.is_active = user_data["active"];
|
||||
user.last_activity = GetCurrentTime();
|
||||
users_[user.id] = user;
|
||||
}
|
||||
|
||||
if (user_list_callback_) {
|
||||
user_list_callback_(GetConnectedUsers());
|
||||
}
|
||||
|
||||
// Update UI with user list
|
||||
json ui_data;
|
||||
ui_data["users"] = msg["list"];
|
||||
UpdateCollaborationUI("users_update", ui_data.dump().c_str());
|
||||
|
||||
} else if (type == "change") {
|
||||
// ROM change from another user
|
||||
if (msg["user_id"] != user_id_) { // Don't process our own changes
|
||||
ChangeEvent change;
|
||||
change.offset = msg["offset"];
|
||||
change.old_data = msg["old_data"].get<std::vector<uint8_t>>();
|
||||
change.new_data = msg["new_data"].get<std::vector<uint8_t>>();
|
||||
change.user_id = msg["user_id"];
|
||||
change.timestamp = msg["timestamp"];
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(changes_mutex_);
|
||||
pending_changes_.push_back(change);
|
||||
}
|
||||
|
||||
UpdateUserActivity(change.user_id);
|
||||
}
|
||||
} else if (type == "cursor") {
|
||||
// Cursor position update
|
||||
if (msg["user_id"] != user_id_) { // Don't process our own cursor
|
||||
CursorInfo cursor;
|
||||
cursor.user_id = msg["user_id"];
|
||||
cursor.editor_type = msg["editor"];
|
||||
cursor.x = msg["x"];
|
||||
cursor.y = msg["y"];
|
||||
if (msg.contains("map_id")) {
|
||||
cursor.map_id = msg["map_id"];
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(cursors_mutex_);
|
||||
cursors_[cursor.user_id] = cursor;
|
||||
}
|
||||
|
||||
if (cursor_callback_) {
|
||||
cursor_callback_(cursor);
|
||||
}
|
||||
|
||||
// Update UI with cursor position
|
||||
json ui_data;
|
||||
ui_data["user_id"] = cursor.user_id;
|
||||
ui_data["editor"] = cursor.editor_type;
|
||||
ui_data["x"] = cursor.x;
|
||||
ui_data["y"] = cursor.y;
|
||||
UpdateCollaborationUI("cursor_update", ui_data.dump().c_str());
|
||||
|
||||
UpdateUserActivity(cursor.user_id);
|
||||
}
|
||||
} else if (type == "error") {
|
||||
ConsoleError(msg["message"].get<std::string>().c_str());
|
||||
if (status_callback_) {
|
||||
status_callback_(false, msg["message"]);
|
||||
}
|
||||
}
|
||||
} catch (const json::exception& e) {
|
||||
ConsoleError(absl::StrFormat("JSON parse error: %s", e.what()).c_str());
|
||||
}
|
||||
}
|
||||
|
||||
std::string WasmCollaboration::GenerateUserId() {
|
||||
std::random_device rd;
|
||||
std::mt19937 gen(rd());
|
||||
std::uniform_int_distribution<> dis(0, 15);
|
||||
|
||||
std::stringstream ss;
|
||||
ss << "user_";
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
ss << std::hex << dis(gen);
|
||||
}
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
std::string WasmCollaboration::GenerateUserColor() {
|
||||
std::random_device rd;
|
||||
std::mt19937 gen(rd());
|
||||
std::uniform_int_distribution<> dis(0, kUserColors.size() - 1);
|
||||
return kUserColors[dis(gen)];
|
||||
}
|
||||
|
||||
void WasmCollaboration::UpdateUserActivity(const std::string& user_id) {
|
||||
std::lock_guard<std::mutex> lock(users_mutex_);
|
||||
if (users_.find(user_id) != users_.end()) {
|
||||
users_[user_id].last_activity = GetCurrentTime();
|
||||
users_[user_id].is_active = true;
|
||||
}
|
||||
}
|
||||
|
||||
void WasmCollaboration::CheckUserTimeouts() {
|
||||
double now = GetCurrentTime();
|
||||
std::lock_guard<std::mutex> lock(users_mutex_);
|
||||
|
||||
double timeout = WasmConfig::Get().collaboration.user_timeout_seconds;
|
||||
bool users_changed = false;
|
||||
for (auto& [id, user] : users_) {
|
||||
if (user.is_active && (now - user.last_activity) > timeout) {
|
||||
user.is_active = false;
|
||||
users_changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (users_changed && user_list_callback_) {
|
||||
user_list_callback_(GetConnectedUsers());
|
||||
}
|
||||
}
|
||||
|
||||
bool WasmCollaboration::IsChangeValid(const ChangeEvent& change) {
|
||||
// Validate change doesn't exceed ROM bounds
|
||||
if (!rom_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (change.offset + change.new_data.size() > rom_->size()) {
|
||||
ConsoleError(absl::StrFormat("Change at offset %u exceeds ROM size",
|
||||
change.offset).c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Could add more validation here (e.g., check if area is editable)
|
||||
return true;
|
||||
}
|
||||
|
||||
void WasmCollaboration::ApplyRemoteChange(const ChangeEvent& change) {
|
||||
if (!rom_) {
|
||||
ConsoleError("ROM not set, cannot apply changes");
|
||||
return;
|
||||
}
|
||||
|
||||
applying_remote_change_ = true;
|
||||
// Apply the change to the ROM
|
||||
for (size_t i = 0; i < change.new_data.size(); ++i) {
|
||||
rom_->WriteByte(change.offset + i, change.new_data[i]);
|
||||
}
|
||||
applying_remote_change_ = false;
|
||||
|
||||
// Notify the UI about the change
|
||||
if (change_callback_) {
|
||||
change_callback_(change);
|
||||
}
|
||||
|
||||
// Update UI with change info
|
||||
json ui_data;
|
||||
ui_data["offset"] = change.offset;
|
||||
ui_data["size"] = change.new_data.size();
|
||||
ui_data["user_id"] = change.user_id;
|
||||
UpdateCollaborationUI("change_applied", ui_data.dump().c_str());
|
||||
}
|
||||
|
||||
void WasmCollaboration::UpdateConnectionState(ConnectionState new_state, const std::string& message) {
|
||||
connection_state_ = new_state;
|
||||
|
||||
// Notify via callback
|
||||
if (connection_state_callback_) {
|
||||
connection_state_callback_(new_state, message);
|
||||
}
|
||||
|
||||
// Update UI
|
||||
std::string state_str;
|
||||
switch (new_state) {
|
||||
case ConnectionState::Disconnected:
|
||||
state_str = "disconnected";
|
||||
break;
|
||||
case ConnectionState::Connecting:
|
||||
state_str = "connecting";
|
||||
break;
|
||||
case ConnectionState::Connected:
|
||||
state_str = "connected";
|
||||
break;
|
||||
case ConnectionState::Reconnecting:
|
||||
state_str = "reconnecting";
|
||||
break;
|
||||
}
|
||||
|
||||
json ui_data;
|
||||
ui_data["state"] = state_str;
|
||||
ui_data["message"] = message;
|
||||
UpdateCollaborationUI("connection_state", ui_data.dump().c_str());
|
||||
}
|
||||
|
||||
void WasmCollaboration::InitiateReconnection() {
|
||||
if (!should_reconnect_ || room_code_.empty()) {
|
||||
UpdateConnectionState(ConnectionState::Disconnected, "Disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconnection_attempts_ >= max_reconnection_attempts_) {
|
||||
ConsoleError(absl::StrFormat("Max reconnection attempts reached (%d), giving up",
|
||||
max_reconnection_attempts_).c_str());
|
||||
UpdateConnectionState(ConnectionState::Disconnected, "Reconnection failed - max attempts reached");
|
||||
ResetReconnectionState();
|
||||
return;
|
||||
}
|
||||
|
||||
reconnection_attempts_++;
|
||||
UpdateConnectionState(ConnectionState::Reconnecting,
|
||||
absl::StrFormat("Reconnecting... (attempt %d/%d)",
|
||||
reconnection_attempts_, max_reconnection_attempts_));
|
||||
|
||||
// Calculate delay with exponential backoff
|
||||
double delay = std::min(reconnection_delay_seconds_ * std::pow(2, reconnection_attempts_ - 1),
|
||||
max_reconnection_delay_);
|
||||
|
||||
ConsoleLog(absl::StrFormat("Will reconnect in %.1f seconds (attempt %d)",
|
||||
delay, reconnection_attempts_).c_str());
|
||||
|
||||
// Schedule reconnection using emscripten_set_timeout
|
||||
emscripten_async_call([](void* arg) {
|
||||
WasmCollaboration* self = static_cast<WasmCollaboration*>(arg);
|
||||
self->AttemptReconnection();
|
||||
}, this, delay * 1000); // Convert to milliseconds
|
||||
}
|
||||
|
||||
void WasmCollaboration::AttemptReconnection() {
|
||||
if (is_connected_ || connection_state_ == ConnectionState::Connected) {
|
||||
// Already reconnected somehow
|
||||
ResetReconnectionState();
|
||||
return;
|
||||
}
|
||||
|
||||
ConsoleLog(absl::StrFormat("Attempting to reconnect to room %s", room_code_).c_str());
|
||||
|
||||
// Create new websocket instance
|
||||
websocket_ = std::make_unique<net::EmscriptenWebSocket>();
|
||||
|
||||
// Attempt connection
|
||||
auto status = websocket_->Connect(websocket_url_);
|
||||
if (!status.ok()) {
|
||||
ConsoleError(absl::StrFormat("Reconnection failed: %s", status.message()).c_str());
|
||||
InitiateReconnection(); // Schedule next attempt
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up WebSocket callbacks for reconnection
|
||||
websocket_->OnOpen([this]() {
|
||||
ConsoleLog("WebSocket reconnected, rejoining session");
|
||||
is_connected_ = true;
|
||||
UpdateConnectionState(ConnectionState::Connected, "Reconnected successfully");
|
||||
|
||||
// Send rejoin message
|
||||
json msg;
|
||||
msg["type"] = "join";
|
||||
msg["room"] = room_code_;
|
||||
msg["user"] = username_;
|
||||
msg["user_id"] = user_id_;
|
||||
msg["color"] = user_color_;
|
||||
if (!stored_password_.empty()) {
|
||||
msg["password"] = stored_password_;
|
||||
}
|
||||
msg["rejoin"] = true; // Indicate this is a reconnection
|
||||
|
||||
auto send_status = websocket_->Send(msg.dump());
|
||||
if (!send_status.ok()) {
|
||||
ConsoleError("Failed to send rejoin message");
|
||||
}
|
||||
|
||||
// Reset reconnection state on success
|
||||
ResetReconnectionState();
|
||||
|
||||
// Send any queued messages
|
||||
std::vector<std::string> messages_to_send;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(message_queue_mutex_);
|
||||
messages_to_send = std::move(queued_messages_);
|
||||
queued_messages_.clear();
|
||||
}
|
||||
|
||||
for (const auto& msg : messages_to_send) {
|
||||
websocket_->Send(msg);
|
||||
}
|
||||
|
||||
if (status_callback_) {
|
||||
status_callback_(true, "Reconnected to session");
|
||||
}
|
||||
UpdateCollaborationUI("session_reconnected", room_code_.c_str());
|
||||
});
|
||||
|
||||
websocket_->OnMessage([this](const std::string& message) {
|
||||
HandleMessage(message);
|
||||
});
|
||||
|
||||
websocket_->OnClose([this](int code, const std::string& reason) {
|
||||
is_connected_ = false;
|
||||
ConsoleLog(absl::StrFormat("Reconnection WebSocket closed: %s", reason).c_str());
|
||||
|
||||
// Attempt reconnection again
|
||||
InitiateReconnection();
|
||||
|
||||
if (status_callback_) {
|
||||
status_callback_(false, absl::StrFormat("Disconnected: %s", reason));
|
||||
}
|
||||
});
|
||||
|
||||
websocket_->OnError([this](const std::string& error) {
|
||||
ConsoleError(absl::StrFormat("Reconnection error: %s", error).c_str());
|
||||
is_connected_ = false;
|
||||
|
||||
// Attempt reconnection again
|
||||
InitiateReconnection();
|
||||
|
||||
if (status_callback_) {
|
||||
status_callback_(false, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void WasmCollaboration::ResetReconnectionState() {
|
||||
reconnection_attempts_ = 0;
|
||||
reconnection_delay_seconds_ = 1.0; // Reset to initial delay
|
||||
}
|
||||
|
||||
void WasmCollaboration::QueueMessageWhileDisconnected(const std::string& message) {
|
||||
std::lock_guard<std::mutex> lock(message_queue_mutex_);
|
||||
|
||||
// Limit queue size to prevent memory issues
|
||||
if (queued_messages_.size() >= max_queued_messages_) {
|
||||
ConsoleLog("Message queue full, dropping oldest message");
|
||||
queued_messages_.erase(queued_messages_.begin());
|
||||
}
|
||||
|
||||
queued_messages_.push_back(message);
|
||||
ConsoleLog(absl::StrFormat("Queued message for reconnection (queue size: %d)",
|
||||
queued_messages_.size()).c_str());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JS bindings for WASM (exported with EMSCRIPTEN_KEEPALIVE)
|
||||
// ---------------------------------------------------------------------------
|
||||
extern "C" {
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE const char* WasmCollaborationCreate(
|
||||
const char* session_name, const char* username, const char* password) {
|
||||
static std::string last_room_code;
|
||||
if (!session_name || !username) {
|
||||
ConsoleError("Invalid session/user parameters");
|
||||
return nullptr;
|
||||
}
|
||||
auto& collab = GetInstance();
|
||||
auto result = collab.CreateSession(session_name, username,
|
||||
password ? std::string(password) : "");
|
||||
if (!result.ok()) {
|
||||
ConsoleError(std::string(result.status().message()).c_str());
|
||||
return nullptr;
|
||||
}
|
||||
last_room_code = *result;
|
||||
return last_room_code.c_str();
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE int WasmCollaborationJoin(const char* room_code,
|
||||
const char* username,
|
||||
const char* password) {
|
||||
if (!room_code || !username) {
|
||||
ConsoleError("room_code and username are required");
|
||||
return 0;
|
||||
}
|
||||
auto& collab = GetInstance();
|
||||
auto status = collab.JoinSession(room_code, username,
|
||||
password ? std::string(password) : "");
|
||||
if (!status.ok()) {
|
||||
ConsoleError(std::string(status.message()).c_str());
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE int WasmCollaborationLeave() {
|
||||
auto& collab = GetInstance();
|
||||
auto status = collab.LeaveSession();
|
||||
return status.ok() ? 1 : 0;
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE int WasmCollaborationSendCursor(
|
||||
const char* editor_type, int x, int y, int map_id) {
|
||||
auto& collab = GetInstance();
|
||||
auto status = collab.SendCursorPosition(editor_type ? editor_type : "unknown",
|
||||
x, y, map_id);
|
||||
return status.ok() ? 1 : 0;
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE int WasmCollaborationBroadcastChange(
|
||||
uint32_t offset, const uint8_t* new_data, size_t length) {
|
||||
if (!new_data && length > 0) {
|
||||
return 0;
|
||||
}
|
||||
auto& collab = GetInstance();
|
||||
std::vector<uint8_t> data;
|
||||
data.reserve(length);
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
data.push_back(new_data[i]);
|
||||
}
|
||||
std::vector<uint8_t> old_data; // Not tracked in WASM path
|
||||
auto status = collab.BroadcastChange(offset, old_data, data);
|
||||
return status.ok() ? 1 : 0;
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE void WasmCollaborationSetServerUrl(const char* url) {
|
||||
if (!url) return;
|
||||
auto& collab = GetInstance();
|
||||
collab.SetWebSocketUrl(std::string(url));
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE int WasmCollaborationIsConnected() {
|
||||
return GetInstance().IsConnected() ? 1 : 0;
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE const char* WasmCollaborationGetRoomCode() {
|
||||
static std::string room;
|
||||
room = GetInstance().GetRoomCode();
|
||||
return room.c_str();
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE const char* WasmCollaborationGetUserId() {
|
||||
static std::string user;
|
||||
user = GetInstance().GetUserId();
|
||||
return user.c_str();
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
440
src/app/platform/wasm/wasm_collaboration.h
Normal file
440
src/app/platform/wasm/wasm_collaboration.h
Normal file
@@ -0,0 +1,440 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_COLLABORATION_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_COLLABORATION_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/html5.h>
|
||||
#include <emscripten/val.h>
|
||||
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/status/statusor.h"
|
||||
#include "app/net/wasm/emscripten_websocket.h"
|
||||
#include "rom/rom.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* @brief Real-time collaboration manager for WASM builds
|
||||
*
|
||||
* Enables multiple users to edit ROMs together in the browser.
|
||||
* Uses WebSocket connection for real-time synchronization.
|
||||
*/
|
||||
class WasmCollaboration {
|
||||
public:
|
||||
/**
|
||||
* @brief User information for collaboration session
|
||||
*/
|
||||
struct User {
|
||||
std::string id;
|
||||
std::string name;
|
||||
std::string color; // Hex color for cursor/highlights
|
||||
bool is_active = true;
|
||||
double last_activity = 0; // Timestamp
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Cursor position information
|
||||
*/
|
||||
struct CursorInfo {
|
||||
std::string user_id;
|
||||
std::string editor_type; // "overworld", "dungeon", etc.
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int map_id = -1; // For context (which map/room)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief ROM change event for synchronization
|
||||
*/
|
||||
struct ChangeEvent {
|
||||
uint32_t offset;
|
||||
std::vector<uint8_t> old_data;
|
||||
std::vector<uint8_t> new_data;
|
||||
std::string user_id;
|
||||
double timestamp;
|
||||
};
|
||||
|
||||
// Connection state enum
|
||||
enum class ConnectionState {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Reconnecting
|
||||
};
|
||||
|
||||
// Callbacks for UI updates
|
||||
using UserListCallback = std::function<void(const std::vector<User>&)>;
|
||||
using ChangeCallback = std::function<void(const ChangeEvent&)>;
|
||||
using CursorCallback = std::function<void(const CursorInfo&)>;
|
||||
using StatusCallback = std::function<void(bool connected, const std::string& message)>;
|
||||
using ConnectionStateCallback = std::function<void(ConnectionState state, const std::string& message)>;
|
||||
|
||||
WasmCollaboration();
|
||||
~WasmCollaboration();
|
||||
|
||||
/**
|
||||
* @brief Set the WebSocket server URL
|
||||
* @param url Full WebSocket URL (e.g., "wss://your-server.com/ws")
|
||||
* @return Status indicating success or validation failure
|
||||
*
|
||||
* For GitHub Pages deployment, you'll need a separate WebSocket server.
|
||||
* Options include:
|
||||
* - Cloudflare Workers with Durable Objects
|
||||
* - Deno Deploy
|
||||
* - Railway, Render, or other PaaS providers
|
||||
* - Self-hosted server (e.g., yaze-server on port 8765)
|
||||
*
|
||||
* URL must start with "ws://" or "wss://" to be valid.
|
||||
*/
|
||||
absl::Status SetWebSocketUrl(const std::string& url) {
|
||||
if (url.empty()) {
|
||||
websocket_url_.clear();
|
||||
return absl::OkStatus();
|
||||
}
|
||||
if (url.find("ws://") != 0 && url.find("wss://") != 0) {
|
||||
return absl::InvalidArgumentError(
|
||||
"WebSocket URL must start with ws:// or wss://");
|
||||
}
|
||||
// Basic URL structure validation
|
||||
if (url.length() < 8) { // Minimum: "ws://x" or "wss://x"
|
||||
return absl::InvalidArgumentError("WebSocket URL is too short");
|
||||
}
|
||||
websocket_url_ = url;
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the current WebSocket server URL
|
||||
* @return Current URL or empty if not configured
|
||||
*/
|
||||
std::string GetWebSocketUrl() const { return websocket_url_; }
|
||||
|
||||
/**
|
||||
* @brief Initialize WebSocket URL from JavaScript configuration
|
||||
*
|
||||
* Looks for window.YAZE_CONFIG.collaborationServerUrl in the browser.
|
||||
* This allows deployment-specific configuration without recompiling.
|
||||
*/
|
||||
void InitializeFromConfig();
|
||||
|
||||
/**
|
||||
* @brief Check if collaboration is configured and available
|
||||
* @return true if WebSocket URL is set and valid
|
||||
*/
|
||||
bool IsConfigured() const { return !websocket_url_.empty(); }
|
||||
|
||||
/**
|
||||
* @brief Create a new collaboration session
|
||||
* @param session_name Name for the session
|
||||
* @param username User's display name
|
||||
* @return Room code for others to join
|
||||
*/
|
||||
absl::StatusOr<std::string> CreateSession(const std::string& session_name,
|
||||
const std::string& username,
|
||||
const std::string& password = "");
|
||||
|
||||
/**
|
||||
* @brief Join an existing collaboration session
|
||||
* @param room_code 6-character room code
|
||||
* @param username User's display name
|
||||
* @return Status of connection attempt
|
||||
*/
|
||||
absl::Status JoinSession(const std::string& room_code,
|
||||
const std::string& username,
|
||||
const std::string& password = "");
|
||||
|
||||
/**
|
||||
* @brief Leave current collaboration session
|
||||
*/
|
||||
absl::Status LeaveSession();
|
||||
|
||||
/**
|
||||
* @brief Broadcast a ROM change to all peers
|
||||
* @param offset ROM offset that changed
|
||||
* @param old_data Original data
|
||||
* @param new_data New data
|
||||
*/
|
||||
absl::Status BroadcastChange(uint32_t offset,
|
||||
const std::vector<uint8_t>& old_data,
|
||||
const std::vector<uint8_t>& new_data);
|
||||
|
||||
/**
|
||||
* @brief Send cursor position update
|
||||
* @param editor_type Current editor ("overworld", "dungeon", etc.)
|
||||
* @param x X position in editor
|
||||
* @param y Y position in editor
|
||||
* @param map_id Optional map/room ID for context
|
||||
*/
|
||||
absl::Status SendCursorPosition(const std::string& editor_type,
|
||||
int x, int y, int map_id = -1);
|
||||
|
||||
/**
|
||||
* @brief Set ROM reference for applying changes
|
||||
* @param rom Pointer to the ROM being edited
|
||||
*/
|
||||
void SetRom(Rom* rom) { rom_ = rom; }
|
||||
|
||||
/**
|
||||
* @brief Register callback for ROM changes from peers
|
||||
* @param callback Function to call when changes arrive
|
||||
*/
|
||||
void SetChangeCallback(ChangeCallback callback) {
|
||||
change_callback_ = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Register callback for user list updates
|
||||
* @param callback Function to call when user list changes
|
||||
*/
|
||||
void SetUserListCallback(UserListCallback callback) {
|
||||
user_list_callback_ = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Register callback for cursor position updates
|
||||
* @param callback Function to call when cursor positions update
|
||||
*/
|
||||
void SetCursorCallback(CursorCallback callback) {
|
||||
cursor_callback_ = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Register callback for connection status changes
|
||||
* @param callback Function to call on status changes
|
||||
*/
|
||||
void SetStatusCallback(StatusCallback callback) {
|
||||
status_callback_ = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Register callback for connection state changes
|
||||
* @param callback Function to call on connection state changes
|
||||
*/
|
||||
void SetConnectionStateCallback(ConnectionStateCallback callback) {
|
||||
connection_state_callback_ = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get current connection state
|
||||
* @return Current connection state
|
||||
*/
|
||||
ConnectionState GetConnectionState() const {
|
||||
return connection_state_;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get list of connected users
|
||||
* @return Vector of active users
|
||||
*/
|
||||
std::vector<User> GetConnectedUsers() const;
|
||||
|
||||
/**
|
||||
* @brief Check if currently connected to a session
|
||||
* @return true if connected
|
||||
*/
|
||||
bool IsConnected() const;
|
||||
|
||||
/**
|
||||
* @brief Whether we're currently applying a remote change (used to avoid rebroadcast)
|
||||
*/
|
||||
bool IsApplyingRemoteChange() const { return applying_remote_change_; }
|
||||
|
||||
/**
|
||||
* @brief Get current room code
|
||||
* @return Room code or empty string if not connected
|
||||
*/
|
||||
std::string GetRoomCode() const { return room_code_; }
|
||||
|
||||
/**
|
||||
* @brief Get session name
|
||||
* @return Session name or empty string if not connected
|
||||
*/
|
||||
std::string GetSessionName() const { return session_name_; }
|
||||
|
||||
/**
|
||||
* @brief Get current user id (stable per session)
|
||||
*/
|
||||
std::string GetUserId() const { return user_id_; }
|
||||
|
||||
/**
|
||||
* @brief Enable/disable automatic conflict resolution
|
||||
* @param enable true to enable auto-resolution
|
||||
*/
|
||||
void SetAutoResolveConflicts(bool enable) {
|
||||
auto_resolve_conflicts_ = enable;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Process pending changes queue
|
||||
* Called periodically to apply remote changes
|
||||
*/
|
||||
void ProcessPendingChanges();
|
||||
|
||||
private:
|
||||
// WebSocket message handlers
|
||||
void HandleMessage(const std::string& message);
|
||||
void HandleCreateResponse(const emscripten::val& data);
|
||||
void HandleJoinResponse(const emscripten::val& data);
|
||||
void HandleUserList(const emscripten::val& data);
|
||||
void HandleChange(const emscripten::val& data);
|
||||
void HandleCursor(const emscripten::val& data);
|
||||
void HandleError(const emscripten::val& data);
|
||||
|
||||
// Utility methods
|
||||
std::string GenerateUserId();
|
||||
std::string GenerateUserColor();
|
||||
void UpdateUserActivity(const std::string& user_id);
|
||||
void CheckUserTimeouts();
|
||||
bool IsChangeValid(const ChangeEvent& change);
|
||||
void ApplyRemoteChange(const ChangeEvent& change);
|
||||
|
||||
// Reconnection management
|
||||
void InitiateReconnection();
|
||||
void AttemptReconnection();
|
||||
void ResetReconnectionState();
|
||||
void UpdateConnectionState(ConnectionState new_state, const std::string& message);
|
||||
void QueueMessageWhileDisconnected(const std::string& message);
|
||||
|
||||
// Connection management
|
||||
std::unique_ptr<net::EmscriptenWebSocket> websocket_;
|
||||
bool is_connected_ = false;
|
||||
ConnectionState connection_state_ = ConnectionState::Disconnected;
|
||||
std::string websocket_url_; // Set via SetWebSocketUrl() or environment
|
||||
|
||||
// Reconnection state
|
||||
int reconnection_attempts_ = 0;
|
||||
int max_reconnection_attempts_ = 10;
|
||||
double reconnection_delay_seconds_ = 1.0; // Initial delay
|
||||
double max_reconnection_delay_ = 30.0; // Max delay between attempts
|
||||
bool should_reconnect_ = false;
|
||||
std::string stored_password_; // Store password for reconnection
|
||||
|
||||
// Session state
|
||||
std::string room_code_;
|
||||
std::string session_name_;
|
||||
std::string user_id_;
|
||||
std::string username_;
|
||||
std::string user_color_;
|
||||
|
||||
// Connected users
|
||||
std::map<std::string, User> users_;
|
||||
mutable std::mutex users_mutex_;
|
||||
|
||||
// Remote cursors
|
||||
std::map<std::string, CursorInfo> cursors_;
|
||||
mutable std::mutex cursors_mutex_;
|
||||
|
||||
// Change queue for conflict resolution
|
||||
std::vector<ChangeEvent> pending_changes_;
|
||||
mutable std::mutex changes_mutex_;
|
||||
|
||||
// Message queue for disconnected state
|
||||
std::vector<std::string> queued_messages_;
|
||||
mutable std::mutex message_queue_mutex_;
|
||||
size_t max_queued_messages_ = 100; // Limit queue size
|
||||
|
||||
// Configuration
|
||||
bool auto_resolve_conflicts_ = true;
|
||||
|
||||
// Callbacks
|
||||
UserListCallback user_list_callback_;
|
||||
ChangeCallback change_callback_;
|
||||
CursorCallback cursor_callback_;
|
||||
StatusCallback status_callback_;
|
||||
ConnectionStateCallback connection_state_callback_;
|
||||
|
||||
// ROM reference for applying changes
|
||||
Rom* rom_ = nullptr;
|
||||
|
||||
// Rate limiting
|
||||
double last_cursor_send_ = 0;
|
||||
|
||||
// Guard to prevent echoing remote writes back to the server
|
||||
bool applying_remote_change_ = false;
|
||||
};
|
||||
|
||||
// Singleton accessor used by JS bindings and the WASM main loop
|
||||
WasmCollaboration& GetWasmCollaborationInstance();
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#else // !__EMSCRIPTEN__
|
||||
|
||||
// Stub for non-WASM builds
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/status/statusor.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
class WasmCollaboration {
|
||||
public:
|
||||
struct User {
|
||||
std::string id;
|
||||
std::string name;
|
||||
std::string color;
|
||||
bool is_active = true;
|
||||
};
|
||||
|
||||
struct CursorInfo {
|
||||
std::string user_id;
|
||||
std::string editor_type;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int map_id = -1;
|
||||
};
|
||||
|
||||
struct ChangeEvent {
|
||||
uint32_t offset;
|
||||
std::vector<uint8_t> old_data;
|
||||
std::vector<uint8_t> new_data;
|
||||
std::string user_id;
|
||||
double timestamp;
|
||||
};
|
||||
|
||||
absl::StatusOr<std::string> CreateSession(const std::string&,
|
||||
const std::string&) {
|
||||
return absl::UnimplementedError("Collaboration requires WASM build");
|
||||
}
|
||||
|
||||
absl::Status JoinSession(const std::string&, const std::string&) {
|
||||
return absl::UnimplementedError("Collaboration requires WASM build");
|
||||
}
|
||||
|
||||
absl::Status LeaveSession() {
|
||||
return absl::UnimplementedError("Collaboration requires WASM build");
|
||||
}
|
||||
|
||||
absl::Status BroadcastChange(uint32_t, const std::vector<uint8_t>&,
|
||||
const std::vector<uint8_t>&) {
|
||||
return absl::UnimplementedError("Collaboration requires WASM build");
|
||||
}
|
||||
|
||||
std::vector<User> GetConnectedUsers() const { return {}; }
|
||||
bool IsConnected() const { return false; }
|
||||
std::string GetRoomCode() const { return ""; }
|
||||
std::string GetSessionName() const { return ""; }
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_COLLABORATION_H_
|
||||
181
src/app/platform/wasm/wasm_config.cc
Normal file
181
src/app/platform/wasm/wasm_config.cc
Normal file
@@ -0,0 +1,181 @@
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_config.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <emscripten.h>
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
// clang-format off
|
||||
|
||||
// Helper to read string from JS config
|
||||
EM_JS(char*, WasmConfig_GetString, (const char* path, const char* defaultVal), {
|
||||
try {
|
||||
var config = window.YAZE_CONFIG || {};
|
||||
var parts = UTF8ToString(path).split('.');
|
||||
var value = config;
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (value && typeof value === 'object' && parts[i] in value) {
|
||||
value = value[parts[i]];
|
||||
} else {
|
||||
value = UTF8ToString(defaultVal);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
value = UTF8ToString(defaultVal);
|
||||
}
|
||||
var lengthBytes = lengthBytesUTF8(value) + 1;
|
||||
var stringOnWasmHeap = _malloc(lengthBytes);
|
||||
stringToUTF8(value, stringOnWasmHeap, lengthBytes);
|
||||
return stringOnWasmHeap;
|
||||
} catch (e) {
|
||||
console.error('[WasmConfig] Error reading string:', e);
|
||||
var def = UTF8ToString(defaultVal);
|
||||
var len = lengthBytesUTF8(def) + 1;
|
||||
var ptr = _malloc(len);
|
||||
stringToUTF8(def, ptr, len);
|
||||
return ptr;
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to read number from JS config
|
||||
EM_JS(double, WasmConfig_GetNumber, (const char* path, double defaultVal), {
|
||||
try {
|
||||
var config = window.YAZE_CONFIG || {};
|
||||
var parts = UTF8ToString(path).split('.');
|
||||
var value = config;
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (value && typeof value === 'object' && parts[i] in value) {
|
||||
value = value[parts[i]];
|
||||
} else {
|
||||
return defaultVal;
|
||||
}
|
||||
}
|
||||
return typeof value === 'number' ? value : defaultVal;
|
||||
} catch (e) {
|
||||
console.error('[WasmConfig] Error reading number:', e);
|
||||
return defaultVal;
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to read int from JS config
|
||||
EM_JS(int, WasmConfig_GetInt, (const char* path, int defaultVal), {
|
||||
try {
|
||||
var config = window.YAZE_CONFIG || {};
|
||||
var parts = UTF8ToString(path).split('.');
|
||||
var value = config;
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (value && typeof value === 'object' && parts[i] in value) {
|
||||
value = value[parts[i]];
|
||||
} else {
|
||||
return defaultVal;
|
||||
}
|
||||
}
|
||||
return typeof value === 'number' ? Math.floor(value) : defaultVal;
|
||||
} catch (e) {
|
||||
console.error('[WasmConfig] Error reading int:', e);
|
||||
return defaultVal;
|
||||
}
|
||||
});
|
||||
|
||||
// clang-format on
|
||||
|
||||
void WasmConfig::LoadFromJavaScript() {
|
||||
// Prevent concurrent loading
|
||||
bool expected = false;
|
||||
if (!loading_.compare_exchange_strong(expected, true,
|
||||
std::memory_order_acq_rel)) {
|
||||
// Already loading, wait for completion
|
||||
return;
|
||||
}
|
||||
|
||||
// Lock for writing config values
|
||||
std::lock_guard<std::mutex> lock(config_mutex_);
|
||||
|
||||
// Collaboration settings
|
||||
char* server_url = WasmConfig_GetString("collaboration.serverUrl", "");
|
||||
collaboration.server_url = std::string(server_url);
|
||||
free(server_url);
|
||||
|
||||
collaboration.user_timeout_seconds =
|
||||
WasmConfig_GetNumber("collaboration.userTimeoutSeconds", 30.0);
|
||||
collaboration.cursor_send_interval_seconds =
|
||||
WasmConfig_GetNumber("collaboration.cursorSendIntervalMs", 100.0) / 1000.0;
|
||||
collaboration.max_change_size_bytes = static_cast<size_t>(
|
||||
WasmConfig_GetInt("collaboration.maxChangeSizeBytes", 1024));
|
||||
|
||||
// Autosave settings
|
||||
autosave.interval_seconds =
|
||||
WasmConfig_GetInt("autosave.intervalSeconds", 60);
|
||||
autosave.max_recovery_slots =
|
||||
WasmConfig_GetInt("autosave.maxRecoverySlots", 5);
|
||||
|
||||
// Terminal settings
|
||||
terminal.max_history_items =
|
||||
WasmConfig_GetInt("terminal.maxHistoryItems", 50);
|
||||
terminal.max_output_lines =
|
||||
WasmConfig_GetInt("terminal.maxOutputLines", 1000);
|
||||
|
||||
// UI settings
|
||||
ui.min_zoom = static_cast<float>(WasmConfig_GetNumber("ui.minZoom", 0.25));
|
||||
ui.max_zoom = static_cast<float>(WasmConfig_GetNumber("ui.maxZoom", 4.0));
|
||||
ui.touch_gesture_threshold =
|
||||
WasmConfig_GetInt("ui.touchGestureThreshold", 10);
|
||||
|
||||
// Cache settings
|
||||
char* cache_version = WasmConfig_GetString("cache.version", "v1");
|
||||
cache.version = std::string(cache_version);
|
||||
free(cache_version);
|
||||
|
||||
cache.max_rom_cache_size_mb =
|
||||
WasmConfig_GetInt("cache.maxRomCacheSizeMb", 100);
|
||||
|
||||
// AI settings
|
||||
ai.enabled = WasmConfig_GetInt("ai.enabled", 1) != 0;
|
||||
char* ai_model = WasmConfig_GetString("ai.model", "gemini-2.5-flash");
|
||||
ai.model = std::string(ai_model);
|
||||
free(ai_model);
|
||||
|
||||
char* ai_endpoint = WasmConfig_GetString("ai.endpoint", "");
|
||||
ai.endpoint = std::string(ai_endpoint);
|
||||
free(ai_endpoint);
|
||||
|
||||
ai.max_response_length = WasmConfig_GetInt("ai.maxResponseLength", 4096);
|
||||
|
||||
// Deployment info (read-only defaults, but can be overridden)
|
||||
char* server_repo = WasmConfig_GetString("deployment.serverRepo",
|
||||
"https://github.com/scawful/yaze-server");
|
||||
deployment.server_repo = std::string(server_repo);
|
||||
free(server_repo);
|
||||
|
||||
deployment.default_port = WasmConfig_GetInt("deployment.defaultPort", 8765);
|
||||
|
||||
char* protocol_version = WasmConfig_GetString("deployment.protocolVersion", "2.0");
|
||||
deployment.protocol_version = std::string(protocol_version);
|
||||
free(protocol_version);
|
||||
|
||||
loaded_.store(true, std::memory_order_release);
|
||||
loading_.store(false, std::memory_order_release);
|
||||
}
|
||||
|
||||
WasmConfig& WasmConfig::Get() {
|
||||
static WasmConfig instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
void WasmConfig::FetchServerStatus() {
|
||||
// Server status fetching is handled via JavaScript in the web shell.
|
||||
// The web shell calls fetch() on /health and populates window.YAZE_SERVER_STATUS.
|
||||
// This C++ function is a stub - actual status is read from JS config on next LoadFromJavaScript().
|
||||
// TODO: Implement async status fetching via emscripten_async_call if needed.
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
270
src/app/platform/wasm/wasm_config.h
Normal file
270
src/app/platform/wasm/wasm_config.h
Normal file
@@ -0,0 +1,270 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_CONFIG_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_CONFIG_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <emscripten.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* @brief Centralized configuration for WASM platform features
|
||||
*
|
||||
* All configurable values are loaded from JavaScript's window.YAZE_CONFIG
|
||||
* object, allowing deployment-specific customization without recompiling.
|
||||
*
|
||||
* Usage in JavaScript (before WASM loads):
|
||||
* @code
|
||||
* window.YAZE_CONFIG = {
|
||||
* collaboration: {
|
||||
* serverUrl: "wss://your-server.com/ws",
|
||||
* userTimeoutSeconds: 30.0,
|
||||
* cursorSendIntervalMs: 100,
|
||||
* maxChangeSizeBytes: 1024
|
||||
* },
|
||||
* autosave: {
|
||||
* intervalSeconds: 60,
|
||||
* maxRecoverySlots: 5
|
||||
* },
|
||||
* terminal: {
|
||||
* maxHistoryItems: 50,
|
||||
* maxOutputLines: 1000
|
||||
* },
|
||||
* ui: {
|
||||
* minZoom: 0.25,
|
||||
* maxZoom: 4.0,
|
||||
* touchGestureThreshold: 10
|
||||
* },
|
||||
* cache: {
|
||||
* version: "v1",
|
||||
* maxRomCacheSizeMb: 100
|
||||
* }
|
||||
* };
|
||||
* @endcode
|
||||
*/
|
||||
struct WasmConfig {
|
||||
// Collaboration settings
|
||||
struct Collaboration {
|
||||
std::string server_url;
|
||||
double user_timeout_seconds = 30.0;
|
||||
double cursor_send_interval_seconds = 0.1; // 100ms
|
||||
size_t max_change_size_bytes = 1024;
|
||||
} collaboration;
|
||||
|
||||
// Autosave settings
|
||||
struct Autosave {
|
||||
int interval_seconds = 60;
|
||||
int max_recovery_slots = 5;
|
||||
} autosave;
|
||||
|
||||
// Terminal settings
|
||||
struct Terminal {
|
||||
int max_history_items = 50;
|
||||
int max_output_lines = 1000;
|
||||
} terminal;
|
||||
|
||||
// UI settings
|
||||
struct UI {
|
||||
float min_zoom = 0.25f;
|
||||
float max_zoom = 4.0f;
|
||||
int touch_gesture_threshold = 10;
|
||||
} ui;
|
||||
|
||||
// Cache settings
|
||||
struct Cache {
|
||||
std::string version = "v1";
|
||||
int max_rom_cache_size_mb = 100;
|
||||
} cache;
|
||||
|
||||
// AI service settings (for terminal AI commands)
|
||||
struct AI {
|
||||
bool enabled = true;
|
||||
std::string model = "gemini-2.5-flash";
|
||||
std::string endpoint; // Empty = use collaboration server
|
||||
int max_response_length = 4096;
|
||||
} ai;
|
||||
|
||||
// Server deployment info
|
||||
struct Deployment {
|
||||
std::string server_repo = "https://github.com/scawful/yaze-server";
|
||||
int default_port = 8765;
|
||||
std::string protocol_version = "2.0";
|
||||
} deployment;
|
||||
|
||||
// Server status (populated by FetchServerStatus)
|
||||
struct ServerStatus {
|
||||
bool fetched = false;
|
||||
bool reachable = false;
|
||||
bool ai_enabled = false;
|
||||
bool ai_configured = false;
|
||||
std::string ai_provider; // "gemini", "external", "none"
|
||||
bool tls_detected = false;
|
||||
std::string persistence_type; // "memory", "file"
|
||||
int active_sessions = 0;
|
||||
int total_connections = 0;
|
||||
std::string server_version;
|
||||
std::string error_message;
|
||||
} server_status;
|
||||
|
||||
/**
|
||||
* @brief Load configuration from JavaScript window.YAZE_CONFIG
|
||||
*
|
||||
* Call this once during initialization to populate all config values
|
||||
* from the JavaScript environment.
|
||||
*/
|
||||
void LoadFromJavaScript();
|
||||
|
||||
/**
|
||||
* @brief Fetch server status from /health endpoint asynchronously
|
||||
*
|
||||
* Populates server_status struct with reachability, AI status, TLS info.
|
||||
* Safe to call multiple times; will update server_status on each call.
|
||||
*/
|
||||
void FetchServerStatus();
|
||||
|
||||
/**
|
||||
* @brief Get the singleton configuration instance
|
||||
* @return Reference to the global config
|
||||
*/
|
||||
static WasmConfig& Get();
|
||||
|
||||
/**
|
||||
* @brief Check if config was loaded from JavaScript
|
||||
* @return true if LoadFromJavaScript() was called successfully
|
||||
*/
|
||||
bool IsLoaded() const { return loaded_.load(std::memory_order_acquire); }
|
||||
|
||||
/**
|
||||
* @brief Check if config is currently being loaded
|
||||
* @return true if LoadFromJavaScript() is in progress
|
||||
*/
|
||||
bool IsLoading() const { return loading_.load(std::memory_order_acquire); }
|
||||
|
||||
/**
|
||||
* @brief Get read access to config (thread-safe)
|
||||
* @return Lock guard for read operations
|
||||
*
|
||||
* Use this when reading multiple config values to ensure consistency.
|
||||
* Example:
|
||||
* @code
|
||||
* auto lock = WasmConfig::Get().GetReadLock();
|
||||
* auto url = WasmConfig::Get().collaboration.server_url;
|
||||
* auto timeout = WasmConfig::Get().collaboration.user_timeout_seconds;
|
||||
* @endcode
|
||||
*/
|
||||
std::unique_lock<std::mutex> GetReadLock() const {
|
||||
return std::unique_lock<std::mutex>(config_mutex_);
|
||||
}
|
||||
|
||||
private:
|
||||
std::atomic<bool> loaded_{false};
|
||||
std::atomic<bool> loading_{false};
|
||||
mutable std::mutex config_mutex_;
|
||||
};
|
||||
|
||||
// External C declarations for functions implemented in .cc
|
||||
extern "C" {
|
||||
char* WasmConfig_GetString(const char* path, const char* defaultVal);
|
||||
double WasmConfig_GetNumber(const char* path, double defaultVal);
|
||||
int WasmConfig_GetInt(const char* path, int defaultVal);
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#else // !__EMSCRIPTEN__
|
||||
|
||||
// Stub for non-WASM builds - provides defaults
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
struct WasmConfig {
|
||||
struct Collaboration {
|
||||
std::string server_url;
|
||||
double user_timeout_seconds = 30.0;
|
||||
double cursor_send_interval_seconds = 0.1;
|
||||
size_t max_change_size_bytes = 1024;
|
||||
} collaboration;
|
||||
|
||||
struct Autosave {
|
||||
int interval_seconds = 60;
|
||||
int max_recovery_slots = 5;
|
||||
} autosave;
|
||||
|
||||
struct Terminal {
|
||||
int max_history_items = 50;
|
||||
int max_output_lines = 1000;
|
||||
} terminal;
|
||||
|
||||
struct UI {
|
||||
float min_zoom = 0.25f;
|
||||
float max_zoom = 4.0f;
|
||||
int touch_gesture_threshold = 10;
|
||||
} ui;
|
||||
|
||||
struct Cache {
|
||||
std::string version = "v1";
|
||||
int max_rom_cache_size_mb = 100;
|
||||
} cache;
|
||||
|
||||
struct AI {
|
||||
bool enabled = true;
|
||||
std::string model = "gemini-2.5-flash";
|
||||
std::string endpoint;
|
||||
int max_response_length = 4096;
|
||||
} ai;
|
||||
|
||||
struct Deployment {
|
||||
std::string server_repo = "https://github.com/scawful/yaze-server";
|
||||
int default_port = 8765;
|
||||
std::string protocol_version = "2.0";
|
||||
} deployment;
|
||||
|
||||
struct ServerStatus {
|
||||
bool fetched = false;
|
||||
bool reachable = false;
|
||||
bool ai_enabled = false;
|
||||
bool ai_configured = false;
|
||||
std::string ai_provider;
|
||||
bool tls_detected = false;
|
||||
std::string persistence_type;
|
||||
int active_sessions = 0;
|
||||
int total_connections = 0;
|
||||
std::string server_version;
|
||||
std::string error_message;
|
||||
} server_status;
|
||||
|
||||
void LoadFromJavaScript() {}
|
||||
void FetchServerStatus() {}
|
||||
static WasmConfig& Get() {
|
||||
static WasmConfig instance;
|
||||
return instance;
|
||||
}
|
||||
bool IsLoaded() const { return true; }
|
||||
bool IsLoading() const { return false; }
|
||||
std::unique_lock<std::mutex> GetReadLock() const {
|
||||
return std::unique_lock<std::mutex>(config_mutex_);
|
||||
}
|
||||
|
||||
private:
|
||||
mutable std::mutex config_mutex_;
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_CONFIG_H_
|
||||
2555
src/app/platform/wasm/wasm_control_api.cc
Normal file
2555
src/app/platform/wasm/wasm_control_api.cc
Normal file
File diff suppressed because it is too large
Load Diff
587
src/app/platform/wasm/wasm_control_api.h
Normal file
587
src/app/platform/wasm/wasm_control_api.h
Normal file
@@ -0,0 +1,587 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_CONTROL_API_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_CONTROL_API_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
|
||||
namespace yaze {
|
||||
|
||||
// Forward declarations
|
||||
class Rom;
|
||||
|
||||
namespace editor {
|
||||
class EditorManager;
|
||||
class PanelManager;
|
||||
} // namespace editor
|
||||
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* @brief Unified WASM Control API for browser/agent access
|
||||
*
|
||||
* Provides programmatic control over the editor UI from JavaScript.
|
||||
* Exposed as window.yaze.control.* in the browser.
|
||||
*
|
||||
* API Surface:
|
||||
* - window.yaze.control.switchEditor("Overworld")
|
||||
* - window.yaze.control.openPanel("dungeon.room_selector")
|
||||
* - window.yaze.control.closePanel("dungeon.room_selector")
|
||||
* - window.yaze.control.togglePanel("dungeon.room_selector")
|
||||
* - window.yaze.control.triggerMenuAction("File.Save")
|
||||
* - window.yaze.control.setPanelLayout("dungeon_default")
|
||||
* - window.yaze.control.getVisiblePanels()
|
||||
* - window.yaze.control.getCurrentEditor()
|
||||
* - window.yaze.control.getAvailableEditors()
|
||||
* - window.yaze.control.getAvailablePanels()
|
||||
*/
|
||||
class WasmControlApi {
|
||||
public:
|
||||
/**
|
||||
* @brief Initialize the control API with editor manager reference
|
||||
* @param editor_manager Pointer to the main editor manager
|
||||
*/
|
||||
static void Initialize(editor::EditorManager* editor_manager);
|
||||
|
||||
/**
|
||||
* @brief Check if the control API is ready
|
||||
* @return true if initialized and ready for use
|
||||
*/
|
||||
static bool IsReady();
|
||||
|
||||
/**
|
||||
* @brief Setup JavaScript bindings for window.yaze.control
|
||||
*/
|
||||
static void SetupJavaScriptBindings();
|
||||
|
||||
// ============================================================================
|
||||
// Editor Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Switch to a specific editor by name
|
||||
* @param editor_name Name of editor ("Overworld", "Dungeon", "Graphics", etc.)
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string SwitchEditor(const std::string& editor_name);
|
||||
|
||||
/**
|
||||
* @brief Get the currently active editor name
|
||||
* @return JSON with editor name and type
|
||||
*/
|
||||
static std::string GetCurrentEditor();
|
||||
|
||||
/**
|
||||
* @brief Get list of available editors
|
||||
* @return JSON array of editor info objects
|
||||
*/
|
||||
static std::string GetAvailableEditors();
|
||||
|
||||
// ============================================================================
|
||||
// Panel Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Open/show a panel by ID
|
||||
* @param card_id Panel identifier (e.g., "dungeon.room_selector")
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string OpenPanel(const std::string& card_id);
|
||||
|
||||
/**
|
||||
* @brief Close/hide a panel by ID
|
||||
* @param card_id Panel identifier
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string ClosePanel(const std::string& card_id);
|
||||
|
||||
/**
|
||||
* @brief Toggle a panel's visibility
|
||||
* @param card_id Panel identifier
|
||||
* @return JSON result with new visibility state
|
||||
*/
|
||||
static std::string TogglePanel(const std::string& card_id);
|
||||
|
||||
/**
|
||||
* @brief Get list of currently visible panels
|
||||
* @return JSON array of visible panel IDs
|
||||
*/
|
||||
static std::string GetVisiblePanels();
|
||||
|
||||
/**
|
||||
* @brief Get all available panels for current session
|
||||
* @return JSON array of panel info objects
|
||||
*/
|
||||
static std::string GetAvailablePanels();
|
||||
|
||||
/**
|
||||
* @brief Get panels for a specific category
|
||||
* @param category Category name (e.g., "Dungeon", "Overworld")
|
||||
* @return JSON array of panel info objects
|
||||
*/
|
||||
static std::string GetPanelsInCategory(const std::string& category);
|
||||
|
||||
/**
|
||||
* @brief Show all panels in the current session
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string ShowAllPanels();
|
||||
|
||||
/**
|
||||
* @brief Hide all panels in the current session
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string HideAllPanels();
|
||||
|
||||
/**
|
||||
* @brief Show all panels in a specific category
|
||||
* @param category Category name
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string ShowAllPanelsInCategory(const std::string& category);
|
||||
|
||||
/**
|
||||
* @brief Hide all panels in a specific category
|
||||
* @param category Category name
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string HideAllPanelsInCategory(const std::string& category);
|
||||
|
||||
/**
|
||||
* @brief Show only one panel, hiding all others in its category
|
||||
* @param card_id Panel identifier
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string ShowOnlyPanel(const std::string& card_id);
|
||||
|
||||
// ============================================================================
|
||||
// Layout Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Apply a predefined panel layout
|
||||
* @param layout_name Layout preset name ("dungeon_default", "overworld_default", etc.)
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string SetPanelLayout(const std::string& layout_name);
|
||||
|
||||
/**
|
||||
* @brief Get list of available layout presets
|
||||
* @return JSON array of layout names
|
||||
*/
|
||||
static std::string GetAvailableLayouts();
|
||||
|
||||
/**
|
||||
* @brief Save current panel visibility as a custom layout
|
||||
* @param layout_name Name for the new layout
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string SaveCurrentLayout(const std::string& layout_name);
|
||||
|
||||
// ============================================================================
|
||||
// Menu/UI Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Trigger a menu action by path
|
||||
* @param action_path Menu path (e.g., "File.Save", "Edit.Undo", "View.ShowEmulator")
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string TriggerMenuAction(const std::string& action_path);
|
||||
|
||||
/**
|
||||
* @brief Get list of available menu actions
|
||||
* @return JSON array of action paths
|
||||
*/
|
||||
static std::string GetAvailableMenuActions();
|
||||
|
||||
/**
|
||||
* @brief Toggle the visibility of the menu bar
|
||||
* @return JSON result with new visibility state
|
||||
*/
|
||||
static std::string ToggleMenuBar();
|
||||
|
||||
// ============================================================================
|
||||
// Session Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Get current session information
|
||||
* @return JSON with session ID, ROM info, editor state
|
||||
*/
|
||||
static std::string GetSessionInfo();
|
||||
|
||||
/**
|
||||
* @brief Create a new editing session
|
||||
* @return JSON with new session info
|
||||
*/
|
||||
static std::string CreateSession();
|
||||
|
||||
/**
|
||||
* @brief Switch to a different session by index
|
||||
* @param session_index Session index to switch to
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string SwitchSession(int session_index);
|
||||
|
||||
// ============================================================================
|
||||
// ROM Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Get ROM status and basic info
|
||||
* @return JSON with ROM loaded state, filename, size
|
||||
*/
|
||||
static std::string GetRomStatus();
|
||||
|
||||
/**
|
||||
* @brief Read bytes from ROM
|
||||
* @param address ROM address
|
||||
* @param count Number of bytes to read
|
||||
* @return JSON with byte array
|
||||
*/
|
||||
static std::string ReadRomBytes(int address, int count);
|
||||
|
||||
/**
|
||||
* @brief Write bytes to ROM
|
||||
* @param address ROM address
|
||||
* @param bytes JSON array of bytes to write
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string WriteRomBytes(int address, const std::string& bytes_json);
|
||||
|
||||
/**
|
||||
* @brief Trigger ROM save
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string SaveRom();
|
||||
|
||||
// ============================================================================
|
||||
// Editor State APIs (for LLM agents and automation)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Get a comprehensive snapshot of the current editor state
|
||||
* @return JSON with editor_type, active_data, visible_cards, etc.
|
||||
*/
|
||||
static std::string GetEditorSnapshot();
|
||||
|
||||
/**
|
||||
* @brief Get current dungeon room information
|
||||
* @return JSON with room_id, room_count, active_rooms, etc.
|
||||
*/
|
||||
static std::string GetCurrentDungeonRoom();
|
||||
|
||||
/**
|
||||
* @brief Get current overworld map information
|
||||
* @return JSON with map_id, world, game_state, etc.
|
||||
*/
|
||||
static std::string GetCurrentOverworldMap();
|
||||
|
||||
/**
|
||||
* @brief Get the current selection in the active editor
|
||||
* @return JSON with selected items/entities
|
||||
*/
|
||||
static std::string GetEditorSelection();
|
||||
|
||||
// ============================================================================
|
||||
// Read-only Data APIs
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Get dungeon room tile data
|
||||
* @param room_id Room ID (0-295)
|
||||
* @return JSON with layer1, layer2 tile arrays
|
||||
*/
|
||||
static std::string GetRoomTileData(int room_id);
|
||||
|
||||
/**
|
||||
* @brief Get objects in a dungeon room
|
||||
* @param room_id Room ID (0-295)
|
||||
* @return JSON array of room objects
|
||||
*/
|
||||
static std::string GetRoomObjects(int room_id);
|
||||
|
||||
/**
|
||||
* @brief Get dungeon room properties
|
||||
* @param room_id Room ID (0-295)
|
||||
* @return JSON with music, palette, tileset, etc.
|
||||
*/
|
||||
static std::string GetRoomProperties(int room_id);
|
||||
|
||||
/**
|
||||
* @brief Get overworld map tile data
|
||||
* @param map_id Map ID (0-159)
|
||||
* @return JSON with tile array
|
||||
*/
|
||||
static std::string GetMapTileData(int map_id);
|
||||
|
||||
/**
|
||||
* @brief Get entities on an overworld map
|
||||
* @param map_id Map ID (0-159)
|
||||
* @return JSON with entrances, exits, items, sprites
|
||||
*/
|
||||
static std::string GetMapEntities(int map_id);
|
||||
|
||||
/**
|
||||
* @brief Get overworld map properties
|
||||
* @param map_id Map ID (0-159)
|
||||
* @return JSON with gfx_group, palette_group, area_size, etc.
|
||||
*/
|
||||
static std::string GetMapProperties(int map_id);
|
||||
|
||||
/**
|
||||
* @brief Get palette colors
|
||||
* @param group_name Palette group name
|
||||
* @param palette_id Palette ID within group
|
||||
* @return JSON with colors array
|
||||
*/
|
||||
static std::string GetPaletteData(const std::string& group_name, int palette_id);
|
||||
|
||||
/**
|
||||
* @brief Get list of available palette groups
|
||||
* @return JSON array of group names
|
||||
*/
|
||||
static std::string ListPaletteGroups();
|
||||
|
||||
/**
|
||||
* @brief Load a font from binary data
|
||||
* @param name Font name
|
||||
* @param data Binary font data
|
||||
* @param size Font size
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string LoadFont(const std::string& name, const std::string& data, float size);
|
||||
|
||||
// ============================================================================
|
||||
// GUI Automation APIs (for LLM agents)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Get the UI element tree for automation
|
||||
* @return JSON with UI elements, their bounds, and types
|
||||
*/
|
||||
static std::string GetUIElementTree();
|
||||
|
||||
/**
|
||||
* @brief Get bounds of a specific UI element by ID
|
||||
* @param element_id Element identifier
|
||||
* @return JSON with x, y, width, height, visible
|
||||
*/
|
||||
static std::string GetUIElementBounds(const std::string& element_id);
|
||||
|
||||
/**
|
||||
* @brief Set selection in active editor
|
||||
* @param ids_json JSON array of IDs to select
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string SetSelection(const std::string& ids_json);
|
||||
|
||||
// ============================================================================
|
||||
// Platform Info API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Get platform information for keyboard shortcuts and UI display
|
||||
* @return JSON with platform name, is_mac, ctrl_name, alt_name
|
||||
*
|
||||
* Example response:
|
||||
* {
|
||||
* "platform": "WebMac",
|
||||
* "is_mac": true,
|
||||
* "ctrl_display": "Cmd",
|
||||
* "alt_display": "Opt",
|
||||
* "shift_display": "Shift"
|
||||
* }
|
||||
*/
|
||||
static std::string GetPlatformInfo();
|
||||
|
||||
// ============================================================================
|
||||
// Agent API (for AI/LLM agent integration)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Check if agent system is ready
|
||||
* @return true if agent is initialized and available
|
||||
*/
|
||||
static bool AgentIsReady();
|
||||
|
||||
/**
|
||||
* @brief Send a message to the AI agent
|
||||
* @param message User message to send
|
||||
* @return JSON with response, status, proposals (if any)
|
||||
*/
|
||||
static std::string AgentSendMessage(const std::string& message);
|
||||
|
||||
/**
|
||||
* @brief Get chat history
|
||||
* @return JSON array of chat messages
|
||||
*/
|
||||
static std::string AgentGetChatHistory();
|
||||
|
||||
/**
|
||||
* @brief Get current agent configuration
|
||||
* @return JSON with provider, model, host, etc.
|
||||
*/
|
||||
static std::string AgentGetConfig();
|
||||
|
||||
/**
|
||||
* @brief Set agent configuration
|
||||
* @param config_json JSON configuration object
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string AgentSetConfig(const std::string& config_json);
|
||||
|
||||
/**
|
||||
* @brief Get available AI providers
|
||||
* @return JSON array of provider info
|
||||
*/
|
||||
static std::string AgentGetProviders();
|
||||
|
||||
/**
|
||||
* @brief Get list of pending/recent proposals
|
||||
* @return JSON array of proposal info
|
||||
*/
|
||||
static std::string AgentGetProposals();
|
||||
|
||||
/**
|
||||
* @brief Accept a proposal by ID
|
||||
* @param proposal_id Proposal ID to accept
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string AgentAcceptProposal(const std::string& proposal_id);
|
||||
|
||||
/**
|
||||
* @brief Reject a proposal by ID
|
||||
* @param proposal_id Proposal ID to reject
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string AgentRejectProposal(const std::string& proposal_id);
|
||||
|
||||
/**
|
||||
* @brief Get detailed proposal information
|
||||
* @param proposal_id Proposal ID
|
||||
* @return JSON with proposal details, diff, etc.
|
||||
*/
|
||||
static std::string AgentGetProposalDetails(const std::string& proposal_id);
|
||||
|
||||
/**
|
||||
* @brief Open the agent sidebar
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string AgentOpenSidebar();
|
||||
|
||||
/**
|
||||
* @brief Close the agent sidebar
|
||||
* @return JSON result with success/error
|
||||
*/
|
||||
static std::string AgentCloseSidebar();
|
||||
|
||||
private:
|
||||
static editor::EditorManager* editor_manager_;
|
||||
static bool initialized_;
|
||||
|
||||
// Helper to get card registry
|
||||
static editor::PanelManager* GetPanelRegistry();
|
||||
|
||||
// Helper to convert EditorType to string
|
||||
static std::string EditorTypeToString(int type);
|
||||
|
||||
// Helper to convert string to EditorType
|
||||
static int StringToEditorType(const std::string& name);
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#else // !__EMSCRIPTEN__
|
||||
|
||||
// Stub for non-WASM builds
|
||||
namespace yaze {
|
||||
namespace editor {
|
||||
class EditorManager;
|
||||
}
|
||||
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
class WasmControlApi {
|
||||
public:
|
||||
static void Initialize(editor::EditorManager*) {}
|
||||
static bool IsReady() { return false; }
|
||||
static void SetupJavaScriptBindings() {}
|
||||
static std::string SwitchEditor(const std::string&) { return "{}"; }
|
||||
static std::string GetCurrentEditor() { return "{}"; }
|
||||
static std::string GetAvailableEditors() { return "[]"; }
|
||||
static std::string OpenPanel(const std::string&) { return "{}"; }
|
||||
static std::string ClosePanel(const std::string&) { return "{}"; }
|
||||
static std::string TogglePanel(const std::string&) { return "{}"; }
|
||||
static std::string GetVisiblePanels() { return "[]"; }
|
||||
static std::string GetAvailablePanels() { return "[]"; }
|
||||
static std::string GetPanelsInCategory(const std::string&) { return "[]"; }
|
||||
static std::string ShowAllPanels() { return "{}"; }
|
||||
static std::string HideAllPanels() { return "{}"; }
|
||||
static std::string ShowAllPanelsInCategory(const std::string&) { return "{}"; }
|
||||
static std::string HideAllPanelsInCategory(const std::string&) { return "{}"; }
|
||||
static std::string ShowOnlyPanel(const std::string&) { return "{}"; }
|
||||
static std::string SetPanelLayout(const std::string&) { return "{}"; }
|
||||
static std::string GetAvailableLayouts() { return "[]"; }
|
||||
static std::string SaveCurrentLayout(const std::string&) { return "{}"; }
|
||||
static std::string TriggerMenuAction(const std::string&) { return "{}"; }
|
||||
static std::string GetAvailableMenuActions() { return "[]"; }
|
||||
static std::string ToggleMenuBar() { return "{}"; }
|
||||
static std::string GetSessionInfo() { return "{}"; }
|
||||
static std::string CreateSession() { return "{}"; }
|
||||
static std::string SwitchSession(int) { return "{}"; }
|
||||
static std::string GetRomStatus() { return "{}"; }
|
||||
static std::string ReadRomBytes(int, int) { return "{}"; }
|
||||
static std::string WriteRomBytes(int, const std::string&) { return "{}"; }
|
||||
static std::string SaveRom() { return "{}"; }
|
||||
// Editor State APIs
|
||||
static std::string GetEditorSnapshot() { return "{}"; }
|
||||
static std::string GetCurrentDungeonRoom() { return "{}"; }
|
||||
static std::string GetCurrentOverworldMap() { return "{}"; }
|
||||
static std::string GetEditorSelection() { return "{}"; }
|
||||
// Read-only Data APIs
|
||||
static std::string GetRoomTileData(int) { return "{}"; }
|
||||
static std::string GetRoomObjects(int) { return "[]"; }
|
||||
static std::string GetRoomProperties(int) { return "{}"; }
|
||||
static std::string GetMapTileData(int) { return "{}"; }
|
||||
static std::string GetMapEntities(int) { return "{}"; }
|
||||
static std::string GetMapProperties(int) { return "{}"; }
|
||||
static std::string GetPaletteData(const std::string&, int) { return "{}"; }
|
||||
static std::string ListPaletteGroups() { return "[]"; }
|
||||
// GUI Automation APIs
|
||||
static std::string GetUIElementTree() { return "{}"; }
|
||||
static std::string GetUIElementBounds(const std::string&) { return "{}"; }
|
||||
static std::string SetSelection(const std::string&) { return "{}"; }
|
||||
// Platform Info API
|
||||
static std::string GetPlatformInfo() { return "{}"; }
|
||||
// Agent API
|
||||
static bool AgentIsReady() { return false; }
|
||||
static std::string AgentSendMessage(const std::string&) { return "{}"; }
|
||||
static std::string AgentGetChatHistory() { return "[]"; }
|
||||
static std::string AgentGetConfig() { return "{}"; }
|
||||
static std::string AgentSetConfig(const std::string&) { return "{}"; }
|
||||
static std::string AgentGetProviders() { return "[]"; }
|
||||
static std::string AgentGetProposals() { return "[]"; }
|
||||
static std::string AgentAcceptProposal(const std::string&) { return "{}"; }
|
||||
static std::string AgentRejectProposal(const std::string&) { return "{}"; }
|
||||
static std::string AgentGetProposalDetails(const std::string&) { return "{}"; }
|
||||
static std::string AgentOpenSidebar() { return "{}"; }
|
||||
static std::string AgentCloseSidebar() { return "{}"; }
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_CONTROL_API_H_
|
||||
472
src/app/platform/wasm/wasm_drop_handler.cc
Normal file
472
src/app/platform/wasm/wasm_drop_handler.cc
Normal file
@@ -0,0 +1,472 @@
|
||||
// clang-format off
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_drop_handler.h"
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/html5.h>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <memory>
|
||||
|
||||
#include "absl/strings/str_format.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
// Static member initialization
|
||||
std::unique_ptr<WasmDropHandler> WasmDropHandler::instance_ = nullptr;
|
||||
|
||||
// JavaScript interop for drag and drop operations
|
||||
EM_JS(void, setupDropZone_impl, (const char* element_id), {
|
||||
var targetElement = document.body;
|
||||
if (element_id && UTF8ToString(element_id).length > 0) {
|
||||
var el = document.getElementById(UTF8ToString(element_id));
|
||||
if (el) {
|
||||
targetElement = el;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove existing event listeners if any
|
||||
if (window.yazeDropListeners) {
|
||||
window.yazeDropListeners.forEach(function(listener) {
|
||||
document.removeEventListener(listener.event, listener.handler);
|
||||
});
|
||||
}
|
||||
window.yazeDropListeners = [];
|
||||
|
||||
// Create drop zone overlay if it doesn't exist
|
||||
var overlay = document.getElementById('yaze-drop-overlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'yaze-drop-overlay';
|
||||
overlay.className = 'yaze-drop-overlay';
|
||||
overlay.innerHTML = '<div class="yaze-drop-content"><div class="yaze-drop-icon">📁</div><div class="yaze-drop-text">Drop file here</div><div class="yaze-drop-info">Supported: .sfc, .smc, .zip, .pal, .tpl</div></div>';
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
// Helper function to check if file is a ROM or supported asset
|
||||
function isSupportedFile(filename) {
|
||||
var ext = filename.toLowerCase().split('.').pop();
|
||||
return ext === 'sfc' || ext === 'smc' || ext === 'zip' ||
|
||||
ext === 'pal' || ext === 'tpl';
|
||||
}
|
||||
|
||||
// Helper function to check if dragged items contain files
|
||||
function containsFiles(e) {
|
||||
if (e.dataTransfer.types) {
|
||||
for (var i = 0; i < e.dataTransfer.types.length; i++) {
|
||||
if (e.dataTransfer.types[i] === "Files") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Drag enter handler
|
||||
function handleDragEnter(e) {
|
||||
if (containsFiles(e)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
Module._yazeHandleDragEnter();
|
||||
overlay.classList.add('yaze-drop-active');
|
||||
}
|
||||
}
|
||||
|
||||
// Drag over handler
|
||||
function handleDragOver(e) {
|
||||
if (containsFiles(e)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}
|
||||
|
||||
// Drag leave handler
|
||||
function handleDragLeave(e) {
|
||||
if (e.target === document || e.target === overlay) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
Module._yazeHandleDragLeave();
|
||||
overlay.classList.remove('yaze-drop-active');
|
||||
}
|
||||
}
|
||||
|
||||
// Drop handler
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
overlay.classList.remove('yaze-drop-active');
|
||||
|
||||
var files = e.dataTransfer.files;
|
||||
if (!files || files.length === 0) {
|
||||
var errPtr = allocateUTF8("No files dropped");
|
||||
Module._yazeHandleDropError(errPtr);
|
||||
_free(errPtr);
|
||||
return;
|
||||
}
|
||||
|
||||
var file = files[0]; // Only handle first file
|
||||
|
||||
if (!isSupportedFile(file.name)) {
|
||||
var errPtr = allocateUTF8("Invalid file type. Please drop a ROM (.sfc) or Palette (.pal, .tpl)");
|
||||
Module._yazeHandleDropError(errPtr);
|
||||
_free(errPtr);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state in overlay
|
||||
overlay.classList.add('yaze-drop-loading');
|
||||
overlay.querySelector('.yaze-drop-text').textContent = 'Loading file...';
|
||||
overlay.querySelector('.yaze-drop-info').textContent = file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)';
|
||||
|
||||
var reader = new FileReader();
|
||||
reader.onload = function() {
|
||||
var filename = file.name;
|
||||
var filenamePtr = allocateUTF8(filename);
|
||||
var data = new Uint8Array(reader.result);
|
||||
var dataPtr = Module._malloc(data.length);
|
||||
Module.HEAPU8.set(data, dataPtr);
|
||||
Module._yazeHandleDroppedFile(filenamePtr, dataPtr, data.length);
|
||||
Module._free(dataPtr);
|
||||
_free(filenamePtr);
|
||||
|
||||
// Hide loading state
|
||||
setTimeout(function() {
|
||||
overlay.classList.remove('yaze-drop-loading');
|
||||
overlay.querySelector('.yaze-drop-text').textContent = 'Drop file here';
|
||||
overlay.querySelector('.yaze-drop-info').textContent = 'Supported: .sfc, .smc, .zip, .pal, .tpl';
|
||||
}, 500);
|
||||
};
|
||||
|
||||
reader.onerror = function() {
|
||||
var errPtr = allocateUTF8("Failed to read file: " + file.name);
|
||||
Module._yazeHandleDropError(errPtr);
|
||||
_free(errPtr);
|
||||
overlay.classList.remove('yaze-drop-loading');
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
|
||||
// Register event listeners
|
||||
var dragEnterHandler = handleDragEnter;
|
||||
var dragOverHandler = handleDragOver;
|
||||
var dragLeaveHandler = handleDragLeave;
|
||||
var dropHandler = handleDrop;
|
||||
|
||||
document.addEventListener('dragenter', dragEnterHandler, false);
|
||||
document.addEventListener('dragover', dragOverHandler, false);
|
||||
document.addEventListener('dragleave', dragLeaveHandler, false);
|
||||
document.addEventListener('drop', dropHandler, false);
|
||||
|
||||
// Store listeners for cleanup
|
||||
window.yazeDropListeners = [
|
||||
{ event: 'dragenter', handler: dragEnterHandler },
|
||||
{ event: 'dragover', handler: dragOverHandler },
|
||||
{ event: 'dragleave', handler: dragLeaveHandler },
|
||||
{ event: 'drop', handler: dropHandler }
|
||||
];
|
||||
});
|
||||
|
||||
EM_JS(void, disableDropZone_impl, (), {
|
||||
if (window.yazeDropListeners) {
|
||||
window.yazeDropListeners.forEach(function(listener) {
|
||||
document.removeEventListener(listener.event, listener.handler);
|
||||
});
|
||||
window.yazeDropListeners = [];
|
||||
}
|
||||
var overlay = document.getElementById('yaze-drop-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('yaze-drop-active');
|
||||
overlay.classList.remove('yaze-drop-loading');
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(void, setOverlayVisible_impl, (bool visible), {
|
||||
var overlay = document.getElementById('yaze-drop-overlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = visible ? 'flex' : 'none';
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(void, setOverlayText_impl, (const char* text), {
|
||||
var overlay = document.getElementById('yaze-drop-overlay');
|
||||
if (overlay) {
|
||||
var textElement = overlay.querySelector('.yaze-drop-text');
|
||||
if (textElement) {
|
||||
textElement.textContent = UTF8ToString(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(bool, isDragDropSupported, (), {
|
||||
return (typeof FileReader !== 'undefined' && typeof DataTransfer !== 'undefined' && 'draggable' in document.createElement('div'));
|
||||
});
|
||||
|
||||
EM_JS(void, injectDropZoneStyles, (), {
|
||||
if (document.getElementById('yaze-drop-styles')) {
|
||||
return; // Already injected
|
||||
}
|
||||
|
||||
var style = document.createElement('style');
|
||||
style.id = 'yaze-drop-styles';
|
||||
style.textContent = `
|
||||
.yaze-drop-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
z-index: 10000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.yaze-drop-overlay.yaze-drop-active {
|
||||
display: flex;
|
||||
pointer-events: all;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.yaze-drop-overlay.yaze-drop-loading {
|
||||
display: flex;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.yaze-drop-content {
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
padding: 60px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 3px dashed rgba(255, 255, 255, 0.5);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.yaze-drop-overlay.yaze-drop-active .yaze-drop-content {
|
||||
border-color: #4CAF50;
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
animation: pulse-active 1s infinite;
|
||||
}
|
||||
|
||||
.yaze-drop-overlay.yaze-drop-loading .yaze-drop-content {
|
||||
border-color: #2196F3;
|
||||
background: rgba(33, 150, 243, 0.2);
|
||||
border-style: solid;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.yaze-drop-icon {
|
||||
font-size: 72px;
|
||||
margin-bottom: 20px;
|
||||
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.yaze-drop-text {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.yaze-drop-info {
|
||||
font-size: 16px;
|
||||
opacity: 0.8;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.02); opacity: 0.9; }
|
||||
}
|
||||
|
||||
@keyframes pulse-active {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
});
|
||||
|
||||
// WasmDropHandler implementation
|
||||
WasmDropHandler& WasmDropHandler::GetInstance() {
|
||||
if (!instance_) {
|
||||
instance_ = std::unique_ptr<WasmDropHandler>(new WasmDropHandler());
|
||||
}
|
||||
return *instance_;
|
||||
}
|
||||
|
||||
WasmDropHandler::WasmDropHandler() = default;
|
||||
WasmDropHandler::~WasmDropHandler() {
|
||||
if (initialized_ && enabled_) {
|
||||
disableDropZone_impl();
|
||||
}
|
||||
}
|
||||
|
||||
absl::Status WasmDropHandler::Initialize(const std::string& element_id,
|
||||
DropCallback on_drop,
|
||||
ErrorCallback on_error) {
|
||||
if (!IsSupported()) {
|
||||
return absl::FailedPreconditionError(
|
||||
"Drag and drop not supported in this browser");
|
||||
}
|
||||
|
||||
// Inject CSS styles
|
||||
injectDropZoneStyles();
|
||||
|
||||
// Set callbacks
|
||||
if (on_drop) {
|
||||
drop_callback_ = on_drop;
|
||||
}
|
||||
if (on_error) {
|
||||
error_callback_ = on_error;
|
||||
}
|
||||
|
||||
// Setup drop zone
|
||||
element_id_ = element_id;
|
||||
setupDropZone_impl(element_id.c_str());
|
||||
|
||||
initialized_ = true;
|
||||
enabled_ = true;
|
||||
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
void WasmDropHandler::SetDropCallback(DropCallback on_drop) {
|
||||
drop_callback_ = on_drop;
|
||||
}
|
||||
|
||||
void WasmDropHandler::SetErrorCallback(ErrorCallback on_error) {
|
||||
error_callback_ = on_error;
|
||||
}
|
||||
|
||||
void WasmDropHandler::SetEnabled(bool enabled) {
|
||||
if (enabled_ != enabled) {
|
||||
enabled_ = enabled;
|
||||
if (!enabled) {
|
||||
disableDropZone_impl();
|
||||
} else if (initialized_) {
|
||||
setupDropZone_impl(element_id_.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void WasmDropHandler::SetOverlayVisible(bool visible) {
|
||||
setOverlayVisible_impl(visible);
|
||||
}
|
||||
|
||||
void WasmDropHandler::SetOverlayText(const std::string& text) {
|
||||
setOverlayText_impl(text.c_str());
|
||||
}
|
||||
|
||||
bool WasmDropHandler::IsSupported() {
|
||||
return isDragDropSupported();
|
||||
}
|
||||
|
||||
bool WasmDropHandler::IsValidRomFile(const std::string& filename) {
|
||||
// Get file extension
|
||||
size_t dot_pos = filename.find_last_of('.');
|
||||
if (dot_pos == std::string::npos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string ext = filename.substr(dot_pos + 1);
|
||||
|
||||
// Convert to lowercase for comparison
|
||||
std::transform(ext.begin(), ext.end(), ext.begin(),
|
||||
[](unsigned char c) { return std::tolower(c); });
|
||||
|
||||
return ext == "sfc" || ext == "smc" || ext == "zip" ||
|
||||
ext == "pal" || ext == "tpl";
|
||||
}
|
||||
|
||||
void WasmDropHandler::HandleDroppedFile(const char* filename,
|
||||
const uint8_t* data, size_t size) {
|
||||
auto& instance = GetInstance();
|
||||
|
||||
// Validate file
|
||||
if (!IsValidRomFile(filename)) {
|
||||
HandleDropError("Invalid file format");
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the drop callback
|
||||
if (instance.drop_callback_) {
|
||||
std::vector<uint8_t> file_data(data, data + size);
|
||||
instance.drop_callback_(filename, file_data);
|
||||
} else {
|
||||
emscripten_log(EM_LOG_WARN, "No drop callback registered for file: %s",
|
||||
filename);
|
||||
}
|
||||
|
||||
// Reset drag counter
|
||||
instance.drag_counter_ = 0;
|
||||
}
|
||||
|
||||
void WasmDropHandler::HandleDropError(const char* error_message) {
|
||||
auto& instance = GetInstance();
|
||||
|
||||
if (instance.error_callback_) {
|
||||
instance.error_callback_(error_message);
|
||||
} else {
|
||||
emscripten_log(EM_LOG_ERROR, "Drop error: %s", error_message);
|
||||
}
|
||||
|
||||
// Reset drag counter
|
||||
instance.drag_counter_ = 0;
|
||||
}
|
||||
|
||||
void WasmDropHandler::HandleDragEnter() {
|
||||
auto& instance = GetInstance();
|
||||
instance.drag_counter_++;
|
||||
}
|
||||
|
||||
void WasmDropHandler::HandleDragLeave() {
|
||||
auto& instance = GetInstance();
|
||||
instance.drag_counter_--;
|
||||
|
||||
// Only truly left when counter reaches 0
|
||||
if (instance.drag_counter_ <= 0) {
|
||||
instance.drag_counter_ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
// C-style callbacks for JavaScript interop - must be extern "C" with EMSCRIPTEN_KEEPALIVE
|
||||
extern "C" {
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void yazeHandleDroppedFile(const char* filename, const uint8_t* data,
|
||||
size_t size) {
|
||||
yaze::platform::WasmDropHandler::HandleDroppedFile(filename, data, size);
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void yazeHandleDropError(const char* error_message) {
|
||||
yaze::platform::WasmDropHandler::HandleDropError(error_message);
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void yazeHandleDragEnter() {
|
||||
yaze::platform::WasmDropHandler::HandleDragEnter();
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void yazeHandleDragLeave() {
|
||||
yaze::platform::WasmDropHandler::HandleDragLeave();
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
// clang-format on
|
||||
169
src/app/platform/wasm/wasm_drop_handler.h
Normal file
169
src/app/platform/wasm/wasm_drop_handler.h
Normal file
@@ -0,0 +1,169 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_WASM_DROP_HANDLER_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_WASM_DROP_HANDLER_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/status/statusor.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* @class WasmDropHandler
|
||||
* @brief Handles drag and drop file operations in WASM/browser environment
|
||||
*
|
||||
* This class provides drag and drop functionality for ROM files in the browser,
|
||||
* allowing users to drag ROM files directly onto the web page to load them.
|
||||
* It supports .sfc, .smc, and .zip files containing ROMs.
|
||||
*/
|
||||
class WasmDropHandler {
|
||||
public:
|
||||
/**
|
||||
* @brief Callback type for when ROM data is received via drop
|
||||
* @param filename Name of the dropped file
|
||||
* @param data Binary data of the file
|
||||
*/
|
||||
using DropCallback = std::function<void(const std::string& filename,
|
||||
const std::vector<uint8_t>& data)>;
|
||||
|
||||
/**
|
||||
* @brief Callback type for error handling
|
||||
* @param error_message Description of the error
|
||||
*/
|
||||
using ErrorCallback = std::function<void(const std::string& error_message)>;
|
||||
|
||||
/**
|
||||
* @brief Get the singleton instance of WasmDropHandler
|
||||
* @return Reference to the singleton instance
|
||||
*/
|
||||
static WasmDropHandler& GetInstance();
|
||||
|
||||
/**
|
||||
* @brief Initialize the drop zone on a specific DOM element
|
||||
* @param element_id ID of the DOM element to use as drop zone (default: document body)
|
||||
* @param on_drop Callback to invoke when a valid file is dropped
|
||||
* @param on_error Optional callback for error handling
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
absl::Status Initialize(const std::string& element_id = "",
|
||||
DropCallback on_drop = nullptr,
|
||||
ErrorCallback on_error = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Register or update the drop callback
|
||||
* @param on_drop Callback to invoke when a file is dropped
|
||||
*/
|
||||
void SetDropCallback(DropCallback on_drop);
|
||||
|
||||
/**
|
||||
* @brief Register or update the error callback
|
||||
* @param on_error Callback to invoke on error
|
||||
*/
|
||||
void SetErrorCallback(ErrorCallback on_error);
|
||||
|
||||
/**
|
||||
* @brief Enable or disable the drop zone
|
||||
* @param enabled true to enable drop zone, false to disable
|
||||
*/
|
||||
void SetEnabled(bool enabled);
|
||||
|
||||
/**
|
||||
* @brief Check if drop zone is currently enabled
|
||||
* @return true if drop zone is active
|
||||
*/
|
||||
bool IsEnabled() const { return enabled_; }
|
||||
|
||||
/**
|
||||
* @brief Show or hide the drop zone overlay
|
||||
* @param visible true to show overlay, false to hide
|
||||
*/
|
||||
void SetOverlayVisible(bool visible);
|
||||
|
||||
/**
|
||||
* @brief Update the overlay text displayed when dragging
|
||||
* @param text Text to display in the overlay
|
||||
*/
|
||||
void SetOverlayText(const std::string& text);
|
||||
|
||||
/**
|
||||
* @brief Check if drag and drop is supported in the current browser
|
||||
* @return true if drag and drop is available
|
||||
*/
|
||||
static bool IsSupported();
|
||||
|
||||
/**
|
||||
* @brief Validate if a file is a supported ROM format
|
||||
* @param filename Name of the file to validate
|
||||
* @return true if file has valid ROM extension
|
||||
*/
|
||||
static bool IsValidRomFile(const std::string& filename);
|
||||
|
||||
/**
|
||||
* @brief Handle a dropped file (called from JavaScript)
|
||||
* @param filename The dropped filename
|
||||
* @param data Pointer to file data
|
||||
* @param size Size of file data
|
||||
*/
|
||||
static void HandleDroppedFile(const char* filename, const uint8_t* data,
|
||||
size_t size);
|
||||
|
||||
/**
|
||||
* @brief Handle drop error (called from JavaScript)
|
||||
* @param error_message Error description
|
||||
*/
|
||||
static void HandleDropError(const char* error_message);
|
||||
|
||||
/**
|
||||
* @brief Handle drag enter event (called from JavaScript)
|
||||
*/
|
||||
static void HandleDragEnter();
|
||||
|
||||
/**
|
||||
* @brief Handle drag leave event (called from JavaScript)
|
||||
*/
|
||||
static void HandleDragLeave();
|
||||
|
||||
public:
|
||||
~WasmDropHandler();
|
||||
|
||||
private:
|
||||
// Singleton pattern - private constructor
|
||||
WasmDropHandler();
|
||||
|
||||
// Delete copy constructor and assignment operator
|
||||
WasmDropHandler(const WasmDropHandler&) = delete;
|
||||
WasmDropHandler& operator=(const WasmDropHandler&) = delete;
|
||||
|
||||
// Instance data
|
||||
bool initialized_ = false;
|
||||
bool enabled_ = false;
|
||||
DropCallback drop_callback_;
|
||||
ErrorCallback error_callback_;
|
||||
std::string element_id_;
|
||||
int drag_counter_ = 0; // Track nested drag enter/leave events
|
||||
|
||||
// Static singleton instance
|
||||
static std::unique_ptr<WasmDropHandler> instance_;
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
// C-style callbacks for JavaScript interop
|
||||
extern "C" {
|
||||
void yazeHandleDroppedFile(const char* filename, const uint8_t* data,
|
||||
size_t size);
|
||||
void yazeHandleDropError(const char* error_message);
|
||||
void yazeHandleDragEnter();
|
||||
void yazeHandleDragLeave();
|
||||
}
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_WASM_DROP_HANDLER_H_
|
||||
133
src/app/platform/wasm/wasm_drop_integration_example.h
Normal file
133
src/app/platform/wasm/wasm_drop_integration_example.h
Normal file
@@ -0,0 +1,133 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_WASM_DROP_INTEGRATION_EXAMPLE_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_WASM_DROP_INTEGRATION_EXAMPLE_H_
|
||||
|
||||
/**
|
||||
* @file wasm_drop_integration_example.h
|
||||
* @brief Example integration of WasmDropHandler with EditorManager
|
||||
*
|
||||
* This file demonstrates how to integrate the drag & drop ROM loading
|
||||
* functionality into the main yaze application when building for WASM.
|
||||
*
|
||||
* INTEGRATION STEPS:
|
||||
*
|
||||
* 1. In your Controller or EditorManager initialization:
|
||||
* @code
|
||||
* #ifdef __EMSCRIPTEN__
|
||||
* #include "app/platform/wasm/wasm_drop_handler.h"
|
||||
*
|
||||
* absl::Status InitializeWasmFeatures() {
|
||||
* // Initialize drop zone with callbacks
|
||||
* auto& drop_handler = yaze::platform::WasmDropHandler::GetInstance();
|
||||
*
|
||||
* return drop_handler.Initialize(
|
||||
* "", // Use document body as drop zone
|
||||
* [this](const std::string& filename, const std::vector<uint8_t>& data) {
|
||||
* // Handle dropped ROM file
|
||||
* HandleDroppedRom(filename, data);
|
||||
* },
|
||||
* [](const std::string& error) {
|
||||
* // Handle drop errors
|
||||
* LOG_ERROR("Drop error: %s", error.c_str());
|
||||
* }
|
||||
* );
|
||||
* }
|
||||
* @endcode
|
||||
*
|
||||
* 2. Implement the ROM loading handler:
|
||||
* @code
|
||||
* void HandleDroppedRom(const std::string& filename,
|
||||
* const std::vector<uint8_t>& data) {
|
||||
* // Create a new ROM instance
|
||||
* auto rom = std::make_unique<Rom>();
|
||||
*
|
||||
* // Load from data instead of file
|
||||
* auto status = rom->LoadFromData(data);
|
||||
* if (!status.ok()) {
|
||||
* toast_manager_.Show("Failed to load ROM: " + status.ToString(),
|
||||
* ToastType::kError);
|
||||
* return;
|
||||
* }
|
||||
*
|
||||
* // Set the filename for display
|
||||
* rom->set_filename(filename);
|
||||
*
|
||||
* // Find or create a session
|
||||
* auto session_id = session_coordinator_->FindEmptySession();
|
||||
* if (session_id == -1) {
|
||||
* session_id = session_coordinator_->CreateNewSession();
|
||||
* }
|
||||
*
|
||||
* // Set the ROM in the session
|
||||
* session_coordinator_->SetSessionRom(session_id, std::move(rom));
|
||||
* session_coordinator_->SetCurrentSession(session_id);
|
||||
*
|
||||
* // Load editor assets
|
||||
* LoadAssets();
|
||||
*
|
||||
* // Update UI
|
||||
* ui_coordinator_->SetWelcomeScreenVisible(false);
|
||||
* ui_coordinator_->SetEditorSelectionVisible(true);
|
||||
*
|
||||
* toast_manager_.Show("ROM loaded via drag & drop: " + filename,
|
||||
* ToastType::kSuccess);
|
||||
* }
|
||||
* @endcode
|
||||
*
|
||||
* 3. Optional: Customize the drop zone appearance:
|
||||
* @code
|
||||
* drop_handler.SetOverlayText("Drop your A Link to the Past ROM here!");
|
||||
* @endcode
|
||||
*
|
||||
* 4. Optional: Enable/disable drop zone based on application state:
|
||||
* @code
|
||||
* // Disable during ROM operations
|
||||
* drop_handler.SetEnabled(false);
|
||||
* PerformRomOperation();
|
||||
* drop_handler.SetEnabled(true);
|
||||
* @endcode
|
||||
*
|
||||
* HTML INTEGRATION:
|
||||
*
|
||||
* Include the CSS in your HTML file:
|
||||
* @code{.html}
|
||||
* <link rel="stylesheet" href="drop_zone.css">
|
||||
* @endcode
|
||||
*
|
||||
* The JavaScript is automatically initialized when the Module is ready.
|
||||
* You can also manually initialize it:
|
||||
* @code{.html}
|
||||
* <script src="drop_zone.js"></script>
|
||||
* <script>
|
||||
* // Optional: Custom initialization after Module is ready
|
||||
* Module.onRuntimeInitialized = function() {
|
||||
* YazeDropZone.init({
|
||||
* config: {
|
||||
* maxFileSize: 8 * 1024 * 1024, // 8MB max
|
||||
* messages: {
|
||||
* dropHere: 'Drop SNES ROM here',
|
||||
* loading: 'Loading ROM...'
|
||||
* }
|
||||
* }
|
||||
* });
|
||||
* };
|
||||
* </script>
|
||||
* @endcode
|
||||
*
|
||||
* TESTING:
|
||||
*
|
||||
* To test the drag & drop functionality:
|
||||
* 1. Build with Emscripten: cmake --preset wasm-dbg && cmake --build build_wasm
|
||||
* 2. Serve the files: python3 -m http.server 8000 -d build_wasm
|
||||
* 3. Open browser: http://localhost:8000/yaze.html
|
||||
* 4. Drag a .sfc/.smc ROM file onto the page
|
||||
* 5. The overlay should appear and the ROM should load
|
||||
*
|
||||
* TROUBLESHOOTING:
|
||||
*
|
||||
* - If overlay doesn't appear: Check browser console for errors
|
||||
* - If ROM doesn't load: Verify Rom::LoadFromData() implementation
|
||||
* - If styles are missing: Ensure drop_zone.css is included
|
||||
* - For debugging: Check Module._yazeHandleDroppedFile in console
|
||||
*/
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_WASM_DROP_INTEGRATION_EXAMPLE_H_
|
||||
217
src/app/platform/wasm/wasm_error_handler.cc
Normal file
217
src/app/platform/wasm/wasm_error_handler.cc
Normal file
@@ -0,0 +1,217 @@
|
||||
// clang-format off
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_error_handler.h"
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/html5.h>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
// Static member initialization
|
||||
std::atomic<bool> WasmErrorHandler::initialized_{false};
|
||||
std::atomic<int> WasmErrorHandler::callback_counter_{0};
|
||||
|
||||
// Store confirmation callbacks with timestamps for timeout cleanup
|
||||
struct CallbackEntry {
|
||||
std::function<void(bool)> callback;
|
||||
double timestamp; // Time when callback was registered (ms since epoch)
|
||||
};
|
||||
static std::map<int, CallbackEntry> g_confirm_callbacks;
|
||||
static std::mutex g_callback_mutex;
|
||||
|
||||
// Callback timeout in milliseconds (5 minutes)
|
||||
constexpr double kCallbackTimeoutMs = 5.0 * 60.0 * 1000.0;
|
||||
|
||||
// Helper to get current time in milliseconds
|
||||
static double GetCurrentTimeMs() {
|
||||
return EM_ASM_DOUBLE({ return Date.now(); });
|
||||
}
|
||||
|
||||
// Cleanup stale callbacks that have exceeded the timeout
|
||||
static void CleanupStaleCallbacks() {
|
||||
std::lock_guard<std::mutex> lock(g_callback_mutex);
|
||||
const double now = GetCurrentTimeMs();
|
||||
|
||||
for (auto it = g_confirm_callbacks.begin(); it != g_confirm_callbacks.end();) {
|
||||
if (now - it->second.timestamp > kCallbackTimeoutMs) {
|
||||
it = g_confirm_callbacks.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JavaScript function to register cleanup handler for page unload
|
||||
EM_JS(void, js_register_cleanup_handler, (), {
|
||||
window.addEventListener('beforeunload', function() {
|
||||
// Signal C++ to cleanup stale callbacks
|
||||
if (Module._cleanupConfirmCallbacks) {
|
||||
Module._cleanupConfirmCallbacks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// C++ cleanup function called from JavaScript on page unload
|
||||
extern "C" EMSCRIPTEN_KEEPALIVE void cleanupConfirmCallbacks() {
|
||||
std::lock_guard<std::mutex> lock(g_callback_mutex);
|
||||
g_confirm_callbacks.clear();
|
||||
}
|
||||
|
||||
// JavaScript functions for browser UI interaction
|
||||
EM_JS(void, js_show_modal, (const char* title, const char* message, const char* type), {
|
||||
var titleStr = UTF8ToString(title);
|
||||
var messageStr = UTF8ToString(message);
|
||||
var typeStr = UTF8ToString(type);
|
||||
if (typeof window.showYazeModal === 'function') {
|
||||
window.showYazeModal(titleStr, messageStr, typeStr);
|
||||
} else {
|
||||
alert(titleStr + '\n\n' + messageStr);
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(void, js_show_toast, (const char* message, const char* type, int duration_ms), {
|
||||
var messageStr = UTF8ToString(message);
|
||||
var typeStr = UTF8ToString(type);
|
||||
if (typeof window.showYazeToast === 'function') {
|
||||
window.showYazeToast(messageStr, typeStr, duration_ms);
|
||||
} else {
|
||||
console.log('[' + typeStr + '] ' + messageStr);
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(void, js_show_progress, (const char* task, float progress), {
|
||||
var taskStr = UTF8ToString(task);
|
||||
if (typeof window.showYazeProgress === 'function') {
|
||||
window.showYazeProgress(taskStr, progress);
|
||||
} else {
|
||||
console.log('Progress: ' + taskStr + ' - ' + (progress * 100).toFixed(0) + '%');
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(void, js_hide_progress, (), {
|
||||
if (typeof window.hideYazeProgress === 'function') {
|
||||
window.hideYazeProgress();
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(void, js_show_confirm, (const char* message, int callback_id), {
|
||||
var messageStr = UTF8ToString(message);
|
||||
if (typeof window.showYazeConfirm === 'function') {
|
||||
window.showYazeConfirm(messageStr, function(result) {
|
||||
Module._handleConfirmCallback(callback_id, result ? 1 : 0);
|
||||
});
|
||||
} else {
|
||||
var result = confirm(messageStr);
|
||||
Module._handleConfirmCallback(callback_id, result ? 1 : 0);
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(void, js_inject_styles, (), {
|
||||
if (document.getElementById('yaze-error-handler-styles')) {
|
||||
return;
|
||||
}
|
||||
var link = document.createElement('link');
|
||||
link.id = 'yaze-error-handler-styles';
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'error_handler.css';
|
||||
link.onerror = function() {
|
||||
var style = document.createElement('style');
|
||||
style.textContent = '.yaze-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:10000}.yaze-modal-content{background:white;border-radius:8px;padding:24px;max-width:500px;box-shadow:0 4px 6px rgba(0,0,0,0.1)}.yaze-toast{position:fixed;bottom:20px;right:20px;padding:12px 20px;border-radius:4px;color:white;z-index:10001}.yaze-toast-info{background:#3498db}.yaze-toast-success{background:#2ecc71}.yaze-toast-warning{background:#f39c12}.yaze-toast-error{background:#e74c3c}';
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
|
||||
// C++ callback handler for confirmation dialogs
|
||||
extern "C" EMSCRIPTEN_KEEPALIVE void handleConfirmCallback(int callback_id, int result) {
|
||||
std::function<void(bool)> callback;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_callback_mutex);
|
||||
auto it = g_confirm_callbacks.find(callback_id);
|
||||
if (it != g_confirm_callbacks.end()) {
|
||||
callback = it->second.callback;
|
||||
g_confirm_callbacks.erase(it);
|
||||
}
|
||||
}
|
||||
if (callback) {
|
||||
callback(result != 0);
|
||||
}
|
||||
}
|
||||
|
||||
void WasmErrorHandler::Initialize() {
|
||||
// Use compare_exchange for thread-safe initialization
|
||||
bool expected = false;
|
||||
if (!initialized_.compare_exchange_strong(expected, true)) {
|
||||
return; // Already initialized by another thread
|
||||
}
|
||||
js_inject_styles();
|
||||
EM_ASM({
|
||||
Module._handleConfirmCallback = Module.cwrap('handleConfirmCallback', null, ['number', 'number']);
|
||||
Module._cleanupConfirmCallbacks = Module.cwrap('cleanupConfirmCallbacks', null, []);
|
||||
});
|
||||
js_register_cleanup_handler();
|
||||
}
|
||||
|
||||
void WasmErrorHandler::ShowError(const std::string& title, const std::string& message) {
|
||||
if (!initialized_.load()) Initialize();
|
||||
js_show_modal(title.c_str(), message.c_str(), "error");
|
||||
}
|
||||
|
||||
void WasmErrorHandler::ShowWarning(const std::string& title, const std::string& message) {
|
||||
if (!initialized_.load()) Initialize();
|
||||
js_show_modal(title.c_str(), message.c_str(), "warning");
|
||||
}
|
||||
|
||||
void WasmErrorHandler::ShowInfo(const std::string& title, const std::string& message) {
|
||||
if (!initialized_.load()) Initialize();
|
||||
js_show_modal(title.c_str(), message.c_str(), "info");
|
||||
}
|
||||
|
||||
void WasmErrorHandler::Toast(const std::string& message, ToastType type, int duration_ms) {
|
||||
if (!initialized_.load()) Initialize();
|
||||
const char* type_str = "info";
|
||||
switch (type) {
|
||||
case ToastType::kSuccess: type_str = "success"; break;
|
||||
case ToastType::kWarning: type_str = "warning"; break;
|
||||
case ToastType::kError: type_str = "error"; break;
|
||||
case ToastType::kInfo:
|
||||
default: type_str = "info"; break;
|
||||
}
|
||||
js_show_toast(message.c_str(), type_str, duration_ms);
|
||||
}
|
||||
|
||||
void WasmErrorHandler::ShowProgress(const std::string& task, float progress) {
|
||||
if (!initialized_.load()) Initialize();
|
||||
if (progress < 0.0f) progress = 0.0f;
|
||||
if (progress > 1.0f) progress = 1.0f;
|
||||
js_show_progress(task.c_str(), progress);
|
||||
}
|
||||
|
||||
void WasmErrorHandler::HideProgress() {
|
||||
if (!initialized_.load()) Initialize();
|
||||
js_hide_progress();
|
||||
}
|
||||
|
||||
void WasmErrorHandler::Confirm(const std::string& message, std::function<void(bool)> callback) {
|
||||
if (!initialized_.load()) Initialize();
|
||||
|
||||
// Cleanup any stale callbacks before adding new one
|
||||
CleanupStaleCallbacks();
|
||||
|
||||
int callback_id = callback_counter_.fetch_add(1) + 1;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_callback_mutex);
|
||||
g_confirm_callbacks[callback_id] = CallbackEntry{callback, GetCurrentTimeMs()};
|
||||
}
|
||||
js_show_confirm(message.c_str(), callback_id);
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
// clang-format on
|
||||
102
src/app/platform/wasm/wasm_error_handler.h
Normal file
102
src/app/platform/wasm/wasm_error_handler.h
Normal file
@@ -0,0 +1,102 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_ERROR_HANDLER_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_ERROR_HANDLER_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* @enum ToastType
|
||||
* @brief Type of toast notification
|
||||
*/
|
||||
enum class ToastType { kInfo, kSuccess, kWarning, kError };
|
||||
|
||||
/**
|
||||
* @class WasmErrorHandler
|
||||
* @brief Browser-based error handling and notification system for WASM builds
|
||||
*
|
||||
* This class provides user-friendly error display, toast notifications,
|
||||
* progress indicators, and confirmation dialogs using browser UI elements.
|
||||
* All methods are static and thread-safe.
|
||||
*/
|
||||
class WasmErrorHandler {
|
||||
public:
|
||||
/**
|
||||
* @brief Display an error dialog in the browser
|
||||
* @param title Dialog title
|
||||
* @param message Error message to display
|
||||
*/
|
||||
static void ShowError(const std::string& title, const std::string& message);
|
||||
|
||||
/**
|
||||
* @brief Display a warning dialog in the browser
|
||||
* @param title Dialog title
|
||||
* @param message Warning message to display
|
||||
*/
|
||||
static void ShowWarning(const std::string& title, const std::string& message);
|
||||
|
||||
/**
|
||||
* @brief Display an info dialog in the browser
|
||||
* @param title Dialog title
|
||||
* @param message Info message to display
|
||||
*/
|
||||
static void ShowInfo(const std::string& title, const std::string& message);
|
||||
|
||||
/**
|
||||
* @brief Show a non-blocking toast notification
|
||||
* @param message Message to display
|
||||
* @param type Toast type (affects styling)
|
||||
* @param duration_ms Duration in milliseconds (default 3000)
|
||||
*/
|
||||
static void Toast(const std::string& message,
|
||||
ToastType type = ToastType::kInfo, int duration_ms = 3000);
|
||||
|
||||
/**
|
||||
* @brief Show a progress indicator
|
||||
* @param task Task description
|
||||
* @param progress Progress value (0.0 to 1.0)
|
||||
*/
|
||||
static void ShowProgress(const std::string& task, float progress);
|
||||
|
||||
/**
|
||||
* @brief Hide the progress indicator
|
||||
*/
|
||||
static void HideProgress();
|
||||
|
||||
/**
|
||||
* @brief Show a confirmation dialog with callback
|
||||
* @param message Confirmation message
|
||||
* @param callback Function to call with user's choice (true = confirmed)
|
||||
*/
|
||||
static void Confirm(const std::string& message,
|
||||
std::function<void(bool)> callback);
|
||||
|
||||
/**
|
||||
* @brief Initialize error handler (called once on startup)
|
||||
* This injects the necessary CSS styles and prepares the DOM
|
||||
*/
|
||||
static void Initialize();
|
||||
|
||||
private:
|
||||
// Prevent instantiation
|
||||
WasmErrorHandler() = delete;
|
||||
~WasmErrorHandler() = delete;
|
||||
|
||||
// Track if handler is initialized (thread-safe)
|
||||
static std::atomic<bool> initialized_;
|
||||
|
||||
// Counter for generating unique callback IDs (thread-safe)
|
||||
static std::atomic<int> callback_counter_;
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_ERROR_HANDLER_H_
|
||||
227
src/app/platform/wasm/wasm_file_dialog.cc
Normal file
227
src/app/platform/wasm/wasm_file_dialog.cc
Normal file
@@ -0,0 +1,227 @@
|
||||
// clang-format off
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_file_dialog.h"
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/html5.h>
|
||||
#include <memory>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "absl/strings/str_format.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
// Static member initialization
|
||||
int WasmFileDialog::next_callback_id_ = 1;
|
||||
std::unordered_map<int, WasmFileDialog::PendingOperation> WasmFileDialog::pending_operations_;
|
||||
std::mutex WasmFileDialog::operations_mutex_;
|
||||
|
||||
// JavaScript interop for file operations
|
||||
EM_JS(void, openFileDialog_impl, (const char* accept, int callback_id, bool is_text), {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = UTF8ToString(accept);
|
||||
input.style.display = 'none';
|
||||
input.onchange = function(e) {
|
||||
var file = e.target.files[0];
|
||||
if (!file) {
|
||||
var errPtr = allocateUTF8("No file selected");
|
||||
Module._yazeHandleFileError(callback_id, errPtr);
|
||||
_free(errPtr);
|
||||
return;
|
||||
}
|
||||
var reader = new FileReader();
|
||||
reader.onload = function() {
|
||||
var filename = file.name;
|
||||
var filenamePtr = allocateUTF8(filename);
|
||||
if (is_text) {
|
||||
var contentPtr = allocateUTF8(reader.result);
|
||||
Module._yazeHandleTextFileLoaded(callback_id, filenamePtr, contentPtr);
|
||||
_free(contentPtr);
|
||||
} else {
|
||||
var data = new Uint8Array(reader.result);
|
||||
var dataPtr = Module._malloc(data.length);
|
||||
Module.HEAPU8.set(data, dataPtr);
|
||||
Module._yazeHandleFileLoaded(callback_id, filenamePtr, dataPtr, data.length);
|
||||
Module._free(dataPtr);
|
||||
}
|
||||
_free(filenamePtr);
|
||||
};
|
||||
reader.onerror = function() {
|
||||
var errPtr = allocateUTF8("Failed to read file");
|
||||
Module._yazeHandleFileError(callback_id, errPtr);
|
||||
_free(errPtr);
|
||||
};
|
||||
if (is_text) {
|
||||
reader.readAsText(file);
|
||||
} else {
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
};
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
setTimeout(function() { document.body.removeChild(input); }, 100);
|
||||
});
|
||||
|
||||
EM_JS(void, downloadFile_impl, (const char* filename, const uint8_t* data, size_t size, const char* mime_type), {
|
||||
var dataArray = HEAPU8.subarray(data, data + size);
|
||||
var blob = new Blob([dataArray], { type: UTF8ToString(mime_type) });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = UTF8ToString(filename);
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(function() {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
});
|
||||
|
||||
EM_JS(void, downloadTextFile_impl, (const char* filename, const char* content, const char* mime_type), {
|
||||
var blob = new Blob([UTF8ToString(content)], { type: UTF8ToString(mime_type) });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = UTF8ToString(filename);
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(function() {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
});
|
||||
|
||||
EM_JS(bool, isFileApiSupported, (), {
|
||||
return (typeof File !== 'undefined' && typeof FileReader !== 'undefined' && typeof Blob !== 'undefined' && typeof URL !== 'undefined' && typeof URL.createObjectURL !== 'undefined');
|
||||
});
|
||||
|
||||
// Implementation of public methods
|
||||
void WasmFileDialog::OpenFileDialog(const std::string& accept, FileLoadCallback on_load, ErrorCallback on_error) {
|
||||
PendingOperation op;
|
||||
op.binary_callback = on_load;
|
||||
op.error_callback = on_error;
|
||||
op.is_text = false;
|
||||
int callback_id = RegisterCallback(std::move(op));
|
||||
openFileDialog_impl(accept.c_str(), callback_id, false);
|
||||
}
|
||||
|
||||
void WasmFileDialog::OpenTextFileDialog(const std::string& accept,
|
||||
std::function<void(const std::string&, const std::string&)> on_load,
|
||||
ErrorCallback on_error) {
|
||||
PendingOperation op;
|
||||
op.text_callback = on_load;
|
||||
op.error_callback = on_error;
|
||||
op.is_text = true;
|
||||
int callback_id = RegisterCallback(std::move(op));
|
||||
openFileDialog_impl(accept.c_str(), callback_id, true);
|
||||
}
|
||||
|
||||
absl::Status WasmFileDialog::DownloadFile(const std::string& filename, const std::vector<uint8_t>& data) {
|
||||
if (!IsSupported()) {
|
||||
return absl::FailedPreconditionError("File API not supported in this browser");
|
||||
}
|
||||
if (data.empty()) {
|
||||
return absl::InvalidArgumentError("Cannot download empty file");
|
||||
}
|
||||
downloadFile_impl(filename.c_str(), data.data(), data.size(), "application/octet-stream");
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
absl::Status WasmFileDialog::DownloadTextFile(const std::string& filename, const std::string& content, const std::string& mime_type) {
|
||||
if (!IsSupported()) {
|
||||
return absl::FailedPreconditionError("File API not supported in this browser");
|
||||
}
|
||||
downloadTextFile_impl(filename.c_str(), content.c_str(), mime_type.c_str());
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
bool WasmFileDialog::IsSupported() {
|
||||
return isFileApiSupported();
|
||||
}
|
||||
|
||||
// Private methods
|
||||
int WasmFileDialog::RegisterCallback(PendingOperation operation) {
|
||||
std::lock_guard<std::mutex> lock(operations_mutex_);
|
||||
int id = next_callback_id_++;
|
||||
operation.id = id;
|
||||
pending_operations_[id] = std::move(operation);
|
||||
return id;
|
||||
}
|
||||
|
||||
std::unique_ptr<WasmFileDialog::PendingOperation> WasmFileDialog::GetPendingOperation(int callback_id) {
|
||||
std::lock_guard<std::mutex> lock(operations_mutex_);
|
||||
auto it = pending_operations_.find(callback_id);
|
||||
if (it == pending_operations_.end()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto op = std::make_unique<PendingOperation>(std::move(it->second));
|
||||
pending_operations_.erase(it);
|
||||
return op;
|
||||
}
|
||||
|
||||
void WasmFileDialog::HandleFileLoaded(int callback_id, const char* filename, const uint8_t* data, size_t size) {
|
||||
auto op = GetPendingOperation(callback_id);
|
||||
if (!op) {
|
||||
emscripten_log(EM_LOG_WARN, "Unknown callback ID: %d", callback_id);
|
||||
return;
|
||||
}
|
||||
if (op->binary_callback) {
|
||||
std::vector<uint8_t> file_data(data, data + size);
|
||||
op->binary_callback(filename, file_data);
|
||||
}
|
||||
}
|
||||
|
||||
void WasmFileDialog::HandleTextFileLoaded(int callback_id, const char* filename, const char* content) {
|
||||
auto op = GetPendingOperation(callback_id);
|
||||
if (!op) {
|
||||
emscripten_log(EM_LOG_WARN, "Unknown callback ID: %d", callback_id);
|
||||
return;
|
||||
}
|
||||
if (op->text_callback) {
|
||||
op->text_callback(filename, content);
|
||||
}
|
||||
}
|
||||
|
||||
void WasmFileDialog::HandleFileError(int callback_id, const char* error_message) {
|
||||
auto op = GetPendingOperation(callback_id);
|
||||
if (!op) {
|
||||
emscripten_log(EM_LOG_WARN, "Unknown callback ID: %d", callback_id);
|
||||
return;
|
||||
}
|
||||
if (op->error_callback) {
|
||||
op->error_callback(error_message);
|
||||
} else {
|
||||
emscripten_log(EM_LOG_ERROR, "File operation error: %s", error_message);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
// C-style callbacks for JavaScript interop - must be extern "C" with EMSCRIPTEN_KEEPALIVE
|
||||
extern "C" {
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void yazeHandleFileLoaded(int callback_id, const char* filename, const uint8_t* data, size_t size) {
|
||||
yaze::platform::WasmFileDialog::HandleFileLoaded(callback_id, filename, data, size);
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void yazeHandleTextFileLoaded(int callback_id, const char* filename, const char* content) {
|
||||
yaze::platform::WasmFileDialog::HandleTextFileLoaded(callback_id, filename, content);
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void yazeHandleFileError(int callback_id, const char* error_message) {
|
||||
yaze::platform::WasmFileDialog::HandleFileError(callback_id, error_message);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
// clang-format on
|
||||
164
src/app/platform/wasm/wasm_file_dialog.h
Normal file
164
src/app/platform/wasm/wasm_file_dialog.h
Normal file
@@ -0,0 +1,164 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_WASM_FILE_DIALOG_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_WASM_FILE_DIALOG_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/status/statusor.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* @class WasmFileDialog
|
||||
* @brief File dialog implementation for WASM/browser environment
|
||||
*
|
||||
* This class provides file input/output functionality in the browser
|
||||
* using HTML5 File API and Blob downloads.
|
||||
*/
|
||||
class WasmFileDialog {
|
||||
public:
|
||||
/**
|
||||
* @brief Callback type for file load operations
|
||||
* @param filename Name of the loaded file
|
||||
* @param data Binary data of the file
|
||||
*/
|
||||
using FileLoadCallback = std::function<void(
|
||||
const std::string& filename, const std::vector<uint8_t>& data)>;
|
||||
|
||||
/**
|
||||
* @brief Callback type for error handling
|
||||
* @param error_message Description of the error
|
||||
*/
|
||||
using ErrorCallback = std::function<void(const std::string& error_message)>;
|
||||
|
||||
/**
|
||||
* @brief Open a file selection dialog
|
||||
* @param accept File type filter (e.g., ".sfc,.smc" for ROM files)
|
||||
* @param on_load Callback to invoke when file is loaded
|
||||
* @param on_error Optional callback for error handling
|
||||
*/
|
||||
static void OpenFileDialog(const std::string& accept,
|
||||
FileLoadCallback on_load,
|
||||
ErrorCallback on_error = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Open a file selection dialog for text files
|
||||
* @param accept File type filter (e.g., ".json,.txt")
|
||||
* @param on_load Callback to invoke with file content as string
|
||||
* @param on_error Optional callback for error handling
|
||||
*/
|
||||
static void OpenTextFileDialog(const std::string& accept,
|
||||
std::function<void(const std::string& filename,
|
||||
const std::string& content)>
|
||||
on_load,
|
||||
ErrorCallback on_error = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Download a file to the user's downloads folder
|
||||
* @param filename Suggested filename for the download
|
||||
* @param data Binary data to download
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status DownloadFile(const std::string& filename,
|
||||
const std::vector<uint8_t>& data);
|
||||
|
||||
/**
|
||||
* @brief Download a text file to the user's downloads folder
|
||||
* @param filename Suggested filename for the download
|
||||
* @param content Text content to download
|
||||
* @param mime_type MIME type (default: "text/plain")
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status DownloadTextFile(
|
||||
const std::string& filename, const std::string& content,
|
||||
const std::string& mime_type = "text/plain");
|
||||
|
||||
/**
|
||||
* @brief Check if file dialogs are supported in the current environment
|
||||
* @return true if file operations are available
|
||||
*/
|
||||
static bool IsSupported();
|
||||
|
||||
/**
|
||||
* @brief Structure to hold pending file operation data
|
||||
*/
|
||||
struct PendingOperation {
|
||||
int id;
|
||||
FileLoadCallback binary_callback;
|
||||
std::function<void(const std::string&, const std::string&)> text_callback;
|
||||
ErrorCallback error_callback;
|
||||
bool is_text;
|
||||
};
|
||||
|
||||
private:
|
||||
// Callback management (thread-safe)
|
||||
static int next_callback_id_;
|
||||
static std::unordered_map<int, PendingOperation> pending_operations_;
|
||||
static std::mutex operations_mutex_;
|
||||
|
||||
/**
|
||||
* @brief Register a callback for async file operations
|
||||
* @param operation The pending operation to register
|
||||
* @return Unique callback ID
|
||||
*/
|
||||
static int RegisterCallback(PendingOperation operation);
|
||||
|
||||
/**
|
||||
* @brief Get and remove a pending operation
|
||||
* @param callback_id The callback ID to retrieve
|
||||
* @return The pending operation or nullptr if not found
|
||||
*/
|
||||
static std::unique_ptr<PendingOperation> GetPendingOperation(int callback_id);
|
||||
|
||||
public:
|
||||
// These must be public to be called from extern "C" functions
|
||||
/**
|
||||
* @brief Handle file load completion (called from JavaScript)
|
||||
* @param callback_id The callback ID
|
||||
* @param filename The loaded filename
|
||||
* @param data Pointer to file data
|
||||
* @param size Size of file data
|
||||
*/
|
||||
static void HandleFileLoaded(int callback_id, const char* filename,
|
||||
const uint8_t* data, size_t size);
|
||||
|
||||
/**
|
||||
* @brief Handle text file load completion (called from JavaScript)
|
||||
* @param callback_id The callback ID
|
||||
* @param filename The loaded filename
|
||||
* @param content The text content
|
||||
*/
|
||||
static void HandleTextFileLoaded(int callback_id, const char* filename,
|
||||
const char* content);
|
||||
|
||||
/**
|
||||
* @brief Handle file load error (called from JavaScript)
|
||||
* @param callback_id The callback ID
|
||||
* @param error_message Error description
|
||||
*/
|
||||
static void HandleFileError(int callback_id, const char* error_message);
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
// C-style callbacks for JavaScript interop
|
||||
extern "C" {
|
||||
void yazeHandleFileLoaded(int callback_id, const char* filename,
|
||||
const uint8_t* data, size_t size);
|
||||
void yazeHandleTextFileLoaded(int callback_id, const char* filename,
|
||||
const char* content);
|
||||
void yazeHandleFileError(int callback_id, const char* error_message);
|
||||
}
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_WASM_FILE_DIALOG_H_
|
||||
245
src/app/platform/wasm/wasm_loading_manager.cc
Normal file
245
src/app/platform/wasm/wasm_loading_manager.cc
Normal file
@@ -0,0 +1,245 @@
|
||||
// clang-format off
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_loading_manager.h"
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/html5.h>
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
// JavaScript interface functions
|
||||
// Note: These functions take uint32_t js_id, not the full 64-bit handle.
|
||||
// The JS layer only sees the low 32 bits which are unique IDs for UI elements.
|
||||
EM_JS(void, js_create_loading_indicator, (uint32_t id, const char* task_name), {
|
||||
if (typeof window.createLoadingIndicator === 'function') {
|
||||
window.createLoadingIndicator(id, UTF8ToString(task_name));
|
||||
} else {
|
||||
console.warn('createLoadingIndicator not defined. Include loading_indicator.js');
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(void, js_update_loading_progress, (uint32_t id, float progress, const char* message), {
|
||||
if (typeof window.updateLoadingProgress === 'function') {
|
||||
window.updateLoadingProgress(id, progress, UTF8ToString(message));
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(void, js_remove_loading_indicator, (uint32_t id), {
|
||||
if (typeof window.removeLoadingIndicator === 'function') {
|
||||
window.removeLoadingIndicator(id);
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(bool, js_check_loading_cancelled, (uint32_t id), {
|
||||
if (typeof window.isLoadingCancelled === 'function') {
|
||||
return window.isLoadingCancelled(id);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
EM_JS(void, js_show_cancel_button, (uint32_t id), {
|
||||
if (typeof window.showCancelButton === 'function') {
|
||||
window.showCancelButton(id, function() {});
|
||||
}
|
||||
});
|
||||
|
||||
// WasmLoadingManager implementation
|
||||
WasmLoadingManager& WasmLoadingManager::GetInstance() {
|
||||
static WasmLoadingManager instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
WasmLoadingManager::WasmLoadingManager() {}
|
||||
|
||||
WasmLoadingManager::~WasmLoadingManager() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
for (const auto& [handle, op] : operations_) {
|
||||
if (op && op->active) {
|
||||
js_remove_loading_indicator(GetJsId(handle));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WasmLoadingManager::LoadingHandle WasmLoadingManager::BeginLoading(const std::string& task_name) {
|
||||
auto& instance = GetInstance();
|
||||
|
||||
// Generate unique JS ID and generation counter atomically
|
||||
uint32_t js_id = instance.next_js_id_.fetch_add(1);
|
||||
uint32_t generation = instance.generation_counter_.fetch_add(1);
|
||||
|
||||
// Create the full 64-bit handle
|
||||
LoadingHandle handle = MakeHandle(js_id, generation);
|
||||
|
||||
auto operation = std::make_unique<LoadingOperation>();
|
||||
operation->task_name = task_name;
|
||||
operation->active = true;
|
||||
operation->generation = generation;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(instance.mutex_);
|
||||
instance.operations_[handle] = std::move(operation);
|
||||
}
|
||||
|
||||
// JS functions receive only the 32-bit ID
|
||||
js_create_loading_indicator(js_id, task_name.c_str());
|
||||
js_show_cancel_button(js_id);
|
||||
return handle;
|
||||
}
|
||||
|
||||
void WasmLoadingManager::UpdateProgress(LoadingHandle handle, float progress) {
|
||||
if (handle == kInvalidHandle) return;
|
||||
auto& instance = GetInstance();
|
||||
std::string message;
|
||||
uint32_t js_id = GetJsId(handle);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(instance.mutex_);
|
||||
auto it = instance.operations_.find(handle);
|
||||
if (it == instance.operations_.end() || !it->second->active) return;
|
||||
it->second->progress = progress;
|
||||
message = it->second->message;
|
||||
}
|
||||
|
||||
js_update_loading_progress(js_id, progress, message.c_str());
|
||||
}
|
||||
|
||||
void WasmLoadingManager::UpdateMessage(LoadingHandle handle, const std::string& message) {
|
||||
if (handle == kInvalidHandle) return;
|
||||
auto& instance = GetInstance();
|
||||
float progress = 0.0f;
|
||||
uint32_t js_id = GetJsId(handle);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(instance.mutex_);
|
||||
auto it = instance.operations_.find(handle);
|
||||
if (it == instance.operations_.end() || !it->second->active) return;
|
||||
it->second->message = message;
|
||||
progress = it->second->progress;
|
||||
}
|
||||
|
||||
js_update_loading_progress(js_id, progress, message.c_str());
|
||||
}
|
||||
|
||||
bool WasmLoadingManager::IsCancelled(LoadingHandle handle) {
|
||||
if (handle == kInvalidHandle) return false;
|
||||
auto& instance = GetInstance();
|
||||
uint32_t js_id = GetJsId(handle);
|
||||
|
||||
// Check JS cancellation state first (outside lock)
|
||||
bool js_cancelled = js_check_loading_cancelled(js_id);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(instance.mutex_);
|
||||
auto it = instance.operations_.find(handle);
|
||||
if (it == instance.operations_.end() || !it->second->active) {
|
||||
return true;
|
||||
}
|
||||
if (js_cancelled && !it->second->cancelled.load()) {
|
||||
it->second->cancelled.store(true);
|
||||
}
|
||||
return it->second->cancelled.load();
|
||||
}
|
||||
}
|
||||
|
||||
void WasmLoadingManager::EndLoading(LoadingHandle handle) {
|
||||
if (handle == kInvalidHandle) return;
|
||||
auto& instance = GetInstance();
|
||||
uint32_t js_id = GetJsId(handle);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(instance.mutex_);
|
||||
auto it = instance.operations_.find(handle);
|
||||
if (it != instance.operations_.end()) {
|
||||
// Mark inactive and erase immediately - no async delay.
|
||||
// The 64-bit handle with generation counter ensures that even if
|
||||
// a new operation starts immediately with the same js_id, it will
|
||||
// have a different generation and thus a different full handle.
|
||||
it->second->active = false;
|
||||
|
||||
// Clear arena handle if it matches this handle
|
||||
if (instance.arena_handle_ == handle) {
|
||||
instance.arena_handle_ = kInvalidHandle;
|
||||
}
|
||||
|
||||
// Erase synchronously - safe because generation counter prevents reuse
|
||||
instance.operations_.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the JS UI element after releasing the lock
|
||||
js_remove_loading_indicator(js_id);
|
||||
}
|
||||
|
||||
bool WasmLoadingManager::ReportArenaProgress(int current, int total, const std::string& item_name) {
|
||||
auto& instance = GetInstance();
|
||||
LoadingHandle handle;
|
||||
uint32_t js_id;
|
||||
float progress = 0.0f;
|
||||
bool should_update_progress = false;
|
||||
bool should_update_message = false;
|
||||
bool is_cancelled = false;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(instance.mutex_);
|
||||
handle = instance.arena_handle_;
|
||||
if (handle == kInvalidHandle) return true;
|
||||
|
||||
js_id = GetJsId(handle);
|
||||
|
||||
// Check if the operation still exists and is active
|
||||
auto it = instance.operations_.find(handle);
|
||||
if (it == instance.operations_.end() || !it->second->active) {
|
||||
// Handle is stale, clear it and return
|
||||
instance.arena_handle_ = kInvalidHandle;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Update progress if applicable
|
||||
if (total > 0) {
|
||||
progress = static_cast<float>(current) / static_cast<float>(total);
|
||||
it->second->progress = progress;
|
||||
should_update_progress = true;
|
||||
} else {
|
||||
progress = it->second->progress;
|
||||
}
|
||||
|
||||
// Update message if applicable
|
||||
if (!item_name.empty()) {
|
||||
it->second->message = item_name;
|
||||
should_update_message = true;
|
||||
}
|
||||
|
||||
// Check cancellation status
|
||||
is_cancelled = it->second->cancelled.load();
|
||||
}
|
||||
|
||||
// Perform JS calls outside the lock to avoid blocking
|
||||
if (should_update_progress || should_update_message) {
|
||||
js_update_loading_progress(js_id, progress,
|
||||
should_update_message ? item_name.c_str() : "");
|
||||
}
|
||||
|
||||
return !is_cancelled;
|
||||
}
|
||||
|
||||
void WasmLoadingManager::SetArenaHandle(LoadingHandle handle) {
|
||||
auto& instance = GetInstance();
|
||||
std::lock_guard<std::mutex> lock(instance.mutex_);
|
||||
instance.arena_handle_ = handle;
|
||||
}
|
||||
|
||||
void WasmLoadingManager::ClearArenaHandle() {
|
||||
auto& instance = GetInstance();
|
||||
std::lock_guard<std::mutex> lock(instance.mutex_);
|
||||
instance.arena_handle_ = kInvalidHandle;
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
// clang-format on
|
||||
229
src/app/platform/wasm/wasm_loading_manager.h
Normal file
229
src/app/platform/wasm/wasm_loading_manager.h
Normal file
@@ -0,0 +1,229 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_WASM_LOADING_MANAGER_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_WASM_LOADING_MANAGER_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/strings/str_format.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* @class WasmLoadingManager
|
||||
* @brief Manages loading operations with progress tracking for WASM builds
|
||||
*
|
||||
* This class provides a centralized loading manager for long-running operations
|
||||
* in the browser environment. It integrates with JavaScript UI to show progress
|
||||
* indicators with cancel capability.
|
||||
*
|
||||
* Handle Design:
|
||||
* The LoadingHandle is a 64-bit value composed of:
|
||||
* - High 32 bits: Generation counter (prevents handle reuse after EndLoading)
|
||||
* - Low 32 bits: Unique ID for JS interop
|
||||
*
|
||||
* This design eliminates race conditions where a new operation could reuse
|
||||
* the same ID as a recently-ended operation. Operations are deleted
|
||||
* synchronously in EndLoading() rather than using async callbacks.
|
||||
*
|
||||
* Example usage:
|
||||
* @code
|
||||
* auto handle = WasmLoadingManager::BeginLoading("Loading Graphics");
|
||||
* for (int i = 0; i < total; i++) {
|
||||
* if (WasmLoadingManager::IsCancelled(handle)) {
|
||||
* WasmLoadingManager::EndLoading(handle);
|
||||
* return absl::CancelledError("User cancelled loading");
|
||||
* }
|
||||
* // Do work...
|
||||
* WasmLoadingManager::UpdateProgress(handle, static_cast<float>(i) / total);
|
||||
* WasmLoadingManager::UpdateMessage(handle, absl::StrFormat("Sheet %d/%d", i, total));
|
||||
* }
|
||||
* WasmLoadingManager::EndLoading(handle);
|
||||
* @endcode
|
||||
*/
|
||||
class WasmLoadingManager {
|
||||
public:
|
||||
/**
|
||||
* @brief Handle for tracking a loading operation
|
||||
*
|
||||
* 64-bit handle with generation counter to prevent reuse race conditions:
|
||||
* - High 32 bits: Generation counter
|
||||
* - Low 32 bits: JS-visible ID
|
||||
*/
|
||||
using LoadingHandle = uint64_t;
|
||||
|
||||
/**
|
||||
* @brief Invalid handle value
|
||||
*/
|
||||
static constexpr LoadingHandle kInvalidHandle = 0;
|
||||
|
||||
/**
|
||||
* @brief Extract the JS-visible ID from a handle (low 32 bits)
|
||||
* @param handle The full 64-bit handle
|
||||
* @return The 32-bit JS ID
|
||||
*/
|
||||
static uint32_t GetJsId(LoadingHandle handle) {
|
||||
return static_cast<uint32_t>(handle & 0xFFFFFFFF);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Begin a new loading operation
|
||||
* @param task_name The name of the task to display in the UI
|
||||
* @return Handle to track this loading operation
|
||||
*/
|
||||
static LoadingHandle BeginLoading(const std::string& task_name);
|
||||
|
||||
/**
|
||||
* @brief Update the progress of a loading operation
|
||||
* @param handle The loading operation handle
|
||||
* @param progress Progress value between 0.0 and 1.0
|
||||
*/
|
||||
static void UpdateProgress(LoadingHandle handle, float progress);
|
||||
|
||||
/**
|
||||
* @brief Update the status message for a loading operation
|
||||
* @param handle The loading operation handle
|
||||
* @param message Status message to display
|
||||
*/
|
||||
static void UpdateMessage(LoadingHandle handle, const std::string& message);
|
||||
|
||||
/**
|
||||
* @brief Check if the user has requested cancellation
|
||||
* @param handle The loading operation handle
|
||||
* @return true if the operation should be cancelled
|
||||
*/
|
||||
static bool IsCancelled(LoadingHandle handle);
|
||||
|
||||
/**
|
||||
* @brief End a loading operation and remove UI
|
||||
* @param handle The loading operation handle
|
||||
*/
|
||||
static void EndLoading(LoadingHandle handle);
|
||||
|
||||
/**
|
||||
* @brief Integration point for gfx::Arena progressive loading
|
||||
*
|
||||
* This method can be called by gfx::Arena during texture loading
|
||||
* to report progress without modifying Arena's core logic.
|
||||
*
|
||||
* @param current Current item being processed
|
||||
* @param total Total items to process
|
||||
* @param item_name Name of the current item (e.g., "Graphics Sheet 42")
|
||||
* @return true if loading should continue, false if cancelled
|
||||
*/
|
||||
static bool ReportArenaProgress(int current, int total,
|
||||
const std::string& item_name);
|
||||
|
||||
/**
|
||||
* @brief Set the global loading handle for Arena operations
|
||||
*
|
||||
* This allows Arena to use a pre-existing loading operation
|
||||
* instead of creating its own.
|
||||
*
|
||||
* @param handle The loading operation handle to use for Arena progress
|
||||
*/
|
||||
static void SetArenaHandle(LoadingHandle handle);
|
||||
|
||||
/**
|
||||
* @brief Clear the global Arena loading handle
|
||||
*/
|
||||
static void ClearArenaHandle();
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief Internal structure to track loading operations
|
||||
*/
|
||||
struct LoadingOperation {
|
||||
std::string task_name;
|
||||
float progress = 0.0f;
|
||||
std::string message;
|
||||
std::atomic<bool> cancelled{false};
|
||||
bool active = true;
|
||||
uint32_t generation = 0; // Generation counter for validation
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Get the singleton instance
|
||||
*/
|
||||
static WasmLoadingManager& GetInstance();
|
||||
|
||||
/**
|
||||
* @brief Constructor (private for singleton)
|
||||
*/
|
||||
WasmLoadingManager();
|
||||
|
||||
/**
|
||||
* @brief Destructor
|
||||
*/
|
||||
~WasmLoadingManager();
|
||||
|
||||
// Disable copy and move
|
||||
WasmLoadingManager(const WasmLoadingManager&) = delete;
|
||||
WasmLoadingManager& operator=(const WasmLoadingManager&) = delete;
|
||||
WasmLoadingManager(WasmLoadingManager&&) = delete;
|
||||
WasmLoadingManager& operator=(WasmLoadingManager&&) = delete;
|
||||
|
||||
/**
|
||||
* @brief Create a handle from JS ID and generation
|
||||
*/
|
||||
static LoadingHandle MakeHandle(uint32_t js_id, uint32_t generation) {
|
||||
return (static_cast<uint64_t>(generation) << 32) | js_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Extract generation from handle (high 32 bits)
|
||||
*/
|
||||
static uint32_t GetGeneration(LoadingHandle handle) {
|
||||
return static_cast<uint32_t>(handle >> 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Next available JS ID (low 32 bits of handle)
|
||||
*
|
||||
* This counter only increments, never wraps. With 32 bits, even at
|
||||
* 1000 operations/second, it would take ~50 days to wrap.
|
||||
*/
|
||||
std::atomic<uint32_t> next_js_id_{1};
|
||||
|
||||
/**
|
||||
* @brief Generation counter to prevent handle reuse
|
||||
*
|
||||
* Incremented each time BeginLoading is called. Combined with js_id
|
||||
* to create unique 64-bit handles that cannot be accidentally reused.
|
||||
*/
|
||||
std::atomic<uint32_t> generation_counter_{1};
|
||||
|
||||
/**
|
||||
* @brief Active loading operations, keyed by full 64-bit handle
|
||||
*/
|
||||
std::unordered_map<LoadingHandle, std::unique_ptr<LoadingOperation>>
|
||||
operations_;
|
||||
|
||||
/**
|
||||
* @brief Mutex for thread safety
|
||||
*/
|
||||
std::mutex mutex_;
|
||||
|
||||
/**
|
||||
* @brief Global handle for Arena operations (protected by mutex_)
|
||||
*
|
||||
* NOTE: This is a non-static member protected by mutex_ to prevent
|
||||
* race conditions between ReportArenaProgress() and ClearArenaHandle().
|
||||
*/
|
||||
LoadingHandle arena_handle_ = kInvalidHandle;
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_WASM_LOADING_MANAGER_H_
|
||||
617
src/app/platform/wasm/wasm_message_queue.cc
Normal file
617
src/app/platform/wasm/wasm_message_queue.cc
Normal file
@@ -0,0 +1,617 @@
|
||||
// clang-format off
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_message_queue.h"
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
|
||||
#include "absl/strings/str_format.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
// JavaScript IndexedDB interface for message queue persistence
|
||||
// All functions use yazeAsyncQueue to serialize async operations
|
||||
EM_JS(int, mq_save_queue, (const char* key, const char* json_data), {
|
||||
return Asyncify.handleAsync(function() {
|
||||
var keyStr = UTF8ToString(key);
|
||||
var jsonStr = UTF8ToString(json_data);
|
||||
var operation = function() {
|
||||
return new Promise(function(resolve) {
|
||||
try {
|
||||
// Open or create the database
|
||||
var request = indexedDB.open('YazeMessageQueue', 1);
|
||||
|
||||
request.onerror = function() {
|
||||
console.error('Failed to open message queue database:', request.error);
|
||||
resolve(-1);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = function(event) {
|
||||
var db = event.target.result;
|
||||
if (!db.objectStoreNames.contains('queues')) {
|
||||
db.createObjectStore('queues');
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = function() {
|
||||
var db = request.result;
|
||||
var transaction = db.transaction(['queues'], 'readwrite');
|
||||
var store = transaction.objectStore('queues');
|
||||
var putRequest = store.put(jsonStr, keyStr);
|
||||
|
||||
putRequest.onsuccess = function() {
|
||||
db.close();
|
||||
resolve(0);
|
||||
};
|
||||
|
||||
putRequest.onerror = function() {
|
||||
console.error('Failed to save message queue:', putRequest.error);
|
||||
db.close();
|
||||
resolve(-1);
|
||||
};
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Exception in mq_save_queue:', e);
|
||||
resolve(-1);
|
||||
}
|
||||
});
|
||||
};
|
||||
if (window.yazeAsyncQueue) {
|
||||
return window.yazeAsyncQueue.enqueue(operation);
|
||||
}
|
||||
return operation();
|
||||
});
|
||||
});
|
||||
|
||||
EM_JS(char*, mq_load_queue, (const char* key), {
|
||||
return Asyncify.handleAsync(function() {
|
||||
var keyStr = UTF8ToString(key);
|
||||
var operation = function() {
|
||||
return new Promise(function(resolve) {
|
||||
try {
|
||||
var request = indexedDB.open('YazeMessageQueue', 1);
|
||||
|
||||
request.onerror = function() {
|
||||
console.error('Failed to open message queue database:', request.error);
|
||||
resolve(0);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = function(event) {
|
||||
var db = event.target.result;
|
||||
if (!db.objectStoreNames.contains('queues')) {
|
||||
db.createObjectStore('queues');
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = function() {
|
||||
var db = request.result;
|
||||
var transaction = db.transaction(['queues'], 'readonly');
|
||||
var store = transaction.objectStore('queues');
|
||||
var getRequest = store.get(keyStr);
|
||||
|
||||
getRequest.onsuccess = function() {
|
||||
var result = getRequest.result;
|
||||
db.close();
|
||||
|
||||
if (result && typeof result === 'string') {
|
||||
var len = lengthBytesUTF8(result) + 1;
|
||||
var ptr = Module._malloc(len);
|
||||
stringToUTF8(result, ptr, len);
|
||||
resolve(ptr);
|
||||
} else {
|
||||
resolve(0);
|
||||
}
|
||||
};
|
||||
|
||||
getRequest.onerror = function() {
|
||||
console.error('Failed to load message queue:', getRequest.error);
|
||||
db.close();
|
||||
resolve(0);
|
||||
};
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Exception in mq_load_queue:', e);
|
||||
resolve(0);
|
||||
}
|
||||
});
|
||||
};
|
||||
if (window.yazeAsyncQueue) {
|
||||
return window.yazeAsyncQueue.enqueue(operation);
|
||||
}
|
||||
return operation();
|
||||
});
|
||||
});
|
||||
|
||||
EM_JS(int, mq_clear_queue, (const char* key), {
|
||||
return Asyncify.handleAsync(function() {
|
||||
var keyStr = UTF8ToString(key);
|
||||
var operation = function() {
|
||||
return new Promise(function(resolve) {
|
||||
try {
|
||||
var request = indexedDB.open('YazeMessageQueue', 1);
|
||||
|
||||
request.onerror = function() {
|
||||
console.error('Failed to open message queue database:', request.error);
|
||||
resolve(-1);
|
||||
};
|
||||
|
||||
request.onsuccess = function() {
|
||||
var db = request.result;
|
||||
var transaction = db.transaction(['queues'], 'readwrite');
|
||||
var store = transaction.objectStore('queues');
|
||||
var deleteRequest = store.delete(keyStr);
|
||||
|
||||
deleteRequest.onsuccess = function() {
|
||||
db.close();
|
||||
resolve(0);
|
||||
};
|
||||
|
||||
deleteRequest.onerror = function() {
|
||||
console.error('Failed to clear message queue:', deleteRequest.error);
|
||||
db.close();
|
||||
resolve(-1);
|
||||
};
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Exception in mq_clear_queue:', e);
|
||||
resolve(-1);
|
||||
}
|
||||
});
|
||||
};
|
||||
if (window.yazeAsyncQueue) {
|
||||
return window.yazeAsyncQueue.enqueue(operation);
|
||||
}
|
||||
return operation();
|
||||
});
|
||||
});
|
||||
|
||||
// Get current time in seconds since epoch
|
||||
static double GetCurrentTime() {
|
||||
return emscripten_get_now() / 1000.0;
|
||||
}
|
||||
|
||||
WasmMessageQueue::WasmMessageQueue() {
|
||||
// Attempt to load queue from storage on construction
|
||||
auto status = LoadFromStorage();
|
||||
if (!status.ok()) {
|
||||
emscripten_log(EM_LOG_WARN, "Failed to load message queue from storage: %s",
|
||||
status.ToString().c_str());
|
||||
}
|
||||
}
|
||||
|
||||
WasmMessageQueue::~WasmMessageQueue() {
|
||||
// Persist queue on destruction if auto-persist is enabled
|
||||
if (auto_persist_ && !queue_.empty()) {
|
||||
auto status = PersistToStorage();
|
||||
if (!status.ok()) {
|
||||
emscripten_log(EM_LOG_ERROR, "Failed to persist message queue on destruction: %s",
|
||||
status.ToString().c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string WasmMessageQueue::Enqueue(const std::string& message_type,
|
||||
const std::string& payload) {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
|
||||
// Check queue size limit
|
||||
if (queue_.size() >= max_queue_size_) {
|
||||
// Remove oldest message if at capacity
|
||||
queue_.pop_front();
|
||||
}
|
||||
|
||||
// Create new message
|
||||
QueuedMessage msg;
|
||||
msg.message_type = message_type;
|
||||
msg.payload = payload;
|
||||
msg.timestamp = GetCurrentTime();
|
||||
msg.retry_count = 0;
|
||||
msg.id = GenerateMessageId();
|
||||
|
||||
// Add to queue
|
||||
queue_.push_back(msg);
|
||||
total_enqueued_++;
|
||||
|
||||
// Notify listeners
|
||||
NotifyStatusChange();
|
||||
|
||||
// Maybe persist to storage
|
||||
MaybePersist();
|
||||
|
||||
return msg.id;
|
||||
}
|
||||
|
||||
void WasmMessageQueue::ReplayAll(MessageSender sender, int max_retries) {
|
||||
if (is_replaying_) {
|
||||
emscripten_log(EM_LOG_WARN, "Already replaying messages, skipping replay request");
|
||||
return;
|
||||
}
|
||||
|
||||
is_replaying_ = true;
|
||||
int replayed = 0;
|
||||
int failed = 0;
|
||||
|
||||
// Copy queue to avoid holding lock during send operations
|
||||
std::vector<QueuedMessage> messages_to_send;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
messages_to_send.reserve(queue_.size());
|
||||
for (const auto& msg : queue_) {
|
||||
messages_to_send.push_back(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Process each message
|
||||
std::vector<std::string> successful_ids;
|
||||
std::vector<QueuedMessage> failed_messages;
|
||||
|
||||
for (auto& msg : messages_to_send) {
|
||||
// Check if message has expired
|
||||
double age = GetCurrentTime() - msg.timestamp;
|
||||
if (age > message_expiry_seconds_) {
|
||||
continue; // Skip expired messages
|
||||
}
|
||||
|
||||
// Try to send the message
|
||||
auto status = sender(msg.message_type, msg.payload);
|
||||
|
||||
if (status.ok()) {
|
||||
successful_ids.push_back(msg.id);
|
||||
replayed++;
|
||||
total_replayed_++;
|
||||
} else {
|
||||
msg.retry_count++;
|
||||
|
||||
if (msg.retry_count >= max_retries) {
|
||||
// Move to failed list
|
||||
failed_messages.push_back(msg);
|
||||
failed++;
|
||||
total_failed_++;
|
||||
} else {
|
||||
// Keep in queue for retry
|
||||
// Message stays in queue
|
||||
}
|
||||
|
||||
emscripten_log(EM_LOG_WARN, "Failed to replay message %s (attempt %d): %s",
|
||||
msg.id.c_str(), msg.retry_count, status.ToString().c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// Update queue with results
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
|
||||
// Remove successful messages
|
||||
for (const auto& id : successful_ids) {
|
||||
queue_.erase(
|
||||
std::remove_if(queue_.begin(), queue_.end(),
|
||||
[&id](const QueuedMessage& m) { return m.id == id; }),
|
||||
queue_.end());
|
||||
}
|
||||
|
||||
// Move failed messages to failed list
|
||||
for (const auto& msg : failed_messages) {
|
||||
failed_messages_.push_back(msg);
|
||||
queue_.erase(
|
||||
std::remove_if(queue_.begin(), queue_.end(),
|
||||
[&msg](const QueuedMessage& m) { return m.id == msg.id; }),
|
||||
queue_.end());
|
||||
}
|
||||
}
|
||||
|
||||
is_replaying_ = false;
|
||||
|
||||
// Notify completion
|
||||
if (replay_complete_callback_) {
|
||||
replay_complete_callback_(replayed, failed);
|
||||
}
|
||||
|
||||
// Update status
|
||||
NotifyStatusChange();
|
||||
|
||||
// Persist changes
|
||||
MaybePersist();
|
||||
}
|
||||
|
||||
size_t WasmMessageQueue::PendingCount() const {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
return queue_.size();
|
||||
}
|
||||
|
||||
WasmMessageQueue::QueueStatus WasmMessageQueue::GetStatus() const {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
|
||||
QueueStatus status;
|
||||
status.pending_count = queue_.size();
|
||||
status.failed_count = failed_messages_.size();
|
||||
status.total_bytes = CalculateTotalBytes();
|
||||
|
||||
if (!queue_.empty()) {
|
||||
double now = GetCurrentTime();
|
||||
status.oldest_message_age = now - queue_.front().timestamp;
|
||||
}
|
||||
|
||||
// Check if queue is persisted (simplified check)
|
||||
status.is_persisted = auto_persist_;
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
void WasmMessageQueue::Clear() {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
queue_.clear();
|
||||
failed_messages_.clear();
|
||||
|
||||
// Clear from storage as well
|
||||
mq_clear_queue(kStorageKey);
|
||||
|
||||
NotifyStatusChange();
|
||||
}
|
||||
|
||||
void WasmMessageQueue::ClearFailed() {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
failed_messages_.clear();
|
||||
|
||||
NotifyStatusChange();
|
||||
MaybePersist();
|
||||
}
|
||||
|
||||
bool WasmMessageQueue::RemoveMessage(const std::string& message_id) {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
|
||||
// Try to remove from main queue
|
||||
auto it = std::find_if(queue_.begin(), queue_.end(),
|
||||
[&message_id](const QueuedMessage& m) {
|
||||
return m.id == message_id;
|
||||
});
|
||||
|
||||
if (it != queue_.end()) {
|
||||
queue_.erase(it);
|
||||
NotifyStatusChange();
|
||||
MaybePersist();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try to remove from failed messages
|
||||
auto failed_it = std::find_if(failed_messages_.begin(), failed_messages_.end(),
|
||||
[&message_id](const QueuedMessage& m) {
|
||||
return m.id == message_id;
|
||||
});
|
||||
|
||||
if (failed_it != failed_messages_.end()) {
|
||||
failed_messages_.erase(failed_it);
|
||||
NotifyStatusChange();
|
||||
MaybePersist();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
absl::Status WasmMessageQueue::PersistToStorage() {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
|
||||
try {
|
||||
// Create JSON representation
|
||||
nlohmann::json json_data;
|
||||
json_data["version"] = 1;
|
||||
json_data["timestamp"] = GetCurrentTime();
|
||||
|
||||
// Serialize main queue
|
||||
nlohmann::json queue_array = nlohmann::json::array();
|
||||
for (const auto& msg : queue_) {
|
||||
nlohmann::json msg_json;
|
||||
msg_json["id"] = msg.id;
|
||||
msg_json["type"] = msg.message_type;
|
||||
msg_json["payload"] = msg.payload;
|
||||
msg_json["timestamp"] = msg.timestamp;
|
||||
msg_json["retry_count"] = msg.retry_count;
|
||||
queue_array.push_back(msg_json);
|
||||
}
|
||||
json_data["queue"] = queue_array;
|
||||
|
||||
// Serialize failed messages
|
||||
nlohmann::json failed_array = nlohmann::json::array();
|
||||
for (const auto& msg : failed_messages_) {
|
||||
nlohmann::json msg_json;
|
||||
msg_json["id"] = msg.id;
|
||||
msg_json["type"] = msg.message_type;
|
||||
msg_json["payload"] = msg.payload;
|
||||
msg_json["timestamp"] = msg.timestamp;
|
||||
msg_json["retry_count"] = msg.retry_count;
|
||||
failed_array.push_back(msg_json);
|
||||
}
|
||||
json_data["failed"] = failed_array;
|
||||
|
||||
// Save statistics
|
||||
json_data["stats"]["total_enqueued"] = total_enqueued_;
|
||||
json_data["stats"]["total_replayed"] = total_replayed_;
|
||||
json_data["stats"]["total_failed"] = total_failed_;
|
||||
|
||||
// Convert to string and save
|
||||
std::string json_str = json_data.dump();
|
||||
int result = mq_save_queue(kStorageKey, json_str.c_str());
|
||||
|
||||
if (result != 0) {
|
||||
return absl::InternalError("Failed to save message queue to IndexedDB");
|
||||
}
|
||||
|
||||
return absl::OkStatus();
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
return absl::InternalError(absl::StrFormat("Failed to serialize message queue: %s", e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
absl::Status WasmMessageQueue::LoadFromStorage() {
|
||||
char* json_ptr = mq_load_queue(kStorageKey);
|
||||
if (!json_ptr) {
|
||||
// No saved queue, which is fine
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
try {
|
||||
std::string json_str(json_ptr);
|
||||
free(json_ptr);
|
||||
|
||||
nlohmann::json json_data = nlohmann::json::parse(json_str);
|
||||
|
||||
// Check version compatibility
|
||||
int version = json_data.value("version", 0);
|
||||
if (version != 1) {
|
||||
return absl::InvalidArgumentError(absl::StrFormat("Unsupported queue version: %d", version));
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
|
||||
// Clear current state
|
||||
queue_.clear();
|
||||
failed_messages_.clear();
|
||||
|
||||
// Load main queue
|
||||
if (json_data.contains("queue")) {
|
||||
for (const auto& msg_json : json_data["queue"]) {
|
||||
QueuedMessage msg;
|
||||
msg.id = msg_json.value("id", "");
|
||||
msg.message_type = msg_json.value("type", "");
|
||||
msg.payload = msg_json.value("payload", "");
|
||||
msg.timestamp = msg_json.value("timestamp", 0.0);
|
||||
msg.retry_count = msg_json.value("retry_count", 0);
|
||||
|
||||
// Skip expired messages
|
||||
double age = GetCurrentTime() - msg.timestamp;
|
||||
if (age <= message_expiry_seconds_) {
|
||||
queue_.push_back(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load failed messages
|
||||
if (json_data.contains("failed")) {
|
||||
for (const auto& msg_json : json_data["failed"]) {
|
||||
QueuedMessage msg;
|
||||
msg.id = msg_json.value("id", "");
|
||||
msg.message_type = msg_json.value("type", "");
|
||||
msg.payload = msg_json.value("payload", "");
|
||||
msg.timestamp = msg_json.value("timestamp", 0.0);
|
||||
msg.retry_count = msg_json.value("retry_count", 0);
|
||||
|
||||
// Keep failed messages for review even if expired
|
||||
failed_messages_.push_back(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Load statistics
|
||||
if (json_data.contains("stats")) {
|
||||
total_enqueued_ = json_data["stats"].value("total_enqueued", 0);
|
||||
total_replayed_ = json_data["stats"].value("total_replayed", 0);
|
||||
total_failed_ = json_data["stats"].value("total_failed", 0);
|
||||
}
|
||||
|
||||
emscripten_log(EM_LOG_INFO, "Loaded %zu messages from storage (%zu failed)",
|
||||
queue_.size(), failed_messages_.size());
|
||||
|
||||
NotifyStatusChange();
|
||||
return absl::OkStatus();
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
free(json_ptr);
|
||||
return absl::InvalidArgumentError(absl::StrFormat("Failed to parse saved queue: %s", e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<WasmMessageQueue::QueuedMessage> WasmMessageQueue::GetQueuedMessages() const {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
return std::vector<QueuedMessage>(queue_.begin(), queue_.end());
|
||||
}
|
||||
|
||||
int WasmMessageQueue::PruneExpiredMessages() {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
|
||||
double now = GetCurrentTime();
|
||||
size_t initial_size = queue_.size();
|
||||
|
||||
// Remove expired messages
|
||||
queue_.erase(
|
||||
std::remove_if(queue_.begin(), queue_.end(),
|
||||
[now, this](const QueuedMessage& msg) {
|
||||
return (now - msg.timestamp) > message_expiry_seconds_;
|
||||
}),
|
||||
queue_.end());
|
||||
|
||||
int removed = initial_size - queue_.size();
|
||||
|
||||
if (removed > 0) {
|
||||
NotifyStatusChange();
|
||||
MaybePersist();
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
std::string WasmMessageQueue::GenerateMessageId() {
|
||||
static std::random_device rd;
|
||||
static std::mt19937 gen(rd());
|
||||
static std::uniform_int_distribution<> dis(0, 15);
|
||||
static const char* hex_chars = "0123456789abcdef";
|
||||
|
||||
std::stringstream ss;
|
||||
ss << "msg_";
|
||||
|
||||
// Add timestamp component
|
||||
ss << static_cast<long long>(GetCurrentTime() * 1000) << "_";
|
||||
|
||||
// Add random component
|
||||
for (int i = 0; i < 8; i++) {
|
||||
ss << hex_chars[dis(gen)];
|
||||
}
|
||||
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
size_t WasmMessageQueue::CalculateTotalBytes() const {
|
||||
size_t total = 0;
|
||||
|
||||
for (const auto& msg : queue_) {
|
||||
total += msg.message_type.size();
|
||||
total += msg.payload.size();
|
||||
total += msg.id.size();
|
||||
total += sizeof(msg.timestamp) + sizeof(msg.retry_count);
|
||||
}
|
||||
|
||||
for (const auto& msg : failed_messages_) {
|
||||
total += msg.message_type.size();
|
||||
total += msg.payload.size();
|
||||
total += msg.id.size();
|
||||
total += sizeof(msg.timestamp) + sizeof(msg.retry_count);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
void WasmMessageQueue::NotifyStatusChange() {
|
||||
if (status_change_callback_) {
|
||||
status_change_callback_(GetStatus());
|
||||
}
|
||||
}
|
||||
|
||||
void WasmMessageQueue::MaybePersist() {
|
||||
if (auto_persist_ && !is_replaying_) {
|
||||
auto status = PersistToStorage();
|
||||
if (!status.ok()) {
|
||||
emscripten_log(EM_LOG_WARN, "Failed to auto-persist message queue: %s",
|
||||
status.ToString().c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
// clang-format on
|
||||
279
src/app/platform/wasm/wasm_message_queue.h
Normal file
279
src/app/platform/wasm/wasm_message_queue.h
Normal file
@@ -0,0 +1,279 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_MESSAGE_QUEUE_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_MESSAGE_QUEUE_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/val.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "nlohmann/json.hpp"
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* @brief Offline message queue for WebSocket collaboration
|
||||
*
|
||||
* This class provides a persistent message queue for the collaboration system,
|
||||
* allowing messages to be queued when offline and replayed when reconnected.
|
||||
* Messages are persisted to IndexedDB to survive browser refreshes/crashes.
|
||||
*/
|
||||
class WasmMessageQueue {
|
||||
public:
|
||||
/**
|
||||
* @brief Message structure for queued items
|
||||
*/
|
||||
struct QueuedMessage {
|
||||
std::string message_type; // "change", "cursor", etc.
|
||||
std::string payload; // JSON payload
|
||||
double timestamp; // When message was queued
|
||||
int retry_count = 0; // Number of send attempts
|
||||
std::string id; // Unique message ID
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Status information for the queue
|
||||
*/
|
||||
struct QueueStatus {
|
||||
size_t pending_count = 0;
|
||||
size_t failed_count = 0;
|
||||
size_t total_bytes = 0;
|
||||
double oldest_message_age = 0; // Seconds
|
||||
bool is_persisted = false;
|
||||
};
|
||||
|
||||
// Callback types
|
||||
using ReplayCompleteCallback = std::function<void(int replayed_count, int failed_count)>;
|
||||
using MessageSender = std::function<absl::Status(const std::string& type, const std::string& payload)>;
|
||||
using StatusChangeCallback = std::function<void(const QueueStatus& status)>;
|
||||
|
||||
WasmMessageQueue();
|
||||
~WasmMessageQueue();
|
||||
|
||||
/**
|
||||
* @brief Enqueue a message for later sending
|
||||
* @param message_type Type of message ("change", "cursor", etc.)
|
||||
* @param payload JSON payload string
|
||||
* @return Unique message ID
|
||||
*/
|
||||
std::string Enqueue(const std::string& message_type, const std::string& payload);
|
||||
|
||||
/**
|
||||
* @brief Set callback for when replay completes
|
||||
* @param callback Function to call after replay attempt
|
||||
*/
|
||||
void SetOnReplayComplete(ReplayCompleteCallback callback) {
|
||||
replay_complete_callback_ = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Set callback for queue status changes
|
||||
* @param callback Function to call when queue status changes
|
||||
*/
|
||||
void SetOnStatusChange(StatusChangeCallback callback) {
|
||||
status_change_callback_ = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Replay all queued messages
|
||||
* @param sender Function to send each message
|
||||
* @param max_retries Maximum send attempts per message (default: 3)
|
||||
*/
|
||||
void ReplayAll(MessageSender sender, int max_retries = 3);
|
||||
|
||||
/**
|
||||
* @brief Get number of pending messages
|
||||
* @return Count of messages in queue
|
||||
*/
|
||||
size_t PendingCount() const;
|
||||
|
||||
/**
|
||||
* @brief Get detailed queue status
|
||||
* @return Current queue status information
|
||||
*/
|
||||
QueueStatus GetStatus() const;
|
||||
|
||||
/**
|
||||
* @brief Clear all messages from queue
|
||||
*/
|
||||
void Clear();
|
||||
|
||||
/**
|
||||
* @brief Clear only failed messages
|
||||
*/
|
||||
void ClearFailed();
|
||||
|
||||
/**
|
||||
* @brief Remove a specific message by ID
|
||||
* @param message_id The message ID to remove
|
||||
* @return true if message was found and removed
|
||||
*/
|
||||
bool RemoveMessage(const std::string& message_id);
|
||||
|
||||
/**
|
||||
* @brief Persist queue to IndexedDB storage
|
||||
* @return Status of persist operation
|
||||
*/
|
||||
absl::Status PersistToStorage();
|
||||
|
||||
/**
|
||||
* @brief Load queue from IndexedDB storage
|
||||
* @return Status of load operation
|
||||
*/
|
||||
absl::Status LoadFromStorage();
|
||||
|
||||
/**
|
||||
* @brief Enable/disable automatic persistence
|
||||
* @param enable true to auto-persist on changes
|
||||
*/
|
||||
void SetAutoPersist(bool enable) {
|
||||
auto_persist_ = enable;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Set maximum queue size (default: 1000)
|
||||
* @param max_size Maximum number of messages to queue
|
||||
*/
|
||||
void SetMaxQueueSize(size_t max_size) {
|
||||
max_queue_size_ = max_size;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Set message expiry time (default: 24 hours)
|
||||
* @param seconds Time in seconds before messages expire
|
||||
*/
|
||||
void SetMessageExpiry(double seconds) {
|
||||
message_expiry_seconds_ = seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get all queued messages (for inspection/debugging)
|
||||
* @return Vector of queued messages
|
||||
*/
|
||||
std::vector<QueuedMessage> GetQueuedMessages() const;
|
||||
|
||||
/**
|
||||
* @brief Prune expired messages from queue
|
||||
* @return Number of messages removed
|
||||
*/
|
||||
int PruneExpiredMessages();
|
||||
|
||||
private:
|
||||
// Generate unique message ID
|
||||
std::string GenerateMessageId();
|
||||
|
||||
// Calculate total size of queued messages
|
||||
size_t CalculateTotalBytes() const;
|
||||
|
||||
// Notify status change listeners
|
||||
void NotifyStatusChange();
|
||||
|
||||
// Check if we should persist to storage
|
||||
void MaybePersist();
|
||||
|
||||
// Message queue
|
||||
std::deque<QueuedMessage> queue_;
|
||||
std::vector<QueuedMessage> failed_messages_;
|
||||
mutable std::mutex queue_mutex_;
|
||||
|
||||
// Configuration
|
||||
bool auto_persist_ = true;
|
||||
size_t max_queue_size_ = 1000;
|
||||
double message_expiry_seconds_ = 86400.0; // 24 hours
|
||||
|
||||
// State tracking
|
||||
bool is_replaying_ = false;
|
||||
size_t total_enqueued_ = 0;
|
||||
size_t total_replayed_ = 0;
|
||||
size_t total_failed_ = 0;
|
||||
|
||||
// Callbacks
|
||||
ReplayCompleteCallback replay_complete_callback_;
|
||||
StatusChangeCallback status_change_callback_;
|
||||
|
||||
// Storage key for IndexedDB
|
||||
static constexpr const char* kStorageKey = "collaboration_message_queue";
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#else // !__EMSCRIPTEN__
|
||||
|
||||
// Stub implementation for non-WASM builds
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
class WasmMessageQueue {
|
||||
public:
|
||||
struct QueuedMessage {
|
||||
std::string message_type;
|
||||
std::string payload;
|
||||
double timestamp;
|
||||
int retry_count = 0;
|
||||
std::string id;
|
||||
};
|
||||
|
||||
struct QueueStatus {
|
||||
size_t pending_count = 0;
|
||||
size_t failed_count = 0;
|
||||
size_t total_bytes = 0;
|
||||
double oldest_message_age = 0;
|
||||
bool is_persisted = false;
|
||||
};
|
||||
|
||||
using ReplayCompleteCallback = std::function<void(int, int)>;
|
||||
using MessageSender = std::function<absl::Status(const std::string&, const std::string&)>;
|
||||
using StatusChangeCallback = std::function<void(const QueueStatus&)>;
|
||||
|
||||
WasmMessageQueue() {}
|
||||
~WasmMessageQueue() {}
|
||||
|
||||
std::string Enqueue(const std::string&, const std::string&) { return ""; }
|
||||
void SetOnReplayComplete(ReplayCompleteCallback) {}
|
||||
void SetOnStatusChange(StatusChangeCallback) {}
|
||||
void ReplayAll(MessageSender, int = 3) {}
|
||||
size_t PendingCount() const { return 0; }
|
||||
QueueStatus GetStatus() const { return {}; }
|
||||
void Clear() {}
|
||||
void ClearFailed() {}
|
||||
bool RemoveMessage(const std::string&) { return false; }
|
||||
absl::Status PersistToStorage() {
|
||||
return absl::UnimplementedError("Message queue requires WASM build");
|
||||
}
|
||||
absl::Status LoadFromStorage() {
|
||||
return absl::UnimplementedError("Message queue requires WASM build");
|
||||
}
|
||||
void SetAutoPersist(bool) {}
|
||||
void SetMaxQueueSize(size_t) {}
|
||||
void SetMessageExpiry(double) {}
|
||||
std::vector<QueuedMessage> GetQueuedMessages() const { return {}; }
|
||||
int PruneExpiredMessages() { return 0; }
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_MESSAGE_QUEUE_H_
|
||||
483
src/app/platform/wasm/wasm_patch_export.cc
Normal file
483
src/app/platform/wasm/wasm_patch_export.cc
Normal file
@@ -0,0 +1,483 @@
|
||||
// clang-format off
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_patch_export.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/html5.h>
|
||||
|
||||
#include "absl/strings/str_format.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
// JavaScript interop for downloading patch files
|
||||
EM_JS(void, downloadPatchFile_impl, (const char* filename, const uint8_t* data, size_t size, const char* mime_type), {
|
||||
var dataArray = HEAPU8.subarray(data, data + size);
|
||||
var blob = new Blob([dataArray], { type: UTF8ToString(mime_type) });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = UTF8ToString(filename);
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(function() {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
});
|
||||
// clang-format on
|
||||
|
||||
// CRC32 implementation
|
||||
static const uint32_t kCRC32Table[256] = {
|
||||
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f,
|
||||
0xe963a535, 0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988,
|
||||
0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2,
|
||||
0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7,
|
||||
0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9,
|
||||
0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172,
|
||||
0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c,
|
||||
0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59,
|
||||
0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423,
|
||||
0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924,
|
||||
0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, 0x76dc4190, 0x01db7106,
|
||||
0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433,
|
||||
0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d,
|
||||
0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e,
|
||||
0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950,
|
||||
0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65,
|
||||
0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7,
|
||||
0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0,
|
||||
0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa,
|
||||
0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f,
|
||||
0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81,
|
||||
0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a,
|
||||
0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, 0xe3630b12, 0x94643b84,
|
||||
0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1,
|
||||
0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb,
|
||||
0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc,
|
||||
0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8, 0xa1d1937e,
|
||||
0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b,
|
||||
0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55,
|
||||
0x316e8eef, 0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236,
|
||||
0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28,
|
||||
0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d,
|
||||
0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f,
|
||||
0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38,
|
||||
0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242,
|
||||
0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777,
|
||||
0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69,
|
||||
0x616bffd3, 0x166ccf45, 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2,
|
||||
0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, 0xaed16a4a, 0xd9d65adc,
|
||||
0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9,
|
||||
0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693,
|
||||
0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94,
|
||||
0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d};
|
||||
|
||||
uint32_t WasmPatchExport::CalculateCRC32(const std::vector<uint8_t>& data) {
|
||||
return CalculateCRC32(data.data(), data.size());
|
||||
}
|
||||
|
||||
uint32_t WasmPatchExport::CalculateCRC32(const uint8_t* data, size_t size) {
|
||||
uint32_t crc = 0xFFFFFFFF;
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
crc = kCRC32Table[(crc ^ data[i]) & 0xFF] ^ (crc >> 8);
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
void WasmPatchExport::WriteVariableLength(std::vector<uint8_t>& output,
|
||||
uint64_t value) {
|
||||
while (true) {
|
||||
uint8_t byte = value & 0x7F;
|
||||
value >>= 7;
|
||||
if (value == 0) {
|
||||
output.push_back(byte | 0x80);
|
||||
break;
|
||||
}
|
||||
output.push_back(byte);
|
||||
}
|
||||
}
|
||||
|
||||
void WasmPatchExport::WriteIPS24BitOffset(std::vector<uint8_t>& output,
|
||||
uint32_t offset) {
|
||||
output.push_back((offset >> 16) & 0xFF);
|
||||
output.push_back((offset >> 8) & 0xFF);
|
||||
output.push_back(offset & 0xFF);
|
||||
}
|
||||
|
||||
void WasmPatchExport::WriteIPS16BitSize(std::vector<uint8_t>& output,
|
||||
uint16_t size) {
|
||||
output.push_back((size >> 8) & 0xFF);
|
||||
output.push_back(size & 0xFF);
|
||||
}
|
||||
|
||||
std::vector<std::pair<size_t, size_t>> WasmPatchExport::FindChangedRegions(
|
||||
const std::vector<uint8_t>& original,
|
||||
const std::vector<uint8_t>& modified) {
|
||||
std::vector<std::pair<size_t, size_t>> regions;
|
||||
|
||||
size_t min_size = std::min(original.size(), modified.size());
|
||||
size_t i = 0;
|
||||
|
||||
while (i < min_size) {
|
||||
// Skip unchanged bytes
|
||||
while (i < min_size && original[i] == modified[i]) {
|
||||
++i;
|
||||
}
|
||||
|
||||
if (i < min_size) {
|
||||
// Found a change, record the start
|
||||
size_t start = i;
|
||||
|
||||
// Find the end of the changed region
|
||||
while (i < min_size && original[i] != modified[i]) {
|
||||
++i;
|
||||
}
|
||||
|
||||
regions.push_back({start, i - start});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle case where modified ROM is larger
|
||||
if (modified.size() > original.size()) {
|
||||
regions.push_back({original.size(), modified.size() - original.size()});
|
||||
}
|
||||
|
||||
return regions;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> WasmPatchExport::GenerateBPSPatch(
|
||||
const std::vector<uint8_t>& source,
|
||||
const std::vector<uint8_t>& target) {
|
||||
std::vector<uint8_t> patch;
|
||||
|
||||
// BPS header "BPS1"
|
||||
patch.push_back('B');
|
||||
patch.push_back('P');
|
||||
patch.push_back('S');
|
||||
patch.push_back('1');
|
||||
|
||||
// Source size (variable-length encoding)
|
||||
WriteVariableLength(patch, source.size());
|
||||
|
||||
// Target size (variable-length encoding)
|
||||
WriteVariableLength(patch, target.size());
|
||||
|
||||
// Metadata size (0 for no metadata)
|
||||
WriteVariableLength(patch, 0);
|
||||
|
||||
// BPS action types:
|
||||
// 0 = SourceRead: copy n bytes from source at outputOffset
|
||||
// 1 = TargetRead: copy n literal bytes from patch
|
||||
// 2 = SourceCopy: copy n bytes from source at sourceRelativeOffset
|
||||
// 3 = TargetCopy: copy n bytes from target at targetRelativeOffset
|
||||
|
||||
size_t output_offset = 0;
|
||||
int64_t source_relative_offset = 0;
|
||||
int64_t target_relative_offset = 0;
|
||||
|
||||
while (output_offset < target.size()) {
|
||||
// Check if we can use SourceRead (bytes match at current position)
|
||||
size_t source_read_len = 0;
|
||||
if (output_offset < source.size()) {
|
||||
while (output_offset + source_read_len < target.size() &&
|
||||
output_offset + source_read_len < source.size() &&
|
||||
source[output_offset + source_read_len] ==
|
||||
target[output_offset + source_read_len]) {
|
||||
++source_read_len;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find a better match elsewhere in source (SourceCopy)
|
||||
size_t best_source_copy_offset = 0;
|
||||
size_t best_source_copy_len = 0;
|
||||
if (source_read_len < 4) { // Only search if SourceRead isn't good enough
|
||||
for (size_t i = 0; i < source.size(); ++i) {
|
||||
size_t match_len = 0;
|
||||
while (i + match_len < source.size() &&
|
||||
output_offset + match_len < target.size() &&
|
||||
source[i + match_len] == target[output_offset + match_len]) {
|
||||
++match_len;
|
||||
}
|
||||
if (match_len > best_source_copy_len && match_len >= 4) {
|
||||
best_source_copy_len = match_len;
|
||||
best_source_copy_offset = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decide which action to use
|
||||
if (source_read_len >= 4 || (source_read_len > 0 && source_read_len >= best_source_copy_len)) {
|
||||
// Use SourceRead
|
||||
uint64_t action = ((source_read_len - 1) << 2) | 0;
|
||||
WriteVariableLength(patch, action);
|
||||
output_offset += source_read_len;
|
||||
} else if (best_source_copy_len >= 4) {
|
||||
// Use SourceCopy
|
||||
uint64_t action = ((best_source_copy_len - 1) << 2) | 2;
|
||||
WriteVariableLength(patch, action);
|
||||
|
||||
// Write relative offset (signed, encoded as unsigned with sign bit)
|
||||
int64_t relative = static_cast<int64_t>(best_source_copy_offset) - source_relative_offset;
|
||||
uint64_t encoded_offset = (relative < 0) ?
|
||||
((static_cast<uint64_t>(-relative - 1) << 1) | 1) :
|
||||
(static_cast<uint64_t>(relative) << 1);
|
||||
WriteVariableLength(patch, encoded_offset);
|
||||
|
||||
source_relative_offset = best_source_copy_offset + best_source_copy_len;
|
||||
output_offset += best_source_copy_len;
|
||||
} else {
|
||||
// Use TargetRead - find how many bytes to write literally
|
||||
size_t target_read_len = 1;
|
||||
while (output_offset + target_read_len < target.size()) {
|
||||
// Check if next position has a good match
|
||||
bool has_good_match = false;
|
||||
|
||||
// Check SourceRead at next position
|
||||
if (output_offset + target_read_len < source.size() &&
|
||||
source[output_offset + target_read_len] ==
|
||||
target[output_offset + target_read_len]) {
|
||||
size_t match_len = 0;
|
||||
while (output_offset + target_read_len + match_len < target.size() &&
|
||||
output_offset + target_read_len + match_len < source.size() &&
|
||||
source[output_offset + target_read_len + match_len] ==
|
||||
target[output_offset + target_read_len + match_len]) {
|
||||
++match_len;
|
||||
}
|
||||
if (match_len >= 4) {
|
||||
has_good_match = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (has_good_match) {
|
||||
break;
|
||||
}
|
||||
++target_read_len;
|
||||
}
|
||||
|
||||
// Write TargetRead action
|
||||
uint64_t action = ((target_read_len - 1) << 2) | 1;
|
||||
WriteVariableLength(patch, action);
|
||||
|
||||
// Write the literal bytes
|
||||
for (size_t i = 0; i < target_read_len; ++i) {
|
||||
patch.push_back(target[output_offset + i]);
|
||||
}
|
||||
output_offset += target_read_len;
|
||||
}
|
||||
}
|
||||
|
||||
// Write checksums (all CRC32, little-endian)
|
||||
uint32_t source_crc = CalculateCRC32(source);
|
||||
uint32_t target_crc = CalculateCRC32(target);
|
||||
|
||||
// Source CRC32
|
||||
patch.push_back(source_crc & 0xFF);
|
||||
patch.push_back((source_crc >> 8) & 0xFF);
|
||||
patch.push_back((source_crc >> 16) & 0xFF);
|
||||
patch.push_back((source_crc >> 24) & 0xFF);
|
||||
|
||||
// Target CRC32
|
||||
patch.push_back(target_crc & 0xFF);
|
||||
patch.push_back((target_crc >> 8) & 0xFF);
|
||||
patch.push_back((target_crc >> 16) & 0xFF);
|
||||
patch.push_back((target_crc >> 24) & 0xFF);
|
||||
|
||||
// Patch CRC32 (includes everything before this point)
|
||||
uint32_t patch_crc = CalculateCRC32(patch.data(), patch.size());
|
||||
patch.push_back(patch_crc & 0xFF);
|
||||
patch.push_back((patch_crc >> 8) & 0xFF);
|
||||
patch.push_back((patch_crc >> 16) & 0xFF);
|
||||
patch.push_back((patch_crc >> 24) & 0xFF);
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> WasmPatchExport::GenerateIPSPatch(
|
||||
const std::vector<uint8_t>& source,
|
||||
const std::vector<uint8_t>& target) {
|
||||
std::vector<uint8_t> patch;
|
||||
|
||||
// IPS header
|
||||
patch.push_back('P');
|
||||
patch.push_back('A');
|
||||
patch.push_back('T');
|
||||
patch.push_back('C');
|
||||
patch.push_back('H');
|
||||
|
||||
// Find all changed regions
|
||||
auto regions = FindChangedRegions(source, target);
|
||||
|
||||
for (const auto& region : regions) {
|
||||
size_t offset = region.first;
|
||||
size_t length = region.second;
|
||||
|
||||
// IPS has a maximum offset of 16MB (0xFFFFFF)
|
||||
if (offset > 0xFFFFFF) {
|
||||
break; // Can't encode offsets beyond 16MB
|
||||
}
|
||||
|
||||
// Process the region, splitting if necessary (max 65535 bytes per record)
|
||||
size_t remaining = length;
|
||||
size_t current_offset = offset;
|
||||
|
||||
while (remaining > 0) {
|
||||
size_t chunk_size = std::min(remaining, static_cast<size_t>(0xFFFF));
|
||||
|
||||
// Check for RLE opportunity (same byte repeated)
|
||||
bool use_rle = false;
|
||||
uint8_t rle_byte = 0;
|
||||
|
||||
if (chunk_size >= 3 && current_offset < target.size()) {
|
||||
rle_byte = target[current_offset];
|
||||
use_rle = true;
|
||||
|
||||
for (size_t i = 1; i < chunk_size; ++i) {
|
||||
if (current_offset + i >= target.size() ||
|
||||
target[current_offset + i] != rle_byte) {
|
||||
use_rle = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (use_rle && chunk_size >= 3) {
|
||||
// RLE record
|
||||
WriteIPS24BitOffset(patch, current_offset);
|
||||
WriteIPS16BitSize(patch, 0); // RLE marker
|
||||
WriteIPS16BitSize(patch, chunk_size);
|
||||
patch.push_back(rle_byte);
|
||||
} else {
|
||||
// Normal record
|
||||
WriteIPS24BitOffset(patch, current_offset);
|
||||
WriteIPS16BitSize(patch, chunk_size);
|
||||
|
||||
for (size_t i = 0; i < chunk_size; ++i) {
|
||||
if (current_offset + i < target.size()) {
|
||||
patch.push_back(target[current_offset + i]);
|
||||
} else {
|
||||
patch.push_back(0); // Pad with zeros if target is shorter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
current_offset += chunk_size;
|
||||
remaining -= chunk_size;
|
||||
}
|
||||
}
|
||||
|
||||
// IPS EOF marker
|
||||
patch.push_back('E');
|
||||
patch.push_back('O');
|
||||
patch.push_back('F');
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
absl::Status WasmPatchExport::DownloadPatchFile(const std::string& filename,
|
||||
const std::vector<uint8_t>& data,
|
||||
const std::string& mime_type) {
|
||||
if (data.empty()) {
|
||||
return absl::InvalidArgumentError("Cannot download empty patch file");
|
||||
}
|
||||
|
||||
if (filename.empty()) {
|
||||
return absl::InvalidArgumentError("Filename cannot be empty");
|
||||
}
|
||||
|
||||
// clang-format off
|
||||
downloadPatchFile_impl(filename.c_str(), data.data(), data.size(), mime_type.c_str());
|
||||
// clang-format on
|
||||
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
absl::Status WasmPatchExport::ExportBPS(const std::vector<uint8_t>& original,
|
||||
const std::vector<uint8_t>& modified,
|
||||
const std::string& filename) {
|
||||
if (original.empty()) {
|
||||
return absl::InvalidArgumentError("Original ROM data is empty");
|
||||
}
|
||||
|
||||
if (modified.empty()) {
|
||||
return absl::InvalidArgumentError("Modified ROM data is empty");
|
||||
}
|
||||
|
||||
if (filename.empty()) {
|
||||
return absl::InvalidArgumentError("Filename cannot be empty");
|
||||
}
|
||||
|
||||
// Generate the BPS patch
|
||||
std::vector<uint8_t> patch = GenerateBPSPatch(original, modified);
|
||||
|
||||
if (patch.empty()) {
|
||||
return absl::InternalError("Failed to generate BPS patch");
|
||||
}
|
||||
|
||||
// Download the patch file
|
||||
return DownloadPatchFile(filename, patch, "application/octet-stream");
|
||||
}
|
||||
|
||||
absl::Status WasmPatchExport::ExportIPS(const std::vector<uint8_t>& original,
|
||||
const std::vector<uint8_t>& modified,
|
||||
const std::string& filename) {
|
||||
if (original.empty()) {
|
||||
return absl::InvalidArgumentError("Original ROM data is empty");
|
||||
}
|
||||
|
||||
if (modified.empty()) {
|
||||
return absl::InvalidArgumentError("Modified ROM data is empty");
|
||||
}
|
||||
|
||||
if (filename.empty()) {
|
||||
return absl::InvalidArgumentError("Filename cannot be empty");
|
||||
}
|
||||
|
||||
// Check for IPS size limitation
|
||||
if (modified.size() > 0xFFFFFF) {
|
||||
return absl::InvalidArgumentError(
|
||||
absl::StrFormat("ROM size (%zu bytes) exceeds IPS format limit (16MB)",
|
||||
modified.size()));
|
||||
}
|
||||
|
||||
// Generate the IPS patch
|
||||
std::vector<uint8_t> patch = GenerateIPSPatch(original, modified);
|
||||
|
||||
if (patch.empty()) {
|
||||
return absl::InternalError("Failed to generate IPS patch");
|
||||
}
|
||||
|
||||
// Download the patch file
|
||||
return DownloadPatchFile(filename, patch, "application/octet-stream");
|
||||
}
|
||||
|
||||
PatchInfo WasmPatchExport::GetPatchPreview(const std::vector<uint8_t>& original,
|
||||
const std::vector<uint8_t>& modified) {
|
||||
PatchInfo info;
|
||||
info.changed_bytes = 0;
|
||||
info.num_regions = 0;
|
||||
|
||||
if (original.empty() || modified.empty()) {
|
||||
return info;
|
||||
}
|
||||
|
||||
// Find all changed regions
|
||||
info.changed_regions = FindChangedRegions(original, modified);
|
||||
info.num_regions = info.changed_regions.size();
|
||||
|
||||
// Calculate total changed bytes
|
||||
for (const auto& region : info.changed_regions) {
|
||||
info.changed_bytes += region.second;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
151
src/app/platform/wasm/wasm_patch_export.h
Normal file
151
src/app/platform/wasm/wasm_patch_export.h
Normal file
@@ -0,0 +1,151 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_WASM_PATCH_EXPORT_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_WASM_PATCH_EXPORT_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/status/statusor.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* @struct PatchInfo
|
||||
* @brief Information about the differences between original and modified ROMs
|
||||
*/
|
||||
struct PatchInfo {
|
||||
size_t changed_bytes; // Total number of changed bytes
|
||||
size_t num_regions; // Number of distinct changed regions
|
||||
std::vector<std::pair<size_t, size_t>> changed_regions; // List of (offset, length) pairs
|
||||
};
|
||||
|
||||
/**
|
||||
* @class WasmPatchExport
|
||||
* @brief Patch export functionality for WASM/browser environment
|
||||
*
|
||||
* This class provides functionality to generate and export BPS and IPS patches
|
||||
* from ROM modifications, allowing users to save their changes as patch files
|
||||
* that can be applied to clean ROMs.
|
||||
*/
|
||||
class WasmPatchExport {
|
||||
public:
|
||||
/**
|
||||
* @brief Export modifications as a BPS (Beat) patch file
|
||||
*
|
||||
* BPS is a modern patching format that supports:
|
||||
* - Variable-length encoding for efficient storage
|
||||
* - Delta encoding for changed regions
|
||||
* - CRC32 checksums for source, target, and patch validation
|
||||
*
|
||||
* @param original Original ROM data
|
||||
* @param modified Modified ROM data
|
||||
* @param filename Suggested filename for the download (e.g., "hack.bps")
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status ExportBPS(const std::vector<uint8_t>& original,
|
||||
const std::vector<uint8_t>& modified,
|
||||
const std::string& filename);
|
||||
|
||||
/**
|
||||
* @brief Export modifications as an IPS patch file
|
||||
*
|
||||
* IPS is a classic patching format that supports:
|
||||
* - Simple record-based structure
|
||||
* - RLE encoding for repeated bytes
|
||||
* - Maximum file size of 16MB (24-bit addressing)
|
||||
*
|
||||
* @param original Original ROM data
|
||||
* @param modified Modified ROM data
|
||||
* @param filename Suggested filename for the download (e.g., "hack.ips")
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status ExportIPS(const std::vector<uint8_t>& original,
|
||||
const std::vector<uint8_t>& modified,
|
||||
const std::string& filename);
|
||||
|
||||
/**
|
||||
* @brief Get a preview of changes between original and modified ROMs
|
||||
*
|
||||
* Analyzes the differences and returns summary information about
|
||||
* what would be included in a patch file.
|
||||
*
|
||||
* @param original Original ROM data
|
||||
* @param modified Modified ROM data
|
||||
* @return PatchInfo structure containing change statistics
|
||||
*/
|
||||
static PatchInfo GetPatchPreview(const std::vector<uint8_t>& original,
|
||||
const std::vector<uint8_t>& modified);
|
||||
|
||||
private:
|
||||
// CRC32 calculation
|
||||
static uint32_t CalculateCRC32(const std::vector<uint8_t>& data);
|
||||
static uint32_t CalculateCRC32(const uint8_t* data, size_t size);
|
||||
|
||||
// BPS format helpers
|
||||
static void WriteVariableLength(std::vector<uint8_t>& output, uint64_t value);
|
||||
static std::vector<uint8_t> GenerateBPSPatch(const std::vector<uint8_t>& source,
|
||||
const std::vector<uint8_t>& target);
|
||||
|
||||
// IPS format helpers
|
||||
static void WriteIPS24BitOffset(std::vector<uint8_t>& output, uint32_t offset);
|
||||
static void WriteIPS16BitSize(std::vector<uint8_t>& output, uint16_t size);
|
||||
static std::vector<uint8_t> GenerateIPSPatch(const std::vector<uint8_t>& source,
|
||||
const std::vector<uint8_t>& target);
|
||||
|
||||
// Common helpers
|
||||
static std::vector<std::pair<size_t, size_t>> FindChangedRegions(
|
||||
const std::vector<uint8_t>& original,
|
||||
const std::vector<uint8_t>& modified);
|
||||
|
||||
// Browser download functionality (implemented via EM_JS)
|
||||
static absl::Status DownloadPatchFile(const std::string& filename,
|
||||
const std::vector<uint8_t>& data,
|
||||
const std::string& mime_type);
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
#else // !__EMSCRIPTEN__
|
||||
|
||||
// Stub implementation for non-WASM builds
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
struct PatchInfo {
|
||||
size_t changed_bytes = 0;
|
||||
size_t num_regions = 0;
|
||||
std::vector<std::pair<size_t, size_t>> changed_regions;
|
||||
};
|
||||
|
||||
class WasmPatchExport {
|
||||
public:
|
||||
static absl::Status ExportBPS(const std::vector<uint8_t>&,
|
||||
const std::vector<uint8_t>&,
|
||||
const std::string&) {
|
||||
return absl::UnimplementedError("Patch export is only available in WASM builds");
|
||||
}
|
||||
|
||||
static absl::Status ExportIPS(const std::vector<uint8_t>&,
|
||||
const std::vector<uint8_t>&,
|
||||
const std::string&) {
|
||||
return absl::UnimplementedError("Patch export is only available in WASM builds");
|
||||
}
|
||||
|
||||
static PatchInfo GetPatchPreview(const std::vector<uint8_t>&,
|
||||
const std::vector<uint8_t>&) {
|
||||
return PatchInfo{};
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_WASM_PATCH_EXPORT_H_
|
||||
497
src/app/platform/wasm/wasm_secure_storage.cc
Normal file
497
src/app/platform/wasm/wasm_secure_storage.cc
Normal file
@@ -0,0 +1,497 @@
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_secure_storage.h"
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/val.h>
|
||||
|
||||
#include "absl/strings/str_cat.h"
|
||||
#include "absl/strings/str_format.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
namespace {
|
||||
|
||||
// JavaScript interop functions for sessionStorage
|
||||
EM_JS(void, js_session_storage_set, (const char* key, const char* value), {
|
||||
try {
|
||||
if (typeof(Storage) !== "undefined" && sessionStorage) {
|
||||
sessionStorage.setItem(UTF8ToString(key), UTF8ToString(value));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to set sessionStorage:', e);
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(char*, js_session_storage_get, (const char* key), {
|
||||
try {
|
||||
if (typeof(Storage) !== "undefined" && sessionStorage) {
|
||||
const value = sessionStorage.getItem(UTF8ToString(key));
|
||||
if (value === null) return null;
|
||||
const len = lengthBytesUTF8(value) + 1;
|
||||
const ptr = _malloc(len);
|
||||
stringToUTF8(value, ptr, len);
|
||||
return ptr;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get from sessionStorage:', e);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
EM_JS(void, js_session_storage_remove, (const char* key), {
|
||||
try {
|
||||
if (typeof(Storage) !== "undefined" && sessionStorage) {
|
||||
sessionStorage.removeItem(UTF8ToString(key));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to remove from sessionStorage:', e);
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(int, js_session_storage_has, (const char* key), {
|
||||
try {
|
||||
if (typeof(Storage) !== "undefined" && sessionStorage) {
|
||||
return sessionStorage.getItem(UTF8ToString(key)) !== null ? 1 : 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to check sessionStorage:', e);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// JavaScript interop functions for localStorage
|
||||
EM_JS(void, js_local_storage_set, (const char* key, const char* value), {
|
||||
try {
|
||||
if (typeof(Storage) !== "undefined" && localStorage) {
|
||||
localStorage.setItem(UTF8ToString(key), UTF8ToString(value));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to set localStorage:', e);
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(char*, js_local_storage_get, (const char* key), {
|
||||
try {
|
||||
if (typeof(Storage) !== "undefined" && localStorage) {
|
||||
const value = localStorage.getItem(UTF8ToString(key));
|
||||
if (value === null) return null;
|
||||
const len = lengthBytesUTF8(value) + 1;
|
||||
const ptr = _malloc(len);
|
||||
stringToUTF8(value, ptr, len);
|
||||
return ptr;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get from localStorage:', e);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
EM_JS(void, js_local_storage_remove, (const char* key), {
|
||||
try {
|
||||
if (typeof(Storage) !== "undefined" && localStorage) {
|
||||
localStorage.removeItem(UTF8ToString(key));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to remove from localStorage:', e);
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(int, js_local_storage_has, (const char* key), {
|
||||
try {
|
||||
if (typeof(Storage) !== "undefined" && localStorage) {
|
||||
return localStorage.getItem(UTF8ToString(key)) !== null ? 1 : 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to check localStorage:', e);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Get all keys from storage
|
||||
EM_JS(char*, js_session_storage_keys, (), {
|
||||
try {
|
||||
if (typeof(Storage) !== "undefined" && sessionStorage) {
|
||||
const keys = [];
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
keys.push(sessionStorage.key(i));
|
||||
}
|
||||
const keysStr = keys.join('|');
|
||||
const len = lengthBytesUTF8(keysStr) + 1;
|
||||
const ptr = _malloc(len);
|
||||
stringToUTF8(keysStr, ptr, len);
|
||||
return ptr;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get sessionStorage keys:', e);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
EM_JS(char*, js_local_storage_keys, (), {
|
||||
try {
|
||||
if (typeof(Storage) !== "undefined" && localStorage) {
|
||||
const keys = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
keys.push(localStorage.key(i));
|
||||
}
|
||||
const keysStr = keys.join('|');
|
||||
const len = lengthBytesUTF8(keysStr) + 1;
|
||||
const ptr = _malloc(len);
|
||||
stringToUTF8(keysStr, ptr, len);
|
||||
return ptr;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get localStorage keys:', e);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Clear all keys with prefix
|
||||
EM_JS(void, js_session_storage_clear_prefix, (const char* prefix), {
|
||||
try {
|
||||
if (typeof(Storage) !== "undefined" && sessionStorage) {
|
||||
const prefixStr = UTF8ToString(prefix);
|
||||
const keysToRemove = [];
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (key && key.startsWith(prefixStr)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(key => sessionStorage.removeItem(key));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to clear sessionStorage prefix:', e);
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(void, js_local_storage_clear_prefix, (const char* prefix), {
|
||||
try {
|
||||
if (typeof(Storage) !== "undefined" && localStorage) {
|
||||
const prefixStr = UTF8ToString(prefix);
|
||||
const keysToRemove = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith(prefixStr)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to clear localStorage prefix:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if storage is available
|
||||
EM_JS(int, js_is_storage_available, (), {
|
||||
try {
|
||||
if (typeof(Storage) !== "undefined") {
|
||||
// Test both storage types
|
||||
const testKey = '__yaze_storage_test__';
|
||||
|
||||
// Test sessionStorage
|
||||
if (sessionStorage) {
|
||||
sessionStorage.setItem(testKey, 'test');
|
||||
sessionStorage.removeItem(testKey);
|
||||
}
|
||||
|
||||
// Test localStorage
|
||||
if (localStorage) {
|
||||
localStorage.setItem(testKey, 'test');
|
||||
localStorage.removeItem(testKey);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Storage not available:', e);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Helper to split string by delimiter
|
||||
std::vector<std::string> SplitString(const std::string& str, char delimiter) {
|
||||
std::vector<std::string> result;
|
||||
size_t start = 0;
|
||||
size_t end = str.find(delimiter);
|
||||
|
||||
while (end != std::string::npos) {
|
||||
result.push_back(str.substr(start, end - start));
|
||||
start = end + 1;
|
||||
end = str.find(delimiter, start);
|
||||
}
|
||||
|
||||
if (start < str.length()) {
|
||||
result.push_back(str.substr(start));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Public methods implementation
|
||||
|
||||
absl::Status WasmSecureStorage::StoreApiKey(const std::string& service,
|
||||
const std::string& key,
|
||||
StorageType storage_type) {
|
||||
if (service.empty()) {
|
||||
return absl::InvalidArgumentError("Service name cannot be empty");
|
||||
}
|
||||
if (key.empty()) {
|
||||
return absl::InvalidArgumentError("API key cannot be empty");
|
||||
}
|
||||
|
||||
std::string storage_key = BuildApiKeyStorageKey(service);
|
||||
|
||||
if (storage_type == StorageType::kSession) {
|
||||
js_session_storage_set(storage_key.c_str(), key.c_str());
|
||||
} else {
|
||||
js_local_storage_set(storage_key.c_str(), key.c_str());
|
||||
}
|
||||
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
absl::StatusOr<std::string> WasmSecureStorage::RetrieveApiKey(
|
||||
const std::string& service,
|
||||
StorageType storage_type) {
|
||||
if (service.empty()) {
|
||||
return absl::InvalidArgumentError("Service name cannot be empty");
|
||||
}
|
||||
|
||||
std::string storage_key = BuildApiKeyStorageKey(service);
|
||||
char* value = nullptr;
|
||||
|
||||
if (storage_type == StorageType::kSession) {
|
||||
value = js_session_storage_get(storage_key.c_str());
|
||||
} else {
|
||||
value = js_local_storage_get(storage_key.c_str());
|
||||
}
|
||||
|
||||
if (value == nullptr) {
|
||||
return absl::NotFoundError(
|
||||
absl::StrFormat("No API key found for service: %s", service));
|
||||
}
|
||||
|
||||
std::string result(value);
|
||||
free(value); // Free the allocated memory from JS
|
||||
return result;
|
||||
}
|
||||
|
||||
absl::Status WasmSecureStorage::ClearApiKey(const std::string& service,
|
||||
StorageType storage_type) {
|
||||
if (service.empty()) {
|
||||
return absl::InvalidArgumentError("Service name cannot be empty");
|
||||
}
|
||||
|
||||
std::string storage_key = BuildApiKeyStorageKey(service);
|
||||
|
||||
if (storage_type == StorageType::kSession) {
|
||||
js_session_storage_remove(storage_key.c_str());
|
||||
} else {
|
||||
js_local_storage_remove(storage_key.c_str());
|
||||
}
|
||||
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
bool WasmSecureStorage::HasApiKey(const std::string& service,
|
||||
StorageType storage_type) {
|
||||
if (service.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string storage_key = BuildApiKeyStorageKey(service);
|
||||
|
||||
if (storage_type == StorageType::kSession) {
|
||||
return js_session_storage_has(storage_key.c_str()) != 0;
|
||||
} else {
|
||||
return js_local_storage_has(storage_key.c_str()) != 0;
|
||||
}
|
||||
}
|
||||
|
||||
absl::Status WasmSecureStorage::StoreSecret(const std::string& key,
|
||||
const std::string& value,
|
||||
StorageType storage_type) {
|
||||
if (key.empty()) {
|
||||
return absl::InvalidArgumentError("Key cannot be empty");
|
||||
}
|
||||
if (value.empty()) {
|
||||
return absl::InvalidArgumentError("Value cannot be empty");
|
||||
}
|
||||
|
||||
std::string storage_key = BuildSecretStorageKey(key);
|
||||
|
||||
if (storage_type == StorageType::kSession) {
|
||||
js_session_storage_set(storage_key.c_str(), value.c_str());
|
||||
} else {
|
||||
js_local_storage_set(storage_key.c_str(), value.c_str());
|
||||
}
|
||||
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
absl::StatusOr<std::string> WasmSecureStorage::RetrieveSecret(
|
||||
const std::string& key,
|
||||
StorageType storage_type) {
|
||||
if (key.empty()) {
|
||||
return absl::InvalidArgumentError("Key cannot be empty");
|
||||
}
|
||||
|
||||
std::string storage_key = BuildSecretStorageKey(key);
|
||||
char* value = nullptr;
|
||||
|
||||
if (storage_type == StorageType::kSession) {
|
||||
value = js_session_storage_get(storage_key.c_str());
|
||||
} else {
|
||||
value = js_local_storage_get(storage_key.c_str());
|
||||
}
|
||||
|
||||
if (value == nullptr) {
|
||||
return absl::NotFoundError(absl::StrFormat("No secret found for key: %s", key));
|
||||
}
|
||||
|
||||
std::string result(value);
|
||||
free(value);
|
||||
return result;
|
||||
}
|
||||
|
||||
absl::Status WasmSecureStorage::ClearSecret(const std::string& key,
|
||||
StorageType storage_type) {
|
||||
if (key.empty()) {
|
||||
return absl::InvalidArgumentError("Key cannot be empty");
|
||||
}
|
||||
|
||||
std::string storage_key = BuildSecretStorageKey(key);
|
||||
|
||||
if (storage_type == StorageType::kSession) {
|
||||
js_session_storage_remove(storage_key.c_str());
|
||||
} else {
|
||||
js_local_storage_remove(storage_key.c_str());
|
||||
}
|
||||
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
std::vector<std::string> WasmSecureStorage::ListStoredApiKeys(
|
||||
StorageType storage_type) {
|
||||
std::vector<std::string> services;
|
||||
char* keys_str = nullptr;
|
||||
|
||||
if (storage_type == StorageType::kSession) {
|
||||
keys_str = js_session_storage_keys();
|
||||
} else {
|
||||
keys_str = js_local_storage_keys();
|
||||
}
|
||||
|
||||
if (keys_str != nullptr) {
|
||||
std::string keys(keys_str);
|
||||
free(keys_str);
|
||||
|
||||
// Split keys by delimiter and filter for API keys
|
||||
auto all_keys = SplitString(keys, '|');
|
||||
std::string api_prefix = kApiKeyPrefix;
|
||||
|
||||
for (const auto& key : all_keys) {
|
||||
if (key.find(api_prefix) == 0) {
|
||||
// Extract service name from key
|
||||
std::string service = ExtractServiceFromKey(key);
|
||||
if (!service.empty()) {
|
||||
services.push_back(service);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
absl::Status WasmSecureStorage::ClearAllApiKeys(StorageType storage_type) {
|
||||
if (storage_type == StorageType::kSession) {
|
||||
js_session_storage_clear_prefix(kApiKeyPrefix);
|
||||
} else {
|
||||
js_local_storage_clear_prefix(kApiKeyPrefix);
|
||||
}
|
||||
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
bool WasmSecureStorage::IsStorageAvailable() {
|
||||
return js_is_storage_available() != 0;
|
||||
}
|
||||
|
||||
absl::StatusOr<WasmSecureStorage::StorageQuota> WasmSecureStorage::GetStorageQuota(
|
||||
StorageType storage_type) {
|
||||
// Browser storage APIs don't provide direct quota information
|
||||
// We can estimate based on typical limits
|
||||
StorageQuota quota;
|
||||
|
||||
if (storage_type == StorageType::kSession) {
|
||||
// sessionStorage typically has 5-10MB limit
|
||||
quota.available_bytes = 5 * 1024 * 1024; // 5MB estimate
|
||||
} else {
|
||||
// localStorage typically has 5-10MB limit
|
||||
quota.available_bytes = 10 * 1024 * 1024; // 10MB estimate
|
||||
}
|
||||
|
||||
// Estimate used bytes by summing all stored values
|
||||
char* keys_str = nullptr;
|
||||
if (storage_type == StorageType::kSession) {
|
||||
keys_str = js_session_storage_keys();
|
||||
} else {
|
||||
keys_str = js_local_storage_keys();
|
||||
}
|
||||
|
||||
size_t used = 0;
|
||||
if (keys_str != nullptr) {
|
||||
std::string keys(keys_str);
|
||||
free(keys_str);
|
||||
|
||||
auto all_keys = SplitString(keys, '|');
|
||||
for (const auto& key : all_keys) {
|
||||
char* value = nullptr;
|
||||
if (storage_type == StorageType::kSession) {
|
||||
value = js_session_storage_get(key.c_str());
|
||||
} else {
|
||||
value = js_local_storage_get(key.c_str());
|
||||
}
|
||||
|
||||
if (value != nullptr) {
|
||||
used += strlen(value) + key.length();
|
||||
free(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
quota.used_bytes = used;
|
||||
return quota;
|
||||
}
|
||||
|
||||
// Private methods implementation
|
||||
|
||||
std::string WasmSecureStorage::BuildApiKeyStorageKey(const std::string& service) {
|
||||
return absl::StrCat(kApiKeyPrefix, service);
|
||||
}
|
||||
|
||||
std::string WasmSecureStorage::BuildSecretStorageKey(const std::string& key) {
|
||||
return absl::StrCat(kSecretPrefix, key);
|
||||
}
|
||||
|
||||
std::string WasmSecureStorage::ExtractServiceFromKey(const std::string& storage_key) {
|
||||
std::string prefix = kApiKeyPrefix;
|
||||
if (storage_key.find(prefix) == 0) {
|
||||
return storage_key.substr(prefix.length());
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
262
src/app/platform/wasm/wasm_secure_storage.h
Normal file
262
src/app/platform/wasm/wasm_secure_storage.h
Normal file
@@ -0,0 +1,262 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_SECURE_STORAGE_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_SECURE_STORAGE_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/status/statusor.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* @class WasmSecureStorage
|
||||
* @brief Secure storage for sensitive data in browser environment
|
||||
*
|
||||
* This class provides secure storage for API keys and other sensitive
|
||||
* data using browser storage APIs. It uses sessionStorage by default
|
||||
* (cleared when tab closes) with optional localStorage for persistence.
|
||||
*
|
||||
* Security considerations:
|
||||
* - API keys are stored in sessionStorage by default (memory only)
|
||||
* - localStorage option available for user convenience (less secure)
|
||||
* - Keys are prefixed with "yaze_secure_" to avoid conflicts
|
||||
* - No encryption currently (future enhancement)
|
||||
*
|
||||
* Storage format:
|
||||
* - Key: "yaze_secure_<service>_<type>"
|
||||
* - Value: Raw string value (API key, token, etc.)
|
||||
*/
|
||||
class WasmSecureStorage {
|
||||
public:
|
||||
/**
|
||||
* @enum StorageType
|
||||
* @brief Type of browser storage to use
|
||||
*/
|
||||
enum class StorageType {
|
||||
kSession, // sessionStorage (cleared on tab close)
|
||||
kLocal // localStorage (persistent)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Store an API key for a service
|
||||
* @param service Service name (e.g., "gemini", "openai")
|
||||
* @param key API key value
|
||||
* @param storage_type Storage type to use
|
||||
* @return Success status
|
||||
*/
|
||||
static absl::Status StoreApiKey(const std::string& service,
|
||||
const std::string& key,
|
||||
StorageType storage_type = StorageType::kSession);
|
||||
|
||||
/**
|
||||
* @brief Retrieve an API key for a service
|
||||
* @param service Service name
|
||||
* @param storage_type Storage type to check
|
||||
* @return API key or NotFound error
|
||||
*/
|
||||
static absl::StatusOr<std::string> RetrieveApiKey(
|
||||
const std::string& service,
|
||||
StorageType storage_type = StorageType::kSession);
|
||||
|
||||
/**
|
||||
* @brief Clear an API key for a service
|
||||
* @param service Service name
|
||||
* @param storage_type Storage type to clear from
|
||||
* @return Success status
|
||||
*/
|
||||
static absl::Status ClearApiKey(const std::string& service,
|
||||
StorageType storage_type = StorageType::kSession);
|
||||
|
||||
/**
|
||||
* @brief Check if an API key exists for a service
|
||||
* @param service Service name
|
||||
* @param storage_type Storage type to check
|
||||
* @return True if key exists
|
||||
*/
|
||||
static bool HasApiKey(const std::string& service,
|
||||
StorageType storage_type = StorageType::kSession);
|
||||
|
||||
/**
|
||||
* @brief Store a generic secret value
|
||||
* @param key Storage key
|
||||
* @param value Secret value
|
||||
* @param storage_type Storage type to use
|
||||
* @return Success status
|
||||
*/
|
||||
static absl::Status StoreSecret(const std::string& key,
|
||||
const std::string& value,
|
||||
StorageType storage_type = StorageType::kSession);
|
||||
|
||||
/**
|
||||
* @brief Retrieve a generic secret value
|
||||
* @param key Storage key
|
||||
* @param storage_type Storage type to check
|
||||
* @return Secret value or NotFound error
|
||||
*/
|
||||
static absl::StatusOr<std::string> RetrieveSecret(
|
||||
const std::string& key,
|
||||
StorageType storage_type = StorageType::kSession);
|
||||
|
||||
/**
|
||||
* @brief Clear a generic secret value
|
||||
* @param key Storage key
|
||||
* @param storage_type Storage type to clear from
|
||||
* @return Success status
|
||||
*/
|
||||
static absl::Status ClearSecret(const std::string& key,
|
||||
StorageType storage_type = StorageType::kSession);
|
||||
|
||||
/**
|
||||
* @brief List all stored API keys (service names only)
|
||||
* @param storage_type Storage type to check
|
||||
* @return List of service names with stored keys
|
||||
*/
|
||||
static std::vector<std::string> ListStoredApiKeys(
|
||||
StorageType storage_type = StorageType::kSession);
|
||||
|
||||
/**
|
||||
* @brief Clear all stored API keys
|
||||
* @param storage_type Storage type to clear
|
||||
* @return Success status
|
||||
*/
|
||||
static absl::Status ClearAllApiKeys(StorageType storage_type = StorageType::kSession);
|
||||
|
||||
/**
|
||||
* @brief Check if browser storage is available
|
||||
* @return True if storage APIs are available
|
||||
*/
|
||||
static bool IsStorageAvailable();
|
||||
|
||||
/**
|
||||
* @brief Get storage quota information
|
||||
* @param storage_type Storage type to check
|
||||
* @return Used and available bytes, or error if not supported
|
||||
*/
|
||||
struct StorageQuota {
|
||||
size_t used_bytes = 0;
|
||||
size_t available_bytes = 0;
|
||||
};
|
||||
static absl::StatusOr<StorageQuota> GetStorageQuota(
|
||||
StorageType storage_type = StorageType::kSession);
|
||||
|
||||
private:
|
||||
// Key prefixes for different types of data
|
||||
static constexpr const char* kApiKeyPrefix = "yaze_secure_api_";
|
||||
static constexpr const char* kSecretPrefix = "yaze_secure_secret_";
|
||||
|
||||
/**
|
||||
* @brief Build storage key for API keys
|
||||
* @param service Service name
|
||||
* @return Full storage key
|
||||
*/
|
||||
static std::string BuildApiKeyStorageKey(const std::string& service);
|
||||
|
||||
/**
|
||||
* @brief Build storage key for secrets
|
||||
* @param key User-provided key
|
||||
* @return Full storage key
|
||||
*/
|
||||
static std::string BuildSecretStorageKey(const std::string& key);
|
||||
|
||||
/**
|
||||
* @brief Extract service name from storage key
|
||||
* @param storage_key Full storage key
|
||||
* @return Service name or empty if not an API key
|
||||
*/
|
||||
static std::string ExtractServiceFromKey(const std::string& storage_key);
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#else // !__EMSCRIPTEN__
|
||||
|
||||
// Stub for non-WASM builds
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/status/statusor.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* Non-WASM stub for WasmSecureStorage.
|
||||
* All methods return Unimplemented/NotFound as secure browser storage is not available.
|
||||
*/
|
||||
class WasmSecureStorage {
|
||||
public:
|
||||
enum class StorageType { kSession, kLocal };
|
||||
|
||||
static absl::Status StoreApiKey(const std::string&, const std::string&,
|
||||
StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Secure storage requires WASM build");
|
||||
}
|
||||
|
||||
static absl::StatusOr<std::string> RetrieveApiKey(
|
||||
const std::string&, StorageType = StorageType::kSession) {
|
||||
return absl::NotFoundError("Secure storage requires WASM build");
|
||||
}
|
||||
|
||||
static absl::Status ClearApiKey(const std::string&,
|
||||
StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Secure storage requires WASM build");
|
||||
}
|
||||
|
||||
static bool HasApiKey(const std::string&,
|
||||
StorageType = StorageType::kSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
static absl::Status StoreSecret(const std::string&, const std::string&,
|
||||
StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Secure storage requires WASM build");
|
||||
}
|
||||
|
||||
static absl::StatusOr<std::string> RetrieveSecret(
|
||||
const std::string&, StorageType = StorageType::kSession) {
|
||||
return absl::NotFoundError("Secure storage requires WASM build");
|
||||
}
|
||||
|
||||
static absl::Status ClearSecret(const std::string&,
|
||||
StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Secure storage requires WASM build");
|
||||
}
|
||||
|
||||
static std::vector<std::string> ListStoredApiKeys(
|
||||
StorageType = StorageType::kSession) {
|
||||
return {};
|
||||
}
|
||||
|
||||
static absl::Status ClearAllApiKeys(StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Secure storage requires WASM build");
|
||||
}
|
||||
|
||||
static bool IsStorageAvailable() { return false; }
|
||||
|
||||
struct StorageQuota {
|
||||
size_t used_bytes = 0;
|
||||
size_t available_bytes = 0;
|
||||
};
|
||||
|
||||
static absl::StatusOr<StorageQuota> GetStorageQuota(
|
||||
StorageType = StorageType::kSession) {
|
||||
return absl::UnimplementedError("Secure storage requires WASM build");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_SECURE_STORAGE_H_
|
||||
681
src/app/platform/wasm/wasm_session_bridge.cc
Normal file
681
src/app/platform/wasm/wasm_session_bridge.cc
Normal file
@@ -0,0 +1,681 @@
|
||||
// clang-format off
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_session_bridge.h"
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/bind.h>
|
||||
|
||||
#include "absl/strings/str_format.h"
|
||||
#include "app/editor/editor.h"
|
||||
#include "app/editor/editor_manager.h"
|
||||
#include "rom/rom.h"
|
||||
#include "nlohmann/json.hpp"
|
||||
#include "util/log.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
// Static member initialization
|
||||
editor::EditorManager* WasmSessionBridge::editor_manager_ = nullptr;
|
||||
bool WasmSessionBridge::initialized_ = false;
|
||||
SharedSessionState WasmSessionBridge::current_state_;
|
||||
std::mutex WasmSessionBridge::state_mutex_;
|
||||
std::vector<WasmSessionBridge::StateChangeCallback> WasmSessionBridge::state_callbacks_;
|
||||
WasmSessionBridge::CommandCallback WasmSessionBridge::command_handler_;
|
||||
std::string WasmSessionBridge::pending_command_;
|
||||
std::string WasmSessionBridge::pending_result_;
|
||||
bool WasmSessionBridge::command_pending_ = false;
|
||||
|
||||
// ============================================================================
|
||||
// SharedSessionState Implementation
|
||||
// ============================================================================
|
||||
|
||||
std::string SharedSessionState::ToJson() const {
|
||||
nlohmann::json j;
|
||||
|
||||
// ROM state
|
||||
j["rom"]["loaded"] = rom_loaded;
|
||||
j["rom"]["filename"] = rom_filename;
|
||||
j["rom"]["title"] = rom_title;
|
||||
j["rom"]["size"] = rom_size;
|
||||
j["rom"]["dirty"] = rom_dirty;
|
||||
|
||||
// Editor state
|
||||
j["editor"]["current"] = current_editor;
|
||||
j["editor"]["type"] = current_editor_type;
|
||||
j["editor"]["visible_cards"] = visible_cards;
|
||||
|
||||
// Session info
|
||||
j["session"]["id"] = session_id;
|
||||
j["session"]["count"] = session_count;
|
||||
j["session"]["name"] = session_name;
|
||||
|
||||
// Feature flags
|
||||
j["flags"]["save_all_palettes"] = flag_save_all_palettes;
|
||||
j["flags"]["save_gfx_groups"] = flag_save_gfx_groups;
|
||||
j["flags"]["save_overworld_maps"] = flag_save_overworld_maps;
|
||||
j["flags"]["load_custom_overworld"] = flag_load_custom_overworld;
|
||||
j["flags"]["apply_zscustom_asm"] = flag_apply_zscustom_asm;
|
||||
|
||||
// Project info
|
||||
j["project"]["name"] = project_name;
|
||||
j["project"]["path"] = project_path;
|
||||
j["project"]["has_project"] = has_project;
|
||||
|
||||
// Z3ed state
|
||||
j["z3ed"]["last_command"] = last_z3ed_command;
|
||||
j["z3ed"]["last_result"] = last_z3ed_result;
|
||||
j["z3ed"]["command_pending"] = z3ed_command_pending;
|
||||
|
||||
return j.dump();
|
||||
}
|
||||
|
||||
SharedSessionState SharedSessionState::FromJson(const std::string& json) {
|
||||
SharedSessionState state;
|
||||
|
||||
try {
|
||||
auto j = nlohmann::json::parse(json);
|
||||
|
||||
// ROM state
|
||||
if (j.contains("rom")) {
|
||||
state.rom_loaded = j["rom"].value("loaded", false);
|
||||
state.rom_filename = j["rom"].value("filename", "");
|
||||
state.rom_title = j["rom"].value("title", "");
|
||||
state.rom_size = j["rom"].value("size", 0);
|
||||
state.rom_dirty = j["rom"].value("dirty", false);
|
||||
}
|
||||
|
||||
// Editor state
|
||||
if (j.contains("editor")) {
|
||||
state.current_editor = j["editor"].value("current", "");
|
||||
state.current_editor_type = j["editor"].value("type", 0);
|
||||
if (j["editor"].contains("visible_cards")) {
|
||||
state.visible_cards = j["editor"]["visible_cards"].get<std::vector<std::string>>();
|
||||
}
|
||||
}
|
||||
|
||||
// Session info
|
||||
if (j.contains("session")) {
|
||||
state.session_id = j["session"].value("id", 0);
|
||||
state.session_count = j["session"].value("count", 1);
|
||||
state.session_name = j["session"].value("name", "");
|
||||
}
|
||||
|
||||
// Feature flags
|
||||
if (j.contains("flags")) {
|
||||
state.flag_save_all_palettes = j["flags"].value("save_all_palettes", false);
|
||||
state.flag_save_gfx_groups = j["flags"].value("save_gfx_groups", false);
|
||||
state.flag_save_overworld_maps = j["flags"].value("save_overworld_maps", true);
|
||||
state.flag_load_custom_overworld = j["flags"].value("load_custom_overworld", false);
|
||||
state.flag_apply_zscustom_asm = j["flags"].value("apply_zscustom_asm", false);
|
||||
}
|
||||
|
||||
// Project info
|
||||
if (j.contains("project")) {
|
||||
state.project_name = j["project"].value("name", "");
|
||||
state.project_path = j["project"].value("path", "");
|
||||
state.has_project = j["project"].value("has_project", false);
|
||||
}
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR("SharedSessionState", "Failed to parse JSON: %s", e.what());
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
void SharedSessionState::UpdateFromEditor(editor::EditorManager* manager) {
|
||||
if (!manager) return;
|
||||
|
||||
// ROM state
|
||||
auto* rom = manager->GetCurrentRom();
|
||||
if (rom && rom->is_loaded()) {
|
||||
rom_loaded = true;
|
||||
rom_filename = rom->filename();
|
||||
rom_title = rom->title();
|
||||
rom_size = rom->size();
|
||||
rom_dirty = rom->dirty();
|
||||
} else {
|
||||
rom_loaded = false;
|
||||
rom_filename = "";
|
||||
rom_title = "";
|
||||
rom_size = 0;
|
||||
rom_dirty = false;
|
||||
}
|
||||
|
||||
// Editor state
|
||||
auto* current = manager->GetCurrentEditor();
|
||||
if (current) {
|
||||
current_editor_type = static_cast<int>(current->type());
|
||||
if (current_editor_type >= 0 &&
|
||||
current_editor_type < static_cast<int>(editor::kEditorNames.size())) {
|
||||
current_editor = editor::kEditorNames[current_editor_type];
|
||||
}
|
||||
}
|
||||
|
||||
// Session info
|
||||
session_id = manager->GetCurrentSessionIndex();
|
||||
session_count = manager->GetActiveSessionCount();
|
||||
|
||||
// Feature flags from global
|
||||
auto& flags = core::FeatureFlags::get();
|
||||
flag_save_all_palettes = flags.kSaveAllPalettes;
|
||||
flag_save_gfx_groups = flags.kSaveGfxGroups;
|
||||
flag_save_overworld_maps = flags.overworld.kSaveOverworldMaps;
|
||||
flag_load_custom_overworld = flags.overworld.kLoadCustomOverworld;
|
||||
flag_apply_zscustom_asm = flags.overworld.kApplyZSCustomOverworldASM;
|
||||
}
|
||||
|
||||
absl::Status SharedSessionState::ApplyToEditor(editor::EditorManager* manager) {
|
||||
if (!manager) {
|
||||
return absl::InvalidArgumentError("EditorManager is null");
|
||||
}
|
||||
|
||||
// Apply feature flags to global
|
||||
auto& flags = core::FeatureFlags::get();
|
||||
flags.kSaveAllPalettes = flag_save_all_palettes;
|
||||
flags.kSaveGfxGroups = flag_save_gfx_groups;
|
||||
flags.overworld.kSaveOverworldMaps = flag_save_overworld_maps;
|
||||
flags.overworld.kLoadCustomOverworld = flag_load_custom_overworld;
|
||||
flags.overworld.kApplyZSCustomOverworldASM = flag_apply_zscustom_asm;
|
||||
|
||||
// Switch editor if changed
|
||||
if (!current_editor.empty()) {
|
||||
for (size_t i = 0; i < editor::kEditorNames.size(); ++i) {
|
||||
if (editor::kEditorNames[i] == current_editor) {
|
||||
manager->SwitchToEditor(static_cast<editor::EditorType>(i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JavaScript Bindings Setup
|
||||
// ============================================================================
|
||||
|
||||
EM_JS(void, SetupYazeSessionApi, (), {
|
||||
if (typeof Module === 'undefined') return;
|
||||
|
||||
// Create unified window.yaze namespace if not exists
|
||||
if (!window.yaze) {
|
||||
window.yaze = {};
|
||||
}
|
||||
|
||||
// Session API namespace
|
||||
window.yaze.session = {
|
||||
// State management
|
||||
getState: function() {
|
||||
if (Module.sessionGetState) {
|
||||
try { return JSON.parse(Module.sessionGetState()); }
|
||||
catch(e) { return {error: e.message}; }
|
||||
}
|
||||
return {error: "API not ready"};
|
||||
},
|
||||
|
||||
setState: function(state) {
|
||||
if (Module.sessionSetState) {
|
||||
try { return JSON.parse(Module.sessionSetState(JSON.stringify(state))); }
|
||||
catch(e) { return {error: e.message}; }
|
||||
}
|
||||
return {error: "API not ready"};
|
||||
},
|
||||
|
||||
getProperty: function(name) {
|
||||
if (Module.sessionGetProperty) {
|
||||
try { return JSON.parse(Module.sessionGetProperty(name)); }
|
||||
catch(e) { return {error: e.message}; }
|
||||
}
|
||||
return {error: "API not ready"};
|
||||
},
|
||||
|
||||
setProperty: function(name, value) {
|
||||
if (Module.sessionSetProperty) {
|
||||
try { return JSON.parse(Module.sessionSetProperty(name, JSON.stringify(value))); }
|
||||
catch(e) { return {error: e.message}; }
|
||||
}
|
||||
return {error: "API not ready"};
|
||||
},
|
||||
|
||||
refresh: function() {
|
||||
if (Module.sessionRefreshState) {
|
||||
try { return JSON.parse(Module.sessionRefreshState()); }
|
||||
catch(e) { return {error: e.message}; }
|
||||
}
|
||||
return {error: "API not ready"};
|
||||
},
|
||||
|
||||
// Feature flags
|
||||
getFlags: function() {
|
||||
if (Module.sessionGetFeatureFlags) {
|
||||
try { return JSON.parse(Module.sessionGetFeatureFlags()); }
|
||||
catch(e) { return {error: e.message}; }
|
||||
}
|
||||
return {error: "API not ready"};
|
||||
},
|
||||
|
||||
setFlag: function(name, value) {
|
||||
if (Module.sessionSetFeatureFlag) {
|
||||
try { return JSON.parse(Module.sessionSetFeatureFlag(name, value)); }
|
||||
catch(e) { return {error: e.message}; }
|
||||
}
|
||||
return {error: "API not ready"};
|
||||
},
|
||||
|
||||
getAvailableFlags: function() {
|
||||
if (Module.sessionGetAvailableFlags) {
|
||||
try { return JSON.parse(Module.sessionGetAvailableFlags()); }
|
||||
catch(e) { return {error: e.message}; }
|
||||
}
|
||||
return {error: "API not ready"};
|
||||
},
|
||||
|
||||
// Z3ed integration
|
||||
executeCommand: function(command) {
|
||||
if (Module.sessionExecuteZ3edCommand) {
|
||||
try { return JSON.parse(Module.sessionExecuteZ3edCommand(command)); }
|
||||
catch(e) { return {error: e.message}; }
|
||||
}
|
||||
return {error: "API not ready"};
|
||||
},
|
||||
|
||||
getPendingCommand: function() {
|
||||
if (Module.sessionGetPendingCommand) {
|
||||
try { return JSON.parse(Module.sessionGetPendingCommand()); }
|
||||
catch(e) { return {error: e.message}; }
|
||||
}
|
||||
return {error: "API not ready"};
|
||||
},
|
||||
|
||||
setCommandResult: function(result) {
|
||||
if (Module.sessionSetCommandResult) {
|
||||
try { return JSON.parse(Module.sessionSetCommandResult(JSON.stringify(result))); }
|
||||
catch(e) { return {error: e.message}; }
|
||||
}
|
||||
return {error: "API not ready"};
|
||||
},
|
||||
|
||||
isReady: function() {
|
||||
return Module.sessionIsReady ? Module.sessionIsReady() : false;
|
||||
}
|
||||
};
|
||||
|
||||
console.log("[yaze] window.yaze.session API initialized");
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// WasmSessionBridge Implementation
|
||||
// ============================================================================
|
||||
|
||||
void WasmSessionBridge::Initialize(editor::EditorManager* editor_manager) {
|
||||
editor_manager_ = editor_manager;
|
||||
initialized_ = (editor_manager_ != nullptr);
|
||||
|
||||
if (initialized_) {
|
||||
SetupJavaScriptBindings();
|
||||
|
||||
// Initialize state from editor
|
||||
std::lock_guard<std::mutex> lock(state_mutex_);
|
||||
current_state_.UpdateFromEditor(editor_manager_);
|
||||
|
||||
LOG_INFO("WasmSessionBridge", "Session bridge initialized");
|
||||
}
|
||||
}
|
||||
|
||||
bool WasmSessionBridge::IsReady() {
|
||||
return initialized_ && editor_manager_ != nullptr;
|
||||
}
|
||||
|
||||
void WasmSessionBridge::SetupJavaScriptBindings() {
|
||||
SetupYazeSessionApi();
|
||||
}
|
||||
|
||||
std::string WasmSessionBridge::GetState() {
|
||||
if (!IsReady()) {
|
||||
return R"({"error": "Session bridge not initialized"})";
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(state_mutex_);
|
||||
current_state_.UpdateFromEditor(editor_manager_);
|
||||
return current_state_.ToJson();
|
||||
}
|
||||
|
||||
std::string WasmSessionBridge::SetState(const std::string& state_json) {
|
||||
nlohmann::json result;
|
||||
|
||||
if (!IsReady()) {
|
||||
result["success"] = false;
|
||||
result["error"] = "Session bridge not initialized";
|
||||
return result.dump();
|
||||
}
|
||||
|
||||
try {
|
||||
std::lock_guard<std::mutex> lock(state_mutex_);
|
||||
auto new_state = SharedSessionState::FromJson(state_json);
|
||||
auto status = new_state.ApplyToEditor(editor_manager_);
|
||||
|
||||
if (status.ok()) {
|
||||
current_state_ = new_state;
|
||||
result["success"] = true;
|
||||
} else {
|
||||
result["success"] = false;
|
||||
result["error"] = status.ToString();
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
result["success"] = false;
|
||||
result["error"] = e.what();
|
||||
}
|
||||
|
||||
return result.dump();
|
||||
}
|
||||
|
||||
std::string WasmSessionBridge::GetProperty(const std::string& property_name) {
|
||||
nlohmann::json result;
|
||||
|
||||
if (!IsReady()) {
|
||||
result["error"] = "Session bridge not initialized";
|
||||
return result.dump();
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(state_mutex_);
|
||||
current_state_.UpdateFromEditor(editor_manager_);
|
||||
|
||||
if (property_name == "rom.loaded") {
|
||||
result["value"] = current_state_.rom_loaded;
|
||||
} else if (property_name == "rom.filename") {
|
||||
result["value"] = current_state_.rom_filename;
|
||||
} else if (property_name == "rom.title") {
|
||||
result["value"] = current_state_.rom_title;
|
||||
} else if (property_name == "rom.size") {
|
||||
result["value"] = current_state_.rom_size;
|
||||
} else if (property_name == "rom.dirty") {
|
||||
result["value"] = current_state_.rom_dirty;
|
||||
} else if (property_name == "editor.current") {
|
||||
result["value"] = current_state_.current_editor;
|
||||
} else if (property_name == "session.id") {
|
||||
result["value"] = current_state_.session_id;
|
||||
} else if (property_name == "session.count") {
|
||||
result["value"] = current_state_.session_count;
|
||||
} else {
|
||||
result["error"] = "Unknown property: " + property_name;
|
||||
}
|
||||
|
||||
return result.dump();
|
||||
}
|
||||
|
||||
std::string WasmSessionBridge::SetProperty(const std::string& property_name,
|
||||
const std::string& value) {
|
||||
nlohmann::json result;
|
||||
|
||||
if (!IsReady()) {
|
||||
result["success"] = false;
|
||||
result["error"] = "Session bridge not initialized";
|
||||
return result.dump();
|
||||
}
|
||||
|
||||
// Most properties are read-only from external sources
|
||||
// Only feature flags can be set
|
||||
if (property_name.find("flags.") == 0) {
|
||||
std::string flag_name = property_name.substr(6);
|
||||
try {
|
||||
bool flag_value = nlohmann::json::parse(value).get<bool>();
|
||||
return SetFeatureFlag(flag_name, flag_value);
|
||||
} catch (const std::exception& e) {
|
||||
result["success"] = false;
|
||||
result["error"] = "Invalid boolean value";
|
||||
}
|
||||
} else {
|
||||
result["success"] = false;
|
||||
result["error"] = "Property is read-only: " + property_name;
|
||||
}
|
||||
|
||||
return result.dump();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Flags
|
||||
// ============================================================================
|
||||
|
||||
std::string WasmSessionBridge::GetFeatureFlags() {
|
||||
nlohmann::json result;
|
||||
|
||||
auto& flags = core::FeatureFlags::get();
|
||||
|
||||
result["save_all_palettes"] = flags.kSaveAllPalettes;
|
||||
result["save_gfx_groups"] = flags.kSaveGfxGroups;
|
||||
result["save_with_change_queue"] = flags.kSaveWithChangeQueue;
|
||||
result["save_dungeon_maps"] = flags.kSaveDungeonMaps;
|
||||
result["save_graphics_sheet"] = flags.kSaveGraphicsSheet;
|
||||
result["log_to_console"] = flags.kLogToConsole;
|
||||
result["enable_performance_monitoring"] = flags.kEnablePerformanceMonitoring;
|
||||
result["enable_tiered_gfx_architecture"] = flags.kEnableTieredGfxArchitecture;
|
||||
result["use_native_file_dialog"] = flags.kUseNativeFileDialog;
|
||||
|
||||
// Overworld flags
|
||||
result["overworld"]["draw_sprites"] = flags.overworld.kDrawOverworldSprites;
|
||||
result["overworld"]["save_maps"] = flags.overworld.kSaveOverworldMaps;
|
||||
result["overworld"]["save_entrances"] = flags.overworld.kSaveOverworldEntrances;
|
||||
result["overworld"]["save_exits"] = flags.overworld.kSaveOverworldExits;
|
||||
result["overworld"]["save_items"] = flags.overworld.kSaveOverworldItems;
|
||||
result["overworld"]["save_properties"] = flags.overworld.kSaveOverworldProperties;
|
||||
result["overworld"]["load_custom"] = flags.overworld.kLoadCustomOverworld;
|
||||
result["overworld"]["apply_zscustom_asm"] = flags.overworld.kApplyZSCustomOverworldASM;
|
||||
|
||||
return result.dump();
|
||||
}
|
||||
|
||||
std::string WasmSessionBridge::SetFeatureFlag(const std::string& flag_name, bool value) {
|
||||
nlohmann::json result;
|
||||
|
||||
auto& flags = core::FeatureFlags::get();
|
||||
bool found = true;
|
||||
|
||||
if (flag_name == "save_all_palettes") {
|
||||
flags.kSaveAllPalettes = value;
|
||||
} else if (flag_name == "save_gfx_groups") {
|
||||
flags.kSaveGfxGroups = value;
|
||||
} else if (flag_name == "save_with_change_queue") {
|
||||
flags.kSaveWithChangeQueue = value;
|
||||
} else if (flag_name == "save_dungeon_maps") {
|
||||
flags.kSaveDungeonMaps = value;
|
||||
} else if (flag_name == "save_graphics_sheet") {
|
||||
flags.kSaveGraphicsSheet = value;
|
||||
} else if (flag_name == "log_to_console") {
|
||||
flags.kLogToConsole = value;
|
||||
} else if (flag_name == "enable_performance_monitoring") {
|
||||
flags.kEnablePerformanceMonitoring = value;
|
||||
} else if (flag_name == "overworld.draw_sprites") {
|
||||
flags.overworld.kDrawOverworldSprites = value;
|
||||
} else if (flag_name == "overworld.save_maps") {
|
||||
flags.overworld.kSaveOverworldMaps = value;
|
||||
} else if (flag_name == "overworld.save_entrances") {
|
||||
flags.overworld.kSaveOverworldEntrances = value;
|
||||
} else if (flag_name == "overworld.save_exits") {
|
||||
flags.overworld.kSaveOverworldExits = value;
|
||||
} else if (flag_name == "overworld.save_items") {
|
||||
flags.overworld.kSaveOverworldItems = value;
|
||||
} else if (flag_name == "overworld.save_properties") {
|
||||
flags.overworld.kSaveOverworldProperties = value;
|
||||
} else if (flag_name == "overworld.load_custom") {
|
||||
flags.overworld.kLoadCustomOverworld = value;
|
||||
} else if (flag_name == "overworld.apply_zscustom_asm") {
|
||||
flags.overworld.kApplyZSCustomOverworldASM = value;
|
||||
} else {
|
||||
found = false;
|
||||
}
|
||||
|
||||
if (found) {
|
||||
result["success"] = true;
|
||||
result["flag"] = flag_name;
|
||||
result["value"] = value;
|
||||
LOG_INFO("WasmSessionBridge", "Set flag %s = %s", flag_name.c_str(), value ? "true" : "false");
|
||||
} else {
|
||||
result["success"] = false;
|
||||
result["error"] = "Unknown flag: " + flag_name;
|
||||
}
|
||||
|
||||
return result.dump();
|
||||
}
|
||||
|
||||
std::string WasmSessionBridge::GetAvailableFlags() {
|
||||
nlohmann::json result = nlohmann::json::array();
|
||||
|
||||
result.push_back("save_all_palettes");
|
||||
result.push_back("save_gfx_groups");
|
||||
result.push_back("save_with_change_queue");
|
||||
result.push_back("save_dungeon_maps");
|
||||
result.push_back("save_graphics_sheet");
|
||||
result.push_back("log_to_console");
|
||||
result.push_back("enable_performance_monitoring");
|
||||
result.push_back("enable_tiered_gfx_architecture");
|
||||
result.push_back("overworld.draw_sprites");
|
||||
result.push_back("overworld.save_maps");
|
||||
result.push_back("overworld.save_entrances");
|
||||
result.push_back("overworld.save_exits");
|
||||
result.push_back("overworld.save_items");
|
||||
result.push_back("overworld.save_properties");
|
||||
result.push_back("overworld.load_custom");
|
||||
result.push_back("overworld.apply_zscustom_asm");
|
||||
|
||||
return result.dump();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Z3ed Command Integration
|
||||
// ============================================================================
|
||||
|
||||
std::string WasmSessionBridge::ExecuteZ3edCommand(const std::string& command) {
|
||||
nlohmann::json result;
|
||||
|
||||
if (!IsReady()) {
|
||||
result["success"] = false;
|
||||
result["error"] = "Session bridge not initialized";
|
||||
return result.dump();
|
||||
}
|
||||
|
||||
// If we have a command handler, use it directly
|
||||
if (command_handler_) {
|
||||
std::string output = command_handler_(command);
|
||||
result["success"] = true;
|
||||
result["output"] = output;
|
||||
|
||||
std::lock_guard<std::mutex> lock(state_mutex_);
|
||||
current_state_.last_z3ed_command = command;
|
||||
current_state_.last_z3ed_result = output;
|
||||
return result.dump();
|
||||
}
|
||||
|
||||
// Otherwise, queue for external CLI
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_mutex_);
|
||||
pending_command_ = command;
|
||||
command_pending_ = true;
|
||||
current_state_.z3ed_command_pending = true;
|
||||
current_state_.last_z3ed_command = command;
|
||||
}
|
||||
|
||||
result["success"] = true;
|
||||
result["queued"] = true;
|
||||
result["command"] = command;
|
||||
|
||||
LOG_INFO("WasmSessionBridge", "Queued z3ed command: %s", command.c_str());
|
||||
return result.dump();
|
||||
}
|
||||
|
||||
std::string WasmSessionBridge::GetPendingCommand() {
|
||||
nlohmann::json result;
|
||||
|
||||
std::lock_guard<std::mutex> lock(state_mutex_);
|
||||
|
||||
if (command_pending_) {
|
||||
result["pending"] = true;
|
||||
result["command"] = pending_command_;
|
||||
} else {
|
||||
result["pending"] = false;
|
||||
}
|
||||
|
||||
return result.dump();
|
||||
}
|
||||
|
||||
std::string WasmSessionBridge::SetCommandResult(const std::string& result_str) {
|
||||
nlohmann::json result;
|
||||
|
||||
std::lock_guard<std::mutex> lock(state_mutex_);
|
||||
|
||||
pending_result_ = result_str;
|
||||
command_pending_ = false;
|
||||
current_state_.z3ed_command_pending = false;
|
||||
current_state_.last_z3ed_result = result_str;
|
||||
|
||||
result["success"] = true;
|
||||
|
||||
return result.dump();
|
||||
}
|
||||
|
||||
void WasmSessionBridge::SetCommandHandler(CommandCallback handler) {
|
||||
command_handler_ = handler;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event System
|
||||
// ============================================================================
|
||||
|
||||
void WasmSessionBridge::OnStateChange(StateChangeCallback callback) {
|
||||
state_callbacks_.push_back(callback);
|
||||
}
|
||||
|
||||
void WasmSessionBridge::NotifyStateChange() {
|
||||
std::lock_guard<std::mutex> lock(state_mutex_);
|
||||
current_state_.UpdateFromEditor(editor_manager_);
|
||||
|
||||
for (const auto& callback : state_callbacks_) {
|
||||
if (callback) {
|
||||
callback(current_state_);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string WasmSessionBridge::RefreshState() {
|
||||
if (!IsReady()) {
|
||||
return R"({"error": "Session bridge not initialized"})";
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(state_mutex_);
|
||||
current_state_.UpdateFromEditor(editor_manager_);
|
||||
|
||||
nlohmann::json result;
|
||||
result["success"] = true;
|
||||
result["state"] = nlohmann::json::parse(current_state_.ToJson());
|
||||
|
||||
return result.dump();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Emscripten Bindings
|
||||
// ============================================================================
|
||||
|
||||
EMSCRIPTEN_BINDINGS(wasm_session_bridge) {
|
||||
emscripten::function("sessionIsReady", &WasmSessionBridge::IsReady);
|
||||
emscripten::function("sessionGetState", &WasmSessionBridge::GetState);
|
||||
emscripten::function("sessionSetState", &WasmSessionBridge::SetState);
|
||||
emscripten::function("sessionGetProperty", &WasmSessionBridge::GetProperty);
|
||||
emscripten::function("sessionSetProperty", &WasmSessionBridge::SetProperty);
|
||||
emscripten::function("sessionGetFeatureFlags", &WasmSessionBridge::GetFeatureFlags);
|
||||
emscripten::function("sessionSetFeatureFlag", &WasmSessionBridge::SetFeatureFlag);
|
||||
emscripten::function("sessionGetAvailableFlags", &WasmSessionBridge::GetAvailableFlags);
|
||||
emscripten::function("sessionExecuteZ3edCommand", &WasmSessionBridge::ExecuteZ3edCommand);
|
||||
emscripten::function("sessionGetPendingCommand", &WasmSessionBridge::GetPendingCommand);
|
||||
emscripten::function("sessionSetCommandResult", &WasmSessionBridge::SetCommandResult);
|
||||
emscripten::function("sessionRefreshState", &WasmSessionBridge::RefreshState);
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
267
src/app/platform/wasm/wasm_session_bridge.h
Normal file
267
src/app/platform/wasm/wasm_session_bridge.h
Normal file
@@ -0,0 +1,267 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_SESSION_BRIDGE_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_SESSION_BRIDGE_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "core/features.h"
|
||||
|
||||
namespace yaze {
|
||||
|
||||
// Forward declarations
|
||||
class Rom;
|
||||
|
||||
namespace editor {
|
||||
class EditorManager;
|
||||
} // namespace editor
|
||||
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* @brief Shared session state structure for IPC between WASM and z3ed
|
||||
*
|
||||
* This structure contains all the state that needs to be synchronized
|
||||
* between the browser-based WASM app and the z3ed CLI tool.
|
||||
*/
|
||||
struct SharedSessionState {
|
||||
// ROM state
|
||||
bool rom_loaded = false;
|
||||
std::string rom_filename;
|
||||
std::string rom_title;
|
||||
size_t rom_size = 0;
|
||||
bool rom_dirty = false;
|
||||
|
||||
// Editor state
|
||||
std::string current_editor;
|
||||
int current_editor_type = 0;
|
||||
std::vector<std::string> visible_cards;
|
||||
|
||||
// Session info
|
||||
size_t session_id = 0;
|
||||
size_t session_count = 1;
|
||||
std::string session_name;
|
||||
|
||||
// Feature flags (serializable subset)
|
||||
bool flag_save_all_palettes = false;
|
||||
bool flag_save_gfx_groups = false;
|
||||
bool flag_save_overworld_maps = true;
|
||||
bool flag_load_custom_overworld = false;
|
||||
bool flag_apply_zscustom_asm = false;
|
||||
|
||||
// Project info
|
||||
std::string project_name;
|
||||
std::string project_path;
|
||||
bool has_project = false;
|
||||
|
||||
// Z3ed integration
|
||||
std::string last_z3ed_command;
|
||||
std::string last_z3ed_result;
|
||||
bool z3ed_command_pending = false;
|
||||
|
||||
// Serialize to JSON string
|
||||
std::string ToJson() const;
|
||||
|
||||
// Deserialize from JSON string
|
||||
static SharedSessionState FromJson(const std::string& json);
|
||||
|
||||
// Update from current EditorManager state
|
||||
void UpdateFromEditor(editor::EditorManager* manager);
|
||||
|
||||
// Apply changes to EditorManager
|
||||
absl::Status ApplyToEditor(editor::EditorManager* manager);
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Session bridge for bidirectional state sync
|
||||
*
|
||||
* Provides:
|
||||
* - window.yaze.session.* JavaScript API
|
||||
* - State change event notifications
|
||||
* - z3ed command execution and result retrieval
|
||||
* - Feature flag synchronization
|
||||
*/
|
||||
class WasmSessionBridge {
|
||||
public:
|
||||
// Callback types
|
||||
using StateChangeCallback = std::function<void(const SharedSessionState&)>;
|
||||
using CommandCallback = std::function<std::string(const std::string&)>;
|
||||
|
||||
/**
|
||||
* @brief Initialize the session bridge
|
||||
* @param editor_manager Pointer to the main editor manager
|
||||
*/
|
||||
static void Initialize(editor::EditorManager* editor_manager);
|
||||
|
||||
/**
|
||||
* @brief Check if the session bridge is ready
|
||||
*/
|
||||
static bool IsReady();
|
||||
|
||||
/**
|
||||
* @brief Setup JavaScript bindings for window.yaze.session
|
||||
*/
|
||||
static void SetupJavaScriptBindings();
|
||||
|
||||
/**
|
||||
* @brief Get current session state as JSON
|
||||
*/
|
||||
static std::string GetState();
|
||||
|
||||
/**
|
||||
* @brief Set session state from JSON (for external updates)
|
||||
*/
|
||||
static std::string SetState(const std::string& state_json);
|
||||
|
||||
/**
|
||||
* @brief Get specific state property
|
||||
*/
|
||||
static std::string GetProperty(const std::string& property_name);
|
||||
|
||||
/**
|
||||
* @brief Set specific state property
|
||||
*/
|
||||
static std::string SetProperty(const std::string& property_name,
|
||||
const std::string& value);
|
||||
|
||||
// ============================================================================
|
||||
// Feature Flags
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Get all feature flags as JSON
|
||||
*/
|
||||
static std::string GetFeatureFlags();
|
||||
|
||||
/**
|
||||
* @brief Set a feature flag by name
|
||||
*/
|
||||
static std::string SetFeatureFlag(const std::string& flag_name, bool value);
|
||||
|
||||
/**
|
||||
* @brief Get available feature flag names
|
||||
*/
|
||||
static std::string GetAvailableFlags();
|
||||
|
||||
// ============================================================================
|
||||
// Z3ed Command Integration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Execute a z3ed command
|
||||
* @param command The z3ed command string
|
||||
* @return JSON result with output or error
|
||||
*/
|
||||
static std::string ExecuteZ3edCommand(const std::string& command);
|
||||
|
||||
/**
|
||||
* @brief Get pending z3ed command (for CLI polling)
|
||||
*/
|
||||
static std::string GetPendingCommand();
|
||||
|
||||
/**
|
||||
* @brief Set z3ed command result (from CLI)
|
||||
*/
|
||||
static std::string SetCommandResult(const std::string& result);
|
||||
|
||||
/**
|
||||
* @brief Register callback for z3ed commands
|
||||
*/
|
||||
static void SetCommandHandler(CommandCallback handler);
|
||||
|
||||
// ============================================================================
|
||||
// Event System
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Subscribe to state changes
|
||||
*/
|
||||
static void OnStateChange(StateChangeCallback callback);
|
||||
|
||||
/**
|
||||
* @brief Notify subscribers of state change
|
||||
*/
|
||||
static void NotifyStateChange();
|
||||
|
||||
/**
|
||||
* @brief Force state refresh from EditorManager
|
||||
*/
|
||||
static std::string RefreshState();
|
||||
|
||||
private:
|
||||
static editor::EditorManager* editor_manager_;
|
||||
static bool initialized_;
|
||||
static SharedSessionState current_state_;
|
||||
static std::mutex state_mutex_;
|
||||
static std::vector<StateChangeCallback> state_callbacks_;
|
||||
static CommandCallback command_handler_;
|
||||
|
||||
// Pending z3ed command queue
|
||||
static std::string pending_command_;
|
||||
static std::string pending_result_;
|
||||
static bool command_pending_;
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#else // !__EMSCRIPTEN__
|
||||
|
||||
// Stub for non-WASM builds
|
||||
#include <string>
|
||||
#include <functional>
|
||||
|
||||
namespace yaze {
|
||||
namespace editor {
|
||||
class EditorManager;
|
||||
}
|
||||
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
struct SharedSessionState {
|
||||
bool rom_loaded = false;
|
||||
std::string rom_filename;
|
||||
std::string ToJson() const { return "{}"; }
|
||||
static SharedSessionState FromJson(const std::string&) { return {}; }
|
||||
void UpdateFromEditor(editor::EditorManager*) {}
|
||||
};
|
||||
|
||||
class WasmSessionBridge {
|
||||
public:
|
||||
using StateChangeCallback = std::function<void(const SharedSessionState&)>;
|
||||
using CommandCallback = std::function<std::string(const std::string&)>;
|
||||
|
||||
static void Initialize(editor::EditorManager*) {}
|
||||
static bool IsReady() { return false; }
|
||||
static void SetupJavaScriptBindings() {}
|
||||
static std::string GetState() { return "{}"; }
|
||||
static std::string SetState(const std::string&) { return "{}"; }
|
||||
static std::string GetProperty(const std::string&) { return "{}"; }
|
||||
static std::string SetProperty(const std::string&, const std::string&) { return "{}"; }
|
||||
static std::string GetFeatureFlags() { return "{}"; }
|
||||
static std::string SetFeatureFlag(const std::string&, bool) { return "{}"; }
|
||||
static std::string GetAvailableFlags() { return "[]"; }
|
||||
static std::string ExecuteZ3edCommand(const std::string&) { return "{}"; }
|
||||
static std::string GetPendingCommand() { return "{}"; }
|
||||
static std::string SetCommandResult(const std::string&) { return "{}"; }
|
||||
static void SetCommandHandler(CommandCallback) {}
|
||||
static void OnStateChange(StateChangeCallback) {}
|
||||
static void NotifyStateChange() {}
|
||||
static std::string RefreshState() { return "{}"; }
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_SESSION_BRIDGE_H_
|
||||
|
||||
501
src/app/platform/wasm/wasm_settings.cc
Normal file
501
src/app/platform/wasm/wasm_settings.cc
Normal file
@@ -0,0 +1,501 @@
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_settings.h"
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
|
||||
#include "absl/strings/str_cat.h"
|
||||
#include "absl/strings/str_format.h"
|
||||
#include "app/gui/core/theme_manager.h"
|
||||
#include "app/platform/font_loader.h"
|
||||
#include "app/platform/wasm/wasm_storage.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
// JavaScript localStorage interface using EM_JS
|
||||
EM_JS(void, localStorage_setItem, (const char* key, const char* value), {
|
||||
try {
|
||||
localStorage.setItem(UTF8ToString(key), UTF8ToString(value));
|
||||
} catch (e) {
|
||||
console.error('Failed to save to localStorage:', e);
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(char*, localStorage_getItem, (const char* key), {
|
||||
try {
|
||||
const value = localStorage.getItem(UTF8ToString(key));
|
||||
if (value === null) return null;
|
||||
const len = lengthBytesUTF8(value) + 1;
|
||||
const ptr = _malloc(len);
|
||||
stringToUTF8(value, ptr, len);
|
||||
return ptr;
|
||||
} catch (e) {
|
||||
console.error('Failed to read from localStorage:', e);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(void, localStorage_removeItem, (const char* key), {
|
||||
try {
|
||||
localStorage.removeItem(UTF8ToString(key));
|
||||
} catch (e) {
|
||||
console.error('Failed to remove from localStorage:', e);
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(int, localStorage_hasItem, (const char* key), {
|
||||
try {
|
||||
return localStorage.getItem(UTF8ToString(key)) !== null ? 1 : 0;
|
||||
} catch (e) {
|
||||
console.error('Failed to check localStorage:', e);
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(void, localStorage_clear, (), {
|
||||
try {
|
||||
// Only clear yaze-specific keys
|
||||
const keys = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith('yaze_')) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
keys.forEach(key => localStorage.removeItem(key));
|
||||
} catch (e) {
|
||||
console.error('Failed to clear localStorage:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Theme Management
|
||||
|
||||
absl::Status WasmSettings::SaveTheme(const std::string& theme) {
|
||||
localStorage_setItem(kThemeKey, theme.c_str());
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
std::string WasmSettings::LoadTheme() {
|
||||
char* theme = localStorage_getItem(kThemeKey);
|
||||
if (!theme) {
|
||||
return "dark"; // Default theme
|
||||
}
|
||||
std::string result(theme);
|
||||
free(theme);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string WasmSettings::GetCurrentThemeData() {
|
||||
return gui::ThemeManager::Get().ExportCurrentThemeJson();
|
||||
}
|
||||
|
||||
absl::Status WasmSettings::LoadUserFont(const std::string& name,
|
||||
const std::string& data, float size) {
|
||||
return LoadFontFromMemory(name, data, size);
|
||||
}
|
||||
|
||||
// Recent Files Management
|
||||
|
||||
nlohmann::json WasmSettings::RecentFilesToJson(
|
||||
const std::vector<RecentFile>& files) {
|
||||
nlohmann::json json_array = nlohmann::json::array();
|
||||
for (const auto& file : files) {
|
||||
nlohmann::json entry;
|
||||
entry["filename"] = file.filename;
|
||||
entry["timestamp"] =
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
file.timestamp.time_since_epoch())
|
||||
.count();
|
||||
json_array.push_back(entry);
|
||||
}
|
||||
return json_array;
|
||||
}
|
||||
|
||||
std::vector<WasmSettings::RecentFile> WasmSettings::JsonToRecentFiles(
|
||||
const nlohmann::json& json) {
|
||||
std::vector<RecentFile> files;
|
||||
if (!json.is_array()) return files;
|
||||
|
||||
for (const auto& entry : json) {
|
||||
if (entry.contains("filename") && entry.contains("timestamp")) {
|
||||
RecentFile file;
|
||||
file.filename = entry["filename"].get<std::string>();
|
||||
auto ms = std::chrono::milliseconds(entry["timestamp"].get<int64_t>());
|
||||
file.timestamp = std::chrono::system_clock::time_point(ms);
|
||||
files.push_back(file);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
absl::Status WasmSettings::AddRecentFile(
|
||||
const std::string& filename,
|
||||
std::chrono::system_clock::time_point timestamp) {
|
||||
// Load existing recent files
|
||||
char* json_str = localStorage_getItem(kRecentFilesKey);
|
||||
std::vector<RecentFile> files;
|
||||
|
||||
if (json_str) {
|
||||
try {
|
||||
nlohmann::json json = nlohmann::json::parse(json_str);
|
||||
files = JsonToRecentFiles(json);
|
||||
} catch (const std::exception& e) {
|
||||
// Ignore parse errors and start fresh
|
||||
emscripten_log(EM_LOG_WARN, "Failed to parse recent files: %s", e.what());
|
||||
}
|
||||
free(json_str);
|
||||
}
|
||||
|
||||
// Remove existing entry if present
|
||||
files.erase(
|
||||
std::remove_if(files.begin(), files.end(),
|
||||
[&filename](const RecentFile& f) {
|
||||
return f.filename == filename;
|
||||
}),
|
||||
files.end());
|
||||
|
||||
// Add new entry at the beginning
|
||||
files.insert(files.begin(), {filename, timestamp});
|
||||
|
||||
// Limit to 20 recent files
|
||||
if (files.size() > 20) {
|
||||
files.resize(20);
|
||||
}
|
||||
|
||||
// Save back to localStorage
|
||||
nlohmann::json json = RecentFilesToJson(files);
|
||||
localStorage_setItem(kRecentFilesKey, json.dump().c_str());
|
||||
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
std::vector<std::string> WasmSettings::GetRecentFiles(size_t max_count) {
|
||||
std::vector<std::string> result;
|
||||
|
||||
char* json_str = localStorage_getItem(kRecentFilesKey);
|
||||
if (!json_str) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
nlohmann::json json = nlohmann::json::parse(json_str);
|
||||
std::vector<RecentFile> files = JsonToRecentFiles(json);
|
||||
|
||||
size_t count = std::min(max_count, files.size());
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
result.push_back(files[i].filename);
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
emscripten_log(EM_LOG_WARN, "Failed to parse recent files: %s", e.what());
|
||||
}
|
||||
|
||||
free(json_str);
|
||||
return result;
|
||||
}
|
||||
|
||||
absl::Status WasmSettings::ClearRecentFiles() {
|
||||
localStorage_removeItem(kRecentFilesKey);
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
absl::Status WasmSettings::RemoveRecentFile(const std::string& filename) {
|
||||
char* json_str = localStorage_getItem(kRecentFilesKey);
|
||||
if (!json_str) {
|
||||
return absl::OkStatus(); // Nothing to remove
|
||||
}
|
||||
|
||||
try {
|
||||
nlohmann::json json = nlohmann::json::parse(json_str);
|
||||
std::vector<RecentFile> files = JsonToRecentFiles(json);
|
||||
|
||||
files.erase(
|
||||
std::remove_if(files.begin(), files.end(),
|
||||
[&filename](const RecentFile& f) {
|
||||
return f.filename == filename;
|
||||
}),
|
||||
files.end());
|
||||
|
||||
nlohmann::json new_json = RecentFilesToJson(files);
|
||||
localStorage_setItem(kRecentFilesKey, new_json.dump().c_str());
|
||||
} catch (const std::exception& e) {
|
||||
free(json_str);
|
||||
return absl::InternalError(
|
||||
absl::StrFormat("Failed to remove recent file: %s", e.what()));
|
||||
}
|
||||
|
||||
free(json_str);
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
// Workspace Layout Management
|
||||
|
||||
absl::Status WasmSettings::SaveWorkspace(const std::string& name,
|
||||
const std::string& layout_json) {
|
||||
std::string key = absl::StrCat(kWorkspacePrefix, name);
|
||||
return WasmStorage::SaveProject(key, layout_json);
|
||||
}
|
||||
|
||||
absl::StatusOr<std::string> WasmSettings::LoadWorkspace(const std::string& name) {
|
||||
std::string key = absl::StrCat(kWorkspacePrefix, name);
|
||||
return WasmStorage::LoadProject(key);
|
||||
}
|
||||
|
||||
std::vector<std::string> WasmSettings::ListWorkspaces() {
|
||||
std::vector<std::string> all_projects = WasmStorage::ListProjects();
|
||||
std::vector<std::string> workspaces;
|
||||
|
||||
const std::string prefix(kWorkspacePrefix);
|
||||
for (const auto& project : all_projects) {
|
||||
if (project.find(prefix) == 0) {
|
||||
workspaces.push_back(project.substr(prefix.length()));
|
||||
}
|
||||
}
|
||||
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
absl::Status WasmSettings::DeleteWorkspace(const std::string& name) {
|
||||
std::string key = absl::StrCat(kWorkspacePrefix, name);
|
||||
return WasmStorage::DeleteProject(key);
|
||||
}
|
||||
|
||||
absl::Status WasmSettings::SetActiveWorkspace(const std::string& name) {
|
||||
localStorage_setItem(kActiveWorkspaceKey, name.c_str());
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
std::string WasmSettings::GetActiveWorkspace() {
|
||||
char* workspace = localStorage_getItem(kActiveWorkspaceKey);
|
||||
if (!workspace) {
|
||||
return "default";
|
||||
}
|
||||
std::string result(workspace);
|
||||
free(workspace);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Undo History Persistence
|
||||
|
||||
absl::Status WasmSettings::SaveUndoHistory(const std::string& editor_id,
|
||||
const std::vector<uint8_t>& history) {
|
||||
std::string key = absl::StrCat(kUndoHistoryPrefix, editor_id);
|
||||
return WasmStorage::SaveRom(key, history); // Use binary storage
|
||||
}
|
||||
|
||||
absl::StatusOr<std::vector<uint8_t>> WasmSettings::LoadUndoHistory(
|
||||
const std::string& editor_id) {
|
||||
std::string key = absl::StrCat(kUndoHistoryPrefix, editor_id);
|
||||
return WasmStorage::LoadRom(key);
|
||||
}
|
||||
|
||||
absl::Status WasmSettings::ClearUndoHistory(const std::string& editor_id) {
|
||||
std::string key = absl::StrCat(kUndoHistoryPrefix, editor_id);
|
||||
return WasmStorage::DeleteRom(key);
|
||||
}
|
||||
|
||||
absl::Status WasmSettings::ClearAllUndoHistory() {
|
||||
std::vector<std::string> all_roms = WasmStorage::ListRoms();
|
||||
const std::string prefix(kUndoHistoryPrefix);
|
||||
|
||||
for (const auto& rom : all_roms) {
|
||||
if (rom.find(prefix) == 0) {
|
||||
auto status = WasmStorage::DeleteRom(rom);
|
||||
if (!status.ok()) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
// General Settings
|
||||
|
||||
absl::Status WasmSettings::SaveSetting(const std::string& key,
|
||||
const nlohmann::json& value) {
|
||||
std::string storage_key = absl::StrCat(kSettingsPrefix, key);
|
||||
localStorage_setItem(storage_key.c_str(), value.dump().c_str());
|
||||
|
||||
// Update last save time
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
now.time_since_epoch()).count();
|
||||
localStorage_setItem(kLastSaveTimeKey, std::to_string(ms).c_str());
|
||||
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
absl::StatusOr<nlohmann::json> WasmSettings::LoadSetting(const std::string& key) {
|
||||
std::string storage_key = absl::StrCat(kSettingsPrefix, key);
|
||||
char* value = localStorage_getItem(storage_key.c_str());
|
||||
|
||||
if (!value) {
|
||||
return absl::NotFoundError(absl::StrFormat("Setting '%s' not found", key));
|
||||
}
|
||||
|
||||
try {
|
||||
nlohmann::json json = nlohmann::json::parse(value);
|
||||
free(value);
|
||||
return json;
|
||||
} catch (const std::exception& e) {
|
||||
free(value);
|
||||
return absl::InvalidArgumentError(
|
||||
absl::StrFormat("Failed to parse setting '%s': %s", key, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
bool WasmSettings::HasSetting(const std::string& key) {
|
||||
std::string storage_key = absl::StrCat(kSettingsPrefix, key);
|
||||
return localStorage_hasItem(storage_key.c_str()) == 1;
|
||||
}
|
||||
|
||||
absl::Status WasmSettings::SaveAllSettings(const nlohmann::json& settings) {
|
||||
if (!settings.is_object()) {
|
||||
return absl::InvalidArgumentError("Settings must be a JSON object");
|
||||
}
|
||||
|
||||
for (auto it = settings.begin(); it != settings.end(); ++it) {
|
||||
auto status = SaveSetting(it.key(), it.value());
|
||||
if (!status.ok()) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
absl::StatusOr<nlohmann::json> WasmSettings::LoadAllSettings() {
|
||||
nlohmann::json settings = nlohmann::json::object();
|
||||
|
||||
// This is a simplified implementation since we can't easily iterate localStorage
|
||||
// from C++. In practice, you'd maintain a list of known setting keys.
|
||||
// For now, we'll just return common settings if they exist.
|
||||
|
||||
std::vector<std::string> common_keys = {
|
||||
"show_grid", "grid_size", "auto_save", "auto_save_interval",
|
||||
"show_tooltips", "confirm_on_delete", "default_editor",
|
||||
"animation_speed", "zoom_level", "show_minimap"
|
||||
};
|
||||
|
||||
for (const auto& key : common_keys) {
|
||||
if (HasSetting(key)) {
|
||||
auto result = LoadSetting(key);
|
||||
if (result.ok()) {
|
||||
settings[key] = *result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
absl::Status WasmSettings::ClearAllSettings() {
|
||||
localStorage_clear();
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
// Utility
|
||||
|
||||
absl::StatusOr<std::string> WasmSettings::ExportSettings() {
|
||||
nlohmann::json export_data = nlohmann::json::object();
|
||||
|
||||
// Export theme
|
||||
export_data["theme"] = LoadTheme();
|
||||
|
||||
// Export recent files
|
||||
char* recent_json = localStorage_getItem(kRecentFilesKey);
|
||||
if (recent_json) {
|
||||
try {
|
||||
export_data["recent_files"] = nlohmann::json::parse(recent_json);
|
||||
} catch (...) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
free(recent_json);
|
||||
}
|
||||
|
||||
// Export active workspace
|
||||
export_data["active_workspace"] = GetActiveWorkspace();
|
||||
|
||||
// Export workspaces
|
||||
nlohmann::json workspaces = nlohmann::json::object();
|
||||
for (const auto& name : ListWorkspaces()) {
|
||||
auto workspace_data = LoadWorkspace(name);
|
||||
if (workspace_data.ok()) {
|
||||
workspaces[name] = nlohmann::json::parse(*workspace_data);
|
||||
}
|
||||
}
|
||||
export_data["workspaces"] = workspaces;
|
||||
|
||||
// Export general settings
|
||||
auto all_settings = LoadAllSettings();
|
||||
if (all_settings.ok()) {
|
||||
export_data["settings"] = *all_settings;
|
||||
}
|
||||
|
||||
return export_data.dump(2); // Pretty print with 2 spaces
|
||||
}
|
||||
|
||||
absl::Status WasmSettings::ImportSettings(const std::string& json_str) {
|
||||
try {
|
||||
nlohmann::json import_data = nlohmann::json::parse(json_str);
|
||||
|
||||
// Import theme
|
||||
if (import_data.contains("theme")) {
|
||||
SaveTheme(import_data["theme"].get<std::string>());
|
||||
}
|
||||
|
||||
// Import recent files
|
||||
if (import_data.contains("recent_files")) {
|
||||
localStorage_setItem(kRecentFilesKey,
|
||||
import_data["recent_files"].dump().c_str());
|
||||
}
|
||||
|
||||
// Import active workspace
|
||||
if (import_data.contains("active_workspace")) {
|
||||
SetActiveWorkspace(import_data["active_workspace"].get<std::string>());
|
||||
}
|
||||
|
||||
// Import workspaces
|
||||
if (import_data.contains("workspaces") && import_data["workspaces"].is_object()) {
|
||||
for (auto it = import_data["workspaces"].begin();
|
||||
it != import_data["workspaces"].end(); ++it) {
|
||||
SaveWorkspace(it.key(), it.value().dump());
|
||||
}
|
||||
}
|
||||
|
||||
// Import general settings
|
||||
if (import_data.contains("settings") && import_data["settings"].is_object()) {
|
||||
SaveAllSettings(import_data["settings"]);
|
||||
}
|
||||
|
||||
return absl::OkStatus();
|
||||
} catch (const std::exception& e) {
|
||||
return absl::InvalidArgumentError(
|
||||
absl::StrFormat("Failed to import settings: %s", e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
absl::StatusOr<std::chrono::system_clock::time_point> WasmSettings::GetLastSaveTime() {
|
||||
char* time_str = localStorage_getItem(kLastSaveTimeKey);
|
||||
if (!time_str) {
|
||||
return absl::NotFoundError("No save time recorded");
|
||||
}
|
||||
|
||||
try {
|
||||
int64_t ms = std::stoll(time_str);
|
||||
free(time_str);
|
||||
return std::chrono::system_clock::time_point(std::chrono::milliseconds(ms));
|
||||
} catch (const std::exception& e) {
|
||||
free(time_str);
|
||||
return absl::InvalidArgumentError(
|
||||
absl::StrFormat("Failed to parse save time: %s", e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
263
src/app/platform/wasm/wasm_settings.h
Normal file
263
src/app/platform/wasm/wasm_settings.h
Normal file
@@ -0,0 +1,263 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_SETTINGS_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_SETTINGS_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <chrono>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/status/statusor.h"
|
||||
#include "nlohmann/json.hpp"
|
||||
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* @class WasmSettings
|
||||
* @brief Browser-based settings persistence for WASM builds
|
||||
*
|
||||
* This class provides persistent storage for user preferences, recent files,
|
||||
* workspace layouts, and undo history using browser localStorage and IndexedDB.
|
||||
* All methods are static and thread-safe.
|
||||
*/
|
||||
class WasmSettings {
|
||||
public:
|
||||
// Theme Management
|
||||
|
||||
/**
|
||||
* @brief Save the current theme selection
|
||||
* @param theme Theme name (e.g., "dark", "light", "classic")
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status SaveTheme(const std::string& theme);
|
||||
|
||||
/**
|
||||
* @brief Load the saved theme selection
|
||||
* @return Theme name or default if not found
|
||||
*/
|
||||
static std::string LoadTheme();
|
||||
|
||||
/**
|
||||
* @brief Get the full JSON data for the current theme
|
||||
* @return JSON string containing all theme colors and style settings
|
||||
*/
|
||||
static std::string GetCurrentThemeData();
|
||||
|
||||
/**
|
||||
* @brief Load a user-provided font from binary data
|
||||
* @param name Font name
|
||||
* @param data Binary font data (TTF/OTF)
|
||||
* @param size Font size in pixels
|
||||
*/
|
||||
static absl::Status LoadUserFont(const std::string& name,
|
||||
const std::string& data, float size);
|
||||
|
||||
// Recent Files Management
|
||||
|
||||
/**
|
||||
* @brief Add a file to the recent files list
|
||||
* @param filename Name/identifier of the file
|
||||
* @param timestamp Optional timestamp (uses current time if not provided)
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status AddRecentFile(
|
||||
const std::string& filename,
|
||||
std::chrono::system_clock::time_point timestamp =
|
||||
std::chrono::system_clock::now());
|
||||
|
||||
/**
|
||||
* @brief Get the list of recent files
|
||||
* @param max_count Maximum number of files to return (default 10)
|
||||
* @return Vector of recent file names, newest first
|
||||
*/
|
||||
static std::vector<std::string> GetRecentFiles(size_t max_count = 10);
|
||||
|
||||
/**
|
||||
* @brief Clear the recent files list
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status ClearRecentFiles();
|
||||
|
||||
/**
|
||||
* @brief Remove a specific file from the recent files list
|
||||
* @param filename Name of the file to remove
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status RemoveRecentFile(const std::string& filename);
|
||||
|
||||
// Workspace Layout Management
|
||||
|
||||
/**
|
||||
* @brief Save a workspace layout configuration
|
||||
* @param name Workspace name (e.g., "default", "debugging", "art")
|
||||
* @param layout_json JSON string containing layout configuration
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status SaveWorkspace(const std::string& name,
|
||||
const std::string& layout_json);
|
||||
|
||||
/**
|
||||
* @brief Load a workspace layout configuration
|
||||
* @param name Workspace name to load
|
||||
* @return JSON string containing layout or error
|
||||
*/
|
||||
static absl::StatusOr<std::string> LoadWorkspace(const std::string& name);
|
||||
|
||||
/**
|
||||
* @brief List all saved workspace names
|
||||
* @return Vector of workspace names
|
||||
*/
|
||||
static std::vector<std::string> ListWorkspaces();
|
||||
|
||||
/**
|
||||
* @brief Delete a workspace layout
|
||||
* @param name Workspace name to delete
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status DeleteWorkspace(const std::string& name);
|
||||
|
||||
/**
|
||||
* @brief Set the active workspace
|
||||
* @param name Name of the workspace to make active
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status SetActiveWorkspace(const std::string& name);
|
||||
|
||||
/**
|
||||
* @brief Get the name of the active workspace
|
||||
* @return Name of active workspace or "default" if none set
|
||||
*/
|
||||
static std::string GetActiveWorkspace();
|
||||
|
||||
// Undo History Persistence (for crash recovery)
|
||||
|
||||
/**
|
||||
* @brief Save undo history for an editor
|
||||
* @param editor_id Editor identifier (e.g., "overworld", "dungeon")
|
||||
* @param history Serialized undo history data
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status SaveUndoHistory(const std::string& editor_id,
|
||||
const std::vector<uint8_t>& history);
|
||||
|
||||
/**
|
||||
* @brief Load undo history for an editor
|
||||
* @param editor_id Editor identifier
|
||||
* @return Undo history data or error
|
||||
*/
|
||||
static absl::StatusOr<std::vector<uint8_t>> LoadUndoHistory(
|
||||
const std::string& editor_id);
|
||||
|
||||
/**
|
||||
* @brief Clear undo history for an editor
|
||||
* @param editor_id Editor identifier
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status ClearUndoHistory(const std::string& editor_id);
|
||||
|
||||
/**
|
||||
* @brief Clear all undo histories
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status ClearAllUndoHistory();
|
||||
|
||||
// General Settings
|
||||
|
||||
/**
|
||||
* @brief Save a general setting value
|
||||
* @param key Setting key
|
||||
* @param value Setting value as JSON
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status SaveSetting(const std::string& key,
|
||||
const nlohmann::json& value);
|
||||
|
||||
/**
|
||||
* @brief Load a general setting value
|
||||
* @param key Setting key
|
||||
* @return Setting value as JSON or error
|
||||
*/
|
||||
static absl::StatusOr<nlohmann::json> LoadSetting(const std::string& key);
|
||||
|
||||
/**
|
||||
* @brief Check if a setting exists
|
||||
* @param key Setting key
|
||||
* @return true if setting exists
|
||||
*/
|
||||
static bool HasSetting(const std::string& key);
|
||||
|
||||
/**
|
||||
* @brief Save all settings as a batch
|
||||
* @param settings JSON object containing all settings
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status SaveAllSettings(const nlohmann::json& settings);
|
||||
|
||||
/**
|
||||
* @brief Load all settings
|
||||
* @return JSON object containing all settings or error
|
||||
*/
|
||||
static absl::StatusOr<nlohmann::json> LoadAllSettings();
|
||||
|
||||
/**
|
||||
* @brief Clear all settings (reset to defaults)
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status ClearAllSettings();
|
||||
|
||||
// Utility
|
||||
|
||||
/**
|
||||
* @brief Export all settings to a JSON string for backup
|
||||
* @return JSON string containing all settings and data
|
||||
*/
|
||||
static absl::StatusOr<std::string> ExportSettings();
|
||||
|
||||
/**
|
||||
* @brief Import settings from a JSON string
|
||||
* @param json_str JSON string containing settings to import
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status ImportSettings(const std::string& json_str);
|
||||
|
||||
/**
|
||||
* @brief Get the last time settings were saved
|
||||
* @return Timestamp of last save or error
|
||||
*/
|
||||
static absl::StatusOr<std::chrono::system_clock::time_point> GetLastSaveTime();
|
||||
|
||||
private:
|
||||
// Storage keys for localStorage
|
||||
static constexpr const char* kThemeKey = "yaze_theme";
|
||||
static constexpr const char* kRecentFilesKey = "yaze_recent_files";
|
||||
static constexpr const char* kActiveWorkspaceKey = "yaze_active_workspace";
|
||||
static constexpr const char* kSettingsPrefix = "yaze_setting_";
|
||||
static constexpr const char* kLastSaveTimeKey = "yaze_last_save_time";
|
||||
|
||||
// Storage keys for IndexedDB (via WasmStorage)
|
||||
static constexpr const char* kWorkspacePrefix = "workspace_";
|
||||
static constexpr const char* kUndoHistoryPrefix = "undo_";
|
||||
|
||||
// Helper structure for recent files
|
||||
struct RecentFile {
|
||||
std::string filename;
|
||||
std::chrono::system_clock::time_point timestamp;
|
||||
};
|
||||
|
||||
// Helper to serialize/deserialize recent files
|
||||
static nlohmann::json RecentFilesToJson(const std::vector<RecentFile>& files);
|
||||
static std::vector<RecentFile> JsonToRecentFiles(const nlohmann::json& json);
|
||||
|
||||
// Prevent instantiation
|
||||
WasmSettings() = delete;
|
||||
~WasmSettings() = delete;
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_SETTINGS_H_
|
||||
543
src/app/platform/wasm/wasm_storage.cc
Normal file
543
src/app/platform/wasm/wasm_storage.cc
Normal file
@@ -0,0 +1,543 @@
|
||||
// clang-format off
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_storage.h"
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/val.h>
|
||||
#include <condition_variable>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
|
||||
#include "absl/strings/str_format.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
// Static member initialization
|
||||
std::atomic<bool> WasmStorage::initialized_{false};
|
||||
|
||||
// JavaScript IndexedDB interface using EM_JS
|
||||
// All functions use yazeAsyncQueue to serialize async operations
|
||||
EM_JS(int, idb_open_database, (const char* db_name, int version), {
|
||||
const asyncify = typeof Asyncify !== 'undefined' ? Asyncify : Module.Asyncify;
|
||||
return asyncify.handleAsync(function() {
|
||||
var dbName = UTF8ToString(db_name); // Must convert before queueing!
|
||||
var operation = function() {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var request = indexedDB.open(dbName, version);
|
||||
request.onerror = function() {
|
||||
console.error('Failed to open IndexedDB:', request.error);
|
||||
resolve(-1);
|
||||
};
|
||||
request.onsuccess = function() {
|
||||
var db = request.result;
|
||||
Module._yazeDB = db;
|
||||
resolve(0);
|
||||
};
|
||||
request.onupgradeneeded = function(event) {
|
||||
var db = event.target.result;
|
||||
if (!db.objectStoreNames.contains('roms')) {
|
||||
db.createObjectStore('roms');
|
||||
}
|
||||
if (!db.objectStoreNames.contains('projects')) {
|
||||
db.createObjectStore('projects');
|
||||
}
|
||||
if (!db.objectStoreNames.contains('preferences')) {
|
||||
db.createObjectStore('preferences');
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
// Use async queue if available to prevent concurrent Asyncify operations
|
||||
if (window.yazeAsyncQueue) {
|
||||
return window.yazeAsyncQueue.enqueue(operation);
|
||||
}
|
||||
return operation();
|
||||
});
|
||||
});
|
||||
|
||||
EM_JS(int, idb_save_binary, (const char* store_name, const char* key, const uint8_t* data, size_t size), {
|
||||
const asyncify = typeof Asyncify !== 'undefined' ? Asyncify : Module.Asyncify;
|
||||
return asyncify.handleAsync(function() {
|
||||
var storeName = UTF8ToString(store_name);
|
||||
var keyStr = UTF8ToString(key);
|
||||
var dataArray = new Uint8Array(HEAPU8.subarray(data, data + size));
|
||||
var operation = function() {
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (!Module._yazeDB) {
|
||||
console.error('Database not initialized');
|
||||
resolve(-1);
|
||||
return;
|
||||
}
|
||||
var transaction = Module._yazeDB.transaction([storeName], 'readwrite');
|
||||
var store = transaction.objectStore(storeName);
|
||||
var request = store.put(dataArray, keyStr);
|
||||
request.onsuccess = function() { resolve(0); };
|
||||
request.onerror = function() {
|
||||
console.error('Failed to save data:', request.error);
|
||||
resolve(-1);
|
||||
};
|
||||
});
|
||||
};
|
||||
if (window.yazeAsyncQueue) {
|
||||
return window.yazeAsyncQueue.enqueue(operation);
|
||||
}
|
||||
return operation();
|
||||
});
|
||||
});
|
||||
|
||||
EM_JS(int, idb_load_binary, (const char* store_name, const char* key, uint8_t** out_data, size_t* out_size), {
|
||||
const asyncify = typeof Asyncify !== 'undefined' ? Asyncify : Module.Asyncify;
|
||||
return asyncify.handleAsync(function() {
|
||||
if (!Module._yazeDB) {
|
||||
console.error('Database not initialized');
|
||||
return -1;
|
||||
}
|
||||
var storeName = UTF8ToString(store_name);
|
||||
var keyStr = UTF8ToString(key);
|
||||
var operation = function() {
|
||||
return new Promise(function(resolve) {
|
||||
var transaction = Module._yazeDB.transaction([storeName], 'readonly');
|
||||
var store = transaction.objectStore(storeName);
|
||||
var request = store.get(keyStr);
|
||||
request.onsuccess = function() {
|
||||
var result = request.result;
|
||||
if (result && result instanceof Uint8Array) {
|
||||
var size = result.length;
|
||||
var ptr = Module._malloc(size);
|
||||
Module.HEAPU8.set(result, ptr);
|
||||
Module.HEAPU32[out_data >> 2] = ptr;
|
||||
Module.HEAPU32[out_size >> 2] = size;
|
||||
resolve(0);
|
||||
} else {
|
||||
resolve(-2);
|
||||
}
|
||||
};
|
||||
request.onerror = function() {
|
||||
console.error('Failed to load data:', request.error);
|
||||
resolve(-1);
|
||||
};
|
||||
});
|
||||
};
|
||||
if (window.yazeAsyncQueue) {
|
||||
return window.yazeAsyncQueue.enqueue(operation);
|
||||
}
|
||||
return operation();
|
||||
});
|
||||
});
|
||||
|
||||
EM_JS(int, idb_save_string, (const char* store_name, const char* key, const char* value), {
|
||||
const asyncify = typeof Asyncify !== 'undefined' ? Asyncify : Module.Asyncify;
|
||||
return asyncify.handleAsync(function() {
|
||||
var storeName = UTF8ToString(store_name);
|
||||
var keyStr = UTF8ToString(key);
|
||||
var valueStr = UTF8ToString(value);
|
||||
var operation = function() {
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (!Module._yazeDB) {
|
||||
console.error('Database not initialized');
|
||||
resolve(-1);
|
||||
return;
|
||||
}
|
||||
var transaction = Module._yazeDB.transaction([storeName], 'readwrite');
|
||||
var store = transaction.objectStore(storeName);
|
||||
var request = store.put(valueStr, keyStr);
|
||||
request.onsuccess = function() { resolve(0); };
|
||||
request.onerror = function() {
|
||||
console.error('Failed to save string:', request.error);
|
||||
resolve(-1);
|
||||
};
|
||||
});
|
||||
};
|
||||
if (window.yazeAsyncQueue) {
|
||||
return window.yazeAsyncQueue.enqueue(operation);
|
||||
}
|
||||
return operation();
|
||||
});
|
||||
});
|
||||
|
||||
EM_JS(char*, idb_load_string, (const char* store_name, const char* key), {
|
||||
const asyncify = typeof Asyncify !== 'undefined' ? Asyncify : Module.Asyncify;
|
||||
return asyncify.handleAsync(function() {
|
||||
if (!Module._yazeDB) {
|
||||
console.error('Database not initialized');
|
||||
return 0;
|
||||
}
|
||||
var storeName = UTF8ToString(store_name);
|
||||
var keyStr = UTF8ToString(key);
|
||||
var operation = function() {
|
||||
return new Promise(function(resolve) {
|
||||
var transaction = Module._yazeDB.transaction([storeName], 'readonly');
|
||||
var store = transaction.objectStore(storeName);
|
||||
var request = store.get(keyStr);
|
||||
request.onsuccess = function() {
|
||||
var result = request.result;
|
||||
if (result && typeof result === 'string') {
|
||||
var len = lengthBytesUTF8(result) + 1;
|
||||
var ptr = Module._malloc(len);
|
||||
stringToUTF8(result, ptr, len);
|
||||
resolve(ptr);
|
||||
} else {
|
||||
resolve(0);
|
||||
}
|
||||
};
|
||||
request.onerror = function() {
|
||||
console.error('Failed to load string:', request.error);
|
||||
resolve(0);
|
||||
};
|
||||
});
|
||||
};
|
||||
if (window.yazeAsyncQueue) {
|
||||
return window.yazeAsyncQueue.enqueue(operation);
|
||||
}
|
||||
return operation();
|
||||
});
|
||||
});
|
||||
|
||||
EM_JS(int, idb_delete_entry, (const char* store_name, const char* key), {
|
||||
const asyncify = typeof Asyncify !== 'undefined' ? Asyncify : Module.Asyncify;
|
||||
return asyncify.handleAsync(function() {
|
||||
var storeName = UTF8ToString(store_name);
|
||||
var keyStr = UTF8ToString(key);
|
||||
var operation = function() {
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (!Module._yazeDB) {
|
||||
console.error('Database not initialized');
|
||||
resolve(-1);
|
||||
return;
|
||||
}
|
||||
var transaction = Module._yazeDB.transaction([storeName], 'readwrite');
|
||||
var store = transaction.objectStore(storeName);
|
||||
var request = store.delete(keyStr);
|
||||
request.onsuccess = function() { resolve(0); };
|
||||
request.onerror = function() {
|
||||
console.error('Failed to delete entry:', request.error);
|
||||
resolve(-1);
|
||||
};
|
||||
});
|
||||
};
|
||||
if (window.yazeAsyncQueue) {
|
||||
return window.yazeAsyncQueue.enqueue(operation);
|
||||
}
|
||||
return operation();
|
||||
});
|
||||
});
|
||||
|
||||
EM_JS(char*, idb_list_keys, (const char* store_name), {
|
||||
const asyncify = typeof Asyncify !== 'undefined' ? Asyncify : Module.Asyncify;
|
||||
return asyncify.handleAsync(function() {
|
||||
if (!Module._yazeDB) {
|
||||
console.error('Database not initialized');
|
||||
return 0;
|
||||
}
|
||||
var storeName = UTF8ToString(store_name);
|
||||
var operation = function() {
|
||||
return new Promise(function(resolve) {
|
||||
var transaction = Module._yazeDB.transaction([storeName], 'readonly');
|
||||
var store = transaction.objectStore(storeName);
|
||||
var request = store.getAllKeys();
|
||||
request.onsuccess = function() {
|
||||
var keys = request.result;
|
||||
var jsonStr = JSON.stringify(keys);
|
||||
var len = lengthBytesUTF8(jsonStr) + 1;
|
||||
var ptr = Module._malloc(len);
|
||||
stringToUTF8(jsonStr, ptr, len);
|
||||
resolve(ptr);
|
||||
};
|
||||
request.onerror = function() {
|
||||
console.error('Failed to list keys:', request.error);
|
||||
resolve(0);
|
||||
};
|
||||
});
|
||||
};
|
||||
if (window.yazeAsyncQueue) {
|
||||
return window.yazeAsyncQueue.enqueue(operation);
|
||||
}
|
||||
return operation();
|
||||
});
|
||||
});
|
||||
|
||||
EM_JS(size_t, idb_get_storage_usage, (), {
|
||||
const asyncify = typeof Asyncify !== 'undefined' ? Asyncify : Module.Asyncify;
|
||||
return asyncify.handleAsync(function() {
|
||||
if (!Module._yazeDB) {
|
||||
console.error('Database not initialized');
|
||||
return 0;
|
||||
}
|
||||
var operation = function() {
|
||||
return new Promise(function(resolve) {
|
||||
var totalSize = 0;
|
||||
var storeNames = ['roms', 'projects', 'preferences'];
|
||||
var completed = 0;
|
||||
|
||||
storeNames.forEach(function(storeName) {
|
||||
var transaction = Module._yazeDB.transaction([storeName], 'readonly');
|
||||
var store = transaction.objectStore(storeName);
|
||||
var request = store.openCursor();
|
||||
|
||||
request.onsuccess = function(event) {
|
||||
var cursor = event.target.result;
|
||||
if (cursor) {
|
||||
var value = cursor.value;
|
||||
if (value instanceof Uint8Array) {
|
||||
totalSize += value.length;
|
||||
} else if (typeof value === 'string') {
|
||||
totalSize += value.length * 2; // UTF-16 estimation
|
||||
} else if (value) {
|
||||
totalSize += JSON.stringify(value).length * 2;
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
completed++;
|
||||
if (completed === storeNames.length) {
|
||||
resolve(totalSize);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = function() {
|
||||
completed++;
|
||||
if (completed === storeNames.length) {
|
||||
resolve(totalSize);
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
if (window.yazeAsyncQueue) {
|
||||
return window.yazeAsyncQueue.enqueue(operation);
|
||||
}
|
||||
return operation();
|
||||
});
|
||||
});
|
||||
|
||||
// Implementation of WasmStorage methods
|
||||
absl::Status WasmStorage::Initialize() {
|
||||
// Use compare_exchange for thread-safe initialization
|
||||
bool expected = false;
|
||||
if (!initialized_.compare_exchange_strong(expected, true)) {
|
||||
return absl::OkStatus(); // Already initialized by another thread
|
||||
}
|
||||
|
||||
int result = idb_open_database(kDatabaseName, kDatabaseVersion);
|
||||
if (result != 0) {
|
||||
initialized_.store(false); // Reset on failure
|
||||
return absl::InternalError("Failed to initialize IndexedDB");
|
||||
}
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
void WasmStorage::EnsureInitialized() {
|
||||
if (!initialized_.load()) {
|
||||
auto status = Initialize();
|
||||
if (!status.ok()) {
|
||||
emscripten_log(EM_LOG_ERROR, "Failed to initialize WasmStorage: %s", status.ToString().c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool WasmStorage::IsStorageAvailable() {
|
||||
EnsureInitialized();
|
||||
return initialized_.load();
|
||||
}
|
||||
|
||||
bool WasmStorage::IsWebContext() {
|
||||
return EM_ASM_INT({
|
||||
return (typeof window !== 'undefined' && typeof indexedDB !== 'undefined') ? 1 : 0;
|
||||
}) == 1;
|
||||
}
|
||||
|
||||
// ROM Storage Operations
|
||||
absl::Status WasmStorage::SaveRom(const std::string& name, const std::vector<uint8_t>& data) {
|
||||
EnsureInitialized();
|
||||
if (!initialized_.load()) {
|
||||
return absl::FailedPreconditionError("Storage not initialized");
|
||||
}
|
||||
int result = idb_save_binary(kRomStoreName, name.c_str(), data.data(), data.size());
|
||||
if (result != 0) {
|
||||
return absl::InternalError(absl::StrFormat("Failed to save ROM '%s'", name));
|
||||
}
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
absl::StatusOr<std::vector<uint8_t>> WasmStorage::LoadRom(const std::string& name) {
|
||||
EnsureInitialized();
|
||||
if (!initialized_.load()) {
|
||||
return absl::FailedPreconditionError("Storage not initialized");
|
||||
}
|
||||
uint8_t* data_ptr = nullptr;
|
||||
size_t data_size = 0;
|
||||
int result = idb_load_binary(kRomStoreName, name.c_str(), &data_ptr, &data_size);
|
||||
if (result == -2) {
|
||||
if (data_ptr) free(data_ptr);
|
||||
return absl::NotFoundError(absl::StrFormat("ROM '%s' not found", name));
|
||||
} else if (result != 0) {
|
||||
if (data_ptr) free(data_ptr);
|
||||
return absl::InternalError(absl::StrFormat("Failed to load ROM '%s'", name));
|
||||
}
|
||||
std::vector<uint8_t> data(data_ptr, data_ptr + data_size);
|
||||
free(data_ptr);
|
||||
return data;
|
||||
}
|
||||
|
||||
absl::Status WasmStorage::DeleteRom(const std::string& name) {
|
||||
EnsureInitialized();
|
||||
if (!initialized_.load()) {
|
||||
return absl::FailedPreconditionError("Storage not initialized");
|
||||
}
|
||||
int result = idb_delete_entry(kRomStoreName, name.c_str());
|
||||
if (result != 0) {
|
||||
return absl::InternalError(absl::StrFormat("Failed to delete ROM '%s'", name));
|
||||
}
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
std::vector<std::string> WasmStorage::ListRoms() {
|
||||
EnsureInitialized();
|
||||
if (!initialized_.load()) {
|
||||
return {};
|
||||
}
|
||||
char* keys_json = idb_list_keys(kRomStoreName);
|
||||
if (!keys_json) {
|
||||
return {};
|
||||
}
|
||||
std::vector<std::string> result;
|
||||
try {
|
||||
nlohmann::json keys = nlohmann::json::parse(keys_json);
|
||||
for (const auto& key : keys) {
|
||||
if (key.is_string()) {
|
||||
result.push_back(key.get<std::string>());
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
emscripten_log(EM_LOG_ERROR, "Failed to parse ROM list: %s", e.what());
|
||||
}
|
||||
free(keys_json);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Project Storage Operations
|
||||
absl::Status WasmStorage::SaveProject(const std::string& name, const std::string& json) {
|
||||
EnsureInitialized();
|
||||
if (!initialized_.load()) {
|
||||
return absl::FailedPreconditionError("Storage not initialized");
|
||||
}
|
||||
int result = idb_save_string(kProjectStoreName, name.c_str(), json.c_str());
|
||||
if (result != 0) {
|
||||
return absl::InternalError(absl::StrFormat("Failed to save project '%s'", name));
|
||||
}
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
absl::StatusOr<std::string> WasmStorage::LoadProject(const std::string& name) {
|
||||
EnsureInitialized();
|
||||
if (!initialized_.load()) {
|
||||
return absl::FailedPreconditionError("Storage not initialized");
|
||||
}
|
||||
char* json_ptr = idb_load_string(kProjectStoreName, name.c_str());
|
||||
if (!json_ptr) {
|
||||
// Note: idb_load_string returns 0 (null) on not found or error,
|
||||
// no memory is allocated in that case, so no free needed here.
|
||||
return absl::NotFoundError(absl::StrFormat("Project '%s' not found", name));
|
||||
}
|
||||
std::string json(json_ptr);
|
||||
free(json_ptr);
|
||||
return json;
|
||||
}
|
||||
|
||||
absl::Status WasmStorage::DeleteProject(const std::string& name) {
|
||||
EnsureInitialized();
|
||||
if (!initialized_.load()) {
|
||||
return absl::FailedPreconditionError("Storage not initialized");
|
||||
}
|
||||
int result = idb_delete_entry(kProjectStoreName, name.c_str());
|
||||
if (result != 0) {
|
||||
return absl::InternalError(absl::StrFormat("Failed to delete project '%s'", name));
|
||||
}
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
std::vector<std::string> WasmStorage::ListProjects() {
|
||||
EnsureInitialized();
|
||||
if (!initialized_.load()) {
|
||||
return {};
|
||||
}
|
||||
char* keys_json = idb_list_keys(kProjectStoreName);
|
||||
if (!keys_json) {
|
||||
return {};
|
||||
}
|
||||
std::vector<std::string> result;
|
||||
try {
|
||||
nlohmann::json keys = nlohmann::json::parse(keys_json);
|
||||
for (const auto& key : keys) {
|
||||
if (key.is_string()) {
|
||||
result.push_back(key.get<std::string>());
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
emscripten_log(EM_LOG_ERROR, "Failed to parse project list: %s", e.what());
|
||||
}
|
||||
free(keys_json);
|
||||
return result;
|
||||
}
|
||||
|
||||
// User Preferences Storage
|
||||
absl::Status WasmStorage::SavePreferences(const nlohmann::json& prefs) {
|
||||
EnsureInitialized();
|
||||
if (!initialized_.load()) {
|
||||
return absl::FailedPreconditionError("Storage not initialized");
|
||||
}
|
||||
std::string json_str = prefs.dump();
|
||||
int result = idb_save_string(kPreferencesStoreName, kPreferencesKey, json_str.c_str());
|
||||
if (result != 0) {
|
||||
return absl::InternalError("Failed to save preferences");
|
||||
}
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
absl::StatusOr<nlohmann::json> WasmStorage::LoadPreferences() {
|
||||
EnsureInitialized();
|
||||
if (!initialized_.load()) {
|
||||
return absl::FailedPreconditionError("Storage not initialized");
|
||||
}
|
||||
char* json_ptr = idb_load_string(kPreferencesStoreName, kPreferencesKey);
|
||||
if (!json_ptr) {
|
||||
return nlohmann::json::object();
|
||||
}
|
||||
try {
|
||||
nlohmann::json prefs = nlohmann::json::parse(json_ptr);
|
||||
free(json_ptr);
|
||||
return prefs;
|
||||
} catch (const std::exception& e) {
|
||||
free(json_ptr);
|
||||
return absl::InvalidArgumentError(absl::StrFormat("Failed to parse preferences: %s", e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
absl::Status WasmStorage::ClearPreferences() {
|
||||
EnsureInitialized();
|
||||
if (!initialized_.load()) {
|
||||
return absl::FailedPreconditionError("Storage not initialized");
|
||||
}
|
||||
int result = idb_delete_entry(kPreferencesStoreName, kPreferencesKey);
|
||||
if (result != 0) {
|
||||
return absl::InternalError("Failed to clear preferences");
|
||||
}
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
// Utility Operations
|
||||
absl::StatusOr<size_t> WasmStorage::GetStorageUsage() {
|
||||
EnsureInitialized();
|
||||
if (!initialized_.load()) {
|
||||
return absl::FailedPreconditionError("Storage not initialized");
|
||||
}
|
||||
return idb_get_storage_usage();
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
// clang-format on
|
||||
167
src/app/platform/wasm/wasm_storage.h
Normal file
167
src/app/platform/wasm/wasm_storage.h
Normal file
@@ -0,0 +1,167 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_WASM_STORAGE_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_WASM_STORAGE_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/status/statusor.h"
|
||||
#include "nlohmann/json.hpp"
|
||||
|
||||
namespace yaze {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* @class WasmStorage
|
||||
* @brief WASM storage implementation using Emscripten IndexedDB
|
||||
*
|
||||
* This class provides persistent storage in the browser using IndexedDB
|
||||
* for ROM files, project data, and user preferences.
|
||||
*
|
||||
* All operations are asynchronous but exposed as synchronous for ease of use.
|
||||
* The implementation uses condition variables to wait for completion.
|
||||
*/
|
||||
class WasmStorage {
|
||||
public:
|
||||
// ROM Storage Operations
|
||||
|
||||
/**
|
||||
* @brief Save ROM data to IndexedDB
|
||||
* @param name Unique name/identifier for the ROM
|
||||
* @param data Binary ROM data
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status SaveRom(const std::string& name,
|
||||
const std::vector<uint8_t>& data);
|
||||
|
||||
/**
|
||||
* @brief Load ROM data from IndexedDB
|
||||
* @param name Name of the ROM to load
|
||||
* @return ROM data or error status
|
||||
*/
|
||||
static absl::StatusOr<std::vector<uint8_t>> LoadRom(const std::string& name);
|
||||
|
||||
/**
|
||||
* @brief Delete a ROM from IndexedDB
|
||||
* @param name Name of the ROM to delete
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status DeleteRom(const std::string& name);
|
||||
|
||||
/**
|
||||
* @brief List all saved ROM names
|
||||
* @return Vector of ROM names in storage
|
||||
*/
|
||||
static std::vector<std::string> ListRoms();
|
||||
|
||||
// Project Storage Operations
|
||||
|
||||
/**
|
||||
* @brief Save project JSON data
|
||||
* @param name Project name/identifier
|
||||
* @param json JSON string containing project data
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status SaveProject(const std::string& name,
|
||||
const std::string& json);
|
||||
|
||||
/**
|
||||
* @brief Load project JSON data
|
||||
* @param name Project name to load
|
||||
* @return JSON string or error status
|
||||
*/
|
||||
static absl::StatusOr<std::string> LoadProject(const std::string& name);
|
||||
|
||||
/**
|
||||
* @brief Delete a project from storage
|
||||
* @param name Project name to delete
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status DeleteProject(const std::string& name);
|
||||
|
||||
/**
|
||||
* @brief List all saved project names
|
||||
* @return Vector of project names in storage
|
||||
*/
|
||||
static std::vector<std::string> ListProjects();
|
||||
|
||||
// User Preferences Storage
|
||||
|
||||
/**
|
||||
* @brief Save user preferences as JSON
|
||||
* @param prefs JSON object containing preferences
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status SavePreferences(const nlohmann::json& prefs);
|
||||
|
||||
/**
|
||||
* @brief Load user preferences
|
||||
* @return JSON preferences or error status
|
||||
*/
|
||||
static absl::StatusOr<nlohmann::json> LoadPreferences();
|
||||
|
||||
/**
|
||||
* @brief Clear all preferences
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status ClearPreferences();
|
||||
|
||||
// Utility Operations
|
||||
|
||||
/**
|
||||
* @brief Get total storage used (in bytes)
|
||||
* @return Total bytes used or error status
|
||||
*/
|
||||
static absl::StatusOr<size_t> GetStorageUsage();
|
||||
|
||||
/**
|
||||
* @brief Check if storage is available and initialized
|
||||
* @return true if IndexedDB is available and ready
|
||||
*/
|
||||
static bool IsStorageAvailable();
|
||||
|
||||
/**
|
||||
* @brief Initialize IndexedDB (called automatically on first use)
|
||||
* @return Status indicating success or failure
|
||||
*/
|
||||
static absl::Status Initialize();
|
||||
|
||||
private:
|
||||
// Database constants
|
||||
static constexpr const char* kDatabaseName = "YazeStorage";
|
||||
static constexpr int kDatabaseVersion = 1;
|
||||
static constexpr const char* kRomStoreName = "roms";
|
||||
static constexpr const char* kProjectStoreName = "projects";
|
||||
static constexpr const char* kPreferencesStoreName = "preferences";
|
||||
static constexpr const char* kPreferencesKey = "user_preferences";
|
||||
|
||||
// Internal helper for async operations
|
||||
struct AsyncResult {
|
||||
bool completed = false;
|
||||
bool success = false;
|
||||
std::string error_message;
|
||||
std::vector<uint8_t> binary_data;
|
||||
std::string string_data;
|
||||
std::vector<std::string> string_list;
|
||||
};
|
||||
|
||||
// Ensure database is initialized
|
||||
static void EnsureInitialized();
|
||||
|
||||
// Check if we're running in a web context
|
||||
static bool IsWebContext();
|
||||
|
||||
// Database initialized flag (thread-safe)
|
||||
static std::atomic<bool> initialized_;
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_WASM_STORAGE_H_
|
||||
626
src/app/platform/wasm/wasm_storage_quota.cc
Normal file
626
src/app/platform/wasm/wasm_storage_quota.cc
Normal file
@@ -0,0 +1,626 @@
|
||||
// clang-format off
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_storage_quota.h"
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/bind.h>
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <queue>
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
namespace {
|
||||
|
||||
// Callback management for async operations
|
||||
struct CallbackManager {
|
||||
static CallbackManager& Get() {
|
||||
static CallbackManager instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
int RegisterStorageCallback(
|
||||
std::function<void(const WasmStorageQuota::StorageInfo&)> cb) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
int id = next_id_++;
|
||||
storage_callbacks_[id] = cb;
|
||||
return id;
|
||||
}
|
||||
|
||||
int RegisterCompressCallback(
|
||||
std::function<void(std::vector<uint8_t>)> cb) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
int id = next_id_++;
|
||||
compress_callbacks_[id] = cb;
|
||||
return id;
|
||||
}
|
||||
|
||||
void InvokeStorageCallback(int id, size_t used, size_t quota, bool persistent) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
auto it = storage_callbacks_.find(id);
|
||||
if (it != storage_callbacks_.end()) {
|
||||
WasmStorageQuota::StorageInfo info;
|
||||
info.used_bytes = used;
|
||||
info.quota_bytes = quota;
|
||||
info.usage_percent = quota > 0 ? (float(used) / float(quota) * 100.0f) : 0.0f;
|
||||
info.persistent = persistent;
|
||||
it->second(info);
|
||||
storage_callbacks_.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
void InvokeCompressCallback(int id, uint8_t* data, size_t size) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
auto it = compress_callbacks_.find(id);
|
||||
if (it != compress_callbacks_.end()) {
|
||||
std::vector<uint8_t> result;
|
||||
if (data && size > 0) {
|
||||
result.assign(data, data + size);
|
||||
free(data); // Free the allocated memory from JS
|
||||
}
|
||||
it->second(result);
|
||||
compress_callbacks_.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
std::mutex mutex_;
|
||||
int next_id_ = 1;
|
||||
std::map<int, std::function<void(const WasmStorageQuota::StorageInfo&)>> storage_callbacks_;
|
||||
std::map<int, std::function<void(std::vector<uint8_t>)>> compress_callbacks_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// External C functions called from JavaScript
|
||||
extern "C" {
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void wasm_storage_quota_estimate_callback(int callback_id, double used,
|
||||
double quota, int persistent) {
|
||||
CallbackManager::Get().InvokeStorageCallback(
|
||||
callback_id, size_t(used), size_t(quota), persistent != 0);
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void wasm_compress_callback(int callback_id, uint8_t* data, size_t size) {
|
||||
CallbackManager::Get().InvokeCompressCallback(callback_id, data, size);
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void wasm_decompress_callback(int callback_id, uint8_t* data, size_t size) {
|
||||
CallbackManager::Get().InvokeCompressCallback(callback_id, data, size);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
||||
// External JS functions declared in header
|
||||
extern void wasm_storage_quota_estimate(int callback_id);
|
||||
extern void wasm_compress_data(const uint8_t* data, size_t size, int callback_id);
|
||||
extern void wasm_decompress_data(const uint8_t* data, size_t size, int callback_id);
|
||||
extern double wasm_get_timestamp_ms();
|
||||
extern int wasm_compression_available();
|
||||
|
||||
// WasmStorageQuota implementation
|
||||
|
||||
WasmStorageQuota& WasmStorageQuota::Get() {
|
||||
static WasmStorageQuota instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
bool WasmStorageQuota::IsSupported() {
|
||||
// Check for required APIs
|
||||
return EM_ASM_INT({
|
||||
return (navigator.storage &&
|
||||
navigator.storage.estimate &&
|
||||
indexedDB) ? 1 : 0;
|
||||
}) != 0;
|
||||
}
|
||||
|
||||
void WasmStorageQuota::GetStorageInfo(
|
||||
std::function<void(const StorageInfo&)> callback) {
|
||||
if (!callback) return;
|
||||
|
||||
// Check if we recently checked (within 5 seconds)
|
||||
double now = wasm_get_timestamp_ms();
|
||||
if (now - last_quota_check_time_.load() < 5000.0 &&
|
||||
last_storage_info_.quota_bytes > 0) {
|
||||
callback(last_storage_info_);
|
||||
return;
|
||||
}
|
||||
|
||||
int callback_id = CallbackManager::Get().RegisterStorageCallback(
|
||||
[this, callback](const StorageInfo& info) {
|
||||
last_storage_info_ = info;
|
||||
last_quota_check_time_.store(wasm_get_timestamp_ms());
|
||||
callback(info);
|
||||
});
|
||||
|
||||
wasm_storage_quota_estimate(callback_id);
|
||||
}
|
||||
|
||||
void WasmStorageQuota::CompressData(
|
||||
const std::vector<uint8_t>& input,
|
||||
std::function<void(std::vector<uint8_t>)> callback) {
|
||||
if (!callback || input.empty()) {
|
||||
if (callback) callback(std::vector<uint8_t>());
|
||||
return;
|
||||
}
|
||||
|
||||
int callback_id = CallbackManager::Get().RegisterCompressCallback(callback);
|
||||
wasm_compress_data(input.data(), input.size(), callback_id);
|
||||
}
|
||||
|
||||
void WasmStorageQuota::DecompressData(
|
||||
const std::vector<uint8_t>& input,
|
||||
std::function<void(std::vector<uint8_t>)> callback) {
|
||||
if (!callback || input.empty()) {
|
||||
if (callback) callback(std::vector<uint8_t>());
|
||||
return;
|
||||
}
|
||||
|
||||
int callback_id = CallbackManager::Get().RegisterCompressCallback(callback);
|
||||
wasm_decompress_data(input.data(), input.size(), callback_id);
|
||||
}
|
||||
|
||||
void WasmStorageQuota::CompressAndStore(
|
||||
const std::string& key,
|
||||
const std::vector<uint8_t>& data,
|
||||
std::function<void(bool success)> callback) {
|
||||
if (key.empty() || data.empty()) {
|
||||
if (callback) callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
size_t original_size = data.size();
|
||||
|
||||
// First compress the data
|
||||
CompressData(data, [this, key, original_size, callback](
|
||||
const std::vector<uint8_t>& compressed) {
|
||||
if (compressed.empty()) {
|
||||
if (callback) callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check quota before storing
|
||||
CheckQuotaAndEvict(compressed.size(), [this, key, compressed, original_size, callback](
|
||||
bool quota_ok) {
|
||||
if (!quota_ok) {
|
||||
if (callback) callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the compressed data
|
||||
StoreCompressedData(key, compressed, original_size, callback);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void WasmStorageQuota::LoadAndDecompress(
|
||||
const std::string& key,
|
||||
std::function<void(std::vector<uint8_t>)> callback) {
|
||||
if (key.empty()) {
|
||||
if (callback) callback(std::vector<uint8_t>());
|
||||
return;
|
||||
}
|
||||
|
||||
// Load compressed data from storage
|
||||
LoadCompressedData(key, [this, key, callback](
|
||||
const std::vector<uint8_t>& compressed,
|
||||
size_t original_size) {
|
||||
if (compressed.empty()) {
|
||||
if (callback) callback(std::vector<uint8_t>());
|
||||
return;
|
||||
}
|
||||
|
||||
// Update access time
|
||||
UpdateAccessTime(key);
|
||||
|
||||
// Decompress the data
|
||||
DecompressData(compressed, callback);
|
||||
});
|
||||
}
|
||||
|
||||
void WasmStorageQuota::StoreCompressedData(
|
||||
const std::string& key,
|
||||
const std::vector<uint8_t>& compressed_data,
|
||||
size_t original_size,
|
||||
std::function<void(bool)> callback) {
|
||||
|
||||
// Use the existing WasmStorage for actual storage
|
||||
EM_ASM({
|
||||
var key = UTF8ToString($0);
|
||||
var dataPtr = $1;
|
||||
var dataSize = $2;
|
||||
var originalSize = $3;
|
||||
var callbackPtr = $4;
|
||||
|
||||
if (!Module._yazeDB) {
|
||||
console.error('[StorageQuota] Database not initialized');
|
||||
Module.dynCall_vi(callbackPtr, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
var data = new Uint8Array(Module.HEAPU8.buffer, dataPtr, dataSize);
|
||||
var metadata = {
|
||||
compressed_size: dataSize,
|
||||
original_size: originalSize,
|
||||
last_access: Date.now(),
|
||||
compression_ratio: originalSize > 0 ? (dataSize / originalSize) : 1.0
|
||||
};
|
||||
|
||||
var transaction = Module._yazeDB.transaction(['roms', 'metadata'], 'readwrite');
|
||||
var romStore = transaction.objectStore('roms');
|
||||
var metaStore = transaction.objectStore('metadata');
|
||||
|
||||
// Store compressed data
|
||||
var dataRequest = romStore.put(data, key);
|
||||
// Store metadata
|
||||
var metaRequest = metaStore.put(metadata, key);
|
||||
|
||||
transaction.oncomplete = function() {
|
||||
Module.dynCall_vi(callbackPtr, 1);
|
||||
};
|
||||
|
||||
transaction.onerror = function() {
|
||||
console.error('[StorageQuota] Failed to store compressed data');
|
||||
Module.dynCall_vi(callbackPtr, 0);
|
||||
};
|
||||
}, key.c_str(), compressed_data.data(), compressed_data.size(),
|
||||
original_size, callback ? new std::function<void(bool)>(callback) : nullptr);
|
||||
|
||||
// Update local metadata cache
|
||||
UpdateMetadata(key, compressed_data.size(), original_size);
|
||||
}
|
||||
|
||||
void WasmStorageQuota::LoadCompressedData(
|
||||
const std::string& key,
|
||||
std::function<void(std::vector<uint8_t>, size_t)> callback) {
|
||||
|
||||
EM_ASM({
|
||||
var key = UTF8ToString($0);
|
||||
var callbackPtr = $1;
|
||||
|
||||
if (!Module._yazeDB) {
|
||||
console.error('[StorageQuota] Database not initialized');
|
||||
Module.dynCall_viii(callbackPtr, 0, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
var transaction = Module._yazeDB.transaction(['roms', 'metadata'], 'readonly');
|
||||
var romStore = transaction.objectStore('roms');
|
||||
var metaStore = transaction.objectStore('metadata');
|
||||
|
||||
var dataRequest = romStore.get(key);
|
||||
var metaRequest = metaStore.get(key);
|
||||
|
||||
var romData = null;
|
||||
var metadata = null;
|
||||
|
||||
dataRequest.onsuccess = function() {
|
||||
romData = dataRequest.result;
|
||||
checkComplete();
|
||||
};
|
||||
|
||||
metaRequest.onsuccess = function() {
|
||||
metadata = metaRequest.result;
|
||||
checkComplete();
|
||||
};
|
||||
|
||||
function checkComplete() {
|
||||
if (romData !== null && metadata !== null) {
|
||||
if (romData && metadata) {
|
||||
var ptr = Module._malloc(romData.length);
|
||||
Module.HEAPU8.set(romData, ptr);
|
||||
Module.dynCall_viii(callbackPtr, ptr, romData.length,
|
||||
metadata.original_size || romData.length);
|
||||
} else {
|
||||
Module.dynCall_viii(callbackPtr, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transaction.onerror = function() {
|
||||
console.error('[StorageQuota] Failed to load compressed data');
|
||||
Module.dynCall_viii(callbackPtr, 0, 0, 0);
|
||||
};
|
||||
}, key.c_str(), callback ? new std::function<void(std::vector<uint8_t>, size_t)>(
|
||||
callback) : nullptr);
|
||||
}
|
||||
|
||||
void WasmStorageQuota::UpdateAccessTime(const std::string& key) {
|
||||
double now = wasm_get_timestamp_ms();
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
access_times_[key] = now;
|
||||
if (item_metadata_.count(key)) {
|
||||
item_metadata_[key].last_access_time = now;
|
||||
}
|
||||
}
|
||||
|
||||
// Update in IndexedDB
|
||||
EM_ASM({
|
||||
var key = UTF8ToString($0);
|
||||
var timestamp = $1;
|
||||
|
||||
if (!Module._yazeDB) return;
|
||||
|
||||
var transaction = Module._yazeDB.transaction(['metadata'], 'readwrite');
|
||||
var store = transaction.objectStore('metadata');
|
||||
|
||||
var request = store.get(key);
|
||||
request.onsuccess = function() {
|
||||
var metadata = request.result || {};
|
||||
metadata.last_access = timestamp;
|
||||
store.put(metadata, key);
|
||||
};
|
||||
}, key.c_str(), now);
|
||||
}
|
||||
|
||||
void WasmStorageQuota::UpdateMetadata(const std::string& key,
|
||||
size_t compressed_size,
|
||||
size_t original_size) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
StorageItem item;
|
||||
item.key = key;
|
||||
item.compressed_size = compressed_size;
|
||||
item.original_size = original_size;
|
||||
item.last_access_time = wasm_get_timestamp_ms();
|
||||
item.compression_ratio = original_size > 0 ?
|
||||
float(compressed_size) / float(original_size) : 1.0f;
|
||||
|
||||
item_metadata_[key] = item;
|
||||
access_times_[key] = item.last_access_time;
|
||||
}
|
||||
|
||||
void WasmStorageQuota::GetStoredItems(
|
||||
std::function<void(std::vector<StorageItem>)> callback) {
|
||||
if (!callback) return;
|
||||
|
||||
LoadMetadata([this, callback]() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
std::vector<StorageItem> items;
|
||||
items.reserve(item_metadata_.size());
|
||||
|
||||
for (const auto& [key, item] : item_metadata_) {
|
||||
items.push_back(item);
|
||||
}
|
||||
|
||||
// Sort by last access time (most recent first)
|
||||
std::sort(items.begin(), items.end(),
|
||||
[](const StorageItem& a, const StorageItem& b) {
|
||||
return a.last_access_time > b.last_access_time;
|
||||
});
|
||||
|
||||
callback(items);
|
||||
});
|
||||
}
|
||||
|
||||
void WasmStorageQuota::LoadMetadata(std::function<void()> callback) {
|
||||
if (metadata_loaded_.load()) {
|
||||
if (callback) callback();
|
||||
return;
|
||||
}
|
||||
|
||||
EM_ASM({
|
||||
var callbackPtr = $0;
|
||||
|
||||
if (!Module._yazeDB) {
|
||||
if (callbackPtr) Module.dynCall_v(callbackPtr);
|
||||
return;
|
||||
}
|
||||
|
||||
var transaction = Module._yazeDB.transaction(['metadata'], 'readonly');
|
||||
var store = transaction.objectStore('metadata');
|
||||
var request = store.getAllKeys();
|
||||
|
||||
request.onsuccess = function() {
|
||||
var keys = request.result;
|
||||
var metadata = {};
|
||||
var pending = keys.length;
|
||||
|
||||
if (pending === 0) {
|
||||
if (callbackPtr) Module.dynCall_v(callbackPtr);
|
||||
return;
|
||||
}
|
||||
|
||||
keys.forEach(function(key) {
|
||||
var getRequest = store.get(key);
|
||||
getRequest.onsuccess = function() {
|
||||
metadata[key] = getRequest.result;
|
||||
pending--;
|
||||
if (pending === 0) {
|
||||
// Pass metadata back to C++
|
||||
Module.storageQuotaMetadata = metadata;
|
||||
if (callbackPtr) Module.dynCall_v(callbackPtr);
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
}, callback ? new std::function<void()>(callback) : nullptr);
|
||||
|
||||
// After JS callback, process the metadata
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
// Access JS metadata object and populate C++ structures
|
||||
emscripten::val metadata = emscripten::val::global("Module")["storageQuotaMetadata"];
|
||||
if (metadata.as<bool>()) {
|
||||
// Process each key in the metadata
|
||||
// Note: This is simplified - in production you'd iterate the JS object properly
|
||||
metadata_loaded_.store(true);
|
||||
}
|
||||
}
|
||||
|
||||
void WasmStorageQuota::EvictOldest(int count,
|
||||
std::function<void(int evicted)> callback) {
|
||||
if (count <= 0) {
|
||||
if (callback) callback(0);
|
||||
return;
|
||||
}
|
||||
|
||||
GetStoredItems([this, count, callback](const std::vector<StorageItem>& items) {
|
||||
// Items are already sorted by access time (newest first)
|
||||
// We want to evict from the end (oldest)
|
||||
int to_evict = std::min(count, static_cast<int>(items.size()));
|
||||
int evicted = 0;
|
||||
|
||||
for (int i = items.size() - to_evict; i < items.size(); ++i) {
|
||||
DeleteItem(items[i].key, [&evicted](bool success) {
|
||||
if (success) evicted++;
|
||||
});
|
||||
}
|
||||
|
||||
if (callback) callback(evicted);
|
||||
});
|
||||
}
|
||||
|
||||
void WasmStorageQuota::EvictToTarget(float target_percent,
|
||||
std::function<void(int evicted)> callback) {
|
||||
if (target_percent <= 0 || target_percent >= 100) {
|
||||
if (callback) callback(0);
|
||||
return;
|
||||
}
|
||||
|
||||
GetStorageInfo([this, target_percent, callback](const StorageInfo& info) {
|
||||
if (info.usage_percent <= target_percent) {
|
||||
if (callback) callback(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate how much space we need to free
|
||||
size_t target_bytes = size_t(info.quota_bytes * target_percent / 100.0f);
|
||||
size_t bytes_to_free = info.used_bytes - target_bytes;
|
||||
|
||||
GetStoredItems([this, bytes_to_free, callback](
|
||||
const std::vector<StorageItem>& items) {
|
||||
size_t freed = 0;
|
||||
int evicted = 0;
|
||||
|
||||
// Evict oldest items until we've freed enough space
|
||||
for (auto it = items.rbegin(); it != items.rend(); ++it) {
|
||||
if (freed >= bytes_to_free) break;
|
||||
|
||||
DeleteItem(it->key, [&evicted, &freed, it](bool success) {
|
||||
if (success) {
|
||||
evicted++;
|
||||
freed += it->compressed_size;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (callback) callback(evicted);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void WasmStorageQuota::CheckQuotaAndEvict(size_t new_size_bytes,
|
||||
std::function<void(bool)> callback) {
|
||||
GetStorageInfo([this, new_size_bytes, callback](const StorageInfo& info) {
|
||||
// Check if we have enough space
|
||||
size_t projected_usage = info.used_bytes + new_size_bytes;
|
||||
float projected_percent = info.quota_bytes > 0 ?
|
||||
(float(projected_usage) / float(info.quota_bytes) * 100.0f) : 100.0f;
|
||||
|
||||
if (projected_percent <= kWarningThreshold) {
|
||||
// Plenty of space available
|
||||
if (callback) callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (projected_percent > kCriticalThreshold) {
|
||||
// Need to evict to make space
|
||||
std::cerr << "[StorageQuota] Approaching quota limit, evicting old ROMs..."
|
||||
<< std::endl;
|
||||
|
||||
EvictToTarget(kDefaultTargetUsage, [callback](int evicted) {
|
||||
std::cerr << "[StorageQuota] Evicted " << evicted << " items"
|
||||
<< std::endl;
|
||||
if (callback) callback(evicted > 0);
|
||||
});
|
||||
} else {
|
||||
// Warning zone but still ok
|
||||
std::cerr << "[StorageQuota] Warning: Storage at " << projected_percent
|
||||
<< "% after this operation" << std::endl;
|
||||
if (callback) callback(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void WasmStorageQuota::DeleteItem(const std::string& key,
|
||||
std::function<void(bool success)> callback) {
|
||||
EM_ASM({
|
||||
var key = UTF8ToString($0);
|
||||
var callbackPtr = $1;
|
||||
|
||||
if (!Module._yazeDB) {
|
||||
if (callbackPtr) Module.dynCall_vi(callbackPtr, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
var transaction = Module._yazeDB.transaction(['roms', 'metadata'], 'readwrite');
|
||||
var romStore = transaction.objectStore('roms');
|
||||
var metaStore = transaction.objectStore('metadata');
|
||||
|
||||
romStore.delete(key);
|
||||
metaStore.delete(key);
|
||||
|
||||
transaction.oncomplete = function() {
|
||||
if (callbackPtr) Module.dynCall_vi(callbackPtr, 1);
|
||||
};
|
||||
|
||||
transaction.onerror = function() {
|
||||
if (callbackPtr) Module.dynCall_vi(callbackPtr, 0);
|
||||
};
|
||||
}, key.c_str(), callback ? new std::function<void(bool)>(callback) : nullptr);
|
||||
|
||||
// Update local cache
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
access_times_.erase(key);
|
||||
item_metadata_.erase(key);
|
||||
}
|
||||
}
|
||||
|
||||
void WasmStorageQuota::ClearAll(std::function<void()> callback) {
|
||||
EM_ASM({
|
||||
var callbackPtr = $0;
|
||||
|
||||
if (!Module._yazeDB) {
|
||||
if (callbackPtr) Module.dynCall_v(callbackPtr);
|
||||
return;
|
||||
}
|
||||
|
||||
var transaction = Module._yazeDB.transaction(['roms', 'metadata'], 'readwrite');
|
||||
var romStore = transaction.objectStore('roms');
|
||||
var metaStore = transaction.objectStore('metadata');
|
||||
|
||||
romStore.clear();
|
||||
metaStore.clear();
|
||||
|
||||
transaction.oncomplete = function() {
|
||||
if (callbackPtr) Module.dynCall_v(callbackPtr);
|
||||
};
|
||||
}, callback ? new std::function<void()>(callback) : nullptr);
|
||||
|
||||
// Clear local cache
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
access_times_.clear();
|
||||
item_metadata_.clear();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
// clang-format on
|
||||
428
src/app/platform/wasm/wasm_storage_quota.h
Normal file
428
src/app/platform/wasm/wasm_storage_quota.h
Normal file
@@ -0,0 +1,428 @@
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_STORAGE_QUOTA_H_
|
||||
#define YAZE_APP_PLATFORM_WASM_STORAGE_QUOTA_H_
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/val.h>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
/**
|
||||
* @brief Manages browser storage quota, compression, and LRU eviction for ROMs
|
||||
*
|
||||
* This class provides efficient storage management for ROM files in the browser:
|
||||
* - Monitors IndexedDB storage usage via navigator.storage.estimate()
|
||||
* - Compresses ROM data using zlib before storage (typically 30-50% compression ratio)
|
||||
* - Implements LRU (Least Recently Used) eviction when approaching quota limits
|
||||
* - Tracks last-access times for intelligent cache management
|
||||
*
|
||||
* Storage Strategy:
|
||||
* - Keep storage usage below 80% of quota to avoid browser warnings
|
||||
* - Compress all ROMs before storage (reduces ~3MB to ~1.5MB typically)
|
||||
* - Evict least recently used ROMs when nearing quota
|
||||
* - Update access times on every ROM load
|
||||
*
|
||||
* Usage Example:
|
||||
* @code
|
||||
* WasmStorageQuota::Get().GetStorageInfo([](const StorageInfo& info) {
|
||||
* printf("Storage: %.1f%% used (%.1fMB / %.1fMB)\n",
|
||||
* info.usage_percent,
|
||||
* info.used_bytes / 1024.0 / 1024.0,
|
||||
* info.quota_bytes / 1024.0 / 1024.0);
|
||||
* });
|
||||
*
|
||||
* // Compress and store a ROM
|
||||
* std::vector<uint8_t> rom_data = LoadRomFromFile();
|
||||
* WasmStorageQuota::Get().CompressAndStore("zelda3.sfc", rom_data,
|
||||
* [](bool success) {
|
||||
* if (success) {
|
||||
* printf("ROM stored successfully with compression\n");
|
||||
* }
|
||||
* });
|
||||
* @endcode
|
||||
*/
|
||||
class WasmStorageQuota {
|
||||
public:
|
||||
/**
|
||||
* @brief Storage information from the browser
|
||||
*/
|
||||
struct StorageInfo {
|
||||
size_t used_bytes = 0; ///< Bytes currently used
|
||||
size_t quota_bytes = 0; ///< Total quota available
|
||||
float usage_percent = 0.0f; ///< Percentage of quota used
|
||||
bool persistent = false; ///< Whether storage is persistent
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Metadata for stored items
|
||||
*/
|
||||
struct StorageItem {
|
||||
std::string key;
|
||||
size_t compressed_size = 0;
|
||||
size_t original_size = 0;
|
||||
double last_access_time = 0.0; ///< Timestamp in milliseconds
|
||||
float compression_ratio = 0.0f;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Get current storage quota and usage information
|
||||
* @param callback Called with storage info when available
|
||||
*
|
||||
* Uses navigator.storage.estimate() to get current usage and quota.
|
||||
* Note: Browsers may report conservative estimates for privacy reasons.
|
||||
*/
|
||||
void GetStorageInfo(std::function<void(const StorageInfo&)> callback);
|
||||
|
||||
/**
|
||||
* @brief Compress ROM data and store in IndexedDB
|
||||
* @param key Unique identifier for the ROM
|
||||
* @param data Raw ROM data to compress and store
|
||||
* @param callback Called with success/failure status
|
||||
*
|
||||
* Compresses the ROM using zlib (deflate) before storage.
|
||||
* Updates access time and manages quota automatically.
|
||||
* If storage would exceed 80% quota, triggers LRU eviction first.
|
||||
*/
|
||||
void CompressAndStore(const std::string& key,
|
||||
const std::vector<uint8_t>& data,
|
||||
std::function<void(bool success)> callback);
|
||||
|
||||
/**
|
||||
* @brief Load ROM from storage and decompress
|
||||
* @param key ROM identifier
|
||||
* @param callback Called with decompressed ROM data (empty if not found)
|
||||
*
|
||||
* Automatically updates access time for LRU tracking.
|
||||
* Returns empty vector if key not found or decompression fails.
|
||||
*/
|
||||
void LoadAndDecompress(const std::string& key,
|
||||
std::function<void(std::vector<uint8_t>)> callback);
|
||||
|
||||
/**
|
||||
* @brief Evict the oldest (least recently used) items from storage
|
||||
* @param count Number of items to evict
|
||||
* @param callback Called with actual number of items evicted
|
||||
*
|
||||
* Removes items based on last_access_time, oldest first.
|
||||
* Useful for making space when approaching quota limits.
|
||||
*/
|
||||
void EvictOldest(int count, std::function<void(int evicted)> callback);
|
||||
|
||||
/**
|
||||
* @brief Evict items until storage usage is below target percentage
|
||||
* @param target_percent Target usage percentage (0-100)
|
||||
* @param callback Called with number of items evicted
|
||||
*
|
||||
* Intelligently evicts LRU items until usage drops below target.
|
||||
* Default target is 70% to leave headroom for new saves.
|
||||
*/
|
||||
void EvictToTarget(float target_percent,
|
||||
std::function<void(int evicted)> callback);
|
||||
|
||||
/**
|
||||
* @brief Update the last access time for a stored item
|
||||
* @param key Item identifier
|
||||
*
|
||||
* Call this when accessing a ROM through other means to keep
|
||||
* LRU tracking accurate. CompressAndStore and LoadAndDecompress
|
||||
* update times automatically.
|
||||
*/
|
||||
void UpdateAccessTime(const std::string& key);
|
||||
|
||||
/**
|
||||
* @brief Get metadata for all stored items
|
||||
* @param callback Called with vector of storage items
|
||||
*
|
||||
* Returns information about all stored ROMs including sizes,
|
||||
* compression ratios, and access times for management UI.
|
||||
*/
|
||||
void GetStoredItems(
|
||||
std::function<void(std::vector<StorageItem>)> callback);
|
||||
|
||||
/**
|
||||
* @brief Delete a specific item from storage
|
||||
* @param key Item identifier
|
||||
* @param callback Called with success status
|
||||
*/
|
||||
void DeleteItem(const std::string& key,
|
||||
std::function<void(bool success)> callback);
|
||||
|
||||
/**
|
||||
* @brief Clear all stored ROMs and metadata
|
||||
* @param callback Called when complete
|
||||
*
|
||||
* Use with caution - removes all compressed ROM data.
|
||||
*/
|
||||
void ClearAll(std::function<void()> callback);
|
||||
|
||||
/**
|
||||
* @brief Check if browser supports required storage APIs
|
||||
* @return true if navigator.storage and compression APIs are available
|
||||
*/
|
||||
static bool IsSupported();
|
||||
|
||||
/**
|
||||
* @brief Get singleton instance
|
||||
* @return Reference to the global storage quota manager
|
||||
*/
|
||||
static WasmStorageQuota& Get();
|
||||
|
||||
// Configuration constants
|
||||
static constexpr float kDefaultTargetUsage = 70.0f; ///< Target usage %
|
||||
static constexpr float kWarningThreshold = 80.0f; ///< Warning at this %
|
||||
static constexpr float kCriticalThreshold = 90.0f; ///< Critical at this %
|
||||
static constexpr size_t kMinQuotaBytes = 50 * 1024 * 1024; ///< 50MB minimum
|
||||
|
||||
private:
|
||||
WasmStorageQuota() = default;
|
||||
|
||||
// Compression helpers (using browser's CompressionStream API)
|
||||
void CompressData(const std::vector<uint8_t>& input,
|
||||
std::function<void(std::vector<uint8_t>)> callback);
|
||||
void DecompressData(const std::vector<uint8_t>& input,
|
||||
std::function<void(std::vector<uint8_t>)> callback);
|
||||
|
||||
// Internal storage operations
|
||||
void StoreCompressedData(const std::string& key,
|
||||
const std::vector<uint8_t>& compressed_data,
|
||||
size_t original_size,
|
||||
std::function<void(bool)> callback);
|
||||
void LoadCompressedData(const std::string& key,
|
||||
std::function<void(std::vector<uint8_t>, size_t)> callback);
|
||||
|
||||
// Metadata management
|
||||
void UpdateMetadata(const std::string& key, size_t compressed_size,
|
||||
size_t original_size);
|
||||
void LoadMetadata(std::function<void()> callback);
|
||||
void SaveMetadata(std::function<void()> callback);
|
||||
|
||||
// Storage monitoring
|
||||
void CheckQuotaAndEvict(size_t new_size_bytes,
|
||||
std::function<void(bool)> callback);
|
||||
|
||||
// Thread safety
|
||||
mutable std::mutex mutex_;
|
||||
|
||||
// Cached metadata (key -> last access time in ms)
|
||||
std::map<std::string, double> access_times_;
|
||||
std::map<std::string, StorageItem> item_metadata_;
|
||||
std::atomic<bool> metadata_loaded_{false};
|
||||
|
||||
// Current storage state
|
||||
StorageInfo last_storage_info_;
|
||||
std::atomic<double> last_quota_check_time_{0.0};
|
||||
};
|
||||
|
||||
// clang-format off
|
||||
|
||||
// JavaScript bridge functions for storage quota API
|
||||
EM_JS(void, wasm_storage_quota_estimate, (int callback_id), {
|
||||
if (!navigator.storage || !navigator.storage.estimate) {
|
||||
// Call back with error values
|
||||
Module.ccall('wasm_storage_quota_estimate_callback',
|
||||
null, ['number', 'number', 'number', 'number'],
|
||||
[callback_id, 0, 0, 0]);
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.storage.estimate().then(function(estimate) {
|
||||
var used = estimate.usage || 0;
|
||||
var quota = estimate.quota || 0;
|
||||
var persistent = estimate.persistent ? 1 : 0;
|
||||
Module.ccall('wasm_storage_quota_estimate_callback',
|
||||
null, ['number', 'number', 'number', 'number'],
|
||||
[callback_id, used, quota, persistent]);
|
||||
}).catch(function(error) {
|
||||
console.error('[StorageQuota] Error estimating storage:', error);
|
||||
Module.ccall('wasm_storage_quota_estimate_callback',
|
||||
null, ['number', 'number', 'number', 'number'],
|
||||
[callback_id, 0, 0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
// Compression using browser's CompressionStream API
|
||||
EM_JS(void, wasm_compress_data, (const uint8_t* data, size_t size, int callback_id), {
|
||||
var input = new Uint8Array(Module.HEAPU8.buffer, data, size);
|
||||
|
||||
// Use CompressionStream if available (Chrome 80+, Firefox 113+)
|
||||
if (typeof CompressionStream !== 'undefined') {
|
||||
var stream = new CompressionStream('deflate');
|
||||
var writer = stream.writable.getWriter();
|
||||
|
||||
writer.write(input).then(function() {
|
||||
return writer.close();
|
||||
}).then(function() {
|
||||
return new Response(stream.readable).arrayBuffer();
|
||||
}).then(function(compressed) {
|
||||
var compressedArray = new Uint8Array(compressed);
|
||||
var ptr = Module._malloc(compressedArray.length);
|
||||
Module.HEAPU8.set(compressedArray, ptr);
|
||||
Module.ccall('wasm_compress_callback',
|
||||
null, ['number', 'number', 'number'],
|
||||
[callback_id, ptr, compressedArray.length]);
|
||||
}).catch(function(error) {
|
||||
console.error('[StorageQuota] Compression error:', error);
|
||||
Module.ccall('wasm_compress_callback',
|
||||
null, ['number', 'number', 'number'],
|
||||
[callback_id, 0, 0]);
|
||||
});
|
||||
} else {
|
||||
// Fallback: No compression, return original data
|
||||
console.warn('[StorageQuota] CompressionStream not available, storing uncompressed');
|
||||
var ptr = Module._malloc(size);
|
||||
Module.HEAPU8.set(input, ptr);
|
||||
Module.ccall('wasm_compress_callback',
|
||||
null, ['number', 'number', 'number'],
|
||||
[callback_id, ptr, size]);
|
||||
}
|
||||
});
|
||||
|
||||
EM_JS(void, wasm_decompress_data, (const uint8_t* data, size_t size, int callback_id), {
|
||||
var input = new Uint8Array(Module.HEAPU8.buffer, data, size);
|
||||
|
||||
if (typeof DecompressionStream !== 'undefined') {
|
||||
var stream = new DecompressionStream('deflate');
|
||||
var writer = stream.writable.getWriter();
|
||||
|
||||
writer.write(input).then(function() {
|
||||
return writer.close();
|
||||
}).then(function() {
|
||||
return new Response(stream.readable).arrayBuffer();
|
||||
}).then(function(decompressed) {
|
||||
var decompressedArray = new Uint8Array(decompressed);
|
||||
var ptr = Module._malloc(decompressedArray.length);
|
||||
Module.HEAPU8.set(decompressedArray, ptr);
|
||||
Module.ccall('wasm_decompress_callback',
|
||||
null, ['number', 'number', 'number'],
|
||||
[callback_id, ptr, decompressedArray.length]);
|
||||
}).catch(function(error) {
|
||||
console.error('[StorageQuota] Decompression error:', error);
|
||||
Module.ccall('wasm_decompress_callback',
|
||||
null, ['number', 'number', 'number'],
|
||||
[callback_id, 0, 0]);
|
||||
});
|
||||
} else {
|
||||
// Fallback: Assume data is uncompressed
|
||||
var ptr = Module._malloc(size);
|
||||
Module.HEAPU8.set(input, ptr);
|
||||
Module.ccall('wasm_decompress_callback',
|
||||
null, ['number', 'number', 'number'],
|
||||
[callback_id, ptr, size]);
|
||||
}
|
||||
});
|
||||
|
||||
// Get current timestamp in milliseconds
|
||||
EM_JS(double, wasm_get_timestamp_ms, (), {
|
||||
return Date.now();
|
||||
});
|
||||
|
||||
// Check if compression APIs are available
|
||||
EM_JS(int, wasm_compression_available, (), {
|
||||
return (typeof CompressionStream !== 'undefined' &&
|
||||
typeof DecompressionStream !== 'undefined') ? 1 : 0;
|
||||
});
|
||||
|
||||
// clang-format on
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#else // !__EMSCRIPTEN__
|
||||
|
||||
// Stub implementation for non-WASM builds
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
|
||||
class WasmStorageQuota {
|
||||
public:
|
||||
struct StorageInfo {
|
||||
size_t used_bytes = 0;
|
||||
size_t quota_bytes = 100 * 1024 * 1024; // 100MB default
|
||||
float usage_percent = 0.0f;
|
||||
bool persistent = false;
|
||||
};
|
||||
|
||||
struct StorageItem {
|
||||
std::string key;
|
||||
size_t compressed_size = 0;
|
||||
size_t original_size = 0;
|
||||
double last_access_time = 0.0;
|
||||
float compression_ratio = 1.0f;
|
||||
};
|
||||
|
||||
void GetStorageInfo(std::function<void(const StorageInfo&)> callback) {
|
||||
StorageInfo info;
|
||||
callback(info);
|
||||
}
|
||||
|
||||
void CompressAndStore(const std::string& key,
|
||||
const std::vector<uint8_t>& data,
|
||||
std::function<void(bool success)> callback) {
|
||||
callback(false);
|
||||
}
|
||||
|
||||
void LoadAndDecompress(const std::string& key,
|
||||
std::function<void(std::vector<uint8_t>)> callback) {
|
||||
callback(std::vector<uint8_t>());
|
||||
}
|
||||
|
||||
void EvictOldest(int count, std::function<void(int evicted)> callback) {
|
||||
callback(0);
|
||||
}
|
||||
|
||||
void EvictToTarget(float target_percent,
|
||||
std::function<void(int evicted)> callback) {
|
||||
callback(0);
|
||||
}
|
||||
|
||||
void UpdateAccessTime(const std::string& key) {}
|
||||
|
||||
void GetStoredItems(
|
||||
std::function<void(std::vector<StorageItem>)> callback) {
|
||||
callback(std::vector<StorageItem>());
|
||||
}
|
||||
|
||||
void DeleteItem(const std::string& key,
|
||||
std::function<void(bool success)> callback) {
|
||||
callback(false);
|
||||
}
|
||||
|
||||
void ClearAll(std::function<void()> callback) { callback(); }
|
||||
|
||||
static bool IsSupported() { return false; }
|
||||
|
||||
static WasmStorageQuota& Get() {
|
||||
static WasmStorageQuota instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
static constexpr float kDefaultTargetUsage = 70.0f;
|
||||
static constexpr float kWarningThreshold = 80.0f;
|
||||
static constexpr float kCriticalThreshold = 90.0f;
|
||||
static constexpr size_t kMinQuotaBytes = 50 * 1024 * 1024;
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_STORAGE_QUOTA_H_
|
||||
599
src/app/platform/wasm/wasm_worker_pool.cc
Normal file
599
src/app/platform/wasm/wasm_worker_pool.cc
Normal file
@@ -0,0 +1,599 @@
|
||||
// clang-format off
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include "app/platform/wasm/wasm_worker_pool.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
|
||||
#include <emscripten/bind.h>
|
||||
#include <emscripten/val.h>
|
||||
|
||||
#include "absl/strings/str_format.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
namespace wasm {
|
||||
|
||||
namespace {
|
||||
|
||||
// Get optimal worker count based on hardware
|
||||
size_t GetOptimalWorkerCount() {
|
||||
#ifdef __EMSCRIPTEN__
|
||||
// In Emscripten, check navigator.hardwareConcurrency
|
||||
EM_ASM({
|
||||
if (typeof navigator !== 'undefined' && navigator.hardwareConcurrency) {
|
||||
Module['_yaze_hardware_concurrency'] = navigator.hardwareConcurrency;
|
||||
} else {
|
||||
Module['_yaze_hardware_concurrency'] = 4; // Default fallback
|
||||
}
|
||||
});
|
||||
|
||||
// Read the value set by JavaScript
|
||||
int concurrency = EM_ASM_INT({
|
||||
return Module['_yaze_hardware_concurrency'] || 4;
|
||||
});
|
||||
|
||||
// Use half the available cores for workers, minimum 2, maximum 8
|
||||
return std::max(2, std::min(8, concurrency / 2));
|
||||
#else
|
||||
// Native platform
|
||||
unsigned int hw_threads = std::thread::hardware_concurrency();
|
||||
if (hw_threads == 0) hw_threads = 4; // Fallback
|
||||
return std::max(2u, std::min(8u, hw_threads / 2));
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WasmWorkerPool::WasmWorkerPool(size_t num_workers)
|
||||
: num_workers_(num_workers == 0 ? GetOptimalWorkerCount() : num_workers) {
|
||||
worker_stats_.resize(num_workers_);
|
||||
}
|
||||
|
||||
WasmWorkerPool::~WasmWorkerPool() {
|
||||
if (initialized_) {
|
||||
Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
bool WasmWorkerPool::Initialize() {
|
||||
if (initialized_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
// Check if SharedArrayBuffer is available (required for pthreads)
|
||||
bool has_shared_array_buffer = EM_ASM_INT({
|
||||
return typeof SharedArrayBuffer !== 'undefined';
|
||||
});
|
||||
|
||||
if (!has_shared_array_buffer) {
|
||||
std::cerr << "WasmWorkerPool: SharedArrayBuffer not available. "
|
||||
<< "Workers will run in degraded mode.\n";
|
||||
// Could fall back to single-threaded mode or use postMessage-based workers
|
||||
// For now, we'll proceed but with reduced functionality
|
||||
num_workers_ = 0;
|
||||
initialized_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Log initialization
|
||||
EM_ASM({
|
||||
console.log('WasmWorkerPool: Initializing with', $0, 'workers');
|
||||
}, num_workers_);
|
||||
#endif
|
||||
|
||||
// Create worker threads
|
||||
workers_.reserve(num_workers_);
|
||||
for (size_t i = 0; i < num_workers_; ++i) {
|
||||
workers_.emplace_back(&WasmWorkerPool::WorkerThread, this, i);
|
||||
}
|
||||
|
||||
initialized_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void WasmWorkerPool::Shutdown() {
|
||||
if (!initialized_) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Signal shutdown
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
shutting_down_ = true;
|
||||
}
|
||||
queue_cv_.notify_all();
|
||||
|
||||
// Wait for all workers to finish
|
||||
for (auto& worker : workers_) {
|
||||
if (worker.joinable()) {
|
||||
worker.join();
|
||||
}
|
||||
}
|
||||
|
||||
workers_.clear();
|
||||
|
||||
// Clear any remaining tasks
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
while (!task_queue_.empty()) {
|
||||
task_queue_.pop();
|
||||
}
|
||||
active_tasks_.clear();
|
||||
}
|
||||
|
||||
initialized_ = false;
|
||||
}
|
||||
|
||||
uint32_t WasmWorkerPool::SubmitTask(TaskType type,
|
||||
const std::vector<uint8_t>& input_data,
|
||||
TaskCallback callback,
|
||||
Priority priority) {
|
||||
return SubmitTaskWithProgress(type, input_data, callback, nullptr, priority);
|
||||
}
|
||||
|
||||
uint32_t WasmWorkerPool::SubmitCustomTask(const std::string& type_string,
|
||||
const std::vector<uint8_t>& input_data,
|
||||
TaskCallback callback,
|
||||
Priority priority) {
|
||||
auto task = std::make_shared<Task>();
|
||||
task->id = next_task_id_++;
|
||||
task->type = TaskType::kCustom;
|
||||
task->type_string = type_string;
|
||||
task->priority = priority;
|
||||
task->input_data = input_data;
|
||||
task->completion_callback = callback;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
active_tasks_[task->id] = task;
|
||||
task_queue_.push(task);
|
||||
total_tasks_submitted_++;
|
||||
}
|
||||
|
||||
queue_cv_.notify_one();
|
||||
return task->id;
|
||||
}
|
||||
|
||||
uint32_t WasmWorkerPool::SubmitTaskWithProgress(TaskType type,
|
||||
const std::vector<uint8_t>& input_data,
|
||||
TaskCallback completion_callback,
|
||||
ProgressCallback progress_callback,
|
||||
Priority priority) {
|
||||
// If no workers available, execute synchronously
|
||||
if (num_workers_ == 0 || !initialized_) {
|
||||
if (completion_callback) {
|
||||
try {
|
||||
auto task = std::make_shared<Task>();
|
||||
task->type = type;
|
||||
task->input_data = input_data;
|
||||
auto result = ExecuteTask(*task);
|
||||
completion_callback(true, result);
|
||||
} catch (const std::exception& e) {
|
||||
completion_callback(false, std::vector<uint8_t>());
|
||||
}
|
||||
}
|
||||
// Return special ID to indicate synchronous execution (task already completed)
|
||||
return kSynchronousTaskId;
|
||||
}
|
||||
|
||||
auto task = std::make_shared<Task>();
|
||||
task->id = next_task_id_++;
|
||||
task->type = type;
|
||||
task->priority = priority;
|
||||
task->input_data = input_data;
|
||||
task->completion_callback = completion_callback;
|
||||
task->progress_callback = progress_callback;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
active_tasks_[task->id] = task;
|
||||
task_queue_.push(task);
|
||||
total_tasks_submitted_++;
|
||||
}
|
||||
|
||||
queue_cv_.notify_one();
|
||||
return task->id;
|
||||
}
|
||||
|
||||
bool WasmWorkerPool::Cancel(uint32_t task_id) {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
|
||||
auto it = active_tasks_.find(task_id);
|
||||
if (it != active_tasks_.end()) {
|
||||
it->second->cancelled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void WasmWorkerPool::CancelAllOfType(TaskType type) {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
|
||||
for (auto& [id, task] : active_tasks_) {
|
||||
if (task->type == type) {
|
||||
task->cancelled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool WasmWorkerPool::WaitAll(uint32_t timeout_ms) {
|
||||
auto start = std::chrono::steady_clock::now();
|
||||
|
||||
while (true) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
if (task_queue_.empty() && active_workers_ == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (timeout_ms > 0) {
|
||||
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - start).count();
|
||||
if (elapsed >= timeout_ms) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
std::unique_lock<std::mutex> lock(queue_mutex_);
|
||||
completion_cv_.wait_for(lock, std::chrono::milliseconds(100));
|
||||
}
|
||||
}
|
||||
|
||||
size_t WasmWorkerPool::GetPendingCount() const {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
return task_queue_.size();
|
||||
}
|
||||
|
||||
size_t WasmWorkerPool::GetActiveWorkerCount() const {
|
||||
return active_workers_.load();
|
||||
}
|
||||
|
||||
std::vector<WasmWorkerPool::WorkerStats> WasmWorkerPool::GetWorkerStats() const {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
return worker_stats_;
|
||||
}
|
||||
|
||||
void WasmWorkerPool::SetMaxWorkers(size_t count) {
|
||||
// This would require stopping and restarting workers
|
||||
// For simplicity, we'll just store the value for next initialization
|
||||
if (!initialized_) {
|
||||
num_workers_ = count;
|
||||
}
|
||||
}
|
||||
|
||||
void WasmWorkerPool::ProcessCallbacks() {
|
||||
std::queue<std::function<void()>> callbacks_to_process;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(callback_mutex_);
|
||||
callbacks_to_process.swap(callback_queue_);
|
||||
}
|
||||
|
||||
while (!callbacks_to_process.empty()) {
|
||||
callbacks_to_process.front()();
|
||||
callbacks_to_process.pop();
|
||||
}
|
||||
}
|
||||
|
||||
void WasmWorkerPool::WorkerThread(size_t worker_id) {
|
||||
#ifdef __EMSCRIPTEN__
|
||||
// Set thread name for debugging
|
||||
emscripten_set_thread_name(pthread_self(),
|
||||
absl::StrFormat("YazeWorker%zu", worker_id).c_str());
|
||||
#endif
|
||||
|
||||
while (true) {
|
||||
std::shared_ptr<Task> task;
|
||||
|
||||
// Get next task from queue
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(queue_mutex_);
|
||||
queue_cv_.wait(lock, [this] {
|
||||
return shutting_down_ || !task_queue_.empty();
|
||||
});
|
||||
|
||||
if (shutting_down_ && task_queue_.empty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!task_queue_.empty()) {
|
||||
task = task_queue_.top();
|
||||
task_queue_.pop();
|
||||
active_workers_++;
|
||||
worker_stats_[worker_id].is_busy = true;
|
||||
worker_stats_[worker_id].current_task_type =
|
||||
task->type == TaskType::kCustom ? task->type_string :
|
||||
absl::StrFormat("Type%d", static_cast<int>(task->type));
|
||||
}
|
||||
}
|
||||
|
||||
if (task && !task->cancelled) {
|
||||
ProcessTask(*task, worker_id);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (task) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
active_tasks_.erase(task->id);
|
||||
active_workers_--;
|
||||
worker_stats_[worker_id].is_busy = false;
|
||||
worker_stats_[worker_id].current_task_type.clear();
|
||||
}
|
||||
completion_cv_.notify_all();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void WasmWorkerPool::ProcessTask(const Task& task, size_t worker_id) {
|
||||
auto start_time = std::chrono::steady_clock::now();
|
||||
bool success = false;
|
||||
std::vector<uint8_t> result;
|
||||
|
||||
try {
|
||||
// Report starting
|
||||
if (task.progress_callback) {
|
||||
ReportProgress(task.id, 0.0f, "Starting task...");
|
||||
}
|
||||
|
||||
// Execute the task
|
||||
result = ExecuteTask(task);
|
||||
success = true;
|
||||
|
||||
// Update stats
|
||||
worker_stats_[worker_id].tasks_completed++;
|
||||
|
||||
// Report completion
|
||||
if (task.progress_callback) {
|
||||
ReportProgress(task.id, 1.0f, "Task completed");
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "Worker " << worker_id << " task failed: " << e.what() << std::endl;
|
||||
worker_stats_[worker_id].tasks_failed++;
|
||||
success = false;
|
||||
}
|
||||
|
||||
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - start_time).count();
|
||||
worker_stats_[worker_id].total_processing_time_ms += elapsed;
|
||||
|
||||
// Queue callback for main thread execution
|
||||
if (task.completion_callback && !task.cancelled) {
|
||||
QueueCallback([callback = task.completion_callback, success, result]() {
|
||||
callback(success, result);
|
||||
});
|
||||
}
|
||||
|
||||
total_tasks_completed_++;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> WasmWorkerPool::ExecuteTask(const Task& task) {
|
||||
switch (task.type) {
|
||||
case TaskType::kRomDecompression:
|
||||
return ProcessRomDecompression(task.input_data);
|
||||
|
||||
case TaskType::kGraphicsDecoding:
|
||||
return ProcessGraphicsDecoding(task.input_data);
|
||||
|
||||
case TaskType::kPaletteCalculation:
|
||||
return ProcessPaletteCalculation(task.input_data);
|
||||
|
||||
case TaskType::kAsarCompilation:
|
||||
return ProcessAsarCompilation(task.input_data);
|
||||
|
||||
case TaskType::kCustom:
|
||||
// For custom tasks, just return the input as we don't know how to process it
|
||||
// Real implementation would need a registry of custom processors
|
||||
return task.input_data;
|
||||
|
||||
default:
|
||||
throw std::runtime_error("Unknown task type");
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<uint8_t> WasmWorkerPool::ProcessRomDecompression(const std::vector<uint8_t>& input) {
|
||||
// Placeholder for LC-LZ2 decompression
|
||||
// In real implementation, this would call the actual decompression routine
|
||||
// from src/app/gfx/compression.cc
|
||||
|
||||
// For now, simulate some work
|
||||
std::vector<uint8_t> result;
|
||||
result.reserve(input.size() * 2); // Assume 2x expansion
|
||||
|
||||
// Simulate decompression (just duplicate data for testing)
|
||||
for (size_t i = 0; i < input.size(); ++i) {
|
||||
result.push_back(input[i]);
|
||||
result.push_back(input[i] ^ 0xFF); // Inverted copy
|
||||
|
||||
// Simulate progress reporting
|
||||
if (i % 1000 == 0) {
|
||||
float progress = static_cast<float>(i) / input.size();
|
||||
// Would call ReportProgress here if we had the task ID
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> WasmWorkerPool::ProcessGraphicsDecoding(const std::vector<uint8_t>& input) {
|
||||
// Placeholder for graphics sheet decoding
|
||||
// In real implementation, this would decode SNES tile formats
|
||||
|
||||
std::vector<uint8_t> result;
|
||||
result.reserve(input.size());
|
||||
|
||||
// Simulate processing
|
||||
for (uint8_t byte : input) {
|
||||
// Simple transformation to simulate work
|
||||
result.push_back((byte << 1) | (byte >> 7));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> WasmWorkerPool::ProcessPaletteCalculation(const std::vector<uint8_t>& input) {
|
||||
// Placeholder for palette calculations
|
||||
// In real implementation, this would process SNES color formats
|
||||
|
||||
std::vector<uint8_t> result;
|
||||
|
||||
// Process in groups of 2 bytes (SNES color format)
|
||||
for (size_t i = 0; i + 1 < input.size(); i += 2) {
|
||||
uint16_t snes_color = (input[i + 1] << 8) | input[i];
|
||||
|
||||
// Extract RGB components (5 bits each)
|
||||
uint8_t r = (snes_color & 0x1F) << 3;
|
||||
uint8_t g = ((snes_color >> 5) & 0x1F) << 3;
|
||||
uint8_t b = ((snes_color >> 10) & 0x1F) << 3;
|
||||
|
||||
// Store as RGB24
|
||||
result.push_back(r);
|
||||
result.push_back(g);
|
||||
result.push_back(b);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> WasmWorkerPool::ProcessAsarCompilation(const std::vector<uint8_t>& input) {
|
||||
// Placeholder for Asar assembly compilation
|
||||
// In real implementation, this would call the Asar wrapper
|
||||
|
||||
// For now, return empty result (compilation succeeded with no output)
|
||||
return std::vector<uint8_t>();
|
||||
}
|
||||
|
||||
void WasmWorkerPool::ReportProgress(uint32_t task_id, float progress, const std::string& message) {
|
||||
// Find the task
|
||||
std::shared_ptr<Task> task;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
auto it = active_tasks_.find(task_id);
|
||||
if (it != active_tasks_.end()) {
|
||||
task = it->second;
|
||||
}
|
||||
}
|
||||
|
||||
if (task && task->progress_callback && !task->cancelled) {
|
||||
QueueCallback([callback = task->progress_callback, progress, message]() {
|
||||
callback(progress, message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void WasmWorkerPool::QueueCallback(std::function<void()> callback) {
|
||||
#ifdef __EMSCRIPTEN__
|
||||
// In Emscripten, we need to execute callbacks on the main thread
|
||||
// Use emscripten_async_run_in_main_runtime_thread for thread safety
|
||||
|
||||
auto* callback_ptr = new std::function<void()>(std::move(callback));
|
||||
|
||||
emscripten_async_run_in_main_runtime_thread(
|
||||
EM_FUNC_SIG_VI,
|
||||
&WasmWorkerPool::MainThreadCallbackHandler,
|
||||
callback_ptr);
|
||||
#else
|
||||
// For non-Emscripten builds, just queue for later processing
|
||||
std::lock_guard<std::mutex> lock(callback_mutex_);
|
||||
callback_queue_.push(callback);
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
void WasmWorkerPool::MainThreadCallbackHandler(void* arg) {
|
||||
auto* callback_ptr = static_cast<std::function<void()>*>(arg);
|
||||
if (callback_ptr) {
|
||||
(*callback_ptr)();
|
||||
delete callback_ptr;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace wasm
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#else // !__EMSCRIPTEN__
|
||||
|
||||
// Stub implementation for non-Emscripten builds
|
||||
#include "app/platform/wasm/wasm_worker_pool.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
namespace wasm {
|
||||
|
||||
WasmWorkerPool::WasmWorkerPool(size_t num_workers) : num_workers_(0) {}
|
||||
WasmWorkerPool::~WasmWorkerPool() {}
|
||||
|
||||
bool WasmWorkerPool::Initialize() { return false; }
|
||||
void WasmWorkerPool::Shutdown() {}
|
||||
|
||||
uint32_t WasmWorkerPool::SubmitTask(TaskType type,
|
||||
const std::vector<uint8_t>& input_data,
|
||||
TaskCallback callback,
|
||||
Priority priority) {
|
||||
// No-op in non-WASM builds
|
||||
if (callback) {
|
||||
callback(false, std::vector<uint8_t>());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint32_t WasmWorkerPool::SubmitCustomTask(const std::string& type_string,
|
||||
const std::vector<uint8_t>& input_data,
|
||||
TaskCallback callback,
|
||||
Priority priority) {
|
||||
if (callback) {
|
||||
callback(false, std::vector<uint8_t>());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint32_t WasmWorkerPool::SubmitTaskWithProgress(TaskType type,
|
||||
const std::vector<uint8_t>& input_data,
|
||||
TaskCallback completion_callback,
|
||||
ProgressCallback progress_callback,
|
||||
Priority priority) {
|
||||
if (completion_callback) {
|
||||
completion_callback(false, std::vector<uint8_t>());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool WasmWorkerPool::Cancel(uint32_t task_id) { return false; }
|
||||
void WasmWorkerPool::CancelAllOfType(TaskType type) {}
|
||||
bool WasmWorkerPool::WaitAll(uint32_t timeout_ms) { return true; }
|
||||
size_t WasmWorkerPool::GetPendingCount() const { return 0; }
|
||||
size_t WasmWorkerPool::GetActiveWorkerCount() const { return 0; }
|
||||
std::vector<WasmWorkerPool::WorkerStats> WasmWorkerPool::GetWorkerStats() const { return {}; }
|
||||
void WasmWorkerPool::SetMaxWorkers(size_t count) {}
|
||||
void WasmWorkerPool::ProcessCallbacks() {}
|
||||
|
||||
void WasmWorkerPool::WorkerThread(size_t worker_id) {}
|
||||
void WasmWorkerPool::ProcessTask(const Task& task, size_t worker_id) {}
|
||||
std::vector<uint8_t> WasmWorkerPool::ExecuteTask(const Task& task) { return {}; }
|
||||
std::vector<uint8_t> WasmWorkerPool::ProcessRomDecompression(const std::vector<uint8_t>& input) { return {}; }
|
||||
std::vector<uint8_t> WasmWorkerPool::ProcessGraphicsDecoding(const std::vector<uint8_t>& input) { return {}; }
|
||||
std::vector<uint8_t> WasmWorkerPool::ProcessPaletteCalculation(const std::vector<uint8_t>& input) { return {}; }
|
||||
std::vector<uint8_t> WasmWorkerPool::ProcessAsarCompilation(const std::vector<uint8_t>& input) { return {}; }
|
||||
void WasmWorkerPool::ReportProgress(uint32_t task_id, float progress, const std::string& message) {}
|
||||
void WasmWorkerPool::QueueCallback(std::function<void()> callback) {
|
||||
if (callback) callback();
|
||||
}
|
||||
|
||||
} // namespace wasm
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
275
src/app/platform/wasm/wasm_worker_pool.h
Normal file
275
src/app/platform/wasm/wasm_worker_pool.h
Normal file
@@ -0,0 +1,275 @@
|
||||
// clang-format off
|
||||
#ifndef YAZE_APP_PLATFORM_WASM_WORKER_POOL_H
|
||||
#define YAZE_APP_PLATFORM_WASM_WORKER_POOL_H
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <queue>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/threading.h>
|
||||
#endif
|
||||
|
||||
namespace yaze {
|
||||
namespace app {
|
||||
namespace platform {
|
||||
namespace wasm {
|
||||
|
||||
/**
|
||||
* @brief Web Worker pool for offloading CPU-intensive operations.
|
||||
*
|
||||
* This class manages a pool of background workers (using pthreads in Emscripten)
|
||||
* to handle heavy processing tasks without blocking the UI thread. Supported
|
||||
* task types include:
|
||||
* - ROM decompression (LC-LZ2)
|
||||
* - Graphics sheet decoding
|
||||
* - Palette calculations
|
||||
* - Asar assembly compilation
|
||||
*
|
||||
* The implementation uses Emscripten's pthread support which maps to Web Workers
|
||||
* in the browser. Callbacks are executed on the main thread to ensure safe
|
||||
* UI updates.
|
||||
*/
|
||||
class WasmWorkerPool {
|
||||
public:
|
||||
// Task types that can be processed in background
|
||||
enum class TaskType {
|
||||
kRomDecompression,
|
||||
kGraphicsDecoding,
|
||||
kPaletteCalculation,
|
||||
kAsarCompilation,
|
||||
kCustom
|
||||
};
|
||||
|
||||
// Task priority levels
|
||||
enum class Priority {
|
||||
kLow = 0,
|
||||
kNormal = 1,
|
||||
kHigh = 2,
|
||||
kCritical = 3
|
||||
};
|
||||
|
||||
// Callback type for task completion
|
||||
using TaskCallback = std::function<void(bool success, const std::vector<uint8_t>& result)>;
|
||||
|
||||
// Progress callback for long-running tasks
|
||||
using ProgressCallback = std::function<void(float progress, const std::string& message)>;
|
||||
|
||||
// Task structure
|
||||
struct Task {
|
||||
uint32_t id;
|
||||
TaskType type;
|
||||
Priority priority;
|
||||
std::vector<uint8_t> input_data;
|
||||
TaskCallback completion_callback;
|
||||
ProgressCallback progress_callback;
|
||||
std::string type_string; // For custom task types
|
||||
bool cancelled = false;
|
||||
};
|
||||
|
||||
// Worker statistics
|
||||
struct WorkerStats {
|
||||
uint32_t tasks_completed = 0;
|
||||
uint32_t tasks_failed = 0;
|
||||
uint64_t total_processing_time_ms = 0;
|
||||
std::string current_task_type;
|
||||
bool is_busy = false;
|
||||
};
|
||||
|
||||
// Special task ID returned when task is executed synchronously (no workers available)
|
||||
static constexpr uint32_t kSynchronousTaskId = UINT32_MAX;
|
||||
|
||||
WasmWorkerPool(size_t num_workers = 0); // 0 = auto-detect optimal count
|
||||
~WasmWorkerPool();
|
||||
|
||||
// Initialize the worker pool
|
||||
bool Initialize();
|
||||
|
||||
// Shutdown the worker pool
|
||||
void Shutdown();
|
||||
|
||||
/**
|
||||
* @brief Submit a task to the worker pool.
|
||||
*
|
||||
* @param type The type of task to process
|
||||
* @param input_data The input data for the task
|
||||
* @param callback Callback to invoke on completion (executed on main thread)
|
||||
* @param priority Task priority (higher priority tasks are processed first)
|
||||
* @return Task ID that can be used for cancellation
|
||||
*/
|
||||
uint32_t SubmitTask(TaskType type,
|
||||
const std::vector<uint8_t>& input_data,
|
||||
TaskCallback callback,
|
||||
Priority priority = Priority::kNormal);
|
||||
|
||||
/**
|
||||
* @brief Submit a custom task type.
|
||||
*
|
||||
* @param type_string Custom task type identifier
|
||||
* @param input_data The input data for the task
|
||||
* @param callback Callback to invoke on completion
|
||||
* @param priority Task priority
|
||||
* @return Task ID
|
||||
*/
|
||||
uint32_t SubmitCustomTask(const std::string& type_string,
|
||||
const std::vector<uint8_t>& input_data,
|
||||
TaskCallback callback,
|
||||
Priority priority = Priority::kNormal);
|
||||
|
||||
/**
|
||||
* @brief Submit a task with progress reporting.
|
||||
*/
|
||||
uint32_t SubmitTaskWithProgress(TaskType type,
|
||||
const std::vector<uint8_t>& input_data,
|
||||
TaskCallback completion_callback,
|
||||
ProgressCallback progress_callback,
|
||||
Priority priority = Priority::kNormal);
|
||||
|
||||
/**
|
||||
* @brief Cancel a pending task.
|
||||
*
|
||||
* @param task_id The task ID to cancel
|
||||
* @return true if task was cancelled, false if already running or completed
|
||||
*/
|
||||
bool Cancel(uint32_t task_id);
|
||||
|
||||
/**
|
||||
* @brief Cancel all pending tasks of a specific type.
|
||||
*/
|
||||
void CancelAllOfType(TaskType type);
|
||||
|
||||
/**
|
||||
* @brief Wait for all pending tasks to complete.
|
||||
*
|
||||
* @param timeout_ms Maximum time to wait in milliseconds (0 = infinite)
|
||||
* @return true if all tasks completed, false if timeout
|
||||
*/
|
||||
bool WaitAll(uint32_t timeout_ms = 0);
|
||||
|
||||
/**
|
||||
* @brief Get the number of pending tasks.
|
||||
*/
|
||||
size_t GetPendingCount() const;
|
||||
|
||||
/**
|
||||
* @brief Get the number of active workers.
|
||||
*/
|
||||
size_t GetActiveWorkerCount() const;
|
||||
|
||||
/**
|
||||
* @brief Get statistics for all workers.
|
||||
*/
|
||||
std::vector<WorkerStats> GetWorkerStats() const;
|
||||
|
||||
/**
|
||||
* @brief Check if the worker pool is initialized.
|
||||
*/
|
||||
bool IsInitialized() const { return initialized_; }
|
||||
|
||||
/**
|
||||
* @brief Set the maximum number of concurrent workers.
|
||||
*/
|
||||
void SetMaxWorkers(size_t count);
|
||||
|
||||
/**
|
||||
* @brief Process any pending callbacks on the main thread.
|
||||
* Should be called periodically from the main loop.
|
||||
*/
|
||||
void ProcessCallbacks();
|
||||
|
||||
private:
|
||||
// Worker thread function
|
||||
void WorkerThread(size_t worker_id);
|
||||
|
||||
// Process a single task
|
||||
void ProcessTask(const Task& task, size_t worker_id);
|
||||
|
||||
// Execute task based on type
|
||||
std::vector<uint8_t> ExecuteTask(const Task& task);
|
||||
|
||||
// Task-specific processing functions
|
||||
std::vector<uint8_t> ProcessRomDecompression(const std::vector<uint8_t>& input);
|
||||
std::vector<uint8_t> ProcessGraphicsDecoding(const std::vector<uint8_t>& input);
|
||||
std::vector<uint8_t> ProcessPaletteCalculation(const std::vector<uint8_t>& input);
|
||||
std::vector<uint8_t> ProcessAsarCompilation(const std::vector<uint8_t>& input);
|
||||
|
||||
// Report progress from worker thread
|
||||
void ReportProgress(uint32_t task_id, float progress, const std::string& message);
|
||||
|
||||
// Queue a callback for execution on main thread
|
||||
void QueueCallback(std::function<void()> callback);
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
// Emscripten-specific callback handler
|
||||
static void MainThreadCallbackHandler(void* arg);
|
||||
#endif
|
||||
|
||||
// Member variables
|
||||
bool initialized_ = false;
|
||||
bool shutting_down_ = false;
|
||||
size_t num_workers_;
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
std::atomic<uint32_t> next_task_id_{1};
|
||||
|
||||
// Worker threads
|
||||
std::vector<std::thread> workers_;
|
||||
std::vector<WorkerStats> worker_stats_;
|
||||
|
||||
// Task queue (priority queue)
|
||||
struct TaskCompare {
|
||||
bool operator()(const std::shared_ptr<Task>& a, const std::shared_ptr<Task>& b) {
|
||||
// Higher priority first, then lower ID (FIFO within priority)
|
||||
if (a->priority != b->priority) {
|
||||
return static_cast<int>(a->priority) < static_cast<int>(b->priority);
|
||||
}
|
||||
return a->id > b->id;
|
||||
}
|
||||
};
|
||||
|
||||
std::priority_queue<std::shared_ptr<Task>,
|
||||
std::vector<std::shared_ptr<Task>>,
|
||||
TaskCompare> task_queue_;
|
||||
|
||||
// Active tasks map (task_id -> task)
|
||||
std::unordered_map<uint32_t, std::shared_ptr<Task>> active_tasks_;
|
||||
|
||||
// Synchronization
|
||||
mutable std::mutex queue_mutex_;
|
||||
std::condition_variable queue_cv_;
|
||||
std::condition_variable completion_cv_;
|
||||
|
||||
// Callback queue for main thread execution
|
||||
std::queue<std::function<void()>> callback_queue_;
|
||||
mutable std::mutex callback_mutex_;
|
||||
|
||||
// Statistics
|
||||
std::atomic<size_t> active_workers_{0};
|
||||
std::atomic<size_t> total_tasks_submitted_{0};
|
||||
std::atomic<size_t> total_tasks_completed_{0};
|
||||
#else
|
||||
// Stub members for non-Emscripten builds
|
||||
uint32_t next_task_id_{1};
|
||||
std::vector<WorkerStats> worker_stats_;
|
||||
size_t active_workers_{0};
|
||||
size_t total_tasks_submitted_{0};
|
||||
size_t total_tasks_completed_{0};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace wasm
|
||||
} // namespace platform
|
||||
} // namespace app
|
||||
} // namespace yaze
|
||||
|
||||
#endif // YAZE_APP_PLATFORM_WASM_WORKER_POOL_H
|
||||
@@ -7,12 +7,15 @@
|
||||
#include "app/gfx/resource/arena.h"
|
||||
#include "app/gui/core/style.h"
|
||||
#include "app/platform/font_loader.h"
|
||||
#include "imgui/backends/imgui_impl_sdl2.h"
|
||||
#include "imgui/backends/imgui_impl_sdlrenderer2.h"
|
||||
#include "imgui/imgui.h"
|
||||
#include "util/log.h"
|
||||
#include "util/sdl_deleter.h"
|
||||
|
||||
#ifndef YAZE_USE_SDL3
|
||||
#include "imgui/backends/imgui_impl_sdl2.h"
|
||||
#include "imgui/backends/imgui_impl_sdlrenderer2.h"
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
// Custom ImGui assertion handler to prevent crashes
|
||||
void ImGuiAssertionHandler(const char* expr, const char* file, int line,
|
||||
@@ -57,6 +60,10 @@ namespace core {
|
||||
bool g_window_is_resizing = false;
|
||||
|
||||
absl::Status CreateWindow(Window& window, gfx::IRenderer* renderer, int flags) {
|
||||
#ifdef YAZE_USE_SDL3
|
||||
return absl::FailedPreconditionError(
|
||||
"Legacy SDL2 window path is unavailable when building with SDL3");
|
||||
#else
|
||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_TIMER) != 0) {
|
||||
return absl::InternalError(
|
||||
absl::StrFormat("SDL_Init: %s\n", SDL_GetError()));
|
||||
@@ -90,6 +97,11 @@ absl::Status CreateWindow(Window& window, gfx::IRenderer* renderer, int flags) {
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
|
||||
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
|
||||
|
||||
// Ensure macOS-style behavior (Cmd acts as Ctrl for shortcuts)
|
||||
#ifdef __APPLE__
|
||||
io.ConfigMacOSXBehaviors = true;
|
||||
#endif
|
||||
|
||||
// Set custom assertion handler to prevent crashes
|
||||
#ifdef IMGUI_DISABLE_DEFAULT_ASSERT_HANDLER
|
||||
ImGui::SetAssertHandler(ImGuiAssertionHandler);
|
||||
@@ -128,18 +140,23 @@ absl::Status CreateWindow(Window& window, gfx::IRenderer* renderer, int flags) {
|
||||
window.audio_buffer_ = std::shared_ptr<int16_t>(
|
||||
new int16_t[buffer_size], std::default_delete<int16_t[]>());
|
||||
|
||||
// Note: Actual audio device is created by Emulator's IAudioBackend
|
||||
// This maintains compatibility with existing code paths
|
||||
LOG_INFO(
|
||||
"Window",
|
||||
"Audio buffer allocated: %zu int16_t samples (backend in Emulator)",
|
||||
buffer_size);
|
||||
// Note: Actual audio device is created by Emulator's IAudioBackend
|
||||
// This maintains compatibility with existing code paths
|
||||
LOG_INFO(
|
||||
"Window",
|
||||
"Audio buffer allocated: %zu int16_t samples (backend in Emulator)",
|
||||
buffer_size);
|
||||
}
|
||||
|
||||
return absl::OkStatus();
|
||||
#endif // YAZE_USE_SDL3
|
||||
}
|
||||
|
||||
absl::Status ShutdownWindow(Window& window) {
|
||||
#ifdef YAZE_USE_SDL3
|
||||
return absl::FailedPreconditionError(
|
||||
"Legacy SDL2 window path is unavailable when building with SDL3");
|
||||
#else
|
||||
SDL_PauseAudioDevice(window.audio_device_, 1);
|
||||
SDL_CloseAudioDevice(window.audio_device_);
|
||||
|
||||
@@ -177,9 +194,14 @@ absl::Status ShutdownWindow(Window& window) {
|
||||
|
||||
LOG_INFO("Window", "Shutdown complete");
|
||||
return absl::OkStatus();
|
||||
#endif // YAZE_USE_SDL3
|
||||
}
|
||||
|
||||
absl::Status HandleEvents(Window& window) {
|
||||
#ifdef YAZE_USE_SDL3
|
||||
return absl::FailedPreconditionError(
|
||||
"Legacy SDL2 window path is unavailable when building with SDL3");
|
||||
#else
|
||||
SDL_Event event;
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
|
||||
@@ -188,14 +210,9 @@ absl::Status HandleEvents(Window& window) {
|
||||
while (SDL_PollEvent(&event)) {
|
||||
ImGui_ImplSDL2_ProcessEvent(&event);
|
||||
switch (event.type) {
|
||||
case SDL_KEYDOWN:
|
||||
case SDL_KEYUP: {
|
||||
io.KeyShift = ((SDL_GetModState() & KMOD_SHIFT) != 0);
|
||||
io.KeyCtrl = ((SDL_GetModState() & KMOD_CTRL) != 0);
|
||||
io.KeyAlt = ((SDL_GetModState() & KMOD_ALT) != 0);
|
||||
io.KeySuper = ((SDL_GetModState() & KMOD_GUI) != 0);
|
||||
break;
|
||||
}
|
||||
// Note: Keyboard modifiers are handled by ImGui_ImplSDL2_ProcessEvent
|
||||
// which respects ConfigMacOSXBehaviors for Cmd/Ctrl swapping on macOS.
|
||||
// Do NOT manually override io.KeyCtrl/KeySuper here.
|
||||
case SDL_WINDOWEVENT:
|
||||
switch (event.window.event) {
|
||||
case SDL_WINDOWEVENT_CLOSE:
|
||||
@@ -236,7 +253,8 @@ absl::Status HandleEvents(Window& window) {
|
||||
int wheel = 0;
|
||||
io.MouseWheel = static_cast<float>(wheel);
|
||||
return absl::OkStatus();
|
||||
#endif // YAZE_USE_SDL3
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
} // namespace yaze
|
||||
} // namespace yaze
|
||||
|
||||
Reference in New Issue
Block a user