Skip to content
Merged
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
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,82 @@ script proxied through the `vercel.json` rewrites. Forks build with the variable
ship no analytics. Setting this var also turns on the cookie popup, so to test the cookie
popup locally, you can use `VITE_ANALYTICS_DOMAIN=localhost bun run dev`.

## Kiosk mode

For running unattended on a shared device, such as an iPad at a conference booth. Turn it on
with the **Kiosk Mode** toggle in Options, or with `?kiosk=1` (`?kiosk=0` turns it off). The
setting is saved in local storage, so the app relaunches straight into it.

In kiosk mode:

- **Idle tour.** On launch, and after 30 seconds without input, the app resets and plays the
first guided tour on its own: steps advance every 12 seconds while the camera orbits. The
first touch hands control to the visitor at the current step. A playing video tour counts
as activity.
- **No way out.** The header and Options are hidden, outbound links are removed, and Contact
Sales becomes a QR code.
- **No tracking.** The cookie banner and trackers are skipped and Plausible ignores the
device. The free-explore tutorial is never marked as seen, so every visitor gets it.
- **Touch lockdown.** No long-press selection or callouts, page pinch-zoom or
rubber-banding. Pinching the 3D view still zooms the camera.
- **Self-refresh.** After 4 hours of uptime, the next idle reset reloads the page to free
GPU memory and pick up any new deploy.

To leave kiosk mode, **hold the top-left corner of the screen for 3 seconds**.

Timings are constants at the top of `src/useKiosk.ts`.

### Offline cache

When launched as a home-screen app (and only in production builds), a service worker
(`public/sw.js`) precaches every built file, model, video and font — about 16MB — so the app
runs without a network after its first launch. The build fills in the file list and a
content hash, so each deploy installs a fresh cache. A running app picks up a deploy the
next time it fully reloads.

### Setting up an iPad

Install from the production domain, not a preview URL: the saved app and its cache are tied
to one origin.

1. **Save it as an app.** In Safari, open
[explorer.oxide.computer](https://explorer.oxide.computer) and tap **Share → Add to Home
Screen** (with **Open as Web App** on, where shown). Launch it from the home-screen icon:
it runs full screen, with no address bar.
2. **Let it cache.** Leave it open on good Wi-Fi for a minute or two. To confirm, switch on
airplane mode, force-quit and relaunch, and play the video tour.
3. **Turn on kiosk mode** from Options _inside the saved app_. Home-screen apps don't share
storage with Safari, and have no address bar for `?kiosk=1`.
4. **Lock the iPad to the app with Guided Access.** In Settings → Accessibility → Guided
Access, turn it on, set a passcode, set its Display Auto-Lock to Never and turn on the
Accessibility Shortcut. Then, in the app, triple-click the top (or Home) button. Under
Options, turn off Motion (locks orientation), Hardware Buttons and Keyboards. Rotate to
landscape, then tap **Start**.
5. **Keep the screen on and bright.** Guided Access's own Auto-Lock only applies while it's
running, so also cover the rest:
- Settings → Display & Brightness → Auto-Lock → **Never**.
- Settings → Accessibility → Display & Text Size → **Auto-Brightness off**, then set
brightness high by hand. Otherwise dim booth lighting dims the screen.
- Settings → Face ID & Passcode → **Attention Aware Features off** (Face ID iPads), or
the screen dims whenever nobody is looking at it.
- Turn off **Night Shift** (Display & Brightness), and optionally True Tone, so colours
stay consistent through the day.
6. **Quiet the device.** Turn on Do Not Disturb, turn off Siri, Low Power Mode (which also
forces a 30-second Auto-Lock) and automatic software updates, and keep it on a charger.

To get out, triple-click and enter the passcode to end Guided Access, then hold the top-left
corner for 3 seconds to leave kiosk mode.

Things to know:

- **Landscape only.** In portrait (under 1000px wide) the app falls back to its mobile
layout, and some of its controls live in the header that kiosk mode hides.
- **Guided Access ends on restart.** Relaunch the app and triple-click again; kiosk mode
itself is remembered. For multi-day unattended setups, Single App Mode via Apple
Configurator or MDM survives restarts but needs a supervised (wiped) iPad.
- **Updating the icon.** iOS caches home-screen icons. After changing
`public/apple-touch-icon.png`, delete the icon and add it again.

## Stack

- React 19 + Vite
Expand All @@ -39,6 +115,8 @@ popup locally, you can use `VITE_ANALYTICS_DOMAIN=localhost bun run dev`.
- `src/data/componentTree.ts` — hardware hierarchy (rack → sleds → components)
- `src/data/guidedTours.ts` — tour definitions
- `src/data/specifications.ts` — hardware specs shown in the side panel
- `src/useKiosk.ts` — kiosk idle detection and the idle tour loop
- `public/sw.js` — offline cache for the home-screen app
- `models/`, `public/models/` — GLB assets (LOD0 / LOD1)

## Licensing
Expand Down
Binary file removed apple-touch-icon.png
Binary file not shown.
34 changes: 2 additions & 32 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="3D Explorer" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<!--analytics-->
<link rel="canonical" href="https://explorer.oxide.computer/" />
</head>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"dependencies": {
"@ctrl/tinycolor": "^4.2.0",
"@oxide/design-system": "^6.4.0",
"@pmndrs/detect-gpu": "^6.0.6",
"@pmndrs/detect-gpu": "^6.0.27",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"@react-three/postprocessing": "^3.0.4",
Expand Down
Binary file added public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes
File renamed without changes
File renamed without changes
16 changes: 16 additions & 0 deletions public/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "Oxide 3D Explorer",
"short_name": "3D Explorer",
"description": "Explore the Oxide Cloud Computer in 3D.",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "landscape",
"background_color": "#080F11",
"theme_color": "#080F11",
"icons": [
{ "src": "/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" },
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}
113 changes: 113 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/

// Offline cache for the home-screen app, so a kiosk survives flaky conference
// Wi-Fi. Only registered in standalone display mode (see main.tsx). The build
// (precachePlugin in vite.config.ts) fills in the version and the full list of
// built files, so every model, video and chunk is cached on install — not just
// the ones a visitor happened to open. Each deploy changes the version, which
// installs a fresh cache and drops the old one.

const VERSION = '__PRECACHE_VERSION__'
/** Same-origin build output, plus cross-origin fonts. */
const PRECACHE = /* __PRECACHE_FILES__ */ []
const CACHE = `rack-explorer-${VERSION}`

self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE)
await Promise.all(
PRECACHE.map(async (url) => {
const sameOrigin = new URL(url, self.location.href).origin === self.location.origin
try {
const res = await fetch(url, { cache: 'reload', mode: 'cors' })
if (!res.ok) throw new Error(`${res.status} ${url}`)
await cache.put(url, res)
} catch (err) {
// A missing build file means a broken offline copy: fail the install
// and keep the previous worker. Fonts are optional — the fallback
// stack still renders.
if (sameOrigin) throw err
}
}),
)
await self.skipWaiting()
})(),
)
})

