import { httpRequest, createHttpRequest, loadScript, decodeUrl,
browser, setBatchMode, isBatchMode, isObject, isFunc, isStr, btoa_func } from './core.mjs';
import { closeCurrentWindow, showProgress, loadOpenui5 } from './gui/utils.mjs';
import { sha256, sha256_2 } from './base/sha256.mjs';
// secret session key used for hashing connections keys
// only if set, all messages from and to server signed with HMAC hash
let sessionKey = '';
/** @summary HMAC implementation
* @desc see https://en.wikipedia.org/wiki/HMAC for more details
* @private */
function HMAC(key, m, o) {
const kbis = sha256(sessionKey + key),
block_size = 64,
opad = 0x5c, ipad = 0x36,
ko = [], ki = [];
while (kbis.length < block_size)
kbis.push(0);
for (let i = 0; i < kbis.length; ++i) {
const code = kbis[i];
ko.push(code ^ opad);
ki.push(code ^ ipad);
}
const hash = sha256_2(ki, (o === undefined) ? m : new Uint8Array(m, o));
return sha256_2(ko, hash, true);
}
/**
* @summary Class emulating web socket with long-poll http requests
*
* @private
*/
class LongPollSocket {
constructor(addr, _raw, _handle, _counter) {
this.path = addr;
this.connid = null;
this.req = null;
this.raw = _raw;
this.handle = _handle;
this.counter = _counter;
this.nextRequest('', 'connect');
}
/** @summary Submit next request */
nextRequest(data, kind) {
let url = this.path, reqmode = 'buf', post = null;
if (kind === 'connect') {
url += this.raw ? '?raw_connect' : '?txt_connect';
if (this.handle) url += '&' + this.handle.getConnArgs(this.counter++);
this.connid = 'connect';
} else if (kind === 'close') {
if ((this.connid === null) || (this.connid === 'close')) return;
url += `?connection=${this.connid}&close`;
if (this.handle) url += '&' + this.handle.getConnArgs(this.counter++);
this.connid = 'close';
reqmode = 'text;sync'; // use sync mode to close connection before browser window closed
} else if ((this.connid === null) || (typeof this.connid !== 'number')) {
if (!browser.qt5 && !browser.qt6) console.error('No connection');
} else {
url += '?connection=' + this.connid;
if (this.handle) url += '&' + this.handle.getConnArgs(this.counter++);
if (kind === 'dummy') url += '&dummy';
}
if (data) {
if (this.raw) {
// special workaround to avoid POST request, use base64 coding
url += '&post=' + btoa_func(data);
} else {
// send data with post request - most efficient way
reqmode = 'postbuf';
post = data;
}
}
createHttpRequest(url, reqmode, function(res) {
// this set to the request itself, res is response
if (this.handle.req === this)
this.handle.req = null; // get response for existing dummy request
if (res === null)
return this.handle.processRequest(null);
if (this.handle.raw) {
// raw mode - all kind of reply data packed into binary buffer
// first 4 bytes header 'txt:' or 'bin:'
// after the 'bin:' there is length of optional text argument like 'bin:14 :optional_text'
// and immediately after text binary data. Server sends binary data so, that offset should be multiple of 8
const u8Arr = new Uint8Array(res);
let str = '', i = 0, offset = u8Arr.length;
if (offset < 4) {
if (!browser.qt5 && !browser.qt6) console.error(`longpoll got short message in raw mode ${offset}`);
return this.handle.processRequest(null);
}
while (i < 4) str += String.fromCharCode(u8Arr[i++]);
if (str !== 'txt:') {
str = '';
while ((i < offset) && (String.fromCharCode(u8Arr[i]) !== ':'))
str += String.fromCharCode(u8Arr[i++]);
++i;
offset = i + parseInt(str.trim());
}
str = '';
while (i < offset) str += String.fromCharCode(u8Arr[i++]);
if (str) {
if (str === '<<nope>>')
this.handle.processRequest(-1111);
else
this.handle.processRequest(str);
}
if (offset < u8Arr.length)
this.handle.processRequest(res, offset);
} else if (this.getResponseHeader('Content-Type') === 'application/x-binary') {
// binary reply with optional header
const extra_hdr = this.getResponseHeader('LongpollHeader');
if (extra_hdr) this.handle.processRequest(extra_hdr);
this.handle.processRequest(res, 0);
} else {
// text reply
if (res && !isStr(res)) {
let str = '';
const u8Arr = new Uint8Array(res);
for (let i = 0; i < u8Arr.length; ++i)
str += String.fromCharCode(u8Arr[i]);
res = str;
}
if (res === '<<nope>>')
this.handle.processRequest(-1111);
else
this.handle.processRequest(res);
}
}, function(/* err, status */) {
this.handle.processRequest(null, 'error');
}, true).then(req => {
req.handle = this;
if (!this.req)
this.req = req; // any request can be used for response, do not submit dummy until req is there
req.send(post);
});
}
/** @summary Process request */
processRequest(res, _offset) {
if (res === null) {
if (isFunc(this.onerror))
this.onerror('receive data with connid ' + (this.connid || '---'));
if ((_offset === 'error') && isFunc(this.onclose))
this.onclose('force_close');
this.connid = null;
return;
} else if (res === -1111)
res = '';
let dummy_tmout = 5;
if (this.connid === 'connect') {
if (!res) {
this.connid = null;
if (isFunc(this.onerror))
this.onerror('connection rejected');
return;
}
this.connid = parseInt(res);
dummy_tmout = 100; // when establishing connection, wait a bit longer to submit dummy package
console.log(`Get new longpoll connection with id ${this.connid}`);
if (isFunc(this.onopen))
this.onopen();
} else if (this.connid === 'close') {
if (isFunc(this.onclose))
this.onclose();
return;
} else {
if (isFunc(this.onmessage) && res)
this.onmessage({ data: res, offset: _offset });
}
// minimal timeout to reduce load, generate dummy only if client not submit new request immediately
if (!this.req)
setTimeout(() => { if (!this.req) this.nextRequest('', 'dummy'); }, dummy_tmout);
}
/** @summary Send data */
send(str) { this.nextRequest(str); }
/** @summary Close connection */
close() { this.nextRequest('', 'close'); }
} // class LongPollSocket
// ========================================================================================
/**
* @summary Class re-playing socket data from stored protocol
*
* @private
*/
class FileDumpSocket {
constructor(receiver) {
this.receiver = receiver;
this.protocol = [];
this.cnt = 0;
httpRequest('protocol.json', 'text').then(res => this.getProtocol(res));
}
/** @summary Get stored protocol */
getProtocol(res) {
if (!res) return;
this.protocol = JSON.parse(res);
if (isFunc(this.onopen)) this.onopen();
this.nextOperation();
}
/** @summary Emulate send - just count operation */
send(/* str */) {
if (this.protocol[this.cnt] === 'send') {
this.cnt++;
setTimeout(() => this.nextOperation(), 10);
}
}
/** @summary Emulate close */
close() {}
/** @summary Read data for next operation */
nextOperation() {
// when file request running - just ignore
if (this.wait_for_file) return;
const fname = this.protocol[this.cnt];
if (!fname) return;
if (fname === 'send') return; // waiting for send
this.wait_for_file = true;
this.cnt++;
httpRequest(fname, (fname.indexOf('.bin') > 0 ? 'buf' : 'text')).then(res => {
this.wait_for_file = false;
if (!res) return;
const p = fname.indexOf('_ch'),
chid = (p > 0) ? Number.parseInt(fname.slice(p+3, fname.indexOf('.', p))) : 1;
if (isFunc(this.receiver.provideData))
this.receiver.provideData(chid, res, 0);
setTimeout(() => this.nextOperation(), 10);
});
}
} // class FileDumpSocket
/**
* @summary Client communication handle for RWebWindow.
*
* @desc Should be created with {@link connectWebWindow} function
*/
class WebWindowHandle {
constructor(socket_kind, credits) {
this.kind = socket_kind;
this.state = 0;
this.credits = Math.max(3, credits || 10);
this.cansend = this.credits;
this.ackn = this.credits; // this number will be send to server with first message
this.send_seq = 1; // sequence counter of send messages
this.recv_seq = 0; // sequence counter of received messages
}
/** @summary Returns arguments specified in the RWebWindow::SetUserArgs() method
* @desc Can be any valid JSON expression. Undefined by default.
* @param {string} [field] - if specified and user args is object, returns correspondent object member
* @return user arguments object */
getUserArgs(field) {
if (field && isStr(field))
return isObject(this.user_args) ? this.user_args[field] : undefined;
return this.user_args;
}
/** @summary Set user args
* @desc Normally set via RWebWindow::SetUserArgs() method */
setUserArgs(args) { this.user_args = args; }
/** @summary Set callbacks receiver.
* @param {object} obj - object with receiver functions
* @param {function} obj.onWebsocketMsg - called when new data received from RWebWindow
* @param {function} obj.onWebsocketOpened - called when connection established
* @param {function} obj.onWebsocketClosed - called when connection closed
* @param {function} obj.onWebsocketError - called when get error via the connection */
setReceiver(obj) { this.receiver = obj; }
/** @summary Cleanup and close connection. */
cleanup() {
delete this.receiver;
this.close(true);
}
/** @summary Invoke method in the receiver.
* @private */
invokeReceiver(brdcst, method, arg, arg2) {
if (this.receiver && isFunc(this.receiver[method]))
this.receiver[method](this, arg, arg2);
if (brdcst && this.channels) {
const ks = Object.keys(this.channels);
for (let n = 0; n < ks.length; ++n)
this.channels[ks[n]].invokeReceiver(false, method, arg, arg2);
}
}
/** @summary Provide data for receiver. When no queue - do it directly.
* @private */
provideData(chid, msg, len) {
if (this.wait_first_recv) {
// here dummy first recv like EMBED_DONE is handled
delete this.wait_first_recv;
this.state = 1;
return this.invokeReceiver(false, 'onWebsocketOpened');
}
if ((chid > 1) && this.channels) {
const channel = this.channels[chid];
if (channel)
return channel.provideData(1, msg, len);
}
const force_queue = len && (len < 0);
if (!force_queue && (!this.msgqueue || !this.msgqueue.length))
return this.invokeReceiver(false, 'onWebsocketMsg', msg, len);
if (!this.msgqueue) this.msgqueue = [];
if (force_queue) len = undefined;
this.msgqueue.push({ ready: true, msg, len });
}
/** @summary Reserve entry in queue for data, which is not yet decoded.
* @private */
reserveQueueItem() {
if (!this.msgqueue) this.msgqueue = [];
const item = { ready: false, msg: null, len: 0 };
this.msgqueue.push(item);
return item;
}
/** @summary Provide data for item which was reserved before.
* @private */
markQueueItemDone(item, _msg, _len) {
item.ready = true;
item.msg = _msg;
item.len = _len;
this.processQueue();
}
/** @summary Process completed messages in the queue
* @private */
processQueue() {
if (this._loop_msgqueue || !this.msgqueue) return;
this._loop_msgqueue = true;
while ((this.msgqueue.length > 0) && this.msgqueue[0].ready) {
const front = this.msgqueue.shift();
this.invokeReceiver(false, 'onWebsocketMsg', front.msg, front.len);
}
if (this.msgqueue.length === 0)
delete this.msgqueue;
delete this._loop_msgqueue;
}
/** @summary Close connection */
close(force) {
if (this.master) {
this.master.send(`CLOSECH=${this.channelid}`, 0);
delete this.master.channels[this.channelid];
delete this.master;
return;
}
if (this.timerid) {
clearTimeout(this.timerid);
delete this.timerid;
}
if (this._websocket && (this.state > 0)) {
this.state = force ? -1 : 0; // -1 prevent socket from reopening
this._websocket.onclose = null; // hide normal handler
this._websocket.close();
delete this._websocket;
}
}
/** @summary Checks number of credits for send operation
* @param {number} [numsend = 1] - number of required send operations
* @return true if one allow to send specified number of text message to server */
canSend(numsend) { return this.cansend >= (numsend || 1); }
/** @summary Returns number of possible send operations relative to number of credits */
getRelCanSend() { return !this.credits ? 1 : this.cansend / this.credits; }
/** @summary Send text message via the connection.
* @param {string} msg - text message to send
* @param {number} [chid] - channel id, 1 by default, 0 used only for internal communication */
send(msg, chid) {
if (this.master)
return this.master.send(msg, this.channelid);
if (!this._websocket || (this.state <= 0)) return false;
if (!Number.isInteger(chid)) chid = 1; // when not configured, channel 1 is used - main widget
if (this.cansend <= 0) console.error(`should be queued before sending cansend: ${this.cansend}`);
const prefix = `${this.send_seq++}:${this.ackn}:${this.cansend}:${chid}:`;
this.ackn = 0;
this.cansend--; // decrease number of allowed send packets
let hash = 'none';
if (this.key && sessionKey)
hash = HMAC(this.key, `${prefix}${msg}`);
this._websocket.send(`${hash}:${prefix}${msg}`);
if ((this.kind === 'websocket') || (this.kind === 'longpoll')) {
if (this.timerid) clearTimeout(this.timerid);
this.timerid = setTimeout(() => this.keepAlive(), 10000);
}
return true;
}
/** @summary Send only last message of specified kind during defined time interval.
* @desc Idea is to prevent sending multiple messages of similar kind and overload connection
* Instead timeout is started after which only last specified message will be send
* @private */
sendLast(kind, tmout, msg) {
let d = this._delayed;
if (!d) d = this._delayed = {};
d[kind] = msg;
if (!d[`${kind}_handler`])
d[`${kind}_handler`] = setTimeout(() => { delete d[`${kind}_handler`]; this.send(d[kind]); }, tmout);
}
/** @summary Inject message(s) into input queue, for debug purposes only
* @private */
inject(msg, chid, immediate) {
// use timeout to avoid too deep call stack
if (!immediate)
return setTimeout(this.inject.bind(this, msg, chid, true), 0);
if (chid === undefined) chid = 1;
if (Array.isArray(msg)) {
for (let k = 0; k < msg.length; ++k)
this.provideData(chid, isStr(msg[k]) ? msg[k] : JSON.stringify(msg[k]), -1);
this.processQueue();
} else if (msg)
this.provideData(chid, isStr(msg) ? msg : JSON.stringify(msg));
}
/** @summary Send keep-alive message.
* @desc Only for internal use, only when used with web sockets
* @private */
keepAlive() {
delete this.timerid;
this.send('KEEPALIVE', 0);
}
/** @summary Request server to resize window
* @desc For local displays like CEF or qt5 only server can do this */
resizeWindow(w, h) {
if (browser.qt5 || browser.qt6 || browser.cef3)
this.send(`RESIZE=${w},${h}`, 0);
else if ((typeof window !== 'undefined') && isFunc(window?.resizeTo))
window.resizeTo(w, h);
}
/** @summary Method open channel, which will share same connection, but can be used independently from main
* @private */
createChannel() {
if (this.master)
return this.master.createChannel();
const channel = new WebWindowHandle('channel', this.credits);
channel.wait_first_recv = true; // first received message via the channel is confirmation of established connection
if (!this.channels) {
this.channels = {};
this.freechannelid = 2;
}
channel.master = this;
channel.channelid = this.freechannelid++;
// register
this.channels[channel.channelid] = channel;
// now server-side entity should be initialized and init message send from server side!
return channel;
}
/** @summary Returns true if socket connected */
isConnected() { return this.state > 0; }
/** @summary Returns used channel ID, 1 by default */
getChannelId() { return this.channelid && this.master ? this.channelid : 1; }
/** @summary Assign href parameter
* @param {string} [path] - absolute path, when not specified window.location.url will be used
* @private */
setHRef(path) {
if (isStr(path) && (path.indexOf('?') > 0)) {
this.href = path.slice(0, path.indexOf('?'));
const d = decodeUrl(path);
this.key = d.get('key');
this.token = d.get('token');
} else {
this.href = path;
delete this.key;
delete this.token;
}
}
/** @summary Return href part
* @param {string} [relative_path] - relative path to the handle
* @private */
getHRef(relative_path) {
if (!relative_path || !this.kind || !this.href)
return this.href;
let addr = this.href;
if (relative_path.indexOf('../') === 0) {
const ddd = addr.lastIndexOf('/', addr.length-2);
addr = addr.slice(0, ddd) + relative_path.slice(2);
} else
addr += relative_path;
return addr;
}
/** @summary provide connection args for the web socket
* @private */
getConnArgs(ntry) {
let args = '';
if (this.key) {
const k = HMAC(this.key, `attempt_${ntry}`);
args += `key=${k}&ntry=${ntry}`;
}
if (this.token) {
if (args) args += '&';
args += `token=${this.token}`;
}
return args;
}
/** @summary Connect to the server
* @param [href] - optional URL to widget, use document URL instead
* @private */
connect(href) {
this.close();
if (href) {
this._secondary = true;
this.setHRef(href);
}
href = this.href;
let ntry = 0;
const retry_open = first_time => {
if (this.state !== 0) return;
if (!first_time)
console.log(`try connect window again ${new Date().toString()}`);
if (this._websocket) {
this._websocket.close();
delete this._websocket;
}
if (!href) {
href = window.location.href;
if (href && href.indexOf('#') > 0)
href = href.slice(0, href.indexOf('#'));
if (href && href.lastIndexOf('/') > 0)
href = href.slice(0, href.lastIndexOf('/') + 1);
}
this.href = href;
ntry++;
if (first_time) console.log(`Opening web socket at ${href}`);
if (ntry > 2) showProgress(`Trying to connect ${href}`);
let path = href;
if (this.kind === 'file') {
path += 'root.filedump';
this._websocket = new FileDumpSocket(this);
console.log(`configure protocol log ${path}`);
} else if ((this.kind === 'websocket') && first_time) {
path = path.replace('http://', 'ws://').replace('https://', 'wss://') + 'root.websocket';
console.log(`configure websocket ${path}`);
path += '?' + this.getConnArgs(ntry);
this._websocket = new WebSocket(path);
} else {
path += 'root.longpoll';
console.log(`configure longpoll ${path}`);
this._websocket = new LongPollSocket(path, (this.kind === 'rawlongpoll'), this, ntry);
}
if (!this._websocket) return;
this._websocket.onopen = () => {
if (ntry > 2) showProgress();
this.state = 1;
const reply = (this._secondary ? '' : 'generate_key;') + (this.key || '');
this.send(`READY=${reply}`, 0); // need to confirm connection and request new key
this.invokeReceiver(false, 'onWebsocketOpened');
};
this._websocket.onmessage = e => {
let msg = e.data;
if (this.next_binary) {
const binchid = this.next_binary,
server_hash = this.next_binary_hash;
delete this.next_binary;
delete this.next_binary_hash;
if (msg instanceof Blob) {
// convert Blob object to BufferArray
const reader = new FileReader(), qitem = this.reserveQueueItem();
// The file's text will be printed here
reader.onload = event => {
let result = event.target.result;
if (this.key && sessionKey) {
const hash = HMAC(this.key, result, 0);
if (hash !== server_hash) {
console.log('Discard binary buffer because of HMAC mismatch');
result = new ArrayBuffer(0);
}
}
this.markQueueItemDone(qitem, result, 0);
};
reader.readAsArrayBuffer(msg, e.offset || 0);
} else {
// this is from CEF or LongPoll handler
let result = msg;
if (this.key && sessionKey) {
const hash = HMAC(this.key, result, e.offset || 0);
if (hash !== server_hash) {
console.log('Discard binary buffer because of HMAC mismatch');
result = new ArrayBuffer(0);
}
}
this.provideData(binchid, result, e.offset || 0);
}
return;
}
if (!isStr(msg))
return console.log(`unsupported message kind: ${typeof msg}`);
const i0 = msg.indexOf(':'),
server_hash = msg.slice(0, i0),
i1 = msg.indexOf(':', i0 + 1),
seq_id = Number.parseInt(msg.slice(i0 + 1, i1)),
i2 = msg.indexOf(':', i1 + 1),
credit = Number.parseInt(msg.slice(i1 + 1, i2)),
i3 = msg.indexOf(':', i2 + 1),
// cansend = parseInt(msg.slice(i2 + 1, i3)), // TODO: take into account when sending messages
i4 = msg.indexOf(':', i3 + 1),
chid = Number.parseInt(msg.slice(i3 + 1, i4));
// for authentication HMAC checksum and sequence id is important
// HMAC used to authenticate server
// sequence id is necessary to exclude submission of same packet again
if (this.key && sessionKey) {
const client_hash = HMAC(this.key, msg.slice(i0+1));
if (server_hash !== client_hash)
return console.log(`Failure checking server HMAC sum ${server_hash}`);
}
if (seq_id <= this.recv_seq)
return console.log(`Failure with packet sequence ${seq_id} <= ${this.recv_seq}`);
this.recv_seq = seq_id; // sequence id of received packet
this.ackn++; // count number of received packets,
this.cansend += credit; // how many packets client can send
msg = msg.slice(i4 + 1);
if (chid === 0) {
// console.log(`GET chid=0 message ${msg}`);
if (msg === 'CLOSE') {
this.close(true); // force closing of socket
this.invokeReceiver(true, 'onWebsocketClosed');
} else if (msg.indexOf('NEW_KEY=') === 0) {
this.new_key = msg.slice(8);
console.log('get new key', this.new_key);
this.storeKeyInUrl();
if (this._ask_reload)
this.askReload(true);
}
} else if (msg.slice(0, 10) === '$$binary$$') {
this.next_binary = chid;
this.next_binary_hash = msg.slice(10);
} else if (msg === '$$nullbinary$$')
this.provideData(chid, new ArrayBuffer(0), 0);
else
this.provideData(chid, msg);
if (this.ackn > Math.max(2, this.credits*0.7))
this.send('READY', 0); // send dummy message to server
};
this._websocket.onclose = arg => {
delete this._websocket;
if ((this.state > 0) || (arg === 'force_close')) {
console.log('websocket closed');
this.state = 0;
this.invokeReceiver(true, 'onWebsocketClosed');
}
};
this._websocket.onerror = err => {
console.log(`websocket error ${err} state ${this.state}`);
if (this.state > 0) {
this.invokeReceiver(true, 'onWebsocketError', err);
this.state = 0;
}
};
// only in interactive mode try to reconnect
if (!isBatchMode())
setTimeout(retry_open, 3000); // after 3 seconds try again
}; // retry_open
retry_open(true); // call for the first time
}
/** @summary Ask to reload web widget
* @desc If new key already exists - reload immediately
* Otherwise request server to generate new key - and then reload page
* WARNING - call only when knowing that you are doing
* @private */
askReload(force) {
if (this.new_key || force) {
this.close(true);
if (typeof location !== 'undefined')
location.reload(true);
} else {
this._ask_reload = true;
this.send('GENERATE_KEY', 0);
}
}
/** @summary Instal Ctrl-R handler to reload web window
* @desc Instead of default window reload invokes {@link askReload} method
* WARNING - only call when you know that you are doing
* @private */
addReloadKeyHandler() {
if ((this.kind === 'file') || this._handling_reload)
return;
// this websocket will handle reload
// embed widgets should not call this method
this._handling_reload = true;
window.addEventListener('keydown', evnt => {
if (((evnt.key === 'R') || (evnt.key === 'r')) && evnt.ctrlKey) {
evnt.stopPropagation();
evnt.preventDefault();
console.log('Prevent Ctrl-R propogation - ask reload RWebWindow!');
this.askReload();
}
});
}
/** @summary Replace widget URL with new key
* @private */
storeKeyInUrl() {
// do not modify document URLs by secondary widgets
if (this._secondary)
return;
let href = (typeof document !== 'undefined') ? document.URL : null;
if (this._can_modify_url && isStr(href) && (typeof window !== 'undefined')) {
let prefix = '&key=', p = href.indexOf(prefix);
if (p < 0) {
prefix = '?key=';
p = href.indexOf(prefix);
}
if ((p > 0) && this.new_key) {
const p1 = href.indexOf('#', p+1), p2 = href.indexOf('&', p+1),
pp = (p1 < 0) ? p2 : (p2 < 0 ? p1 : Math.min(p1, p2));
href = href.slice(0, p) + prefix + this.new_key + (pp < 0 ? '' : href.slice(pp));
window.history?.replaceState(window.history.state, undefined, href);
}
}
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem('RWebWindow_SessionKey', sessionKey);
sessionStorage.setItem('RWebWindow_Key', this.new_key);
}
}
/** @summary Create new instance of same kind
* @private */
createNewInstance(url) {
const handle = new WebWindowHandle(this.kind);
handle._secondary = true;
handle.setHRef(this.getHRef(url));
return handle;
}
} // class WebWindowHandle
/** @summary Method used to initialize connection to web window.
* @param {object} arg - arguments
* @param {string} [arg.socket_kind] - kind of connection longpoll|websocket, detected automatically from URL
* @param {number} [arg.credits = 10] - number of packets which can be send to server without acknowledge
* @param {object} arg.receiver - instance of receiver for websocket events, allows to initiate connection immediately
* @param {string} [arg.first_recv] - required prefix in the first message from RWebWindow, remain part of message will be returned in handle.first_msg
* @param {string} [arg.href] - URL to RWebWindow, using window.location.href by default
* @return {Promise} for ready-to-use {@link WebWindowHandle} instance */
async function connectWebWindow(arg) {
// mark that jsroot used with RWebWindow
browser.webwindow = true;
if (isFunc(arg))
arg = { callback: arg };
else if (!isObject(arg))
arg = {};
let d_key, d_token, new_key;
if (!arg.href) {
let href = (typeof document !== 'undefined') ? document.URL : '', s_key;
const p = href.indexOf('#');
if (p > 0) {
s_key = href.slice(p + 1);
href = href.slice(0, p);
}
const d = decodeUrl(href);
d_key = d.get('key');
d_token = d.get('token');
if (d_key && s_key && (s_key.length > 20)) {
sessionKey = s_key;
if (typeof window !== 'undefined')
window.history?.replaceState(window.history.state, undefined, href);
}
if (typeof sessionStorage !== 'undefined') {
new_key = sessionStorage.getItem('RWebWindow_Key');
sessionStorage.removeItem('RWebWindow_Key');
if (sessionKey)
sessionStorage.setItem('RWebWindow_SessionKey', sessionKey);
else
sessionKey = sessionStorage.getItem('RWebWindow_SessionKey') || '';
}
// special holder script, prevents headless chrome browser from too early exit
if (d.has('headless') && d_key && (browser.isChromeHeadless || browser.isChrome) && !arg.ignore_chrome_batch_holder)
loadScript('root_batch_holder.js?key=' + (new_key || d_key));
if (!arg.platform)
arg.platform = d.get('platform');
if (arg.platform === 'qt5')
browser.qt5 = true;
else if (arg.platform === 'qt6')
browser.qt6 = true;
else if (arg.platform === 'cef3')
browser.cef3 = true;
if (arg.batch === undefined)
arg.batch = d.has('headless');
if (arg.batch) setBatchMode(true);
if (!arg.socket_kind)
arg.socket_kind = d.get('ws');
if (!new_key && arg.winW && arg.winH && !isBatchMode() && isFunc(window?.resizeTo))
window.resizeTo(arg.winW, arg.winH);
if (!new_key && arg.winX && arg.winY && !isBatchMode() && isFunc(window?.moveTo))
window.moveTo(arg.winX, arg.winY);
}
if (!arg.socket_kind) {
if (browser.qt5 || browser.qt6)
arg.socket_kind = 'rawlongpoll';
else if (browser.cef3)
arg.socket_kind = 'longpoll';
else
arg.socket_kind = 'websocket';
}
// only for debug purposes
// arg.socket_kind = 'longpoll';
const main = new Promise(resolveFunc => {
const handle = new WebWindowHandle(arg.socket_kind, arg.credits);
handle.setUserArgs(arg.user_args);
handle._can_modify_url = !!d_key; // if key appears in URL, we can put there new key
if (arg.href)
handle.setHRef(arg.href); // apply href now while connect can be called from other place
else {
handle.key = new_key || d_key;
handle.token = d_token;
}
if (typeof window !== 'undefined') {
window.onbeforeunload = () => handle.close(true);
if (browser.qt5 || browser.qt6) window.onqt5unload = window.onbeforeunload;
}
if (arg.receiver) {
// when receiver exists, it handles itself callbacks
handle.setReceiver(arg.receiver);
handle.connect();
return resolveFunc(handle);
}
if (!arg.first_recv)
return resolveFunc(handle);
handle.setReceiver({
onWebsocketOpened() {}, // dummy function when websocket connected
onWebsocketMsg(handle, msg) {
if (msg.indexOf(arg.first_recv) !== 0)
return handle.close();
handle.first_msg = msg.slice(arg.first_recv.length);
resolveFunc(handle);
},
onWebsocketClosed() { closeCurrentWindow(); } // when connection closed, close panel as well
});
handle.connect();
});
if (!arg.ui5) return main;
return Promise.all([main, loadOpenui5(arg)]).then(arr => arr[0]);
}
export { WebWindowHandle, connectWebWindow };