NeutronC - Run GUIs in Chrome!

Post your working scripts, libraries and tools for AHK v1.1 and older
erohtar
Posts: 20
Joined: 24 Jun 2021, 10:53

NeutronC - Run GUIs in Chrome!

14 Jul 2021, 12:17

This Library uses @GeekDude's Chrome.ahk, and tries to replicate his Neutron.ahk based GUIs.
He has done amazing and super impressive work, and I wondered if something like Neutron could also be created in Chrome instead of the IE/Trident base that Neutron uses.

NeutronC
Image

What works:
- The GUI looks like a modern Windows app, and can be fully styled using HTML/CSS/JS
- Interacting with GUI notifies AHK with the element's id/name/value/innerText etc and any custom data you may want to send
- Everything in Chrome.ahk is supported

Notes:
- This may still be a work in progress. Hopefully it'll evolve based on inputs/help/error reports from experts and users here

Credits:
- GeekDude for Chrome.ahk and Neutron.ahk
- Everyone in this forum who helped me whenever I got stuck

NeutronC.1.ahk Sample AHK for above GUI

Code: Select all

#ErrorStdOut
SetBatchLines -1
#NoEnv  ; Recommended for performance and compatibility with future AutoHotkey releases.
SendMode Input  ; Recommended for new scripts due to its superior speed and reliability.
SetWorkingDir %A_ScriptDir%  ; Ensures a consistent starting directory.
#SingleInstance Off
DetectHiddenWindows, On
SetTitleMatchMode, 2
SetKeyDelay, 10


Global chromeInst,pageInst,chromeExe,chromeProfile

/*
my chrome.ini contents

[Internal]
chromeExe=E:\Portables2\Chrome\App\Chrome-bin\chrome.exe
chromeProfile=C:\ahk\chromeProfile

*/


IniRead, chromeExe, chrome.ini, Internal, chromeExe, %A_Space%
IniRead, chromeProfile, chrome.ini, Internal, chromeProfile

IfNotExist, %chromeProfile%
	FileCreateDir, %chromeProfile%
chm_app(chromeExe, chromeProfile,  A_ScriptDir "\NeutronC.html", 640, 600)
	
; Set up a timer to demonstrate making dynamic page updates every so often.
WinWait, NeutronC App
SetTimer, DynamicContent, 100

OnExit, cleanup
Return


handler(data) {
	;name of this function is defined in the html
	If (chm_getVal("nc_id",data) = "button1")
		msgbox % "You clicked the cool button - its name is: "  chm_getVal("nc_name",data) "`n`n(You can also read its id, value, name, innerText, innerHTML here)"
	
	If (chm_getVal("nc_id",data) = "form1")
		msgbox % "Glad to meet you " chm_getVal("firstName",data) " " chm_getVal("lastName",data) "!"
}

dynamicContent() {
	; Get the mouse position
	MouseGetPos, x, y
	
	; Update the page with the new position
	chm_Set(pageInst,"Id","ahk_x",1,"i",x)
	chm_Set(pageInst,"Id","ahk_y",1,"i",y)
}

cleanup:
	;delete profile folder on exit - not everyone might want to do that!
	;rmDir is faster than FileRemoveDir, and also runs in background asynchronously
	;~ Run, %comspec% /c echo y|rmDir /s "%chromeProfile%",, Hide
	
	chm_exit(chromeInst)
ExitApp

#Include f_chrome.ahk
NeutronC.html Sample HTML for above GUI

Code: Select all

<!DOCTYPE html><html>
<head>

  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <link rel="icon" href="https://www.autohotkey.com/favicon.ico" sizes="32x32" type="image/ico">

<style>
	html, body {
		width: 100%; height: 100%;
		margin: 0; padding: 0;
		font-family: sans-serif;
	}

	body {
		display: flex;
		flex-direction: column;
	}

	header {
		width: 100%;
		display: flex;
		background: silver;
		font-family: Segoe UI;
		font-size: 9pt;
	}

	.title-bar {
		padding: 0.35em 0.5em;
		flex-grow: 1;
	}

	.title-btn {
		padding: 0.35em 1.0em;
		cursor: pointer;
		vertical-align: bottom;
		font-family: Webdings;
		font-size: 11pt;
	}

	.title-btn:hover {
		background: rgba(0, 0, 0, .2);
	}

	.title-btn-close:hover {
		background: #dc3545;
	}

	.main {
		flex-grow: 1;
		padding: 0.5em;
		overflow: auto;
	}
</style>
<style>/* Make the title bar dark with light text */
	header {
		background: #333;
		color: white;
	}

	/* Make the content area dark with light text */
	.main {
		background: #444;
		color: white;
	}

	/* Make input boxes follow the dark theme */
	input {
		margin: 0.25em;
		padding: 0.5em;
		border: none;
		background: #333;
		color: white;
		border-radius: 0.25em;
	}
	:-ms-input-placeholder {
		color: silver;
	}
	button {
		background: slategray;
		border: none;
		color: white;
		padding: 0.25em 0.5em;
		border-radius: 0.25em;
	}</style>

</head>
<title>NeutronC App</title>
<body>

<header>
</header>

<div class='main'><h1>Welcome to NeutronC!</h1>

	<p>
		NeutronC provides a powerful set of tools for building HTML-based user interfaces
		with AutoHotkey. It leverages the Chrome Browser, known for its wide use and speed.
	</p>
	<p>
		This example is designed to show how to use the default NeutronC template page.
		It also demonstrates how to call AHK functions from the GUI and pass them data bits.
	</p>

	<h2>Example Button</h2>
	<button name="Tim" id="button1" onclick="console.log('NC:handler~data1=Your Data Here~data2=More Data Here' + stdData(this))">Click Me!</button>
	
	<h2>Example Form</h2>
	<form id="form1" onsubmit="console.log('NC:handler~firstName=' + document.getElementById('firstName').value + '~lastName=' + document.getElementById('lastName').value + stdData(this));">
		<label for="firstName">First Name:</label>
		<input type="text" id="firstName" placeholder="John" required>
		<br>
		<label for="lastName">Last Name:</label>
		<input type="text" id="lastName" placeholder="Smith" required>
		<br>
		<button type="submit">Submit</button>
	</form>

	<h2>Example Dynamic Content</h2>
	<p>
		Your mouse is at <span id="ahk_x">0</span>, <span id="ahk_y">0</span>.
	</p></div>


</body>

