From 35420bb9209265a47b21a8743e64cea3d6e8c83e Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:51:54 +0200 Subject: [PATCH 1/2] Linux: tools installeren, detecteren en starten via AppImages De installer koos altijd een Windows-installer-asset en kon op Linux dus niets installeren, detecteren of starten. Deze commit maakt de backend platform-bewust. - github.rs kiest het asset dat bij het platform past: op Linux alleen x86-64 AppImages (.deb vergt root, ARM-builds draaien niet op een gewone desktop). Op macOS wordt bewust niets gekozen, zodat de app eerlijk "geen installer" toont in plaats van een Windows-exe aan te bieden. - linux.rs beheert AppImages zonder beheerdersrechten: plaatsen in ~/.local/share/openaec-installer/apps//, uitvoerbaar maken, oude versies opruimen en een menu-snelkoppeling schrijven. Zelf bijwerken vervangt de draaiende AppImage via $APPIMAGE. - registry.rs detecteert op Linux via die beheerde map; de versie komt uit de bestandsnaam, met dezelfde logica als voor de release-assets. - Gestarte tools krijgen een eigen procesgroep, losgekoppelde uitvoer en worden opgeruimd. Zonder de eigen procesgroep sleepte het afsluiten van de installer elke gestarte tool mee het graf in. Getest met een install-, detectie- en updaterondgang op schijf, en met alle zeven tools die daadwerkelijk een AppImage publiceren. Co-Authored-By: Claude Fable 5 --- README.md | 11 +- package-lock.json | 378 ++++++++++++++++++++++++++++++++++++- src-tauri/Cargo.lock | 2 +- src-tauri/src/github.rs | 59 +++++- src-tauri/src/installer.rs | 84 +++++++-- src-tauri/src/lib.rs | 12 +- src-tauri/src/linux.rs | 238 +++++++++++++++++++++++ src-tauri/src/registry.rs | 14 +- src/data/catalog.ts | 9 +- src/lib/api.ts | 20 +- 10 files changed, 787 insertions(+), 40 deletions(-) create mode 100644 src-tauri/src/linux.rs diff --git a/README.md b/README.md index 9d85136..b966742 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,15 @@ De centrale hub voor de open source bouwsoftware van de [OpenAEC Foundation](htt ## Functies - **Catalogus** van alle OpenAEC-tools met omschrijving en categorie — desktop-apps én webtools -- **Installatiedetectie**: ziet via het Windows-register welke tools al geïnstalleerd zijn en welke versie +- **Installatiedetectie**: ziet welke tools al geïnstalleerd zijn en welke versie (Windows: via het register; Linux: via de beheerde AppImage-map) - **Updatedetectie**: vergelijkt de geïnstalleerde versie met de laatste GitHub-release - **Eén klik installeren of bijwerken**: downloadt de officiële installer van GitHub Releases en start hem -- **Starten** van geïnstalleerde tools en openen van webtools in de browser +- **Starten** van geïnstalleerde tools +- **Webtools in tabbladen**: webtools (en de webversies van desktop-tools) draaien in tabs binnen de installer zelf; open tabs worden onthouden en behouden hun staat bij wisselen. Openen in de externe browser blijft mogelijk - **Werkt zichzelf bij**: de installer controleert ook zijn eigen versie en meldt het wanneer er een nieuwe versie is — bijwerken kan met één klik vanuit de app - Volledig in de [OpenAEC-huisstijl](https://github.com/OpenAEC-Foundation/OpenAEC-style-book), met vijf thema's (licht, Forge, OpenAEC, Blueprint, hoog contrast) en tweetalige interface (NL/EN) -> Installatie- en updatedetectie werkt momenteel op Windows. De catalogus en websnelkoppelingen werken op alle platformen. +> Installeren, bijwerken en starten werkt op Windows (via de officiële installers) en Linux (via AppImages, zonder beheerdersrechten — inclusief menu-snelkoppeling). De catalogus en websnelkoppelingen werken op alle platformen; macOS-installatie volgt nog. ## Downloaden @@ -37,6 +38,8 @@ Vereisten: [Node.js](https://nodejs.org/) 20+, [Rust](https://rustup.rs/) (stabl npm install npm run tauri dev # ontwikkelmodus npm run tauri build # productie-build + installers +npm test # frontend-tests (vitest) +cd src-tauri && cargo test # backend-tests ``` ### Een tool toevoegen aan de catalogus @@ -46,7 +49,7 @@ Voeg een entry toe aan [`src/data/catalog.ts`](src/data/catalog.ts): - `kind: "desktop"` — met `repo` (GitHub `owner/naam`), `registryName` (DisplayName in de Windows Uninstall-registersleutel, meestal gelijk aan de productnaam) en `exeName` als fallback - `kind: "web"` — met alleen een `webUrl` -De laatste release en het juiste Windows-installer-asset worden automatisch via de GitHub API opgehaald. +De laatste release en het juiste installer-asset voor het platform (Windows: `-setup.exe`/`.msi`, Linux: `.AppImage`) worden automatisch via de GitHub API opgehaald. ## Release maken diff --git a/package-lock.json b/package-lock.json index 955baa4..75c6f71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openaec-installer", - "version": "0.1.0", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openaec-installer", - "version": "0.1.0", + "version": "0.1.6", "dependencies": { "@fontsource/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", @@ -26,7 +26,8 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", "typescript": "~5.9.3", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.1.10" } }, "node_modules/@babel/code-frame": { @@ -1235,6 +1236,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tauri-apps/api": { "version": "2.11.1", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", @@ -1540,6 +1548,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1588,6 +1614,129 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.43", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", @@ -1656,6 +1805,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1695,6 +1854,13 @@ "dev": true, "license": "ISC" }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -1747,6 +1913,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1882,6 +2068,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1918,6 +2114,27 @@ "node": ">=18" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2086,6 +2303,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2096,6 +2320,37 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2113,6 +2368,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2242,6 +2507,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -2251,6 +2606,23 @@ "node": ">=0.10.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 36a99dd..1b311bc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2331,7 +2331,7 @@ dependencies = [ [[package]] name = "openaec-installer" -version = "0.1.5" +version = "0.1.6" dependencies = [ "futures-util", "reqwest 0.12.28", diff --git a/src-tauri/src/github.rs b/src-tauri/src/github.rs index 2353196..461bdda 100644 --- a/src-tauri/src/github.rs +++ b/src-tauri/src/github.rs @@ -1,9 +1,10 @@ //! Laatste releases ophalen via de GitHub API (ongeauthenticeerd, 60 req/uur — //! de frontend cachet het resultaat). //! -//! De versie wordt uit de naam van het Windows-installer-asset geparst in -//! plaats van uit de tag: bij sommige repo's (bijv. monty-ifc-viewer v1.0.1) -//! wijkt de tag af van de daadwerkelijke bestandsversie. +//! De versie wordt uit de naam van het installer-asset geparst in plaats van +//! uit de tag: bij sommige repo's (bijv. monty-ifc-viewer v1.0.1) wijkt de tag +//! af van de daadwerkelijke bestandsversie. Welk asset gekozen wordt hangt af +//! van het platform: Windows-installers op Windows, AppImages op Linux. use serde::{Deserialize, Serialize}; @@ -94,7 +95,7 @@ async fn fetch_one(client: reqwest::Client, repo: RepoRef) -> ReleaseInfo { .iter() .filter_map(|a| { let name = a["name"].as_str()?; - let score = score_windows_asset(name)?; + let score = score_asset(name)?; Some((score, name.to_string(), a)) }) .max_by_key(|(score, _, _)| *score); @@ -128,6 +129,39 @@ async fn fetch_one(client: reqwest::Client, repo: RepoRef) -> ReleaseInfo { } } +/// Kies het juiste asset voor het huidige platform. `cfg!` in plaats van +/// `#[cfg]` zodat alle scorers op elk platform meecompileren en getest worden. +fn score_asset(name: &str) -> Option { + if cfg!(target_os = "linux") { + score_linux_asset(name) + } else if cfg!(target_os = "macos") { + // macOS-installatie is nog niet geïmplementeerd; geen asset kiezen + // betekent dat de app netjes "geen installer" toont. + None + } else { + score_windows_asset(name) + } +} + +/// Voorkeursvolgorde voor Linux: alleen AppImages — die kan de app zelf +/// beheren (plaatsen, uitvoerbaar maken, starten) zonder beheerdersrechten. +/// .deb vergt root en dpkg en valt daarom af; None = geen bruikbaar asset. +fn score_linux_asset(name: &str) -> Option { + let n = name.to_ascii_lowercase(); + if !n.ends_with(".appimage") { + return None; + } + // Alleen x86-64; ARM-builds zouden op een gewone desktop niet starten. + if ["aarch64", "arm64", "armv7", "armhf", "i386", "i686"] + .iter() + .any(|a| n.contains(a)) + { + return None; + } + let x64 = n.contains("x64") || n.contains("x86_64") || n.contains("amd64"); + Some(if x64 { 100 } else { 90 }) +} + /// Voorkeursvolgorde voor Windows-installers; None = geen installer-asset. fn score_windows_asset(name: &str) -> Option { let n = name.to_ascii_lowercase(); @@ -160,7 +194,9 @@ fn score_windows_asset(name: &str) -> Option { /// Zoek een versienummer (minimaal `x.y`) in een assetnaam, /// bijv. "Open.Frame.Studio_0.5.2_x64-setup.exe" → "0.5.2". -fn extract_version(name: &str) -> Option { +/// Ook gebruikt door de Linux-detectie om de versie uit een +/// geïnstalleerde AppImage-bestandsnaam te halen. +pub(crate) fn extract_version(name: &str) -> Option { name.split(['_', '-']) .map(|t| t.trim_start_matches('v').trim_start_matches('V')) .find(|t| { @@ -219,4 +255,17 @@ mod tests { assert_eq!(score_windows_asset("Open.PDF.Studio_1.67.0_x64-setup.exe.sig"), None); assert_eq!(score_windows_asset("Open.2D.Studio_0.35.0_amd64.AppImage"), None); } + + #[test] + fn linux_picks_only_x86_64_appimages() { + // AppImage wint; Windows-installers en .deb (root vereist) vallen af. + assert!(score_linux_asset("Open.2D.Studio_0.35.0_amd64.AppImage").is_some()); + assert!(score_linux_asset("OpenAEC.Installer_0.1.6_x86_64.AppImage").is_some()); + assert_eq!(score_linux_asset("Open.2D.Studio_0.35.0_amd64.deb"), None); + assert_eq!(score_linux_asset("Open.PDF.Studio_1.67.0_x64-setup.exe"), None); + assert_eq!(score_linux_asset("Open.2D.Studio_0.35.0_amd64.AppImage.sig"), None); + // ARM- en 32-bits-builds starten niet op een x86-64-desktop. + assert_eq!(score_linux_asset("Open.2D.Studio_0.35.0_aarch64.AppImage"), None); + assert_eq!(score_linux_asset("Open.2D.Studio_0.35.0_i386.AppImage"), None); + } } diff --git a/src-tauri/src/installer.rs b/src-tauri/src/installer.rs index ffd04e1..0bb3686 100644 --- a/src-tauri/src/installer.rs +++ b/src-tauri/src/installer.rs @@ -1,9 +1,24 @@ -//! Installer-assets downloaden (met voortgang-events) en starten, +//! Installer-assets downloaden (met voortgang-events) en installeren, //! en geïnstalleerde tools opstarten. +//! +//! Windows: de gedownloade setup wordt gestart en de gebruiker doorloopt de +//! setup-UI. Linux: de AppImage wordt direct geïnstalleerd (zie `linux.rs`); +//! er is geen setup-UI. Het verschil wordt via `InstallOutcome.mode` aan de +//! frontend doorgegeven zodat die de juiste melding toont. use serde::Serialize; use tauri::Emitter; +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallOutcome { + pub path: String, + /// "ranInstaller" — setup-UI gestart (Windows); + /// "installed" — direct geïnstalleerd, klaar voor gebruik (Linux); + /// "selfReplaced" — eigen AppImage vervangen, herstart nodig (Linux). + pub mode: &'static str, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct DownloadProgress { @@ -13,14 +28,15 @@ struct DownloadProgress { done: bool, } -/// Download het asset naar %TEMP%\openaec-installer\ en start daarna de -/// installer. De gebruiker doorloopt zelf de setup-UI van de tool. +/// Download het asset naar de tempmap en installeer het daarna op de manier +/// die bij het platform past. pub async fn download_and_run( app: tauri::AppHandle, id: String, url: String, file_name: String, -) -> Result { + display_name: Option, +) -> Result { // Alleen GitHub release-assets toestaan. if !url.starts_with("https://github.com/") { return Err("Alleen downloads vanaf github.com zijn toegestaan.".into()); @@ -85,27 +101,35 @@ pub async fn download_and_run( }, ); - run_installer(&target)?; - Ok(target.to_string_lossy().into_owned()) + let mode = run_installer(&id, display_name.as_deref(), &target)?; + Ok(InstallOutcome { + path: target.to_string_lossy().into_owned(), + mode, + }) } #[cfg(windows)] -fn run_installer(path: &std::path::Path) -> Result<(), String> { +fn run_installer( + _id: &str, + _display_name: Option<&str>, + path: &std::path::Path, +) -> Result<&'static str, String> { let is_msi = path .extension() .map(|e| e.eq_ignore_ascii_case("msi")) .unwrap_or(false); if is_msi { // msiexec vraagt zelf om elevatie voor een perMachine-installatie. - shell_execute("msiexec.exe", Some(&format!("/i \"{}\"", path.display()))) + shell_execute("msiexec.exe", Some(&format!("/i \"{}\"", path.display())))?; } else { // Direct starten via CreateProcess (std::process::Command) faalt met // "os error 740" (ERROR_ELEVATION_REQUIRED) zodra de installer // beheerdersrechten vereist — CreateProcess kan niet eleveren. // ShellExecuteW honoreert het uitvoerbaar-manifest en toont zo nodig // de UAC-prompt (net als dubbelklikken in Verkenner). - shell_execute(&path.to_string_lossy(), None) + shell_execute(&path.to_string_lossy(), None)?; } + Ok("ranInstaller") } /// Start een bestand via de Windows-shell (ShellExecuteW). Met een leeg werkwoord @@ -148,9 +172,36 @@ fn shell_execute(file: &str, params: Option<&str>) -> Result<(), String> { } } -#[cfg(not(windows))] -fn run_installer(_path: &std::path::Path) -> Result<(), String> { - Err("Installeren wordt momenteel alleen op Windows ondersteund.".into()) +#[cfg(target_os = "linux")] +fn run_installer( + id: &str, + display_name: Option<&str>, + path: &std::path::Path, +) -> Result<&'static str, String> { + let is_appimage = path + .extension() + .map(|e| e.eq_ignore_ascii_case("appimage")) + .unwrap_or(false); + if !is_appimage { + return Err("Geen ondersteund installatiebestand voor Linux (AppImage verwacht).".into()); + } + if id == "openaec-installer" { + // Zelf-update: vervang de draaiende AppImage; herstart daarna. + crate::linux::self_update(path)?; + Ok("selfReplaced") + } else { + crate::linux::install_appimage(id, display_name.unwrap_or(id), path)?; + Ok("installed") + } +} + +#[cfg(not(any(windows, target_os = "linux")))] +fn run_installer( + _id: &str, + _display_name: Option<&str>, + _path: &std::path::Path, +) -> Result<&'static str, String> { + Err("Installeren wordt op dit platform nog niet ondersteund.".into()) } /// Start een geïnstalleerde tool. @@ -173,7 +224,12 @@ pub fn launch(exe_path: &str) -> Result<(), String> { Ok(()) } -#[cfg(not(windows))] +#[cfg(target_os = "linux")] +pub fn launch(exe_path: &str) -> Result<(), String> { + crate::linux::launch(exe_path) +} + +#[cfg(not(any(windows, target_os = "linux")))] pub fn launch(_exe_path: &str) -> Result<(), String> { - Err("Starten wordt momenteel alleen op Windows ondersteund.".into()) + Err("Starten wordt op dit platform nog niet ondersteund.".into()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6a9e0bb..a438b9e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,10 @@ mod github; mod installer; +#[cfg(target_os = "linux")] +mod linux; mod registry; -/// Scan het Windows-register op geïnstalleerde OpenAEC-tools. +/// Scan op geïnstalleerde OpenAEC-tools (Windows-register / Linux-appmap). #[tauri::command] fn get_installed_tools(queries: Vec) -> Vec { registry::scan(&queries) @@ -14,15 +16,17 @@ async fn get_latest_releases(repos: Vec) -> Vec Result { - installer::download_and_run(app, id, url, file_name).await + display_name: Option, +) -> Result { + installer::download_and_run(app, id, url, file_name, display_name).await } /// Start een geïnstalleerde tool. diff --git a/src-tauri/src/linux.rs b/src-tauri/src/linux.rs new file mode 100644 index 0000000..8968f03 --- /dev/null +++ b/src-tauri/src/linux.rs @@ -0,0 +1,238 @@ +//! Linux-ondersteuning: AppImages beheren zonder beheerdersrechten. +//! +//! "Installeren" betekent hier: de AppImage in een eigen beheerde map zetten +//! (`~/.local/share/openaec-installer/apps//`), uitvoerbaar maken en +//! een menu-snelkoppeling (.desktop) aanmaken. Detectie scant diezelfde map en +//! haalt de versie uit de bestandsnaam (bijv. "Open.2D.Studio_0.35.0_amd64 +//! .AppImage" → 0.35.0). Zelf-update vervangt de draaiende AppImage via het +//! `$APPIMAGE`-pad dat de AppImage-runtime zet. + +use std::path::{Path, PathBuf}; + +use crate::registry::{InstalledTool, ToolQuery}; + +/// Basismap voor gebruikersdata volgens XDG (`~/.local/share`). +fn data_home() -> Option { + std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share"))) +} + +/// Map waarin de app geïnstalleerde AppImages beheert, per tool-id. +fn apps_dir() -> Option { + Some(data_home()?.join("openaec-installer").join("apps")) +} + +/// Alle AppImages in een map (meestal 0 of 1). +fn appimages_in(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.is_file() + && p.extension() + .map(|e| e.eq_ignore_ascii_case("appimage")) + .unwrap_or(false) + }) + .collect() +} + +/// Scan de beheerde map op geïnstalleerde tools. +pub fn scan(queries: &[ToolQuery]) -> Vec { + let Some(root) = apps_dir() else { + return Vec::new(); + }; + queries + .iter() + .filter_map(|q| { + let dir = root.join(&q.id); + // Bij meerdere bestanden (hoort niet, maar kan na een mislukte + // update) wint de hoogste versie. + let best = appimages_in(&dir).into_iter().max_by_key(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .and_then(crate::github::extract_version) + .map(version_key) + .unwrap_or_default() + })?; + let version = best + .file_name() + .and_then(|n| n.to_str()) + .and_then(crate::github::extract_version); + Some(InstalledTool { + id: q.id.clone(), + display_name: q.display_name.clone(), + version, + exe_path: Some(best.to_string_lossy().into_owned()), + }) + }) + .collect() +} + +/// Sorteersleutel voor versies: "0.35.0" → [0, 35, 0]. +fn version_key(v: String) -> Vec { + v.split('.').filter_map(|p| p.parse().ok()).collect() +} + +/// Verplaats een bestand, met kopie-fallback voor over bestandssysteemgrenzen +/// heen (temp is vaak tmpfs). De kopie gaat via een `.part`-bestand en een +/// rename, zodat het doel nooit half geschreven is. +fn move_file(src: &Path, target: &Path) -> Result<(), String> { + if std::fs::rename(src, target).is_ok() { + return Ok(()); + } + let part = target.with_extension("part"); + std::fs::copy(src, &part).map_err(|e| format!("kopiëren: {e}"))?; + std::fs::rename(&part, target).map_err(|e| format!("verplaatsen: {e}"))?; + let _ = std::fs::remove_file(src); + Ok(()) +} + +fn make_executable(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + .map_err(|e| format!("uitvoerbaar maken: {e}")) +} + +/// Plaats een gedownloade AppImage in de beheerde map en maak een +/// menu-snelkoppeling. Oude versies van dezelfde tool worden opgeruimd. +pub fn install_appimage(id: &str, display_name: &str, downloaded: &Path) -> Result<(), String> { + let dir = apps_dir() + .ok_or("thuismap niet gevonden")? + .join(id); + std::fs::create_dir_all(&dir).map_err(|e| format!("appmap: {e}"))?; + + for old in appimages_in(&dir) { + let _ = std::fs::remove_file(old); + } + + let file_name = downloaded.file_name().ok_or("ongeldige bestandsnaam")?; + let target = dir.join(file_name); + move_file(downloaded, &target)?; + make_executable(&target)?; + + // Snelkoppeling is nuttig maar niet essentieel — een fout hier mag de + // installatie niet laten mislukken. + let _ = write_desktop_entry(id, display_name, &target); + Ok(()) +} + +/// Schrijf `~/.local/share/applications/openaec-.desktop`, zodat de tool +/// ook in het applicatiemenu verschijnt. Wordt bij elke (her)installatie +/// overschreven zodat het pad naar de nieuwste versie wijst. +fn write_desktop_entry(id: &str, display_name: &str, appimage: &Path) -> Result<(), String> { + let apps = data_home().ok_or("thuismap niet gevonden")?.join("applications"); + std::fs::create_dir_all(&apps).map_err(|e| e.to_string())?; + let entry = format!( + "[Desktop Entry]\nType=Application\nName={}\nExec=\"{}\"\nTerminal=false\nCategories=Office;Engineering;\nComment=Geïnstalleerd door OpenAEC Installer\n", + display_name, + appimage.display(), + ); + std::fs::write(apps.join(format!("openaec-{id}.desktop")), entry).map_err(|e| e.to_string()) +} + +/// Vervang de draaiende AppImage door de nieuwe versie. De AppImage-runtime +/// zet `$APPIMAGE` naar het pad van het draaiende bestand; een rename eroverheen +/// is veilig (de gemounte image blijft via de open inode beschikbaar). +pub fn self_update(downloaded: &Path) -> Result<(), String> { + let current = std::env::var("APPIMAGE").map_err(|_| { + "Zelf bijwerken kan alleen vanuit de AppImage-versie — download de nieuwe versie via GitHub." + .to_string() + })?; + make_executable(downloaded)?; + move_file(downloaded, Path::new(¤t)) +} + +/// Start een geïnstalleerde AppImage als losstaand programma. +pub fn launch(exe_path: &str) -> Result<(), String> { + let path = Path::new(exe_path); + if !path + .extension() + .map(|e| e.eq_ignore_ascii_case("appimage")) + .unwrap_or(false) + || !path.exists() + { + return Err("Programma niet gevonden.".into()); + } + let mut cmd = std::process::Command::new(path); + if let Some(parent) = path.parent() { + cmd.current_dir(parent); + } + // Losgekoppelde uitvoer: anders schrijft de gestarte tool zijn logregels in + // de uitvoer van de installer en blijft hij aan diens pijpen hangen. + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + + // Eigen procesgroep. Zonder dit staat de tool in de procesgroep van de + // installer en sleept het afsluiten daarvan (een signaal naar de hele + // groep) elke gestarte tool mee — de tool verdwijnt dan zomaar van het + // scherm. Een gestarte tool hoort de installer te overleven. + use std::os::unix::process::CommandExt; + cmd.process_group(0); + + let mut child = cmd.spawn().map_err(|e| format!("starten: {e}"))?; + + // De gestarte tool opruimen zodra de gebruiker hem sluit. Zonder wait() + // blijft elk afgesloten programma als zombieproces in de procestabel staan + // zolang de installer draait. + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_key_orders_numerically() { + // 0.10.0 hoort ná 0.9.0 (numeriek, niet lexicografisch). + assert!(version_key("0.10.0".into()) > version_key("0.9.0".into())); + assert!(version_key("2026.7.10".into()) > version_key("2026.7.9".into())); + } + + #[test] + fn install_scan_and_update_roundtrip() { + // Eigen HOME zodat de test nooit in de echte gebruikersmap schrijft. + let tmp = std::env::temp_dir().join(format!("openaec-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + std::env::set_var("XDG_DATA_HOME", &tmp); + + let download = tmp.join("Open.2D.Studio_0.35.0_amd64.AppImage"); + std::fs::write(&download, b"fake").unwrap(); + install_appimage("open-2d-studio", "Open 2D Studio", &download).unwrap(); + + let queries = vec![ToolQuery { + id: "open-2d-studio".into(), + display_name: "Open 2D Studio".into(), + exe_name: None, + }]; + let found = scan(&queries); + assert_eq!(found.len(), 1); + assert_eq!(found[0].version.as_deref(), Some("0.35.0")); + let exe = found[0].exe_path.clone().unwrap(); + assert!(Path::new(&exe).exists()); + + // Update: nieuwe versie vervangt de oude, ook in de detectie. + let newer = tmp.join("Open.2D.Studio_0.36.0_amd64.AppImage"); + std::fs::write(&newer, b"fake2").unwrap(); + install_appimage("open-2d-studio", "Open 2D Studio", &newer).unwrap(); + let found = scan(&queries); + assert_eq!(found[0].version.as_deref(), Some("0.36.0")); + assert!(!Path::new(&exe).exists(), "oude versie is opgeruimd"); + + // Snelkoppeling wijst naar de nieuwe versie. + let desktop = tmp.join("applications/openaec-open-2d-studio.desktop"); + let entry = std::fs::read_to_string(desktop).unwrap(); + assert!(entry.contains("0.36.0")); + + let _ = std::fs::remove_dir_all(&tmp); + } +} diff --git a/src-tauri/src/registry.rs b/src-tauri/src/registry.rs index e993ea4..189a374 100644 --- a/src-tauri/src/registry.rs +++ b/src-tauri/src/registry.rs @@ -1,9 +1,12 @@ -//! Detectie van geïnstalleerde tools via de Windows Uninstall-registersleutels. +//! Detectie van geïnstalleerde tools. //! -//! NSIS-installaties (Tauri `x64-setup.exe`) registreren onder +//! Windows: via de Uninstall-registersleutels. NSIS-installaties (Tauri +//! `x64-setup.exe`) registreren onder //! `HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\{ProductName}` met //! DisplayIcon = pad naar de exe. MSI-installaties registreren onder HKLM, //! vaak zonder InstallLocation — daarvoor zoeken we op bekende paden. +//! +//! Linux: via de beheerde AppImage-map (zie `linux.rs`). use serde::{Deserialize, Serialize}; @@ -156,7 +159,12 @@ fn resolve_exe( None } -#[cfg(not(windows))] +#[cfg(target_os = "linux")] +pub fn scan(queries: &[ToolQuery]) -> Vec { + crate::linux::scan(queries) +} + +#[cfg(not(any(windows, target_os = "linux")))] pub fn scan(_queries: &[ToolQuery]) -> Vec { Vec::new() } diff --git a/src/data/catalog.ts b/src/data/catalog.ts index 01fe489..3f23041 100644 --- a/src/data/catalog.ts +++ b/src/data/catalog.ts @@ -1,10 +1,11 @@ /** * Catalogus van OpenAEC Foundation tools. * - * - `desktop`-tools hebben Windows-installers op GitHub Releases; de app - * detecteert de geïnstalleerde versie via het Windows-register - * (`registryName` = DisplayName in de Uninstall-sleutel, `exeName` = fallback - * om de exe te vinden als het register geen pad geeft). + * - `desktop`-tools hebben installers op GitHub Releases (Windows-setup en + * AppImage); de app detecteert de geïnstalleerde versie op Windows via het + * register (`registryName` = DisplayName in de Uninstall-sleutel, `exeName` = + * fallback om de exe te vinden als het register geen pad geeft) en op Linux + * via de eigen beheerde AppImage-map. * - `web`-tools draaien in de browser en worden als snelkoppeling geopend. * * Omschrijvingen komen van https://open-aec.com/. diff --git a/src/lib/api.ts b/src/lib/api.ts index 25e8350..81d0aa6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -27,6 +27,16 @@ export interface ReleaseInfo { rateLimitReset?: number | null; } +export interface InstallOutcome { + path: string; + /** + * ranInstaller — setup-UI gestart (Windows); + * installed — direct geïnstalleerd, klaar voor gebruik (Linux); + * selfReplaced — eigen AppImage vervangen, herstart nodig (Linux). + */ + mode: "ranInstaller" | "installed" | "selfReplaced"; +} + export interface DownloadProgress { id: string; downloaded: number; @@ -58,8 +68,14 @@ export async function downloadAndRunInstaller( id: string, url: string, fileName: string, -): Promise { - return invoke("download_and_run_installer", { id, url, fileName }); + displayName?: string, +): Promise { + return invoke("download_and_run_installer", { + id, + url, + fileName, + displayName: displayName ?? null, + }); } export async function launchTool(exePath: string): Promise { From 57fe7e4184ab893bb34092e1b3c228ea4e96812c Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:52:13 +0200 Subject: [PATCH 2/2] Webtools in tabbladen openen, plus platformbewuste installatiemeldingen Webtools openden altijd in een externe browser. Ze hebben alleen een webview nodig, dus draaien ze nu in tabbladen binnen de installer zelf. - Browserachtige tabbalk: een vaste catalogus-tab plus een sluitbare tab per geopende webtool. De balk verschijnt alleen zodra er iets openstaat. - Elke tool met een webUrl kan in een tab, dus ook de webversies van desktop-tools. Die krijgen daarvoor een eigen knop op de kaart; zonder die knop was er voor een geinstalleerde tool geen enkele route naar een tabblad, omdat een klik op de kaart de native app start. - Open tabs blijven gemount, zodat wisselen de tool niet herlaadt en sessie en invoer behouden blijven. Open tabs worden bewaard en bij het opstarten hersteld; ids die niet meer bestaan vallen weg. - Openen in de externe browser blijft overal beschikbaar, ook als vluchtroute mocht een site ooit embedding blokkeren. Daarnaast in deze commit: - De installatiemelding volgt het platform: op Linux is de tool direct klaar voor gebruik, op Windows loopt de gebruiker een setup-UI door. - De voortgangsbalk blijft staan tot de installatie helemaal afrondt. Hij verdween eerder al bij het laatste downloadevent, waarna melding en statuswissel er los achteraan kwamen: dat gaf zichtbaar geknipper. - Vitest toegevoegd voor de tab-logica (10 tests) en meegenomen in CI. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 3 + .../specs/2026-07-20-webtool-tabs-design.md | 81 ++++++++++++++ package.json | 4 +- src/App.css | 13 +++ src/App.tsx | 101 ++++++++++++++++-- src/components/TabBar.css | 98 +++++++++++++++++ src/components/TabBar.tsx | 70 ++++++++++++ src/components/ToolCard.tsx | 62 +++++++++-- src/components/WebToolView.css | 66 ++++++++++++ src/components/WebToolView.tsx | 67 ++++++++++++ src/i18n/locales/en/common.json | 14 ++- src/i18n/locales/nl/common.json | 14 ++- src/lib/tabs.test.ts | 72 +++++++++++++ src/lib/tabs.ts | 63 +++++++++++ 14 files changed, 707 insertions(+), 21 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-20-webtool-tabs-design.md create mode 100644 src/components/TabBar.css create mode 100644 src/components/TabBar.tsx create mode 100644 src/components/WebToolView.css create mode 100644 src/components/WebToolView.tsx create mode 100644 src/lib/tabs.test.ts create mode 100644 src/lib/tabs.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bad9d72..131c26a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,9 @@ jobs: - name: Install frontend dependencies run: npm install + - name: Frontend tests + run: npm test + - name: Rust tests working-directory: src-tauri run: cargo test diff --git a/docs/superpowers/specs/2026-07-20-webtool-tabs-design.md b/docs/superpowers/specs/2026-07-20-webtool-tabs-design.md new file mode 100644 index 0000000..8133a20 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-webtool-tabs-design.md @@ -0,0 +1,81 @@ +# Webtools in tabbladen — ontwerp + +Datum: 2026-07-20 + +## Doel + +Webtools uit de catalogus openen in tabbladen binnen de OpenAEC Installer in +plaats van in een externe browser. De installer wordt daarmee één werkplek voor +zowel desktop-tools als webtools. + +## Uitgangspunt + +De webtools hebben alleen een webview nodig. Geen enkele tool op +`*.open-aec.com` stuurt `X-Frame-Options` of een `frame-ancestors`-CSP +(gecontroleerd op alle zes de domeinen), en `tauri.conf.json` zet zelf geen CSP +(`"csp": null`). Iframes binnen het bestaande webview volstaan dus — er is geen +Rust-code en geen extra Tauri-feature nodig. + +## Gedrag + +**Tabmodel — browserachtig.** Een vaste, niet-sluitbare tab "Catalogus" plus een +sluitbare tab per geopende webtool. De tabbalk verschijnt alleen zodra er een +webtool open staat, zodat de gewone catalogusweergave geen overbodige chrome +krijgt. + +**Reikwijdte.** Elke tool met een `webUrl` kan in een tab: de vijf web-only +tools én de webversies van desktop-tools. Installeren en starten van +desktop-tools verandert niet. + +**Knopgedrag.** Bij web-only tools is "Openen" de primaire knop en opent die een +tab. Bij alle tools met een `webUrl` opent de klikbare preview eveneens een tab. +Een icoonknop "Openen in browser" blijft op elke kaart aanwezig, en ook in de +balk boven een geopende tab — dat is tevens de vluchtroute mocht een site ooit +embedding gaan blokkeren. + +**Staat behouden.** Alle open tabs blijven gemount; inactieve worden met +`hidden` verborgen. Wisselen tussen tabs herlaadt de tool dus niet en behoudt +sessie en invoer. De catalogus blijft eveneens gemount (scrollpositie behouden). + +**Dedupliceren.** Een tool die al open staat, opent geen tweede tab maar +activeert de bestaande. + +**Sluiten.** Sluit je de actieve tab, dan springt de focus naar de rechterbuur, +anders de linker, anders terug naar de catalogus — zoals in een browser. + +**Herstel.** De open tabs en de actieve tab worden bewaard in de bestaande +instellingen-store en bij het opstarten hersteld. Bewaarde ids die niet meer in +de catalogus staan of geen `webUrl` meer hebben, vallen weg. De pagina-staat +binnen een tool wordt niet bewaard — die laadt vers. + +## Componenten + +| Bestand | Verantwoordelijkheid | +|---|---| +| `src/lib/tabs.ts` | Pure tab-logica: openen, dedupliceren, sluiten, herstellen. Geen React. | +| `src/components/TabBar.tsx` | De tabbalk. | +| `src/components/WebToolView.tsx` | Eén webtool in een iframe, met herlaad- en browserknop. | +| `src/App.tsx` | Tab-state, persistentie, weergaveschakeling. | +| `src/components/ToolCard.tsx` | Roept `onOpenTab` aan in plaats van extern openen. | + +De tab-logica staat bewust los van React zodat ze zonder DOM te testen is. + +## Beperkingen + +- Links binnen een webtool die naar een externe site wijzen, navigeren binnen de + tab; met de herlaadknop kom je terug bij de tool. +- `target="_blank"`-popups werken binnen een iframe mogelijk niet. Als dat in de + praktijk knelt, is de vervolgstap native child-webviews (Tauri `unstable`) — + bewust uitgesteld tot er een concrete aanleiding is. +- Zware tools (Pointcloud) open houden kost geheugen; de tab sluiten ruimt op. +- Herladen gebeurt door de iframe te vervangen: cross-origin `location.reload()` + is vanuit de app niet toegestaan. + +## Verificatie + +- `npm test` — 10 tests op de tab-logica (openen, dedupliceren, sluitfocus, + herstelfiltering). +- `npm run build` — typecontrole en productiebuild. +- Handmatig in de draaiende frontend: tab openen, wisselen met behoud van staat, + dubbel openen, sluiten van actieve en laatste tab, iframe-lading van + `open-calc-studio` en `open-heatloss-studio`. diff --git a/package.json b/package.json index 7e9a96c..4750f3b 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "test": "vitest run", "preview": "vite preview", "tauri": "tauri" }, @@ -28,6 +29,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", "typescript": "~5.9.3", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.1.10" } } diff --git a/src/App.css b/src/App.css index 3bb0238..4931abe 100644 --- a/src/App.css +++ b/src/App.css @@ -56,6 +56,19 @@ body, padding-top: 32px; } +/* Catalogusweergave — blijft gemount (scrollpositie behouden) terwijl een + webtool-tab actief is. */ +.catalog-view { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.catalog-view[hidden] { + display: none; +} + /* ── Header ── */ .app-header { display: flex; diff --git a/src/App.tsx b/src/App.tsx index 8ef0f90..c27c311 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,8 @@ import SettingsDialog, { applyTheme } from "./components/settings/SettingsDialog import ToolCard from "./components/ToolCard"; import OpenAecLogo from "./components/OpenAecLogo"; import SelfUpdateBanner from "./components/SelfUpdateBanner"; +import TabBar from "./components/TabBar"; +import WebToolView from "./components/WebToolView"; import { DESKTOP_TOOLS, SELF_ID, WEB_TOOLS, type CatalogTool } from "./data/catalog"; import { downloadAndRunInstaller, @@ -18,11 +20,24 @@ import { type ReleaseInfo, } from "./lib/api"; import { compareVersions, deriveStatus } from "./lib/status"; +import { + CATALOG_TAB, + EMPTY_TABS, + closeTab, + openTab, + restoreTabs, + toolForTab, + type TabState, +} from "./lib/tabs"; import { getSetting, setSetting } from "./store"; import "./themes.css"; import "./App.css"; -const RELEASE_CACHE_KEY = "releaseCache"; +// ".v2" sinds het gekozen asset platform-afhankelijk is: een cache van een +// oudere versie kan nog Windows-installers bevatten, wat op Linux een +// "AppImage verwacht"-fout bij het installeren zou geven. +const RELEASE_CACHE_KEY = "releaseCache.v2"; +const TABS_KEY = "openTabs"; // Releases veranderen hooguit een paar keer per week, terwijl elke ronde 13 // GitHub-aanroepen kost tegen een limiet van 60 per uur per IP. Een ruime cache // houdt de app bruikbaar (en meerdere gebruikers achter één kantoor-IP uit @@ -49,6 +64,8 @@ function App() { const [appVersion, setAppVersion] = useState(""); // Weggeklikte update-melding (per sessie; komt bij herstart weer terug). const [selfUpdateDismissed, setSelfUpdateDismissed] = useState(false); + // Geopende webtool-tabs (browserachtig; blijven bewaard tussen sessies). + const [tabs, setTabs] = useState(EMPTY_TABS); const [installed, setInstalled] = useState>({}); const [releases, setReleases] = useState>({}); @@ -63,6 +80,8 @@ function App() { // Voorkomt dat de trage opstart-load van groupByStatus een klik overschrijft // die de gebruiker doet vóór die load klaar is (race bij opstart). const groupTouchedRef = useRef(false); + // Idem voor de tabs: een klik vóór de opstart-load mag niet overschreven worden. + const tabsTouchedRef = useRef(false); const scanInstalled = useCallback(async () => { try { @@ -138,6 +157,9 @@ function App() { getSetting("groupByStatus", false).then((saved) => { if (!groupTouchedRef.current) setGroupByStatus(saved); }); + getSetting | null>(TABS_KEY, null).then((saved) => { + if (!tabsTouchedRef.current) setTabs(restoreTabs(saved)); + }); import("@tauri-apps/api/app") .then(({ getVersion }) => getVersion()) .then(setAppVersion) @@ -175,8 +197,19 @@ function App() { setErrors(({ [tool.id]: _drop, ...rest }) => rest); setNotes(({ [tool.id]: _drop, ...rest }) => rest); try { - await downloadAndRunInstaller(tool.id, release.assetUrl, release.assetName); - setNotes((n) => ({ ...n, [tool.id]: t("installerStarted") })); + const outcome = await downloadAndRunInstaller( + tool.id, + release.assetUrl, + release.assetName, + tool.name, + ); + // Op Linux is de tool direct geïnstalleerd (geen setup-UI): andere + // melding, en meteen opnieuw scannen zodat de status verspringt. + setNotes((n) => ({ + ...n, + [tool.id]: t(outcome.mode === "installed" ? "installedDone" : "installerStarted"), + })); + if (outcome.mode === "installed") void scanInstalled(); } catch (e) { setErrors((er) => ({ ...er, @@ -187,7 +220,7 @@ function App() { setProgress(({ [tool.id]: _drop, ...rest }) => rest); } }, - [releases, t], + [releases, t, scanInstalled], ); const handleLaunch = useCallback( @@ -203,6 +236,35 @@ function App() { [installed], ); + // ── Tabbladen ── + const updateTabs = useCallback((next: TabState) => { + tabsTouchedRef.current = true; + setTabs(next); + void setSetting(TABS_KEY, next); + }, []); + + const handleOpenTab = useCallback( + (tool: CatalogTool) => { + if (!tool.webUrl) return; + setTabs((prev) => { + const next = openTab(prev, tool.id); + tabsTouchedRef.current = true; + void setSetting(TABS_KEY, next); + return next; + }); + }, + [], + ); + + const handleCloseTab = useCallback((id: string) => { + setTabs((prev) => { + const next = closeTab(prev, id); + tabsTouchedRef.current = true; + void setSetting(TABS_KEY, next); + return next; + }); + }, []); + // Zoeken over naam, omschrijving en categorie. const lang = i18n.language?.startsWith("en") ? "en" : "nl"; const matches = useCallback( @@ -247,8 +309,18 @@ function App() { setErrors(({ [SELF_ID]: _drop, ...rest }) => rest); setNotes(({ [SELF_ID]: _drop, ...rest }) => rest); try { - await downloadAndRunInstaller(SELF_ID, selfRelease.assetUrl, selfRelease.assetName); - setNotes((n) => ({ ...n, [SELF_ID]: t("selfUpdate.started") })); + const outcome = await downloadAndRunInstaller( + SELF_ID, + selfRelease.assetUrl, + selfRelease.assetName, + ); + // Op Linux is de eigen AppImage al vervangen — alleen nog herstarten. + setNotes((n) => ({ + ...n, + [SELF_ID]: t( + outcome.mode === "selfReplaced" ? "selfUpdate.restartToApply" : "selfUpdate.started", + ), + })); } catch (e) { setErrors((er) => ({ ...er, [SELF_ID]: `${t("errors.installFailed")}: ${String(e)}` })); } finally { @@ -284,6 +356,7 @@ function App() { note={notes[tool.id]} onInstall={handleInstall} onLaunch={handleLaunch} + onOpenTab={handleOpenTab} /> ); @@ -291,6 +364,14 @@ function App() { <> setSettingsOpen(true)} /> + updateTabs({ ...tabs, active: id })} + onClose={handleCloseTab} + /> + + - {t("actions.downloading")} {pct !== null ? `${pct}%` : ""} + {progress?.done + ? t("actions.starting") + : `${t("actions.downloading")} ${pct !== null ? `${pct}%` : ""}`} )} @@ -204,8 +216,8 @@ export default function ToolCard({
{tool.kind === "web" ? ( - ) : ( <> @@ -246,12 +258,42 @@ export default function ToolCard({ )} - {tool.kind === "desktop" && (tool.webUrl || tool.siteUrl) && ( + {/* Desktop-tools: de webversie in een tabblad. Bij web-tools doet de + primaire knop dit al, dus daar zou het icoon dubbelop zijn. */} + {tool.kind === "desktop" && tool.webUrl && ( + + )} + {tool.webUrl && ( + + )} + {tool.kind === "desktop" && !tool.webUrl && tool.siteUrl && ( + {url} + {loading && {t("tabs.loading")}} + +
+ +