9 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
Zack SheppardandClaude Opus 5.5 4a300ed5fa Let electron-builder find the signing identity in signed Mac builds (#5)
CSC_IDENTITY_AUTO_DISCOVERY=false was set for every Mac build. With it,
electron-builder imports CSC_LINK into its keychain but won't look the
identity up, so the v0.0.3 build skipped signing and failed the signature
check. It's now set only for unsigned builds.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:01:44 +01:00
Zack SheppardandClaude Opus 5.5 cd8f3b7671 Pin electron-builder to ^26.17 to fix macOS signing on macos-26 runners (#4)
electron-builder before 26.16.1 passes the .p12 password to
`security set-key-partition-list -k`, which expects its temporary
keychain's password. macOS 26 runners enforce that check, so signed
release builds failed with "SecKeychainUnlock: The user name or passphrase
you entered is not correct" (electron-builder#10066). 26.17.0 passes the
keychain password. npm's `latest` tag still points at 26.15.3, so the
range is pinned explicitly.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:42:56 +01:00
Zack SheppardandClaude Opus 5.5 0693668b08 Set the author to Traveler Dev Ltd <hi@traveler.dev> in every package.json (#3)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:20:32 +01:00
Zack SheppardandClaude Opus 5.5 26872c7396 Sign and notarize macOS release builds (#1)
* Sign and notarize macOS release builds

Release builds of the Mac app now run in the protected release-signing
environment (approval required, v* tags only), are signed with the
Developer ID certificate, notarized with the App Store Connect key, and
checked with codesign, spctl and stapler before upload. Push and PR
builds stay ad-hoc signed and never see the secrets.

- electron-builder config moved to electron-builder.config.cjs, which
  switches between ad-hoc and Developer ID signing based on CSC_LINK
- Hardened runtime entitlements for Electron
- Local network usage description and Bonjour services in Info.plist
  for the macOS 15+ local network prompt
- README: signing setup and current "Open Anyway" steps

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

* Only export signing variables for signed Mac builds

electron-builder treats an empty CSC_LINK as a certificate path, so PR
builds failed with "apps/desktop not a file". The variables are now
exported only by the signing step, which also checks every secret is
present.

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

* Keep Mac signing secrets inside the build step

They were exported through GITHUB_ENV, which makes them visible to every
later step and action in the job (artifact upload, post steps). They now
live only in the build step's environment, and the notarization key file
is deleted when the step exits.

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

* Build pull requests once, not once per push and once per PR

Push builds now run only for main; branch commits are built through their
pull request.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:12:19 +01:00
Zack SheppardandClaude Opus 5.5 c6f1c8b203 Add the app icon and put the logo at the top of the README (#2)
build/icon.icns is the macOS version (Apple's rounded-square grid with
padding and shadow); build/icon.png is the full-bleed square electron-builder
uses for Windows and Linux.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:09:24 +01:00
37 changed files with 399 additions and 206 deletions

No files matched your search

+81 -7
View File
@@ -1,12 +1,13 @@
name: Build
# Every push and pull request: check the code, then build installers for each
# Every push to main and every pull request: check the code, then build installers for each
# platform and attach them to the run as artifacts. The release workflow calls
# this same file with a version to stamp into the app.
on:
# Pushes to main, plus pull requests. Branch pushes aren't built separately, so a
# pull request's commits run once (as pull_request), not twice.
push:
branches: ["**"]
tags-ignore: ["**"]
branches: [main]
pull_request:
workflow_call:
inputs:
@@ -18,6 +19,10 @@ on:
description: Commit to build.
type: string
default: ""
sign_mac:
description: Sign and notarize the macOS build (needs the release-signing environment).
type: boolean
default: false
concurrency:
group: build-${{ github.workflow }}-${{ inputs.ref || github.ref }}
@@ -56,9 +61,6 @@ jobs:
fail-fast: false
matrix:
include:
- name: macOS
os: macos-latest
args: --mac --arm64 --x64
- name: Windows
os: windows-latest
args: --win --x64
@@ -98,7 +100,7 @@ jobs:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: |
pnpm build
pnpm exec electron-builder ${{ matrix.args }} --publish never
pnpm exec electron-builder --config electron-builder.config.cjs ${{ matrix.args }} --publish never
- uses: actions/upload-artifact@v4
with:
name: frameloader-${{ matrix.os }}
@@ -109,3 +111,75 @@ jobs:
apps/desktop/release/*.AppImage
apps/desktop/release/*.deb
if-no-files-found: error
# macOS is its own job so release builds can run inside the protected
# release-signing environment (approval required, v* tags only), which holds
# the Developer ID certificate and the notarization key. Pushes and pull
# requests never touch it and produce ad-hoc signed builds.
package-mac:
name: Package (macOS${{ inputs.sign_mac && ', signed' || '' }})
needs: check
runs-on: macos-latest
environment: ${{ inputs.sign_mac && 'release-signing' || '' }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.sha }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Stamp version
if: inputs.version != ''
run: node scripts/version.mjs set "${{ inputs.version }}"
# Signing secrets live only in this step's environment, never in GITHUB_ENV, so no
# later step or action (artifact upload, post steps) can read them. They're exported
# only for signed builds: electron-builder treats an empty CSC_LINK as a file path.
- name: Build installers
working-directory: apps/desktop
env:
SIGN_MAC: ${{ inputs.sign_mac }}
SIGN_CSC_LINK: ${{ secrets.CSC_LINK }}
SIGN_CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
SIGN_APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
SIGN_APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
SIGN_APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
if [ "$SIGN_MAC" = "true" ]; then
for name in SIGN_CSC_LINK SIGN_CSC_KEY_PASSWORD SIGN_APPLE_API_KEY SIGN_APPLE_API_KEY_ID SIGN_APPLE_API_ISSUER; do
if [ -z "${!name}" ]; then
echo "::error::release-signing secret ${name#SIGN_} is missing. See README → Signing."
exit 1
fi
done
key_file="$RUNNER_TEMP/notary.p8"
printf '%s' "$SIGN_APPLE_API_KEY" > "$key_file"
trap 'rm -f "$key_file"' EXIT
export CSC_LINK="$SIGN_CSC_LINK" CSC_KEY_PASSWORD="$SIGN_CSC_KEY_PASSWORD"
export APPLE_API_KEY="$key_file" APPLE_API_KEY_ID="$SIGN_APPLE_API_KEY_ID" APPLE_API_ISSUER="$SIGN_APPLE_API_ISSUER"
else
# Unsigned (ad-hoc) build: don't let electron-builder look for a certificate.
# Not set for signed builds: with it, electron-builder imports CSC_LINK but then
# won't look the identity up, and silently skips signing.
export CSC_IDENTITY_AUTO_DISCOVERY=false
fi
unset SIGN_CSC_LINK SIGN_CSC_KEY_PASSWORD SIGN_APPLE_API_KEY SIGN_APPLE_API_KEY_ID SIGN_APPLE_API_ISSUER
pnpm build
pnpm exec electron-builder --config electron-builder.config.cjs --mac --arm64 --x64 --publish never
- name: Check signature
if: inputs.sign_mac
working-directory: apps/desktop
run: |
app="release/mac-arm64/Frameloader.app"
codesign --verify --deep --strict --verbose=2 "$app"
spctl --assess --type execute --verbose=2 "$app"
xcrun stapler validate "$app"
- uses: actions/upload-artifact@v4
with:
name: frameloader-macos
path: |
apps/desktop/release/*.dmg
apps/desktop/release/*.zip
if-no-files-found: error
+2
View File
@@ -37,9 +37,11 @@ jobs:
name: Build
needs: prepare
uses: ./.github/workflows/build.yml
secrets: inherit
with:
version: ${{ needs.prepare.outputs.version }}
ref: refs/tags/${{ github.event.release.tag_name }}
sign_mac: true
attach:
name: Attach installers to the release
+25 -19
View File
@@ -1,19 +1,23 @@
<p align="center">
<img src="apps/desktop/build/icon.png" alt="Frameloader logo" width="160" />
</p>
# Frameloader
[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 |
|---|---|---|
@@ -21,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:
@@ -58,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 and 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.
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.
Builds are not code-signed yet. On macOS the app is ad-hoc signed, so the first launch needs right-click → Open (or `xattr -dr com.apple.quarantine /Applications/Frameloader.app`). Windows SmartScreen will warn until the installer is signed.
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.
@@ -92,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
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- V8's JIT needs these under the hardened runtime. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

+85
View File
@@ -0,0 +1,85 @@
// electron-builder config. Pass with: electron-builder --config electron-builder.config.cjs
//
// macOS signing depends on the environment:
// - Release builds (the release-signing GitHub environment) set CSC_LINK, so the app is
// signed with the Developer ID certificate, uses the hardened runtime, and is notarized
// with the App Store Connect key (APPLE_API_KEY / APPLE_API_KEY_ID / APPLE_API_ISSUER).
// - Everything else (local builds, pushes, pull requests) is ad-hoc signed.
const signed = Boolean(process.env.CSC_LINK);
const base = {
"appId": "dev.traveler.frameloader",
"productName": "Frameloader",
"directories": {
"output": "release",
"buildResources": "build"
},
"files": [
"dist/**/*",
"package.json",
"!dist/test/**",
"!dist/**/*.map"
],
"extraResources": [
{
"from": "vendor/devkit-utils",
"to": "devkit-utils",
"filter": [
"**/*",
"!**/__pycache__/**"
]
}
],
"mac": {
"category": "public.app-category.utilities",
"target": [
"dmg",
"zip"
],
"artifactName": "Frameloader-mac-${arch}.${ext}",
"extendInfo": {
"NSLocalNetworkUsageDescription": "Frameloader connects to your Steam Frame on your local network to install and launch apps.",
"NSBonjourServices": [
"_steamos-devkit._tcp",
"_ssh._tcp"
]
}
},
"win": {
"target": [
"nsis",
"zip"
],
"artifactName": "Frameloader-win-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true
},
"linux": {
"target": [
"AppImage",
"deb"
],
"category": "Utility",
"artifactName": "Frameloader-linux-${arch}.${ext}",
"executableName": "frameloader"
},
"electronVersion": "44.5.1"
};
module.exports = {
...base,
mac: {
...base.mac,
...(signed
? {
hardenedRuntime: true,
entitlements: "build/entitlements.mac.plist",
entitlementsInherit: "build/entitlements.mac.plist",
notarize: true,
}
: { identity: "-", hardenedRuntime: false }),
},
};
+11 -65
View File
@@ -4,7 +4,10 @@
"version": "0.0.1",
"description": "Drop an APK, Windows app, or Linux build onto your Steam Frame and it shows up in your Steam library.",
"license": "MIT",
"author": "Zack <zack@traveler.dev>",
"author": {
"name": "Traveler Dev Ltd",
"email": "hi@traveler.dev"
},
"private": true,
"main": "dist/main.cjs",
"type": "module",
@@ -14,88 +17,31 @@
"start": "pnpm build && electron .",
"typecheck": "tsc -p tsconfig.main.json --noEmit && tsc -p tsconfig.renderer.json --noEmit",
"test": "pnpm build && node --test dist/test/*.test.cjs",
"dist": "pnpm build && electron-builder --publish never",
"dist:mac": "pnpm build && electron-builder --mac --arm64 --publish never",
"dist:win": "pnpm build && electron-builder --win --x64 --publish never",
"dist:linux": "pnpm build && electron-builder --linux --x64 --arm64 --publish never",
"dist": "pnpm build && electron-builder --config electron-builder.config.cjs --publish never",
"dist:mac": "pnpm build && electron-builder --config electron-builder.config.cjs --mac --arm64 --publish never",
"dist:win": "pnpm build && electron-builder --config electron-builder.config.cjs --win --x64 --publish never",
"dist:linux": "pnpm build && electron-builder --config electron-builder.config.cjs --linux --x64 --arm64 --publish never",
"screenshots": "pnpm build && electron scripts/screenshot.cjs"
},
"devDependencies": {
"frameloader-tokens": "workspace:*",
"@types/multicast-dns": "^7.2.4",
"@types/node": "^24.0.0",
"@types/ssh2": "^1.15.6",
"@types/yauzl": "^2.10.3",
"app-info-parser": "^1.1.6",
"electron": "44.5.1",
"electron-builder": "^26.15.3",
"electron-builder": "^26.17.0",
"esbuild": "^0.28.2",
"frameloader-tokens": "workspace:*",
"multicast-dns": "^7.2.5",
"ssh2": "^1.17.0",
"typescript": "^7.0.2",
"yauzl": "^3.2.0"
},
"build": {
"appId": "dev.traveler.frameloader",
"productName": "Frameloader",
"directories": {
"output": "release",
"buildResources": "build"
},
"files": [
"dist/**/*",
"package.json",
"!dist/test/**",
"!dist/**/*.map"
],
"extraResources": [
{
"from": "vendor/devkit-utils",
"to": "devkit-utils",
"filter": [
"**/*",
"!**/__pycache__/**"
]
}
],
"mac": {
"category": "public.app-category.utilities",
"target": [
"dmg",
"zip"
],
"artifactName": "Frameloader-mac-${arch}.${ext}",
"identity": "-",
"hardenedRuntime": false
},
"win": {
"target": [
"nsis",
"zip"
],
"artifactName": "Frameloader-win-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true
},
"linux": {
"target": [
"AppImage",
"deb"
],
"category": "Utility",
"artifactName": "Frameloader-linux-${arch}.${ext}",
"executableName": "frameloader"
},
"electronVersion": "44.5.1"
},
"homepage": "https://www.frameloader.com",
"repository": {
"type": "git",
"url": "https://github.com/travelerdev/frameloader.git",
"directory": "apps/desktop"
},
"dependencies": {}
}
}
+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">
+4
View File
@@ -4,6 +4,10 @@
"private": true,
"description": "The Frameloader website.",
"license": "MIT",
"author": {
"name": "Traveler Dev Ltd",
"email": "hi@traveler.dev"
},
"type": "module",
"scripts": {
"dev": "vite",
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
View File
@@ -2,6 +2,10 @@
"name": "frameloader-monorepo",
"private": true,
"license": "MIT",
"author": {
"name": "Traveler Dev Ltd",
"email": "hi@traveler.dev"
},
"packageManager": "pnpm@11.24.0",
"scripts": {
"build": "pnpm -r build",
+4
View File
@@ -4,6 +4,10 @@
"private": true,
"description": "Shared color, type and shape tokens for the Frameloader app and website.",
"license": "MIT",
"author": {
"name": "Traveler Dev Ltd",
"email": "hi@traveler.dev"
},
"exports": {
"./tokens.css": "./tokens.css"
},
+36 -48
View File
@@ -33,8 +33,8 @@ importers:
specifier: 44.5.1
version: 44.5.1(supports-color@10.2.2)
electron-builder:
specifier: ^26.15.3
version: 26.15.3(electron-builder-squirrel-windows@26.15.3)(supports-color@10.2.2)
specifier: ^26.17.0
version: 26.17.0(electron-builder-squirrel-windows@26.17.0)(supports-color@10.2.2)
esbuild:
specifier: ^0.28.2
version: 0.28.2
@@ -665,10 +665,6 @@ packages:
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@noble/hashes@2.4.0':
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
engines: {node: '>= 20.19.0'}
'@oxc-project/types@0.152.0':
resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
@@ -992,12 +988,12 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
app-builder-lib@26.15.3:
resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==}
app-builder-lib@26.17.0:
resolution: {integrity: sha512-wfL7EhujuODDW3uMSD8RPcr/MLzmT0dmpVfWV0wFUWXI0rIdaukzAJ7EFUSyHXOpxuBZqaIGlQt6VeQhtHhetw==}
engines: {node: '>=14.0.0'}
peerDependencies:
dmg-builder: 26.15.3
electron-builder-squirrel-windows: 26.15.3
dmg-builder: 26.17.0
electron-builder-squirrel-windows: 26.17.0
app-info-parser@1.1.6:
resolution: {integrity: sha512-ZAFCM0bN88cbpsMoRhL/JfdX3b+nb5iBEXcu30xABvbaqtw6tXfHujDnuKSpNmA3P0uwpkIxTV/Wun5HfEch8A==}
@@ -1091,8 +1087,8 @@ packages:
resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==}
engines: {node: '>=12.0.0'}
builder-util@26.15.3:
resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==}
builder-util@26.16.0:
resolution: {integrity: sha512-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg==}
engines: {node: '>=14.0.0'}
bytebuffer@5.0.1:
@@ -1234,8 +1230,8 @@ packages:
dir-compare@4.2.0:
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
dmg-builder@26.15.3:
resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==}
dmg-builder@26.17.0:
resolution: {integrity: sha512-EAw1f2iGkICmUvAVBOjm7v5mxJdB7/+OZSycHFhKBimiTkCXtvkCC6wBKzHo7ZQYi58C2MmwIleRbgRnROlK2Q==}
dns-packet@5.6.1:
resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==}
@@ -1261,16 +1257,16 @@ packages:
engines: {node: '>=0.10.0'}
hasBin: true
electron-builder-squirrel-windows@26.15.3:
resolution: {integrity: sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==}
electron-builder-squirrel-windows@26.17.0:
resolution: {integrity: sha512-uyh+D+B0m12qCRp9c/7dwBirHTh7JcvAzj5eTEbb4dXPPGAhYPx1nozzbu1fC6AdULfat6ZNqgWhipTFpb82lQ==}
electron-builder@26.15.3:
resolution: {integrity: sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==}
electron-builder@26.17.0:
resolution: {integrity: sha512-iYHBRiagS9sDIbZx1ZD113f5rEGQvtpvTvHf70ovHKJ8mRvxwIkWX7JCwfmVQmVS4eX735p7TZmoxHnBPwF3vA==}
engines: {node: '>=14.0.0'}
hasBin: true
electron-publish@26.15.3:
resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==}
electron-publish@26.16.0:
resolution: {integrity: sha512-Vt3KzQIiw9BImvNOYtndg9Mjki+tl4+1sQiC/+G5j8khWaENOJFWodiB+sUl6yyHwtd37avehskdtPw7f8y/+Q==}
electron-winstaller@5.4.0:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
@@ -2283,7 +2279,7 @@ snapshots:
'@electron/get@5.1.0(supports-color@10.2.2)':
dependencies:
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3(supports-color@10.2.2)
env-paths: 3.0.0
graceful-fs: 4.2.11
progress: 2.0.3
@@ -2644,8 +2640,6 @@ snapshots:
'@noble/hashes@1.8.0': {}
'@noble/hashes@2.4.0': {}
'@oxc-project/types@0.152.0': {}
'@peculiar/asn1-schema@2.10.0':
@@ -2870,7 +2864,7 @@ snapshots:
dependencies:
color-convert: 2.0.1
app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)(supports-color@10.2.2):
app-builder-lib@26.17.0(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0)(supports-color@10.2.2):
dependencies:
'@electron/asar': 3.4.1
'@electron/fuses': 1.8.0
@@ -2880,23 +2874,23 @@ snapshots:
'@electron/rebuild': 4.2.0(supports-color@10.2.2)
'@electron/universal': 2.0.3(supports-color@10.2.2)
'@malept/flatpak-bundler': 0.4.0(supports-color@10.2.2)
'@noble/hashes': 2.4.0
'@noble/hashes': 1.8.0
'@peculiar/webcrypto': 1.7.1
'@types/fs-extra': 9.0.13
ajv: 8.20.0
asn1js: 3.0.10
async-exit-hook: 2.0.1
builder-util: 26.15.3(supports-color@10.2.2)
builder-util: 26.16.0(supports-color@10.2.2)
builder-util-runtime: 9.7.0(supports-color@10.2.2)
chromium-pickle-js: 0.2.0
ci-info: 4.3.1
debug: 4.4.3(supports-color@10.2.2)
dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3)(supports-color@10.2.2)
dmg-builder: 26.17.0(electron-builder-squirrel-windows@26.17.0)(supports-color@10.2.2)
dotenv: 16.6.1
dotenv-expand: 11.0.7
ejs: 3.1.10
electron-builder-squirrel-windows: 26.15.3(dmg-builder@26.15.3)(supports-color@10.2.2)
electron-publish: 26.15.3(supports-color@10.2.2)
electron-builder-squirrel-windows: 26.17.0(dmg-builder@26.17.0)(supports-color@10.2.2)
electron-publish: 26.16.0(supports-color@10.2.2)
fs-extra: 10.1.0
hosted-git-info: 4.1.0
isbinaryfile: 5.0.7
@@ -3006,7 +3000,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
builder-util@26.15.3(supports-color@10.2.2):
builder-util@26.16.0(supports-color@10.2.2):
dependencies:
'@types/debug': 4.1.13
builder-util-runtime: 9.7.0(supports-color@10.2.2)
@@ -3128,12 +3122,6 @@ snapshots:
optionalDependencies:
supports-color: 10.2.2
debug@4.4.3(supports-color@7.2.0):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 7.2.0
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
@@ -3166,10 +3154,10 @@ snapshots:
minimatch: 3.1.5
p-limit: 3.1.0
dmg-builder@26.15.3(electron-builder-squirrel-windows@26.15.3)(supports-color@10.2.2):
dmg-builder@26.17.0(electron-builder-squirrel-windows@26.17.0)(supports-color@10.2.2):
dependencies:
app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)(supports-color@10.2.2)
builder-util: 26.15.3(supports-color@10.2.2)
app-builder-lib: 26.17.0(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0)(supports-color@10.2.2)
builder-util: 26.16.0(supports-color@10.2.2)
fs-extra: 10.1.0
js-yaml: 4.3.2
transitivePeerDependencies:
@@ -3200,23 +3188,23 @@ snapshots:
dependencies:
jake: 10.9.4
electron-builder-squirrel-windows@26.15.3(dmg-builder@26.15.3)(supports-color@10.2.2):
electron-builder-squirrel-windows@26.17.0(dmg-builder@26.17.0)(supports-color@10.2.2):
dependencies:
app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)(supports-color@10.2.2)
builder-util: 26.15.3(supports-color@10.2.2)
app-builder-lib: 26.17.0(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0)(supports-color@10.2.2)
builder-util: 26.16.0(supports-color@10.2.2)
electron-winstaller: 5.4.0(supports-color@10.2.2)
transitivePeerDependencies:
- dmg-builder
- supports-color
electron-builder@26.15.3(electron-builder-squirrel-windows@26.15.3)(supports-color@10.2.2):
electron-builder@26.17.0(electron-builder-squirrel-windows@26.17.0)(supports-color@10.2.2):
dependencies:
app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)(supports-color@10.2.2)
builder-util: 26.15.3(supports-color@10.2.2)
app-builder-lib: 26.17.0(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0)(supports-color@10.2.2)
builder-util: 26.16.0(supports-color@10.2.2)
builder-util-runtime: 9.7.0(supports-color@10.2.2)
chalk: 4.1.2
ci-info: 4.4.0
dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3)(supports-color@10.2.2)
dmg-builder: 26.17.0(electron-builder-squirrel-windows@26.17.0)(supports-color@10.2.2)
fs-extra: 10.1.0
lazy-val: 1.0.5
simple-update-notifier: 2.0.0
@@ -3225,11 +3213,11 @@ snapshots:
- electron-builder-squirrel-windows
- supports-color
electron-publish@26.15.3(supports-color@10.2.2):
electron-publish@26.16.0(supports-color@10.2.2):
dependencies:
'@types/fs-extra': 9.0.13
aws4: 1.13.2
builder-util: 26.15.3(supports-color@10.2.2)
builder-util: 26.16.0(supports-color@10.2.2)
builder-util-runtime: 9.7.0(supports-color@10.2.2)
chalk: 4.1.2
form-data: 4.0.6
+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,