4 Commits
Author SHA1 Message Date
Zack Sheppard 69952688fa Human rewrite on the README 2026-10-08 11:50:02 +01:00
Zack SheppardandClaude Sonnet 5.5 229077980e Declare the apex frameloader.com as a custom domain in wrangler.jsonc (#8)
wrangler deploy treats the custom_domain routes as the full set for the Worker and detaches any domain not listed, so the apex (added in the dashboard) was removed on every deploy while www survived.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 11:14:05 +01:00
Zack SheppardandClaude Opus 5.5 0a7d47cf14 Fix headset discovery against SteamOS's mDNS responder (#7)
* Fix headset discovery against SteamOS's mDNS responder

SteamOS answers a _steamos-devkit._tcp browse with only the PTR record,
not the SRV/TXT it points to, so browse() dropped every headset. It now
asks for SRV and TXT, falls back to <name>.local:32000, re-asks over three
rounds (responders skip records multicast in the last second, and an idle
headset's Wi-Fi adds latency), and remembers addresses between scans. The
test's fake headset now answers the same way SteamOS does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Scan for headsets once per visit, and harden the discovery cache

- The connect screen scans once when it opens instead of every 5 s. If
  nothing answers it shows "No headset found" with a "Click to try
  again" button; when headsets are found, a "Search again" link.
- Discovery only remembers addresses for names it asked about, and
  connecting never falls back to a remembered address: connection
  targets always come from a live answer (security review on #7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:24:12 +01:00
Zack SheppardandClaude Opus 5.5 2cbc333477 Use the new logo in the app header and on the website (#6)
- App: the logo replaces the old blue mark in the home top bar and on
  the connect screen
- Website: logo in the nav of every page, PNG favicon and Apple touch
  icon, and screenshots regenerated so they show the new header

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:26:37 +01:00
26 changed files with 156 additions and 85 deletions

No files matched your search

+20 -18
View File
@@ -6,18 +6,18 @@
[www.frameloader.com](https://www.frameloader.com)
Drop an APK, a Windows app, or a Linux ARM64 build onto Frameloader and it shows up in your Steam Frame's library, under Non-Steam, ready to launch. Open source, cross-platform (macOS, Windows, Linux), and built on the same mechanism Valve's own SteamOS Devkit Client uses.
Drop an (unbundled) APK, a Windows app, or a Linux ARM64 build onto Frameloader and it shows up in your Steam Frame's library, under Non-Steam, ready to launch.
**Drop it in. It's in your library.**
Open source, cross-platform (macOS, Windows, Linux), and built on the same mechanism Valve's own SteamOS Devkit Client uses.
## How it works
Frameloader does not touch Steam's shortcut files or require anything installed on the headset beyond Developer Mode. It speaks Valve's **SteamOS devkit protocol**:
Frameloader uses the **SteamOS devkit protocol** to install your APK or other app as if it was one you had developed:
1. **Pairing.** The headset runs a small pairing service on port 32000. Frameloader generates an RSA key, posts it to `/register`, and you approve the request on the headset (Settings → Developer → Pair new host). From then on it logs in over SSH with that key. You can also connect with the Developer Mode password instead.
2. **Upload.** Valve's device-side helper scripts (`devkit-utils`, MIT, vendored unmodified in `vendor/devkit-utils`) are copied to `~/devkit-utils` on the headset. `steamos-prepare-upload` creates `~/devkit-game/<id>/`, and your files are copied there over SFTP.
3. **Register.** `steam-client-create-shortcut` tells the running Steam client about the title and which runtime to use. Steam adds "Devkit Game: <id>" to your library, no restart needed.
4. **Launch.** `steam-devkit-rpc run-game` starts it, or you pick it from the headset's library.
1. **Pairing.** When you turn on dev mode, the headset runs a small pairing service on port 32000. Frameloader generates an RSA key, posts it to `/register`, and you approve the request on the headset by clicking "Pair new host" (Settings → Developer → Pair new host). Then Frameloader can use its RSA key to log in over SSH to upload apps. (You can also connect with the Developer Mode password instead.)
2. **Upload.** Valve's device-side helper scripts (`devkit-utils`, copied into `vendor/devkit-utils` because they don't seem to publish it on NPM) are copied to `~/devkit-utils` on the headset. `steamos-prepare-upload` creates `~/devkit-game/<id>/`, and your files are copied there over SFTP.
3. **Register.** `steam-client-create-shortcut` tells the running Steam client about the title and which runtime to use. The frame-side code then adds "Devkit Game: <id>" to your library live. Frameloader then reaches over and renames it to drop the Devkit Game prefix.
4. **Launch.** Frameloader calls `steam-devkit-rpc run-game` to start it, or you pick it from the headset's library.
| You drop | Runtime (Steam compat tool) | Status |
|---|---|---|
@@ -25,17 +25,16 @@ Frameloader does not touch Steam's shortcut files or require anything installed
| Windows `.exe` or a zip/folder with one | Proton Experimental or Proton (stable) | Works through FEX. Needs a Proton already installed on the headset. |
| Linux ARM64 build | Steam Linux Runtime 4 (ARM64) | Starts natively; self-contained builds only. |
| Linux x86-64 build | – | Refused: the Frame won't install the x86-64 runtime for sideloaded titles. |
| `.apkm` (bundled APKs) | – | Refused: Lepton can only handle an individual APK binary right now, I think? |
For Android apps, Frameloader reads the manifest to pick a name and icon, refuses 32-bit-only and too-new APKs with a plain explanation, and writes Lepton's `lepton-show-flatscreen` marker for non-VR apps so they're actually visible. OBB files dropped with an APK go into `obb/` next to it.
For Android apps, Frameloader reads the manifest to pick a name and icon, refuses 32-bit-only and too-new APKs, and writes Lepton's `lepton-show-flatscreen` marker for non-VR apps so they're actually visible. OBB files dropped with an APK go into `obb/` next to it.
## Using it
1. On the headset: Steam Settings → System → **Enable Developer Mode**, then Settings → Developer → **Set User Password**.
2. Open Frameloader. Headsets with Developer Mode on show up under "Found on your network" (they advertise Valve's devkit service over mDNS); click one, or type a hostname or IP. Click **Pair with headset**. Open Settings → Developer → **Pair new host** on the headset and approve.
1. On the headset: Steam Settings → System → **Enable Developer Mode**. (I'd also recommend doing Settings → Developer → **Set User Password** for security if you're doing this.)
2. Open Frameloader. Headsets with Developer Mode on show up under "Found on your network" (they advertise Valve's devkit service over mDNS); click one, or type a hostname or IP. Click **Pair with headset** in Frameloader. On your Frame, open Settings → Developer → **Pair new host** and approve.
3. Drop a file. Check the name, pick a runtime if there's a choice, click **Install to Frame**.
Logs for Android titles stream straight into the app (row → Logs). The Activity drawer at the bottom shows every command sent to the headset.
## Development
This is a pnpm workspace:
@@ -62,23 +61,26 @@ The renderer can be previewed in a normal browser with simulated data: serve `ap
`pnpm install` must be allowed to run the `electron` and `esbuild` build scripts (see `pnpm-workspace.yaml`). If Electron's binary is missing, run `node node_modules/electron/install.js`.
## Agent use
This app is heavily agent-coded, but the expectation is that architectural decisions should be human-made and code should be human-reviewed. We're dealing with system-level access, so we should be cautious about Agentic hallucinations or missed cases.
## Privacy
Frameloader has no analytics, telemetry, crash reporting or accounts. It connects to your headset on your local network: SSH, Valve's devkit pairing service on port 32000, and mDNS discovery. The only other request is an update check: twice a day it asks GitHub's public releases API for the latest version number, sending nothing but the app version in its User-Agent. It never downloads anything, and Settings → Updates turns it off. A remembered password is encrypted with the operating system's secure storage. The website loads no third-party scripts, fonts or cookies.
Frameloader has no analytics, telemetry, crash reporting or accounts. It connects to your headset on your local network: SSH, Valve's devkit pairing service on port 32000, and mDNS discovery. The only other request is an update check: twice a day it asks GitHub's public releases API for the latest version number. It never downloads anything, and Settings → Updates turns it off if you want a truly LAN-only setup. Any remembered passwords should be encrypted with the operating system's secure storage.
## Builds and releases
Every push to `main` and every pull request runs `.github/workflows/build.yml`: typecheck, tests, the website build, then installers for macOS (arm64 and x64), Windows x64, and Linux x64 and arm64. The installers are attached to the run as artifacts.
To ship a release:
To ship a release you must be an Admin:
1. On GitHub, go to **Releases → Draft a new release**.
2. Create a new tag named `vX.Y.Z` (for example `v0.1.0`) targeting `main`, write the notes (or use **Generate release notes**), and publish.
3. Publishing runs `.github/workflows/release.yml`, which builds that tag stamped as version `X.Y.Z` and attaches the installers to the release. It takes a few minutes. If it fails, re-run the workflow; uploads replace any partial ones.
4. The release step will attempt to sign the mac release before attaching it to the public release (to avoid the security blocks Apple requires on all Mac systems after 15 or 16 - somewhere in there). This step requires an org admin's approval because it uses the org's secret keys read from the environment. It will hang until you contact Zack.
Nothing is pushed to `main`, so this works with branch protection. The version in `apps/desktop/package.json` is only what local and CI builds report; releases take their version from the tag.
**Signing.** Release builds of the Mac app are signed with a Developer ID certificate and notarized, so they open normally. The certificate and the App Store Connect notarization key live only in the `release-signing` GitHub environment as `CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_API_KEY`, `APPLE_API_KEY_ID` and `APPLE_API_ISSUER`. That environment only accepts `v*` tags and needs approval, so the release run pauses until you approve it. Push and pull-request builds are ad-hoc signed; to open one on macOS 15 or later, use System Settings → Privacy & Security → Open Anyway, or run `xattr -dr com.apple.quarantine /Applications/Frameloader.app`. Windows builds are unsigned, so SmartScreen warns before the installer runs.
The version in `apps/desktop/package.json` is only what local and CI builds report; releases take their version from the tag at build time.
Installed apps notice new releases on their own: they check GitHub's releases API and show a banner with a link once a release has its installers attached. Nothing is downloaded or installed automatically, and Settings → Updates turns the check off.
@@ -96,7 +98,7 @@ Workers Builds settings (Worker → Settings → Build):
| Preview command | `pnpm exec wrangler preview` |
| Build variables | `PNPM_VERSION=11.24.0`, `SKIP_DEPENDENCY_INSTALL=1`, `ELECTRON_SKIP_BINARY_DOWNLOAD=1` |
Locally: `pnpm preview:web` runs the built site in the Workers runtime, and `pnpm deploy:web` deploys by hand.
Locally: `pnpm preview:web` runs the built site in the Workers runtime, and `pnpm deploy:web` deploys by hand using your local wrangler auth.
## Troubleshooting
+2 -1
View File
@@ -53,6 +53,7 @@ const configs = [
function copyStatic() {
mkdirSync("dist", { recursive: true });
cpSync("src/renderer/index.html", "dist/index.html");
cpSync("src/renderer/logo.png", "dist/logo.png");
// Shared tokens first, then the app's own styles.
writeFileSync("dist/styles.css", readFileSync(TOKENS, "utf8") + "\n" + readFileSync("src/renderer/styles.css", "utf8"));
}
@@ -64,7 +65,7 @@ if (watch) {
// Keep static files fresh too.
const { watch: fsWatch } = await import("node:fs");
fsWatch("src/renderer", { recursive: true }, (_e, f) => {
if (f && (f.endsWith(".html") || f.endsWith(".css"))) copyStatic();
if (f && (f.endsWith(".html") || f.endsWith(".css") || f.endsWith(".png"))) copyStatic();
});
fsWatch(TOKENS, () => copyStatic());
console.log("watching…");
+63 -15
View File
@@ -42,12 +42,33 @@ function open(): Mdns | undefined {
}
}
// Addresses seen for headset hostnames, across scans, for display only. Responders won't
// repeat a record they multicast within the last second, so a scan can see a name without
// its address. Only names a scan asked about are stored, and connecting never uses this:
// it always resolves live, and known headsets are pinned by SSH host key anyway.
const seenAddresses = new Map<string, { address: string; at: number }>();
const SEEN_TTL_MS = 10 * 60 * 1000;
function remember(host: string, address: string): void {
if (isIP(address) === 4) seenAddresses.set(host, { address, at: Date.now() });
}
function recalled(host: string): string | undefined {
const hit = seenAddresses.get(host);
return hit && Date.now() - hit.at < SEEN_TTL_MS ? hit.address : undefined;
}
function records(r: { answers?: Record_[]; additionals?: Record_[] }): Record_[] {
return [...(r.answers ?? []), ...(r.additionals ?? [])];
}
/** "<label>.local" when a service instance name is also a valid hostname (SteamOS uses the hostname). */
function hostGuess(instanceName: string): string | undefined {
return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i.test(instanceName) ? `${instanceName.toLowerCase()}.local` : undefined;
}
/** Browse for headsets advertising the devkit service. Resolves after timeoutMs with whatever answered. */
export function browse(timeoutMs = 2500): Promise<DiscoveredFrame[]> {
export function browse(timeoutMs = 3000): Promise<DiscoveredFrame[]> {
return new Promise((resolve) => {
const m = open();
if (!m) return resolve([]);
@@ -60,12 +81,23 @@ export function browse(timeoutMs = 2500): Promise<DiscoveredFrame[]> {
const instances = new Map<string, Inst>();
const addresses = new Map<string, Set<string>>();
const asked = new Set<string>();
const askA = (host: string) => {
if (addresses.has(host) || asked.has(host)) return;
asked.add(host);
m.query({ questions: [{ name: host, type: "A" }] });
};
const inst = (full: string): Inst => {
const key = norm(full);
let i = instances.get(key);
if (!i) {
i = { name: key.replace("." + DEVKIT_SERVICE, ""), txt: {} };
instances.set(key, i);
// SteamOS's responder answers the PTR browse with just the PTR (plus an A record),
// not the SRV/TXT it points to, so ask for them. Also ask for <name>.local, which
// is what SteamOS uses as the target, in case the SRV answer never comes.
m.query({ questions: [{ name: key, type: "SRV" }, { name: key, type: "TXT" }] });
const guess = hostGuess(i.name);
if (guess) askA(guess);
}
return i;
};
@@ -78,28 +110,41 @@ export function browse(timeoutMs = 2500): Promise<DiscoveredFrame[]> {
const i = inst(name);
i.host = norm(d.target);
i.port = d.port;
if (!addresses.has(i.host) && !asked.has(i.host)) {
asked.add(i.host);
m.query({ questions: [{ name: i.host, type: "A" }] });
}
askA(i.host);
} else if (rec.type === "TXT" && name.endsWith("." + DEVKIT_SERVICE)) Object.assign(inst(name).txt, parseTxt(rec.data));
else if (rec.type === "A") {
if (!addresses.has(name)) addresses.set(name, new Set());
addresses.get(name)!.add(String(rec.data));
if (asked.has(name) || [...instances.values()].some((i) => (i.host ?? hostGuess(i.name)) === name)) remember(name, String(rec.data));
}
}
});
const ask = () => m.query({ questions: [{ name: DEVKIT_SERVICE, type: "PTR" }] });
ask();
const again = setTimeout(ask, Math.min(900, timeoutMs / 2));
// Ask a few times: an idle headset's Wi-Fi drops or delays packets, and responders
// skip records they multicast in the last second. Each round also re-asks for any
// instance still missing its SRV or address.
const round = () => {
m.query({ questions: [{ name: DEVKIT_SERVICE, type: "PTR" }] });
for (const [key, i] of instances) {
if (!i.host) m.query({ questions: [{ name: key, type: "SRV" }, { name: key, type: "TXT" }] });
const host = i.host ?? hostGuess(i.name);
if (host && !addresses.get(host)?.size) m.query({ questions: [{ name: host, type: "A" }] });
}
};
round();
const rounds = [0.3, 0.6].map((f) => setTimeout(round, Math.round(timeoutMs * f)));
setTimeout(() => {
clearTimeout(again);
rounds.forEach(clearTimeout);
m.destroy();
const out: DiscoveredFrame[] = [];
for (const i of instances.values()) {
if (!i.host) continue;
const addrs = [...(addresses.get(i.host) ?? [])].filter((a) => isIP(a) === 4);
out.push({ name: i.name, host: i.host, address: addrs[0] ?? "", port: i.port ?? 32000, login: i.txt.login, addresses: addrs });
const host = i.host ?? hostGuess(i.name);
if (!host) continue;
const addrs = [...(addresses.get(host) ?? [])].filter((a) => isIP(a) === 4);
if (!addrs.length) {
const prior = recalled(host);
if (prior) addrs.push(prior);
}
out.push({ name: i.name, host, address: addrs[0] ?? "", port: i.port ?? 32000, login: i.txt.login, addresses: addrs });
}
resolve(out.sort((a, b) => a.name.localeCompare(b.name)));
}, timeoutMs);
@@ -107,7 +152,7 @@ export function browse(timeoutMs = 2500): Promise<DiscoveredFrame[]> {
}
/** Resolve "<name>.local" (or a bare name) to an IPv4 address with our own mDNS query. */
export function resolveMdns(host: string, timeoutMs = 2000): Promise<string | undefined> {
export function resolveMdns(host: string, timeoutMs = 3000): Promise<string | undefined> {
return new Promise((resolve) => {
const m = open();
if (!m) return resolve(undefined);
@@ -116,7 +161,7 @@ export function resolveMdns(host: string, timeoutMs = 2000): Promise<string | un
const finish = (addr?: string) => {
if (done) return;
done = true;
clearTimeout(again);
retries.forEach(clearTimeout);
clearTimeout(timer);
m.destroy();
resolve(addr);
@@ -127,8 +172,11 @@ export function resolveMdns(host: string, timeoutMs = 2000): Promise<string | un
}
});
const ask = () => m.query({ questions: [{ name: want, type: "A" }] });
// Responders drop repeats of a record they multicast within the last second,
// and an idle headset's Wi-Fi adds latency, so ask a few times.
ask();
const again = setTimeout(ask, 700);
const retries = [700, 1600].map((ms) => setTimeout(ask, ms));
// No cached fallback here: this result is a connection target, so it must be a live answer.
const timer = setTimeout(() => finish(undefined), timeoutMs);
});
}
+1 -1
View File
@@ -121,7 +121,7 @@ function wireIpc(): void {
),
);
ipcMain.handle("connection:discover", () => browse(2500));
ipcMain.handle("connection:discover", () => browse());
ipcMain.handle("payload:inspect", async (_e, paths: string[]): Promise<PayloadInfo> => {
discardAll();
+10 -10
View File
@@ -276,34 +276,34 @@ export function cleanError(e: unknown): string {
return m.replace(/^Error invoking remote method '[^']+': (Error: )?/, "");
}
let discoveryTimer: number | undefined;
let discoveryStarted = false;
let discoveryBusy = false;
async function discoverOnce(): Promise<void> {
/** One scan of the local network. Results stay on screen until the user asks again. */
export async function discoverOnce(): Promise<void> {
if (discoveryBusy) return;
discoveryBusy = true;
store.set({ discovering: true });
try {
const discovered = await api.connection.discover();
store.set({ discovered, discovering: false });
store.set({ discovered, discovering: false, discoveryDone: true });
} catch {
store.set({ discovering: false });
store.set({ discovering: false, discoveryDone: true });
} finally {
discoveryBusy = false;
}
}
/** Keep browsing for headsets while the connect screen is up. */
/** Scan once when the connect screen opens; further scans happen only when the user asks. */
export function startDiscovery(): void {
if (discoveryTimer !== undefined) return;
if (discoveryStarted) return;
discoveryStarted = true;
void discoverOnce();
discoveryTimer = window.setInterval(() => void discoverOnce(), 5000);
}
/** Called when the connect screen closes, so the next visit scans again. */
export function stopDiscovery(): void {
if (discoveryTimer === undefined) return;
clearInterval(discoveryTimer);
discoveryTimer = undefined;
discoveryStarted = false;
}
export function useDiscovered(host: string): void {
-1
View File
@@ -52,7 +52,6 @@ const ICONS: Record<string, string> = {
eraser: '<path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
drop: '<path d="M12 3v12"/><path d="m8 11 4 4 4-4"/><rect x="4" y="15" width="16" height="6" rx="2"/>',
};
export function icon(name: string, cls = ""): HTMLElement {
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+2 -1
View File
@@ -77,7 +77,8 @@ export function installMock(): void {
installRuntime: async () => ({ ok: true }),
discover: async () => {
await sleep(600);
return scenario === "fresh" || scenario === "connected"
// ?nodisc=1 pretends no headset answers.
return !q.has("nodisc") && (scenario === "fresh" || scenario === "connected")
? [{ name: "frame", host: "frame.local", address: "192.168.1.42", addresses: ["192.168.1.42"], port: 32000, login: "steamos" }]
: [];
},
+3
View File
@@ -68,6 +68,8 @@ export interface State {
inspectError?: string;
discovered: DiscoveredFrame[];
discovering: boolean;
/** At least one scan has finished since the connect screen opened. */
discoveryDone: boolean;
}
export const initialState: State = {
@@ -89,6 +91,7 @@ export const initialState: State = {
inspecting: false,
discovered: [],
discovering: false,
discoveryDone: false,
};
type Listener = (s: State) => void;
+2 -4
View File
@@ -36,11 +36,9 @@ p { margin: 0; }
}
.topbar > * { -webkit-app-region: no-drag; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 15px; }
.brand .logo { width: 24px; height: 24px; border-radius: 7px; background: var(--accent); color: #fff; display: grid; place-items: center; }
.brand .logo svg { width: 15px; height: 15px; }
.brand .logo { width: 24px; height: 24px; border-radius: 6px; display: block; }
.brand-lg { font-size: 20px; gap: 12px; }
.brand-lg .logo { width: 32px; height: 32px; border-radius: 9px; }
.brand-lg .logo svg { width: 20px; height: 20px; }
.brand-lg .logo { width: 32px; height: 32px; border-radius: 8px; }
.main { flex: 1; overflow: auto; padding: 24px 20px 32px; }
.container { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
.center { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100%; gap: 20px; }
+19 -8
View File
@@ -2,7 +2,7 @@ import { h, icon } from "../dom";
const TERMS_URL = "https://www.frameloader.com/terms";
import { store, type State } from "../state";
import { cancelPair, connectSaved, connectWithPassword, openSettings, pair, useDiscovered } from "../actions";
import { cancelPair, connectSaved, connectWithPassword, discoverOnce, openSettings, pair, useDiscovered } from "../actions";
export function connectView(s: State): HTMLElement {
const f = s.connectForm;
@@ -30,15 +30,25 @@ export function connectView(s: State): HTMLElement {
h("div", { class: "help" }, "Changed the hostname in Steam Settings → System? Enter it here. Frameloader finds the address itself, so .local is optional."),
);
const searching = s.discovering || !s.discoveryDone;
const found = h(
"div",
{ class: "found" },
h(
"div",
{ class: "row small muted", style: "gap: 8px" },
s.discovering && !s.discovered.length ? h("span", { class: "spinner" }) : icon("headset"),
s.discovered.length ? "Found on your network" : s.discovering ? "Looking for headsets on your network…" : "No headset found yet. Developer Mode must be on and the Frame awake.",
),
s.discovered.length
? h(
"div",
{ class: "row spread small muted", style: "gap: 8px" },
h("span", { class: "row", style: "gap: 8px" }, icon("headset"), "Found on your network"),
h("button", { class: "link small", disabled: searching || pairing, onclick: () => void discoverOnce() }, searching ? "Searching…" : "Search again"),
)
: searching
? h("div", { class: "row small muted", style: "gap: 8px" }, h("span", { class: "spinner" }), "Looking for headsets on your network…")
: h(
"div",
{ class: "row spread small muted", style: "gap: 8px" },
h("span", { class: "row", style: "gap: 8px" }, icon("headset"), "No headset found. Developer Mode must be on and the Frame awake."),
h("button", { class: "btn sm", style: "white-space: nowrap; flex: none", disabled: pairing, onclick: () => void discoverOnce() }, icon("refresh"), "Click to try again"),
),
s.discovered.length
? h(
"div",
@@ -56,6 +66,7 @@ export function connectView(s: State): HTMLElement {
: null,
);
const pairBlock = h(
"div",
{ class: "stack" },
@@ -144,7 +155,7 @@ export function connectView(s: State): HTMLElement {
step(3, "Open Pair new host", "Settings → Developer → Pair new host, when you click Pair."),
);
const brand = h("div", { class: "brand brand-lg" }, h("span", { class: "logo" }, icon("drop")), "Frameloader");
const brand = h("div", { class: "brand brand-lg" }, h("img", { class: "logo", src: "logo.png", alt: "" }), "Frameloader");
const settingsBtn = h("button", { class: "btn sm quiet corner", "aria-label": "Settings", title: "Settings", onclick: () => openSettings() }, icon("settings"));
+1 -1
View File
@@ -40,7 +40,7 @@ export function topbar(s: State): HTMLElement {
return h(
"div",
{ class: "topbar" },
h("div", { class: "brand" }, h("span", { class: "logo" }, icon("drop")), "Frameloader"),
h("div", { class: "brand" }, h("img", { class: "logo", src: "logo.png", alt: "" }), "Frameloader"),
h(
"div",
{ class: "row", style: "align-items: flex-start; gap: 6px" },
+19 -14
View File
@@ -3,24 +3,29 @@ import assert from "node:assert/strict";
import mdns from "multicast-dns";
import { browse, resolveHost, DEVKIT_SERVICE } from "../src/main/discovery";
// A fake Steam Frame answering on the local multicast group. Skips (not fails)
// when multicast isn't available on this machine.
// A fake Steam Frame answering on the local multicast group the way SteamOS does:
// the PTR browse gets only the PTR and an A record; SRV and TXT are answered only
// when asked for. Skips (not fails) when multicast isn't available on this machine.
function fakeFrame(name: string, host: string, address: string) {
const m = mdns();
m.on("error", () => undefined);
const instance = `${name}.${DEVKIT_SERVICE}`;
m.on("query", (q) => {
const wantsPtr = q.questions.some((x) => x.type === "PTR" && x.name.toLowerCase() === DEVKIT_SERVICE);
const wantsA = q.questions.some((x) => x.type === "A" && x.name.toLowerCase() === host);
if (!wantsPtr && !wantsA) return;
const instance = `${name}.${DEVKIT_SERVICE}`;
m.respond({
answers: wantsPtr ? [{ name: DEVKIT_SERVICE, type: "PTR", ttl: 120, data: instance }] : [],
additionals: [
{ name: instance, type: "SRV", ttl: 120, data: { target: host, port: 32000, priority: 0, weight: 0 } },
{ name: instance, type: "TXT", ttl: 120, data: [Buffer.from("login=steamos")] },
{ name: host, type: "A", ttl: 120, data: address },
],
});
const answers: { name: string; type: string; ttl: number; data: unknown }[] = [];
for (const x of q.questions) {
const qname = x.name.toLowerCase();
if (x.type === "PTR" && qname === DEVKIT_SERVICE) {
answers.push({ name: DEVKIT_SERVICE, type: "PTR", ttl: 120, data: instance });
answers.push({ name: host, type: "A", ttl: 120, data: address });
} else if (x.type === "SRV" && qname === instance) {
answers.push({ name: instance, type: "SRV", ttl: 120, data: { target: host, port: 32000, priority: 0, weight: 0 } });
} else if (x.type === "TXT" && qname === instance) {
answers.push({ name: instance, type: "TXT", ttl: 120, data: [Buffer.from("login=steamos")] });
} else if (x.type === "A" && qname === host) {
answers.push({ name: host, type: "A", ttl: 120, data: address });
}
}
if (answers.length) m.respond({ answers } as never);
});
return () => m.destroy();
}
+3 -2
View File
@@ -6,14 +6,15 @@
<title>Page not found · Frameloader</title>
<meta name="robots" content="noindex" />
<meta name="color-scheme" content="light dark" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="stylesheet" href="./src/styles.css" />
</head>
<body>
<header class="nav">
<div class="wrap nav-inner">
<a class="brand" href="/" aria-label="Frameloader home">
<span class="logo"><svg viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m8 11 4 4 4-4"/><rect x="4" y="15" width="16" height="6" rx="2"/></svg></span>
<img class="logo" src="/logo.png" width="28" height="28" alt="" />
Frameloader
</a>
</div>
+3 -2
View File
@@ -13,14 +13,15 @@
<meta property="og:image" content="https://www.frameloader.com/screenshots/home-light.png" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="canonical" href="https://www.frameloader.com/" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="stylesheet" href="./src/styles.css" />
</head>
<body>
<header class="nav">
<div class="wrap nav-inner">
<a class="brand" href="#top" aria-label="Frameloader home">
<span class="logo"><svg viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m8 11 4 4 4-4"/><rect x="4" y="15" width="16" height="6" rx="2"/></svg></span>
<img class="logo" src="/logo.png" width="28" height="28" alt="" />
Frameloader
</a>
<nav class="nav-links" aria-label="Sections">
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="9" fill="#2E6BE6"/><g fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M16 6v12"/><path d="m11.5 13.5 4.5 4.5 4.5-4.5"/><rect x="8" y="20" width="16" height="6" rx="2"/></g></svg>

Before

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 134 KiB

+1 -2
View File
@@ -28,8 +28,7 @@ svg { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; s
.nav-inner { display: flex; align-items: center; justify-content: space-between; height: 60px; gap: 16px; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 16px; color: var(--text); }
.brand:hover { text-decoration: none; }
.logo { width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: #fff; display: grid; place-items: center; }
.logo svg { width: 17px; height: 17px; stroke-width: 2.2; }
.logo { width: 28px; height: 28px; border-radius: 7px; display: block; }
.nav-links { display: flex; align-items: center; gap: 20px; font-size: 15px; }
.nav-links > a:not(.btn) { color: var(--text-2); }
.nav-links > a:not(.btn):hover { color: var(--text); text-decoration: none; }
+3 -2
View File
@@ -7,14 +7,15 @@
<meta name="description" content="Frameloader is free software provided as is, with no warranty. Use it at your own risk." />
<meta name="color-scheme" content="light dark" />
<link rel="canonical" href="https://www.frameloader.com/terms" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="stylesheet" href="./src/styles.css" />
</head>
<body>
<header class="nav">
<div class="wrap nav-inner">
<a class="brand" href="/" aria-label="Frameloader home">
<span class="logo"><svg viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m8 11 4 4 4-4"/><rect x="4" y="15" width="16" height="6" rx="2"/></svg></span>
<img class="logo" src="/logo.png" width="28" height="28" alt="" />
Frameloader
</a>
<nav class="nav-links" aria-label="Sections">
+4 -2
View File
@@ -14,10 +14,12 @@
"html_handling": "auto-trailing-slash"
},
"routes": [
// Cloudflare creates the DNS record and certificate for this on deploy.
// Cloudflare creates the DNS record and certificate for these on deploy. Every custom domain
// must be listed: `wrangler deploy` detaches any domain on the Worker that isn't in this list.
{ "pattern": "frameloader.com", "custom_domain": true },
{ "pattern": "www.frameloader.com", "custom_domain": true }
],
// Keep the *.workers.dev address off; the site lives on www.frameloader.com.
// Keep the *.workers.dev address off; the site lives on frameloader.com and www.frameloader.com.
"workers_dev": false,
// Pull request / branch previews get their own URL (Worker Previews).
"preview_urls": true,