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">
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!
-
-
-
-
-
-
-
- Rapidly prepare your data and metadata according to the NWB Best Practices and DANDI
- requirements
-
-
-
-
-
-
Easily upload your curated dataset to the DANDI archive
-
-
-
-
-
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();
+ });
+ })
+});