diff --git a/apps/desktop/src/api/os-integration.ts b/apps/desktop/src/api/os-integration.ts index fc608eee9..f26cf9f70 100644 --- a/apps/desktop/src/api/os-integration.ts +++ b/apps/desktop/src/api/os-integration.ts @@ -117,6 +117,14 @@ export const osIntegrationRouter = t.router({ writeFileSync(resolvedPath, data); }), + resolvePath: t.procedure + .input(z.object({ filePath: z.string() })) + .query(({ input }) => { + const { filePath } = input; + if (!filePath) return; + return resolvePath(filePath); + }), + showNotification: t.procedure .input(NotificationOptions) .query(({ input }) => { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index c4bd47dd7..f97d040da 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -31,7 +31,6 @@ declare global { } process.once("loaded", async () => { - console.log("HELLO!"); const electronTRPC: RendererGlobalElectronTRPC = { sendMessage: (operation) => ipcRenderer.send(ELECTRON_TRPC_CHANNEL, operation), diff --git a/apps/web/src/common/desktop-bridge/index.desktop.ts b/apps/web/src/common/desktop-bridge/index.desktop.ts index 3a3c5367d..9bb6eff7f 100644 --- a/apps/web/src/common/desktop-bridge/index.desktop.ts +++ b/apps/web/src/common/desktop-bridge/index.desktop.ts @@ -63,3 +63,17 @@ function attachListener(event: string) { } }; } + +export async function createWritableStream(path: string) { + const resolvedPath = await desktop.integration.resolvePath.query({ + filePath: path + }); + if (!resolvedPath) throw new Error("invalid path."); + const fs = require("fs"); + const { Writable } = require("stream"); + return new WritableStream( + Writable.toWeb( + fs.createWriteStream(resolvedPath, { encoding: "utf-8" }) + ).getWriter() + ); +} diff --git a/apps/web/src/common/desktop-bridge/index.ts b/apps/web/src/common/desktop-bridge/index.ts index 67c0620c2..6d8eb967d 100644 --- a/apps/web/src/common/desktop-bridge/index.ts +++ b/apps/web/src/common/desktop-bridge/index.ts @@ -17,6 +17,10 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ +import { createWriteStream } from "../../utils/stream-saver"; import { type desktop as bridge } from "./index.desktop"; export const desktop: typeof bridge | undefined = undefined; +export async function createWritableStream(filename: string) { + return createWriteStream(filename); +} diff --git a/apps/web/src/common/index.js b/apps/web/src/common/index.js deleted file mode 100644 index 84c16337d..000000000 --- a/apps/web/src/common/index.js +++ /dev/null @@ -1,217 +0,0 @@ -/* -This file is part of the Notesnook project (https://notesnook.com/) - -Copyright (C) 2023 Streetwriters (Private) Limited - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -*/ - -import { - showFeatureDialog, - showLoadingDialog, - showPasswordDialog, - showReminderDialog -} from "../common/dialog-controller"; -import Config from "../utils/config"; -import { hashNavigate, getCurrentHash } from "../navigation"; -import { db } from "./db"; -import { sanitizeFilename } from "@notesnook/common"; -import { store as userstore } from "../stores/user-store"; -import FileSaver from "file-saver"; -import { showToast } from "../utils/toast"; -import { SUBSCRIPTION_STATUS } from "./constants"; -import { showFilePicker } from "../utils/file-picker"; -import { logger } from "../utils/logger"; -import { PATHS } from "@notesnook/desktop"; -import { TaskManager } from "./task-manager"; -import { EVENTS } from "@notesnook/core/dist/common"; -import { getFormattedDate } from "@notesnook/common"; -import { desktop } from "./desktop-bridge"; - -export const CREATE_BUTTON_MAP = { - notes: { - title: "Add a note", - onClick: () => - hashNavigate("/notes/create", { addNonce: true, replace: true }) - }, - notebooks: { - title: "Create a notebook", - onClick: () => hashNavigate("/notebooks/create", { replace: true }) - }, - topics: { - title: "Create a topic", - onClick: () => hashNavigate(`/topics/create`, { replace: true }) - }, - tags: { - title: "Create a tag", - onClick: () => hashNavigate(`/tags/create`, { replace: true }) - }, - reminders: { - title: "Add a reminder", - onClick: () => hashNavigate(`/reminders/create`, { replace: true }) - } -}; - -export async function introduceFeatures() { - const hash = getCurrentHash().replace("#", ""); - if (!!hash || IS_TESTING) return; - const features = []; - for (let feature of features) { - if (!Config.get(`feature:${feature}`)) { - await showFeatureDialog(feature); - } - } -} - -export const DEFAULT_CONTEXT = { colors: [], tags: [], notebook: {} }; - -export async function createBackup() { - const encryptBackups = - userstore.get().isLoggedIn && Config.get("encryptBackups", false); - const data = await showLoadingDialog({ - title: "Creating backup", - subtitle: "We are creating a backup of your data. Please wait...", - action: async () => { - return await db.backup.export("web", encryptBackups); - } - }); - if (!data) { - showToast("error", "Could not create a backup of your data."); - return; - } - - const filename = sanitizeFilename(`notesnook-backup-${getFormattedDate()}`); - - const ext = "nnbackup"; - if (IS_DESKTOP_APP) { - const directory = Config.get( - "backupStorageLocation", - PATHS.backupsDirectory - ); - const filePath = `${directory}/${filename}.${ext}`; - await desktop?.integration.saveFile.query({ filePath, data }); - showToast("success", `Backup saved at ${filePath}.`); - } else { - FileSaver.saveAs(new Blob([Buffer.from(data)]), `${filename}.${ext}`); - } -} - -export async function selectBackupFile() { - const file = await showFilePicker({ - acceptedFileTypes: ".nnbackup,application/json" - }); - if (!file) return; - - const reader = new FileReader(); - const backup = await new Promise((resolve) => { - reader.addEventListener("load", (event) => { - const text = event.target.result; - try { - resolve(JSON.parse(text)); - } catch (e) { - alert( - "Error: Could not read the backup file provided. Either it's corrupted or invalid." - ); - resolve(); - } - }); - reader.readAsText(file); - }); - - return { file, backup }; -} - -export async function importBackup() { - const { backup } = await selectBackupFile(); - await restoreBackupFile(backup); -} - -export async function restoreBackupFile(backup) { - if (backup.data.iv && backup.data.salt) { - await showPasswordDialog("ask_backup_password", async ({ password }) => { - const error = await restoreWithProgress(backup, password); - return !error; - }); - } else { - await restoreWithProgress(backup); - } -} - -async function restoreWithProgress(backup, password) { - await TaskManager.startTask({ - title: "Restoring backup", - subtitle: "This might take a while", - type: "modal", - action: (report) => { - db.eventManager.subscribe( - EVENTS.migrationProgress, - ({ collection, total, current }) => { - report({ - text: `Restoring ${collection}...`, - current, - total - }); - } - ); - - report({ text: `Restoring...` }); - return restore(backup, password); - } - }); -} - -export async function verifyAccount() { - if (!(await db.user.getUser())) return true; - return showPasswordDialog("verify_account", ({ password }) => { - return db.user.verifyPassword(password); - }); -} - -export function totalSubscriptionConsumed(user) { - if (!user) return 0; - const start = user.subscription?.start; - const end = user.subscription?.expiry; - if (!start || !end) return 0; - - const total = end - start; - const consumed = Date.now() - start; - - return Math.round((consumed / total) * 100); -} - -export async function showUpgradeReminderDialogs() { - if (IS_TESTING) return; - - const user = userstore.get().user; - if (!user || !user.subscription || user.subscription?.expiry === 0) return; - - const consumed = totalSubscriptionConsumed(user); - const isTrial = user?.subscription?.type === SUBSCRIPTION_STATUS.TRIAL; - const isBasic = user?.subscription?.type === SUBSCRIPTION_STATUS.BASIC; - if (isBasic && consumed >= 100) { - await showReminderDialog("trialexpired"); - } else if (isTrial && consumed >= 75) { - await showReminderDialog("trialexpiring"); - } -} - -async function restore(backup, password) { - try { - await db.backup.import(backup, password); - showToast("success", "Backup restored!"); - } catch (e) { - logger.error(e, "Could not restore the backup"); - showToast("error", `Could not restore the backup: ${e.message || e}`); - } -} diff --git a/apps/web/src/common/index.ts b/apps/web/src/common/index.ts new file mode 100644 index 000000000..97e86428c --- /dev/null +++ b/apps/web/src/common/index.ts @@ -0,0 +1,290 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { + showFeatureDialog, + showPasswordDialog, + showReminderDialog +} from "./dialog-controller"; +import Config from "../utils/config"; +import { hashNavigate, getCurrentHash } from "../navigation"; +import { db } from "./db"; +import { sanitizeFilename } from "@notesnook/common"; +import { store as userstore } from "../stores/user-store"; +import { showToast } from "../utils/toast"; +import { SUBSCRIPTION_STATUS } from "./constants"; +import { readFile, showFilePicker } from "../utils/file-picker"; +import { logger } from "../utils/logger"; +import { PATHS } from "@notesnook/desktop"; +import { TaskManager } from "./task-manager"; +import { EVENTS } from "@notesnook/core/dist/common"; +import { getFormattedDate } from "@notesnook/common"; +import { createWritableStream } from "./desktop-bridge"; +import { ZipStream } from "../utils/streams/zip-stream"; +import { register } from "../utils/stream-saver/mitm"; +import { FeatureKeys } from "../dialogs/feature-dialog"; +import { Reader } from "../utils/zip-reader"; + +export const CREATE_BUTTON_MAP = { + notes: { + title: "Add a note", + onClick: () => + hashNavigate("/notes/create", { addNonce: true, replace: true }) + }, + notebooks: { + title: "Create a notebook", + onClick: () => hashNavigate("/notebooks/create", { replace: true }) + }, + topics: { + title: "Create a topic", + onClick: () => hashNavigate(`/topics/create`, { replace: true }) + }, + tags: { + title: "Create a tag", + onClick: () => hashNavigate(`/tags/create`, { replace: true }) + }, + reminders: { + title: "Add a reminder", + onClick: () => hashNavigate(`/reminders/create`, { replace: true }) + } +}; + +export async function introduceFeatures() { + const hash = getCurrentHash().replace("#", ""); + if (!!hash || IS_TESTING) return; + const features: FeatureKeys[] = []; + for (const feature of features) { + if (!Config.get(`feature:${feature}`)) { + await showFeatureDialog(feature); + } + } +} + +export const DEFAULT_CONTEXT = { colors: [], tags: [], notebook: {} }; + +export async function createBackup() { + await register(); + const encryptBackups = + userstore.get().isLoggedIn && Config.get("encryptBackups", false); + + const filename = sanitizeFilename( + `notesnook-backup-${getFormattedDate(Date.now())}` + ); + const directory = Config.get("backupStorageLocation", PATHS.backupsDirectory); + const ext = "nnbackupz"; + const filePath = IS_DESKTOP_APP + ? `${directory}/${filename}.${ext}` + : `${filename}.${ext}`; + + const encoder = new TextEncoder(); + const error = await TaskManager.startTask({ + type: "modal", + title: "Creating backup", + subtitle: "We are creating a backup of your data. Please wait...", + action: async (report) => { + const writeStream = await createWritableStream(filePath); + + await new ReadableStream({ + start() {}, + async pull(controller) { + for await (const file of db.backup!.export("web", encryptBackups)) { + report({ + text: `Saving chunk ${file.path}` + }); + controller.enqueue({ + path: file.path, + data: encoder.encode(file.data) + }); + } + controller.close(); + } + }) + .pipeThrough(new ZipStream()) + .pipeTo(writeStream); + } + }); + if (error) { + showToast( + "error", + `Could not create a backup of your data: ${(error as Error).message}` + ); + console.error(error); + } else { + showToast("success", `Backup saved at ${filePath}.`); + } +} + +export async function selectBackupFile() { + const file = await showFilePicker({ + acceptedFileTypes: ".nnbackup,.nnbackupz" + }); + if (!file) return; + return file; +} + +export async function importBackup() { + const backupFile = await selectBackupFile(); + if (!backupFile) return; + await restoreBackupFile(backupFile); +} + +export async function restoreBackupFile(backupFile: File) { + const isLegacy = !backupFile.name.endsWith(".nnbackupz"); + + if (isLegacy) { + const backup = JSON.parse(await readFile(backupFile)); + + if (backup.data.iv && backup.data.salt) { + await showPasswordDialog("ask_backup_password", async ({ password }) => { + if (!password) return false; + const error = await restoreWithProgress(backup, password); + return !error; + }); + } else { + await restoreWithProgress(backup); + } + } else { + const error = await TaskManager.startTask({ + title: "Restoring backup", + subtitle: "Please wait while we restore your backup...", + type: "modal", + action: async (report) => { + let cachedPassword: string | undefined = undefined; + const { read, totalFiles } = await Reader(backupFile); + let filesProcessed = 0; + for await (const entry of read()) { + if (filesProcessed++ === 0 && entry.name !== ".nnbackup") + throw new Error("Invalid backup."); + else if (entry.name === ".nnbackup") continue; + + const backup = JSON.parse(await entry.text()); + if (backup.encrypted) { + if (!cachedPassword) { + const result = await showPasswordDialog( + "ask_backup_password", + async ({ password }) => { + if (!password) return false; + await db.backup?.import(backup, password); + cachedPassword = password; + return true; + } + ); + if (!result) break; + } else await db.backup?.import(backup, cachedPassword); + } else { + await db.backup?.import(backup, null); + } + + report({ + total: totalFiles, + text: `Processed ${entry.name}`, + current: filesProcessed + }); + } + await db.initCollections(); + } + }); + if (error) { + console.error(error); + showToast("error", `Failed to restore backup: ${error.message}`); + } + } +} + +async function restoreWithProgress( + backup: Record, + password?: string +) { + return await TaskManager.startTask({ + title: "Restoring backup", + subtitle: "This might take a while", + type: "modal", + action: (report) => { + db.eventManager.subscribe( + EVENTS.migrationProgress, + ({ + collection, + total, + current + }: { + collection: string; + total: number; + current: number; + }) => { + report({ + text: `Restoring ${collection}...`, + current, + total + }); + } + ); + + report({ text: `Restoring...` }); + return restore(backup, password); + } + }); +} + +export async function verifyAccount() { + if (!(await db.user?.getUser())) return true; + return showPasswordDialog("verify_account", ({ password }) => { + return db.user?.verifyPassword(password) || false; + }); +} + +export function totalSubscriptionConsumed(user: User) { + if (!user) return 0; + const start = user.subscription?.start; + const end = user.subscription?.expiry; + if (!start || !end) return 0; + + const total = end - start; + const consumed = Date.now() - start; + + return Math.round((consumed / total) * 100); +} + +export async function showUpgradeReminderDialogs() { + if (IS_TESTING) return; + + const user = userstore.get().user; + if (!user || !user.subscription || user.subscription?.expiry === 0) return; + + const consumed = totalSubscriptionConsumed(user); + const isTrial = user?.subscription?.type === SUBSCRIPTION_STATUS.TRIAL; + const isBasic = user?.subscription?.type === SUBSCRIPTION_STATUS.BASIC; + if (isBasic && consumed >= 100) { + await showReminderDialog("trialexpired"); + } else if (isTrial && consumed >= 75) { + await showReminderDialog("trialexpiring"); + } +} + +async function restore(backup: Record, password?: string) { + try { + await db.backup?.import(backup, password); + showToast("success", "Backup restored!"); + } catch (e) { + logger.error(e as Error, "Could not restore the backup"); + showToast( + "error", + `Could not restore the backup: ${(e as Error).message || e}` + ); + } +} diff --git a/apps/web/src/utils/stream-saver/mitm.ts b/apps/web/src/utils/stream-saver/mitm.ts index c63732f76..44c609213 100644 --- a/apps/web/src/utils/stream-saver/mitm.ts +++ b/apps/web/src/utils/stream-saver/mitm.ts @@ -16,11 +16,26 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ -export {}; let sw: ServiceWorker | null = null; let scope = ""; +let keepAlive = () => { + keepAlive = () => {}; + const interval = setInterval(() => { + if (sw) { + sw.postMessage("ping"); + } else { + const ping = + location.href.substr(0, location.href.lastIndexOf("/")) + "/ping"; + fetch(ping).then((res) => { + !res.ok && clearInterval(interval); + return res.text(); + }); + } + }, 10000); +}; + function registerWorker() { return navigator.serviceWorker .getRegistration("./") @@ -104,9 +119,12 @@ export function postMessage( // messageChannel.port2 to the service worker. The service worker can // then use the transferred port to reply via postMessage(), which // will in turn trigger the onmessage handler on messageChannel.port1. - const transferable = [ports[0]]; + if (!data.transferringReadable) { + keepAlive(); + } + return sw?.postMessage(data, transferable); } @@ -114,4 +132,8 @@ export async function register() { if (navigator.serviceWorker) { await registerWorker(); } + // FF v102 just started to supports transferable streams, but still needs to ping sw.js + // even tough the service worker dose not have to do any kind of work and listen to any + // messages... #305 + keepAlive(); } diff --git a/apps/web/src/views/recovery.tsx b/apps/web/src/views/recovery.tsx index 396a99eea..ca286cede 100644 --- a/apps/web/src/views/recovery.tsx +++ b/apps/web/src/views/recovery.tsx @@ -40,10 +40,7 @@ type RecoveryKeyFormData = { }; type BackupFileFormData = { - backupFile: { - file: File; - backup: Record; - }; + backupFile: File; }; type NewPasswordFormData = BackupFileFormData & { @@ -406,7 +403,7 @@ function BackupFileMethod(props: BaseRecoveryComponentProps<"method:backup">) { if (!backupFile) return; const backupFileInput = document.getElementById("backupFile"); if (!(backupFileInput instanceof HTMLInputElement)) return; - backupFileInput.value = backupFile?.file?.name; + backupFileInput.value = backupFile?.name; }, [backupFile]); return ( @@ -519,7 +516,7 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) { throw new Error("Could not reset account password."); if (formData?.backupFile) { - await restoreBackupFile(formData?.backupFile.backup); + await restoreBackupFile(formData?.backupFile); await db.sync(true, true); }