diff --git a/flow-client/src/main/frontend/Flow.ts b/flow-client/src/main/frontend/Flow.ts index 61cb614b28a..f90db11f526 100644 --- a/flow-client/src/main/frontend/Flow.ts +++ b/flow-client/src/main/frontend/Flow.ts @@ -6,6 +6,7 @@ import { } from '@vaadin/common-frontend'; import './Geolocation'; import { currentVisibility } from './PageVisibility'; +import './WakeLock'; export interface FlowConfig { imports?: () => Promise; diff --git a/flow-client/src/main/frontend/WakeLock.ts b/flow-client/src/main/frontend/WakeLock.ts new file mode 100644 index 00000000000..bcbd7897a80 --- /dev/null +++ b/flow-client/src/main/frontend/WakeLock.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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 + * + * http://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. + */ + +type VaadinWakeLockState = 'ACTIVE' | 'RELEASED'; + +// Whether the server-side has asked us to hold the lock. The browser releases +// the lock whenever the tab is hidden; this flag is what lets the +// visibilitychange handler re-acquire silently when the tab returns. +let wanted = false; +let sentinel: WakeLockSentinel | null = null; +let visibilityListenerInstalled = false; + +function dispatch(element: HTMLElement, state: VaadinWakeLockState): void { + element.dispatchEvent(new CustomEvent('vaadin-wake-lock-change', { detail: state })); +} + +async function acquire(element: HTMLElement): Promise { + if (sentinel) { + return; + } + if (!('wakeLock' in navigator)) { + return; + } + try { + const next = await (navigator as any).wakeLock.request('screen'); + // The user (or the browser) may have released the lock or the tab may have + // been hidden again while the request was in flight. + if (!wanted || document.visibilityState !== 'visible') { + try { + await next.release(); + } catch (_e) { + // Ignore; releasing an already-released sentinel throws on some + // browsers and there is nothing meaningful to do here. + } + return; + } + sentinel = next; + next.addEventListener('release', () => { + sentinel = null; + dispatch(element, 'RELEASED'); + }); + dispatch(element, 'ACTIVE'); + } catch (_e) { + // The browser refused (insecure context, feature unavailable, low battery, + // user revoked, …). The signal stays in 'RELEASED'; no need to surface + // the error — applications observe activeSignal() to know the truth. + } +} + +function installVisibilityListener(element: HTMLElement): void { + if (visibilityListenerInstalled) { + return; + } + visibilityListenerInstalled = true; + document.addEventListener('visibilitychange', () => { + if (wanted && !sentinel && document.visibilityState === 'visible') { + acquire(element); + } + }); +} + +const $wnd = window as any; +$wnd.Vaadin ??= {}; +$wnd.Vaadin.Flow ??= {}; +$wnd.Vaadin.Flow.wakeLock = { + request(element: HTMLElement): Promise { + wanted = true; + installVisibilityListener(element); + if (document.visibilityState !== 'visible') { + // The browser will not grant a lock while the page is hidden; the + // visibilitychange listener will pick it up on the next 'visible'. + return Promise.resolve(); + } + return acquire(element); + }, + + async release(element: HTMLElement): Promise { + wanted = false; + if (!sentinel) { + return; + } + const current = sentinel; + sentinel = null; + try { + await current.release(); + } catch (_e) { + // Ignore; the 'release' event listener installed in acquire() also + // dispatches RELEASED, so the state still reaches the server even when + // the explicit release() call rejects. + } + dispatch(element, 'RELEASED'); + } +}; + +// Empty export to ensure TypeScript emits this as an ES module, which is +// required for Vite to load it via import. +export {}; diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java b/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java index c9b6ac5c482..21fb1631390 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java @@ -59,6 +59,7 @@ public class Page implements Serializable { private final UI ui; private final History history; + private final WakeLock wakeLock; private DomListenerRegistration resizeReceiver; private ArrayList resizeListeners; private ValueSignal windowSizeSignal; @@ -76,6 +77,7 @@ public class Page implements Serializable { public Page(UI ui) { this.ui = ui; history = new History(ui); + wakeLock = new WakeLock(ui); ui.getElement() .addEventListener("vaadin-page-visibility-change", e -> setPageVisibility(e.getEventDetail(String.class))) @@ -392,6 +394,16 @@ public History getHistory() { return history; } + /** + * Returns the {@link WakeLock} facade for this page. Use it to keep the + * screen from dimming or locking while a view is active. + * + * @return the wake lock facade, never {@code null} + */ + public WakeLock getWakeLock() { + return wakeLock; + } + /** * Reloads the page in the browser. */ diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/WakeLock.java b/flow-server/src/main/java/com/vaadin/flow/component/page/WakeLock.java new file mode 100644 index 00000000000..fa81ebb1753 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/WakeLock.java @@ -0,0 +1,157 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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 + * + * http://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. + */ +package com.vaadin.flow.component.page; + +import java.io.Serializable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.signals.Signal; +import com.vaadin.flow.signals.local.ValueSignal; + +/** + * Browser Screen Wake Lock API for Flow applications. Keeps the screen from + * dimming or locking while the lock is held — useful for dashboards, kiosks, + * presentations, and recipe / workout screens. + *

+ * Reach the per-UI instance through {@link Page#getWakeLock()}. + * + *

+ * Example: + * + *

+ * WakeLock wakeLock = UI.getCurrent().getPage().getWakeLock();
+ * wakeLock.request();
+ *
+ * Signal.effect(this, () -> {
+ *     statusLabel.setText(
+ *             wakeLock.activeSignal().get() ? "Screen will stay on" : "Idle");
+ * });
+ * 
+ * + *

+ * Lifecycle. {@link #request()} fires the browser request asynchronously + * and {@link #activeSignal()} flips to {@code true} once the browser confirms. + * The browser releases the lock automatically when the tab is hidden; the + * client transparently re-acquires it when the tab becomes visible again, so a + * single {@link #request()} is enough for the lifetime of the view. Call + * {@link #release()} to stop re-acquiring and drop the current lock. + * + *

+ * Reliability caveats. + *

    + *
  • Safari only ships the Screen Wake Lock API from version 16.4.
  • + *
  • The browser requires a secure context (HTTPS or {@code localhost}); on an + * insecure origin the call fails silently and {@link #activeSignal()} stays + * {@code false}.
  • + *
  • Some browsers release the lock on low battery or when the device enters + * power-saving mode; {@link #activeSignal()} reflects this immediately.
  • + *
+ * + * @see Page#getWakeLock() + */ +public final class WakeLock implements Serializable { + + private static final Logger LOGGER = LoggerFactory + .getLogger(WakeLock.class); + + static final String STATE_CHANGE_EVENT = "vaadin-wake-lock-change"; + + private final UI ui; + private final ValueSignal activeSignal = new ValueSignal<>( + Boolean.FALSE); + private final Signal activeSignalReadOnly = activeSignal + .asReadonly(); + + WakeLock(UI ui) { + this.ui = ui; + ui.getElement() + .addEventListener(STATE_CHANGE_EVENT, + e -> setActive(e.getEventDetail(String.class))) + .addEventDetail().allowInert(); + } + + /** + * Asks the browser to acquire a screen wake lock and keep re-acquiring it + * across tab visibility changes. + *

+ * The call is asynchronous and fire-and-forget: by the time this method + * returns the browser has not necessarily granted the lock yet. Observe + * {@link #activeSignal()} to react to the actual state. Calling + * {@code request()} when a lock is already held is a no-op. + */ + public void request() { + ui.getElement().executeJs("window.Vaadin.Flow.wakeLock.request(this)") + .then(ignored -> { + }, err -> LOGGER + .debug("Client-side wakeLock.request failed: {}", err)); + } + + /** + * Releases the screen wake lock and stops re-acquiring it on subsequent + * visibility changes. Idempotent — calling it when no lock is held is a + * no-op. + */ + public void release() { + ui.getElement().executeJs("window.Vaadin.Flow.wakeLock.release(this)") + .then(ignored -> { + }, err -> LOGGER + .debug("Client-side wakeLock.release failed: {}", err)); + } + + /** + * Returns a read-only signal that reflects whether the browser is currently + * holding the wake lock on behalf of this UI. + *

+ * Starts as {@code false}, flips to {@code true} when the browser confirms + * a request, and flips back to {@code false} whenever the browser releases + * the lock — explicitly through {@link #release()}, automatically when the + * tab becomes hidden, or because the browser dropped it (power saving, low + * battery). When the tab is shown again the client re-requests the lock if + * {@link #release()} has not been called, so the signal flips back to + * {@code true} shortly after. + *

+ * Use {@code Signal.effect(owner, ...)} to react to changes and + * {@code .peek()} for a snapshot outside a reactive context. + * + * @return the read-only active-state signal + */ + public Signal activeSignal() { + return activeSignalReadOnly; + } + + /** + * Updates the signal from a raw state value reported by the client. Unknown + * values are logged at debug level so a forward-compatible client value + * does not silently disappear. + * + * @param value + * the raw value, or {@code null} + */ + void setActive(String value) { + if (value == null) { + return; + } + switch (value) { + case "ACTIVE" -> activeSignal.set(Boolean.TRUE); + case "RELEASED" -> activeSignal.set(Boolean.FALSE); + default -> + LOGGER.debug("Unknown wake lock state from client: {}", value); + } + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/component/page/WakeLockSignalTest.java b/flow-server/src/test/java/com/vaadin/flow/component/page/WakeLockSignalTest.java new file mode 100644 index 00000000000..2b1a9650e93 --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/page/WakeLockSignalTest.java @@ -0,0 +1,119 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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 + * + * http://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. + */ +package com.vaadin.flow.component.page; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.internal.PendingJavaScriptInvocation; +import com.vaadin.flow.dom.DomEvent; +import com.vaadin.flow.internal.JacksonUtils; +import com.vaadin.flow.internal.nodefeature.ElementListenerMap; +import com.vaadin.flow.signals.Signal; +import com.vaadin.flow.signals.local.ValueSignal; +import com.vaadin.tests.util.MockUI; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WakeLockSignalTest { + + @Test + void activeSignal_isReadOnly() { + WakeLock wakeLock = new MockUI().getPage().getWakeLock(); + Signal signal = wakeLock.activeSignal(); + assertFalse(signal instanceof ValueSignal, + "activeSignal() should return a read-only signal"); + } + + @Test + void activeSignal_defaultsToFalse() { + WakeLock wakeLock = new MockUI().getPage().getWakeLock(); + assertEquals(Boolean.FALSE, wakeLock.activeSignal().peek(), + "Before any client confirmation the lock is not held"); + } + + @Test + void activeSignal_readonlyWrapperIsCached() { + WakeLock wakeLock = new MockUI().getPage().getWakeLock(); + assertSame(wakeLock.activeSignal(), wakeLock.activeSignal(), + "Repeated calls must return the same read-only wrapper so " + + "subscriber identity stays stable"); + } + + @Test + void activeSignal_tracksStateChanges() { + MockUI ui = new MockUI(); + WakeLock wakeLock = ui.getPage().getWakeLock(); + + fireStateEvent(ui, "ACTIVE"); + assertEquals(Boolean.TRUE, wakeLock.activeSignal().peek()); + + fireStateEvent(ui, "RELEASED"); + assertEquals(Boolean.FALSE, wakeLock.activeSignal().peek()); + } + + @Test + void activeSignal_unknownDetailKeepsPreviousValue() { + MockUI ui = new MockUI(); + WakeLock wakeLock = ui.getPage().getWakeLock(); + + fireStateEvent(ui, "ACTIVE"); + fireStateEvent(ui, "SOMETHING_NEW"); + + assertEquals(Boolean.TRUE, wakeLock.activeSignal().peek(), + "Unknown detail values from a newer client should not reset " + + "the signal"); + } + + @Test + void request_executesClientCall() { + MockUI ui = new MockUI(); + ui.getPage().getWakeLock().request(); + + List invocations = ui + .dumpPendingJsInvocations(); + assertTrue(invocations.stream() + .anyMatch(i -> i.getInvocation().getExpression() + .contains("window.Vaadin.Flow.wakeLock.request(this)")), + "request() should invoke window.Vaadin.Flow.wakeLock.request"); + } + + @Test + void release_executesClientCall() { + MockUI ui = new MockUI(); + ui.getPage().getWakeLock().release(); + + List invocations = ui + .dumpPendingJsInvocations(); + assertTrue(invocations.stream() + .anyMatch(i -> i.getInvocation().getExpression() + .contains("window.Vaadin.Flow.wakeLock.release(this)")), + "release() should invoke window.Vaadin.Flow.wakeLock.release"); + } + + private void fireStateEvent(MockUI ui, String state) { + ObjectNode eventData = JacksonUtils.createObjectNode(); + eventData.put("event.detail", state); + ui.getElement().getNode().getFeature(ElementListenerMap.class) + .fireEvent(new DomEvent(ui.getElement(), + WakeLock.STATE_CHANGE_EVENT, eventData)); + } +}