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