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/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/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-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/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-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/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")}} + +
+ +