diff --git a/.gitignore b/.gitignore
index 4056c79b9f..4be17e24d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,7 +38,10 @@ cockpit/public/main*
.sentryclirc
# cockpit lib dir
-pkg/lib
+pkg/lib/*
+!pkg/lib/cockpit/
+!pkg/lib/cockpit.d.ts
+!pkg/lib/os-release.ts
rpmbuild
diff --git a/Makefile b/Makefile
index 77a0cdefa3..9b252e493e 100644
--- a/Makefile
+++ b/Makefile
@@ -7,7 +7,7 @@ VERSION := $(shell (cd "$(SRCDIR)" && grep "^Version:" cockpit/$(PACKAGE_NAME).s
COMMIT = $(shell (cd "$(SRCDIR)" && git rev-parse HEAD))
# TODO: figure out a strategy for keeping this updated
-COCKPIT_REPO_COMMIT = a70142a7a6f9c4e78e71f3c4ec738b6db2fbb04f
+COCKPIT_REPO_COMMIT = 0ee23c07488e343e04ec766a891d1fceea781d10
COCKPIT_REPO_URL = https://github.com/cockpit-project/cockpit.git
COCKPIT_REPO_TREE = '$(strip $(COCKPIT_REPO_COMMIT))^{tree}'
diff --git a/pkg/lib/cockpit.d.ts b/pkg/lib/cockpit.d.ts
new file mode 100644
index 0000000000..8979eb3c1e
--- /dev/null
+++ b/pkg/lib/cockpit.d.ts
@@ -0,0 +1,444 @@
+/* This file is part of Cockpit.
+ *
+ * Copyright (C) 2024 Red Hat, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import '_internal/common'; // side-effecting import (`window` augmentations)
+import type { Info } from './cockpit/_internal/info';
+
+declare module 'cockpit' {
+ type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
+ type JsonObject = Record;
+
+ class BasicError {
+ problem: string;
+ message: string;
+ toString(): string;
+ }
+
+ type SuperuserMode = "require" | "try" | null | undefined;
+
+ function init(): Promise;
+
+ function assert(predicate: unknown, message?: string): asserts predicate;
+
+ export const manifests: { [package in string]?: JsonObject };
+ export const info: Info;
+
+ export let language: string;
+ export let language_direction: string;
+
+ interface Transport {
+ csrf_token: string;
+ origin: string;
+ host: string;
+ options: JsonObject;
+ uri(suffix?: string): string;
+ wait(callback: (transport: Transport) => void): void;
+ close(problem?: string): void;
+ application(): string;
+ control(command: string, options: JsonObject): void;
+ }
+
+ export const transport: Transport;
+
+ /* === jQuery compatible promise ============== */
+
+ interface DeferredPromise extends Promise {
+ /* jQuery Promise compatibility */
+ done(callback: (data: T) => void): DeferredPromise
+ fail(callback: (exc: Error) => void): DeferredPromise
+ always(callback: () => void): DeferredPromise
+ progress(callback: (message: T, cancel: () => void) => void): DeferredPromise
+ }
+
+ interface Deferred {
+ resolve(): Deferred;
+ reject(): Deferred;
+ notify(): Deferred;
+ promise: DeferredPromise
+ }
+
+ function defer(): Deferred;
+
+ /* === Events mix-in ========================= */
+
+ interface EventMap {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ [_: string]: (...args: any[]) => void;
+ }
+
+ type EventListener void> =
+ (event: CustomEvent>, ...args: Parameters) => void;
+
+ interface EventSource {
+ addEventListener(event: E, listener: EventListener): void;
+ removeEventListener(event: E, listener: EventListener): void;
+ dispatchEvent(event: E, ...args: Parameters): void;
+ }
+
+ interface CockpitEvents extends EventMap {
+ locationchanged(): void;
+ visibilitychange(): void;
+ }
+
+ function addEventListener(
+ event: E, listener: EventListener
+ ): void;
+ function removeEventListener(
+ event: E, listener: EventListener
+ ): void;
+
+ interface ChangedEvents {
+ changed(): void;
+ }
+
+ function event_target(obj: T): T & EventSource;
+
+ /* === Channel =============================== */
+
+ interface ControlMessage extends JsonObject {
+ command: string;
+ }
+
+ interface ChannelEvents extends EventMap {
+ control(options: JsonObject): void;
+ ready(options: JsonObject): void;
+ close(options: JsonObject): void;
+ message(data: T): void;
+ }
+
+ interface Channel extends EventSource> {
+ id: string | null;
+ binary: boolean;
+ options: JsonObject;
+ ready: boolean;
+ valid: boolean;
+ send(data: T): void;
+ control(options: ControlMessage): void;
+ wait(callback?: (data: T) => void): Promise;
+ close(options?: string | JsonObject): void;
+ }
+
+ // these apply to all channels
+ interface ChannelOptions {
+ superuser?: SuperuserMode;
+ [_: string]: JsonValue | undefined;
+ binary?: boolean,
+
+ // for remote channels
+ host?: string;
+ user?: string;
+ password?: string;
+ session?: "shared" | "private";
+ }
+
+ // this applies to opening a generic channel() with explicit payload
+ interface ChannelOpenOptions extends ChannelOptions {
+ payload: string;
+ }
+
+ function channel(options: ChannelOpenOptions & { binary?: false; }): Channel;
+ function channel(options: ChannelOpenOptions & { binary: true; }): Channel;
+
+ /* === cockpit.{spawn,script} ============================= */
+
+ class ProcessError {
+ problem: string | null;
+ exit_status: number | null;
+ exit_signal: number | null;
+ message: string;
+ }
+
+ interface Spawn extends DeferredPromise {
+ input(message?: T | null, stream?: boolean): DeferredPromise;
+ stream(callback: (data: T) => void): DeferredPromise;
+ close(options?: string | JsonObject): void;
+ }
+
+ interface SpawnOptions extends ChannelOptions {
+ directory?: string;
+ err?: "out" | "ignore" | "message";
+ environ?: string[];
+ pty?: boolean;
+ }
+
+ function spawn(
+ args: string[],
+ options?: SpawnOptions & { binary?: false }
+ ): Spawn;
+ function spawn(
+ args: string[],
+ options: SpawnOptions & { binary: true }
+ ): Spawn;
+
+ function script(
+ script: string,
+ args?: string[],
+ options?: SpawnOptions & { binary?: false }
+ ): Spawn;
+ function script(
+ script: string,
+ args?: string[],
+ options?: SpawnOptions & { binary: true }
+ ): Spawn;
+
+ /* === cockpit.location ========================== */
+
+ interface Location {
+ url_root: string;
+ options: { [name: string]: string | Array };
+ path: Array;
+ href: string;
+ go(path: Location | string[] | string, options?: { [key: string]: string }): void;
+ replace(path: Location | string[] | string, options?: { [key: string]: string }): void;
+
+ encode(path: string[], options?: { [key: string]: string }, with_root?: boolean): string;
+ decode(string: string, options?: { [key: string]: string }): string[];
+ }
+
+ export let location: Location;
+
+ /* === cockpit.jump ========================== */
+
+ function jump(path: string | string[], host?: string): void;
+
+ /* === cockpit page visibility =============== */
+
+ export let hidden: boolean;
+
+ /* === cockpit.dbus ========================== */
+
+ interface DBusProxyEvents extends EventMap {
+ changed(changes: { [property: string]: unknown }): void;
+ }
+
+ interface DBusProxiesEvents extends EventMap {
+ added(proxy: DBusProxy): void;
+ changed(proxy: DBusProxy): void;
+ removed(proxy: DBusProxy): void;
+ }
+
+ interface DBusProxy extends EventSource {
+ valid: boolean;
+ [property: string]: unknown;
+ }
+
+ interface DBusOptions {
+ bus?: string;
+ address?: string;
+ host?: string;
+ superuser?: SuperuserMode;
+ track?: boolean;
+ }
+
+ type DBusCallOptions = {
+ flags?: "" | "i",
+ type?: string,
+ timeout?: number,
+ };
+
+ interface DBusProxies extends EventSource {
+ client: DBusClient;
+ iface: string;
+ path_namespace: string;
+ wait(callback?: () => void): Promise;
+ }
+
+ interface DBusClient {
+ readonly unique_name: string;
+ readonly options: DBusOptions;
+ proxy(interface?: string, path?: string, options?: { watch?: boolean }): DBusProxy;
+ proxies(interface?: string, path_namespace?: string, options?: { watch?: boolean }): DBusProxies;
+ call(path: string, iface: string, method: string, args?: unknown[] | null, options?: DBusCallOptions): Promise;
+ watch(path: string): DeferredPromise,
+ subscribe: (
+ match: {
+ path?: string,
+ path_namespace?: string,
+ interface?: string,
+ member?: string,
+ arg?: string
+ },
+ callback: (path: string, iface: string, signal: string, args: unknown[]) => void,
+ rule?: boolean,
+ ) => {
+ remove: () => void;
+ },
+ close(): void;
+ }
+
+ type VariantType = string | Uint8Array | number | boolean | VariantType[];
+ interface Variant {
+ t: string;
+ v: VariantType;
+ }
+
+ function dbus(name: string | null, options?: DBusOptions): DBusClient;
+
+ function variant(type: string, value: VariantType): Variant;
+ function byte_array(string: string): string;
+
+ /* === cockpit.file ========================== */
+
+ interface FileSyntaxObject {
+ parse(content: B): T;
+ stringify?(content: T): B;
+ }
+
+ type FileTag = string;
+
+ type FileWatchCallback = (data: T | null, tag: FileTag | null, error: BasicError | null) => void;
+ interface FileWatchHandle {
+ remove(): void;
+ }
+
+ interface FileHandle {
+ // BUG: This should be Promise, but this isn't representable (it's a cockpit.defer underneath)
+ read(): Promise;
+ replace(new_content: T | null, expected_tag?: FileTag): Promise;
+ watch(callback: FileWatchCallback, options?: { read?: boolean }): FileWatchHandle;
+ // BUG: same as read
+ modify(callback: (data: T | null) => T | null, initial_content?: string, initial_tag?: FileTag): Promise;
+ close(): void;
+ path: string;
+ }
+
+ type FileOpenOptions = {
+ max_read_size?: number;
+ superuser?: SuperuserMode;
+ };
+
+ function file(
+ path: string,
+ options?: FileOpenOptions & { binary?: false; syntax?: never; }
+ ): FileHandle;
+ function file(
+ path: string,
+ options: FileOpenOptions & { binary: true; syntax?: never; }
+ ): FileHandle;
+ function file(
+ path: string,
+ options: FileOpenOptions & { binary?: false; syntax: FileSyntaxObject; }
+ ): FileHandle;
+ function file(
+ path: string,
+ options: FileOpenOptions & { binary: true; syntax: FileSyntaxObject; }
+ ): FileHandle;
+
+ /* === cockpit.user ========================== */
+
+ type UserInfo = {
+ id: number;
+ gid: number;
+ name: string;
+ full_name: string;
+ groups: Array;
+ home: string;
+ shell: string;
+ };
+ /** @deprecated */ export function user(): Promise>;
+
+ /* === cockpit.http ====================== */
+
+ export interface TlsCert {
+ file?: string;
+ data?: string;
+ }
+
+ export interface HttpOptions {
+ // target address; if omitted, the endpoint string must include the host
+ address?: string;
+ port?: number;
+ tls?: {
+ authority?: TlsCert;
+ certificate?: TlsCert;
+ key?: TlsCert;
+ validate?: boolean;
+ };
+ superuser?: SuperuserMode;
+ binary?: boolean;
+ // default HTTP headers to send with every request
+ headers?: HttpHeaders;
+ // Default query parameters to include with every request
+ params?: { [key: string]: string | number };
+ }
+
+ export type HttpHeaders = { [key: string]: string };
+
+ export interface HttpRequestOptions {
+ path?: string;
+ method?: string;
+ headers?: HttpHeaders;
+ params?: { [key: string]: string | number };
+ body?: string | Uint8Array | null;
+ }
+
+ // Cockpit HTTP client instance
+ // The generic parameter TResponse controls the type returned by the request methods.
+ export interface HttpInstance {
+ request(options: HttpRequestOptions): Promise;
+ get(path: string, options?: Omit): Promise;
+ post(path: string,
+ // JSON stringification is only implemented in post()
+ body?: string | Uint8Array | JsonObject | null,
+ options?: Omit
+ ): Promise;
+ close(): void;
+ }
+
+ function http(endpoint: string): HttpInstance;
+ function http(endpoint: string, options: HttpOptions & { binary?: false | undefined }): HttpInstance;
+ function http(endpoint: string, options: HttpOptions & { binary: true }): HttpInstance;
+
+ /* === String helpers ======================== */
+
+ function message(problem: string | JsonObject): string;
+
+ function format(format_string: string, ...args: unknown[]): string;
+
+ /* === i18n ===================== */
+
+ function gettext(message: string): string;
+ function gettext(context: string, message?: string): string;
+ function ngettext(message1: string, messageN: string, n: number): string;
+ function ngettext(context: string, message1: string, messageN: string, n: number): string;
+
+ function translate(): void;
+
+ /* === Number formatting ===================== */
+
+ type FormatOptions = {
+ precision?: number;
+ base2?: boolean;
+ };
+ type MaybeNumber = number | null | undefined;
+
+ function format_number(n: MaybeNumber, precision?: number): string
+ function format_bytes(n: MaybeNumber, options?: FormatOptions): string;
+ function format_bytes_per_sec(n: MaybeNumber, options?: FormatOptions): string;
+ function format_bits_per_sec(n: MaybeNumber, options?: FormatOptions & { base2?: false }): string;
+
+ /** @deprecated */ function format_bytes(n: MaybeNumber, factor: unknown, options?: object | boolean): string | string[];
+ /** @deprecated */ function format_bytes_per_sec(n: MaybeNumber, factor: unknown, options?: object | boolean): string | string[];
+ /** @deprecated */ function format_bits_per_sec(n: MaybeNumber, factor: unknown, options?: object | boolean): string | string[];
+
+ /* === Session ====================== */
+ function logout(reload: boolean, reason?: string): void;
+
+ export let localStorage: Storage;
+ export let sessionStorage: Storage;
+}
diff --git a/pkg/lib/cockpit/_internal/base64.js b/pkg/lib/cockpit/_internal/base64.js
new file mode 100644
index 0000000000..390dfe1dd2
--- /dev/null
+++ b/pkg/lib/cockpit/_internal/base64.js
@@ -0,0 +1,65 @@
+/*
+ * These are the polyfills from Mozilla. It's pretty nasty that
+ * these weren't in the typed array standardization.
+ *
+ * https://developer.mozilla.org/en-US/docs/Glossary/Base64
+ */
+
+function uint6_to_b64 (x) {
+ return x < 26 ? x + 65 : x < 52 ? x + 71 : x < 62 ? x - 4 : x === 62 ? 43 : x === 63 ? 47 : 65;
+}
+
+export function base64_encode(data) {
+ if (typeof data === "string")
+ return window.btoa(data);
+ /* For when the caller has chosen to use ArrayBuffer */
+ if (data instanceof window.ArrayBuffer)
+ data = new window.Uint8Array(data);
+ const length = data.length;
+ let mod3 = 2;
+ let str = "";
+ for (let uint24 = 0, i = 0; i < length; i++) {
+ mod3 = i % 3;
+ uint24 |= data[i] << (16 >>> mod3 & 24);
+ if (mod3 === 2 || length - i === 1) {
+ str += String.fromCharCode(uint6_to_b64(uint24 >>> 18 & 63),
+ uint6_to_b64(uint24 >>> 12 & 63),
+ uint6_to_b64(uint24 >>> 6 & 63),
+ uint6_to_b64(uint24 & 63));
+ uint24 = 0;
+ }
+ }
+
+ return str.substring(0, str.length - 2 + mod3) + (mod3 === 2 ? '' : mod3 === 1 ? '=' : '==');
+}
+
+function b64_to_uint6 (x) {
+ return x > 64 && x < 91
+ ? x - 65
+ : x > 96 && x < 123
+ ? x - 71
+ : x > 47 && x < 58 ? x + 4 : x === 43 ? 62 : x === 47 ? 63 : 0;
+}
+
+export function base64_decode(str, constructor) {
+ if (constructor === String)
+ return window.atob(str);
+ const ilen = str.length;
+ let eq;
+ for (eq = 0; eq < 3; eq++) {
+ if (str[ilen - (eq + 1)] != '=')
+ break;
+ }
+ const olen = (ilen * 3 + 1 >> 2) - eq;
+ const data = new (constructor || Array)(olen);
+ for (let mod3, mod4, uint24 = 0, oi = 0, ii = 0; ii < ilen; ii++) {
+ mod4 = ii & 3;
+ uint24 |= b64_to_uint6(str.charCodeAt(ii)) << 18 - 6 * mod4;
+ if (mod4 === 3 || ilen - ii === 1) {
+ for (mod3 = 0; mod3 < 3 && oi < olen; mod3++, oi++)
+ data[oi] = uint24 >>> (16 >>> mod3 & 24) & 255;
+ uint24 = 0;
+ }
+ }
+ return data;
+}
diff --git a/pkg/lib/cockpit/_internal/channel.js b/pkg/lib/cockpit/_internal/channel.js
new file mode 100644
index 0000000000..5a80002f23
--- /dev/null
+++ b/pkg/lib/cockpit/_internal/channel.js
@@ -0,0 +1,245 @@
+import { join_data } from './common';
+import { Deferred } from './deferred';
+import { event_mixin } from './event-mixin';
+import { ensure_transport, transport_globals } from './transport';
+
+/* -------------------------------------------------------------------------
+ * Channels
+ *
+ * Public: https://cockpit-project.org/guide/latest/api-base1.html
+ */
+
+export function Channel(options) {
+ const self = this;
+
+ /* We can trigger events */
+ event_mixin(self, { });
+
+ let transport;
+ let ready = null;
+ let closed = null;
+ let waiting = null;
+ let received_done = false;
+ let sent_done = false;
+ let id = null;
+ const binary = (options.binary === true);
+
+ /*
+ * Queue while waiting for transport, items are tuples:
+ * [is_control ? true : false, payload]
+ */
+ const queue = [];
+
+ /* Handy for callers, but not used by us */
+ self.valid = true;
+ self.options = options;
+ self.binary = binary;
+ self.id = id;
+
+ function on_message(payload) {
+ if (received_done) {
+ console.warn("received message after done");
+ self.close("protocol-error");
+ } else {
+ self.dispatchEvent("message", payload);
+ }
+ }
+
+ function on_close(data) {
+ closed = data;
+ self.valid = false;
+ if (transport && id)
+ transport.unregister(id);
+ if (closed.message && !options.err)
+ console.warn(closed.message);
+ self.dispatchEvent("close", closed);
+ if (waiting)
+ waiting.resolve(closed);
+ }
+
+ function on_ready(data) {
+ ready = data;
+ self.dispatchEvent("ready", ready);
+ }
+
+ function on_control(data) {
+ if (data.command == "close") {
+ on_close(data);
+ return;
+ } else if (data.command == "ready") {
+ on_ready(data);
+ }
+
+ const done = data.command === "done";
+ if (done && received_done) {
+ console.warn("received two done commands on channel");
+ self.close("protocol-error");
+ } else {
+ if (done)
+ received_done = true;
+ self.dispatchEvent("control", data);
+ }
+ }
+
+ function send_payload(payload) {
+ if (!binary) {
+ if (typeof payload !== "string")
+ payload = String(payload);
+ }
+ transport.send_message(payload, id);
+ }
+
+ ensure_transport(function(trans) {
+ transport = trans;
+ if (closed)
+ return;
+
+ id = transport.next_channel();
+ self.id = id;
+
+ /* Register channel handlers */
+ transport.register(id, on_control, on_message);
+
+ /* Now open the channel */
+ const command = { };
+ for (const i in options)
+ if (i !== "binary")
+ command[i] = options[i];
+ /* handle binary specially: Our JS API has always been boolean, while the wire protocol is
+ * a string with the only valid value "raw". */
+ if (binary)
+ command.binary = "raw";
+ command.command = "open";
+ command.channel = id;
+
+ if (!command.host) {
+ if (transport_globals.default_host)
+ command.host = transport_globals.default_host;
+ }
+
+ command["flow-control"] = true;
+ transport.send_control(command);
+
+ /* Now drain the queue */
+ while (queue.length > 0) {
+ const item = queue.shift();
+ if (item[0]) {
+ item[1].channel = id;
+ transport.send_control(item[1]);
+ } else {
+ send_payload(item[1]);
+ }
+ }
+ });
+
+ self.send = function send(message) {
+ if (closed)
+ console.warn("sending message on closed channel");
+ else if (sent_done)
+ console.warn("sending message after done");
+ else if (!transport)
+ queue.push([false, message]);
+ else
+ send_payload(message);
+ };
+
+ self.control = function control(options) {
+ options = options || { };
+ if (!options.command)
+ options.command = "options";
+ if (options.command === "done")
+ sent_done = true;
+ options.channel = id;
+ if (!transport)
+ queue.push([true, options]);
+ else
+ transport.send_control(options);
+ };
+
+ self.wait = function wait(callback) {
+ if (!waiting) {
+ waiting = new Deferred();
+ if (closed) {
+ waiting.reject(closed);
+ } else if (ready) {
+ waiting.resolve(ready);
+ } else {
+ self.addEventListener("ready", function(event, data) {
+ waiting.resolve(data);
+ });
+ self.addEventListener("close", function(event, data) {
+ waiting.reject(data);
+ });
+ }
+ }
+ const promise = waiting.promise;
+ if (callback)
+ promise.then(callback, callback);
+ return promise;
+ };
+
+ self.close = function close(options) {
+ if (closed)
+ return;
+
+ if (!options)
+ options = { };
+ else if (typeof options == "string")
+ options = { problem: options };
+ options.command = "close";
+ options.channel = id;
+
+ if (!transport)
+ queue.push([true, options]);
+ else
+ transport.send_control(options);
+ on_close(options);
+ };
+
+ self.buffer = function buffer(callback) {
+ const buffers = [];
+ buffers.callback = callback;
+ buffers.squash = function squash() {
+ return join_data(buffers, binary);
+ };
+
+ function on_message(event, data) {
+ buffers.push(data);
+ if (buffers.callback) {
+ const block = join_data(buffers, binary);
+ if (block.length > 0) {
+ const consumed = buffers.callback.call(self, block);
+ if (typeof consumed !== "number" || consumed === block.length) {
+ buffers.length = 0;
+ } else if (consumed === 0) {
+ buffers.length = 1;
+ buffers[0] = block;
+ } else if (consumed !== 0) {
+ buffers.length = 1;
+ if (block.subarray)
+ buffers[0] = block.subarray(consumed);
+ else if (block.substring)
+ buffers[0] = block.substring(consumed);
+ else
+ buffers[0] = block.slice(consumed);
+ }
+ }
+ }
+ }
+
+ function on_close() {
+ self.removeEventListener("message", on_message);
+ self.removeEventListener("close", on_close);
+ }
+
+ self.addEventListener("message", on_message);
+ self.addEventListener("close", on_close);
+
+ return buffers;
+ };
+
+ self.toString = function toString() {
+ const host = options.host || "localhost";
+ return "[Channel " + (self.valid ? id : "") + " -> " + host + "]";
+ };
+}
diff --git a/pkg/lib/cockpit/_internal/common.ts b/pkg/lib/cockpit/_internal/common.ts
new file mode 100644
index 0000000000..77f4cc940c
--- /dev/null
+++ b/pkg/lib/cockpit/_internal/common.ts
@@ -0,0 +1,97 @@
+export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
+export type JsonObject = Record;
+
+export type StrOrBytes = string | Uint8Array;
+
+declare global {
+ interface CockpitMockOptions {
+ url_root?: string;
+ pathname?: string;
+ last_transport?: unknown;
+ url?: string;
+ }
+
+ interface Window {
+ mock?: CockpitMockOptions;
+ debugging?: string;
+ }
+}
+
+/*
+ * The debugging property is a global that is used
+ * by various parts of the code to show/hide debug
+ * messages in the javascript console.
+ *
+ * We support using storage to get/set that property
+ * so that it carries across the various frames or
+ * alternatively persists across refreshes.
+ */
+if (typeof window.debugging === "undefined") {
+ try {
+ // Sometimes this throws a SecurityError such as during testing
+ Object.defineProperty(window, "debugging", {
+ get: function() { return window.sessionStorage.debugging || window.localStorage.debugging },
+ set: function(x) { window.sessionStorage.debugging = x }
+ });
+ } catch { }
+}
+
+export function in_array(array: unknown[], val: unknown): boolean {
+ const length = array.length;
+ for (let i = 0; i < length; i++) {
+ if (val === array[i])
+ return true;
+ }
+ return false;
+}
+
+export function is_function(x: unknown): x is (...args: unknown[]) => unknown {
+ return typeof x === 'function';
+}
+
+export function is_object(x: unknown): x is object {
+ return x !== null && typeof x === 'object';
+}
+
+export function is_plain_object(x: unknown): boolean {
+ return is_object(x) && Object.prototype.toString.call(x) === '[object Object]';
+}
+
+export function invoke_functions void>(functions: F[], self: ThisType, args: Parameters): void {
+ const length = functions?.length ?? 0;
+ for (let i = 0; i < length; i++) {
+ if (functions[i])
+ functions[i].apply(self, args);
+ }
+}
+
+export function iterate_data(data: StrOrBytes, callback: (chunk: StrOrBytes) => void, batch: number = 64 * 1024): void {
+ if (typeof data === 'string') {
+ for (let i = 0; i < data.length; i += batch) {
+ callback(data.substring(i, i + batch));
+ }
+ } else if (data) {
+ for (let i = 0; i < data.byteLength; i += batch) {
+ const n = Math.min(data.byteLength - i, batch);
+ callback(new Uint8Array(data.buffer, i, n));
+ }
+ }
+}
+
+export function join_data(buffers: StrOrBytes[], binary: boolean): StrOrBytes {
+ if (!binary)
+ return buffers.join("");
+
+ let total = 0;
+ const length = buffers.length;
+ for (let i = 0; i < length; i++)
+ total += buffers[i].length;
+
+ const data = new Uint8Array(total);
+ for (let j = 0, i = 0; i < length; i++) {
+ data.set(buffers[i] as Uint8Array, j);
+ j += buffers[i].length;
+ }
+
+ return data;
+}
diff --git a/pkg/lib/cockpit/_internal/deferred.js b/pkg/lib/cockpit/_internal/deferred.js
new file mode 100644
index 0000000000..cd816471b4
--- /dev/null
+++ b/pkg/lib/cockpit/_internal/deferred.js
@@ -0,0 +1,237 @@
+import { is_function, is_object } from './common';
+
+/* ------------------------------------------------------------------------------------
+ * An ordered queue of functions that should be called later.
+ */
+
+let later_queue = [];
+let later_timeout = null;
+
+function later_drain() {
+ const queue = later_queue;
+ later_timeout = null;
+ later_queue = [];
+ for (;;) {
+ const func = queue.shift();
+ if (!func)
+ break;
+ func();
+ }
+}
+
+export function later_invoke(func) {
+ if (func)
+ later_queue.push(func);
+ if (later_timeout === null)
+ later_timeout = window.setTimeout(later_drain, 0);
+}
+
+/* ------------------------------------------------------------------------------------
+ * Promises.
+ * Based on Q and angular promises, with some jQuery compatibility. See the angular
+ * license in COPYING.node for license lineage. There are some key differences with
+ * both Q and jQuery.
+ *
+ * * Exceptions thrown in handlers are not treated as rejections or failures.
+ * Exceptions remain actual exceptions.
+ * * Unlike jQuery callbacks added to an already completed promise don't execute
+ * immediately. Wait until control is returned to the browser.
+ */
+
+function promise_then(state, fulfilled, rejected, updated) {
+ if (fulfilled === undefined && rejected === undefined && updated === undefined)
+ return null;
+ const result = new Deferred();
+ state.pending = state.pending || [];
+ state.pending.push([result, fulfilled, rejected, updated]);
+ if (state.status > 0)
+ schedule_process_queue(state);
+ return result.promise;
+}
+
+function create_promise(state) {
+ /* Like jQuery the promise object is callable */
+ const self = function Promise(target) {
+ if (target) {
+ Object.assign(target, self);
+ return target;
+ }
+ return self;
+ };
+
+ state.status = 0;
+
+ self.then = function then(fulfilled, rejected, updated) {
+ return promise_then(state, fulfilled, rejected, updated) || self;
+ };
+
+ self.catch = function catch_(callback) {
+ return promise_then(state, null, callback) || self;
+ };
+
+ self.finally = function finally_(callback, updated) {
+ return promise_then(state, function() {
+ return handle_callback(arguments, true, callback);
+ }, function() {
+ return handle_callback(arguments, false, callback);
+ }, updated) || self;
+ };
+
+ /* Basic jQuery Promise compatibility */
+ self.done = function done(fulfilled) {
+ promise_then(state, fulfilled);
+ return self;
+ };
+
+ self.fail = function fail(rejected) {
+ promise_then(state, null, rejected);
+ return self;
+ };
+
+ self.always = function always(callback) {
+ promise_then(state, callback, callback);
+ return self;
+ };
+
+ self.progress = function progress(updated) {
+ promise_then(state, null, null, updated);
+ return self;
+ };
+
+ self.state = function state_() {
+ if (state.status == 1)
+ return "resolved";
+ if (state.status == 2)
+ return "rejected";
+ return "pending";
+ };
+
+ /* Promises are recursive like jQuery */
+ self.promise = self;
+
+ return self;
+}
+
+function process_queue(state) {
+ const pending = state.pending;
+ state.process_scheduled = false;
+ state.pending = undefined;
+ for (let i = 0, ii = pending.length; i < ii; ++i) {
+ state.pur = true;
+ const deferred = pending[i][0];
+ const fn = pending[i][state.status];
+ if (is_function(fn)) {
+ deferred.resolve(fn.apply(state.promise, state.values));
+ } else if (state.status === 1) {
+ deferred.resolve.apply(deferred.resolve, state.values);
+ } else {
+ deferred.reject.apply(deferred.reject, state.values);
+ }
+ }
+}
+
+function schedule_process_queue(state) {
+ if (state.process_scheduled || !state.pending)
+ return;
+ state.process_scheduled = true;
+ later_invoke(function() { process_queue(state) });
+}
+
+function deferred_resolve(state, values) {
+ let then;
+ let done = false;
+ if (is_object(values[0]) || is_function(values[0]))
+ then = values[0]?.then;
+ if (is_function(then)) {
+ state.status = -1;
+ then.call(values[0], function(/* ... */) {
+ if (done)
+ return;
+ done = true;
+ deferred_resolve(state, arguments);
+ }, function(/* ... */) {
+ if (done)
+ return;
+ done = true;
+ deferred_reject(state, arguments);
+ }, function(/* ... */) {
+ deferred_notify(state, arguments);
+ });
+ } else {
+ state.values = values;
+ state.status = 1;
+ schedule_process_queue(state);
+ }
+}
+
+function deferred_reject(state, values) {
+ state.values = values;
+ state.status = 2;
+ schedule_process_queue(state);
+}
+
+function deferred_notify(state, values) {
+ const callbacks = state.pending;
+ if ((state.status <= 0) && callbacks?.length) {
+ later_invoke(function() {
+ for (let i = 0, ii = callbacks.length; i < ii; i++) {
+ const result = callbacks[i][0];
+ const callback = callbacks[i][3];
+ if (is_function(callback))
+ result.notify(callback.apply(state.promise, values));
+ else
+ result.notify.apply(result, values);
+ }
+ });
+ }
+}
+
+export function Deferred() {
+ const self = this;
+ const state = { };
+ self.promise = state.promise = create_promise(state);
+
+ self.resolve = function resolve(/* ... */) {
+ if (arguments[0] === state.promise)
+ throw new Error("Expected promise to be resolved with other value than itself");
+ if (!state.status)
+ deferred_resolve(state, arguments);
+ return self;
+ };
+
+ self.reject = function reject(/* ... */) {
+ if (state.status)
+ return;
+ deferred_reject(state, arguments);
+ return self;
+ };
+
+ self.notify = function notify(/* ... */) {
+ deferred_notify(state, arguments);
+ return self;
+ };
+}
+
+function prep_promise(values, resolved) {
+ const result = new Deferred();
+ if (resolved)
+ result.resolve.apply(result, values);
+ else
+ result.reject.apply(result, values);
+ return result.promise;
+}
+
+function handle_callback(values, is_resolved, callback) {
+ let callback_output = null;
+ if (is_function(callback))
+ callback_output = callback();
+ if (callback_output && is_function(callback_output.then)) {
+ return callback_output.then(function() {
+ return prep_promise(values, is_resolved);
+ }, function() {
+ return prep_promise(arguments, false);
+ });
+ } else {
+ return prep_promise(values, is_resolved);
+ }
+}
diff --git a/pkg/lib/cockpit/_internal/event-mixin.js b/pkg/lib/cockpit/_internal/event-mixin.js
new file mode 100644
index 0000000000..0366c38af9
--- /dev/null
+++ b/pkg/lib/cockpit/_internal/event-mixin.js
@@ -0,0 +1,62 @@
+import { is_function, invoke_functions } from './common';
+
+/*
+ * Extends an object to have the standard DOM style addEventListener
+ * removeEventListener and dispatchEvent methods. The dispatchEvent
+ * method has the additional capability to create a new event from a type
+ * string and arguments.
+ */
+export function event_mixin(obj, handlers) {
+ Object.defineProperties(obj, {
+ addEventListener: {
+ enumerable: false,
+ value: function addEventListener(type, handler) {
+ if (handlers[type] === undefined)
+ handlers[type] = [];
+ handlers[type].push(handler);
+ }
+ },
+ removeEventListener: {
+ enumerable: false,
+ value: function removeEventListener(type, handler) {
+ const length = handlers[type] ? handlers[type].length : 0;
+ for (let i = 0; i < length; i++) {
+ if (handlers[type][i] === handler) {
+ handlers[type][i] = null;
+ break;
+ }
+ }
+ }
+ },
+ dispatchEvent: {
+ enumerable: false,
+ value: function dispatchEvent(event) {
+ let type, args;
+ if (typeof event === "string") {
+ type = event;
+ args = Array.prototype.slice.call(arguments, 1);
+
+ let detail = null;
+ if (arguments.length == 2)
+ detail = arguments[1];
+ else if (arguments.length > 2)
+ detail = args;
+
+ event = new CustomEvent(type, {
+ bubbles: false,
+ cancelable: false,
+ detail
+ });
+
+ args.unshift(event);
+ } else {
+ type = event.type;
+ args = arguments;
+ }
+ if (is_function(obj['on' + type]))
+ obj['on' + type].apply(obj, args);
+ invoke_functions(handlers[type], obj, args);
+ }
+ }
+ });
+}
diff --git a/pkg/lib/cockpit/_internal/info.ts b/pkg/lib/cockpit/_internal/info.ts
new file mode 100644
index 0000000000..d7fdcb56fc
--- /dev/null
+++ b/pkg/lib/cockpit/_internal/info.ts
@@ -0,0 +1,79 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import { Channel } from '../channel';
+
+/* We allow accessing arbitrary fields by a Partial> type, but having
+ * an explicit list is useful for autocompletion. */
+export interface OsRelease {
+ NAME?: string;
+ VERSION?: string;
+ RELEASE_TYPE?: string;
+ ID?: string;
+ VERSION_ID?: string;
+ VERSION_CODENAME?: string;
+ PLATFORM_ID?: string;
+ PRETTY_NAME?: string;
+ ANSI_COLOR?: string;
+ LOGO?: string;
+ CPE_NAME?: string;
+ DEFAULT_HOSTNAME?: string;
+ HOME_URL?: string;
+ DOCUMENTATION_URL?: string;
+ SUPPORT_URL?: string;
+ BUG_REPORT_URL?: string;
+ SUPPORT_END?: string;
+ VARIANT?: string;
+ VARIANT_ID?: string;
+}
+
+export interface User {
+ fullname: string;
+ gid: number;
+ group: string;
+ groups: string[];
+ home: string;
+ name: string;
+ shell: string;
+ uid: number;
+}
+
+export interface WebserverInfo {
+ version: string;
+}
+
+export interface Info {
+ channels: Partial>;
+ os_release: OsRelease & Partial>;
+ user: User;
+ ws: WebserverInfo;
+}
+
+export function fetch_info(): Promise {
+ const channel = new Channel({ payload: 'info' });
+ return new Promise((resolve, reject) => {
+ channel.on('data', data => {
+ resolve(JSON.parse(data));
+ channel.close();
+ });
+ channel.on('close', msg => {
+ reject(msg);
+ });
+ });
+}
diff --git a/pkg/lib/cockpit/_internal/location-utils.ts b/pkg/lib/cockpit/_internal/location-utils.ts
new file mode 100644
index 0000000000..98cfc256ea
--- /dev/null
+++ b/pkg/lib/cockpit/_internal/location-utils.ts
@@ -0,0 +1,62 @@
+function get_url_root(): string | null {
+ const meta_url_root = document.head.querySelector("meta[name='url-root']");
+ if (meta_url_root instanceof HTMLMetaElement) {
+ return meta_url_root.content.replace(/^\/+|\/+$/g, '');
+ } else {
+ // fallback for cockpit-ws < 272
+ try {
+ // Sometimes this throws a SecurityError such as during testing
+ return window.localStorage.getItem('url-root');
+ } catch {
+ return null;
+ }
+ }
+}
+export const url_root = get_url_root();
+
+export const transport_origin = window.location.origin;
+
+export function calculate_application(): string {
+ let path = window.location.pathname || "/";
+ let _url_root = url_root;
+ if (window.mock?.pathname)
+ path = window.mock.pathname;
+ if (window.mock?.url_root)
+ _url_root = window.mock.url_root;
+
+ if (_url_root && path.indexOf('/' + _url_root) === 0)
+ path = path.replace('/' + _url_root, '') || '/';
+
+ if (path.indexOf("/cockpit/") !== 0 && path.indexOf("/cockpit+") !== 0) {
+ if (path.indexOf("/=") === 0)
+ path = "/cockpit+" + path.split("/")[1];
+ else
+ path = "/cockpit";
+ }
+
+ return path.split("/")[1];
+}
+
+export function calculate_url(suffix?: string): string {
+ if (!suffix)
+ suffix = "socket";
+ const window_loc = window.location.toString();
+ let _url_root = url_root;
+
+ if (window.mock?.url)
+ return window.mock.url;
+ if (window.mock?.url_root)
+ _url_root = window.mock.url_root;
+
+ let prefix = calculate_application();
+ if (_url_root)
+ prefix = _url_root + "/" + prefix;
+
+ if (window_loc.indexOf('http:') === 0) {
+ return "ws://" + window.location.host + "/" + prefix + "/" + suffix;
+ } else if (window_loc.indexOf('https:') === 0) {
+ return "wss://" + window.location.host + "/" + prefix + "/" + suffix;
+ } else {
+ throw new Error("Cockpit must be used over http or https");
+ }
+}
diff --git a/pkg/lib/cockpit/_internal/location.ts b/pkg/lib/cockpit/_internal/location.ts
new file mode 100644
index 0000000000..635b158478
--- /dev/null
+++ b/pkg/lib/cockpit/_internal/location.ts
@@ -0,0 +1,190 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2024 Red Hat, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+/*
+ * This is the "cockpit.location" API converted to TypeScript and moved out of
+ * cockpit.js. In the future a new Location API should be designed to replace
+ * the "cockpit.location" one and become importable from pkg/lib/cockpit as ESM module.
+ */
+
+import { url_root, calculate_application } from './location-utils';
+
+type Options = { [name: string]: string | Array };
+type Path = string | string[] | Location;
+
+export class Location {
+ path: string[];
+ href: string;
+ url_root: string;
+ options: Options;
+ #hash_changed: boolean = false;
+
+ constructor() {
+ const application = calculate_application();
+ this.url_root = url_root || "";
+
+ if (window.mock?.url_root)
+ this.url_root = window.mock.url_root;
+
+ if (application.indexOf("cockpit+=") === 0) {
+ if (this.url_root)
+ this.url_root += '/';
+ this.url_root = this.url_root + application.replace("cockpit+", '');
+ }
+
+ this.href = window.location.hash.slice(1);
+ this.options = {};
+ this.path = this.decode(this.href, this.options);
+ }
+
+ #resolve_path_dots(parts: string[]): string[] {
+ const out = [];
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i];
+ if (part === "" || part == ".") {
+ continue;
+ } else if (part == "..") {
+ if (out.length === 0)
+ return [];
+ out.pop();
+ } else {
+ out.push(part);
+ }
+ }
+ return out;
+ }
+
+ #href_for_go_or_replace(path: Path, options?: Options): string {
+ options = options || {};
+ if (typeof path === "string") {
+ return this.encode(this.decode(path, options), options);
+ } else if (path instanceof Location) {
+ return path.href;
+ } else {
+ return this.encode(path, options);
+ }
+ }
+
+ #decode_path(input: string): string[] {
+ let result, i;
+ let pre_parts: string[] = [];
+ const parts = input.split('/').map(decodeURIComponent);
+
+ if (this.url_root)
+ pre_parts = this.url_root.split('/').map(decodeURIComponent);
+
+ if (input && input[0] !== "/" && this.path !== undefined) {
+ result = [...this.path];
+ result.pop();
+ result = result.concat(parts);
+ } else {
+ result = parts;
+ }
+
+ result = this.#resolve_path_dots(result);
+ for (i = 0; i < pre_parts.length; i++) {
+ if (pre_parts[i] !== result[i])
+ break;
+ }
+ if (i == pre_parts.length)
+ result.splice(0, pre_parts.length);
+
+ return result;
+ }
+
+ encode(path: string | string[], options: Options, with_root: boolean = false): string {
+ if (typeof path == "string")
+ path = this.#decode_path(path);
+
+ let href = "/" + path.map(encodeURIComponent).join("/");
+ if (with_root && this.url_root && href.indexOf("/" + this.url_root + "/") !== 0)
+ href = "/" + this.url_root + href;
+
+ /* Undo unnecessary encoding of these */
+ href = href.replaceAll("%40", "@");
+ href = href.replaceAll("%3D", "=");
+ href = href.replaceAll("%2B", "+");
+ href = href.replaceAll("%23", "#");
+
+ const query: string[] = [];
+ if (options) {
+ for (const opt in options) {
+ let value = options[opt];
+ if (!Array.isArray(value))
+ value = [value];
+ value.forEach(function(v: string) {
+ query.push(encodeURIComponent(opt) + "=" + encodeURIComponent(v));
+ });
+ }
+ if (query.length > 0)
+ href += "?" + query.join("&");
+ }
+ return href;
+ }
+
+ // NOTE: The options argument is modified in place
+ decode(href: string, options: Options): string[] {
+ if (href[0] == '#')
+ href = href.substring(1);
+
+ const pos = href.indexOf('?');
+ const first = (pos === -1) ? href : href.substring(0, pos);
+ const path = this.#decode_path(first);
+ if (pos !== -1 && options) {
+ href.substring(pos + 1).split("&")
+ .forEach(function(opt) {
+ const parts = opt.split('=');
+ const name = decodeURIComponent(parts[0]);
+ const value = decodeURIComponent(parts[1]);
+ if (options[name]) {
+ let last = options[name];
+ if (!Array.isArray(last))
+ last = options[name] = [last];
+ last.push(value);
+ } else {
+ options[name] = value;
+ }
+ });
+ }
+
+ return path;
+ }
+
+ replace(path: Path, options?: Options) {
+ if (this.#hash_changed)
+ return;
+ const href = this.#href_for_go_or_replace(path, options);
+ window.location.replace(window.location.pathname + '#' + href);
+ }
+
+ go(path: Path, options?: Options) {
+ if (this.#hash_changed)
+ return;
+ const href = this.#href_for_go_or_replace(path, options);
+ window.location.hash = '#' + href;
+ }
+
+ invalidate() {
+ this.#hash_changed = true;
+ }
+
+ toString() {
+ return this.href;
+ }
+}
diff --git a/pkg/lib/cockpit/_internal/parentwebsocket.ts b/pkg/lib/cockpit/_internal/parentwebsocket.ts
new file mode 100644
index 0000000000..beb800d689
--- /dev/null
+++ b/pkg/lib/cockpit/_internal/parentwebsocket.ts
@@ -0,0 +1,58 @@
+import { transport_origin } from './location-utils';
+
+/*
+ * A WebSocket that connects to parent frame. The mechanism
+ * for doing this will eventually be documented publicly,
+ * but for now:
+ *
+ * * Forward raw cockpit1 string protocol messages via window.postMessage
+ * * Listen for cockpit1 string protocol messages via window.onmessage
+ * * Never accept or send messages to another origin
+ * * An empty string message means "close" (not completely used yet)
+ */
+export class ParentWebSocket {
+ binaryType = 'arraybuffer' as const; // compatibility with Transport, which sets this
+ readyState = 0;
+
+ // essentially signal handlers: these are assigned to from Transport
+ onopen(): void {
+ }
+
+ onclose(): void {
+ }
+
+ onmessage(_event: MessageEvent): void {
+ }
+
+ constructor(parent: Window) {
+ window.addEventListener("message", event => {
+ if (event.origin !== transport_origin || event.source !== parent)
+ return;
+ const data = event.data;
+ if (data === undefined || (data.length === undefined && data.byteLength === undefined))
+ return;
+ if (data.length === 0) {
+ this.readyState = 3;
+ this.onclose();
+ } else {
+ this.onmessage(event);
+ }
+ }, false);
+
+ window.setTimeout(() => {
+ this.readyState = 1;
+ this.onopen();
+ }, 0);
+ }
+
+ // same types as the real WebSocket
+ send(message: string | ArrayBufferLike | Blob | ArrayBufferView): void {
+ parent.postMessage(message, transport_origin);
+ }
+
+ close(): void {
+ this.readyState = 3;
+ parent.postMessage("", transport_origin);
+ this.onclose();
+ }
+}
diff --git a/pkg/lib/cockpit/_internal/transport.ts b/pkg/lib/cockpit/_internal/transport.ts
new file mode 100644
index 0000000000..4ea0a0c93f
--- /dev/null
+++ b/pkg/lib/cockpit/_internal/transport.ts
@@ -0,0 +1,324 @@
+import { EventEmitter } from '../event';
+
+import type { JsonObject } from './common';
+import { calculate_application, calculate_url } from './location-utils';
+import { ParentWebSocket } from './parentwebsocket';
+
+type ControlCallback = (message: JsonObject) => void;
+type MessageCallback = (data: string | Uint8Array) => void;
+type FilterCallback = (message: string | ArrayBuffer, channel: string | null, control: JsonObject | null) => boolean;
+
+class TransportGlobals {
+ default_transport: Transport | null = null;
+ reload_after_disconnect = false;
+ expect_disconnect = false;
+ init_callback: ControlCallback | null = null;
+ default_host: string | null = null;
+ process_hints: ControlCallback | null = null;
+ incoming_filters: FilterCallback[] = [];
+}
+
+// the transport globals must be a *real* global, across bundles -- i.e. a