<script>
function stdData(e) {
	return '~nc_id=' + e.id + '~nc_value=' + e.value + '~nc_name=' + e.name + '~nc_innerText=' + e.innerText + '~nc_innerHtml=' + e.innerHTML + '~';
}
</script>

</html>
f_Chrome.ahk Original Chrome.ahk Library with wrappers and functions I added

Code: Select all

chm_app(chromeExe, chromeProfile, filePath, cW, cH) {
	;a function that simplifies running chrome looking like a desktop app
	chromeInst := Chm_Launch(chromeExe, chromeProfile, 1, 1, chm_winsize(cW, cH) " --force-device-scale-factor --new-window --safe-mode  --app=file://" filePath)
	
	pageInst := chromeInst.GetPage(,,Func("chm_callBack").Bind()) ;this callback function allows to receive events, and their sender's various particulars
	
	pageInst.Call("Console.enable")	;Enable Console Messages
}

chm_callBack(Event)
{
	if (Event.Method == "Console.messageAdded"  && InStr(Event.params.message.text,"NC:") >= 1) ;only recognize NC: console messages	
	{		
		;Here's an example of a call from JS to AHK console.log("NC:handler,hello world!");
		data := Event.params.message.text ;grab the console's message
		RegExMatch(data, "U)NC:(.*)(~.*)$", o) ;remove NC:, extract handler function and data
		
		%o1%(o2)	; Call that handler function, passing in the rest of data as string
 	}
	else if (Event.Method == "Inspector.detached")
	{ ;capture browser closing, we can save stuff to our local files before closing.
		chm_exit(chromeInst)
		ExitApp
	}
}

chm_getVal(field,data) {
	;function accepts data as returned by chm_callBack() and extracts the value of the field given
	RegExMatch(data, "isU)~" field "=(.*)~", o)
	Return o1
}

