diff --git a/.eslintrc.json b/.eslintrc.json index 3f5a528..3069218 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -22,7 +22,9 @@ "rules": { "quotes": ["error", "double", { "allowTemplateLiterals": true }], "semi": "error", - "indent": ["error", 2], + "indent": ["error", 2, { + "FunctionDeclaration": { "parameters": "first" } + }], "eol-last": ["error", "always"], "tsdoc/syntax": "warn", "react/function-component-definition": ["warn", { diff --git a/.vscode/settings.json b/.vscode/settings.json index 559db62..35c2d53 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,6 +8,7 @@ }, "cSpell.words": [ "Transpiler", + "arraybuffer", "esnext", "firstseen", "keepalive", diff --git a/package-lock.json b/package-lock.json index 00052bf..caa9570 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3254,6 +3254,11 @@ } } }, + "base64-arraybuffer": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz", + "integrity": "sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ==" + }, "base64-js": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", @@ -12942,6 +12947,11 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, + "spu-md5": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/spu-md5/-/spu-md5-0.0.4.tgz", + "integrity": "sha512-+YM92iiWpGNmAM+ZiWd2vAviA5utCB0I0Y8vptIaSF6FwPqyCbXskAGJ3VcVfdgS1YSARiBgyz7yOEJqzFSCWQ==" + }, "sshpk": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", diff --git a/package.json b/package.json index 7e21269..55eae45 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "private": true, "dependencies": { "@craco/craco": "^5.6.4", + "base64-arraybuffer": "^0.2.0", "bootstrap": "^4.5.2", "debug": "^4.1.1", "prop-types": "^15.7.2", @@ -25,6 +26,7 @@ "react-router-dom": "^5.2.0", "react-scripts": "^3.4.3", "semver": "^7.3.2", + "spu-md5": "0.0.4", "typescript": "^3.9.7", "websocket-as-promised": "^1.0.1" }, diff --git a/src/app/App.tsx b/src/app/App.tsx index 86892e6..4b97e3c 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -2,13 +2,18 @@ import "./App.scss"; import { MainLayout } from "../layouts/main"; + +import { StorageManager } from "./StorageManager"; import { kristService } from "@krist/KristConnectionService"; import packageJson from "@/package.json"; -// TODO -kristService().connect(packageJson.defaultSyncNode) + +kristService().connect(packageJson.defaultSyncNode) // TODO .catch(console.error); export const App = (): JSX.Element => ( - + <> + + + ); diff --git a/src/app/MasterPasswordDialog.tsx b/src/app/MasterPasswordDialog.tsx new file mode 100644 index 0000000..23429c9 --- /dev/null +++ b/src/app/MasterPasswordDialog.tsx @@ -0,0 +1,33 @@ +import React, { Component } from "react"; + +import Modal from "react-bootstrap/Modal"; +import Button from "react-bootstrap/Button"; +import Form from "react-bootstrap/Form"; + +export class MasterPasswordDialog extends Component { + render(): JSX.Element { + return ( + /* TODO: Animation is disabled for now, because react-bootstrap (or more + specifically, react-transition-group) has an incompatibility with + strict mode. */ + + + Master password + + +

Enter your master password to access your wallets, or browse + KristWeb as a guest.

