From 9d827733c5753069f7e1d364ff3fc1bad74323ed Mon Sep 17 00:00:00 2001 From: nielm Date: Thu, 7 Mar 2024 17:13:12 +0100 Subject: [PATCH] Add popup to select which mode, and configurable timeout Keep backward compatible behavior, and add a context menu option to show the popup. --- .../power/keepAwake Advanced/README.md | 14 +- .../_locales/en/messages.json | 32 ++ .../power/keepAwake Advanced/background.js | 298 ++++++++++++++---- .../power/keepAwake Advanced/common.js | 75 +++++ .../power/keepAwake Advanced/manifest.json | 13 +- .../power/keepAwake Advanced/popup.css | 83 +++++ .../power/keepAwake Advanced/popup.html | 44 +++ api-samples/power/keepAwake Advanced/popup.js | 145 +++++++++ 8 files changed, 639 insertions(+), 65 deletions(-) create mode 100644 api-samples/power/keepAwake Advanced/common.js create mode 100644 api-samples/power/keepAwake Advanced/popup.css create mode 100644 api-samples/power/keepAwake Advanced/popup.html create mode 100644 api-samples/power/keepAwake Advanced/popup.js diff --git a/api-samples/power/keepAwake Advanced/README.md b/api-samples/power/keepAwake Advanced/README.md index 0ef26affe2..6448511b2f 100644 --- a/api-samples/power/keepAwake Advanced/README.md +++ b/api-samples/power/keepAwake Advanced/README.md @@ -4,10 +4,22 @@ This extension demonstrates the `chrome.power` API by allowing users to override ## Overview -The extension adds a popup that cycles different states when clicked. It will go though a mode that prevents the display from dimming or going to sleep, a mode that keeps the system awake but allows the screen to dim/go to sleep, and a mode that uses the system's default. +The extension adds an icon that allows the user to choose different power management states when clicked: + +- System Default +- Screen stays awake +- System stays awake, but screen can sleep + +There is also a context menu popup where the user can also optionally specify an automatic timeout for the chosen state. ## Running this extension +Either install it from the Chrome Web Store: + +- [Keep Awake Extension](https://chrome.google.com/webstore/detail/keep-awake/bijihlabcfdnabacffofojgmehjdielb) + +Or load it as an upacked extension: + 1. Clone this repository. 2. Load this directory in Chrome as an [unpacked extension](https://developer.chrome.com/docs/extensions/mv3/getstarted/development-basics/#load-unpacked). 3. Pin the extension and click the action button. diff --git a/api-samples/power/keepAwake Advanced/_locales/en/messages.json b/api-samples/power/keepAwake Advanced/_locales/en/messages.json index ef50944de1..af8dabb29e 100644 --- a/api-samples/power/keepAwake Advanced/_locales/en/messages.json +++ b/api-samples/power/keepAwake Advanced/_locales/en/messages.json @@ -18,5 +18,37 @@ "systemTitle": { "message": "System will stay awake", "description": "Browser action title when preventing system sleep." + }, + "untilText": { + "message": " until: ", + "description": "Suffix to append to above Titles to append an end time" + }, + "autoDisableText": { + "message": "Automatically disable after:", + "description": "Text labelling a slider allowing setting a timeout for disabling the power saving state." + }, + "autoDisableHoursSuffix": { + "message": "h", + "description": "Text to append after a number indicating a quantity of hours" + }, + "disabledLabel": { + "message": "Disabled", + "description": "Button label to indicated keep awake is disabled." + }, + "displayLabel": { + "message": "Screen on", + "description": "Button label to indicated keep awake is preventing screen-off." + }, + "systemLabel": { + "message": "System on", + "description": "Button label to indicated keep awake is preventing system sleep." + }, + "usePopupMenuTitle": { + "message": "Always show State Popup", + "description": "Checkbox item indicating that the popup menu should always be shown." + }, + "openStateWindowMenuTitle": { + "message": "Change State...", + "description": "Menu item opening a popup window to change the state." } } diff --git a/api-samples/power/keepAwake Advanced/background.js b/api-samples/power/keepAwake Advanced/background.js index 59dd9b62a5..75e244d074 100644 --- a/api-samples/power/keepAwake Advanced/background.js +++ b/api-samples/power/keepAwake Advanced/background.js @@ -1,47 +1,45 @@ -// Copyright (c) 2013 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. +// @ts-check -/** - * States that the extension can be in. - */ -let StateEnum = { - DISABLED: 'disabled', - DISPLAY: 'display', - SYSTEM: 'system' -}; +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. -/** - * Key used for storing the current state in {localStorage}. - */ -let STATE_KEY = 'state'; +import { StateEnum, getSavedMode, verifyMode } from './common.js'; +/** @typedef {import('./common.js').KeepAwakeMode} KeepAwakeMode */ + +const ALARM_NAME = 'keepAwakeTimeout'; +const HOUR_TO_MILLIS = 60 * 60 * 1000; +const USE_POPUP_DEFAULT = { usePopup: true }; /** - * Loads the locally-saved state asynchronously. - * @param {function} callback Callback invoked with the loaded {StateEnum}. + * Simple timestamped log function + * @param {string} msg + * @param {...*} args */ -function loadSavedState(callback) { - chrome.storage.local.get(STATE_KEY, function (items) { - let savedState = items[STATE_KEY]; - for (let key in StateEnum) { - if (savedState == StateEnum[key]) { - callback(savedState); - return; - } - } - callback(StateEnum.DISABLED); - }); +function log(msg, ...args) { + console.log(new Date().toLocaleTimeString('short') + ' ' + msg, ...args); } /** - * Switches to a new state. - * @param {string} newState New {StateEnum} to use. + * Set keep awake mode, and update icon. + * + * @param {KeepAwakeMode} mode */ -function setState(newState) { - let imagePrefix = 'night'; - let title = ''; +function updateState(mode) { + let imagePrefix; + let title; - switch (newState) { + switch (mode.state) { case StateEnum.DISABLED: chrome.power.releaseKeepAwake(); imagePrefix = 'night'; @@ -58,42 +56,224 @@ function setState(newState) { title = chrome.i18n.getMessage('systemTitle'); break; default: - throw 'Invalid state "' + newState + '"'; + throw 'Invalid state "' + mode.state + '"'; } - let items = {}; - items[STATE_KEY] = newState; - chrome.storage.local.set(items); - chrome.action.setIcon({ path: { 19: 'images/' + imagePrefix + '-19.png', 38: 'images/' + imagePrefix + '-38.png' } }); - chrome.action.setTitle({ title: title }); + + if (mode.endMillis && mode.state != StateEnum.DISABLED) { + // a timeout is specified, update the badge and the title text + let hoursLeft = Math.ceil((mode.endMillis - Date.now()) / HOUR_TO_MILLIS); + chrome.action.setBadgeText({ text: `${hoursLeft}h` }); + const endDate = new Date(mode.endMillis); + chrome.action.setTitle({ + title: `${title}${chrome.i18n.getMessage('untilText')} ${endDate.toLocaleTimeString('short')}` + }); + log( + `mode = ${mode.state} for the next ${hoursLeft}hrs until ${endDate.toLocaleTimeString('short')}` + ); + } else { + // No timeout. + chrome.action.setBadgeText({ text: '' }); + chrome.action.setTitle({ title: title }); + log(`mode = ${mode.state}`); + } } -chrome.action.onClicked.addListener(function () { - loadSavedState(function (state) { - switch (state) { - case StateEnum.DISABLED: - setState(StateEnum.DISPLAY); - break; - case StateEnum.DISPLAY: - setState(StateEnum.SYSTEM); - break; - case StateEnum.SYSTEM: - setState(StateEnum.DISABLED); - break; - default: - throw 'Invalid state "' + state + '"'; - } +/** + * Apply a new KeepAwake mode. + * + * @param {KeepAwakeMode} newMode + */ +async function setNewMode(newMode) { + // Clear any old alarms + await chrome.alarms.clearAll(); + + // is a timeout required? + if (newMode.defaultDurationHrs && newMode.state !== StateEnum.DISABLED) { + // Set an alarm every 60 mins. + chrome.alarms.create(ALARM_NAME, { + delayInMinutes: 60, + periodInMinutes: 60 + }); + newMode.endMillis = + Date.now() + newMode.defaultDurationHrs * HOUR_TO_MILLIS; + } else { + newMode.endMillis = null; + } + + // Store the new mode. + chrome.storage.local.set(newMode); + updateState(newMode); +} + +/** + * Check to see if any set timeout has expired, and if so, reset the mode. + */ +async function checkTimeoutAndUpdateDisplay() { + const mode = await getSavedMode(); + if (mode.endMillis && mode.endMillis < Date.now()) { + log(`timer expired`); + // reset state to disabled + mode.state = StateEnum.DISABLED; + mode.endMillis = null; + setNewMode(mode); + } else { + updateState(mode); + } +} + +async function recreateAlarms() { + const mode = await getSavedMode(); + await chrome.alarms.clearAll(); + if ( + mode.state !== StateEnum.DISABLED && + mode.endMillis && + mode.endMillis > Date.now() + ) { + // previous timeout has not yet expired... + // restart alarm to be triggered at the next 1hr of the timeout + const remainingMillis = mode.endMillis - Date.now(); + const millisToNextHour = remainingMillis % HOUR_TO_MILLIS; + + log( + `recreating alarm, next = ${new Date(Date.now() + millisToNextHour).toLocaleTimeString()}` + ); + chrome.alarms.create(ALARM_NAME, { + delayInMinutes: millisToNextHour / 60_000, + periodInMinutes: 60 + }); + } +} + +/** + * Creates the context menu buttons on the action icon. + */ +async function reCreateContextMenus() { + chrome.contextMenus.removeAll(); + + chrome.contextMenus.create({ + type: 'normal', + id: 'openStateMenu', + title: chrome.i18n.getMessage('openStateWindowMenuTitle'), + contexts: ['action'] + }); + chrome.contextMenus.create({ + type: 'checkbox', + checked: USE_POPUP_DEFAULT.usePopup, + id: 'usePopupMenu', + title: chrome.i18n.getMessage('usePopupMenuTitle'), + contexts: ['action'] }); + + updateUsePopupMenu( + (await chrome.storage.sync.get(USE_POPUP_DEFAULT)).usePopup + ); +} + +/** + * Sets whether or not to use the popup menu when clicking on the action icon. + * + * @param {boolean} usePopup + */ +function updateUsePopupMenu(usePopup) { + chrome.contextMenus.update('usePopupMenu', { checked: usePopup }); + if (usePopup) { + chrome.action.setPopup({ popup: 'popup.html' }); + } else { + chrome.action.setPopup({ popup: '' }); + } +} + +// Handle messages received from the popup. +chrome.runtime.onMessage.addListener(function (request, _, sendResponse) { + log( + `Got message from popup: state: %s, duration: %d`, + request.state, + request.duration + ); + sendResponse({}); + + setNewMode( + verifyMode({ + state: request.state, + defaultDurationHrs: request.duration, + endMillis: null + }) + ); }); -chrome.runtime.onStartup.addListener(function () { - loadSavedState(function (state) { - setState(state); - }); +// Handle action clicks - rotates the mode to the next mode. +chrome.action.onClicked.addListener(async () => { + log(`Action clicked`); + + const mode = await getSavedMode(); + switch (mode.state) { + case StateEnum.DISABLED: + mode.state = StateEnum.DISPLAY; + break; + case StateEnum.DISPLAY: + mode.state = StateEnum.SYSTEM; + break; + case StateEnum.SYSTEM: + mode.state = StateEnum.DISABLED; + break; + default: + throw 'Invalid state "' + mode.state + '"'; + } + setNewMode(mode); }); + +// Handle context menu clicks +chrome.contextMenus.onClicked.addListener(async (e) => { + switch (e.menuItemId) { + case 'openStateMenu': + chrome.windows.create({ + focused: true, + height: 220, + width: 240, + type: 'popup', + url: './popup.html' + }); + break; + + case 'usePopupMenu': + // e.checked is new state, after being clicked. + chrome.storage.sync.set({ usePopup: !!e.checked }); + updateUsePopupMenu(!!e.checked); + break; + } +}); + +// Whenever the alarm is triggered check the timeout and update the icon. +chrome.alarms.onAlarm.addListener(() => { + log('alarm!'); + checkTimeoutAndUpdateDisplay(); +}); + +chrome.runtime.onStartup.addListener(async () => { + log('onStartup'); + recreateAlarms(); + reCreateContextMenus(); +}); + +chrome.runtime.onInstalled.addListener(async () => { + log('onInstalled'); + recreateAlarms(); + reCreateContextMenus(); +}); + +chrome.storage.sync.onChanged.addListener((changes) => { + if (changes.usePopup != null) { + log('usePopup changed to %s', changes.usePopup.newValue); + updateUsePopupMenu(!!changes.usePopup.newValue); + } +}); + +// Whenever the service worker starts up, check the timeout and update the state +checkTimeoutAndUpdateDisplay(); diff --git a/api-samples/power/keepAwake Advanced/common.js b/api-samples/power/keepAwake Advanced/common.js new file mode 100644 index 0000000000..4a82904c78 --- /dev/null +++ b/api-samples/power/keepAwake Advanced/common.js @@ -0,0 +1,75 @@ +// @ts-check + +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export const DURATION_FOR_EVER = 13; + +/** + * States that the extension can be in. + * @readonly + * @enum {string} + */ +export const StateEnum = { + DISABLED: 'disabled', + DISPLAY: 'display', + SYSTEM: 'system' +}; + +/** + * @typedef {{ + * state: StateEnum; + * defaultDurationHrs: number?; + * endMillis: number | null | undefined; + * }} KeepAwakeMode + */ + +/** + * Key used for storing the current state in {localStorage}. + * @type {KeepAwakeMode} + */ +const DEFAULT_MODE = { + state: StateEnum.DISABLED, + defaultDurationHrs: null, // no timeout. + endMillis: null +}; + +/** + * Gets the saved Keep Awake mode from local storage + * @return {Promise} + */ +export async function getSavedMode() { + let mode = await chrome.storage.local.get(DEFAULT_MODE); + return verifyMode(mode); +} + +/** + * Validates the values of the keepawake mode + * + * @param {*} mode + * @return {KeepAwakeMode} + */ +export function verifyMode(mode) { + if (!Object.values(StateEnum).includes(mode.state)) { + mode.state = DEFAULT_MODE.state; + } + mode.defaultDurationHrs = Number(mode.defaultDurationHrs); + if ( + mode.defaultDurationHrs < 1 || + mode.defaultDurationHrs >= DURATION_FOR_EVER + ) { + mode.defaultDurationHrs = null; // no timeout. + } + return mode; +} diff --git a/api-samples/power/keepAwake Advanced/manifest.json b/api-samples/power/keepAwake Advanced/manifest.json index 00b569af95..7cd7010958 100644 --- a/api-samples/power/keepAwake Advanced/manifest.json +++ b/api-samples/power/keepAwake Advanced/manifest.json @@ -1,26 +1,29 @@ { + "update_url": "https://clients2.google.com/service/update2/crx", + "manifest_version": 3, "name": "__MSG_extensionName__", "description": "__MSG_extensionDescription__", - "version": "1.9", + "version": "2.0", "icons": { "16": "images/icon-16.png", "48": "images/icon-48.png", "128": "images/icon-128.png" }, - "permissions": ["power", "storage"], + "permissions": ["power", "storage", "alarms", "contextMenus"], "action": { "default_title": "__MSG_disabledTitle__", "default_icon": { "19": "images/night-19.png", "38": "images/night-38.png" - } + }, + "default_popup": "popup.html" }, "background": { - "service_worker": "background.js" + "service_worker": "background.js", + "type": "module" }, - "default_locale": "en" } diff --git a/api-samples/power/keepAwake Advanced/popup.css b/api-samples/power/keepAwake Advanced/popup.css new file mode 100644 index 0000000000..cbccdd566d --- /dev/null +++ b/api-samples/power/keepAwake Advanced/popup.css @@ -0,0 +1,83 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:root { + --background-color: #e8eef7; + --border-color: #00b4fe; + --main-color: #000000; + --button-background-color: #d5deec; + --button-active-background-color: #c0ccde; +} + +@media (prefers-color-scheme: dark) { + body { + --background-color: #333333; + --main-color: #dddddd; + --button-background-color: #444444; + --button-active-background-color: #555555; + --border-color: #005b80; + } +} + +body { + font: 13px arial, sans-serif; + margin: 0px; + background-color: var(--background-color); + color: var(--main-color); +} + +#popup { + padding: 12px; + border: 3px solid var(--border-color); + margin: auto; + width: fit-content; + text-align: left; + text-wrap: nowrap; +} + +h3 { + margin: 0px; +} + +div { + margin-top:5px; +} + +div#buttons { + display:flex; + gap: 6px; +} + +div#buttons button { + flex: 1 0 0; + min-width: 0; + background-color: var(--button-background-color); + color: var(--main-color); + border: 0; + border-radius: 0.5em; + box-shadow: 3px 3px 2px rgba(0,0,0,0.5); + padding: 5px; +} +div#buttons button:active, div#buttons button.active { + top:2px; + left:1px; + box-shadow: inset 3px 3px 2px rgba(0,0,0,0.5); + background-color: var(--button-active-background-color); +} + +.buttonLabel { + font-size: 11px; +} diff --git a/api-samples/power/keepAwake Advanced/popup.html b/api-samples/power/keepAwake Advanced/popup.html new file mode 100644 index 0000000000..8c1aef91d1 --- /dev/null +++ b/api-samples/power/keepAwake Advanced/popup.html @@ -0,0 +1,44 @@ + + + + Keep Awake + + + + + + + + diff --git a/api-samples/power/keepAwake Advanced/popup.js b/api-samples/power/keepAwake Advanced/popup.js new file mode 100644 index 0000000000..dfb87501a3 --- /dev/null +++ b/api-samples/power/keepAwake Advanced/popup.js @@ -0,0 +1,145 @@ +// @ts-check + +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { DURATION_FOR_EVER, getSavedMode, StateEnum } from './common.js'; + +/** @type {HTMLLabelElement} */ +let durationLabel; +/** @type {HTMLInputElement} */ +let durationSlider; + +/** + * Get an HTML element by selector and asserts not null. + * @param {string} query + * @return {HTMLElement} + */ +function querySelectorAndAssert(query) { + const e = document.querySelector(query); + if (!e) { + throw new Error('unable to get element with query: ' + query); + } + return /** @type {HTMLElement} */ (e); +} + +/** + * Updates the label for the duration slider. + */ +function updateDurationLabel() { + durationLabel.textContent = + (durationSlider.value === DURATION_FOR_EVER.toString() + ? '∞' + : durationSlider.value) + + ' ' + + chrome.i18n.getMessage('autoDisableHoursSuffix'); +} + +/** + * Called when the duration slider is changed + * + * Sends a message to the background service worker to set the new state + * and duration. + */ +async function durationSliderChanged() { + updateDurationLabel(); + + await sendMessageToBackground( + (await getSavedMode()).state, + Number(durationSlider.value) + ); +} + +/** + * Sends a message to the background service worker to set the new state and + * duration. + * @param {string} state + * @param {number} duration + */ +async function sendMessageToBackground(state, duration) { + if (!Object.values(StateEnum).includes(state)) { + throw new Error('invalid State: ' + state); + } + + const message = { state: state }; + if (duration < 1 || duration >= DURATION_FOR_EVER) { + // no timeout + message.duration = null; + } else { + message.duration = duration; + } + + chrome.runtime.sendMessage(message); +} + +/** + * Called when one of the keepalive buttons is clicked. + * + * Sends a message to the background service worker to set the new state + * and duration. + * + * @param {MouseEvent} e + */ +async function buttonClicked(e) { + const button = /** @type {HTMLElement} */ (e.currentTarget); + await sendMessageToBackground( + // Button id is named after state. + button.id, + Number(durationSlider.value) + ); + // Re-set active button state. + document.querySelector(`#buttons .active`)?.classList?.remove('active'); + button.classList.add('active'); +} + +/** + * Run when document is loaded. + * + * Initializes the popup, and sets I18n labels. + */ +async function onload() { + durationSlider = /** @type {HTMLInputElement} */ ( + querySelectorAndAssert('#durationSlider') + ); + durationLabel = /** @type {HTMLLabelElement} */ ( + querySelectorAndAssert('#durationLabel') + ); + + querySelectorAndAssert('#title').textContent = + chrome.i18n.getMessage('extensionName'); + querySelectorAndAssert('#autodisable-text').title = + chrome.i18n.getMessage('autoDisableText'); + + // set button titles and listeners + for (const id of Object.values(StateEnum)) { + const button = querySelectorAndAssert(`#buttons #${id}`); + button.addEventListener('click', buttonClicked); + button.title = chrome.i18n.getMessage(button.id + 'Title'); + querySelectorAndAssert(`#buttons #${id} .buttonLabel`).textContent = + chrome.i18n.getMessage(button.id + 'Label'); + } + + // set active button state. Assumes buttons have same IDs as state names. + const mode = await getSavedMode(); + querySelectorAndAssert(`#buttons #${mode.state}`).classList?.add('active'); + + durationSlider.max = DURATION_FOR_EVER.toString(); + durationSlider.value = ( + mode.defaultDurationHrs ? mode.defaultDurationHrs : DURATION_FOR_EVER + ).toString(); + updateDurationLabel(); + durationSlider.addEventListener('input', durationSliderChanged); +} + +document.addEventListener('DOMContentLoaded', onload);