From b6bf4416134603aaec9fcdc689a999673aace1ef Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 7 Oct 2026 13:34:43 +1300 Subject: [PATCH 001/184] New version. qwe qwe --- package.json | 2 +- src/renderer/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 435ec073c..1dfb901d2 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "url": "git+https://github.com/opencor/webapp.git" }, "type": "module", - "version": "1.20261007.1", + "version": "1.20261010.0", "engines": { "bun": ">=1.2.0" }, diff --git a/src/renderer/package.json b/src/renderer/package.json index f4b37713c..1fb220a4e 100644 --- a/src/renderer/package.json +++ b/src/renderer/package.json @@ -42,7 +42,7 @@ }, "./style.css": "./dist/opencor.css" }, - "version": "1.20261007.1", + "version": "1.20261010.0", "libopencorVersion": "1.20261007.0", "scripts": { "build": "vite build && bun scripts/generate.version.ts", From 1602ced4e2c145c3214c0381384c0d30c62445dd Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 17:10:38 +1300 Subject: [PATCH 002/184] Updated our dependencies. --- bun.lock | 72 +++++++++++------------ package.json | 4 +- src/renderer/bun.lock | 62 +++++++++---------- src/renderer/package.json | 2 +- src/renderer/src/common/initialisation.ts | 4 +- 5 files changed, 72 insertions(+), 72 deletions(-) diff --git a/bun.lock b/bun.lock index 45e44f06d..adce2949c 100644 --- a/bun.lock +++ b/bun.lock @@ -22,13 +22,13 @@ "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/vite": "^4.3.3", "@types/node": "^26.6.4", - "@types/plotly.js": "^3.0.14", + "@types/plotly.js": "^3.0.15", "@vitejs/plugin-vue": "^6.0.9", "@vue/tsconfig": "^0.9.1", "@wasm-fmt/clang-format": "^23.1.1", "autoprefixer": "^10.6.1", "cmake-js": "^8.0.0", - "electron": "^44.6.0", + "electron": "^44.7.0", "electron-builder": "^26.15.3", "electron-conf": "^1.3.0", "electron-updater": "^6.8.9", @@ -80,7 +80,7 @@ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], - "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + "@babel/helpers": ["@babel/helpers@7.29.10", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.8" } }, "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg=="], "@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], @@ -88,7 +88,7 @@ "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + "@babel/traverse": ["@babel/traverse@7.29.10", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ=="], "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], @@ -284,55 +284,55 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.64.0", "", { "os": "android", "cpu": "arm" }, "sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.64.2", "", { "os": "android", "cpu": "arm" }, "sha512-gjNDUwMswogkw3fNqV00u54uZlgmInuw5EU/1TeqQ6K0bEagoGvqjPdKzy112lKyhe7PkGudZ/kSQHsc9qroIg=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.64.0", "", { "os": "android", "cpu": "arm64" }, "sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg=="], + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.64.2", "", { "os": "android", "cpu": "arm64" }, "sha512-YkAW8q7p5SCLSaGWaUkmsRnBEb3MGA1pB5nI/8k0lAK5fZsQKRBqkdR1h4UU9p8u3c7ZGdUF2Zcx7mK+E/pUKg=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.64.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.64.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hNmuIHv+LnrP/+q/44vsmIMWfoh7X2YXOp5FLtjGuvqN52VdtFpOpnshTc1k28tWv1wE/0D5gLjsozBUowEGbQ=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.64.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.64.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-B0t5CoxgF2KL7R5FHAsyN8yVEQCSIkh4XVs8MCDjuBLNjEa8l3zd5tVKaFMv53LfUtHs9p2rnqeavPTOunV+Ww=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.64.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.64.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-QQdYnfwrPmc1DoPlddjFvQPzUHFbStKXsHrG6EBYsWHhBVUCjsCRdcfGGXGAvhDpDtTzKK4vy1UrGFJ45rQaiw=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.64.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.64.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1xRxtGWif6BB58VSfMavi0Yfs2M4x6CtRp4o7ScY4W7AGlgvf6GSPKdDNdw7TX3tMiQwZY5W/mvyrOJTq/7AKA=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.64.0", "", { "os": "linux", "cpu": "arm" }, "sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.64.2", "", { "os": "linux", "cpu": "arm" }, "sha512-CXFLEt384BUwwOLR9DURWNHl1bQOVLd1ibOee+yKBJm/fZtvrSAOZ0JbxjAxszVMIovYMwFMS+oP+Q1aPcx8Eg=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.64.0", "", { "os": "linux", "cpu": "arm" }, "sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.64.2", "", { "os": "linux", "cpu": "arm" }, "sha512-T1dorY0WHm5mMNpMHFl5rYRZ02gLSZcr6J45r66tYb3S3R3DyIRGiGWVdLtF+zOQjratNJaObl9jYumueRiL1A=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.64.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.64.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-ia6bfaCIc0ldp6bvs9oEkFrJVJKNUMoiDF0+LBZsQAZg+vE0znXnzGqH+ew/FYswE30cruAWPTO3dPw7keaVkQ=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.64.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.64.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-mB/gwfBfXtT0EBpbSssqKCqMFuX8b21Zy2TGoIhehSJEssZ5ksybxymwp1BkEM5Nczh/AAxpmh5q+MkhtundGg=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.64.2", "", { "os": "linux", "cpu": "none" }, "sha512-VPd0a+XlHoNRI9jIPRHww2UKhDkD6XjbtcKYXZijAnhJS1jLLbiNtVl6a5LyEfNN+eyiyWcJOZXPzBo69oTsew=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.64.2", "", { "os": "linux", "cpu": "none" }, "sha512-10N1I6oBpERqzGtHKcCLrEpHlbXSBbMK9TURHvqgWbIFZuU3oJROxJ0H2nHfq96TVI0XJHQ9qzolJMFd2tz0LA=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.64.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.64.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-dhJrLVZ3wNNhwCmy9/VTg6Pq6J8a8eZ0n15dVRlbSWcDb+8V/1mDfbnvis+vt8W6Pbxzv6gUbTEl4piOT3bbUQ=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.64.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.64.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tPd2QuwxkTxgju5eARyb3RdgBWWi/O9dn/NFH0VkvGbLzoQeXrH6qMABtW8IODb5lUeBzB3sy2U7qmI4MtQ72g=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.64.2", "", { "os": "linux", "cpu": "none" }, "sha512-njtDdPvSQWI9CDhIPPxsiEMeHayCfu7wwcbSQ7c2kYPhGVviL+WGVhq6Jfj/b+VuhSTkFuQ0z4ZFbFjkWwBFmg=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.64.2", "", { "os": "linux", "cpu": "none" }, "sha512-EbOBo3dqCj9ecEV9Z7zvulB6AieMP2ztnKQYGVOSN0pzIyOu+3JH2YJZnLmbPeVvfn6yP6Qi7NbBM27EzGbj2w=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.64.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.64.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-8wLt2Q5n4qmfiE2Gk8V6KNLSf+oJuBX7Kdw5joFDtnZIZvirS0hLzBc79OiJjV71oYDmBUZLNy6pDgvylo4qiQ=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.64.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.64.2", "", { "os": "linux", "cpu": "x64" }, "sha512-aIChbkhC4d33bxkVP0LSi2/2lUSGRXE6k4Qa0G7tgpgTOK7kxHTixmx5qC3SKUJ6yUMznHBtQoWaG4QMqLUnhw=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.64.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.64.2", "", { "os": "linux", "cpu": "x64" }, "sha512-gQ+TkVZRfjZVcl/UOAxxfWw+sHjzIXoieNwxEyJlD3tRBmBhR8ZDq11w+QEgQDcpslEB+fhN/OEpaTsDoIIHhQ=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.64.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.64.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-sUaJpMKS2JBCvUk8fYimG7aMq2jHVT4kw/uVHxPolKLrMmkKy0KXirmZyZehdNpL7PffqAlPAWUELQcTiD8ChA=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.64.0", "", { "os": "none", "cpu": "arm64" }, "sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.64.2", "", { "os": "none", "cpu": "arm64" }, "sha512-j1RdJWb0CrlSuwt12OPjLucPlwfLtA/KMOKVvh5rXSJEtl/WDoIGR3CSykK/jLGTo9RqNfW5Ub6kY5Z5ccYl9w=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.64.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.64.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-i3r04aHCIY3eggTBLUp/4IxiBjDpAgQaK89Ue2nMJ2MKuIXX5JfoeZjHw+kPjGGNVDlhi8FT8xzuGMJZe3aehg=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.64.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.64.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-kCQ15h/uwNMGdN8gjXBZGf+aGE+ODzHfbi90/cAm4+Q89WoMiLODNoj8HoGZmYx87HPxfu5CBNPsZ+ZG4uIAoA=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.64.0", "", { "os": "win32", "cpu": "x64" }, "sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ=="], + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.64.2", "", { "os": "win32", "cpu": "x64" }, "sha512-yYAuySSs+0X2gSMJPw/Q4jHsHZHyMq1dpAE+XnN790Jg1WuReUAD/+j+e4vuDvS4PXJ4PA4NCzTR4OYtDf18/g=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.64.0", "", { "os": "win32", "cpu": "x64" }, "sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.64.2", "", { "os": "win32", "cpu": "x64" }, "sha512-JY1rQdWOJrKutORqKO1uzrHXV86jFiQzfLFKx2s8S1CjLXL9fC5LOIvk0q7Z7PXJvsByt+sdoE1Q2a9gBr4gLQ=="], "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], @@ -388,7 +388,7 @@ "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], - "@types/plotly.js": ["@types/plotly.js@3.0.14", "", {}, "sha512-3JZH20UBXQ8rZFPeV40fKinx5Le0T2cuz6kM8LEYkkAuDrljzezlwvZuXnXQhyBost7YwgKxEDg7SVTIBbA6eg=="], + "@types/plotly.js": ["@types/plotly.js@3.0.15", "", {}, "sha512-ftBxk2fB7BxxeMpT0IVEb/3HKpYTXO5nCdR5mGEz6gHpLd6GGRFxvDVoG1gpD/gBAgoN36KIyRflxCnOfWlskQ=="], "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], @@ -546,7 +546,7 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001814", "", {}, "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001815", "", {}, "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -636,7 +636,7 @@ "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - "electron": ["electron@44.6.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-qjzKZ82yTsAoo0Q/gqdy4PEMQkzYpukZqIduaHWZYHapFhK9wgWwU4YId60xHmwTCraqF4AfVCm05o90dZJKMQ=="], + "electron": ["electron@44.7.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw=="], "electron-builder": ["electron-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="], @@ -646,7 +646,7 @@ "electron-publish": ["electron-publish@26.15.3", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q=="], - "electron-to-chromium": ["electron-to-chromium@1.5.447", "", {}, "sha512-vhYiRwTPq7tvwplP6fOFVRJxfZifavlr6tK8FD1EO5RxRuvZ9qdQM0lr1mvNBeDe1s2XrfL3n5BBwvigXJ7KuA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.451", "", {}, "sha512-vUNKPrUzw8ByEMOxloq0a71iD/mPuPxVkbDm3G5YhnxTzOw/I6qAg2MUS0cYj/c267lWmw26yW4Vv7foxTxPLg=="], "electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="], @@ -932,7 +932,7 @@ "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], - "node-abi": ["node-abi@4.36.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-zrvE1Qy5ElbZVqmJFSiNfwzNZzUCepasgp/4dw0Z0fsn40LJkAk9FA9oMh2SKMeNFRRe/3IfeOKvWwWr8u4SmQ=="], + "node-abi": ["node-abi@4.37.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-O9XXMXLeRHQtvh43NCah3y9N1K69tecOmu8Y5p2ay4iXvtpbQr0VaroJF9EmRVp3vkdoj3qn4IFfX+ZYtC94fw=="], "node-addon-api": ["node-addon-api@8.9.2", "", {}, "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg=="], @@ -1056,7 +1056,7 @@ "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], - "rollup": ["rollup@4.64.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.0", "@rollup/rollup-android-arm64": "4.64.0", "@rollup/rollup-darwin-arm64": "4.64.0", "@rollup/rollup-darwin-x64": "4.64.0", "@rollup/rollup-freebsd-arm64": "4.64.0", "@rollup/rollup-freebsd-x64": "4.64.0", "@rollup/rollup-linux-arm-gnueabihf": "4.64.0", "@rollup/rollup-linux-arm-musleabihf": "4.64.0", "@rollup/rollup-linux-arm64-gnu": "4.64.0", "@rollup/rollup-linux-arm64-musl": "4.64.0", "@rollup/rollup-linux-loong64-gnu": "4.64.0", "@rollup/rollup-linux-loong64-musl": "4.64.0", "@rollup/rollup-linux-ppc64-gnu": "4.64.0", "@rollup/rollup-linux-ppc64-musl": "4.64.0", "@rollup/rollup-linux-riscv64-gnu": "4.64.0", "@rollup/rollup-linux-riscv64-musl": "4.64.0", "@rollup/rollup-linux-s390x-gnu": "4.64.0", "@rollup/rollup-linux-x64-gnu": "4.64.0", "@rollup/rollup-linux-x64-musl": "4.64.0", "@rollup/rollup-openbsd-x64": "4.64.0", "@rollup/rollup-openharmony-arm64": "4.64.0", "@rollup/rollup-win32-arm64-msvc": "4.64.0", "@rollup/rollup-win32-ia32-msvc": "4.64.0", "@rollup/rollup-win32-x64-gnu": "4.64.0", "@rollup/rollup-win32-x64-msvc": "4.64.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ=="], + "rollup": ["rollup@4.64.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.2", "@rollup/rollup-android-arm64": "4.64.2", "@rollup/rollup-darwin-arm64": "4.64.2", "@rollup/rollup-darwin-x64": "4.64.2", "@rollup/rollup-freebsd-arm64": "4.64.2", "@rollup/rollup-freebsd-x64": "4.64.2", "@rollup/rollup-linux-arm-gnueabihf": "4.64.2", "@rollup/rollup-linux-arm-musleabihf": "4.64.2", "@rollup/rollup-linux-arm64-gnu": "4.64.2", "@rollup/rollup-linux-arm64-musl": "4.64.2", "@rollup/rollup-linux-loong64-gnu": "4.64.2", "@rollup/rollup-linux-loong64-musl": "4.64.2", "@rollup/rollup-linux-ppc64-gnu": "4.64.2", "@rollup/rollup-linux-ppc64-musl": "4.64.2", "@rollup/rollup-linux-riscv64-gnu": "4.64.2", "@rollup/rollup-linux-riscv64-musl": "4.64.2", "@rollup/rollup-linux-s390x-gnu": "4.64.2", "@rollup/rollup-linux-x64-gnu": "4.64.2", "@rollup/rollup-linux-x64-musl": "4.64.2", "@rollup/rollup-openbsd-x64": "4.64.2", "@rollup/rollup-openharmony-arm64": "4.64.2", "@rollup/rollup-win32-arm64-msvc": "4.64.2", "@rollup/rollup-win32-ia32-msvc": "4.64.2", "@rollup/rollup-win32-x64-gnu": "4.64.2", "@rollup/rollup-win32-x64-msvc": "4.64.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RxXm0XVVV1hFI3dYe12ruaOgsYQ725lUcUWeMSQqkF9tTxVErY+fy2Ets6vWhItL9MZMSNZGbvZFc7rH/VHp2Q=="], "rollup-plugin-visualizer": ["rollup-plugin-visualizer@7.1.1", "", { "dependencies": { "open": "^11.0.0", "picomatch": "^4.0.5", "source-map": "^0.8.0", "yargs": "^18.1.0" }, "peerDependencies": { "rolldown": "1.x || ^1.0.0-beta || ^1.0.0-rc", "rollup": "2.x || 3.x || 4.x" }, "optionalPeers": ["rolldown", "rollup"], "bin": { "rollup-plugin-visualizer": "dist/bin/cli.js" } }, "sha512-ThaGiHTU8XW02OkK80TrTHATraJmM9OAduU4otal+7gyXLpYEtmGBLfx5kW+EHvvLwn03YGW2NnwKUIqsYlJAA=="], @@ -1174,7 +1174,7 @@ "unzipper": ["unzipper@0.12.5", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A=="], - "update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="], + "update-browserslist-db": ["update-browserslist-db@1.3.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="], "url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="], diff --git a/package.json b/package.json index 1dfb901d2..f153e9645 100644 --- a/package.json +++ b/package.json @@ -73,13 +73,13 @@ "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/vite": "^4.3.3", "@types/node": "^26.6.4", - "@types/plotly.js": "^3.0.14", + "@types/plotly.js": "^3.0.15", "@vitejs/plugin-vue": "^6.0.9", "@vue/tsconfig": "^0.9.1", "@wasm-fmt/clang-format": "^23.1.1", "autoprefixer": "^10.6.1", "cmake-js": "^8.0.0", - "electron": "^44.6.0", + "electron": "^44.7.0", "electron-builder": "^26.15.3", "electron-conf": "^1.3.0", "electron-updater": "^6.8.9", diff --git a/src/renderer/bun.lock b/src/renderer/bun.lock index b45f9997c..5f2e2a40a 100644 --- a/src/renderer/bun.lock +++ b/src/renderer/bun.lock @@ -20,7 +20,7 @@ "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/vite": "^4.3.3", "@types/node": "^26.6.4", - "@types/plotly.js": "^3.0.14", + "@types/plotly.js": "^3.0.15", "@vitejs/plugin-vue": "^6.0.9", "@vue/tsconfig": "^0.9.1", "autoprefixer": "^10.6.1", @@ -209,55 +209,55 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.64.0", "", { "os": "android", "cpu": "arm" }, "sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.64.2", "", { "os": "android", "cpu": "arm" }, "sha512-gjNDUwMswogkw3fNqV00u54uZlgmInuw5EU/1TeqQ6K0bEagoGvqjPdKzy112lKyhe7PkGudZ/kSQHsc9qroIg=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.64.0", "", { "os": "android", "cpu": "arm64" }, "sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg=="], + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.64.2", "", { "os": "android", "cpu": "arm64" }, "sha512-YkAW8q7p5SCLSaGWaUkmsRnBEb3MGA1pB5nI/8k0lAK5fZsQKRBqkdR1h4UU9p8u3c7ZGdUF2Zcx7mK+E/pUKg=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.64.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.64.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hNmuIHv+LnrP/+q/44vsmIMWfoh7X2YXOp5FLtjGuvqN52VdtFpOpnshTc1k28tWv1wE/0D5gLjsozBUowEGbQ=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.64.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.64.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-B0t5CoxgF2KL7R5FHAsyN8yVEQCSIkh4XVs8MCDjuBLNjEa8l3zd5tVKaFMv53LfUtHs9p2rnqeavPTOunV+Ww=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.64.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.64.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-QQdYnfwrPmc1DoPlddjFvQPzUHFbStKXsHrG6EBYsWHhBVUCjsCRdcfGGXGAvhDpDtTzKK4vy1UrGFJ45rQaiw=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.64.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.64.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1xRxtGWif6BB58VSfMavi0Yfs2M4x6CtRp4o7ScY4W7AGlgvf6GSPKdDNdw7TX3tMiQwZY5W/mvyrOJTq/7AKA=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.64.0", "", { "os": "linux", "cpu": "arm" }, "sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.64.2", "", { "os": "linux", "cpu": "arm" }, "sha512-CXFLEt384BUwwOLR9DURWNHl1bQOVLd1ibOee+yKBJm/fZtvrSAOZ0JbxjAxszVMIovYMwFMS+oP+Q1aPcx8Eg=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.64.0", "", { "os": "linux", "cpu": "arm" }, "sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.64.2", "", { "os": "linux", "cpu": "arm" }, "sha512-T1dorY0WHm5mMNpMHFl5rYRZ02gLSZcr6J45r66tYb3S3R3DyIRGiGWVdLtF+zOQjratNJaObl9jYumueRiL1A=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.64.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.64.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-ia6bfaCIc0ldp6bvs9oEkFrJVJKNUMoiDF0+LBZsQAZg+vE0znXnzGqH+ew/FYswE30cruAWPTO3dPw7keaVkQ=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.64.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.64.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-mB/gwfBfXtT0EBpbSssqKCqMFuX8b21Zy2TGoIhehSJEssZ5ksybxymwp1BkEM5Nczh/AAxpmh5q+MkhtundGg=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.64.2", "", { "os": "linux", "cpu": "none" }, "sha512-VPd0a+XlHoNRI9jIPRHww2UKhDkD6XjbtcKYXZijAnhJS1jLLbiNtVl6a5LyEfNN+eyiyWcJOZXPzBo69oTsew=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.64.2", "", { "os": "linux", "cpu": "none" }, "sha512-10N1I6oBpERqzGtHKcCLrEpHlbXSBbMK9TURHvqgWbIFZuU3oJROxJ0H2nHfq96TVI0XJHQ9qzolJMFd2tz0LA=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.64.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.64.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-dhJrLVZ3wNNhwCmy9/VTg6Pq6J8a8eZ0n15dVRlbSWcDb+8V/1mDfbnvis+vt8W6Pbxzv6gUbTEl4piOT3bbUQ=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.64.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.64.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tPd2QuwxkTxgju5eARyb3RdgBWWi/O9dn/NFH0VkvGbLzoQeXrH6qMABtW8IODb5lUeBzB3sy2U7qmI4MtQ72g=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.64.2", "", { "os": "linux", "cpu": "none" }, "sha512-njtDdPvSQWI9CDhIPPxsiEMeHayCfu7wwcbSQ7c2kYPhGVviL+WGVhq6Jfj/b+VuhSTkFuQ0z4ZFbFjkWwBFmg=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.64.2", "", { "os": "linux", "cpu": "none" }, "sha512-EbOBo3dqCj9ecEV9Z7zvulB6AieMP2ztnKQYGVOSN0pzIyOu+3JH2YJZnLmbPeVvfn6yP6Qi7NbBM27EzGbj2w=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.64.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.64.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-8wLt2Q5n4qmfiE2Gk8V6KNLSf+oJuBX7Kdw5joFDtnZIZvirS0hLzBc79OiJjV71oYDmBUZLNy6pDgvylo4qiQ=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.64.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.64.2", "", { "os": "linux", "cpu": "x64" }, "sha512-aIChbkhC4d33bxkVP0LSi2/2lUSGRXE6k4Qa0G7tgpgTOK7kxHTixmx5qC3SKUJ6yUMznHBtQoWaG4QMqLUnhw=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.64.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.64.2", "", { "os": "linux", "cpu": "x64" }, "sha512-gQ+TkVZRfjZVcl/UOAxxfWw+sHjzIXoieNwxEyJlD3tRBmBhR8ZDq11w+QEgQDcpslEB+fhN/OEpaTsDoIIHhQ=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.64.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.64.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-sUaJpMKS2JBCvUk8fYimG7aMq2jHVT4kw/uVHxPolKLrMmkKy0KXirmZyZehdNpL7PffqAlPAWUELQcTiD8ChA=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.64.0", "", { "os": "none", "cpu": "arm64" }, "sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.64.2", "", { "os": "none", "cpu": "arm64" }, "sha512-j1RdJWb0CrlSuwt12OPjLucPlwfLtA/KMOKVvh5rXSJEtl/WDoIGR3CSykK/jLGTo9RqNfW5Ub6kY5Z5ccYl9w=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.64.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.64.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-i3r04aHCIY3eggTBLUp/4IxiBjDpAgQaK89Ue2nMJ2MKuIXX5JfoeZjHw+kPjGGNVDlhi8FT8xzuGMJZe3aehg=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.64.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.64.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-kCQ15h/uwNMGdN8gjXBZGf+aGE+ODzHfbi90/cAm4+Q89WoMiLODNoj8HoGZmYx87HPxfu5CBNPsZ+ZG4uIAoA=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.64.0", "", { "os": "win32", "cpu": "x64" }, "sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ=="], + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.64.2", "", { "os": "win32", "cpu": "x64" }, "sha512-yYAuySSs+0X2gSMJPw/Q4jHsHZHyMq1dpAE+XnN790Jg1WuReUAD/+j+e4vuDvS4PXJ4PA4NCzTR4OYtDf18/g=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.64.0", "", { "os": "win32", "cpu": "x64" }, "sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.64.2", "", { "os": "win32", "cpu": "x64" }, "sha512-JY1rQdWOJrKutORqKO1uzrHXV86jFiQzfLFKx2s8S1CjLXL9fC5LOIvk0q7Z7PXJvsByt+sdoE1Q2a9gBr4gLQ=="], "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], @@ -297,7 +297,7 @@ "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], - "@types/plotly.js": ["@types/plotly.js@3.0.14", "", {}, "sha512-3JZH20UBXQ8rZFPeV40fKinx5Le0T2cuz6kM8LEYkkAuDrljzezlwvZuXnXQhyBost7YwgKxEDg7SVTIBbA6eg=="], + "@types/plotly.js": ["@types/plotly.js@3.0.15", "", {}, "sha512-ftBxk2fB7BxxeMpT0IVEb/3HKpYTXO5nCdR5mGEz6gHpLd6GGRFxvDVoG1gpD/gBAgoN36KIyRflxCnOfWlskQ=="], "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], @@ -405,7 +405,7 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001814", "", {}, "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001815", "", {}, "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q=="], "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], @@ -439,7 +439,7 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "electron-to-chromium": ["electron-to-chromium@1.5.447", "", {}, "sha512-vhYiRwTPq7tvwplP6fOFVRJxfZifavlr6tK8FD1EO5RxRuvZ9qdQM0lr1mvNBeDe1s2XrfL3n5BBwvigXJ7KuA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.451", "", {}, "sha512-vUNKPrUzw8ByEMOxloq0a71iD/mPuPxVkbDm3G5YhnxTzOw/I6qAg2MUS0cYj/c267lWmw26yW4Vv7foxTxPLg=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -651,7 +651,7 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - "rollup": ["rollup@4.64.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.0", "@rollup/rollup-android-arm64": "4.64.0", "@rollup/rollup-darwin-arm64": "4.64.0", "@rollup/rollup-darwin-x64": "4.64.0", "@rollup/rollup-freebsd-arm64": "4.64.0", "@rollup/rollup-freebsd-x64": "4.64.0", "@rollup/rollup-linux-arm-gnueabihf": "4.64.0", "@rollup/rollup-linux-arm-musleabihf": "4.64.0", "@rollup/rollup-linux-arm64-gnu": "4.64.0", "@rollup/rollup-linux-arm64-musl": "4.64.0", "@rollup/rollup-linux-loong64-gnu": "4.64.0", "@rollup/rollup-linux-loong64-musl": "4.64.0", "@rollup/rollup-linux-ppc64-gnu": "4.64.0", "@rollup/rollup-linux-ppc64-musl": "4.64.0", "@rollup/rollup-linux-riscv64-gnu": "4.64.0", "@rollup/rollup-linux-riscv64-musl": "4.64.0", "@rollup/rollup-linux-s390x-gnu": "4.64.0", "@rollup/rollup-linux-x64-gnu": "4.64.0", "@rollup/rollup-linux-x64-musl": "4.64.0", "@rollup/rollup-openbsd-x64": "4.64.0", "@rollup/rollup-openharmony-arm64": "4.64.0", "@rollup/rollup-win32-arm64-msvc": "4.64.0", "@rollup/rollup-win32-ia32-msvc": "4.64.0", "@rollup/rollup-win32-x64-gnu": "4.64.0", "@rollup/rollup-win32-x64-msvc": "4.64.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ=="], + "rollup": ["rollup@4.64.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.2", "@rollup/rollup-android-arm64": "4.64.2", "@rollup/rollup-darwin-arm64": "4.64.2", "@rollup/rollup-darwin-x64": "4.64.2", "@rollup/rollup-freebsd-arm64": "4.64.2", "@rollup/rollup-freebsd-x64": "4.64.2", "@rollup/rollup-linux-arm-gnueabihf": "4.64.2", "@rollup/rollup-linux-arm-musleabihf": "4.64.2", "@rollup/rollup-linux-arm64-gnu": "4.64.2", "@rollup/rollup-linux-arm64-musl": "4.64.2", "@rollup/rollup-linux-loong64-gnu": "4.64.2", "@rollup/rollup-linux-loong64-musl": "4.64.2", "@rollup/rollup-linux-ppc64-gnu": "4.64.2", "@rollup/rollup-linux-ppc64-musl": "4.64.2", "@rollup/rollup-linux-riscv64-gnu": "4.64.2", "@rollup/rollup-linux-riscv64-musl": "4.64.2", "@rollup/rollup-linux-s390x-gnu": "4.64.2", "@rollup/rollup-linux-x64-gnu": "4.64.2", "@rollup/rollup-linux-x64-musl": "4.64.2", "@rollup/rollup-openbsd-x64": "4.64.2", "@rollup/rollup-openharmony-arm64": "4.64.2", "@rollup/rollup-win32-arm64-msvc": "4.64.2", "@rollup/rollup-win32-ia32-msvc": "4.64.2", "@rollup/rollup-win32-x64-gnu": "4.64.2", "@rollup/rollup-win32-x64-msvc": "4.64.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RxXm0XVVV1hFI3dYe12ruaOgsYQ725lUcUWeMSQqkF9tTxVErY+fy2Ets6vWhItL9MZMSNZGbvZFc7rH/VHp2Q=="], "rollup-plugin-visualizer": ["rollup-plugin-visualizer@7.1.1", "", { "dependencies": { "open": "^11.0.0", "picomatch": "^4.0.5", "source-map": "^0.8.0", "yargs": "^18.1.0" }, "peerDependencies": { "rolldown": "1.x || ^1.0.0-beta || ^1.0.0-rc", "rollup": "2.x || 3.x || 4.x" }, "optionalPeers": ["rolldown", "rollup"], "bin": { "rollup-plugin-visualizer": "dist/bin/cli.js" } }, "sha512-ThaGiHTU8XW02OkK80TrTHATraJmM9OAduU4otal+7gyXLpYEtmGBLfx5kW+EHvvLwn03YGW2NnwKUIqsYlJAA=="], @@ -713,7 +713,7 @@ "unplugin-vue-components": ["unplugin-vue-components@32.1.0", "", { "dependencies": { "chokidar": "^5.0.0", "local-pkg": "^1.2.0", "magic-string": "^0.30.21", "mlly": "^1.8.2", "obug": "^2.1.1", "picomatch": "^4.0.4", "tinyglobby": "^0.2.16", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1" }, "peerDependencies": { "@nuxt/kit": "^3.2.2 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@nuxt/kit"] }, "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg=="], - "update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="], + "update-browserslist-db": ["update-browserslist-db@1.3.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], diff --git a/src/renderer/package.json b/src/renderer/package.json index 1fb220a4e..7b622cdb4 100644 --- a/src/renderer/package.json +++ b/src/renderer/package.json @@ -82,7 +82,7 @@ "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/vite": "^4.3.3", "@types/node": "^26.6.4", - "@types/plotly.js": "^3.0.14", + "@types/plotly.js": "^3.0.15", "@vitejs/plugin-vue": "^6.0.9", "@vue/tsconfig": "^0.9.1", "autoprefixer": "^10.6.1", diff --git a/src/renderer/src/common/initialisation.ts b/src/renderer/src/common/initialisation.ts index c60ddfb6a..658c0eeb3 100644 --- a/src/renderer/src/common/initialisation.ts +++ b/src/renderer/src/common/initialisation.ts @@ -26,7 +26,7 @@ const externalDependencies: ExternalDependency[] = [ }, { name: 'JSZip', - url: 'https://cdn.jsdelivr.net/npm/jszip@3.10.1/+esm', + url: 'https://cdn.jsdelivr.net/npm/jszip@3.10.2/+esm', set: (m) => dependencies.setJsZip(m) }, { @@ -36,7 +36,7 @@ const externalDependencies: ExternalDependency[] = [ }, { name: 'Plotly.js', - url: 'https://cdn.jsdelivr.net/npm/plotly.js-cartesian-dist-min@3.7.0/+esm', + url: 'https://cdn.jsdelivr.net/npm/plotly.js-cartesian-dist-min@4.1.2/+esm', set: (m) => dependencies.setPlotlyJs(m) } ]; From b2651c3a91100015f549aaf6866e33d6c3d218a2 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 9 Oct 2026 16:52:38 +1300 Subject: [PATCH 003/184] GHA: make sure that our actions can run on at least Node.js 24. --- .github/workflows/cd.yml | 24 +++++++++++------------- .github/workflows/cddev.yml | 4 +--- .github/workflows/ci.yml | 12 +++++++----- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index f053231ec..e6129448b 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -43,11 +43,13 @@ jobs: DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }} steps: - name: Set the timezone to New Zealand - uses: szenius/set-timezone@v2.0 - with: - timezoneWindows: 'New Zealand Standard Time' - timezoneLinux: 'Pacific/Auckland' - timezoneMacos: 'Pacific/Auckland' + shell: bash + run: | + case "${RUNNER_OS}" in + Windows) MSYS_NO_PATHCONV=1 tzutil /s "New Zealand Standard Time" ;; + Linux) sudo timedatectl set-timezone Pacific/Auckland ;; + macOS) sudo systemsetup -settimezone Pacific/Auckland ;; + esac - name: Check out OpenCOR uses: actions/checkout@v4 - name: Retrieve the EV certificate (Windows only) @@ -97,9 +99,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Set the timezone to New Zealand - uses: szenius/set-timezone@v2.0 - with: - timezoneLinux: 'Pacific/Auckland' + run: sudo timedatectl set-timezone Pacific/Auckland - name: Check out OpenCOR uses: actions/checkout@v4 - name: Install bun @@ -130,7 +130,7 @@ jobs: - name: Archive OpenCOR's Web app run: bun archive:web - name: Release OpenCOR's Web app - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: dist/OpenCOR-*-WebApp.tar.gz npm: @@ -138,13 +138,11 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Set the timezone to New Zealand - uses: szenius/set-timezone@v2.0 - with: - timezoneLinux: 'Pacific/Auckland' + run: sudo timedatectl set-timezone Pacific/Auckland - name: Check out OpenCOR uses: actions/checkout@v4 - name: Install Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 'lts/*' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/cddev.yml b/.github/workflows/cddev.yml index 140556155..28a0725b1 100644 --- a/.github/workflows/cddev.yml +++ b/.github/workflows/cddev.yml @@ -12,9 +12,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Set the timezone to New Zealand - uses: szenius/set-timezone@v2.0 - with: - timezoneLinux: 'Pacific/Auckland' + run: sudo timedatectl set-timezone Pacific/Auckland - name: Check out OpenCOR uses: actions/checkout@v4 - name: Install bun diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ecb12183..a6840fd58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,11 +33,13 @@ jobs: os: ubuntu-22.04 steps: - name: Set the timezone to New Zealand - uses: szenius/set-timezone@v2.0 - with: - timezoneWindows: 'New Zealand Standard Time' - timezoneLinux: 'Pacific/Auckland' - timezoneMacos: 'Pacific/Auckland' + shell: bash + run: | + case "${RUNNER_OS}" in + Windows) MSYS_NO_PATHCONV=1 tzutil /s "New Zealand Standard Time" ;; + Linux) sudo timedatectl set-timezone Pacific/Auckland ;; + macOS) sudo systemsetup -settimezone Pacific/Auckland ;; + esac - name: Check out OpenCOR uses: actions/checkout@v4 - name: Install CMake and Ninja From 8531977a086a73ad48b9a565980f8643663e9602 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 10:34:43 +1300 Subject: [PATCH 004/184] CORS proxy: reworked it by checking the content of a requested file - Only allow GET requests. - Return downloads with strict sandboxed/octet-stream headers. - Validate fetched content by inspecting bytes and only serving CellML, SED-ML, CSV, and OMEX files, rejecting other content with explicit 403 reasons and stripping risky forwarding headers. --- src/extern/corsProxy.js | 378 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 361 insertions(+), 17 deletions(-) diff --git a/src/extern/corsProxy.js b/src/extern/corsProxy.js index dcbf48116..9efab6957 100644 --- a/src/extern/corsProxy.js +++ b/src/extern/corsProxy.js @@ -1,33 +1,321 @@ // A simple CORS proxy handler for Cloudflare Workers (see https://workers.cloudflare.com/). +// Note: the proxy accepts any target URL, but it only returns content that is a CellML file, a SED-ML file, an OMEX file, +// or a CSV file (as expected by the app), and that is not too large (see MAX_SIZE). Such content is returned as +// an opaque, sandboxed download, so that a browser never renders or executes it. const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST', + 'Access-Control-Allow-Methods': 'GET', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Allow-Credentials': 'false' }; +const CONTENT_HEADERS = { + ...CORS_HEADERS, + 'Content-Type': 'application/octet-stream', + 'Content-Security-Policy': "sandbox; default-src 'none'", + 'X-Content-Type-Options': 'nosniff' +}; + +const SNIFF_SIZE = 64 * 1024; // 64 KB. +const MAX_SIZE = 32 * 1024 * 1024; // 32 MB. +// Note: an OMEX file needs to be fully read (see forwardRequest()), so MAX_SIZE must be small enough for at least two +// copies of it to fit in the memory available to a Cloudflare Worker (i.e. 128 MB). + +const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50; +const ZIP_CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50; +const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50; +const ZIP64_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06064b50; +const ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIGNATURE = 0x07064b50; + const extractTargetUrl = (url) => url.searchParams.get('url'); const isValidTargetUrl = (targetUrl) => { try { - const parsedUrl = new URL(targetUrl); - const allowedHosts = ['cellml.org', 'figshare.com', 'opencor.ws', 'physiomeproject.org']; - const allowedExtensions = ['.cellml', '.sedml', '.omex', '.csv']; + return ['http:', 'https:'].includes(new URL(targetUrl).protocol); + } catch { + return false; + } +}; + +const concatenate = (chunks, length) => { + const bytes = new Uint8Array(length); + let offset = 0; + + for (const chunk of chunks) { + bytes.set(chunk, offset); + + offset += chunk.length; + } + + return bytes; +}; + +const readBytes = async (reader, maxSize) => { + // Read up to (roughly) the given number of bytes and let the caller know whether the end of the stream was reached. + + const chunks = []; + let length = 0; + let done = false; + + while (length < maxSize) { + const result = await reader.read(); + + if (result.done) { + done = true; + + break; + } + + chunks.push(result.value); + + length += result.value.length; + } + + return { bytes: concatenate(chunks, length), done }; +}; + +const replayStream = (bytes, reader) => { + // Return a stream that first outputs the given bytes (i.e. those we already read to check the content) and then the + // rest of the given reader's data, unless there is too much data, in which case the stream errors. + + let size = bytes.length; + + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + }, + async pull(controller) { + const { done, value } = await reader.read(); + + if (done) { + controller.close(); + + return; + } + + size += value.length; + + if (size > MAX_SIZE) { + await reader.cancel(); + + controller.error(new Error('The target content is too large.')); + + return; + } + + controller.enqueue(value); + }, + cancel(reason) { + return reader.cancel(reason); + } + }); +}; + +const isZipFile = (bytes) => { + // Check that the given bytes are a ZIP file by looking for the local file header signature at the start of the file. + + return ( + bytes.length >= 4 && + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0, true) === + ZIP_LOCAL_FILE_HEADER_SIGNATURE + ); +}; + +const isOmexFile = (bytes) => { + // An OMEX file is a ZIP file with a manifest.xml file at its root, so look for it in the ZIP file's central + // directory. + + // Find the end of central directory record, which is at the end of the ZIP file, but may be followed by a comment of + // up to 65,535 bytes. + + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let endOfCentralDirectoryOffset = -1; + + for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 22 - 0xffff); --offset) { + if (view.getUint32(offset, true) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) { + endOfCentralDirectoryOffset = offset; + + break; + } + } + + if (endOfCentralDirectoryOffset === -1) { + return false; + } + + // Use the ZIP64 end of central directory record, if needed. + + let nbOfEntries = view.getUint16(endOfCentralDirectoryOffset + 10, true); + let offset = view.getUint32(endOfCentralDirectoryOffset + 16, true); + + if ( + (nbOfEntries === 0xffff || offset === 0xffffffff) && + endOfCentralDirectoryOffset >= 20 && + view.getUint32(endOfCentralDirectoryOffset - 20, true) === ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIGNATURE + ) { + const zip64EndOfCentralDirectoryOffset = Number(view.getBigUint64(endOfCentralDirectoryOffset - 12, true)); if ( - !allowedHosts.some((host) => parsedUrl.hostname.endsWith(host)) && - !allowedExtensions.some((extension) => parsedUrl.pathname.toLowerCase().endsWith(extension)) + zip64EndOfCentralDirectoryOffset + 56 > bytes.length || + view.getUint32(zip64EndOfCentralDirectoryOffset, true) !== ZIP64_END_OF_CENTRAL_DIRECTORY_SIGNATURE ) { return false; } - return ['http:', 'https:'].includes(parsedUrl.protocol); - } catch { + nbOfEntries = Number(view.getBigUint64(zip64EndOfCentralDirectoryOffset + 32, true)); + offset = Number(view.getBigUint64(zip64EndOfCentralDirectoryOffset + 48, true)); + } + + // Look for a central directory entry with the name "manifest.xml". + + const textDecoder = new TextDecoder(); + + for (let i = 0; i < nbOfEntries; ++i) { + if (offset + 46 > bytes.length || view.getUint32(offset, true) !== ZIP_CENTRAL_DIRECTORY_HEADER_SIGNATURE) { + return false; + } + + const fileNameLength = view.getUint16(offset + 28, true); + const extraFieldLength = view.getUint16(offset + 30, true); + const fileCommentLength = view.getUint16(offset + 32, true); + + if (offset + 46 + fileNameLength > bytes.length) { + return false; + } + + if (textDecoder.decode(bytes.subarray(offset + 46, offset + 46 + fileNameLength)) === 'manifest.xml') { + return true; + } + + offset += 46 + fileNameLength + extraFieldLength + fileCommentLength; + } + + return false; +}; + +const xmlRootElement = (text) => { + // Return the local name and namespace of the root element of the given XML text, skipping its prolog (i.e. its XML + // declaration, processing instructions, comments, and document type declaration). + + const skipPast = (string, marker, from = 0) => { + const index = string.indexOf(marker, from); + + return index === -1 ? null : string.slice(index + marker.length); + }; + + let rest = text; + + for (;;) { + rest = rest.trimStart(); + + if (rest.startsWith(''); + } else if (rest.startsWith(''); + } else if (rest.startsWith(''); + + rest = + internalSubsetStart !== -1 && internalSubsetStart < declarationEnd + ? skipPast(rest, '>', rest.indexOf(']', internalSubsetStart)) + : skipPast(rest, '>'); + } else { + break; + } + + if (rest === null) { + return null; + } + } + + const match = /^<(?:([\w.-]+):)?([\w.-]+)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*\/?>/.exec(rest); + + if (!match) { + return null; + } + + const [, prefix, name, attributes] = match; + const namespaceAttribute = prefix ? `xmlns:${prefix}` : 'xmlns'; + + for (const [, attribute, doubleQuotedValue, singleQuotedValue] of attributes.matchAll( + /([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g + )) { + if (attribute === namespaceAttribute) { + return { name, namespace: doubleQuotedValue ?? singleQuotedValue }; + } + } + + return { name, namespace: null }; +}; + +const isCellmlOrSedmlFile = (bytes) => { + const rootElement = xmlRootElement(new TextDecoder().decode(bytes)); + + if (!rootElement?.namespace) { return false; } + + return ( + (rootElement.name === 'model' && rootElement.namespace.startsWith('http://www.cellml.org/cellml/')) || + (rootElement.name === 'sedML' && rootElement.namespace.startsWith('http://sed-ml.org/')) + ); }; +const isCsvFile = (bytes, complete) => { + // Check that the given bytes are a CSV file as expected by the app (see parseExternalCsvData()), i.e. text (without + // control characters other than tabs and line breaks) where lines are separated by line breaks and values by commas, + // and where there are at least two lines (i.e. a header and some data) and every line has the same number of values, + // which must be at least two (i.e. a VOI column and a data column). Like the app, we decode the text as UTF-8 + // (replacing invalid byte sequences, like Response.text() does), trim lines, and skip blank lines. + // Note: we don't check that the CSV file can be used by the app since the app can tell the user why it cannot be used + // (see parseExternalCsvData()). If we only have the start of the CSV file, then we ignore its last (and likely + // incomplete) line. + + const text = new TextDecoder().decode(bytes, { stream: !complete }); + + // biome-ignore lint/suspicious/noControlCharactersInRegex: we are looking for control characters. + if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text)) { + return false; + } + + const lines = text.split(/\r?\n/); + + if (!complete) { + lines.pop(); + } + + let nbOfLines = 0; + let nbOfValuesPerLine = -1; + + for (const line of lines) { + const trimmedLine = line.trim(); + + if (!trimmedLine) { + continue; + } + + const nbOfValues = trimmedLine.split(',').length; + + if (nbOfValues < 2 || (nbOfValuesPerLine !== -1 && nbOfValues !== nbOfValuesPerLine)) { + return false; + } + + nbOfValuesPerLine = nbOfValues; + + ++nbOfLines; + } + + return nbOfLines >= 2; +}; + +const rejectedResponse = (reason) => + new Response(`Target content is not allowed (${reason}).`, { + status: 403, + headers: CONTENT_HEADERS + }); + const forwardRequest = async (targetUrl, request) => { const forwardHeaders = new Headers(request.headers); @@ -35,9 +323,11 @@ const forwardRequest = async (targetUrl, request) => { 'authorization', 'cookie', 'connection', + 'if-range', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', + 'range', 'te', 'trailers', 'transfer-encoding', @@ -47,20 +337,74 @@ const forwardRequest = async (targetUrl, request) => { } const response = await fetch(targetUrl, { - method: request.method, - headers: forwardHeaders, - body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : null + method: 'GET', + headers: forwardHeaders }); - const newHeaders = new Headers(response.headers); + // Note: we don't return the body of an error response since it is likely to be an HTML page. + + if (!response.ok || !response.body) { + return response.ok + ? rejectedResponse('no content') + : new Response(null, { + status: response.status, + headers: CONTENT_HEADERS + }); + } + + // Reject content that we know is too large. + // Note: the content length may not be known or, if the content is compressed, it may be smaller than the actual size + // of the content, so we also check the size of the content as we read it. + + if (Number(response.headers.get('content-length')) > MAX_SIZE) { + await response.body.cancel(); + + return rejectedResponse('too large'); + } + + // A CellML, SED-ML, or CSV file only needs its start to be checked, after which it can be streamed. + + const reader = response.body.getReader(); + const { bytes: head, done } = await readBytes(reader, SNIFF_SIZE); + + if (isCellmlOrSedmlFile(head) || isCsvFile(head, done)) { + return new Response(done ? head : replayStream(head, reader), { + status: response.status, + headers: CONTENT_HEADERS + }); + } + + // An OMEX file needs to be fully read since we need its central directory, which is at the end of the file. + + if (!isZipFile(head)) { + await reader.cancel(); + + return rejectedResponse('not a CellML, SED-ML, OMEX, or CSV file'); + } + + let bytes = head; + + if (!done) { + // Note: we read (up to) one more byte than allowed, so that we know whether the OMEX file is too large. + + const rest = await readBytes(reader, MAX_SIZE - head.length + 1); + + if (head.length + rest.bytes.length > MAX_SIZE) { + await reader.cancel(); + + return rejectedResponse('too large'); + } + + bytes = concatenate([head, rest.bytes], head.length + rest.bytes.length); + } - for (const [name, value] of Object.entries(CORS_HEADERS)) { - newHeaders.set(name, value); + if (!isOmexFile(bytes)) { + return rejectedResponse('OMEX file without a manifest.xml file'); } - return new Response(response.body, { + return new Response(bytes, { status: response.status, - headers: newHeaders + headers: CONTENT_HEADERS }); }; @@ -86,7 +430,7 @@ export default { return new Response('Target URL is not allowed.', { status: 403 }); } - if (!['GET', 'POST'].includes(request.method)) { + if (request.method !== 'GET') { return new Response('Method not allowed.', { status: 405 }); } From 912ebdab7d9504c0d4721250cec0ff57416cc5aa Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 18:52:38 +1300 Subject: [PATCH 005/184] Added CORS proxy fallback helper for URL fetches. --- src/renderer/src/common/common.ts | 18 ++++++++++++++++++ ...ExperimentInteractiveViewSettingsDialog.vue | 4 +--- .../SimulationExperimentInteractiveView.vue | 2 +- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/common/common.ts b/src/renderer/src/common/common.ts index e33b6aa2d..9e2713e50 100644 --- a/src/renderer/src/common/common.ts +++ b/src/renderer/src/common/common.ts @@ -97,6 +97,24 @@ export const corsProxyUrl = (url: string): string => { return `https://cors-proxy.opencor.workers.dev/?url=${url}`; }; +// A method to fetch a URL through our CORS proxy or, if that fails, directly. +// Note: our CORS proxy may not be reachable, not allow the content of the URL (HTTP 403 error), or not find the URL +// (HTTP 404 error), in which case fetching the URL directly may still work (see also locCommon.file()). + +export const fetchThroughCorsProxy = async (url: string): Promise => { + try { + const response = await fetch(corsProxyUrl(url)); + + if (response.status !== 403 && response.status !== 404) { + return response; + } + } catch { + // Our CORS proxy is not reachable, so fetch the URL directly. + } + + return fetch(url); +}; + // A method to compute the XXH64 value of some data. export const xxh64 = (data: string | Uint8Array): string => { diff --git a/src/renderer/src/components/dialogs/SimulationExperimentInteractiveViewSettingsDialog.vue b/src/renderer/src/components/dialogs/SimulationExperimentInteractiveViewSettingsDialog.vue index 5162d519d..91315a971 100644 --- a/src/renderer/src/components/dialogs/SimulationExperimentInteractiveViewSettingsDialog.vue +++ b/src/renderer/src/components/dialogs/SimulationExperimentInteractiveViewSettingsDialog.vue @@ -1315,9 +1315,7 @@ const importExternalDataFromUrl = async (): Promise => { const url = externalDataUrl.value.trim(); try { - const response = await fetch(common.corsProxyUrl(url)).catch(() => { - return fetch(url); - }); + const response = await common.fetchThroughCorsProxy(url); if (!response.ok) { throw new Error(`Could not retrieve CSV file from URL (${response.status}: ${response.statusText}).`); diff --git a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue index b6fb42ac6..922901d78 100644 --- a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue +++ b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue @@ -533,7 +533,7 @@ const addExternalData = async ( if (common.isUrl(csv)) { try { - const response = await fetch(common.corsProxyUrl(csv)); + const response = await common.fetchThroughCorsProxy(csv); if (!response.ok) { return Promise.resolve( From 7719afd9e0ce897efd8ade8e7bd2e2f4b3165feb Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 7 Oct 2026 13:55:55 +1300 Subject: [PATCH 006/184] SED-ML: handle non-CVODE based simulations. Note: at this stage, the interactive Simulation Experiment view only supports CVODE-based simulations. So, if a SED-ML file references another solver then OpenCOR won't crash (as it did before), but the interactive view won't be usable. --- src/preload/index.ts | 1 + .../SimulationExperimentInteractiveView.vue | 8 ++-- src/renderer/src/libopencor/locApi.ts | 1 + src/renderer/src/libopencor/locSedApi.ts | 34 +++++++++++++---- src/renderer/src/libopencor/src/main.cpp | 1 + src/renderer/src/libopencor/src/sed.cpp | 36 ++++++++++++++---- src/renderer/src/libopencor/src/sed.h | 1 + tests/models/lorenz_euler.omex | Bin 0 -> 2355 bytes 8 files changed, 63 insertions(+), 19 deletions(-) create mode 100644 tests/models/lorenz_euler.omex diff --git a/src/preload/index.ts b/src/preload/index.ts index a0e574ff4..42abc0131 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -263,6 +263,7 @@ electron.contextBridge.exposeInMainWorld('locApi', { // SolverCvode API. // TODO: this is only temporary until we have full support for our different solvers. + solverCvodeExists: (documentId: number, index: number) => loc.solverCvodeExists(documentId, index), solverCvodeMaximumStep: (documentId: number, index: number) => loc.solverCvodeMaximumStep(documentId, index), solverCvodeSetMaximumStep: (documentId: number, index: number, value: number) => loc.solverCvodeSetMaximumStep(documentId, index, value), diff --git a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue index 922901d78..d7b468d61 100644 --- a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue +++ b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue @@ -1736,7 +1736,7 @@ const onDownloadCombineArchive = (): void => { // Settings dialog event handler. const onSettingsOk = (updatedSettings: ISimulationExperimentInteractiveViewSettingsDialog): void => { - if (!uniformTimeCourse || !cvode) { + if (!uniformTimeCourse) { settingsVisible.value = false; return; @@ -1759,7 +1759,7 @@ const onSettingsOk = (updatedSettings: ISimulationExperimentInteractiveViewSetti // Update our settings and hide the dialog. - const oldCvodeMaximumStep = cvode.maximumStep(); + const oldCvodeMaximumStep = cvode?.maximumStep(); uniformTimeCourse.setInitialTime(updatedSettings.simulation.initialPoint); uniformTimeCourse.setOutputStartTime(updatedSettings.simulation.startingPoint); @@ -1774,7 +1774,7 @@ const onSettingsOk = (updatedSettings: ISimulationExperimentInteractiveViewSetti ) ); - cvode.setMaximumStep(updatedSettings.solvers.cvodeMaximumStep); + cvode?.setMaximumStep(updatedSettings.solvers.cvodeMaximumStep); actualUiJson.value = locApi.cleanUiJson(updatedSettings.interactive.uiJson); settingsVisible.value = false; @@ -1792,7 +1792,7 @@ const onSettingsOk = (updatedSettings: ISimulationExperimentInteractiveViewSetti // Reinstantiate our instance in case we modified CVODE's maximum step. - if (cvode.maximumStep() !== oldCvodeMaximumStep) { + if (cvode && cvode.maximumStep() !== oldCvodeMaximumStep) { reinstantiateInstance(); } diff --git a/src/renderer/src/libopencor/locApi.ts b/src/renderer/src/libopencor/locApi.ts index 36f838fc2..3169e25e7 100644 --- a/src/renderer/src/libopencor/locApi.ts +++ b/src/renderer/src/libopencor/locApi.ts @@ -57,6 +57,7 @@ export interface ICppLocApi { // SolverCvode API. // TODO: this is only temporary until we have full support for our different solvers. + solverCvodeExists: (documentId: number, index: number) => boolean; solverCvodeMaximumStep: (documentId: number, index: number) => number; solverCvodeSetMaximumStep: (documentId: number, index: number, value: number) => void; diff --git a/src/renderer/src/libopencor/locSedApi.ts b/src/renderer/src/libopencor/locSedApi.ts index ed324c157..257aef60b 100644 --- a/src/renderer/src/libopencor/locSedApi.ts +++ b/src/renderer/src/libopencor/locSedApi.ts @@ -383,7 +383,7 @@ export class SedOneStep extends SedSimulation { export class SedUniformTimeCourse extends SedSimulation { private _wasmSedUniformTimeCourse: IWasmSedUniformTimeCourse = {} as IWasmSedUniformTimeCourse; - private _cvode: SolverCvode | null = null; + private _cvode: SolverCvode | null | undefined; constructor(cppDocumentId: number, wasmSedDocument: IWasmSedDocument, index: number, type: ESedSimulationType) { super(cppDocumentId, wasmSedDocument, index, type); @@ -396,7 +396,7 @@ export class SedUniformTimeCourse extends SedSimulation { override release(): void { this._cvode?.release(); - this._cvode = null; + this._cvode = undefined; if (wasmVersion()) { this._wasmSedUniformTimeCourse?.delete(); @@ -459,10 +459,28 @@ export class SedUniformTimeCourse extends SedSimulation { } } - cvode(): SolverCvode { - // Note: we cache our solver so that we can release it when releasing the simulation. + cvode(): SolverCvode | null { + // Note: we cache our solver so that we can release it when releasing the simulation. Also, the simulation's ODE + // solver is not necessarily CVODE (e.g., it could be Forward Euler or not set at all), in which case we + // return null. - this._cvode ??= new SolverCvode(this._cppDocumentId, this._wasmSedUniformTimeCourse, this._index); + if (this._cvode === undefined) { + if (cppVersion()) { + this._cvode = _cppLocApi.solverCvodeExists(this._cppDocumentId, this._index) + ? new SolverCvode(this._cppDocumentId, null, this._index) + : null; + } else { + const wasmOdeSolver = this._wasmSedUniformTimeCourse.odeSolver; + + if (wasmOdeSolver instanceof _wasmLocApi.SolverCvode) { + this._cvode = new SolverCvode(this._cppDocumentId, wasmOdeSolver as IWasmSolverCvode, this._index); + } else { + wasmOdeSolver?.delete(); + + this._cvode = null; + } + } + } return this._cvode; } @@ -472,13 +490,13 @@ export class SolverCvode extends SedIndex { private _cppDocumentId: number; private _wasmSolverCvode: IWasmSolverCvode = {} as IWasmSolverCvode; - constructor(cppDocumentId: number, wasmSedUniformTimeCourse: IWasmSedUniformTimeCourse, index: number) { + constructor(cppDocumentId: number, wasmSolverCvode: IWasmSolverCvode | null, index: number) { super(index); this._cppDocumentId = cppDocumentId; - if (wasmVersion()) { - this._wasmSolverCvode = wasmSedUniformTimeCourse.odeSolver as IWasmSolverCvode; + if (wasmSolverCvode) { + this._wasmSolverCvode = wasmSolverCvode; } } diff --git a/src/renderer/src/libopencor/src/main.cpp b/src/renderer/src/libopencor/src/main.cpp index b594710e8..bd6515877 100644 --- a/src/renderer/src/libopencor/src/main.cpp +++ b/src/renderer/src/libopencor/src/main.cpp @@ -57,6 +57,7 @@ Napi::Object init(Napi::Env pEnv, Napi::Object pExports) // SolverCvode API. // TODO: this is only temporary until we have full support for our different solvers. + pExports.Set(Napi::String::New(pEnv, "solverCvodeExists"), Napi::Function::New(pEnv, solverCvodeExists)); pExports.Set(Napi::String::New(pEnv, "solverCvodeMaximumStep"), Napi::Function::New(pEnv, solverCvodeMaximumStep)); pExports.Set(Napi::String::New(pEnv, "solverCvodeSetMaximumStep"), Napi::Function::New(pEnv, solverCvodeSetMaximumStep)); diff --git a/src/renderer/src/libopencor/src/sed.cpp b/src/renderer/src/libopencor/src/sed.cpp index cce8bb70e..f0361f837 100644 --- a/src/renderer/src/libopencor/src/sed.cpp +++ b/src/renderer/src/libopencor/src/sed.cpp @@ -195,22 +195,44 @@ void sedUniformTimeCourseSetNumberOfSteps(const Napi::CallbackInfo &pInfo) // SolverCvode API. // TODO: this is only temporary until we have full support for our different solvers. -napi_value solverCvodeMaximumStep(const Napi::CallbackInfo &pInfo) +namespace { + +libOpenCOR::SolverCvodePtr solverCvode(const Napi::CallbackInfo &pInfo) { + // Note: the simulation's ODE solver is not necessarily CVODE (e.g., it could be Forward Euler or not set at all), + // hence we return nullptr in that case. + auto sedDocument = toSedDocument(toSizeT(pInfo[0])); - auto simulation = sedDocument->simulation(toInt32(pInfo[1])); - auto solver = std::dynamic_pointer_cast(simulation->odeSolver()); + auto simulation = (sedDocument != nullptr) ? sedDocument->simulation(toInt32(pInfo[1])) : nullptr; + + return (simulation != nullptr) ? std::dynamic_pointer_cast(simulation->odeSolver()) : nullptr; +} + +} // namespace + +napi_value solverCvodeExists(const Napi::CallbackInfo &pInfo) +{ + return Napi::Boolean::New(pInfo.Env(), solverCvode(pInfo) != nullptr); +} + +napi_value solverCvodeMaximumStep(const Napi::CallbackInfo &pInfo) +{ + auto solver = solverCvode(pInfo); + + if (solver == nullptr) { + return pInfo.Env().Undefined(); + } return Napi::Number::New(pInfo.Env(), solver->maximumStep()); } void solverCvodeSetMaximumStep(const Napi::CallbackInfo &pInfo) { - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); - auto simulation = sedDocument->simulation(toInt32(pInfo[1])); - auto solver = std::dynamic_pointer_cast(simulation->odeSolver()); + auto solver = solverCvode(pInfo); - solver->setMaximumStep(toDouble(pInfo[2])); + if (solver != nullptr) { + solver->setMaximumStep(toDouble(pInfo[2])); + } } // SedInstance API. diff --git a/src/renderer/src/libopencor/src/sed.h b/src/renderer/src/libopencor/src/sed.h index 82e3e9dda..32ee89ce0 100644 --- a/src/renderer/src/libopencor/src/sed.h +++ b/src/renderer/src/libopencor/src/sed.h @@ -37,6 +37,7 @@ void sedUniformTimeCourseSetNumberOfSteps(const Napi::CallbackInfo &pInfo); // SolverCvode API. // TODO: this is only temporary until we have full support for our different solvers. +napi_value solverCvodeExists(const Napi::CallbackInfo &pInfo); napi_value solverCvodeMaximumStep(const Napi::CallbackInfo &pInfo); void solverCvodeSetMaximumStep(const Napi::CallbackInfo &pInfo); diff --git a/tests/models/lorenz_euler.omex b/tests/models/lorenz_euler.omex new file mode 100644 index 0000000000000000000000000000000000000000..73dd4ad89316b50e3f31b48069c295e4ec5e8d3b GIT binary patch literal 2355 zcmZ{mc{mj67sqD|VUTUOiA0)E8EZreVJu@GOU)3*NHXMxESDi>7}*;C#J_x3!$_x$mF&-*-|KhE=>=X{SD0>lgf0NB_7Xm>q3cCT^e zMgRcN3j_c-kJR1K!}Yeamp9DM9rrAuG?p&Mskw{SO@t!(M@;f^aX8!tux5~`9~pxQ zo33YA{MsxdNeQ4GObV;7h(ZgmSeg(fajUITjrvG>xk}z#{dc)X&}oQ*xZf7D@~cwe z7maf~a$SvCX`s>NsJ88a8YQ4%8j_4EaQP)a5X#(XLvlYAMiYGXruT_GQS{2tZ4#mX zPSPizg^HoOxu{U;7q{JF6(q|+B`31ojh}1nms)1Q7q<|voA$AUr^71tH1`aYVc7mY zsdOJ`_K!+0YG3!uVF3Uflzvo-yLaE&BLL>*jCIGYqR{uol_B``VxB=(jZ3r@L1U&- zyLg;-DYJB%$A}z+MWzqT`$$QuqjYoGSH~cln!f7XJ!^fOhcvIMJ{-4@*K$|_POUhr zkQHirth>+Cfp?8PW79!l@32$$UM%bB+~Nt+p3M_M2?YIIrIRq%Z#Z-bDDK=&kwp}e zpt)(>7E9pV)T300Bo;!zd7_RNvG8>UgCUjPUpp$n6me^Oz(z387B1m)wSn)Nqq%ZU zUb8nni_!AlwDOG{H%25r@)QCOHX*FgQaX!7Rj0x$1tM<*h_w>V9*9?YWKW0`jCa~q zXF9$s33ceew><48ayInFKW%R1?YpK67c7ITT0zP!v6wKB`f(SXTRj@=9Rie%4?Nc@ zp_mHNP|rjU^HgfSFfr#m?X6(#=w*Ee zPv?u!(R%i|Y*{%POD?T?Yc>bHEpLEkxiq4SKKUS_^oF5QfpfrXOvUE>ulS}uyBdeb z7ul>^Oc#BMI`}K0na*NNO;?#fe7remN6ZBw^4%Rpjtpc{_`AVsHaL!?YklQY0Y0}TSoxn>fgCg`Z^kckOv`HuM!{=T5?Z$g_KN=-3SS5VI zX(K>8imCjK_TUzg#<&#eqNInG5AU=Giq5fE@7d5JTjF!eT=4!{ugXwlpL*4y{_qLOhpBx5!^D7b{Qrov~#zss3YM@_1W1=XIlR({81WEYGv%L5%Y}(ROVz%L}vCvif(UT z#kU<9wL{%g$6vK)vJA|W?2GB_r839Z%fqF#-Orxb-UZG$9j2{AH7}F%qIoqmB(}7w zyw?W(Qq+>*o0mr|9>1hDt77WM+}0sz59V(IbLz>Rt?1+@u%)PQe7~jWAIm+FMd`-e z3KMySBCt)4uRJ+6_e&zl?)&7c9zMO;COj# zv;<9Ts`9JSU+6imsbDA+3l|1DL+ZAj@E|G;JSGw%44pA5DaxMryfSsIP3q0Oe48(3 zfV3`}Wj)S5=7t*b$`hW33%DDiu8f?9s<{ICLp;vO4$T3gU0eH1wBWLlp*+D0l)zab zDSgL+JTZZX&qHZoMhOHfX}ch<&VKJIOa8?j2qjN!xt~=}3vQYzmfZ!RFy2bcwKti? z>mcKYW-Q`oQ`V(P=F8G;$G{4B14xU8zsduX=fqG*ZUESVcS%~maGD#T^?>dyN+)l; zN5*$xOtk{QWw0Pm2=)L1R+u!>`usi8NnP~>c++zULb<%~8yGWq#Ab;R6ThufA!3Z7 zk6(v0{yENzH*~R&j!a7p3;=K)8P~tZxsx*v=Z^be-RVJ>l~}{P%vW2`m%Mc2kxvX(ydxOJcKMV^5+{idxQB0eQ|sVNrBBGB3klEHG}uae^drTKs+hp{*vW zt+5~xcR`;uXE?#?{DXohlg-$k2m!Q35W*&O=~=VOPKga61fVUf}Yf_qex;Tr& z9oE8ct_zzXfJ^|;|KB%`e(ql<) literal 0 HcmV?d00001 From ced4953bcda01ba9bd79b8317f961a1c01e0f8b5 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 7 Oct 2026 16:02:46 +1300 Subject: [PATCH 007/184] Hardened native libOpenCOR error handling. --- CMakeLists.txt | 6 + src/preload/index.ts | 1 + src/renderer/src/libopencor/locApi.ts | 1 + src/renderer/src/libopencor/locFileApi.ts | 2 +- src/renderer/src/libopencor/src/common.cpp | 35 +++- src/renderer/src/libopencor/src/common.h | 4 +- src/renderer/src/libopencor/src/file.cpp | 5 + src/renderer/src/libopencor/src/file.h | 1 + src/renderer/src/libopencor/src/main.cpp | 1 + src/renderer/src/libopencor/src/sed.cpp | 200 ++++++++++----------- 10 files changed, 146 insertions(+), 110 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 540620f27..f841208fe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -139,6 +139,12 @@ add_library(${CMAKE_PROJECT_NAME} SHARED ${CMAKE_JS_SRC} ${CPP_FILES}) +# Convert any C++ exception (and not just Napi::Error) into a JavaScript exception rather than have it terminate the +# renderer process. + +target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE + NODE_ADDON_API_CPP_EXCEPTIONS_ALL) + # Don't have a "lib" prefix and use a ".node" suffix. set_target_properties(${CMAKE_PROJECT_NAME} PROPERTIES diff --git a/src/preload/index.ts b/src/preload/index.ts index 42abc0131..ee25db654 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -213,6 +213,7 @@ electron.contextBridge.exposeInMainWorld('locApi', { // FileManager API. + fileManagerHasFile: (path: string) => loc.fileManagerHasFile(path), fileManagerUnmanage: (path: string) => loc.fileManagerUnmanage(path), // File API. diff --git a/src/renderer/src/libopencor/locApi.ts b/src/renderer/src/libopencor/locApi.ts index 3169e25e7..7e2ce11d4 100644 --- a/src/renderer/src/libopencor/locApi.ts +++ b/src/renderer/src/libopencor/locApi.ts @@ -6,6 +6,7 @@ import type { IIssue } from './locLoggerApi'; export interface ICppLocApi { // FileManager API. + fileManagerHasFile: (path: string) => boolean; fileManagerUnmanage: (path: string) => void; // File API. diff --git a/src/renderer/src/libopencor/locFileApi.ts b/src/renderer/src/libopencor/locFileApi.ts index 9a260d7bd..9f4bfcd2d 100644 --- a/src/renderer/src/libopencor/locFileApi.ts +++ b/src/renderer/src/libopencor/locFileApi.ts @@ -39,7 +39,7 @@ class FileManager { // Note: the file we are after is already managed, so we just wrap it rather than create (and therefore own) it. if (cppVersion()) { - return _cppLocApi.fileContents(path) ? new File(path, undefined, {}) : null; + return _cppLocApi.fileManagerHasFile(path) ? new File(path, undefined, {}) : null; } // Note: every access to a WASM vector (and to its elements) yields a new handle that must be deleted, except for diff --git a/src/renderer/src/libopencor/src/common.cpp b/src/renderer/src/libopencor/src/common.cpp index ec28702f5..2556915e0 100644 --- a/src/renderer/src/libopencor/src/common.cpp +++ b/src/renderer/src/libopencor/src/common.cpp @@ -5,26 +5,47 @@ std::map files; std::map sedDocuments; std::map sedInstances; +// Note: the following functions throw a JavaScript exception (rather than return nullptr) if the file, SED-ML document, +// or SED-ML instance is unknown (e.g., it has been released). This way, an invalid call results in a JavaScript +// error rather than in a crash of the renderer process. + libOpenCOR::FilePtr toFile(const Napi::Value &pValue) { - return fileManager.file(pValue.ToString().Utf8Value()); + auto filePath = pValue.ToString().Utf8Value(); + auto file = fileManager.file(filePath); + + if (file == nullptr) { + throw Napi::Error::New(pValue.Env(), "Unknown file: " + filePath + "."); + } + + return file; } // Note: we use find() rather than operator[] since the latter would (re)insert an entry for an unknown (e.g., released) // ID. -libOpenCOR::SedDocumentPtr toSedDocument(size_t pId) +libOpenCOR::SedDocumentPtr toSedDocument(const Napi::Value &pValue) { - auto iter = sedDocuments.find(pId); + auto id = toSizeT(pValue); + auto iter = sedDocuments.find(id); - return (iter != sedDocuments.end()) ? iter->second : nullptr; + if (iter == sedDocuments.end()) { + throw Napi::Error::New(pValue.Env(), "Unknown SED-ML document: " + std::to_string(id) + "."); + } + + return iter->second; } -libOpenCOR::SedInstancePtr toSedInstance(size_t pId) +libOpenCOR::SedInstancePtr toSedInstance(const Napi::Value &pValue) { - auto iter = sedInstances.find(pId); + auto id = toSizeT(pValue); + auto iter = sedInstances.find(id); + + if (iter == sedInstances.end()) { + throw Napi::Error::New(pValue.Env(), "Unknown SED-ML instance: " + std::to_string(id) + "."); + } - return (iter != sedInstances.end()) ? iter->second : nullptr; + return iter->second; } size_t toSizeT(const Napi::Value &pValue) diff --git a/src/renderer/src/libopencor/src/common.h b/src/renderer/src/libopencor/src/common.h index 8d1693e65..9d78e858a 100644 --- a/src/renderer/src/libopencor/src/common.h +++ b/src/renderer/src/libopencor/src/common.h @@ -12,8 +12,8 @@ extern std::map sedDocuments; extern std::map sedInstances; libOpenCOR::FilePtr toFile(const Napi::Value &pValue); -libOpenCOR::SedDocumentPtr toSedDocument(size_t pId); -libOpenCOR::SedInstancePtr toSedInstance(size_t pId); +libOpenCOR::SedDocumentPtr toSedDocument(const Napi::Value &pValue); +libOpenCOR::SedInstancePtr toSedInstance(const Napi::Value &pValue); size_t toSizeT(const Napi::Value &pValue); int32_t toInt32(const Napi::Value &pValue); double toDouble(const Napi::Value &pValue); diff --git a/src/renderer/src/libopencor/src/file.cpp b/src/renderer/src/libopencor/src/file.cpp index c0f8a01bb..dbec52976 100644 --- a/src/renderer/src/libopencor/src/file.cpp +++ b/src/renderer/src/libopencor/src/file.cpp @@ -5,6 +5,11 @@ // FileManager API. +napi_value fileManagerHasFile(const Napi::CallbackInfo &pInfo) +{ + return Napi::Boolean::New(pInfo.Env(), fileManager.file(pInfo[0].ToString().Utf8Value()) != nullptr); +} + void fileManagerUnmanage(const Napi::CallbackInfo &pInfo) { auto filePath = pInfo[0].ToString().Utf8Value(); diff --git a/src/renderer/src/libopencor/src/file.h b/src/renderer/src/libopencor/src/file.h index da8372e8d..03ac899f1 100644 --- a/src/renderer/src/libopencor/src/file.h +++ b/src/renderer/src/libopencor/src/file.h @@ -4,6 +4,7 @@ // FileManager API. +napi_value fileManagerHasFile(const Napi::CallbackInfo &pInfo); void fileManagerUnmanage(const Napi::CallbackInfo &pInfo); // File API. diff --git a/src/renderer/src/libopencor/src/main.cpp b/src/renderer/src/libopencor/src/main.cpp index bd6515877..c52926045 100644 --- a/src/renderer/src/libopencor/src/main.cpp +++ b/src/renderer/src/libopencor/src/main.cpp @@ -12,6 +12,7 @@ Napi::Object init(Napi::Env pEnv, Napi::Object pExports) // FileManager API. + pExports.Set(Napi::String::New(pEnv, "fileManagerHasFile"), Napi::Function::New(pEnv, fileManagerHasFile)); pExports.Set(Napi::String::New(pEnv, "fileManagerUnmanage"), Napi::Function::New(pEnv, fileManagerUnmanage)); // File API. diff --git a/src/renderer/src/libopencor/src/sed.cpp b/src/renderer/src/libopencor/src/sed.cpp index f0361f837..e3e9ffc1a 100644 --- a/src/renderer/src/libopencor/src/sed.cpp +++ b/src/renderer/src/libopencor/src/sed.cpp @@ -3,6 +3,48 @@ #include +namespace { + +// Note: the following functions throw a JavaScript exception (rather than return nullptr) if the SED-ML model, SED-ML +// simulation, or SED-ML instance task is unknown. This way, an invalid call results in a JavaScript error rather +// than in a crash of the renderer process. + +libOpenCOR::SedModelPtr toSedModel(const Napi::CallbackInfo &pInfo) +{ + auto model = toSedDocument(pInfo[0])->model(toSizeT(pInfo[1])); + + if (model == nullptr) { + throw Napi::Error::New(pInfo.Env(), "Unknown SED-ML model."); + } + + return model; +} + +template +std::shared_ptr toSedSimulation(const Napi::CallbackInfo &pInfo) +{ + auto simulation = std::dynamic_pointer_cast(toSedDocument(pInfo[0])->simulation(toSizeT(pInfo[1]))); + + if (simulation == nullptr) { + throw Napi::Error::New(pInfo.Env(), "Unknown or unexpected SED-ML simulation."); + } + + return simulation; +} + +libOpenCOR::SedInstanceTaskPtr toSedInstanceTask(const Napi::CallbackInfo &pInfo) +{ + auto task = toSedInstance(pInfo[0])->task(toSizeT(pInfo[1])); + + if (task == nullptr) { + throw Napi::Error::New(pInfo.Env(), "Unknown SED-ML instance task."); + } + + return task; +} + +} // namespace + // SedDocument API. napi_value sedDocumentCreate(const Napi::CallbackInfo &pInfo) @@ -23,7 +65,7 @@ napi_value sedDocumentInstantiate(const Napi::CallbackInfo &pInfo) static size_t instanceId {std::numeric_limits::max()}; auto id = ++instanceId; - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); + auto sedDocument = toSedDocument(pInfo[0]); auto sedInstance = sedDocument->instantiate(); sedInstances[id] = sedInstance; @@ -38,30 +80,29 @@ void sedDocumentRelease(const Napi::CallbackInfo &pInfo) napi_value sedDocumentIssues(const Napi::CallbackInfo &pInfo) { - return issues(pInfo, toSedDocument(toSizeT(pInfo[0]))->issues()); + return issues(pInfo, toSedDocument(pInfo[0])->issues()); } napi_value sedDocumentModelCount(const Napi::CallbackInfo &pInfo) { - return Napi::Number::New(pInfo.Env(), toSedDocument(toSizeT(pInfo[0]))->modelCount()); + return Napi::Number::New(pInfo.Env(), toSedDocument(pInfo[0])->modelCount()); } napi_value sedDocumentSimulationCount(const Napi::CallbackInfo &pInfo) { - return Napi::Number::New(pInfo.Env(), toSedDocument(toSizeT(pInfo[0]))->simulationCount()); + return Napi::Number::New(pInfo.Env(), toSedDocument(pInfo[0])->simulationCount()); } napi_value sedDocumentSerialise(const Napi::CallbackInfo &pInfo) { - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); + auto sedDocument = toSedDocument(pInfo[0]); return Napi::String::New(pInfo.Env(), sedDocument->serialise()); } napi_value sedDocumentSimulationType(const Napi::CallbackInfo &pInfo) { - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); - auto simulation = sedDocument->simulation(toInt32(pInfo[1])); + auto simulation = toSedSimulation(pInfo); if (std::dynamic_pointer_cast(simulation) != nullptr) { return Napi::Number::New(pInfo.Env(), 0); @@ -82,16 +123,18 @@ napi_value sedDocumentSimulationType(const Napi::CallbackInfo &pInfo) napi_value sedModelFilePath(const Napi::CallbackInfo &pInfo) { - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); - auto model = sedDocument->model(toInt32(pInfo[1])); + auto file = toSedModel(pInfo)->file(); + + if (file == nullptr) { + throw Napi::Error::New(pInfo.Env(), "The SED-ML model has no file."); + } - return Napi::String::New(pInfo.Env(), model->file()->path()); + return Napi::String::New(pInfo.Env(), file->path()); } void sedModelAddChange(const Napi::CallbackInfo &pInfo) { - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); - auto model = sedDocument->model(toInt32(pInfo[1])); + auto model = toSedModel(pInfo); auto changeAttribute = libOpenCOR::SedChangeAttribute::create(toString(pInfo[2]), toString(pInfo[3]), toString(pInfo[4])); @@ -101,8 +144,7 @@ void sedModelAddChange(const Napi::CallbackInfo &pInfo) void sedModelRemoveAllChanges(const Napi::CallbackInfo &pInfo) { - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); - auto model = sedDocument->model(toInt32(pInfo[1])); + auto model = toSedModel(pInfo); model->removeAllChanges(); } @@ -111,9 +153,7 @@ void sedModelRemoveAllChanges(const Napi::CallbackInfo &pInfo) napi_value sedOneStepStep(const Napi::CallbackInfo &pInfo) { - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); - auto simulation = sedDocument->simulation(toInt32(pInfo[1])); - auto oneStep = std::dynamic_pointer_cast(simulation); + auto oneStep = toSedSimulation(pInfo); return Napi::Number::New(pInfo.Env(), oneStep->step()); } @@ -122,72 +162,56 @@ napi_value sedOneStepStep(const Napi::CallbackInfo &pInfo) napi_value sedUniformTimeCourseInitialTime(const Napi::CallbackInfo &pInfo) { - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); - auto simulation = sedDocument->simulation(toInt32(pInfo[1])); - auto uniformTimeCourse = std::dynamic_pointer_cast(simulation); + auto uniformTimeCourse = toSedSimulation(pInfo); return Napi::Number::New(pInfo.Env(), uniformTimeCourse->initialTime()); } void sedUniformTimeCourseSetInitialTime(const Napi::CallbackInfo &pInfo) { - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); - auto simulation = sedDocument->simulation(toInt32(pInfo[1])); - auto uniformTimeCourse = std::dynamic_pointer_cast(simulation); + auto uniformTimeCourse = toSedSimulation(pInfo); uniformTimeCourse->setInitialTime(toDouble(pInfo[2])); } napi_value sedUniformTimeCourseOutputStartTime(const Napi::CallbackInfo &pInfo) { - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); - auto simulation = sedDocument->simulation(toInt32(pInfo[1])); - auto uniformTimeCourse = std::dynamic_pointer_cast(simulation); + auto uniformTimeCourse = toSedSimulation(pInfo); return Napi::Number::New(pInfo.Env(), uniformTimeCourse->outputStartTime()); } void sedUniformTimeCourseSetOutputStartTime(const Napi::CallbackInfo &pInfo) { - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); - auto simulation = sedDocument->simulation(toInt32(pInfo[1])); - auto uniformTimeCourse = std::dynamic_pointer_cast(simulation); + auto uniformTimeCourse = toSedSimulation(pInfo); uniformTimeCourse->setOutputStartTime(toDouble(pInfo[2])); } napi_value sedUniformTimeCourseOutputEndTime(const Napi::CallbackInfo &pInfo) { - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); - auto simulation = sedDocument->simulation(toInt32(pInfo[1])); - auto uniformTimeCourse = std::dynamic_pointer_cast(simulation); + auto uniformTimeCourse = toSedSimulation(pInfo); return Napi::Number::New(pInfo.Env(), uniformTimeCourse->outputEndTime()); } void sedUniformTimeCourseSetOutputEndTime(const Napi::CallbackInfo &pInfo) { - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); - auto simulation = sedDocument->simulation(toInt32(pInfo[1])); - auto uniformTimeCourse = std::dynamic_pointer_cast(simulation); + auto uniformTimeCourse = toSedSimulation(pInfo); uniformTimeCourse->setOutputEndTime(toDouble(pInfo[2])); } napi_value sedUniformTimeCourseNumberOfSteps(const Napi::CallbackInfo &pInfo) { - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); - auto simulation = sedDocument->simulation(toInt32(pInfo[1])); - auto uniformTimeCourse = std::dynamic_pointer_cast(simulation); + auto uniformTimeCourse = toSedSimulation(pInfo); return Napi::Number::New(pInfo.Env(), uniformTimeCourse->numberOfSteps()); } void sedUniformTimeCourseSetNumberOfSteps(const Napi::CallbackInfo &pInfo) { - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); - auto simulation = sedDocument->simulation(toInt32(pInfo[1])); - auto uniformTimeCourse = std::dynamic_pointer_cast(simulation); + auto uniformTimeCourse = toSedSimulation(pInfo); uniformTimeCourse->setNumberOfSteps(toInt32(pInfo[2])); } @@ -200,12 +224,11 @@ namespace { libOpenCOR::SolverCvodePtr solverCvode(const Napi::CallbackInfo &pInfo) { // Note: the simulation's ODE solver is not necessarily CVODE (e.g., it could be Forward Euler or not set at all), - // hence we return nullptr in that case. + // hence we return nullptr in that case (but we throw a JavaScript exception if the simulation is unknown). - auto sedDocument = toSedDocument(toSizeT(pInfo[0])); - auto simulation = (sedDocument != nullptr) ? sedDocument->simulation(toInt32(pInfo[1])) : nullptr; + auto simulation = toSedSimulation(pInfo); - return (simulation != nullptr) ? std::dynamic_pointer_cast(simulation->odeSolver()) : nullptr; + return std::dynamic_pointer_cast(simulation->odeSolver()); } } // namespace @@ -239,63 +262,63 @@ void solverCvodeSetMaximumStep(const Napi::CallbackInfo &pInfo) napi_value sedInstanceHasIssues(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); + auto sedInstance = toSedInstance(pInfo[0]); return Napi::Boolean::New(pInfo.Env(), sedInstance->hasIssues()); } napi_value sedInstanceIssues(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); + auto sedInstance = toSedInstance(pInfo[0]); return issues(pInfo, sedInstance->issues()); } napi_value sedInstanceStatus(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); + auto sedInstance = toSedInstance(pInfo[0]); return Napi::Number::New(pInfo.Env(), static_cast(sedInstance->status())); } napi_value sedInstanceProgress(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); + auto sedInstance = toSedInstance(pInfo[0]); return Napi::Number::New(pInfo.Env(), sedInstance->progress()); } napi_value sedInstanceStartRun(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); + auto sedInstance = toSedInstance(pInfo[0]); return Napi::Boolean::New(pInfo.Env(), sedInstance->startRun()); } napi_value sedInstanceWaitForRun(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); + auto sedInstance = toSedInstance(pInfo[0]); return Napi::Number::New(pInfo.Env(), sedInstance->waitForRun()); } void sedInstancePauseRun(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); + auto sedInstance = toSedInstance(pInfo[0]); sedInstance->pauseRun(); } void sedInstanceResumeRun(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); + auto sedInstance = toSedInstance(pInfo[0]); sedInstance->resumeRun(); } void sedInstanceStopRun(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); + auto sedInstance = toSedInstance(pInfo[0]); sedInstance->stopRun(); } @@ -309,184 +332,161 @@ void sedInstanceRelease(const Napi::CallbackInfo &pInfo) napi_value sedInstanceTaskVoiName(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::String::New(pInfo.Env(), task->voiName()); } napi_value sedInstanceTaskVoiUnit(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::String::New(pInfo.Env(), task->voiUnit()); } napi_value sedInstanceTaskVoi(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return doublesToNapiFloat64Array(pInfo.Env(), task->voi()); } napi_value sedInstanceTaskStateCount(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::Number::New(pInfo.Env(), task->stateCount()); } napi_value sedInstanceTaskStateName(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::String::New(pInfo.Env(), task->stateName(toInt32(pInfo[2]))); } napi_value sedInstanceTaskStateUnit(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::String::New(pInfo.Env(), task->stateUnit(toInt32(pInfo[2]))); } napi_value sedInstanceTaskState(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return doublesToNapiFloat64Array(pInfo.Env(), task->state(toInt32(pInfo[2]))); } napi_value sedInstanceTaskRateCount(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::Number::New(pInfo.Env(), task->rateCount()); } napi_value sedInstanceTaskRateName(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::String::New(pInfo.Env(), task->rateName(toInt32(pInfo[2]))); } napi_value sedInstanceTaskRateUnit(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::String::New(pInfo.Env(), task->rateUnit(toInt32(pInfo[2]))); } napi_value sedInstanceTaskRate(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return doublesToNapiFloat64Array(pInfo.Env(), task->rate(toInt32(pInfo[2]))); } napi_value sedInstanceTaskConstantCount(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::Number::New(pInfo.Env(), task->constantCount()); } napi_value sedInstanceTaskConstantName(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::String::New(pInfo.Env(), task->constantName(toInt32(pInfo[2]))); } napi_value sedInstanceTaskConstantUnit(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::String::New(pInfo.Env(), task->constantUnit(toInt32(pInfo[2]))); } napi_value sedInstanceTaskConstant(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return doublesToNapiFloat64Array(pInfo.Env(), task->constant(toInt32(pInfo[2]))); } napi_value sedInstanceTaskComputedConstantCount(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::Number::New(pInfo.Env(), task->computedConstantCount()); } napi_value sedInstanceTaskComputedConstantName(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::String::New(pInfo.Env(), task->computedConstantName(toInt32(pInfo[2]))); } napi_value sedInstanceTaskComputedConstantUnit(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::String::New(pInfo.Env(), task->computedConstantUnit(toInt32(pInfo[2]))); } napi_value sedInstanceTaskComputedConstant(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return doublesToNapiFloat64Array(pInfo.Env(), task->computedConstant(toInt32(pInfo[2]))); } napi_value sedInstanceTaskAlgebraicVariableCount(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::Number::New(pInfo.Env(), task->algebraicVariableCount()); } napi_value sedInstanceTaskAlgebraicVariableName(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::String::New(pInfo.Env(), task->algebraicVariableName(toInt32(pInfo[2]))); } napi_value sedInstanceTaskAlgebraicVariableUnit(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return Napi::String::New(pInfo.Env(), task->algebraicVariableUnit(toInt32(pInfo[2]))); } napi_value sedInstanceTaskAlgebraicVariable(const Napi::CallbackInfo &pInfo) { - auto sedInstance = toSedInstance(toSizeT(pInfo[0])); - auto task = sedInstance->task(toInt32(pInfo[1])); + auto task = toSedInstanceTask(pInfo); return doublesToNapiFloat64Array(pInfo.Env(), task->algebraicVariable(toInt32(pInfo[2]))); } From 969e5433989ad8b2ff95a871e36fbda1865e300c Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 7 Oct 2026 17:20:22 +1300 Subject: [PATCH 008/184] Fixed the reopening flow when files fail to open. --- src/main/MainWindow.ts | 33 ++++++++++++----- .../src/components/ContentsComponent.vue | 35 +++++++++++++------ 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/src/main/MainWindow.ts b/src/main/MainWindow.ts index 7615d11bd..025b2b9b6 100644 --- a/src/main/MainWindow.ts +++ b/src/main/MainWindow.ts @@ -117,6 +117,11 @@ export const fileIssue = (filePath: string): void => { removeRecentFilePath(filePath); updateReopenMenu(recentFilePaths); + + // A file couldn't be opened, possibly while reopening files during OpenCOR startup, in which case we need to make sure + // that it doesn't get selected and reopen the next file. + + MainWindow.instance?.reopenFilePathsAndSelectFilePath(filePath); }; export const fileOpened = (filePath: string): void => { @@ -374,24 +379,34 @@ export class MainWindow extends ApplicationWindow { // Note: we reopen one file at a time since a file may be a remote file which means that it may take some time to // reopen. So, we need to wait for the file to be reopened before reopening the next one. - reopenFilePathsAndSelectFilePath(): void { - if (this._openedFilePathIndex < this._openedFilePaths.length) { + reopenFilePathsAndSelectFilePath(failedFilePath?: string): void { + // Make sure that we don't select a file that couldn't be (re)opened. + + if (failedFilePath && failedFilePath === this._selectedFilePath) { + this._selectedFilePath = ''; + } + + // Reopen the next file, if any. + // Note: we reopen files one at a time, i.e. we only reopen the next file once the renderer has told us that the + // previous file has been opened (or couldn't be opened). + + while (this._openedFilePathIndex < this._openedFilePaths.length) { const filePath = this._openedFilePaths[this._openedFilePathIndex]; + ++this._openedFilePathIndex; + if (filePath) { this.webContents.send('open', filePath); - } - - ++this._openedFilePathIndex; - if (this._openedFilePathIndex < this._openedFilePaths.length) { return; } - - this._openedFilePaths = []; - this._openedFilePathIndex = 0; } + this._openedFilePaths = []; + this._openedFilePathIndex = 0; + + // All the files have been reopened (or couldn't be reopened), so we can now select the previously selected file. + if (this._selectedFilePath) { this.webContents.send('select', this._selectedFilePath); diff --git a/src/renderer/src/components/ContentsComponent.vue b/src/renderer/src/components/ContentsComponent.vue index ff4da7e18..5a05d6ab6 100644 --- a/src/renderer/src/components/ContentsComponent.vue +++ b/src/renderer/src/components/ContentsComponent.vue @@ -220,7 +220,9 @@ const waitForTabsUpdate = async (): Promise => { }; const selectFile = async (filePath: string, wait: boolean = false): Promise => { - if (props.simulationOnly) { + // Note: we can only select a file that has a file tab (hasFile() also returns false in simulation-only mode). + + if (!hasFile(filePath)) { return; } @@ -268,20 +270,25 @@ const openFile = async (file: locApi.File, wait: boolean = false): Promise }; const closeFile = async (filePath: string): Promise => { - locApi.fileManager.unmanage(filePath); + // Note: there is nothing to close if the file has no file tab, so we don't want to let people know that it has been + // closed (and, in the case of Electron, have it added to our list of recent files). const fileTabIndex = fileTabs.value.findIndex((fileTab) => fileTab.file.path() === filePath); const fileTab = fileTabs.value[fileTabIndex]; - if (fileTab) { - fileTabs.value.splice(fileTabIndex, 1); + if (!fileTab) { + return; + } - // Release the file once its views have been unmounted (and have therefore released their own resources). + locApi.fileManager.unmanage(filePath); - vue.nextTick(() => { - fileTab.file.release(); - }); - } + fileTabs.value.splice(fileTabIndex, 1); + + // Release the file once its views have been unmounted (and have therefore released their own resources). + + vue.nextTick(() => { + fileTab.file.release(); + }); if (activeFile.value === filePath && fileTabs.value.length) { const nextFileTab = fileTabs.value[Math.min(fileTabIndex, fileTabs.value.length - 1)]; @@ -309,8 +316,14 @@ const closeAllFiles = async (): Promise => { return; } - while (fileTabs.value.length) { - await closeCurrentFile(); + // Close the files from a snapshot of our file tabs, so that we are guaranteed to terminate, and close the current + // file last, so that closing the other files doesn't result in a new file being selected each time. + + const currentFilePath = activeFile.value; + const otherFilePaths = filePaths.value.filter((filePath) => filePath !== currentFilePath); + + for (const filePath of [...otherFilePaths, currentFilePath]) { + await closeFile(filePath); } }; From ae0690274f496bbd736c806f4edeab26b956c3b6 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 7 Oct 2026 17:32:29 +1300 Subject: [PATCH 009/184] Interactive Simulation Experiment view: coalesce simulation input updates. --- .../SimulationExperimentInteractiveView.vue | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue index d7b468d61..2978160fb 100644 --- a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue +++ b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue @@ -55,7 +55,7 @@ :possibleValues="locApi.isDiscreteInput(input) ? input.possibleValues : undefined" :stepValue="locApi.isScalarInput(input) ? input.stepValue : undefined" :class="index !== 0 ? 'mt-6' : ''" - @change="updateSimulation()" + @change="onInputChange()" />
@@ -1816,11 +1816,49 @@ const onSettingsOk = (updatedSettings: ISimulationExperimentInteractiveViewSetti onResetMargins(); }; +// Update the simulation following a change to one of our inputs. +// Note: an input (e.g., a slider being dragged) can change faster than a simulation run completes. So, rather than +// starting a new simulation run (and therefore instantiating a new instance) for every change, we let the current +// simulation run complete and then start a new one using the latest input values (i.e. latest value wins). This +// means that intermediate input values may be skipped, but that we always have at most one simulation run in +// flight and that the simulation is still updated while an input is being changed. + +let inputSimulationUpdateInFlight = false; +let inputSimulationUpdatePending = false; + +const onInputChange = (): void => { + if (inputSimulationUpdateInFlight) { + inputSimulationUpdatePending = true; + + return; + } + + inputSimulationUpdateInFlight = true; + + updateSimulation().finally(async () => { + // Give our plots a chance to be rendered before starting a new simulation run, if needed. + + if (inputSimulationUpdatePending) { + await common.waitForNextAnimationFrame(); + } + + inputSimulationUpdateInFlight = false; + + if (inputSimulationUpdatePending) { + inputSimulationUpdatePending = false; + + onInputChange(); + } + }); +}; + // Make sure that any pending simulation updates are discarded once we get unmounted. vue.onBeforeUnmount(() => { ++simulationGeneration; + inputSimulationUpdatePending = false; + if (instance?.status() !== locSedApi.ESedInstanceStatus.IDLE) { instance?.stopRun(); } From 37d31bd37b096f2a6636f601d6ff3e903f5e2d9b Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 7 Oct 2026 17:45:32 +1300 Subject: [PATCH 010/184] Interactive Simulation Experiment view: prioritise the final slider value simulation updates. --- .../SimulationExperimentInteractiveView.vue | 50 ++++++++++++++----- .../src/components/widgets/InputWidget.vue | 11 ++++ 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue index 2978160fb..cd5caa3fc 100644 --- a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue +++ b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue @@ -56,6 +56,7 @@ :stepValue="locApi.isScalarInput(input) ? input.stepValue : undefined" :class="index !== 0 ? 'mt-6' : ''" @change="onInputChange()" + @changeEnd="onInputChangeEnd()" />
@@ -1823,33 +1824,56 @@ const onSettingsOk = (updatedSettings: ISimulationExperimentInteractiveViewSetti // means that intermediate input values may be skipped, but that we always have at most one simulation run in // flight and that the simulation is still updated while an input is being changed. -let inputSimulationUpdateInFlight = false; +let inputSimulationUpdateInFlight: Promise | null = null; let inputSimulationUpdatePending = false; -const onInputChange = (): void => { - if (inputSimulationUpdateInFlight) { - inputSimulationUpdatePending = true; - - return; - } - - inputSimulationUpdateInFlight = true; - - updateSimulation().finally(async () => { +const startInputSimulationUpdate = (): void => { + const inputSimulationUpdate = updateSimulation().finally(async () => { // Give our plots a chance to be rendered before starting a new simulation run, if needed. if (inputSimulationUpdatePending) { await common.waitForNextAnimationFrame(); } - inputSimulationUpdateInFlight = false; + // Check whether we have been superseded by a newer input simulation update (see onInputChangeEnd()), in which case + // it is up to that update to deal with any pending input change. + + if (inputSimulationUpdate !== inputSimulationUpdateInFlight) { + return; + } + + inputSimulationUpdateInFlight = null; if (inputSimulationUpdatePending) { inputSimulationUpdatePending = false; - onInputChange(); + startInputSimulationUpdate(); } }); + + inputSimulationUpdateInFlight = inputSimulationUpdate; +}; + +const onInputChange = (): void => { + if (inputSimulationUpdateInFlight) { + inputSimulationUpdatePending = true; + + return; + } + + startInputSimulationUpdate(); +}; + +const onInputChangeEnd = (): void => { + // An input has finished changing (e.g., a slider has been released), so rather than wait for the current simulation + // run to complete, cancel it and start a new one straight away using the final input values, if needed. + // Note: updateSimulation() takes care of stopping the current simulation run. + + if (inputSimulationUpdatePending) { + inputSimulationUpdatePending = false; + + startInputSimulationUpdate(); + } }; // Make sure that any pending simulation updates are discarded once we get unmounted. diff --git a/src/renderer/src/components/widgets/InputWidget.vue b/src/renderer/src/components/widgets/InputWidget.vue index 10f504f2d..ae9505b97 100644 --- a/src/renderer/src/components/widgets/InputWidget.vue +++ b/src/renderer/src/components/widgets/InputWidget.vue @@ -26,6 +26,7 @@ :min="minimumValue" :max="maximumValue" :step="compStepValue" size="small" @change="sliderChange" + @slideend="sliderChangeEnd" /> @@ -48,6 +49,7 @@ const props = defineProps<{ const emits = defineEmits<{ change: [name: string, newValue: number]; + changeEnd: [name: string]; }>(); let oldValue = value.value; @@ -119,4 +121,13 @@ const sliderChange = (newValue: number | number[]) => { emitChange(valueToEmit); } }; + +const sliderChangeEnd = () => { + // Note: we emit our changeEnd event in the next tick so that it gets emitted after any pending change event (see + // emitChange()). + + vue.nextTick(() => { + emits('changeEnd', props.name); + }); +}; From 75542f51224fb4a5a1094f1f0030b2ea8875fa4a Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 7 Oct 2026 18:00:28 +1300 Subject: [PATCH 011/184] Share our Math.js instance across our `Float64ArrayMath` objects. --- src/renderer/src/common/math.ts | 1192 ++++++++++++++++--------------- 1 file changed, 605 insertions(+), 587 deletions(-) diff --git a/src/renderer/src/common/math.ts b/src/renderer/src/common/math.ts index 74d85b4db..ed434552a 100644 --- a/src/renderer/src/common/math.ts +++ b/src/renderer/src/common/math.ts @@ -30,761 +30,829 @@ interface IMathJsLike { const ELEMENTWISE_REGEX = /\.([*/^])/g; -export class Float64ArrayMath { - private readonly math: IMathJsLike; - private readonly compiledExpressions = new Map(); - private readonly validatedTokens = new Map(); - - constructor() { - this.math = dependencies._mathJs.create(dependencies._mathJs.all, {}); - - this.math.import( - // Note: everything is inlined to keep call sites monomorphic and let V8 emit optimal machine code. +// The functions and constants that can be used in an expression. + +const ALLOWED_FUNCTIONS = new Set([ + // Note: this list must be kept in sync with the functions imported into Math.js in sharedMathJs(), as well as some + // constants. + + 'add', + 'subtract', + 'multiply', + 'divide', + 'pow', + 'mod', + 'unaryMinus', + 'sqrt', + 'abs', + 'exp', + 'log', + 'log10', + 'ceil', + 'floor', + 'min', + 'max', + 'sin', + 'cos', + 'tan', + 'sec', + 'csc', + 'cot', + 'sinh', + 'cosh', + 'tanh', + 'sech', + 'csch', + 'coth', + 'asin', + 'acos', + 'atan', + 'asec', + 'acsc', + 'acot', + 'asinh', + 'acosh', + 'atanh', + 'asech', + 'acsch', + 'acoth', + 'true', + 'false', + 'e', + 'pi' +]); + +// Our Math.js instance. +// Note: creating a Math.js instance is expensive, so we create it once and share it between all our Float64ArrayMath +// objects. Also, we create it lazily since Math.js is loaded asynchronously (see dependencies.ts). + +let mathJs: IMathJsLike | null = null; + +const sharedMathJs = (): IMathJsLike => { + if (mathJs) { + return mathJs; + } - { - // Core arithmetic operators. + // Note: we only keep track of our Math.js instance once it has been fully set up. - add: (a: MathJsValue, b: MathJsValue): MathJsValue => { - if (typeof a === 'number') { - if (typeof b === 'number') { - return a + b; - } + const res: IMathJsLike = dependencies._mathJs.create(dependencies._mathJs.all, {}); - const len = b.length; - const res = new Float64Array(len); + res.import( + // Note: everything is inlined to keep call sites monomorphic and let V8 emit optimal machine code. - for (let i = 0; i < len; ++i) { - res[i] = a + b[i]; - } + { + // Core arithmetic operators. - return res; + add: (a: MathJsValue, b: MathJsValue): MathJsValue => { + if (typeof a === 'number') { + if (typeof b === 'number') { + return a + b; } - const len = a.length; + const len = b.length; const res = new Float64Array(len); - if (typeof b === 'number') { - for (let i = 0; i < len; ++i) { - res[i] = a[i] + b; - } - } else { - for (let i = 0; i < len; ++i) { - res[i] = a[i] + b[i]; - } + for (let i = 0; i < len; ++i) { + res[i] = a + b[i]; } return res; - }, - - subtract: (a: MathJsValue, b: MathJsValue): MathJsValue => { - if (typeof a === 'number') { - if (typeof b === 'number') { - return a - b; - } - - const len = b.length; - const res = new Float64Array(len); + } - for (let i = 0; i < len; ++i) { - res[i] = a - b[i]; - } + const len = a.length; + const res = new Float64Array(len); - return res; + if (typeof b === 'number') { + for (let i = 0; i < len; ++i) { + res[i] = a[i] + b; + } + } else { + for (let i = 0; i < len; ++i) { + res[i] = a[i] + b[i]; } + } - const len = a.length; - const res = new Float64Array(len); + return res; + }, + subtract: (a: MathJsValue, b: MathJsValue): MathJsValue => { + if (typeof a === 'number') { if (typeof b === 'number') { - for (let i = 0; i < len; ++i) { - res[i] = a[i] - b; - } - } else { - for (let i = 0; i < len; ++i) { - res[i] = a[i] - b[i]; - } + return a - b; } - return res; - }, - - multiply: (a: MathJsValue, b: MathJsValue): MathJsValue => { - if (typeof a === 'number') { - if (typeof b === 'number') { - return a * b; - } - - const len = b.length; - const res = new Float64Array(len); - - for (let i = 0; i < len; ++i) { - res[i] = a * b[i]; - } - - return res; - } - const len = a.length; + const len = b.length; const res = new Float64Array(len); - if (typeof b === 'number') { - for (let i = 0; i < len; ++i) { - res[i] = a[i] * b; - } - } else { - for (let i = 0; i < len; ++i) { - res[i] = a[i] * b[i]; - } + for (let i = 0; i < len; ++i) { + res[i] = a - b[i]; } return res; - }, + } - divide: (a: MathJsValue, b: MathJsValue): MathJsValue => { - if (typeof a === 'number') { - if (typeof b === 'number') { - return a / b; - } + const len = a.length; + const res = new Float64Array(len); - const len = b.length; - const res = new Float64Array(len); + if (typeof b === 'number') { + for (let i = 0; i < len; ++i) { + res[i] = a[i] - b; + } + } else { + for (let i = 0; i < len; ++i) { + res[i] = a[i] - b[i]; + } + } - for (let i = 0; i < len; ++i) { - res[i] = a / b[i]; - } + return res; + }, - return res; + multiply: (a: MathJsValue, b: MathJsValue): MathJsValue => { + if (typeof a === 'number') { + if (typeof b === 'number') { + return a * b; } - const len = a.length; + const len = b.length; const res = new Float64Array(len); - if (typeof b === 'number') { - for (let i = 0; i < len; ++i) { - res[i] = a[i] / b; - } - } else { - for (let i = 0; i < len; ++i) { - res[i] = a[i] / b[i]; - } + for (let i = 0; i < len; ++i) { + res[i] = a * b[i]; } return res; - }, - - pow: (a: MathJsValue, b: MathJsValue): MathJsValue => { - if (typeof a === 'number') { - if (typeof b === 'number') { - return a ** b; - } + } + const len = a.length; + const res = new Float64Array(len); - const len = b.length; - const res = new Float64Array(len); + if (typeof b === 'number') { + for (let i = 0; i < len; ++i) { + res[i] = a[i] * b; + } + } else { + for (let i = 0; i < len; ++i) { + res[i] = a[i] * b[i]; + } + } - for (let i = 0; i < len; ++i) { - res[i] = a ** b[i]; - } + return res; + }, - return res; + divide: (a: MathJsValue, b: MathJsValue): MathJsValue => { + if (typeof a === 'number') { + if (typeof b === 'number') { + return a / b; } - const len = a.length; + const len = b.length; const res = new Float64Array(len); - if (typeof b === 'number') { - for (let i = 0; i < len; ++i) { - res[i] = a[i] ** b; - } - } else { - for (let i = 0; i < len; ++i) { - res[i] = a[i] ** b[i]; - } + for (let i = 0; i < len; ++i) { + res[i] = a / b[i]; } - return res; - }, - - mod: (a: MathJsValue, b: MathJsValue): MathJsValue => { - if (typeof a === 'number') { - if (typeof b === 'number') { - return a % b; - } - const len = b.length; - const res = new Float64Array(len); + return res; + } - for (let i = 0; i < len; ++i) { - res[i] = a % b[i]; - } + const len = a.length; + const res = new Float64Array(len); - return res; + if (typeof b === 'number') { + for (let i = 0; i < len; ++i) { + res[i] = a[i] / b; } - - const len = a.length; - const res = new Float64Array(len); - - if (typeof b === 'number') { - for (let i = 0; i < len; ++i) { - res[i] = a[i] % b; - } - } else { - for (let i = 0; i < len; ++i) { - res[i] = a[i] % b[i]; - } + } else { + for (let i = 0; i < len; ++i) { + res[i] = a[i] / b[i]; } + } - return res; - }, + return res; + }, - unaryMinus: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return -x; + pow: (a: MathJsValue, b: MathJsValue): MathJsValue => { + if (typeof a === 'number') { + if (typeof b === 'number') { + return a ** b; } - const len = x.length; + const len = b.length; const res = new Float64Array(len); for (let i = 0; i < len; ++i) { - res[i] = -x[i]; + res[i] = a ** b[i]; } return res; - }, + } - // Math functions. + const len = a.length; + const res = new Float64Array(len); - sqrt: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.sqrt(x); + if (typeof b === 'number') { + for (let i = 0; i < len; ++i) { + res[i] = a[i] ** b; } - - const len = x.length; - const res = new Float64Array(len); - + } else { for (let i = 0; i < len; ++i) { - res[i] = Math.sqrt(x[i]); + res[i] = a[i] ** b[i]; } + } + return res; + }, - return res; - }, - - abs: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.abs(x); + mod: (a: MathJsValue, b: MathJsValue): MathJsValue => { + if (typeof a === 'number') { + if (typeof b === 'number') { + return a % b; } - const len = x.length; + const len = b.length; const res = new Float64Array(len); for (let i = 0; i < len; ++i) { - res[i] = Math.abs(x[i]); + res[i] = a % b[i]; } return res; - }, - - exp: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.exp(x); - } + } - const len = x.length; - const res = new Float64Array(len); + const len = a.length; + const res = new Float64Array(len); + if (typeof b === 'number') { + for (let i = 0; i < len; ++i) { + res[i] = a[i] % b; + } + } else { for (let i = 0; i < len; ++i) { - res[i] = Math.exp(x[i]); + res[i] = a[i] % b[i]; } + } - return res; - }, + return res; + }, - log: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.log(x); - } + unaryMinus: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return -x; + } - const len = x.length; - const res = new Float64Array(len); + const len = x.length; + const res = new Float64Array(len); - for (let i = 0; i < len; ++i) { - res[i] = Math.log(x[i]); - } + for (let i = 0; i < len; ++i) { + res[i] = -x[i]; + } - return res; - }, + return res; + }, - log10: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.log10(x); - } + // Math functions. - const len = x.length; - const res = new Float64Array(len); + sqrt: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.sqrt(x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.log10(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = Math.sqrt(x[i]); + } - ceil: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.ceil(x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + abs: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.abs(x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.ceil(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = Math.abs(x[i]); + } - floor: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.floor(x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + exp: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.exp(x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.floor(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = Math.exp(x[i]); + } - min: (a: MathJsValue, b: MathJsValue): MathJsValue => { - if (typeof a === 'number') { - if (typeof b === 'number') { - return Math.min(a, b); - } + return res; + }, - const len = b.length; - const res = new Float64Array(len); + log: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.log(x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.min(a, b[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - } + for (let i = 0; i < len; ++i) { + res[i] = Math.log(x[i]); + } - const len = a.length; - const res = new Float64Array(len); + return res; + }, - if (typeof b === 'number') { - for (let i = 0; i < len; ++i) { - res[i] = Math.min(a[i], b); - } - } else { - for (let i = 0; i < len; ++i) { - res[i] = Math.min(a[i], b[i]); - } - } + log10: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.log10(x); + } - return res; - }, + const len = x.length; + const res = new Float64Array(len); - max: (a: MathJsValue, b: MathJsValue): MathJsValue => { - if (typeof a === 'number') { - if (typeof b === 'number') { - return Math.max(a, b); - } + for (let i = 0; i < len; ++i) { + res[i] = Math.log10(x[i]); + } - const len = b.length; - const res = new Float64Array(len); + return res; + }, - for (let i = 0; i < len; ++i) { - res[i] = Math.max(a, b[i]); - } + ceil: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.ceil(x); + } - return res; - } + const len = x.length; + const res = new Float64Array(len); - const len = a.length; - const res = new Float64Array(len); + for (let i = 0; i < len; ++i) { + res[i] = Math.ceil(x[i]); + } - if (typeof b === 'number') { - for (let i = 0; i < len; ++i) { - res[i] = Math.max(a[i], b); - } - } else { - for (let i = 0; i < len; ++i) { - res[i] = Math.max(a[i], b[i]); - } - } + return res; + }, - return res; - }, + floor: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.floor(x); + } - // Trigonometric functions. + const len = x.length; + const res = new Float64Array(len); - sin: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.sin(x); + for (let i = 0; i < len; ++i) { + res[i] = Math.floor(x[i]); + } + + return res; + }, + + min: (a: MathJsValue, b: MathJsValue): MathJsValue => { + if (typeof a === 'number') { + if (typeof b === 'number') { + return Math.min(a, b); } - const len = x.length; + const len = b.length; const res = new Float64Array(len); for (let i = 0; i < len; ++i) { - res[i] = Math.sin(x[i]); + res[i] = Math.min(a, b[i]); } return res; - }, - - cos: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.cos(x); - } + } - const len = x.length; - const res = new Float64Array(len); + const len = a.length; + const res = new Float64Array(len); + if (typeof b === 'number') { + for (let i = 0; i < len; ++i) { + res[i] = Math.min(a[i], b); + } + } else { for (let i = 0; i < len; ++i) { - res[i] = Math.cos(x[i]); + res[i] = Math.min(a[i], b[i]); } + } - return res; - }, + return res; + }, - tan: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.tan(x); + max: (a: MathJsValue, b: MathJsValue): MathJsValue => { + if (typeof a === 'number') { + if (typeof b === 'number') { + return Math.max(a, b); } - const len = x.length; + const len = b.length; const res = new Float64Array(len); for (let i = 0; i < len; ++i) { - res[i] = Math.tan(x[i]); + res[i] = Math.max(a, b[i]); } return res; - }, - - sec: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return 1 / Math.cos(x); - } + } - const len = x.length; - const res = new Float64Array(len); + const len = a.length; + const res = new Float64Array(len); + if (typeof b === 'number') { for (let i = 0; i < len; ++i) { - res[i] = 1 / Math.cos(x[i]); + res[i] = Math.max(a[i], b); } + } else { + for (let i = 0; i < len; ++i) { + res[i] = Math.max(a[i], b[i]); + } + } - return res; - }, + return res; + }, - csc: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return 1 / Math.sin(x); - } + // Trigonometric functions. - const len = x.length; - const res = new Float64Array(len); + sin: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.sin(x); + } - for (let i = 0; i < len; ++i) { - res[i] = 1 / Math.sin(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = Math.sin(x[i]); + } - cot: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return 1 / Math.tan(x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + cos: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.cos(x); + } - for (let i = 0; i < len; ++i) { - res[i] = 1 / Math.tan(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = Math.cos(x[i]); + } - sinh: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.sinh(x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + tan: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.tan(x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.sinh(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = Math.tan(x[i]); + } - cosh: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.cosh(x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + sec: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return 1 / Math.cos(x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.cosh(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = 1 / Math.cos(x[i]); + } - tanh: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.tanh(x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + csc: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return 1 / Math.sin(x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.tanh(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = 1 / Math.sin(x[i]); + } - sech: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return 1 / Math.cosh(x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + cot: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return 1 / Math.tan(x); + } - for (let i = 0; i < len; ++i) { - res[i] = 1 / Math.cosh(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = 1 / Math.tan(x[i]); + } - csch: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return 1 / Math.sinh(x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + sinh: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.sinh(x); + } - for (let i = 0; i < len; ++i) { - res[i] = 1 / Math.sinh(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = Math.sinh(x[i]); + } - coth: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return 1 / Math.tanh(x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + cosh: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.cosh(x); + } - for (let i = 0; i < len; ++i) { - res[i] = 1 / Math.tanh(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = Math.cosh(x[i]); + } - asin: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.asin(x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + tanh: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.tanh(x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.asin(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = Math.tanh(x[i]); + } - acos: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.acos(x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + sech: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return 1 / Math.cosh(x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.acos(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = 1 / Math.cosh(x[i]); + } - atan: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.atan(x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + csch: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return 1 / Math.sinh(x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.atan(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = 1 / Math.sinh(x[i]); + } - asec: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.acos(1 / x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + coth: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return 1 / Math.tanh(x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.acos(1 / x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = 1 / Math.tanh(x[i]); + } - acsc: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.asin(1 / x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + asin: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.asin(x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.asin(1 / x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = Math.asin(x[i]); + } - acot: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.atan(1 / x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + acos: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.acos(x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.atan(1 / x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = Math.acos(x[i]); + } - asinh: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.asinh(x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + atan: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.atan(x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.asinh(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = Math.atan(x[i]); + } - acosh: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.acosh(x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + asec: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.acos(1 / x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.acosh(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = Math.acos(1 / x[i]); + } - atanh: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.atanh(x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + acsc: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.asin(1 / x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.atanh(x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = Math.asin(1 / x[i]); + } - asech: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.acosh(1 / x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + acot: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.atan(1 / x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.acosh(1 / x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = Math.atan(1 / x[i]); + } - acsch: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.asinh(1 / x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + asinh: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.asinh(x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.asinh(1 / x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; - }, + for (let i = 0; i < len; ++i) { + res[i] = Math.asinh(x[i]); + } - acoth: (x: MathJsValue): MathJsValue => { - if (typeof x === 'number') { - return Math.atanh(1 / x); - } + return res; + }, - const len = x.length; - const res = new Float64Array(len); + acosh: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.acosh(x); + } - for (let i = 0; i < len; ++i) { - res[i] = Math.atanh(1 / x[i]); - } + const len = x.length; + const res = new Float64Array(len); - return res; + for (let i = 0; i < len; ++i) { + res[i] = Math.acosh(x[i]); } + + return res; + }, + + atanh: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.atanh(x); + } + + const len = x.length; + const res = new Float64Array(len); + + for (let i = 0; i < len; ++i) { + res[i] = Math.atanh(x[i]); + } + + return res; + }, + + asech: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.acosh(1 / x); + } + + const len = x.length; + const res = new Float64Array(len); + + for (let i = 0; i < len; ++i) { + res[i] = Math.acosh(1 / x[i]); + } + + return res; }, - { override: true } - ); - } + + acsch: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.asinh(1 / x); + } + + const len = x.length; + const res = new Float64Array(len); + + for (let i = 0; i < len; ++i) { + res[i] = Math.asinh(1 / x[i]); + } + + return res; + }, + + acoth: (x: MathJsValue): MathJsValue => { + if (typeof x === 'number') { + return Math.atanh(1 / x); + } + + const len = x.length; + const res = new Float64Array(len); + + for (let i = 0; i < len; ++i) { + res[i] = Math.atanh(1 / x[i]); + } + + return res; + } + }, + { override: true } + ); + + mathJs = res; + + return res; +}; + +export class Float64ArrayMath { + private readonly math = sharedMathJs(); + private readonly compiledExpressions = new Map(); + private readonly validatedTokens = new Map(); // Reset both the compiled expressions and the token validation cache. @@ -821,56 +889,6 @@ export class Float64ArrayMath { // is used for expression results. evaluate(expression: string, scope: ExpressionScope): MathJsResult { - const allowedFunctions = new Set([ - // Note: this list must be kept in sync with the functions imported into Math.js in the constructor, as well as - // some constants. - - 'add', - 'subtract', - 'multiply', - 'divide', - 'pow', - 'mod', - 'unaryMinus', - 'sqrt', - 'abs', - 'exp', - 'log', - 'log10', - 'ceil', - 'floor', - 'min', - 'max', - 'sin', - 'cos', - 'tan', - 'sec', - 'csc', - 'cot', - 'sinh', - 'cosh', - 'tanh', - 'sech', - 'csch', - 'coth', - 'asin', - 'acos', - 'atan', - 'asec', - 'acsc', - 'acot', - 'asinh', - 'acosh', - 'atanh', - 'asech', - 'acsch', - 'acoth', - 'true', - 'false', - 'e', - 'pi' - ]); - // If this expression has already been validated and its scope-allowed variables haven't changed, skip token // scanning entirely. @@ -888,7 +906,7 @@ export class Float64ArrayMath { seenTokens.add(token); - if (!allowedVariables.has(token) && !allowedFunctions.has(token)) { + if (!allowedVariables.has(token) && !ALLOWED_FUNCTIONS.has(token)) { throw new MathError(expression, `Unknown symbol ${token}.`); } } From ef239ba48a87769090f4f5aeb23810aaea7deda6 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 7 Oct 2026 18:37:10 +1300 Subject: [PATCH 012/184] Interactive Simulation Experiment view: reuse the initial instance for our first simulation run. --- .../SimulationExperimentInteractiveView.vue | 141 ++++++++++++++---- 1 file changed, 109 insertions(+), 32 deletions(-) diff --git a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue index cd5caa3fc..9ed1c9478 100644 --- a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue +++ b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue @@ -237,7 +237,95 @@ const documentIssues = document.issues(); const isDocumentValid = documentIssues.length === 0; const uniformTimeCourse = isDocumentValid ? (document.simulation(0) as locApi.SedUniformTimeCourse) : null; const cvode = uniformTimeCourse?.cvode() ?? null; +const mathEval = new math.Float64ArrayMath(); +const model = isDocumentValid ? document.model(0) : null; + +// Some helper functions to evaluate and apply the model changes resulting from the given parameters and scope. + +interface IModelChange { + componentName: string; + variableName: string; + newValue: string; +} + +const evaluateModelChanges = ( + parameters: locApi.IUiJsonParameter[], + modelScope: math.ExpressionScope, + issues: locApi.IIssue[] +): IModelChange[] => { + const res: IModelChange[] = []; + + for (const parameter of parameters) { + const componentVariableNames = parameter.name.split('/'); + + if (componentVariableNames[0] && componentVariableNames[1]) { + try { + res.push({ + componentName: componentVariableNames[0], + variableName: componentVariableNames[1], + newValue: String(mathEval.evaluate(parameter.value, modelScope)) + }); + } catch (error: unknown) { + issues.push({ + type: locApi.EIssueType.ERROR, + description: `An error occurred while applying parameter change for '${parameter.name}' (${common.formatMessage(common.formatError(error), false)}).` + }); + } + } + } + + return res; +}; + +const applyModelChanges = (modelChanges: IModelChange[]): void => { + if (!model) { + return; + } + + model.removeAllChanges(); + + for (const modelChange of modelChanges) { + model.addChange(modelChange.componentName, modelChange.variableName, modelChange.newValue); + } +}; + +// Apply the model changes resulting from our default input values before instantiating our document, so that our first +// simulation run can use our instance rather than have to instantiate our document again (see updateSimulation()). +// Note: if those model changes result in our instance having issues, then we instantiate our document without them so +// that our view remains usable (any issue with those model changes will be reported when running the simulation). + +const NO_MODEL_CHANGES_KEY = JSON.stringify([]); +let instanceModelChangesKey = NO_MODEL_CHANGES_KEY; + +if (model) { + const initialModelScope: math.ExpressionScope = {}; + + for (const input of props.uiJson?.input ?? []) { + initialModelScope[input.id] = input.defaultValue; + } + + const initialModelChanges = evaluateModelChanges(props.uiJson?.parameters ?? [], initialModelScope, []); + + applyModelChanges(initialModelChanges); + + instanceModelChangesKey = JSON.stringify(initialModelChanges); +} + let instance = isDocumentValid ? document.instantiate() : null; + +if (instance?.hasIssues() && instanceModelChangesKey !== NO_MODEL_CHANGES_KEY) { + instance.release(); + + applyModelChanges([]); + + instance = document.instantiate(); + instanceModelChangesKey = NO_MODEL_CHANGES_KEY; +} + +let instanceIsPristine = true; +// Note: whether our instance has never been run. If so, and if it was built with the same model changes as those wanted +// by our next simulation run (see instanceModelChangesKey), then that run can use it as is rather than build a +// new one. In practice, this only applies to our first simulation run. const issues = documentIssues.length > 0 ? documentIssues : (instance?.issues() ?? []); let instanceTask = issues.length > 0 ? null : (instance?.task(0) ?? null); const allModelParameters = vue.ref([]); @@ -284,8 +372,6 @@ const uiJsonEmpty = vue.computed(() => { return false; }); -const mathEval = new math.Float64ArrayMath(); -const model = isDocumentValid ? document.model(0) : null; const isSimulating = vue.ref(false); const liveData = vue.ref([]); let margins: Record = {}; @@ -1143,28 +1229,9 @@ const updateSimulation = async (): Promise => { } } - // Update the SED-ML document. - - model.removeAllChanges(); - - for (const parameter of actualUiJson.value.parameters) { - const componentVariableNames = parameter.name.split('/'); + // Evaluate the model changes. - if (componentVariableNames[0] && componentVariableNames[1]) { - try { - model.addChange( - componentVariableNames[0], - componentVariableNames[1], - String(mathEval.evaluate(parameter.value, modelScope)) - ); - } catch (error: unknown) { - simulationIssues.value.push({ - type: locApi.EIssueType.ERROR, - description: `An error occurred while applying parameter change for '${parameter.name}' (${common.formatMessage(common.formatError(error), false)}).` - }); - } - } - } + const modelChanges = evaluateModelChanges(actualUiJson.value.parameters, modelScope, simulationIssues.value); // Make sure that we haven't come across any issues so far. @@ -1182,11 +1249,22 @@ const updateSimulation = async (): Promise => { return; } - // Create a fresh instance for the new simulation run. - // Note: this ensures that the instance picks up the latest model changes and avoids reusing an instance which + // Use our instance as is if it has yet to be run and was instantiated with the same model changes (e.g., for our first + // simulation run), otherwise apply the model changes and create a fresh instance for the new simulation run. + // Note: using a fresh instance ensures that it picks up the latest model changes and avoids reusing an instance which // internal state may have been corrupted by a previous cancellation. - const crtInstance = reinstantiateInstance(); + const modelChangesKey = JSON.stringify(modelChanges); + let crtInstance = instance; + + if (!instanceIsPristine || modelChangesKey !== instanceModelChangesKey) { + applyModelChanges(modelChanges); + + crtInstance = reinstantiateInstance(); + instanceModelChangesKey = modelChangesKey; + } + + instanceIsPristine = false; // Start the simulation in a background thread and yield to the UI to keep it responsive while the simulation runs. @@ -1760,8 +1838,6 @@ const onSettingsOk = (updatedSettings: ISimulationExperimentInteractiveViewSetti // Update our settings and hide the dialog. - const oldCvodeMaximumStep = cvode?.maximumStep(); - uniformTimeCourse.setInitialTime(updatedSettings.simulation.initialPoint); uniformTimeCourse.setOutputStartTime(updatedSettings.simulation.startingPoint); uniformTimeCourse.setOutputEndTime(updatedSettings.simulation.endingPoint); @@ -1791,11 +1867,12 @@ const onSettingsOk = (updatedSettings: ISimulationExperimentInteractiveViewSetti return; } - // Reinstantiate our instance in case we modified CVODE's maximum step. + // Make sure that our next simulation run doesn't use our current instance since it was instantiated using our old + // settings. + // Note: this is only relevant if our current instance has yet to be run (e.g., if our previous simulation run couldn't + // be started because of some issues). - if (cvode && cvode.maximumStep() !== oldCvodeMaximumStep) { - reinstantiateInstance(); - } + instanceIsPristine = false; // Update our UI. From 36bd43df2d68c3d729739b78c2b6c269b36801e2 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 7 Oct 2026 18:47:45 +1300 Subject: [PATCH 013/184] Interactive Simulation Experiment view: guard the simulation state on run failures. --- .../SimulationExperimentInteractiveView.vue | 80 ++++++++++++------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue index 9ed1c9478..9c325ed6c 100644 --- a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue +++ b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue @@ -1160,28 +1160,15 @@ const reinstantiateInstance = (): locApi.SedInstance => { }; // Run the interactive simulation. +// Note: this is called by updateSimulation(), which takes care of keeping track of the fact that we are simulating. -const updateSimulation = async (): Promise => { +const runSimulation = async (currentSimulationGeneration: number): Promise => { // Make sure that the view is usable, i.e. that we have a model and an instance. if (!model || !instance) { return; } - // Make sure that there are no issues with the UI JSON. - - if (uiJsonIssues.value.length) { - return; - } - - // Increment the simulation generation so that we can cancel any previous simulation runs that are still in progress. - - const currentSimulationGeneration = ++simulationGeneration; - - // Keep track of the fact that we are simulating. - - isSimulating.value = true; - // Stop the current simulation if it is still running or paused. if (instance.status() !== locSedApi.ESedInstanceStatus.IDLE) { @@ -1238,8 +1225,6 @@ const updateSimulation = async (): Promise => { if (simulationIssues.value.length) { simulationIssues.value.push(informationIssue); - isSimulating.value = false; - return; } @@ -1269,16 +1254,16 @@ const updateSimulation = async (): Promise => { // Start the simulation in a background thread and yield to the UI to keep it responsive while the simulation runs. if (!crtInstance.startRun()) { - isSimulating.value = false; - return; } runningInstances.add(crtInstance); - await vueCommon.waitWhileRunning(crtInstance).promise; - - runningInstances.delete(crtInstance); + try { + await vueCommon.waitWhileRunning(crtInstance).promise; + } finally { + runningInstances.delete(crtInstance); + } // Release our instance if it has been replaced by a newer one while the simulation was running, in which case our // results are stale anyway. @@ -1300,8 +1285,6 @@ const updateSimulation = async (): Promise => { if (crtInstance.hasIssues()) { simulationIssues.value = crtInstance.issues(); - isSimulating.value = false; - return; } @@ -1371,8 +1354,6 @@ const updateSimulation = async (): Promise => { if (simulationIssues.value.length) { simulationIssues.value.push(informationIssue); - isSimulating.value = false; - return; } @@ -1560,10 +1541,6 @@ const updateSimulation = async (): Promise => { liveData.value = newLiveData; - // The simulation has completed, so we are no longer simulating. - - isSimulating.value = false; - // Make sure that we haven't come across any issues so far. if (simulationIssues.value.length) { @@ -1577,6 +1554,49 @@ const updateSimulation = async (): Promise => { emit('simulationData'); }; +const updateSimulation = async (): Promise => { + // Make sure that the view is usable, i.e. that we have a model and an instance. + + if (!model || !instance) { + return; + } + + // Make sure that there are no issues with the UI JSON. + + if (uiJsonIssues.value.length) { + return; + } + + // Increment the simulation generation so that we can cancel any previous simulation runs that are still in progress. + + const currentSimulationGeneration = ++simulationGeneration; + + // Run the simulation, keeping track of the fact that we are simulating, and report any unexpected error (e.g., an + // exception thrown by libOpenCOR) as a simulation issue rather than leave us in a simulating state forever. + // Note: only the latest simulation run gets to report an error and to say that we are no longer simulating since an + // older one has been superseded and the latest one is still in charge. + + isSimulating.value = true; + + try { + await runSimulation(currentSimulationGeneration); + } catch (error: unknown) { + if (currentSimulationGeneration === simulationGeneration) { + simulationIssues.value = [ + { + type: locApi.EIssueType.ERROR, + description: `An unexpected error occurred while running the simulation (${common.formatMessage(common.formatError(error), false)}).` + }, + informationIssue + ]; + } + } finally { + if (currentSimulationGeneration === simulationGeneration) { + isSimulating.value = false; + } + } +}; + // Margins-related event handlers. const onMarginsUpdated = (plotId: string, newMargins: IGraphPanelMargins): void => { From 5a7dc0337dce2f37a26fc4ba9ec578b618b15e93 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 7 Oct 2026 18:59:51 +1300 Subject: [PATCH 014/184] Graph Panel widget: guard Plotly graph updates after unmounting. --- .../components/widgets/GraphPanelWidget.vue | 66 ++++++++++++++++--- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index a7fba280a..aeeebd33c 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -95,14 +95,12 @@ const trackSize = (): void => { }; const queueResize = (): void => { - if (resizeQueued) { + if (queueResizeRafId !== undefined) { return; } - resizeQueued = true; - - requestAnimationFrame(() => { - resizeQueued = false; + queueResizeRafId = requestAnimationFrame(() => { + queueResizeRafId = undefined; resize(); }); @@ -120,11 +118,20 @@ const resize = (): Promise => { resizeRafId = requestAnimationFrame(() => { resizeRafId = undefined; - dependencies._plotlyJs.Plots.resize(mainDivRef.value).then(() => { - trackSize(); + // Note: Plotly rejects resizing a plot that is not displayed (e.g., in a hidden file tab), in which case there is + // nothing to do. - updateMarginsAsync(); - }); + dependencies._plotlyJs.Plots.resize(mainDivRef.value) + .then(() => { + if (isUnmounted) { + return; + } + + trackSize(); + + updateMarginsAsync(); + }) + .catch(() => {}); }); return Promise.resolve(); @@ -145,12 +152,13 @@ const contextMenuRef = vue.ref | null>(null); const appendTarget = vueCommon.useAppendTarget(rootRef); const progressMessage = vue.inject('progressMessage'); let plotIsReady = false; -let resizeQueued = false; +let isUnmounted = false; let trackedWidth = 0; let trackedHeight = 0; let trackedMargins: IGraphPanelMargins | undefined; let stopTrackingContainerSize: (() => void) | undefined; let marginsRafId: number | undefined; +let queueResizeRafId: number | undefined; let resizeRafId: number | undefined; let lastLegendClickIndex: number | undefined; let lastLegendClickTime = 0; @@ -719,6 +727,13 @@ const plotlyTraceData = (): IPlotlyTraceState[] | undefined => { }; const updatePlot = (): void => { + // Make sure that we haven't been unmounted (e.g., if this was scheduled using vue.nextTick() just before we got + // unmounted), in which case our plot has been purged and shouldn't be recreated. + + if (isUnmounted) { + return; + } + plotIsReady = false; // Reset our margins if they are not overridden. @@ -806,12 +821,20 @@ const updatePlot = (): void => { // Note: the various keys can be found at https://plotly.com/javascript/configuration-options/. responsive: true, + // Note: to make our plot responsive means that Plotly sizes it relative to its container (rather than using a + // fixed size), which is needed for our plot to shrink when our container does. It also means that Plotly + // adds a window resize listener that keeps a reference to our plot, hence we purge our plot when we get + // unmounted (see onBeforeUnmount()). displayModeBar: false, doubleClickDelay: DOUBLE_CLICK_DELAY, scrollZoom: true, showTips: false }) .then(() => { + if (isUnmounted) { + return; + } + plotIsReady = true; // Recompute margins after every plot update. @@ -978,6 +1001,29 @@ vue.onMounted(() => { }); }); +// Release our plot before we get unmounted, i.e. while we still have access to it. +// Note: Plotly keeps a lot of data (traces, calculated data, SVG elements, event handlers, etc.) attached to our plot, +// so we need to purge it to make sure that all of that data gets released. + +vue.onBeforeUnmount(() => { + isUnmounted = true; + plotIsReady = false; + + for (const rafId of [marginsRafId, queueResizeRafId, resizeRafId]) { + if (rafId !== undefined) { + cancelAnimationFrame(rafId); + } + } + + marginsRafId = undefined; + queueResizeRafId = undefined; + resizeRafId = undefined; + + if (mainDivRef.value) { + dependencies._plotlyJs.purge(mainDivRef.value); + } +}); + vue.onUnmounted(() => { window.removeEventListener(CONTEXT_MENU_EVENT, handleContextMenu); From 809bcfd8a0456fbba24f289fe88ad819f64fa256 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 7 Oct 2026 19:05:30 +1300 Subject: [PATCH 015/184] File: fixed unmanage lookup for canonicalised paths. --- src/renderer/src/libopencor/src/file.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/libopencor/src/file.cpp b/src/renderer/src/libopencor/src/file.cpp index dbec52976..557f160e4 100644 --- a/src/renderer/src/libopencor/src/file.cpp +++ b/src/renderer/src/libopencor/src/file.cpp @@ -12,16 +12,16 @@ napi_value fileManagerHasFile(const Napi::CallbackInfo &pInfo) void fileManagerUnmanage(const Napi::CallbackInfo &pInfo) { - auto filePath = pInfo[0].ToString().Utf8Value(); + // Note: libOpenCOR canonicalises the path of a file (e.g., it resolves symbolic links and decodes URLs), so the + // given file path may differ from the path of the file, hence we retrieve the file through our file manager + // (which canonicalises the given file path) rather than compare file paths ourselves. - for (auto file : fileManager.files()) { - if (file->path() == filePath) { - files.erase(filePath); + auto file = fileManager.file(pInfo[0].ToString().Utf8Value()); - fileManager.unmanage(file); + if (file != nullptr) { + files.erase(file->path()); - break; - } + fileManager.unmanage(file); } } From e00a5e49bb3681bcbb2d4cc032ac42b007d4d2ad Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 7 Oct 2026 23:13:28 +1300 Subject: [PATCH 016/184] Hardened the opening of a file and its UI JSON handling. --- .../src/components/ContentsComponent.vue | 5 +- src/renderer/src/components/OpenCOR.vue | 85 ++++----- .../SimulationExperimentInteractiveView.vue | 49 ++++-- src/renderer/src/libopencor/locApi.ts | 1 + src/renderer/src/libopencor/locUiJsonApi.ts | 165 +++++++++++++++--- 5 files changed, 225 insertions(+), 80 deletions(-) diff --git a/src/renderer/src/components/ContentsComponent.vue b/src/renderer/src/components/ContentsComponent.vue index 5a05d6ab6..cddeb0e90 100644 --- a/src/renderer/src/components/ContentsComponent.vue +++ b/src/renderer/src/components/ContentsComponent.vue @@ -255,11 +255,12 @@ const selectPreviousFile = async (): Promise => { const openFile = async (file: locApi.File, wait: boolean = false): Promise => { const filePath = file.path(); const prevActiveFile = activeFile.value; + const uiJson = file.uiJson(); fileTabs.value.splice(fileTabs.value.findIndex((fileTab) => fileTab.file.path() === prevActiveFile) + 1, 0, { file: file, - uiJson: file.uiJson(), - activeViewId: file.uiJson() ? 'simulation-experiment-interactive' : 'simulation-experiment-standard' + uiJson: uiJson, + activeViewId: uiJson ? 'simulation-experiment-interactive' : 'simulation-experiment-standard' }); await selectFile(filePath, wait); diff --git a/src/renderer/src/components/OpenCOR.vue b/src/renderer/src/components/OpenCOR.vue index a20854a29..1d67175c8 100644 --- a/src/renderer/src/components/OpenCOR.vue +++ b/src/renderer/src/components/OpenCOR.vue @@ -686,6 +686,27 @@ interface IFileInfo { filePath: string; } +// A helper function to report an issue with a file that couldn't be opened. + +const reportFileIssue = (filePath: string, issueMessage: string): void => { + if (props.omex) { + vue.nextTick(() => { + issues.value.push({ type: locApi.EIssueType.ERROR, description: issueMessage }); + }); + } else { + addToast({ + severity: 'error', + summary: 'Opening a file', + detail: `${filePath}\n\n${issueMessage}`, + life: TOAST_LIFE + }); + } + + electronApi?.fileIssue(filePath); + + emit('file', { type: 'issue', filePath, issues: [issueMessage] }); +}; + const processFile = async (fileFilePathOrFileContents: string | Uint8Array | File): Promise => { // Check whether we were passed a ZIP-CellML data URL. @@ -760,27 +781,12 @@ const processFile = async (fileFilePathOrFileContents: string | Uint8Array | Fil fileType === locApi.EFileType.IRRETRIEVABLE_FILE || (props.omex && fileType !== locApi.EFileType.COMBINE_ARCHIVE) ) { - const issueMessage = + reportFileIssue( + filePath, fileType === locApi.EFileType.IRRETRIEVABLE_FILE ? 'The file could not be retrieved.' - : 'Only COMBINE archives are supported.'; - - if (props.omex) { - vue.nextTick(() => { - issues.value.push({ type: locApi.EIssueType.ERROR, description: issueMessage }); - }); - } else { - addToast({ - severity: 'error', - summary: 'Opening a file', - detail: `${filePath}\n\n${issueMessage}`, - life: TOAST_LIFE - }); - } - - electronApi?.fileIssue(filePath); - - emit('file', { type: 'issue', filePath, issues: [issueMessage] }); + : 'Only COMBINE archives are supported.' + ); file.release(); @@ -793,25 +799,7 @@ const processFile = async (fileFilePathOrFileContents: string | Uint8Array | Fil filePath }; } catch (error: unknown) { - if (props.omex) { - vue.nextTick(() => { - issues.value.push({ - type: locApi.EIssueType.ERROR, - description: common.formatMessage(common.formatError(error)) - }); - }); - } else { - addToast({ - severity: 'error', - summary: 'Opening a file', - detail: `${filePath}\n\n${common.formatMessage(common.formatError(error))}`, - life: TOAST_LIFE - }); - } - - electronApi?.fileIssue(filePath); - - emit('file', { type: 'issue', filePath, issues: [common.formatMessage(common.formatError(error))] }); + reportFileIssue(filePath, common.formatMessage(common.formatError(error))); return null; } finally { @@ -825,11 +813,26 @@ const processFile = async (fileFilePathOrFileContents: string | Uint8Array | Fil const openFileInContents = async (file: locApi.File, wait: boolean = false): Promise => { // Open the given file in our contents component or release it if we can't (e.g., we got unmounted in the meantime). + // Note: to open a file may fail (e.g., if libOpenCOR throws an exception), in which case we report the issue and + // release the file, unless it got opened (i.e. it has a file tab), in which case it is now managed by our + // contents component. Either way, the issue doesn't stop other files from being opened. - if (contentsRef.value) { - await contentsRef.value.openFile(file, wait); - } else { + if (!contentsRef.value) { file.release(); + + return; + } + + try { + await contentsRef.value.openFile(file, wait); + } catch (error: unknown) { + const filePath = file.path(); + + if (!contentsRef.value?.hasFile(filePath)) { + file.release(); + } + + reportFileIssue(filePath, common.formatMessage(common.formatError(error))); } }; diff --git a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue index 9c325ed6c..34ec4b093 100644 --- a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue +++ b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue @@ -289,26 +289,46 @@ const applyModelChanges = (modelChanges: IModelChange[]): void => { } }; +// Our UI JSON with the structure that our view relies on (i.e. one that can be safely accessed). If our original UI +// JSON doesn't have that structure (e.g., it has no output), then the parts that don't have it are replaced with empty +// equivalents while the other parts are kept as is, so that they can still be used (e.g., in our settings dialog). Any +// issue with our original UI JSON is then reported (see uiJsonIssues). + +const uiJsonWithExpectedStructure = props.uiJson ? locApi.uiJsonWithExpectedStructure(props.uiJson) : undefined; +const uiJsonStructureRepaired = vue.ref(uiJsonWithExpectedStructure?.repaired ?? false); + // Apply the model changes resulting from our default input values before instantiating our document, so that our first // simulation run can use our instance rather than have to instantiate our document again (see updateSimulation()). // Note: if those model changes result in our instance having issues, then we instantiate our document without them so // that our view remains usable (any issue with those model changes will be reported when running the simulation). +// Also, our UI JSON has yet to be validated at this stage, so we ignore any error that may occur while evaluating +// those model changes (any issue with our UI JSON will be reported once it has been validated). const NO_MODEL_CHANGES_KEY = JSON.stringify([]); let instanceModelChangesKey = NO_MODEL_CHANGES_KEY; -if (model) { - const initialModelScope: math.ExpressionScope = {}; +if (model && uiJsonWithExpectedStructure) { + try { + const initialModelScope: math.ExpressionScope = {}; - for (const input of props.uiJson?.input ?? []) { - initialModelScope[input.id] = input.defaultValue; - } + for (const input of uiJsonWithExpectedStructure.uiJson.input) { + initialModelScope[input.id] = input.defaultValue; + } - const initialModelChanges = evaluateModelChanges(props.uiJson?.parameters ?? [], initialModelScope, []); + const initialModelChanges = evaluateModelChanges( + uiJsonWithExpectedStructure.uiJson.parameters, + initialModelScope, + [] + ); - applyModelChanges(initialModelChanges); + applyModelChanges(initialModelChanges); - instanceModelChangesKey = JSON.stringify(initialModelChanges); + instanceModelChangesKey = JSON.stringify(initialModelChanges); + } catch (_error: unknown) { + applyModelChanges([]); + + instanceModelChangesKey = NO_MODEL_CHANGES_KEY; + } } let instance = isDocumentValid ? document.instantiate() : null; @@ -334,8 +354,8 @@ const voiName = vue.ref(instanceTask ? instanceTask.voiName() : ''); const voiId = vue.ref(instanceTask ? (voiName.value.split('/')[1] ?? '') : ''); const actualUiJson = vue.ref( - props.uiJson - ? (JSON.parse(JSON.stringify(props.uiJson, locApi.uiJsonReplacer)) as locApi.IUiJson) + uiJsonWithExpectedStructure + ? (JSON.parse(JSON.stringify(uiJsonWithExpectedStructure.uiJson, locApi.uiJsonReplacer)) as locApi.IUiJson) : { input: [], output: { @@ -352,6 +372,12 @@ const actualUiJson = vue.ref( ); const uiJsonEmpty = vue.computed(() => { + if (uiJsonStructureRepaired.value) { + // Our original UI JSON didn't have the expected structure, so it isn't empty, it's invalid (see uiJsonIssues). + + return false; + } + if ( actualUiJson.value.input.length === 0 && actualUiJson.value.parameters.length === 0 && @@ -378,7 +404,7 @@ let margins: Record = {}; const compMargins = vue.ref(); const uiJsonIssues = vue.ref( - locApi.validateUiJson(actualUiJson.value, { + locApi.validateUiJson(uiJsonStructureRepaired.value ? props.uiJson : actualUiJson.value, { allModelParameters: allModelParameters.value, editableModelParameters: editableModelParameters.value }) @@ -1874,6 +1900,7 @@ const onSettingsOk = (updatedSettings: ISimulationExperimentInteractiveViewSetti cvode?.setMaximumStep(updatedSettings.solvers.cvodeMaximumStep); actualUiJson.value = locApi.cleanUiJson(updatedSettings.interactive.uiJson); + uiJsonStructureRepaired.value = false; settingsVisible.value = false; // Validate the new UI JSON settings. diff --git a/src/renderer/src/libopencor/locApi.ts b/src/renderer/src/libopencor/locApi.ts index 7e2ce11d4..b2a475e78 100644 --- a/src/renderer/src/libopencor/locApi.ts +++ b/src/renderer/src/libopencor/locApi.ts @@ -157,6 +157,7 @@ export { isDiscreteInput, normaliseUiJson, uiJsonReplacer, + uiJsonWithExpectedStructure, validateUiJson } from './locUiJsonApi'; diff --git a/src/renderer/src/libopencor/locUiJsonApi.ts b/src/renderer/src/libopencor/locUiJsonApi.ts index 421eaa424..8511c23d4 100644 --- a/src/renderer/src/libopencor/locUiJsonApi.ts +++ b/src/renderer/src/libopencor/locUiJsonApi.ts @@ -90,43 +90,156 @@ export const uiJsonReplacer = (_key: string, value: unknown): unknown => { return value instanceof Float64Array ? Array.from(value) : value; }; -export const normaliseUiJson = (uiJson: IUiJson): IUiJson => { - for (const externalData of uiJson.output.externalData ?? []) { - if (!(externalData.voiValues instanceof Float64Array)) { - externalData.voiValues = new Float64Array(externalData.voiValues); +// A helper function to determine whether the given value is an object (and not an array). + +const isObject = (value: unknown): value is Record => { + return !!value && typeof value === 'object' && !Array.isArray(value); +}; + +// A helper function to return a version of the given UI JSON that has the structure that the rest of our code relies +// on, i.e. that can be safely accessed. Anything that doesn't have that structure (e.g., a missing output or a plot +// that is not an object) is replaced with an empty equivalent (or removed) while everything else is kept as is, so that +// it can still be used (e.g., in our settings dialog). +// Note: whether a UI JSON is valid is determined by validateUiJson(), which should therefore be given the original UI +// JSON so that all of its issues get reported. + +export interface IUiJsonWithExpectedStructure { + uiJson: IUiJson; + repaired: boolean; +} + +export const uiJsonWithExpectedStructure = (value: unknown): IUiJsonWithExpectedStructure => { + let repaired = false; + + const object = (value: unknown): Record => { + if (isObject(value)) { + return value; } - for (const dataSeries of externalData.dataSeries) { - if (!(dataSeries.values instanceof Float64Array)) { - dataSeries.values = new Float64Array(dataSeries.values); - } + repaired = true; + + return {}; + }; + + const objects = (value: unknown): Record[] => { + if (!Array.isArray(value)) { + repaired = true; + + return []; } - } - return uiJson; -}; + const res = value.filter(isObject); -const uiJsonForSchemaValidation = (uiJson: IUiJson): unknown => { - if (!uiJson.output.externalData?.length) { - return uiJson; - } + if (res.length !== value.length) { + repaired = true; + } - return { + return res; + }; + + const uiJson = object(value); + const output = object(uiJson.output); + const res: Record = { ...uiJson, + input: objects(uiJson.input), output: { - ...uiJson.output, - externalData: uiJson.output.externalData.map((externalData) => ({ - ...externalData, - voiValues: Array.from(externalData.voiValues), - dataSeries: externalData.dataSeries.map((dataSeries) => ({ - ...dataSeries, - values: Array.from(dataSeries.values) - })) - })) - } + ...output, + data: objects(output.data), + plots: objects(output.plots), + ...(output.externalData === undefined + ? {} + : { + externalData: objects(output.externalData).map((externalData) => { + return { + ...externalData, + data: objects(externalData.data), + dataSeries: objects(externalData.dataSeries) + }; + }) + }) + }, + parameters: objects(uiJson.parameters) + }; + + return { + uiJson: res as unknown as IUiJson, + repaired }; }; +// Some helper functions to convert values to/from a Float64Array. +// Note: we only convert arrays (to a Float64Array) and Float64Arrays (from a Float64Array), leaving other values as +// they are so that validateUiJson() can report them as they are. This also means that we never call +// new Float64Array(n), which would create an array of n zeros and therefore could result in a huge allocation if +// n was some (invalid) large number. + +const toFloat64Array = (values: unknown): unknown => { + return Array.isArray(values) ? new Float64Array(values) : values; +}; + +const fromFloat64Array = (values: unknown): unknown => { + return values instanceof Float64Array ? Array.from(values) : values; +}; + +// A helper function to map the external data of a UI JSON, if it has any. +// Note: the UI JSON may not have the expected structure, in which case we only map what can be mapped. + +const mapExternalData = ( + uiJson: IUiJson, + mapValues: (values: unknown) => unknown, + inPlace: boolean +): IUiJson | undefined => { + const output: unknown = (uiJson as unknown as Record).output; + + if (!isObject(output) || !Array.isArray(output.externalData) || !output.externalData.length) { + return undefined; + } + + const externalData = output.externalData.map((externalDataItem: unknown) => { + if (!isObject(externalDataItem)) { + return externalDataItem; + } + + const res = inPlace ? externalDataItem : { ...externalDataItem }; + + res.voiValues = mapValues(externalDataItem.voiValues); + + if (Array.isArray(externalDataItem.dataSeries)) { + res.dataSeries = externalDataItem.dataSeries.map((dataSeries: unknown) => { + if (!isObject(dataSeries)) { + return dataSeries; + } + + const dataSeriesRes = inPlace ? dataSeries : { ...dataSeries }; + + dataSeriesRes.values = mapValues(dataSeries.values); + + return dataSeriesRes; + }); + } + + return res; + }); + + if (inPlace) { + output.externalData = externalData; + + return uiJson; + } + + return { ...uiJson, output: { ...output, externalData } } as unknown as IUiJson; +}; + +export const normaliseUiJson = (uiJson: IUiJson): IUiJson => { + mapExternalData(uiJson, toFloat64Array, true); + + return uiJson; +}; + +const uiJsonForSchemaValidation = (uiJson: IUiJson): unknown => { + return mapExternalData(uiJson, fromFloat64Array, false) ?? uiJson; +}; + export const cleanUiJson = (uiJson: IUiJson): IUiJson => { const cleanUiJsonRec = (value: unknown): unknown => { if (value === undefined || value === null || (typeof value === 'string' && value === '')) { From 18a6f8eb1eb1d0446e13f0a75553165648b7a0af Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 7 Oct 2026 23:30:16 +1300 Subject: [PATCH 017/184] Prevent a file from being open more than once. --- src/renderer/src/components/OpenCOR.vue | 44 +++++++++++++++++-------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/OpenCOR.vue b/src/renderer/src/components/OpenCOR.vue index 1d67175c8..fdf6ab0bf 100644 --- a/src/renderer/src/components/OpenCOR.vue +++ b/src/renderer/src/components/OpenCOR.vue @@ -679,6 +679,7 @@ const onUpdateAvailable = () => { // Open a file. let globalOmexDataUrlCounter = 0; +const filePathsBeingOpened = new Set(); interface IFileInfo { alreadyOpen: boolean; @@ -751,11 +752,16 @@ const processFile = async (fileFilePathOrFileContents: string | Uint8Array | Fil } } - // Check whether the file is already open and if so then select it. + // Check whether the file is already open or being opened and if so then select it. + // Note: a file may be opened more than once at the same time (e.g., by dropping the same file twice or by selecting + // the same sample file twice). Since a file is only considered open once it has a file tab, we also need to + // keep track of the files that are being opened, so that we don't end up with several file tabs for the same + // file (and, with the C++ version of libOpenCOR, several File objects sharing, and therefore releasing, the + // same native file). const filePath = locCommon.filePath(fileFilePathOrFileContents, cellmlDataUrlFileName, omexDataUrlCounter); - if (contentsRef.value?.hasFile(filePath) ?? false) { + if ((contentsRef.value?.hasFile(filePath) ?? false) || filePathsBeingOpened.has(filePath)) { return { alreadyOpen: true, file: null, @@ -763,6 +769,8 @@ const processFile = async (fileFilePathOrFileContents: string | Uint8Array | Fil }; } + filePathsBeingOpened.add(filePath); + // Retrieve a locApi.File object for the given file or file path. const isRemoteFilePath = common.isUrl(filePath); @@ -790,6 +798,8 @@ const processFile = async (fileFilePathOrFileContents: string | Uint8Array | Fil file.release(); + filePathsBeingOpened.delete(filePath); + return null; } @@ -801,6 +811,8 @@ const processFile = async (fileFilePathOrFileContents: string | Uint8Array | Fil } catch (error: unknown) { reportFileIssue(filePath, common.formatMessage(common.formatError(error))); + filePathsBeingOpened.delete(filePath); + return null; } finally { if (isRemoteFilePath) { @@ -813,26 +825,29 @@ const processFile = async (fileFilePathOrFileContents: string | Uint8Array | Fil const openFileInContents = async (file: locApi.File, wait: boolean = false): Promise => { // Open the given file in our contents component or release it if we can't (e.g., we got unmounted in the meantime). - // Note: to open a file may fail (e.g., if libOpenCOR throws an exception), in which case we report the issue and - // release the file, unless it got opened (i.e. it has a file tab), in which case it is now managed by our - // contents component. Either way, the issue doesn't stop other files from being opened. + // Note #1: to open a file may fail (e.g., if libOpenCOR throws an exception), in which case we report the issue and + // release the file, unless it got opened (i.e. it has a file tab), in which case it is now managed by our + // contents component. Either way, the issue doesn't stop other files from being opened. + // Note #2: either way, the file is no longer being opened once we are done (see processFile()). - if (!contentsRef.value) { - file.release(); - - return; - } + const filePath = file.path(); try { + if (!contentsRef.value) { + file.release(); + + return; + } + await contentsRef.value.openFile(file, wait); } catch (error: unknown) { - const filePath = file.path(); - if (!contentsRef.value?.hasFile(filePath)) { file.release(); } reportFileIssue(filePath, common.formatMessage(common.formatError(error))); + } finally { + filePathsBeingOpened.delete(filePath); } }; @@ -862,7 +877,10 @@ const openFiles = (filesFilePathsOrFileContents: (string | Uint8Array | File)[]) }); filePromises.reduce(async (previousFilePromises, currentFilePromise) => { - await previousFilePromises; + // Note: we ignore any error with the previous files so that it doesn't prevent the current file from being opened + // (and therefore from no longer being considered as being opened, see processFile()). + + await previousFilePromises.catch(() => {}); const currentFileInfo = await currentFilePromise; From a7bfa4f7534453adf71ff131941ba52fc99da734 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 7 Oct 2026 23:42:07 +1300 Subject: [PATCH 018/184] Stop a SED-ML run before releasing its SED-ML instance. --- src/renderer/src/libopencor/locSedApi.ts | 13 ++++++++++--- src/renderer/src/libopencor/src/sed.cpp | 12 +++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/libopencor/locSedApi.ts b/src/renderer/src/libopencor/locSedApi.ts index 257aef60b..c3d1a9624 100644 --- a/src/renderer/src/libopencor/locSedApi.ts +++ b/src/renderer/src/libopencor/locSedApi.ts @@ -602,9 +602,12 @@ export class SedInstance { } // Release the resources held by the instance (and its tasks). - // Note: this must only be done once the instance is idle and no longer used. Not releasing an instance means that it - // (and its tasks) never gets freed, which with the WASM version of libOpenCOR eventually results in an - // out-of-memory error. + // Note #1: this should only be done once the instance is idle and no longer used. Not releasing an instance means + // that it (and its tasks) never gets freed, which with the WASM version of libOpenCOR eventually results in + // an out-of-memory error. + // Note #2: libOpenCOR waits for a run to finish before deleting an instance, so we stop any run first. Indeed, a + // paused run would otherwise never finish (and therefore hang the UI) while a running one would block the UI + // until it finishes. release(): void { if (this._released) { @@ -613,6 +616,10 @@ export class SedInstance { this._released = true; + if (this.status() !== ESedInstanceStatus.IDLE) { + this.stopRun(); + } + for (const task of this._tasks) { task.release(); } diff --git a/src/renderer/src/libopencor/src/sed.cpp b/src/renderer/src/libopencor/src/sed.cpp index e3e9ffc1a..d603b9945 100644 --- a/src/renderer/src/libopencor/src/sed.cpp +++ b/src/renderer/src/libopencor/src/sed.cpp @@ -325,7 +325,17 @@ void sedInstanceStopRun(const Napi::CallbackInfo &pInfo) void sedInstanceRelease(const Napi::CallbackInfo &pInfo) { - sedInstances.erase(toSizeT(pInfo[0])); + // Note: libOpenCOR waits for a run to finish before deleting an instance, so we stop any run first. Indeed, a + // paused run would otherwise never finish (and therefore hang the renderer process) while a running one would + // block the renderer process until it finishes. + + auto iter = sedInstances.find(toSizeT(pInfo[0])); + + if (iter != sedInstances.end()) { + iter->second->stopRun(); + + sedInstances.erase(iter); + } } // SedInstanceTask API. From 7a4741030401822474b003cbdad7758957a318f9 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 7 Oct 2026 23:53:20 +1300 Subject: [PATCH 019/184] Normalise cloned UI JSON and settings snapshots. Wrap the deep-cloned interactive UI JSON with `locApi.normaliseUiJson` so external data values are restored to `Float64Array` instead of Vue-tracked plain arrays. Also serialise both old and new settings with `locApi.uiJsonReplacer` to keep JSON comparisons consistent for change detection. --- .../views/SimulationExperimentInteractiveView.vue | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue index 34ec4b093..8166dfb67 100644 --- a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue +++ b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue @@ -354,8 +354,15 @@ const voiName = vue.ref(instanceTask ? instanceTask.voiName() : ''); const voiId = vue.ref(instanceTask ? (voiName.value.split('/')[1] ?? '') : ''); const actualUiJson = vue.ref( + // Note: we deep clone our UI JSON using JSON serialisation, which means that we need to normalise it afterwards so + // that our external data values are Float64Arrays again rather than plain arrays. Indeed, plain arrays would be + // deeply tracked by Vue (i.e. element by element), which would be both slow and memory hungry for large + // external data. + uiJsonWithExpectedStructure - ? (JSON.parse(JSON.stringify(uiJsonWithExpectedStructure.uiJson, locApi.uiJsonReplacer)) as locApi.IUiJson) + ? locApi.normaliseUiJson( + JSON.parse(JSON.stringify(uiJsonWithExpectedStructure.uiJson, locApi.uiJsonReplacer)) as locApi.IUiJson + ) : { input: [], output: { @@ -534,7 +541,7 @@ const preSimulationDuration = vue.computed(() => { return settingsVal.simulation.startingPoint - settingsVal.simulation.initialPoint; }); -const oldSettings = vue.ref(JSON.stringify(vue.toRaw(settings.value))); +const oldSettings = vue.ref(JSON.stringify(vue.toRaw(settings.value), locApi.uiJsonReplacer)); // Information issue shown when an interactive simulation error occurs. @@ -1867,7 +1874,7 @@ const onSettingsOk = (updatedSettings: ISimulationExperimentInteractiveViewSetti return; } - const newSettingsJson = JSON.stringify(vue.toRaw(updatedSettings)); + const newSettingsJson = JSON.stringify(vue.toRaw(updatedSettings), locApi.uiJsonReplacer); const settingsHaveChanges = newSettingsJson !== oldSettings.value; oldSettings.value = newSettingsJson; From 029f46380cf0bd7bf69c39dd541133e6a873c6c1 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 00:06:49 +1300 Subject: [PATCH 020/184] Optimised UI JSON cloning and settings change detection. --- ...xperimentInteractiveViewSettingsDialog.vue | 10 ++--- .../SimulationExperimentInteractiveView.vue | 30 ++++++++++---- src/renderer/src/libopencor/locApi.ts | 1 + src/renderer/src/libopencor/locUiJsonApi.ts | 41 +++++++++++++++++-- 4 files changed, 64 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/components/dialogs/SimulationExperimentInteractiveViewSettingsDialog.vue b/src/renderer/src/components/dialogs/SimulationExperimentInteractiveViewSettingsDialog.vue index 91315a971..b6a49ca38 100644 --- a/src/renderer/src/components/dialogs/SimulationExperimentInteractiveViewSettingsDialog.vue +++ b/src/renderer/src/components/dialogs/SimulationExperimentInteractiveViewSettingsDialog.vue @@ -892,13 +892,11 @@ const DEFAULT_INTERACTIVE_TAB = 'simulationInputs'; function deepCloneSettings( settings: ISimulationExperimentInteractiveViewSettingsDialog ): ISimulationExperimentInteractiveViewSettingsDialog { - // Perform a deep clone of our settings using JSON serialisation. - // Note: we use our custom replacer to make sure that any typed arrays (e.g., Float64Array) in our UI JSON are - // correctly serialised and deserialised. + // Perform a deep clone of our settings. + // Note: our external data values (i.e. Float64Arrays) are shared rather than copied (see cloneUiJson()), which is + // fine since we never modify them in place. - const deepClonedSettings = JSON.parse( - JSON.stringify(settings, locApi.uiJsonReplacer) - ) as ISimulationExperimentInteractiveViewSettingsDialog; + const deepClonedSettings = locApi.cloneUiJson(settings); // Make sure that our UI JSON has the expected structure. diff --git a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue index 8166dfb67..27c3d0b84 100644 --- a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue +++ b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue @@ -354,15 +354,13 @@ const voiName = vue.ref(instanceTask ? instanceTask.voiName() : ''); const voiId = vue.ref(instanceTask ? (voiName.value.split('/')[1] ?? '') : ''); const actualUiJson = vue.ref( - // Note: we deep clone our UI JSON using JSON serialisation, which means that we need to normalise it afterwards so - // that our external data values are Float64Arrays again rather than plain arrays. Indeed, plain arrays would be - // deeply tracked by Vue (i.e. element by element), which would be both slow and memory hungry for large - // external data. + // Note: we deep clone our UI JSON, sharing its external data values rather than copying them (see cloneUiJson()), + // and normalise it to make sure that its external data values are Float64Arrays rather than plain arrays. + // Indeed, plain arrays would be deeply tracked by Vue (i.e. element by element), which would be both slow and + // memory hungry for large external data. uiJsonWithExpectedStructure - ? locApi.normaliseUiJson( - JSON.parse(JSON.stringify(uiJsonWithExpectedStructure.uiJson, locApi.uiJsonReplacer)) as locApi.IUiJson - ) + ? locApi.normaliseUiJson(locApi.cloneUiJson(uiJsonWithExpectedStructure.uiJson)) : { input: [], output: { @@ -541,7 +539,21 @@ const preSimulationDuration = vue.computed(() => { return settingsVal.simulation.startingPoint - settingsVal.simulation.initialPoint; }); -const oldSettings = vue.ref(JSON.stringify(vue.toRaw(settings.value), locApi.uiJsonReplacer)); +// A helper function to serialise some settings so that they can be compared with some other settings. +// Note: our settings dialog adds an empty list of external data to a UI JSON that doesn't have any, so we ignore an +// empty list of external data to avoid detecting changes where there are none. + +const serialisedSettings = (settingsToSerialise: ISimulationExperimentInteractiveViewSettingsDialog): string => { + return JSON.stringify(vue.toRaw(settingsToSerialise), (key: string, value: unknown): unknown => { + if (key === 'externalData' && Array.isArray(value) && !value.length) { + return undefined; + } + + return locApi.uiJsonReplacer(key, value); + }); +}; + +const oldSettings = vue.ref(serialisedSettings(settings.value)); // Information issue shown when an interactive simulation error occurs. @@ -1874,7 +1886,7 @@ const onSettingsOk = (updatedSettings: ISimulationExperimentInteractiveViewSetti return; } - const newSettingsJson = JSON.stringify(vue.toRaw(updatedSettings), locApi.uiJsonReplacer); + const newSettingsJson = serialisedSettings(updatedSettings); const settingsHaveChanges = newSettingsJson !== oldSettings.value; oldSettings.value = newSettingsJson; diff --git a/src/renderer/src/libopencor/locApi.ts b/src/renderer/src/libopencor/locApi.ts index b2a475e78..6ae5f016b 100644 --- a/src/renderer/src/libopencor/locApi.ts +++ b/src/renderer/src/libopencor/locApi.ts @@ -153,6 +153,7 @@ export { type IUiJsonParameter, type IUiJsonScalarInput, cleanUiJson, + cloneUiJson, isScalarInput, isDiscreteInput, normaliseUiJson, diff --git a/src/renderer/src/libopencor/locUiJsonApi.ts b/src/renderer/src/libopencor/locUiJsonApi.ts index 8511c23d4..9cc2e54bf 100644 --- a/src/renderer/src/libopencor/locUiJsonApi.ts +++ b/src/renderer/src/libopencor/locUiJsonApi.ts @@ -177,8 +177,17 @@ const toFloat64Array = (values: unknown): unknown => { return Array.isArray(values) ? new Float64Array(values) : values; }; -const fromFloat64Array = (values: unknown): unknown => { - return values instanceof Float64Array ? Array.from(values) : values; +const fromFloat64ArrayForSchemaValidation = (values: unknown): unknown => { + // Note: a Float64Array can only contain numbers, so for schema validation purposes, we only need an array that has + // the same "emptiness" and that contains numbers. This means that we don't have to convert (and then validate) + // all the values of a Float64Array, which would be costly for large external data. (The actual length of a + // Float64Array is checked separately, see validateUiJson().) + + if (!(values instanceof Float64Array)) { + return values; + } + + return values.length ? [values[0]] : []; }; // A helper function to map the external data of a UI JSON, if it has any. @@ -237,7 +246,33 @@ export const normaliseUiJson = (uiJson: IUiJson): IUiJson => { }; const uiJsonForSchemaValidation = (uiJson: IUiJson): unknown => { - return mapExternalData(uiJson, fromFloat64Array, false) ?? uiJson; + return mapExternalData(uiJson, fromFloat64ArrayForSchemaValidation, false) ?? uiJson; +}; + +// A helper function to deep clone a UI JSON (or some settings containing a UI JSON). +// Note: Float64Arrays (i.e. external data values) are shared rather than copied since they are never modified in place +// (they are only ever replaced), which avoids copying potentially large external data. + +export const cloneUiJson = (value: T): T => { + if (value instanceof Float64Array) { + return value; + } + + if (Array.isArray(value)) { + return value.map(cloneUiJson) as T; + } + + if (value && typeof value === 'object') { + const res: Record = {}; + + for (const [key, keyValue] of Object.entries(value)) { + res[key] = cloneUiJson(keyValue); + } + + return res as T; + } + + return value; }; export const cleanUiJson = (uiJson: IUiJson): IUiJson => { From a097219e933719a17c564da565952f74fc44e430 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 00:27:39 +1300 Subject: [PATCH 021/184] Optimised simulation data lookup and retrieval. --- src/renderer/src/common/locCommon.ts | 68 ++++++++++++++-- src/renderer/src/common/math.ts | 20 ++++- .../SimulationExperimentInteractiveView.vue | 79 ++++++++++++++----- 3 files changed, 143 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/common/locCommon.ts b/src/renderer/src/common/locCommon.ts index 4fa19192a..3d7d2d74f 100644 --- a/src/renderer/src/common/locCommon.ts +++ b/src/renderer/src/common/locCommon.ts @@ -306,7 +306,9 @@ export const simulationDataInfo = (instanceTask: locApi.SedInstanceTask, name: s }; } - for (let i = 0; i < instanceTask.stateCount(); i++) { + const stateCount = instanceTask.stateCount(); + + for (let i = 0; i < stateCount; ++i) { if (name === instanceTask.stateName(i)) { return { type: ESimulationDataInfoType.STATE, @@ -315,7 +317,9 @@ export const simulationDataInfo = (instanceTask: locApi.SedInstanceTask, name: s } } - for (let i = 0; i < instanceTask.rateCount(); i++) { + const rateCount = instanceTask.rateCount(); + + for (let i = 0; i < rateCount; ++i) { if (name === instanceTask.rateName(i)) { return { type: ESimulationDataInfoType.RATE, @@ -324,7 +328,9 @@ export const simulationDataInfo = (instanceTask: locApi.SedInstanceTask, name: s } } - for (let i = 0; i < instanceTask.constantCount(); i++) { + const constantCount = instanceTask.constantCount(); + + for (let i = 0; i < constantCount; ++i) { if (name === instanceTask.constantName(i)) { return { type: ESimulationDataInfoType.CONSTANT, @@ -333,7 +339,9 @@ export const simulationDataInfo = (instanceTask: locApi.SedInstanceTask, name: s } } - for (let i = 0; i < instanceTask.computedConstantCount(); i++) { + const computedConstantCount = instanceTask.computedConstantCount(); + + for (let i = 0; i < computedConstantCount; ++i) { if (name === instanceTask.computedConstantName(i)) { return { type: ESimulationDataInfoType.COMPUTED_CONSTANT, @@ -342,7 +350,9 @@ export const simulationDataInfo = (instanceTask: locApi.SedInstanceTask, name: s } } - for (let i = 0; i < instanceTask.algebraicVariableCount(); i++) { + const algebraicVariableCount = instanceTask.algebraicVariableCount(); + + for (let i = 0; i < algebraicVariableCount; ++i) { if (name === instanceTask.algebraicVariableName(i)) { return { type: ESimulationDataInfoType.ALGEBRAIC, @@ -354,6 +364,54 @@ export const simulationDataInfo = (instanceTask: locApi.SedInstanceTask, name: s return NoSimulationDataInfo; }; +// A method to retrieve the simulation data information of all the simulation data of an instance task, indexed by name. +// Note: this is much faster than calling simulationDataInfo() for several names since retrieving a name requires a call +// to libOpenCOR (and, with the C++ version of libOpenCOR, a round trip through our preload script). Also, should +// several simulation data share the same name, the first one wins, as with simulationDataInfo(). + +export const simulationDataInfos = (instanceTask: locApi.SedInstanceTask): Map => { + const res = new Map(); + const addSimulationDataInfo = (name: string, type: ESimulationDataInfoType, index: number): void => { + if (name && !res.has(name)) { + res.set(name, { type, index }); + } + }; + + addSimulationDataInfo(instanceTask.voiName(), ESimulationDataInfoType.VOI, -1); + + const stateCount = instanceTask.stateCount(); + + for (let i = 0; i < stateCount; ++i) { + addSimulationDataInfo(instanceTask.stateName(i), ESimulationDataInfoType.STATE, i); + } + + const rateCount = instanceTask.rateCount(); + + for (let i = 0; i < rateCount; ++i) { + addSimulationDataInfo(instanceTask.rateName(i), ESimulationDataInfoType.RATE, i); + } + + const constantCount = instanceTask.constantCount(); + + for (let i = 0; i < constantCount; ++i) { + addSimulationDataInfo(instanceTask.constantName(i), ESimulationDataInfoType.CONSTANT, i); + } + + const computedConstantCount = instanceTask.computedConstantCount(); + + for (let i = 0; i < computedConstantCount; ++i) { + addSimulationDataInfo(instanceTask.computedConstantName(i), ESimulationDataInfoType.COMPUTED_CONSTANT, i); + } + + const algebraicVariableCount = instanceTask.algebraicVariableCount(); + + for (let i = 0; i < algebraicVariableCount; ++i) { + addSimulationDataInfo(instanceTask.algebraicVariableName(i), ESimulationDataInfoType.ALGEBRAIC, i); + } + + return res; +}; + // A method to retrieve the simulation data value for a given name from an instance task. export const simulationDataValue = ( diff --git a/src/renderer/src/common/math.ts b/src/renderer/src/common/math.ts index ed434552a..b7d204a23 100644 --- a/src/renderer/src/common/math.ts +++ b/src/renderer/src/common/math.ts @@ -29,6 +29,24 @@ interface IMathJsLike { } const ELEMENTWISE_REGEX = /\.([*/^])/g; +// Note: we match numbers as well as identifiers so that the exponent of a number in scientific notation (e.g., e3 in +// 2e3) is not mistaken for an identifier while still allowing for implicit multiplication (e.g., 2x or 2e). + +const NUMBER_OR_IDENTIFIER_REGEX = /(\d*\.?\d+(?:[eE][+-]?\d+)?)|([A-Za-z_$][A-Za-z0-9_$]*)/g; + +// A helper function to retrieve the identifiers (i.e. variables, functions, and constants) used in an expression. + +export const expressionIdentifiers = (expression: string): string[] => { + const res: string[] = []; + + for (const match of expression.matchAll(NUMBER_OR_IDENTIFIER_REGEX)) { + if (match[2]) { + res.push(match[2]); + } + } + + return res; +}; // The functions and constants that can be used in an expression. @@ -895,7 +913,7 @@ export class Float64ArrayMath { const normalisedExpression = this.normaliseExpression(expression); if (!this.validatedTokens.has(normalisedExpression)) { - const tokens = expression.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []; + const tokens = expressionIdentifiers(expression); const allowedVariables = new Set(Object.keys(scope)); const seenTokens = new Set(); diff --git a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue index 27c3d0b84..84832703d 100644 --- a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue +++ b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue @@ -185,7 +185,11 @@ import * as vueusecore from '@vueuse/core'; import Popover from 'primevue/popover'; import * as vue from 'vue'; -import type { IOpenCORExternalDataEvent, IOpenCORSimulationDataEvent } from '../../../index'; +import type { + IOpenCORExternalDataEvent, + IOpenCORSimulationDataEvent, + IOpenCORSimulationDataValue +} from '../../../index'; import * as colors from '../../common/colors'; import * as common from '../../common/common'; @@ -420,6 +424,37 @@ const inputValues = vue.ref([]); const showInput = vue.ref([]); const idToInfo: Record = {}; +// A helper function to retrieve the simulation data information for the given name. +// Note: we build (and cache) the simulation data information of all our simulation data the first time we need it. +// Indeed, it is much faster than looking up simulation data information by name every time (see +// locCommon.simulationDataInfos()). Also, the names of our simulation data don't change when we reinstantiate our +// document (since our model changes and settings only affect values), so our cache remains valid for as long as +// our document is used. + +let simulationDataInfosCache: Map | null = null; + +const simulationDataInfo = (task: locSedApi.SedInstanceTask, name: string): locCommon.ISimulationDataInfo => { + simulationDataInfosCache ??= locCommon.simulationDataInfos(task); + + return simulationDataInfosCache.get(name) ?? locCommon.NoSimulationDataInfo; +}; + +// A helper function to retrieve the simulation data value for the given simulation data information. +// Note: with the WASM version of libOpenCOR, the simulation data may be a view of the WASM heap, so we need to copy it. +// Otherwise, it would become a dangling view (with potentially corrupted data) as soon as the corresponding +// instance gets released (e.g., when it gets reinstantiated by a newer simulation run), which would in turn mess +// up our plots (in particular those of our tracked runs). With the C++ version of libOpenCOR, the simulation data +// is already a copy that we own, so there is no need to copy it again. + +const simulationDataValue = ( + task: locSedApi.SedInstanceTask, + info: locCommon.ISimulationDataInfo +): IOpenCORSimulationDataValue => { + const res = locCommon.simulationDataValue(task, info); + + return locApi.cppVersion() ? res : { ...res, data: new Float64Array(res.data) }; +}; + const runs = vue.ref([ { id: 'live', @@ -932,7 +967,7 @@ const simulationData = (modelParameters: string[]): Promise actualUiJson.value.output.data.forEach((data: locApi.IUiJsonOutputData) => { if (data.id && instanceTask) { - idToInfo[data.id] = locCommon.simulationDataInfo(instanceTask, data.name); + idToInfo[data.id] = simulationDataInfo(instanceTask, data.name); } }); // Update our scope with the latest simulation data. + // Note: we only retrieve the simulation data that is used by our plots since retrieving simulation data means copying + // it (see simulationDataValue()), which can be costly for large simulations. if (instanceTask) { - // Latest simulation data. - // Note: we make a copy of the data since, with the WASM version of libOpenCOR, it may be a view of the WASM heap. - // If we were to keep it as is, it would become a dangling view (with potentially corrupted data) as soon as - // the corresponding instance gets reinstantiated and destroyed by a newer simulation run, which would in turn - // mess up our plots (in particular those of our tracked runs). + const usedDataIds = new Set(); + + for (const plot of actualUiJson.value.output.plots) { + const expressions = [plot.xValue, plot.yValue]; + + for (const additionalTrace of plot.additionalTraces ?? []) { + expressions.push(additionalTrace.xValue, additionalTrace.yValue); + } + + for (const expression of expressions) { + for (const identifier of math.expressionIdentifiers(expression ?? '')) { + usedDataIds.add(identifier); + } + } + } for (const data of actualUiJson.value.output.data) { - modelScope[data.id] = new Float64Array(locCommon.simulationDataValue(instanceTask, idToInfo[data.id]).data); + const info = idToInfo[data.id]; + + if (info && usedDataIds.has(data.id)) { + modelScope[data.id] = simulationDataValue(instanceTask, info).data; + } } } From 4242c386e972a2d78c3282411fb9bb5b873c05f2 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 01:03:05 +1300 Subject: [PATCH 022/184] Added support for partial simulation data and also refined margin synchronisation. --- src/preload/index.ts | 23 ++++----- src/renderer/src/common/locCommon.ts | 18 ++++--- .../SimulationExperimentInteractiveView.vue | 8 +++- .../SimulationExperimentStandardView.vue | 17 +++++-- .../components/widgets/GraphPanelWidget.vue | 33 +++++-------- src/renderer/src/libopencor/locApi.ts | 37 ++++++++++++--- src/renderer/src/libopencor/locSedApi.ts | 47 ++++++++++++------- src/renderer/src/libopencor/src/sed.cpp | 25 +++++++--- 8 files changed, 132 insertions(+), 76 deletions(-) diff --git a/src/preload/index.ts b/src/preload/index.ts index ee25db654..082c9aa6e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -286,43 +286,44 @@ electron.contextBridge.exposeInMainWorld('locApi', { sedInstanceTaskVoiName: (instanceId: number, index: number) => loc.sedInstanceTaskVoiName(instanceId, index), sedInstanceTaskVoiUnit: (instanceId: number, index: number) => loc.sedInstanceTaskVoiUnit(instanceId, index), - sedInstanceTaskVoi: (instanceId: number, index: number) => loc.sedInstanceTaskVoi(instanceId, index), + sedInstanceTaskVoi: (instanceId: number, index: number, count?: number) => + loc.sedInstanceTaskVoi(instanceId, index, count), sedInstanceTaskStateCount: (instanceId: number, index: number) => loc.sedInstanceTaskStateCount(instanceId, index), sedInstanceTaskStateName: (instanceId: number, index: number, stateIndex: number) => loc.sedInstanceTaskStateName(instanceId, index, stateIndex), sedInstanceTaskStateUnit: (instanceId: number, index: number, stateIndex: number) => loc.sedInstanceTaskStateUnit(instanceId, index, stateIndex), - sedInstanceTaskState: (instanceId: number, index: number, stateIndex: number) => - loc.sedInstanceTaskState(instanceId, index, stateIndex), + sedInstanceTaskState: (instanceId: number, index: number, stateIndex: number, count?: number) => + loc.sedInstanceTaskState(instanceId, index, stateIndex, count), sedInstanceTaskRateCount: (instanceId: number, index: number) => loc.sedInstanceTaskRateCount(instanceId, index), sedInstanceTaskRateName: (instanceId: number, index: number, rateIndex: number) => loc.sedInstanceTaskRateName(instanceId, index, rateIndex), sedInstanceTaskRateUnit: (instanceId: number, index: number, rateIndex: number) => loc.sedInstanceTaskRateUnit(instanceId, index, rateIndex), - sedInstanceTaskRate: (instanceId: number, index: number, rateIndex: number) => - loc.sedInstanceTaskRate(instanceId, index, rateIndex), + sedInstanceTaskRate: (instanceId: number, index: number, rateIndex: number, count?: number) => + loc.sedInstanceTaskRate(instanceId, index, rateIndex, count), sedInstanceTaskConstantCount: (instanceId: number, index: number) => loc.sedInstanceTaskConstantCount(instanceId, index), sedInstanceTaskConstantName: (instanceId: number, index: number, constantIndex: number) => loc.sedInstanceTaskConstantName(instanceId, index, constantIndex), sedInstanceTaskConstantUnit: (instanceId: number, index: number, constantIndex: number) => loc.sedInstanceTaskConstantUnit(instanceId, index, constantIndex), - sedInstanceTaskConstant: (instanceId: number, index: number, constantIndex: number) => - loc.sedInstanceTaskConstant(instanceId, index, constantIndex), + sedInstanceTaskConstant: (instanceId: number, index: number, constantIndex: number, count?: number) => + loc.sedInstanceTaskConstant(instanceId, index, constantIndex, count), sedInstanceTaskComputedConstantCount: (instanceId: number, index: number) => loc.sedInstanceTaskComputedConstantCount(instanceId, index), sedInstanceTaskComputedConstantName: (instanceId: number, index: number, computedConstantIndex: number) => loc.sedInstanceTaskComputedConstantName(instanceId, index, computedConstantIndex), sedInstanceTaskComputedConstantUnit: (instanceId: number, index: number, computedConstantIndex: number) => loc.sedInstanceTaskComputedConstantUnit(instanceId, index, computedConstantIndex), - sedInstanceTaskComputedConstant: (instanceId: number, index: number, computedConstantIndex: number) => - loc.sedInstanceTaskComputedConstant(instanceId, index, computedConstantIndex), + sedInstanceTaskComputedConstant: (instanceId: number, index: number, computedConstantIndex: number, count?: number) => + loc.sedInstanceTaskComputedConstant(instanceId, index, computedConstantIndex, count), sedInstanceTaskAlgebraicVariableCount: (instanceId: number, index: number) => loc.sedInstanceTaskAlgebraicVariableCount(instanceId, index), sedInstanceTaskAlgebraicVariableName: (instanceId: number, index: number, algebraicVariableIndex: number) => loc.sedInstanceTaskAlgebraicVariableName(instanceId, index, algebraicVariableIndex), sedInstanceTaskAlgebraicVariableUnit: (instanceId: number, index: number, algebraicVariableIndex: number) => loc.sedInstanceTaskAlgebraicVariableUnit(instanceId, index, algebraicVariableIndex), - sedInstanceTaskAlgebraicVariable: (instanceId: number, index: number, algebraicVariableIndex: number) => - loc.sedInstanceTaskAlgebraicVariable(instanceId, index, algebraicVariableIndex) + sedInstanceTaskAlgebraicVariable: (instanceId: number, index: number, algebraicVariableIndex: number, count?: number) => + loc.sedInstanceTaskAlgebraicVariable(instanceId, index, algebraicVariableIndex, count) }); diff --git a/src/renderer/src/common/locCommon.ts b/src/renderer/src/common/locCommon.ts index 3d7d2d74f..5e26a2ffb 100644 --- a/src/renderer/src/common/locCommon.ts +++ b/src/renderer/src/common/locCommon.ts @@ -416,37 +416,41 @@ export const simulationDataInfos = (instanceTask: locApi.SedInstanceTask): Map { + // Note: count allows us to retrieve only the first values (e.g., those that have been computed so far while a + // simulation is running), see locApi.SedInstanceTask.voi() for instance. + switch (info.type) { case ESimulationDataInfoType.VOI: return { - data: instanceTask.voi(), + data: instanceTask.voi(count), unit: instanceTask.voiUnit() }; case ESimulationDataInfoType.STATE: return { - data: instanceTask.state(info.index), + data: instanceTask.state(info.index, count), unit: instanceTask.stateUnit(info.index) }; case ESimulationDataInfoType.RATE: return { - data: instanceTask.rate(info.index), + data: instanceTask.rate(info.index, count), unit: instanceTask.rateUnit(info.index) }; case ESimulationDataInfoType.CONSTANT: return { - data: instanceTask.constant(info.index), + data: instanceTask.constant(info.index, count), unit: instanceTask.constantUnit(info.index) }; case ESimulationDataInfoType.COMPUTED_CONSTANT: return { - data: instanceTask.computedConstant(info.index), + data: instanceTask.computedConstant(info.index, count), unit: instanceTask.computedConstantUnit(info.index) }; case ESimulationDataInfoType.ALGEBRAIC: return { - data: instanceTask.algebraicVariable(info.index), + data: instanceTask.algebraicVariable(info.index, count), unit: instanceTask.algebraicVariableUnit(info.index) }; default: diff --git a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue index 84832703d..a34996f15 100644 --- a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue +++ b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue @@ -146,7 +146,6 @@ :margins="compMargins" :data="compData[index] || { traces: [] }" @marginsUpdated="(newMargins: IGraphPanelMargins) => onMarginsUpdated(`plot_${index}`, newMargins)" - @resetMargins="() => onResetMargins()" /> @@ -1713,6 +1712,13 @@ const onMarginsUpdated = (plotId: string, newMargins: IGraphPanelMargins): void return; } + // Update our shared margins, but only if they have actually changed since updating them means that all our plots get + // relaid out. + + if (compMargins.value?.left === maxLeft && compMargins.value?.right === maxRight) { + return; + } + compMargins.value = { left: maxLeft, right: maxRight diff --git a/src/renderer/src/components/views/SimulationExperimentStandardView.vue b/src/renderer/src/components/views/SimulationExperimentStandardView.vue index 384dd6f26..36a4210bc 100644 --- a/src/renderer/src/components/views/SimulationExperimentStandardView.vue +++ b/src/renderer/src/components/views/SimulationExperimentStandardView.vue @@ -183,9 +183,16 @@ const updatePlot = (dataSize: number = 0): void => { : undefined; // Retrieve the data for the selected X and Y parameters and update the plot. - - const xData = locCommon.simulationDataValue(instanceTask, xInfo.value).data; - const yData = locCommon.simulationDataValue(instanceTask, yInfo.value).data; + // Note: we only retrieve the data that has been computed so far. With the C++ version of libOpenCOR, that data is a + // copy that we own, so we can use it as is. With the WASM version of libOpenCOR, that data is a view of the WASM + // heap, so we need to copy it. + + const dataCount = dataSize > 0 ? dataSize : undefined; + const xData = locCommon.simulationDataValue(instanceTask, xInfo.value, dataCount).data; + const yData = locCommon.simulationDataValue(instanceTask, yInfo.value, dataCount).data; + const ownData = (values: Float64Array): Float64Array => { + return locApi.cppVersion() ? values : values.slice(); + }; data.value = { xAxisTitle: xParameter.value, @@ -196,9 +203,9 @@ const updatePlot = (dataSize: number = 0): void => { { name: vueCommon.traceName(undefined, xParameter.value, yParameter.value), xValue: xParameter.value, - x: dataSize > 0 ? xData.slice(0, dataSize) : xData.slice(), + x: ownData(xData), yValue: yParameter.value, - y: dataSize > 0 ? yData.slice(0, dataSize) : yData.slice(), + y: ownData(yData), color: colors.DEFAULT_COLOR } ] diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index aeeebd33c..4c01e82ec 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -86,7 +86,6 @@ const props = withDefaults( const emit = defineEmits<{ marginsUpdated: [newMargins: IGraphPanelMargins]; - resetMargins: []; }>(); const trackSize = (): void => { @@ -262,8 +261,6 @@ const zoomIn = (): void => { 'xaxis.range': [xCenter - 0.5 * xSpan, xCenter + 0.5 * xSpan], 'yaxis.range': [yCenter - 0.5 * ySpan, yCenter + 0.5 * ySpan] }); - - emit('resetMargins'); }; const zoomOut = (): void => { @@ -289,8 +286,6 @@ const zoomOut = (): void => { 'xaxis.range': [xCenter - 2 * xSpan, xCenter + 2 * xSpan], 'yaxis.range': [yCenter - 2 * ySpan, yCenter + 2 * ySpan] }); - - emit('resetMargins'); }; const resetZoom = (): void => { @@ -302,8 +297,6 @@ const resetZoom = (): void => { 'xaxis.autorange': true, 'yaxis.autorange': true }); - - emit('resetMargins'); }; const copyToClipboard = async (): Promise => { @@ -736,13 +729,6 @@ const updatePlot = (): void => { plotIsReady = false; - // Reset our margins if they are not overridden. - - if (!props.margins) { - margins.value.left = -1; - margins.value.right = -1; - } - // Update the plots. const traceVisibilityKey = (trace: IPlotlyTraceState): string | undefined => { @@ -891,19 +877,16 @@ vue.onMounted(() => { stopTrackingContainerSize = stop; vue.nextTick(() => { - // Reset our margins on double-click and relayout. + // Update our margins whenever our axes change (e.g., following a zoom, a pan, a double-click, or a call to + // zoomIn(), zoomOut(), or resetZoom()), since the width of our tick labels may have changed. const plotlyElement = mainDivRef.value as IPlotlyHTMLElement; - plotlyElement.on('plotly_doubleclick', () => { - emit('resetMargins'); - }); - plotlyElement.on('plotly_relayout', (...args: unknown[]) => { const eventData = args[0] as Record | undefined; - if (eventData && ('xaxis.range[0]' in eventData || 'yaxis.range[0]' in eventData)) { - emit('resetMargins'); + if (eventData && Object.keys(eventData).some((key) => key.startsWith('xaxis.') || key.startsWith('yaxis.'))) { + updateMarginsAsync(); } }); @@ -1057,7 +1040,13 @@ vue.watch( vue.watch( () => props.margins, - () => { + (newMargins, oldMargins) => { + // Nothing to do if our margins haven't actually changed (e.g., if we were given a new object with the same values). + + if (newMargins && oldMargins && sameMargins(oldMargins, newMargins)) { + return; + } + vue .nextTick(() => { if (plotIsReady && mainDivRef.value) { diff --git a/src/renderer/src/libopencor/locApi.ts b/src/renderer/src/libopencor/locApi.ts index 6ae5f016b..64b6da786 100644 --- a/src/renderer/src/libopencor/locApi.ts +++ b/src/renderer/src/libopencor/locApi.ts @@ -79,27 +79,52 @@ export interface ICppLocApi { sedInstanceTaskVoiName: (instanceId: number, index: number) => string; sedInstanceTaskVoiUnit: (instanceId: number, index: number) => string; - sedInstanceTaskVoi: (instanceId: number, index: number) => Float64Array; + sedInstanceTaskVoi: (instanceId: number, index: number, count?: number) => Float64Array; sedInstanceTaskStateCount: (instanceId: number, index: number) => number; sedInstanceTaskStateName: (instanceId: number, index: number, stateIndex: number) => string; sedInstanceTaskStateUnit: (instanceId: number, index: number, stateIndex: number) => string; - sedInstanceTaskState: (instanceId: number, index: number, stateIndex: number) => Float64Array; + sedInstanceTaskState: ( + instanceId: number, + index: number, + stateIndex: number, + count?: number + ) => Float64Array; sedInstanceTaskRateCount: (instanceId: number, index: number) => number; sedInstanceTaskRateName: (instanceId: number, index: number, rateIndex: number) => string; sedInstanceTaskRateUnit: (instanceId: number, index: number, rateIndex: number) => string; - sedInstanceTaskRate: (instanceId: number, index: number, rateIndex: number) => Float64Array; + sedInstanceTaskRate: ( + instanceId: number, + index: number, + rateIndex: number, + count?: number + ) => Float64Array; sedInstanceTaskConstantCount: (instanceId: number, index: number) => number; sedInstanceTaskConstantName: (instanceId: number, index: number, constantIndex: number) => string; sedInstanceTaskConstantUnit: (instanceId: number, index: number, constantIndex: number) => string; - sedInstanceTaskConstant: (instanceId: number, index: number, constantIndex: number) => Float64Array; + sedInstanceTaskConstant: ( + instanceId: number, + index: number, + constantIndex: number, + count?: number + ) => Float64Array; sedInstanceTaskComputedConstantCount: (instanceId: number, index: number) => number; sedInstanceTaskComputedConstantName: (instanceId: number, index: number, computedConstantIndex: number) => string; sedInstanceTaskComputedConstantUnit: (instanceId: number, index: number, computedConstantIndex: number) => string; - sedInstanceTaskComputedConstant: (instanceId: number, index: number, computedConstantIndex: number) => Float64Array; + sedInstanceTaskComputedConstant: ( + instanceId: number, + index: number, + computedConstantIndex: number, + count?: number + ) => Float64Array; sedInstanceTaskAlgebraicVariableCount: (instanceId: number, index: number) => number; sedInstanceTaskAlgebraicVariableName: (instanceId: number, index: number, algebraicVariableIndex: number) => string; sedInstanceTaskAlgebraicVariableUnit: (instanceId: number, index: number, algebraicVariableIndex: number) => string; - sedInstanceTaskAlgebraicVariable: (instanceId: number, index: number, algebraicVariableIndex: number) => Float64Array; + sedInstanceTaskAlgebraicVariable: ( + instanceId: number, + index: number, + algebraicVariableIndex: number, + count?: number + ) => Float64Array; // Version API. diff --git a/src/renderer/src/libopencor/locSedApi.ts b/src/renderer/src/libopencor/locSedApi.ts index c3d1a9624..0ad1bc33e 100644 --- a/src/renderer/src/libopencor/locSedApi.ts +++ b/src/renderer/src/libopencor/locSedApi.ts @@ -634,6 +634,12 @@ export class SedInstance { } } +// A helper function to retrieve (a view of) the first values of the given values. + +const firstValues = (values: Float64Array, count?: number): Float64Array => { + return count === undefined ? values : values.subarray(0, count); +}; + export class SedInstanceTask extends SedIndex { private _cppInstanceId: number; private _wasmSedInstanceTask: IWasmSedInstanceTask = {} as IWasmSedInstanceTask; @@ -666,10 +672,15 @@ export class SedInstanceTask extends SedIndex { : this._wasmSedInstanceTask.voiUnit; } - voi(): Float64Array { + // Note: the various methods to retrieve simulation data accept an optional number of values, which allows us to + // retrieve only the values that have been computed so far (e.g., while a simulation is running). With the C++ + // version of libOpenCOR, this avoids copying values that are not needed. With the WASM version of libOpenCOR, + // the simulation data is a view of the WASM heap, so we simply return a view of the first values. + + voi(count?: number): Float64Array { return cppVersion() - ? _cppLocApi.sedInstanceTaskVoi(this._cppInstanceId, this._index) - : this._wasmSedInstanceTask.voi; + ? _cppLocApi.sedInstanceTaskVoi(this._cppInstanceId, this._index, count) + : firstValues(this._wasmSedInstanceTask.voi, count); } stateCount(): number { @@ -690,10 +701,10 @@ export class SedInstanceTask extends SedIndex { : this._wasmSedInstanceTask.stateUnit(index); } - state(index: number): Float64Array { + state(index: number, count?: number): Float64Array { return cppVersion() - ? _cppLocApi.sedInstanceTaskState(this._cppInstanceId, this._index, index) - : this._wasmSedInstanceTask.state(index); + ? _cppLocApi.sedInstanceTaskState(this._cppInstanceId, this._index, index, count) + : firstValues(this._wasmSedInstanceTask.state(index), count); } rateCount(): number { @@ -714,10 +725,10 @@ export class SedInstanceTask extends SedIndex { : this._wasmSedInstanceTask.rateUnit(index); } - rate(index: number): Float64Array { + rate(index: number, count?: number): Float64Array { return cppVersion() - ? _cppLocApi.sedInstanceTaskRate(this._cppInstanceId, this._index, index) - : this._wasmSedInstanceTask.rate(index); + ? _cppLocApi.sedInstanceTaskRate(this._cppInstanceId, this._index, index, count) + : firstValues(this._wasmSedInstanceTask.rate(index), count); } constantCount(): number { @@ -738,10 +749,10 @@ export class SedInstanceTask extends SedIndex { : this._wasmSedInstanceTask.constantUnit(index); } - constant(index: number): Float64Array { + constant(index: number, count?: number): Float64Array { return cppVersion() - ? _cppLocApi.sedInstanceTaskConstant(this._cppInstanceId, this._index, index) - : this._wasmSedInstanceTask.constant(index); + ? _cppLocApi.sedInstanceTaskConstant(this._cppInstanceId, this._index, index, count) + : firstValues(this._wasmSedInstanceTask.constant(index), count); } computedConstantCount(): number { @@ -762,10 +773,10 @@ export class SedInstanceTask extends SedIndex { : this._wasmSedInstanceTask.computedConstantUnit(index); } - computedConstant(index: number): Float64Array { + computedConstant(index: number, count?: number): Float64Array { return cppVersion() - ? _cppLocApi.sedInstanceTaskComputedConstant(this._cppInstanceId, this._index, index) - : this._wasmSedInstanceTask.computedConstant(index); + ? _cppLocApi.sedInstanceTaskComputedConstant(this._cppInstanceId, this._index, index, count) + : firstValues(this._wasmSedInstanceTask.computedConstant(index), count); } algebraicVariableCount(): number { @@ -786,9 +797,9 @@ export class SedInstanceTask extends SedIndex { : this._wasmSedInstanceTask.algebraicVariableUnit(index); } - algebraicVariable(index: number): Float64Array { + algebraicVariable(index: number, count?: number): Float64Array { return cppVersion() - ? _cppLocApi.sedInstanceTaskAlgebraicVariable(this._cppInstanceId, this._index, index) - : this._wasmSedInstanceTask.algebraicVariable(index); + ? _cppLocApi.sedInstanceTaskAlgebraicVariable(this._cppInstanceId, this._index, index, count) + : firstValues(this._wasmSedInstanceTask.algebraicVariable(index), count); } } diff --git a/src/renderer/src/libopencor/src/sed.cpp b/src/renderer/src/libopencor/src/sed.cpp index d603b9945..89fabb6b1 100644 --- a/src/renderer/src/libopencor/src/sed.cpp +++ b/src/renderer/src/libopencor/src/sed.cpp @@ -43,6 +43,19 @@ libOpenCOR::SedInstanceTaskPtr toSedInstanceTask(const Napi::CallbackInfo &pInfo return task; } +// Retrieve the first values of the given values. +// Note: the number of values is optional and allows us to retrieve only the values that have been computed so far (e.g., +// while a simulation is running), thus avoiding the copy of values that are not needed. + +std::span firstValues(std::span pValues, const Napi::Value &pCount) +{ + if (!pCount.IsNumber()) { + return pValues; + } + + return pValues.first(std::min(toSizeT(pCount), pValues.size())); +} + } // namespace // SedDocument API. @@ -358,7 +371,7 @@ napi_value sedInstanceTaskVoi(const Napi::CallbackInfo &pInfo) { auto task = toSedInstanceTask(pInfo); - return doublesToNapiFloat64Array(pInfo.Env(), task->voi()); + return doublesToNapiFloat64Array(pInfo.Env(), firstValues(task->voi(), pInfo[2])); } napi_value sedInstanceTaskStateCount(const Napi::CallbackInfo &pInfo) @@ -386,7 +399,7 @@ napi_value sedInstanceTaskState(const Napi::CallbackInfo &pInfo) { auto task = toSedInstanceTask(pInfo); - return doublesToNapiFloat64Array(pInfo.Env(), task->state(toInt32(pInfo[2]))); + return doublesToNapiFloat64Array(pInfo.Env(), firstValues(task->state(toInt32(pInfo[2])), pInfo[3])); } napi_value sedInstanceTaskRateCount(const Napi::CallbackInfo &pInfo) @@ -414,7 +427,7 @@ napi_value sedInstanceTaskRate(const Napi::CallbackInfo &pInfo) { auto task = toSedInstanceTask(pInfo); - return doublesToNapiFloat64Array(pInfo.Env(), task->rate(toInt32(pInfo[2]))); + return doublesToNapiFloat64Array(pInfo.Env(), firstValues(task->rate(toInt32(pInfo[2])), pInfo[3])); } napi_value sedInstanceTaskConstantCount(const Napi::CallbackInfo &pInfo) @@ -442,7 +455,7 @@ napi_value sedInstanceTaskConstant(const Napi::CallbackInfo &pInfo) { auto task = toSedInstanceTask(pInfo); - return doublesToNapiFloat64Array(pInfo.Env(), task->constant(toInt32(pInfo[2]))); + return doublesToNapiFloat64Array(pInfo.Env(), firstValues(task->constant(toInt32(pInfo[2])), pInfo[3])); } napi_value sedInstanceTaskComputedConstantCount(const Napi::CallbackInfo &pInfo) @@ -470,7 +483,7 @@ napi_value sedInstanceTaskComputedConstant(const Napi::CallbackInfo &pInfo) { auto task = toSedInstanceTask(pInfo); - return doublesToNapiFloat64Array(pInfo.Env(), task->computedConstant(toInt32(pInfo[2]))); + return doublesToNapiFloat64Array(pInfo.Env(), firstValues(task->computedConstant(toInt32(pInfo[2])), pInfo[3])); } napi_value sedInstanceTaskAlgebraicVariableCount(const Napi::CallbackInfo &pInfo) @@ -498,5 +511,5 @@ napi_value sedInstanceTaskAlgebraicVariable(const Napi::CallbackInfo &pInfo) { auto task = toSedInstanceTask(pInfo); - return doublesToNapiFloat64Array(pInfo.Env(), task->algebraicVariable(toInt32(pInfo[2]))); + return doublesToNapiFloat64Array(pInfo.Env(), firstValues(task->algebraicVariable(toInt32(pInfo[2])), pInfo[3])); } From a9d0551022e5c7d986b00066fa114e574aa2bda9 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 01:08:52 +1300 Subject: [PATCH 023/184] Prevent libOpenCOR teardown crashes. --- src/renderer/src/libopencor/src/common.cpp | 32 ++++++++++++++++++++-- src/renderer/src/libopencor/src/common.h | 8 ++++-- src/renderer/src/libopencor/src/main.cpp | 5 ++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/libopencor/src/common.cpp b/src/renderer/src/libopencor/src/common.cpp index 2556915e0..9aef22e97 100644 --- a/src/renderer/src/libopencor/src/common.cpp +++ b/src/renderer/src/libopencor/src/common.cpp @@ -1,9 +1,35 @@ #include "common.h" libOpenCOR::FileManager fileManager = libOpenCOR::FileManager::instance(); -std::map files; -std::map sedDocuments; -std::map sedInstances; + +// Note: our global maps are intentionally never destroyed (i.e. they are allocated on the heap and never deleted). +// Indeed, they would otherwise get destroyed during static destruction, i.e. possibly after libOpenCOR's own +// static objects (e.g., its file manager) have been destroyed, which would result in a crash (e.g., "mutex lock +// failed") when destroying what is left in them. Instead, we release what they hold when our Node.js environment +// gets torn down (see releaseAll()) and, if the process exits without tearing down our Node.js environment (e.g., +// using process.exit()), the OS reclaims everything. + +std::map &files = *new std::map(); +std::map &sedDocuments = *new std::map(); +std::map &sedInstances = *new std::map(); + +// Release all our SED-ML instances, SED-ML documents, and files. +// Note: this is called when our Node.js environment gets torn down (e.g., when the renderer process exits or gets +// reloaded), i.e. while libOpenCOR's own static objects still exist (see the note about our global maps above). +// We release our SED-ML instances first (stopping any run since libOpenCOR waits for a run to finish before +// deleting an instance), then our SED-ML documents and finally our files, i.e. in the reverse order of their +// dependencies. + +void releaseAll() +{ + for (auto &[id, sedInstance] : sedInstances) { + sedInstance->stopRun(); + } + + sedInstances.clear(); + sedDocuments.clear(); + files.clear(); +} // Note: the following functions throw a JavaScript exception (rather than return nullptr) if the file, SED-ML document, // or SED-ML instance is unknown (e.g., it has been released). This way, an invalid call results in a JavaScript diff --git a/src/renderer/src/libopencor/src/common.h b/src/renderer/src/libopencor/src/common.h index 9d78e858a..12ba95c97 100644 --- a/src/renderer/src/libopencor/src/common.h +++ b/src/renderer/src/libopencor/src/common.h @@ -7,9 +7,9 @@ #include extern libOpenCOR::FileManager fileManager; -extern std::map files; -extern std::map sedDocuments; -extern std::map sedInstances; +extern std::map &files; +extern std::map &sedDocuments; +extern std::map &sedInstances; libOpenCOR::FilePtr toFile(const Napi::Value &pValue); libOpenCOR::SedDocumentPtr toSedDocument(const Napi::Value &pValue); @@ -19,6 +19,8 @@ int32_t toInt32(const Napi::Value &pValue); double toDouble(const Napi::Value &pValue); std::string toString(const Napi::Value &pValue); +void releaseAll(); + napi_value issues(const Napi::CallbackInfo &pInfo, libOpenCOR::IssuePtrs pIssues); napi_value doublesToNapiFloat64Array(const Napi::Env &pEnv, std::span pDoubles); diff --git a/src/renderer/src/libopencor/src/main.cpp b/src/renderer/src/libopencor/src/main.cpp index c52926045..1375374d3 100644 --- a/src/renderer/src/libopencor/src/main.cpp +++ b/src/renderer/src/libopencor/src/main.cpp @@ -1,9 +1,14 @@ +#include "common.h" #include "file.h" #include "sed.h" #include "version.h" Napi::Object init(Napi::Env pEnv, Napi::Object pExports) { + // Release everything we hold when our Node.js environment gets torn down (see releaseAll()). + + pEnv.AddCleanupHook(releaseAll); + // Note: this must be in sync with src/preload/index.ts. // Some general methods. From c2f1bda1d72aa2c442ae969d61c1557e0cb22f83 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 01:35:41 +1300 Subject: [PATCH 024/184] Added IPC unsubscribe and OpenCOR cleanup tracking. --- src/preload/index.ts | 119 ++++------- src/renderer/src/common/electronApi.ts | 38 ++-- src/renderer/src/components/OpenCOR.vue | 250 +++++++++++++++--------- src/renderer/src/libopencor/locApi.ts | 21 +- 4 files changed, 223 insertions(+), 205 deletions(-) diff --git a/src/preload/index.ts b/src/preload/index.ts index 082c9aa6e..eefb114ec 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,23 @@ import type { ISplashScreenInfo } from '../renderer/src/common/electronApi'; const exec = promisify(_exec); +// A helper function to listen to a channel from the main process. +// Note: it returns a function to stop listening to that channel so that a listener can be removed (e.g., when the +// component that registered it gets unmounted). Otherwise, a listener would remain registered (and keep its +// callback, and therefore its component, alive) for the lifetime of the renderer process. + +const onIpc = (channel: string, callback: (...args: T) => void): (() => void) => { + const listener = (_event: electron.IpcRendererEvent, ...args: T): void => { + callback(...args); + }; + + electron.ipcRenderer.on(channel, listener); + + return () => { + electron.ipcRenderer.removeListener(channel, listener); + }; +}; + let _operatingSystem: string | null = null; const defaultOperatingSystem = `${process.platform} (${process.arch === 'x64' ? 'Intel' : 'ARM'})`; @@ -100,9 +117,7 @@ electron.contextBridge.exposeInMainWorld('electronApi', { // Splash screen window. onInitSplashScreenWindow: (callback: (info: ISplashScreenInfo) => void) => - electron.ipcRenderer.on('init-splash-screen-window', (_event, info: ISplashScreenInfo) => { - callback(info); - }), + onIpc('init-splash-screen-window', callback), // Renderer process asking the main process to do something for it. @@ -129,78 +144,24 @@ electron.contextBridge.exposeInMainWorld('electronApi', { // Renderer process listening to the main process. - onAbout: (callback: () => void) => - electron.ipcRenderer.on('about', () => { - callback(); - }), - onAction: (callback: (action: string) => void) => - electron.ipcRenderer.on('action', (_event, action: string) => { - callback(action); - }), - onCheckForUpdates: (callback: () => void) => - electron.ipcRenderer.on('check-for-updates', () => { - callback(); - }), - onEnableDisableUi: (callback: (enable: boolean) => void) => - electron.ipcRenderer.on('enable-disable-ui', (_event, enable: boolean) => { - callback(enable); - }), - onOpen: (callback: (filePath: string) => void) => - electron.ipcRenderer.on('open', (_event, filePath: string) => { - callback(filePath); - }), - onOpenRemote: (callback: () => void) => - electron.ipcRenderer.on('open-remote', () => { - callback(); - }), - onOpenSampleLorenz: (callback: () => void) => - electron.ipcRenderer.on('open-sample-lorenz', () => { - callback(); - }), - onClose: (callback: () => void) => - electron.ipcRenderer.on('close', () => { - callback(); - }), - onCloseAll: (callback: () => void) => - electron.ipcRenderer.on('close-all', () => { - callback(); - }), - onResetAll: (callback: () => void) => - electron.ipcRenderer.on('reset-all', () => { - callback(); - }), - onSelect: (callback: (filePath: string) => void) => - electron.ipcRenderer.on('select', (_event, filePath: string) => { - callback(filePath); - }), - onSettings: (callback: () => void) => - electron.ipcRenderer.on('settings', () => { - callback(); - }), - onUpdateAvailable: (callback: (version: string) => void) => - electron.ipcRenderer.on('update-available', (_event, version: string) => { - callback(version); - }), - onUpdateCheckError: (callback: (issue: string) => void) => - electron.ipcRenderer.on('update-check-error', (_event, issue: string) => { - callback(issue); - }), - onUpdateDownloaded: (callback: () => void) => - electron.ipcRenderer.on('update-downloaded', () => { - callback(); - }), - onUpdateDownloadError: (callback: (issue: string) => void) => - electron.ipcRenderer.on('update-download-error', (_event, issue: string) => { - callback(issue); - }), - onUpdateDownloadProgress: (callback: (percent: number) => void) => - electron.ipcRenderer.on('update-download-progress', (_event, percent: number) => { - callback(percent); - }), - onUpdateNotAvailable: (callback: () => void) => - electron.ipcRenderer.on('update-not-available', () => { - callback(); - }) + onAbout: (callback: () => void) => onIpc('about', callback), + onAction: (callback: (action: string) => void) => onIpc('action', callback), + onCheckForUpdates: (callback: () => void) => onIpc('check-for-updates', callback), + onEnableDisableUi: (callback: (enable: boolean) => void) => onIpc('enable-disable-ui', callback), + onOpen: (callback: (filePath: string) => void) => onIpc('open', callback), + onOpenRemote: (callback: () => void) => onIpc('open-remote', callback), + onOpenSampleLorenz: (callback: () => void) => onIpc('open-sample-lorenz', callback), + onClose: (callback: () => void) => onIpc('close', callback), + onCloseAll: (callback: () => void) => onIpc('close-all', callback), + onResetAll: (callback: () => void) => onIpc('reset-all', callback), + onSelect: (callback: (filePath: string) => void) => onIpc('select', callback), + onSettings: (callback: () => void) => onIpc('settings', callback), + onUpdateAvailable: (callback: (version: string) => void) => onIpc('update-available', callback), + onUpdateCheckError: (callback: (issue: string) => void) => onIpc('update-check-error', callback), + onUpdateDownloaded: (callback: () => void) => onIpc('update-downloaded', callback), + onUpdateDownloadError: (callback: (issue: string) => void) => onIpc('update-download-error', callback), + onUpdateDownloadProgress: (callback: (percent: number) => void) => onIpc('update-download-progress', callback), + onUpdateNotAvailable: (callback: () => void) => onIpc('update-not-available', callback) }); // Give our renderer process access to the native node module for libOpenCOR. @@ -324,6 +285,10 @@ electron.contextBridge.exposeInMainWorld('locApi', { loc.sedInstanceTaskAlgebraicVariableName(instanceId, index, algebraicVariableIndex), sedInstanceTaskAlgebraicVariableUnit: (instanceId: number, index: number, algebraicVariableIndex: number) => loc.sedInstanceTaskAlgebraicVariableUnit(instanceId, index, algebraicVariableIndex), - sedInstanceTaskAlgebraicVariable: (instanceId: number, index: number, algebraicVariableIndex: number, count?: number) => - loc.sedInstanceTaskAlgebraicVariable(instanceId, index, algebraicVariableIndex, count) + sedInstanceTaskAlgebraicVariable: ( + instanceId: number, + index: number, + algebraicVariableIndex: number, + count?: number + ) => loc.sedInstanceTaskAlgebraicVariable(instanceId, index, algebraicVariableIndex, count) }); diff --git a/src/renderer/src/common/electronApi.ts b/src/renderer/src/common/electronApi.ts index 7439441d2..0a3bf4200 100644 --- a/src/renderer/src/common/electronApi.ts +++ b/src/renderer/src/common/electronApi.ts @@ -14,7 +14,7 @@ interface IElectronApi { // Splash screen window. - onInitSplashScreenWindow: (callback: (info: ISplashScreenInfo) => void) => void; + onInitSplashScreenWindow: (callback: (info: ISplashScreenInfo) => void) => () => void; // Renderer process asking the main process to do something for it. @@ -39,24 +39,24 @@ interface IElectronApi { // Renderer process listening to the main process. - onAbout: (callback: () => void) => void; - onAction: (callback: (action: string) => void) => void; - onCheckForUpdates: (callback: () => void) => void; - onEnableDisableUi: (callback: (enable: boolean) => void) => void; - onOpen: (callback: (filePath: string) => void) => void; - onOpenRemote: (callback: () => void) => void; - onOpenSampleLorenz: (callback: () => void) => void; - onClose: (callback: () => void) => void; - onCloseAll: (callback: () => void) => void; - onResetAll: (callback: () => void) => void; - onSelect: (callback: (filePath: string) => void) => void; - onSettings: (callback: () => void) => void; - onUpdateAvailable: (callback: (version: string) => void) => void; - onUpdateCheckError: (callback: (issue: string) => void) => void; - onUpdateDownloaded: (callback: () => void) => void; - onUpdateDownloadError: (callback: (issue: string) => void) => void; - onUpdateDownloadProgress: (callback: (percent: number) => void) => void; - onUpdateNotAvailable: (callback: () => void) => void; + onAbout: (callback: () => void) => () => void; + onAction: (callback: (action: string) => void) => () => void; + onCheckForUpdates: (callback: () => void) => () => void; + onEnableDisableUi: (callback: (enable: boolean) => void) => () => void; + onOpen: (callback: (filePath: string) => void) => () => void; + onOpenRemote: (callback: () => void) => () => void; + onOpenSampleLorenz: (callback: () => void) => () => void; + onClose: (callback: () => void) => () => void; + onCloseAll: (callback: () => void) => () => void; + onResetAll: (callback: () => void) => () => void; + onSelect: (callback: (filePath: string) => void) => () => void; + onSettings: (callback: () => void) => () => void; + onUpdateAvailable: (callback: (version: string) => void) => () => void; + onUpdateCheckError: (callback: (issue: string) => void) => () => void; + onUpdateDownloaded: (callback: () => void) => () => void; + onUpdateDownloadError: (callback: (issue: string) => void) => () => void; + onUpdateDownloadProgress: (callback: (percent: number) => void) => () => void; + onUpdateNotAvailable: (callback: () => void) => () => void; } interface IWindow { diff --git a/src/renderer/src/components/OpenCOR.vue b/src/renderer/src/components/OpenCOR.vue index fdf6ab0bf..d6aa3270f 100644 --- a/src/renderer/src/components/OpenCOR.vue +++ b/src/renderer/src/components/OpenCOR.vue @@ -305,13 +305,43 @@ const compIsActiveApp = vue.computed(() => { return activeInstanceUid.value === crtInstanceUid; }); +// Keep track of what needs to be cleaned up when we get unmounted (e.g., when OpenCOR is used as a library or during +// development with hot module replacement), i.e. our Electron API listeners (which would otherwise keep us alive and +// get called for an unmounted instance), our timers, and our watchers that are not bound to our component. + +const cleanups: (() => void)[] = []; + +const trackCleanup = (cleanup: (() => void) | undefined): void => { + if (cleanup) { + cleanups.push(cleanup); + } +}; + +const setTrackedTimeout = (callback: () => void, delay: number): void => { + const timeoutId = window.setTimeout(callback, delay); + + trackCleanup(() => { + window.clearTimeout(timeoutId); + }); +}; + +vue.onUnmounted(() => { + for (const cleanup of cleanups) { + cleanup(); + } + + cleanups.length = 0; +}); + // Enable/disable the UI from Electron. const electronUiEnabled = vue.ref(true); -electronApi?.onEnableDisableUi((enable: boolean) => { - electronUiEnabled.value = enable; -}); +trackCleanup( + electronApi?.onEnableDisableUi((enable: boolean) => { + electronUiEnabled.value = enable; + }) +); // Determine whether the component UI should be blocked/enabled. // Note: compBlockUiEnabled is used to determine whether PrimeVue's BlockUI component should be enabled, whereas @@ -518,9 +548,11 @@ loadGitHubAccessToken(); // Handle an action. -electronApi?.onAction((action: string) => { - handleAction(action); -}); +trackCleanup( + electronApi?.onAction((action: string) => { + handleAction(action); + }) +); const handleAction = (action: string): void => { const isAction = (actionName: string, expectedActionName: string): boolean => { @@ -566,9 +598,11 @@ vue.watch(hasFiles, (newHasFiles: boolean) => { // Auto update. -electronApi?.onCheckForUpdates(() => { - electronApi?.checkForUpdates(false); -}); +trackCleanup( + electronApi?.onCheckForUpdates(() => { + electronApi?.checkForUpdates(false); + }) +); const updateErrorVisible = vue.ref(false); const updateErrorTitle = vue.ref(''); @@ -584,10 +618,12 @@ const updateDownloadProgressVisible = vue.ref(false); const updateVersion = vue.ref(''); const updateDownloadPercent = vue.ref(0); -electronApi?.onUpdateAvailable((version: string) => { - desktopUpdateAvailableVisible.value = true; - updateVersion.value = version; -}); +trackCleanup( + electronApi?.onUpdateAvailable((version: string) => { + desktopUpdateAvailableVisible.value = true; + updateVersion.value = version; + }) +); const onDownloadAndInstall = (): void => { updateDownloadPercent.value = 0; // Just to be on the safe side. @@ -597,33 +633,43 @@ const onDownloadAndInstall = (): void => { electronApi?.downloadAndInstallUpdate(); }; -electronApi?.onUpdateDownloadError((issue: string) => { - updateErrorTitle.value = 'Downloading Update...'; - updateErrorIssue.value = `An error occurred while downloading the update (${common.formatMessage(issue, false)}).`; - updateErrorVisible.value = true; -}); +trackCleanup( + electronApi?.onUpdateDownloadError((issue: string) => { + updateErrorTitle.value = 'Downloading Update...'; + updateErrorIssue.value = `An error occurred while downloading the update (${common.formatMessage(issue, false)}).`; + updateErrorVisible.value = true; + }) +); -electronApi?.onUpdateDownloadProgress((percent: number) => { - updateDownloadPercent.value = percent; -}); +trackCleanup( + electronApi?.onUpdateDownloadProgress((percent: number) => { + updateDownloadPercent.value = percent; + }) +); -electronApi?.onUpdateDownloaded(() => { - updateDownloadPercent.value = 100; // Just to be on the safe side. +trackCleanup( + electronApi?.onUpdateDownloaded(() => { + updateDownloadPercent.value = 100; // Just to be on the safe side. - electronApi?.installUpdateAndRestart(); -}); + electronApi?.installUpdateAndRestart(); + }) +); const updateNotAvailableVisible = vue.ref(false); -electronApi?.onUpdateNotAvailable(() => { - updateNotAvailableVisible.value = true; -}); +trackCleanup( + electronApi?.onUpdateNotAvailable(() => { + updateNotAvailableVisible.value = true; + }) +); -electronApi?.onUpdateCheckError((issue: string) => { - updateErrorTitle.value = 'Checking For Updates...'; - updateErrorIssue.value = `An error occurred while checking for updates (${common.formatMessage(issue, false)}).`; - updateErrorVisible.value = true; -}); +trackCleanup( + electronApi?.onUpdateCheckError((issue: string) => { + updateErrorTitle.value = 'Checking For Updates...'; + updateErrorIssue.value = `An error occurred while checking for updates (${common.formatMessage(issue, false)}).`; + updateErrorVisible.value = true; + }) +); // Handle errors. @@ -640,9 +686,11 @@ const onError = (message: string): void => { const aboutVisible = vue.ref(false); -electronApi?.onAbout(() => { - onAboutMenu(); -}); +trackCleanup( + electronApi?.onAbout(() => { + onAboutMenu(); + }) +); const onAboutMenu = (): void => { if (props.omex) { @@ -656,9 +704,11 @@ const onAboutMenu = (): void => { const settingsVisible = vue.ref(false); -electronApi?.onSettings(() => { - onSettingsMenu(); -}); +trackCleanup( + electronApi?.onSettings(() => { + onSettingsMenu(); + }) +); const onSettingsMenu = (): void => { if (props.omex) { @@ -959,9 +1009,11 @@ const onDragLeave = (): void => { // Open. -electronApi?.onOpen((filePath: string) => { - openFile(filePath); -}); +trackCleanup( + electronApi?.onOpen((filePath: string) => { + openFile(filePath); + }) +); const onOpenMenu = (): void => { if (props.omex) { @@ -975,9 +1027,11 @@ const onOpenMenu = (): void => { const openRemoteVisible = vue.ref(false); -electronApi?.onOpenRemote(() => { - openRemoteVisible.value = true; -}); +trackCleanup( + electronApi?.onOpenRemote(() => { + openRemoteVisible.value = true; + }) +); const onOpenRemoteMenu = (): void => { if (props.omex) { @@ -998,9 +1052,11 @@ const onOpenRemote = (url: string): void => { // Open sample Lorenz. -electronApi?.onOpenSampleLorenz(() => { - onOpenSampleLorenzMenu(); -}); +trackCleanup( + electronApi?.onOpenSampleLorenz(() => { + onOpenSampleLorenzMenu(); + }) +); const onOpenSampleLorenzMenu = (): void => { if (props.omex) { @@ -1012,9 +1068,11 @@ const onOpenSampleLorenzMenu = (): void => { // Close. -electronApi?.onClose(() => { - onCloseMenu(); -}); +trackCleanup( + electronApi?.onClose(() => { + onCloseMenu(); + }) +); const onCloseMenu = (): void => { if (props.omex) { @@ -1026,9 +1084,11 @@ const onCloseMenu = (): void => { // Close all. -electronApi?.onCloseAll(() => { - onCloseAllMenu(); -}); +trackCleanup( + electronApi?.onCloseAll(() => { + onCloseAllMenu(); + }) +); const onCloseAllMenu = (): void => { if (props.omex) { @@ -1042,9 +1102,11 @@ const onCloseAllMenu = (): void => { const resetAllVisible = vue.ref(false); -electronApi?.onResetAll(() => { - resetAllVisible.value = true; -}); +trackCleanup( + electronApi?.onResetAll(() => { + resetAllVisible.value = true; + }) +); const onResetAll = (): void => { electronApi?.resetAll(); @@ -1052,9 +1114,11 @@ const onResetAll = (): void => { // Select. -electronApi?.onSelect((filePath: string) => { - contentsRef.value?.selectFile(filePath); -}); +trackCleanup( + electronApi?.onSelect((filePath: string) => { + contentsRef.value?.selectFile(filePath); + }) +); // A few things that can only be done when the component is mounted. @@ -1063,13 +1127,13 @@ vue.onMounted(() => { // Make ourselves the active instance. - setTimeout(() => { + setTrackedTimeout(() => { activateInstance(); }, SHORT_DELAY); // Ensure that our toasts are shown within our block UI. - setTimeout(() => { + setTrackedTimeout(() => { const toastElement = document.getElementById(toastId.value); if (toastElement && safeBlockUiElement && toastElement.parentElement !== safeBlockUiElement) { @@ -1147,7 +1211,7 @@ if (props.omex) { vue.onMounted(() => { // Do what follows with a bit of a delay to give our background (with the OpenCOR logo) time to be renderered. - setTimeout(() => { + setTrackedTimeout(() => { if (electronApi) { // Check for updates. // Note: the main process will actually check for updates if requested and if OpenCOR is packaged. @@ -1155,35 +1219,39 @@ if (props.omex) { electronApi.checkForUpdates(true); } else { // Handle the action passed to our Web app, if any. - // Note: to use vue.nextTick() doesn't do the trick, so we have no choice but to use setTimeout(). - - vue.watch(initialisingOpencorMessageVisible, (newInitialisingOpencorMessageVisible: boolean) => { - if (!newInitialisingOpencorMessageVisible && window.location.search) { - // Retrieve the action from the URL. - // Note: we also include the hash since it is used to pass a model that is encoded as a data URL. - - let action = window.location.search.substring(1); - - if (window.location.hash) { - action += window.location.hash; + // Note #1: to use vue.nextTick() doesn't do the trick, so we have no choice but to use setTimeout(). + // Note #2: our watcher is created outside of our component's setup, so it is not automatically stopped when we + // get unmounted, hence we keep track of it. + + trackCleanup( + vue.watch(initialisingOpencorMessageVisible, (newInitialisingOpencorMessageVisible: boolean) => { + if (!newInitialisingOpencorMessageVisible && window.location.search) { + // Retrieve the action from the URL. + // Note: we also include the hash since it is used to pass a model that is encoded as a data URL. + + let action = window.location.search.substring(1); + + if (window.location.hash) { + action += window.location.hash; + } + + // Ensure that the URL is cleaned up. + + window.history.replaceState({}, document.title, window.location.pathname); + + if (action.startsWith(FULL_URI_SCHEME)) { + handleAction(action.slice(FULL_URI_SCHEME.length)); + } else { + addToast({ + severity: 'error', + summary: 'Handling an action', + detail: `${action}\n\nThe action could not be handled.`, + life: TOAST_LIFE + }); + } } - - // Ensure that the URL is cleaned up. - - window.history.replaceState({}, document.title, window.location.pathname); - - if (action.startsWith(FULL_URI_SCHEME)) { - handleAction(action.slice(FULL_URI_SCHEME.length)); - } else { - addToast({ - severity: 'error', - summary: 'Handling an action', - detail: `${action}\n\nThe action could not be handled.`, - life: TOAST_LIFE - }); - } - } - }); + }) + ); } }, SHORT_DELAY); }); diff --git a/src/renderer/src/libopencor/locApi.ts b/src/renderer/src/libopencor/locApi.ts index 64b6da786..289c62ccc 100644 --- a/src/renderer/src/libopencor/locApi.ts +++ b/src/renderer/src/libopencor/locApi.ts @@ -83,30 +83,15 @@ export interface ICppLocApi { sedInstanceTaskStateCount: (instanceId: number, index: number) => number; sedInstanceTaskStateName: (instanceId: number, index: number, stateIndex: number) => string; sedInstanceTaskStateUnit: (instanceId: number, index: number, stateIndex: number) => string; - sedInstanceTaskState: ( - instanceId: number, - index: number, - stateIndex: number, - count?: number - ) => Float64Array; + sedInstanceTaskState: (instanceId: number, index: number, stateIndex: number, count?: number) => Float64Array; sedInstanceTaskRateCount: (instanceId: number, index: number) => number; sedInstanceTaskRateName: (instanceId: number, index: number, rateIndex: number) => string; sedInstanceTaskRateUnit: (instanceId: number, index: number, rateIndex: number) => string; - sedInstanceTaskRate: ( - instanceId: number, - index: number, - rateIndex: number, - count?: number - ) => Float64Array; + sedInstanceTaskRate: (instanceId: number, index: number, rateIndex: number, count?: number) => Float64Array; sedInstanceTaskConstantCount: (instanceId: number, index: number) => number; sedInstanceTaskConstantName: (instanceId: number, index: number, constantIndex: number) => string; sedInstanceTaskConstantUnit: (instanceId: number, index: number, constantIndex: number) => string; - sedInstanceTaskConstant: ( - instanceId: number, - index: number, - constantIndex: number, - count?: number - ) => Float64Array; + sedInstanceTaskConstant: (instanceId: number, index: number, constantIndex: number, count?: number) => Float64Array; sedInstanceTaskComputedConstantCount: (instanceId: number, index: number) => number; sedInstanceTaskComputedConstantName: (instanceId: number, index: number, computedConstantIndex: number) => string; sedInstanceTaskComputedConstantUnit: (instanceId: number, index: number, computedConstantIndex: number) => string; From a8e8e1e114147cff498ae8b878221d4fb3974ec1 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 10:50:00 +1300 Subject: [PATCH 025/184] Queue startup arguments until the renderer is ready. --- src/main/MainWindow.ts | 111 ++++++++++++++---------- src/main/index.ts | 21 ++++- src/preload/index.ts | 1 + src/renderer/src/common/electronApi.ts | 1 + src/renderer/src/components/OpenCOR.vue | 7 ++ 5 files changed, 91 insertions(+), 50 deletions(-) diff --git a/src/main/MainWindow.ts b/src/main/MainWindow.ts index 025b2b9b6..4e783bc54 100644 --- a/src/main/MainWindow.ts +++ b/src/main/MainWindow.ts @@ -3,7 +3,7 @@ import { autoUpdater, type ProgressInfo, type UpdateCheckResult } from 'electron import path from 'node:path'; import { formatError, isDataUrlOmexFileName, type ISettings, isUrl } from '../renderer/src/common/common'; -import { FULL_URI_SCHEME, LONG_DELAY, SHORT_DELAY } from '../renderer/src/common/constants'; +import { FULL_URI_SCHEME, LONG_DELAY } from '../renderer/src/common/constants'; import { isLinux, isMacOs, isPackaged, isWindows } from '../renderer/src/common/electron'; /* TODO: enable once our GitHub integration is fully ready. import { deleteGitHubAccessToken } from '../renderer/src/common/gitHubIntegration'; @@ -158,7 +158,8 @@ export class MainWindow extends ApplicationWindow { static instance: MainWindow | null = null; private _splashScreenWindowClosed = false; - + private _rendererReady = false; + private _pendingArguments: string[] = []; private _openedFilePaths: string[] = []; private _openedFilePathIndex = 0; private _selectedFilePath = ''; @@ -190,74 +191,59 @@ export class MainWindow extends ApplicationWindow { electron.app.dock?.setIcon(icon); } - // Restore our state, if needed. - - if (state.isMaximized) { - this.maximize(); - } else if (state.isFullScreen) { - this.setFullScreen(true); - } - - // Ask for the splash screen window to be closed with a short delay once we are visible and handle our command line - // (also with a short delay if needed). + // Close our splash screen window with a short delay once we are visible. + // Note: we must do this before restoring our state since maximising a window may also show it (on Windows and + // Linux), in which case we would otherwise miss the show event and never close our splash screen window. this.once('show', () => { - let handleCommandLineDelay = SHORT_DELAY; - if (!this._splashScreenWindowClosed) { this._splashScreenWindowClosed = true; - handleCommandLineDelay = LONG_DELAY; - setTimeout(() => { splashScreenWindow.close(); }, LONG_DELAY); } + }); - setTimeout(() => { - // Retrieve the recently opened files and our Reopen menu. - // Note: for some reasons, recentFilePAths may, in some cases, end up having only one null entry, so just in - // case we filter out all null entries. + // Restore our state, if needed. - recentFilePaths = (electronConf.get('app.files.recent') as string[]).filter( - (filePath: string | null) => filePath - ); + if (state.isMaximized) { + this.maximize(); + } else if (state.isFullScreen) { + this.setFullScreen(true); + } - updateReopenMenu(recentFilePaths); + // Retrieve the recently opened files and our Reopen menu. - // Reopen previously opened files, if any, and select the previously selected file. + recentFilePaths = electronConf.get('app.files.recent'); - this._openedFilePaths = electronConf.get('app.files.opened'); - this._openedFilePathIndex = 0; - this._selectedFilePath = electronConf.get('app.files.selected'); + updateReopenMenu(recentFilePaths); - this.reopenFilePathsAndSelectFilePath(); + // The command line can either be a classical command line or an OpenCOR action (i.e. an opencor:// link). In the + // former case, we need to remove one or two arguments while, in the latter case, nothing should be removed. - // The command line can either be a classical command line or an OpenCOR action (i.e. an opencor:// link). In - // the former case, we need to remove one or two arguments while, in the latter case, nothing should be removed. + if (!this.isAction(commandLine[0])) { + // The first argument is not an action, so remove the first argument and then the second argument, but only if we + // are not packaged. - if (!this.isAction(commandLine[0])) { - // The first argument is not an action, so remove the first argument and then the second argument, but only - // if we are not packaged. + commandLine.shift(); - commandLine.shift(); + if (!isPackaged() && commandLine.length) { + commandLine.shift(); + } + } - if (!isPackaged() && commandLine.length) { - commandLine.shift(); - } - } + // When auto updating OpenCOR, we may end up with an extra argument that we need to ignore. - // When auto updating OpenCOR, we may end up with an extra argument that we need to ignore. + if ((isWindows() || isMacOs()) && commandLine[0] === '--updated') { + commandLine.shift(); + } else if (isLinux() && commandLine[0] === '--no-sandbox') { + commandLine.shift(); + } - if ((isWindows() || isMacOs()) && commandLine[0] === '--updated') { - commandLine.shift(); - } else if (isLinux() && commandLine[0] === '--no-sandbox') { - commandLine.shift(); - } + // Handle our command line, which will effectively be done once our renderer is ready (see rendererReady()). - this.handleArguments(commandLine); - }, handleCommandLineDelay); - }); + this.handleArguments(commandLine); // Keep track of our settings unless we are resetting all. @@ -375,6 +361,27 @@ export class MainWindow extends ApplicationWindow { }); } + // Our renderer is ready, so reopen previously opened files, if any, select the previously selected file, and handle + // any arguments that we were asked to handle until now (e.g., our command line). + // Note: our renderer may get reloaded (e.g., during development), in which case it will let us know again that it is + // ready, but there is nothing more for us to do then. + + rendererReady(): void { + if (this._rendererReady) { + return; + } + + this._rendererReady = true; + + this._openedFilePaths = electronConf.get('app.files.opened'); + this._openedFilePathIndex = 0; + this._selectedFilePath = electronConf.get('app.files.selected'); + + this.reopenFilePathsAndSelectFilePath(); + + this.handleArguments(this._pendingArguments.splice(0)); + } + // Reopen previously opened files, if any, and select the previously selected file. // Note: we reopen one file at a time since a file may be a remote file which means that it may take some time to // reopen. So, we need to wait for the file to be reopened before reopening the next one. @@ -429,6 +436,14 @@ export class MainWindow extends ApplicationWindow { return; } + // Wait for our renderer to be ready, if needed. + + if (!this._rendererReady) { + this._pendingArguments.push(...commandLine); + + return; + } + for (let argument of commandLine) { if (this.isAction(argument)) { this.webContents.send('action', argument.slice(FULL_URI_SCHEME.length)); diff --git a/src/main/index.ts b/src/main/index.ts index 979aa07e4..2c0f771e6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -72,7 +72,15 @@ if (!electron.app.requestSingleInstanceLock()) { export let mainWindow: MainWindow | null = null; +// Note: another instance of OpenCOR may be started before our main window has been created (i.e. while our splash +// screen is shown), in which case we keep track of its arguments so that they can be handled once our main window +// has been created. + +const pendingArguments: string[][] = []; + electron.app.on('second-instance', (_event, argv) => { + argv.shift(); // Remove the first argument, which is the path to OpenCOR. + if (mainWindow) { if (mainWindow.isMinimized()) { mainWindow.restore(); @@ -80,9 +88,9 @@ electron.app.on('second-instance', (_event, argv) => { mainWindow.focus(); - argv.shift(); // Remove the first argument, which is the path to OpenCOR. - mainWindow.handleArguments(argv); + } else { + pendingArguments.push(argv); } }); @@ -287,6 +295,9 @@ electron.app electron.ipcMain.handle('load-settings', (): ISettings => { return loadSettings(); }); + electron.ipcMain.handle('renderer-ready', () => { + MainWindow.instance?.rendererReady(); + }); electron.ipcMain.handle('reset-all', resetAll); /* TODO: enable once our GitHub integration is fully ready. electron.ipcMain.handle('save-github-access-token', async (_event, token: string): Promise => { @@ -305,6 +316,12 @@ electron.app splashScreenWindow, process.env.ELECTRON_RENDERER_URL ?? (await startRendererServer()) ); + + // Handle the arguments of any instance of OpenCOR that was started while our splash screen was being shown. + + for (const argv of pendingArguments.splice(0)) { + mainWindow.handleArguments(argv); + } }, SHORT_DELAY); }); }) diff --git a/src/preload/index.ts b/src/preload/index.ts index eefb114ec..168df6fff 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -137,6 +137,7 @@ electron.contextBridge.exposeInMainWorld('electronApi', { installUpdateAndRestart: () => electron.ipcRenderer.invoke('install-update-and-restart'), loadGitHubAccessToken: (): Promise => electron.ipcRenderer.invoke('load-github-access-token'), loadSettings: (): Promise => electron.ipcRenderer.invoke('load-settings'), + rendererReady: () => electron.ipcRenderer.invoke('renderer-ready'), resetAll: () => electron.ipcRenderer.invoke('reset-all'), saveGitHubAccessToken: (token: string): Promise => electron.ipcRenderer.invoke('save-github-access-token', token), diff --git a/src/renderer/src/common/electronApi.ts b/src/renderer/src/common/electronApi.ts index 0a3bf4200..8c842df5f 100644 --- a/src/renderer/src/common/electronApi.ts +++ b/src/renderer/src/common/electronApi.ts @@ -33,6 +33,7 @@ interface IElectronApi { installUpdateAndRestart: () => void; loadGitHubAccessToken: () => Promise; loadSettings: () => Promise; + rendererReady: () => void; resetAll: () => void; saveGitHubAccessToken: (token: string) => Promise; saveSettings: (settings: ISettings) => void; diff --git a/src/renderer/src/components/OpenCOR.vue b/src/renderer/src/components/OpenCOR.vue index d6aa3270f..f0fbccba2 100644 --- a/src/renderer/src/components/OpenCOR.vue +++ b/src/renderer/src/components/OpenCOR.vue @@ -493,6 +493,13 @@ vue.watch( initialisingOpencorMessageVisible.value = false; + // Let our main process know that we are ready, i.e. that we have registered our listeners and that we can now + // open files, handle actions, etc. + // Note: we don't do this if our initialisation failed since we wouldn't be able to open files, handle actions, + // etc. anyway. + + electronApi?.rendererReady(); + // We are all done, so let's start checking for a newer version of OpenCOR, but only if we are running the Web app // and not in isolation mode (i.e. with a COMBINE archive) since in those cases we don't want to let the user know // about a newer version of OpenCOR. From e74bffaf37b2c9c5cdf73921cc1c3a89f8e2499f Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 11:35:27 +1300 Subject: [PATCH 026/184] Handle renderer crashes and startup failures. --- src/main/MainWindow.ts | 161 ++++++++++++++++++++++++++++++++++++----- src/main/index.ts | 23 ++++-- 2 files changed, 158 insertions(+), 26 deletions(-) diff --git a/src/main/MainWindow.ts b/src/main/MainWindow.ts index 4e783bc54..d3fef72c6 100644 --- a/src/main/MainWindow.ts +++ b/src/main/MainWindow.ts @@ -152,14 +152,51 @@ export const fileSelected = (filePath: string): void => { selectedFilePath = filePath; }; +// Retrieve the files that are currently open and the file that is currently selected, so that they can be reopened and +// selected (e.g., the next time OpenCOR is started or after our renderer has crashed). +// Note: a COMBINE archive that was opened using a data URL only exists in memory (under a made-up name, e.g., +// "OMEX #1"), so it cannot be reopened. Hence, we don't reopen such a file and, if it is the selected file, we +// select the first file that is to be reopened instead. + +interface IFilesToReopen { + opened: string[]; + selected: string; +} + +const currentFilesToReopen = (): IFilesToReopen => { + const opened = openedFilePaths.filter((filePath) => !isDataUrlOmexFileName(filePath)); + const selected = selectedFilePath && !isDataUrlOmexFileName(selectedFilePath) ? selectedFilePath : (opened[0] ?? ''); + + return { opened, selected }; +}; + +// Report a fatal error (e.g., OpenCOR couldn't be started) and quit. +// Note: our splash screen window is always on top, so we must close it first. Otherwise, it would hide our error dialog +// and remain shown forever. + +export const reportFatalErrorAndQuit = (message: string, splashScreenWindow?: SplashScreenWindow | null): void => { + console.error(`OpenCOR: ${message}`); + + if (splashScreenWindow && !splashScreenWindow.isDestroyed()) { + splashScreenWindow.close(); + } + + electron.dialog.showErrorBox('OpenCOR', message); + + electron.app.quit(); +}; + export class MainWindow extends ApplicationWindow { // Properties. static instance: MainWindow | null = null; - private _splashScreenWindowClosed = false; + private _splashScreenWindow: SplashScreenWindow | null = null; private _rendererReady = false; private _pendingArguments: string[] = []; + private _filesToReopen: IFilesToReopen | null = null; + // Note: the files to reopen and select once our renderer is ready after it has been reloaded following a crash (see + // onRenderProcessGone()). private _openedFilePaths: string[] = []; private _openedFilePathIndex = 0; private _selectedFilePath = ''; @@ -195,14 +232,12 @@ export class MainWindow extends ApplicationWindow { // Note: we must do this before restoring our state since maximising a window may also show it (on Windows and // Linux), in which case we would otherwise miss the show event and never close our splash screen window. - this.once('show', () => { - if (!this._splashScreenWindowClosed) { - this._splashScreenWindowClosed = true; + this._splashScreenWindow = splashScreenWindow; - setTimeout(() => { - splashScreenWindow.close(); - }, LONG_DELAY); - } + this.once('show', () => { + setTimeout(() => { + this.closeSplashScreenWindow(); + }, LONG_DELAY); }); // Restore our state, if needed. @@ -278,17 +313,17 @@ export class MainWindow extends ApplicationWindow { electronConf.set('app.files.recent', recentFilePaths); // Opened files and selected file. - // Note: make sure that no data URL OMEX file is to be reopened or selected. + // Note: if our renderer is not ready, then either it crashed and we are being closed before it could be + // reloaded, in which case we save the files that were open (and selected) at the time of the crash, or it + // never was ready (e.g., OpenCOR couldn't be loaded), in which case it never told us about the files that + // are open, so we keep the files that were open when OpenCOR was last closed. - const actualOpenedFilePaths = openedFilePaths.filter((filePath) => !isDataUrlOmexFileName(filePath)); - let actualSelectedFilePath = selectedFilePath; + const filesToReopen = this._rendererReady ? currentFilesToReopen() : this._filesToReopen; - if (selectedFilePath && isDataUrlOmexFileName(selectedFilePath)) { - actualSelectedFilePath = actualOpenedFilePaths[0] || null; + if (filesToReopen) { + electronConf.set('app.files.opened', filesToReopen.opened); + electronConf.set('app.files.selected', filesToReopen.selected); } - - electronConf.set('app.files.opened', actualOpenedFilePaths); - electronConf.set('app.files.selected', actualSelectedFilePath); } }); @@ -354,13 +389,91 @@ export class MainWindow extends ApplicationWindow { }; }); + // Handle our renderer process crashing (or being killed). + + this.webContents.on('render-process-gone', (_event, details) => { + this.onRenderProcessGone(details); + }); + // Load the renderer URL. + // Note: a navigation that gets interrupted (e.g., by another navigation) results in an ERR_ABORTED error, which is + // not fatal. Any other error means that OpenCOR cannot be used, so we report it and quit. this.loadURL(rendererUrl).catch((error: unknown) => { - console.error(`OpenCOR: failed to load URL (${rendererUrl}):`, formatError(error)); + if ((error as { code?: string }).code === 'ERR_ABORTED') { + console.warn(`OpenCOR: loading of URL (${rendererUrl}) was aborted:`, formatError(error)); + + return; + } + + reportFatalErrorAndQuit(`OpenCOR could not be loaded (${formatError(error)}).`, this._splashScreenWindow); }); } + // Close our splash screen window, if it is still shown. + + closeSplashScreenWindow(): void { + if (this._splashScreenWindow && !this._splashScreenWindow.isDestroyed()) { + this._splashScreenWindow.close(); + } + + this._splashScreenWindow = null; + } + + // Our renderer process is gone (e.g., it crashed because of the native libOpenCOR module), so let the user know and + // either reload our renderer (and reopen the files that were open) or quit. + + onRenderProcessGone(details: electron.RenderProcessGoneDetails): void { + if (details.reason === 'clean-exit') { + return; + } + + console.error(`OpenCOR: the renderer process is gone (${details.reason}, exit code ${details.exitCode}).`); + + // Make sure that our splash screen window doesn't hide our message box and that we are visible. + + this.closeSplashScreenWindow(); + + if (!this.isVisible()) { + this.show(); + } + + // Keep track of the files that were open and of the selected file, so that they can be reopened and selected once + // our renderer is ready again. + + const filesToReopen = currentFilesToReopen(); + + const choice = electron.dialog.showMessageBoxSync(this, { + type: 'error', + title: 'OpenCOR', + message: 'OpenCOR has encountered a problem and needs to be reloaded.', + detail: `Reason: ${details.reason} (exit code ${details.exitCode}).`, + buttons: ['Reload', 'Quit'], + defaultId: 0, + cancelId: 1 + }); + + if (choice !== 0) { + electron.app.quit(); + + return; + } + + // Reload our renderer, making sure that our main menu is enabled (our renderer may have disabled it, e.g., because + // a dialog was open) and that we reopen our files once our renderer is ready again. + + enableDisableMainMenu(true); + + this._rendererReady = false; + this._pendingArguments = []; + this._openedFilePaths = []; + this._openedFilePathIndex = 0; + this._selectedFilePath = ''; + this._filesToReopen = filesToReopen; + + this.webContents.reload(); + } + // Our renderer is ready, so reopen previously opened files, if any, select the previously selected file, and handle // any arguments that we were asked to handle until now (e.g., our command line). // Note: our renderer may get reloaded (e.g., during development), in which case it will let us know again that it is @@ -373,9 +486,19 @@ export class MainWindow extends ApplicationWindow { this._rendererReady = true; - this._openedFilePaths = electronConf.get('app.files.opened'); + // If our renderer has been reloaded following a crash, then we reopen the files that were open at the time rather + // than those that were open when OpenCOR was last closed. + + const filesToReopen = this._filesToReopen ?? { + opened: electronConf.get('app.files.opened'), + selected: electronConf.get('app.files.selected') + }; + + this._filesToReopen = null; + + this._openedFilePaths = filesToReopen.opened; this._openedFilePathIndex = 0; - this._selectedFilePath = electronConf.get('app.files.selected'); + this._selectedFilePath = filesToReopen.selected; this.reopenFilePathsAndSelectFilePath(); diff --git a/src/main/index.ts b/src/main/index.ts index 2c0f771e6..e507ac7af 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -32,6 +32,7 @@ import { installUpdateAndRestart, loadSettings, MainWindow, + reportFatalErrorAndQuit, resetAll, saveSettings } from './MainWindow'; @@ -310,12 +311,20 @@ electron.app // Create our main window and pass to it our command line arguments or, if we got started via a URI scheme, the // triggering URL. - - mainWindow = new MainWindow( - triggeringUrl ? [triggeringUrl] : process.argv, - splashScreenWindow, - process.env.ELECTRON_RENDERER_URL ?? (await startRendererServer()) - ); + // Note: if that fails (e.g., our renderer server couldn't be started), then OpenCOR cannot be used, so we + // report the error and quit (rather than leave our splash screen window shown forever). + + try { + mainWindow = new MainWindow( + triggeringUrl ? [triggeringUrl] : process.argv, + splashScreenWindow, + process.env.ELECTRON_RENDERER_URL ?? (await startRendererServer()) + ); + } catch (error: unknown) { + reportFatalErrorAndQuit(`OpenCOR could not be started (${formatError(error)}).`, splashScreenWindow); + + return; + } // Handle the arguments of any instance of OpenCOR that was started while our splash screen was being shown. @@ -326,7 +335,7 @@ electron.app }); }) .catch((error: unknown) => { - console.error('OpenCOR: failed to create the main window:', formatError(error)); + reportFatalErrorAndQuit(`OpenCOR could not be started (${formatError(error)}).`); }); // Ensure that the renderer server is stopped when quitting. From 0125bc519638e16a38a7be53376d8035aa4efa84 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 11:48:33 +1300 Subject: [PATCH 027/184] Handle simulation wait errors safely. --- src/renderer/src/common/vueCommon.ts | 41 ++++++++++++++--- .../SimulationExperimentStandardView.vue | 46 ++++++++++++++----- 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/common/vueCommon.ts b/src/renderer/src/common/vueCommon.ts index f3d64eb5e..175157d40 100644 --- a/src/renderer/src/common/vueCommon.ts +++ b/src/renderer/src/common/vueCommon.ts @@ -225,7 +225,21 @@ export const waitWhileRunning = ( clearTimeout(progressResetTimer); }; - const promise = new Promise((resolve) => { + const promise = new Promise((resolve, reject) => { + // Note: since we poll using setTimeout(), an error thrown while polling would otherwise be uncaught and our promise + // would never settle (meaning that our caller would wait forever and, for instance, never release its + // instance). So, an error thrown by one of our callbacks is reported, but doesn't stop us from polling, while + // an error thrown by our instance (e.g., by libOpenCOR) rejects our promise since we cannot know its status + // anymore. + + const callSafely = (callback: () => void): void => { + try { + callback(); + } catch (error: unknown) { + console.error('OpenCOR: an error occurred while waiting for a simulation run to finish:', error); + } + }; + const poll = (): void => { if (cancelled) { resolve(); @@ -233,12 +247,27 @@ export const waitWhileRunning = ( return; } - const status = instance.status(); + let status: locSedApi.ESedInstanceStatus; + let progress = 0; + + try { + status = instance.status(); + + if (status === locSedApi.ESedInstanceStatus.RUNNING) { + progress = instance.progress(); + } + } catch (error: unknown) { + cancel(); + + reject(error); + + return; + } if (status !== lastStatus) { lastStatus = status; - onStatusChange?.(status); + callSafely(() => onStatusChange?.(status)); } // Update the progress bar and keep polling while the simulation is running or paused, and resolve when the @@ -246,7 +275,7 @@ export const waitWhileRunning = ( switch (status) { case locSedApi.ESedInstanceStatus.RUNNING: - onProgress?.(100 * instance.progress()); + callSafely(() => onProgress?.(100 * progress)); setTimeout(poll, VERY_SHORT_DELAY); @@ -257,13 +286,13 @@ export const waitWhileRunning = ( break; default: // locSedApi.ESedInstanceStatus.IDLE: if (onProgress && !runAbortedRef?.value) { - onProgress(100); + callSafely(() => onProgress(100)); // Reset the progress bar after a short delay. progressResetTimer = setTimeout(() => { if (!cancelled) { - onProgress?.(0); + callSafely(() => onProgress(0)); } }, MEDIUM_DELAY); } diff --git a/src/renderer/src/components/views/SimulationExperimentStandardView.vue b/src/renderer/src/components/views/SimulationExperimentStandardView.vue index 36a4210bc..5ceeafe4f 100644 --- a/src/renderer/src/components/views/SimulationExperimentStandardView.vue +++ b/src/renderer/src/components/views/SimulationExperimentStandardView.vue @@ -219,17 +219,24 @@ let isUnmounted = false; let isWaitingOnRun = false; const releaseResources = async (): Promise => { - if (instance) { - if (instance.status() !== locSedApi.ESedInstanceStatus.IDLE) { - instance.stopRun(); + // Note: we always release our document, even if something goes wrong with our instance (e.g., if libOpenCOR throws an + // exception while we wait for it to be idle) since we would otherwise leak it. - await vueCommon.waitWhileRunning(instance).promise; - } + try { + if (instance) { + if (instance.status() !== locSedApi.ESedInstanceStatus.IDLE) { + instance.stopRun(); - instance.release(); - } + await vueCommon.waitWhileRunning(instance).promise; + } - document.release(); + instance.release(); + } + } catch (error: unknown) { + console.error('OpenCOR: an error occurred while releasing a simulation instance:', common.formatError(error)); + } finally { + document.release(); + } }; // Event handlers. @@ -308,11 +315,17 @@ const onRunPause = async (): Promise => { isWaitingOnRun = true; - await runPromise; + let runError: unknown = null; - isWaitingOnRun = false; + try { + await runPromise; + } catch (error: unknown) { + runError = error; + } finally { + isWaitingOnRun = false; - progressResetCancel = undefined; + progressResetCancel = undefined; + } // Release our resources if we got unmounted while the simulation was running (see onUnmounted() below). @@ -322,6 +335,17 @@ const onRunPause = async (): Promise => { return; } + // Report any error that occurred while waiting for the simulation to finish (e.g., if libOpenCOR threw an + // exception), in which case we cannot rely on our instance anymore. + + if (runError) { + simulationStatus.value = locSedApi.ESedInstanceStatus.IDLE; + + consoleContents.value += `
  Error: ${common.formatMessage(common.formatError(runError))}`; + + return; + } + // Update the console with any issues that occurred during the simulation, or display the simulation time if it // completed successfully. From 8787a404a37c3b40a2bc3e8cc7c8625c8a153da6 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 11:53:01 +1300 Subject: [PATCH 028/184] Respect the menu state when refreshing the Reopen menu. --- src/main/MainMenu.ts | 20 +++++++++++++++++--- src/main/MainWindow.ts | 23 ++++++++++++++++------- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/main/MainMenu.ts b/src/main/MainMenu.ts index eae769780..f58ad9cbb 100644 --- a/src/main/MainMenu.ts +++ b/src/main/MainMenu.ts @@ -10,8 +10,13 @@ let enabledMenu: electron.Menu | null = null; let disabledMenu: electron.Menu | null = null; let recentFilePaths: string[] = []; let hasFiles = false; +let menuEnabled = true; export const enableDisableMainMenu = (enable: boolean): void => { + // Keep track of whether our menu is enabled (see updateReopenMenu()). + + menuEnabled = enable; + // Build our menu, if needed. if (enable && enabledMenu) { @@ -293,9 +298,12 @@ export const enableDisableMainMenu = (enable: boolean): void => { }; export const enableDisableFileCloseAndCloseAllMenuItems = (enable: boolean): void => { - if (enabledMenu) { - hasFiles = enable; + // Note: we keep track of whether we have files even if we don't currently have an enabled menu (e.g., because it got + // invalidated while our menu was disabled, see updateReopenMenu()), so that it gets built accordingly. + + hasFiles = enable; + if (enabledMenu) { const fileCloseMenu = enabledMenu.getMenuItemById('fileClose'); const fileCloseAllMenu = enabledMenu.getMenuItemById('fileCloseAll'); @@ -307,8 +315,14 @@ export const enableDisableFileCloseAndCloseAllMenuItems = (enable: boolean): voi }; export const updateReopenMenu = (filePaths: string[]): void => { + // Invalidate our enabled menu so that it gets rebuilt with our new Reopen menu, but only rebuild (and set) it now if + // our menu is enabled. Indeed, our menu may be disabled (e.g., because a dialog is open in our renderer), in which + // case it must remain disabled and it will get rebuilt when it gets enabled again. + enabledMenu = null; recentFilePaths = filePaths; - enableDisableMainMenu(true); + if (menuEnabled) { + enableDisableMainMenu(true); + } }; diff --git a/src/main/MainWindow.ts b/src/main/MainWindow.ts index d3fef72c6..060599027 100644 --- a/src/main/MainWindow.ts +++ b/src/main/MainWindow.ts @@ -81,12 +81,21 @@ export const resetAll = (): void => { let recentFilePaths: string[] = []; -const removeRecentFilePath = (filePath: string): void => { +const removeRecentFilePath = (filePath: string): boolean => { + // Remove the given file path from our recent file paths and return whether it was removed (in which case our Reopen + // menu needs to be updated). + + let res = false; + for (let i = recentFilePaths.length - 1; i >= 0; --i) { if (recentFilePaths[i] === filePath) { recentFilePaths.splice(i, 1); + + res = true; } } + + return res; }; export const clearRecentFiles = (): void => { @@ -114,9 +123,9 @@ export const fileClosed = (filePath: string): void => { }; export const fileIssue = (filePath: string): void => { - removeRecentFilePath(filePath); - - updateReopenMenu(recentFilePaths); + if (removeRecentFilePath(filePath)) { + updateReopenMenu(recentFilePaths); + } // A file couldn't be opened, possibly while reopening files during OpenCOR startup, in which case we need to make sure // that it doesn't get selected and reopen the next file. @@ -125,9 +134,9 @@ export const fileIssue = (filePath: string): void => { }; export const fileOpened = (filePath: string): void => { - removeRecentFilePath(filePath); - - updateReopenMenu(recentFilePaths); + if (removeRecentFilePath(filePath)) { + updateReopenMenu(recentFilePaths); + } // A file has been opened, but it may have been opened while reopening files during OpenCOR startup, in which case we // need to reopen the next file, hence our call to reopenFilePathsAndSelectFilePath(), which will do nothing if there From 2f3fe9c9d3bb3ae84135f72ef8af8abe21a88d5b Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 11:57:32 +1300 Subject: [PATCH 029/184] Don't send messages to our renderer if the application window has been destroyed. --- src/main/ApplicationWindow.ts | 11 +++++++++++ src/main/MainMenu.ts | 20 ++++++++++---------- src/main/MainWindow.ts | 30 ++++++++++++++++++------------ src/main/SplashScreenWindow.ts | 2 +- src/main/index.ts | 7 +++++++ 5 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/main/ApplicationWindow.ts b/src/main/ApplicationWindow.ts index ba4860da7..2abae70fa 100644 --- a/src/main/ApplicationWindow.ts +++ b/src/main/ApplicationWindow.ts @@ -24,4 +24,15 @@ export class ApplicationWindow extends electron.BrowserWindow { this.show(); }); } + + // Send a message to our renderer. + // Note: we may have been closed (and therefore destroyed) while some asynchronous operation was in progress (e.g., + // checking for updates or showing an open dialog), in which case sending a message would throw an "Object has + // been destroyed" error, so we don't send it. + + send(channel: string, ...args: unknown[]): void { + if (!this.isDestroyed() && !this.webContents.isDestroyed()) { + this.webContents.send(channel, ...args); + } + } } diff --git a/src/main/MainMenu.ts b/src/main/MainMenu.ts index f58ad9cbb..9ea992423 100644 --- a/src/main/MainMenu.ts +++ b/src/main/MainMenu.ts @@ -30,7 +30,7 @@ export const enableDisableMainMenu = (enable: boolean): void => { label: 'Settings...', accelerator: 'CmdOrCtrl+,', click: () => { - mainWindow?.webContents.send('settings'); + mainWindow?.send('settings'); } }; @@ -40,7 +40,7 @@ export const enableDisableMainMenu = (enable: boolean): void => { checkForUpdatesMenuItem = { label: 'Check for Updates...', click: () => { - mainWindow?.webContents.send('check-for-updates'); + mainWindow?.send('check-for-updates'); } }; } @@ -48,7 +48,7 @@ export const enableDisableMainMenu = (enable: boolean): void => { const aboutOpencorMenuItem: electron.MenuItemConstructorOptions = { label: 'About OpenCOR', click: () => { - mainWindow?.webContents.send('about'); + mainWindow?.send('about'); } }; @@ -103,7 +103,7 @@ export const enableDisableMainMenu = (enable: boolean): void => { label: 'Open Remote...', accelerator: 'CmdOrCtrl+Shift+O', click: () => { - mainWindow?.webContents.send('open-remote'); + mainWindow?.send('open-remote'); } }); fileSubMenu.push({ @@ -112,7 +112,7 @@ export const enableDisableMainMenu = (enable: boolean): void => { { label: 'Lorenz', click: () => { - mainWindow?.webContents.send('open-sample-lorenz'); + mainWindow?.send('open-sample-lorenz'); } } ] @@ -124,7 +124,7 @@ export const enableDisableMainMenu = (enable: boolean): void => { label: 'Most Recent', accelerator: 'CmdOrCtrl+Shift+T', click: () => { - mainWindow?.webContents.send('open', recentFilePaths[0]); + mainWindow?.send('open', recentFilePaths[0]); }, enabled: recentFilePaths.length > 0 }); @@ -136,7 +136,7 @@ export const enableDisableMainMenu = (enable: boolean): void => { fileReopenSubMenu.push({ label: filePath, click: () => { - mainWindow?.webContents.send('open', filePath); + mainWindow?.send('open', filePath); } }); }); @@ -163,7 +163,7 @@ export const enableDisableMainMenu = (enable: boolean): void => { label: 'Close', accelerator: 'CmdOrCtrl+W', click: () => { - mainWindow?.webContents.send('close'); + mainWindow?.send('close'); }, enabled: hasFiles }); @@ -171,7 +171,7 @@ export const enableDisableMainMenu = (enable: boolean): void => { id: 'fileCloseAll', label: 'Close All', click: () => { - mainWindow?.webContents.send('close-all'); + mainWindow?.send('close-all'); }, enabled: hasFiles }); @@ -227,7 +227,7 @@ export const enableDisableMainMenu = (enable: boolean): void => { toolsSubMenu.push({ label: 'Reset All...', click: () => { - mainWindow?.webContents.send('reset-all'); + mainWindow?.send('reset-all'); } }); diff --git a/src/main/MainWindow.ts b/src/main/MainWindow.ts index 060599027..33c34d5ab 100644 --- a/src/main/MainWindow.ts +++ b/src/main/MainWindow.ts @@ -28,29 +28,29 @@ export const checkForUpdates = (atStartup: boolean): void => { const updateAvailable = result?.isUpdateAvailable ?? false; if (updateAvailable) { - MainWindow.instance?.webContents.send('update-available', result?.updateInfo.version); + MainWindow.instance?.send('update-available', result?.updateInfo.version); } else if (!atStartup) { - MainWindow.instance?.webContents.send('update-not-available'); + MainWindow.instance?.send('update-not-available'); } }) .catch((error: unknown) => { - MainWindow.instance?.webContents.send('update-check-error', formatError(error)); + MainWindow.instance?.send('update-check-error', formatError(error)); }); } }; autoUpdater.on('download-progress', (info: ProgressInfo) => { - MainWindow.instance?.webContents.send('update-download-progress', info.percent); + MainWindow.instance?.send('update-download-progress', info.percent); }); export const downloadAndInstallUpdate = (): void => { autoUpdater .downloadUpdate() .then(() => { - MainWindow.instance?.webContents.send('update-downloaded'); + MainWindow.instance?.send('update-downloaded'); }) .catch((error: unknown) => { - MainWindow.instance?.webContents.send('update-download-error', formatError(error)); + MainWindow.instance?.send('update-download-error', formatError(error)); }); }; @@ -231,6 +231,12 @@ export class MainWindow extends ApplicationWindow { MainWindow.instance = this; + this.on('closed', () => { + if (MainWindow.instance === this) { + MainWindow.instance = null; + } + }); + // Set our dock icon (macOS only). if (!isPackaged() && isMacOs()) { @@ -535,7 +541,7 @@ export class MainWindow extends ApplicationWindow { ++this._openedFilePathIndex; if (filePath) { - this.webContents.send('open', filePath); + this.send('open', filePath); return; } @@ -547,7 +553,7 @@ export class MainWindow extends ApplicationWindow { // All the files have been reopened (or couldn't be reopened), so we can now select the previously selected file. if (this._selectedFilePath) { - this.webContents.send('select', this._selectedFilePath); + this.send('select', this._selectedFilePath); this._selectedFilePath = ''; } @@ -578,7 +584,7 @@ export class MainWindow extends ApplicationWindow { for (let argument of commandLine) { if (this.isAction(argument)) { - this.webContents.send('action', argument.slice(FULL_URI_SCHEME.length)); + this.send('action', argument.slice(FULL_URI_SCHEME.length)); continue; } @@ -595,7 +601,7 @@ export class MainWindow extends ApplicationWindow { argument = path.resolve(argument); } - this.webContents.send('open', argument); + this.send('open', argument); } } @@ -604,7 +610,7 @@ export class MainWindow extends ApplicationWindow { enableDisableUi(enable: boolean): void { enableDisableMainMenu(enable); - this.webContents.send('enable-disable-ui', enable); + this.send('enable-disable-ui', enable); } // Handle our File|Open menu. @@ -618,7 +624,7 @@ export class MainWindow extends ApplicationWindow { }) .then(({ filePaths }) => { for (const filePath of filePaths) { - this.webContents.send('open', filePath); + this.send('open', filePath); } this.enableDisableUi(true); diff --git a/src/main/SplashScreenWindow.ts b/src/main/SplashScreenWindow.ts index 468972a59..44271774c 100644 --- a/src/main/SplashScreenWindow.ts +++ b/src/main/SplashScreenWindow.ts @@ -35,7 +35,7 @@ export class SplashScreenWindow extends ApplicationWindow { // Initialise our Web contents. this.on('ready-to-show', () => { - this.webContents.send('init-splash-screen-window', { + this.send('init-splash-screen-window', { copyright: COPYRIGHT, version: electron.app.getVersion() }); diff --git a/src/main/index.ts b/src/main/index.ts index e507ac7af..3701d0518 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -320,6 +320,13 @@ electron.app splashScreenWindow, process.env.ELECTRON_RENDERER_URL ?? (await startRendererServer()) ); + + // Forget about our main window once it has been closed (and therefore destroyed), so that we don't try to + // use it (e.g., from our main menu or when another instance of OpenCOR is started). + + mainWindow.on('closed', () => { + mainWindow = null; + }); } catch (error: unknown) { reportFatalErrorAndQuit(`OpenCOR could not be started (${formatError(error)}).`, splashScreenWindow); From ef487fde83b04a7eee45c33a5c3ae37be648b224 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 12:34:11 +1300 Subject: [PATCH 030/184] Add retries for libOpenCOR and lazy imports. --- src/renderer/src/common/initialisation.ts | 267 +++++++++++++--------- 1 file changed, 162 insertions(+), 105 deletions(-) diff --git a/src/renderer/src/common/initialisation.ts b/src/renderer/src/common/initialisation.ts index 658c0eeb3..41d71f494 100644 --- a/src/renderer/src/common/initialisation.ts +++ b/src/renderer/src/common/initialisation.ts @@ -56,22 +56,70 @@ const totalNbOfSteps = const libOpenCORWasmBaseUrl = `https://opencor.ws/libopencor/downloads/wasm/${__LIBOPENCOR_WASM_VERSION__}`; -// Import and instantiate libOpenCOR. +// A helper function to retry an asynchronous operation (e.g., importing an external dependency from a CDN) a few times, +// with an increasing delay, before giving up. This way, a transient network issue doesn't prevent OpenCOR from being +// initialised (and therefore require the user to reload the page). -const importAndInstantiateLibOpenCOR = async (libOpenCORJSUrl: string): Promise => { - const libOpenCOR = (await import(/* @vite-ignore */ libOpenCORJSUrl)).default as WasmFactory; +const NB_OF_ATTEMPTS = 3; +const RETRY_DELAY = 1000; + +const withRetries = async (name: string, operation: (attempt: number) => Promise): Promise => { + for (let attempt = 1; ; ++attempt) { + try { + return await operation(attempt); + } catch (error: unknown) { + if (attempt >= NB_OF_ATTEMPTS) { + throw error; + } + + console.warn( + `OpenCOR: failed to load ${name} (attempt ${attempt} of ${NB_OF_ATTEMPTS}), retrying:`, + common.formatError(error) + ); + + await common.sleep(attempt * RETRY_DELAY); + } + } +}; + +// A helper function to return the URL to use for the given attempt at loading a resource. +// Note: browsers remember that a module failed to be imported from a given URL and will fail to import it again from +// that same URL without even trying. So, from our second attempt onwards, we add a query string to the URL, which +// is ignored when serving static files (e.g., by jsDelivr and https://opencor.ws), so that our browser considers +// it to be a different URL. Note that this doesn't apply to blob URLs, which wouldn't be valid anymore with a +// query string (and which don't involve the network anyway). + +const attemptUrl = (url: string, attempt: number): string => { + return attempt === 1 || url.startsWith('blob:') ? url : `${url}${url.includes('?') ? '&' : '?'}retry=${attempt}`; +}; + +// Import libOpenCOR's glue (i.e. its JavaScript loader), retrying if needed. + +const importLibOpenCOR = async (libOpenCORJSUrl: string): Promise => { + const res = await withRetries( + 'libOpenCOR', + async (attempt) => (await import(/* @vite-ignore */ attemptUrl(libOpenCORJSUrl, attempt))).default as WasmFactory + ); ++crtNbOfSteps.value; + return res; +}; + +// Instantiate libOpenCOR (which fetches its WASM), retrying if needed. + +const instantiateLibOpenCOR = async (libOpenCOR: WasmFactory): Promise => { locApi.setWasmLocApi( - await libOpenCOR({ - locateFile: (path: string) => { - // Note: the only file that is loaded by libOpenCOR is its threaded WASM, hence fetching it directly from - // https://opencor.ws. + await withRetries('libOpenCOR', () => + libOpenCOR({ + locateFile: (path: string) => { + // Note: the only file that is loaded by libOpenCOR is its threaded WASM, hence fetching it directly from + // https://opencor.ws. - return `${libOpenCORWasmBaseUrl}/${path}`; - } - }) + return `${libOpenCORWasmBaseUrl}/${path}`; + } + }) + ) ); ++crtNbOfSteps.value; @@ -88,110 +136,115 @@ export const initialiseLocApi = async (): Promise => { // @ts-expect-error (window.locApi is defined) locApi.setCppLocApi(window.locApi); - } else { - // We are running OpenCOR's Web app, so we must import libOpenCOR's WebAssembly module and instantiate it. - try { - // libOpenCOR's glue (i.e. its JavaScript loader) can be served from various places, depending on the host - // application: - // - OpenCOR's Web app serves it from a same-origin URL (from its public folder in development and from its own - // Web root in production). This is required by its Content Security Policy, which only allows scripts and - // workers from 'self'. - // - Other host applications (e.g., a third-party app using @opencor/opencor as an npm package) don't serve it at - // all, in which case we import it from https://opencor.ws. To make the worker same-origin (cross-origin - // workers are not always allowed, e.g., under COEP or in restricted embedders), we fetch the glue's source, - // patch its worker-script URL so that it points to a same-origin blob URL, and import the patched source from - // that blob URL. - // We therefore first try to import the glue from a same-origin URL, and only fall back on importing it from - // https://opencor.ws if the host application doesn't serve it. - - // The relative URL, with respect to the host application, from which the glue might be served. It matches both - // OpenCOR's Web app in production (https://opencor.ws serves libOpenCOR from /libopencor/downloads/wasm/...) and - // a copy of libOpenCOR's files at the root of the host application (e.g., in OpenCOR's Web app's public folder, - // which mirrors the same path). - - const url = new URL(`libopencor/downloads/wasm/${__LIBOPENCOR_WASM_VERSION__}`, document.baseURI).href; - // Note: document.baseURI is used rather than window.location.href, since the former doesn't change with - // client-side routing. - - try { - const response = await fetch(`${url}/libopencor.js`, { method: 'HEAD' }); - const contentType = response.headers.get('content-type') ?? ''; - // Note: we use a HEAD request to check that the glue is served from this URL before actually importing it. We - // only fall back on https://opencor.ws when the host application clearly doesn't serve it: a 405 (Method - // Not Allowed) response means that the glue is served but HEAD requests are not supported, so we try to - // import it anyway, and the content-type check is only meant to detect an HTML or XHTML fallback page - // (some host applications return one for any URL), since the content-type of a statically served - // JavaScript file is not always reported as such. - - if ( - response.status === 405 || - (response.ok && !contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) - ) { - await importAndInstantiateLibOpenCOR(`${url}/libopencor.js`); - - return; - } - } catch { - // The host application doesn't serve libOpenCOR from this URL, so import it from https://opencor.ws instead. - } + return; + } - // The host application doesn't serve libOpenCOR itself, so import it from https://opencor.ws. To make its module - // worker same-origin (see above), we fetch the glue's source, patch its worker-script URL so that it points to a - // same-origin blob URL, and import the patched source from that blob URL. + // We are running OpenCOR's Web app, so we must import libOpenCOR's WebAssembly module and instantiate it. - const response = await fetch(`${libOpenCORWasmBaseUrl}/libopencor.js`); + try { + await instantiateLibOpenCOR(await importLibOpenCORGlue()); + } catch (error: unknown) { + console.error('OpenCOR: failed to load libOpenCOR:', common.formatError(error)); - if (!response.ok) { - throw new Error( - `Failed to load libOpenCOR's glue from ${libOpenCORWasmBaseUrl} (${response.status}: ${response.statusText}).` - ); - } + throw error; + } +}; - const libOpenCORSource = await response.text(); +// Import libOpenCOR's glue from wherever it is served. +// Note: libOpenCOR's glue can be served from various places, depending on the host application: +// - OpenCOR's Web app serves it from a same-origin URL (from its public folder in development and from its own +// Web root in production). This is required by its Content Security Policy which only allows scripts and +// workers from 'self'. +// - Other host applications (e.g., a third-party app using @opencor/opencor as an npm package) don't serve it at +// all, in which case we import it from https://opencor.ws. To make the worker same-origin (cross-origin +// workers are not always allowed, e.g., under COEP or in restricted embedders), we fetch the glue's source, +// patch its worker-script URL so that it points to a same-origin blob URL, and import the patched source from +// that blob URL. +// We therefore first try to import the glue from a same-origin URL, and only fall back on importing it from +// https://opencor.ws if the host application doesn't serve it (or we couldn't import it from there). + +const importLibOpenCORGlue = async (): Promise => { + // The relative URL, with respect to the host application, from which the glue might be served. It matches both + // OpenCOR's Web app in production (https://opencor.ws serves libOpenCOR from /libopencor/downloads/wasm/...) and a + // copy of libOpenCOR's files at the root of the host application (e.g., in OpenCOR's Web app's public folder, which + // mirrors the same path). + // Note: document.baseURI is used rather than window.location.href, since the former doesn't change with client-side + // routing. + + const url = new URL(`libopencor/downloads/wasm/${__LIBOPENCOR_WASM_VERSION__}`, document.baseURI).href; + + try { + const response = await fetch(`${url}/libopencor.js`, { method: 'HEAD' }); + const contentType = response.headers.get('content-type') ?? ''; + // Note: we use a HEAD request to check that the glue is served from this URL before actually importing it. We only + // fall back on https://opencor.ws when the host application clearly doesn't serve it: a 405 (Method Not + // Allowed) response means that the glue is served but HEAD requests are not supported, so we try to import it + // anyway, and the content-type check is only meant to detect an HTML or XHTML fallback page (some host + // applications return one for any URL), since the content-type of a statically served JavaScript file is not + // always reported as such. + + if ( + response.status === 405 || + (response.ok && !contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) + ) { + return await importLibOpenCOR(`${url}/libopencor.js`); + } + } catch { + // The host application doesn't serve libOpenCOR from this URL (or we couldn't import it from there), so import it + // from https://opencor.ws instead. + } - // The URL of the worker script, which must be the same glue, but served from a same-origin blob URL. + // The host application doesn't serve libOpenCOR itself, so import it from https://opencor.ws. To make its module + // worker same-origin (see above), we fetch the glue's source, patch its worker-script URL so that it points to a + // same-origin blob URL, and import the patched source from that blob URL. - const libOpenCORWorkerUrl = URL.createObjectURL(new Blob([libOpenCORSource], { type: 'text/javascript' })); + const libOpenCORSource = await withRetries('libOpenCOR', async (attempt) => { + const response = await fetch(attemptUrl(`${libOpenCORWasmBaseUrl}/libopencor.js`, attempt)); - // Patch the glue's worker-script URL so that its worker is spawned from the same-origin blob URL. + if (!response.ok) { + throw new Error( + `Failed to load libOpenCOR's glue from ${libOpenCORWasmBaseUrl} (${response.status}: ${response.statusText}).` + ); + } - const workerUrlPattern = 'new URL("libopencor.js",import.meta.url)'; + return response.text(); + }); - const patchedLibOpenCORSource = libOpenCORSource.includes(workerUrlPattern) - ? libOpenCORSource.replace(workerUrlPattern, JSON.stringify(libOpenCORWorkerUrl)) - : undefined; + // The URL of the worker script, which must be the same glue, but served from a same-origin blob URL. - if (patchedLibOpenCORSource !== undefined) { - const patchedLibOpenCORUrl = URL.createObjectURL( - new Blob([patchedLibOpenCORSource], { type: 'text/javascript' }) - ); + const libOpenCORWorkerUrl = URL.createObjectURL(new Blob([libOpenCORSource], { type: 'text/javascript' })); - try { - await importAndInstantiateLibOpenCOR(patchedLibOpenCORUrl); - } finally { - // The patched glue has now been imported, so its blob URL is no longer needed and can be revoked. - // Note: libOpenCORWorkerUrl, on the other hand, must remain valid since libOpenCOR spawns its module worker - // lazily (on the first simulation run, and again whenever no worker is available), so revoking it now - // would prevent the worker from being spawned. + // Patch the glue's worker-script URL so that its worker is spawned from the same-origin blob URL. - URL.revokeObjectURL(patchedLibOpenCORUrl); - } - } else { - // The glue doesn't contain the expected worker-script URL, which means a future version of libOpenCOR generates - // different code. So, import it directly from https://opencor.ws, which works in standard browsers (the worker - // will then be cross-origin, which is allowed with CORS), but may not in restricted ones. + const workerUrlPattern = 'new URL("libopencor.js",import.meta.url)'; - URL.revokeObjectURL(libOpenCORWorkerUrl); - // Note: the worker URL is not used in this case, so it can be revoked. + if (!libOpenCORSource.includes(workerUrlPattern)) { + // The glue doesn't contain the expected worker-script URL, which means a future version of libOpenCOR generates + // different code. So, import it directly from https://opencor.ws, which works in standard browsers (the worker will + // then be cross-origin, which is allowed with CORS), but may not in restricted ones. + // Note: the worker URL is not used in this case, so it can be revoked. - await importAndInstantiateLibOpenCOR(`${libOpenCORWasmBaseUrl}/libopencor.js`); - } - } catch (error: unknown) { - console.error('OpenCOR: failed to load libOpenCOR:', common.formatError(error)); + URL.revokeObjectURL(libOpenCORWorkerUrl); - throw error; - } + return importLibOpenCOR(`${libOpenCORWasmBaseUrl}/libopencor.js`); + } + + const patchedLibOpenCORUrl = URL.createObjectURL( + new Blob([libOpenCORSource.replace(workerUrlPattern, JSON.stringify(libOpenCORWorkerUrl))], { + type: 'text/javascript' + }) + ); + + try { + return await importLibOpenCOR(patchedLibOpenCORUrl); + } finally { + // The patched glue has now been imported, so its blob URL is no longer needed and can be revoked. + // Note: libOpenCORWorkerUrl, on the other hand, must remain valid since libOpenCOR spawns its module worker lazily + // (on the first simulation run, and again whenever no worker is available), so revoking it now would prevent + // the worker from being spawned. + + URL.revokeObjectURL(patchedLibOpenCORUrl); } }; @@ -207,9 +260,9 @@ const createLazyInitialiser = ( ) => { return async (): Promise => { try { - // Import the exteral dependency and set it. + // Import the external dependency (retrying if needed) and set it. - const module = await import(/* @vite-ignore */ url); + const module = await withRetries(name, (attempt) => import(/* @vite-ignore */ attemptUrl(url, attempt))); set((module as dependencies.Module).default ?? module); @@ -219,15 +272,19 @@ const createLazyInitialiser = ( if (cssUrl) { if (!injectedCss.has(cssUrl)) { - const response = await fetch(/* @vite-ignore */ cssUrl, { mode: 'cors' }); + const cssText = await withRetries(`${name}'s stylesheet`, async () => { + const response = await fetch(/* @vite-ignore */ cssUrl, { mode: 'cors' }); + + if (!response.ok) { + throw new Error(`Failed to load ${name ?? 'stylesheet'} (${response.status}: ${response.statusText}).`); + } - if (!response.ok) { - throw new Error(`Failed to load ${name ?? 'stylesheet'} (${response.status}: ${response.statusText}).`); - } + return response.text(); + }); const style = document.createElement('style'); - style.textContent = await response.text(); + style.textContent = cssText; document.head.appendChild(style); From cdf5bcac4b3be8ab47181db63af9700534b044a9 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 12:51:54 +1300 Subject: [PATCH 031/184] Speed startup by slimming down our splash screen window. --- src/main/ApplicationWindow.ts | 17 ++++++--- src/main/SplashScreenWindow.ts | 49 +++++++++++++----------- src/main/assets/splashscreen.js | 8 ++-- src/main/index.ts | 13 ++++++- src/preload/index.ts | 53 ++++---------------------- src/renderer/src/common/electronApi.ts | 9 ----- 6 files changed, 62 insertions(+), 87 deletions(-) diff --git a/src/main/ApplicationWindow.ts b/src/main/ApplicationWindow.ts index 2abae70fa..378cab22d 100644 --- a/src/main/ApplicationWindow.ts +++ b/src/main/ApplicationWindow.ts @@ -2,7 +2,11 @@ import electron from 'electron'; import path from 'node:path'; export class ApplicationWindow extends electron.BrowserWindow { - constructor(options: electron.BrowserWindowConstructorOptions) { + // Note: a window that doesn't need to communicate with our main process (e.g., our splash screen window) doesn't need + // our preload script, which is heavy (it loads the native libOpenCOR module, among other things), in which case + // it also uses Electron's default (i.e. secure) Web preferences. + + constructor(options: electron.BrowserWindowConstructorOptions, usePreload = true) { // Add some common options and call our parent constructor. // Note: we use backgroundColor to minimise the flickering that may occur when first showing a window. This means // that the colours used here should be the same as the ones used by --p-content-background in PrimeVue, i.e. @@ -11,10 +15,13 @@ export class ApplicationWindow extends electron.BrowserWindow { options.backgroundColor = electron.nativeTheme.shouldUseDarkColors ? '#18181b' : '#ffffff'; options.show = false; options.useContentSize = true; - options.webPreferences = { - preload: path.join(import.meta.dirname, '../preload/index.mjs'), - sandbox: false - }; + + if (usePreload) { + options.webPreferences = { + preload: path.join(import.meta.dirname, '../preload/index.mjs'), + sandbox: false + }; + } super(options); diff --git a/src/main/SplashScreenWindow.ts b/src/main/SplashScreenWindow.ts index 44271774c..00b6e28ab 100644 --- a/src/main/SplashScreenWindow.ts +++ b/src/main/SplashScreenWindow.ts @@ -14,31 +14,34 @@ export class SplashScreenWindow extends ApplicationWindow { const height = 351 + 42; const state: IElectronConfState = electronConf.get('app.state'); - super({ - x: state.x + ((state.width - width) >> 1), - y: state.y + ((state.height - height) >> 1), - width: width, - height: height, - minWidth: width, - minHeight: height, - maxWidth: width, - maxHeight: height, - frame: false, - show: false, - alwaysOnTop: true - }); - - this.loadFile('./src/main/assets/splashscreen.html').catch((error: unknown) => { - console.warn('OpenCOR: failed to load splash screen:', formatError(error)); - }); - - // Initialise our Web contents. - - this.on('ready-to-show', () => { - this.send('init-splash-screen-window', { + super( + { + x: state.x + ((state.width - width) >> 1), + y: state.y + ((state.height - height) >> 1), + width: width, + height: height, + minWidth: width, + minHeight: height, + maxWidth: width, + maxHeight: height, + frame: false, + show: false, + alwaysOnTop: true + }, + false + ); + + // Load our splash screen, passing it our copyright and version. + // Note: we pass them using a query string rather than IPC since our splash screen window doesn't use our preload + // script (see ApplicationWindow). + + this.loadFile('./src/main/assets/splashscreen.html', { + query: { copyright: COPYRIGHT, version: electron.app.getVersion() - }); + } + }).catch((error: unknown) => { + console.warn('OpenCOR: failed to load the splash screen:', formatError(error)); }); } } diff --git a/src/main/assets/splashscreen.js b/src/main/assets/splashscreen.js index b7f0291fe..b54e928c3 100644 --- a/src/main/assets/splashscreen.js +++ b/src/main/assets/splashscreen.js @@ -1,4 +1,4 @@ -window.electronApi.onInitSplashScreenWindow((info) => { - document.getElementById('copyright').innerText = info.copyright; - document.getElementById('version').innerText = info.version; -}); +const searchParams = new URLSearchParams(window.location.search); + +document.getElementById('copyright').innerText = searchParams.get('copyright') ?? ''; +document.getElementById('version').innerText = searchParams.get('version') ?? ''; diff --git a/src/main/index.ts b/src/main/index.ts index 3701d0518..4ee9d9078 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -226,6 +226,17 @@ electron.app const splashScreenWindow = new SplashScreenWindow(); + // Start our renderer server straight away (rather than once our splash screen window has been shown), so that it is + // ready by the time we create our main window. + // Note: any error is handled when creating our main window, but we need to handle it here too, so that it isn't + // reported as an unhandled rejection in the meantime. + + const rendererUrl = process.env.ELECTRON_RENDERER_URL + ? Promise.resolve(process.env.ELECTRON_RENDERER_URL) + : startRendererServer(); + + rendererUrl.catch(() => {}); + // Set our app user model id for Windows. electronToolkitUtils.electronApp.setAppUserModelId('ws.opencor.app'); @@ -318,7 +329,7 @@ electron.app mainWindow = new MainWindow( triggeringUrl ? [triggeringUrl] : process.argv, splashScreenWindow, - process.env.ELECTRON_RENDERER_URL ?? (await startRendererServer()) + await rendererUrl ); // Forget about our main window once it has been closed (and therefore destroyed), so that we don't try to diff --git a/src/preload/index.ts b/src/preload/index.ts index 168df6fff..b889fa6e0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,14 +1,9 @@ import electron from 'electron'; -import { exec as _exec } from 'node:child_process'; import { promises as fs } from 'node:fs'; -import { promisify } from 'node:util'; import loc from '../../dist/libOpenCOR/Release/libOpenCOR.node'; import type { ISettings } from '../renderer/src/common/common'; -import type { ISplashScreenInfo } from '../renderer/src/common/electronApi'; - -const exec = promisify(_exec); // A helper function to listen to a channel from the main process. // Note: it returns a function to stop listening to that channel so that a listener can be removed (e.g., when the @@ -31,33 +26,19 @@ let _operatingSystem: string | null = null; const defaultOperatingSystem = `${process.platform} (${process.arch === 'x64' ? 'Intel' : 'ARM'})`; const retrieveOperatingSystem = async (): Promise => { - const safeExec = async (cmd: string): Promise => { - try { - const { stdout } = await exec(cmd); - - return stdout?.toString().trim() || null; - } catch { - return null; - } - }; + // Note: we don't spawn any process (e.g., sw_vers on macOS or wmic on Windows, the latter not being available on + // recent versions of Windows 11) to retrieve the operating system since it would slow down our startup. + // Instead, we use Electron's process.getSystemVersion() on macOS and Windows, and read /etc/os-release on + // Linux. let operatingSystem: string = ''; if (process.platform === 'win32') { - const res = await safeExec('wmic os get Caption'); - - if (res) { - const lines = res - .split(/\r?\n/) - .map((s) => s.trim()) - .filter(Boolean); + // Note: Windows 11 still reports itself as Windows 10.0, but with a build number of 22000 or above. - if (lines[1]) { - operatingSystem = lines[1].replace(/^Microsoft\s+/, ''); - } - } + const [major, , build] = process.getSystemVersion().split('.').map(Number); - operatingSystem = operatingSystem || 'Windows'; + operatingSystem = major === 10 && (build ?? 0) >= 22000 ? 'Windows 11' : major === 10 ? 'Windows 10' : 'Windows'; } else if (process.platform === 'linux') { let res: string | null = null; @@ -71,22 +52,9 @@ const retrieveOperatingSystem = async (): Promise => { operatingSystem = (match[1] || match[2] || '').replace(/"/g, '').trim(); } - if (!operatingSystem) { - res = await safeExec('lsb_release -ds'); - - operatingSystem = res?.replace(/^"|"$/g, '') || ''; - } - operatingSystem = operatingSystem || 'Linux'; } else if (process.platform === 'darwin') { - const nameRes = await safeExec('sw_vers -productName'); - const versionRes = await safeExec('sw_vers -productVersion'); - - if (nameRes || versionRes) { - operatingSystem = `${nameRes || 'macOS'} ${versionRes || ''}`.trim(); - } - - operatingSystem = operatingSystem || 'macOS'; + operatingSystem = `macOS ${process.getSystemVersion()}`.trim(); } return operatingSystem ? `${operatingSystem} (${process.arch === 'x64' ? 'Intel' : 'ARM'})` : defaultOperatingSystem; @@ -114,11 +82,6 @@ electron.contextBridge.exposeInMainWorld('electronApi', { return _operatingSystem || defaultOperatingSystem; }, - // Splash screen window. - - onInitSplashScreenWindow: (callback: (info: ISplashScreenInfo) => void) => - onIpc('init-splash-screen-window', callback), - // Renderer process asking the main process to do something for it. checkForUpdates: (atStartup: boolean) => electron.ipcRenderer.invoke('check-for-updates', atStartup), diff --git a/src/renderer/src/common/electronApi.ts b/src/renderer/src/common/electronApi.ts index 8c842df5f..da3b5558a 100644 --- a/src/renderer/src/common/electronApi.ts +++ b/src/renderer/src/common/electronApi.ts @@ -1,10 +1,5 @@ import type { ISettings } from './common'; -export interface ISplashScreenInfo { - copyright: string; - version: string; -} - interface IElectronApi { // Note: this must be in sync with src/preload/index.ts. @@ -12,10 +7,6 @@ interface IElectronApi { operatingSystem: () => string; - // Splash screen window. - - onInitSplashScreenWindow: (callback: (info: ISplashScreenInfo) => void) => () => void; - // Renderer process asking the main process to do something for it. checkForUpdates: (atStartup: boolean) => void; From 7cefde420c160dd95b79f9a033a6127f379adf6a Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 13:08:25 +1300 Subject: [PATCH 032/184] Refactored PrimeIcons handling in Vite builds. --- electron.vite.config.ts | 14 +----- src/renderer/scripts/clean.ts | 3 +- .../scripts/download.libopencor.js.ts | 13 ++++++ src/renderer/scripts/primeicons.plugin.ts | 44 +++++++++++++++++++ src/renderer/vite.config.ts | 14 +----- src/renderer/vite.lib.config.ts | 8 +++- 6 files changed, 70 insertions(+), 26 deletions(-) create mode 100644 src/renderer/scripts/primeicons.plugin.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 2d3305b17..293e2c265 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -10,6 +10,7 @@ import path from 'node:path'; import vitePlugin from 'unplugin-vue-components/vite'; import { libopencorVersion } from './src/renderer/scripts/libopencor.version'; +import { stripPrimeIconsFontFacePlugin } from './src/renderer/scripts/primeicons.plugin'; export default electronVite.defineConfig({ main: { @@ -36,19 +37,8 @@ export default electronVite.defineConfig({ plugins: [ // Note: this must be in sync with src/renderer/vite.config.ts. - { - // Plugin to strip unneeded PrimeIcons files. - - name: 'strip-unneeded-primeicons-files', - generateBundle(_options, bundle) { - for (const fileName of Object.keys(bundle)) { - if (fileName.includes('assets/primeicons') && /\.(eot|svg|ttf|woff2?)$/.test(fileName)) { - delete bundle[fileName]; - } - } - } - }, tailwindcssPlugin(), + stripPrimeIconsFontFacePlugin(), vuePlugin({ script: { fs: { diff --git a/src/renderer/scripts/clean.ts b/src/renderer/scripts/clean.ts index 87b8c4010..edcca10b0 100644 --- a/src/renderer/scripts/clean.ts +++ b/src/renderer/scripts/clean.ts @@ -9,7 +9,8 @@ for (const path of [ 'src/main/build', 'src/renderer/components.d.ts', 'src/renderer/dist', - 'src/renderer/node_modules' + 'src/renderer/node_modules', + 'src/renderer/public/libopencor' ]) { if (fs.existsSync(path)) { fs.rmSync(path, { recursive: true, force: true }); diff --git a/src/renderer/scripts/download.libopencor.js.ts b/src/renderer/scripts/download.libopencor.js.ts index 08e9c7489..a9c8ea079 100644 --- a/src/renderer/scripts/download.libopencor.js.ts +++ b/src/renderer/scripts/download.libopencor.js.ts @@ -4,6 +4,19 @@ import * as path from 'node:path'; import { libopencorVersion } from './libopencor.version'; export const downloadLibopencorJsIfNeeded = async (destDir: string): Promise => { + // Remove any other version of libopencor.js that we may have downloaded in the past, so that it doesn't end up being + // shipped (our public folder gets copied as is when building). + + const parentDir = path.dirname(destDir); + + if (fs.existsSync(parentDir)) { + for (const entry of fs.readdirSync(parentDir, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name !== libopencorVersion) { + fs.rmSync(path.join(parentDir, entry.name), { recursive: true, force: true }); + } + } + } + // Check if the file already exists. const destFile = path.join(destDir, 'libopencor.js'); diff --git a/src/renderer/scripts/primeicons.plugin.ts b/src/renderer/scripts/primeicons.plugin.ts new file mode 100644 index 000000000..30acdb837 --- /dev/null +++ b/src/renderer/scripts/primeicons.plugin.ts @@ -0,0 +1,44 @@ +import * as postcss from 'postcss'; +import type { Plugin } from 'vite'; + +// A Vite plugin to remove PrimeIcons' @font-face rule. +// Note: PrimeIcons' CSS (which we import in src/renderer/src/assets/app.css) has a @font-face rule that references its +// fonts in various formats (EOT, WOFF2, WOFF, TTF, and SVG), all of which Vite would either emit (application +// builds) or inline (library build). Yet, we don't need that rule since we inject our own @font-face rule, which +// only references an inlined WOFF2 font (see src/renderer/src/assets/primeicons-assets.ts). So, we remove that +// rule once Tailwind CSS has inlined PrimeIcons' CSS, but before Vite processes the URLs referenced by our CSS, +// hence our plugin must be enforced as 'pre' and be placed right after Tailwind CSS's plugin (which is also +// enforced as 'pre'). + +export const stripPrimeIconsFontFacePlugin = (): Plugin => { + return { + name: 'strip-primeicons-font-face', + enforce: 'pre', + transform(code, id) { + if (!id.split('?')[0]?.endsWith('.css') || !code.includes('primeicons')) { + return null; + } + + const root = postcss.parse(code); + let removed = false; + + root.walkAtRules('font-face', (atRule) => { + let isPrimeIconsFontFace = false; + + atRule.walkDecls('font-family', (decl) => { + if (decl.value.includes('primeicons')) { + isPrimeIconsFontFace = true; + } + }); + + if (isPrimeIconsFontFace) { + atRule.remove(); + + removed = true; + } + }); + + return removed ? { code: root.toString(), map: null } : null; + } + }; +}; diff --git a/src/renderer/vite.config.ts b/src/renderer/vite.config.ts index 65442b4d0..1067e350a 100644 --- a/src/renderer/vite.config.ts +++ b/src/renderer/vite.config.ts @@ -13,6 +13,7 @@ import * as vite from 'vite'; import { downloadLibopencorJsIfNeeded } from './scripts/download.libopencor.js'; import { libopencorVersion } from './scripts/libopencor.version'; +import { stripPrimeIconsFontFacePlugin } from './scripts/primeicons.plugin'; await downloadLibopencorJsIfNeeded( path.join(import.meta.dirname, 'public', 'libopencor', 'downloads', 'wasm', libopencorVersion) @@ -38,19 +39,8 @@ export default vite.defineConfig({ plugins: [ // Note: this must be in sync with electron.vite.config.ts. - { - // Plugin to strip unneeded PrimeIcons files. - - name: 'strip-unneeded-primeicons-files', - generateBundle(_options, bundle) { - for (const fileName of Object.keys(bundle)) { - if (fileName.includes('assets/primeicons') && /\.(eot|svg|ttf|woff2?)$/.test(fileName)) { - delete bundle[fileName]; - } - } - } - }, tailwindcssPlugin(), + stripPrimeIconsFontFacePlugin(), vuePlugin({ script: { fs: { diff --git a/src/renderer/vite.lib.config.ts b/src/renderer/vite.lib.config.ts index 0a30aa264..62fb1f422 100644 --- a/src/renderer/vite.lib.config.ts +++ b/src/renderer/vite.lib.config.ts @@ -10,6 +10,7 @@ import * as vite from 'vite'; import { downloadLibopencorJsIfNeeded } from './scripts/download.libopencor.js'; import { libopencorVersion } from './scripts/libopencor.version'; +import { stripPrimeIconsFontFacePlugin } from './scripts/primeicons.plugin'; await downloadLibopencorJsIfNeeded( path.join(import.meta.dirname, 'public', 'libopencor', 'downloads', 'wasm', libopencorVersion) @@ -39,7 +40,11 @@ export default vite.defineConfig({ } } }, - target: 'esnext' + target: 'esnext', + copyPublicDir: false + // Note: our public folder only contains libOpenCOR's glue (see downloadLibopencorJsIfNeeded()), which host + // applications don't serve from our package anyway (we fall back on importing it from https://opencor.ws in + // that case, see src/common/initialisation.ts), so there is no need to ship it. }, publicDir: path.join(import.meta.dirname, 'public'), define: { @@ -91,6 +96,7 @@ export default vite.defineConfig({ } }, tailwindcssPlugin(), + stripPrimeIconsFontFacePlugin(), vuePlugin({ script: { fs: { From d86300a4c93b63032ed1eb411d0c921f4c1de67f Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 13:15:19 +1300 Subject: [PATCH 033/184] Ref-count OpenCOR overlay containers. --- src/renderer/src/common/vueCommon.ts | 112 ++++++++++++++++++++------- 1 file changed, 83 insertions(+), 29 deletions(-) diff --git a/src/renderer/src/common/vueCommon.ts b/src/renderer/src/common/vueCommon.ts index 175157d40..3bac976d2 100644 --- a/src/renderer/src/common/vueCommon.ts +++ b/src/renderer/src/common/vueCommon.ts @@ -104,6 +104,78 @@ export const trackElementHeight = ( return stopTrackingElementHeight; }; +// The overlay container of each OpenCOR instance (i.e. of each .opencor element), along with the number of components +// using it. +// Note: an overlay container is shared by all the components of an OpenCOR instance that use it, and it comes with a +// window scroll listener, which keeps the container (and therefore, through its parent, the whole OpenCOR +// instance) alive. So, we keep track of the number of components using an overlay container and remove both the +// container and its window scroll listener once no component uses it anymore (e.g., once its OpenCOR instance has +// been unmounted). + +interface IOverlayContainer { + element: HTMLElement; + users: number; + remove: () => void; +} + +const overlayContainers = new WeakMap(); + +const overlayContainer = (opencor: Element, containerClass: string): IOverlayContainer => { + let res = overlayContainers.get(opencor); + + if (res) { + return res; + } + + // Remove any stale overlay container (e.g., one created by a previous version of this module following a hot module + // replacement during development). + + opencor.querySelector(`:scope > .${containerClass}`)?.remove(); + + // Create our overlay container. + + const divElement = document.createElement('div'); + + divElement.className = containerClass; + divElement.style.cssText = + 'position: fixed; top: 0; left: 0; width: 0; height: 0; overflow: visible; pointer-events: none; z-index: 99999;'; + + // Restore pointer events for overlay content teleported into the container. + + divElement.appendChild( + Object.assign(document.createElement('style'), { + textContent: `.${containerClass} > * { pointer-events: auto; }` + }) + ); + + opencor.appendChild(divElement); + + const updateOffset = (): void => { + divElement.style.top = `-${window.scrollY}px`; + divElement.style.left = `-${window.scrollX}px`; + }; + + updateOffset(); + + window.addEventListener('scroll', updateOffset, { passive: true }); + + res = { + element: divElement, + users: 0, + remove: () => { + window.removeEventListener('scroll', updateOffset); + + divElement.remove(); + + overlayContainers.delete(opencor); + } + }; + + overlayContainers.set(opencor, res); + + return res; +}; + // A composable that provides an overlay container as an append target for PrimeVue overlays. PrimeVue's // `absolutePosition()` computes document-absolute coordinates (viewport-relative `getBoundingClientRect()` plus // `windowScrollTop`/`windowScrollLeft`). The container uses `position: fixed` inside `.opencor`, and its `top`/`left` @@ -118,44 +190,26 @@ export const trackElementHeight = ( export const useAppendTarget = (ancestorRef: vue.Ref) => { const appendTarget = vue.shallowRef(undefined); const containerClass = 'opencor-overlay-container'; + let usedOverlayContainer: IOverlayContainer | null = null; vue.onMounted(() => { const opencor = ancestorRef.value?.closest('.opencor'); if (opencor) { - let container = opencor.querySelector(`.${containerClass}`) as HTMLElement | null; - - if (!container) { - const divElement = document.createElement('div'); - - divElement.className = containerClass; - divElement.style.cssText = - 'position: fixed; top: 0; left: 0; width: 0; height: 0; overflow: visible; pointer-events: none; z-index: 99999;'; - - // Restore pointer events for overlay content teleported into the container. + usedOverlayContainer = overlayContainer(opencor, containerClass); - divElement.appendChild( - Object.assign(document.createElement('style'), { - textContent: `.${containerClass} > * { pointer-events: auto; }` - }) - ); + ++usedOverlayContainer.users; - opencor.appendChild(divElement); - - const updateOffset = (): void => { - divElement.style.top = `-${window.scrollY}px`; - divElement.style.left = `-${window.scrollX}px`; - }; - - updateOffset(); - - window.addEventListener('scroll', updateOffset, { passive: true }); - - container = divElement; - } + appendTarget.value = usedOverlayContainer.element; + } + }); - appendTarget.value = container; + vue.onBeforeUnmount(() => { + if (usedOverlayContainer && --usedOverlayContainer.users === 0) { + usedOverlayContainer.remove(); } + + usedOverlayContainer = null; }); return appendTarget; From a5dd094675c75ac59b0199561309a3584097cfbc Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 13:28:34 +1300 Subject: [PATCH 034/184] Stream simulation data with slice ranges. --- src/preload/index.ts | 30 +++--- src/renderer/src/common/locCommon.ts | 19 ++-- .../SimulationExperimentStandardView.vue | 97 ++++++++++++++++--- src/renderer/src/libopencor/locApi.ts | 32 ++++-- src/renderer/src/libopencor/locSedApi.ts | 51 +++++----- src/renderer/src/libopencor/src/sed.cpp | 30 +++--- 6 files changed, 181 insertions(+), 78 deletions(-) diff --git a/src/preload/index.ts b/src/preload/index.ts index b889fa6e0..8d432dadf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -211,38 +211,43 @@ electron.contextBridge.exposeInMainWorld('locApi', { sedInstanceTaskVoiName: (instanceId: number, index: number) => loc.sedInstanceTaskVoiName(instanceId, index), sedInstanceTaskVoiUnit: (instanceId: number, index: number) => loc.sedInstanceTaskVoiUnit(instanceId, index), - sedInstanceTaskVoi: (instanceId: number, index: number, count?: number) => - loc.sedInstanceTaskVoi(instanceId, index, count), + sedInstanceTaskVoi: (instanceId: number, index: number, start?: number, end?: number) => + loc.sedInstanceTaskVoi(instanceId, index, start, end), sedInstanceTaskStateCount: (instanceId: number, index: number) => loc.sedInstanceTaskStateCount(instanceId, index), sedInstanceTaskStateName: (instanceId: number, index: number, stateIndex: number) => loc.sedInstanceTaskStateName(instanceId, index, stateIndex), sedInstanceTaskStateUnit: (instanceId: number, index: number, stateIndex: number) => loc.sedInstanceTaskStateUnit(instanceId, index, stateIndex), - sedInstanceTaskState: (instanceId: number, index: number, stateIndex: number, count?: number) => - loc.sedInstanceTaskState(instanceId, index, stateIndex, count), + sedInstanceTaskState: (instanceId: number, index: number, stateIndex: number, start?: number, end?: number) => + loc.sedInstanceTaskState(instanceId, index, stateIndex, start, end), sedInstanceTaskRateCount: (instanceId: number, index: number) => loc.sedInstanceTaskRateCount(instanceId, index), sedInstanceTaskRateName: (instanceId: number, index: number, rateIndex: number) => loc.sedInstanceTaskRateName(instanceId, index, rateIndex), sedInstanceTaskRateUnit: (instanceId: number, index: number, rateIndex: number) => loc.sedInstanceTaskRateUnit(instanceId, index, rateIndex), - sedInstanceTaskRate: (instanceId: number, index: number, rateIndex: number, count?: number) => - loc.sedInstanceTaskRate(instanceId, index, rateIndex, count), + sedInstanceTaskRate: (instanceId: number, index: number, rateIndex: number, start?: number, end?: number) => + loc.sedInstanceTaskRate(instanceId, index, rateIndex, start, end), sedInstanceTaskConstantCount: (instanceId: number, index: number) => loc.sedInstanceTaskConstantCount(instanceId, index), sedInstanceTaskConstantName: (instanceId: number, index: number, constantIndex: number) => loc.sedInstanceTaskConstantName(instanceId, index, constantIndex), sedInstanceTaskConstantUnit: (instanceId: number, index: number, constantIndex: number) => loc.sedInstanceTaskConstantUnit(instanceId, index, constantIndex), - sedInstanceTaskConstant: (instanceId: number, index: number, constantIndex: number, count?: number) => - loc.sedInstanceTaskConstant(instanceId, index, constantIndex, count), + sedInstanceTaskConstant: (instanceId: number, index: number, constantIndex: number, start?: number, end?: number) => + loc.sedInstanceTaskConstant(instanceId, index, constantIndex, start, end), sedInstanceTaskComputedConstantCount: (instanceId: number, index: number) => loc.sedInstanceTaskComputedConstantCount(instanceId, index), sedInstanceTaskComputedConstantName: (instanceId: number, index: number, computedConstantIndex: number) => loc.sedInstanceTaskComputedConstantName(instanceId, index, computedConstantIndex), sedInstanceTaskComputedConstantUnit: (instanceId: number, index: number, computedConstantIndex: number) => loc.sedInstanceTaskComputedConstantUnit(instanceId, index, computedConstantIndex), - sedInstanceTaskComputedConstant: (instanceId: number, index: number, computedConstantIndex: number, count?: number) => - loc.sedInstanceTaskComputedConstant(instanceId, index, computedConstantIndex, count), + sedInstanceTaskComputedConstant: ( + instanceId: number, + index: number, + computedConstantIndex: number, + start?: number, + end?: number + ) => loc.sedInstanceTaskComputedConstant(instanceId, index, computedConstantIndex, start, end), sedInstanceTaskAlgebraicVariableCount: (instanceId: number, index: number) => loc.sedInstanceTaskAlgebraicVariableCount(instanceId, index), sedInstanceTaskAlgebraicVariableName: (instanceId: number, index: number, algebraicVariableIndex: number) => @@ -253,6 +258,7 @@ electron.contextBridge.exposeInMainWorld('locApi', { instanceId: number, index: number, algebraicVariableIndex: number, - count?: number - ) => loc.sedInstanceTaskAlgebraicVariable(instanceId, index, algebraicVariableIndex, count) + start?: number, + end?: number + ) => loc.sedInstanceTaskAlgebraicVariable(instanceId, index, algebraicVariableIndex, start, end) }); diff --git a/src/renderer/src/common/locCommon.ts b/src/renderer/src/common/locCommon.ts index 5e26a2ffb..e685fb89d 100644 --- a/src/renderer/src/common/locCommon.ts +++ b/src/renderer/src/common/locCommon.ts @@ -417,40 +417,41 @@ export const simulationDataInfos = (instanceTask: locApi.SedInstanceTask): Map { - // Note: count allows us to retrieve only the first values (e.g., those that have been computed so far while a - // simulation is running), see locApi.SedInstanceTask.voi() for instance. + // Note: start and end allow us to retrieve only some of the values (e.g., those that have been computed since we last + // retrieved some while a simulation is running), see locApi.SedInstanceTask.voi() for instance. switch (info.type) { case ESimulationDataInfoType.VOI: return { - data: instanceTask.voi(count), + data: instanceTask.voi(start, end), unit: instanceTask.voiUnit() }; case ESimulationDataInfoType.STATE: return { - data: instanceTask.state(info.index, count), + data: instanceTask.state(info.index, start, end), unit: instanceTask.stateUnit(info.index) }; case ESimulationDataInfoType.RATE: return { - data: instanceTask.rate(info.index, count), + data: instanceTask.rate(info.index, start, end), unit: instanceTask.rateUnit(info.index) }; case ESimulationDataInfoType.CONSTANT: return { - data: instanceTask.constant(info.index, count), + data: instanceTask.constant(info.index, start, end), unit: instanceTask.constantUnit(info.index) }; case ESimulationDataInfoType.COMPUTED_CONSTANT: return { - data: instanceTask.computedConstant(info.index, count), + data: instanceTask.computedConstant(info.index, start, end), unit: instanceTask.computedConstantUnit(info.index) }; case ESimulationDataInfoType.ALGEBRAIC: return { - data: instanceTask.algebraicVariable(info.index, count), + data: instanceTask.algebraicVariable(info.index, start, end), unit: instanceTask.algebraicVariableUnit(info.index) }; default: diff --git a/src/renderer/src/components/views/SimulationExperimentStandardView.vue b/src/renderer/src/components/views/SimulationExperimentStandardView.vue index 5ceeafe4f..72b185dce 100644 --- a/src/renderer/src/components/views/SimulationExperimentStandardView.vue +++ b/src/renderer/src/components/views/SimulationExperimentStandardView.vue @@ -158,6 +158,67 @@ const yInfo = vue.computed(() => { return instanceTask ? locCommon.simulationDataInfo(instanceTask, yParameter.value) : locCommon.NoSimulationDataInfo; }); +// Our live data, i.e. the data that we have retrieved so far while a simulation is running. +// Note: while a simulation is running, we only retrieve the data that has been computed since our last update (rather +// than all of it every time) and store it in some buffers, which we then plot using views of them (so no copy is +// involved, while still providing Plotly with new arrays, which it needs to update our plot). Also, libOpenCOR +// counts a step as completed just before storing its results, so the last point that we retrieved may not have +// been stored at the time, which is why we always retrieve it again. + +interface ILiveData { + task: locSedApi.SedInstanceTask; + xInfo: locCommon.ISimulationDataInfo; + yInfo: locCommon.ISimulationDataInfo; + x: Float64Array; + y: Float64Array; + size: number; +} + +let liveData: ILiveData | null = null; + +const retrieveLiveData = (task: locSedApi.SedInstanceTask, dataSize: number): ILiveData => { + // (Re)create our buffers if needed (e.g., at the start of a simulation run or if the X or Y parameter has changed) or + // grow them if they are too small. + + if (!liveData || liveData.task !== task || liveData.xInfo !== xInfo.value || liveData.yInfo !== yInfo.value) { + const capacity = Math.max(dataSize, (uniformTimeCourse?.numberOfSteps() ?? 0) + 1); + + liveData = { + task, + xInfo: xInfo.value, + yInfo: yInfo.value, + x: new Float64Array(capacity), + y: new Float64Array(capacity), + size: 0 + }; + } else if (dataSize > liveData.x.length) { + const x = new Float64Array(dataSize); + const y = new Float64Array(dataSize); + + x.set(liveData.x); + y.set(liveData.y); + + liveData.x = x; + liveData.y = y; + } + + // Retrieve the data that has been computed since our last update (as well as our last point, see above). + + const start = Math.max(0, liveData.size - 1); + + if (dataSize > start) { + const x = locCommon.simulationDataValue(task, liveData.xInfo, start, dataSize).data; + const y = locCommon.simulationDataValue(task, liveData.yInfo, start, dataSize).data; + + liveData.x.set(x, start); + liveData.y.set(y, start); + + liveData.size = Math.max(liveData.size, start + Math.min(x.length, y.length)); + } + + return liveData; +}; + const updatePlot = (dataSize: number = 0): void => { if (!instanceTask) { data.value = { @@ -183,16 +244,28 @@ const updatePlot = (dataSize: number = 0): void => { : undefined; // Retrieve the data for the selected X and Y parameters and update the plot. - // Note: we only retrieve the data that has been computed so far. With the C++ version of libOpenCOR, that data is a - // copy that we own, so we can use it as is. With the WASM version of libOpenCOR, that data is a view of the WASM - // heap, so we need to copy it. - - const dataCount = dataSize > 0 ? dataSize : undefined; - const xData = locCommon.simulationDataValue(instanceTask, xInfo.value, dataCount).data; - const yData = locCommon.simulationDataValue(instanceTask, yInfo.value, dataCount).data; - const ownData = (values: Float64Array): Float64Array => { - return locApi.cppVersion() ? values : values.slice(); - }; + // Note: while a simulation is running (i.e. dataSize > 0), we use our live data (see retrieveLiveData()). Otherwise, + // we retrieve all the data. With the C++ version of libOpenCOR, that data is a copy that we own, so we can use + // it as is. With the WASM version of libOpenCOR, that data is a view of the WASM heap, so we need to copy it. + + let xData: Float64Array; + let yData: Float64Array; + + if (dataSize > 0) { + const live = retrieveLiveData(instanceTask, dataSize); + + xData = live.x.subarray(0, live.size); + yData = live.y.subarray(0, live.size); + } else { + liveData = null; + + const ownData = (values: Float64Array): Float64Array => { + return locApi.cppVersion() ? values : values.slice(); + }; + + xData = ownData(locCommon.simulationDataValue(instanceTask, xInfo.value).data); + yData = ownData(locCommon.simulationDataValue(instanceTask, yInfo.value).data); + } data.value = { xAxisTitle: xParameter.value, @@ -203,9 +276,9 @@ const updatePlot = (dataSize: number = 0): void => { { name: vueCommon.traceName(undefined, xParameter.value, yParameter.value), xValue: xParameter.value, - x: ownData(xData), + x: xData, yValue: yParameter.value, - y: ownData(yData), + y: yData, color: colors.DEFAULT_COLOR } ] diff --git a/src/renderer/src/libopencor/locApi.ts b/src/renderer/src/libopencor/locApi.ts index 289c62ccc..acf1eaadd 100644 --- a/src/renderer/src/libopencor/locApi.ts +++ b/src/renderer/src/libopencor/locApi.ts @@ -79,19 +79,37 @@ export interface ICppLocApi { sedInstanceTaskVoiName: (instanceId: number, index: number) => string; sedInstanceTaskVoiUnit: (instanceId: number, index: number) => string; - sedInstanceTaskVoi: (instanceId: number, index: number, count?: number) => Float64Array; + sedInstanceTaskVoi: (instanceId: number, index: number, start?: number, end?: number) => Float64Array; sedInstanceTaskStateCount: (instanceId: number, index: number) => number; sedInstanceTaskStateName: (instanceId: number, index: number, stateIndex: number) => string; sedInstanceTaskStateUnit: (instanceId: number, index: number, stateIndex: number) => string; - sedInstanceTaskState: (instanceId: number, index: number, stateIndex: number, count?: number) => Float64Array; + sedInstanceTaskState: ( + instanceId: number, + index: number, + stateIndex: number, + start?: number, + end?: number + ) => Float64Array; sedInstanceTaskRateCount: (instanceId: number, index: number) => number; sedInstanceTaskRateName: (instanceId: number, index: number, rateIndex: number) => string; sedInstanceTaskRateUnit: (instanceId: number, index: number, rateIndex: number) => string; - sedInstanceTaskRate: (instanceId: number, index: number, rateIndex: number, count?: number) => Float64Array; + sedInstanceTaskRate: ( + instanceId: number, + index: number, + rateIndex: number, + start?: number, + end?: number + ) => Float64Array; sedInstanceTaskConstantCount: (instanceId: number, index: number) => number; sedInstanceTaskConstantName: (instanceId: number, index: number, constantIndex: number) => string; sedInstanceTaskConstantUnit: (instanceId: number, index: number, constantIndex: number) => string; - sedInstanceTaskConstant: (instanceId: number, index: number, constantIndex: number, count?: number) => Float64Array; + sedInstanceTaskConstant: ( + instanceId: number, + index: number, + constantIndex: number, + start?: number, + end?: number + ) => Float64Array; sedInstanceTaskComputedConstantCount: (instanceId: number, index: number) => number; sedInstanceTaskComputedConstantName: (instanceId: number, index: number, computedConstantIndex: number) => string; sedInstanceTaskComputedConstantUnit: (instanceId: number, index: number, computedConstantIndex: number) => string; @@ -99,7 +117,8 @@ export interface ICppLocApi { instanceId: number, index: number, computedConstantIndex: number, - count?: number + start?: number, + end?: number ) => Float64Array; sedInstanceTaskAlgebraicVariableCount: (instanceId: number, index: number) => number; sedInstanceTaskAlgebraicVariableName: (instanceId: number, index: number, algebraicVariableIndex: number) => string; @@ -108,7 +127,8 @@ export interface ICppLocApi { instanceId: number, index: number, algebraicVariableIndex: number, - count?: number + start?: number, + end?: number ) => Float64Array; // Version API. diff --git a/src/renderer/src/libopencor/locSedApi.ts b/src/renderer/src/libopencor/locSedApi.ts index 0ad1bc33e..f2cda95a9 100644 --- a/src/renderer/src/libopencor/locSedApi.ts +++ b/src/renderer/src/libopencor/locSedApi.ts @@ -634,10 +634,10 @@ export class SedInstance { } } -// A helper function to retrieve (a view of) the first values of the given values. +// A helper function to retrieve (a view of) a slice of the given values. -const firstValues = (values: Float64Array, count?: number): Float64Array => { - return count === undefined ? values : values.subarray(0, count); +const sliceValues = (values: Float64Array, start?: number, end?: number): Float64Array => { + return start === undefined && end === undefined ? values : values.subarray(start ?? 0, end); }; export class SedInstanceTask extends SedIndex { @@ -672,15 +672,16 @@ export class SedInstanceTask extends SedIndex { : this._wasmSedInstanceTask.voiUnit; } - // Note: the various methods to retrieve simulation data accept an optional number of values, which allows us to - // retrieve only the values that have been computed so far (e.g., while a simulation is running). With the C++ - // version of libOpenCOR, this avoids copying values that are not needed. With the WASM version of libOpenCOR, - // the simulation data is a view of the WASM heap, so we simply return a view of the first values. + // Note: the various methods to retrieve simulation data accept an optional start and end (with the same semantics as + // Array.prototype.slice(), except for negative values, which are not supported), which allow us to retrieve + // only some of the values (e.g., those that have been computed since we last retrieved some while a simulation + // is running). With the C++ version of libOpenCOR, this avoids copying values that are not needed. With the + // WASM version of libOpenCOR, the simulation data is a view of the WASM heap, so we simply return a view of it. - voi(count?: number): Float64Array { + voi(start?: number, end?: number): Float64Array { return cppVersion() - ? _cppLocApi.sedInstanceTaskVoi(this._cppInstanceId, this._index, count) - : firstValues(this._wasmSedInstanceTask.voi, count); + ? _cppLocApi.sedInstanceTaskVoi(this._cppInstanceId, this._index, start, end) + : sliceValues(this._wasmSedInstanceTask.voi, start, end); } stateCount(): number { @@ -701,10 +702,10 @@ export class SedInstanceTask extends SedIndex { : this._wasmSedInstanceTask.stateUnit(index); } - state(index: number, count?: number): Float64Array { + state(index: number, start?: number, end?: number): Float64Array { return cppVersion() - ? _cppLocApi.sedInstanceTaskState(this._cppInstanceId, this._index, index, count) - : firstValues(this._wasmSedInstanceTask.state(index), count); + ? _cppLocApi.sedInstanceTaskState(this._cppInstanceId, this._index, index, start, end) + : sliceValues(this._wasmSedInstanceTask.state(index), start, end); } rateCount(): number { @@ -725,10 +726,10 @@ export class SedInstanceTask extends SedIndex { : this._wasmSedInstanceTask.rateUnit(index); } - rate(index: number, count?: number): Float64Array { + rate(index: number, start?: number, end?: number): Float64Array { return cppVersion() - ? _cppLocApi.sedInstanceTaskRate(this._cppInstanceId, this._index, index, count) - : firstValues(this._wasmSedInstanceTask.rate(index), count); + ? _cppLocApi.sedInstanceTaskRate(this._cppInstanceId, this._index, index, start, end) + : sliceValues(this._wasmSedInstanceTask.rate(index), start, end); } constantCount(): number { @@ -749,10 +750,10 @@ export class SedInstanceTask extends SedIndex { : this._wasmSedInstanceTask.constantUnit(index); } - constant(index: number, count?: number): Float64Array { + constant(index: number, start?: number, end?: number): Float64Array { return cppVersion() - ? _cppLocApi.sedInstanceTaskConstant(this._cppInstanceId, this._index, index, count) - : firstValues(this._wasmSedInstanceTask.constant(index), count); + ? _cppLocApi.sedInstanceTaskConstant(this._cppInstanceId, this._index, index, start, end) + : sliceValues(this._wasmSedInstanceTask.constant(index), start, end); } computedConstantCount(): number { @@ -773,10 +774,10 @@ export class SedInstanceTask extends SedIndex { : this._wasmSedInstanceTask.computedConstantUnit(index); } - computedConstant(index: number, count?: number): Float64Array { + computedConstant(index: number, start?: number, end?: number): Float64Array { return cppVersion() - ? _cppLocApi.sedInstanceTaskComputedConstant(this._cppInstanceId, this._index, index, count) - : firstValues(this._wasmSedInstanceTask.computedConstant(index), count); + ? _cppLocApi.sedInstanceTaskComputedConstant(this._cppInstanceId, this._index, index, start, end) + : sliceValues(this._wasmSedInstanceTask.computedConstant(index), start, end); } algebraicVariableCount(): number { @@ -797,9 +798,9 @@ export class SedInstanceTask extends SedIndex { : this._wasmSedInstanceTask.algebraicVariableUnit(index); } - algebraicVariable(index: number, count?: number): Float64Array { + algebraicVariable(index: number, start?: number, end?: number): Float64Array { return cppVersion() - ? _cppLocApi.sedInstanceTaskAlgebraicVariable(this._cppInstanceId, this._index, index, count) - : firstValues(this._wasmSedInstanceTask.algebraicVariable(index), count); + ? _cppLocApi.sedInstanceTaskAlgebraicVariable(this._cppInstanceId, this._index, index, start, end) + : sliceValues(this._wasmSedInstanceTask.algebraicVariable(index), start, end); } } diff --git a/src/renderer/src/libopencor/src/sed.cpp b/src/renderer/src/libopencor/src/sed.cpp index 89fabb6b1..efb9396fe 100644 --- a/src/renderer/src/libopencor/src/sed.cpp +++ b/src/renderer/src/libopencor/src/sed.cpp @@ -43,17 +43,19 @@ libOpenCOR::SedInstanceTaskPtr toSedInstanceTask(const Napi::CallbackInfo &pInfo return task; } -// Retrieve the first values of the given values. -// Note: the number of values is optional and allows us to retrieve only the values that have been computed so far (e.g., -// while a simulation is running), thus avoiding the copy of values that are not needed. +// Retrieve a slice of the given values. +// Note: the start and end of the slice are optional (with the same semantics as JavaScript's Array.prototype.slice(), +// except for negative values, which are not supported) and allow us to retrieve only some of the values (e.g., +// the values that have been computed since we last retrieved some while a simulation is running), thus avoiding +// the copy of values that are not needed. -std::span firstValues(std::span pValues, const Napi::Value &pCount) +std::span sliceValues(std::span pValues, const Napi::Value &pStart, const Napi::Value &pEnd) { - if (!pCount.IsNumber()) { - return pValues; - } + const auto size = pValues.size(); + const auto start = pStart.IsNumber() ? std::min(toSizeT(pStart), size) : 0; + const auto end = pEnd.IsNumber() ? std::min(toSizeT(pEnd), size) : size; - return pValues.first(std::min(toSizeT(pCount), pValues.size())); + return (start < end) ? pValues.subspan(start, end - start) : std::span(); } } // namespace @@ -371,7 +373,7 @@ napi_value sedInstanceTaskVoi(const Napi::CallbackInfo &pInfo) { auto task = toSedInstanceTask(pInfo); - return doublesToNapiFloat64Array(pInfo.Env(), firstValues(task->voi(), pInfo[2])); + return doublesToNapiFloat64Array(pInfo.Env(), sliceValues(task->voi(), pInfo[2], pInfo[3])); } napi_value sedInstanceTaskStateCount(const Napi::CallbackInfo &pInfo) @@ -399,7 +401,7 @@ napi_value sedInstanceTaskState(const Napi::CallbackInfo &pInfo) { auto task = toSedInstanceTask(pInfo); - return doublesToNapiFloat64Array(pInfo.Env(), firstValues(task->state(toInt32(pInfo[2])), pInfo[3])); + return doublesToNapiFloat64Array(pInfo.Env(), sliceValues(task->state(toInt32(pInfo[2])), pInfo[3], pInfo[4])); } napi_value sedInstanceTaskRateCount(const Napi::CallbackInfo &pInfo) @@ -427,7 +429,7 @@ napi_value sedInstanceTaskRate(const Napi::CallbackInfo &pInfo) { auto task = toSedInstanceTask(pInfo); - return doublesToNapiFloat64Array(pInfo.Env(), firstValues(task->rate(toInt32(pInfo[2])), pInfo[3])); + return doublesToNapiFloat64Array(pInfo.Env(), sliceValues(task->rate(toInt32(pInfo[2])), pInfo[3], pInfo[4])); } napi_value sedInstanceTaskConstantCount(const Napi::CallbackInfo &pInfo) @@ -455,7 +457,7 @@ napi_value sedInstanceTaskConstant(const Napi::CallbackInfo &pInfo) { auto task = toSedInstanceTask(pInfo); - return doublesToNapiFloat64Array(pInfo.Env(), firstValues(task->constant(toInt32(pInfo[2])), pInfo[3])); + return doublesToNapiFloat64Array(pInfo.Env(), sliceValues(task->constant(toInt32(pInfo[2])), pInfo[3], pInfo[4])); } napi_value sedInstanceTaskComputedConstantCount(const Napi::CallbackInfo &pInfo) @@ -483,7 +485,7 @@ napi_value sedInstanceTaskComputedConstant(const Napi::CallbackInfo &pInfo) { auto task = toSedInstanceTask(pInfo); - return doublesToNapiFloat64Array(pInfo.Env(), firstValues(task->computedConstant(toInt32(pInfo[2])), pInfo[3])); + return doublesToNapiFloat64Array(pInfo.Env(), sliceValues(task->computedConstant(toInt32(pInfo[2])), pInfo[3], pInfo[4])); } napi_value sedInstanceTaskAlgebraicVariableCount(const Napi::CallbackInfo &pInfo) @@ -511,5 +513,5 @@ napi_value sedInstanceTaskAlgebraicVariable(const Napi::CallbackInfo &pInfo) { auto task = toSedInstanceTask(pInfo); - return doublesToNapiFloat64Array(pInfo.Env(), firstValues(task->algebraicVariable(toInt32(pInfo[2])), pInfo[3])); + return doublesToNapiFloat64Array(pInfo.Env(), sliceValues(task->algebraicVariable(toInt32(pInfo[2])), pInfo[3], pInfo[4])); } From f699268bc84c5d5ee7edb92ad9a9ca77f60fcaef Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 13:42:49 +1300 Subject: [PATCH 035/184] Reuse the renderer's port, if possible, and harden file serving. --- src/main/index.ts | 14 +- src/renderer/src/common/rendererServer.ts | 148 ++++++++++++++++------ 2 files changed, 125 insertions(+), 37 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 4ee9d9078..d6980d812 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -56,6 +56,7 @@ interface IElectronConf { recent: string[]; selected: string; }; + rendererServerPort: number; state: IElectronConfState; }; settings: ISettings; @@ -205,6 +206,7 @@ electron.app recent: [], selected: '' }, + rendererServerPort: 0, state: { x: horizontalSpace, y: verticalSpace, @@ -231,9 +233,19 @@ electron.app // Note: any error is handled when creating our main window, but we need to handle it here too, so that it isn't // reported as an unhandled rejection in the meantime. + // Note: we want our renderer server to always use the same port (if possible), so that the origin of our renderer + // remains the same from one launch to another. This means that our renderer's HTTP cache can be used across + // launches and that our renderer's origin-scoped storage doesn't get orphaned (and accumulate) on each + // launch. So, we ask our renderer server to try the port it used last time and keep track of the port it ends + // up using. + const rendererUrl = process.env.ELECTRON_RENDERER_URL ? Promise.resolve(process.env.ELECTRON_RENDERER_URL) - : startRendererServer(); + : startRendererServer(electronConf.get('app.rendererServerPort')).then((url: string) => { + electronConf.set('app.rendererServerPort', Number(new URL(url).port)); + + return url; + }); rendererUrl.catch(() => {}); diff --git a/src/renderer/src/common/rendererServer.ts b/src/renderer/src/common/rendererServer.ts index 322c17df5..4ef1ff2da 100644 --- a/src/renderer/src/common/rendererServer.ts +++ b/src/renderer/src/common/rendererServer.ts @@ -1,17 +1,22 @@ // Note: this is a very simple HTTP server to serve the renderer files from out/renderer. This is needed so that we can // authenticate with GitHub OAuth when running the packaged version of OpenCOR. This means that we can get away with not -// checking a lot of things (e.g., that a requested file is within our renderer distribution) since the server is only -// accessible from localhost and only serves our own files. +// checking a lot of things since the server is only accessible from localhost and only serves our own files. Still, we +// must make sure that a request cannot result in an exception being thrown in our main process. -import { createReadStream } from 'node:fs'; +import { createReadStream, promises as fs } from 'node:fs'; import http from 'node:http'; import type { AddressInfo } from 'node:net'; import path from 'node:path'; +import { pipeline } from 'node:stream'; let rendererServer: http.Server | null = null; let rendererBaseUrl: string | null = null; -export const startRendererServer = async (): Promise => { +export const startRendererServer = async (preferredPort: number = 0): Promise => { + // Note: we try to use the given preferred port (if any), so that the origin of our renderer remains the same from one + // launch to another (see src/main/index.ts), and fall back on a random available port if it cannot be used + // (e.g., because it is already in use by another application). + // If we already have a base URL then return it. if (rendererBaseUrl) { @@ -34,58 +39,129 @@ export const startRendererServer = async (): Promise => { '.woff2': 'font/woff2' }; - rendererServer = http.createServer((request: http.IncomingMessage, response: http.ServerResponse) => { - // Retrieve the requested path or default to /index.html. + const notFound = (response: http.ServerResponse): void => { + response.writeHead(404, { 'Content-Type': 'text/plain; charset=UTF-8' }); + response.end('Not found'); + }; - const requestPath = request.url === '/' || !request.url ? '/index.html' : request.url; + const handleRequest = async (request: http.IncomingMessage, response: http.ServerResponse): Promise => { + // Retrieve the requested path (ignoring any query string or fragment, and decoding any percent-encoded character) + // or default to /index.html. - // Set the headers before streaming. + let requestPath: string; - const filePath = path.join(rendererDistPath, requestPath); - const ext = path.extname(filePath).toLowerCase(); + try { + requestPath = decodeURIComponent(new URL(request.url ?? '/', 'http://localhost').pathname); + } catch { + notFound(response); - response.setHeader('Content-Type', MIME_TYPES[ext] ?? 'application/octet-stream'); - response.setHeader('Cache-Control', ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable'); - response.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); - response.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); + return; + } - // Create and pipe the read stream. + if (requestPath === '/') { + requestPath = '/index.html'; + } - createReadStream(filePath) - .on('error', () => { - response.writeHead(404, { 'Content-Type': 'text/plain; charset=UTF-8' }); - response.end('Not found'); - }) - .on('open', () => response.writeHead(200)) - .pipe(response); - }); + // Make sure that the requested path is a file within our renderer distribution. + // Note: since we decode the requested path, it could otherwise be used to access a file outside of our renderer + // distribution (e.g., using %2E%2E%2F, i.e. ../). - // Optimise TCP for localhost connections by disabling Nagle's algorithm. + const filePath = path.join(rendererDistPath, requestPath); - rendererServer.on('connection', (socket) => socket.setNoDelay(true)); + if (!filePath.startsWith(rendererDistPath + path.sep)) { + notFound(response); - // Start listening on a random available port. + return; + } - await new Promise((resolve, reject) => { - // Handle any errors that occur while starting the server. + try { + if (!(await fs.stat(filePath)).isFile()) { + notFound(response); - rendererServer?.once('error', reject); + return; + } + } catch { + notFound(response); - // Start the server listening. + return; + } - rendererServer?.listen(0, rendererHost, () => { - const addressInfo = rendererServer?.address() as AddressInfo | null; + // Set the headers and stream the file. + // Note: we use pipeline() rather than pipe() so that our read stream gets destroyed (and its file descriptor + // closed) if the client aborts the request. Also, an error may still occur while streaming the file, in which + // case our headers will have been sent, so we can only destroy our response (trying to send a 404 would throw + // an ERR_HTTP_HEADERS_SENT error in our main process). - if (addressInfo?.port) { - rendererBaseUrl = `http://${rendererHost}:${addressInfo.port}`; + const ext = path.extname(filePath).toLowerCase(); - resolve(); + response.writeHead(200, { + 'Content-Type': MIME_TYPES[ext] ?? 'application/octet-stream', + 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable', + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp' + }); + + pipeline(createReadStream(filePath), response, (error) => { + if (error && !response.destroyed) { + response.destroy(); + } + }); + }; + + // Optimise TCP for localhost connections by disabling Nagle's algorithm. + + rendererServer = http.createServer((request: http.IncomingMessage, response: http.ServerResponse) => { + handleRequest(request, response).catch(() => { + if (response.headersSent) { + response.destroy(); } else { - reject(new Error("Failed to determine the renderer server's port.")); + notFound(response); } }); }); + rendererServer.on('connection', (socket) => socket.setNoDelay(true)); + + // Start listening on our preferred port or, if it cannot be used, on a random available port. + + const listen = (port: number): Promise => { + return new Promise((resolve, reject) => { + // Handle any errors that occur while starting the server. + + const onError = (error: Error): void => { + reject(error); + }; + + rendererServer?.once('error', onError); + + // Start the server listening. + + rendererServer?.listen(port, rendererHost, () => { + rendererServer?.off('error', onError); + + const addressInfo = rendererServer?.address() as AddressInfo | null; + + if (addressInfo?.port) { + rendererBaseUrl = `http://${rendererHost}:${addressInfo.port}`; + + resolve(); + } else { + reject(new Error("Failed to determine the renderer server's port.")); + } + }); + }); + }; + + try { + await listen(preferredPort); + } catch (error: unknown) { + if (!preferredPort) { + throw error; + } + + await listen(0); + } + if (!rendererBaseUrl) { throw new Error('Failed to initialise the renderer server.'); } From 48b5bce61f282ff206c3fd67a8f5508f8e944525 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 13:53:42 +1300 Subject: [PATCH 036/184] Trimmed our Electron app packaging inputs. --- bun.lock | 14 +++++++------- electron-builder.yml | 9 +++++++++ package.json | 14 +++++++------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/bun.lock b/bun.lock index adce2949c..37642b2f0 100644 --- a/bun.lock +++ b/bun.lock @@ -6,25 +6,22 @@ "name": "@opencor/opencor", "dependencies": { "@napi-rs/keyring": "^2.1.0", - "@opencor/libopencor-types": "^1.20261007.0", - "@primeuix/themes": "2.0.3", - "@primevue/auto-import-resolver": "4.2.5", - "@vueuse/core": "12.8.2", - "primeicons": "7.0.0", - "primevue": "4.2.5", - "vue": "3.4.21", "xxhash-wasm": "^1.1.0", }, "devDependencies": { "@biomejs/biome": "^2.5.15", "@electron-toolkit/tsconfig": "^2.0.0", "@electron-toolkit/utils": "^4.0.0", + "@opencor/libopencor-types": "^1.20261007.0", + "@primeuix/themes": "2.0.3", + "@primevue/auto-import-resolver": "4.2.5", "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/vite": "^4.3.3", "@types/node": "^26.6.4", "@types/plotly.js": "^3.0.15", "@vitejs/plugin-vue": "^6.0.9", "@vue/tsconfig": "^0.9.1", + "@vueuse/core": "12.8.2", "@wasm-fmt/clang-format": "^23.1.1", "autoprefixer": "^10.6.1", "cmake-js": "^8.0.0", @@ -36,6 +33,8 @@ "esbuild": "^0.28.2", "node-addon-api": "^8.9.2", "postcss": "^8.5.29", + "primeicons": "7.0.0", + "primevue": "4.2.5", "rollup-plugin-visualizer": "^7.1.1", "stylelint": "^17.16.0", "stylelint-config-standard": "^40.0.0", @@ -46,6 +45,7 @@ "typescript-6": "npm:typescript@^6.0.3", "unplugin-vue-components": "^32.1.0", "vite": "7.3.6", + "vue": "3.4.21", "vue-tsc": "^3.3.12", }, "peerDependencies": { diff --git a/electron-builder.yml b/electron-builder.yml index 6c340559d..21973ef90 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -1,5 +1,14 @@ appId: ws.opencor.app productName: OpenCOR +# Only package what is needed at runtime, i.e. our built main, preload, and renderer files (out/), the files used by our +# splash screen window and for our application icon (src/main/assets/ and our logo), and package.json. +# Note: our production dependencies get added automatically. +files: + - out/** + - src/main/assets/** + - "!src/main/assets/README.md" + - src/renderer/src/assets/logo.svg + - package.json publish: provider: github channel: latest-${arch} diff --git a/package.json b/package.json index f153e9645..631f5ef3f 100644 --- a/package.json +++ b/package.json @@ -57,25 +57,22 @@ }, "dependencies": { "@napi-rs/keyring": "^2.1.0", - "@opencor/libopencor-types": "^1.20261007.0", - "@primeuix/themes": "2.0.3", - "@primevue/auto-import-resolver": "4.2.5", - "@vueuse/core": "12.8.2", - "primeicons": "7.0.0", - "primevue": "4.2.5", - "vue": "3.4.21", "xxhash-wasm": "^1.1.0" }, "devDependencies": { "@biomejs/biome": "^2.5.15", "@electron-toolkit/tsconfig": "^2.0.0", "@electron-toolkit/utils": "^4.0.0", + "@opencor/libopencor-types": "^1.20261007.0", + "@primeuix/themes": "2.0.3", + "@primevue/auto-import-resolver": "4.2.5", "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/vite": "^4.3.3", "@types/node": "^26.6.4", "@types/plotly.js": "^3.0.15", "@vitejs/plugin-vue": "^6.0.9", "@vue/tsconfig": "^0.9.1", + "@vueuse/core": "12.8.2", "@wasm-fmt/clang-format": "^23.1.1", "autoprefixer": "^10.6.1", "cmake-js": "^8.0.0", @@ -87,6 +84,8 @@ "esbuild": "^0.28.2", "node-addon-api": "^8.9.2", "postcss": "^8.5.29", + "primeicons": "7.0.0", + "primevue": "4.2.5", "rollup-plugin-visualizer": "^7.1.1", "stylelint": "^17.16.0", "stylelint-config-standard": "^40.0.0", @@ -97,6 +96,7 @@ "typescript-6": "npm:typescript@^6.0.3", "unplugin-vue-components": "^32.1.0", "vite": "7.3.6", + "vue": "3.4.21", "vue-tsc": "^3.3.12" } } From 7d7102fb691887f17bc0d48529fd69c00d99edfc Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 14:00:21 +1300 Subject: [PATCH 037/184] Check whether checking for updates should be allowed. --- src/main/MainMenu.ts | 6 +++--- src/main/MainWindow.ts | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/main/MainMenu.ts b/src/main/MainMenu.ts index 9ea992423..443b40089 100644 --- a/src/main/MainMenu.ts +++ b/src/main/MainMenu.ts @@ -1,10 +1,10 @@ import electron from 'electron'; import { formatError } from '../renderer/src/common/common'; -import { isMacOs, isPackaged } from '../renderer/src/common/electron'; +import { isMacOs } from '../renderer/src/common/electron'; import { mainWindow } from './index'; -import { clearRecentFiles } from './MainWindow'; +import { canCheckForUpdates, clearRecentFiles } from './MainWindow'; let enabledMenu: electron.Menu | null = null; let disabledMenu: electron.Menu | null = null; @@ -36,7 +36,7 @@ export const enableDisableMainMenu = (enable: boolean): void => { let checkForUpdatesMenuItem: electron.MenuItemConstructorOptions | null = null; - if (isPackaged()) { + if (canCheckForUpdates()) { checkForUpdatesMenuItem = { label: 'Check for Updates...', click: () => { diff --git a/src/main/MainWindow.ts b/src/main/MainWindow.ts index 33c34d5ab..c6c71f88e 100644 --- a/src/main/MainWindow.ts +++ b/src/main/MainWindow.ts @@ -1,5 +1,6 @@ import electron from 'electron'; import { autoUpdater, type ProgressInfo, type UpdateCheckResult } from 'electron-updater'; +import fs from 'node:fs'; import path from 'node:path'; import { formatError, isDataUrlOmexFileName, type ISettings, isUrl } from '../renderer/src/common/common'; @@ -18,10 +19,23 @@ import type { SplashScreenWindow } from './SplashScreenWindow'; autoUpdater.autoDownload = false; autoUpdater.logger = null; +// Determine whether we can check for updates. +// Note: we can only check for updates if OpenCOR is packaged and has some update information, i.e. an app-update.yml +// file next to its app.asar file, which electron-builder only generates when building a release (e.g., not when +// building an unpacked version of OpenCOR using electron-builder --dir). + +let _canCheckForUpdates: boolean | null = null; + +export const canCheckForUpdates = (): boolean => { + _canCheckForUpdates ??= isPackaged() && fs.existsSync(path.join(process.resourcesPath, 'app-update.yml')); + + return _canCheckForUpdates; +}; + export const checkForUpdates = (atStartup: boolean): void => { - // Check for updates, if requested and if OpenCOR is packaged. + // Check for updates, if we can and if requested (i.e. at startup, if the user wants us to, or by the user). - if (isPackaged() && electronConf.get('settings.general.checkForUpdatesAtStartup')) { + if (canCheckForUpdates() && (!atStartup || electronConf.get('settings.general.checkForUpdatesAtStartup'))) { autoUpdater .checkForUpdates() .then((result: UpdateCheckResult | null) => { From 05972337eef39207a2b661c02b0a1cb538e42ef8 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 16:10:55 +1300 Subject: [PATCH 038/184] Normalise argv handling across app instances. --- src/main/MainWindow.ts | 97 ++++++++++++++++++++---------------------- src/main/index.ts | 17 +++++--- 2 files changed, 58 insertions(+), 56 deletions(-) diff --git a/src/main/MainWindow.ts b/src/main/MainWindow.ts index c6c71f88e..9f155988c 100644 --- a/src/main/MainWindow.ts +++ b/src/main/MainWindow.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { formatError, isDataUrlOmexFileName, type ISettings, isUrl } from '../renderer/src/common/common'; import { FULL_URI_SCHEME, LONG_DELAY } from '../renderer/src/common/constants'; -import { isLinux, isMacOs, isPackaged, isWindows } from '../renderer/src/common/electron'; +import { isMacOs, isPackaged } from '../renderer/src/common/electron'; /* TODO: enable once our GitHub integration is fully ready. import { deleteGitHubAccessToken } from '../renderer/src/common/gitHubIntegration'; */ @@ -193,6 +193,45 @@ const currentFilesToReopen = (): IFilesToReopen => { return { opened, selected }; }; +// Determine whether the given argument is an OpenCOR action (i.e. an opencor:// link). + +const isAction = (argument: string | undefined): boolean => { + return argument?.startsWith(FULL_URI_SCHEME) ?? false; +}; + +// Normalise the given command line (i.e. the process.argv of an instance of OpenCOR), so that it only contains the +// files to open and the OpenCOR actions to handle, with files given as absolute paths. +// Note: this is used both for our own command line and for the command line of another instance of OpenCOR that was +// started while we were running (see the second-instance event in src/main/index.ts), in which case relative +// paths must be resolved against the working directory of that other instance. + +export const normaliseCommandLine = (commandLine: string[], workingDirectory: string): string[] => { + const res = [...commandLine]; + + // The command line can either be a classical command line or an OpenCOR action (i.e. an opencor:// link). In the + // former case, we need to remove the path to OpenCOR and, if we are not packaged, the path to our app, while, in the + // latter case, nothing should be removed. + + if (!isAction(res[0])) { + res.shift(); + + if (!isPackaged() && res.length) { + res.shift(); + } + } + + // Ignore any switch (e.g., --updated when auto-updating OpenCOR on Windows and macOS, --no-sandbox on Linux, or any + // switch added by Electron or Chromium) and make sure that local files are given as absolute paths. + + return res + .filter((argument) => !argument.startsWith('--')) + .map((argument) => + isAction(argument) || isUrl(argument) || path.isAbsolute(argument) + ? argument + : path.resolve(workingDirectory, argument) + ); +}; + // Report a fatal error (e.g., OpenCOR couldn't be started) and quit. // Note: our splash screen window is always on top, so we must close it first. Otherwise, it would hide our error dialog // and remain shown forever. @@ -283,31 +322,9 @@ export class MainWindow extends ApplicationWindow { updateReopenMenu(recentFilePaths); - // The command line can either be a classical command line or an OpenCOR action (i.e. an opencor:// link). In the - // former case, we need to remove one or two arguments while, in the latter case, nothing should be removed. - - if (!this.isAction(commandLine[0])) { - // The first argument is not an action, so remove the first argument and then the second argument, but only if we - // are not packaged. - - commandLine.shift(); - - if (!isPackaged() && commandLine.length) { - commandLine.shift(); - } - } - - // When auto updating OpenCOR, we may end up with an extra argument that we need to ignore. - - if ((isWindows() || isMacOs()) && commandLine[0] === '--updated') { - commandLine.shift(); - } else if (isLinux() && commandLine[0] === '--no-sandbox') { - commandLine.shift(); - } - // Handle our command line, which will effectively be done once our renderer is ready (see rendererReady()). - this.handleArguments(commandLine); + this.handleArguments(normaliseCommandLine(commandLine, process.cwd())); // Keep track of our settings unless we are resetting all. @@ -573,15 +590,7 @@ export class MainWindow extends ApplicationWindow { } } - // Handle our command line arguments. - - isAction(argument: string | undefined): boolean { - if (!argument) { - return false; - } - - return argument.startsWith(FULL_URI_SCHEME); - } + // Handle the given (normalised) command line arguments (see normaliseCommandLine()). handleArguments(commandLine: string[]): void { if (!commandLine.length) { @@ -596,26 +605,12 @@ export class MainWindow extends ApplicationWindow { return; } - for (let argument of commandLine) { - if (this.isAction(argument)) { + for (const argument of commandLine) { + if (isAction(argument)) { this.send('action', argument.slice(FULL_URI_SCHEME.length)); - - continue; - } - - if (argument === '--allow-file-access-from-files' || argument === '--enable-avfoundation') { - continue; - } - - // The argument is not an action (and not --allow-file-access-from-files or --enable-avfoundation either), so it - // must be a file to open. But, first, check whether the argument is a relative path and, if so, convert it to - // an absolute path. - - if (!path.isAbsolute(argument)) { - argument = path.resolve(argument); + } else { + this.send('open', argument); } - - this.send('open', argument); } } diff --git a/src/main/index.ts b/src/main/index.ts index d6980d812..eda4f92e8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -32,6 +32,7 @@ import { installUpdateAndRestart, loadSettings, MainWindow, + normaliseCommandLine, reportFatalErrorAndQuit, resetAll, saveSettings @@ -65,8 +66,10 @@ interface IElectronConf { export let electronConf: ElectronConf; // Allow only one instance of OpenCOR. +// Note: we pass our command line and working directory to the instance that is already running (see the second-instance +// event below). -if (!electron.app.requestSingleInstanceLock()) { +if (!electron.app.requestSingleInstanceLock({ argv: process.argv, cwd: process.cwd() })) { electron.app.quit(); } @@ -80,8 +83,12 @@ export let mainWindow: MainWindow | null = null; const pendingArguments: string[][] = []; -electron.app.on('second-instance', (_event, argv) => { - argv.shift(); // Remove the first argument, which is the path to OpenCOR. +electron.app.on('second-instance', (_event, argv, workingDirectory, additionalData) => { + // Use the command line and working directory that the other instance passed to us, if available, since, according to + // Electron's documentation, argv may have been reordered and have additional switches. + + const otherInstanceData = additionalData as { argv?: string[]; cwd?: string } | undefined; + const commandLine = normaliseCommandLine(otherInstanceData?.argv ?? argv, otherInstanceData?.cwd ?? workingDirectory); if (mainWindow) { if (mainWindow.isMinimized()) { @@ -90,9 +97,9 @@ electron.app.on('second-instance', (_event, argv) => { mainWindow.focus(); - mainWindow.handleArguments(argv); + mainWindow.handleArguments(commandLine); } else { - pendingArguments.push(argv); + pendingArguments.push(commandLine); } }); From df30674fc97df9b78c07c74b574da4dfdef48f07 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 16:17:57 +1300 Subject: [PATCH 039/184] Fixed startup file reopen sequencing. --- src/main/MainWindow.ts | 43 ++++++++++++++++++------- src/renderer/src/components/OpenCOR.vue | 9 ++++++ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/main/MainWindow.ts b/src/main/MainWindow.ts index 9f155988c..ce1fdd8df 100644 --- a/src/main/MainWindow.ts +++ b/src/main/MainWindow.ts @@ -144,7 +144,7 @@ export const fileIssue = (filePath: string): void => { // A file couldn't be opened, possibly while reopening files during OpenCOR startup, in which case we need to make sure // that it doesn't get selected and reopen the next file. - MainWindow.instance?.reopenFilePathsAndSelectFilePath(filePath); + MainWindow.instance?.fileOpenedOrNot(filePath, false); }; export const fileOpened = (filePath: string): void => { @@ -152,11 +152,10 @@ export const fileOpened = (filePath: string): void => { updateReopenMenu(recentFilePaths); } - // A file has been opened, but it may have been opened while reopening files during OpenCOR startup, in which case we - // need to reopen the next file, hence our call to reopenFilePathsAndSelectFilePath(), which will do nothing if there - // are no more files to reopen. + // A file has been opened, possibly while reopening files during OpenCOR startup, in which case we need to reopen the + // next file. - MainWindow.instance?.reopenFilePathsAndSelectFilePath(); + MainWindow.instance?.fileOpenedOrNot(filePath, true); }; let openedFilePaths: string[] = []; @@ -261,6 +260,7 @@ export class MainWindow extends ApplicationWindow { // onRenderProcessGone()). private _openedFilePaths: string[] = []; private _openedFilePathIndex = 0; + private _reopeningFilePath: string | null = null; private _selectedFilePath = ''; // Constructor. @@ -514,6 +514,7 @@ export class MainWindow extends ApplicationWindow { this._pendingArguments = []; this._openedFilePaths = []; this._openedFilePathIndex = 0; + this._reopeningFilePath = null; this._selectedFilePath = ''; this._filesToReopen = filesToReopen; @@ -551,20 +552,36 @@ export class MainWindow extends ApplicationWindow { this.handleArguments(this._pendingArguments.splice(0)); } - // Reopen previously opened files, if any, and select the previously selected file. - // Note: we reopen one file at a time since a file may be a remote file which means that it may take some time to - // reopen. So, we need to wait for the file to be reopened before reopening the next one. + // A file has been opened or not (e.g., because it couldn't be retrieved). + // Note: we only reopen the next file if the given file is the one that we are reopening. Indeed, other files may be + // opened while we are reopening files (e.g., files passed on the command line or files opened by the user) + // and they must not result in us reopening the next file before the file that we are reopening has been opened + // (or not) since we would otherwise end up reopening several files at once and selecting the previously + // selected file before it has been reopened. - reopenFilePathsAndSelectFilePath(failedFilePath?: string): void { + fileOpenedOrNot(filePath: string, opened: boolean): void { // Make sure that we don't select a file that couldn't be (re)opened. - if (failedFilePath && failedFilePath === this._selectedFilePath) { + if (!opened && filePath === this._selectedFilePath) { this._selectedFilePath = ''; } + // Reopen the next file, if any, if the given file is the one that we are reopening. + + if (filePath === this._reopeningFilePath) { + this._reopeningFilePath = null; + + this.reopenFilePathsAndSelectFilePath(); + } + } + + // Reopen previously opened files, if any, and select the previously selected file. + // Note: we reopen one file at a time since a file may be a remote file which means that it may take some time to + // reopen. So, we only reopen the next file once our renderer has told us that the previous file has been + // opened (or couldn't be opened), see fileOpenedOrNot(). + + reopenFilePathsAndSelectFilePath(): void { // Reopen the next file, if any. - // Note: we reopen files one at a time, i.e. we only reopen the next file once the renderer has told us that the - // previous file has been opened (or couldn't be opened). while (this._openedFilePathIndex < this._openedFilePaths.length) { const filePath = this._openedFilePaths[this._openedFilePathIndex]; @@ -572,6 +589,8 @@ export class MainWindow extends ApplicationWindow { ++this._openedFilePathIndex; if (filePath) { + this._reopeningFilePath = filePath; + this.send('open', filePath); return; diff --git a/src/renderer/src/components/OpenCOR.vue b/src/renderer/src/components/OpenCOR.vue index f0fbccba2..2bae57952 100644 --- a/src/renderer/src/components/OpenCOR.vue +++ b/src/renderer/src/components/OpenCOR.vue @@ -915,6 +915,11 @@ const openFile = (fileFilePathOrFileContents: string | Uint8Array | File): void } if (fileInfo.alreadyOpen) { + // Note: we let our main process know that the file is open since it may be waiting for it (e.g., if the file is + // being reopened at startup, see MainWindow.fileOpenedOrNot()). + + electronApi?.fileOpened(fileInfo.filePath); + await contentsRef.value?.selectFile(fileInfo.filePath); return; @@ -946,6 +951,10 @@ const openFiles = (filesFilePathsOrFileContents: (string | Uint8Array | File)[]) } if (currentFileInfo.alreadyOpen) { + // Note: see the note in openFile() above. + + electronApi?.fileOpened(currentFileInfo.filePath); + await contentsRef.value?.selectFile(currentFileInfo.filePath, true); return; From 2251bcb24109b6fe2438d7ea22473eba21ee05a8 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 16:23:40 +1300 Subject: [PATCH 040/184] Handle update install errors in the renderer. --- src/main/MainWindow.ts | 19 +++++++++++++++++++ src/preload/index.ts | 1 + src/renderer/src/common/electronApi.ts | 1 + src/renderer/src/components/OpenCOR.vue | 8 ++++++++ 4 files changed, 29 insertions(+) diff --git a/src/main/MainWindow.ts b/src/main/MainWindow.ts index ce1fdd8df..7e24fa409 100644 --- a/src/main/MainWindow.ts +++ b/src/main/MainWindow.ts @@ -57,10 +57,29 @@ autoUpdater.on('download-progress', (info: ProgressInfo) => { MainWindow.instance?.send('update-download-progress', info.percent); }); +// Forward any issue with installing an update. +// Note: on macOS, electron-updater hands a downloaded update over to Squirrel.Mac, which reports any issue (e.g., the +// update couldn't be staged or its signature is invalid) only through an error event, i.e. after downloadUpdate() +// has resolved, so our renderer would otherwise wait forever for the update to be installed. Other issues are +// reported through the promises returned by checkForUpdates() and downloadUpdate() (as well as through an error +// event), so we only forward error events once an update has been downloaded, to avoid reporting an issue twice. + +let updateDownloaded = false; + +autoUpdater.on('error', (error: Error) => { + if (updateDownloaded) { + MainWindow.instance?.send('update-install-error', formatError(error)); + } +}); + export const downloadAndInstallUpdate = (): void => { + updateDownloaded = false; + autoUpdater .downloadUpdate() .then(() => { + updateDownloaded = true; + MainWindow.instance?.send('update-downloaded'); }) .catch((error: unknown) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 8d432dadf..f5860bbea 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -124,6 +124,7 @@ electron.contextBridge.exposeInMainWorld('electronApi', { onUpdateCheckError: (callback: (issue: string) => void) => onIpc('update-check-error', callback), onUpdateDownloaded: (callback: () => void) => onIpc('update-downloaded', callback), onUpdateDownloadError: (callback: (issue: string) => void) => onIpc('update-download-error', callback), + onUpdateInstallError: (callback: (issue: string) => void) => onIpc('update-install-error', callback), onUpdateDownloadProgress: (callback: (percent: number) => void) => onIpc('update-download-progress', callback), onUpdateNotAvailable: (callback: () => void) => onIpc('update-not-available', callback) }); diff --git a/src/renderer/src/common/electronApi.ts b/src/renderer/src/common/electronApi.ts index da3b5558a..523164c61 100644 --- a/src/renderer/src/common/electronApi.ts +++ b/src/renderer/src/common/electronApi.ts @@ -47,6 +47,7 @@ interface IElectronApi { onUpdateCheckError: (callback: (issue: string) => void) => () => void; onUpdateDownloaded: (callback: () => void) => () => void; onUpdateDownloadError: (callback: (issue: string) => void) => () => void; + onUpdateInstallError: (callback: (issue: string) => void) => () => void; onUpdateDownloadProgress: (callback: (percent: number) => void) => () => void; onUpdateNotAvailable: (callback: () => void) => () => void; } diff --git a/src/renderer/src/components/OpenCOR.vue b/src/renderer/src/components/OpenCOR.vue index 2bae57952..26ce04d3a 100644 --- a/src/renderer/src/components/OpenCOR.vue +++ b/src/renderer/src/components/OpenCOR.vue @@ -648,6 +648,14 @@ trackCleanup( }) ); +trackCleanup( + electronApi?.onUpdateInstallError((issue: string) => { + updateErrorTitle.value = 'Installing Update...'; + updateErrorIssue.value = `An error occurred while installing the update (${common.formatMessage(issue, false)}).`; + updateErrorVisible.value = true; + }) +); + trackCleanup( electronApi?.onUpdateDownloadProgress((percent: number) => { updateDownloadPercent.value = percent; From 2b8d1cc381db9e44a5074018bfde28c82ac79137 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 16:27:17 +1300 Subject: [PATCH 041/184] Prevent startup work in secondary instances. --- src/main/index.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index eda4f92e8..1ab809c5b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -66,10 +66,15 @@ interface IElectronConf { export let electronConf: ElectronConf; // Allow only one instance of OpenCOR. -// Note: we pass our command line and working directory to the instance that is already running (see the second-instance -// event below). +// Note #1: we pass our command line and working directory to the instance that is already running (see the +// second-instance event below). +// Note #2: app.quit() doesn't stop the rest of this module from being executed, so we must make sure that we don't do +// anything else if we are not the primary instance (e.g., register our URI scheme or create our splash screen +// window). -if (!electron.app.requestSingleInstanceLock({ argv: process.argv, cwd: process.cwd() })) { +const isPrimaryInstance = electron.app.requestSingleInstanceLock({ argv: process.argv, cwd: process.cwd() }); + +if (!isPrimaryInstance) { electron.app.quit(); } @@ -105,7 +110,9 @@ electron.app.on('second-instance', (_event, argv, workingDirectory, additionalDa // Register our URI scheme. -electron.app.setAsDefaultProtocolClient(URI_SCHEME, isWindows() ? process.execPath : undefined); +if (isPrimaryInstance) { + electron.app.setAsDefaultProtocolClient(URI_SCHEME, isWindows() ? process.execPath : undefined); +} // Set up Linux desktop integration by creating a desktop file and making our application icon available. // Note: this is not needed on Windows and macOS since they automatically pick up the necessary information from @@ -185,6 +192,12 @@ electron.app.on('open-url', (_event, url) => { electron.app .whenReady() .then(() => { + // Nothing to do if we are not the primary instance (see above). + + if (!isPrimaryInstance) { + return; + } + // Set up Linux desktop integration. if (isLinux()) { From ceff1be167dafe16f1018ce4d86596fd536d3812 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 16:36:26 +1300 Subject: [PATCH 042/184] Lazily initialise xxHash's WASM. --- src/renderer/src/common/dependencies.ts | 19 +++++++++++++++---- src/renderer/src/common/initialisation.ts | 3 ++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/common/dependencies.ts b/src/renderer/src/common/dependencies.ts index f066ddf62..7d9ef9766 100644 --- a/src/renderer/src/common/dependencies.ts +++ b/src/renderer/src/common/dependencies.ts @@ -25,8 +25,19 @@ export const setPlotlyJs = (module: Module): void => { _plotlyJs = module; }; -export const initialiseXxhash: Promise = xxhash().then((module: Module) => { - _xxhash = module; +// Initialise xxHash. +// Note: this compiles xxHash's WASM, so we only do it when asked to (i.e. by our renderer, see initialisation.ts) rather +// than when this module is imported. Indeed, this module is also (indirectly) imported by our main process, which +// doesn't need xxHash. - return module; -}); +let xxhashInitialisation: Promise | null = null; + +export const initialiseXxhash = (): Promise => { + xxhashInitialisation ??= xxhash().then((module: Module) => { + _xxhash = module; + + return module; + }); + + return xxhashInitialisation; +}; diff --git a/src/renderer/src/common/initialisation.ts b/src/renderer/src/common/initialisation.ts index 41d71f494..c36323c12 100644 --- a/src/renderer/src/common/initialisation.ts +++ b/src/renderer/src/common/initialisation.ts @@ -336,7 +336,8 @@ for (const externalDependency of externalDependencies) { }); } -dependencies.initialiseXxhash +dependencies + .initialiseXxhash() .then(() => { ++crtNbOfSteps.value; }) From 62f913ad2065b2d2bd27bfb9b6831bae32d819ea Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 16:42:20 +1300 Subject: [PATCH 043/184] Harden settings loading with some default settings. --- src/renderer/src/common/settings.ts | 54 ++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/common/settings.ts b/src/renderer/src/common/settings.ts index 23096b0f0..f71a9f6f6 100644 --- a/src/renderer/src/common/settings.ts +++ b/src/renderer/src/common/settings.ts @@ -1,6 +1,31 @@ import { formatError, type ISettings, type ISettingsGeneral } from './common'; import { electronApi } from './electronApi'; +// Our default settings. + +const defaultSettings = (): ISettings => { + return { + general: { + checkForUpdatesAtStartup: true + } + }; +}; + +// A helper function to merge the given (loaded) settings over our default settings. +// Note: the loaded settings may be incomplete (e.g., if they were saved by an older version of OpenCOR or edited by +// hand) or not even be an object, in which case we use our default settings for whatever is missing. + +const withDefaultSettings = (settings: unknown): ISettings => { + const res = defaultSettings(); + const general = (settings as Partial | null)?.general; + + if (general && typeof general === 'object') { + res.general = { ...res.general, ...general }; + } + + return res; +}; + class Settings { protected static _instance: Settings | null = null; private _settings!: ISettings; @@ -44,18 +69,27 @@ class Settings { load(): void { if (electronApi) { - electronApi.loadSettings().then((settings: ISettings) => { - this._settings = settings; - this._oldRawSettings = JSON.stringify(settings); - - this.emitInitialised(); - }); + // Note: if our settings cannot be loaded, then we keep our default settings, but we still let people know that we + // are initialised (since they would otherwise wait forever). + + electronApi + .loadSettings() + .then((settings: ISettings) => { + this._settings = withDefaultSettings(settings); + this._oldRawSettings = JSON.stringify(settings); + }) + .catch((error: unknown) => { + console.warn('OpenCOR: failed to load the settings, so using the default settings:', formatError(error)); + }) + .finally(() => { + this.emitInitialised(); + }); } else { try { const raw = window.localStorage.getItem('settings'); if (raw) { - this._settings = JSON.parse(raw); + this._settings = withDefaultSettings(JSON.parse(raw)); this._oldRawSettings = raw; } } catch (error: unknown) { @@ -94,11 +128,7 @@ class Settings { } reset(): void { - this._settings = { - general: { - checkForUpdatesAtStartup: true - } - }; + this._settings = defaultSettings(); this._oldRawSettings = null; } From 0cd2e8b35b980f55e2fbee9f96bac16e8e7b4c4c Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 16:49:12 +1300 Subject: [PATCH 044/184] Reset off-screen window state on startup. --- src/main/index.ts | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 1ab809c5b..3ae92c379 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -217,6 +217,14 @@ electron.app const workAreaSize = electron.screen.getPrimaryDisplay().workAreaSize; const horizontalSpace = Math.round(workAreaSize.width / 13); const verticalSpace = Math.round(workAreaSize.height / 13); + const defaultState: IElectronConfState = { + x: horizontalSpace, + y: verticalSpace, + width: workAreaSize.width - 2 * horizontalSpace, + height: workAreaSize.height - 2 * verticalSpace, + isMaximized: false, + isFullScreen: false + }; electronConf = new ElectronConf({ defaults: { @@ -227,14 +235,7 @@ electron.app selected: '' }, rendererServerPort: 0, - state: { - x: horizontalSpace, - y: verticalSpace, - width: workAreaSize.width - 2 * horizontalSpace, - height: workAreaSize.height - 2 * verticalSpace, - isMaximized: false, - isFullScreen: false - } + state: defaultState }, settings: { general: { @@ -244,6 +245,30 @@ electron.app } }); + // Make sure that our saved window state is (still) visible on one of our displays. Indeed, a display may have been + // disconnected (or its resolution changed) since OpenCOR was last closed, in which case our main window (and our + // splash screen window, which is centred on it) would be shown off-screen. So, if our saved window state is not + // sufficiently visible on any of our displays, we reset its position and size (but keep whether it was maximised or + // in full-screen mode). + + const MIN_VISIBLE_SIZE = 100; + const state: IElectronConfState = electronConf.get('app.state'); + const stateIsVisible = electron.screen.getAllDisplays().some(({ workArea }) => { + const visibleWidth = Math.min(state.x + state.width, workArea.x + workArea.width) - Math.max(state.x, workArea.x); + const visibleHeight = + Math.min(state.y + state.height, workArea.y + workArea.height) - Math.max(state.y, workArea.y); + + return visibleWidth >= MIN_VISIBLE_SIZE && visibleHeight >= MIN_VISIBLE_SIZE; + }); + + if (!stateIsVisible) { + electronConf.set('app.state', { + ...defaultState, + isMaximized: state.isMaximized, + isFullScreen: state.isFullScreen + }); + } + // Create our splash window. const splashScreenWindow = new SplashScreenWindow(); From 9241f908a3dd177e4f7e70167326b854ab8820e7 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 17:00:16 +1300 Subject: [PATCH 045/184] Hash the renderer's assets and adjusted the deployment ordering. --- .github/workflows/cd.yml | 10 ++++--- .github/workflows/cddev.yml | 10 ++++--- src/renderer/scripts/generate.version.ts | 21 +++----------- src/renderer/src/common/version.ts | 36 ++++++------------------ src/renderer/vite.config.ts | 11 ++++++-- 5 files changed, 33 insertions(+), 55 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index e6129448b..e07655204 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -123,10 +123,12 @@ jobs: local-path: ./src/renderer/dist remote-path: /home/www/opencor.ws/app sync: full - # Note: we upload version.json last so that OpenCOR's Web app only reports a new version once all the other - # files have been uploaded. - ftp-mirror-options: --exclude-glob=assets/version.json - ftp-post-sync-commands: put -O /home/www/opencor.ws/app/assets ./src/renderer/dist/assets/version.json + # Note: our assets have a hash in their file names, so we upload index.html (which references them) once they + # have all been uploaded, so that it never references assets that are not available yet. Then, we upload + # version.json last so that OpenCOR's Web app only reports a new version once all the other files have + # been uploaded. + ftp-mirror-options: --exclude-glob=index.html --exclude-glob=assets/version.json + ftp-post-sync-commands: put -O /home/www/opencor.ws/app ./src/renderer/dist/index.html; put -O /home/www/opencor.ws/app/assets ./src/renderer/dist/assets/version.json - name: Archive OpenCOR's Web app run: bun archive:web - name: Release OpenCOR's Web app diff --git a/.github/workflows/cddev.yml b/.github/workflows/cddev.yml index 28a0725b1..21d25c0be 100644 --- a/.github/workflows/cddev.yml +++ b/.github/workflows/cddev.yml @@ -36,7 +36,9 @@ jobs: local-path: ./src/renderer/dist remote-path: /home/www/opencor.ws/appdev sync: full - # Note: we upload version.json last so that OpenCOR's Web app only reports a new version once all the other - # files have been uploaded. - ftp-mirror-options: --exclude-glob=assets/version.json - ftp-post-sync-commands: put -O /home/www/opencor.ws/appdev/assets ./src/renderer/dist/assets/version.json + # Note: our assets have a hash in their file names, so we upload index.html (which references them) once they + # have all been uploaded, so that it never references assets that are not available yet. Then, we upload + # version.json last so that OpenCOR's Web app only reports a new version once all the other files have + # been uploaded. + ftp-mirror-options: --exclude-glob=index.html --exclude-glob=assets/version.json + ftp-post-sync-commands: put -O /home/www/opencor.ws/appdev ./src/renderer/dist/index.html; put -O /home/www/opencor.ws/appdev/assets ./src/renderer/dist/assets/version.json diff --git a/src/renderer/scripts/generate.version.ts b/src/renderer/scripts/generate.version.ts index 24a407388..26cf263c6 100644 --- a/src/renderer/scripts/generate.version.ts +++ b/src/renderer/scripts/generate.version.ts @@ -19,25 +19,12 @@ if (!fs.existsSync(distAssetsPath)) { fs.mkdirSync(distAssetsPath, { recursive: true }); } -// List the files that the Web app needs to refresh when force reloading it. -// Note: we exclude index.html (the Web app refreshes the page itself using its actual URL), version.json (it is always -// fetched with cache busting), stats.html (it is not part of the Web app), and libOpenCOR's files (their path -// contains libOpenCOR's version, so they never get stale). - -const files = (fs.readdirSync(distPath, { recursive: true }) as string[]) - .map((file) => file.split(path.sep).join('/')) - .filter( - (file) => - fs.statSync(path.join(distPath, file)).isFile() && - !['index.html', 'assets/version.json', 'stats.html'].includes(file) && - !file.startsWith('libopencor/') - ) - .sort(); - // Write the version file. +// Note: our assets have a hash in their file names (see vite.config.ts), so they never get stale, which means that we +// don't need to list them for when the Web app gets force reloaded (see src/common/version.ts). -fs.writeFileSync(path.join(distAssetsPath, 'version.json'), JSON.stringify({ version, files }, null, 2)); +fs.writeFileSync(path.join(distAssetsPath, 'version.json'), JSON.stringify({ version }, null, 2)); // Log the generated version. -console.log(`Generated version.json with version ${version} and ${files.length} files.`); +console.log(`Generated version.json with version ${version}.`); diff --git a/src/renderer/src/common/version.ts b/src/renderer/src/common/version.ts index dbd7e9b87..377f11c08 100644 --- a/src/renderer/src/common/version.ts +++ b/src/renderer/src/common/version.ts @@ -13,7 +13,6 @@ const latestVersion = vue.ref(''); interface IVersionInfo { version: string; - files?: string[]; } const checkForUpdates = async (): Promise => { @@ -98,10 +97,10 @@ const startCheck = (): void => { // Force reload the Web app. // Note: there is no standard way to force reload a page (location.reload(true) is non-standard and ignored by modern -// browsers). Also, our assets don't have a hash in their filenames (see vite.config.ts), so they may be stale in -// the HTTP cache too. So, we first fetch the page and all the files listed in the latest version.json file with -// cache: 'reload', which bypasses the HTTP cache and updates it, and then we reload the page. We do this within a -// given amount of time after which we reload the page anyway, so that the user is never left waiting forever. +// browsers). So, we first fetch the page with cache: 'reload', which bypasses the HTTP cache and updates it, and +// then we reload the page. (Our assets have a hash in their file names (see vite.config.ts), so the new version +// of the page references new assets, which cannot be stale.) We do this within a given amount of time after +// which we reload the page anyway, so that the user is never left waiting forever. const FORCE_RELOAD_TIMEOUT = 30 * 1000; // 30 seconds. @@ -114,37 +113,20 @@ const forceReload = async (): Promise => { forceReloading = true; - // Create an AbortController to abort the fetch requests if they take too long. + // Create an AbortController to abort the fetch request if it takes too long. const abortController = new AbortController(); const timeoutId = window.setTimeout(() => { abortController.abort(); }, FORCE_RELOAD_TIMEOUT); - // Refresh the given URL in the HTTP cache. - // Note: we need to read the body since the HTTP cache is only updated once the body has been fully received. - - const refresh = async (url: string): Promise => { - const response = await fetch(url, { cache: 'reload', signal: abortController.signal }); - - await response.blob(); - }; - try { - // Retrieve the list of files used by the latest version of OpenCOR's Web app. - - const response = await fetch(`./assets/version.json?t=${Date.now()}`, { - cache: 'no-store', - signal: abortController.signal - }); - const versionInfo: IVersionInfo = response.ok ? await response.json() : { version: '' }; + // Refresh the page in the HTTP cache. + // Note: we need to read the body since the HTTP cache is only updated once the body has been fully received. - // Refresh the page and all the files. + const response = await fetch(window.location.href, { cache: 'reload', signal: abortController.signal }); - await Promise.allSettled([ - refresh(window.location.href), - ...(versionInfo.files ?? []).map((file) => refresh(new URL(file, document.baseURI).href)) - ]); + await response.blob(); } catch (_error: unknown) { // Ignore any error (including a timeout) and reload the page anyway. } finally { diff --git a/src/renderer/vite.config.ts b/src/renderer/vite.config.ts index 1067e350a..0d0da2ec7 100644 --- a/src/renderer/vite.config.ts +++ b/src/renderer/vite.config.ts @@ -25,9 +25,14 @@ export default vite.defineConfig({ chunkSizeWarningLimit: 2048, rollupOptions: { output: { - entryFileNames: `assets/[name].js`, - chunkFileNames: `assets/[name].js`, - assetFileNames: `assets/[name].[ext]` + // Note: our file names include a hash of their contents, so that they can be cached for a long time (since a + // new version of a file will have a different name). Only index.html and assets/version.json have a fixed + // name, which is why they must never be cached for long (see src/common/version.ts) and why they must be + // uploaded last when deploying our Web app (see .github/workflows/cd.yml and cddev.yml). + + entryFileNames: `assets/[name]-[hash].js`, + chunkFileNames: `assets/[name]-[hash].js`, + assetFileNames: `assets/[name]-[hash].[ext]` } }, target: 'esnext' From 2467812d9b824c3daf4fd91f9bc54b695c21ffdc Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 17:04:57 +1300 Subject: [PATCH 046/184] Delay blob URL revocation for downloads. --- src/renderer/src/common/common.ts | 9 ++++++++- src/renderer/src/common/constants.ts | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/common/common.ts b/src/renderer/src/common/common.ts index 9e2713e50..a4412ef43 100644 --- a/src/renderer/src/common/common.ts +++ b/src/renderer/src/common/common.ts @@ -1,5 +1,6 @@ import type { IOpenCORSimulationDataValue, OpenCORSimulationData } from '../../index'; +import { VERY_LONG_DELAY } from './constants'; import * as dependencies from './dependencies'; import { electronApi } from './electronApi'; @@ -215,7 +216,13 @@ export const downloadFile = (filename: string, content: string | Blob, type: str document.body.removeChild(link); - URL.revokeObjectURL(url); + // Revoke our object URL, but only once the download has started. + // Note: some browsers (e.g., Safari) start the download asynchronously, so revoking our object URL straight away may + // cancel it. + + setTimeout(() => { + URL.revokeObjectURL(url); + }, VERY_LONG_DELAY); }; // A method to get the file name from a file path. diff --git a/src/renderer/src/common/constants.ts b/src/renderer/src/common/constants.ts index 0b80ad5b9..d1fb37666 100644 --- a/src/renderer/src/common/constants.ts +++ b/src/renderer/src/common/constants.ts @@ -6,6 +6,7 @@ export const VERY_SHORT_DELAY: number = 19; export const SHORT_DELAY: number = 69; export const MEDIUM_DELAY: number = 169; export const LONG_DELAY: number = 369; +export const VERY_LONG_DELAY: number = 969; export const TOAST_LIFE: number = 3000; From 5ddcaf4270800fecb16e97f4ddc4aa1b9b8bf9e6 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 17:09:00 +1300 Subject: [PATCH 047/184] Optimise parameter population in vueCommon. --- src/renderer/src/common/vueCommon.ts | 45 ++++++++++++++++++---------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/common/vueCommon.ts b/src/renderer/src/common/vueCommon.ts index 3bac976d2..21f41ae65 100644 --- a/src/renderer/src/common/vueCommon.ts +++ b/src/renderer/src/common/vueCommon.ts @@ -216,47 +216,62 @@ export const useAppendTarget = (ancestorRef: vue.Ref) => { }; // Populate the parameters of the given instance task. +// Note: we collect our parameters into a plain array (rather than push them one by one into our reactive array, which +// would go through Vue's reactivity for each of them), sort them using a collator (which is faster than +// String.prototype.localeCompare() for a large number of comparisons), and then update our reactive array once. + +const parametersCollator = new Intl.Collator(); export const populateParameters = ( parameters: vue.Ref, instanceTask: locSedApi.SedInstanceTask, onlyEditableModelParameters = false ): void => { - const addParameter = (param: string): void => { - parameters.value.push(param); - }; + const res = [...parameters.value]; if (!onlyEditableModelParameters) { - addParameter(instanceTask.voiName()); + res.push(instanceTask.voiName()); } - for (let i = 0; i < instanceTask.stateCount(); i++) { - addParameter(instanceTask.stateName(i)); + const stateCount = instanceTask.stateCount(); + + for (let i = 0; i < stateCount; ++i) { + res.push(instanceTask.stateName(i)); } if (!onlyEditableModelParameters) { - for (let i = 0; i < instanceTask.rateCount(); i++) { - addParameter(instanceTask.rateName(i)); + const rateCount = instanceTask.rateCount(); + + for (let i = 0; i < rateCount; ++i) { + res.push(instanceTask.rateName(i)); } } - for (let i = 0; i < instanceTask.constantCount(); i++) { - addParameter(instanceTask.constantName(i)); + const constantCount = instanceTask.constantCount(); + + for (let i = 0; i < constantCount; ++i) { + res.push(instanceTask.constantName(i)); } if (!onlyEditableModelParameters) { - for (let i = 0; i < instanceTask.computedConstantCount(); i++) { - addParameter(instanceTask.computedConstantName(i)); + const computedConstantCount = instanceTask.computedConstantCount(); + + for (let i = 0; i < computedConstantCount; ++i) { + res.push(instanceTask.computedConstantName(i)); } - for (let i = 0; i < instanceTask.algebraicVariableCount(); i++) { - addParameter(instanceTask.algebraicVariableName(i)); + const algebraicVariableCount = instanceTask.algebraicVariableCount(); + + for (let i = 0; i < algebraicVariableCount; ++i) { + res.push(instanceTask.algebraicVariableName(i)); } } // Sort the parameters alphabetically. - parameters.value.sort((parameter1: string, parameter2: string) => parameter1.localeCompare(parameter2)); + res.sort(parametersCollator.compare); + + parameters.value = res; }; // A helper function to wait while a simulation instance is running, yielding to the UI to keep it responsive. From 1cbb16ea9a02052ace051c96cd7c87edfe25e28a Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 18:32:46 +1300 Subject: [PATCH 048/184] Preserve the state of the files to reopen after the renderer crashed. --- src/main/MainWindow.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/MainWindow.ts b/src/main/MainWindow.ts index 7e24fa409..e46d72560 100644 --- a/src/main/MainWindow.ts +++ b/src/main/MainWindow.ts @@ -503,11 +503,6 @@ export class MainWindow extends ApplicationWindow { this.show(); } - // Keep track of the files that were open and of the selected file, so that they can be reopened and selected once - // our renderer is ready again. - - const filesToReopen = currentFilesToReopen(); - const choice = electron.dialog.showMessageBoxSync(this, { type: 'error', title: 'OpenCOR', @@ -526,16 +521,23 @@ export class MainWindow extends ApplicationWindow { // Reload our renderer, making sure that our main menu is enabled (our renderer may have disabled it, e.g., because // a dialog was open) and that we reopen our files once our renderer is ready again. + // Note: if our renderer was ready, then we keep track of the files that were open and of the selected file, so that + // they can be reopened and selected once our renderer is ready again. If it wasn't ready, then it never told + // us about the files that are open, so we keep the files that we were going to reopen (i.e. those from a + // previous crash, if any, or those that were open when OpenCOR was last closed, see rendererReady()), as well + // as the arguments that we were asked to handle (e.g., our command line), which were never handled. enableDisableMainMenu(true); + if (this._rendererReady) { + this._filesToReopen = currentFilesToReopen(); + } + this._rendererReady = false; - this._pendingArguments = []; this._openedFilePaths = []; this._openedFilePathIndex = 0; this._reopeningFilePath = null; this._selectedFilePath = ''; - this._filesToReopen = filesToReopen; this.webContents.reload(); } From 41d5769f758779faecd11d07c0d8fe7a1c84d883 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 18:35:43 +1300 Subject: [PATCH 049/184] Standard Simulation Experiment view: reset live data when starting a new run. --- .../components/views/SimulationExperimentStandardView.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/views/SimulationExperimentStandardView.vue b/src/renderer/src/components/views/SimulationExperimentStandardView.vue index 72b185dce..202bac26c 100644 --- a/src/renderer/src/components/views/SimulationExperimentStandardView.vue +++ b/src/renderer/src/components/views/SimulationExperimentStandardView.vue @@ -334,9 +334,12 @@ const onRunPause = async (): Promise => { break; default: { // locSedApi.ESedInstanceStatus.IDLE: - // Reset our abort flag. + // Reset our abort flag and our live data. + // Note: our live data is normally reset at the end of a simulation run (see updatePlot()), but not if the run was + // aborted or failed, in which case we would otherwise end up plotting (some of) the data of that run. runAborted.value = false; + liveData = null; // Start the simulation. From c0cdb2a8d58493135cd78be803392daa38f2af05 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 18:39:57 +1300 Subject: [PATCH 050/184] Adjusted `fromFloat64ArrayForSchemaValidation()` so that it now detects non-finite values (`NaN`, `Infinity`) in `Float64Array` inputs and converts the full array when present. --- src/renderer/src/libopencor/locUiJsonApi.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/libopencor/locUiJsonApi.ts b/src/renderer/src/libopencor/locUiJsonApi.ts index 9cc2e54bf..9f1a30d3a 100644 --- a/src/renderer/src/libopencor/locUiJsonApi.ts +++ b/src/renderer/src/libopencor/locUiJsonApi.ts @@ -181,12 +181,21 @@ const fromFloat64ArrayForSchemaValidation = (values: unknown): unknown => { // Note: a Float64Array can only contain numbers, so for schema validation purposes, we only need an array that has // the same "emptiness" and that contains numbers. This means that we don't have to convert (and then validate) // all the values of a Float64Array, which would be costly for large external data. (The actual length of a - // Float64Array is checked separately, see validateUiJson().) + // Float64Array is checked separately, see validateUiJson().) However, a Float64Array may contain non-finite + // numbers (e.g., NaN for a non-numeric value, see toFloat64Array()), which are not valid numbers as far as our + // schema validation is concerned, so if there are any then we convert all the values of the Float64Array so + // that each of them gets reported. if (!(values instanceof Float64Array)) { return values; } + for (const value of values) { + if (!Number.isFinite(value)) { + return Array.from(values); + } + } + return values.length ? [values[0]] : []; }; From 98f46be94d254c1013ce8052003cb87aa3036665 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 19:16:49 +1300 Subject: [PATCH 051/184] Handle canonical paths for open files. --- src/preload/index.ts | 1 + .../src/components/ContentsComponent.vue | 31 ++++++++++++++---- src/renderer/src/components/OpenCOR.vue | 32 ++++++++++++------- src/renderer/src/libopencor/locApi.ts | 1 + src/renderer/src/libopencor/locFileApi.ts | 16 ++++++++++ src/renderer/src/libopencor/src/file.cpp | 12 +++++++ src/renderer/src/libopencor/src/file.h | 1 + src/renderer/src/libopencor/src/main.cpp | 1 + 8 files changed, 78 insertions(+), 17 deletions(-) diff --git a/src/preload/index.ts b/src/preload/index.ts index f5860bbea..a29a36552 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -139,6 +139,7 @@ electron.contextBridge.exposeInMainWorld('locApi', { // FileManager API. + fileManagerCanonicalPath: (path: string) => loc.fileManagerCanonicalPath(path), fileManagerHasFile: (path: string) => loc.fileManagerHasFile(path), fileManagerUnmanage: (path: string) => loc.fileManagerUnmanage(path), diff --git a/src/renderer/src/components/ContentsComponent.vue b/src/renderer/src/components/ContentsComponent.vue index cddeb0e90..4f685a5f4 100644 --- a/src/renderer/src/components/ContentsComponent.vue +++ b/src/renderer/src/components/ContentsComponent.vue @@ -189,12 +189,28 @@ const onSelectView = (fileTab: IFileTab, viewId: string): void => { fileTab.activeViewId = viewId; }; -const hasFile = (filePath: string): boolean => { +const findFileTab = (filePath: string): IFileTab | undefined => { + // Retrieve the file tab for the given file path. + // Note: a file may be referred to using different paths (e.g., through a symbolic link), hence we also compare + // canonical paths, but only if needed since it is more costly. + if (props.simulationOnly) { - return false; + return undefined; + } + + const res = fileTabs.value.find((fileTab) => fileTab.file.path() === filePath); + + if (res) { + return res; } - return fileTabs.value.find((fileTab) => fileTab.file.path() === filePath) !== undefined; + const canonicalFilePath = locApi.fileManager.canonicalPath(filePath); + + return fileTabs.value.find((fileTab) => fileTab.file.canonicalPath() === canonicalFilePath); +}; + +const hasFile = (filePath: string): boolean => { + return findFileTab(filePath) !== undefined; }; const hasFiles = (): boolean => { @@ -220,13 +236,16 @@ const waitForTabsUpdate = async (): Promise => { }; const selectFile = async (filePath: string, wait: boolean = false): Promise => { - // Note: we can only select a file that has a file tab (hasFile() also returns false in simulation-only mode). + // Note: we can only select a file that has a file tab (findFileTab() also returns undefined in simulation-only mode), + // and the given file path may not be the one of that file tab (see findFileTab()). + + const selectedFileTab = findFileTab(filePath); - if (!hasFile(filePath)) { + if (!selectedFileTab) { return; } - activeFile.value = filePath; + activeFile.value = selectedFileTab.file.path(); if (wait) { await waitForTabsUpdate(); diff --git a/src/renderer/src/components/OpenCOR.vue b/src/renderer/src/components/OpenCOR.vue index 26ce04d3a..52115c610 100644 --- a/src/renderer/src/components/OpenCOR.vue +++ b/src/renderer/src/components/OpenCOR.vue @@ -750,6 +750,7 @@ interface IFileInfo { alreadyOpen: boolean; file: locApi.File | null; filePath: string; + canonicalFilePath: string; } // A helper function to report an issue with a file that couldn't be opened. @@ -822,19 +823,23 @@ const processFile = async (fileFilePathOrFileContents: string | Uint8Array | Fil // the same sample file twice). Since a file is only considered open once it has a file tab, we also need to // keep track of the files that are being opened, so that we don't end up with several file tabs for the same // file (and, with the C++ version of libOpenCOR, several File objects sharing, and therefore releasing, the - // same native file). + // same native file). Also, a file may be referred to using different paths (e.g., through a symbolic link), + // hence we keep track of the canonical path of the files that are being opened (our contents component does + // the same for the files that are open, see ContentsComponent.findFileTab()). const filePath = locCommon.filePath(fileFilePathOrFileContents, cellmlDataUrlFileName, omexDataUrlCounter); + const canonicalFilePath = locApi.fileManager.canonicalPath(filePath); - if ((contentsRef.value?.hasFile(filePath) ?? false) || filePathsBeingOpened.has(filePath)) { + if ((contentsRef.value?.hasFile(filePath) ?? false) || filePathsBeingOpened.has(canonicalFilePath)) { return { alreadyOpen: true, file: null, - filePath + filePath, + canonicalFilePath }; } - filePathsBeingOpened.add(filePath); + filePathsBeingOpened.add(canonicalFilePath); // Retrieve a locApi.File object for the given file or file path. @@ -863,7 +868,7 @@ const processFile = async (fileFilePathOrFileContents: string | Uint8Array | Fil file.release(); - filePathsBeingOpened.delete(filePath); + filePathsBeingOpened.delete(canonicalFilePath); return null; } @@ -871,12 +876,13 @@ const processFile = async (fileFilePathOrFileContents: string | Uint8Array | Fil return { alreadyOpen: false, file, - filePath + filePath, + canonicalFilePath }; } catch (error: unknown) { reportFileIssue(filePath, common.formatMessage(common.formatError(error))); - filePathsBeingOpened.delete(filePath); + filePathsBeingOpened.delete(canonicalFilePath); return null; } finally { @@ -888,7 +894,11 @@ const processFile = async (fileFilePathOrFileContents: string | Uint8Array | Fil } }; -const openFileInContents = async (file: locApi.File, wait: boolean = false): Promise => { +const openFileInContents = async ( + file: locApi.File, + canonicalFilePath: string, + wait: boolean = false +): Promise => { // Open the given file in our contents component or release it if we can't (e.g., we got unmounted in the meantime). // Note #1: to open a file may fail (e.g., if libOpenCOR throws an exception), in which case we report the issue and // release the file, unless it got opened (i.e. it has a file tab), in which case it is now managed by our @@ -912,7 +922,7 @@ const openFileInContents = async (file: locApi.File, wait: boolean = false): Pro reportFileIssue(filePath, common.formatMessage(common.formatError(error))); } finally { - filePathsBeingOpened.delete(filePath); + filePathsBeingOpened.delete(canonicalFilePath); } }; @@ -934,7 +944,7 @@ const openFile = (fileFilePathOrFileContents: string | Uint8Array | File): void } if (fileInfo.file) { - await openFileInContents(fileInfo.file); + await openFileInContents(fileInfo.file, fileInfo.canonicalFilePath); } }); }; @@ -969,7 +979,7 @@ const openFiles = (filesFilePathsOrFileContents: (string | Uint8Array | File)[]) } if (currentFileInfo.file) { - await openFileInContents(currentFileInfo.file, true); + await openFileInContents(currentFileInfo.file, currentFileInfo.canonicalFilePath, true); } }, Promise.resolve()); }; diff --git a/src/renderer/src/libopencor/locApi.ts b/src/renderer/src/libopencor/locApi.ts index acf1eaadd..b5ec9b0b8 100644 --- a/src/renderer/src/libopencor/locApi.ts +++ b/src/renderer/src/libopencor/locApi.ts @@ -6,6 +6,7 @@ import type { IIssue } from './locLoggerApi'; export interface ICppLocApi { // FileManager API. + fileManagerCanonicalPath: (path: string) => string; fileManagerHasFile: (path: string) => boolean; fileManagerUnmanage: (path: string) => void; diff --git a/src/renderer/src/libopencor/locFileApi.ts b/src/renderer/src/libopencor/locFileApi.ts index 9f4bfcd2d..5a9e5e2a2 100644 --- a/src/renderer/src/libopencor/locFileApi.ts +++ b/src/renderer/src/libopencor/locFileApi.ts @@ -35,6 +35,15 @@ class FileManager { return this._fileManager; } + canonicalPath(path: string): string { + // Note: with the C++ version of libOpenCOR, a file may be referred to using different paths (e.g., through a + // symbolic link), all of which refer to the same native file, i.e. the one with the canonical path. With the + // WASM version of libOpenCOR, a file is referred to using a URL or a name that we generate (see + // locCommon.filePath()), so there is no need to canonicalise it. + + return cppVersion() ? _cppLocApi.fileManagerCanonicalPath(path) : path; + } + file(path: string): File | null { // Note: the file we are after is already managed, so we just wrap it rather than create (and therefore own) it. @@ -112,6 +121,7 @@ export interface IManagedFile { export class File { _path: string; + private _canonicalPath: string | undefined; _wasmFile: IWasmFile = {} as IWasmFile; _issues: IIssue[] = []; private _isOwner = true; @@ -182,6 +192,12 @@ export class File { return this._path; } + canonicalPath(): string { + this._canonicalPath ??= fileManager.canonicalPath(this._path); + + return this._canonicalPath; + } + issues(): IIssue[] { return this._issues; } diff --git a/src/renderer/src/libopencor/src/file.cpp b/src/renderer/src/libopencor/src/file.cpp index 557f160e4..d0c8fb6df 100644 --- a/src/renderer/src/libopencor/src/file.cpp +++ b/src/renderer/src/libopencor/src/file.cpp @@ -5,6 +5,18 @@ // FileManager API. +napi_value fileManagerCanonicalPath(const Napi::CallbackInfo &pInfo) +{ + // Note: libOpenCOR canonicalises the path of a file when creating it (e.g., it resolves symbolic links and decodes + // URLs), so we create a file, without retrieving its contents, to get the canonical version of the given + // path. If the file is already managed, then we get it rather than a new file, while if it isn't, then the + // new file gets unmanaged as soon as it goes out of scope. + + auto file = libOpenCOR::File::create(pInfo[0].ToString().Utf8Value(), false); + + return Napi::String::New(pInfo.Env(), file->path()); +} + napi_value fileManagerHasFile(const Napi::CallbackInfo &pInfo) { return Napi::Boolean::New(pInfo.Env(), fileManager.file(pInfo[0].ToString().Utf8Value()) != nullptr); diff --git a/src/renderer/src/libopencor/src/file.h b/src/renderer/src/libopencor/src/file.h index 03ac899f1..e8809195f 100644 --- a/src/renderer/src/libopencor/src/file.h +++ b/src/renderer/src/libopencor/src/file.h @@ -4,6 +4,7 @@ // FileManager API. +napi_value fileManagerCanonicalPath(const Napi::CallbackInfo &pInfo); napi_value fileManagerHasFile(const Napi::CallbackInfo &pInfo); void fileManagerUnmanage(const Napi::CallbackInfo &pInfo); diff --git a/src/renderer/src/libopencor/src/main.cpp b/src/renderer/src/libopencor/src/main.cpp index 1375374d3..12b7fd946 100644 --- a/src/renderer/src/libopencor/src/main.cpp +++ b/src/renderer/src/libopencor/src/main.cpp @@ -17,6 +17,7 @@ Napi::Object init(Napi::Env pEnv, Napi::Object pExports) // FileManager API. + pExports.Set(Napi::String::New(pEnv, "fileManagerCanonicalPath"), Napi::Function::New(pEnv, fileManagerCanonicalPath)); pExports.Set(Napi::String::New(pEnv, "fileManagerHasFile"), Napi::Function::New(pEnv, fileManagerHasFile)); pExports.Set(Napi::String::New(pEnv, "fileManagerUnmanage"), Napi::Function::New(pEnv, fileManagerUnmanage)); From b698aa94854dcb18f0065214d3af825381e56600 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 19:42:55 +1300 Subject: [PATCH 052/184] Track pending file opens with promises. When a file is requested while still opening, the code now carries that pending promise and defers `fileOpened`/`fileIssue` signalling until the first open attempt finishes. --- src/renderer/src/components/OpenCOR.vue | 82 ++++++++++++++++++++----- 1 file changed, 65 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/components/OpenCOR.vue b/src/renderer/src/components/OpenCOR.vue index 52115c610..937ce66a8 100644 --- a/src/renderer/src/components/OpenCOR.vue +++ b/src/renderer/src/components/OpenCOR.vue @@ -744,10 +744,36 @@ const onUpdateAvailable = () => { // Open a file. let globalOmexDataUrlCounter = 0; -const filePathsBeingOpened = new Set(); + +// The files that are being opened, keyed by their canonical path, each with a promise that gets resolved once the file +// has been opened (or not). + +interface IFileBeingOpened { + promise: Promise; + resolve: () => void; +} + +const filesBeingOpened = new Map(); + +const fileBeingOpened = (canonicalFilePath: string): void => { + let resolve: () => void = () => {}; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + + filesBeingOpened.set(canonicalFilePath, { promise, resolve }); +}; + +const fileNoLongerBeingOpened = (canonicalFilePath: string): void => { + filesBeingOpened.get(canonicalFilePath)?.resolve(); + filesBeingOpened.delete(canonicalFilePath); +}; interface IFileInfo { alreadyOpen: boolean; + beingOpened?: Promise; + // Note: if the file is already being opened (i.e. it doesn't have a file tab yet), then a promise that gets resolved + // once the file has been opened (or not). file: locApi.File | null; filePath: string; canonicalFilePath: string; @@ -830,16 +856,20 @@ const processFile = async (fileFilePathOrFileContents: string | Uint8Array | Fil const filePath = locCommon.filePath(fileFilePathOrFileContents, cellmlDataUrlFileName, omexDataUrlCounter); const canonicalFilePath = locApi.fileManager.canonicalPath(filePath); - if ((contentsRef.value?.hasFile(filePath) ?? false) || filePathsBeingOpened.has(canonicalFilePath)) { + const fileIsOpen = contentsRef.value?.hasFile(filePath) ?? false; + const beingOpened = fileIsOpen ? undefined : filesBeingOpened.get(canonicalFilePath)?.promise; + + if (fileIsOpen || beingOpened) { return { alreadyOpen: true, + beingOpened, file: null, filePath, canonicalFilePath }; } - filePathsBeingOpened.add(canonicalFilePath); + fileBeingOpened(canonicalFilePath); // Retrieve a locApi.File object for the given file or file path. @@ -868,7 +898,7 @@ const processFile = async (fileFilePathOrFileContents: string | Uint8Array | Fil file.release(); - filePathsBeingOpened.delete(canonicalFilePath); + fileNoLongerBeingOpened(canonicalFilePath); return null; } @@ -882,7 +912,7 @@ const processFile = async (fileFilePathOrFileContents: string | Uint8Array | Fil } catch (error: unknown) { reportFileIssue(filePath, common.formatMessage(common.formatError(error))); - filePathsBeingOpened.delete(canonicalFilePath); + fileNoLongerBeingOpened(canonicalFilePath); return null; } finally { @@ -922,10 +952,37 @@ const openFileInContents = async ( reportFileIssue(filePath, common.formatMessage(common.formatError(error))); } finally { - filePathsBeingOpened.delete(canonicalFilePath); + fileNoLongerBeingOpened(canonicalFilePath); } }; +const fileAlreadyOpen = async (fileInfo: IFileInfo, wait: boolean = false): Promise => { + // Let our main process know whether the file is open since it may be waiting for it (e.g., if the file is being + // reopened at startup, see MainWindow.fileOpenedOrNot()), and select it. + // Note: if the file is being opened, then we can only let our main process know once it has been opened (or not), in + // which case it will also have been selected (see ContentsComponent.openFile()). However, we must not wait for + // that here since we may be called from openFiles() and the file may be opened after us in its chain, in which + // case we would wait forever. + + const beingOpened = fileInfo.beingOpened; + + if (beingOpened) { + void beingOpened.then(() => { + if (contentsRef.value?.hasFile(fileInfo.filePath)) { + electronApi?.fileOpened(fileInfo.filePath); + } else { + electronApi?.fileIssue(fileInfo.filePath); + } + }); + + return; + } + + electronApi?.fileOpened(fileInfo.filePath); + + await contentsRef.value?.selectFile(fileInfo.filePath, wait); +}; + const openFile = (fileFilePathOrFileContents: string | Uint8Array | File): void => { processFile(fileFilePathOrFileContents).then(async (fileInfo) => { if (!fileInfo) { @@ -933,12 +990,7 @@ const openFile = (fileFilePathOrFileContents: string | Uint8Array | File): void } if (fileInfo.alreadyOpen) { - // Note: we let our main process know that the file is open since it may be waiting for it (e.g., if the file is - // being reopened at startup, see MainWindow.fileOpenedOrNot()). - - electronApi?.fileOpened(fileInfo.filePath); - - await contentsRef.value?.selectFile(fileInfo.filePath); + await fileAlreadyOpen(fileInfo); return; } @@ -969,11 +1021,7 @@ const openFiles = (filesFilePathsOrFileContents: (string | Uint8Array | File)[]) } if (currentFileInfo.alreadyOpen) { - // Note: see the note in openFile() above. - - electronApi?.fileOpened(currentFileInfo.filePath); - - await contentsRef.value?.selectFile(currentFileInfo.filePath, true); + await fileAlreadyOpen(currentFileInfo, true); return; } From 3dd6887cb92f2bc1b710203bed389a554c6651ff Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 19:50:43 +1300 Subject: [PATCH 053/184] Hardened the error handling of the renderer's server's startup. --- src/renderer/src/common/rendererServer.ts | 28 +++++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/common/rendererServer.ts b/src/renderer/src/common/rendererServer.ts index 4ef1ff2da..dbe2b25f4 100644 --- a/src/renderer/src/common/rendererServer.ts +++ b/src/renderer/src/common/rendererServer.ts @@ -127,16 +127,18 @@ export const startRendererServer = async (preferredPort: number = 0): Promise => { return new Promise((resolve, reject) => { // Handle any errors that occur while starting the server. + // Note: we stop listening for the server to be listening since we may try to start it again (on another port), in + // which case we don't want this attempt to be notified about it. const onError = (error: Error): void => { + rendererServer?.off('listening', onListening); + reject(error); }; - rendererServer?.once('error', onError); - - // Start the server listening. + // Handle the server listening. - rendererServer?.listen(port, rendererHost, () => { + const onListening = (): void => { rendererServer?.off('error', onError); const addressInfo = rendererServer?.address() as AddressInfo | null; @@ -148,7 +150,14 @@ export const startRendererServer = async (preferredPort: number = 0): Promise { + console.error('OpenCOR: the renderer server encountered an error:', error); + }); + return rendererBaseUrl; }; From c613846f0c2a27c56ee8e6e0a1dc224cfa04847d Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 19:55:20 +1300 Subject: [PATCH 054/184] Bind the renderer's server to both loopback IPs. Serve the renderer on both 127.0.0.1 and ::1 using the same port, then expose it through `http://localhost:`. --- src/renderer/src/common/rendererServer.ts | 155 ++++++++++++++-------- 1 file changed, 100 insertions(+), 55 deletions(-) diff --git a/src/renderer/src/common/rendererServer.ts b/src/renderer/src/common/rendererServer.ts index dbe2b25f4..34efa2fd3 100644 --- a/src/renderer/src/common/rendererServer.ts +++ b/src/renderer/src/common/rendererServer.ts @@ -9,9 +9,21 @@ import type { AddressInfo } from 'node:net'; import path from 'node:path'; import { pipeline } from 'node:stream'; -let rendererServer: http.Server | null = null; +let rendererServers: http.Server[] = []; let rendererBaseUrl: string | null = null; +const closeServer = (server: http.Server): Promise => { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +}; + export const startRendererServer = async (preferredPort: number = 0): Promise => { // Note: we try to use the given preferred port (if any), so that the origin of our renderer remains the same from one // launch to another (see src/main/index.ts), and fall back on a random available port if it cannot be used @@ -27,7 +39,6 @@ export const startRendererServer = async (preferredPort: number = 0): Promise = { '.css': 'text/css', '.eot': 'application/vnd.ms-fontobject', @@ -108,30 +119,35 @@ export const startRendererServer = async (preferredPort: number = 0): Promise { - handleRequest(request, response).catch(() => { - if (response.headersSent) { - response.destroy(); - } else { - notFound(response); - } + const createServer = (): http.Server => { + const server = http.createServer((request: http.IncomingMessage, response: http.ServerResponse) => { + handleRequest(request, response).catch(() => { + if (response.headersSent) { + response.destroy(); + } else { + notFound(response); + } + }); }); - }); - rendererServer.on('connection', (socket) => socket.setNoDelay(true)); + server.on('connection', (socket) => socket.setNoDelay(true)); - // Start listening on our preferred port or, if it cannot be used, on a random available port. + return server; + }; - const listen = (port: number): Promise => { - return new Promise((resolve, reject) => { + // Start a server listening on the given port and host, and return the port it is listening on. + + const listen = (server: http.Server, port: number, host: string): Promise => { + return new Promise((resolve, reject) => { // Handle any errors that occur while starting the server. - // Note: we stop listening for the server to be listening since we may try to start it again (on another port), in - // which case we don't want this attempt to be notified about it. + // Note: we stop listening for the server to be listening since we may try to start another server (on another + // port), in which case we don't want this attempt to be notified about it. const onError = (error: Error): void => { - rendererServer?.off('listening', onListening); + server.off('listening', onListening); reject(error); }; @@ -139,77 +155,106 @@ export const startRendererServer = async (preferredPort: number = 0): Promise { - rendererServer?.off('error', onError); + server.off('error', onError); - const addressInfo = rendererServer?.address() as AddressInfo | null; + const addressInfo = server.address() as AddressInfo | null; if (addressInfo?.port) { - rendererBaseUrl = `http://${rendererHost}:${addressInfo.port}`; - - resolve(); + resolve(addressInfo.port); } else { reject(new Error("Failed to determine the renderer server's port.")); } }; - rendererServer?.once('error', onError); - rendererServer?.once('listening', onListening); + server.once('error', onError); + server.once('listening', onListening); // Start the server listening. - rendererServer?.listen(port, rendererHost); + server.listen(port, host); }); }; - try { - await listen(preferredPort); - } catch (error: unknown) { - if (!preferredPort) { - throw error; + // Start servers listening on both the IPv4 and IPv6 loopback addresses, using the same port. + // Note: our renderer is loaded from http://localhost: (localhost being an authorised domain for GitHub OAuth, + // see GITHUB.md), which may resolve to either loopback address. So, if we only listened on one of them, then + // another application could listen on the other one, using the same port (which is predictable since we try to + // always use the same port), and our renderer could then be loaded from that application. If there is no IPv6 + // loopback address, then we only listen on the IPv4 loopback address since no other application can listen on + // the IPv6 loopback address either. + + const listenOnLoopbackAddresses = async (port: number): Promise => { + const ipv4Server = createServer(); + const loopbackPort = await listen(ipv4Server, port, '127.0.0.1'); + const ipv6Server = createServer(); + + try { + await listen(ipv6Server, loopbackPort, '::1'); + + rendererServers = [ipv4Server, ipv6Server]; + } catch (error: unknown) { + const errorCode = (error as NodeJS.ErrnoException).code; + + if (errorCode !== 'EADDRNOTAVAIL' && errorCode !== 'EAFNOSUPPORT') { + await closeServer(ipv4Server).catch(() => {}); + + throw error; + } + + rendererServers = [ipv4Server]; } - await listen(0); + rendererBaseUrl = `http://localhost:${loopbackPort}`; + }; + + // Start listening on our preferred port or, if it cannot be used, on a random available port. + // Note: a random port that is available on the IPv4 loopback address may not be available on the IPv6 loopback + // address, hence we try a few random ports, if needed. + + const ports = preferredPort ? [preferredPort, 0, 0, 0] : [0, 0, 0]; + let listenError: unknown = null; + + for (const port of ports) { + try { + await listenOnLoopbackAddresses(port); + + break; + } catch (error: unknown) { + listenError = error; + } } if (!rendererBaseUrl) { - throw new Error('Failed to initialise the renderer server.'); + throw listenError ?? new Error('Failed to initialise the renderer server.'); } - // Log any error that occurs once the server is listening (e.g., if it fails to accept a connection because there are - // too many open files). - // Note: indeed, the server keeps listening after such an error, but without an error listener, the error would result + // Log any error that occurs once our servers are listening (e.g., if one of them fails to accept a connection because + // there are too many open files). + // Note: indeed, a server keeps listening after such an error, but without an error listener, the error would result // in an uncaught exception in our main process. - rendererServer.on('error', (error: Error) => { - console.error('OpenCOR: the renderer server encountered an error:', error); - }); + for (const rendererServer of rendererServers) { + rendererServer.on('error', (error: Error) => { + console.error('OpenCOR: the renderer server encountered an error:', error); + }); + } return rendererBaseUrl; }; export const stopRendererServer = async (): Promise => { - // Make sure that we have a server to stop. + // Make sure that we have servers to stop. - if (!rendererServer) { + if (!rendererServers.length) { return; } - // Close the server. + // Close our servers. - await new Promise((resolve, reject) => { - rendererServer?.close((error) => { - if (error) { - // An error occurred, so reject the promise. - - reject(error); - } else { - resolve(); - } - }); - }); + await Promise.all(rendererServers.map(closeServer)); - // Clear our server and base URL references. + // Clear our servers and base URL references. - rendererServer = null; + rendererServers = []; rendererBaseUrl = null; }; From 1e8141573cd27f17b2d2c89a03510666450fab09 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 20:08:07 +1300 Subject: [PATCH 055/184] Expanded the support for math identifier. --- src/renderer/src/common/math.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/common/math.ts b/src/renderer/src/common/math.ts index b7d204a23..9c6cd1718 100644 --- a/src/renderer/src/common/math.ts +++ b/src/renderer/src/common/math.ts @@ -29,10 +29,16 @@ interface IMathJsLike { } const ELEMENTWISE_REGEX = /\.([*/^])/g; -// Note: we match numbers as well as identifiers so that the exponent of a number in scientific notation (e.g., e3 in -// 2e3) is not mistaken for an identifier while still allowing for implicit multiplication (e.g., 2x or 2e). - -const NUMBER_OR_IDENTIFIER_REGEX = /(\d*\.?\d+(?:[eE][+-]?\d+)?)|([A-Za-z_$][A-Za-z0-9_$]*)/g; +// Note #1: we match numbers as well as identifiers so that the exponent of a number in scientific notation (e.g., e3 +// in 2e3) is not mistaken for an identifier while still allowing for implicit multiplication (e.g., 2x or 2e). +// Note #2: an identifier starts with a letter and is followed by letters and/or digits where a letter is what Math.js +// considers to be a letter (see parse.isAlpha() in Math.js), i.e. a Latin letter, an underscore, a dollar +// sign, a Latin-1 Supplement or Latin Extended letter (U+00C0 to U+02AF), a Greek or Coptic letter (U+0370 to +// U+03FF), a letterlike symbol (U+2100 to U+214F), or a mathematical alphanumeric symbol (U+1D400 to U+1D7FF, +// i.e. a surrogate pair, since we don't use the u flag). + +const NUMBER_OR_IDENTIFIER_REGEX = + /(\d*\.?\d+(?:[eE][+-]?\d+)?)|((?:[A-Za-z_$\u00C0-\u02AF\u0370-\u03FF\u2100-\u214F]|\uD835[\uDC00-\uDFFF])(?:[A-Za-z0-9_$\u00C0-\u02AF\u0370-\u03FF\u2100-\u214F]|\uD835[\uDC00-\uDFFF])*)/g; // A helper function to retrieve the identifiers (i.e. variables, functions, and constants) used in an expression. From a25b76eca74d886845c15d0c837f06338ea99cdc Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 20:13:28 +1300 Subject: [PATCH 056/184] Hardened simulation instance cleanup paths. --- .../SimulationExperimentInteractiveView.vue | 18 +++++++++++++----- src/renderer/src/libopencor/locSedApi.ts | 12 ++++++++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue index a34996f15..687806771 100644 --- a/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue +++ b/src/renderer/src/components/views/SimulationExperimentInteractiveView.vue @@ -1330,18 +1330,26 @@ const runSimulation = async (currentSimulationGeneration: number): Promise runningInstances.add(crtInstance); + // Release our instance if it has been replaced by a newer one (or if we got unmounted) while the simulation was + // running, in which case our results are stale anyway. + // Note: we do this even if waiting for the simulation to finish failed (e.g., if libOpenCOR threw an exception) since + // nothing else will release our instance (see reinstantiateInstance() and onBeforeUnmount()). + + let instanceReplaced = false; + try { await vueCommon.waitWhileRunning(crtInstance).promise; } finally { runningInstances.delete(crtInstance); - } - // Release our instance if it has been replaced by a newer one while the simulation was running, in which case our - // results are stale anyway. + if (crtInstance !== instance) { + instanceReplaced = true; - if (crtInstance !== instance) { - crtInstance.release(); + crtInstance.release(); + } + } + if (instanceReplaced) { return; } diff --git a/src/renderer/src/libopencor/locSedApi.ts b/src/renderer/src/libopencor/locSedApi.ts index f2cda95a9..34acf8c3a 100644 --- a/src/renderer/src/libopencor/locSedApi.ts +++ b/src/renderer/src/libopencor/locSedApi.ts @@ -616,8 +616,16 @@ export class SedInstance { this._released = true; - if (this.status() !== ESedInstanceStatus.IDLE) { - this.stopRun(); + // Stop our simulation run, if needed. + // Note: we release our tasks and instance even if this fails (e.g., if libOpenCOR throws an exception) since we + // would otherwise leak them (we are now considered released, so we won't get another chance). + + try { + if (this.status() !== ESedInstanceStatus.IDLE) { + this.stopRun(); + } + } catch (error: unknown) { + console.error('OpenCOR: an error occurred while stopping a simulation run:', error); } for (const task of this._tasks) { From 671cb5cebce81913141f0b9f7d6882cb4c250071 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 20:32:13 +1300 Subject: [PATCH 057/184] Handle loadURL() interruption when the main window closes. --- src/main/MainWindow.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/MainWindow.ts b/src/main/MainWindow.ts index e46d72560..209d2b723 100644 --- a/src/main/MainWindow.ts +++ b/src/main/MainWindow.ts @@ -461,12 +461,22 @@ export class MainWindow extends ApplicationWindow { }); // Load the renderer URL. - // Note: a navigation that gets interrupted (e.g., by another navigation) results in an ERR_ABORTED error, which is - // not fatal. Any other error means that OpenCOR cannot be used, so we report it and quit. + // Note: a navigation that gets interrupted by another navigation (e.g., our renderer being reloaded after it + // crashed) results in an ERR_ABORTED error while one that gets interrupted by us being closed (e.g., because + // OpenCOR is being quit) or destroyed results in an ERR_FAILED error. Neither is fatal, but since Electron + // rejects the navigation after we have been asked to close but before we have been destroyed, we need to keep + // track of whether we are being closed. Any other error means that OpenCOR cannot be used, so we report it + // and quit. + + let closing = false; + + this.once('close', () => { + closing = true; + }); this.loadURL(rendererUrl).catch((error: unknown) => { - if ((error as { code?: string }).code === 'ERR_ABORTED') { - console.warn(`OpenCOR: loading of URL (${rendererUrl}) was aborted:`, formatError(error)); + if ((error as { code?: string }).code === 'ERR_ABORTED' || closing || this.isDestroyed()) { + console.warn(`OpenCOR: loading of URL (${rendererUrl}) was interrupted:`, formatError(error)); return; } From 1265079a559bf5d1a33b61862fa3589fbce5d822 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 20:35:45 +1300 Subject: [PATCH 058/184] Reset the tracking of an update installation error. --- src/main/MainWindow.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/MainWindow.ts b/src/main/MainWindow.ts index 209d2b723..46f3d9cf1 100644 --- a/src/main/MainWindow.ts +++ b/src/main/MainWindow.ts @@ -36,6 +36,11 @@ export const checkForUpdates = (atStartup: boolean): void => { // Check for updates, if we can and if requested (i.e. at startup, if the user wants us to, or by the user). if (canCheckForUpdates() && (!atStartup || electronConf.get('settings.general.checkForUpdatesAtStartup'))) { + // Note: any issue with checking for updates is reported through the promise returned by checkForUpdates(), so we + // must make sure that it doesn't also get forwarded as an issue with installing an update (see below). + + updateDownloaded = false; + autoUpdater .checkForUpdates() .then((result: UpdateCheckResult | null) => { @@ -63,11 +68,15 @@ autoUpdater.on('download-progress', (info: ProgressInfo) => { // has resolved, so our renderer would otherwise wait forever for the update to be installed. Other issues are // reported through the promises returned by checkForUpdates() and downloadUpdate() (as well as through an error // event), so we only forward error events once an update has been downloaded, to avoid reporting an issue twice. +// Once forwarded, the installation of the update has failed, so we are done with it (and any subsequent error +// event, e.g., from checking for updates again, must not be forwarded as an issue with installing an update). let updateDownloaded = false; autoUpdater.on('error', (error: Error) => { if (updateDownloaded) { + updateDownloaded = false; + MainWindow.instance?.send('update-install-error', formatError(error)); } }); From a481063aa629f1fbbc384be7174008ffad179a9f Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 20:40:27 +1300 Subject: [PATCH 059/184] Increased the delay for revoking a download URL. --- src/renderer/src/common/common.ts | 10 ++++++---- src/renderer/src/common/constants.ts | 1 - 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/common/common.ts b/src/renderer/src/common/common.ts index a4412ef43..496668219 100644 --- a/src/renderer/src/common/common.ts +++ b/src/renderer/src/common/common.ts @@ -1,6 +1,5 @@ import type { IOpenCORSimulationDataValue, OpenCORSimulationData } from '../../index'; -import { VERY_LONG_DELAY } from './constants'; import * as dependencies from './dependencies'; import { electronApi } from './electronApi'; @@ -202,6 +201,8 @@ export const isDivisible = (a: number, b: number): boolean => { // A method to trigger a browser download for a file. +const DOWNLOAD_OBJECT_URL_LIFETIME = 60000; // 60 seconds. + export const downloadFile = (filename: string, content: string | Blob, type: string): void => { const link = document.createElement('a'); const blob = content instanceof Blob ? content : new Blob([content], { type }); @@ -217,12 +218,13 @@ export const downloadFile = (filename: string, content: string | Blob, type: str document.body.removeChild(link); // Revoke our object URL, but only once the download has started. - // Note: some browsers (e.g., Safari) start the download asynchronously, so revoking our object URL straight away may - // cancel it. + // Note: some browsers start the download asynchronously, so revoking our object URL straight away may cancel it. In + // Safari, this may even be once the user has allowed downloads from our site (it asks the first time a site + // downloads a file), so we give the user plenty of time to do so (like FileSaver.js does). setTimeout(() => { URL.revokeObjectURL(url); - }, VERY_LONG_DELAY); + }, DOWNLOAD_OBJECT_URL_LIFETIME); }; // A method to get the file name from a file path. diff --git a/src/renderer/src/common/constants.ts b/src/renderer/src/common/constants.ts index d1fb37666..0b80ad5b9 100644 --- a/src/renderer/src/common/constants.ts +++ b/src/renderer/src/common/constants.ts @@ -6,7 +6,6 @@ export const VERY_SHORT_DELAY: number = 19; export const SHORT_DELAY: number = 69; export const MEDIUM_DELAY: number = 169; export const LONG_DELAY: number = 369; -export const VERY_LONG_DELAY: number = 969; export const TOAST_LIFE: number = 3000; From 77d6c010bceac427e8f4af6a4752ad1330d570ef Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 8 Oct 2026 20:48:09 +1300 Subject: [PATCH 060/184] Added jsDelivr host failover for module retries. --- src/renderer/index.html | 2 +- src/renderer/src/common/initialisation.ts | 31 ++++++++++++++++++----- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/renderer/index.html b/src/renderer/index.html index e72ae4684..2148083ad 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -7,7 +7,7 @@ + content="default-src 'self'; connect-src *; script-src 'unsafe-eval'; script-src-elem 'self' https://apis.google.com https://cdn.jsdelivr.net https://fastly.jsdelivr.net https://gcore.jsdelivr.net https://cors-proxy.opencor.workers.dev; worker-src 'self'; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' https://opencorapp.firebaseapp.com; img-src 'self' blob: data:" />