diff --git a/bun.lockb b/bun.lockb index 3dc8a47..b7b9998 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index e5a7f53..8575c58 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "vite-plugin-pwa": "^0.20.5", "vite-plugin-solid": "^2.10.2", "vite-plugin-solid-styled": "^0.11.1", + "workbox-build": "^7.1.1", "wrangler": "^3.78.2" }, "dependencies": { @@ -49,7 +50,9 @@ "solid-js": "^1.8.22", "solid-styled": "^0.11.1", "stacktrace-js": "^2.0.2", - "web-animations-js": "^2.3.2" + "web-animations-js": "^2.3.2", + "workbox-core": "^7.1.0", + "workbox-precaching": "^7.1.0" }, "packageManager": "bun@1.1.21" } diff --git a/src/App.tsx b/src/App.tsx index f532882..6a73749 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { createEffect, createMemo, createRenderEffect, + createSignal, ErrorBoundary, lazy, onCleanup, @@ -17,6 +18,14 @@ import { import { $accounts, updateAcctInf } from "./accounts/stores.js"; import { useStore } from "@nanostores/solid"; import { DateFnScope, useLanguage } from "./platform/i18n.jsx"; +import { useRegisterSW } from "virtual:pwa-register/solid"; +import { + isJSONRPCResult, + ResultDispatcher, + type JSONRPC, +} from "./serviceworker/services.js"; +import { makeEventListener } from "@solid-primitives/event-listener"; +import { ServiceWorkerProvider } from "./platform/host.js"; const AccountSignIn = lazy(() => import("./accounts/SignIn.js")); const AccountMastodonOAuth2Callback = lazy( @@ -58,6 +67,43 @@ const App: Component = () => { const theme = useRootTheme(); const accts = useStore($accounts); const lang = useLanguage(); + const [serviceWorker, setServiceWorker] = createSignal(); + const dispatcher = new ResultDispatcher(); + + let checkAge = 0; + const untilServiceWorkerAlive = async ( + worker: ServiceWorker, + expectedAge: number, + ) => { + const [call, ret] = dispatcher.createTypedCall("ping", undefined); + worker.postMessage(await call); + const result = await ret; + console.assert(!result.error, result); + if (expectedAge === checkAge) { + setServiceWorker(worker); + } + }; + + makeEventListener(window, "message", (event: MessageEvent) => { + if (isJSONRPCResult(event.data)) { + dispatcher.dispatch(event.data.id, event.data); + } + }); + + const { + needRefresh: [needRefresh], + offlineReady: [offlineReady], + } = useRegisterSW({ + onRegisteredSW(scriptUrl, reg) { + console.info("service worker is registered from %s", scriptUrl); + const active = reg?.active; + if (!active) { + console.warn("No service is in activating or activated"); + return; + } + untilServiceWorkerAlive(active, checkAge++); + }, + }); const clients = createMemo(() => { return accts().map((x) => ({ @@ -103,7 +149,15 @@ const App: Component = () => { - + + + diff --git a/src/platform/host.ts b/src/platform/host.ts index f1a2e27..9745742 100644 --- a/src/platform/host.ts +++ b/src/platform/host.ts @@ -1,12 +1,37 @@ +import { createContext, useContext, type Accessor } from "solid-js"; +import { useRegisterSW } from "virtual:pwa-register/solid"; + export function isiOS() { - return [ - 'iPad Simulator', - 'iPhone Simulator', - 'iPod Simulator', - 'iPad', - 'iPhone', - 'iPod' - ].includes(navigator.platform) - // iPad on iOS 13 detection - || (navigator.userAgent.includes("Mac") && "ontouchend" in document) -} \ No newline at end of file + return ( + [ + "iPad Simulator", + "iPhone Simulator", + "iPod Simulator", + "iPad", + "iPhone", + "iPod", + ].includes(navigator.platform) || + // iPad on iOS 13 detection + (navigator.userAgent.includes("Mac") && "ontouchend" in document) + ); +} + +export type ServiceWorkerService = { + needRefresh: Accessor; + offlineReady: Accessor; + serviceWorker: Accessor; +}; + +const ServiceWorkerContext = /* @__PURE__ */ createContext< + ServiceWorkerService +>(({ + needRefresh: () => false, + offlineReady: () => false, + serviceWorker: () => undefined +})); + +export const ServiceWorkerProvider = ServiceWorkerContext.Provider; + +export function useServiceWorker(): ServiceWorkerService { + return useContext(ServiceWorkerContext); +} diff --git a/src/serviceworker/main.ts b/src/serviceworker/main.ts new file mode 100644 index 0000000..457875e --- /dev/null +++ b/src/serviceworker/main.ts @@ -0,0 +1,35 @@ +import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching"; +import { clientsClaim } from "workbox-core"; +import type { AnyCall } from "./services"; + +function checkServiceWorker( + self: WorkerGlobalScope, +): self is ServiceWorkerGlobalScope { + return !!(self as unknown as ServiceWorkerGlobalScope).registration; +} + +if (checkServiceWorker(self)) { + cleanupOutdatedCaches(); + precacheAndRoute(self.__WB_MANIFEST, { + cleanURLs: false + }); + + // auto update + self.skipWaiting(); + clientsClaim(); +} else { + throw new TypeError("This entry point must be run in a service worker"); +} + +self.addEventListener("message", (event: MessageEvent) => { + if (event.data.method === "ping") { + event.source.postMessage({id: event.data.id, jsonrpc: "2.0", result: undefined}) + } else { + event.source.postMessage({ + error: { + code: -32601, + message: "Method not found" + } + }) + } +}) diff --git a/src/serviceworker/services.ts b/src/serviceworker/services.ts new file mode 100644 index 0000000..61f70ec --- /dev/null +++ b/src/serviceworker/services.ts @@ -0,0 +1,177 @@ +export type JSONRPC = { + jsonrpc: "2.0"; + id?: string | number; +}; + +export type Call = JSONRPC & { + method: string; + params: T; +}; + +export type RemoteError = { data: E } & ( + | { + code: number; + message: string; + } + | { + code: -32700; + message: "Parse Error"; + } + | { + code: -32600; + message: "Invalid Request"; + } + | { + code: -32601; + message: "Method not found"; + } + | { + code: -32602; + message: "Invalid params"; + } + | { + code: -32603; + message: "Internal error"; + } +); + +export type Result = JSONRPC & { id: string | number } & ( + | { + result: T; + error: undefined; + } + | { error: RemoteError; result: undefined } + ); + +export function isJSONRPCResult( + object: Record, +): object is Result { + return object["jsonrpc"] === "2.0" && object["id"] && !object["method"]; +} + +export function isJSONRPCCall( + object: Record, +): object is Call { + return object["jsonrpc"] === "2.0" && !!object["method"]; +} + +export class ResultDispatcher { + private map: Map< + number | string, + ((value: Result) => void) | true // `true` = a call is generated, but the promise not created + >; + private nextId: number = Number.MIN_SAFE_INTEGER; + + constructor() { + this.map = new Map(); + } + + private rollId() { + let id = 0; + while (this.map.get((id = this.nextId++))) { + if (this.nextId >= Number.MAX_SAFE_INTEGER) { + this.nextId = Number.MIN_SAFE_INTEGER; + } + } + return id; + } + + createCall( + method: string, + params: T, + ): [Promise>, Promise>] { + const id = this.rollId(); + const p = new Promise>((resolve) => + this.map.set(id, resolve), + ); + this.map.set(id, true); + const call: Call = { + jsonrpc: "2.0", + id, + method, + params, + }; + + return [ + new Promise((resolve) => { + const waitUntilTheIdSet = () => { + // We must do this check to make sure the id is set before the call made. + // or the dispatching may lost the callback + if (this.map.get(id)) { + resolve(call); + } else { + setTimeout(waitUntilTheIdSet, 0); + } + }; + + waitUntilTheIdSet(); + }), + p, + ]; + } + + dispatch(id: string | number, message: Result) { + { + const callback = this.map.get(id); + if (!callback) return; + if (typeof callback !== "boolean") { + callback(message); + this.map.delete(id); + return Promise.resolve(); + } + } + + return new Promise((resolve) => { + let retried = 0; + + const checkAndDispatch = () => { + const callback = this.map.get(id); + if (typeof callback !== "boolean") { + callback(message); + this.map.delete(id); + resolve(); + return; + } + setTimeout(checkAndDispatch, 0); + if (++retried > 3) { + console.warn( + `retried ${retried} time(s) but the callback is still disappeared, id is "${id}"`, + ); + } + }; + + // start the loop + checkAndDispatch(); + }); + } + + createTypedCall< + K extends keyof RequestParams, + P extends RequestParams[K], + R extends ResponseResults[K], + E extends ResponseErrorDatas[K], + >(method: K, params: P): [Promise>, Promise>] { + return this.createCall(method, params) as [ + Promise>, + Promise>, + ]; + } +} + +interface RequestParams { + ping: void; +} + +interface ResponseResults { + ping: void; +} + +interface ResponseErrorDatas { + [key: string]: void; +} + +export type AnyCall = + JSONRPC & { + method: K; + params: RequestParams[K]; + }; diff --git a/src/serviceworker/tsconfig.json b/src/serviceworker/tsconfig.json new file mode 100644 index 0000000..bc88f57 --- /dev/null +++ b/src/serviceworker/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "WebWorker"], + }, +} \ No newline at end of file diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 826fea9..8a02813 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -45,6 +45,7 @@ import { } from "../platform/i18n.jsx"; import { type Template } from "@solid-primitives/i18n"; import BottomSheet from "../material/BottomSheet.jsx"; +import { useServiceWorker } from "../platform/host.js"; type Strings = { ["lang.auto"]: Template<{ detected: string }>; @@ -60,9 +61,7 @@ const Settings: ParentComponent = (props) => { ); const navigate = useNavigate(); const settings$ = useStore($settings); - const { - needRefresh: [needRefresh], - } = useRegisterSW(); + const { needRefresh, offlineReady } = useServiceWorker(); const dateFnLocale = useDateFnLocale(); const [profiles] = useSignedInProfiles(); @@ -236,6 +235,16 @@ const Settings: ParentComponent = (props) => { + + + {t("availability")} + + + diff --git a/src/settings/i18n/en.json b/src/settings/i18n/en.json index 98424be..d264cdc 100644 --- a/src/settings/i18n/en.json +++ b/src/settings/i18n/en.json @@ -32,5 +32,9 @@ "motions.gifs": "GIFs", "motions.gifs.autoplay": "Auto-play GIFs", "motions.vids": "Videos", - "motions.vids.autoplay": "Auto-play Videos" + "motions.vids.autoplay": "Auto-play Videos", + + "availability": "Availability", + "availability.offline": "Offline ready", + "availability.online": "Online only" } \ No newline at end of file diff --git a/src/settings/i18n/zh-Hans.json b/src/settings/i18n/zh-Hans.json index 481d57c..8725c46 100644 --- a/src/settings/i18n/zh-Hans.json +++ b/src/settings/i18n/zh-Hans.json @@ -32,5 +32,9 @@ "motions.gifs": "动图", "motions.gifs.autoplay": "自动播放动图", "motions.vids": "视频", - "motions.vids.autoplay": "自动播放视频" + "motions.vids.autoplay": "自动播放视频", + + "availability": "离线可用程度", + "availability.offline": "可以离线使用", + "availability.online": "需要联网使用" } \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 7ebb905..bd0b6d1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,10 +16,16 @@ export default defineConfig(({ mode }) => ({ }, }), VitePWA({ + strategies: "injectManifest", registerType: "autoUpdate", devOptions: { - enabled: mode === "staging", + enabled: mode === "staging" || mode === "dev", }, + srcDir: "src/serviceworker", + filename: "main.ts", + manifest: { + theme_color: "#673ab7" + } }), version(), ],