Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
211599e0fc | ||
|
|
ac1048c8f3 | ||
|
|
dd6b48fcf5 | ||
|
|
6e678cbcde |
@@ -129,7 +129,7 @@ Server push only. `pushBotNotice` (`server:bot-message`, `server:bot-updated`),
|
|||||||
|
|
||||||
- An AIO sandbox per docker container on `AppData.DockerNetworkName`, managed over the docker socket (`src/functions/backend/docker/`). `containers` records every container except the app's own. `createComputer` + background `provisionComputer` (status `creating` until the health check passes). Resource changes recreate the container.
|
- An AIO sandbox per docker container on `AppData.DockerNetworkName`, managed over the docker socket (`src/functions/backend/docker/`). `containers` records every container except the app's own. `createComputer` + background `provisionComputer` (status `creating` until the health check passes). Resource changes recreate the container.
|
||||||
- Only `/home/gem/workspace` is mounted, from `DATA_DIR/computers/<id>` (with `bots/`, `workspaces/`, marker `AppData.ComputerWorkspaceMarker`), plus the browser profile; the rest of home lives in the container.
|
- Only `/home/gem/workspace` is mounted, from `DATA_DIR/computers/<id>` (with `bots/`, `workspaces/`, marker `AppData.ComputerWorkspaceMarker`), plus the browser profile; the rest of home lives in the container.
|
||||||
- **Browser profile:** `DATA_DIR/computer-browsers/<id>` mounted at `~/.cobble-browser`, which Chrome is pointed at (`BROWSER_EXTRA_ARGS=--user-data-dir=... --password-store=basic`), so logins, extensions and site permissions survive restarts and recreates. Never use the image's `~/.config/browser`: every boot overwrites its `Default/Preferences` with session-only cookies. Seed prefs (`computer-browser-preferences.ts`) are written only when missing; stale `Singleton*` links are removed before each provision. Bumping `AppData.ComputerWorkspaceLayout` recreates every computer once (`migrateComputerLayout`, skipped while a job runs on it). Deleting a computer keeps its directory. Outside docker (dev) containers are reached by IP (`pickReachableAddress`).
|
- **Browser profile:** `DATA_DIR/computer-browsers/<id>` mounted at `~/.cobble-browser`, which Chrome is pointed at (`BROWSER_EXTRA_ARGS=--user-data-dir=... --password-store=basic`), so logins, extensions and site permissions survive restarts and recreates. Never use the image's `~/.config/browser`: every boot overwrites its `Default/Preferences` with session-only cookies. Seed prefs (`computer-browser-preferences.ts`) are written only when missing; stale `Singleton*` links are removed before each provision. Bumping `AppData.ComputerWorkspaceLayout` recreates every computer once (`migrateComputerLayout`, skipped while a job runs on it). Default extensions (`ComputerBrowserExtensions`, `normal_installed`: can be turned off, not removed) come from a managed policy written as root into the running container (`syncComputerBrowserPolicy`, on provision and every `sync-computers` run), not a mount, so existing computers get new ones without a recreate; Chrome installs them live. Deleting a computer keeps its directory. Outside docker (dev) containers are reached by IP (`pickReachableAddress`).
|
||||||
- One main computer (`ensureMainComputer`), whose coordinator grant is locked (`isLockedComputerAccess`). With `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. Only super admins create/delete/retry computers.
|
- One main computer (`ensureMainComputer`), whose coordinator grant is locked (`isLockedComputerAccess`). With `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. Only super admins create/delete/retry computers.
|
||||||
- A bot's effective computers (`grab-bot-computers`): the workspace's in a workspace; otherwise direct grants plus its workspaces' (coordinator: all); `bots.computer_id` first. Only a `running` (healthy) computer is reached (`grabComputerMcpUrl` `ready`, `computerNotReadyMessage`).
|
- A bot's effective computers (`grab-bot-computers`): the workspace's in a workspace; otherwise direct grants plus its workspaces' (coordinator: all); `bots.computer_id` first. Only a `running` (healthy) computer is reached (`grabComputerMcpUrl` `ready`, `computerNotReadyMessage`).
|
||||||
- **Folders (enforced in tools, not a Linux user):** a run works only in `bots/<bot>/users/<user>` (cwd, `HOME`), `bots/<bot>` and `workspaces/<workspace>` (`computerRunDirs`, `withComputerDirs`, `resolveComputerPath`; `sudo` refused).
|
- **Folders (enforced in tools, not a Linux user):** a run works only in `bots/<bot>/users/<user>` (cwd, `HOME`), `bots/<bot>` and `workspaces/<workspace>` (`computerRunDirs`, `withComputerDirs`, `resolveComputerPath`; `sudo` refused).
|
||||||
@@ -161,7 +161,7 @@ Set in Settings > Environment (super admin), not `.env`. `envs` (private; encryp
|
|||||||
- **Install name:** app setting `install_name` (Settings > General, super admin). `handlePwaFile` rewrites the manifest's `name`/`short_name` per request (`namePwaManifest`), the root head uses it for iOS tags, the loading page's `<title>` is the `{{name}}` placeholder. Devices pick up a new name only on reinstall.
|
- **Install name:** app setting `install_name` (Settings > General, super admin). `handlePwaFile` rewrites the manifest's `name`/`short_name` per request (`namePwaManifest`), the root head uses it for iOS tags, the loading page's `<title>` is the `{{name}}` placeholder. Devices pick up a new name only on reinstall.
|
||||||
- **Install icon:** app setting `install_icon` (JSON, `parseInstallIcon`): a stone, or an upload (decoded, cropped to `source.png`, never stored as sent, no SVG) with the stone at `PwaCornerMarkInset`/`PwaCornerMarkScale`, inside a round launcher's crop. `saveInstallIcon` draws the set (`renderInstallIcons`, shared with `gen:favicon` via `renderAppIcon`) into `DATA_DIR/install-icon/<version>/`, served publicly and `immutable` by `handleInstallIconFile`; the manifest, head links and push payloads point at it. Ember stone = stock icons, no set.
|
- **Install icon:** app setting `install_icon` (JSON, `parseInstallIcon`): a stone, or an upload (decoded, cropped to `source.png`, never stored as sent, no SVG) with the stone at `PwaCornerMarkInset`/`PwaCornerMarkScale`, inside a round launcher's crop. `saveInstallIcon` draws the set (`renderInstallIcons`, shared with `gen:favicon` via `renderAppIcon`) into `DATA_DIR/install-icon/<version>/`, served publicly and `immutable` by `handleInstallIconFile`; the manifest, head links and push payloads point at it. Ember stone = stock icons, no set.
|
||||||
- Notification `badge` is `icons/badge-96.png` (white silhouette; Android masks by alpha, so never the opaque app icon). Brave on Android has no WebAPK, so its notifications always carry Brave's name and icon.
|
- Notification `badge` is `icons/badge-96.png` (white silhouette; Android masks by alpha, so never the opaque app icon). Brave on Android has no WebAPK, so its notifications always carry Brave's name and icon.
|
||||||
- `public/sw.js` is served `no-store`; navigations network-only (authenticated HTML never cached); `/api/`, `/ws`, `/media/`, `/computers/`, `/build-info.json` never intercepted. It never reloads on its own. In development the worker and caches are actively removed (`clear-development-service-worker.ts`, stub `/sw.js`).
|
- `public/sw.js` is served `no-store`; navigations network-only (authenticated HTML never cached); `/api/`, `/ws`, `/media/`, `/computers/`, `/build-info.json` never intercepted. A new worker takes over by itself (`skipWaiting` on install; nothing in it is per-version) and never reloads a page; there is no update prompt. In development the worker and caches are actively removed (`clear-development-service-worker.ts`, stub `/sw.js`).
|
||||||
- **Precache (production):** a navigation without the current build cookie gets `loading.html`, which downloads all of `.bunext/public` into `cobble-assets-<build_id>`, verifies, sets the cookie and reloads (falls back to network on stall). React knows nothing of this.
|
- **Precache (production):** a navigation without the current build cookie gets `loading.html`, which downloads all of `.bunext/public` into `cobble-assets-<build_id>`, verifies, sets the cookie and reloads (falls back to network on stall). React knows nothing of this.
|
||||||
- Phone shell: rail is a slide-over below `md`; rail internals use the `@rail:` container variant, not viewport `lg:`. `LiveSocket` is remounted on online/pageshow/visibility since twui stops retrying after three attempts. Nothing is queued offline.
|
- Phone shell: rail is a slide-over below `md`; rail internals use the `@rail:` container variant, not viewport `lg:`. `LiveSocket` is remounted on online/pageshow/visibility since twui stops retrying after three attempts. Nothing is queued offline.
|
||||||
- **Web Push:** `push_subscriptions`, `user_presence`; VAPID keys generated into `envs` by `ensureVapidKeys`. `pushBotNotice` is the one trigger, skipped while the user is present (`AppData.PushPresenceWindowMs`, per user not per device). Best effort, no retry. Needs HTTPS.
|
- **Web Push:** `push_subscriptions`, `user_presence`; VAPID keys generated into `envs` by `ensureVapidKeys`. `pushBotNotice` is the one trigger, skipped while the user is present (`AppData.PushPresenceWindowMs`, per user not per device). Best effort, no retry. Needs HTTPS.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cobble",
|
"name": "cobble",
|
||||||
"version": "0.5.0",
|
"version": "0.5.2",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
+9
-3
@@ -85,7 +85,14 @@ function isBypassRequest(url) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nothing the worker holds belongs to one version (navigations go to the
|
||||||
|
* network, the code cache belongs to the build), so a new worker takes
|
||||||
|
* over at once instead of waiting for every tab to close. It never
|
||||||
|
* reloads a page: the open one keeps running, now served by it.
|
||||||
|
*/
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
|
self.skipWaiting();
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then((cache) =>
|
caches.open(CACHE_NAME).then((cache) =>
|
||||||
Promise.allSettled(SHELL.map((url) => cache.add(url))),
|
Promise.allSettled(SHELL.map((url) => cache.add(url))),
|
||||||
@@ -190,9 +197,8 @@ async function cacheFirst(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The page asks for a new version from its update notice: nothing skips
|
* The loading page asks a waiting worker to take over. Workers from
|
||||||
* the wait until the user says so. The loading page asks too, since it
|
* before `install` skipped the wait on their own only answer this.
|
||||||
* isn't the app and nothing of the app's is running.
|
|
||||||
*/
|
*/
|
||||||
self.addEventListener("message", (event) => {
|
self.addEventListener("message", (event) => {
|
||||||
if (event.data?.type === "SKIP_WAITING") {
|
if (event.data?.type === "SKIP_WAITING") {
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import Paper from "@moduletrace/twui/elements/Paper";
|
|
||||||
import Row from "@moduletrace/twui/layout/Row";
|
|
||||||
import Span from "@moduletrace/twui/layout/Span";
|
|
||||||
import Button from "@moduletrace/twui/layout/Button";
|
|
||||||
import { RefreshCw } from "lucide-react";
|
|
||||||
import { PwaServiceWorkerPath } from "@/src/data/pwa-data";
|
import { PwaServiceWorkerPath } from "@/src/data/pwa-data";
|
||||||
import clearDevelopmentServiceWorker from "@/src/functions/frontend/clear-development-service-worker";
|
import clearDevelopmentServiceWorker from "@/src/functions/frontend/clear-development-service-worker";
|
||||||
|
|
||||||
@@ -24,8 +19,11 @@ const DevelopmentClearMaxAttempts = 3;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers the app's service worker (production only, so development
|
* Registers the app's service worker (production only, so development
|
||||||
* HMR is never cached) and offers a reload once a new version is
|
* HMR is never cached) and checks for a new one hourly and whenever the
|
||||||
* installed. It never reloads on its own: a bot reply may be running.
|
* app comes back into view. A new worker takes over by itself
|
||||||
|
* (`public/sw.js`), so there is nothing to offer and nothing reloads: a
|
||||||
|
* bot reply may be running. New builds reach the device through the
|
||||||
|
* loading page.
|
||||||
*
|
*
|
||||||
* In development it does the opposite: it clears the worker and the
|
* In development it does the opposite: it clears the worker and the
|
||||||
* caches a production deploy left on the device, so the dev server's
|
* caches a production deploy left on the device, so the dev server's
|
||||||
@@ -33,8 +31,6 @@ const DevelopmentClearMaxAttempts = 3;
|
|||||||
* `clear-development-service-worker.ts`).
|
* `clear-development-service-worker.ts`).
|
||||||
*/
|
*/
|
||||||
export default function ServiceWorkerRegistrar() {
|
export default function ServiceWorkerRegistrar() {
|
||||||
const [update_ready, setUpdateReady] = useState(false);
|
|
||||||
const [reloading, setReloading] = useState(false);
|
|
||||||
const registration = useRef<ServiceWorkerRegistration | null>(null);
|
const registration = useRef<ServiceWorkerRegistration | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,26 +41,6 @@ export default function ServiceWorkerRegistrar() {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let interval: ReturnType<typeof setInterval> | undefined;
|
let interval: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
const watch = (reg: ServiceWorkerRegistration) => {
|
|
||||||
if (reg.waiting && navigator.serviceWorker.controller) {
|
|
||||||
setUpdateReady(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
reg.addEventListener("updatefound", () => {
|
|
||||||
const installing = reg.installing;
|
|
||||||
|
|
||||||
installing?.addEventListener("statechange", () => {
|
|
||||||
if (cancelled) return;
|
|
||||||
if (
|
|
||||||
installing.state == "installed" &&
|
|
||||||
navigator.serviceWorker.controller
|
|
||||||
) {
|
|
||||||
setUpdateReady(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register(PwaServiceWorkerPath, {
|
.register(PwaServiceWorkerPath, {
|
||||||
scope: "/",
|
scope: "/",
|
||||||
@@ -73,7 +49,6 @@ export default function ServiceWorkerRegistrar() {
|
|||||||
.then((reg) => {
|
.then((reg) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
registration.current = reg;
|
registration.current = reg;
|
||||||
watch(reg);
|
|
||||||
interval = setInterval(() => reg.update().catch(() => {}), UpdateCheckMs);
|
interval = setInterval(() => reg.update().catch(() => {}), UpdateCheckMs);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -160,57 +135,5 @@ export default function ServiceWorkerRegistrar() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
return null;
|
||||||
if (!reloading) return;
|
|
||||||
|
|
||||||
const onControllerChange = () => window.location.reload();
|
|
||||||
navigator.serviceWorker?.addEventListener("controllerchange", onControllerChange);
|
|
||||||
registration.current?.waiting?.postMessage({ type: "SKIP_WAITING" });
|
|
||||||
const fallback = setTimeout(() => window.location.reload(), 1500);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(fallback);
|
|
||||||
navigator.serviceWorker?.removeEventListener("controllerchange", onControllerChange);
|
|
||||||
};
|
|
||||||
}, [reloading]);
|
|
||||||
|
|
||||||
if (!update_ready) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
className="fixed bottom-4 right-4 z-[250] w-[min(320px,calc(100vw-32px))] gap-3! p-4 bg-surface shadow-popover! border-hairline dark:bg-surface dark:border-hairline"
|
|
||||||
>
|
|
||||||
<Span className="text-sm font-bold leading-5 text-ink">
|
|
||||||
A new version is ready
|
|
||||||
</Span>
|
|
||||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
|
||||||
Reload to use it. Your conversations are saved.
|
|
||||||
</Span>
|
|
||||||
<Row className="gap-2! flex-nowrap justify-end">
|
|
||||||
<Button
|
|
||||||
title="Reload later"
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
color="gray"
|
|
||||||
size="small"
|
|
||||||
disabled={reloading}
|
|
||||||
onClick={() => setUpdateReady(false)}
|
|
||||||
>
|
|
||||||
Later
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
title="Reload the app"
|
|
||||||
type="button"
|
|
||||||
size="small"
|
|
||||||
loading={reloading}
|
|
||||||
beforeIcon={<RefreshCw size={15} />}
|
|
||||||
onClick={() => setReloading(true)}
|
|
||||||
>
|
|
||||||
Reload
|
|
||||||
</Button>
|
|
||||||
</Row>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
|||||||
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
||||||
import migrateComputerLayout from "@/src/functions/backend/db/computers/migrate-computer-layout";
|
import migrateComputerLayout from "@/src/functions/backend/db/computers/migrate-computer-layout";
|
||||||
import refreshComputerContainer from "@/src/functions/backend/db/computers/refresh-computer-container";
|
import refreshComputerContainer from "@/src/functions/backend/db/computers/refresh-computer-container";
|
||||||
|
import syncComputerBrowserPolicy from "@/src/functions/backend/db/computers/sync-computer-browser-policy";
|
||||||
|
|
||||||
type Row = Pick<
|
type Row = Pick<
|
||||||
BUN_SQLITE_COBBLE_CONTAINERS,
|
BUN_SQLITE_COBBLE_CONTAINERS,
|
||||||
@@ -14,7 +15,9 @@ type Row = Pick<
|
|||||||
* (`refreshComputerContainer`), telling owners about changes. Containers
|
* (`refreshComputerContainer`), telling owners about changes. Containers
|
||||||
* still being set up, or that failed, are left alone. A container that
|
* still being set up, or that failed, are left alone. A container that
|
||||||
* still mounts its computer's whole home is recreated with only the
|
* still mounts its computer's whole home is recreated with only the
|
||||||
* workspace (`migrateComputerLayout`, once per computer).
|
* workspace (`migrateComputerLayout`, once per computer). A running one
|
||||||
|
* gets the browser policy it lacks (`syncComputerBrowserPolicy`), so
|
||||||
|
* computers made before an extension was added get it too.
|
||||||
*/
|
*/
|
||||||
export default async function syncComputers() {
|
export default async function syncComputers() {
|
||||||
const rows =
|
const rows =
|
||||||
@@ -37,5 +40,9 @@ export default async function syncComputers() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (refreshed?.changed) changed++;
|
if (refreshed?.changed) changed++;
|
||||||
|
|
||||||
|
if (refreshed?.status == "running" && container.docker_id) {
|
||||||
|
await syncComputerBrowserPolicy({ computer_id, docker_id: container.docker_id });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,12 @@ export const AppData = {
|
|||||||
*/
|
*/
|
||||||
ComputerBrowserDataDir: "computer-browsers",
|
ComputerBrowserDataDir: "computer-browsers",
|
||||||
ComputerBrowserProfileDir: "/home/gem/.cobble-browser",
|
ComputerBrowserProfileDir: "/home/gem/.cobble-browser",
|
||||||
|
/**
|
||||||
|
* The managed policy file a computer's Chrome reads (the image's
|
||||||
|
* Chrome looks in `/etc/chromium/policies/managed`), written into the
|
||||||
|
* container by `syncComputerBrowserPolicy`.
|
||||||
|
*/
|
||||||
|
ComputerBrowserPolicyFile: "/etc/chromium/policies/managed/cobble.json",
|
||||||
/**
|
/**
|
||||||
* Written at the root of a computer's workspace once it uses that
|
* Written at the root of a computer's workspace once it uses that
|
||||||
* layout, so an older computer's whole home is told apart and moved
|
* layout, so an older computer's whole home is told apart and moved
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Chrome Web Store extensions every computer's browser gets, installed by
|
||||||
|
* a managed policy (`computerBrowserPolicy`, written by
|
||||||
|
* `syncComputerBrowserPolicy`). They're installed as `normal_installed`:
|
||||||
|
* a user can turn one off in that browser, but not remove it.
|
||||||
|
*/
|
||||||
|
export const ComputerBrowserExtensions = [
|
||||||
|
{ id: "hlkenndednhfkekhgcdicdfddnkalmdm", name: "Cookie-Editor" },
|
||||||
|
] as const;
|
||||||
@@ -7,6 +7,7 @@ import { logError } from "@/src/functions/backend/logger";
|
|||||||
import prepareComputerBrowserDir from "./prepare-computer-browser-dir";
|
import prepareComputerBrowserDir from "./prepare-computer-browser-dir";
|
||||||
import prepareComputerDataDir from "./prepare-computer-data-dir";
|
import prepareComputerDataDir from "./prepare-computer-data-dir";
|
||||||
import pushComputerUpdate from "./push-computer-update";
|
import pushComputerUpdate from "./push-computer-update";
|
||||||
|
import syncComputerBrowserPolicy from "./sync-computer-browser-policy";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
computer_id: number;
|
computer_id: number;
|
||||||
@@ -22,7 +23,8 @@ type Params = {
|
|||||||
* (`cpus`, `memory_mb`, else the `AppData` defaults), then records it (`containers`:
|
* (`cpus`, `memory_mb`, else the `AppData` defaults), then records it (`containers`:
|
||||||
* docker id, state, and its IP address on the network). A failure is
|
* docker id, state, and its IP address on the network). A failure is
|
||||||
* recorded as `failed` with its reason, which the computer lists show. The
|
* recorded as `failed` with its reason, which the computer lists show. The
|
||||||
* owner's tabs hear about it either way. The metadata's `layout` says the
|
* owner's tabs hear about it either way. Its browser gets its policy
|
||||||
|
* (`syncComputerBrowserPolicy`). The metadata's `layout` says the
|
||||||
* container mounts only the workspace and the browser profile (`syncComputers` recreates one that
|
* container mounts only the workspace and the browser profile (`syncComputers` recreates one that
|
||||||
* doesn't). Never throws.
|
* doesn't). Never throws.
|
||||||
*/
|
*/
|
||||||
@@ -83,6 +85,8 @@ export default async function provisionComputer({ computer_id, fresh }: Params)
|
|||||||
row.id,
|
row.id,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await syncComputerBrowserPolicy({ computer_id, docker_id: created.docker_id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import execDockerContainer from "@/src/functions/backend/docker/exec-docker-container";
|
||||||
|
import { logError } from "@/src/functions/backend/logger";
|
||||||
|
import computerBrowserPolicy from "@/src/utils/computer-browser-policy";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
computer_id: number | string;
|
||||||
|
/**
|
||||||
|
* The computer's running container.
|
||||||
|
*/
|
||||||
|
docker_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the browser policy (`computerBrowserPolicy`: the extensions every
|
||||||
|
* computer gets) into a running computer's container at
|
||||||
|
* `AppData.ComputerBrowserPolicyFile`, as root, only when it differs.
|
||||||
|
* Chrome watches that folder, so a running browser installs them without
|
||||||
|
* a restart. The file lives in the container, not on a mount, so a running
|
||||||
|
* computer gets it without being recreated; `provisionComputer` and the
|
||||||
|
* `sync-computers` cron write it again. Never throws.
|
||||||
|
*/
|
||||||
|
export default async function syncComputerBrowserPolicy({ computer_id, docker_id }: Params) {
|
||||||
|
const file = AppData.ComputerBrowserPolicyFile;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { exit_code } = await execDockerContainer({
|
||||||
|
container: docker_id,
|
||||||
|
cmd: [
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
`[ "$(cat "$POLICY_FILE" 2>/dev/null)" = "$(printf '%s' "$POLICY")" ] && exit 0
|
||||||
|
mkdir -p "$(dirname "$POLICY_FILE")" && printf '%s' "$POLICY" > "$POLICY_FILE.tmp" && chmod 644 "$POLICY_FILE.tmp" && mv "$POLICY_FILE.tmp" "$POLICY_FILE"`,
|
||||||
|
],
|
||||||
|
env: [`POLICY_FILE=${file}`, `POLICY=${computerBrowserPolicy()}`],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exit_code !== 0) throw new Error(`Writing ${file} exited with ${exit_code}.`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
await logError({ title: `Computer #${computer_id} couldn't get its browser policy`, error }).catch(() => {});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import dockerRequest from "./docker-request";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The container's id or name.
|
||||||
|
*/
|
||||||
|
container: string;
|
||||||
|
cmd: string[];
|
||||||
|
/**
|
||||||
|
* Who runs it, `root` by default.
|
||||||
|
*/
|
||||||
|
user?: string;
|
||||||
|
env?: string[];
|
||||||
|
timeout_ms?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a command in a running container (the API's `docker exec`) and
|
||||||
|
* waits for it. Resolves with its exit code, or throws when docker
|
||||||
|
* couldn't run it.
|
||||||
|
*/
|
||||||
|
export default async function execDockerContainer({
|
||||||
|
container,
|
||||||
|
cmd,
|
||||||
|
user = "root",
|
||||||
|
env,
|
||||||
|
timeout_ms = 30 * 1000,
|
||||||
|
}: Params) {
|
||||||
|
const created = await dockerRequest<{ Id: string }>({
|
||||||
|
method: "POST",
|
||||||
|
path: `/containers/${container}/exec`,
|
||||||
|
body: { Cmd: cmd, User: user, Env: env, AttachStdout: true, AttachStderr: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!created.ok || !created.data?.Id) {
|
||||||
|
throw new Error(`Couldn't run a command in the container: ${created.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = await dockerRequest({
|
||||||
|
method: "POST",
|
||||||
|
path: `/exec/${created.data.Id}/start`,
|
||||||
|
body: { Detach: false, Tty: false },
|
||||||
|
timeout_ms,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!started.ok) {
|
||||||
|
throw new Error(`Couldn't run a command in the container: ${started.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inspected = await dockerRequest<{ ExitCode: number | null }>({
|
||||||
|
path: `/exec/${created.data.Id}/json`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { exit_code: inspected.data?.ExitCode ?? null };
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { ComputerBrowserExtensions } from "@/src/data/computer-browser-extensions";
|
||||||
|
import computerBrowserPolicy from "./computer-browser-policy";
|
||||||
|
|
||||||
|
describe("computerBrowserPolicy", () => {
|
||||||
|
test("installs every default extension from the Web Store, turned off but not removed", () => {
|
||||||
|
const policy = JSON.parse(computerBrowserPolicy());
|
||||||
|
|
||||||
|
for (const { id } of ComputerBrowserExtensions) {
|
||||||
|
expect(policy.ExtensionSettings[id]).toEqual({
|
||||||
|
installation_mode: "normal_installed",
|
||||||
|
update_url: "https://clients2.google.com/service/update2/crx",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is the same text for the same list", () => {
|
||||||
|
expect(computerBrowserPolicy()).toBe(computerBrowserPolicy());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty list installs nothing", () => {
|
||||||
|
expect(JSON.parse(computerBrowserPolicy({ extensions: [] }))).toEqual({ ExtensionSettings: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { ComputerBrowserExtensions } from "@/src/data/computer-browser-extensions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where Chrome fetches Web Store extensions from.
|
||||||
|
*/
|
||||||
|
const WebStoreUpdateUrl = "https://clients2.google.com/service/update2/crx";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
extensions?: ReadonlyArray<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The managed policy file (`AppData.ComputerBrowserPolicyFile`) a
|
||||||
|
* computer's Chrome reads: each extension (`ComputerBrowserExtensions` by
|
||||||
|
* default) installed from the Web Store, which the user may turn off but
|
||||||
|
* not remove. Always the same text for the same list, so a computer that
|
||||||
|
* already has it is left alone.
|
||||||
|
*/
|
||||||
|
export default function computerBrowserPolicy({ extensions = ComputerBrowserExtensions }: Params = {}) {
|
||||||
|
const settings = Object.fromEntries(
|
||||||
|
extensions.map(({ id }) => [
|
||||||
|
id,
|
||||||
|
{ installation_mode: "normal_installed", update_url: WebStoreUpdateUrl },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return `${JSON.stringify({ ExtensionSettings: settings }, null, 4)}\n`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user