offsite 0.0.11 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -15
- package/apps/server/dist/main.mjs +141 -60
- package/apps/server/dist/main.mjs.map +1 -1
- package/apps/server/drizzle/20261017120143_device_tokens/migration.sql +15 -0
- package/apps/server/drizzle/20261017120143_device_tokens/snapshot.json +305 -0
- package/apps/web/dist/apple-touch-icon.png +0 -0
- package/apps/web/dist/assets/{angular-html-DTNmgvth.js → angular-html-DNGrmdhg.js} +1 -1
- package/apps/web/dist/assets/{angular-ts-DOOLa99A.js → angular-ts-D4hBqsOh.js} +1 -1
- package/apps/web/dist/assets/{apl-CSWmNqJI.js → apl-CYvdPOPE.js} +1 -1
- package/apps/web/dist/assets/{astro-CK7We5bo.js → astro-BqtVnRyx.js} +1 -1
- package/apps/web/dist/assets/{blade-B9Q94PMc.js → blade-BPJV2sdT.js} +1 -1
- package/apps/web/dist/assets/{c-D05zRIsd.js → c-CBQDE6Uf.js} +1 -1
- package/apps/web/dist/assets/{chapel-CJbjuVPR.js → chapel-BHOESU2p.js} +1 -1
- package/apps/web/dist/assets/{cobol-CE0VhU2R.js → cobol-CMpvzrel.js} +1 -1
- package/apps/web/dist/assets/{coffee-C0i-RL39.js → coffee-Bv27C06e.js} +1 -1
- package/apps/web/dist/assets/{cpp-CBJhfD_V.js → cpp-MTwxWDTP.js} +1 -1
- package/apps/web/dist/assets/{crystal-DRdgVTW1.js → crystal-CI5fSUCy.js} +1 -1
- package/apps/web/dist/assets/{css-DgMip4qW.js → css-BW8DHcmj.js} +1 -1
- package/apps/web/dist/assets/{edge-BCmeW_3H.js → edge-C2HOl06F.js} +1 -1
- package/apps/web/dist/assets/{elixir-BiRSxKnc.js → elixir-OV4G1_r7.js} +1 -1
- package/apps/web/dist/assets/{elm-zBiqCp9W.js → elm-BF0pK-yg.js} +1 -1
- package/apps/web/dist/assets/{erb-1QviMyT9.js → erb-DFQCtOmK.js} +1 -1
- package/apps/web/dist/assets/{git-rebase-TjFhp_Gn.js → git-rebase-C9nAAu5A.js} +1 -1
- package/apps/web/dist/assets/{glimmer-js-dThwC_hZ.js → glimmer-js-B9gnYdT1.js} +1 -1
- package/apps/web/dist/assets/{glimmer-ts-cCThNW17.js → glimmer-ts-BkG38Inm.js} +1 -1
- package/apps/web/dist/assets/{glsl-BxmyIQYG.js → glsl-wwo6dKBr.js} +1 -1
- package/apps/web/dist/assets/{graphql-DygyczJj.js → graphql-tQJRbYuy.js} +1 -1
- package/apps/web/dist/assets/{hack-Bl1_oIjY.js → hack-C9e9oyUG.js} +1 -1
- package/apps/web/dist/assets/{haml-CeOBocB0.js → haml-CTolMb7J.js} +1 -1
- package/apps/web/dist/assets/{handlebars-Bw1KMTCv.js → handlebars-CrJTM5vD.js} +1 -1
- package/apps/web/dist/assets/{html-BaNo1IFo.js → html-Be6zOrn3.js} +1 -1
- package/apps/web/dist/assets/{html-derivative-f4LhlsLO.js → html-derivative-10Bvy_Et.js} +1 -1
- package/apps/web/dist/assets/{http-Be2FOF-U.js → http-GZlGWwa-.js} +1 -1
- package/apps/web/dist/assets/{hurl-BrtPQQhS.js → hurl-DEYfhn1R.js} +1 -1
- package/apps/web/dist/assets/index-CiQ326rG.js +1203 -0
- package/apps/web/dist/assets/{java-Bum5bcia.js → java-BsFb26JS.js} +1 -1
- package/apps/web/dist/assets/{javascript-C5oYhZKk.js → javascript-DDLm5bzW.js} +1 -1
- package/apps/web/dist/assets/{jinja-CNXoe8pn.js → jinja-NBeNjUXf.js} +1 -1
- package/apps/web/dist/assets/{jison-g1hzxo4W.js → jison-DKz4RK3A.js} +1 -1
- package/apps/web/dist/assets/{json-CiAAuc5j.js → json-Bg4r8xCd.js} +1 -1
- package/apps/web/dist/assets/{jsx-Z0awY4ip.js → jsx-BMnPLKsa.js} +1 -1
- package/apps/web/dist/assets/{julia-DX3JjwcM.js → julia-Wll5K4al.js} +1 -1
- package/apps/web/dist/assets/{just-DS6eCX0g.js → just-BLn1iI69.js} +1 -1
- package/apps/web/dist/assets/{latex-CaBic_pJ.js → latex-D15HRF2E.js} +1 -1
- package/apps/web/dist/assets/{liquid-DMaOBf3v.js → liquid-COHkGI02.js} +1 -1
- package/apps/web/dist/assets/{lua-DeLz5pfA.js → lua-CkbAAxOH.js} +1 -1
- package/apps/web/dist/assets/{marko-DqlzpgnR.js → marko-Dmgo6IU5.js} +1 -1
- package/apps/web/dist/assets/{mdc-CRcSVpSn.js → mdc-D_OV_cCt.js} +1 -1
- package/apps/web/dist/assets/{nginx-BvszHPa-.js → nginx-ADn2lIMW.js} +1 -1
- package/apps/web/dist/assets/{nim-B19-Hebo.js → nim-C6zTq4FI.js} +1 -1
- package/apps/web/dist/assets/{org-Dx7yMTgG.js → org-D9XGvcFV.js} +1 -1
- package/apps/web/dist/assets/{perl-C-dISCJW.js → perl-B7-K-xLH.js} +1 -1
- package/apps/web/dist/assets/{php-BHBxrwsl.js → php-CO9ZzQRp.js} +1 -1
- package/apps/web/dist/assets/{pug-4a3x4vRs.js → pug-BaAyPwTO.js} +1 -1
- package/apps/web/dist/assets/{qml-CGOhzFqi.js → qml-Cp3pyCa-.js} +1 -1
- package/apps/web/dist/assets/{r-8jr0rtQL.js → r-CT05_vfW.js} +1 -1
- package/apps/web/dist/assets/{razor-BDrURPIA.js → razor-D5rHsTSp.js} +1 -1
- package/apps/web/dist/assets/{regexp-Cg-MfP18.js → regexp-D6kzd_X5.js} +1 -1
- package/apps/web/dist/assets/{rst-0OrP55QZ.js → rst-AkFwrhnR.js} +1 -1
- package/apps/web/dist/assets/{ruby-dAApWLhj.js → ruby-LNJDVfMG.js} +1 -1
- package/apps/web/dist/assets/{sas-3_hKTjL9.js → sas-C8o3xi__.js} +1 -1
- package/apps/web/dist/assets/{scss-D6ollEQv.js → scss-Ch7Tact0.js} +1 -1
- package/apps/web/dist/assets/{shellscript-V3H5Rwnn.js → shellscript-CN-vf6fZ.js} +1 -1
- package/apps/web/dist/assets/{shellsession-TxydKEoU.js → shellsession-CxId-awN.js} +1 -1
- package/apps/web/dist/assets/{soy-t3rHc1tL.js → soy-PXtFJtGw.js} +1 -1
- package/apps/web/dist/assets/{sql-B-NkeM8R.js → sql-B7js2_Lu.js} +1 -1
- package/apps/web/dist/assets/{stata-BW6neeVL.js → stata-C0Zwsf36.js} +1 -1
- package/apps/web/dist/assets/{surrealql-CRBKdx6K.js → surrealql-DawzxLLe.js} +1 -1
- package/apps/web/dist/assets/{svelte-DTTmtyn-.js → svelte-CbCoXemF.js} +1 -1
- package/apps/web/dist/assets/{templ-CCBYQttY.js → templ-CQyn0W4C.js} +1 -1
- package/apps/web/dist/assets/{tex-D9XxqR5N.js → tex-FeZKQwyK.js} +1 -1
- package/apps/web/dist/assets/{ts-tags-CI4pWHnF.js → ts-tags-KsJvoinT.js} +1 -1
- package/apps/web/dist/assets/{tsx-DlfK3Vuf.js → tsx-Cw6pGPeZ.js} +1 -1
- package/apps/web/dist/assets/{twig-D-C2s1f2.js → twig-Cj9MLPms.js} +1 -1
- package/apps/web/dist/assets/{typescript-BnqYTwqM.js → typescript-DwZR66Da.js} +1 -1
- package/apps/web/dist/assets/{typst-DdTyavvE.js → typst--K-bHYFW.js} +1 -1
- package/apps/web/dist/assets/{vue-zbXTTw4n.js → vue-BrB0TrD_.js} +1 -1
- package/apps/web/dist/assets/{vue-html-CIXNcZez.js → vue-html-BgNSGBvl.js} +1 -1
- package/apps/web/dist/assets/{vue-vine-Czo_W6u3.js → vue-vine-C2qLo1pd.js} +1 -1
- package/apps/web/dist/assets/{xml-DWv3jkXo.js → xml-DzdlZG_g.js} +1 -1
- package/apps/web/dist/assets/{xsl-BiggF1J3.js → xsl-D3HxAJEY.js} +1 -1
- package/apps/web/dist/assets/{yaml-D9oC3m1d.js → yaml-SX7TQG-a.js} +1 -1
- package/apps/web/dist/icon-192.png +0 -0
- package/apps/web/dist/icon-512.png +0 -0
- package/apps/web/dist/icon-maskable-512.png +0 -0
- package/apps/web/dist/icon.svg +4 -2
- package/apps/web/dist/index.html +3 -3
- package/apps/web/dist/manifest.webmanifest +1 -0
- package/apps/web/dist/sw.js +1 -1
- package/package.json +1 -1
- package/apps/web/dist/assets/index-NWUHLKte.js +0 -1203
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
<img src="apps/web/public/icon-512.png" width="96" alt="OffSite logo">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# OffSite
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Claude Code, Codex and OpenCode in your browser. It uses the same sessions as their CLIs, so you can start in one and resume in the other.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Usage
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
npx offsite
|
|
@@ -20,21 +20,21 @@ Then open the pairing link it prints. It pairs this browser with the server, so
|
|
|
20
20
|
| `offsite pair` | Makes a pairing token for another browser and prints its link. `--name` labels it |
|
|
21
21
|
| `offsite unpair` | Lists paired browsers so you can pick which to unpair |
|
|
22
22
|
|
|
23
|
-
| Flag
|
|
24
|
-
|
|
|
25
|
-
| `--host`
|
|
26
|
-
| `--port`
|
|
27
|
-
| `--dir`
|
|
28
|
-
| `--no-auth`
|
|
29
|
-
| `--no-pair`
|
|
30
|
-
| `--
|
|
23
|
+
| Flag | Default | What it sets |
|
|
24
|
+
| ----------- | ------------ | ------------------------------------------------------------ |
|
|
25
|
+
| `--host` | `127.0.0.1` | Address to listen on |
|
|
26
|
+
| `--port` | `47841` | Port to listen on |
|
|
27
|
+
| `--dir` | `~/.offsite` | Folder for settings and accounts |
|
|
28
|
+
| `--no-auth` | auth on | Accept connections without the pairing token |
|
|
29
|
+
| `--no-pair` | pair on | Don't create a pairing token or print pairing links on start |
|
|
30
|
+
| `--pwa` | off | Serve the web app as an installable app that works offline |
|
|
31
31
|
|
|
32
|
-
Pairing tokens stay in `tokens.json` inside `--dir`. A running
|
|
32
|
+
Pairing tokens stay in `tokens.json` inside `--dir`. A running OffSite disconnects an unpaired browser within seconds.
|
|
33
33
|
|
|
34
34
|
> [!WARNING]
|
|
35
|
-
> With `--no-auth`, anyone who can reach its port, including any website open in your browser, can run
|
|
35
|
+
> With `--no-auth`, anyone who can reach its port, including any website open in your browser, can run your coding agents and open a terminal on your machine.
|
|
36
36
|
|
|
37
|
-
Don't use the same session in
|
|
37
|
+
Don't use the same session in OffSite and the CLI at the same time. Switching between them is fine.
|
|
38
38
|
|
|
39
39
|
## Separate instances
|
|
40
40
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
1
2
|
import { chmodSync, closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
|
2
3
|
import { createServer } from "node:http";
|
|
3
4
|
import path from "node:path";
|
|
@@ -5,7 +6,6 @@ import { consola } from "consola";
|
|
|
5
6
|
import { WebSocketServer } from "ws";
|
|
6
7
|
import os from "node:os";
|
|
7
8
|
import { deleteSession, forkSession, getSessionMessages, getSubagentMessages, listSessions, query, renameSession } from "@anthropic-ai/claude-agent-sdk";
|
|
8
|
-
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
9
9
|
import { execFile, spawn } from "node:child_process";
|
|
10
10
|
import readline from "node:readline";
|
|
11
11
|
import { z } from "zod";
|
|
@@ -13,7 +13,7 @@ import { Command } from "@commander-js/extra-typings";
|
|
|
13
13
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
14
14
|
import { OpenCode } from "@opencode/client";
|
|
15
15
|
import { ensure, headers } from "@opencode/client/service";
|
|
16
|
-
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
16
|
+
import { and, eq, gt, inArray, lte, sql } from "drizzle-orm";
|
|
17
17
|
import { sumBy } from "es-toolkit";
|
|
18
18
|
import { DatabaseSync } from "node:sqlite";
|
|
19
19
|
import { drizzle } from "drizzle-orm/node-sqlite";
|
|
@@ -127,7 +127,7 @@ var AppServer = class {
|
|
|
127
127
|
this.ready = this.call("initialize", {
|
|
128
128
|
clientInfo: {
|
|
129
129
|
name: "offsite",
|
|
130
|
-
title: "
|
|
130
|
+
title: "OffSite",
|
|
131
131
|
version: "0.0.0"
|
|
132
132
|
},
|
|
133
133
|
capabilities: {
|
|
@@ -698,12 +698,12 @@ function record$1(value) {
|
|
|
698
698
|
//#region src/args.ts
|
|
699
699
|
let command = "web";
|
|
700
700
|
let name = null;
|
|
701
|
-
const program = new Command("offsite").description("Starts the server and the web app").option("--host <host>", "Address to listen on", "127.0.0.1").option("--port <port>", "Port to listen on", "47841").option("--dir <dir>", "Folder for settings and accounts", path.join(os.homedir(), ".offsite")).option("--no-auth", "Accept connections without
|
|
701
|
+
const program = new Command("offsite").description("Starts the server and the web app").option("--host <host>", "Address to listen on", "127.0.0.1").option("--port <port>", "Port to listen on", "47841").option("--dir <dir>", "Folder for settings and accounts", path.join(os.homedir(), ".offsite")).option("--no-auth", "Accept connections without pairing").option("--no-pair", "Don't create a pairing code or print pairing links").option("--pwa", "Serve the web app as an installable app that works offline");
|
|
702
702
|
program.command("web", { isDefault: true }).description("Starts the server and the web app, same as offsite");
|
|
703
703
|
program.command("serve").description("Starts only the server, without the web app").action(() => {
|
|
704
704
|
command = "serve";
|
|
705
705
|
});
|
|
706
|
-
program.command("pair").description("Creates a pairing
|
|
706
|
+
program.command("pair").description("Creates a pairing code for another browser and prints its link").option("--name <name>", "Name to recognize this browser by").action((options) => {
|
|
707
707
|
command = "pair";
|
|
708
708
|
name = options.name ?? null;
|
|
709
709
|
});
|
|
@@ -2423,10 +2423,16 @@ const sessions$1 = snakeCase.table("sessions", {
|
|
|
2423
2423
|
"minimized"
|
|
2424
2424
|
] }).notNull()
|
|
2425
2425
|
});
|
|
2426
|
-
const
|
|
2426
|
+
const pairingCodes = snakeCase.table("pairing_codes", {
|
|
2427
|
+
hash: text().primaryKey(),
|
|
2428
|
+
name: text(),
|
|
2429
|
+
expiresAt: integer().notNull()
|
|
2430
|
+
});
|
|
2431
|
+
const devices = snakeCase.table("devices", {
|
|
2427
2432
|
hash: text().primaryKey(),
|
|
2428
2433
|
name: text(),
|
|
2429
2434
|
createdAt: integer().notNull(),
|
|
2435
|
+
expiresAt: integer().notNull(),
|
|
2430
2436
|
lastUsedAt: integer()
|
|
2431
2437
|
});
|
|
2432
2438
|
const pushSubscriptions = snakeCase.table("push_subscriptions", {
|
|
@@ -5821,38 +5827,58 @@ async function answer(client, text) {
|
|
|
5821
5827
|
}
|
|
5822
5828
|
}
|
|
5823
5829
|
//#endregion
|
|
5824
|
-
//#region src/storage/
|
|
5825
|
-
|
|
5826
|
-
|
|
5830
|
+
//#region src/storage/auth.ts
|
|
5831
|
+
const CODE_LIFETIME = 3e5;
|
|
5832
|
+
const DEVICE_LIFETIME = 2592e6;
|
|
5833
|
+
function addCode(name) {
|
|
5834
|
+
const code = randomBytes(16).toString("base64url");
|
|
5835
|
+
db.delete(pairingCodes).where(lte(pairingCodes.expiresAt, Date.now())).run();
|
|
5836
|
+
db.insert(pairingCodes).values({
|
|
5837
|
+
hash: hash(code),
|
|
5838
|
+
name,
|
|
5839
|
+
expiresAt: Date.now() + CODE_LIFETIME
|
|
5840
|
+
}).run();
|
|
5841
|
+
return code;
|
|
5827
5842
|
}
|
|
5828
|
-
function
|
|
5843
|
+
function redeemCode(code) {
|
|
5844
|
+
const found = db.delete(pairingCodes).where(eq(pairingCodes.hash, hash(code))).returning().get();
|
|
5845
|
+
if (found === void 0 || found.expiresAt <= Date.now()) return null;
|
|
5829
5846
|
const token = randomBytes(32).toString("base64url");
|
|
5830
|
-
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
|
|
5847
|
+
const now = Date.now();
|
|
5848
|
+
db.insert(devices).values({
|
|
5849
|
+
hash: hash(token),
|
|
5850
|
+
name: found.name,
|
|
5851
|
+
createdAt: now,
|
|
5852
|
+
expiresAt: now + DEVICE_LIFETIME
|
|
5834
5853
|
}).run();
|
|
5835
5854
|
return token;
|
|
5836
5855
|
}
|
|
5837
|
-
function
|
|
5838
|
-
db.
|
|
5856
|
+
function findDevice(token) {
|
|
5857
|
+
const found = db.select().from(devices).where(and(eq(devices.hash, hash(token)), gt(devices.expiresAt, Date.now()))).get();
|
|
5858
|
+
return found === void 0 ? null : found.hash;
|
|
5859
|
+
}
|
|
5860
|
+
function isPaired(device) {
|
|
5861
|
+
return db.select().from(devices).where(and(eq(devices.hash, device), gt(devices.expiresAt, Date.now()))).get() !== void 0;
|
|
5839
5862
|
}
|
|
5840
|
-
function
|
|
5841
|
-
return db.select().from(
|
|
5863
|
+
function hasDevices() {
|
|
5864
|
+
return db.select().from(devices).where(gt(devices.expiresAt, Date.now())).limit(1).get() !== void 0;
|
|
5842
5865
|
}
|
|
5843
|
-
function
|
|
5844
|
-
|
|
5845
|
-
return db.select().from(tokens).where(eq(tokens.hash, hashToken(given))).get() !== void 0;
|
|
5866
|
+
function readDevices() {
|
|
5867
|
+
return db.select().from(devices).orderBy(devices.createdAt).all();
|
|
5846
5868
|
}
|
|
5847
|
-
function
|
|
5848
|
-
db.
|
|
5869
|
+
function removeDevices(hashes) {
|
|
5870
|
+
db.delete(devices).where(inArray(devices.hash, hashes)).run();
|
|
5849
5871
|
}
|
|
5850
|
-
function
|
|
5851
|
-
|
|
5872
|
+
function recordUse(device) {
|
|
5873
|
+
db.update(devices).set({ lastUsedAt: Date.now() }).where(eq(devices.hash, device)).run();
|
|
5874
|
+
}
|
|
5875
|
+
function hash(secret) {
|
|
5876
|
+
return createHash("sha256").update(secret).digest("hex");
|
|
5852
5877
|
}
|
|
5853
5878
|
//#endregion
|
|
5854
5879
|
//#region src/main.ts
|
|
5855
5880
|
const UNPAIR_CHECK = 5e3;
|
|
5881
|
+
const TICKET_LIFETIME = 6e4;
|
|
5856
5882
|
const contentTypes = {
|
|
5857
5883
|
".html": "text/html; charset=utf-8",
|
|
5858
5884
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -5870,12 +5896,10 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
|
5870
5896
|
process.exit(1);
|
|
5871
5897
|
}
|
|
5872
5898
|
const address = `http://${args.host}:${port}`;
|
|
5873
|
-
const fallback = args.fallback === void 0 ? null : readFallback(args.fallback);
|
|
5874
5899
|
if (args.command === "pair") {
|
|
5875
|
-
const links = pairingLinks(
|
|
5876
|
-
console.log("Open this link in
|
|
5900
|
+
const links = pairingLinks(addCode(args.name));
|
|
5901
|
+
console.log("Open this link in a browser within 5 minutes to pair it:");
|
|
5877
5902
|
console.log(` ${links.local}`);
|
|
5878
|
-
if (fallback !== null) console.log(`Fallback: ${fallback}`);
|
|
5879
5903
|
process.exit(0);
|
|
5880
5904
|
}
|
|
5881
5905
|
if (args.command === "unpair") {
|
|
@@ -5891,9 +5915,14 @@ if (findClaude() === null) {
|
|
|
5891
5915
|
process.exit(1);
|
|
5892
5916
|
}
|
|
5893
5917
|
const publicDir = path.join(import.meta.dirname, "..", "..", "web", "dist");
|
|
5894
|
-
const
|
|
5918
|
+
const code = args.auth && args.pair && !hasDevices() ? addCode(null) : null;
|
|
5919
|
+
const tickets = /* @__PURE__ */ new Map();
|
|
5895
5920
|
const server = createServer((request, response) => {
|
|
5896
5921
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
5922
|
+
if (url.pathname.startsWith("/api/")) {
|
|
5923
|
+
answerApi(url.pathname, request, response);
|
|
5924
|
+
return;
|
|
5925
|
+
}
|
|
5897
5926
|
if (args.command === "serve") {
|
|
5898
5927
|
response.statusCode = 404;
|
|
5899
5928
|
response.end("offsite serve has no web app");
|
|
@@ -5905,26 +5934,27 @@ const sockets = new WebSocketServer({ noServer: true });
|
|
|
5905
5934
|
const paired = /* @__PURE__ */ new Map();
|
|
5906
5935
|
server.on("upgrade", (request, socket, head) => {
|
|
5907
5936
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
5908
|
-
const
|
|
5909
|
-
if (url.pathname !== "/ws" ||
|
|
5937
|
+
const device = args.auth ? redeemTicket(url.searchParams.get("ticket")) : null;
|
|
5938
|
+
if (url.pathname !== "/ws" || args.auth && device === null) {
|
|
5910
5939
|
socket.destroy();
|
|
5911
5940
|
return;
|
|
5912
5941
|
}
|
|
5913
5942
|
sockets.handleUpgrade(request, socket, head, (client) => {
|
|
5914
5943
|
connectClient(client);
|
|
5915
|
-
if (
|
|
5916
|
-
paired.set(client,
|
|
5944
|
+
if (device === null) return;
|
|
5945
|
+
paired.set(client, device);
|
|
5917
5946
|
client.on("close", () => paired.delete(client));
|
|
5918
|
-
recordUse(
|
|
5947
|
+
recordUse(device);
|
|
5919
5948
|
});
|
|
5920
5949
|
});
|
|
5921
5950
|
if (args.auth) setInterval(() => {
|
|
5922
|
-
for (const [client,
|
|
5951
|
+
for (const [client, device] of paired) if (!isPaired(device)) client.close();
|
|
5952
|
+
for (const [ticket, item] of tickets) if (item.expiresAt <= Date.now()) tickets.delete(ticket);
|
|
5923
5953
|
}, UNPAIR_CHECK);
|
|
5924
5954
|
server.listen(port, args.host, () => {
|
|
5925
5955
|
startPush(`${args.host}:${port}`);
|
|
5926
|
-
if (args.pair && (
|
|
5927
|
-
const links = pairingLinks(
|
|
5956
|
+
if (args.pair && (code !== null || !args.auth)) {
|
|
5957
|
+
const links = pairingLinks(code);
|
|
5928
5958
|
if (args.command === "web") console.log(`offsite is running at ${links.local}`);
|
|
5929
5959
|
} else {
|
|
5930
5960
|
console.log(`offsite is running at ${address}`);
|
|
@@ -5938,12 +5968,67 @@ function stop() {
|
|
|
5938
5968
|
stopAll();
|
|
5939
5969
|
process.exit(0);
|
|
5940
5970
|
}
|
|
5941
|
-
function
|
|
5942
|
-
|
|
5971
|
+
function answerApi(pathname, request, response) {
|
|
5972
|
+
response.setHeader("access-control-allow-origin", "*");
|
|
5973
|
+
if (request.method === "OPTIONS") {
|
|
5974
|
+
response.setHeader("access-control-allow-methods", "POST");
|
|
5975
|
+
response.setHeader("access-control-allow-headers", "authorization");
|
|
5976
|
+
response.statusCode = 204;
|
|
5977
|
+
response.end();
|
|
5978
|
+
return;
|
|
5979
|
+
}
|
|
5980
|
+
if (request.method !== "POST") {
|
|
5981
|
+
response.statusCode = 405;
|
|
5982
|
+
response.end("use POST");
|
|
5983
|
+
return;
|
|
5984
|
+
}
|
|
5985
|
+
const given = request.headers.authorization?.match(/^Bearer (.+)$/)?.[1] ?? null;
|
|
5986
|
+
if (pathname === "/api/pair") {
|
|
5987
|
+
const token = given === null ? null : redeemCode(given);
|
|
5988
|
+
if (token === null) {
|
|
5989
|
+
response.statusCode = 401;
|
|
5990
|
+
response.end("That pairing code expired or was already used");
|
|
5991
|
+
return;
|
|
5992
|
+
}
|
|
5993
|
+
answerJson(response, { token });
|
|
5994
|
+
return;
|
|
5995
|
+
}
|
|
5996
|
+
if (pathname === "/api/ticket") {
|
|
5997
|
+
if (!args.auth) {
|
|
5998
|
+
answerJson(response, { ticket: null });
|
|
5999
|
+
return;
|
|
6000
|
+
}
|
|
6001
|
+
const device = given === null ? null : findDevice(given);
|
|
6002
|
+
if (device === null) {
|
|
6003
|
+
response.statusCode = 401;
|
|
6004
|
+
response.end("This browser needs pairing");
|
|
6005
|
+
return;
|
|
6006
|
+
}
|
|
6007
|
+
const ticket = randomBytes(32).toString("base64url");
|
|
6008
|
+
tickets.set(ticket, {
|
|
6009
|
+
device,
|
|
6010
|
+
expiresAt: Date.now() + TICKET_LIFETIME
|
|
6011
|
+
});
|
|
6012
|
+
answerJson(response, { ticket });
|
|
6013
|
+
return;
|
|
6014
|
+
}
|
|
6015
|
+
response.statusCode = 404;
|
|
6016
|
+
response.end("not found");
|
|
6017
|
+
}
|
|
6018
|
+
function answerJson(response, body) {
|
|
6019
|
+
response.setHeader("content-type", "application/json");
|
|
6020
|
+
response.end(JSON.stringify(body));
|
|
6021
|
+
}
|
|
6022
|
+
function redeemTicket(ticket) {
|
|
6023
|
+
if (ticket === null) return null;
|
|
6024
|
+
const found = tickets.get(ticket);
|
|
6025
|
+
tickets.delete(ticket);
|
|
6026
|
+
if (found === void 0 || found.expiresAt <= Date.now()) return null;
|
|
6027
|
+
return found.device;
|
|
5943
6028
|
}
|
|
5944
6029
|
async function unpair() {
|
|
5945
|
-
const
|
|
5946
|
-
if (
|
|
6030
|
+
const devices = readDevices();
|
|
6031
|
+
if (devices.length === 0) {
|
|
5947
6032
|
console.log("No browsers are paired");
|
|
5948
6033
|
return;
|
|
5949
6034
|
}
|
|
@@ -5955,11 +6040,12 @@ async function unpair() {
|
|
|
5955
6040
|
type: "multiselect",
|
|
5956
6041
|
required: false,
|
|
5957
6042
|
cancel: "null",
|
|
5958
|
-
options:
|
|
6043
|
+
options: devices.map((item) => ({
|
|
5959
6044
|
value: item.hash,
|
|
5960
6045
|
label: [
|
|
5961
6046
|
item.name ?? "Unnamed",
|
|
5962
6047
|
`paired ${new Date(item.createdAt).toLocaleString()}`,
|
|
6048
|
+
`${item.expiresAt <= Date.now() ? "expired" : "expires"} ${new Date(item.expiresAt).toLocaleString()}`,
|
|
5963
6049
|
item.lastUsedAt === null ? "never used" : `last used ${new Date(item.lastUsedAt).toLocaleString()}`
|
|
5964
6050
|
].join(" · ")
|
|
5965
6051
|
}))
|
|
@@ -5968,30 +6054,21 @@ async function unpair() {
|
|
|
5968
6054
|
console.log("Nothing unpaired");
|
|
5969
6055
|
return;
|
|
5970
6056
|
}
|
|
5971
|
-
|
|
6057
|
+
removeDevices(picked);
|
|
5972
6058
|
console.log(`Unpaired ${picked.length} ${picked.length === 1 ? "browser" : "browsers"}; a running offsite disconnects them within seconds`);
|
|
5973
6059
|
}
|
|
5974
|
-
function
|
|
5975
|
-
let url;
|
|
5976
|
-
try {
|
|
5977
|
-
url = new URL(link);
|
|
5978
|
-
} catch {
|
|
5979
|
-
console.error(`invalid fallback URL ${link}`);
|
|
5980
|
-
process.exit(1);
|
|
5981
|
-
}
|
|
5982
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
5983
|
-
console.error(`the fallback URL ${link} needs http:// or https://`);
|
|
5984
|
-
process.exit(1);
|
|
5985
|
-
}
|
|
5986
|
-
return url.origin;
|
|
5987
|
-
}
|
|
5988
|
-
function pairingLinks(token) {
|
|
6060
|
+
function pairingLinks(code) {
|
|
5989
6061
|
return { local: `${address}/pair#${new URLSearchParams({
|
|
5990
6062
|
machine: address,
|
|
5991
|
-
...
|
|
6063
|
+
...code !== null && { code }
|
|
5992
6064
|
})}` };
|
|
5993
6065
|
}
|
|
5994
6066
|
function serveFile(pathname, response) {
|
|
6067
|
+
if (!args.pwa && pathname === "/manifest.webmanifest") {
|
|
6068
|
+
response.statusCode = 404;
|
|
6069
|
+
response.end("offsite runs without --pwa");
|
|
6070
|
+
return;
|
|
6071
|
+
}
|
|
5995
6072
|
const requested = path.join(publicDir, path.normalize(pathname));
|
|
5996
6073
|
const file = requested.startsWith(publicDir + path.sep) && statSync(requested, { throwIfNoEntry: false })?.isFile() ? requested : path.join(publicDir, "index.html");
|
|
5997
6074
|
if (!existsSync(file)) {
|
|
@@ -6000,6 +6077,10 @@ function serveFile(pathname, response) {
|
|
|
6000
6077
|
return;
|
|
6001
6078
|
}
|
|
6002
6079
|
response.setHeader("content-type", contentTypes[path.extname(file)] ?? "application/octet-stream");
|
|
6080
|
+
if (!args.pwa && path.basename(file) === "index.html") {
|
|
6081
|
+
response.end(readFileSync(file, "utf8").replace(/<link rel="manifest"[^>]*>/, ""));
|
|
6082
|
+
return;
|
|
6083
|
+
}
|
|
6003
6084
|
createReadStream(file).pipe(response);
|
|
6004
6085
|
}
|
|
6005
6086
|
//#endregion
|