Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Configure installer keymap #917

Merged
merged 14 commits into from
Dec 14, 2023
6 changes: 4 additions & 2 deletions web/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ import {
ShowTerminalButton,
Sidebar
} from "~/components/core";
import { LanguageSwitcher } from "./components/l10n";
import { InstallerKeymapSwitcher, InstallerLocaleSwitcher } from "./components/l10n";
import { Layout, Loading, Title } from "./components/layout";
import { useInstallerL10n } from "./context/installerL10n";
import { localConnection } from "~/utils";

// D-Bus connection attempts before displaying an error.
const ATTEMPTS = 3;
Expand Down Expand Up @@ -116,7 +117,8 @@ function App() {
</div>
<div className="full-width highlighted">
<div className="flex-stack">
<LanguageSwitcher />
<InstallerLocaleSwitcher />
{localConnection() ? <InstallerKeymapSwitcher /> : null }
lslezak marked this conversation as resolved.
Show resolved Hide resolved
</div>
</div>
</Sidebar>
Expand Down
55 changes: 55 additions & 0 deletions web/src/components/l10n/InstallerKeymapSwitcher.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright (c) [2023] SUSE LLC
*
* All Rights Reserved.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of version 2 of the GNU General Public License as published
* by the Free Software Foundation.
*
* 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, contact SUSE LLC.
*
* To contact SUSE LLC about this file by physical or electronic mail, you may
* find current contact information at www.suse.com.
*/

import React from "react";
import { Icon } from "../layout";
import { FormSelect, FormSelectOption } from "@patternfly/react-core";
import { _ } from "~/i18n";
import { useInstallerL10n } from "~/context/installerL10n";
import { useL10n } from "~/context/l10n";

const sort = (keymaps) => keymaps.sort((k1, k2) => k1.name > k2.name ? 1 : -1);

export default function InstallerKeymapSwitcher() {
const { keymap, changeKeymap } = useInstallerL10n();
const { keymaps } = useL10n();

const onChange = (_, id) => changeKeymap(id);

const options = sort(keymaps)
.map((keymap, index) => <FormSelectOption key={index} value={keymap.id} label={keymap.name} />);

return (
<>
<h3>
<Icon name="keyboard" size="24" />{_("Keyboard")}
</h3>
<FormSelect
lslezak marked this conversation as resolved.
Show resolved Hide resolved
id="keyboard"
aria-label={_("keyboard")}
value={keymap}
onChange={onChange}
>
{options}
</FormSelect>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { _ } from "~/i18n";
import { useInstallerL10n } from "~/context/installerL10n";
import cockpit from "~/lib/cockpit";

export default function LanguageSwitcher() {
export default function InstallerLocaleSwitcher() {
const { language, changeLanguage } = useInstallerL10n();
const [selected, setSelected] = useState(null);
const languages = cockpit.manifests.agama?.locales || [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import React from "react";
import { screen } from "@testing-library/react";
import { plainRender } from "~/test-utils";
import LanguageSwitcher from "./LanguageSwitcher";
import InstallerLocaleSwitcher from "./InstallerLocaleSwitcher";

const mockLanguage = "es-es";
let mockChangeLanguageFn;
Expand Down Expand Up @@ -52,8 +52,8 @@ beforeEach(() => {
mockChangeLanguageFn = jest.fn();
});

it("LanguageSwitcher", async () => {
const { user } = plainRender(<LanguageSwitcher />);
it("InstallerLocaleSwitcher", async () => {
const { user } = plainRender(<InstallerLocaleSwitcher />);
expect(screen.getByRole("option", { name: "Español" }).selected).toBe(true);
await user.selectOptions(
screen.getByRole("combobox", { label: "Display Language" }),
Expand Down
5 changes: 3 additions & 2 deletions web/src/components/l10n/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) [2022] SUSE LLC
* Copyright (c) [2022-2023] SUSE LLC
*
* All Rights Reserved.
*
Expand All @@ -19,8 +19,9 @@
* find current contact information at www.suse.com.
*/

export { default as InstallerKeymapSwitcher } from "./InstallerKeymapSwitcher";
export { default as InstallerLocaleSwitcher } from "./InstallerLocaleSwitcher";
export { default as KeymapSelector } from "./KeymapSelector";
export { default as L10nPage } from "./L10nPage";
export { default as LanguageSwitcher } from "./LanguageSwitcher";
export { default as LocaleSelector } from "./LocaleSelector";
export { default as TimezoneSelector } from "./TimezoneSelector";
31 changes: 30 additions & 1 deletion web/src/context/installerL10n.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* find current contact information at www.suse.com.
*/

// cspell:ignore localectl
// @ts-check

import React, { useCallback, useEffect, useState } from "react";
Expand Down Expand Up @@ -178,6 +179,18 @@ function reload(newLanguage) {
}
}

/**
* Extracts keymap from localectl output.
*
* @param {string} output
* @returns {string|undefined}
*/
function keymapFromLocalectl(output) {
const matcher = /X11 Layout: (.*)\n/;

return matcher.exec(output)?.at(1);
}

/**
* This provider sets the installer locale. By default, it uses the URL "lang" query parameter or
* the preferred locale from the browser and synchronizes the UI and the backend locales. To
Expand All @@ -196,6 +209,7 @@ function reload(newLanguage) {
function InstallerL10nProvider({ children }) {
const client = useInstallerClient();
const [language, setLanguage] = useState(undefined);
const [keymap, setKeymap] = useState(undefined);
const [backendPending, setBackendPending] = useState(false);
const { cancellablePromise } = useCancellablePromise();

Expand Down Expand Up @@ -240,6 +254,15 @@ function InstallerL10nProvider({ children }) {
}
}, [storeInstallerLanguage, setLanguage]);

const changeKeymap = useCallback(async (id) => {
setKeymap(id);
// write the config to file (/etc/X11/xorg.conf.d/00-keyboard.conf),
// this also sets the console keyboard!
await cockpit.spawn(["localectl", "set-x11-keymap", id]);
// set the current X11 keyboard
await cockpit.spawn(["setxkbmap", id], { environ: ["DISPLAY=:0"] });
}, [setKeymap]);

useEffect(() => {
if (!language) changeLanguage();
}, [changeLanguage, language]);
Expand All @@ -251,8 +274,14 @@ function InstallerL10nProvider({ children }) {
setBackendPending(false);
}, [client, language, backendPending, storeInstallerLanguage]);

useEffect(() => {
cockpit.spawn(["localectl", "status"]).then(output => setKeymap(keymapFromLocalectl(output)));
}, [setKeymap]);

const value = { language, changeLanguage, keymap, changeKeymap };

return (
<L10nContext.Provider value={{ language, changeLanguage }}>{children}</L10nContext.Provider>
<L10nContext.Provider value={value}>{children}</L10nContext.Provider>
);
}

Expand Down
35 changes: 35 additions & 0 deletions web/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,39 @@ const setLocationSearch = (query) => {
window.location.search = query;
};

/**
* Is the Agama server running locally?
*
* This function should be used only in special cases, the Agama behavior should
* be the same regardless of the user connection.
*
* The local connection can be forced by setting the `LOCAL_CONNECTION`
* environment variable to `1`. This can be useful for debugging or for
* development.
*
* @returns {boolean} `true` if the connection is local, `false` otherwise
*/
const localConnection = function (location = window.location) {
lslezak marked this conversation as resolved.
Show resolved Hide resolved
// forced local behavior
if (process.env.LOCAL_CONNECTION === "1") return true;

// when running in a development server use the COCKPIT_TARGET_URL value
// (a proxy is used) otherwise use the page URL from the browser
const hostname = process.env.WEBPACK_SERVE ? (new URL(COCKPIT_TARGET_URL)).hostname : location.hostname;

// using the loopback device? (hostname or IP address)
return hostname === "localhost" || hostname.startsWith("127.");
};

/**
* Is the Agama server running remotely?
*
* @see localConnection
*
* @returns {boolean} `true` if the connection is remote, `false` otherwise
*/
const remoteConnection = (location = window.location) => !localConnection(location);
lslezak marked this conversation as resolved.
Show resolved Hide resolved

/**
* Time for the given timezone.
*
Expand Down Expand Up @@ -310,6 +343,8 @@ export {
toValidationError,
locationReload,
setLocationSearch,
localConnection,
remoteConnection,
timezoneTime,
timezoneUTCOffset
};
47 changes: 46 additions & 1 deletion web/src/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
* find current contact information at www.suse.com.
*/

import { classNames, partition, noop, toValidationError } from "./utils";
import { classNames, partition, noop, toValidationError,
localConnection, remoteConnection } from "./utils";

describe("noop", () => {
it("returns undefined", () => {
Expand Down Expand Up @@ -61,3 +62,47 @@ describe("toValidationError", () => {
expect(toValidationError(issue)).toEqual({ message: "Issue 1" });
});
});

const localURL = new URL("http://127.0.0.90/");
const localURL2 = new URL("http://localhost:9090/");
const remoteURL = new URL("http://example.com");

describe("localConnection", () => {
describe("when the page URL is " + localURL, () => {
it("returns true", () => {
expect(localConnection(localURL)).toEqual(true);
});
});

describe("when the page URL is " + localURL2, () => {
it("returns true", () => {
expect(localConnection(localURL2)).toEqual(true);
});
});

describe("when the page URL is " + remoteURL, () => {
it("returns false", () => {
expect(localConnection(remoteURL)).toEqual(false);
});
});
});

describe("remoteConnection", () => {
describe("when the page URL is " + localURL, () => {
it("returns true", () => {
expect(remoteConnection(localURL)).toEqual(false);
});
});

describe("when the page URL is " + localURL2, () => {
it("returns true", () => {
expect(remoteConnection(localURL2)).toEqual(false);
});
});

describe("when the page URL is " + remoteURL, () => {
it("returns false", () => {
expect(remoteConnection(remoteURL)).toEqual(true);
});
});
});
2 changes: 1 addition & 1 deletion web/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const plugins = [
// the current value of the environment variable, that variable is set to
// "true" when running the development server ("npm run server")
// https://webpack.js.org/plugins/environment-plugin/
new webpack.EnvironmentPlugin({ WEBPACK_SERVE: null }),
new webpack.EnvironmentPlugin({ WEBPACK_SERVE: null, LOCAL_CONNECTION: null }),
// similarly for a non-environment value
// https://webpack.js.org/plugins/define-plugin/
// but because ESlint runs *before* the DefinePlugin we need to
Expand Down
Loading