2026-04-25 12:58:56 +08:00

925 lines
23 KiB
JavaScript

/*
* This file is part of Xpra.
* Copyright (C) 2016 Antoine Martin <antoine@xpra.org>
* Copyright (c) 2016 Spikes, Inc.
* Licensed under MPL 2.0, see:
* http://www.mozilla.org/MPL/2.0/
*
*/
const Utilities = {
VERSION : "19",
REVISION : 0,
LOCAL_MODIFICATIONS: 0,
BRANCH: "v16.x",
exc: console_error_safe,
error: console_error_safe,
warn: console_warn_safe,
log: console_log_safe,
debug: console_debug_safe,
//these versions should not be redirected:
cexc: console_error_safe,
cerror: console_error_safe,
cwarn: console_warn_safe,
clog: console_log_safe,
cdebug: console_debug_safe,
stristrue(v, default_value) {
if (!v) {
return default_value;
}
return ["true", "on", "1", "yes", "enabled"].includes(
String(v).toLowerCase()
);
},
removeChars(validChars, inputString) {
const regex = new RegExp('[^' + validChars + ']', 'g');
return inputString.replace(regex, '');
},
getHexUUID() {
const s = [];
const hexDigits = "0123456789abcdef";
for (let index = 0; index < 36; index++) {
if (index === 8 || index === 13 || index === 18 || index === 23) {
s[index] = "-";
} else {
s[index] = hexDigits.slice(
Math.floor(Math.random() * 0x10),
Math.floor(Math.random() * 0x10) + 1
);
}
}
return s.join("");
},
getSecureRandomBytes(len) {
const u = new Uint8Array(len);
crypto.getRandomValues(u);
return u;
},
getSecureRandomString(len) {
const u = Utilities.getSecureRandomBytes(len);
return String.fromCharCode.apply(null, u);
},
xorString(string1, string2) {
if (string1.length !== string2.length) {
throw "strings must be equal length";
}
let result = "";
for (let index = 0; index < string1.length; index++) {
const character1 = string1[index];
const character2 = string2[index];
result += String.fromCharCode(character1.charCodeAt(0) ^ character2.charCodeAt(0));
}
return result;
},
u8(value) {
const type = typeof value;
if (type === 'object' && value.constructor === Uint8Array) {
return value;
}
if (type === "string") {
return Uint8Array.from(value.split("").map(x => x.charCodeAt()));
}
return new Uint8Array(value);
},
xor(str1, str2) {
const trimmed_str2 = str2.slice(0, str1.length);
return Utilities.xorString(str1, trimmed_str2);
},
gendigest(digest, password, salt) {
// Utilities.clog("gendigest(", digest, ", ", password, ", ", salt, ")");
// Utilities.clog("gendigest(", digest, ", ", Utilities.convertToHex(password), ", ", Utilities.convertToHex(salt), ")");
if (digest === "xor") {
const trimmed_salt = salt.slice(0, password.length);
// Utilities.debug("xoring with trimmed salt:", Utilities.convertToHex(trimmed_salt));
return new Promise(function(resolve, reject) {
const xored = Utilities.xorString(trimmed_salt, password);
resolve(xored);
});
}
if (!digest.startsWith("hmac")) {
return new Promise(function(resolve, reject) {
reject(new Error("unsupported digest '" + digest + "'"));
});
}
let hash = "SHA-1";
if (digest.indexOf("+") > 0) {
// "hmac+sha512" -> "sha512"
hash = digest.split("+")[1];
}
hash = hash.toUpperCase();
if (hash.startsWith("SHA") && !hash.startsWith("SHA-")) {
hash = "SHA-" + hash.substring(3);
}
if (typeof crypto.subtle === "undefined") {
// use hmac.js
return new Promise(function(resolve, reject) {
if (hash !== "SHA-256") {
reject(new Error("crypto.subtle API is not available in this context"));
}
else {
value = hmac(Utilities.u8(password), Utilities.u8(salt));
resolve(value);
}
});
}
return new Promise(function(resolve, reject) {
Utilities.clog("crypto.subtle=", crypto.subtle);
Utilities.clog("crypto.subtle.importKey=", crypto.subtle.importKey);
crypto.subtle.importKey("raw", Utilities.u8(password), {
name: "HMAC",
hash: hash
}, false, ["sign", "verify"])
.then(
(key) => {
Utilities.cdebug("imported hmac key: ", key);
const u8salt = Utilities.u8(salt);
crypto.subtle.sign("HMAC", key, u8salt).then(
(result) => {
const u8digest = new Uint8Array(result);
Utilities.clog("hmac result=", u8digest);
resolve(Utilities.ArrayBufferToString(u8digest));
},
(error) => {
Utilities.clog("hmac signing failed:", error);
reject(error);
}
);
},
(error) => {
Utilities.clog("hmac key import failed:", error);
reject(error);
}
);
});
},
trimString(string_, trimLength) {
if (!string_) {
return "";
}
return string_.length > trimLength ?
`${string_.slice(0, Math.max(0, trimLength - 3))}...` :
string_;
},
convertToHex(value) {
if (value instanceof Uint8Array) {
return Utilities.arrayhex(value);
}
let hex = "";
for (let index = 0; index < value.length; index++) {
hex += `${value.charCodeAt(index).toString(16).padStart(2, "0")}`;
}
return hex;
},
hexToString(hexval) {
const hex = hexval.toString();
let str = '';
for (let i = 0; i < hex.length; i += 2) {
str += String.fromCharCode(parseInt(hex.substr(i, 2), 16));
}
return str;
},
arrayhex(arr) {
return Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join("");
},
hexarray(hex) {
const bytes = new Uint8Array(Math.ceil(hex.length / 2));
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
},
getPlatformProcessor() {
//mozilla property:
if (navigator.oscpu) {
return navigator.oscpu;
}
//ie:
if (Object.hasOwn(navigator, "cpuClass")) {
return navigator.cpuClass;
}
return "unknown";
},
getPlatformName() {
if (navigator.appVersion.includes("Win")) {
return "Microsoft Windows";
}
if (navigator.appVersion.includes("Mac")) {
return "Mac OSX";
}
if (navigator.appVersion.includes("Linux")) {
return "Linux";
}
if (navigator.appVersion.includes("X11")) {
return "Posix";
}
return "unknown";
},
getFirstBrowserLanguage() {
const nav = window.navigator;
const browserLanguagePropertyKeys = [
"language",
"browserLanguage",
"systemLanguage",
"userLanguage",
];
let language;
// support for HTML 5.1 "navigator.languages"
if (Array.isArray(nav.languages)) {
for (let language of nav.languages) {
if (language && language.length > 0) {
return language;
}
}
}
// support for other well known properties in browsers
for (const property of browserLanguagePropertyKeys) {
language = nav[property];
if (language && language.length > 0) {
return language;
}
}
return null;
},
getKeyboardLayout() {
let v = Utilities.getFirstBrowserLanguage();
if (v === null) {
return "us";
}
let layout = LANGUAGE_TO_LAYOUT[v];
if (!layout) {
//ie: v="en_GB";
v = v.split(",")[0];
let l = v.split("-", 2);
if (l.length === 1) {
l = v.split("_", 2);
}
//ie: "en"
layout = l[0].toLowerCase();
const temporary = LANGUAGE_TO_LAYOUT[layout];
if (temporary) {
layout = temporary;
}
}
return layout;
},
isMacOS() {
return navigator.platform.includes("Mac");
},
isWindows() {
return navigator.platform.includes("Win");
},
isFirefox() {
const ua = navigator.userAgent.toLowerCase();
return ua.includes("firefox");
},
isOpera() {
const ua = navigator.userAgent.toLowerCase();
return ua.includes("opera");
},
isSafari() {
const ua = navigator.userAgent.toLowerCase();
return ua.includes("safari") && !ua.includes("chrome");
},
isWebkit() {
const ua = navigator.userAgent.toLowerCase();
return ua.includes("webkit");
},
isEdge() {
return navigator.userAgent.includes("Edge");
},
isChrome() {
const isChromium = Object.hasOwn(window, "chrome");
const winNav = window.navigator;
const vendorName = winNav.vendor;
const isOpera = winNav.userAgent.includes("OPR");
const isIEedge = winNav.userAgent.includes("Edge");
const isIOSChrome = winNav.userAgent.match("CriOS");
if (isIOSChrome) {
return true;
} else return isChromium !== null &&
isChromium !== undefined &&
vendorName === "Google Inc." &&
isOpera === false &&
isIEedge === false;
},
isIE() {
return (
navigator.userAgent.includes("MSIE") ||
navigator.userAgent.includes("Trident/")
);
},
getBrowserName() {
if (this.isFirefox()) {
return "Firefox";
}
if (this.isOpera()) {
return "Opera";
}
if (this.isChrome()) {
return "Chrome";
}
if (this.isEdge()) {
return "Edge";
}
if (this.isSafari()) {
return "Safari";
}
return "Unknown";
},
getOS() {
const userAgent = navigator.userAgent;
let os = "Unknown";
if (userAgent.includes("Windows NT")) {
os = "MS Windows";
} else if (userAgent.includes("Macintosh") || userAgent.includes("Mac OS X")) {
os = "macOS";
} else if (userAgent.includes("Android")) {
os = "Android";
} else if (userAgent.includes("iPhone") || userAgent.includes("iPad")) {
os = "iOS/iPadOS";
} else if (userAgent.includes("Linux")) {
os = "Linux";
}
return os;
},
is_64bit() {
const _to_check = [];
if (Object.hasOwn(window.navigator, "cpuClass"))
_to_check.push(`${window.navigator.cpuClass}`.toLowerCase());
if (window.navigator.platform)
_to_check.push(`${window.navigator.platform}`.toLowerCase());
if (navigator.userAgent)
_to_check.push(`${navigator.userAgent}`.toLowerCase());
const _64bits_signatures = [
"x86_64",
"x86-64",
"Win64",
"x64;",
"amd64",
"AMD64",
"WOW64",
"x64_64",
"ia64",
"sparc64",
"ppc64",
"IRIX64",
];
for (const a of _to_check) {
for (const b of _64bits_signatures) {
if (a.includes(b.toLowerCase())) {
return true;
}
}
}
return false;
},
isMobile() {
return /iphone|ipad|ipod|android/i.test(navigator.userAgent);
},
getSimpleUserAgentString() {
if (Utilities.isFirefox()) {
return "Firefox";
} else if (Utilities.isOpera()) {
return "Opera";
} else if (Utilities.isSafari()) {
return "Safari";
} else if (Utilities.isChrome()) {
return "Chrome";
} else if (Utilities.isIE()) {
return "MSIE";
} else {
return "";
}
},
getColorGamut() {
if (!window.matchMedia) {
//unknown
return "";
} else if (window.matchMedia("(color-gamut: rec2020)").matches) {
return "rec2020";
} else if (window.matchMedia("(color-gamut: p3)").matches) {
return "P3";
} else if (window.matchMedia("(color-gamut: srgb)").matches) {
return "srgb";
} else {
return "";
}
},
isEventSupported(event) {
let testElement = document.createElement("div");
let isSupported;
event = `on${event}`;
isSupported = event in testElement;
if (!isSupported) {
testElement.setAttribute(event, "return;");
isSupported = typeof testElement[event] === "function";
}
testElement = null;
return isSupported;
},
//https://github.com/facebook/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js
//BSD license
normalizeWheel( /*object*/ event) /*object*/ {
// Reasonable defaults
const PIXEL_STEP = 10;
const LINE_HEIGHT = 40;
const PAGE_HEIGHT = 800;
let sX = 0;
let sY = 0; // spinX, spinY
// Legacy
if ("detail" in event) {
sY = event.detail;
}
if ("wheelDelta" in event) {
sY = -event.wheelDelta / 120;
}
if ("wheelDeltaY" in event) {
sY = -event.wheelDeltaY / 120;
}
if ("wheelDeltaX" in event) {
sX = -event.wheelDeltaX / 120;
}
// side scrolling on FF with DOMMouseScroll
if ("axis" in event && event.axis === event.HORIZONTAL_AXIS) {
sX = sY;
sY = 0;
}
let pX = sX * PIXEL_STEP; // pixelX
let pY = sY * PIXEL_STEP; // pixelY
if ("deltaY" in event) {
pY = event.deltaY;
}
if ("deltaX" in event) {
pX = event.deltaX;
}
if ((pX || pY) && event.deltaMode) {
if (event.deltaMode === 1) {
// delta in LINE units
pX *= LINE_HEIGHT;
pY *= LINE_HEIGHT;
} else {
// delta in PAGE units
pX *= PAGE_HEIGHT;
pY *= PAGE_HEIGHT;
}
}
// Fall-back if spin cannot be determined
if (pX && !sX) {
sX = pX < 1 ? -1 : 1;
}
if (pY && !sY) {
sY = pY < 1 ? -1 : 1;
}
return {
spinX: sX,
spinY: sY,
pixelX: pX,
pixelY: pY,
deltaMode: event.deltaMode || 0,
};
},
saveFile(filename, data, mimetype) {
const a = document.createElement("a");
a.setAttribute("style", "display: none");
document.body.append(a);
const blob = new Blob([data], mimetype);
const url = window.URL.createObjectURL(blob);
if (navigator.msSaveOrOpenBlob) {
navigator.msSaveOrOpenBlob(blob, filename);
} else {
a.href = url;
a.download = filename;
a.click();
window.URL.revokeObjectURL(url);
}
},
StringToUint8(value) {
return new TextEncoder().encode(value);
},
Uint8ToString(value) {
return new TextDecoder().decode(value);
},
s(v) {
if (v === undefined) {
return "";
}
const type = typeof v;
if (type === "object" && v.constructor === Uint8Array) {
return Utilities.Uint8ToString(v);
}
return v.toString();
},
u(v) {
if (v === undefined) {
return new Uint8Array(0);
}
const type = typeof v;
if (type === 'object' && v.constructor === Uint8Array) {
return v;
}
return Utilities.StringToUint8(v.toString());
},
ArrayBufferToString(uintArray) {
// apply in chunks of 10400 to avoid call stack overflow
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/apply
let s = "";
const skip = 10_400;
if (uintArray.subarray) {
for (
let index = 0, length_ = uintArray.length; index < length_; index += skip
) {
s += String.fromCharCode.apply(
null,
uintArray.subarray(index, Math.min(index + skip, length_))
);
}
} else {
for (
let index = 0, length_ = uintArray.length; index < length_; index += skip
) {
s += String.fromCharCode.apply(
null,
uintArray.slice(index, Math.min(index + skip, length_))
);
}
}
return s;
},
ArrayBufferToBase64(uintArray) {
return window.btoa(Utilities.ArrayBufferToString(uintArray));
},
ToBase64(v) {
try {
return window.btoa(v);
} catch {
return ArrayBufferToBase64(v);
}
},
convertDataURIToBinary(dataURI) {
const BASE64_MARKER = ";base64,";
const base64Index = dataURI.indexOf(BASE64_MARKER) + BASE64_MARKER.length;
const base64 = dataURI.slice(Math.max(0, base64Index));
const raw = window.atob(base64);
const rawLength = raw.length;
const array = new Uint8Array(new ArrayBuffer(rawLength));
for (let index = 0; index < rawLength; index++) {
array[index] = raw.charCodeAt(index);
}
return array;
},
parseINIString(data) {
const regex = {
section: /^\s*\[\s*([^\]]*)\s*]\s*$/,
param: /^\s*([^=]+?)\s*=\s*(.*?)\s*$/,
comment: /^\s*[#;].*$/,
};
const value = {};
const lines = data.split(/[\n\r]+/);
let section = null;
for (const line of lines) {
if (regex.comment.test(line)) {
// just continue
} else if (regex.param.test(line)) {
const match = line.match(regex.param);
if (section) {
value[section][match[1]] = match[2];
} else {
value[match[1]] = match[2];
}
} else if (regex.section.test(line)) {
const match = line.match(regex.section);
value[match[1]] = {};
section = match[1];
} else if (line.length === 0 && section) {
section = null;
}
}
return value;
},
isSafeHost(host) {
return host && ["localhost", "127.0.0.1", "::1"].indexOf(host) >= 0;
},
/**
* XmlHttpRequest's getAllResponseHeaders() method returns a string of response
* headers according to the format described here:
* http://www.w3.org/TR/XMLHttpRequest/#the-getallresponseheaders-method
* This method parses that string into a user-friendly key/value pair object.
*/
ParseResponseHeaders(headerString) {
const headers = {};
if (!headerString) {
return headers;
}
const headerPairs = headerString.split("\u000D\u000A");
for (const headerPair of headerPairs) {
// Can't use split() here because it does the wrong thing
// if the header value has the string ": " in it.
const index = headerPair.indexOf("\u003A\u0020");
if (index > 0) {
const key = headerPair.slice(0, Math.max(0, index));
headers[key] = headerPair.slice(Math.max(0, index + 2));
}
}
return headers;
},
parseParams(q) {
const parameters = {};
let e;
const a = /\+/g; // Regex for replacing addition symbol with a space
const r = /([^&=]+)=?([^&]*)/g;
const d = (s) => {
return decodeURIComponent(s.replace(a, " "));
};
while ((e = r.exec(q))) parameters[d(e[1])] = d(e[2]);
return parameters;
},
getparam(property) {
let getParameter = window.location.getParameter;
if (!getParameter) {
getParameter = function(key) {
if (!window.location.queryStringParams)
window.location.queryStringParams = Utilities.parseParams(
window.location.search.slice(1)
);
return window.location.queryStringParams[key];
};
}
let value = getParameter(property);
if (value === undefined) {
try {
value = Utilities.getSessionStorageValue(property);
} catch {}
}
return value;
},
hasSessionStorage() {
if (typeof Storage === "undefined") {
return false;
}
try {
const key = "just for testing sessionStorage support";
sessionStorage.setItem(key, "store-whatever");
sessionStorage.removeItem(key);
return true;
} catch {
return false;
}
},
getSessionStorageValue(property) {
const params = JSON.parse(sessionStorage.getItem(Utilities.getSessionStoragePrefix()))
if (property in params) {
return String(params[property]);
}
return undefined;
},
setSessionStorageValue(property, value) {
const prefix = Utilities.getSessionStoragePrefix();
let params = JSON.parse(sessionStorage.getItem(prefix)) || {}
if (value === null || value === "undefined") {
delete params[property];
} else {
params[property] = String(value);
}
sessionStorage.setItem(prefix, JSON.stringify(params));
},
clearSessionStorage() {
sessionStorage.removeItem(Utilities.getSessionStoragePrefix());
},
getSessionStoragePrefix() {
const urlPath = new URL(window.location.href).pathname
return urlPath.substring(0, urlPath.lastIndexOf("/"));
},
getConnectionInfo() {
if (!Object.hasOwn(navigator, "connection")) {
return {};
}
const c = navigator.connection;
const index = {};
if (c.type) {
index["type"] = c.type;
}
if (Object.hasOwn(c, "effectiveType")) {
index["effective-type"] = c.effectiveType;
}
if (!isNaN(c.downlink) && c.downlink > 0 && isFinite(c.downlink)) {
index["downlink"] = Math.round(c.downlink * 1000 * 1000);
}
if (
Object.hasOwn(c, "downlinkMax") &&
!isNaN(c.downlinkMax) &&
!isNaN(c.downlinkMax) &&
c.downlinkMax > 0 &&
isFinite(c.downlinkMax)
) {
index["downlink.max"] = Math.round(c.downlinkMax * 1000 * 1000);
}
if (!isNaN(c.rtt) && c.rtt > 0) {
index["rtt"] = c.rtt;
}
return index;
},
json_action(uri, success_function, error_function, username, password) {
Utilities.log(
"json_action(",
uri,
", ",
success_function,
", ",
error_function,
")"
);
const xhr = new XMLHttpRequest();
let url = uri;
if (uri.startsWith("/")) {
//relative URI
url = document.location.href.split("/connect.html")[0] + uri;
}
xhr.open("GET", url, true);
if (username && password) {
const credentials = btoa(`${username}:${password}`);
xhr.setRequestHeader("Authorization", `Basic ${credentials}`);
}
xhr.responseType = "json";
xhr.addEventListener("load", function() {
Utilities.log("loaded", url, "status", xhr.status);
const status = xhr.status;
if (status === 200) {
success_function(xhr, xhr.response);
} else {
Utilities.log(uri, "failed:", status + xhr.response);
if (error_function) {
error_function(`failed: ${status}${xhr.response}`);
}
}
});
xhr.addEventListener("error", function(e) {
Utilities.log(uri, "error:", e);
if (error_function) {
error_function(e);
}
});
xhr.addEventListener("abort", function(e) {
Utilities.log(uri, "abort:", e);
if (error_function) {
error_function(e);
}
});
xhr.send();
return xhr;
},
};
//convert a language code into an X11 keyboard layout code:
const LANGUAGE_TO_LAYOUT = {
en_GB: "gb",
en: "us",
zh: "cn",
af: "za",
sq: "al",
ca: "ca",
"zh-TW": "tw",
"zh-CN": "cn",
cs: "cz",
da: "dk",
"nl-BE": "be",
"en-US": "us",
"en-AU": "us",
"en-GB": "gb",
"en-CA": "ca",
"en-NZ": "us",
"en-IE": "ie",
"en-ZA": "za",
"en-JM": "us",
"en-TT": "tr",
et: "ee",
fa: "ir",
"fr-BE": "be",
"fr-CA": "ca",
"fr-CH": "ch",
"fr-LU": "fr",
"gd-IE": "ie",
"de-CH": "ch",
"de-AT": "at",
"de-LU": "de",
"de-LI": "de",
he: "il",
hi: "in",
"it-CH": "ch",
ja: "jp",
ko: "kr",
"pt-BR": "br",
pt: "pt",
sr: "rs",
sl: "si",
es: "es",
sv: "se",
"sv-FI": "fi",
tr: "tr",
uk: "ua",
ur: "pk",
vi: "vn",
//Unknown conversions
//"en-BZ": ??
//"en-EG": ??
//"gd": ??
//"id": ??
//"ji": ??
//"rm": ??
//"ro-MO": ??
//"ru-MI": ??
//"sb": ??
//"sx": ??
//"sz": ??
//"ts": ??
//"tn": ??
//"ve": ??
//"xh": ??
//"zh-HK": ??
//"zh-SG": ??
//"zu": ??
};
function console_debug_safe() {
// if (console) console.debug.apply(console, arguments);
}
function console_error_safe() {
// if (console) console.error.apply(console, arguments);
}
function console_warn_safe() {
// if (console) console.warn.apply(console, arguments);
}
function console_log_safe() {
// if (console) console.log.apply(console, arguments);
}