diff --git a/CHANGELOG.md b/CHANGELOG.md
index d630617..dfd06f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
All notable changes to FloRo Sign Controller are documented here. Version numbers match `APP_VERSION` in `js/ui.js`. Bump `CACHE_NAME` in `sw.js` when static assets change so installed PWAs pick up updates.
+## [2.2.2] - 2026-09-05
+
+### Fixed
+- Match Health Hub's native installation contract: Android and desktop show **Install** only while Chromium supplies `beforeinstallprompt`, and clicking it calls `prompt()` directly.
+- Remove the Android menu-help route that could lead users to **Create shortcut**, which is a Chrome bookmark and not a standalone PWA.
+- Hide Settings > Install App when no native Chromium prompt exists; iOS guidance remains available.
+- Remove the separate install-help control, self-referential `related_applications`, and installed-app detection for strict Health Hub parity.
+- Restore the canonical production manifest ID used by Health Hub's identity pattern.
+
## [2.2.1] - 2026-09-05
### Fixed
diff --git a/README.md b/README.md
index d97d7fb..bab6be9 100644
--- a/README.md
+++ b/README.md
@@ -116,11 +116,11 @@ See [`specs/pwa.md`](specs/pwa.md). Short version: **Install** calls the browser
| Platform | Banner | Install button |
|----------|--------|----------------|
-| **Android (Chrome/Edge)** | Native prompt when available; otherwise immediate honest guidance | Native **Install**, or clearly labeled **Install help** |
+| **Android (Chrome/Edge)** | Native prompt when available; otherwise immediate status guidance | Native **Install** only—never a browser shortcut |
| **iOS (Safari)** | On first visit (Share → Add to Home Screen copy) | Hidden (Apple has no auto-install) |
| **Desktop** | Hidden until `beforeinstallprompt` | Native **Install** when Chrome/Edge offers it |
-Dismiss **Not now** stores `floro_install_dismissed_v6` for **7 days**. Settings > **Install App** still works (native prompt, or a help sheet). On Android, an installed app appears in the app drawer as **FloRo Sign**; the launcher may not automatically pin it to the Home screen.
+Dismiss **Not now** stores `floro_install_dismissed_v7` for **7 days**. On Android, **Install** appears only when Chrome offers the native standalone-app prompt; FloRo never routes users through **Create shortcut**. An installed app appears in the app drawer as **FloRo Sign**; the launcher may not automatically pin it to the Home screen.
## Assets
diff --git a/index.html b/index.html
index 782794d..baa8b9c 100644
--- a/index.html
+++ b/index.html
@@ -249,7 +249,6 @@
Install FloRo so it opens full screen, not as a browser tab.
-
@@ -298,7 +297,7 @@
Activity Log
FloRo Sign Controller
-
v2.2.1
+
v2.2.2
diff --git a/js/app.js b/js/app.js
index 56a9502..e98b2cb 100644
--- a/js/app.js
+++ b/js/app.js
@@ -1190,7 +1190,6 @@ setupPwaInstall({
bannerTitle: document.getElementById('install-banner-title'),
bannerSubtitle: document.getElementById('install-banner-subtitle'),
btnInstall: document.getElementById('btn-install'),
- btnInstallHelp: document.getElementById('btn-install-help'),
btnDismiss: document.getElementById('btn-dismiss-install'),
settingsBtn: document.getElementById('btn-settings-install'),
modal: document.getElementById('install-modal'),
diff --git a/js/pwa-install.js b/js/pwa-install.js
index 0b993a2..0b0aaea 100644
--- a/js/pwa-install.js
+++ b/js/pwa-install.js
@@ -1,6 +1,6 @@
/** Native PWA install with honest manual and already-installed fallbacks. */
-export const DISMISS_KEY = 'floro_install_dismissed_v6';
+export const DISMISS_KEY = 'floro_install_dismissed_v7';
const DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export function isStandalone() {
@@ -43,27 +43,12 @@ function shouldSuppressBanner() {
return isStandalone() || isDismissed();
}
-async function detectInstalledPwa() {
- if (isStandalone()) return true;
- if (!('getInstalledRelatedApps' in navigator)) return false;
-
- try {
- const apps = await navigator.getInstalledRelatedApps();
- return apps.some((app) => app.platform === 'webapp');
- } catch {
- return false;
- }
-}
-
-function installHelpText(hasNativePrompt, installedPwa) {
- if (installedPwa) {
- return 'FloRo Sign is installed. Find it in your Android app drawer; Chrome may not add a Home screen icon.';
- }
+function installHelpText(hasNativePrompt) {
if (isIOS() && !hasNativePrompt) {
return 'Open this page in Safari, tap Share, then Add to Home Screen.';
}
if (isAndroid() && !hasNativePrompt) {
- return 'Chrome has not offered one-tap install yet. You can still install from the Chrome menu.';
+ return 'Waiting for Chrome’s app installer. Do not create a shortcut—it opens as a browser tab.';
}
return 'Install FloRo so it opens full screen, not as a browser tab.';
}
@@ -73,7 +58,6 @@ export function setupPwaInstall({
bannerTitle,
bannerSubtitle,
btnInstall,
- btnInstallHelp,
btnDismiss,
settingsBtn,
modal,
@@ -85,7 +69,6 @@ export function setupPwaInstall({
log,
}) {
let deferredPrompt = window.__floroDeferredInstall || null;
- let installedPwa = isStandalone();
function showBanner() {
if (!bannerEl || shouldSuppressBanner()) return;
@@ -102,23 +85,25 @@ export function setupPwaInstall({
function syncBanner() {
const hasNativePrompt = Boolean(deferredPrompt);
if (bannerTitle) {
- bannerTitle.textContent = installedPwa ? 'FloRo Sign is installed' : 'Install FloRo Sign';
+ bannerTitle.textContent = 'Install FloRo Sign';
}
if (bannerSubtitle) {
- bannerSubtitle.textContent = installHelpText(hasNativePrompt, installedPwa);
+ bannerSubtitle.textContent = installHelpText(hasNativePrompt);
}
if (btnInstall) {
btnInstall.textContent = 'Install';
- btnInstall.classList.toggle('hidden', !hasNativePrompt || installedPwa);
- }
- if (btnInstallHelp) {
- btnInstallHelp.textContent = installedPwa ? 'Find app' : 'Install help';
- btnInstallHelp.classList.toggle(
- 'hidden',
- hasNativePrompt || (!installedPwa && !isAndroid() && !isIOS())
- );
+ btnInstall.classList.toggle('hidden', !hasNativePrompt);
}
btnDismiss?.classList.remove('hidden');
+ syncSettingsInstall();
+ }
+
+ function syncSettingsInstall() {
+ const section = settingsBtn?.closest('.settings-section');
+ if (!section) return;
+ const canInstallOrHelp =
+ !isStandalone() && (Boolean(deferredPrompt) || isIOS());
+ section.classList.toggle('hidden', !canInstallOrHelp);
}
function renderModalSteps(items) {
@@ -137,18 +122,6 @@ export function setupPwaInstall({
modalSteps?.classList.remove('hidden');
modalAction?.classList.add('hidden');
- if (installedPwa) {
- modalTitle.textContent = 'Find FloRo Sign';
- modalDesc.textContent =
- 'Android installs the app in your app drawer, but your launcher may not add it to the Home screen.';
- renderModalSteps([
- 'Swipe up from your Home screen to open the app drawer',
- 'Search for FloRo Sign (older installs may appear as FloRo)',
- 'Long-press the app icon and drag it to your Home screen',
- ]);
- return;
- }
-
if (isIOS()) {
modalTitle.textContent = 'Add to Home Screen';
modalDesc.textContent = 'iPhone and iPad cannot auto-install. In Safari:';
@@ -160,14 +133,14 @@ export function setupPwaInstall({
return;
}
- modalTitle.textContent = 'Install FloRo Sign';
+ modalTitle.textContent = 'App install not ready';
modalDesc.textContent =
- 'Chrome can delay or temporarily suppress its one-tap prompt. Manual installation is always available:';
+ 'Chrome has not made native app installation available yet. A shortcut is only a bookmark and will keep the browser bar.';
renderModalSteps([
'Open this page directly in Chrome or Edge, not an in-app browser',
- 'Tap the three-dot Chrome menu',
- 'Tap Add to Home screen (or Install and create shortcut), then choose Install app',
- 'After installation, swipe up and search your app drawer for FloRo Sign',
+ 'Keep the page open briefly and interact with it so Chrome can finish checking the app',
+ 'Reload the page and use FloRo’s Install button when it appears',
+ 'Do not choose Create shortcut from Chrome’s menu',
]);
}
@@ -177,13 +150,10 @@ export function setupPwaInstall({
async function triggerNativeInstall() {
if (!deferredPrompt) return false;
- let accepted = false;
try {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
- accepted = true;
- installedPwa = true;
log?.('User installed FloRo Sign.', 'success');
}
} catch (err) {
@@ -193,15 +163,14 @@ export function setupPwaInstall({
deferredPrompt = null;
window.__floroDeferredInstall = null;
closeHelpSheet();
- if (accepted) showBanner();
- else hideBanner();
+ hideBanner();
+ syncSettingsInstall();
return true;
}
function capturePrompt(event) {
event.preventDefault();
deferredPrompt = event;
- installedPwa = false;
window.__floroDeferredInstall = event;
showBanner();
}
@@ -217,21 +186,20 @@ export function setupPwaInstall({
} else {
hideBanner();
}
+ syncSettingsInstall();
window.addEventListener('appinstalled', () => {
- installedPwa = true;
log?.('FloRo Sign installed.', 'success');
deferredPrompt = null;
window.__floroDeferredInstall = null;
- showBanner();
+ hideBanner();
+ syncSettingsInstall();
});
btnInstall?.addEventListener('click', async () => {
await triggerNativeInstall();
});
- btnInstallHelp?.addEventListener('click', openHelpSheet);
-
btnDismiss?.addEventListener('click', () => {
try {
localStorage.setItem(DISMISS_KEY, String(Date.now()));
@@ -256,15 +224,4 @@ export function setupPwaInstall({
if (e.target === modal) closeHelpSheet();
});
- if (settingsBtn && isStandalone()) {
- settingsBtn.closest('.settings-section')?.classList.add('hidden');
- }
-
- detectInstalledPwa().then((installed) => {
- if (!installed || isStandalone()) return;
- installedPwa = true;
- deferredPrompt = null;
- window.__floroDeferredInstall = null;
- showBanner();
- });
}
diff --git a/js/ui.js b/js/ui.js
index 7e5b4c2..77c16b9 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -1,6 +1,6 @@
import { formatModeLabel, getModeName, MODE_COUNT } from './mode-names.js';
-export const APP_VERSION = '2.2.1';
+export const APP_VERSION = '2.2.2';
export function createLogger(consoleBody) {
return function log(message, type = 'info') {
diff --git a/manifest.webmanifest b/manifest.webmanifest
index a75b601..d3b9df8 100644
--- a/manifest.webmanifest
+++ b/manifest.webmanifest
@@ -4,19 +4,13 @@
"description": "Control your FloRo neon sign over Bluetooth",
"start_url": "./",
"scope": "./",
- "id": "./",
+ "id": "https://rawshn.com/sign-controller/",
"display": "standalone",
"display_override": ["standalone"],
"orientation": "portrait",
"background_color": "#020D0A",
"theme_color": "#020D0A",
"prefer_related_applications": false,
- "related_applications": [
- {
- "platform": "webapp",
- "url": "./manifest.webmanifest"
- }
- ],
"launch_handler": {
"client_mode": ["navigate-existing", "auto"]
},
diff --git a/scripts/check-pwa.mjs b/scripts/check-pwa.mjs
index 68dc8ea..898d653 100644
--- a/scripts/check-pwa.mjs
+++ b/scripts/check-pwa.mjs
@@ -41,8 +41,8 @@ if (!html.includes('beforeinstallprompt')) {
if (!html.includes('id="btn-install"') || !html.includes('install-banner-action-primary hidden')) {
fail('Install button must start hidden until a native prompt exists');
}
-if (!html.includes('id="btn-install-help"')) {
- fail('Android needs an honest manual-install fallback when Chrome withholds prompt()');
+if (html.includes('id="btn-install-help"')) {
+ fail('do not expose a separate install-help action; match Health Hub native prompt flow');
}
if (!html.includes("updateViaCache: 'none'")) {
fail('service worker registration must bypass stale HTTP caches');
@@ -68,8 +68,8 @@ if (manifest) {
if (manifest.start_url !== './' || manifest.scope !== './') {
fail('manifest start_url and scope must be "./" (portable across localhost and /sign-controller/)');
}
- if (manifest.id !== './') {
- fail('manifest id must be "./" so it resolves consistently on production and localhost');
+ if (manifest.id !== 'https://rawshn.com/sign-controller/') {
+ fail('manifest id must be the canonical production identity');
}
if (manifest.display !== 'standalone') {
fail('manifest display must be standalone');
@@ -89,10 +89,8 @@ if (manifest) {
if (!icons.some((i) => i.sizes === '192x192') || !icons.some((i) => i.sizes === '512x512')) {
fail('manifest needs 192x192 and 512x512 icons');
}
- if (!manifest.related_applications?.some(
- (app) => app.platform === 'webapp' && app.url === './manifest.webmanifest'
- )) {
- fail('manifest must self-reference so Android can detect an existing installation');
+ if (manifest.related_applications) {
+ fail('do not self-reference related applications; it is not part of Health Hub installation');
}
}
@@ -135,9 +133,19 @@ if (!nativeInstallHandler.includes('triggerNativeInstall') ||
nativeInstallHandler.includes('openHelpSheet')) {
fail('Install button click must call prompt() only; do not open the help sheet');
}
-if (!pwaSrc.includes("btnInstall.classList.toggle('hidden', !hasNativePrompt || installedPwa)")) {
+if (!pwaSrc.includes("btnInstall.classList.toggle('hidden', !hasNativePrompt)")) {
fail('Install button visibility must follow hasNativePrompt');
}
+if (!pwaSrc.includes('syncSettingsInstall')) {
+ fail('Settings install action must be hidden until native install or valid platform help exists');
+}
+if (pwaSrc.includes('getInstalledRelatedApps')) {
+ fail('do not let installed-app detection clear a valid deferred native prompt');
+}
+if (pwaSrc.includes('Install and create shortcut') ||
+ pwaSrc.includes('Tap the three-dot Chrome menu')) {
+ fail('Android install guidance must never route users into Chrome shortcut creation');
+}
if (!process.exitCode) {
console.log('check-pwa: ok');
diff --git a/specs/pwa.md b/specs/pwa.md
index 024251a..a191d93 100644
--- a/specs/pwa.md
+++ b/specs/pwa.md
@@ -30,14 +30,12 @@ No `` tag. Relative URLs resolve against the page URL (`/` locally, `/sign
|-------|--------|
| `name` / `short_name` | FloRo Sign Controller / FloRo Sign |
| `start_url` / `scope` | `./` |
-| `id` | `./` (resolves to the canonical scope in production and localhost in development) |
+| `id` | `https://rawshn.com/sign-controller/` (canonical production identity) |
| `display` | `standalone` |
| `display_override` | `["standalone"]` |
| `launch_handler.client_mode` | `["navigate-existing", "auto"]` (array, not a string) |
| `icons` | `./icons/icon-192.png` (`any`), `./icons/icon-512.png` (`any` + `maskable`) |
| `theme_color` / `background_color` | `#020D0A` |
-| `related_applications` | Self-reference used to detect an existing Android installation |
-
`prefer_related_applications` stays `false` so Chrome does not skip the install UI looking for a Play Store app.
## Service worker
@@ -61,15 +59,14 @@ Module: `js/pwa-install.js`. Early capture in `index.html` (before other scripts
| Situation | Banner | Install button | Click |
|-----------|--------|----------------|-------|
| `beforeinstallprompt` fired | Show | Show | `event.prompt()` only |
-| Android, no native event | Show immediately (honest copy) | **Install help** | Chrome menu instructions |
-| Android, PWA already installed | Show find-app copy | **Find app** | App-drawer instructions |
-| iOS, not standalone | Show (Share → A2HS copy) | **Install help** | Share → Add to Home Screen instructions |
-| Desktop, no event | Hidden | Hidden | Settings help sheet only |
+| Android, no native event | Show immediately (honest copy) | Hidden | Wait for native eligibility; never create a shortcut |
+| iOS, not standalone | Show (Share → A2HS copy) | Hidden | Settings retains Share → Add to Home Screen help |
+| Desktop, no event | Hidden | Hidden | Settings install row hidden |
| Already standalone | Hidden | Hidden | Settings install row hidden |
-Dismiss **Not now** writes `localStorage` `floro_install_dismissed_v6` with a 7-day TTL. Settings → **Install App** ignores dismiss: native `prompt()` if held, otherwise the help sheet. A self-reference in `related_applications` lets supporting Chrome versions distinguish “prompt unavailable” from “already installed.” Android launchers may install into the app drawer without pinning a Home screen icon, so the installed help names both **FloRo Sign** and the old **FloRo** short name.
+Dismiss **Not now** writes `localStorage` `floro_install_dismissed_v7` with a 7-day TTL. On Android and desktop, Settings → **Install App** is visible only while a native prompt is held. It never directs users into Chrome’s **Create shortcut** flow, which creates a browser bookmark instead of a standalone app. As in Health Hub, FloRo does not use `related_applications` or installed-app detection as part of installation.
-`preventDefault` on `beforeinstallprompt` is required to call `prompt()` later. The in-page **Install** button must call that event directly. If Chrome does not emit the event (for example, after a recent dismissal), the separately labeled **Install help** action may explain the manual menu route; it must never pretend to invoke native installation.
+`preventDefault` on `beforeinstallprompt` is required to call `prompt()` later. The in-page **Install** button must call that event directly. If Chrome does not emit the event, FloRo must not offer an Android install action. **Create shortcut** is not an installation and must never be presented as a fallback.
## Verification
@@ -83,5 +80,6 @@ Manual: Chrome or Edge on Android, https://rawshn.com/sign-controller/, tap **In
| Date | Change |
|------|--------|
+| 2026-09-05 | Removed Android shortcut flow; only native `beforeinstallprompt` can expose Install |
| 2026-09-05 | Register SW immediately, detect existing installs, add honest manual fallback, and make cached launches fast |
| 2026-09-05 | Reimplemented to match Health Hub: native prompt only, static manifest, relative scope |
diff --git a/sw.js b/sw.js
index faa3c22..303dc52 100644
--- a/sw.js
+++ b/sw.js
@@ -1,4 +1,4 @@
-const CACHE_NAME = 'floro-controller-v62';
+const CACHE_NAME = 'floro-controller-v64';
const ASSETS = [
'./',
'./index.html',