Name each install, fix the notification badge, switch to Archivo
- Settings > General: an App name (super admin) for the installed app. The manifest's name/short_name, the iOS head tags and the loading page's title use it, so two installs on one phone can be told apart. - Notifications get a real badge (white stone silhouette): Android masks the badge by alpha, so the opaque app icon showed as a square. - The loading and offline pages load the app's font. - The font is now Archivo (variable, one request for every weight), matching the marketing site. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -147,6 +147,8 @@ Set in Settings > Environment (super admin), not `.env`. `envs` (private; encryp
|
|||||||
### PWA and notifications
|
### PWA and notifications
|
||||||
|
|
||||||
- Generated assets (manifest, icons, `offline.html`, `loading.html`) come from `bun run gen:favicon`, which reads `main.css`; outputs are committed. Shared paths in `src/data/pwa-data.ts`.
|
- Generated assets (manifest, icons, `offline.html`, `loading.html`) come from `bun run gen:favicon`, which reads `main.css`; outputs are committed. Shared paths in `src/data/pwa-data.ts`.
|
||||||
|
- **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.
|
||||||
|
- 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. It never reloads on its own. 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.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 760 B |
+5
-2
@@ -5,8 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#fbfaf7" />
|
<meta name="theme-color" content="#fbfaf7" />
|
||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
<title>cobble</title>
|
<title>{{name}}</title>
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:[email protected]&display=swap" />
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--paper: #fbfaf7;
|
--paper: #fbfaf7;
|
||||||
@@ -33,7 +36,7 @@ body {
|
|||||||
padding: calc(24px + env(safe-area-inset-top)) 24px calc(24px + env(safe-area-inset-bottom));
|
padding: calc(24px + env(safe-area-inset-top)) 24px calc(24px + env(safe-area-inset-bottom));
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: "Lato", system-ui, -apple-system, "Segoe UI", sans-serif;
|
font-family: "Archivo", "Liberation Sans", system-ui, -apple-system, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
|
|||||||
+4
-1
@@ -6,6 +6,9 @@
|
|||||||
<meta name="theme-color" content="#fbfaf7" />
|
<meta name="theme-color" content="#fbfaf7" />
|
||||||
<title>Offline | cobble</title>
|
<title>Offline | cobble</title>
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:[email protected]&display=swap" />
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--paper: #fbfaf7;
|
--paper: #fbfaf7;
|
||||||
@@ -32,7 +35,7 @@ body {
|
|||||||
padding: calc(24px + env(safe-area-inset-top)) 24px calc(24px + env(safe-area-inset-bottom));
|
padding: calc(24px + env(safe-area-inset-top)) 24px calc(24px + env(safe-area-inset-bottom));
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: "Lato", system-ui, -apple-system, "Segoe UI", sans-serif;
|
font-family: "Archivo", "Liberation Sans", system-ui, -apple-system, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
|
|||||||
+12
-3
@@ -59,8 +59,17 @@ const ASSET_PATHS = ["/favicon.ico", "/favicon.png", "/apple-touch-icon.png"];
|
|||||||
const BYPASS_PREFIXES = ["/api/", "/ws", "/media", "/computers/"];
|
const BYPASS_PREFIXES = ["/api/", "/ws", "/media", "/computers/"];
|
||||||
const BYPASS_PATHS = ["/sw.js", "/manifest.webmanifest", "/auth/logout", BUILD_INFO_URL];
|
const BYPASS_PATHS = ["/sw.js", "/manifest.webmanifest", "/auth/logout", BUILD_INFO_URL];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android draws a badge from its alpha alone, so it's the white stone on
|
||||||
|
* transparent (`PwaBadgePath`); the app icon sits on opaque paper and
|
||||||
|
* would show as a square.
|
||||||
|
*/
|
||||||
const DEFAULT_ICON = "/icons/icon-192.png";
|
const DEFAULT_ICON = "/icons/icon-192.png";
|
||||||
const DEFAULT_BADGE = "/icons/icon-192.png";
|
const DEFAULT_BADGE = "/icons/badge-96.png";
|
||||||
|
|
||||||
|
function absoluteUrl(path) {
|
||||||
|
return new URL(path, self.location.origin).href;
|
||||||
|
}
|
||||||
|
|
||||||
function isAssetRequest(url) {
|
function isAssetRequest(url) {
|
||||||
return (
|
return (
|
||||||
@@ -203,8 +212,8 @@ self.addEventListener("push", (event) => {
|
|||||||
const title = payload.title || "Cobble";
|
const title = payload.title || "Cobble";
|
||||||
const options = {
|
const options = {
|
||||||
body: payload.body || "",
|
body: payload.body || "",
|
||||||
icon: payload.icon || DEFAULT_ICON,
|
icon: absoluteUrl(payload.icon || DEFAULT_ICON),
|
||||||
badge: payload.badge || DEFAULT_BADGE,
|
badge: absoluteUrl(payload.badge || DEFAULT_BADGE),
|
||||||
tag: payload.tag || undefined,
|
tag: payload.tag || undefined,
|
||||||
renotify: Boolean(payload.tag),
|
renotify: Boolean(payload.tag),
|
||||||
requireInteraction: Boolean(payload.requireInteraction),
|
requireInteraction: Boolean(payload.requireInteraction),
|
||||||
|
|||||||
@@ -90,3 +90,23 @@ export const PwaIconPath = (name: `icon` | `maskable`, size: number) =>
|
|||||||
*/
|
*/
|
||||||
export const PwaManifestName = SiteData["SiteName"];
|
export const PwaManifestName = SiteData["SiteName"];
|
||||||
export const PwaShortName = SiteData["SiteName"];
|
export const PwaShortName = SiteData["SiteName"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A notification's `badge`: the stone's white silhouette, since Android
|
||||||
|
* draws only its alpha. `public/sw.js` repeats the path.
|
||||||
|
*/
|
||||||
|
export const PwaBadgeSize = 96;
|
||||||
|
export const PwaBadgePath = `/icons/badge-${PwaBadgeSize}.png`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What `handlePrecacheGate` swaps for the install's name in the loading
|
||||||
|
* page's title.
|
||||||
|
*/
|
||||||
|
export const PwaLoadingNamePlaceholder = "{{name}}";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The install's own name (Settings > General), so two installs on one
|
||||||
|
* phone don't both read `SiteName`. Saved in `app_settings`.
|
||||||
|
*/
|
||||||
|
export const PwaInstallNameSettingKey = "install_name";
|
||||||
|
export const PwaInstallNameMaxLength = 30;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { PwaInstallNameSettingKey } from "@/src/data/pwa-data";
|
||||||
|
import grabAppSetting from "./grab-app-setting";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name this install was given in Settings > General, or empty when it
|
||||||
|
* was never named (`installDisplayName` falls back to `SiteName`).
|
||||||
|
*/
|
||||||
|
export default async function grabInstallName() {
|
||||||
|
const saved = await grabAppSetting({ key: PwaInstallNameSettingKey });
|
||||||
|
|
||||||
|
return saved?.trim() || "";
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import { PwaLoadingPath } from "@/src/data/pwa-data";
|
import { PwaLoadingNamePlaceholder, PwaLoadingPath } from "@/src/data/pwa-data";
|
||||||
|
import grabInstallName from "@/src/functions/backend/db/app-settings/grab-install-name";
|
||||||
|
import installDisplayName from "@/src/utils/install-display-name";
|
||||||
|
import escapeHtml from "@/src/utils/escape-html";
|
||||||
import { getCookie } from "@/src/utils/cookies";
|
import { getCookie } from "@/src/utils/cookies";
|
||||||
import needsPrecachePage from "@/src/utils/needs-precache-page";
|
import needsPrecachePage from "@/src/utils/needs-precache-page";
|
||||||
import grabPrecacheManifest from "./grab-precache-manifest";
|
import grabPrecacheManifest from "./grab-precache-manifest";
|
||||||
@@ -53,7 +56,15 @@ export default async function handlePrecacheGate({
|
|||||||
total: manifest.files.length,
|
total: manifest.files.length,
|
||||||
}).replace(/</g, "\\u003c");
|
}).replace(/</g, "\\u003c");
|
||||||
|
|
||||||
return new Response((await file.text()).replace(BuildPlaceholder, build), {
|
const name = escapeHtml({
|
||||||
|
text: installDisplayName({ install_name: await grabInstallName().catch(() => "") }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = (await file.text())
|
||||||
|
.replace(BuildPlaceholder, build)
|
||||||
|
.replace(PwaLoadingNamePlaceholder, name);
|
||||||
|
|
||||||
|
return new Response(page, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/html; charset=utf-8",
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
"Cache-Control": "no-store, max-age=0, must-revalidate",
|
"Cache-Control": "no-store, max-age=0, must-revalidate",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { PwaManifestPath, PwaServiceWorkerPath } from "@/src/data/pwa-data";
|
import { PwaManifestPath, PwaServiceWorkerPath } from "@/src/data/pwa-data";
|
||||||
|
import grabInstallName from "@/src/functions/backend/db/app-settings/grab-install-name";
|
||||||
|
import namePwaManifest from "@/src/utils/name-pwa-manifest";
|
||||||
|
|
||||||
const PUBLIC_DIR = path.resolve(import.meta.dir, "../../../../public");
|
const PUBLIC_DIR = path.resolve(import.meta.dir, "../../../../public");
|
||||||
|
|
||||||
@@ -77,7 +79,11 @@ export default async function handlePwaFile({
|
|||||||
headers.set("Service-Worker-Allowed", "/");
|
headers.set("Service-Worker-Allowed", "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = new Response(development_stub || file, { headers });
|
const response = new Response(
|
||||||
|
development_stub ||
|
||||||
|
(file_name == "manifest.webmanifest" ? await namedManifest({ file }) : file),
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `Bun.file` adds a filename disposition; a manifest is inline data.
|
* `Bun.file` adds a filename disposition; a manifest is inline data.
|
||||||
@@ -86,3 +92,20 @@ export default async function handlePwaFile({
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The generated manifest under the install's own name (Settings >
|
||||||
|
* General). Unnamed, or when the file isn't JSON, it goes out as it is.
|
||||||
|
*/
|
||||||
|
async function namedManifest({ file }: { file: ReturnType<typeof Bun.file> }) {
|
||||||
|
const text = await file.text();
|
||||||
|
const install_name = await grabInstallName().catch(() => "");
|
||||||
|
|
||||||
|
if (!install_name) return text;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(namePwaManifest({ manifest: JSON.parse(text), install_name }), null, 4);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import isAdminSectionPath from "../utils/is-admin-section-path";
|
|||||||
import { AdminHomePath } from "../dict/admin-sections-dict";
|
import { AdminHomePath } from "../dict/admin-sections-dict";
|
||||||
import isUnknownSettingsPath from "../utils/is-unknown-settings-path";
|
import isUnknownSettingsPath from "../utils/is-unknown-settings-path";
|
||||||
import { SettingsDefaultPath } from "../dict/settings-pages-dict";
|
import { SettingsDefaultPath } from "../dict/settings-pages-dict";
|
||||||
|
import grabInstallName from "../functions/backend/db/app-settings/grab-install-name";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routing for a local install. There are no public pages:
|
* Routing for a local install. There are no public pages:
|
||||||
@@ -80,6 +81,7 @@ const server: BunextPageServerFn<PagePropsType> = async ({ req, url }) => {
|
|||||||
},
|
},
|
||||||
user,
|
user,
|
||||||
user_types,
|
user_types,
|
||||||
|
install_name: await grabInstallName().catch(() => ""),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-3
@@ -13,6 +13,7 @@ import type {
|
|||||||
PagePropsType,
|
PagePropsType,
|
||||||
} from "@/src/types";
|
} from "@/src/types";
|
||||||
import { SiteData } from "@/src/data/site-data";
|
import { SiteData } from "@/src/data/site-data";
|
||||||
|
import installDisplayName from "@/src/utils/install-display-name";
|
||||||
import Layout from "../layouts/main";
|
import Layout from "../layouts/main";
|
||||||
import AuthLayout from "../layouts/auth";
|
import AuthLayout from "../layouts/auth";
|
||||||
import BotsLayout from "../layouts/bots";
|
import BotsLayout from "../layouts/bots";
|
||||||
@@ -93,16 +94,23 @@ export default function App({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
|
export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
|
||||||
|
/**
|
||||||
|
* iOS names a home-screen app from these tags, not the manifest.
|
||||||
|
*/
|
||||||
|
const app_name = installDisplayName({
|
||||||
|
install_name: (serverRes?.props as PagePropsType | undefined)?.install_name,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ThemeInitScript />
|
<ThemeInitScript />
|
||||||
<PwaInitScript />
|
<PwaInitScript />
|
||||||
<meta name="application-name" content={SiteData["SiteName"]} />
|
<meta name="application-name" content={app_name} />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta
|
<meta
|
||||||
name="apple-mobile-web-app-title"
|
name="apple-mobile-web-app-title"
|
||||||
content={SiteData["SiteName"]}
|
content={app_name}
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="apple-mobile-web-app-status-bar-style"
|
name="apple-mobile-web-app-status-bar-style"
|
||||||
@@ -115,7 +123,7 @@ export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
|
|||||||
crossOrigin=""
|
crossOrigin=""
|
||||||
/>
|
/>
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Lato:wght@400;700;900&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Archivo:wght@400..900&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<link
|
<link
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||||
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
|
import Form from "@moduletrace/twui/form/Form";
|
||||||
|
import Input from "@moduletrace/twui/form/Input";
|
||||||
|
import { PwaInstallNameMaxLength } from "@/src/data/pwa-data";
|
||||||
|
import { SiteData } from "@/src/data/site-data";
|
||||||
|
import SettingsSaveBar from "../../(partials)/settings-save-bar";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
install_name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name the install goes by as an installed app, saved on its own
|
||||||
|
* without a reload. Empty puts the site name back.
|
||||||
|
*/
|
||||||
|
export default function InstallNameForm({ install_name }: Props) {
|
||||||
|
const [saved, setSaved] = useState(install_name || "");
|
||||||
|
const [value, setValue] = useState(install_name || "");
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
/**
|
||||||
|
* The input only reads `defaultValue` when it mounts, so discarding or
|
||||||
|
* saving remounts it with the saved name.
|
||||||
|
*/
|
||||||
|
const [form_key, setFormKey] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [status, setStatus] = useState<{
|
||||||
|
msg?: string;
|
||||||
|
error?: boolean;
|
||||||
|
success?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!window.confirm("Save the install name?")) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setStatus(undefined);
|
||||||
|
|
||||||
|
const res = await fetchApi<{}, APIResponseObject<{ install_name: string }>>(
|
||||||
|
"/api/admin/settings/install-name",
|
||||||
|
{ method: "POST", body: { install_name: value } },
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (!res?.success || !res.singleRes) {
|
||||||
|
setStatus({ error: true, msg: res?.msg || `Couldn't save the name.` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaved(res.singleRes.install_name);
|
||||||
|
setValue(res.singleRes.install_name);
|
||||||
|
setFormKey((key) => key + 1);
|
||||||
|
setDirty(false);
|
||||||
|
setStatus({
|
||||||
|
success: true,
|
||||||
|
msg: res.singleRes.install_name
|
||||||
|
? `Saved. Reinstall the app on a device to see "${res.singleRes.install_name}".`
|
||||||
|
: `Removed. The app is called ${SiteData["SiteName"]} again.`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const discard = () => {
|
||||||
|
setValue(saved);
|
||||||
|
setFormKey((key) => key + 1);
|
||||||
|
setDirty(false);
|
||||||
|
setStatus(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
save().catch(() => {});
|
||||||
|
}}
|
||||||
|
className="w-full gap-3! items-stretch"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
key={`install-name-${form_key}`}
|
||||||
|
label="Install name"
|
||||||
|
placeholder={SiteData["SiteName"]}
|
||||||
|
maxLength={PwaInstallNameMaxLength}
|
||||||
|
defaultValue={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
setValue(event.target.value);
|
||||||
|
setDirty(true);
|
||||||
|
setStatus(undefined);
|
||||||
|
}}
|
||||||
|
info={`Shown under the app's icon on phones and computers, e.g. "Acme". Empty uses ${SiteData["SiteName"]}. A device that already installed the app keeps the old name until it's installed again.`}
|
||||||
|
/>
|
||||||
|
<SettingsSaveBar
|
||||||
|
dirty={dirty}
|
||||||
|
loading={loading}
|
||||||
|
status={status}
|
||||||
|
save_label="Save name"
|
||||||
|
onDiscard={discard}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||||
|
import SettingsGroup from "../../(partials)/settings-group";
|
||||||
|
import InstallNameForm from "../(partials)/install-name-form";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this install is called once installed as an app, so installs for
|
||||||
|
* different teams can sit side by side on one device. Super admin only.
|
||||||
|
*/
|
||||||
|
export default function InstallNameSection() {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
|
||||||
|
if (!isPassThroughUser({ user_types: pageProps?.user_types })) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsGroup
|
||||||
|
title="App name"
|
||||||
|
description="The name this install goes by when it's added to a home screen or app list."
|
||||||
|
id="install-name"
|
||||||
|
>
|
||||||
|
<InstallNameForm install_name={pageProps?.install_name} />
|
||||||
|
</SettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppearanceSection from "./(sections)/appearance-section";
|
import AppearanceSection from "./(sections)/appearance-section";
|
||||||
|
import InstallNameSection from "./(sections)/install-name-section";
|
||||||
import NotificationsSection from "./(sections)/notifications-section";
|
import NotificationsSection from "./(sections)/notifications-section";
|
||||||
import PromptSection from "./(sections)/prompt-section";
|
import PromptSection from "./(sections)/prompt-section";
|
||||||
import RunLimitsSection from "./(sections)/run-limits-section";
|
import RunLimitsSection from "./(sections)/run-limits-section";
|
||||||
@@ -15,6 +16,7 @@ export default function AdminSettingsGeneralPage() {
|
|||||||
description="How the app looks and behaves."
|
description="How the app looks and behaves."
|
||||||
>
|
>
|
||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
|
<InstallNameSection />
|
||||||
<NotificationsSection />
|
<NotificationsSection />
|
||||||
<PromptSection />
|
<PromptSection />
|
||||||
<RunLimitsSection />
|
<RunLimitsSection />
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||||
|
import grabInstallName from "@/src/functions/backend/db/app-settings/grab-install-name";
|
||||||
|
import saveAppSetting from "@/src/functions/backend/db/app-settings/save-app-setting";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import { PwaInstallNameSettingKey } from "@/src/data/pwa-data";
|
||||||
|
import { SaveInstallNameRequestSchema } from "@/src/schemas/push-schema";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Super admin only: the name this install goes by when it's installed as
|
||||||
|
* an app. Empty puts `SiteName` back.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<{ install_name: string }>> = async ({
|
||||||
|
req,
|
||||||
|
body,
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") throw new AppError(`Method not allowed`);
|
||||||
|
|
||||||
|
const user = await grabSuperAdminUser({ req });
|
||||||
|
|
||||||
|
if (!user?.id) throw new AppError(`Only a super admin can rename the install.`);
|
||||||
|
|
||||||
|
const { install_name } = parseWithSchema({
|
||||||
|
schema: SaveInstallNameRequestSchema,
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveAppSetting({ key: PwaInstallNameSettingKey, value: install_name || null });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Install renamed",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} set the install name to ${install_name ? `"${install_name}"` : "the default"}.`,
|
||||||
|
metadata: { install_name },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, singleRes: { install_name: await grabInstallName() } };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { PwaInstallNameMaxLength } from "@/src/data/pwa-data";
|
||||||
|
|
||||||
const PushEndpointField = z
|
const PushEndpointField = z
|
||||||
.string()
|
.string()
|
||||||
@@ -34,3 +35,7 @@ export const PushEndpointRequestSchema = z.object({
|
|||||||
export const PushSubscriptionIdRequestSchema = z.object({
|
export const PushSubscriptionIdRequestSchema = z.object({
|
||||||
id: z.coerce.number().int().positive("Pick a device"),
|
id: z.coerce.number().int().positive("Pick a device"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const SaveInstallNameRequestSchema = z.object({
|
||||||
|
install_name: z.string().trim().max(PwaInstallNameMaxLength),
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import { SiteData } from "../data/site-data";
|
|||||||
import { AppData } from "../data/app-data";
|
import { AppData } from "../data/app-data";
|
||||||
import { HashedAssetPattern } from "../utils/is-hashed-asset-path";
|
import { HashedAssetPattern } from "../utils/is-hashed-asset-path";
|
||||||
import {
|
import {
|
||||||
|
PwaBadgePath,
|
||||||
|
PwaBadgeSize,
|
||||||
PwaBuildInfoPath,
|
PwaBuildInfoPath,
|
||||||
PwaIcons,
|
PwaIcons,
|
||||||
|
PwaLoadingNamePlaceholder,
|
||||||
PwaPrecacheCachePrefix,
|
PwaPrecacheCachePrefix,
|
||||||
PwaPrecacheMarkerPath,
|
PwaPrecacheMarkerPath,
|
||||||
PwaServiceWorkerPath,
|
PwaServiceWorkerPath,
|
||||||
@@ -68,6 +71,52 @@ function buildIco(pngs: Record<number, Buffer>): Buffer {
|
|||||||
return Buffer.concat([header, ...entries, ...blobs]);
|
return Buffer.concat([header, ...entries, ...blobs]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's font, loaded the way `src/pages/__root.tsx` loads it, so the
|
||||||
|
* generated pages read like the app. Offline, the browser's cached copy is
|
||||||
|
* used when it has one, else the fallbacks.
|
||||||
|
*/
|
||||||
|
const FontLinks = `<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:[email protected]&display=swap" />`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The status-bar icon Android draws from a notification's `badge`. Only
|
||||||
|
* the alpha channel counts, so it is the stone's silhouette alone, white
|
||||||
|
* on transparent (the app icons sit on opaque paper and mask to a square).
|
||||||
|
*/
|
||||||
|
async function renderBadge({ svg, size }: { svg: Buffer; size: number }) {
|
||||||
|
const silhouette = await sharp(svg)
|
||||||
|
.resize(Math.round(size * 0.84), Math.round(size * 0.84), { fit: "contain" })
|
||||||
|
.ensureAlpha()
|
||||||
|
.extractChannel("alpha")
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const mark = await sharp({
|
||||||
|
create: {
|
||||||
|
width: Math.round(size * 0.84),
|
||||||
|
height: Math.round(size * 0.84),
|
||||||
|
channels: 3,
|
||||||
|
background: "#ffffff",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.joinChannel(silhouette)
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
return sharp({
|
||||||
|
create: {
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
channels: 4,
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.composite([{ input: mark, gravity: "center" }])
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(text: string) {
|
function escapeHtml(text: string) {
|
||||||
return text
|
return text
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -202,6 +251,7 @@ function buildOfflinePage({
|
|||||||
<meta name="theme-color" content="${colors.paper}" />
|
<meta name="theme-color" content="${colors.paper}" />
|
||||||
<title>Offline | ${name}</title>
|
<title>Offline | ${name}</title>
|
||||||
<link rel="manifest" href="${PwaManifestPath}" />
|
<link rel="manifest" href="${PwaManifestPath}" />
|
||||||
|
${FontLinks}
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--paper: ${colors.paper};
|
--paper: ${colors.paper};
|
||||||
@@ -228,7 +278,7 @@ body {
|
|||||||
padding: calc(24px + env(safe-area-inset-top)) 24px calc(24px + env(safe-area-inset-bottom));
|
padding: calc(24px + env(safe-area-inset-top)) 24px calc(24px + env(safe-area-inset-bottom));
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: "Lato", system-ui, -apple-system, "Segoe UI", sans-serif;
|
font-family: "Archivo", "Liberation Sans", system-ui, -apple-system, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
@@ -310,8 +360,9 @@ function buildLoadingPage({
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="${colors.paper}" />
|
<meta name="theme-color" content="${colors.paper}" />
|
||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
<title>${name}</title>
|
<title>${PwaLoadingNamePlaceholder}</title>
|
||||||
<link rel="manifest" href="${PwaManifestPath}" />
|
<link rel="manifest" href="${PwaManifestPath}" />
|
||||||
|
${FontLinks}
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--paper: ${colors.paper};
|
--paper: ${colors.paper};
|
||||||
@@ -338,7 +389,7 @@ body {
|
|||||||
padding: calc(24px + env(safe-area-inset-top)) 24px calc(24px + env(safe-area-inset-bottom));
|
padding: calc(24px + env(safe-area-inset-top)) 24px calc(24px + env(safe-area-inset-bottom));
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: "Lato", system-ui, -apple-system, "Segoe UI", sans-serif;
|
font-family: "Archivo", "Liberation Sans", system-ui, -apple-system, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
@@ -610,6 +661,11 @@ async function main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
path.join(PUBLIC_DIR, PwaBadgePath.replace(/^\//, "")),
|
||||||
|
await renderBadge({ svg, size: PwaBadgeSize }),
|
||||||
|
);
|
||||||
|
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
path.join(PUBLIC_DIR, "manifest.webmanifest"),
|
path.join(PUBLIC_DIR, "manifest.webmanifest"),
|
||||||
`${JSON.stringify(buildManifest({ colors }), null, 4)}\n`,
|
`${JSON.stringify(buildManifest({ colors }), null, 4)}\n`,
|
||||||
@@ -634,6 +690,7 @@ async function main() {
|
|||||||
console.log(" public/icons/icon-512.png (app icon)");
|
console.log(" public/icons/icon-512.png (app icon)");
|
||||||
console.log(" public/icons/maskable-192.png (maskable, safe zone)");
|
console.log(" public/icons/maskable-192.png (maskable, safe zone)");
|
||||||
console.log(" public/icons/maskable-512.png (maskable, safe zone)");
|
console.log(" public/icons/maskable-512.png (maskable, safe zone)");
|
||||||
|
console.log(" public/icons/badge-96.png (notification badge, alpha only)");
|
||||||
console.log(" public/manifest.webmanifest (name, colors and icons)");
|
console.log(" public/manifest.webmanifest (name, colors and icons)");
|
||||||
console.log(" public/offline.html (self-contained offline page)");
|
console.log(" public/offline.html (self-contained offline page)");
|
||||||
console.log(" public/loading.html (precache loading page)");
|
console.log(" public/loading.html (precache loading page)");
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@
|
|||||||
@theme inline {
|
@theme inline {
|
||||||
/* Typography */
|
/* Typography */
|
||||||
--font-sans:
|
--font-sans:
|
||||||
"Lato", "Liberation Sans", system-ui, -apple-system, sans-serif;
|
"Archivo", "Liberation Sans", system-ui, -apple-system, sans-serif;
|
||||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
|
||||||
/* Cobble neutrals - switch with the color scheme */
|
/* Cobble neutrals - switch with the color scheme */
|
||||||
|
|||||||
@@ -1438,6 +1438,11 @@ export type PagePropsType = {
|
|||||||
* at once, with the code's defaults (the General settings page).
|
* at once, with the code's defaults (the General settings page).
|
||||||
*/
|
*/
|
||||||
skill_limits?: SkillLimitsOverview | null;
|
skill_limits?: SkillLimitsOverview | null;
|
||||||
|
/**
|
||||||
|
* The name the install goes by as an installed app (Settings >
|
||||||
|
* General, and the root's head tags). Empty when never named.
|
||||||
|
*/
|
||||||
|
install_name?: string | null;
|
||||||
/**
|
/**
|
||||||
* The release the server is running, for the General settings page.
|
* The release the server is running, for the General settings page.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
type Params = {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text made safe to drop into HTML, in an element or a quoted attribute.
|
||||||
|
*/
|
||||||
|
export default function escapeHtml({ text }: Params) {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { SiteData } from "@/src/data/site-data";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
install_name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the installed app is called: the install's own name, else
|
||||||
|
* `SiteName`.
|
||||||
|
*/
|
||||||
|
export default function installDisplayName({ install_name }: Params) {
|
||||||
|
return install_name?.trim() || SiteData["SiteName"];
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { SiteData } from "@/src/data/site-data";
|
||||||
|
import namePwaManifest from "./name-pwa-manifest";
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
name: SiteData["SiteName"],
|
||||||
|
short_name: SiteData["SiteName"],
|
||||||
|
start_url: "/admin/bots",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("namePwaManifest", () => {
|
||||||
|
test("leaves an unnamed install's manifest alone", () => {
|
||||||
|
expect(namePwaManifest({ manifest, install_name: "" })).toEqual(manifest);
|
||||||
|
expect(namePwaManifest({ manifest, install_name: " " })).toEqual(manifest);
|
||||||
|
expect(namePwaManifest({ manifest })).toEqual(manifest);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("labels the icon with the name and adds the site name to the full name", () => {
|
||||||
|
expect(namePwaManifest({ manifest, install_name: " Acme " })).toEqual({
|
||||||
|
...manifest,
|
||||||
|
name: `Acme - ${SiteData["SiteName"]}`,
|
||||||
|
short_name: "Acme",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doesn't repeat the site name when the name already has it", () => {
|
||||||
|
const install_name = `Acme ${SiteData["SiteName"].toUpperCase()}`;
|
||||||
|
|
||||||
|
expect(namePwaManifest({ manifest, install_name })).toMatchObject({
|
||||||
|
name: install_name,
|
||||||
|
short_name: install_name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { SiteData } from "@/src/data/site-data";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
manifest: Record<string, unknown>;
|
||||||
|
install_name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The generated manifest with the install's own name, so the launcher
|
||||||
|
* tells two installs apart. `short_name` (the label under the icon) is the
|
||||||
|
* name alone; `name` adds `SiteName` unless the name already says it.
|
||||||
|
* Unnamed, the manifest is unchanged.
|
||||||
|
*/
|
||||||
|
export default function namePwaManifest({ manifest, install_name }: Params) {
|
||||||
|
const name = install_name?.trim();
|
||||||
|
|
||||||
|
if (!name) return manifest;
|
||||||
|
|
||||||
|
const site_name = SiteData["SiteName"];
|
||||||
|
const says_site_name = name.toLowerCase().includes(site_name.toLowerCase());
|
||||||
|
|
||||||
|
return {
|
||||||
|
...manifest,
|
||||||
|
name: says_site_name ? name : `${name} - ${site_name}`,
|
||||||
|
short_name: name,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user