+ + {/* Provide a username field for browser autofill */} +
+ + + + + +
+ ); + } +} diff --git a/src/app/MasterPasswordSetupDialog.tsx b/src/app/MasterPasswordSetupDialog.tsx new file mode 100644 index 0000000..f2c5c4c --- /dev/null +++ b/src/app/MasterPasswordSetupDialog.tsx @@ -0,0 +1,62 @@ +import React, { Component, RefObject, SyntheticEvent } from "react"; + +import Modal from "react-bootstrap/Modal"; +import Button from "react-bootstrap/Button"; +import Form from "react-bootstrap/Form"; + +import { StorageManager } from "./StorageManager"; + +interface MasterPasswordSetupDialogProps { + storageManager: StorageManager; +} + +export class MasterPasswordSetupDialog extends Component { + passwordInput: RefObject; + + constructor(props: MasterPasswordSetupDialogProps) { + super(props); + + this.passwordInput = React.createRef(); + } + + onSave(event: SyntheticEvent): void { + event.preventDefault(); + + if (!this.passwordInput.current) + throw new Error("passwordInput ref is undefined!"); + + const masterPassword = this.passwordInput.current.value; + this.props.storageManager.setMasterPassword(masterPassword); + } + + render(): JSX.Element { + return ( + /* TODO: Animation is disabled for now, because react-bootstrap (or more + specifically, react-transition-group) has an incompatibility with + strict mode. */ + +
+ + Master password + + +

Enter a master password to encrypt your wallets, + or browse KristWeb as a guest.

+

+ Never forget this password. If you forget it, you will have to + create a new one and add all your wallets again. +

+ + {/* Provide a username field for browser autofill */} +
+ + + + +
+
+ ); + } +} diff --git a/src/app/StorageManager.tsx b/src/app/StorageManager.tsx new file mode 100644 index 0000000..f2ee3fd --- /dev/null +++ b/src/app/StorageManager.tsx @@ -0,0 +1,75 @@ +import React, { Component } from "react"; + +import { toHex } from "@utils"; +import { aesGcmEncrypt, aesGcmDecrypt } from "@utils/crypto"; + +import { MasterPasswordDialog } from "./MasterPasswordDialog"; +import { MasterPasswordSetupDialog } from "./MasterPasswordSetupDialog"; + +import Debug from "debug"; +const debug = Debug("kristweb:storage"); + +type StorageManagerData = { + /** Whether or not the user has logged in, either as a guest, or with a + * master password. */ + isLoggedIn: boolean; + + /** Whether or not the user is browsing KristWeb as a guest. */ + isGuest: boolean; + + /** Secure random string that is encrypted with the master password to create + * the "tester" string. */ + salt?: string; + /** The `salt` encrypted with the master password, to test the password is + * correct. */ + tester?: string; + + /** Whether or not the user has configured and saved a master password + * before (whether or not salt+tester are present in local storage). */ + hasMasterPassword: boolean; +} + +export class StorageManager extends Component { + constructor(props: unknown) { + super(props); + + // Check current data stored in local storage. + const salt = localStorage.getItem("salt") || undefined; + const tester = localStorage.getItem("tester") || undefined; + + this.state = { + isLoggedIn: false, + isGuest: true, + + // Salt and tester from local storage (or undefined) + salt, tester, + // There is a master password configured if both `salt` and `tester` exist + hasMasterPassword: !!salt && !!tester + }; + + debug("hasMasterPassword: %b", this.state.hasMasterPassword); + } + + async setMasterPassword(password: string): Promise { + if (!password) throw new Error("Password is required."); + + // Generate the salt (to be encrypted with the master password) + const salt = window.crypto.getRandomValues(new Uint8Array(32)); + + // Generate the encryption tester + const tester = await aesGcmEncrypt(toHex(salt), password); + + debug("master password salt: %x tester: %s", salt, tester); + } + + /** Render the master password login/setup dialog */ + render(): JSX.Element | null { + const { isLoggedIn, hasMasterPassword } = this.state; + if (isLoggedIn) return null; // Don't show the dialog again + + if (hasMasterPassword) // Let the user log in with existing master password + return ; + else // Have the user set a password up first + return ; + } +} diff --git a/src/index.tsx b/src/index.tsx index 11b9263..c69f121 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,8 +1,9 @@ +import "./utils/setup"; // Set up service worker and some libs + import React from "react"; import ReactDOM from "react-dom"; import "./index.scss"; import { App } from "./app/App"; -import * as serviceWorker from "./serviceWorker"; ReactDOM.render( @@ -10,8 +11,3 @@ , document.getElementById("root") ); - -// If you want your app to work offline and load faster, you can change -// unregister() to register() below. Note this comes with some pitfalls. -// Learn more about service workers: https://bit.ly/CRA-PWA -serviceWorker.unregister(); diff --git a/src/scss/_variables.scss b/src/scss/_variables.scss index 1262472..df939d2 100644 --- a/src/scss/_variables.scss +++ b/src/scss/_variables.scss @@ -7,6 +7,16 @@ $font-family-base: "Lato", sans-serif; +$enable-shadows: true; + +$btn-box-shadow: none; +$input-box-shadow: none; + +$modal-content-box-shadow-xs: 0 .5rem 1rem rgba(#000, .15); +$modal-content-box-shadow-sm-up: 0 .5rem 1rem rgba(#000, .15); +$modal-content-border-width: 0; +$modal-header-border-width: 1px; + /* Import Bootstrap after redefining */ @import "~bootstrap/scss/functions", @@ -41,4 +51,4 @@ /* -------------------------------------------------------------------------- */ /* MISC */ /* -------------------------------------------------------------------------- */ -$krist-value-alt-color: $text-muted; // TODO: maybe green? \ No newline at end of file +$krist-value-alt-color: $text-muted; // TODO: maybe green? diff --git a/src/serviceWorker.ts b/src/serviceWorker.ts deleted file mode 100644 index 49eedf4..0000000 --- a/src/serviceWorker.ts +++ /dev/null @@ -1,149 +0,0 @@ -// This optional code is used to register a service worker. -// register() is not called by default. - -// This lets the app load faster on subsequent visits in production, and gives -// it offline capabilities. However, it also means that developers (and users) -// will only see deployed updates on subsequent visits to a page, after all the -// existing tabs open on the page have been closed, since previously cached -// resources are updated in the background. - -// To learn more about the benefits of this model and instructions on how to -// opt-in, read https://bit.ly/CRA-PWA - -const isLocalhost = Boolean( - window.location.hostname === "localhost" || - // [::1] is the IPv6 localhost address. - window.location.hostname === "[::1]" || - // 127.0.0.0/8 are considered localhost for IPv4. - window.location.hostname.match( - /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ - ) -); - -type Config = { - onSuccess?: (registration: ServiceWorkerRegistration) => void; - onUpdate?: (registration: ServiceWorkerRegistration) => void; -}; - -export function register(config?: Config): void { - if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { - // The URL constructor is available in all browsers that support SW. - const publicUrl = new URL( - process.env.PUBLIC_URL, - window.location.href - ); - if (publicUrl.origin !== window.location.origin) { - // Our service worker won"t work if PUBLIC_URL is on a different origin - // from what our page is served on. This might happen if a CDN is used to - // serve assets; see https://github.com/facebook/create-react-app/issues/2374 - return; - } - - window.addEventListener("load", () => { - const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; - - if (isLocalhost) { - // This is running on localhost. Let"s check if a service worker still exists or not. - checkValidServiceWorker(swUrl, config); - - // Add some additional logging to localhost, pointing developers to the - // service worker/PWA documentation. - navigator.serviceWorker.ready.then(() => { - console.log( - "This web app is being served cache-first by a service " + - "worker. To learn more, visit https://bit.ly/CRA-PWA" - ); - }); - } else { - // Is not localhost. Just register service worker - registerValidSW(swUrl, config); - } - }); - } -} - -function registerValidSW(swUrl: string, config?: Config) { - navigator.serviceWorker - .register(swUrl) - .then(registration => { - registration.onupdatefound = () => { - const installingWorker = registration.installing; - if (installingWorker == null) { - return; - } - installingWorker.onstatechange = () => { - if (installingWorker.state === "installed") { - if (navigator.serviceWorker.controller) { - // At this point, the updated precached content has been fetched, - // but the previous service worker will still serve the older - // content until all client tabs are closed. - console.log( - "New content is available and will be used when all " + - "tabs for this page are closed. See https://bit.ly/CRA-PWA." - ); - - // Execute callback - if (config && config.onUpdate) { - config.onUpdate(registration); - } - } else { - // At this point, everything has been precached. - // It"s the perfect time to display a - // "Content is cached for offline use." message. - console.log("Content is cached for offline use."); - - // Execute callback - if (config && config.onSuccess) { - config.onSuccess(registration); - } - } - } - }; - }; - }) - .catch(error => { - console.error("Error during service worker registration:", error); - }); -} - -function checkValidServiceWorker(swUrl: string, config?: Config) { - // Check if the service worker can be found. If it can"t reload the page. - fetch(swUrl, { - headers: { "Service-Worker": "script" } - }) - .then(response => { - // Ensure service worker exists, and that we really are getting a JS file. - const contentType = response.headers.get("content-type"); - if ( - response.status === 404 || - (contentType != null && contentType.indexOf("javascript") === -1) - ) { - // No service worker found. Probably a different app. Reload the page. - navigator.serviceWorker.ready.then(registration => { - registration.unregister().then(() => { - window.location.reload(); - }); - }); - } else { - // Service worker found. Proceed as normal. - registerValidSW(swUrl, config); - } - }) - .catch(() => { - console.log( - "No internet connection found. App is running in offline mode." - ); - }); -} - -export function unregister(): void { - if ("serviceWorker" in navigator) { - navigator.serviceWorker.ready - .then(registration => { - registration.unregister(); - }) - .catch(error => { - console.error(error.message); - }); - } -} diff --git a/src/utils/CryptoJS.ts b/src/utils/CryptoJS.ts new file mode 100644 index 0000000..93c7132 --- /dev/null +++ b/src/utils/CryptoJS.ts @@ -0,0 +1,119 @@ +/** This file contains a polyfill for CryptoJS AES decryption and password + * derivation function. */ + +import { MD5 } from "spu-md5"; +import base64 from "base64-arraybuffer"; + +interface EvpKey { + key: Uint8Array, + iv: Uint8Array, + cryptoKey: CryptoKey +} + +/** + * Derive an AES key using {@link https://wiki.openssl.org/index.php/EVP_Key_Derivation|EvpKDF}. + * Uses a single iteration and MD5 for hashing. Designed to be compatible with CryptoJS.AES. + * + * Links: + * {@link https://wiki.openssl.org/index.php/EVP_Key_Derivation} + * {@link https://github.com/brix/crypto-js/blob/develop/src/evpkdf.js} + * {@link https://github.com/brix/crypto-js/blob/develop/src/cipher-core.js} + * {@link https://github.com/brix/crypto-js/blob/develop/src/aes.js} + * + * Implementation mostly sourced from: + * {@link https://stackoverflow.com/a/27250883/1499974} + * {@link https://stackoverflow.com/a/52598588/1499974} + * {@link https://stackoverflow.com/a/29152379/1499974} + * + * @param password - The bytes of the password used for key derivation. + * @param keySize - The number of bytes used for the key. + * @param ivSize - The number of bytes used for the IV. + * @param salt - The salt used for key derivation. + * @param iterations - The number of iterations used. + * @returns The key and IV (Uint8Array) derived from the password. + */ +async function evpKDF(password: Uint8Array, keySize: number, ivSize: number, + salt: Uint8Array, iterations: number): Promise { + const targetKeySize = keySize + ivSize; + const derivedBytes = new Uint8Array(targetKeySize * 4); + + let numberOfDerivedWords = 0; + let block: Uint8Array | null = null; + let md5 = new MD5(); + + while (numberOfDerivedWords < targetKeySize) { + for (let i = 0; i < iterations; i++) { + if (block != null) md5.update(block); + + if (i === 0) { // hash the password and salt on the first iteration only + md5.update(password); + md5.update(salt); + } + + block = md5.toUint8Array(); + md5 = new MD5(); + } + + if (block == null) + throw new Error("EvpKDF block is null!"); + + const blockLength = Math.min(block.length, (targetKeySize - numberOfDerivedWords) * 4); + derivedBytes.set(block.subarray(0, blockLength), numberOfDerivedWords * 4); + + numberOfDerivedWords += block.length / 4; + } + + // get the key from the first 32 bytes, then the IV from the next 32 + const key = derivedBytes.subarray(0, keySize * 4); + const iv = derivedBytes.subarray(keySize * 4, (keySize * 4) + (ivSize * 4)); + + // create a SubtleCrypto CryptoKey with the given raw key bytes (for AES-CBC encrypt/decrypt) + const cryptoKey = await crypto.subtle.importKey("raw", key, "AES-CBC", true, ["encrypt", "decrypt"]); + + return { key, iv, cryptoKey }; +} + +/** + * Decrypt using AES-CBC and {@link https://wiki.openssl.org/index.php/EVP_Key_Derivation|EvpKDF}. + * Uses a single iteration and MD5 for hashing. Designed to be compatible with CryptoJS.AES.decrypt. + * + * Links: + * {@link https://wiki.openssl.org/index.php/EVP_Key_Derivation} + * {@link https://github.com/brix/crypto-js/blob/develop/src/evpkdf.js} + * {@link https://github.com/brix/crypto-js/blob/develop/src/cipher-core.js} + * {@link https://github.com/brix/crypto-js/blob/develop/src/aes.js} + * + * Implementation mostly sourced from: + * {@link https://stackoverflow.com/a/27250883/1499974} + * {@link https://stackoverflow.com/a/52598588/1499974} + * {@link https://stackoverflow.com/a/29152379/1499974} + * + * @param encrypted - The base64-encoded encrypted data. + * @param password - The password used to decrypt the data. + * @returns The decrypted data. + */ +export async function decryptCryptoJS(encrypted: string, password: string): Promise { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + const keySize = 256 / 32; + const ivSize = 128 / 32; + + // Decode the encrypted base64 string to get the raw bytes + const cipherText = new Uint8Array(base64.decode(encrypted)); + + // Check if the cipher text begins with "Salted__": + const prefix = new Uint32Array(cipherText.buffer, 0, 2); + const salted = prefix[0] === 0x746c6153 && prefix[1] === 0x5f5f6465; + + // Fetch the salt from the cipher text, if necessary, and get the actual cipher text + const salt = cipherText.subarray(8, 16); + const actualCipherText = salted ? cipherText.subarray(16, cipherText.length) : cipherText; + + // Derive the key and IV + const { cryptoKey, iv } = await evpKDF(encoder.encode(password), keySize, ivSize, salt, 1); + + // Decrypt the data + const decrypted = await crypto.subtle.decrypt({ name: "AES-CBC", iv }, cryptoKey, actualCipherText); + return decoder.decode(decrypted); // decode Uint8Array to UTF-8 +} diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts new file mode 100644 index 0000000..6b3f042 --- /dev/null +++ b/src/utils/crypto.ts @@ -0,0 +1,104 @@ +import base64 from "base64-arraybuffer"; + +import { toHex, fromHex } from "./"; + +// ----------------------------------------------------------------------------- +// SHA256 +// ----------------------------------------------------------------------------- + +/** + * Utility function to return the hexadecimal SHA-256 digest of an input string. + * + * @param input - The input string to hash. + * @returns The hexadecimal SHA-256 digest of input. + */ +export async function sha256(input: string): Promise { + const inputUtf8 = new TextEncoder().encode(input); // Convert input to UTF-8 + return toHex(await crypto.subtle.digest("SHA-256", inputUtf8)); +} + +/** + * Utility function to return the double hexadecimal SHA-256 digest of an input string. + * This is equivalent to sha256(sha256(input)). + * + * @param input - The input string to hash. + * @returns The double hexadecimal SHA-256 digest of input. + */ +export async function doubleSHA256(input: string): Promise { + return sha256(await sha256(input)); +} + +// ----------------------------------------------------------------------------- +// AES-GCM +// ----------------------------------------------------------------------------- +export type AESEncryptedString = string; + +/** + * Encrypts the given input string with the AES-GCM cipher, deriving a key from + * the given password with SHA-256. + * + * Implementation mostly sourced from: + * {@link https://gist.github.com/chrisveness/43bcda93af9f646d083fad678071b90a} + * + * @param input - The plain text input to be encrypted. + * @param password - The password used to encrypt the input data. + * @returns The encrypted cipher text (`IV (12 bytes hex) + CT (base64)`) + */ +export async function aesGcmEncrypt(input: string, password: string): Promise { + // Hash the password as UTF-8 + const passwordHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(password)); + + // Generate a 96-bit random IV + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // Generate the key from the password + const algorithm = { name: "AES-GCM", iv }; + const key = await crypto.subtle.importKey("raw", passwordHash, algorithm, false, ["encrypt"]); + + // Encrypt the UTF-8-encoded input + const cipherText = await crypto.subtle.encrypt(algorithm, key, new TextEncoder().encode(input)); + + // Return the IV (as hex) + cipher text (as base64) together + return toHex(iv) + base64.encode(cipherText); +} + +/** + * Decrypts the given input cipher text with the AES-GCM cipher, deriving a key + * from the given password with SHA-256. The input must be of the form + * `IV (12 bytes hex) + CT (base64)`. + * + * Implementation mostly sourced from: + * {@link https://gist.github.com/chrisveness/43bcda93af9f646d083fad678071b90a} + * + * @param input - The IV and cipher text to decrypt. + * @param password - The password used to decrypt the input cipher text. + * @returns The decrypted plain text data. + */ +export async function aesGcmDecrypt(input: AESEncryptedString, password: string): Promise { + // Hash the password as UTF-8 + const passwordHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(password)); + + // Get the IV from the encrypted string (first 96 bits/12 bytes/24 hex chars) + const iv = fromHex(input.slice(0, 24)); + + // Generate the key from the password + const algorithm = { name: "AES-GCM", iv }; + const key = await crypto.subtle.importKey("raw", passwordHash, algorithm, false, ["decrypt"]); + + // Decode the base64 cipher text to a UTF-8 Uint8Array + const cipherText = base64.decode(input.slice(24)); + + // Decrypt the cipher text + const dec = await crypto.subtle.decrypt(algorithm, key, cipherText); + + // Decode from UTF-8 + return new TextDecoder().decode(dec); +} + +// ----------------------------------------------------------------------------- +// CryptoJS +// ----------------------------------------------------------------------------- + +/** Polyfill for decrypting CryptoJS AES strings. This is used to migrate + * local storage from KristWeb v1. */ +export { decryptCryptoJS } from "./CryptoJS"; diff --git a/src/utils/index.ts b/src/utils/index.ts index f7c52f2..a429b51 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,10 @@ export const sleep = (duration: number): Promise => new Promise(resolve => setTimeout(resolve, duration)); + +export const toHex = (input: ArrayBufferLike | Uint8Array): string => + [...(input instanceof Uint8Array ? input : new Uint8Array(input))] + .map(b => b.toString(16).padStart(2, "0")) + .join(""); + +export const fromHex = (input: string): Uint8Array => + new Uint8Array((input.match(/.{1,2}/g) || []).map(b => parseInt(b, 16))); diff --git a/src/utils/serviceWorker.ts b/src/utils/serviceWorker.ts new file mode 100644 index 0000000..49eedf4 --- /dev/null +++ b/src/utils/serviceWorker.ts @@ -0,0 +1,149 @@ +// This optional code is used to register a service worker. +// register() is not called by default. + +// This lets the app load faster on subsequent visits in production, and gives +// it offline capabilities. However, it also means that developers (and users) +// will only see deployed updates on subsequent visits to a page, after all the +// existing tabs open on the page have been closed, since previously cached +// resources are updated in the background. + +// To learn more about the benefits of this model and instructions on how to +// opt-in, read https://bit.ly/CRA-PWA + +const isLocalhost = Boolean( + window.location.hostname === "localhost" || + // [::1] is the IPv6 localhost address. + window.location.hostname === "[::1]" || + // 127.0.0.0/8 are considered localhost for IPv4. + window.location.hostname.match( + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ + ) +); + +type Config = { + onSuccess?: (registration: ServiceWorkerRegistration) => void; + onUpdate?: (registration: ServiceWorkerRegistration) => void; +}; + +export function register(config?: Config): void { + if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { + // The URL constructor is available in all browsers that support SW. + const publicUrl = new URL( + process.env.PUBLIC_URL, + window.location.href + ); + if (publicUrl.origin !== window.location.origin) { + // Our service worker won"t work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebook/create-react-app/issues/2374 + return; + } + + window.addEventListener("load", () => { + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + + if (isLocalhost) { + // This is running on localhost. Let"s check if a service worker still exists or not. + checkValidServiceWorker(swUrl, config); + + // Add some additional logging to localhost, pointing developers to the + // service worker/PWA documentation. + navigator.serviceWorker.ready.then(() => { + console.log( + "This web app is being served cache-first by a service " + + "worker. To learn more, visit https://bit.ly/CRA-PWA" + ); + }); + } else { + // Is not localhost. Just register service worker + registerValidSW(swUrl, config); + } + }); + } +} + +function registerValidSW(swUrl: string, config?: Config) { + navigator.serviceWorker + .register(swUrl) + .then(registration => { + registration.onupdatefound = () => { + const installingWorker = registration.installing; + if (installingWorker == null) { + return; + } + installingWorker.onstatechange = () => { + if (installingWorker.state === "installed") { + if (navigator.serviceWorker.controller) { + // At this point, the updated precached content has been fetched, + // but the previous service worker will still serve the older + // content until all client tabs are closed. + console.log( + "New content is available and will be used when all " + + "tabs for this page are closed. See https://bit.ly/CRA-PWA." + ); + + // Execute callback + if (config && config.onUpdate) { + config.onUpdate(registration); + } + } else { + // At this point, everything has been precached. + // It"s the perfect time to display a + // "Content is cached for offline use." message. + console.log("Content is cached for offline use."); + + // Execute callback + if (config && config.onSuccess) { + config.onSuccess(registration); + } + } + } + }; + }; + }) + .catch(error => { + console.error("Error during service worker registration:", error); + }); +} + +function checkValidServiceWorker(swUrl: string, config?: Config) { + // Check if the service worker can be found. If it can"t reload the page. + fetch(swUrl, { + headers: { "Service-Worker": "script" } + }) + .then(response => { + // Ensure service worker exists, and that we really are getting a JS file. + const contentType = response.headers.get("content-type"); + if ( + response.status === 404 || + (contentType != null && contentType.indexOf("javascript") === -1) + ) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then(registration => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl, config); + } + }) + .catch(() => { + console.log( + "No internet connection found. App is running in offline mode." + ); + }); +} + +export function unregister(): void { + if ("serviceWorker" in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.unregister(); + }) + .catch(error => { + console.error(error.message); + }); + } +} diff --git a/src/utils/setup.ts b/src/utils/setup.ts new file mode 100644 index 0000000..20e11a9 --- /dev/null +++ b/src/utils/setup.ts @@ -0,0 +1,15 @@ +import { toHex } from "./"; + +import * as serviceWorker from "./serviceWorker"; +import Debug from "debug"; + +// Set up custom debug formatters +// Booleans (%b) +Debug.formatters.b = (v: boolean) => v ? "true" : "false"; +// Buffers as hex strings (%x) +Debug.formatters.x = (v: ArrayBufferLike | Uint8Array) => toHex(v); + +// Set up and register the service worker to cache the app and handle +// notifications +// TODO: change `unregister` to `register` +serviceWorker.unregister(); diff --git a/tsconfig.extend.json b/tsconfig.extend.json index 5b2cf58..dc65e92 100644 --- a/tsconfig.extend.json +++ b/tsconfig.extend.json @@ -5,9 +5,9 @@ "@components/*": ["./src/shared-components/*"], "@layouts/*": ["./src/layouts/*"], "@krist/*": ["./src/krist/*"], - "@utils/*": ["./src/utils/*"], "@utils": ["./src/utils/index.ts"], + "@utils/*": ["./src/utils/*"], "@/*": ["./*"] } } -} \ No newline at end of file +}