self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
for (const key of await caches.keys()) {
if (key.startsWith('rack-explorer-') && key !== CACHE) await caches.delete(key)
}
await self.clients.claim()
})(),
)
})

self.addEventListener('fetch', (event) => {
const { request } = event
if (request.method !== 'GET') return
const url = new URL(request.url)

// SPA navigations (`/`, `/?tour=…`) all get the cached shell, which matches
// the cached hashed chunks exactly.
if (request.mode === 'navigate' && url.origin === self.location.origin) {
event.respondWith(cacheFirst(request, '/index.html'))
return
}

if (!PRECACHE.includes(url.origin === self.location.origin ? url.pathname : url.href)) {
return
}
event.respondWith(cacheFirst(request))
})

async function cacheFirst(request, key = request) {
const cached = await caches.match(key, { cacheName: CACHE })
if (!cached) return fetch(request)
const range = request.headers.get('range')
return range ? rangeResponse(cached, range) : cached
}

/** Safari fetches video in byte ranges and won't play a full 200 response, so
* slice the cached body into the 206 it asked for. */
async function rangeResponse(response, range) {
const body = await response.arrayBuffer()
const size = body.byteLength
const match = /^bytes=(\d*)-(\d*)$/.exec(range)
if (!match) return new Response(null, { status: 416 })
let start
let end
if (match[1] === '') {
// Suffix range: the last N bytes.
start = Math.max(0, size - Number(match[2]))
end = size - 1
} else {
start = Number(match[1])
end = match[2] === '' ? size - 1 : Math.min(Number(match[2]), size - 1)
}
if (start > end || start >= size) {
return new Response(null, {
status: 416,
headers: { 'Content-Range': `bytes */${size}` },
})
}
return new Response(body.slice(start, end + 1), {
status: 206,
headers: {
'Content-Type': response.headers.get('Content-Type') ?? 'application/octet-stream',
'Content-Range': `bytes ${start}-${end}/${size}`,
'Content-Length': String(end - start + 1),
'Accept-Ranges': 'bytes',
},
})
}
Loading
Loading