diff --git a/CONTEXT.md b/CONTEXT.md
index 0e4a54d..b7c0ae1 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -31,6 +31,9 @@ backgrounds and music, all on top of the webOS Luna service bus.
`off`.
- **Weather panel**: the home-screen card or inline weather line (`weather.ts`).
The inline line also appears in the screensaver clock block.
+- **Network status**: the Wi-Fi/Ethernet and internet chip in the top bar, left of
+ the Settings gear (`network-status.ts`). Link type comes from the connection
+ manager; internet reachability comes from a two-host probe.
- **Settings panel**: the category list and pane UI (`settings.ts`).
- **Screensaver**: two different things. The system screensaver is LG's gallery
saver (`screensaverMinutes`). The in-app screensaver is Ember's own
diff --git a/docs/agents/architecture.md b/docs/agents/architecture.md
index d7f28fa..5506c66 100644
--- a/docs/agents/architecture.md
+++ b/docs/agents/architecture.md
@@ -78,8 +78,8 @@ an object with methods plus the config accessor `applyConfig()`. The composition
root calls those methods. Modules never call each other except through injected
callbacks. Examples include `BackgroundController`, `MusicPlayer`, `AppGrid`,
`FocusManager`, `SettingsPanelHandle`, `CustomScreensaver`,
-`WeatherPanelHandle`, `InputRowHandle`, `ChannelStripHandle`, `HomePaint` and
-`AllApps`.
+`WeatherPanelHandle`, `NetworkStatusHandle`, `InputRowHandle`,
+`ChannelStripHandle`, `HomePaint` and `AllApps`.
Cross-cutting callbacks are passed in, such as `onToast`, `focusControl` and
`onBeforeLaunch`. If a module needs to do something the host owns, add a
diff --git a/docs/agents/layout.md b/docs/agents/layout.md
index 6468b53..bdcae15 100644
--- a/docs/agents/layout.md
+++ b/docs/agents/layout.md
@@ -29,7 +29,7 @@ grouped by domain:
| `music/` | `@music` | The ambient music player and its built-in track manifest. |
| `settings/` | `@settings` | The Settings panel (the composition root in `settings.ts`), the panel's key navigation and wheel scrolling (`navigation.ts`), backup/restore, field bindings, the text-field gate (`text-gate.ts`) and the pane seam (`pane.ts`). UI components live in `settings/components/`, category panes in `settings/panes/`. |
| `focus/` | `@focus` | Focus manager and the row model it moves over. |
-| `home/` | `@home` | Home-screen rows and panels: TV inputs, channels, weather, and the home paint pass. |
+| `home/` | `@home` | Home-screen rows and panels: TV inputs, channels, weather, the network status chip, and the home paint pass. |
| `config/` | `@config` | The config tree: load, save, normalize, and profiles. |
| `luna/` | `@luna` | The webOS Luna service transport (`luna-transport.ts`), the service calls (`luna.ts`), the privileged root script (`root.ts`) and platform/device helpers. |
| `lib/` | `@lib` | Shared vocabulary and utilities: `types.ts`, `compat.ts`, `version.ts`, `usb.ts`, `remote.ts`, and other cross-cutting helpers. |
diff --git a/src/index.html b/src/index.html
index 1244ccc..d951079 100644
--- a/src/index.html
+++ b/src/index.html
@@ -23,6 +23,7 @@
+
diff --git a/src/styles/main.css b/src/styles/main.css
index 504741c..35af570 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -413,6 +413,19 @@ body.all-apps-open #background-layer.gradient-animated {
0 0 30px rgba(var(--focus-halo-rgb), 0.3);
}
+/* Network status: quiet white when fine, amber with no uplink, red with no link. */
+.network-status[hidden] {
+ display: none;
+}
+
+.network-status[data-state="no-internet"] {
+ color: #f2c078;
+}
+
+.network-status[data-state="offline"] {
+ color: #ff6b6b;
+}
+
.clock-stack {
position: absolute;
top: 36px;
@@ -473,6 +486,11 @@ body.clock-right .clock-stack {
max-width: calc(100% - 280px);
}
+/* The network chip widens the top-right cluster: keep the clock clear of it. */
+body.has-network-status.clock-right .clock-stack {
+ right: 248px;
+}
+
.clock {
font-weight: 500;
letter-spacing: 0.01em;
@@ -694,6 +712,11 @@ body.clock-left .weather-panel {
right: 160px;
}
+/* The network chip widens the top-right cluster: keep the forecast clear of it. */
+body.has-network-status.clock-left .weather-panel {
+ right: 248px;
+}
+
.weather-glyph {
display: block;
flex: 0 0 auto;
diff --git a/src/ts/config/config.ts b/src/ts/config/config.ts
index 54872f6..8388c5c 100644
--- a/src/ts/config/config.ts
+++ b/src/ts/config/config.ts
@@ -10,7 +10,7 @@ const STORAGE_KEY = BRAND.storage.config;
// means, its type or its name? Bump this and add an entry to CONFIG_MIGRATIONS
// keyed by the new version: stored settings and restored backups from older
// versions both run through those steps (see migrateConfig / configFromBackup).
-export const CONFIG_SCHEMA_VERSION = 28;
+export const CONFIG_SCHEMA_VERSION = 29;
export const DEFAULT_CONFIG: Config = {
version: CONFIG_SCHEMA_VERSION,
@@ -85,6 +85,8 @@ export const DEFAULT_CONFIG: Config = {
channels: 'favourites',
showClock: true,
showDate: true,
+ // Wi-Fi/Ethernet and internet chip next to the Settings gear.
+ showNetworkStatus: true,
// '24' (18:30) or '12' (6:30 PM), home screen and screensaver.
clockFormat: '24',
// Glass colour of the app tiles, inputs and Settings button: light (the
@@ -481,6 +483,13 @@ export const CONFIG_MIGRATIONS: Record = {
if (typeof config.launcher.tvSettingsTile !== 'boolean') {
config.launcher.tvSettingsTile = true;
}
+ },
+
+ // v29: the home-screen network status chip.
+ 29: function addNetworkStatus(config) {
+ if (typeof config.launcher.showNetworkStatus !== 'boolean') {
+ config.launcher.showNetworkStatus = true;
+ }
}
};
diff --git a/src/ts/focus/focus.ts b/src/ts/focus/focus.ts
index c5db2a5..d948dc5 100644
--- a/src/ts/focus/focus.ts
+++ b/src/ts/focus/focus.ts
@@ -661,7 +661,10 @@ export function createFocusManager(
const primary = isHorizontal ? Math.abs(dx) : Math.abs(dy);
const secondary = isHorizontal ? Math.abs(dy) : Math.abs(dx);
// Prefer top-chrome when moving up so we hit Settings over a distant input.
- const chromeBias = (keyCode === REMOTE_KEY.UP && isTopChrome(item)) ? -5000 : 0;
+ // The network chip shares that row; keep UP on Settings (Left reaches the chip).
+ const chromeBias = (keyCode === REMOTE_KEY.UP && isTopChrome(item))
+ ? (item.id === 'app-settings-btn' ? -6000 : -5000)
+ : 0;
const score = primary * 100 + secondary + chromeBias;
if (score < bestScore) {
diff --git a/src/ts/home/network-status.ts b/src/ts/home/network-status.ts
new file mode 100644
index 0000000..0780580
--- /dev/null
+++ b/src/ts/home/network-status.ts
@@ -0,0 +1,333 @@
+/**
+ * The home-screen Network status chip: the TV's link type plus whether the
+ * internet is actually reachable.
+ *
+ * The link comes from the connection manager's getStatus subscription. The
+ * internet answer is a probe, not LG's `isInternetConnectionAvailable` flag:
+ * that flag stays true when the router has no uplink. Two hosts are tried
+ * (Cloudflare, then the weather host) so a regional block of one host does not
+ * look like an outage, and one failed probe is retried after ten seconds
+ * before the chip turns amber. Probing pauses while another app or the in-app
+ * screensaver is in front.
+ *
+ * If the connection manager refuses the subscription, the chip degrades to a
+ * plain internet globe instead of guessing a link type.
+ */
+
+import {t} from '@lib/i18n.js';
+import {lunaSubscribe} from '@luna/luna-transport.js';
+import type {TranslationKey} from '@lib/i18n.js';
+import type {Config, LauncherConfig, WebOSServiceHandle} from '@lib/types.js';
+
+/** What the chip paints. `unknown` means there is nothing honest to show yet. */
+export type NetworkState =
+ | 'wifi'
+ | 'ethernet'
+ | 'no-internet'
+ | 'offline'
+ | 'internet'
+ | 'unknown';
+
+export interface NetworkStatusOptions {
+ getConfig: () => Config;
+ /** False while another app or the in-app screensaver is in front. */
+ isActive: () => boolean;
+ onOpenNetworkSettings: () => void;
+}
+
+export interface NetworkStatusHandle {
+ start(): void;
+ stop(): void;
+ applyConfig(): void;
+ refresh(): void;
+}
+
+type LinkState = 'wifi' | 'ethernet' | 'offline';
+
+const CONNECTION_SERVICES = [
+ 'luna://com.palm.connectionmanager',
+ 'luna://com.webos.service.connectionmanager'
+];
+const PROBE_URLS = [
+ 'https://cloudflare.com/cdn-cgi/trace',
+ 'https://api.open-meteo.com/v1/forecast'
+];
+const PROBE_TIMEOUT_MS = 8000;
+const RETRY_MS = 10000;
+const CHECK_MS = 60000;
+
+const LABELS: Record, TranslationKey> = {
+ wifi: 'network.wifi',
+ ethernet: 'network.ethernet',
+ internet: 'network.internet',
+ 'no-internet': 'network.noInternet',
+ offline: 'network.offline'
+};
+
+const GLYPHS: Record, string> = {
+ // Wi-Fi arcs.
+ wifi: ' ' +
+ ' ' +
+ ' ' +
+ ' ',
+ // An RJ45 plug.
+ ethernet: ' ' +
+ ' ' +
+ ' ' +
+ ' ',
+ // A globe, for the degraded internet-only reading.
+ internet: ' ' +
+ ' ' +
+ ' ',
+ // A globe with an exclamation: link up, uplink missing.
+ 'no-internet': ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ',
+ // A globe with a slash: no link at all.
+ offline: ' ' +
+ ' ' +
+ ' ' +
+ ' '
+};
+
+function withTimeout(promise: Promise, ms: number): Promise {
+ return new Promise(function (resolve, reject) {
+ const timer: ReturnType = setTimeout(function () {
+ reject(new Error('Network probe timed out'));
+ }, ms);
+ promise.then(function (value) {
+ clearTimeout(timer);
+ resolve(value);
+ }, function (err) {
+ clearTimeout(timer);
+ reject(err);
+ });
+ });
+}
+
+/** True when the request reached something; a no-cors reply counts. */
+function probeHost(url: string): Promise {
+ return withTimeout(fetch(url, {mode: 'no-cors', cache: 'no-store'}), PROBE_TIMEOUT_MS)
+ .then(function () { return true; }, function () { return false; });
+}
+
+/** The two-host internet probe; resolves false only when both hosts fail. */
+function probeInternet(): Promise {
+ return probeHost(PROBE_URLS[0]).then(function (ok) {
+ return ok ? true : probeHost(PROBE_URLS[1]);
+ });
+}
+
+export function createNetworkStatus(
+ button: HTMLElement | null,
+ options: NetworkStatusOptions
+): NetworkStatusHandle {
+ if (!button) {
+ return {start: function () {}, stop: function () {}, applyConfig: function () {}, refresh: function () {}};
+ }
+ const el = button;
+
+ let started = false;
+ let enabled = true;
+ let link: LinkState | null = null;
+ let refused = false;
+ let internet: boolean | null = null;
+ let probing = false;
+ let checkTimer: ReturnType | null = null;
+ let retryTimer: ReturnType | null = null;
+ let handle: WebOSServiceHandle | null = null;
+ let generation = 0;
+ let painted = '';
+
+ function launcherConfig(): LauncherConfig {
+ return options.getConfig().launcher || ({} as LauncherConfig);
+ }
+
+ function paint(): void {
+ const state = stateNow();
+ if (state === 'unknown') {
+ el.hidden = true;
+ return;
+ }
+ el.hidden = !enabled;
+ if (!enabled) return;
+ if (state !== painted) {
+ el.dataset.state = state;
+ el.innerHTML = '' +
+ GLYPHS[state] + ' ';
+ painted = state;
+ }
+ el.setAttribute('aria-label', t(LABELS[state]));
+ }
+
+ function stateNow(): NetworkState {
+ if (refused) {
+ if (internet === null) return 'unknown';
+ return internet ? 'internet' : 'no-internet';
+ }
+ if (link === null) return 'unknown';
+ if (link === 'offline') return 'offline';
+ if (internet === false) return 'no-internet';
+ return link;
+ }
+
+ function clearRetry(): void {
+ if (retryTimer) clearTimeout(retryTimer);
+ retryTimer = null;
+ }
+
+ function clearCheck(): void {
+ if (checkTimer) clearInterval(checkTimer);
+ checkTimer = null;
+ }
+
+ function cancelHandle(): void {
+ if (handle && typeof handle.cancel === 'function') {
+ try { handle.cancel(); } catch (err) { /* ignore */ }
+ }
+ handle = null;
+ }
+
+ function runProbe(confirming: boolean): void {
+ if (!started || !enabled || !options.isActive() || probing) return;
+ if (!confirming) clearRetry();
+ probing = true;
+ probeInternet().then(function (ok) {
+ probing = false;
+ if (!started || !enabled) return;
+ if (ok) {
+ clearRetry();
+ internet = true;
+ paint();
+ return;
+ }
+ if (confirming || internet === false) {
+ internet = false;
+ paint();
+ return;
+ }
+ // One failure is a hiccup until a second probe says otherwise.
+ retryTimer = setTimeout(function () {
+ retryTimer = null;
+ runProbe(true);
+ }, RETRY_MS);
+ });
+ }
+
+ function parseLink(res: Record | null | undefined): LinkState | null {
+ if (!res) return null;
+ const wired = res.wired as {state?: unknown} | undefined;
+ const wifi = res.wifi as {state?: unknown} | undefined;
+ if (wired && wired.state === 'connected') return 'ethernet';
+ if (wifi && wifi.state === 'connected') return 'wifi';
+ if (wired || wifi) return 'offline';
+ return null;
+ }
+
+ function subscribeAt(index: number): void {
+ if (!started || !enabled) return;
+ if (index >= CONNECTION_SERVICES.length) {
+ refused = true;
+ paint();
+ return;
+ }
+ const gen = ++generation;
+ let moved = false;
+
+ function next(): void {
+ if (moved || gen !== generation) return;
+ moved = true;
+ cancelHandle();
+ subscribeAt(index + 1);
+ }
+
+ let opened: WebOSServiceHandle | null = null;
+ try {
+ opened = lunaSubscribe(CONNECTION_SERVICES[index], {
+ method: 'getStatus',
+ parameters: {subscribe: true},
+ onSuccess: function (res) {
+ if (gen !== generation) return;
+ if (res && res.returnValue === false) {
+ next();
+ return;
+ }
+ refused = false;
+ const reading = parseLink(res);
+ if (reading === null) return;
+ const wasConnected = link === 'wifi' || link === 'ethernet';
+ link = reading;
+ if (!wasConnected && reading !== 'offline') runProbe(false);
+ paint();
+ },
+ onFailure: function () {
+ if (gen === generation) next();
+ }
+ });
+ } catch (err) {
+ opened = null;
+ }
+ // A synchronous failure already moved on to the next service; bin the
+ // handle this call handed back.
+ if (gen !== generation) {
+ if (opened && typeof opened.cancel === 'function') {
+ try { opened.cancel(); } catch (err) { /* ignore */ }
+ }
+ return;
+ }
+ if (!opened) next();
+ else handle = opened;
+ }
+
+ function applyConfig(): void {
+ enabled = launcherConfig().showNetworkStatus !== false;
+ if (document.body) document.body.classList.toggle('has-network-status', enabled);
+ if (!started || !enabled) {
+ clearCheck();
+ clearRetry();
+ paint();
+ return;
+ }
+ if (!handle && !refused) subscribeAt(0);
+ if (!checkTimer) {
+ checkTimer = setInterval(function () {
+ if (enabled && options.isActive()) runProbe(false);
+ }, CHECK_MS);
+ }
+ runProbe(false);
+ paint();
+ }
+
+ el.addEventListener('click', function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ options.onOpenNetworkSettings();
+ });
+
+ return {
+ start: function () {
+ if (started) return;
+ started = true;
+ applyConfig();
+ },
+ stop: function () {
+ started = false;
+ clearCheck();
+ clearRetry();
+ probing = false;
+ generation += 1;
+ cancelHandle();
+ link = null;
+ internet = null;
+ refused = false;
+ painted = '';
+ if (document.body) document.body.classList.remove('has-network-status');
+ },
+ applyConfig: applyConfig,
+ refresh: applyConfig
+ };
+}
diff --git a/src/ts/lib/i18n-de.ts b/src/ts/lib/i18n-de.ts
index 98d4640..8e81aac 100644
--- a/src/ts/lib/i18n-de.ts
+++ b/src/ts/lib/i18n-de.ts
@@ -106,6 +106,13 @@ export const de: Record = {
'look.clock.format.24': '24 Stunden (18:30)',
'look.clock.format.12': '12 Stunden (6:30 PM)',
'look.clock.showDate': 'Datum anzeigen',
+ 'look.network.show': 'Netzwerkstatus',
+ 'look.network.hint': 'Zeigt WLAN oder Ethernet und ob das Internet erreichbar ist, neben der Einstellungen-Schaltfläche.',
+ 'network.wifi': 'WLAN verbunden',
+ 'network.ethernet': 'Ethernet verbunden',
+ 'network.internet': 'Internet verbunden',
+ 'network.noInternet': 'Keine Internetverbindung',
+ 'network.offline': 'Keine Netzwerkverbindung',
'look.clock.position': 'Uhrposition',
'look.clock.position.left': 'Oben links',
'look.clock.position.center': 'Oben mittig',
@@ -416,6 +423,7 @@ export const de: Record = {
'markup.weather': 'Wetter',
'markup.settings': 'Einstellungen',
+ 'markup.network': 'Netzwerkstatus',
'markup.channels': 'TV-Sender',
'markup.inputs': 'Eingänge',
'markup.apps': 'Apps',
diff --git a/src/ts/lib/i18n-en.ts b/src/ts/lib/i18n-en.ts
index f705479..5b9221b 100644
--- a/src/ts/lib/i18n-en.ts
+++ b/src/ts/lib/i18n-en.ts
@@ -117,6 +117,14 @@ export const en = {
'look.clock.format.24': '24-hour (18:30)',
'look.clock.format.12': '12-hour (6:30 PM)',
'look.clock.showDate': 'Show date',
+ 'look.network.show': 'Network status',
+ 'look.network.hint': 'Shows Wi-Fi or Ethernet and whether the internet is reachable, next to the Settings button.',
+ // Network status chip (home/network-status.ts).
+ 'network.wifi': 'Wi-Fi connected',
+ 'network.ethernet': 'Ethernet connected',
+ 'network.internet': 'Internet connected',
+ 'network.noInternet': 'No internet connection',
+ 'network.offline': 'No network connection',
'look.clock.position': 'Clock position',
'look.clock.position.left': 'Top left',
'look.clock.position.center': 'Centre top',
@@ -444,6 +452,7 @@ export const en = {
// ── The page markup (index.html) ──────────────────────────────────────────
'markup.weather': 'Weather',
'markup.settings': 'Settings',
+ 'markup.network': 'Network status',
'markup.channels': 'TV channels',
'markup.inputs': 'Inputs',
'markup.apps': 'Apps',
diff --git a/src/ts/lib/i18n-fr.ts b/src/ts/lib/i18n-fr.ts
index d872817..4093af8 100644
--- a/src/ts/lib/i18n-fr.ts
+++ b/src/ts/lib/i18n-fr.ts
@@ -106,6 +106,13 @@ export const fr: Record = {
'look.clock.format.24': '24 heures (18:30)',
'look.clock.format.12': '12 heures (6:30 PM)',
'look.clock.showDate': 'Afficher la date',
+ 'look.network.show': 'État du réseau',
+ 'look.network.hint': 'Affiche le Wi‑Fi ou Ethernet et si Internet est accessible, à côté du bouton Réglages.',
+ 'network.wifi': 'Wi‑Fi connecté',
+ 'network.ethernet': 'Ethernet connecté',
+ 'network.internet': 'Internet connecté',
+ 'network.noInternet': 'Pas de connexion Internet',
+ 'network.offline': 'Pas de connexion réseau',
'look.clock.position': 'Position de l’horloge',
'look.clock.position.left': 'En haut à gauche',
'look.clock.position.center': 'En haut au centre',
@@ -416,6 +423,7 @@ export const fr: Record = {
'markup.weather': 'Météo',
'markup.settings': 'Réglages',
+ 'markup.network': 'État du réseau',
'markup.channels': 'Chaînes TV',
'markup.inputs': 'Entrées',
'markup.apps': 'Applis',
diff --git a/src/ts/lib/i18n-nl.ts b/src/ts/lib/i18n-nl.ts
index 4c63ad7..6371632 100644
--- a/src/ts/lib/i18n-nl.ts
+++ b/src/ts/lib/i18n-nl.ts
@@ -106,6 +106,13 @@ export const nl: Record = {
'look.clock.format.24': '24-uurs (18:30)',
'look.clock.format.12': '12-uurs (6:30 p.m.)',
'look.clock.showDate': 'Datum tonen',
+ 'look.network.show': 'Netwerkstatus',
+ 'look.network.hint': 'Toont wifi of ethernet en of het internet bereikbaar is, naast de instellingenknop.',
+ 'network.wifi': 'Wifi verbonden',
+ 'network.ethernet': 'Ethernet verbonden',
+ 'network.internet': 'Internet verbonden',
+ 'network.noInternet': 'Geen internetverbinding',
+ 'network.offline': 'Geen netwerkverbinding',
'look.clock.position': 'Klokpositie',
'look.clock.position.left': 'Linksboven',
'look.clock.position.center': 'Midden boven',
@@ -413,6 +420,7 @@ export const nl: Record = {
'markup.weather': 'Weer',
'markup.settings': 'Instellingen',
+ 'markup.network': 'Netwerkstatus',
'markup.channels': 'TV-zenders',
'markup.inputs': 'Ingangen',
'markup.apps': 'Apps',
diff --git a/src/ts/lib/types.ts b/src/ts/lib/types.ts
index 543fb79..cf9b4bb 100644
--- a/src/ts/lib/types.ts
+++ b/src/ts/lib/types.ts
@@ -97,6 +97,8 @@ export interface LauncherConfig {
channels: string;
showClock: boolean;
showDate: boolean;
+ /** Show the Wi-Fi/Ethernet and internet chip next to the Settings gear. */
+ showNetworkStatus: boolean;
clockFormat: string;
glassTint: string;
glassBorder: string;
diff --git a/src/ts/luna/luna.ts b/src/ts/luna/luna.ts
index f656ef4..b6107c4 100644
--- a/src/ts/luna/luna.ts
+++ b/src/ts/luna/luna.ts
@@ -27,10 +27,10 @@ function tryLunaRequest(uri: string, options: LunaRequestOptions) {
});
}
-export function launchApp(id: string) {
+export function launchApp(id: string, params?: Record) {
return lunaRequest('luna://com.webos.applicationManager', {
method: 'launch',
- parameters: {id: id, params: {}}
+ parameters: {id: id, params: params || {}}
}, TIMEOUTS.launch);
}
diff --git a/src/ts/main.ts b/src/ts/main.ts
index 4516d43..b468408 100644
--- a/src/ts/main.ts
+++ b/src/ts/main.ts
@@ -22,6 +22,8 @@ import {createCustomScreensaver} from '@background/screensaver.js';
import type {CustomScreensaver} from '@background/screensaver.js';
import {createWeatherPanel} from '@home/weather.js';
import type {WeatherPanelHandle} from '@home/weather.js';
+import {createNetworkStatus} from '@home/network-status.js';
+import type {NetworkStatusHandle} from '@home/network-status.js';
import {createHomePaint} from '@home/paint.js';
import type {HomePaint} from '@home/paint.js';
import {
@@ -67,6 +69,7 @@ interface HomeElements {
clockDate: HTMLElement | null;
clockWeather: HTMLElement | null;
appSettingsBtn: HTMLElement | null;
+ networkStatusBtn: HTMLElement | null;
inputRow: HTMLElement | null;
channelStrip: HTMLElement | null;
launcher: Element | null;
@@ -116,7 +119,7 @@ function whenIdle(fn: () => void) {
}
}
-function launchSystemApp(id: string) {
+function launchSystemApp(id: string, params?: Record) {
return new Promise(function (resolve, reject) {
let settled = false;
let failures = 0;
@@ -141,7 +144,7 @@ function launchSystemApp(id: string) {
launchAppViaRoot(id).then(succeed, failOne);
}
rootTimer = setTimeout(startRoot, SYSTEM_APP_ROOT_AFTER_MS);
- launchApp(id).then(succeed, function () {
+ launchApp(id, params).then(succeed, function () {
clearTimeout(rootTimer);
failOne();
startRoot();
@@ -216,6 +219,7 @@ export function createApp(): AppLifecycle {
clockDate: document.getElementById('clock-date'),
clockWeather: document.getElementById('clock-weather'),
appSettingsBtn: document.getElementById('app-settings-btn'),
+ networkStatusBtn: document.getElementById('network-status'),
inputRow: document.getElementById('input-row'),
channelStrip: document.getElementById('channel-strip'),
launcher: document.querySelector('.launcher'),
@@ -237,6 +241,7 @@ export function createApp(): AppLifecycle {
// an unsupported TV never constructs them (some subscribe to the Luna bus on
// construction). The handles are assigned before any callback can run.
let weather!: WeatherPanelHandle;
+ let networkStatus!: NetworkStatusHandle;
let background!: BackgroundController;
let music!: MusicPlayer;
let focus!: FocusManager;
@@ -279,6 +284,7 @@ export function createApp(): AppLifecycle {
function applyMarkupLabels() {
if (elements.weatherPanel) elements.weatherPanel.setAttribute('aria-label', t('markup.weather'));
if (elements.appSettingsBtn) elements.appSettingsBtn.setAttribute('aria-label', t('markup.settings'));
+ if (elements.networkStatusBtn) elements.networkStatusBtn.setAttribute('aria-label', t('markup.network'));
if (elements.channelStrip) elements.channelStrip.setAttribute('aria-label', t('markup.channels'));
if (elements.inputRow) elements.inputRow.setAttribute('aria-label', t('markup.inputs'));
if (elements.appGrid) elements.appGrid.setAttribute('aria-label', t('markup.apps'));
@@ -379,6 +385,15 @@ export function createApp(): AppLifecycle {
isVisible: function () { return visible; },
inlineEls: [elements.clockWeather, elements.screensaverWeather]
});
+ networkStatus = createNetworkStatus(elements.networkStatusBtn, {
+ getConfig: getConfig,
+ isActive: function () {
+ return visible &&
+ !customScreensaver.isActive() &&
+ !document.body.classList.contains('app-inactive');
+ },
+ onOpenNetworkSettings: openNetworkSettings
+ });
background = createBackgroundController(elements, getConfig);
music = createMusicPlayer(getConfig, Object.assign({}, elements, {
onToast: showToast,
@@ -675,7 +690,7 @@ export function createApp(): AppLifecycle {
]);
}
- async function openTvSettings() {
+ async function openTvSettings(target?: string): Promise {
// TV Settings opens as a panel over Ember, which stays on screen
// underneath: pause its animations now rather than waiting for a blur event
// (older webOS may not send one). Any key back in Ember clears it.
@@ -689,13 +704,21 @@ export function createApp(): AppLifecycle {
const ids = getAppIdCandidates('com.palm.app.settings');
for (let i = 0; i < ids.length; i += 1) {
try {
- await launchSystemApp(ids[i]);
- return;
+ await launchSystemApp(ids[i], target ? {target: target} : undefined);
+ return true;
} catch (err) {
// Try the next candidate id.
}
}
+ // A deep-link attempt that the TV rejects falls back to the plain launch.
+ if (target) return false;
showToast(t('apps.tvSettingsFailed'));
+ return false;
+ }
+
+ async function openNetworkSettings() {
+ // The TV may ignore the target page; the plain launch is the fallback.
+ if (!(await openTvSettings('network'))) await openTvSettings();
}
async function applyUsbOverrides() {
@@ -723,6 +746,7 @@ export function createApp(): AppLifecycle {
async function refreshAll(opts?: {resume?: boolean}) {
const resume = !!(opts && opts.resume);
paint.apply();
+ networkStatus.applyConfig();
music.applyConfig();
// Paints the cached forecast at once; the network fetch (if stale) is not awaited.
weather.refresh();
@@ -1014,6 +1038,7 @@ export function createApp(): AppLifecycle {
await applyUsbOverrides();
if (stopped) return;
paint.start();
+ networkStatus.start();
applyHomeVolume();
applyScreensaverSetting();
@@ -1141,6 +1166,7 @@ export function createApp(): AppLifecycle {
pendingTimers.length = 0;
foregroundWatcher.stop();
paint.stop();
+ networkStatus.stop();
if (settings.isVisible()) settings.hide();
weather.stop();
customScreensaver.stop();
diff --git a/src/ts/settings/panes/look.ts b/src/ts/settings/panes/look.ts
index 831a4e8..bafd36a 100644
--- a/src/ts/settings/panes/look.ts
+++ b/src/ts/settings/panes/look.ts
@@ -217,6 +217,15 @@ export function createLookPane(options: LookPaneOptions = {}): PaneHandle {
});
clockSection.appendChild(showDateField.row);
+ const networkField = createToggle({
+ label: t('look.network.show'),
+ focusIndex: '1097',
+ path: 'launcher.showNetworkStatus',
+ binder: ctx.binder
+ });
+ clockSection.appendChild(networkField.row);
+ clockSection.appendChild(createHint(t('look.network.hint')));
+
const clockAlignSelect = createOptionStepper({
focusIndex: 1002,
choices: [
diff --git a/test/__snapshots__/settings-focus-index.test.ts.snap b/test/__snapshots__/settings-focus-index.test.ts.snap
index f165605..b283dd7 100644
--- a/test/__snapshots__/settings-focus-index.test.ts.snap
+++ b/test/__snapshots__/settings-focus-index.test.ts.snap
@@ -69,6 +69,7 @@ exports[`settings panel focus-index allocation > keeps each pane's focus-index m
"1093": 1,
"1094": 1,
"1096": 1,
+ "1097": 1,
"901": 1,
"902": 1,
"903": 1,
diff --git a/test/config.test.ts b/test/config.test.ts
index 4c2f4ee..0184a92 100644
--- a/test/config.test.ts
+++ b/test/config.test.ts
@@ -64,6 +64,18 @@ describe('config migration registry', () => {
expect(hidden.launcher.tvSettingsTile).toBe(false);
});
+ it('backfills the network status chip without overruling an explicit choice (v29)', () => {
+ const missing = configAt(28);
+ delete (missing.launcher as Partial).showNetworkStatus;
+ migrateConfig(missing);
+ expect(missing.launcher.showNetworkStatus).toBe(true);
+
+ const hidden = configAt(28);
+ hidden.launcher.showNetworkStatus = false;
+ migrateConfig(hidden);
+ expect(hidden.launcher.showNetworkStatus).toBe(false);
+ });
+
it('collapses the old weather and saver toggles into the new settings (v26)', () => {
const off = configAt(25);
delete (off.weather as Partial).style;
@@ -180,6 +192,16 @@ describe('first-run import', () => {
expect(result?.version).toBe(CONFIG_SCHEMA_VERSION);
});
+ it('fills the network status field in a backup that predates it', () => {
+ const config = configAt(28);
+ delete (config.launcher as Partial).showNetworkStatus;
+
+ const fit = configFromBackup(config, DEFAULT_CONFIG);
+
+ expect(fit?.older).toBe(true);
+ expect(fit?.config.launcher.showNetworkStatus).toBe(true);
+ });
+
it('does not import when Ember already has a stored config', async () => {
const result = await importLegacySettings('{"version":' + CONFIG_SCHEMA_VERSION + '}', DEFAULT_CONFIG,
async () => [{where: 'TV', text: legacyBackupText({}, '2024-06-01T00:00:00Z')}]);
diff --git a/test/home/network-status.test.ts b/test/home/network-status.test.ts
new file mode 100644
index 0000000..dec76fc
--- /dev/null
+++ b/test/home/network-status.test.ts
@@ -0,0 +1,298 @@
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {createNetworkStatus} from '@home/network-status.js';
+import {setLanguage} from '@lib/i18n.js';
+import {setLunaAdapter} from '@luna/luna-transport.js';
+import type {LunaAdapter} from '@luna/luna-transport.js';
+import type {Config, WebOSServiceRequestOptions} from '@lib/types.js';
+
+interface Sent {
+ uri: string;
+ options: WebOSServiceRequestOptions;
+}
+
+function fakeBus(handler: (uri: string, options: WebOSServiceRequestOptions) => void): {
+ adapter: LunaAdapter;
+ sent: Sent[];
+ cancel: ReturnType;
+} {
+ const sent: Sent[] = [];
+ const cancel = vi.fn();
+ return {
+ sent: sent,
+ cancel: cancel,
+ adapter: {
+ request: function (uri, options) {
+ sent.push({uri: uri, options: options});
+ handler(uri, options);
+ return {cancel: cancel};
+ }
+ }
+ };
+}
+
+function config(show: boolean): Config {
+ return {launcher: {showNetworkStatus: show}} as unknown as Config;
+}
+
+function wifiHandler(_uri: string, options: WebOSServiceRequestOptions): void {
+ options.onSuccess!({
+ returnValue: true,
+ wifi: {state: 'connected'},
+ wired: {state: 'disconnected'}
+ });
+}
+
+async function flush(): Promise {
+ await Promise.resolve();
+ await Promise.resolve();
+ await vi.advanceTimersByTimeAsync(0);
+}
+
+function setup(handler: (uri: string, options: WebOSServiceRequestOptions) => void, show = true) {
+ const button = document.createElement('button');
+ document.body.appendChild(button);
+ const bus = fakeBus(handler);
+ setLunaAdapter(bus.adapter);
+ const state = {enabled: show, active: true};
+ const onClick = vi.fn();
+ const handle = createNetworkStatus(button, {
+ getConfig: function () { return config(state.enabled); },
+ isActive: function () { return state.active; },
+ onOpenNetworkSettings: onClick
+ });
+ return {button: button, bus: bus, handle: handle, onClick: onClick, state: state};
+}
+
+beforeEach(() => {
+ document.body.innerHTML = '';
+ setLanguage('en');
+});
+
+afterEach(() => {
+ setLunaAdapter(null);
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+describe('network status chip', () => {
+ it('paints Wi-Fi when the link is up and the probe resolves', async () => {
+ vi.useFakeTimers();
+ vi.stubGlobal('fetch', vi.fn(async () => ({})));
+ const chip = setup(wifiHandler);
+
+ chip.handle.start();
+ await flush();
+
+ expect(chip.button.hidden).toBe(false);
+ expect(chip.button.dataset.state).toBe('wifi');
+ expect(chip.button.getAttribute('aria-label')).toBe('Wi-Fi connected');
+ });
+
+ it('paints Ethernet when the wired link is up', async () => {
+ vi.useFakeTimers();
+ vi.stubGlobal('fetch', vi.fn(async () => ({})));
+ const chip = setup(function (_uri, options) {
+ options.onSuccess!({
+ returnValue: true,
+ wifi: {state: 'disconnected'},
+ wired: {state: 'connected'}
+ });
+ });
+
+ chip.handle.start();
+ await flush();
+
+ expect(chip.button.dataset.state).toBe('ethernet');
+ expect(chip.button.getAttribute('aria-label')).toBe('Ethernet connected');
+ });
+
+ it('waits for a second failed probe before painting no internet', async () => {
+ vi.useFakeTimers();
+ vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
+ const chip = setup(wifiHandler);
+
+ chip.handle.start();
+ await flush();
+
+ // A single failure is not yet an outage.
+ expect(chip.button.dataset.state).toBe('wifi');
+
+ await vi.advanceTimersByTimeAsync(10000);
+ expect(chip.button.dataset.state).toBe('no-internet');
+ expect(chip.button.getAttribute('aria-label')).toBe('No internet connection');
+ });
+
+ it('does not call no-internet when only the first probe host is blocked', async () => {
+ vi.useFakeTimers();
+ vi.stubGlobal('fetch', vi.fn(async (url: string) => {
+ if (url.indexOf('cloudflare') >= 0) throw new Error('blocked');
+ return {};
+ }));
+ const chip = setup(wifiHandler);
+
+ chip.handle.start();
+ await flush();
+
+ expect(chip.button.dataset.state).toBe('wifi');
+ });
+
+ it('paints offline as soon as there is no link', async () => {
+ vi.useFakeTimers();
+ vi.stubGlobal('fetch', vi.fn(async () => ({})));
+ const chip = setup(function (_uri, options) {
+ options.onSuccess!({
+ returnValue: true,
+ wifi: {state: 'disconnected'},
+ wired: {state: 'disconnected'}
+ });
+ });
+
+ chip.handle.start();
+ await flush();
+
+ expect(chip.button.dataset.state).toBe('offline');
+ expect(chip.button.getAttribute('aria-label')).toBe('No network connection');
+ });
+
+ it('stays hidden until the first link reading', async () => {
+ vi.useFakeTimers();
+ vi.stubGlobal('fetch', vi.fn(async () => ({})));
+ const chip = setup(function () { /* never answers */ });
+
+ chip.handle.start();
+ await flush();
+
+ expect(chip.button.hidden).toBe(true);
+ });
+
+ it('hides and stops probing when the setting is off', async () => {
+ vi.useFakeTimers();
+ const fetchMock = vi.fn(async () => ({}));
+ vi.stubGlobal('fetch', fetchMock);
+ const chip = setup(wifiHandler, false);
+
+ chip.handle.start();
+ await flush();
+
+ expect(chip.button.hidden).toBe(true);
+ expect(document.body.classList.contains('has-network-status')).toBe(false);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('pauses probing while Ember is inactive and probes again on return', async () => {
+ vi.useFakeTimers();
+ const fetchMock = vi.fn(async () => ({}));
+ vi.stubGlobal('fetch', fetchMock);
+ const chip = setup(wifiHandler);
+ chip.state.active = false;
+
+ chip.handle.start();
+ await flush();
+ expect(fetchMock).not.toHaveBeenCalled();
+
+ chip.state.active = true;
+ chip.handle.applyConfig();
+ await flush();
+
+ expect(fetchMock).toHaveBeenCalled();
+ expect(chip.button.dataset.state).toBe('wifi');
+ });
+
+ it('cancels the link subscription on stop', async () => {
+ vi.useFakeTimers();
+ vi.stubGlobal('fetch', vi.fn(async () => ({})));
+ const chip = setup(wifiHandler);
+
+ chip.handle.start();
+ chip.handle.stop();
+
+ expect(chip.bus.cancel).toHaveBeenCalled();
+ });
+
+ it('degrades to an internet globe when the connection service is refused', async () => {
+ vi.useFakeTimers();
+ vi.stubGlobal('fetch', vi.fn(async () => ({})));
+ const chip = setup(function () { throw new Error('denied'); });
+
+ chip.handle.start();
+ await flush();
+
+ expect(chip.button.hidden).toBe(false);
+ expect(chip.button.dataset.state).toBe('internet');
+ expect(chip.button.getAttribute('aria-label')).toBe('Internet connected');
+ });
+
+ it('treats a returnValue:false subscription as a refusal', async () => {
+ vi.useFakeTimers();
+ vi.stubGlobal('fetch', vi.fn(async () => ({})));
+ const chip = setup(function (_uri, options) {
+ options.onSuccess!({returnValue: false, errorText: 'denied'});
+ });
+
+ chip.handle.start();
+ await flush();
+
+ expect(chip.button.dataset.state).toBe('internet');
+ });
+
+ it('shows no internet in degraded mode when the probe also fails', async () => {
+ vi.useFakeTimers();
+ vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
+ const chip = setup(function () { throw new Error('denied'); });
+
+ chip.handle.start();
+ await flush();
+ await vi.advanceTimersByTimeAsync(10000);
+
+ expect(chip.button.dataset.state).toBe('no-internet');
+ });
+
+ it('probes once a minute while active', async () => {
+ vi.useFakeTimers();
+ const fetchMock = vi.fn(async () => ({}));
+ vi.stubGlobal('fetch', fetchMock);
+ const chip = setup(wifiHandler);
+
+ chip.handle.start();
+ await flush();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ await vi.advanceTimersByTimeAsync(59000);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('recovers when a later probe resolves', async () => { vi.useFakeTimers();
+ let online = false;
+ vi.stubGlobal('fetch', vi.fn(async () => {
+ if (!online) throw new Error('offline');
+ return {};
+ }));
+ const chip = setup(wifiHandler);
+
+ chip.handle.start();
+ await flush();
+ await vi.advanceTimersByTimeAsync(10000);
+ expect(chip.button.dataset.state).toBe('no-internet');
+
+ online = true;
+ await vi.advanceTimersByTimeAsync(60000);
+
+ expect(chip.button.dataset.state).toBe('wifi');
+ });
+
+ it('opens network settings when the chip is selected', async () => {
+ vi.useFakeTimers();
+ vi.stubGlobal('fetch', vi.fn(async () => ({})));
+ const chip = setup(wifiHandler);
+ chip.handle.start();
+ await flush();
+
+ chip.button.click();
+
+ expect(chip.onClick).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/test/settings-panel.test.ts b/test/settings-panel.test.ts
index f244748..1964ab8 100644
--- a/test/settings-panel.test.ts
+++ b/test/settings-panel.test.ts
@@ -147,6 +147,31 @@ describe('settings panel integration', () => {
expect(saved[0].launcher.clockShadow).toBe('soft');
});
+ it('saves the network status toggle', async () => {
+ const panel = document.createElement('div');
+ panel.id = 'settings-panel';
+ document.body.appendChild(panel);
+
+ const saved: Config[] = [];
+ const handle = createSettingsPanel(panel, loadConfig, {
+ onSave: function (config) { saved.push(config); }
+ });
+ handle.show();
+
+ const toggle = await waitFor('input[data-focus-index="1097"]', panel) as HTMLInputElement;
+ expect(toggle.checked).toBe(true);
+ toggle.checked = false;
+ toggle.dispatchEvent(new Event('change'));
+
+ const saveBtn = await waitFor('.settings-save', panel);
+ // Wait out the open guard, which swallows the click sequence that opened us.
+ await new Promise(function (resolve) { setTimeout(resolve, 800); });
+ (saveBtn as HTMLButtonElement).click();
+
+ expect(saved.length).toBe(1);
+ expect(saved[0].launcher.showNetworkStatus).toBe(false);
+ });
+
it('renders the panel shell in Dutch when the language is Dutch', async () => {
setLanguage('nl');