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(iterable: Iterable, 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, // replaces variables in the message (e.g. {error: err.message}) options?: Record, // used to override the locale (e.g. {locale: "de"}) ): LocalizedString | undefined { try { type MessageFn = ( inputs?: Record, options?: Record, ) => LocalizedString; const fn = (m as unknown as Record)[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 { 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; }