Skip to content

Commit ffa6ff4

Browse files
committed
feat: initial protocol API
1 parent 03c9b99 commit ffa6ff4

5 files changed

Lines changed: 314 additions & 5 deletions

File tree

src/ElectronNET.API/API/Electron.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ public static Dock Dock
186186
}
187187

188188
/// <summary>
189-
/// Electeon extensions to the Nodejs process object.
189+
/// Electron extensions to the Nodejs process object.
190190
/// </summary>
191191
public static Process Process
192192
{
@@ -195,5 +195,16 @@ public static Process Process
195195
return Process.Instance;
196196
}
197197
}
198+
199+
/// <summary>
200+
/// Register a custom protocol and intercept existing protocol requests.
201+
/// </summary>
202+
public static Protocol Protocol
203+
{
204+
get
205+
{
206+
return Protocol.Instance;
207+
}
208+
}
198209
}
199210
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text;
4+
using System.Threading.Tasks;
5+
6+
namespace ElectronNET.API
7+
{
8+
9+
public sealed class Protocol : ApiBase
10+
{
11+
protected override SocketTaskEventNameTypes SocketTaskEventNameType => SocketTaskEventNameTypes.DashesLowerFirst;
12+
protected override SocketTaskMessageNameTypes SocketTaskMessageNameType => SocketTaskMessageNameTypes.DashesLowerFirst;
13+
14+
internal Protocol()
15+
{
16+
}
17+
18+
internal static Protocol Instance
19+
{
20+
get
21+
{
22+
if (_protocol == null)
23+
{
24+
lock (_syncRoot)
25+
{
26+
_protocol ??= new Protocol();
27+
}
28+
}
29+
30+
return _protocol;
31+
}
32+
}
33+
34+
private static Protocol _protocol;
35+
36+
private static readonly object _syncRoot = new();
37+
38+
/// <summary>
39+
/// Registers the scheme as standard, secure, bypasses content security policy for resources, allows registering ServiceWorker, supports fetch API, streaming video/audio, and V8 code cache.
40+
/// Specify a privilege with the value of true to enable the capability.
41+
/// </summary>
42+
/// <param name="customSchemes">Custom schemes to be registered with options.</param>
43+
/// <remarks>This method can only be used before the <see cref="App.Ready"/> event of the app module gets emitted and can be called only once.</remarks>
44+
public Task RegisterSchemesAsPrivilegedAsync(params CustomScheme[] customSchemes)
45+
{
46+
var tsc = new TaskCompletionSource();
47+
48+
BridgeConnector.Socket.Once("registerSchemesAsPrivilegedComplete", tsc.SetResult);
49+
BridgeConnector.Socket.Emit("registerSchemesAsPrivileged", customSchemes);
50+
51+
return tsc.Task;
52+
}
53+
54+
/// <summary>
55+
/// Register a protocol handler for scheme. Requests made to URLs with this scheme will delegate to this handler to determine what response should be sent.
56+
/// </summary>
57+
/// <param name="scheme">scheme to handle, for example https or my-app. This is the bit before the : in a URL.</param>
58+
/// <param name="handler">Either a <see cref="Response" /> or a <see cref="Task{Response}"/> can be returned.</param>
59+
public void Handle(string scheme, Func<Request, Response> handler)
60+
{
61+
if (string.IsNullOrWhiteSpace(scheme))
62+
throw new ArgumentException("Scheme must not be null or empty.", nameof(scheme));
63+
64+
if (handler == null)
65+
throw new ArgumentNullException(nameof(handler));
66+
67+
Handle(scheme, req => Task.FromResult(handler(req)));
68+
}
69+
70+
public void Handle(string scheme, Func<Request, Task<Response>> handler)
71+
{
72+
if (string.IsNullOrWhiteSpace(scheme))
73+
throw new ArgumentException("Scheme must not be null or empty.", nameof(scheme));
74+
75+
if (handler == null)
76+
throw new ArgumentNullException(nameof(handler));
77+
78+
var tsc = new TaskCompletionSource();
79+
80+
// Tell TS to register protocol.handle for this scheme.
81+
BridgeConnector.Socket.Emit("protocol-handle-register", new
82+
{
83+
scheme
84+
});
85+
86+
// Listen for incoming requests from TS
87+
BridgeConnector.Socket.On<Request>("protocol-handle-request", async (request) =>
88+
{
89+
try
90+
{
91+
if (request == null || !string.Equals(request.Scheme, scheme, StringComparison.OrdinalIgnoreCase))
92+
return; // Not our scheme, ignore.
93+
94+
var response = await handler(request).ConfigureAwait(false) ?? new Response
95+
{
96+
Status = 204
97+
};
98+
99+
// Ensure headers dictionary exists
100+
response.Headers ??= new();
101+
102+
// Push ContentType also as header, for TS convenience
103+
if (!string.IsNullOrEmpty(response.ContentType))
104+
{
105+
response.Headers["content-type"] = new[] { response.ContentType! };
106+
}
107+
108+
BridgeConnector.Socket.Emit("protocol-handle-response", new
109+
{
110+
id = request.Id,
111+
status = response.Status,
112+
headers = response.Headers,
113+
body = response.Body != null
114+
? Convert.ToBase64String(response.Body)
115+
: null
116+
});
117+
}
118+
catch (Exception ex)
119+
{
120+
// In case of error, send a 500 back
121+
var errorBody = Encoding.UTF8.GetBytes("Protocol handler error:\n" + ex);
122+
123+
BridgeConnector.Socket.Emit("protocol-handle-response", new
124+
{
125+
id = request.Id,
126+
status = 500,
127+
headers = new
128+
{
129+
// minimal header set
130+
contentType = new[] { "text/plain; charset=utf-8" }
131+
},
132+
body = Convert.ToBase64String(errorBody)
133+
});
134+
}
135+
});
136+
}
137+
}
138+
139+
public sealed class Request
140+
{
141+
public string Id { get; set; } = default!;
142+
public string Scheme { get; set; } = default!;
143+
public string Url { get; set; } = default!;
144+
public string Method { get; set; } = "GET";
145+
146+
// Case-insensitive header name, multiple values per header.
147+
public Dictionary<string, string[]> Headers { get; set; } = new(StringComparer.OrdinalIgnoreCase);
148+
149+
/// <summary>
150+
/// Request body as raw bytes, or null if none.
151+
/// </summary>
152+
public byte[] Body { get; set; }
153+
}
154+
155+
public sealed class Response
156+
{
157+
/// <summary>
158+
/// HTTP-like status code. Default 200.
159+
/// </summary>
160+
public int Status { get; set; } = 200;
161+
162+
/// <summary>
163+
/// Content-Type header value; convenience property.
164+
/// </summary>
165+
public string ContentType { get; set; }
166+
167+
/// <summary>
168+
/// Response headers (without Content-Type).
169+
/// </summary>
170+
public Dictionary<string, string[]> Headers { get; set; } = new(StringComparer.OrdinalIgnoreCase);
171+
172+
/// <summary>
173+
/// Raw response body (may be null for no body).
174+
/// </summary>
175+
public byte[] Body { get; set; }
176+
}
177+
178+
public sealed class CustomScheme
179+
{
180+
public string Scheme { get; set; }
181+
public CustomSchemePrivileges Privileges { get; set; }
182+
183+
}
184+
public sealed class CustomSchemePrivileges
185+
{
186+
public bool? Standard { get; set; }
187+
public bool? Secure { get; set; }
188+
public bool? BypassCSP { get; set; }
189+
public bool? AllowServiceWorkers { get; set; }
190+
public bool? SupportFetchAPI { get; set; }
191+
public bool? CorsEnabled { get; set; }
192+
public bool? Stream { get; set; }
193+
public bool? CodeCache { get; set; }
194+
}
195+
196+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { Socket } from "net";
2+
import { protocol } from "electron";
3+
import { randomUUID } from "crypto"; // Node 14+; or a simple counter
4+
5+
let electronSocket: Socket;
6+
7+
type Request1 = {
8+
id: string;
9+
scheme: string;
10+
url: string;
11+
method: string;
12+
headers: Record<string, string[]>;
13+
body?: string | null;
14+
};
15+
16+
type Response1 = {
17+
id: string;
18+
status: number;
19+
headers: Record<string, string[]>;
20+
body?: string | null;
21+
};
22+
23+
export = (socket: Socket) => {
24+
electronSocket = socket;
25+
26+
// Already handled earlier:
27+
socket.on("registerSchemesAsPrivileged", (schemes) => {
28+
protocol.registerSchemesAsPrivileged(schemes);
29+
electronSocket.emit("registerSchemesAsPrivilegedCompleted");
30+
});
31+
32+
// New: protocol.handle
33+
socket.on("protocol-handle-register", ({ scheme }: { scheme: string }) => {
34+
protocol.handle(scheme, (request) => handle(scheme, request));
35+
});
36+
};
37+
38+
async function handle(scheme: string, request: Request): Promise<Response> {
39+
const id = randomUUID();
40+
41+
const headers: Record<string, string[]> = {};
42+
for (const [value, key] of request.headers) {
43+
headers[key] = Array.isArray(value) ? value : [value];
44+
}
45+
46+
let body: string | undefined;
47+
if (request.body) {
48+
const buffer = Buffer.from(await request.arrayBuffer());
49+
body = buffer.toString("base64");
50+
}
51+
52+
const dto: Request1 = {
53+
id,
54+
scheme,
55+
url: request.url,
56+
method: request.method,
57+
headers,
58+
body
59+
};
60+
61+
return new Promise<Response>((resolve, reject) => {
62+
const handle = (res: Response1) => {
63+
// Filter by correlation ID
64+
if (res?.id !== id) {
65+
return;
66+
}
67+
68+
electronSocket.off("protocol-handle-response", handle as any);
69+
70+
try {
71+
const status = res.status ?? 200;
72+
const headers = new Headers();
73+
74+
if (res.headers) {
75+
for (const [key, values] of Object.entries(res.headers)) {
76+
if (Array.isArray(values)) {
77+
for (const v of values) {
78+
headers.append(key, v);
79+
}
80+
} else if (typeof values === "string") {
81+
headers.append(key, values);
82+
}
83+
}
84+
}
85+
86+
let body: Buffer | undefined;
87+
if (res.body) {
88+
body = Buffer.from(res.body, "base64");
89+
}
90+
91+
const response = new Response(body, { status, headers });
92+
resolve(response);
93+
} catch (err) {
94+
reject(err);
95+
}
96+
};
97+
98+
electronSocket.on("protocol-handle-response", handle);
99+
electronSocket.emit("protocol-handle-request", dto);
100+
});
101+
}

src/ElectronNET.Host/main.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const portscanner = require('portscanner');
77
const { imageSize } = require('image-size');
88
const { HookService } = require('electron-host-hook');
99
let io, server, browserWindows, ipc, apiProcess, loadURL;
10-
let appApi, menu, dialogApi, notification, tray, webContents;
10+
let appApi, menu, dialogApi, notification, tray, webContents, protocolApi;
1111
let globalShortcut, shellApi, screen, clipboard, autoUpdater;
1212
let commandLine, browserView;
1313
let powerMonitor;
@@ -329,6 +329,7 @@ function startSocketApiBridge(port) {
329329
if (nativeTheme === undefined) nativeTheme = require('./api/nativeTheme')(socket);
330330
if (dock === undefined) dock = require('./api/dock')(socket);
331331
if (processInfo === undefined) processInfo = require('./api/process')(socket);
332+
if (protocolApi === undefined) protocolApi = require('./api/protocol')(socket);
332333

333334
socket.on('register-app-open-file', (id) => {
334335
global['electronsocket'] = socket;
@@ -373,7 +374,7 @@ function startSocketApiBridge(port) {
373374
function startAspCoreBackend(electronPort) {
374375
startBackend();
375376

376-
function startBackend() {
377+
function startBackend() {
377378
loadURL = `about:blank`;
378379
const envParam = getEnvironmentParameter();
379380
const parameters = [
@@ -437,4 +438,4 @@ function getEnvironmentParameter() {
437438
}
438439

439440
return '';
440-
}
441+
}

src/ElectronNET.WebApp/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,4 @@ private static void AddDevelopmentTests()
102102
}
103103
}
104104
}
105-
}
105+
}

0 commit comments

Comments
 (0)