diff --git a/package-lock.json b/package-lock.json index 85ed3fe..e4a940d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.3.2", "license": "ISC", "dependencies": { + "fp-ts": "^2.11.7", + "io-ts": "^2.2.16", "js-yaml": "^4.1.0", "node-gtk": "^0.9.0", "winston": "^3.3.3", @@ -3133,6 +3135,11 @@ "node": ">= 0.12" } }, + "node_modules/fp-ts": { + "version": "2.11.7", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.11.7.tgz", + "integrity": "sha512-UUpeygu50mV/J96Nk92fzHDznYXJxsO20wrUZGJppja1f8P+fhCaclcqcfubEyrH7XXPsmYn98CJF0BVAEn3ZQ==" + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -3629,6 +3636,14 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true }, + "node_modules/io-ts": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.16.tgz", + "integrity": "sha512-y5TTSa6VP6le0hhmIyN0dqEXkrZeJLeC5KApJq6VLci3UEKF80lZ+KuoUs02RhBxNWlrqSNxzfI7otLX1Euv8Q==", + "peerDependencies": { + "fp-ts": "^2.5.0" + } + }, "node_modules/is-arrayish": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", @@ -8653,6 +8668,11 @@ "mime-types": "^2.1.12" } }, + "fp-ts": { + "version": "2.11.7", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.11.7.tgz", + "integrity": "sha512-UUpeygu50mV/J96Nk92fzHDznYXJxsO20wrUZGJppja1f8P+fhCaclcqcfubEyrH7XXPsmYn98CJF0BVAEn3ZQ==" + }, "fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -9025,6 +9045,12 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true }, + "io-ts": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.16.tgz", + "integrity": "sha512-y5TTSa6VP6le0hhmIyN0dqEXkrZeJLeC5KApJq6VLci3UEKF80lZ+KuoUs02RhBxNWlrqSNxzfI7otLX1Euv8Q==", + "requires": {} + }, "is-arrayish": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", diff --git a/package.json b/package.json index 0de24cc..a2dca98 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,8 @@ "typescript": "^4.3.5" }, "dependencies": { + "fp-ts": "^2.11.7", + "io-ts": "^2.2.16", "js-yaml": "^4.1.0", "node-gtk": "^0.9.0", "winston": "^3.3.3", diff --git a/themes/dracula/index.yml b/themes/dracula/index.yml new file mode 100644 index 0000000..5b85972 --- /dev/null +++ b/themes/dracula/index.yml @@ -0,0 +1,2 @@ +primary_html: "index.html" +secondary_html: "secondary.html" diff --git a/themes/dracula/js/backgrounds.js b/themes/dracula/js/backgrounds.js index 515d883..f311244 100644 --- a/themes/dracula/js/backgrounds.js +++ b/themes/dracula/js/backgrounds.js @@ -5,12 +5,22 @@ class Backgrounds { "assets/dracula.png", "assets/window-blurred.png", ]; - this._sidebar = document.querySelector("#sidebar"); this._backgroundsList = document.querySelector("#background-selector"); this._background = document.querySelector("#background"); this._backgroundImages = null; this._backgroundImagesDir = null; this._backgroundPath = ""; + + /** + * Background change requests are handled via broadcast events so that all + * windows correctly update. + */ + window.addEventListener("NodyBroadcastEvent", (ev) => { + if (ev.data.type == "change-background") { + this._backgroundPath = ev.data.path; + this._updateBackgroundImages(); + } + }); } _createImage(path) { @@ -57,8 +67,15 @@ class Backgrounds { const path = this._backgroundImages[i]; let button = this._createImage(path); button.addEventListener("click", () => { - this._backgroundPath = path; - this._updateBackgroundImages(); + if (window.nody_greeter) { + nody_greeter.broadcast({ + type: "change-background", + path, + }); + } else { + this._backgroundPath = path; + this._updateBackgroundImages(); + } }); this._backgroundsList.appendChild(button); } diff --git a/themes/dracula/js/index.js b/themes/dracula/js/index.js index 7544e32..b9b246e 100644 --- a/themes/dracula/js/index.js +++ b/themes/dracula/js/index.js @@ -46,11 +46,11 @@ async function initGreeter() { battery = new Battery(); brightness = new Brightness(); -} -if (window._ready_event === undefined) { - _ready_event = new Event("GreeterReady"); - window.dispatchEvent(_ready_event); + if (window.nody_greeter && !window.nody_greeter.window_metadata.is_primary) { + // Hide login elements on non-primary screen + document.querySelector("#screen").classList.add("hide"); + } } window.addEventListener("GreeterReady", initGreeter); diff --git a/themes/dracula/js/secondary.js b/themes/dracula/js/secondary.js new file mode 100644 index 0000000..4ba7850 --- /dev/null +++ b/themes/dracula/js/secondary.js @@ -0,0 +1,31 @@ +async function wait(ms) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, ms); + }); +} + +async function _authentication_done() { + await wait(1000); + var body = document.querySelector("body"); + body.style.opacity = 0; +} + +function authentication_done() { + if (lightdm.is_authenticated) _authentication_done(); +} + +function initGreeter() { + lightdm.authentication_complete?.connect(() => authentication_done()); + + backgrounds = new Backgrounds(); + backgrounds._init(); +} + +if (window._ready_event === undefined) { + _ready_event = new Event("GreeterReady"); + window.dispatchEvent(_ready_event); +} + +window.addEventListener("GreeterReady", initGreeter); diff --git a/themes/dracula/secondary.html b/themes/dracula/secondary.html new file mode 100644 index 0000000..6937649 --- /dev/null +++ b/themes/dracula/secondary.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + Dracula theme + + +
+
+
+ + + + + + + diff --git a/themes/gruvbox/index.yml b/themes/gruvbox/index.yml new file mode 100644 index 0000000..5b85972 --- /dev/null +++ b/themes/gruvbox/index.yml @@ -0,0 +1,2 @@ +primary_html: "index.html" +secondary_html: "secondary.html" diff --git a/themes/gruvbox/js/index.js b/themes/gruvbox/js/index.js index 6d1b56a..d33446f 100644 --- a/themes/gruvbox/js/index.js +++ b/themes/gruvbox/js/index.js @@ -48,13 +48,13 @@ async function initGreeter() { if (lock) { document.querySelector("#lock-label").classList.remove("hide"); } + + if (window.nody_greeter && !window.nody_greeter.window_metadata.is_primary) { + // Hide login elements on non-primary screen + document.querySelector("#screen").classList.add("hide"); + } } const notGreeter = false; -if (window._ready_event === undefined) { - _ready_event = new Event("GreeterReady"); - window.dispatchEvent(_ready_event); -} - window.addEventListener("GreeterReady", initGreeter); diff --git a/themes/gruvbox/js/secondary.js b/themes/gruvbox/js/secondary.js new file mode 100644 index 0000000..04eb704 --- /dev/null +++ b/themes/gruvbox/js/secondary.js @@ -0,0 +1,28 @@ +async function wait(ms) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, ms); + }); +} + +async function _authentication_done() { + await wait(500); + var body = document.querySelector("body"); + body.style.opacity = 0; +} + +function authentication_done() { + if (lightdm.is_authenticated) _authentication_done(); +} + +function initGreeter() { + lightdm.authentication_complete?.connect(() => authentication_done()); +} + +if (window._ready_event === undefined) { + _ready_event = new Event("GreeterReady"); + window.dispatchEvent(_ready_event); +} + +window.addEventListener("GreeterReady", initGreeter); diff --git a/themes/gruvbox/secondary.html b/themes/gruvbox/secondary.html new file mode 100644 index 0000000..0a84ea0 --- /dev/null +++ b/themes/gruvbox/secondary.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + Gruvbox theme + + +
+ + + + diff --git a/ts/bridge/bridge.ts b/ts/bridge/bridge.ts index ece5a77..a2b740c 100644 --- a/ts/bridge/bridge.ts +++ b/ts/bridge/bridge.ts @@ -30,6 +30,7 @@ import { LightDMUser, } from "../ldm_interfaces"; import { logger } from "../logger"; +import { CONSTS } from "../consts"; export class Greeter { _config: web_greeter_config; @@ -53,7 +54,7 @@ export class Greeter { } catch (err) { logger.error(err); browser.whenReady().then(() => { - dialog.showMessageBoxSync(browser.win, { + dialog.showMessageBoxSync(browser.primary_window, { message: "Detected a problem that could interfere with the system login process", // Yeah, that problematic message detail: `LightDM: ${err}\nYou can continue without major problems, but you won't be able to log in`, @@ -103,7 +104,13 @@ export class Greeter { _emit_signal(signal: string, ...args: unknown[]): void { //console.log("SIGNAL EMITTED", signal, args) - browser.win.webContents.send("LightDMSignal", signal, ...args); + for (const win of browser.windows) { + win.window.webContents.send( + CONSTS.channel.lightdm_signal, + signal, + ...args + ); + } } /** @@ -727,6 +734,37 @@ ipcMain.on("lightdm", (ev, ...args) => { handler(globalThis.lightdm, ev, ...args); }); +ipcMain.on(CONSTS.channel.window_metadata, (ev) => { + /** + * A request on this channel simply means that a browser window is ready to + * receive metadata (i.e. on initial load or a refresh) + */ + for (const window of browser.windows) { + if (window.window.webContents === ev.sender) { + window.window.webContents.send( + CONSTS.channel.window_metadata, + window.meta + ); + } + } +}); + +ipcMain.on(CONSTS.channel.window_broadcast, (ev, data: unknown) => { + const sendingWindow = browser.windows.find( + (w) => w.window.webContents === ev.sender + ); + if (!sendingWindow) { + throw new Error(`Unable to find window for event ${ev}`); + } + for (const window of browser.windows) { + window.window.webContents.send( + CONSTS.channel.window_broadcast, + sendingWindow.meta, + data + ); + } +}); + browser.whenReady().then(() => { new Greeter(nody_greeter.config); new GreeterConfig(nody_greeter.config); diff --git a/ts/browser.ts b/ts/browser.ts index 367be27..b0aa894 100644 --- a/ts/browser.ts +++ b/ts/browser.ts @@ -8,15 +8,25 @@ import { MenuItemConstructorOptions, } from "electron"; import * as path from "path"; -import * as fs from "fs"; -import { nody_greeter } from "./config"; +import { + load_primary_theme_path, + load_secondary_theme_path, + nody_greeter, +} from "./config"; import { URL } from "url"; import * as url from "url"; import { Brightness } from "./utils/brightness"; import { logger } from "./logger"; import { set_screensaver, reset_screensaver } from "./utils/screensaver"; +import { WindowMetadata } from "./preload"; +interface NodyWindow { + is_primary: boolean; + display: Electron.Display; + window: BrowserWindow; + meta: WindowMetadata; +} class Browser { ready = false; @@ -27,8 +37,7 @@ class Browser { }); } - // @ts-ignore - win: BrowserWindow; + windows: NodyWindow[]; whenReady(): Promise { return new Promise((resolve) => { @@ -43,7 +52,7 @@ class Browser { init(): void { this.set_protocol(); - this.win = this.create_window(); + this.windows = this.create_windows(); this.load_theme(); this.init_listeners(); } @@ -76,69 +85,104 @@ class Browser { } load_theme(): void { - const theme = nody_greeter.config.greeter.theme; - const dir = nody_greeter.app.theme_dir; - let path_to_theme = path.join(dir, theme, "index.html"); - const def_theme = "gruvbox"; - - if (theme.startsWith("/")) path_to_theme = theme; - else if (theme.includes(".") || theme.includes("/")) - path_to_theme = path.join(process.cwd(), theme); - - if (!path_to_theme.endsWith(".html")) - path_to_theme = path.join(path_to_theme, "index.html"); - - if (!fs.existsSync(path_to_theme)) { - logger.warn( - `"${theme}" theme does not exists. Using "${def_theme}" theme` - ); - path_to_theme = path.join(dir, def_theme, "index.html"); - } - - nody_greeter.config.greeter.theme = path_to_theme; + const primary_html = load_primary_theme_path(); + const secondary_html = load_secondary_theme_path(); - //this.win.loadFile(path_to_theme); - const theme_url = url.format({ - pathname: path_to_theme, + const primary_url = url.format({ + pathname: primary_html, host: "app", hostname: "app", protocol: "web-greeter:", }); - //console.log({ theme_url, url: new URL(theme_url) }); - this.win.loadURL(`${theme_url}`); - this.win.setBackgroundColor("#000000"); - - this.win.webContents.on("before-input-event", (_event, input) => { - const value = nody_greeter.config.features.backlight.value; - if (input.type == "keyUp") return; - if (input.code == "BrightnessDown") { - Brightness.dec_brightness(value); - } else if (input.code == "BrightnessUp") { - Brightness.inc_brightness(value); - } + const secondary_url = url.format({ + pathname: secondary_html, + host: "app", + hostname: "app", + protocol: "web-greeter:", }); + //console.log({ primary_url, secondary_url }); + for (const w of this.windows) { + if (w.is_primary) w.window.loadURL(`${primary_url}`); + else w.window.loadURL(`${secondary_url}`); + w.window.setBackgroundColor("#000000"); + + w.window.webContents.on("before-input-event", (_event, input) => { + const value = nody_greeter.config.features.backlight.value; + if (input.type == "keyUp") return; + if (input.code == "BrightnessDown") { + Brightness.dec_brightness(value); + } else if (input.code == "BrightnessUp") { + Brightness.inc_brightness(value); + } + }); + } logger.debug("Theme loaded"); } - create_window(): BrowserWindow { + create_windows(): NodyWindow[] { logger.debug("Initializing Browser Window"); - const screen_size = screen.getPrimaryDisplay().workAreaSize; - - const win = new BrowserWindow({ - height: screen_size.height, - width: screen_size.width, - backgroundColor: "#000000", - frame: nody_greeter.app.frame, - show: false, - webPreferences: { - preload: path.join(__dirname, "preload.js"), - nodeIntegration: false, - contextIsolation: false, - allowRunningInsecureContent: !nody_greeter.config.greeter.secure_mode, // Should set option - devTools: nody_greeter.app.debug_mode, // Should set option - }, + const displays = screen.getAllDisplays(); + const primaryDisplay = screen.getPrimaryDisplay(); + + // Calculate the total display area + const overallBoundary: WindowMetadata["overallBoundary"] = { + minX: Infinity, + maxX: -Infinity, + minY: Infinity, + maxY: -Infinity, + }; + + for (const display of displays) { + overallBoundary.minX = Math.min(overallBoundary.minX, display.bounds.x); + overallBoundary.minY = Math.min(overallBoundary.minY, display.bounds.y); + overallBoundary.maxX = Math.max( + overallBoundary.maxX, + display.bounds.x + display.bounds.width + ); + overallBoundary.maxY = Math.max( + overallBoundary.maxY, + display.bounds.y + display.bounds.height + ); + } + + const windows: NodyWindow[] = displays.map((display) => { + const is_primary = display.id === primaryDisplay.id; + return { + is_primary, + display, + window: new BrowserWindow({ + height: display.workAreaSize.height, + width: display.workAreaSize.width, + x: display.bounds.x, + y: display.bounds.y, + backgroundColor: "#000000", + frame: nody_greeter.app.frame, + show: false, + webPreferences: { + preload: path.join(__dirname, "preload.js"), + nodeIntegration: false, + contextIsolation: false, + allowRunningInsecureContent: + !nody_greeter.config.greeter.secure_mode, // Should set option + devTools: nody_greeter.app.debug_mode, // Should set option + }, + }), + meta: { + id: display.id, + is_primary, + size: { + width: display.workAreaSize.width, + height: display.workAreaSize.height, + }, + position: { + x: display.bounds.x, + y: display.bounds.y, + }, + overallBoundary, + }, + }; }); logger.debug("Browser Window created"); @@ -157,61 +201,69 @@ class Browser { this.ready = true; - return win; + return windows; } init_listeners(): void { - this.win.once("ready-to-show", () => { - this.win.setFullScreen(nody_greeter.app.fullscreen); - this.win.show(); - this.win.focus(); - logger.debug("Nody Greeter started"); - }); - this.win.webContents.on("devtools-opened", () => { - this.win.webContents.devToolsWebContents.focus(); - }); + for (const w of this.windows) { + w.window.once("ready-to-show", () => { + w.window.setFullScreen(nody_greeter.app.fullscreen); + w.window.show(); + if (w.is_primary) { + w.window.focus(); + } + logger.debug("Nody Greeter started win: " + w.meta.id); + }); + w.window.webContents.on("devtools-opened", () => { + w.window.webContents.devToolsWebContents.focus(); + }); + + w.window.webContents.on("context-menu", (_ev, params) => { + if (!nody_greeter.app.debug_mode) return; + const position = { x: params.x, y: params.y }; + const menu_template: MenuItemConstructorOptions[] = [ + { role: "undo", enabled: params.editFlags.canUndo, accelerator: "U" }, + { role: "redo", enabled: params.editFlags.canRedo, accelerator: "R" }, + { type: "separator" }, + { role: "cut", enabled: params.editFlags.canCut, accelerator: "C" }, + { role: "copy", enabled: params.editFlags.canCopy, accelerator: "C" }, + { + role: "paste", + enabled: params.editFlags.canPaste, + accelerator: "P", + }, + { + role: "delete", + enabled: params.editFlags.canDelete, + accelerator: "D", + }, + { + role: "selectAll", + enabled: params.editFlags.canSelectAll, + accelerator: "S", + registerAccelerator: true, + }, + { type: "separator" }, + { role: "reload", accelerator: "R", registerAccelerator: false }, + { role: "forceReload", accelerator: "F", registerAccelerator: false }, + { role: "toggleDevTools", accelerator: "T" }, + { + label: "Inspect Element", + click: (): void => { + w.window.webContents.inspectElement(position.x, position.y); + }, + accelerator: "I", + }, + ]; + const menu = Menu.buildFromTemplate(menu_template); + menu.popup(); + }); + } app.on("quit", () => { reset_screensaver(); }); - this.win.webContents.on("context-menu", (_ev, params) => { - if (!nody_greeter.app.debug_mode) return; - const position = { x: params.x, y: params.y }; - const menu_template: MenuItemConstructorOptions[] = [ - { role: "undo", enabled: params.editFlags.canUndo, accelerator: "U" }, - { role: "redo", enabled: params.editFlags.canRedo, accelerator: "R" }, - { type: "separator" }, - { role: "cut", enabled: params.editFlags.canCut, accelerator: "C" }, - { role: "copy", enabled: params.editFlags.canCopy, accelerator: "C" }, - { role: "paste", enabled: params.editFlags.canPaste, accelerator: "P" }, - { - role: "delete", - enabled: params.editFlags.canDelete, - accelerator: "D", - }, - { - role: "selectAll", - enabled: params.editFlags.canSelectAll, - accelerator: "S", - registerAccelerator: true, - }, - { type: "separator" }, - { role: "reload", accelerator: "R", registerAccelerator: false }, - { role: "forceReload", accelerator: "F", registerAccelerator: false }, - { role: "toggleDevTools", accelerator: "T" }, - { - label: "Inspect Element", - click: (): void => { - this.win.webContents.inspectElement(position.x, position.y); - }, - accelerator: "I", - }, - ]; - const menu = Menu.buildFromTemplate(menu_template); - menu.popup(); - }); - session.defaultSession.webRequest.onBeforeRequest((details, callback) => { const url = new URL(details.url); //console.log({ origin: details.url, url }); @@ -224,6 +276,15 @@ class Browser { callback({ cancel: block }); }); } + + public get primary_window(): BrowserWindow { + for (const w of this.windows) { + if (w.is_primary) { + return w.window; + } + } + throw new Error("No primary window initialized"); + } } export { Browser }; diff --git a/ts/config.ts b/ts/config.ts index 4931399..a397f2f 100644 --- a/ts/config.ts +++ b/ts/config.ts @@ -1,32 +1,64 @@ -import * as yaml from "js-yaml"; +import { isRight } from "fp-ts/Either"; +import * as util from "util"; import * as fs from "fs"; +import * as io_ts from "io-ts"; +import * as yaml from "js-yaml"; +import * as path from "path"; import { logger } from "./logger"; -export interface web_greeter_config { - branding: { - background_images_dir: string; - logo_image: string; - user_image: string; - }; - greeter: { - debug_mode: boolean; - detect_theme_errors: boolean; - screensaver_timeout: number; - secure_mode: boolean; - theme: string; - icon_theme: string | undefined; - time_language: string | undefined; - }; - layouts: string[]; - features: { - battery: boolean; - backlight: { - enabled: boolean; - value: number; - steps: number; - }; - }; -} +export const WEB_GREETER_CONFIG = io_ts.type({ + branding: io_ts.type({ + background_images_dir: io_ts.string, + logo_image: io_ts.string, + user_image: io_ts.string, + }), + greeter: io_ts.type({ + debug_mode: io_ts.boolean, + detect_theme_errors: io_ts.boolean, + screensaver_timeout: io_ts.number, + secure_mode: io_ts.boolean, + theme: io_ts.string, + icon_theme: io_ts.union([io_ts.string, io_ts.null, io_ts.undefined]), + time_language: io_ts.union([io_ts.string, io_ts.null, io_ts.undefined]), + }), + layouts: io_ts.array(io_ts.string), + features: io_ts.type({ + battery: io_ts.boolean, + backlight: io_ts.type({ + enabled: io_ts.boolean, + value: io_ts.number, + steps: io_ts.number, + }), + }), +}); + +export const THEME_CONFIG = io_ts.intersection([ + io_ts.type({ + /** + * HTML file to use in main monitor + * @example primary_html: "index.html" + */ + primary_html: io_ts.string, + }), + io_ts.partial({ + /** + * HTML file to use in non-main (secondary) monitors + * If the file does not exists or it's not set, `primary_html` will be used + * @example secondary_html: "secondary.html" + * @example secondary_html: "" + */ + secondary_html: io_ts.string, + }), +]); + +/** + * web-greeter's config inside `/etc/lightdm/web-greeter.yml` + */ +export type web_greeter_config = io_ts.TypeOf; +/** + * Theme's config inside `$THEME/index.yml` + */ +export type theme_config = io_ts.TypeOf; export interface app_config { fullscreen: boolean; @@ -38,6 +70,7 @@ export interface app_config { export interface nody_config { config: web_greeter_config; app: app_config; + theme: theme_config; } export const nody_greeter: nody_config = { @@ -70,21 +103,153 @@ export const nody_greeter: nody_config = { fullscreen: true, frame: false, debug_mode: false, - theme_dir: "/usr/share/web-greeter/themes/", + theme_dir: + process.env.NODY_GREETER_THEME_DIR || "/usr/share/web-greeter/themes/", + }, + theme: { + primary_html: "index.html", + secondary_html: "", }, }; -const path_to_config = "/etc/lightdm/web-greeter.yml"; +const path_to_config = + process.env.NODY_GREETER_CONFIG || "/etc/lightdm/web-greeter.yml"; + +let theme_dir: string | undefined; + +export function load_theme_dir(): string { + const theme = nody_greeter.config.greeter.theme; + const dir = nody_greeter.app.theme_dir; + const def_theme = "gruvbox"; + theme_dir = path.join(dir, theme); + + if (theme.startsWith("/")) theme_dir = theme; + else if (theme.includes(".") || theme.includes("/")) + theme_dir = path.join(process.cwd(), theme); + + if (theme_dir.endsWith(".html")) theme_dir = path.dirname(theme_dir); + + if (!fs.existsSync(theme_dir)) { + logger.warn(`"${theme}" theme does not exists. Using "${def_theme}" theme`); + theme_dir = path.join(dir, def_theme); + } + + return theme_dir; +} + +export function load_primary_theme_path(): string { + if (!theme_dir) load_theme_dir(); + const dir = nody_greeter.app.theme_dir; + const def_theme = "gruvbox"; + const primary = nody_greeter.theme.primary_html; + let path_to_theme = path.join(theme_dir, primary); + + if (!path_to_theme.endsWith(".html")) + path_to_theme = path.join(path_to_theme, "index.html"); + + if (!fs.existsSync(path_to_theme)) { + logger.warn( + `"${path_to_theme}" theme does not exists. Using "${def_theme}" theme` + ); + path_to_theme = path.join(dir, def_theme, "index.html"); + } + + nody_greeter.config.greeter.theme = path_to_theme; + return path_to_theme; +} +export function load_secondary_theme_path(): string { + if (!theme_dir) load_theme_dir(); + const primary = nody_greeter.theme.primary_html; + const secondary = nody_greeter.theme.secondary_html; + let path_to_theme = path.join(theme_dir, secondary); + + if (!path_to_theme.endsWith(".html")) + path_to_theme = path.join(path_to_theme, "index.html"); + + if (!fs.existsSync(path_to_theme)) { + logger.warn( + `"${secondary}" does not exists. Using "${primary}" for secondary monitors` + ); + path_to_theme = load_primary_theme_path(); + } + + return path_to_theme; +} + +function validate_config( + decoder: io_ts.Type, + obj: unknown +): T { + const decoded = decoder.decode(obj); + if (isRight(decoded)) { + return decoded.right; + } else { + const errors = decoded.left; + let message = ""; + const fm_errors: { key: string; value: string; type: string }[] = []; + for (let i = 0; i < errors.length; i++) { + const context = errors[i].context; + const type = context[context.length - 1].type.name; + const key = context[context.length - 2].key; + const value = errors[i].value; + const can_color = process.stdout.isTTY && process.stdout.hasColors(); + const fm_value = util.inspect(value, { colors: can_color }); + + const ind = fm_errors.findIndex((e) => e.key === key); + if (ind == -1) { + fm_errors.push({ key, value: fm_value, type }); + } else { + fm_errors[ind].type += "|" + type; + } + } + for (const err of fm_errors) { + message += `{ ${err.key}: ${err.value} } couldn't be validated as (${err.type})\n`; + } + throw new Error(`Invalid config: ${message}`); + } +} + +export function load_theme_config(): void { + if (!theme_dir) load_theme_dir(); + const path_to_theme_config = path.join(theme_dir, "index.yml"); + try { + const file = fs.readFileSync(path_to_theme_config, "utf-8"); + const theme_config = yaml.load(file); + + nody_greeter.theme = validate_config(THEME_CONFIG, theme_config); + } catch (err) { + logger.warn(`Theme config was not loaded:\n\t${err}`); + logger.debug("Using default theme config"); + } +} + +export function ensure_theme(): void { + if (!theme_dir) load_theme_dir(); + + const primary = nody_greeter.theme.primary_html; + const dir = nody_greeter.app.theme_dir; + const def_theme = "gruvbox"; + + const primary_exists = fs.existsSync(path.join(theme_dir, primary)); + + if (!primary_exists) { + theme_dir = path.join(dir, def_theme); + load_theme_config(); + } +} export function load_config(): void { try { - if (!fs.existsSync(path_to_config)) - throw new Error("Config file not found"); const file = fs.readFileSync(path_to_config, "utf-8"); - nody_greeter.config = yaml.load(file) as web_greeter_config; + const webg_config = yaml.load(file); + + nody_greeter.config = validate_config(WEB_GREETER_CONFIG, webg_config); } catch (err) { logger.error(`Config was not loaded:\n\t${err}`); + logger.warn("Using default config"); } } load_config(); + +export {}; diff --git a/ts/consts.ts b/ts/consts.ts new file mode 100644 index 0000000..d8c6982 --- /dev/null +++ b/ts/consts.ts @@ -0,0 +1,12 @@ +/** + * Constant values shared across the application + * + * (used by both backend (node) Node frontend (browser-window) code) + */ +export const CONSTS = { + channel: { + lightdm_signal: "LightDMSignal", + window_metadata: "WindowMetadata", + window_broadcast: "WindowBroadcast", + }, +} as const; diff --git a/ts/globals.ts b/ts/globals.ts index 27dcd28..a3fb82a 100644 --- a/ts/globals.ts +++ b/ts/globals.ts @@ -1,4 +1,4 @@ -import { dialog } from "electron"; +import { BrowserWindow, dialog } from "electron"; import { Browser } from "./browser"; import { logger } from "./logger"; import { nody_greeter } from "./config"; @@ -12,39 +12,47 @@ browser.whenReady().then(() => { function initLogger(): void { logger.debug("Javascript logger is ready"); - browser.win.webContents.addListener( - "console-message", - (_ev, code, message, line, sourceID) => { - sourceID = sourceID == "" ? "console" : sourceID; - if (code == 3) { - logger.log({ - level: "error", - message: message, - line: line, - source: sourceID, - }); - error_prompt(message, sourceID, line); - } else if (code == 2) { - logger.log({ - level: "warn", - message: message, - line: line, - source: sourceID, - }); + for (const win of browser.windows) { + win.window.webContents.addListener( + "console-message", + (ev, code, message, line, sourceID) => { + sourceID = sourceID == "" ? "console" : sourceID; + if (code == 3) { + logger.log({ + level: "error", + message: message, + line: line, + source: sourceID, + }); + error_prompt(win.window, message, sourceID, line); + } else if (code == 2) { + logger.log({ + level: "warn", + message: message, + line: line, + source: sourceID, + }); + } } - } - ); + ); + } } /** * Prompts to change to default theme (gruvbox) on error + * @param {BrowserWindow} win The browser window originating the message * @param {string} message Message or error to show * @param {string} source Source of error * @param {number} line Line number where error was detected */ -function error_prompt(message: string, source: string, line: number): void { +function error_prompt( + win: BrowserWindow, + message: string, + source: string, + line: number +): void { if (!nody_greeter.config.greeter.detect_theme_errors) return; - const ind = dialog.showMessageBoxSync(browser.win, { + const ind = dialog.showMessageBoxSync(win, { message: "An error ocurred. Do you want to change to default theme? (gruvbox)", detail: `${source} ${line}: ${message}`, @@ -60,7 +68,9 @@ function error_prompt(message: string, source: string, line: number): void { browser.load_theme(); break; case 2: // Reload theme - browser.win.reload(); + for (const win of browser.windows) { + win.window.reload(); + } break; default: break; diff --git a/ts/index.ts b/ts/index.ts index 1774383..7700d34 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -2,7 +2,7 @@ import * as yargs from "yargs"; import * as fs from "fs"; import * as path from "path"; -import { nody_greeter } from "./config"; +import { ensure_theme, load_theme_config, nody_greeter } from "./config"; const res = yargs .scriptName("nody-greeter") @@ -61,6 +61,9 @@ if (nody_greeter.config.greeter.debug_mode == true) { // Import browser and bridge to initialize nody-greeter +load_theme_config(); +ensure_theme(); + import "./browser"; import "./bridge/bridge"; diff --git a/ts/preload.ts b/ts/preload.ts index 81f8c65..ddbc483 100644 --- a/ts/preload.ts +++ b/ts/preload.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from "electron"; +import { CONSTS } from "./consts"; import { LightDMBattery, LightDMLanguage, @@ -7,6 +8,108 @@ import { LightDMUser, } from "./ldm_interfaces"; +/** + * Metadata that is sent to each window to handle more interesting multi-monitor + * functionality / themes. + */ +export interface WindowMetadata { + id: number; + is_primary: boolean; + position: { + x: number; + y: number; + }; + size: { + width: number; + height: number; + }; + /** + * The total real-estate across all screens, + * this can be used to assist in, for example, + * correctly positioning multi-monitor backgrounds. + */ + overallBoundary: { + minX: number; + maxX: number; + minY: number; + maxY: number; + }; +} + +/** + * An event that is fired and dispatched when one browser window of a theme + * sends a broadcast to all windows (which happens for multi-monitor setups) + */ +export class NodyBroadcastEvent extends Event { + constructor( + /** Metadata for the window that originated the request */ + public readonly window: WindowMetadata, + /** Data sent in the broadcast */ + public readonly data: unknown + ) { + super("NodyBroadcastEvent"); + } +} + +/** + * A class that exposes functionality that is unique to `nody-greeter` and not + * present in `web-greeter` + */ +export class Nody { + private _window_metadata: WindowMetadata | null = null; + /** + * callback that should be called when the metadata is received + */ + private _ready: (() => void) | null = null; + private readonly _ready_promise: Promise; + + constructor() { + if ("nody_greeter" in globalThis) { + return globalThis.nody_greeter; + } + + globalThis.nody_greeter = this; + + ipcRenderer.on(CONSTS.channel.window_metadata, (_ev, metadata) => { + this._window_metadata = metadata; + this._ready(); + }); + + // Send initial request for metadata + ipcRenderer.send(CONSTS.channel.window_metadata); + + ipcRenderer.on(CONSTS.channel.window_broadcast, (_ev, metadata, data) => { + const event = new NodyBroadcastEvent(metadata, data); + window.dispatchEvent(event); + }); + + this._ready_promise = new Promise((resolve) => (this._ready = resolve)); + + return globalThis.nody_greeter; + } + + public get window_metadata(): WindowMetadata { + if (this._window_metadata) { + return this._window_metadata; + } + throw new Error( + `window_metadata not available, did you wait for the GreeterReady event?` + ); + } + + /** Resolves when we have received WindowMetadata */ + public whenReady = (): Promise => this._ready_promise; + + /** + * Send a message to all windows currently open for the greeter. + * + * This is primarily for themes that are runing in multi-monitor environments + */ + public broadcast(data: unknown): void { + ipcRenderer.send(CONSTS.channel.window_broadcast, data); + } +} + const allSignals = []; export class Signal { @@ -58,7 +161,7 @@ export class PromptSignal extends Signal { } } -ipcRenderer.on("LightDMSignal", (_ev, signal, ...args) => { +ipcRenderer.on(CONSTS.channel.lightdm_signal, (_ev, signal, ...args) => { allSignals.forEach((v) => { if (v._name == signal) { //console.log(args) @@ -799,18 +902,33 @@ export class ThemeUtils { } } +new Nody(); new ThemeUtils(); new GreeterConfig(); new Greeter(); window._ready_event = new Event("GreeterReady"); -window.addEventListener("DOMContentLoaded", () => { - setTimeout(() => { - window.dispatchEvent(globalThis._ready_event); - }, 2); +const domLoaded = new Promise((resolve) => { + window.addEventListener("DOMContentLoaded", () => { + setTimeout(() => { + resolve(); + }, 2); + }); }); +/** + * Promise that fires when all initialization has completed, + * and the theme can start (i.e. _ready_event can be sent) + */ +const readyPromise = Promise.all([ + domLoaded, + globalThis.nody_greeter.whenReady(), +]); + +readyPromise.then(() => window.dispatchEvent(globalThis._ready_event)); + +export declare const nody_greeter: Nody; export declare const lightdm: Greeter; export declare const greeter_config: GreeterConfig; export declare const theme_utils: ThemeUtils; @@ -818,6 +936,7 @@ export declare const _ready_event: Event; declare global { interface Window { + nody_greeter: Nody | undefined; lightdm: Greeter | undefined; greeter_config: GreeterConfig | undefined; theme_utils: ThemeUtils | undefined;