Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69952688fa | ||
|
|
229077980e | ||
|
|
0a7d47cf14 | ||
|
|
2cbc333477 | ||
|
|
4a300ed5fa | ||
|
|
cd8f3b7671 | ||
|
|
0693668b08 | ||
|
|
26872c7396 | ||
|
|
c6f1c8b203 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 821 KiB |
@@ -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 }),
|
||||
},
|
||||
};
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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…");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
After Width: | Height: | Size: 23 KiB |
@@ -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" }]
|
||||
: [];
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
@@ -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 |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 134 KiB |
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||