From 74877bca0c093e79e648031e43989543ca8f1664 Mon Sep 17 00:00:00 2001 From: CypherPotato Date: Sat, 6 Jun 2026 16:07:10 -0300 Subject: [PATCH 1/2] Add JavaScript execution wrapper --- Photino.NET/PhotinoDllImports.cs | 17 ++- Photino.NET/PhotinoNetDelegates.cs | 3 + Photino.NET/PhotinoScriptExecution.cs | 196 ++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 Photino.NET/PhotinoScriptExecution.cs diff --git a/Photino.NET/PhotinoDllImports.cs b/Photino.NET/PhotinoDllImports.cs index 645acefc..c6b43996 100644 --- a/Photino.NET/PhotinoDllImports.cs +++ b/Photino.NET/PhotinoDllImports.cs @@ -146,12 +146,17 @@ public partial class PhotinoWindow [UnmanagedCallConv(CallConvs = new Type[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] static partial void Photino_NavigateToString(IntPtr instance, string content); - [LibraryImport(DLL_NAME, SetLastError = true, StringMarshalling = StringMarshalling.Utf8)] - [UnmanagedCallConv(CallConvs = new Type[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] - static partial void Photino_NavigateToUrl(IntPtr instance, string url); - - - //SET + [LibraryImport(DLL_NAME, SetLastError = true, StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = new Type[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + static partial void Photino_NavigateToUrl(IntPtr instance, string url); + + [LibraryImport(DLL_NAME, SetLastError = true, StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = new Type[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + [return: MarshalAs(UnmanagedType.I1)] + private static partial bool Photino_ExecuteScript(IntPtr instance, string script); + + + //SET [LibraryImport(DLL_NAME, SetLastError = true, StringMarshalling = StringMarshalling.Utf8)] [UnmanagedCallConv(CallConvs = new Type[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] static partial void Photino_setWebView2RuntimePath_win32(IntPtr instance, string webView2RuntimePath); diff --git a/Photino.NET/PhotinoNetDelegates.cs b/Photino.NET/PhotinoNetDelegates.cs index 098fa9e6..46873192 100644 --- a/Photino.NET/PhotinoNetDelegates.cs +++ b/Photino.NET/PhotinoNetDelegates.cs @@ -192,6 +192,9 @@ public PhotinoWindow RegisterWebMessageReceivedHandler(EventHandler hand /// internal void OnWebMessageReceived(string message) { + if (TryHandleScriptExecutionCallback(message)) + return; + WebMessageReceived?.Invoke(this, message); } diff --git a/Photino.NET/PhotinoScriptExecution.cs b/Photino.NET/PhotinoScriptExecution.cs new file mode 100644 index 00000000..ffb4e7f4 --- /dev/null +++ b/Photino.NET/PhotinoScriptExecution.cs @@ -0,0 +1,196 @@ +#nullable enable + +using System.Collections.Concurrent; +using System.Text.Json; + +namespace Photino.NET; + +public partial class PhotinoWindow +{ + private const string ExecuteScriptCallbackMessageType = "__photino_execute_script_result"; + private readonly ConcurrentDictionary> _scriptExecutionTasks = new(); + + /// + /// Executes JavaScript in the current page and waits for a JSON-serializable result. + /// + /// The JavaScript body to execute. Use a return statement to return a value. + /// The JSON-serializable result converted to a .NET primitive, list, dictionary, or null. + /// Thrown when the window is not initialized, the script fails, or the method is called synchronously on the UI thread. + public object? ExecuteScript(string script) + { + if (Environment.CurrentManagedThreadId == _managedThreadId) + throw new ApplicationException("ExecuteScript cannot be called synchronously on the Photino UI thread. Use ExecuteScriptAsync instead."); + + return ExecuteScriptAsync(script).GetAwaiter().GetResult(); + } + + /// + /// Executes JavaScript in the current page and asynchronously waits for a JSON-serializable result. + /// + /// The JavaScript body to execute. Use a return statement to return a value. + /// A token that cancels waiting for the script result. + /// A task that completes with the JSON-serializable result converted to a .NET primitive, list, dictionary, or null. + /// Thrown when the window is not initialized or the script fails. + public async Task ExecuteScriptAsync(string script, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(script); + + if (_nativeInstance == IntPtr.Zero) + throw new ApplicationException("ExecuteScriptAsync cannot be called until after the Photino window is initialized."); + + cancellationToken.ThrowIfCancellationRequested(); + + var id = Guid.NewGuid().ToString("N"); + var task = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _scriptExecutionTasks[id] = task; + + using var cancellationRegistration = cancellationToken.Register(() => + { + if (_scriptExecutionTasks.TryRemove(id, out var pendingTask)) + pendingTask.TrySetCanceled(cancellationToken); + }); + + try + { + var wrappedScript = CreateScriptExecutionWrapper(id, script); + var executed = false; + Invoke(() => executed = Photino_ExecuteScript(_nativeInstance, wrappedScript)); + if (!executed) + throw new ApplicationException("ExecuteScriptAsync cannot execute because the browser control is not ready."); + + return await task.Task.ConfigureAwait(false); + } + catch + { + _scriptExecutionTasks.TryRemove(id, out _); + throw; + } + } + + private static string CreateScriptExecutionWrapper(string id, string script) + { + var serializedId = JsonSerializer.Serialize(id); + var serializedType = JsonSerializer.Serialize(ExecuteScriptCallbackMessageType); + var serializedScript = JsonSerializer.Serialize(script); + + return $$""" + (() => { + const id = {{serializedId}}; + const type = {{serializedType}}; + const script = {{serializedScript}}; + const send = payload => window.external.sendMessage(JSON.stringify(payload)); + const serializeResult = value => { + if (typeof value === "undefined") + return { resultKind: "undefined", result: null }; + + const json = JSON.stringify(value); + if (typeof json === "undefined") + return { resultKind: "undefined", result: null }; + + return { resultKind: "json", result: JSON.parse(json) }; + }; + const serializeError = error => ({ + name: error && error.name ? error.name : "Error", + message: error && error.message ? error.message : String(error), + stack: error && error.stack ? error.stack : null + }); + + Promise.resolve() + .then(() => new Function(script)()) + .then(result => { + const serializedResult = serializeResult(result); + send({ + type, + id, + success: true, + resultKind: serializedResult.resultKind, + result: serializedResult.result + }); + }) + .catch(error => { + send({ + type, + id, + success: false, + error: serializeError(error) + }); + }); + })(); + """; + } + + private bool TryHandleScriptExecutionCallback(string message) + { + try + { + using var document = JsonDocument.Parse(message); + var root = document.RootElement; + + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("type", out var type) || + type.GetString() != ExecuteScriptCallbackMessageType) + return false; + + if (!root.TryGetProperty("id", out var idProperty) || + idProperty.GetString() is not { } id || + !_scriptExecutionTasks.TryRemove(id, out var task)) + return true; + + if (root.TryGetProperty("success", out var success) && + success.ValueKind == JsonValueKind.True) + { + if (root.TryGetProperty("resultKind", out var resultKind) && + resultKind.GetString() == "undefined") + { + task.TrySetResult(null); + return true; + } + + task.TrySetResult( + root.TryGetProperty("result", out var result) + ? ConvertScriptResult(result) + : null); + return true; + } + + task.TrySetException(new ApplicationException(GetScriptExecutionErrorMessage(root))); + return true; + } + catch (JsonException) + { + return false; + } + } + + private static string GetScriptExecutionErrorMessage(JsonElement root) + { + if (!root.TryGetProperty("error", out var error)) + return "JavaScript execution failed."; + + var name = error.TryGetProperty("name", out var errorName) + ? errorName.GetString() + : null; + var message = error.TryGetProperty("message", out var errorMessage) + ? errorMessage.GetString() + : null; + + return string.IsNullOrWhiteSpace(name) + ? message ?? "JavaScript execution failed." + : $"{name}: {message ?? "JavaScript execution failed."}"; + } + + private static object? ConvertScriptResult(JsonElement result) + { + return result.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.False => false, + JsonValueKind.True => true, + JsonValueKind.String => result.GetString(), + JsonValueKind.Number => result.TryGetInt64(out var longValue) ? longValue : result.GetDouble(), + JsonValueKind.Array => result.EnumerateArray().Select(ConvertScriptResult).ToList(), + JsonValueKind.Object => result.EnumerateObject().ToDictionary(x => x.Name, x => ConvertScriptResult(x.Value)), + _ => null + }; + } +} From b25b5d3dc5900fdb9952f455c1a5cab88a5cc99e Mon Sep 17 00:00:00 2001 From: CypherPotato Date: Sat, 6 Jun 2026 19:20:15 -0300 Subject: [PATCH 2/2] Add popup requested event --- Photino.NET/PhotinoNativeDelegates.cs | 1 + Photino.NET/PhotinoNativeParameters.cs | 3 ++ Photino.NET/PhotinoNetDelegates.cs | 25 +++++++++++ Photino.NET/PhotinoWindow.NET.cs | 1 + Photino.NET/PopupRequestedEventArgs.cs | 58 ++++++++++++++++++++++++++ 5 files changed, 88 insertions(+) create mode 100644 Photino.NET/PopupRequestedEventArgs.cs diff --git a/Photino.NET/PhotinoNativeDelegates.cs b/Photino.NET/PhotinoNativeDelegates.cs index ddab55db..37da313e 100644 --- a/Photino.NET/PhotinoNativeDelegates.cs +++ b/Photino.NET/PhotinoNativeDelegates.cs @@ -14,6 +14,7 @@ namespace Photino.NET; [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Auto)] public delegate void CppMinimizedDelegate(); [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Auto)] public delegate void CppMovedDelegate(int x, int y); [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Auto)] public delegate void CppWebMessageReceivedDelegate(string message); +[UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Auto)] public delegate byte CppPopupRequestedDelegate(string url, string name, int x, int y, int width, int height); [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Auto)] public delegate IntPtr CppWebResourceRequestedDelegate(string url, out int outNumBytes, out string outContentType); //These are sent in during the request diff --git a/Photino.NET/PhotinoNativeParameters.cs b/Photino.NET/PhotinoNativeParameters.cs index cd138b62..ba5ddcb2 100644 --- a/Photino.NET/PhotinoNativeParameters.cs +++ b/Photino.NET/PhotinoNativeParameters.cs @@ -79,6 +79,9 @@ internal struct PhotinoNativeParameters ///SET BY PHOTINIWINDOW CONSTRUCTOR [MarshalAs(UnmanagedType.FunctionPtr)] internal CppWebMessageReceivedDelegate WebMessageReceivedHandler; + ///SET BY PHOTINOWINDOW CONSTRUCTOR + [MarshalAs(UnmanagedType.FunctionPtr)] internal CppPopupRequestedDelegate PopupRequestedHandler; + ///OPTIONAL: Names of custom URL Schemes. e.g. 'app', 'custom'. Array length must be 16. Default is none. [MarshalAs(UnmanagedType.ByValArray, ArraySubType = UnmanagedType.LPStr, SizeConst = 16)] internal string[] CustomSchemeNames; diff --git a/Photino.NET/PhotinoNetDelegates.cs b/Photino.NET/PhotinoNetDelegates.cs index 46873192..c426e7d6 100644 --- a/Photino.NET/PhotinoNetDelegates.cs +++ b/Photino.NET/PhotinoNetDelegates.cs @@ -198,6 +198,31 @@ internal void OnWebMessageReceived(string message) WebMessageReceived?.Invoke(this, message); } + public event EventHandler PopupRequested; + + /// + /// Registers user-defined handler methods to receive callbacks when the browser requests a popup or new window. + /// + /// + /// Returns the current instance. + /// + /// + public PhotinoWindow RegisterPopupRequestedHandler(EventHandler handler) + { + PopupRequested += handler; + return this; + } + + /// + /// Invokes registered user-defined handler methods when the browser requests a popup or new window. + /// + internal byte OnPopupRequested(string url, string name, int x, int y, int width, int height) + { + var args = new PopupRequestedEventArgs(this, url, name, x, y, width, height); + PopupRequested?.Invoke(this, args); + return (byte)(args.Handled ? 1 : 0); + } + public delegate bool NetClosingDelegate(object sender, EventArgs e); public event NetClosingDelegate WindowClosing; diff --git a/Photino.NET/PhotinoWindow.NET.cs b/Photino.NET/PhotinoWindow.NET.cs index 1f7e9ae4..0f93f7bf 100644 --- a/Photino.NET/PhotinoWindow.NET.cs +++ b/Photino.NET/PhotinoWindow.NET.cs @@ -1483,6 +1483,7 @@ public PhotinoWindow(PhotinoWindow parent = null) _startupParameters.FocusInHandler = OnFocusIn; _startupParameters.FocusOutHandler = OnFocusOut; _startupParameters.WebMessageReceivedHandler = OnWebMessageReceived; + _startupParameters.PopupRequestedHandler = OnPopupRequested; _startupParameters.CustomSchemeHandler = OnCustomScheme; } diff --git a/Photino.NET/PopupRequestedEventArgs.cs b/Photino.NET/PopupRequestedEventArgs.cs new file mode 100644 index 00000000..be8e3bc1 --- /dev/null +++ b/Photino.NET/PopupRequestedEventArgs.cs @@ -0,0 +1,58 @@ +namespace Photino.NET; + +/// +/// Provides data for a browser popup or new-window request. +/// +public sealed class PopupRequestedEventArgs : EventArgs +{ + internal PopupRequestedEventArgs(PhotinoWindow window, string url, string name, int x, int y, int width, int height) + { + Window = window; + Url = url; + Name = name; + X = x >= 0 ? x : null; + Y = y >= 0 ? y : null; + Width = width >= 0 ? width : null; + Height = height >= 0 ? height : null; + } + + /// + /// Gets the Photino window that requested the popup. + /// + public PhotinoWindow Window { get; } + + /// + /// Gets the requested popup URL. + /// + public string Url { get; } + + /// + /// Gets the target window name when supplied by the browser engine. + /// + public string Name { get; } + + /// + /// Gets the requested X position when supplied by the browser engine. + /// + public int? X { get; } + + /// + /// Gets the requested Y position when supplied by the browser engine. + /// + public int? Y { get; } + + /// + /// Gets the requested width when supplied by the browser engine. + /// + public int? Width { get; } + + /// + /// Gets the requested height when supplied by the browser engine. + /// + public int? Height { get; } + + /// + /// Gets or sets whether Photino should suppress the browser engine's default popup handling. + /// + public bool Handled { get; set; } +}