Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions app/src/pages/HardwareBridgePage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { markPageReady } from "@/util/renderState";

// PoC page: an HTTPS-hosted Luminary instance calling a visitor's local
// Hardware Bridge at http://localhost:4781. Surfaces the three obstacles
// (mixed content, CORS, Local Network Access) so it doubles as a diagnostic.
// Copy is hardcoded English on purpose — this is a throwaway PoC page and
// must not add entries to the CouchDB Language docs / i18n workflow.

const bridgeUrl = ref("http://localhost:4781");
const lnaState = ref<"granted" | "denied" | "prompt" | "unsupported" | "checking">(
"checking",
);
const lnaNote = ref("");
const output = ref("—");
const pageOrigin = typeof window !== "undefined" ? window.location.origin : "";

const browser = (() => {
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
if (/Edg\//.test(ua)) return "Edge (Chromium)";
if (/OPR\//.test(ua)) return "Opera (Chromium)";
if (/Firefox\//.test(ua)) return "Firefox";
if (/Chrome\//.test(ua) && !/Edg|OPR/.test(ua)) return "Chrome";
if (/Safari\//.test(ua)) return "Safari";
return "Other (Chromium-based)";
})();

const isLoopback = (host: string) => /localhost|127\.0\.0\.1|\.localhost$/.test(host);

async function refreshLna() {
if (!navigator.permissions || !navigator.permissions.query) {
lnaState.value = "unsupported";
lnaNote.value = "navigator.permissions.query unavailable";
return;
}
try {
// 'local-network-access' is unknown to Firefox/Safari → throws.
const status = await navigator.permissions.query({
name: "local-network-access" as PermissionName,
});
lnaState.value = status.state as "granted" | "denied" | "prompt";
lnaNote.value =
status.state === "prompt"
? "first call will trigger a browser permission prompt"
: "";
status.onchange = () => {
lnaState.value = status.state as "granted" | "denied" | "prompt";
};
} catch {
lnaState.value = "unsupported";
lnaNote.value =
"browser doesn't know the 'local-network-access' permission (Firefox/Safari)";
}
}

// Fetch helper that turns an ambiguous TypeError: Failed to fetch into a
// best-effort explanation of *which* of the three obstacles bit us.
async function call(path: string, init?: RequestInit) {
const url = bridgeUrl.value.replace(/\/$/, "") + path;
try {
const res = await fetch(url, init);
let data: unknown = null;
try {
data = await res.json();
} catch {
/* non-JSON body is fine */
}
return { ok: res.ok, status: res.status, data, url };
} catch (e) {
const err = e as Error;
let why = "Network error.";
try {
const u = new URL(url);
const loopbackTarget = isLoopback(u.hostname);
if (!loopbackTarget && u.protocol === "http:") {
why =
"Likely a MIXED CONTENT block: non-loopback HTTP from an HTTPS page is not allowed (loopback exception does not extend to LAN IPs).";
} else if (lnaState.value === "denied") {
why =
"Local Network Access permission DENIED — the browser blocked the public→loopback request.";
} else if (lnaState.value === "prompt") {
why =
"Local Network Access permission still on 'prompt' — approve the browser prompt, then retry.";
} else if (lnaState.value === "granted") {
why =
"LNA granted but fetch failed — likely CORS (bridge not allowing this origin) or the bridge is not running.";
} else {
why =
"Likely CORS (origin not allowed) or the bridge is not running on " +
u.host +
".";
}
} catch {
/* malformed URL — leave default */
}
return { ok: false, error: `${err.name}: ${err.message}`, why, url };
}
}

const jsonHeaders = { headers: { "Content-Type": "application/json" } };

async function ping() {
output.value = JSON.stringify(await call("/api/ping"), null, 2);
}
async function getSystem() {
output.value = JSON.stringify(await call("/api/system"), null, 2);
}
async function getDevice() {
output.value = JSON.stringify(await call("/api/device"), null, 2);
}
async function toggleDevice() {
const current = await call("/api/device");
const nextOn = !(current.data && (current.data as { on?: boolean }).on);
const res = await call("/api/device", {
method: "POST",
...jsonHeaders,
body: JSON.stringify({ on: nextOn }),
});
output.value = JSON.stringify(res, null, 2);
refreshLna();
}

onMounted(() => {
refreshLna();
markPageReady();
});
</script>

<template>
<div class="mx-auto max-w-2xl px-4 py-8">
<h1 class="text-xl font-bold tracking-tight">Hardware Bridge (PoC)</h1>
<p class="mt-1 text-sm text-zinc-500">
This page is served from
<code class="rounded bg-zinc-100 px-1 dark:bg-zinc-800">{{ pageOrigin }}</code>
and calls a local service on your machine over HTTP.
</p>

<div class="mt-4 text-sm">
<div>Browser: <span class="font-medium">{{ browser }}</span></div>
<div class="mt-1 flex flex-wrap items-center gap-2">
Local Network Access permission:
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold"
:class="{
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200':
lnaState === 'granted',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200':
lnaState === 'prompt',
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200':
lnaState === 'denied',
'bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-200':
lnaState === 'unsupported' || lnaState === 'checking',
}"
>
{{ lnaState }}
</span>
<span class="text-xs text-zinc-400">{{ lnaNote }}</span>
</div>
</div>

<div class="mt-5 flex flex-wrap items-center gap-2">
<input
v-model="bridgeUrl"
type="text"
class="w-64 rounded border border-zinc-300 px-2 py-1 text-sm dark:border-zinc-600 dark:bg-zinc-900"
aria-label="Bridge base URL"
/>
<button
class="rounded border border-zinc-300 px-3 py-1 text-sm hover:bg-zinc-50 dark:border-zinc-600 dark:hover:bg-zinc-800"
@click="ping"
>
Ping
</button>
<button
class="rounded border border-zinc-300 px-3 py-1 text-sm hover:bg-zinc-50 dark:border-zinc-600 dark:hover:bg-zinc-800"
@click="getSystem"
>
Get system
</button>
<button
class="rounded border border-zinc-300 px-3 py-1 text-sm hover:bg-zinc-50 dark:border-zinc-600 dark:hover:bg-zinc-800"
@click="getDevice"
>
Get device
</button>
<button
class="rounded border border-zinc-300 px-3 py-1 text-sm hover:bg-zinc-50 dark:border-zinc-600 dark:hover:bg-zinc-800"
@click="toggleDevice"
>
Toggle device
</button>
</div>
<p class="mt-1 text-xs text-zinc-400">
Toggle writes via POST → proves bidirectional browser→local-hardware interaction.
</p>

<h2 class="mt-6 text-sm font-semibold">Result</h2>
<pre
class="mt-1 overflow-auto rounded bg-zinc-100 p-3 text-xs dark:bg-zinc-900"
><code>{{ output }}</code></pre>

<h2 class="mt-6 text-sm font-semibold">Failure modes</h2>
<ul class="mt-1 list-disc pl-5 text-xs text-zinc-500">
<li>
Mixed content block: only when targeting a non-loopback HTTP URL (e.g.
<code>http://192.168.x.x</code>) from an HTTPS page.
</li>
<li>
CORS failure: the bridge didn't return
<code>Access-Control-Allow-Origin</code> for this origin.
</li>
<li>
LNA denied/prompt (Chrome 142+): a TypeError while the pill reads
<code>prompt</code>/<code>denied</code>. Firefox/Safari show no prompt.
</li>
</ul>
</div>
</template>
12 changes: 12 additions & 0 deletions app/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const SettingsPage = import("@/pages/SettingsPage.vue");
const BookmarksPage = import("@/pages/BookmarksPage.vue");
const SingleContent = import("@/pages/SingleContent/SingleContent.vue");
const NotFoundPage = import("@/pages/NotFoundPage.vue");
const HardwareBridgePage = import("@/pages/HardwareBridgePage.vue");

// Track if navigation is from within the app
let isInternalNavigation = false;
Expand Down Expand Up @@ -86,6 +87,17 @@ const router = createRouter({
},
},

// PoC: HTTPS Luminary page that calls the visitor's local Hardware Bridge.
// Keep before /:slug so a misconfigured slug can't shadow it.
{
path: "/hardware-bridge",
component: () => HardwareBridgePage,
name: "hardware-bridge",
meta: {
analyticsIgnore: true,
},
},

// Note that this route should always come after all defined routes,
// to prevent wrongly configured slugs from taking over pages
{
Expand Down
137 changes: 137 additions & 0 deletions hardware-bridge-poc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Hardware Bridge PoC

Proof that an **HTTPS-hosted site** (e.g. the Luminary app) can call an **HTTP API on the visitor's own machine** at `http://localhost:PORT`, and use it to interact with local hardware — through all three obstacles:

1. **Mixed content** — solved automatically because loopback (`localhost` / `127.0.0.1`) is a *secure origin* per the W3C spec. Does **not** extend to LAN IPs.
2. **CORS** — the local server returns `Access-Control-Allow-Origin` for the calling origin and handles `OPTIONS` preflight.
3. **Local Network Access (Chrome 142+)** — Chromium shows a mandatory permission prompt for public→loopback requests; checkable via `navigator.permissions.query({ name: "local-network-access" })`. Firefox/Safari don't implement it.

The "hardware" is intentionally minimal (no native deps): `GET /api/system` returns real local machine info (CPU, memory, uptime) and `GET/POST /api/device` is an in-memory virtual device you can read and write — enough to prove **bidirectional browser → local-hardware interaction** through the bridge.

## Layout

```
hardware-bridge-poc/
electron-service/ # Electron app: loopback HTTP API (127.0.0.1:4781) + status window
frontend/ # Standalone HTTPS demo page (self-signed, port 4782)
docker/ # Dockerfile: builds Linux binary, hosts it for download
```

## 1. Run the local service (Electron)

```sh
cd electron-service
npm install
npm start
```

A small window opens showing the API URL (`http://127.0.0.1:4781`) and endpoints. The HTTP server is bound to **loopback only**, so it is reachable from the browser but not from the LAN/Internet.

By default CORS allows any origin (`*`) for easy testing. For anything real, restrict it:

```sh
ALLOWED_ORIGINS=https://your-luminary-domain.com npm start
```

You can sanity-check the API directly (bypasses CORS/mixed-content — this is just a server check):

```sh
curl http://localhost:4781/api/ping
curl http://localhost:4781/api/system
```

## 2. Run the HTTPS demo page

```sh
cd frontend
npm install
npm start
```

It prints two URLs:

- `https://localhost:4782` — **loopback origin**: tests mixed-content + CORS only. No LNA prompt (loopback→loopback is same address space).
- `https://<your-LAN-IP>:4782` — **local origin**: triggers the **Chrome 142+ LNA permission prompt** on the first call to `http://localhost:4781`. This is the faithful reproduction of a public site calling localhost.

Open the **LAN-IP URL** in Chrome to see all three layers. The self-signed cert will warn — click *Advanced → Proceed*. Click **Ping**, **Get system**, **Toggle device**. On the first call Chrome shows the "This site wants to access devices on your local network" prompt; approve it. The page shows the LNA permission pill (`prompt`/`granted`/`denied`/`unsupported`), the browser, and categorises any failure (mixed content vs CORS vs LNA denied vs service-down).

> For a true **public** origin (most faithful), deploy `frontend/index.html` to GitHub Pages / Vercel / Netlify and update the default target URL in the page. Public→loopback always triggers the LNA prompt in Chrome 142+.

### Cross-browser expectations

| Browser | Mixed content (loopback) | LNA prompt |
|---|---|---|
| Chrome 142+ / Edge / Brave | allowed (secure origin) | **yes** — permission prompt |
| Firefox | allowed (since FF84) | no |
| Safari | allowed | no |

## 3. Package the binary & host it via Docker

Build a Linux binary and serve it for download:

```sh
docker build -f docker/Dockerfile -t hardware-bridge ../ # from repo root of the poc
# or, from this folder:
docker build -f docker/Dockerfile -t hardware-bridge .
docker run -p 8080:8080 hardware-bridge
```

Then browse `http://localhost:8080` and download `HardwareBridge-1.0.0.tar.gz`.

**macOS / Windows builds:** `electron-builder` cannot produce macOS targets on Linux (and vice versa). To ship a mac `.dmg` or Windows `.exe`, run `npm run dist` on that OS (or add a CI matrix). The Docker path is for the Linux artifact + hosting.

## 4. Integrate into Luminary

The Luminary app/cms, served over HTTPS, can call the bridge directly from browser JS. Example composable:

```ts
// shared/src/composables/useHardwareBridge.ts (PoC — adapt to luminary conventions)
const BRIDGE = 'http://localhost:4781'

export async function checkLnaAccess(): Promise<'granted' | 'denied' | 'prompt' | 'unsupported'> {
try {
const s = await navigator.permissions.query({ name: 'local-network-access' as PermissionName })
return s.state as 'granted' | 'denied' | 'prompt'
} catch {
return 'unsupported' // Firefox/Safari
}
}

export async function bridgeFetch(path: string, init?: RequestInit) {
const res = await fetch(`${BRIDGE}${path}`, init)
if (!res.ok) throw new Error(`bridge ${path} -> ${res.status}`)
return res.json()
}

// Usage from a Vue component:
// const lna = await checkLnaAccess() // show a UI hint if 'prompt'/'denied'
// const sys = await bridgeFetch('/api/system')
// await bridgeFetch('/api/device', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ on: true }) })
```

On the Electron side, start the service with Luminary's origin allow-listed:

```sh
ALLOWED_ORIGINS=https://your-luminary-domain.com npm start
```

If the page calling the bridge lives in an **iframe**, the parent must delegate the permission:

```html
<iframe src="https://app.luminary.example/" allow="local-network-access"></iframe>
```

## Security notes

- The bridge binds to **loopback only**. Don't bind `0.0.0.0` in production.
- Use an explicit `ALLOWED_ORIGINS` allow-list, never `*`, for anything handling sensitive hardware.
- `POST /api/device` is an open in-memory setter for the PoC. A real hardware bridge must authenticate/authorize commands (e.g. a one-time pairing token shown in the Electron status window) before driving real hardware.
- For enterprise/internal Chrome deployments, origins can be pre-allowed via the `LocalNetworkAccessAllowedForUrls` policy — don't rely on this for a general audience.

## References

- [MDN — Mixed content](https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content)
- [MDN — Local network access](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Local_network_access)
- [Chrome for Developers — LNA prompt](https://developer.chrome.com/blog/local-network-access)
- [WICG LNA explainer](https://github.com/WICG/local-network-access/blob/main/explainer.md)
- [Firefox loopback mixed-content history](https://bugzilla.mozilla.org/show_bug.cgi?id=903966)
Loading
Loading