diff --git a/src/electron/frontend/assets/css/custom.css b/src/electron/frontend/assets/css/custom.css new file mode 100644 index 0000000000..8b422f30c6 --- /dev/null +++ b/src/electron/frontend/assets/css/custom.css @@ -0,0 +1,51 @@ +/* Update Notification Box */ +#update-available:not(:empty) { + margin-top: 15px; + background-color: #ffe5d2; + color: black; + padding: 15px 10px; + border: 1px solid #f6c39c; + border-radius: 5px; +} + +#update-available .update-container { + display: flex; + align-items: center; + gap: 15px; +} + +#update-available .update-container::before { + content: ""; + width: 6px; + height: 6px; + margin-left: 5px; + background-color: #d17128; + border-radius: 50%; +} + +#update-available .header { + display: flex; + align-items: center; + gap: 5px; +} + +#update-available h4 { + margin: 0; +} + +#update-available span { + color: gray; + font-size: 80%; +} + +#update-available .header svg { + height: 15px; + width: auto; + cursor: pointer; +} + +#update-available .controls { + margin-left: auto; + display: flex; + gap: 10px; +} diff --git a/src/electron/frontend/assets/css/nav.css b/src/electron/frontend/assets/css/nav.css index f0f0576c98..a5a15982c9 100755 --- a/src/electron/frontend/assets/css/nav.css +++ b/src/electron/frontend/assets/css/nav.css @@ -158,8 +158,10 @@ a[data-toggle="collapse"] { #main-nav .sidebar-body a { font-size: 14px; - display: block; - line-height: 45px; + display: flex; + align-items: center; + position: relative; + padding: 15px 0px; padding-left: 20px; margin-bottom: 5px; text-align: left; @@ -170,6 +172,19 @@ a[data-toggle="collapse"] { border-left: 4px solid transparent; } +[data-update-available] [data-id="settings"] > div { + display: flex; + flex-direction: column; +} + +[data-update-available] [data-id="settings"] > div::after { + content: "Update Available"; + color: gray; + font-size: 70%; + font-weight: normal; + line-height: 1.5; +} + #main-nav .sidebar-body svg { fill: #000; } diff --git a/src/electron/frontend/assets/icons/dandi.svg b/src/electron/frontend/assets/icons/dandi.svg index 23dcdd8d12..0b62d3ddb0 100644 --- a/src/electron/frontend/assets/icons/dandi.svg +++ b/src/electron/frontend/assets/icons/dandi.svg @@ -3,7 +3,7 @@ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> Created by potrace 1.16, written by Peter Selinger 2001-2019 diff --git a/src/electron/frontend/assets/icons/download.svg b/src/electron/frontend/assets/icons/download.svg new file mode 100644 index 0000000000..7b794bd408 --- /dev/null +++ b/src/electron/frontend/assets/icons/download.svg @@ -0,0 +1 @@ + diff --git a/src/electron/frontend/assets/icons/exploration.svg b/src/electron/frontend/assets/icons/exploration.svg index 2b5f3e8fc9..082dc98def 100644 --- a/src/electron/frontend/assets/icons/exploration.svg +++ b/src/electron/frontend/assets/icons/exploration.svg @@ -3,5 +3,5 @@ viewBox="0 -960 960 960" width="22" height="22" - style="margin-right: 28px; margin-bottom: -5px" + style="margin-right: 28px;" > diff --git a/src/electron/frontend/assets/icons/info.svg b/src/electron/frontend/assets/icons/info.svg new file mode 100644 index 0000000000..9dfdef6d67 --- /dev/null +++ b/src/electron/frontend/assets/icons/info.svg @@ -0,0 +1 @@ + diff --git a/src/electron/frontend/assets/icons/inspect.svg b/src/electron/frontend/assets/icons/inspect.svg index d788e86ff4..dcb66ff7af 100644 --- a/src/electron/frontend/assets/icons/inspect.svg +++ b/src/electron/frontend/assets/icons/inspect.svg @@ -3,5 +3,5 @@ xmlns="http://www.w3.org/2000/svg" height="25" viewBox="0 -960 960 960" width="25" -style="margin-right: 25px; margin-bottom: -5px" +style="margin-right: 25px;" > diff --git a/src/electron/frontend/assets/icons/neurosift-logo.svg b/src/electron/frontend/assets/icons/neurosift-logo.svg index 6886c10c28..90368db67b 100644 --- a/src/electron/frontend/assets/icons/neurosift-logo.svg +++ b/src/electron/frontend/assets/icons/neurosift-logo.svg @@ -6,7 +6,7 @@ xmlns="http://www.w3.org/2000/svg" height="22" width="22" - style="margin-right: 28px; margin-bottom: -5px" + style="margin-right: 28px;" viewBox="0 0 192.000000 192.000000" preserveAspectRatio="xMidYMid meet" > diff --git a/src/electron/frontend/assets/icons/preview.svg b/src/electron/frontend/assets/icons/preview.svg index 467b1d1868..192e9bada2 100644 --- a/src/electron/frontend/assets/icons/preview.svg +++ b/src/electron/frontend/assets/icons/preview.svg @@ -3,5 +3,5 @@ xmlns="http://www.w3.org/2000/svg" height="25" viewBox="0 -960 960 960" width="25" -style="margin-right: 30px; margin-bottom: -5px" +style="margin-right: 30px;" > diff --git a/src/electron/frontend/assets/icons/settings.svg b/src/electron/frontend/assets/icons/settings.svg index 273e062186..41928d8075 100644 --- a/src/electron/frontend/assets/icons/settings.svg +++ b/src/electron/frontend/assets/icons/settings.svg @@ -3,5 +3,5 @@ height="25" viewBox="0 -960 960 960" width="25" - style="margin-right: 25px; margin-bottom: -5px" + style="margin-right: 25px;" > diff --git a/src/electron/frontend/core/components/Dashboard.js b/src/electron/frontend/core/components/Dashboard.js index 6ed8f3d7b9..9dd1287244 100644 --- a/src/electron/frontend/core/components/Dashboard.js +++ b/src/electron/frontend/core/components/Dashboard.js @@ -5,6 +5,9 @@ import { Main, checkIfPageIsSkipped } from "./Main.js"; import { Sidebar } from "./sidebar.js"; import { NavigationSidebar } from "./NavigationSidebar.js"; +// Defined by Garrett late in GUIDE development to clearly separate global styles unrelated to SODA (May 20th, 2024) +import "../../assets/css/custom.css"; + // Global styles to apply with the dashboard import "../../assets/css/variables.css"; import "../../assets/css/nativize.css"; @@ -31,7 +34,6 @@ import "../../../../../node_modules/@sweetalert2/theme-bulma/bulma.css"; import "../../assets/css/guided.css"; import { isElectron } from "../../utils/electron.js"; import { isStorybook, reloadPageToHome } from "../globals.js"; - import { getCurrentProjectName, updateAppProgress } from "../progress/index.js"; // import "https://jsuites.net/v4/jsuites.js" @@ -295,7 +297,12 @@ export class Dashboard extends LitElement { const section = info.section; let state = globalState.sections[section]; - if (!state) state = globalState.sections[section] = { open: false, active: false, pages: {} }; + if (!state) + state = globalState.sections[section] = { + open: false, + active: false, + pages: {}, + }; let pageState = state.pages[id]; if (!pageState) diff --git a/src/electron/frontend/core/components/ProgressBar.ts b/src/electron/frontend/core/components/ProgressBar.ts index 06da911777..bb30bbdbd7 100644 --- a/src/electron/frontend/core/components/ProgressBar.ts +++ b/src/electron/frontend/core/components/ProgressBar.ts @@ -5,13 +5,37 @@ import { LitElement, html, css, unsafeCSS } from 'lit'; export type ProgressProps = { size?: string, + isBytes?: boolean, + format?: { n: number, total: number, [key: string]: any, - }, + } } +export function humanReadableBytes(size: number | string) { + + // Define the units + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + // Initialize the index to 0 + let index = 0; + + // Convert the size to a floating point number + size = parseFloat(size); + + // Loop until the size is less than 1024 and increment the unit + while (size >= 1000 && index < units.length - 1) { + size /= 1000; + index += 1; + } + + // Return the size formatted with 2 decimal places and the appropriate unit + return `${size.toFixed(2)} ${units[index]}`; +} + + const animationDuration = 500 // ms export class ProgressBar extends LitElement { @@ -88,12 +112,14 @@ export class ProgressBar extends LitElement { declare format: any declare size: string + declare isBytes: boolean constructor(props: ProgressProps = {}) { super(); this.size = props.size ?? 'medium' this.format = props.format ?? {} + this.isBytes = props.isBytes ?? false if (!('n' in this.format)) this.format.n = 0 if (!('total' in this.format)) this.format.total = 0 } @@ -103,6 +129,17 @@ export class ProgressBar extends LitElement { const percent = this.format.total ? 100 * (this.format.n / this.format.total) : 0; const remaining = this.format.rate && this.format.total ? (this.format.total - this.format.n) / this.format.rate : 0; // Seconds + const numerator = this.isBytes ? humanReadableBytes(this.format.n) : this.format.n + const denominator = this.isBytes ? humanReadableBytes(this.format.total) : this.format.total + + + const elapsed = this.format.elapsed + + let subMessage = '' + if ('elapsed' in this.format && 'rate' in this.format) subMessage = `${elapsed?.toFixed(1)}s elapsed, ${remaining.toFixed(1)}s remaining` + else if ('elapsed' in this.format) subMessage = `${elapsed?.toFixed(1)}s elapsed` + else if ('rate' in this.format) subMessage = `${remaining.toFixed(1)}s remaining` + return html`
${this.format.prefix ? html`
@@ -112,10 +149,10 @@ export class ProgressBar extends LitElement {
- ${this.format.n} / ${this.format.total} (${percent.toFixed(1)}%) + ${numerator} / ${denominator} (${percent.toFixed(1)}%)
- ${'elapsed' in this.format && 'rate' in this.format ? html`${this.format.elapsed?.toFixed(1)}s elapsed, ${remaining.toFixed(1)}s remaining` : ''} + ${subMessage ? html`${subMessage}` : ''}
`; diff --git a/src/electron/frontend/core/components/pages/getting-started/GettingStarted.js b/src/electron/frontend/core/components/pages/getting-started/GettingStarted.js deleted file mode 100644 index 2a25d8200f..0000000000 --- a/src/electron/frontend/core/components/pages/getting-started/GettingStarted.js +++ /dev/null @@ -1,87 +0,0 @@ -import { html } from "lit"; -import { column1Lottie, column2Lottie, column3Lottie } from "../../../../assets/lotties/overview-lotties.js"; -import { Page } from "../Page.js"; - -import { startLottie } from "../../../dependencies/globals"; - -export class GettingStartedPage extends Page { - constructor(...args) { - super(...args); - } - - updated() { - // this.content = (this.shadowRoot ?? this).querySelector("#content"); - let column1 = this.query("#lottie1"); - let column2 = this.query("#lottie2"); - let column3 = this.query("#lottie3"); - startLottie(column1, column1Lottie); - startLottie(column2, column2Lottie); - startLottie(column3, column3Lottie); - } - - render() { - return html` -
-

Your one-stop tool for converting and uploading NWB datasets to the DANDI Archive!

- -
-
-

Curate

- -
-

- Rapidly prepare your data and metadata according to the NWB Best Practices and DANDI - requirements -

-
-
-

Share

-
-

Easily upload your curated dataset to the DANDI archive

-
-
-

Relax

-
-

Use our intuitive interface and automations to streamline your process

-
-
-
- - - -
-
- `; - } -} - -customElements.get("nwbguide-start-page") || customElements.define("nwbguide-start-page", GettingStartedPage); diff --git a/src/electron/frontend/core/components/pages/settings/SettingsPage.js b/src/electron/frontend/core/components/pages/settings/SettingsPage.js index 8a9f543161..74e8501f49 100644 --- a/src/electron/frontend/core/components/pages/settings/SettingsPage.js +++ b/src/electron/frontend/core/components/pages/settings/SettingsPage.js @@ -15,18 +15,30 @@ import { merge, setUndefinedIfNotDeclared } from "../utils"; import { notyf } from "../../../dependencies.js"; import { homeDirectory, testDataFolderPath } from "../../../globals.js"; -import { SERVER_FILE_PATH, electron, path, port, fs } from "../../../../utils/electron.js"; +import { + SERVER_FILE_PATH, + electron, + path, + port, + fs, + onUpdateAvailable, + onUpdateProgress, +} from "../../../../utils/electron.js"; import saveSVG from "../../../../assets/icons/save.svg?raw"; import folderSVG from "../../../../assets/icons/folder_open.svg?raw"; import deleteSVG from "../../../../assets/icons/delete.svg?raw"; import generateSVG from "../../../../assets/icons/restart.svg?raw"; +import downloadSVG from "../../../../assets/icons/download.svg?raw"; +import infoSVG from "../../../../assets/icons/info.svg?raw"; import { header } from "../../forms/utils"; import examplePipelines from "../../../../../../example_pipelines.yml"; import { run } from "../guided-mode/options/utils.js"; import { joinPath } from "../../../globals"; +import { Modal } from "../../Modal"; +import { ProgressBar, humanReadableBytes } from "../../ProgressBar"; const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); const DATASET_OUTPUT_PATH = joinPath(testDataFolderPath, "multi_session_dataset"); @@ -180,6 +192,7 @@ export class SettingsPage extends Page { { title: "Generating test data", html: "This will take several minutes to complete.", + base: "data", } ).catch((error) => { this.notify(error.message, "error"); @@ -219,6 +232,90 @@ export class SettingsPage extends Page { this.#openNotyf(`Global settings changes saved.`, "success"); }; + #releaseNotesModal; + + // Populate the Update Available display + updated() { + const updateDiv = this.querySelector("#update-available"); + + if (updateDiv.innerHTML) return; // Only populate once + + onUpdateAvailable((updateInfo) => { + console.warn("Update Available", updateInfo); + + const relativePath = updateInfo.path; + const file = updateInfo.files.find((f) => f.url === relativePath); + const filesize = file.size; + + const container = document.createElement("div"); + container.classList.add("update-container"); + + const mainUpdateInfo = document.createElement("div"); + + const infoIcon = document.createElement("slot"); + infoIcon.innerHTML = infoSVG; + + infoIcon.onclick = () => { + if (this.#releaseNotesModal) return (this.#releaseNotesModal.open = true); + + const modal = (this.#releaseNotesModal = new Modal({ + header: `Release Notes`, + })); + + const releaseNotes = document.createElement("div"); + releaseNotes.style.padding = "25px"; + releaseNotes.innerHTML = updateInfo.releaseNotes; + modal.append(releaseNotes); + + document.body.append(modal); + + modal.open = true; + }; + + const controls = document.createElement("div"); + controls.classList.add("controls"); + const downloadButton = new Button({ + icon: downloadSVG, + label: `Update (${humanReadableBytes(filesize)})`, + size: "extra-small", + onClick: () => electron.ipcRenderer.send("download-update"), + }); + + controls.append(downloadButton); + + const header = document.createElement("div"); + header.classList.add("header"); + + const title = document.createElement("h4"); + title.innerText = `NWB GUIDE ${updateInfo.version}`; + header.append(title, infoIcon); + + const description = document.createElement("span"); + description.innerText = `A new version of the application is available.`; + + mainUpdateInfo.append(header, description); + + container.append(mainUpdateInfo, controls); + + let progressBarEl; + onUpdateProgress((progress) => { + if (!progressBarEl) { + progressBarEl = new ProgressBar({ + isBytes: true, + format: { total: filesize }, + }); + const hr = document.createElement("hr"); + updateDiv.append(hr, progressBarEl); + } + progressBarEl.format = { + prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, + ...progress, + }; + }); + updateDiv.append(container); + }); + } + render() { this.localState = structuredClone(global.data); @@ -236,7 +333,7 @@ export class SettingsPage extends Page { }); const generatePipelineButton = new Button({ - label: "Generate Example Pipelines", + label: "Generate Test Pipelines", onClick: async () => { const { testing_data_folder } = this.form.results.developer ?? {}; @@ -336,6 +433,7 @@ export class SettingsPage extends Page { +


${this.form} diff --git a/src/electron/frontend/core/components/sidebar.js b/src/electron/frontend/core/components/sidebar.js index 88a1ff0127..6d79ed6e0a 100644 --- a/src/electron/frontend/core/components/sidebar.js +++ b/src/electron/frontend/core/components/sidebar.js @@ -181,7 +181,16 @@ export class Sidebar extends LitElement { const a = document.createElement("a"); a.setAttribute("data-id", id); a.href = "#"; - a.innerHTML = `${icon} ${label}`; + + a.insertAdjacentHTML("afterbegin", icon); + + const labelContainer = document.createElement("div"); + const labelEl = document.createElement("span"); + labelEl.innerHTML = label; + labelContainer.append(labelEl); + + a.append(labelContainer); + a.onclick = () => this.#onClick(id); const li = document.createElement("li"); diff --git a/src/electron/frontend/core/pages.js b/src/electron/frontend/core/pages.js index 09e1b4c169..11f042adcf 100644 --- a/src/electron/frontend/core/pages.js +++ b/src/electron/frontend/core/pages.js @@ -44,7 +44,7 @@ const guidedIcon = ` fill="white" class="bi bi-compass-fill" viewBox="0 0 16 16" - style="margin-right: 30px; margin-bottom: -5px" + style="margin-right: 30px;" > diff --git a/src/electron/frontend/utils/electron.js b/src/electron/frontend/utils/electron.js index 7d85eb45fa..e8c6f2e4a0 100644 --- a/src/electron/frontend/utils/electron.js +++ b/src/electron/frontend/utils/electron.js @@ -14,6 +14,32 @@ export let path = null; export let log = null; export let crypto = null; +let updateAvailable = false; +const updateAvailableCallbacks = []; +export const onUpdateAvailable = (callback) => { + if (updateAvailable) callback(updateAvailable); + else updateAvailableCallbacks.push(callback); +}; + +let updateProgress = null; + +const updateProgressCallbacks = []; +export const onUpdateProgress = (callback) => { + if (updateProgress) callback(updateProgress); + else updateProgressCallbacks.push(callback); +}; + +export const registerUpdateProgress = (info) => { + updateProgress = info; + updateProgressCallbacks.forEach((cb) => cb(info)); +}; + +const registerUpdate = (info) => { + updateAvailable = info; + document.body.setAttribute("data-update-available", JSON.stringify(info)); + updateAvailableCallbacks.forEach((cb) => cb(info)); +}; + // Used in tests try { crypto = require("crypto"); @@ -39,6 +65,15 @@ if (isElectron) { electron.ipcRenderer.on(`console.${method}`, (_, ...args) => console[method](`[main-process]:`, ...args)) ); + electron.ipcRenderer.on(`checking-for-update`, (_, ...args) => console.log(`[Update]:`, ...args)); + + electron.ipcRenderer.on(`update-available`, (_, info) => (info ? registerUpdate(info) : "")); + + electron.ipcRenderer.on(`update-progress`, (_, info) => registerUpdateProgress(info)); + electron.ipcRenderer.on(`update-complete`, (_, ...args) => console.log(`[Update]:`, ...args)); + + electron.ipcRenderer.on(`update-error`, (_, ...args) => console.log(`[Update]:`, ...args)); + port = electron.ipcRenderer.sendSync("get-port"); console.log("User OS:", os.type(), os.platform(), "version:", os.release()); diff --git a/src/electron/main/main.ts b/src/electron/main/main.ts index 511eb15ef0..c8bc7fc8d4 100755 --- a/src/electron/main/main.ts +++ b/src/electron/main/main.ts @@ -21,16 +21,24 @@ import icon from '../frontend/assets/img/logo-guide-draft.png?asset' import splashHTML from './splash-screen.html?asset' import preloadUrl from '../preload/preload.js?asset' +import devUpdateConfig from './dev-app-update.yml?asset' + + +import { autoUpdater } from 'electron-updater'; + + const runByTestSuite = !!process.env.VITEST +// Configure AutoUpdater +autoUpdater.autoDownload = false; // Disable auto download of updates +autoUpdater.channel = "latest"; + // Enable remote debugging port for Vitest if (runByTestSuite) { app.commandLine.appendSwitch('remote-debugging-port', `${8315}`) // Mirrors the global electronDebugPort variable app.commandLine.appendSwitch('remote-allow-origins', '*') // Allow all remote origins } -// autoUpdater.channel = "latest"; - /************************************************************* * Python Process *************************************************************/ @@ -215,8 +223,6 @@ const killAllPreviousProcesses = async () => { } else console.error('Cannot kill previous processes because fetch is not defined in this version of Node.js') }; -let updatechecked = false; - let hasBeenOpened = false; function initialize() { @@ -352,8 +358,8 @@ function initialize() { win.show(); createWindow(); - // autoUpdater.checkForUpdatesAndNotify(); - updatechecked = true; + // Check for updates + autoUpdater.checkForUpdates(); // Clear ready queue readyQueue.forEach(f => onWindowReady(f)) @@ -361,6 +367,7 @@ function initialize() { }, hasBeenOpened ? 100 : 1000); }); + } @@ -455,7 +462,8 @@ app.on("before-quit", async (ev: Event) => { type: "question", buttons: ["Yes", "No"], title: "Confirm", - message: "Any running process will be stopped. Are you sure you want to quit?", + message: 'Are you sure you want to quit?', + detail: 'Any running processes will be stopped.' }) if (response !== 0) return // Skip quitting @@ -515,3 +523,48 @@ ipcMain.on("get-server-file-path", (event) => { ipcMain.on("python.status", (event) => { if (globals.python.sent) ((globals.python.status) ? pythonIsOpen : pythonIsClosed)(true); // Force send }); + +// Add auto-updater events + +ipcMain.on('download-update', () => { + autoUpdater.downloadUpdate() +}) + +autoUpdater.on('update-available', (info) => { + onWindowReady((win) => send.call(win, 'update-available', info)) +}); + +autoUpdater.on('update-not-available', () => { + onWindowReady((win) => send.call(win, 'update-available', false)) +}); + +autoUpdater.on('error', (err) => { + onWindowReady((win) => send.call(win, 'update-error', err)) +}); + +autoUpdater.on('download-progress', (progressObj) => { + onWindowReady((win) => send.call(win, 'update-progress', { + n: progressObj.transferred, + total: progressObj.total, + rate: progressObj.bytesPerSecond, + })) +}); + +autoUpdater.on('update-downloaded', (info) => { + + onWindowReady((win) => { + send.call(win, 'update-complete', info) + + // Prompt user to install update + dialog + .showMessageBox(win, { + type: 'info', + buttons: ['Restart', 'Later'], + title: 'Update available', + message: 'A new version has been downloaded. Restart now to install the update?', + }) + .then((result) => { + if (result.response === 0) autoUpdater.quitAndInstall(); + }); + }) +});