diff --git a/.github/workflows/Build-and-deploy-mac.yml b/.github/workflows/Build-and-deploy-mac.yml index 7e8afcfe5..310b5cc2d 100644 --- a/.github/workflows/Build-and-deploy-mac.yml +++ b/.github/workflows/Build-and-deploy-mac.yml @@ -37,25 +37,14 @@ jobs: - uses: apple-actions/import-codesign-certs@v1 with: - # https://developer.apple.com/account/resources/certificates/add - # Sign up for an Apple Developer account (annual fee) - # Create a new certificate for Mac development at website above (max 1 certificate) - # Create a Certificate Signing Request (CSR) on your Mac -- - # see https://developer.apple.com/help/account/create-certificates/create-a-certificate-signing-request - # Keychain Access > Certificate Assistant > Request a Certificate from a Certificate Authority... - # Enter email address, common name - # Download the new certificate (.cer file) - # Double click the .cer file to install in Keychain Access - # Use Keychain Access to export the certificate as a .p12 - # base64 -i cent.p12 -o base64.txt - # Open base64.txt and copy the contents to the nwb-guide repository secret for MACOS_CERTIFICATE - # Currently this is set up to use Ryan's account and certificate + # Currently this is set to Ryan's Developer ID certificate p12-file-base64: ${{ secrets.MACOS_CERTIFICATE }} p12-password: ${{ secrets.MACOS_CERTIFICATE_PWD }} - name: Build and deploy on MAC env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - appleId: ${{ secrets.APPLE_ID }} # currently this is set to Ryan's Apple ID and password + # Currently this is set to Ryan's Apple ID and app-specific password + appleId: ${{ secrets.APPLE_ID }} appleIdPassword: ${{ secrets.APPLE_PASSWORD }} run: npm run deploy:mac diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 0cfb20d1b..0ef322e2c 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -9,7 +9,7 @@ concurrency: # Cancel previous workflows on the same pull request cancel-in-progress: true env: - CACHE_NUMBER: 1 # increase to reset cache manually + CACHE_NUMBER: 2 # increase to reset cache manually jobs: testing: @@ -24,20 +24,14 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] include: - - python-version: "3.9" - os: ubuntu-latest + - os: ubuntu-latest label: environments/environment-Linux.yml - prefix: /usr/share/miniconda3/envs/env-electron-python - - python-version: "3.10" - os: macos-latest + - os: macos-latest label: environments/environment-Mac.yml - prefix: /Users/runner/miniconda3/envs/env-electron-python - - python-version: "3.9" - os: windows-latest + - os: windows-latest label: environments/environment-Windows.yml - prefix: C:\Miniconda3\envs\env-electron-python steps: @@ -50,7 +44,7 @@ jobs: with: miniforge-variant: Mambaforge miniforge-version: latest - activate-environment: env-electron-python + activate-environment: nwb-guide use-mamba: true - name: Set cache date @@ -62,17 +56,12 @@ jobs: uses: actions/cache@v2 with: path: ${{ env.CONDA }}/envs - key: - conda-${{ runner.os }}-${{ runner.arch }}-${{steps.get-date.outputs.today }}-${{ hashFiles(matrix.label) }}-${{ env.CACHE_NUMBER }} - env: - # Increase this value to reset cache if environment file has not changed - CACHE_NUMBER: 0 + key: conda-${{ runner.os }}-${{ runner.arch }}-${{steps.get-date.outputs.today }}-${{ hashFiles(matrix.label) }}-${{ env.CACHE_NUMBER }} id: cache - - name: Update environment - run: - mamba env update -n env-electron-python -f ${{ matrix.label }} - if: steps.cache.outputs.cache-hit != 'true' + - if: steps.cache.outputs.cache-hit != 'true' + name: Create and activate environment + run: mamba env update -n nwb-guide -f ${{ matrix.label }} - name: Use Node.js 18 uses: actions/setup-node@v3 diff --git a/docs/developer_guide.rst b/docs/developer_guide.rst index eb206f426..e191ff894 100644 --- a/docs/developer_guide.rst +++ b/docs/developer_guide.rst @@ -125,3 +125,49 @@ Pre-Commit We use an automated pre-commit bot to enforce these on the main repo, but contributions from external forks would either have to grant bot permissions on their own fork (via :pre-commit-bot:`the pre-commit bot website <>`) or run pre-commit manually. For instructions to install pre-commit, as well as some other minor coding styles we follow, refer to the :neuroconv-coding-style:`NeuroConv style guide <>`. + +Code signing on Mac OS +--------------------------- + +1. Sign up for an Apple Developer account (99 USD annual fee). + +2. Follow steps in https://developer.apple.com/help/account/create-certificates/create-developer-id-certificates/ + a. Browse current Certificates at https://developer.apple.com/account/resources/certificates/list. + b. Click Certificates in the sidebar. On the top left, click the add button (+). + c. Under Software, select Developer ID Application. + d. Select Profile Type: G2 Sub-CA (Xcode 11.4.1 or later). + e. Create a certificate signing request (CSR) by following the steps in https://developer.apple.com/help/account/create-certificates/create-a-certificate-signing-request + i. Open Keychain Access. + ii. Choose Keychain Access > Certificate Assistant > Request a Certificate from a Certificate Authority. + iii. In the Certificate Assistant dialog, enter an email address in the User Email Address field. + iv. In the Common Name field, enter a name for the key (for example, John Doe Dev Key). Ryan entered "Ryan Ly". + v. Leave the CA Email Address field empty. + vi. Choose “Saved to disk”, and click Continue. + vii. Save the certificate request file to disk. + f. Select the certificate request file (a file with a .certSigningRequest file extension), then click Choose. + g. Click Continue, click Download - The certificate file (.cer file) appears in your Downloads folder. + h. To install the certificate in your keychain, double-click the downloaded certificate file. + i. The certificate appears in the My Certificates category in Keychain Access, but may not be trusted. + j. For local development, download the appropriate Apple Intermediate Certificate. + k. from https://www.apple.com/certificateauthority/ to make certificate trusted/valid. + l. For this, it is Developer ID - G2 (Expiring 09/17/2031 00:00:00 UTC). + m. Double-click the downloaded file. + n. Confirm that the certificate now shows up as trusted in Keychain Access. + +3. Provide a p12 file for notarizing via GitHub Action. + a. Open Keychain Access. + b. Select the Developer ID Application certificate. + c. Choose Keychain Access > Export Items... + d. Export the certificate to a file with a password. + e. Get a base64 version of the certificate by running: base64 -i Certificate.p12 -o base64.txt + f. Open base64.txt and copy the contents to the nwb-guide repository secret MACOS_CERTIFICATE. + g. Set the password for the certificate in the nwb-guide repository secret MACOS_CERTIFICATE_PASSWORD. + +4. Create an app-specific password for building locally and via the GitHub Action. + a. Go to https://appleid.apple.com/account/manage. + b. Follow the steps to create an App-Specific Password. + c. Use that for local building and in the secrets.APPLE_PASSWORD repository secret. + +5. Review and agree to any pending agreements. + a. Go to https://appstoreconnect.apple.com/agreements/#/ and agree to pending agreements for Free Apps. + b. Review and agree to the Apple Developer Program License Agreement, which updates periodically. diff --git a/package.json b/package.json index 15cc2528a..3653a3054 100644 --- a/package.json +++ b/package.json @@ -77,8 +77,20 @@ "mac": { "asar": true, "target": [ - "dmg", - "zip" + { + "target": "dmg", + "arch": [ + "x64", + "arm64" + ] + }, + { + "target": "zip", + "arch": [ + "x64", + "arm64" + ] + } ], "icon": "src/renderer/assets/img/logo-guide-draft.png", "darkModeSupport": false, diff --git a/prepare_pyinstaller_spec.py b/prepare_pyinstaller_spec.py index db06d8e91..a71d0923e 100644 --- a/prepare_pyinstaller_spec.py +++ b/prepare_pyinstaller_spec.py @@ -20,9 +20,11 @@ lines[data_line_index] = "datas = [('./paths.config.json', '.'), ('./package.json', '.')]\n" # Another platform specific difference is the app.py location -app_py_line_index = next(index for index, line in enumerate(lines) if "app.py" in line) -app_py_line = " [f\"{Path('pyflask') / 'app.py'}\"],\n" -lines[app_py_line_index] = app_py_line +app_py_line_index, app_py_line = next((index, line) for index, line in enumerate(lines) if "app.py" in line) +pyflask_start = app_py_line.find("pyflask") # Can change on certain systems +injected_app_py_line_base = app_py_line[: (pyflask_start - 1)] +injected_app_py_line = injected_app_py_line_base + "f\"{Path('pyflask') / 'app.py'}\"],\n" +lines[app_py_line_index] = injected_app_py_line with open(file=Path(__file__).parent / "nwb-guide.spec", mode="w") as io: io.writelines(lines) diff --git a/src/main/main.ts b/src/main/main.ts index 582090189..08b915698 100755 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -125,7 +125,7 @@ const pythonIsClosed = (err = globals.python.latestError) => { * @returns {boolean} True if the app is packaged, false if it is running from a dev version. */ const getPackagedPath = () => { - const scriptPath = isWindows ? path.join(__dirname, PY_FLASK_DIST_FOLDER, PYFLASK_BUILD_SUBFOLDER_NAME, `${PYINSTALLER_NAME}.exe`) : path.join(process.resourcesPath, PYFLASK_BUILD_SUBFOLDER_NAME, PYINSTALLER_NAME) + const scriptPath = isWindows ? path.join(__dirname, PY_FLASK_DIST_FOLDER, PYINSTALLER_NAME, `${PYINSTALLER_NAME}.exe`) : path.join(process.resourcesPath, PYFLASK_BUILD_SUBFOLDER_NAME, PYINSTALLER_NAME) if (fs.existsSync(scriptPath)) return scriptPath; }; @@ -190,7 +190,7 @@ const exitPyProc = async () => { "/t", ]) // Windows does not properly shut off the python server process. This ensures it is killed. - else pyflaskProcess.kill() + pyflaskProcess.kill() // Try killing twice on Windows pyflaskProcess = null; }; @@ -223,6 +223,8 @@ let hasBeenOpened = false; function initialize() { + if (globals.mainWindow) return // Do not re-initialize if the main window is already declared + makeSingleInstance(); function createWindow() { @@ -254,42 +256,39 @@ function initialize() { }) .then((responseObject) => { let { response } = responseObject; - if (response === 0) quit_app() + if (response === 0) app.quit(); else globals.mainWindowReady = true }); } } else { - await exitPyProc(); - app.exit(); + app.quit(); } }); } - const quit_app = () => { - globals.mainWindow.close(); - if (!globals.mainWindow.closed) globals.mainWindow.destroy() - }; - function onAppReady () { - const promise = createPyProc(); - - // Listen after first load - promise.then(() => { - const chokidar = require('chokidar'); - chokidar.watch(path.join(__dirname, "../../pyflask"), { - ignored: ['**/__pycache__/**'] - }).on('all', async (event: string) => { - if (event === 'change' && !globals.python.restart) { - globals.python.restart = true - await exitPyProc(); - setTimeout(async () => { - await createPyProc(); - globals.python.restart = false - }, 1000) - } - }); - }) + // Only create one python process + if (!pyflaskProcess) { + const promise = createPyProc(); + + // Listen after first load + promise.then(() => { + const chokidar = require('chokidar'); + chokidar.watch(path.join(__dirname, "../../pyflask"), { + ignored: ['**/__pycache__/**'] + }).on('all', async (event: string) => { + if (event === 'change' && !globals.python.restart) { + globals.python.restart = true + await exitPyProc(); + setTimeout(async () => { + await createPyProc(); + globals.python.restart = false + }, 1000) + } + }); + }) + } const windowOptions = { minWidth: 1121, @@ -394,13 +393,22 @@ app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) initialize() }) -app.on("window-all-closed", async () => { - if (process.platform != 'darwin') { - await exitPyProc(); - app.quit(); +app.on('will-quit', async () => { + try { + await exitPyProc() + if (globals.mainWindow) { + globals.mainWindow.close(); + if (!globals.mainWindow.closed) globals.mainWindow.destroy() + } + } catch (err) { + console.error(err); } }); +app.on("window-all-closed", async () => { + if (process.platform != 'darwin') app.quit(); +}); + // app.on("will-quit", () => app.quit()); app.on("open-file", onFileOpened) diff --git a/src/renderer/src/progress/index.js b/src/renderer/src/progress/index.js index 8e39c7e97..212954f47 100644 --- a/src/renderer/src/progress/index.js +++ b/src/renderer/src/progress/index.js @@ -14,6 +14,8 @@ import { merge } from "../stories/pages/utils.js"; import { updateAppProgress, updateFile } from "./update.js"; import { updateURLParams } from "../../utils/url.js"; +import * as operations from "./operations"; + export * from "./update"; class GlobalAppConfig { @@ -122,24 +124,7 @@ export const remove = async (name) => { focusCancel: true, }); - if (result.isConfirmed) { - //Get the path of the progress file to delete - const progressFilePathToDelete = joinPath(guidedProgressFilePath, name + ".json"); - - //delete the progress file - if (fs) fs.unlinkSync(progressFilePathToDelete); - else localStorage.removeItem(progressFilePathToDelete); - - if (fs) { - // delete default stub location - fs.rmSync(joinPath(stubSaveFolderPath, name), { recursive: true, force: true }); - - // delete default conversion location - fs.rmSync(joinPath(conversionSaveFolderPath, name), { recursive: true, force: true }); - } - - return true; - } + if (result.isConfirmed) return operations.remove(name); return false; }; diff --git a/src/renderer/src/progress/operations.js b/src/renderer/src/progress/operations.js new file mode 100644 index 000000000..20ec92b2c --- /dev/null +++ b/src/renderer/src/progress/operations.js @@ -0,0 +1,22 @@ +import { joinPath } from "../globals"; +import { conversionSaveFolderPath, guidedProgressFilePath, stubSaveFolderPath } from "../dependencies/simple"; +import { fs } from "../electron"; + +export const remove = (name) => { + //Get the path of the progress file to delete + const progressFilePathToDelete = joinPath(guidedProgressFilePath, name + ".json"); + + //delete the progress file + if (fs) fs.unlinkSync(progressFilePathToDelete); + else localStorage.removeItem(progressFilePathToDelete); + + if (fs) { + // delete default stub location + fs.rmSync(joinPath(stubSaveFolderPath, name), { recursive: true, force: true }); + + // delete default conversion location + fs.rmSync(joinPath(conversionSaveFolderPath, name), { recursive: true, force: true }); + } + + return true; +}; diff --git a/src/renderer/src/progress/update.js b/src/renderer/src/progress/update.js index 8756db83a..2eafc52a3 100644 --- a/src/renderer/src/progress/update.js +++ b/src/renderer/src/progress/update.js @@ -2,15 +2,15 @@ import { updateURLParams } from "../../utils/url.js"; import { guidedProgressFilePath } from "../dependencies/simple.js"; import { fs } from "../electron/index.js"; import { joinPath } from "../globals.js"; -import { get } from "./index.js"; +import { get, hasEntry } from "./index.js"; -export const update = (newDatasetName, previousDatasetName) => { - //If updataing the dataset, update the old banner image path with a new one +export const rename = (newDatasetName, previousDatasetName) => { + //If updating the dataset, update the old banner image path with a new one if (previousDatasetName) { - if (previousDatasetName === newDatasetName) return "No changes made to dataset name"; + if (previousDatasetName === newDatasetName) return; if (hasEntry(newDatasetName)) - throw new Error("An existing progress file already exists with that name. Please choose a different name."); + throw new Error("An existing project already exists with that name. Please choose a different name."); // update old progress file with new dataset name const oldProgressFilePath = `${guidedProgressFilePath}/${previousDatasetName}.json`; @@ -20,10 +20,9 @@ export const update = (newDatasetName, previousDatasetName) => { localStorage.setItem(newProgressFilePath, localStorage.getItem(oldProgressFilePath)); localStorage.removeItem(oldProgressFilePath); } - - return "Dataset name updated"; - } else throw new Error("No previous dataset name provided"); + } else throw new Error("No previous project name provided"); }; + export const updateAppProgress = ( pageId, dataOrProjectName = {}, diff --git a/src/renderer/src/stories/pages/Page.js b/src/renderer/src/stories/pages/Page.js index cfd4cc474..49b22071b 100644 --- a/src/renderer/src/stories/pages/Page.js +++ b/src/renderer/src/stories/pages/Page.js @@ -64,7 +64,7 @@ export class Page extends LitElement { this.beforeTransition(); // Otherwise note unsaved updates if present - if (this.unsavedUpdates || ('states' in this.info && !this.info.states.saved)) { + if (this.unsavedUpdates || ("states" in this.info && !this.info.states.saved)) { if (transition === 1) await this.save(); // Save before a single forward transition else { Swal.fire({ diff --git a/src/renderer/src/stories/pages/guided-mode/setup/GuidedNewDatasetInfo.js b/src/renderer/src/stories/pages/guided-mode/setup/GuidedNewDatasetInfo.js index b43b849be..4ed72ce29 100644 --- a/src/renderer/src/stories/pages/guided-mode/setup/GuidedNewDatasetInfo.js +++ b/src/renderer/src/stories/pages/guided-mode/setup/GuidedNewDatasetInfo.js @@ -1,5 +1,5 @@ import { html } from "lit"; -import { global, hasEntry, update } from "../../../../progress/index.js"; +import { global, hasEntry, rename } from "../../../../progress/index.js"; import { JSONSchemaForm } from "../../../JSONSchemaForm.js"; import { Page } from "../../Page.js"; import { validateOnChange } from "../../../../validation/index.js"; @@ -54,7 +54,7 @@ export class GuidedNewDatasetPage extends Page { // Update existing progress file if (globalState.initialized) { try { - const res = update(name, globalState.name); + const res = rename(name, globalState.name); if (typeof res === "string") this.notify(res); if (res === false) return; } catch (e) { @@ -65,7 +65,7 @@ export class GuidedNewDatasetPage extends Page { const has = await hasEntry(name); if (has) { this.notify( - "An existing progress file already exists with that name. Please choose a different name.", + "An existing project already exists with that name. Please choose a different name.", "error" ); return; diff --git a/tests/progress.test.ts b/tests/progress.test.ts index 52f68b634..f00782f73 100644 --- a/tests/progress.test.ts +++ b/tests/progress.test.ts @@ -1,4 +1,27 @@ -import { test } from 'vitest' -import { updateAppProgress } from '../src/renderer/src/progress/update' +import { expect, test } from 'vitest' +import { updateAppProgress, updateFile, rename } from '../src/renderer/src/progress/update' +import { get } from '../src/renderer/src/progress' +import { remove } from '../src/renderer/src/progress/operations' test('updates to app progress do not fail', () => updateAppProgress('/', {})) + +const initialName = '.progressTestPipelineName' +const renameName = initialName + 2 +const info = { random: Math.random() } + +// Remove before tests +remove(initialName) +remove(renameName) + +// create pipeline +test('pipeline creation works', () => { + updateFile(initialName, () => info) + const result = get(initialName) + expect(result.random).toEqual(info.random) // NOTE: Result has an extra lastModified field +}) + +// rename pipeline +test('pipeline renaming works', () => rename(renameName, initialName)) + +// delete pipeline +test('pipeline deletion works', () => remove(renameName))