Automation Engines

Inside the GPAL REST API Server (OttoMagic's Backend)

What This Program Is

GPALRestAPI.exe is a small console/Forms hybrid app that does two jobs at once: it hosts an HttpListener on a local port (3000 at startup, incrementing until it finds a free one, which is what browser.RestApiBaseUrl then points at), and it speaks Chrome's native messaging protocol over stdin/stdout to a companion browser extension. When browser.OttoMagicClient sends an endpoint like LeftClick or GoTo, this server receives the HTTP request, forwards it to the extension as a native message, and the extension performs the action in the real browser tab.

[STAThread]

static void Main(string[] args)

{

try

{

if (args.Length == 1 && int.TryParse(args[0], out int port) && port > 1024 && port < 65536)

_listenPort = args[0];


// one instance runs per browser, each recorded with its process id and port in ports-in-use.log

AppDomain.CurrentDomain.ProcessExit += (s, e) => UnregisterFromPortsLog();

RegisterForToasts();

StartRestServer();

if (listener != null)

RegisterInPortsLog();

ShowFirstRunNotification();


var stdinSafe = new SafeFileHandle(GetStdHandle(STD_INPUT_HANDLE), ownsHandle: false);

var stdoutSafe = new SafeFileHandle(GetStdHandle(STD_OUTPUT_HANDLE), ownsHandle: false);

_stdin = new FileStream(stdinSafe, FileAccess.Read);

_stdout = new FileStream(stdoutSafe, FileAccess.Write);


// StartRestServer ran before stdout existed, so the port goes to the extension now

SendPortToExtension();


// returns when the browser closes stdin: the browser exiting or the extension reloading

ListenForNativeMessages();


Environment.Exit(0);

}

catch (Exception ex)

{

File.AppendAllText(CrashLogPath, $"{DateTime.Now}: {ex}

{ex.StackTrace}


");

}

}

Starting the HTTP Listener

StartRestServer starts at port 3000 (or the port given on the command line) and walks up until an HttpListener can bind it, on localhost plus every active local IPv4 address, so the API is reachable both from localhost and from the LAN. If the LAN addresses are refused with access denied, it binds localhost alone. Once it is listening it announces the port, over UDP to the GPAL that launched the browser and over native messaging to the extension. Each incoming request is handed to HandleAPIRequest on a thread-pool thread, so the listener can take the next one while a request waits on the extension.

private static void StartRestServer()

{

var ips = GetActiveIPv4Addresses();

int port = int.TryParse(_listenPort, out int parsed) ? parsed : 3000;


while (port < 65535)

{

_listenPort = port.ToString();

bool skipIps = false;

int ipRetry = 2;


while (0 < ipRetry--)

try

{

listener = new HttpListener();

listener.Prefixes.Add($"http://localhost:{_listenPort}/");


if (false == skipIps)

foreach (var ip in ips)

listener.Prefixes.Add($"http://{ip}:{_listenPort}/");


listener.Start();


SendPortHandshake(port); // UDP, to the GPAL that launched the browser

SendPortToExtension();


new Thread(() =>

{

while (listener.IsListening)

{

HttpListenerContext context = listener.GetContext();

ThreadPool.QueueUserWorkItem(_ => HandleAPIRequest(context));

}

}).Start();


return;

}

catch (HttpListenerException ex) when (ex.ErrorCode == 5 && false == skipIps)

{

// access denied on the LAN addresses: try again on localhost alone

StopServer();

skipIps = true;

}

catch (HttpListenerException)

{

// port in use: try the next one

try { listener?.Close(); } catch { }

listener = null;

ipRetry = 0;

port++;

}

}

}

WARNING

If binding to a LAN IP fails with a permission error, the server quietly falls back to localhost only. To open it to the LAN, call /openlan: it copies the netsh urlacl and firewall rule commands for its port to the clipboard and shows a notification. Paste them into an elevated command prompt and restart the server.

Routing Requests: Built-In Commands vs Endpoint Forwarding

HandleAPIRequest first checks for a small set of built-in routes: status, process-id, help, mcp, controller, hosts, openlan, upload, the OAuth helpers access-token and get-access-token, and the ANTKEY, XAIKEY and OPENAIKEY key lookups. Anything else falls through to the default case, which relays it to the extension: GET requests go as-is, POST requests carry the body too, and the request waits for the extension's reply. This default case is what handles every ApiEndpoint that browser.OttoMagicClient calls.

switch (message)

{

case "status":

// a request only arrives while the listener is up, so any answer means it is running

SendRestResponse(response, HttpStatusCode.OK, "Server is running");

break;


case "help":

SendRestResponse(response, HttpStatusCode.OK, GetHelpText(), "text/plain");

break;


default:

if ("GET" == context.Request.HttpMethod)

RelayToExtension(context, message, null);

else if ("POST" == context.Request.HttpMethod)

{

string jsonBody = new StreamReader(context.Request.InputStream).ReadToEnd();

RelayToExtension(context, message, jsonBody);

}

else

SendRestResponse(response, HttpStatusCode.MethodNotAllowed, "Method not supported");

break;

}

NOTE

When browser.OttoMagicClient calls .GoTo("https://example.com").Execute(), it issues an HTTP request whose path matches the endpoint (for example /goto). The path becomes 'message' here, with every slash removed, and is forwarded straight to the extension. RelayToExtension waits up to two minutes for the extension's reply and answers the request with it; if the browser closed first the request gets a 503, and if no reply comes in time, a 504.

Native Messaging: Talking to the Browser Extension

Native messages use a 4-byte length prefix followed by a UTF-8 JSON payload, read from and written to stdin/stdout. ReadNativeMessage blocks waiting for the extension's responses, and SendNativeMessageToExtension packages an outgoing command (with optional JSON body) the same way. That same blocking read is what tells the server when to stop: the loop runs until the pipe closes, and closing the pipe is what the browser does on its way out.

private static void SendNativeMessageToExtension(string message, string jsonBody = null)

{

string payload = string.IsNullOrEmpty(jsonBody)

? $@"{{""message"":""{message}""}}"

: $@"{{""message"":""{message}"",""data"":{jsonBody}}}";


byte[] data = Encoding.UTF8.GetBytes(payload);

byte[] len = BitConverter.GetBytes(data.Length);


_stdout.Write(len, 0, 4);

_stdout.Write(data, 0, data.Length);

_stdout.Flush();

}

WARNING

The server lives exactly as long as the browser does. Closing the browser or reloading the extension breaks stdin, and the server exits with it rather than lingering as an orphan. The port goes too: a workflow still holding that base url has nothing to talk to, and the next server takes whichever port is free and broadcasts the new one.

The Controller Page

Hitting /controller serves a small HTML/JS workflow builder page. It lists every ApiEndpoint, lets you fill in parameters for each step, and runs the resulting sequence by calling the same endpoints over fetch() - useful for manually testing or demoing the API without writing any C# at all.

case "controller":

ServeControllerPage(response);

break;


// inside the served page:

// const EP = {

// 'goto': {cat:'Navigation', method:'POST', label:'GoTo', params:[{n:'url', req:true}]},

// 'back': {cat:'Navigation', method:'GET', label:'Back', params:[]},

// ...

// };

// async function execStep(s) {

// const info = EP[s.ep]; if (!info) return null;

// const url = apiBase() + '/' + s.ep;

// const hasP = Object.keys(s.params).length > 0;

// const method = (hasP && info.method === 'POST') ? 'POST' : 'GET';

// const opts = method === 'POST'

// ? { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(s.params) }

// : { method: 'GET' };

// const res = await fetch(url, opts);

// const txt = await res.text();

// try { return JSON.parse(txt); } catch { return txt; }

// }

💬 Ask GPAL