chm_launch(chromeExe:="", chromeProfile:="", chromeVisible:=0, chromeIncognito:=0, chromeParams:="") {
	If (chromeExe <> "")
	IfNotExist, %chromeExe%
	{
		MsgBox, 16, %appName% ERROR, Chrome.exe not found at given path:`n%chromeExe%
		ExitApp
	}
	
	if chromeVisible = 0
		chromeFlags .= " --headless"
	if chromeIncognito = 1
		chromeFlags .= " --incognito"
	
	chromeFlags .= " --no-default-browser-check --disable-crash-reporter " chromeParams

	; If the wanted port is taken, use the largest taken port plus one
	DebugPort := 9222
	if (Chromes := Chrome.FindInstances()).HasKey(DebugPort)
		DebugPort := Chromes.MaxIndex() + 1
	chromeInst := new Chrome(chromeProfile, "about:blank", chromeFlags, chromeExe, debugPort)
	
	If chromeVisible = 1
	IfNotInString, chromeParams, --app=		;if running in app mode, then starting url is already provided other than about:blank
	{
		WinWait, about:blank - Google Chrome,, 10
		Sleep, 200
	}
	Return chromeInst
}

chm_winsize(chromeW,chromeH) {
	chromeX := Round((A_ScreenWidth - chromeW) / 2, 0)
	chromeY := Round((A_ScreenHeight - chromeH) / 2, 0)
	
	return "--window-size=""" chromeW "," chromeH """ --window-position=""" chromeX "," chromeY """"
}

chm_exit(chromeInst,Tab:="") {
	If (Tab <> "")
	{
		Tab.close()
		;~ Tab.Call("Browser.close")
		Tab.Disconnect()
	}
	chromeInst.Kill()
}

chm_scrollBy(Tab, x, y) {
	Tab.evaluate("window.scrollBy(" x "," y ")")
}

chm_scrollIntoView(Tab, method, identifier, index:=1) {
	If method = selector
		Try c := Tab.Evaluate("document.querySelector('" identifier "').scrollIntoView()")
	Else If method = class
		Try c := Tab.Evaluate("document.getElementsByClassName('" identifier "')[" index-1 "].scrollIntoView()")
	Else If method = tag
		Try c := Tab.Evaluate("document.getElementsByTagName('" identifier "')[" index-1 "].scrollIntoView()")
	Else If method = id
		Try c := Tab.Evaluate("document.getElementById('" identifier "').scrollIntoView()")
	Else If method = name
		Try c := Tab.Evaluate("document.getElementsByName('" identifier "')[" Index-1 "].scrollIntoView()")
}

chm_click(Tab, method, identifier, index:=1) {
	If method = selector
		Try c := Tab.Evaluate("document.querySelector('" identifier "').click()")
	Else If method = class
		Try c := Tab.Evaluate("document.getElementsByClassName('" identifier "')[" index-1 "].click()")
	Else If method = tag
		Try c := Tab.Evaluate("document.getElementsByTagName('" identifier "')[" index-1 "].click()")
	Else If method = id
		Try c := Tab.Evaluate("document.getElementById('" identifier "').click()")
	Else If method = name
		Try c := Tab.Evaluate("document.getElementsByName('" identifier "')[" Index-1 "].click()")
}


chm_waitForElement(Tab, method, identifier, index:=1) {
	Loop
	{
		If method = selector
			Try c := Tab.Evaluate("document.querySelector('" identifier "')")
		Else If method = class
			Try c := Tab.Evaluate("document.getElementsByClassName('" identifier "')[" index-1 "]")
		Else If method = tag
			Try c := Tab.Evaluate("document.getElementsByTagName('" identifier "')[" index-1 "]")
		Else If method = id
			Try c := Tab.Evaluate("document.getElementById('" identifier "')")
		Else If method = name
			Try c := Tab.Evaluate("document.getElementsByName('" identifier "')[" Index-1 "]")
		
		If (IsObject(c) = 1)
			break
		
		Sleep, 100
	}
}

chm_waitForElementContent(content, Tab, method, identifier, index:=1, valType := "value" ) {
	Loop
	{
		If method = selector
			Try c := Tab.Evaluate("document.querySelector('" identifier "')." valType).value
		Else If method = class
			Try c := Tab.Evaluate("document.getElementsByClassName('" identifier "')[" index-1 "]." valType).value
		Else If method = tag
			Try c := Tab.Evaluate("document.getElementsByTagName('" identifier "')[" index-1 "]." valType).value
		Else If method = id
			Try c := Tab.Evaluate("document.getElementById('" identifier "')." valType).value
		Else If method = name
			Try c := Tab.Evaluate("document.getElementsByName('" identifier "')[" Index-1 "]." valType).Value
		
		IfInString, c, %content%
			break
		
		Sleep, 100
	}
}

chm_tableToArray(Tab, method, identifier, index:=1) {
	
	If method = selector
		Try e := Tab.Evaluate("document.querySelector('" identifier "').outerHTML")
	Else If method = class
		Try e := Tab.Evaluate("document.getElementsByClassName('" identifier "')[" index-1 "].outerHTML")
	Else If method = tag
		Try e := Tab.Evaluate("document.getElementsByTagName('" identifier "')[" index-1 "].outerHTML")
	Else If method = id
		Try e := Tab.Evaluate("document.getElementById('" identifier "').outerHTML")
	Else If method = name
		Try e := Tab.Evaluate("document.getElementsByName('" identifier "')[" Index-1 "].outerHTML")

	d := ComObjCreate("HTMLFile")
	d.write(e.value)

	tObj := []
	loop % d.getElementsByTagName("table")[0].rows.length
	{
		row:= (A_Index-1)
		loop % d.getElementsByTagName("table")[0].rows[row].cells.length
		{
			col := (A_Index-1)
			tObj[row, col] := d.getElementsByTagName("table")[0].rows[row].cells[col].innertext
		}
	}
	d := ""
	return tObj
}

;********************Method: Class, Tag, Name, ID  **** ***********************************
;********************Get Property***********************************
chm_Get(Tab,Method="Class",Attribute="hfeed",Index=1) {
 Obj:={}
 ;***********Class*******************
 if (Format("{:L}",Method)="class"){ ;Case-insensitive check to see if method = Class
 Obj.OuterHTML:= Tab.Evaluate("document.getElementsByClassName('" Attribute "')[" Index-1 "].outerHTML").Value
 Obj.Value:= Tab.Evaluate("document.getElementsByClassName('" Attribute "')[" Index-1 "].value").Value
 Obj.InnerText:= Tab.Evaluate("document.getElementsByClassName('" Attribute "')[" Index-1 "].innerText").Value
 Obj.TextContent:= Tab.Evaluate("document.getElementsByClassName('" Attribute "')[" Index-1 "].textContent").Value
 } ;***********Tag*******************
 Else if (Format("{:L}",Method)="tag"){  ;Case-insensitive check to see if method = Tag
 Obj.OuterHTML:= Tab.Evaluate("document.getElementsByTagName('" Attribute "')[" Index-1 "].outerHTML").Value
 Obj.Value:= Tab.Evaluate("document.getElementsByTagName('" Attribute "')[" Index-1 "].value").Value
 Obj.InnerText:= Tab.Evaluate("document.getElementsByTagName('" Attribute "')[" Index-1 "].innerText").Value
 Obj.TextContent:= Tab.Evaluate("document.getElementsByTagName('" Attribute "')[" Index-1 "].textContent").Value
 } ;************Name******************
 Else if (Format("{:L}",Method)="name"){  ;Case-insensitive check to see if method = Name
 Obj.OuterHTML:= Tab.Evaluate("document.getElementsByName('" Attribute "')[" Index-1 "].outerHTML").Value
 Obj.Value:= Tab.Evaluate("document.getElementsByName('" Attribute "')[" Index-1 "].value").Value
 Obj.InnerText:= Tab.Evaluate("document.getElementsByName('" Attribute "')[" Index-1 "].innerText").Value
 Obj.TextContent:= Tab.Evaluate("document.getElementsByName('" Attribute "')[" Index-1 "].textContent").Value
 } ;************Selector******************
 Else if (Format("{:L}",Method)="selector"){  ;Case-insensitive check to see if method = Selector
 Obj.OuterHTML:= Tab.Evaluate("document.querySelector('" Attribute "').outerHTML").Value
 Obj.Value:= Tab.Evaluate("document.querySelector('" Attribute "').value").Value
 Obj.InnerText:= Tab.Evaluate("document.querySelector('" Attribute "').innerText").Value
 Obj.TextContent:= Tab.Evaluate("document.querySelector('" Attribute "').textContent").Value
 } ;***********ID*******************
 Else if (Format("{:L}",Method)="id"){  ;Case-insensitive check to see if method = ID
 Obj.OuterHTML:= Tab.Evaluate("document.getElementById('" Attribute "').outerHTML").Value
 Obj.Value:= Tab.Evaluate("document.getElementById('" Attribute "').value").Value
 Obj.InnerText:= Tab.Evaluate("document.getElementById('" Attribute "').innerText").Value
 Obj.TextContent:= Tab.Evaluate("document.getElementById('" Attribute "').textContent").Value
 } Else{ MsgBox fix Method - Valid values are: Name, Class, Tag, Selector, ID
}
return obj
}

;~ chm_Set(Tab,Method:="Class",Attribute:="s",Index:=2,Property:="v",Value:="Just a test")
;~ chm_Set(Tab,Method:="Name",Attribute:="s",Index:=2,Property:="v",Value:="Just another test")
;~ chm_Set(Tab,Method:="tag",Attribute:="Input",Index:=1,Property:="v",Value:="Just a test")
;~ chm_Set(Tab,Method:="ID",Attribute:="cat",Index:=2,Property:="v",Value:="11")
 

chm_Set(Tab,Method="Class",Attribute="hfeed",Index=1,Property="i",Value="") {
 ;***********Class*******************
 if (Format("{:L}",Method)="class"){  ;Case-insensitive check to see if method = Class
 if (Format("{:L}",Property)="o") ;If Property="o" then set OuterHTML
 Tab.Evaluate("document.getElementsByClassName('" Attribute "')[" Index-1 "].outerHTML='" Value "'").Value
 Else if (Format("{:L}",Property)="v") ;If Property="v" then set Value
 Tab.Evaluate("document.getElementsByClassName('" Attribute "')[" Index-1 "].value='" Value "'").Value
 Else if (Format("{:L}",Property)="i") ;If Property="i" then set innerText
 Tab.Evaluate("document.getElementsByClassName('" Attribute "')[" Index-1 "].innerText='" Value "'").Value
 } ;***********Tag*******************
 Else if (Format("{:L}",Method)="tag"){ ;Case-insensitive check to see if method = Tag
 if (Format("{:L}",Property)="o") ;If Property="o" then set OuterHTML
 Tab.Evaluate("document.getElementsByTagName('" Attribute "')[" Index-1 "].outerHTML='" Value "'").Value
 Else if (Format("{:L}",Property)="v") ;If Property="v" then set Value
 Tab.Evaluate("document.getElementsByTagName('" Attribute "')[" Index-1 "].value='" Value "'").Value
 Else if (Format("{:L}",Property)="i") ;If Property="i" then set innerText
 Tab.Evaluate("document.getElementsByTagName('" Attribute "')[" Index-1 "].innerText='" Value "'").Value
 } ;***********Name*******************
 Else if (Format("{:L}",Method)="Name"){ ;Case-insensitive check to see if method = Name
 if (Format("{:L}",Property)="o") ;If Property="o" then set OuterHTML
 Tab.Evaluate("document.getElementsByName('" Attribute "')[" Index-1 "].outerHTML='" Value "'").Value
 Else if (Format("{:L}",Property)="v") ;If Property="v" then set Value
 Tab.Evaluate("document.getElementsByName('" Attribute "')[" Index-1 "].value='" Value "'").Value
 Else if (Format("{:L}",Property)="i") ;If Property="i" then set innerText
 Tab.Evaluate("document.getElementsByName('" Attribute "')[" Index-1 "].innerText='" Value "'").Value
 } ;***********Selector*******************
 Else if (Format("{:L}",Method)="Selector"){ ;Case-insensitive check to see if method = Selector
 if (Format("{:L}",Property)="o") ;If Property="o" then set OuterHTML
 Tab.Evaluate("document.querySelector('" Attribute "').outerHTML='" Value "'").Value
 Else if (Format("{:L}",Property)="v") ;If Property="v" then set Value
 Tab.Evaluate("document.querySelector('" Attribute "').value='" Value "'").Value
 Else if (Format("{:L}",Property)="i") ;If Property="i" then set innerText
 Tab.Evaluate("document.querySelector('" Attribute "').innerText='" Value "'").Value
 } ;***********ID*******************
 Else if (Format("{:L}",Method)="id"){ ;Case-insensitive check to see if method = ID
 if (Format("{:L}",Property)="o") ;If Property="o" then set OuterHTML
 Tab.Evaluate("document.getElementById('" Attribute "').outerHTML='" Value "'").Value
 Else if (Format("{:L}",Property)="v") ;If Property="v" then set Value
 Tab.Evaluate("document.getElementById('" Attribute "').value='" Value "'").Value
 Else if (Format("{:L}",Property)="i") ;If Property="i" then set innerText
 Tab.Evaluate("document.getElementById('" Attribute "').innerText='" Value "'").Value
 } Else{ MsgBox fix Method- Valid values are: Name, Class, Tag, Selector, ID (case of text does not matter)
}
}

;----------->;----------->;----------->;----------->;

; Chrome.ahk v1.2
; Copyright GeekDude 2018
; https://github.com/G33kDude/Chrome.ahk

class Chrome
{
	static DebugPort := 9222
	
	/*
		Escape a string in a manner suitable for command line parameters
	*/
	CliEscape(Param)
	{
		return """" RegExReplace(Param, "(\\*)""", "$1$1\""") """"
	}
	
	/*
		Finds instances of chrome in debug mode and the ports they're running
		on. If no instances are found, returns a false value. If one or more
		instances are found, returns an associative array where the keys are
		the ports, and the values are the full command line texts used to start
		the processes.
		
		One example of how this may be used would be to open chrome on a
		different port if an instance of chrome is already open on the port
		you wanted to used.
		
		```
		; If the wanted port is taken, use the largest taken port plus one
		DebugPort := 9222
		if (Chromes := Chrome.FindInstances()).HasKey(DebugPort)
			DebugPort := Chromes.MaxIndex() + 1
		ChromeInst := new Chrome(ProfilePath,,,, DebugPort)
		```
		
		Another use would be to scan for running instances and attach to one
		instead of starting a new instance.
		
		```
		if (Chromes := Chrome.FindInstances())
			ChromeInst := {"base": Chrome, "DebugPort": Chromes.MinIndex()}
		else
			ChromeInst := new Chrome(ProfilePath)
		```
	*/
	FindInstances()
	{
		static Needle := "--remote-debugging-port=(\d+)"
		Out := {}
		for Item in ComObjGet("winmgmts:")
			.ExecQuery("SELECT CommandLine FROM Win32_Process"
			. " WHERE Name = 'chrome.exe'")
			if RegExMatch(Item.CommandLine, Needle, Match)
				Out[Match1] := Item.CommandLine
		return Out.MaxIndex() ? Out : False
	}
	
	/*
		ProfilePath - Path to the user profile directory to use. Will use the standard if left blank.
		URLs        - The page or array of pages for Chrome to load when it opens
		Flags       - Additional flags for chrome when launching
		ChromePath  - Path to chrome.exe, will detect from start menu when left blank
		DebugPort   - What port should Chrome's remote debugging server run on
	*/
	__New(ProfilePath:="", URLs:="about:blank", Flags:="", ChromePath:="", DebugPort:="")
	{
		; Verify ProfilePath
		if (ProfilePath != "" && !InStr(FileExist(ProfilePath), "D"))
			throw Exception("The given ProfilePath does not exist")
		this.ProfilePath := ProfilePath
		
		; Verify ChromePath
		if (ChromePath == "")
			FileGetShortcut, %A_StartMenuCommon%\Programs\Google Chrome.lnk, ChromePath
		if (ChromePath == "")
			RegRead, ChromePath, HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe
		if !FileExist(ChromePath)
			throw Exception("Chrome could not be found")
		this.ChromePath := ChromePath
		
		; Verify DebugPort
		if (DebugPort != "")
		{
			if DebugPort is not integer
				throw Exception("DebugPort must be a positive integer")
			else if (DebugPort <= 0)
				throw Exception("DebugPort must be a positive integer")
			this.DebugPort := DebugPort
		}
		
		; Escape the URL(s)
		for Index, URL in IsObject(URLs) ? URLs : [URLs]
			URLString .= " " this.CliEscape(URL)
		
		Run, % this.CliEscape(ChromePath)
		. " --remote-debugging-port=" this.DebugPort
		. (ProfilePath ? " --user-data-dir=" this.CliEscape(ProfilePath) : "")
		. (Flags ? " " Flags : "")
		. URLString
		,,, OutputVarPID
		this.PID := OutputVarPID
	}
	
	/*
		End Chrome by terminating the process.
	*/
	Kill()
	{
		Process, Close, % this.PID
	}
	
	/*
		Queries chrome for a list of pages that expose a debug interface.
		In addition to standard tabs, these include pages such as extension
		configuration pages.
	*/
	GetPageList()
	{
		http := ComObjCreate("WinHttp.WinHttpRequest.5.1")
		http.open("GET", "http://127.0.0.1:" this.DebugPort "/json")
		http.send()
		return this.Jxon_Load(http.responseText)
	}
	
	/*
		Returns a connection to the debug interface of a page that matches the
		provided criteria. When multiple pages match the criteria, they appear
		ordered by how recently the pages were opened.
		
		Key        - The key from the page list to search for, such as "url" or "title"
		Value      - The value to search for in the provided key
		MatchMode  - What kind of search to use, such as "exact", "contains", "startswith", or "regex"
		Index      - If multiple pages match the given criteria, which one of them to return
		fnCallback - A function to be called whenever message is received from the page
	*/
	GetPageBy(Key, Value, MatchMode:="exact", Index:=1, fnCallback:="")
	{
		Count := 0
		for n, PageData in this.GetPageList()
		{
			if (((MatchMode = "exact" && PageData[Key] = Value) ; Case insensitive
				|| (MatchMode = "contains" && InStr(PageData[Key], Value))
				|| (MatchMode = "startswith" && InStr(PageData[Key], Value) == 1)
				|| (MatchMode = "regex" && PageData[Key] ~= Value))
				&& ++Count == Index)
				return new this.Page(PageData.webSocketDebuggerUrl, fnCallback)
		}
	}
	
	/*
		Shorthand for GetPageBy("url", Value, "startswith")
	*/
	GetPageByURL(Value, MatchMode:="startswith", Index:=1, fnCallback:="")
	{
		return this.GetPageBy("url", Value, MatchMode, Index, fnCallback)
	}
	
	/*
		Shorthand for GetPageBy("title", Value, "startswith")
	*/
	GetPageByTitle(Value, MatchMode:="startswith", Index:=1, fnCallback:="")
	{
		return this.GetPageBy("title", Value, MatchMode, Index, fnCallback)
	}
	
	/*
		Shorthand for GetPageBy("type", Type, "exact")
		
		The default type to search for is "page", which is the visible area of
		a normal Chrome tab.
	*/
	GetPage(Index:=1, Type:="page", fnCallback:="")
	{
		return this.GetPageBy("type", Type, "exact", Index, fnCallback)
	}
	
	/*
		Connects to the debug interface of a page given its WebSocket URL.
	*/
	class Page
	{
		Connected := False
		ID := 0
		Responses := []
		
		/*
			wsurl      - The desired page's WebSocket URL
			fnCallback - A function to be called whenever message is received
		*/
		__New(wsurl, fnCallback:="")
		{
			this.fnCallback := fnCallback
			this.BoundKeepAlive := this.Call.Bind(this, "Browser.getVersion",, False)
			
			; TODO: Throw exception on invalid objects
			if IsObject(wsurl)
				wsurl := wsurl.webSocketDebuggerUrl
			
			wsurl := StrReplace(wsurl, "localhost", "127.0.0.1")
			this.ws := {"base": this.WebSocket, "_Event": this.Event, "Parent": this}
			this.ws.__New(wsurl)
			
			while !this.Connected
				Sleep, 50
		}
		
		/*
			Calls the specified endpoint and provides it with the given
			parameters.
			
			DomainAndMethod - The endpoint domain and method name for the
			endpoint you would like to call. For example:
			PageInst.Call("Browser.close")
			PageInst.Call("Schema.getDomains")
			
			Params - An associative array of parameters to be provided to the
			endpoint. For example:
			PageInst.Call("Page.printToPDF", {"scale": 0.5 ; Numeric Value
			, "landscape": Chrome.Jxon_True() ; Boolean Value
			, "pageRanges: "1-5, 8, 11-13"}) ; String value
			PageInst.Call("Page.navigate", {"url": "https://autohotkey.com/"})
			
			WaitForResponse - Whether to block until a response is received from
			Chrome, which is necessary to receive a return value, or whether
			to continue on with the script without waiting for a response.
		*/
		Call(DomainAndMethod, Params:="", WaitForResponse:=True)
		{
			if !this.Connected
				throw Exception("Not connected to tab")
			
			; Use a temporary variable for ID in case more calls are made
			; before we receive a response.
			ID := this.ID += 1
			this.ws.Send(Chrome.Jxon_Dump({"id": ID
			, "params": Params ? Params : {}
			, "method": DomainAndMethod}))
			
			if !WaitForResponse
				return
			
			; Wait for the response
			this.responses[ID] := False
			while !this.responses[ID]
			{
				Sleep, 50
				If A_Index > 10
					break
			}
			
			; Get the response, check if it's an error
			response := this.responses.Delete(ID)
			if (response.error)
				throw Exception("Chrome indicated error in response",, Chrome.Jxon_Dump(response.error))
			
			return response.result
		}
		
		/*
			Run some JavaScript on the page. For example:
			
			PageInst.Evaluate("alert(""I can't believe it's not IE!"");")
			PageInst.Evaluate("document.getElementsByTagName('button')[0].click();")
		*/
		Evaluate(JS)
		{
			response := this.Call("Runtime.evaluate",
			( LTrim Join
			{
				"expression": JS,
				"objectGroup": "console",
				"includeCommandLineAPI": Chrome.Jxon_True(),
				"silent": Chrome.Jxon_False(),
				"returnByValue": Chrome.Jxon_False(),
				"userGesture": Chrome.Jxon_True(),
				"awaitPromise": Chrome.Jxon_False()
			}
			))
			
			if (response.exceptionDetails)
				throw Exception(response.result.description,, Chrome.Jxon_Dump(response.exceptionDetails))
			
			return response.result
		}
		
		EvaluateObj(JS)
		{
			response := this.Call("Runtime.evaluate",
			( LTrim Join
			{
				"expression": JS,
				"objectGroup": "console",
				"includeCommandLineAPI": Chrome.Jxon_True(),
				"silent": Chrome.Jxon_False(),
				"returnByValue": Chrome.Jxon_True(),
				"userGesture": Chrome.Jxon_True(),
				"awaitPromise": Chrome.Jxon_False()
			}
			))
			
			if (response.exceptionDetails)
			{
				throw Exception(Chrome.Jxon_Dump(response.result.description)) ; Chrome.Jxon_Dump(response.exceptionDetails)
			}
			
			return response.result
		}
		
		/*
			Waits for the page's readyState to match the DesiredState.
			
			DesiredState - The state to wait for the page's ReadyState to match
			Interval     - How often it should check whether the state matches
		*/
		WaitForLoad(DesiredState:="complete", Interval:=100)
		{
			while this.Evaluate("document.readyState").value != DesiredState
				Sleep, %Interval%
		}
		
		/*
			Internal function triggered when the script receives a message on
			the WebSocket connected to the page.
		*/
		Event(EventName, Event)
		{
			; If it was called from the WebSocket adjust the class context
			if this.Parent
				this := this.Parent
			
			; TODO: Handle Error events
			if (EventName == "Open")
			{
				this.Connected := True
				BoundKeepAlive := this.BoundKeepAlive
				SetTimer, %BoundKeepAlive%, 15000
			}
			else if (EventName == "Message")
			{
				data := Chrome.Jxon_Load(Event.data)
				
				; Run the callback routine
				fnCallback := this.fnCallback
				if (newData := %fnCallback%(data))
					data := newData
				
				if this.responses.HasKey(data.ID)
					this.responses[data.ID] := data
			}
			else if (EventName == "Close")
			{
				this.Disconnect()
			}
			else if (EventName == "Error")
			{
				throw Exception("Websocket Error!")
			}
		}
		
		/*
			Disconnect from the page's debug interface, allowing the instance
			to be garbage collected.
			
			This method should always be called when you are finished with a
			page or else your script will leak memory.
		*/
		Disconnect()
		{
			if !this.Connected
				return
			
			this.Connected := False
			this.ws.Delete("Parent")
			this.ws.Disconnect()
			
			BoundKeepAlive := this.BoundKeepAlive
			SetTimer, %BoundKeepAlive%, Delete
			this.Delete("BoundKeepAlive")
		}
		
		class WebSocket
		{
			__New(WS_URL)
			{
				static wb
				
				; Create an IE instance
				Gui, +hWndhOld
				Gui, New, +hWndhWnd
				this.hWnd := hWnd
				Gui, Add, ActiveX, vWB, Shell.Explorer
				Gui, %hOld%: Default
				
				; Write an appropriate document
				WB.Navigate("about:<!DOCTYPE html><meta http-equiv='X-UA-Compatible'"
				. "content='IE=edge'><body></body>")
				while (WB.ReadyState < 4)
					sleep, 50
				this.document := WB.document
				
				; Add our handlers to the JavaScript namespace
				this.document.parentWindow.ahk_savews := this._SaveWS.Bind(this)
				this.document.parentWindow.ahk_event := this._Event.Bind(this)
				this.document.parentWindow.ahk_ws_url := WS_URL
				
				; Add some JavaScript to the page to open a socket
				Script := this.document.createElement("script")
				Script.text := "ws = new WebSocket(ahk_ws_url);`n"
				. "ws.onopen = function(event){ ahk_event('Open', event); };`n"
				. "ws.onclose = function(event){ ahk_event('Close', event); };`n"
				. "ws.onerror = function(event){ ahk_event('Error', event); };`n"
				. "ws.onmessage = function(event){ ahk_event('Message', event); };"
				this.document.body.appendChild(Script)
			}
			
			; Called by the JS in response to WS events
			_Event(EventName, Event)
			{
				this["On" EventName](Event)
			}
			
			; Sends data through the WebSocket
			Send(Data)
			{
				this.document.parentWindow.ws.send(Data)
			}
			
			; Closes the WebSocket connection
			Close(Code:=1000, Reason:="")
			{
				this.document.parentWindow.ws.close(Code, Reason)
			}
			
			; Closes and deletes the WebSocket, removing
			; references so the class can be garbage collected
			Disconnect()
			{
				if this.hWnd
				{
					this.Close()
					Gui, % this.hWnd ": Destroy"
					this.hWnd := False
				}
			}
		}
	}
	
	Jxon_Load(ByRef src, args*)
	{
		static q := Chr(34)
		
		key := "", is_key := false
		stack := [ tree := [] ]
		is_arr := { (tree): 1 }
		next := q . "{[01234567890-tfn"
		pos := 0
		while ( (ch := SubStr(src, ++pos, 1)) != "" )
		{
			if InStr(" `t`n`r", ch)
				continue
			if !InStr(next, ch, true)
			{
				ln := ObjLength(StrSplit(SubStr(src, 1, pos), "`n"))
				col := pos - InStr(src, "`n",, -(StrLen(src)-pos+1))
				
				msg := Format("{}: line {} col {} (char {})"
				,   (next == "")      ? ["Extra data", ch := SubStr(src, pos)][1]
				: (next == "'")     ? "Unterminated string starting at"
				: (next == "\")     ? "Invalid \escape"
				: (next == ":")     ? "Expecting ':' delimiter"
				: (next == q)       ? "Expecting object key enclosed in double quotes"
				: (next == q . "}") ? "Expecting object key enclosed in double quotes or object closing '}'"
				: (next == ",}")    ? "Expecting ',' delimiter or object closing '}'"
				: (next == ",]")    ? "Expecting ',' delimiter or array closing ']'"
				: [ "Expecting JSON value(string, number, [true, false, null], object or array)"
				, ch := SubStr(src, pos, (SubStr(src, pos)~="[\]\},\s]|$")-1) ][1]
				, ln, col, pos)
				
				throw Exception(msg, -1, ch)
			}
			
			is_array := is_arr[obj := stack[1]]
			
			if i := InStr("{[", ch)
			{
				val := (proto := args[i]) ? new proto : {}
				is_array? ObjPush(obj, val) : obj[key] := val
				ObjInsertAt(stack, 1, val)
				
				is_arr[val] := !(is_key := ch == "{")
				next := q . (is_key ? "}" : "{[]0123456789-tfn")
			}
			
			else if InStr("}]", ch)
			{
				ObjRemoveAt(stack, 1)
				next := stack[1]==tree ? "" : is_arr[stack[1]] ? ",]" : ",}"
			}
			
			else if InStr(",:", ch)
			{
				is_key := (!is_array && ch == ",")
				next := is_key ? q : q . "{[0123456789-tfn"
			}
			
			else ; string | number | true | false | null
			{
				if (ch == q) ; string
				{
					i := pos
					while i := InStr(src, q,, i+1)
					{
						val := StrReplace(SubStr(src, pos+1, i-pos-1), "\\", "\u005C")
						static end := A_AhkVersion<"2" ? 0 : -1
						if (SubStr(val, end) != "\")
							break
					}
					if !i ? (pos--, next := "'") : 0
						continue
					
					pos := i ; update pos
					
					val := StrReplace(val,    "\/",  "/")
					, val := StrReplace(val, "\" . q,    q)
					, val := StrReplace(val,    "\b", "`b")
					, val := StrReplace(val,    "\f", "`f")
					, val := StrReplace(val,    "\n", "`n")
					, val := StrReplace(val,    "\r", "`r")
					, val := StrReplace(val,    "\t", "`t")
					
					i := 0
					while i := InStr(val, "\",, i+1)
					{
						if (SubStr(val, i+1, 1) != "u") ? (pos -= StrLen(SubStr(val, i)), next := "\") : 0
							continue 2
						
						; \uXXXX - JSON unicode escape sequence
						xxxx := Abs("0x" . SubStr(val, i+2, 4))
						if (A_IsUnicode || xxxx < 0x100)
							val := SubStr(val, 1, i-1) . Chr(xxxx) . SubStr(val, i+6)
					}
					
					if is_key
					{
						key := val, next := ":"
						continue
					}
				}
				
				else ; number | true | false | null
				{
					val := SubStr(src, pos, i := RegExMatch(src, "[\]\},\s]|$",, pos)-pos)
					
					; For numerical values, numerify integers and keep floats as is.
					; I'm not yet sure if I should numerify floats in v2.0-a ...
					static number := "number", integer := "integer"
					if val is %number%
					{
						if val is %integer%
							val += 0
					}
					; in v1.1, true,false,A_PtrSize,A_IsUnicode,A_Index,A_EventInfo,
					; SOMETIMES return strings due to certain optimizations. Since it
					; is just 'SOMETIMES', numerify to be consistent w/ v2.0-a
					else if (val == "true" || val == "false")
						val := %value% + 0
					; AHK_H has built-in null, can't do 'val := %value%' where value == "null"
					; as it would raise an exception in AHK_H(overriding built-in var)
					else if (val == "null")
						val := ""
					; any other values are invalid, continue to trigger error
					else if (pos--, next := "#")
						continue
					
					pos += i-1
				}
				
				is_array? ObjPush(obj, val) : obj[key] := val
				next := obj==tree ? "" : is_array ? ",]" : ",}"
			}
		}
		
		return tree[1]
	}
	
	Jxon_Dump(obj, indent:="", lvl:=1)
	{
		static q := Chr(34)
		
		if IsObject(obj)
		{
			static Type := Func("Type")
			if Type ? (Type.Call(obj) != "Object") : (ObjGetCapacity(obj) == "")
				throw Exception("Object type not supported.", -1, Format("<Object at 0x{:p}>", &obj))
			
			prefix := SubStr(A_ThisFunc, 1, InStr(A_ThisFunc, ".",, 0))
			fn_t := prefix "Jxon_True",  obj_t := this ? %fn_t%(this) : %fn_t%()
			fn_f := prefix "Jxon_False", obj_f := this ? %fn_f%(this) : %fn_f%()
			
			if (&obj == &obj_t)
				return "true"
			else if (&obj == &obj_f)
				return "false"
			
			is_array := 0
			for k in obj
				is_array := k == A_Index
			until !is_array
			
			static integer := "integer"
			if indent is %integer%
			{
				if (indent < 0)
					throw Exception("Indent parameter must be a postive integer.", -1, indent)
				spaces := indent, indent := ""
				Loop % spaces
					indent .= " "
			}
			indt := ""
			Loop, % indent ? lvl : 0
				indt .= indent
			
			this_fn := this ? Func(A_ThisFunc).Bind(this) : A_ThisFunc
			lvl += 1, out := "" ; Make #Warn happy
			for k, v in obj
			{
				if IsObject(k) || (k == "")
					throw Exception("Invalid object key.", -1, k ? Format("<Object at 0x{:p}>", &obj) : "<blank>")
				
				if !is_array
					out .= ( ObjGetCapacity([k], 1) ? %this_fn%(k) : q . k . q ) ;// key
				.  ( indent ? ": " : ":" ) ; token + padding
				out .= %this_fn%(v, indent, lvl) ; value
				.  ( indent ? ",`n" . indt : "," ) ; token + indent
			}
			
			if (out != "")
			{
				out := Trim(out, ",`n" . indent)
				if (indent != "")
					out := "`n" . indt . out . "`n" . SubStr(indt, StrLen(indent)+1)
			}
			
			return is_array ? "[" . out . "]" : "{" . out . "}"
		}
		
		; Number
		else if (ObjGetCapacity([obj], 1) == "")
			return obj
		
		; String (null -> not supported by AHK)
		if (obj != "")
		{
			obj := StrReplace(obj,  "\",    "\\")
			, obj := StrReplace(obj,  "/",    "\/")
			, obj := StrReplace(obj,    q, "\" . q)
			, obj := StrReplace(obj, "`b",    "\b")
			, obj := StrReplace(obj, "`f",    "\f")
			, obj := StrReplace(obj, "`n",    "\n")
			, obj := StrReplace(obj, "`r",    "\r")
			, obj := StrReplace(obj, "`t",    "\t")
			
			static needle := (A_AhkVersion<"2" ? "O)" : "") . "[^\x20-\x7e]"
			while RegExMatch(obj, needle, m)
				obj := StrReplace(obj, m[0], Format("\u{:04X}", Ord(m[0])))
		}
		
		return q . obj . q
	}
	
	Jxon_True()
	{
		static obj := {}
		return obj
	}
	
	Jxon_False()
	{
		static obj := {}
		return obj
	}
}
Last edited by erohtar on 15 Jul 2021, 11:02, edited 2 times in total.
erohtar
Posts: 20
Joined: 24 Jun 2021, 10:53

Re: NeutronC - Run GUIs in Chrome!

15 Jul 2021, 08:22

Just to try it out, I adapted GeekDude's Bootstrap GUI and my AHK for NeutronC.
It was quite simple really!

Image

Attached file set should be ready to run and test on any PC, as long as your Chrome path is "C:\Program Files\Google\Chrome\Application\chrome.exe"

In HTML file:
1. Removed titlebar, added favicon (optional)
2. Replaced onClick and onSubmit code for buttons and form
3. Added the tiny Javascript at bottom

In AHK file:
1. Just slightly updated handler function to display received data from this GUI

Edit: Made the attached file set even more plug-and-play for people with Chrome installed
Attachments
NeutronC.2.zip
(80.49 KiB) Downloaded 226 times
djuga
Posts: 88
Joined: 28 Jul 2019, 08:16

Re: NeutronC - Run GUIs in Chrome!

03 Aug 2021, 21:30

Nice, but how call js function from ahk, without this - it makes no sense, and how hide title bar ?
My questions are baffling! :crazy:
tuzi
Posts: 223
Joined: 27 Apr 2016, 23:40

Re: NeutronC - Run GUIs in Chrome!

04 Oct 2021, 05:09

@erohtar

hi erohtar, i am very interesting with this project and found some useful information.

1. When the user's language is not English, the Bootstrap GUI will pop up a translation box.
Unfortunately, I didn't find any command line to disable it pop up.
But add <meta name="google" content="notranslate" /> in NeutronC.2.html will work. :D

2. When I tried to solve problem 1, I found a similar project lorca.
In the source code, he set the following command line for chrome, which may be useful.

Code: Select all

	str:=["--disable-background-networking"
			, "--disable-background-timer-throttling"
			, "--disable-backgrounding-occluded-windows"
			, "--disable-breakpad"
			, "--disable-client-side-phishing-detection"
			, "--disable-default-apps"
			, "--disable-dev-shm-usage"
			, "--disable-infobars"
			, "--disable-extensions"
			, "--disable-features=site-per-process"
			, "--disable-hang-monitor"
			, "--disable-ipc-flooding-protection"
			, "--disable-popup-blocking"
			, "--disable-prompt-on-repost"
			, "--disable-renderer-backgrounding"
			, "--disable-sync"
			, "--disable-translate"
			, "--disable-windows10-custom-titlebar"
			, "--metrics-recording-only"
			, "--no-first-run"
			, "--no-default-browser-check"
			, "--safebrowsing-disable-auto-update"
			, "--enable-automation"
			, "--password-store=basic"
			, "--use-mock-keychain"]
Thank you for your sharing :thumbup: :thumbup:
User avatar
Xeo786
Posts: 759
Joined: 09 Nov 2015, 02:43
Location: Karachi, Pakistan

Re: NeutronC - Run GUIs in Chrome!

08 Oct 2021, 00:13

just for your information, Chrome.ahk also uses IE/Trident based websocket
"When there is no gravity, there is absolute vacuum and light travel with no time" -Game changer theory
Stavencross
Posts: 90
Joined: 24 May 2016, 16:42

Re: NeutronC - Run GUIs in Chrome!

29 Oct 2021, 10:12

Xeo786 wrote:
08 Oct 2021, 00:13
just for your information, Chrome.ahk also uses IE/Trident based websocket
I wanted to expand on this and offer some context about Neutron and Chrome.ahk.

What is the point of Neutron?
- Neutron is more about allowing you to have a boilerplate to create an HTML GUI as quickly as possible, however it runs on IE / Trident (basically, it runs on a stripped down file explorer window.)

Why use chrome.ahk instead of Neutron?
- using Chrome.ahk allows you to create a autohotkey native application within a chrome window, or automate a flat chrome window. (extract text, interact with items on the page, ect). You get a lot of access to tools that using the IE / Trident engine doesn't have, such as the ability to use google inspector to troubleshoot code.

Why use Neutron instead of Chrome.ahk?
- Chrome.ahk requires a good bit of effort to work it, its meant as a mostly blank slate, and you have to set up a lot of call back executions to really use it as a GUI. Neutron is much more plug and play with a lower barrier to entry.

How does Chrome.ahk work?
- Chrome.ahk starts off with GeekDude's WebSocket class, which actually creates an internet explorer window, and bundles chrome into it, using chrome's debug mode to facilitate communication between the two. (this is a layman's explanation of it) so although you're using chrome, its wrapped in internet explorer.

that being said, what the OP has managed to do, is lower the cost of entry to chrome.ahk and provide you with a faster starting point to get up and running with chrome.ahk (In fact, I see some of the code I contributed to chrome.ahk, so +1 haha =).)

Overall, this could be very helpful to someone that wants to use chrome.ahk, and just isn't sure where to start
Stavencross
Posts: 90
Joined: 24 May 2016, 16:42

Re: NeutronC - Run GUIs in Chrome!

29 Oct 2021, 10:14

tuzi wrote:
04 Oct 2021, 05:09
@erohtar

hi erohtar, i am very interesting with this project and found some useful information.

1. When the user's language is not English, the Bootstrap GUI will pop up a translation box.
Unfortunately, I didn't find any command line to disable it pop up.
But add <meta name="google" content="notranslate" /> in NeutronC.2.html will work. :D

2. When I tried to solve problem 1, I found a similar project lorca.
In the source code, he set the following command line for chrome, which may be useful.

Code: Select all

	str:=["--disable-background-networking"
			, "--disable-background-timer-throttling"
			, "--disable-backgrounding-occluded-windows"
			, "--disable-breakpad"
			, "--disable-client-side-phishing-detection"
			, "--disable-default-apps"
			, "--disable-dev-shm-usage"
			, "--disable-infobars"
			, "--disable-extensions"
			, "--disable-features=site-per-process"
			, "--disable-hang-monitor"
			, "--disable-ipc-flooding-protection"
			, "--disable-popup-blocking"
			, "--disable-prompt-on-repost"
			, "--disable-renderer-backgrounding"
			, "--disable-sync"
			, "--disable-translate"
			, "--disable-windows10-custom-titlebar"
			, "--metrics-recording-only"
			, "--no-first-run"
			, "--no-default-browser-check"
			, "--safebrowsing-disable-auto-update"
			, "--enable-automation"
			, "--password-store=basic"
			, "--use-mock-keychain"]
Thank you for your sharing :thumbup: :thumbup:
This is expected functionality of Google Chrome. If you create a new chrome profile, it can get confused about the default language and attempt to translate it. These run flags that you provided work because you're telling chrome what to do without it having to go through new user sign in and configuration. You could instead, just sign in to the debug instance of chrome i believe?
Leox23
Posts: 14
Joined: 23 Dec 2016, 20:13
Contact:

Re: NeutronC - Run GUIs in Chrome!

10 Feb 2023, 08:42

This is good, thank you very much for the contribution!

Return to “Scripts and Functions (v1)”

Who is online

Users browsing this forum: gwarble and 113 guests