diff --git a/.cargo/config.toml b/.cargo/config.toml index c1c64baf8fa5..404470391b0a 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,11 @@ +[target.'cfg(all())'] +# NOTE that the web build overrides this setting in package.json via the RUSTFLAGS environment variable +rustflags = [ + # We need to specify this flag for all targets because Clippy checks all of our code against all targets + # and our web code does not compile without this flag + "--cfg=web_sys_unstable_apis", +] + [target.x86_64-pc-windows-msvc] # Use the LLD linker, it should be faster than the default. # See: https://github.com/rust-lang/rust/issues/71520 diff --git a/.github/workflows/test_rust.yml b/.github/workflows/test_rust.yml index 07b75c0d29bf..00982e161a93 100644 --- a/.github/workflows/test_rust.yml +++ b/.github/workflows/test_rust.yml @@ -69,7 +69,7 @@ jobs: - name: Disable linker start-stop-gc # Note: We also use `rust-lld` on Windows, see `config.toml`. if: runner.os != 'macOS' - run: echo RUSTFLAGS=${RUSTFLAGS}\ -Clink-args=-znostart-stop-gc >> $GITHUB_ENV + run: echo RUSTFLAGS=${RUSTFLAGS}\ --cfg=web_sys_unstable_apis\ -Clink-args=-znostart-stop-gc >> $GITHUB_ENV - name: Cache Cargo output uses: Swatinem/rust-cache@v2 diff --git a/web/Cargo.toml b/web/Cargo.toml index 4b8291b1039e..0f060f3a9024 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -74,7 +74,7 @@ features = [ "EventTarget", "GainNode", "Headers", "HtmlCanvasElement", "HtmlDocument", "HtmlElement", "HtmlFormElement", "HtmlInputElement", "HtmlTextAreaElement", "KeyboardEvent", "Location", "PointerEvent", "Request", "RequestInit", "Response", "Storage", "WheelEvent", "Window", "ReadableStream", "RequestCredentials", - "Url", + "Url", "Clipboard", ] [package.metadata.cargo-machete] diff --git a/web/packages/core/src/ruffle-player.ts b/web/packages/core/src/ruffle-player.ts index c4c298a228b3..0407fbc37214 100644 --- a/web/packages/core/src/ruffle-player.ts +++ b/web/packages/core/src/ruffle-player.ts @@ -70,7 +70,7 @@ interface ContextMenuItem { * * @param event The mouse event that triggered the click. */ - onClick: (event: MouseEvent) => void; + onClick: (event: MouseEvent) => Promise; /** * Whether this item is clickable. @@ -146,6 +146,7 @@ export class RufflePlayer extends HTMLElement { private readonly volumeControls: HTMLDivElement; private readonly videoModal: HTMLDivElement; private readonly hardwareAccelerationModal: HTMLDivElement; + private readonly clipboardModal: HTMLDivElement; private readonly contextMenuOverlay: HTMLElement; // Firefox has a read-only "contextMenu" property, @@ -271,10 +272,14 @@ export class RufflePlayer extends HTMLElement { this.volumeControls = ( this.shadow.getElementById("volume-controls-modal") ); + this.clipboardModal = ( + this.shadow.getElementById("clipboard-modal") + ); this.addModalJavaScript(this.saveManager); this.addModalJavaScript(this.volumeControls); this.addModalJavaScript(this.videoModal); this.addModalJavaScript(this.hardwareAccelerationModal); + this.addModalJavaScript(this.clipboardModal); this.volumeSettings = new VolumeControls(false, 100); this.addVolumeControlsJavaScript(this.volumeControls); @@ -1380,7 +1385,7 @@ export class RufflePlayer extends HTMLElement { /** * Opens the save manager. */ - private openSaveManager(): void { + private async openSaveManager(): Promise { this.saveManager.classList.remove("hidden"); } @@ -1467,7 +1472,7 @@ export class RufflePlayer extends HTMLElement { // TODO: better checkboxes text: item.caption + (item.checked ? ` (${CHECKMARK})` : ``), - onClick: () => + onClick: async () => this.instance?.run_context_menu_callback(index), enabled: item.enabled, }); @@ -1480,19 +1485,19 @@ export class RufflePlayer extends HTMLElement { if (this.isFullscreen) { items.push({ text: text("context-menu-exit-fullscreen"), - onClick: () => this.setFullscreen(false), + onClick: async () => this.setFullscreen(false), }); } else { items.push({ text: text("context-menu-enter-fullscreen"), - onClick: () => this.setFullscreen(true), + onClick: async () => this.setFullscreen(true), }); } } items.push({ text: text("context-menu-volume-controls"), - onClick: () => { + onClick: async () => { this.openVolumeControls(); }, }); @@ -1533,7 +1538,7 @@ export class RufflePlayer extends HTMLElement { flavor: isExtension ? "extension" : "", version: buildInfo.versionName, }), - onClick() { + async onClick() { window.open(RUFFLE_ORIGIN, "_blank"); }, }); @@ -1543,7 +1548,9 @@ export class RufflePlayer extends HTMLElement { addSeparator(); items.push({ text: text("context-menu-hide"), - onClick: () => (this.contextMenuForceDisabled = true), + onClick: async () => { + this.contextMenuForceDisabled = true; + }, }); } return items; @@ -1663,7 +1670,17 @@ export class RufflePlayer extends HTMLElement { if (enabled !== false) { menuItem.addEventListener( this.contextMenuSupported ? "click" : "pointerup", - onClick, + async (event: MouseEvent) => { + // Prevent the menu from being destroyed. + // It's required when we're dealing with async callbacks, + // as the async callback may still use the menu in the future. + event.stopPropagation(); + + await onClick(event); + + // Then we have to close the context menu manually after the callback finishes. + this.hideContextMenu(); + }, ); } else { menuItem.classList.add("disabled"); @@ -2354,6 +2371,18 @@ export class RufflePlayer extends HTMLElement { } } + protected displayClipboardModal(accessDenied: boolean): void { + const description = this.clipboardModal.querySelector( + "#clipboard-modal-description", + ); + if (description) { + description.textContent = text("clipboard-message-description", { + variant: accessDenied ? "access-denied" : "unsupported", + }); + this.clipboardModal.classList.remove("hidden"); + } + } + protected debugPlayerInfo(): string { return ""; } diff --git a/web/packages/core/src/shadow-template.ts b/web/packages/core/src/shadow-template.ts index 7aa937d4a7d4..f7aa53d761d5 100644 --- a/web/packages/core/src/shadow-template.ts +++ b/web/packages/core/src/shadow-template.ts @@ -815,6 +815,36 @@ hardwareModalLink.target = "_blank"; hardwareModalLink.className = "acceleration-link"; hardwareModalLink.textContent = text("enable-hardware-acceleration"); +// Clipboard message +const clipboardModal = createElement("div", "clipboard-modal", "modal hidden"); +const clipboardModalArea = createElement("div", undefined, "modal-area"); +const clipboardModalClose = createElement("span", undefined, "close-modal"); +clipboardModalClose.textContent = "\u00D7"; +const clipboardModalHeading = createElement("h2", undefined); +clipboardModalHeading.textContent = text("clipboard-message-title"); +const clipboardModalTextDescription = createElement( + "p", + "clipboard-modal-description", +); +const shortcutModifier = navigator.userAgent.includes("Mac OS X") + ? "Command" + : "Ctrl"; +const clipboardModalTextCopy = createElement("p", undefined); +const clipboardModalTextCopyShortcut = createElement("b", undefined); +clipboardModalTextCopyShortcut.textContent = `${shortcutModifier}+C`; +const clipboardModalTextCopyText = createElement("span", undefined); +clipboardModalTextCopyText.textContent = text("clipboard-message-copy"); +const clipboardModalTextCut = createElement("p", undefined); +const clipboardModalTextCutShortcut = createElement("b", undefined); +clipboardModalTextCutShortcut.textContent = `${shortcutModifier}+X`; +const clipboardModalTextCutText = createElement("span", undefined); +clipboardModalTextCutText.textContent = text("clipboard-message-cut"); +const clipboardModalTextPaste = createElement("p", undefined); +const clipboardModalTextPasteShortcut = createElement("b", undefined); +clipboardModalTextPasteShortcut.textContent = `${shortcutModifier}+V`; +const clipboardModalTextPasteText = createElement("span", undefined); +clipboardModalTextPasteText.textContent = text("clipboard-message-paste"); + // Context menu overlay elements const contextMenuOverlay = createElement( "div", @@ -888,6 +918,21 @@ appendElement(ruffleShadowTemplate.content, hardwareModal); appendElement(hardwareModal, hardwareModalArea); appendElement(hardwareModalArea, hardwareModalClose); appendElement(hardwareModalArea, hardwareModalLink); +// Clipboard modal append +appendElement(ruffleShadowTemplate.content, clipboardModal); +appendElement(clipboardModal, clipboardModalArea); +appendElement(clipboardModalArea, clipboardModalClose); +appendElement(clipboardModalArea, clipboardModalHeading); +appendElement(clipboardModalArea, clipboardModalTextDescription); +appendElement(clipboardModalArea, clipboardModalTextCopy); +appendElement(clipboardModalTextCopy, clipboardModalTextCopyShortcut); +appendElement(clipboardModalTextCopy, clipboardModalTextCopyText); +appendElement(clipboardModalArea, clipboardModalTextCut); +appendElement(clipboardModalTextCut, clipboardModalTextCutShortcut); +appendElement(clipboardModalTextCut, clipboardModalTextCutText); +appendElement(clipboardModalArea, clipboardModalTextPaste); +appendElement(clipboardModalTextPaste, clipboardModalTextPasteShortcut); +appendElement(clipboardModalTextPaste, clipboardModalTextPasteText); // Context menu overlay append appendElement(ruffleShadowTemplate.content, contextMenuOverlay); appendElement(contextMenuOverlay, contextMenu); diff --git a/web/packages/core/texts/en-US/messages.ftl b/web/packages/core/texts/en-US/messages.ftl index ff283b8e12ab..1ae7d4328604 100644 --- a/web/packages/core/texts/en-US/messages.ftl +++ b/web/packages/core/texts/en-US/messages.ftl @@ -14,6 +14,15 @@ enable-hardware-acceleration = It looks like hardware acceleration is not enable view-error-details = View Error Details open-in-new-tab = Open in a new tab click-to-unmute = Click to unmute +clipboard-message-title = Copying and pasting in Ruffle +clipboard-message-description = + { $variant -> + *[unsupported] Your browser does not support full clipboard access, + [access-denied] Access to the clipboard has been denied, + } but you can always use these shortcuts instead: +clipboard-message-copy = { " " } for copy +clipboard-message-cut = { " " } for cut +clipboard-message-paste = { " " } for paste error-file-protocol = It appears you are running Ruffle on the "file:" protocol. This doesn't work as browsers block many features from working for security reasons. diff --git a/web/packages/core/tools/build_wasm.ts b/web/packages/core/tools/build_wasm.ts index 8e4affd47760..61c93ea52c3a 100644 --- a/web/packages/core/tools/build_wasm.ts +++ b/web/packages/core/tools/build_wasm.ts @@ -84,7 +84,7 @@ function buildWasm( extensions: boolean, wasmSource: string, ) { - const rustFlags = ["-Aunknown_lints"]; + const rustFlags = ["--cfg=web_sys_unstable_apis", "-Aunknown_lints"]; const wasmBindgenFlags = []; const wasmOptFlags = []; const flavor = extensions ? "extensions" : "vanilla"; diff --git a/web/src/lib.rs b/web/src/lib.rs index 98e7217dd18f..a56e3544995a 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -17,6 +17,7 @@ use external_interface::{external_to_js_value, js_to_external_value}; use input::{web_key_to_codepoint, web_to_ruffle_key_code, web_to_ruffle_text_control}; use js_sys::{Error as JsError, Uint8Array}; use ruffle_core::context::UpdateContext; +use ruffle_core::context_menu::ContextMenuCallback; use ruffle_core::events::{MouseButton, MouseWheelDelta, TextControlCode}; use ruffle_core::tag_utils::SwfMovie; use ruffle_core::{Player, PlayerEvent, StaticCallstack, ViewportDimensions}; @@ -178,6 +179,9 @@ extern "C" { #[wasm_bindgen(method, js_name = "displayUnsupportedVideo")] fn display_unsupported_video(this: &JavascriptPlayer, url: &str); + + #[wasm_bindgen(method, js_name = "displayClipboardModal")] + fn display_clipboard_modal(this: &JavascriptPlayer, access_denied: bool); } #[derive(Debug, Clone)] @@ -211,7 +215,7 @@ impl RuffleHandle { /// /// `parameters` are *extra* parameters to set on the LoaderInfo - /// parameters from `movie_url` query parameters will be automatically added. - pub fn stream_from(&mut self, movie_url: String, parameters: JsValue) -> Result<(), JsValue> { + pub fn stream_from(&self, movie_url: String, parameters: JsValue) -> Result<(), JsValue> { let _ = self.with_core_mut(|core| { let parameters_to_load = parse_movie_parameters(¶meters); @@ -229,7 +233,7 @@ impl RuffleHandle { /// /// This method should only be called once per player. pub fn load_data( - &mut self, + &self, swf_data: Uint8Array, parameters: JsValue, swf_name: String, @@ -265,19 +269,19 @@ impl RuffleHandle { Ok(()) } - pub fn play(&mut self) { + pub fn play(&self) { let _ = self.with_core_mut(|core| { core.set_is_playing(true); }); } - pub fn pause(&mut self) { + pub fn pause(&self) { let _ = self.with_core_mut(|core| { core.set_is_playing(false); }); } - pub fn is_playing(&mut self) -> bool { + pub fn is_playing(&self) -> bool { self.with_core(|core| core.is_playing()).unwrap_or_default() } @@ -285,7 +289,7 @@ impl RuffleHandle { self.with_core(|core| core.volume()).unwrap_or_default() } - pub fn set_volume(&mut self, value: f32) { + pub fn set_volume(&self, value: f32) { let _ = self.with_core_mut(|core| core.set_volume(value)); } @@ -300,7 +304,7 @@ impl RuffleHandle { } // after the context menu is closed, remember to call `clear_custom_menu_items`! - pub fn prepare_context_menu(&mut self) -> JsValue { + pub fn prepare_context_menu(&self) -> JsValue { self.with_core_mut(|core| { let info = core.prepare_context_menu(); serde_wasm_bindgen::to_value(&info).unwrap_or(JsValue::UNDEFINED) @@ -308,19 +312,76 @@ impl RuffleHandle { .unwrap_or(JsValue::UNDEFINED) } - pub fn run_context_menu_callback(&mut self, index: usize) { - let _ = self.with_core_mut(|core| core.run_context_menu_callback(index)); + pub async fn run_context_menu_callback(&self, index: usize) { + let is_paste = self + .with_core_mut(|core| { + let is_paste = core.mutate_with_update_context(|context| { + matches!( + context + .current_context_menu + .as_ref() + .map(|menu| menu.callback(index)), + Some(ContextMenuCallback::TextControl { + code: TextControlCode::Paste, + .. + }) + ) + }); + if !is_paste { + core.run_context_menu_callback(index) + } + is_paste + }) + .unwrap_or_default(); + + // When the user selects paste, we need to use the Clipboard API which + // requests the clipboard asynchronously, so that the browser can ask for permission. + if is_paste { + self.run_context_menu_callback_paste(index).await; + } + } + + async fn run_context_menu_callback_paste(&self, index: usize) { + let window = web_sys::window().expect("Missing window"); + let Some(clipboard) = window.navigator().clipboard() else { + tracing::warn!("Clipboard unsupported"); + let _ = self.with_instance(|inst| inst.js_player.display_clipboard_modal(false)); + return; + }; + + let promise = clipboard.read_text(); + tracing::debug!("Requested text from clipboard"); + let clipboard = wasm_bindgen_futures::JsFuture::from(promise) + .await + .ok() + .and_then(|value| value.as_string()); + let Some(clipboard) = clipboard else { + tracing::warn!("Clipboard permission denied"); + let _ = self.with_instance(|inst| inst.js_player.display_clipboard_modal(true)); + return; + }; + + if !clipboard.is_empty() { + let _ = self.with_core_mut(|core| { + core.mutate_with_update_context(|context| { + context.ui.set_clipboard_content(clipboard); + }); + core.run_context_menu_callback(index); + }); + } else { + tracing::info!("Clipboard was empty"); + } } - pub fn set_fullscreen(&mut self, is_fullscreen: bool) { + pub fn set_fullscreen(&self, is_fullscreen: bool) { let _ = self.with_core_mut(|core| core.set_fullscreen(is_fullscreen)); } - pub fn clear_custom_menu_items(&mut self) { + pub fn clear_custom_menu_items(&self) { let _ = self.with_core_mut(Player::clear_custom_menu_items); } - pub fn destroy(&mut self) { + pub fn destroy(&self) { // Remove instance from the active list. let _ = self.remove_instance(); // Instance is dropped at this point.