(null);
+export const NotificationManager = () => html` Notifications
+
+ This is an experimental feature and may not work as expected.
+ It will not work in offline-mode, and requires subscribing to the VAPID Web
+ Push server at ${API_BASE_URL}
for it to send Push
+ Notifications to your device in the background.
+
+
+ If enabled, you will receive a notification whenever a user generates a URL
+ with a message to send to you. Upon clicking on the notification, it will
+ immediately take you to their generated URL and reveal the message.
+
+ Notifications: ${notificationPermission.value}
+ Subscribed: ${pushSubscription.value !== null}
+
+
+
+
+
`;
+
+export async function sendNotification(publicKey: CryptoKey, url: URL) {
+ const exportedKey = await window.crypto.subtle.exportKey("raw", publicKey);
+ const publicKeyString = btoa(
+ String.fromCharCode(...new Uint8Array(exportedKey))
+ );
+
+ await fetch(`${API_BASE_URL}api/send-notification`, {
+ method: "POST",
+ body: JSON.stringify({
+ publicKey: publicKeyString,
+ url: url.toString(),
+ }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+}
+
+export async function checkPushSubscription() {
+ const registration = await navigator.serviceWorker.ready;
+ const subscription = await registration.pushManager.getSubscription();
+ pushSubscription.value = subscription;
+}
+
+export async function unsubscribeUserFromPush() {
+ try {
+ const registration = await navigator.serviceWorker.ready;
+ const subscription = await registration.pushManager.getSubscription();
+
+ if (!subscription) {
+ throw new Error("No subscription to unsubscribe");
+ }
+
+ const successful = await subscription.unsubscribe();
+ if (!successful) {
+ throw new Error("Failed to unsubscribe");
+ }
+
+ // await fetch(`${API_BASE_URL}api/unsubscribe`, {
+ // method: "POST",
+ // headers: {
+ // "Content-Type": "application/json",
+ // },
+ // body: JSON.stringify({ subscription }),
+ // });
+
+ pushSubscription.value = null;
+ console.log("User is unsubscribed.");
+ } catch (err) {
+ console.error("Failed to unsubscribe the user: ", err);
+ }
+}
+
+export async function subscribeUserToPush(
+ event: Event | undefined = undefined
+) {
+ event?.preventDefault();
+ try {
+ const permission = await Notification.requestPermission();
+ if (permission !== "granted") {
+ throw new Error("Notification permission not granted");
+ }
+
+ notificationPermission.value = permission;
+
+ const registration = await navigator.serviceWorker.ready;
+ let subscription = await registration.pushManager.getSubscription();
+ const response = await fetch(`${API_BASE_URL}api/public-key`);
+ const { publicKey: serverPublicKey } = await response.json();
+ if (!subscription) {
+ subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: serverPublicKey,
+ });
+ }
+
+ const { publicKey: publicKey } = await retrieveOrGenerateKeyPair();
+
+ const exportedKey = await window.crypto.subtle.exportKey("raw", publicKey);
+ const exportedKeyString = btoa(
+ String.fromCharCode(...new Uint8Array(exportedKey))
+ );
+
+ await fetch(`${API_BASE_URL}api/subscribe`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ subscription, publicKey: exportedKeyString }),
+ });
+
+ console.log("User is subscribed.");
+ checkPushSubscription();
+ } catch (err) {
+ console.error("Failed to subscribe the user: ", err);
+ }
+}
diff --git a/src/components/settings/Settings.ts b/src/components/settings/Settings.ts
new file mode 100644
index 0000000..18eb5f6
--- /dev/null
+++ b/src/components/settings/Settings.ts
@@ -0,0 +1,56 @@
+import { html } from "uhtml";
+import { closeModal, openModal } from "../Modal.ts";
+import { NotificationManager } from "./NotificationManager.ts";
+import { KeyManager, initKeyManager } from "./KeyManager.ts";
+import { hasServiceWorkers } from "../../utils/store.ts";
+
+export const Settings = () => html`Settings
+
+
+
+
+
+ `;
+
+export function openSettings(event: Event) {
+ event.preventDefault;
+ openModal(Settings);
+}
+
+async function openKeyManager(event: Event) {
+ event.preventDefault();
+ await initKeyManager();
+ openModal(KeyManager);
+}
+
+async function openNotificationManager(event: Event) {
+ event.preventDefault();
+ openModal(NotificationManager);
+}
+
+export async function checkServiceWorkers() {
+ if ("serviceWorker" in navigator) {
+ const registrations = await navigator.serviceWorker.getRegistrations();
+ hasServiceWorkers.value = registrations.length > 0;
+ console.log("Service Workers registered:", hasServiceWorkers.value);
+ }
+}
+
+export async function deregisterServiceWorkers(event: Event) {
+ event.preventDefault();
+ if ("serviceWorker" in navigator) {
+ const registrations = await navigator.serviceWorker.getRegistrations();
+ await Promise.all(
+ registrations.map((registration) => registration.unregister())
+ );
+ }
+ await checkServiceWorkers();
+ alert(
+ "Service Workers deregistered! If you think you're not seeing the latest version of the app, try refreshing the page."
+ );
+}
diff --git a/src/main.ts b/src/main.ts
index 24a23b2..b52e38f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,34 +1,40 @@
import "./style.scss";
-import { KeyManager } from "./template/keyManager.ts";
-import { Header } from "./template/header.ts";
+import { page } from "./utils/store.ts";
+import { Header } from "./components/Header.ts";
import { html } from "uhtml";
import { effect } from "uhtml/preactive";
import { reactive } from "uhtml/reactive";
-import { Footer } from "./template/footer.ts";
-import { HowItWorks } from "./template/howitworks.ts";
-import { FAQ } from "./template/faq.ts";
-import { page } from "./utils/store.ts";
+import { Footer } from "./components/Footer.ts";
+import { HowItWorks } from "./components/HowItWorks.ts";
+import { FAQ } from "./components/FAQ.ts";
import { routeToPage, hashChange } from "./router.ts";
-import { UpdateModal, checkForUpdates } from "./template/update.ts";
+import { UpdateModal, checkForUpdates } from "./components/Update.ts";
+import { subscribeUserToPush } from "./components/settings/NotificationManager.ts";
+import { AppModal } from "./components/Modal.ts";
-const render = reactive(effect);
-checkForUpdates();
-const template = () => html` ${KeyManager()} ${UpdateModal()}
-
- ${routeToPage(page.value)}
- ${HowItWorks}
- ${FAQ}
-
- ${Footer}`;
+try {
+ const render = reactive(effect);
+ const template = () => html` ${AppModal()} ${UpdateModal()}
+
+ ${routeToPage(page.value)}
+ ${HowItWorks}
+ ${FAQ}
+
+ ${Footer}`;
-// Listen for changes in the hash and update the state accordingly
-window.addEventListener("hashchange", hashChange);
+ // Listen for changes in the hash and update the state accordingly
+ window.addEventListener("hashchange", hashChange);
-// Initialize the app
-hashChange();
+ // Initialize the app
+ hashChange();
+ checkForUpdates();
+ const headerElement = document.getElementById("header");
+ render(headerElement, Header);
-const headerElement = document.getElementById("header");
-render(headerElement, Header);
+ const appElement = document.getElementById("page");
+ render(appElement, template);
-const appElement = document.getElementById("page");
-render(appElement, template);
+ if (Notification.permission === "granted") subscribeUserToPush();
+} catch (error) {
+ console.error(error);
+}
diff --git a/src/routes/request.ts b/src/routes/request.ts
index 3339cc5..898754e 100644
--- a/src/routes/request.ts
+++ b/src/routes/request.ts
@@ -4,7 +4,7 @@ import { signal } from "uhtml/preactive";
const URL_INPUT_ID = "request-url";
const COPY_BUTTON_ID = "copy-button";
-const requestUrl = signal(new URL("https://sure.dog/"));
+const requestUrl = signal(undefined);
export function RequestPage() {
return html`
@@ -22,7 +22,7 @@ export function RequestPage() {
type="text"
name="request-url"
.id=${URL_INPUT_ID}
- .value=${requestUrl.toString()}
+ .value=${requestUrl && requestUrl.toString()}
placeholder=""
aria-label="Request URL"
required
@@ -31,7 +31,7 @@ export function RequestPage() {
aria-label="Copy to Clipboard"
.id=${COPY_BUTTON_ID}
@click=${(event: Event) =>
- copyToClipboard(event, requestUrl.toString())}
+ copyToClipboard(event, requestUrl && requestUrl.toString())}
>
Copy to Clipboard
@@ -40,16 +40,27 @@ export function RequestPage() {
}
async function copyToClipboard(event: Event, url: string) {
- event.preventDefault();
- await navigator.clipboard.writeText(url);
- alert("Copied to clipboard!");
+ try {
+ event.preventDefault();
+ await navigator.clipboard.writeText(url);
+ alert("Copied to clipboard!");
+ } catch (err) {
+ console.error("Failed to copy to clipboard: ", err);
+ }
}
export async function generateRequestUrl() {
- const { publicKey: ecdhPublic } = await retrieveOrGenerateKeyPair();
- const ecdhPublicJwk = await window.crypto.subtle.exportKey("jwk", ecdhPublic);
- const updatedUrl = new URL(window.location.toString());
- updatedUrl.search = "";
- updatedUrl.hash = `p=${btoa(JSON.stringify(ecdhPublicJwk))}`;
- requestUrl.value = updatedUrl;
+ try {
+ const { publicKey: ecdhPublic } = await retrieveOrGenerateKeyPair();
+ const ecdhPublicJwk = await window.crypto.subtle.exportKey(
+ "jwk",
+ ecdhPublic
+ );
+ const updatedUrl = new URL(window.location.toString());
+ updatedUrl.search = "";
+ updatedUrl.hash = `p=${btoa(JSON.stringify(ecdhPublicJwk))}`;
+ requestUrl.value = updatedUrl;
+ } catch (err) {
+ console.error("Failed to generate request URL: ", err);
+ }
}
diff --git a/src/routes/send.ts b/src/routes/send.ts
index d2f1bda..b99708c 100644
--- a/src/routes/send.ts
+++ b/src/routes/send.ts
@@ -6,6 +6,7 @@ import {
} from "../utils/crypto.ts";
import { signal } from "uhtml/preactive";
import { LOCAL_STORAGE_KEYS } from "../utils/store.ts";
+import { sendNotification } from "../components/settings/NotificationManager.ts";
const messageToEncrypt = signal("");
const encryptedUrl = signal("");
@@ -71,7 +72,9 @@ export const SendPage = () => {
async function copyToClipboard(event: Event) {
event.preventDefault();
await navigator.clipboard.writeText(encryptedUrl.value);
- alert("Copied to clipboard! Send this URL to the requester.");
+ alert(
+ "Copied to clipboard! Send this URL to the requester, or they will be notified via a Push Notification if they have it enabled."
+ );
}
async function encryptData(event: Event) {
@@ -89,7 +92,7 @@ async function encryptData(event: Event) {
name: "ECDH",
namedCurve: "P-256",
},
- false,
+ true,
[]
);
@@ -117,6 +120,8 @@ async function encryptData(event: Event) {
)
)}`;
+ sendNotification(publicA, url);
+
encryptedUrl.value = url.toString();
isCopyButtonDisabled.value = false;
}
diff --git a/src/sw.js b/src/sw.js
new file mode 100644
index 0000000..61e54ef
--- /dev/null
+++ b/src/sw.js
@@ -0,0 +1,41 @@
+import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
+
+// This array will be injected with the actual file manifest
+
+precacheAndRoute(self.__WB_MANIFEST);
+cleanupOutdatedCaches();
+
+// Receive push notifications
+self.addEventListener("push", function (e) {
+ if (!(self.Notification && self.Notification.permission === "granted")) {
+ //notifications aren't supported or permission not granted!
+ return;
+ }
+
+ if (e.data) {
+ let message = e.data.json();
+ e.waitUntil(
+ self.registration.showNotification("SURE DOG", {
+ body: message.body,
+ icon: "img/logo.png",
+ data: message.data,
+ })
+ );
+ }
+});
+
+self.addEventListener("notificationclick", function (event) {
+ event.notification.close();
+ var url = event.notification.data.url;
+ event.waitUntil(
+ clients
+ .openWindow(event.notification.data.url)
+ .catch((err) => console.log(err))
+ );
+});
+
+self.addEventListener("message", (event) => {
+ if (event.data && event.data.type === "SKIP_WAITING") {
+ self.skipWaiting();
+ }
+});
diff --git a/src/template/update.ts b/src/template/update.ts
deleted file mode 100644
index fb08606..0000000
--- a/src/template/update.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { html } from "uhtml";
-import { signal } from "uhtml/preactive";
-import { registerSW } from "virtual:pwa-register";
-
-const open = signal(false);
-const updateSW = signal<
- ((reloadPage?: boolean | undefined) => Promise) | undefined
->(undefined);
-
-export const UpdateModal = () => {
- return html` `;
-};
-
-const update = (event: Event) => {
- event.preventDefault();
- if (updateSW.value) {
- console.log("Service Worker is updating.");
- updateSW.value(true);
- } else {
- console.error("Service Worker failed to update.");
- }
-};
-
-export const checkForUpdates = () => {
- if ("serviceWorker" in navigator) {
- const promise = registerSW({
- onNeedRefresh() {
- console.log("Service Worker is ready to update.");
- open.value = true;
- },
- onRegisteredSW() {
- console.log("Service Worker has been registered.");
- },
- onOfflineReady() {
- console.log("Service Worker is ready to handle offline usage.");
- },
- });
-
- updateSW.value = promise;
- }
-};
diff --git a/src/utils/store.ts b/src/utils/store.ts
index 1d7a248..bf7a370 100644
--- a/src/utils/store.ts
+++ b/src/utils/store.ts
@@ -1,6 +1,13 @@
import { signal } from "uhtml/preactive";
import { Route } from "../router";
+export const API_BASE_URL = new URL(
+ import.meta.env.VITE_API_BASE_URL || "http://localhost:3000"
+);
+export const BASE_URL = new URL(
+ import.meta.env.VITE_BASE_URL || "http://localhost:3000"
+);
+
// the current page / params for this URL
export const page = signal(Route.Receive);
export const params = signal(
@@ -12,3 +19,5 @@ export enum LOCAL_STORAGE_KEYS {
ECDH_PUBLIC_KEY = "ecdhPublic",
ECDH_PRIVATE_KEY = "ecdhPrivate",
}
+
+export const hasServiceWorkers = signal(false);
diff --git a/src/utils/vite-env.d.ts b/src/utils/vite-env.d.ts
index 11f02fe..3ec4b64 100644
--- a/src/utils/vite-env.d.ts
+++ b/src/utils/vite-env.d.ts
@@ -1 +1,6 @@
-///
+///
+
+interface ImportMetaEnv {
+ VITE_BASE_URL?: string;
+ VITE_API_BASE_URL?: string;
+}
diff --git a/vite.config.ts b/vite.config.ts
index ab87376..028204d 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,18 +1,58 @@
import { defineConfig } from "vite";
-import { VitePWA } from "vite-plugin-pwa";
import { version } from "./package.json";
+import { VitePWA } from "vite-plugin-pwa";
+
+const isProduction = process.env.NODE_ENV === "production";
+
+const API_BASE_URL = new URL(
+ process.env.VITE_API_BASE_URL || "http://localhost:3000"
+);
+const API_BASE_URL_SECURE = API_BASE_URL.protocol === "https:";
+const BASE_URL = new URL(process.env.VITE_BASE_URL || "http://localhost:5137");
+const BASE_URL_SECURE = BASE_URL.protocol === "https:";
export default defineConfig({
+ server: {
+ proxy: {
+ "/ws": {
+ target: API_BASE_URL_SECURE
+ ? `wss://${API_BASE_URL.hostname}`
+ : `ws://${API_BASE_URL.hostname}`,
+ ws: true,
+ changeOrigin: true,
+ secure: isProduction,
+ },
+ "/api": {
+ target: BASE_URL_SECURE
+ ? `https://${BASE_URL.hostname}`
+ : `http://${BASE_URL.hostname}`,
+ changeOrigin: true,
+ secure: isProduction,
+ },
+ },
+ },
define: {
"process.env.PACKAGE_VERSION": JSON.stringify(version),
"process.env.BUILD_TIME": JSON.stringify(new Date().toISOString()),
},
+ publicDir: "public",
plugins: [
VitePWA({
- devOptions: {
- enabled: true,
+ srcDir: "src",
+ filename: "sw.js",
+ registerType: "prompt",
+ includeAssets: [
+ "**/*.png",
+ "**/*.jpg",
+ "**/*.jpeg",
+ "**/*.svg",
+ "**/*.gif",
+ ],
+ strategies: "injectManifest",
+ workbox: {
+ globPatterns: ["**/*"],
+ globDirectory: "dist",
},
- includeAssets: ["**/*.png", "**/*.ico", "**/*.webmanifest"],
manifest: {
name: "SURE",
short_name: "SURE",
diff --git a/vite.standalone.config.ts b/vite.standalone.config.ts
index 20d6b19..46954c2 100644
--- a/vite.standalone.config.ts
+++ b/vite.standalone.config.ts
@@ -1,35 +1,6 @@
import { defineConfig } from "vite";
import { makeOffline } from "vite-plugin-make-offline";
-import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
- plugins: [
- makeOffline(),
- VitePWA({
- devOptions: {
- enabled: true,
- },
- manifest: {
- name: "SURE",
- short_name: "SURE",
- theme_color: "#44a616",
- background_color: "#3d006e",
- display: "minimal-ui",
- scope: "/",
- start_url: "/",
- icons: [
- {
- src: "/android-chrome-192x192.png",
- sizes: "192x192",
- type: "image/png",
- },
- {
- src: "/android-chrome-512x512.png",
- sizes: "512x512",
- type: "image/png",
- },
- ],
- },
- }),
- ],
+ plugins: [makeOffline()],
});