diff --git a/pkg/base1/test-location.js b/pkg/base1/test-location.js index a760fd60eff9..0f21f132a0a0 100644 --- a/pkg/base1/test-location.js +++ b/pkg/base1/test-location.js @@ -285,6 +285,7 @@ QUnit.test("test", function (assert) { assert.deepEqual(cockpit.location.path, ["hello"], "path is right"); window.setTimeout(function() { + /* window.location.hash has changed so the old `location` object is no longer valid and .go/replace are ignored */ location.go(["not-gonna-happen"]); assert.strictEqual(window.location.hash, "#/other", "hash is correct"); diff --git a/pkg/lib/cockpit.d.ts b/pkg/lib/cockpit.d.ts index f371d563cee6..cd4e9111e532 100644 --- a/pkg/lib/cockpit.d.ts +++ b/pkg/lib/cockpit.d.ts @@ -186,8 +186,8 @@ declare module 'cockpit' { options: { [name: string]: string | Array }; path: Array; href: string; - go(path: Location | string, options?: { [key: string]: string }): void; - replace(path: Location | string, options?: { [key: string]: string }): void; + go(path: Location | string[] | string, options?: { [key: string]: string }): void; + replace(path: Location | string[] | string, options?: { [key: string]: string }): void; } export const location: Location; diff --git a/pkg/lib/cockpit.js b/pkg/lib/cockpit.js index 94bd7e11886d..15475abbbd6b 100644 --- a/pkg/lib/cockpit.js +++ b/pkg/lib/cockpit.js @@ -26,7 +26,8 @@ import { } from './cockpit/_internal/common'; import { Deferred, later_invoke } from './cockpit/_internal/deferred'; import { event_mixin } from './cockpit/_internal/event-mixin'; -import { url_root, transport_origin, calculate_application, calculate_url } from './cockpit/_internal/location'; +import { transport_origin, calculate_application, calculate_url } from './cockpit/_internal/location-utils'; +import { get_window_location_hash, Location } from 'cockpit/location'; import { ensure_transport, transport_globals } from './cockpit/_internal/transport'; import { FsInfoClient } from "./cockpit/fsinfo"; @@ -1127,184 +1128,6 @@ function factory() { let last_loc = null; - function get_window_location_hash() { - return (window.location.href.split('#')[1] || ''); - } - - function Location() { - const self = this; - const application = cockpit.transport.application(); - self.url_root = url_root || ""; - - if (window.mock?.url_root) - self.url_root = window.mock.url_root; - - if (application.indexOf("cockpit+=") === 0) { - if (self.url_root) - self.url_root += '/'; - self.url_root = self.url_root + application.replace("cockpit+", ''); - } - - const href = get_window_location_hash(); - const options = { }; - self.path = decode(href, options); - - /* Resolve dots and double dots */ - function resolve_path_dots(parts) { - const out = []; - const length = parts.length; - for (let i = 0; i < 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; - } - - function decode_path(input) { - const parts = input.split('/').map(decodeURIComponent); - let result, i; - let pre_parts = []; - - if (self.url_root) - pre_parts = self.url_root.split('/').map(decodeURIComponent); - - if (input && input[0] !== "/" && self.path !== undefined) { - result = [...self.path]; - result.pop(); - result = result.concat(parts); - } else { - result = parts; - } - - result = 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; - } - - function encode(path, options, with_root) { - if (typeof path == "string") - path = decode_path(path); - - let href = "/" + path.map(encodeURIComponent).join("/"); - if (with_root && self.url_root && href.indexOf("/" + self.url_root + "/") !== 0) - href = "/" + self.url_root + href; - - /* Undo unnecessary encoding of these */ - href = href.replaceAll("%40", "@"); - href = href.replaceAll("%3D", "="); - href = href.replaceAll("%2B", "+"); - href = href.replaceAll("%23", "#"); - - let opt; - const query = []; - function push_option(v) { - query.push(encodeURIComponent(opt) + "=" + encodeURIComponent(v)); - } - - if (options) { - for (opt in options) { - let value = options[opt]; - if (!Array.isArray(value)) - value = [value]; - value.forEach(push_option); - } - if (query.length > 0) - href += "?" + query.join("&"); - } - return href; - } - - function decode(href, options) { - if (href[0] == '#') - href = href.substring(1); - - const pos = href.indexOf('?'); - const first = (pos === -1) ? href : href.substring(0, pos); - const path = 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; - } - - function href_for_go_or_replace(/* ... */) { - let href; - if (arguments.length == 1 && arguments[0] instanceof Location) { - href = String(arguments[0]); - } else if (typeof arguments[0] == "string") { - const options = arguments[1] || { }; - href = encode(decode(arguments[0], options), options); - } else { - href = encode.apply(self, arguments); - } - return href; - } - - function replace(/* ... */) { - if (self !== last_loc) - return; - const href = href_for_go_or_replace.apply(self, arguments); - window.location.replace(window.location.pathname + '#' + href); - } - - function go(/* ... */) { - if (self !== last_loc) - return; - const href = href_for_go_or_replace.apply(self, arguments); - window.location.hash = '#' + href; - } - - Object.defineProperties(self, { - path: { - enumerable: true, - writable: false, - value: self.path - }, - options: { - enumerable: true, - writable: false, - value: options - }, - href: { - enumerable: true, - value: href - }, - go: { value: go }, - replace: { value: replace }, - encode: { value: encode }, - decode: { value: decode }, - toString: { value: function() { return href } } - }); - } - Object.defineProperty(cockpit, "location", { enumerable: true, get: function() { @@ -1318,6 +1141,8 @@ function factory() { }); window.addEventListener("hashchange", function() { + if (last_loc) + last_loc.invalidate(); last_loc = null; let hash = window.location.hash; if (hash.indexOf("#") === 0) diff --git a/pkg/lib/cockpit/_internal/location.ts b/pkg/lib/cockpit/_internal/location-utils.ts similarity index 100% rename from pkg/lib/cockpit/_internal/location.ts rename to pkg/lib/cockpit/_internal/location-utils.ts diff --git a/pkg/lib/cockpit/_internal/parentwebsocket.ts b/pkg/lib/cockpit/_internal/parentwebsocket.ts index 8718d0981fba..beb800d6890b 100644 --- a/pkg/lib/cockpit/_internal/parentwebsocket.ts +++ b/pkg/lib/cockpit/_internal/parentwebsocket.ts @@ -1,4 +1,4 @@ -import { transport_origin } from './location'; +import { transport_origin } from './location-utils'; /* * A WebSocket that connects to parent frame. The mechanism diff --git a/pkg/lib/cockpit/_internal/transport.ts b/pkg/lib/cockpit/_internal/transport.ts index 1e877fbb04aa..4ea0a0c93fb5 100644 --- a/pkg/lib/cockpit/_internal/transport.ts +++ b/pkg/lib/cockpit/_internal/transport.ts @@ -1,7 +1,7 @@ import { EventEmitter } from '../event'; import type { JsonObject } from './common'; -import { calculate_application, calculate_url } from './location'; +import { calculate_application, calculate_url } from './location-utils'; import { ParentWebSocket } from './parentwebsocket'; type ControlCallback = (message: JsonObject) => void; diff --git a/pkg/lib/cockpit/location.ts b/pkg/lib/cockpit/location.ts new file mode 100644 index 000000000000..12b681ad496e --- /dev/null +++ b/pkg/lib/cockpit/location.ts @@ -0,0 +1,192 @@ +/* + * 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 { url_root, calculate_application } from './_internal/location-utils'; + +/* HACK: Mozilla will unescape 'window.location.hash' before returning +* it, which is broken. +* +* https://bugzilla.mozilla.org/show_bug.cgi?id=135309 +*/ +export function get_window_location_hash() { + return (window.location.href.split('#')[1] || ''); +} + +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 = get_window_location_hash(); + this.options = {}; + this.path = this.decode(this.href, this.options); + } + + #resolve_path_dots(parts: 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) { + 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) { + 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) { + 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; + } + + decode(href: string, options: Options) { + 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/tsconfig.json b/tsconfig.json index 6df13da74b64..122e1de671c3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "jsx": "react", "lib": [ "dom", - "es2020" + "es2021" ], "moduleResolution": "node", "noEmit": true, // we only use `tsc` for type checking