Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions Photino.NET/PhotinoDllImports.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions Photino.NET/PhotinoNativeDelegates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Photino.NET/PhotinoNativeParameters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ internal struct PhotinoNativeParameters
///<summary>SET BY PHOTINIWINDOW CONSTRUCTOR</summary>
[MarshalAs(UnmanagedType.FunctionPtr)] internal CppWebMessageReceivedDelegate WebMessageReceivedHandler;

///<summary>SET BY PHOTINOWINDOW CONSTRUCTOR</summary>
[MarshalAs(UnmanagedType.FunctionPtr)] internal CppPopupRequestedDelegate PopupRequestedHandler;

///<summary>OPTIONAL: Names of custom URL Schemes. e.g. 'app', 'custom'. Array length must be 16. Default is none.</summary>
[MarshalAs(UnmanagedType.ByValArray, ArraySubType = UnmanagedType.LPStr, SizeConst = 16)]
internal string[] CustomSchemeNames;
Expand Down
28 changes: 28 additions & 0 deletions Photino.NET/PhotinoNetDelegates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,37 @@ public PhotinoWindow RegisterWebMessageReceivedHandler(EventHandler<string> hand
/// </summary>
internal void OnWebMessageReceived(string message)
{
if (TryHandleScriptExecutionCallback(message))
return;

WebMessageReceived?.Invoke(this, message);
}

public event EventHandler<PopupRequestedEventArgs> PopupRequested;

/// <summary>
/// Registers user-defined handler methods to receive callbacks when the browser requests a popup or new window.
/// </summary>
/// <returns>
/// Returns the current <see cref="PhotinoWindow"/> instance.
/// </returns>
/// <param name="handler"><see cref="EventHandler{PopupRequestedEventArgs}"/></param>
public PhotinoWindow RegisterPopupRequestedHandler(EventHandler<PopupRequestedEventArgs> handler)
{
PopupRequested += handler;
return this;
}

/// <summary>
/// Invokes registered user-defined handler methods when the browser requests a popup or new window.
/// </summary>
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;
Expand Down
196 changes: 196 additions & 0 deletions Photino.NET/PhotinoScriptExecution.cs
Original file line number Diff line number Diff line change
@@ -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<string, TaskCompletionSource<object?>> _scriptExecutionTasks = new();

/// <summary>
/// Executes JavaScript in the current page and waits for a JSON-serializable result.
/// </summary>
/// <param name="script">The JavaScript body to execute. Use a return statement to return a value.</param>
/// <returns>The JSON-serializable result converted to a .NET primitive, list, dictionary, or <c>null</c>.</returns>
/// <exception cref="ApplicationException">Thrown when the window is not initialized, the script fails, or the method is called synchronously on the UI thread.</exception>
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();
}

/// <summary>
/// Executes JavaScript in the current page and asynchronously waits for a JSON-serializable result.
/// </summary>
/// <param name="script">The JavaScript body to execute. Use a return statement to return a value.</param>
/// <param name="cancellationToken">A token that cancels waiting for the script result.</param>
/// <returns>A task that completes with the JSON-serializable result converted to a .NET primitive, list, dictionary, or <c>null</c>.</returns>
/// <exception cref="ApplicationException">Thrown when the window is not initialized or the script fails.</exception>
public async Task<object?> 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<object?>(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
};
}
}
1 change: 1 addition & 0 deletions Photino.NET/PhotinoWindow.NET.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1483,6 +1483,7 @@ public PhotinoWindow(PhotinoWindow parent = null)
_startupParameters.FocusInHandler = OnFocusIn;
_startupParameters.FocusOutHandler = OnFocusOut;
_startupParameters.WebMessageReceivedHandler = OnWebMessageReceived;
_startupParameters.PopupRequestedHandler = OnPopupRequested;
_startupParameters.CustomSchemeHandler = OnCustomScheme;
}

Expand Down
58 changes: 58 additions & 0 deletions Photino.NET/PopupRequestedEventArgs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
namespace Photino.NET;

/// <summary>
/// Provides data for a browser popup or new-window request.
/// </summary>
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;
}

/// <summary>
/// Gets the Photino window that requested the popup.
/// </summary>
public PhotinoWindow Window { get; }

/// <summary>
/// Gets the requested popup URL.
/// </summary>
public string Url { get; }

/// <summary>
/// Gets the target window name when supplied by the browser engine.
/// </summary>
public string Name { get; }

/// <summary>
/// Gets the requested X position when supplied by the browser engine.
/// </summary>
public int? X { get; }

/// <summary>
/// Gets the requested Y position when supplied by the browser engine.
/// </summary>
public int? Y { get; }

/// <summary>
/// Gets the requested width when supplied by the browser engine.
/// </summary>
public int? Width { get; }

/// <summary>
/// Gets the requested height when supplied by the browser engine.
/// </summary>
public int? Height { get; }

/// <summary>
/// Gets or sets whether Photino should suppress the browser engine's default popup handling.
/// </summary>
public bool Handled { get; set; }
}