Browse Source
Completely remove Progressive Web App features including VitePWA plugin, service workers, install prompts, and platform service PWA methods. Delete PWA component, service worker files, help images, and update build configurations. Simplify application architecture by removing PWA complexity while maintaining core functionality.pull/142/head
15 changed files with 6 additions and 1107 deletions
Before Width: | Height: | Size: 5.1 KiB |
@ -1,177 +0,0 @@ |
|||
<template> |
|||
<transition |
|||
enter-active-class="transform ease-out duration-300 transition" |
|||
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4" |
|||
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0" |
|||
leave-active-class="transition ease-in duration-500" |
|||
leave-from-class="opacity-100" |
|||
leave-to-class="opacity-0" |
|||
> |
|||
<div |
|||
v-if="showInstallPrompt" |
|||
class="fixed z-[100] top-4 right-4 max-w-sm bg-white rounded-lg shadow-lg border border-gray-200" |
|||
> |
|||
<div class="p-4"> |
|||
<div class="flex items-start"> |
|||
<div class="flex-shrink-0"> |
|||
<font-awesome |
|||
icon="download" |
|||
class="h-6 w-6 text-blue-600" |
|||
title="Install App" |
|||
/> |
|||
</div> |
|||
<div class="ml-3 flex-1"> |
|||
<h3 class="text-sm font-medium text-gray-900"> |
|||
Install Time Safari |
|||
</h3> |
|||
<p class="mt-1 text-sm text-gray-500"> |
|||
Install this app on your device for a better experience |
|||
</p> |
|||
<div class="mt-4 flex space-x-3"> |
|||
<button |
|||
class="flex-1 bg-blue-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" |
|||
@click="installPWA" |
|||
> |
|||
Install |
|||
</button> |
|||
<button |
|||
class="flex-1 bg-gray-100 text-gray-700 text-sm font-medium px-3 py-2 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2" |
|||
@click="dismissPrompt" |
|||
> |
|||
Later |
|||
</button> |
|||
</div> |
|||
</div> |
|||
<div class="ml-4 flex-shrink-0"> |
|||
<button |
|||
class="text-gray-400 hover:text-gray-600 focus:outline-none" |
|||
@click="dismissPrompt" |
|||
> |
|||
<font-awesome icon="times" class="h-4 w-4" /> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</transition> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue } from "vue-facing-decorator"; |
|||
import { logger } from "@/utils/logger"; |
|||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; |
|||
|
|||
interface BeforeInstallPromptEvent extends Event { |
|||
prompt(): Promise<void>; |
|||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; |
|||
} |
|||
|
|||
@Component({ name: "PWAInstallPrompt" }) |
|||
export default class PWAInstallPrompt extends Vue { |
|||
$notify!: (notification: any, timeout?: number) => void; |
|||
private showInstallPrompt = false; |
|||
private deferredPrompt: BeforeInstallPromptEvent | null = null; |
|||
private dismissed = false; |
|||
|
|||
mounted() { |
|||
if (!PlatformServiceFactory.getInstance().isPWAEnabled) return; |
|||
this.setupInstallPrompt(); |
|||
} |
|||
|
|||
private setupInstallPrompt() { |
|||
// Check if already installed |
|||
if (this.isPWAInstalled()) { |
|||
logger.debug("[PWA] Install prompt disabled - PWA already installed"); |
|||
return; |
|||
} |
|||
|
|||
// Listen for the beforeinstallprompt event |
|||
window.addEventListener("beforeinstallprompt", (e) => { |
|||
logger.debug("[PWA] beforeinstallprompt event fired"); |
|||
|
|||
// Stash the event so it can be triggered later |
|||
this.deferredPrompt = e as BeforeInstallPromptEvent; |
|||
|
|||
// Show the install prompt |
|||
this.showInstallPrompt = true; |
|||
}); |
|||
|
|||
// Listen for successful installation |
|||
window.addEventListener("appinstalled", () => { |
|||
logger.debug("[PWA] App installed successfully"); |
|||
this.showInstallPrompt = false; |
|||
this.deferredPrompt = null; |
|||
|
|||
// Show success notification |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "success", |
|||
title: "App Installed!", |
|||
text: "Time Safari has been installed on your device.", |
|||
}, |
|||
5000, |
|||
); |
|||
}); |
|||
} |
|||
|
|||
private isPWAInstalled(): boolean { |
|||
// Check if running in standalone mode (installed PWA) |
|||
if (window.matchMedia("(display-mode: standalone)").matches) { |
|||
return true; |
|||
} |
|||
|
|||
// Check if running in fullscreen mode (installed PWA) |
|||
if (window.matchMedia("(display-mode: fullscreen)").matches) { |
|||
return true; |
|||
} |
|||
|
|||
// Check if running in minimal-ui mode (installed PWA) |
|||
if (window.matchMedia("(display-mode: minimal-ui)").matches) { |
|||
return true; |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
private async installPWA() { |
|||
if (!this.deferredPrompt) { |
|||
logger.warn("[PWA] No install prompt available"); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
// Show the install prompt |
|||
this.deferredPrompt.prompt(); |
|||
|
|||
// Wait for the user to respond to the prompt |
|||
const { outcome } = await this.deferredPrompt.userChoice; |
|||
|
|||
logger.debug(`[PWA] User response to install prompt: ${outcome}`); |
|||
|
|||
if (outcome === "accepted") { |
|||
logger.debug("[PWA] User accepted the install prompt"); |
|||
this.showInstallPrompt = false; |
|||
} else { |
|||
logger.debug("[PWA] User dismissed the install prompt"); |
|||
this.dismissed = true; |
|||
this.showInstallPrompt = false; |
|||
} |
|||
|
|||
// Clear the deferred prompt |
|||
this.deferredPrompt = null; |
|||
} catch (error) { |
|||
logger.error("[PWA] Error during install prompt:", error); |
|||
this.showInstallPrompt = false; |
|||
} |
|||
} |
|||
|
|||
private dismissPrompt() { |
|||
this.dismissed = true; |
|||
this.showInstallPrompt = false; |
|||
|
|||
// Don't show again for this session |
|||
sessionStorage.setItem("pwa-install-dismissed", "true"); |
|||
} |
|||
} |
|||
</script> |
@ -1,29 +0,0 @@ |
|||
/** |
|||
* We've seen cases where the functions inside safari-notifications.js are not found. |
|||
* This is our attempt to ensure that all the functions are available. |
|||
*/ |
|||
|
|||
const fs = require("fs"); |
|||
const path = require("path"); |
|||
|
|||
const swScriptsDir = path.resolve(__dirname, "sw_scripts"); |
|||
const outputFile = path.resolve(__dirname, "sw_scripts-combined.js"); |
|||
|
|||
// Read all files in the sw_scripts directory
|
|||
fs.readdir(swScriptsDir, (err, files) => { |
|||
if (err) { |
|||
console.error("Error reading directory:", err); |
|||
return; |
|||
} |
|||
|
|||
// Combine files content into one script
|
|||
const combinedContent = files |
|||
.filter((file) => path.extname(file) === ".js") |
|||
.map((file) => fs.readFileSync(path.join(swScriptsDir, file), "utf8")) |
|||
.join("\n"); |
|||
|
|||
// Write the combined content to the output file
|
|||
fs.writeFileSync(outputFile, combinedContent, "utf8"); |
|||
|
|||
console.log("Service worker files combined."); |
|||
}); |
@ -1,182 +0,0 @@ |
|||
/* eslint-env serviceworker */ |
|||
/* global workbox */ |
|||
/* eslint-disable */ /* ... because old-browser-compatible files in this directory are combined into a single script during `npm run build` */ |
|||
importScripts( |
|||
"https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js", |
|||
); |
|||
|
|||
// similar method is in the src/db/index.ts file
|
|||
function logConsoleAndDb(message, arg1, arg2) { |
|||
// in chrome://serviceworker-internals note that the arg1 and arg2 here will show as "[object Object]" in that page but will show as expandable objects in the console
|
|||
console.log(`${new Date().toISOString()} ${message}`, arg1, arg2); |
|||
// appendDailyLog is injected at build time by the vue.config.js configureWebpack apply plugin
|
|||
// eslint-disable-next-line no-undef
|
|||
if (appendDailyLog) { |
|||
let fullMessage = `${new Date().toISOString()} ${message}`; |
|||
if (arg1) { |
|||
if (typeof arg1 === "string") { |
|||
fullMessage += `\n${arg1}`; |
|||
} else { |
|||
fullMessage += `\n${JSON.stringify(arg1)}`; |
|||
} |
|||
} |
|||
if (arg2) { |
|||
if (typeof arg2 === "string") { |
|||
fullMessage += `\n${arg2}`; |
|||
} else { |
|||
fullMessage += `\n${JSON.stringify(arg2)}`; |
|||
} |
|||
} |
|||
// appendDailyLog is injected from safari-notifications.js at build time by the vue.config.js configureWebpack apply plugin
|
|||
// eslint-disable-next-line no-undef
|
|||
appendDailyLog(fullMessage); |
|||
} else { |
|||
// sometimes we get the error: "Uncaught TypeError: appendDailyLog is not a function"
|
|||
console.log( |
|||
"Not logging to DB (often because appendDailyLog doesn't exist).", |
|||
); |
|||
} |
|||
} |
|||
|
|||
self.addEventListener("install", async (/* event */) => { |
|||
logConsoleAndDb("Service worker finished installation."); |
|||
}); |
|||
|
|||
self.addEventListener("activate", (event) => { |
|||
logConsoleAndDb("Service worker is activating...", event); |
|||
// see https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim
|
|||
// and https://web.dev/articles/service-worker-lifecycle#clientsclaim
|
|||
event.waitUntil(clients.claim()); |
|||
logConsoleAndDb("Service worker is activated."); |
|||
}); |
|||
|
|||
self.addEventListener("push", function (event) { |
|||
let text = null; |
|||
if (event.data) { |
|||
text = event.data.text(); |
|||
} |
|||
logConsoleAndDb("Service worker received a push event.", text, event); |
|||
event.waitUntil( |
|||
(async () => { |
|||
try { |
|||
let payload; |
|||
if (text) { |
|||
try { |
|||
payload = JSON.parse(text); |
|||
} catch (e) { |
|||
// don't use payload since it is not JSON
|
|||
} |
|||
} |
|||
|
|||
// This is a special value that tells the service worker to trigger its daily check.
|
|||
// See https://gitea.anomalistdesign.com/trent_larson/py-push-server/src/commit/c1ed026662e754348a5f91542680bd4f57e5b81e/app.py#L217
|
|||
const DAILY_UPDATE_TITLE = "DAILY_CHECK"; |
|||
|
|||
// This is shared with the notification-test code and should be a constant. Look for the same name in HelpNotificationsView.vue
|
|||
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
|
|||
// Make sure it is something different from the DAILY_UPDATE_TITLE.
|
|||
const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION"; |
|||
|
|||
let title; |
|||
let message = "Got some empty message."; |
|||
if (payload && payload.title == DIRECT_PUSH_TITLE) { |
|||
// skip any search logic and show the message directly
|
|||
title = "Direct Message"; |
|||
message = payload.message || "No details were provided."; |
|||
} else { |
|||
// any other title will run through regular filtering logic
|
|||
if (payload && payload.title === DAILY_UPDATE_TITLE) { |
|||
title = "Daily Update"; |
|||
} else { |
|||
title = payload.title || "Update"; |
|||
} |
|||
// getNotificationCount is injected from safari-notifications.js at build time by the sw_combine.js script
|
|||
// eslint-disable-next-line no-undef
|
|||
message = await getNotificationCount(); |
|||
} |
|||
if (message) { |
|||
const options = { |
|||
body: message, |
|||
icon: payload ? payload.icon : "icon.png", |
|||
badge: payload ? payload.badge : "badge.png", |
|||
}; |
|||
await self.registration.showNotification(title, options); |
|||
logConsoleAndDb("Notified user:", options); |
|||
} else { |
|||
logConsoleAndDb("No notification message."); |
|||
} |
|||
} catch (error) { |
|||
logConsoleAndDb("Error with push event", event, error); |
|||
} |
|||
})(), |
|||
); |
|||
}); |
|||
|
|||
self.addEventListener("message", (event) => { |
|||
logConsoleAndDb("Service worker got a message...", event); |
|||
if (event.data && event.data.type === "SEND_LOCAL_DATA") { |
|||
self.secret = event.data.data; // used in safari-notifications.js to decrypt the account identity
|
|||
event.ports[0].postMessage({ success: true }); |
|||
} |
|||
logConsoleAndDb("Service worker posted a message."); |
|||
}); |
|||
|
|||
self.addEventListener("notificationclick", (event) => { |
|||
logConsoleAndDb("Notification got clicked.", event); |
|||
event.notification.close(); |
|||
// from https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/notificationclick_event
|
|||
// ... though I don't see any benefit over just "clients.openWindow"
|
|||
event.waitUntil( |
|||
clients |
|||
.matchAll({ |
|||
type: "window", |
|||
}) |
|||
.then((clientList) => { |
|||
for (const client of clientList) { |
|||
if (client.url === "/" && "focus" in client) return client.focus(); |
|||
} |
|||
if (clients.openWindow) return clients.openWindow("/"); |
|||
}), |
|||
); |
|||
}); |
|||
|
|||
// This is invoked when the user chooses this as a share_target, mapped to share-target in the manifest.
|
|||
self.addEventListener("fetch", (event) => { |
|||
// Skipping this because we get so many of them, at startup and other times, all with an event of: {isTrusted:true}
|
|||
//logConsoleAndDb("Service worker got fetch event.", event);
|
|||
|
|||
// Bypass any regular requests not related to Web Share Target
|
|||
// and also requests that are not exactly to the timesafari.app
|
|||
// (note that Chrome will send subdomain requests like image-api.timesafari.app through this service worker).
|
|||
if ( |
|||
event.request.method !== "POST" || |
|||
!event.request.url.endsWith("/share-target") |
|||
) { |
|||
event.respondWith(fetch(event.request)); |
|||
return; |
|||
} |
|||
|
|||
// Requests related to Web Share share-target Target.
|
|||
event.respondWith( |
|||
(async () => { |
|||
const formData = await event.request.formData(); |
|||
const photo = formData.get("photo"); |
|||
// savePhoto is injected from safari-notifications.js at build time by the sw_combine.js script
|
|||
// eslint-disable-next-line no-undef
|
|||
await savePhoto(photo); |
|||
return Response.redirect("/shared-photo", 303); |
|||
})(), |
|||
); |
|||
}); |
|||
|
|||
self.addEventListener("error", (event) => { |
|||
logConsoleAndDb("Service worker error", event); |
|||
console.error("Full Error:", event); |
|||
console.error("Message:", event.message); |
|||
console.error("File:", event.filename); |
|||
console.error("Line:", event.lineno); |
|||
console.error("Column:", event.colno); |
|||
console.error("Error Object:", event.error); |
|||
}); |
|||
|
|||
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST); |
@ -1,599 +0,0 @@ |
|||
function bufferFromBase64(base64) { |
|||
const binaryString = atob(base64); |
|||
const length = binaryString.length; |
|||
const bytes = new Uint8Array(length); |
|||
|
|||
for (let i = 0; i < length; i++) { |
|||
bytes[i] = binaryString.charCodeAt(i); |
|||
} |
|||
|
|||
return bytes; |
|||
} |
|||
|
|||
function fromString(str, encoding = "utf8") { |
|||
if (encoding === "utf8") { |
|||
return new TextEncoder().encode(str); |
|||
} else if (encoding === "base16") { |
|||
if (str.length % 2 !== 0) { |
|||
throw new Error("Invalid hex string length."); |
|||
} |
|||
let bytes = new Uint8Array(str.length / 2); |
|||
for (let i = 0; i < str.length; i += 2) { |
|||
bytes[i / 2] = parseInt(str.substring(i, i + 2), 16); |
|||
} |
|||
return bytes; |
|||
} else if (encoding === "base64url") { |
|||
str = str.replace(/-/g, "+").replace(/_/g, "/"); |
|||
while (str.length % 4) { |
|||
str += "="; |
|||
} |
|||
return new Uint8Array(bufferFromBase64(str)); |
|||
} else { |
|||
throw new Error(`Unsupported encoding "${encoding}"`); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Convert a Uint8Array to a string with the given encoding. |
|||
* |
|||
* @param {Uint8Array} byteArray - The Uint8Array to convert. |
|||
* @param {string} [encoding='utf8'] - The desired encoding ('utf8', 'base16', 'base64url'). |
|||
* @returns {string} - The encoded string. |
|||
* @throws {Error} - Throws an error if the encoding is unsupported. |
|||
*/ |
|||
function toString(byteArray, encoding = "utf8") { |
|||
switch (encoding) { |
|||
case "utf8": |
|||
return decodeUTF8(byteArray); |
|||
case "base16": |
|||
return toBase16(byteArray); |
|||
case "base64url": |
|||
return toBase64Url(byteArray); |
|||
default: |
|||
throw new Error(`Unsupported encoding "${encoding}"`); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Decode a Uint8Array as a UTF-8 string. |
|||
* |
|||
* @param {Uint8Array} byteArray |
|||
* @returns {string} |
|||
*/ |
|||
function decodeUTF8(byteArray) { |
|||
return new TextDecoder().decode(byteArray); |
|||
} |
|||
|
|||
/** |
|||
* Convert a Uint8Array to a base16 (hex) encoded string. |
|||
* |
|||
* @param {Uint8Array} byteArray |
|||
* @returns {string} |
|||
*/ |
|||
function toBase16(byteArray) { |
|||
return Array.from(byteArray) |
|||
.map((byte) => byte.toString(16).padStart(2, "0")) |
|||
.join(""); |
|||
} |
|||
|
|||
/** |
|||
* Convert a Uint8Array to a base64url encoded string. |
|||
* |
|||
* @param {Uint8Array} byteArray |
|||
* @returns {string} |
|||
*/ |
|||
function toBase64Url(byteArray) { |
|||
let uint8Array = new Uint8Array(byteArray); |
|||
let binaryString = ""; |
|||
for (let i = 0; i < uint8Array.length; i++) { |
|||
binaryString += String.fromCharCode(uint8Array[i]); |
|||
} |
|||
|
|||
// Encode to base64
|
|||
let base64 = btoa(binaryString); |
|||
|
|||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
|||
} |
|||
|
|||
const u8a = { toString, fromString }; |
|||
|
|||
function sha256(payload) { |
|||
const data = typeof payload === "string" ? u8a.fromString(payload) : payload; |
|||
return nobleHashes.sha256(data); |
|||
} |
|||
|
|||
async function accessToken(identifier) { |
|||
const did = identifier["did"]; |
|||
const privateKeyHex = identifier["keys"][0]["privateKeyHex"]; |
|||
|
|||
const signer = await SimpleSigner(privateKeyHex); |
|||
|
|||
const nowEpoch = Math.floor(Date.now() / 1000); |
|||
const endEpoch = nowEpoch + 60; // add one minute
|
|||
|
|||
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did }; |
|||
const alg = undefined; // defaults to 'ES256K', more standardized but harder to verify vs ES256K-R
|
|||
const jwt = await createJWT(tokenPayload, { |
|||
alg, |
|||
issuer: did, |
|||
signer, |
|||
}); |
|||
return jwt; |
|||
} |
|||
|
|||
async function createJWT(payload, options, header = {}) { |
|||
const { issuer, signer, alg, expiresIn, canonicalize } = options; |
|||
|
|||
if (!signer) |
|||
throw new Error( |
|||
"missing_signer: No Signer functionality has been configured", |
|||
); |
|||
if (!issuer) |
|||
throw new Error("missing_issuer: No issuing DID has been configured"); |
|||
if (!header.typ) header.typ = "JWT"; |
|||
if (!header.alg) header.alg = alg; |
|||
|
|||
const timestamps = { |
|||
iat: Math.floor(Date.now() / 1000), |
|||
exp: undefined, |
|||
}; |
|||
|
|||
if (expiresIn) { |
|||
if (typeof expiresIn === "number") { |
|||
timestamps.exp = (payload.nbf || timestamps.iat) + Math.floor(expiresIn); |
|||
} else { |
|||
throw new Error("invalid_argument: JWT expiresIn is not a number"); |
|||
} |
|||
} |
|||
|
|||
const fullPayload = { ...timestamps, ...payload, iss: issuer }; |
|||
return createJWS(fullPayload, signer, header, { canonicalize }); |
|||
} |
|||
|
|||
const defaultAlg = "ES256K"; |
|||
|
|||
async function createJWS(payload, signer, header = {}, options = {}) { |
|||
if (!header.alg) header.alg = defaultAlg; |
|||
const encodedPayload = |
|||
typeof payload === "string" |
|||
? payload |
|||
: encodeSection(payload, options.canonicalize); |
|||
const signingInput = [ |
|||
encodeSection(header, options.canonicalize), |
|||
encodedPayload, |
|||
].join("."); |
|||
|
|||
const jwtSigner = ES256KSignerAlg(false); |
|||
const signature = await jwtSigner(signingInput, signer); |
|||
|
|||
// JWS Compact Serialization
|
|||
// https://www.rfc-editor.org/rfc/rfc7515#section-7.1
|
|||
return [signingInput, signature].join("."); |
|||
} |
|||
|
|||
function canonicalizeData(object) { |
|||
if (typeof object === "number" && isNaN(object)) { |
|||
throw new Error("NaN is not allowed"); |
|||
} |
|||
|
|||
if (typeof object === "number" && !isFinite(object)) { |
|||
throw new Error("Infinity is not allowed"); |
|||
} |
|||
|
|||
if (object === null || typeof object !== "object") { |
|||
return JSON.stringify(object); |
|||
} |
|||
|
|||
if (object.toJSON instanceof Function) { |
|||
return serialize(object.toJSON()); |
|||
} |
|||
|
|||
if (Array.isArray(object)) { |
|||
const values = object.reduce((t, cv, ci) => { |
|||
const comma = ci === 0 ? "" : ","; |
|||
const value = cv === undefined || typeof cv === "symbol" ? null : cv; |
|||
return `${t}${comma}${serialize(value)}`; |
|||
}, ""); |
|||
return `[${values}]`; |
|||
} |
|||
|
|||
const values = Object.keys(object) |
|||
.sort() |
|||
.reduce((t, cv) => { |
|||
if (object[cv] === undefined || typeof object[cv] === "symbol") { |
|||
return t; |
|||
} |
|||
const comma = t.length === 0 ? "" : ","; |
|||
return `${t}${comma}${serialize(cv)}:${serialize(object[cv])}`; |
|||
}, ""); |
|||
return `{${values}}`; |
|||
} |
|||
|
|||
function encodeSection(data, shouldCanonicalize = false) { |
|||
if (shouldCanonicalize) { |
|||
return encodeBase64url(canonicalizeData(data)); |
|||
} else { |
|||
return encodeBase64url(JSON.stringify(data)); |
|||
} |
|||
} |
|||
|
|||
function encodeBase64url(s) { |
|||
return bytesToBase64url(u8a.fromString(s)); |
|||
} |
|||
|
|||
function instanceOfEcdsaSignature(object) { |
|||
return typeof object === "object" && "r" in object && "s" in object; |
|||
} |
|||
|
|||
function ES256KSignerAlg(recoverable) { |
|||
return async function sign(payload, signer) { |
|||
const signature = await signer(payload); |
|||
if (instanceOfEcdsaSignature(signature)) { |
|||
return toJose(signature, recoverable); |
|||
} else { |
|||
if ( |
|||
recoverable && |
|||
typeof fromJose(signature).recoveryParam === "undefined" |
|||
) { |
|||
throw new Error( |
|||
`not_supported: ES256K-R not supported when signer doesn't provide a recovery param`, |
|||
); |
|||
} |
|||
return signature; |
|||
} |
|||
}; |
|||
} |
|||
|
|||
function leftpad(data, size = 64) { |
|||
if (data.length === size) return data; |
|||
return "0".repeat(size - data.length) + data; |
|||
} |
|||
|
|||
async function SimpleSigner(hexPrivateKey) { |
|||
const signer = await ES256KSigner(hexToBytes(hexPrivateKey), true); |
|||
return async (data) => { |
|||
const signature = await signer(data); |
|||
return fromJose(signature); |
|||
}; |
|||
} |
|||
|
|||
function hexToBytes(s, minLength) { |
|||
let input = s.startsWith("0x") ? s.substring(2) : s; |
|||
|
|||
if (input.length % 2 !== 0) { |
|||
input = `0${input}`; |
|||
} |
|||
|
|||
if (minLength) { |
|||
const paddedLength = Math.max(input.length, minLength * 2); |
|||
input = input.padStart(paddedLength, "00"); |
|||
} |
|||
|
|||
return u8a.fromString(input.toLowerCase(), "base16"); |
|||
} |
|||
|
|||
function ES256KSigner(privateKey, recoverable = false) { |
|||
const privateKeyBytes = privateKey; |
|||
if (privateKeyBytes.length !== 32) { |
|||
throw new Error( |
|||
`bad_key: Invalid private key format. Expecting 32 bytes, but got ${privateKeyBytes.length}`, |
|||
); |
|||
} |
|||
|
|||
return async function (data) { |
|||
const signature = nobleCurves.secp256k1.sign(sha256(data), privateKeyBytes); |
|||
return toJose( |
|||
{ |
|||
r: leftpad(signature.r.toString(16)), |
|||
s: leftpad(signature.s.toString(16)), |
|||
recoveryParam: signature.recovery, |
|||
}, |
|||
recoverable, |
|||
); |
|||
}; |
|||
} |
|||
|
|||
function toJose(signature, recoverable) { |
|||
const { r, s, recoveryParam } = signature; |
|||
const jose = new Uint8Array(recoverable ? 65 : 64); |
|||
jose.set(u8a.fromString(r, "base16"), 0); |
|||
jose.set(u8a.fromString(s, "base16"), 32); |
|||
|
|||
if (recoverable) { |
|||
if (typeof recoveryParam === "undefined") { |
|||
throw new Error("Signer did not return a recoveryParam"); |
|||
} |
|||
jose[64] = recoveryParam; |
|||
} |
|||
return bytesToBase64url(jose); |
|||
} |
|||
|
|||
function bytesToBase64url(b) { |
|||
return u8a.toString(b, "base64url"); |
|||
} |
|||
|
|||
function base64ToBytes(s) { |
|||
const inputBase64Url = s |
|||
.replace(/\+/g, "-") |
|||
.replace(/\//g, "_") |
|||
.replace(/=/g, ""); |
|||
return u8a.fromString(inputBase64Url, "base64url"); |
|||
} |
|||
|
|||
function bytesToHex(b) { |
|||
return u8a.toString(b, "base16"); |
|||
} |
|||
|
|||
function fromJose(signature) { |
|||
const signatureBytes = base64ToBytes(signature); |
|||
if (signatureBytes.length < 64 || signatureBytes.length > 65) { |
|||
throw new TypeError( |
|||
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`, |
|||
); |
|||
} |
|||
const r = bytesToHex(signatureBytes.slice(0, 32)); |
|||
const s = bytesToHex(signatureBytes.slice(32, 64)); |
|||
const recoveryParam = |
|||
signatureBytes.length === 65 ? signatureBytes[64] : undefined; |
|||
|
|||
return { r, s, recoveryParam }; |
|||
} |
|||
|
|||
function validateBase64(s) { |
|||
if ( |
|||
!/^(?:[A-Za-z0-9+\/]{2}[A-Za-z0-9+\/]{2})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/.test( |
|||
s, |
|||
) |
|||
) { |
|||
throw new TypeError("invalid encoding"); |
|||
} |
|||
} |
|||
|
|||
function decodeBase64(s) { |
|||
validateBase64(s); |
|||
var i, |
|||
d = atob(s), |
|||
b = new Uint8Array(d.length); |
|||
for (i = 0; i < d.length; i++) b[i] = d.charCodeAt(i); |
|||
return b; |
|||
} |
|||
|
|||
async function getSettingById(id) { |
|||
return new Promise((resolve, reject) => { |
|||
let openRequest = indexedDB.open("TimeSafari"); |
|||
|
|||
openRequest.onupgradeneeded = (event) => { |
|||
// Handle database setup if necessary
|
|||
let db = event.target.result; |
|||
if (!db.objectStoreNames.contains("settings")) { |
|||
db.createObjectStore("settings", { keyPath: "id" }); |
|||
} |
|||
}; |
|||
|
|||
openRequest.onsuccess = (event) => { |
|||
let db = event.target.result; |
|||
let transaction = db.transaction("settings", "readonly"); |
|||
let objectStore = transaction.objectStore("settings"); |
|||
let getRequest = objectStore.get(id); |
|||
|
|||
getRequest.onsuccess = () => resolve(getRequest.result); |
|||
getRequest.onerror = () => reject(getRequest.error); |
|||
}; |
|||
|
|||
openRequest.onerror = () => reject(openRequest.error); |
|||
}); |
|||
} |
|||
|
|||
async function setMostRecentNotified(id) { |
|||
try { |
|||
const db = await openIndexedDB("TimeSafari"); |
|||
const transaction = db.transaction("settings", "readwrite"); |
|||
const store = transaction.objectStore("settings"); |
|||
const data = await getRecord(store, 1); |
|||
|
|||
if (data) { |
|||
data["lastNotifiedClaimId"] = id; |
|||
await updateRecord(store, data); |
|||
} else { |
|||
console.error( |
|||
"safari-notifications setMostRecentNotified IndexedDB settings record not found", |
|||
); |
|||
} |
|||
|
|||
transaction.oncomplete = () => db.close(); |
|||
} catch (error) { |
|||
console.error( |
|||
"safari-notifications setMostRecentNotified IndexedDB error", |
|||
error, |
|||
); |
|||
} |
|||
} |
|||
|
|||
async function appendDailyLog(message) { |
|||
try { |
|||
const db = await openIndexedDB("TimeSafari"); |
|||
const transaction = db.transaction("logs", "readwrite"); |
|||
const store = transaction.objectStore("logs"); |
|||
// only keep one day's worth of logs
|
|||
const todayKey = new Date().toDateString(); |
|||
const previous = await getRecord(store, todayKey); |
|||
if (!previous) { |
|||
await store.clear(); // clear out everything previous when this is today's first log
|
|||
} |
|||
let fullMessage = (previous && previous.message) || ""; |
|||
if (fullMessage) { |
|||
fullMessage += "\n"; |
|||
} |
|||
fullMessage += message; |
|||
await updateRecord(store, { date: todayKey, message: fullMessage }); |
|||
transaction.oncomplete = () => db.close(); |
|||
return true; |
|||
} catch (error) { |
|||
console.error("safari-notifications logMessage IndexedDB error", error); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
function openIndexedDB(dbName) { |
|||
return new Promise((resolve, reject) => { |
|||
const request = indexedDB.open(dbName); |
|||
request.onerror = () => reject(request.error); |
|||
request.onsuccess = () => resolve(request.result); |
|||
}); |
|||
} |
|||
|
|||
function getRecord(store, key) { |
|||
return new Promise((resolve, reject) => { |
|||
const request = store.get(key); |
|||
request.onsuccess = () => resolve(request.result); |
|||
request.onerror = () => reject(request.error); |
|||
}); |
|||
} |
|||
|
|||
// Note that this assumes there is only one record in the store.
|
|||
function updateRecord(store, data) { |
|||
return new Promise((resolve, reject) => { |
|||
const request = store.put(data); |
|||
request.onsuccess = () => resolve(request.result); |
|||
request.onerror = () => reject(request.error); |
|||
}); |
|||
} |
|||
|
|||
async function fetchAllAccounts() { |
|||
return new Promise((resolve, reject) => { |
|||
const openRequest = indexedDB.open("TimeSafariAccounts"); |
|||
|
|||
openRequest.onupgradeneeded = function (event) { |
|||
const db = event.target.result; |
|||
if (!db.objectStoreNames.contains("accounts")) { |
|||
db.createObjectStore("accounts", { keyPath: "id" }); |
|||
} |
|||
}; |
|||
|
|||
openRequest.onsuccess = function (event) { |
|||
const db = event.target.result; |
|||
const transaction = db.transaction("accounts", "readonly"); |
|||
const objectStore = transaction.objectStore("accounts"); |
|||
const getAllRequest = objectStore.getAll(); |
|||
|
|||
getAllRequest.onsuccess = function () { |
|||
resolve(getAllRequest.result); |
|||
}; |
|||
getAllRequest.onerror = function () { |
|||
reject(getAllRequest.error); |
|||
}; |
|||
}; |
|||
|
|||
openRequest.onerror = function () { |
|||
reject(openRequest.error); |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
async function getNotificationCount() { |
|||
let accounts = []; |
|||
let result = null; |
|||
// 1 is our master settings ID; see MASTER_SETTINGS_KEY
|
|||
const settings = await getSettingById(1); |
|||
let lastNotifiedClaimId = null; |
|||
if ("lastNotifiedClaimId" in settings) { |
|||
lastNotifiedClaimId = settings["lastNotifiedClaimId"]; |
|||
} |
|||
const activeDid = settings["activeDid"]; |
|||
accounts = await fetchAllAccounts(); |
|||
let activeAccount = null; |
|||
for (let i = 0; i < accounts.length; i++) { |
|||
if (accounts[i]["did"] == activeDid) { |
|||
activeAccount = accounts[i]; |
|||
break; |
|||
} |
|||
} |
|||
|
|||
const headers = { |
|||
"Content-Type": "application/json", |
|||
}; |
|||
|
|||
const identity = activeAccount && activeAccount["identity"]; |
|||
if (identity && "secret" in self) { |
|||
// get the "secret" pulled in additional-scripts.js to decrypt the "identity" inside the IndexedDB; see account.ts
|
|||
const secret = self.secret; |
|||
const secretUint8Array = self.decodeBase64(secret); |
|||
const messageWithNonceAsUint8Array = self.decodeBase64(identity); |
|||
const nonce = messageWithNonceAsUint8Array.slice(0, 24); |
|||
const message = messageWithNonceAsUint8Array.slice(24, identity.length); |
|||
const decoder = new TextDecoder("utf-8"); |
|||
const decrypted = self.secretbox.open(message, nonce, secretUint8Array); |
|||
const msg = decoder.decode(decrypted); |
|||
const identifier = JSON.parse(JSON.parse(msg)); |
|||
|
|||
headers["Authorization"] = "Bearer " + (await accessToken(identifier)); |
|||
} |
|||
|
|||
const response = await fetch( |
|||
settings["apiServer"] + "/api/v2/report/claims", |
|||
{ |
|||
method: "GET", |
|||
headers: headers, |
|||
}, |
|||
); |
|||
if (response.status == 200) { |
|||
const json = await response.json(); |
|||
const claims = json["data"]; |
|||
let newClaims = 0; |
|||
for (let i = 0; i < claims.length; i++) { |
|||
const claim = claims[i]; |
|||
if (claim["id"] === lastNotifiedClaimId) { |
|||
break; |
|||
} |
|||
newClaims++; |
|||
} |
|||
if (newClaims > 0) { |
|||
if (newClaims === 1) { |
|||
result = "There is 1 new activity on Time Safari"; |
|||
} else { |
|||
result = `There are ${newClaims} new activities on Time Safari`; |
|||
} |
|||
} |
|||
const most_recent_notified = claims[0]["id"]; |
|||
await setMostRecentNotified(most_recent_notified); |
|||
} else { |
|||
console.error( |
|||
"safari-notifications getNotificationsCount got a bad response status when fetching claims", |
|||
response.status, |
|||
response, |
|||
); |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
|
|||
async function blobToBase64String(blob) { |
|||
return new Promise((resolve, reject) => { |
|||
const reader = new FileReader(); |
|||
reader.onloadend = () => resolve(reader.result); // potential problem if it returns an ArrayBuffer?
|
|||
reader.onerror = reject; |
|||
reader.readAsDataURL(blob); |
|||
}); |
|||
} |
|||
|
|||
// Store the image blob and go immediate to a page to upload it.
|
|||
// @param photo - image Blob to store for later retrieval after redirect
|
|||
async function savePhoto(photo) { |
|||
try { |
|||
const photoBase64 = await blobToBase64String(photo); |
|||
const db = await openIndexedDB("TimeSafari"); |
|||
const transaction = db.transaction("temp", "readwrite"); |
|||
const store = transaction.objectStore("temp"); |
|||
await updateRecord(store, { |
|||
id: "shared-photo-base64", |
|||
blobB64: photoBase64, |
|||
}); |
|||
transaction.oncomplete = () => db.close(); |
|||
} catch (error) { |
|||
console.error("safari-notifications logMessage IndexedDB error", error); |
|||
} |
|||
} |
|||
|
|||
self.appendDailyLog = appendDailyLog; |
|||
self.getNotificationCount = getNotificationCount; |
|||
self.decodeBase64 = decodeBase64; |
Loading…
Reference in new issue