// Portions of this file are taken from GNOME Shell and adapted. // Because of that, this gnome extension is distributed under // the terms of the GNU General Public License, version 2. const { AccountsService, Atk, Clutter, Gio, GLib, Graphene, Meta, Shell, St, } = imports.gi; const Background = imports.ui.background; const Layout = imports.ui.layout; const Main = imports.ui.main; // half of the time for which a frame is displayed const HALF_FRAME_TIME_MS = 8; const BLUR_BRIGHTNESS = 0.55; const BLUR_SIGMA = 60; const POINTER_HIDE_TIMEOUT = 10 * GLib.USEC_PER_SEC; let actor; let lockDialog; let labelCountdown; let labelTitle; let labelMessage; let labelUser; const bgManagers = []; let backgroundGroup; let cursorTracker; let motionId = 0; let lastMotionTime = 0; let pointerHidden = false; let pointerHideId = 0; let user; let grab; let countdownTimeoutId = 0; let configFile; let configMonitor; let config; let startTime; let isExtensionEnabled = false; let isActive = false; let isShellReady = false; let isActiveChanging = false; function extLog (msg) { log(`[contest-lock] ${msg}`) } function extLogError (msg) { printerr(`[contest-lock] Error: ${msg}`); } function loadConfig () { configFile.load_contents_async(null, (obj, res) => { // If there is a poblem with the config file, log an error and keep // using the old config. let newConfig; try { const [ok, bytes] = configFile.load_contents_finish(res); // TextDecoder is used in upstream gnome-shell, but not yet // supported in current Debian. const contentStr = imports.byteArray.toString(bytes); //const contentStr = new TextDecoder().decode(bytes); newConfig = JSON.parse(contentStr); } catch (err) { logError(err, '[contest-lock] config file'); return; } if (!(typeof newConfig === 'object' && newConfig != null)) { extLogError('config file: invalid format'); return; } if (typeof newConfig.title !== 'string') { extLogError('config file: "title" must be a string'); return; } if (typeof newConfig.message !== 'string') { extLogError('config file: "message" must be a string'); return; } if ( typeof newConfig.startTime !== 'string' || !/^\d{4,}-\d\d-\d\dT\d\d:\d\d:\d\d\+\d\d:\d\d$/.test(newConfig.startTime) ) { extLogError('config file: "startTime" must be a string with format 0000-00-00T00:00:00+00:00'); return; } extLog('Loaded new config.') config = newConfig; startTime = (new Date(newConfig.startTime)).getTime(); updateConfig(); syncActive(); }); } function syncActive () { if (isActiveChanging) return; let beforeStart = false; if (startTime != null) { const now = new Date(); const timeToStart = startTime - now.getTime() - HALF_FRAME_TIME_MS; beforeStart = timeToStart > 0; } // ignore disable event when active if (beforeStart && isShellReady && (isExtensionEnabled || isActive)) { activate(); } else { deactivate(); } } function updateConfig () { if (labelTitle != null) { labelTitle.text = config.title; } if (labelMessage != null) { labelMessage.text = config.message; } } function updateUser () { if (labelUser != null) { const realName = user.get_real_name(); if (realName != null) labelUser.text = realName; } } function updateCountdown () { countdownTimeoutId = 0; const now = new Date(); const nowTime = now.getTime() + HALF_FRAME_TIME_MS; const timeToStart = startTime - nowTime; const beforeStart = timeToStart > 0; if (!beforeStart) { deactivate(); return GLib.SOURCE_REMOVE; } const allSecondsToStart = Math.floor(timeToStart / 1000); const secondsToStart = allSecondsToStart % 60 const allMinutesToStart = Math.floor(allSecondsToStart / 60); const minutesToStart = allMinutesToStart % 60; const hoursToStart = Math.floor(allMinutesToStart / 60); let hoursString = ''; if (hoursToStart !== 0) hoursString = `${hoursToStart}∶`; labelCountdown.text = hoursString + minutesToStart.toString().padStart(2, '0') + '∶' + secondsToStart.toString().padStart(2, '0'); const nextUpdateTime = 1000 - nowTime % 1000 countdownTimeoutId = GLib.timeout_add( GLib.PRIORITY_HIGH, nextUpdateTime, updateCountdown ); GLib.Source.set_name_by_id(countdownTimeoutId, '[contest-lock] updateCountdown'); return GLib.SOURCE_REMOVE; } function updateBackgrounds () { if (!isActive) return; while (bgManagers.length) bgManagers.pop().destroy(); backgroundGroup.destroy_all_children(); for (let monitorIndex = 0; monitorIndex < Main.layoutManager.monitors.length; monitorIndex++) { const monitor = Main.layoutManager.monitors[monitorIndex]; const widget = new St.Widget({ style_class: 'screen-shield-background', x: monitor.x, y: monitor.y, width: monitor.width, height: monitor.height, effect: new Shell.BlurEffect({ name: 'blur', brightness: BLUR_BRIGHTNESS, sigma: BLUR_SIGMA, }), }); const bgManager = new Background.BackgroundManager({ container: widget, monitorIndex, controlPosition: false, }); bgManagers.push(bgManager); backgroundGroup.add_child(widget); } } function pointerHideTimer () { if (pointerHideId !== 0) { GLib.source_remove(pointerHideId); pointerHideId = 0; } if (!isActive) return GLib.SOURCE_REMOVE; const timeToHide = lastMotionTime + POINTER_HIDE_TIMEOUT - GLib.get_monotonic_time(); if (timeToHide <= 0) { cursorTracker.set_pointer_visible(false); pointerHidden = true; return GLib.SOURCE_REMOVE; } pointerHideId = GLib.timeout_add( GLib.PRIORITY_HIGH, timeToHide / 1000 + 20, pointerHideTimer ); GLib.Source.set_name_by_id(pointerHideId, '[contest-lock] pointerHide'); return GLib.SOURCE_REMOVE; } function activate () { if (isActive) return; isActiveChanging = true; isActive = true; grab = Main.pushModal(Main.uiGroup, { actionMode: Shell.ActionMode.LOCK_SCREEN }); if (typeof grab === 'boolean') { // gnome 38 if (!grab) { grab = Main.pushModal(Main.uiGroup, { options: Meta.ModalOptions.POINTER_ALREADY_GRABBED, actionMode: Shell.ActionMode.LOCK_SCREEN }); } if (!grab) { extLogError('Failed to activate: Could not obtain keyboard grab.'); return; } grab = Main.uiGroup; } else if ((grab.get_seat_state() & Clutter.GrabState.KEYBOARD) === 0) { Main.popModal(grab); grab = null; extLogError('Failed to activate: Could not obtain keyboard grab.'); return; } actor.show(); Main.sessionMode.pushMode('unlock-dialog'); backgroundGroup = new Clutter.Actor(); motionId = global.stage.connect('captured-event', (stage, event) => { if (event.type() === Clutter.EventType.MOTION) { lastMotionTime = GLib.get_monotonic_time(); if (pointerHidden) { cursorTracker.set_pointer_visible(true); pointerHidden = false; pointerHideTimer(); } } return Clutter.EVENT_PROPAGATE; }); cursorTracker.set_pointer_visible(false); pointerHidden = true; labelCountdown = new St.Label({ style_class: 'contest-lock-countdown', x_align: Clutter.ActorAlign.CENTER, }); labelTitle = new St.Label({ style_class: 'contest-lock-title', x_align: Clutter.ActorAlign.CENTER, }); labelMessage = new St.Label({ style_class: 'contest-lock-message', x_align: Clutter.ActorAlign.CENTER, }); labelUser = new St.Label({ style_class: 'contest-lock-user', x_align: Clutter.ActorAlign.CENTER, }); const stack = new St.BoxLayout({ style_class: 'contest-lock-stack', vertical: true, x_expand: true, y_expand: true, x_align: Clutter.ActorAlign.CENTER, y_align: Clutter.ActorAlign.CENTER, }) stack.add_child(labelUser); stack.add_child(labelCountdown); stack.add_child(labelTitle); stack.add_child(labelMessage); const mainBox = new St.BoxLayout(); mainBox.add_constraint(new Layout.MonitorConstraint({ primary: true })); mainBox.add_child(stack); lockDialog = new St.Widget({ name: 'contestLockDialog', accessible_role: Atk.Role.WINDOW, visible: false, reactive: true, can_focus: true, x_expand: true, y_expand: true, pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), }); lockDialog.add_child(backgroundGroup); lockDialog.add_child(mainBox); updateConfig(); updateUser(); updateCountdown(); updateBackgrounds(); // countdown may have just expired before we called updateCountdown if (!isActive) return; actor.add_child(lockDialog); lockDialog.show(); extLog('Activated.') isActiveChanging = false; } function deactivate () { if (!isActive) return; isActiveChanging = true; isActive = false; if (Main.sessionMode.currentMode === 'unlock-dialog') { Main.sessionMode.popMode('unlock-dialog'); } Main.popModal(grab); grab = null; if (countdownTimeoutId !== 0) { GLib.source_remove(countdownTimeoutId); countdownTimeoutId = 0; } actor.hide(); labelCountdown = null; labelTitle = null; labelMessage = null; labelUser = null; while (bgManagers.length) bgManagers.pop().destroy(); lockDialog.destroy(); lockDialog = null; if (motionId) { global.stage.disconnect(motionId); motionId = 0; } cursorTracker.set_pointer_visible(true); extLog('Deactivated.'); isActiveChanging = false; } function init (extension) { actor = Main.layoutManager.screenShieldGroup; const userName = GLib.get_user_name(); user = AccountsService.UserManager.get_default().get_user(userName); if (!user) return; user.connect('notify::is-loaded', updateUser); user.connect('changed', updateUser); updateUser(); Main.layoutManager.connect('monitors-changed', updateBackgrounds); cursorTracker = Meta.CursorTracker.get_for_display(global.display); if (!Main.layoutManager._startingUp) { isShellReady = true; } else { Main.layoutManager.connect('startup-complete', () => { isShellReady = true; syncActive(); }); } // TODO: When we drop compatibility with gnome <42, remove this code, // rename the stylesheet back to stylesheet.css (so that it is loaded // by the extension system) and add a session-modes property which // includes unlock-dialog to metadata.json. const theme = St.ThemeContext.get_for_stage(global.stage).get_theme(); const stylesheetFile = extension.dir.get_child('stylesheet-always.css'); theme.load_stylesheet(stylesheetFile); // TODO: When we drop compatibility with gnome <42, remove this code. // gnome 38 has a bug that causes extensions to break when running // `dconf update` while the screen is locked. Main.extensionManager.reloadExtension = function () {}; configFile = Gio.File.new_for_path('/etc/contest-lock.json'); configMonitor = configFile.monitor_file(Gio.FileMonitorFlags.NONE, null); configMonitor.set_rate_limit(1000); configMonitor.connect('changed', loadConfig); loadConfig(); } function enable () { isExtensionEnabled = true; syncActive(); } function disable () { isExtensionEnabled = false; syncActive(); }