mirror of
https://github.com/jetkvm/kvm.git
synced 2026-05-21 05:20:35 +00:00
bfa7336bea
Many Linux browsers (Chrome on NVIDIA proprietary, Brave/Chromium on Wayland, most Firefox builds) advertise H.265 in RTCRtpReceiver.getCapabilities and in the SDP offer but cannot actually decode the stream, leaving users stuck on a black "Loading video stream..." screen (#1413). On Linux desktop we now hide H.265 from the codec dropdown and strip it from the video transceiver's setCodecPreferences immediately before createOffer, so the existing server-side resolveCodec naturally negotiates H.264. No protocol, backend, or persisted-preference changes.
348 lines
14 KiB
TypeScript
348 lines
14 KiB
TypeScript
import semver from "semver";
|
|
|
|
import { KeySequence } from "@hooks/stores";
|
|
import { getLocale, locales, type LocalizedString } from "@localizations/runtime.js";
|
|
import { m } from "@localizations/messages.js";
|
|
import { CLOUD_BACKWARDS_COMPATIBLE_VERSION, CLOUD_ENABLE_VERSIONED_UI } from "@/ui.config";
|
|
|
|
const isInvalidDate = (date: Date) => date instanceof Date && isNaN(date.getTime());
|
|
|
|
export const formatters = {
|
|
date: (date: Date, options?: Intl.DateTimeFormatOptions) =>
|
|
isInvalidDate(date)
|
|
? "Invalid Date"
|
|
: new Intl.DateTimeFormat(getLocale() || "en-US", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
...(options || {}),
|
|
}).format(date),
|
|
|
|
bytes: (bytes: number, decimals = 2) => {
|
|
if (!+bytes) return "0 Bytes";
|
|
|
|
const k = 1024;
|
|
const dm = decimals < 0 ? 0 : decimals;
|
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
|
},
|
|
|
|
hertz: (hz: number, decimals = 2) => {
|
|
if (!+hz) return "0 Hz";
|
|
|
|
const k = 1000; // The scaling factor for Hertz is 1000
|
|
const dm = decimals < 0 ? 0 : decimals;
|
|
const sizes = ["Hz", "kHz", "MHz", "GHz", "THz", "PHz", "EHz", "ZHz", "YHz"];
|
|
|
|
const i = Math.floor(Math.log(hz) / Math.log(k));
|
|
|
|
return `${parseFloat((hz / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
|
},
|
|
|
|
timeAgo: (date: Date, options?: Intl.RelativeTimeFormatOptions) => {
|
|
const relativeTimeFormat = new Intl.RelativeTimeFormat(getLocale() || "en-US", {
|
|
numeric: "auto",
|
|
...(options || {}),
|
|
});
|
|
|
|
// Note, do not translate the unit names in DIVISIONS, as they must match Intl.RelativeTimeFormatUnit
|
|
const DIVISIONS: {
|
|
amount: number;
|
|
name: Intl.RelativeTimeFormatUnit;
|
|
}[] = [
|
|
{ amount: 60, name: "seconds" },
|
|
{ amount: 60, name: "minutes" },
|
|
{ amount: 24, name: "hours" },
|
|
{ amount: 7, name: "days" },
|
|
{ amount: 4.34524, name: "weeks" },
|
|
{ amount: 12, name: "months" },
|
|
{ amount: Number.POSITIVE_INFINITY, name: "years" },
|
|
];
|
|
|
|
let duration = (date.valueOf() - new Date().valueOf()) / 1000;
|
|
|
|
for (const division of DIVISIONS) {
|
|
if (Math.abs(duration) < division.amount) {
|
|
return relativeTimeFormat.format(Math.round(duration), division.name);
|
|
}
|
|
duration /= division.amount;
|
|
}
|
|
},
|
|
|
|
price: (price: number | bigint | string, options?: Intl.NumberFormatOptions) => {
|
|
const opts: Intl.NumberFormatOptions = {
|
|
style: "currency",
|
|
currency: "USD",
|
|
...(options || {}),
|
|
};
|
|
|
|
// Convert the price to a number for comparison
|
|
const numericPrice = typeof price === "string" ? parseFloat(price) : Number(price);
|
|
|
|
// Check if the price is less than 1 and not zero, then adjust minimumFractionDigits
|
|
if (numericPrice > 0 && numericPrice < 1) {
|
|
opts.minimumFractionDigits = Math.max(2, -Math.floor(Math.log10(numericPrice)));
|
|
} else {
|
|
opts.minimumFractionDigits = 0;
|
|
}
|
|
|
|
return new Intl.NumberFormat(getLocale() || "en-US", opts).format(numericPrice);
|
|
},
|
|
|
|
truncateMiddle: (str: string | null | undefined, maxLength: number): string => {
|
|
if (!str) return "";
|
|
if (str.length <= maxLength) {
|
|
return str;
|
|
}
|
|
|
|
const halfLength = Math.floor(maxLength / 2);
|
|
const firstPart = str.slice(0, halfLength - 1);
|
|
const lastPart = str.slice(-halfLength + 2);
|
|
|
|
return `${firstPart}...${lastPart}`;
|
|
},
|
|
};
|
|
|
|
export function someIterable<T>(iterable: Iterable<T>, predicate: (item: T) => boolean): boolean {
|
|
for (const item of iterable) {
|
|
if (predicate(item)) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export const VIDEO = new Blob(
|
|
[
|
|
new Uint8Array([
|
|
0, 0, 0, 28, 102, 116, 121, 112, 105, 115, 111, 109, 0, 0, 2, 0, 105, 115, 111, 109, 105, 115,
|
|
111, 50, 109, 112, 52, 49, 0, 0, 0, 8, 102, 114, 101, 101, 0, 0, 2, 239, 109, 100, 97, 116,
|
|
33, 16, 5, 32, 164, 27, 255, 192, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
55, 167, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 112, 33, 16, 5, 32, 164, 27, 255, 192, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 55, 167, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 112, 0, 0, 2, 194, 109, 111, 111, 118, 0, 0, 0, 108, 109, 118, 104, 100,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 232, 0, 0, 0, 47, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 1, 236, 116, 114, 97, 107, 0, 0, 0, 92, 116, 107, 104, 100, 0,
|
|
0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 47, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 36, 101, 100, 116,
|
|
115, 0, 0, 0, 28, 101, 108, 115, 116, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 47, 0, 0, 0, 0, 0, 1,
|
|
0, 0, 0, 0, 1, 100, 109, 100, 105, 97, 0, 0, 0, 32, 109, 100, 104, 100, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 172, 68, 0, 0, 8, 0, 85, 196, 0, 0, 0, 0, 0, 45, 104, 100, 108, 114, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 115, 111, 117, 110, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 83, 111, 117,
|
|
110, 100, 72, 97, 110, 100, 108, 101, 114, 0, 0, 0, 1, 15, 109, 105, 110, 102, 0, 0, 0, 16,
|
|
115, 109, 104, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 36, 100, 105, 110, 102, 0, 0, 0, 28, 100,
|
|
114, 101, 102, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 12, 117, 114, 108, 32, 0, 0, 0, 1, 0, 0, 0,
|
|
211, 115, 116, 98, 108, 0, 0, 0, 103, 115, 116, 115, 100, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 87,
|
|
109, 112, 52, 97, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 16, 0, 0, 0, 0,
|
|
172, 68, 0, 0, 0, 0, 0, 51, 101, 115, 100, 115, 0, 0, 0, 0, 3, 128, 128, 128, 34, 0, 2, 0, 4,
|
|
128, 128, 128, 20, 64, 21, 0, 0, 0, 0, 1, 244, 0, 0, 1, 243, 249, 5, 128, 128, 128, 2, 18, 16,
|
|
6, 128, 128, 128, 1, 2, 0, 0, 0, 24, 115, 116, 116, 115, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2,
|
|
0, 0, 4, 0, 0, 0, 0, 28, 115, 116, 115, 99, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 2, 0,
|
|
0, 0, 1, 0, 0, 0, 28, 115, 116, 115, 122, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 1, 115, 0,
|
|
0, 1, 116, 0, 0, 0, 20, 115, 116, 99, 111, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 44, 0, 0, 0, 98,
|
|
117, 100, 116, 97, 0, 0, 0, 90, 109, 101, 116, 97, 0, 0, 0, 0, 0, 0, 0, 33, 104, 100, 108,
|
|
114, 0, 0, 0, 0, 0, 0, 0, 0, 109, 100, 105, 114, 97, 112, 112, 108, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 45, 105, 108, 115, 116, 0, 0, 0, 37, 169, 116, 111, 111, 0, 0, 0, 29, 100, 97, 116,
|
|
97, 0, 0, 0, 1, 0, 0, 0, 0, 76, 97, 118, 102, 53, 54, 46, 52, 48, 46, 49, 48, 49,
|
|
]),
|
|
],
|
|
{ type: "video/mp4" },
|
|
);
|
|
|
|
export function canAutoPlayVideo(
|
|
muted = true,
|
|
timeout = 250,
|
|
inline = false,
|
|
): Promise<{ result: boolean; error: Error | null }> {
|
|
const videoElm = document.createElement("video");
|
|
videoElm.muted = muted;
|
|
if (muted) {
|
|
videoElm.setAttribute("muted", "muted");
|
|
}
|
|
|
|
if (inline) {
|
|
videoElm.setAttribute("playsinline", "playsinline");
|
|
}
|
|
|
|
videoElm.src = URL.createObjectURL(VIDEO);
|
|
|
|
return new Promise(resolve => {
|
|
const playResult = videoElm.play();
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
sendOutput(false, new Error(`Timeout ${timeout} ms has been reached`));
|
|
}, timeout);
|
|
|
|
const sendOutput = (result: boolean, error: Error | null = null) => {
|
|
videoElm.remove();
|
|
videoElm.src = "";
|
|
|
|
clearTimeout(timeoutId);
|
|
resolve({ result, error });
|
|
};
|
|
|
|
if (playResult !== undefined) {
|
|
playResult
|
|
.then(() => {
|
|
sendOutput(true);
|
|
})
|
|
.catch(playError => {
|
|
sendOutput(false, playError);
|
|
});
|
|
} else {
|
|
sendOutput(true);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function isMac() {
|
|
return !!/mac/i.exec(navigator.platform);
|
|
}
|
|
|
|
export function isWindows() {
|
|
return !!/win/i.exec(navigator.platform);
|
|
}
|
|
|
|
export function isIOS() {
|
|
return (
|
|
!!/ipad/i.exec(navigator.platform) ||
|
|
!!/iphone/i.exec(navigator.platform) ||
|
|
!!/ipod/i.exec(navigator.platform)
|
|
);
|
|
}
|
|
|
|
export function isAndroid() {
|
|
/* Android sets navigator.platform to Linux :/ */
|
|
return !!navigator.userAgent.match("Android ");
|
|
}
|
|
|
|
export function isChromeOS() {
|
|
/* ChromeOS sets navigator.platform to Linux :/ */
|
|
return !!navigator.userAgent.match(" CrOS ");
|
|
}
|
|
|
|
/**
|
|
* Linux desktop browsers, excluding Android and ChromeOS.
|
|
*
|
|
* Used to gate H.265 support: many Linux browsers advertise H.265 in
|
|
* `RTCRtpReceiver.getCapabilities()` and in their SDP offers but cannot
|
|
* actually decode the stream (Chrome on NVIDIA proprietary, Brave/Chromium
|
|
* on Wayland, Firefox on most Linux setups), leaving users stuck on the
|
|
* "Loading video stream..." screen. See jetkvm/kvm#1413.
|
|
*/
|
|
export function isLinuxDesktop() {
|
|
const uaData = (navigator as Navigator & {
|
|
userAgentData?: { platform?: string };
|
|
}).userAgentData;
|
|
|
|
if (uaData?.platform) return uaData.platform === "Linux";
|
|
|
|
return /\bLinux\b/.test(navigator.userAgent) && !isAndroid() && !isChromeOS();
|
|
}
|
|
|
|
export function normalizeSortOrders(macros: KeySequence[]): KeySequence[] {
|
|
return macros.map((macro, index) => ({
|
|
...macro,
|
|
sortOrder: index + 1,
|
|
}));
|
|
}
|
|
|
|
// Retrieves a localized message by its key, with optional inputs and options.
|
|
function getLocalizedMessage(
|
|
messageKey: string, // name of the message in a localization file (e.g. "some_action_error")
|
|
inputs?: Record<string, unknown>, // replaces variables in the message (e.g. {error: err.message})
|
|
options?: Record<string, unknown>, // used to override the locale (e.g. {locale: "de"})
|
|
): LocalizedString | undefined {
|
|
try {
|
|
type MessageFn = (
|
|
inputs?: Record<string, unknown>,
|
|
options?: Record<string, unknown>,
|
|
) => LocalizedString;
|
|
const fn = (m as unknown as Record<string, MessageFn | undefined>)[messageKey];
|
|
return fn ? fn(inputs, options) : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
type LocaleCode = (typeof locales)[number];
|
|
|
|
export function map_locale_code_to_name(
|
|
currentLocale: LocaleCode,
|
|
locale: string,
|
|
): [string, string] {
|
|
if (locale === "") {
|
|
return [m.locale_auto(), ""];
|
|
}
|
|
|
|
// Use the locale directly with the message function
|
|
// The key pattern is locale_{locale} where underscores replace hyphens
|
|
const messageKey = `locale_${locale.replace("-", "_")}` as const;
|
|
const localizedName = getLocalizedMessage(messageKey, undefined, { locale: currentLocale });
|
|
const nativeName = getLocalizedMessage(messageKey, undefined, { locale });
|
|
|
|
if (localizedName && nativeName) {
|
|
return [localizedName, nativeName];
|
|
}
|
|
|
|
// Fallback if locale is not found or function not available
|
|
return [locale, ""];
|
|
}
|
|
|
|
export function deleteCookie(name: string, domain?: string, path = "/") {
|
|
const domainPart = domain ? `; domain=${domain}` : "";
|
|
// max-age=0 removes the cookie immediately in modern browsers
|
|
document.cookie = `${name}=; path=${path}; max-age=0${domainPart}`;
|
|
// fallback: set an expires in the past for older agents
|
|
document.cookie = `${name}=; path=${path}; expires=Thu, 01 Jan 1970 00:00:00 GMT${domainPart}`;
|
|
}
|
|
|
|
export function sleep(ms: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* Builds a versioned cloud URL for a device.
|
|
* Uses the device's app version to construct /v/{version}/devices/{id}{path}
|
|
* Falls back to CLOUD_BACKWARDS_COMPATIBLE_VERSION for older or invalid versions.
|
|
*/
|
|
export function buildCloudUrl(deviceId: string, appVersion: string | undefined, path = ""): string {
|
|
let uri = `/devices/${deviceId}${path}`;
|
|
if (CLOUD_ENABLE_VERSIONED_UI) {
|
|
const version =
|
|
appVersion &&
|
|
semver.valid(appVersion) &&
|
|
semver.gte(appVersion, CLOUD_BACKWARDS_COMPATIBLE_VERSION)
|
|
? appVersion
|
|
: CLOUD_BACKWARDS_COMPATIBLE_VERSION;
|
|
uri = `/v/${version}${uri}`;
|
|
}
|
|
return new URL(uri, window.location.origin).href;
|
|
}
|