diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0eb3601 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +src/test/resources/study/viewer-authoritative-v1.json text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43c722d..afa3ff6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [windows-latest, macos-latest] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -48,6 +48,15 @@ jobs: - name: Frontend tests run: pnpm --dir frontend test + - name: Desktop dependency lock + run: pnpm --dir desktop install --frozen-lockfile + + - name: Desktop boundaries and release receipt tests + run: pnpm --dir desktop test + + - name: Desktop syntax + run: pnpm --dir desktop check + - name: Test if: runner.os != 'Windows' run: ./gradlew --no-daemon test diff --git a/.gitignore b/.gitignore index 9740afb..082f783 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,7 @@ Thumbs.db *.dzi # Runtime and packaging -runtime/ +/runtime/ dist/ installers/ *.dmg @@ -55,3 +55,6 @@ installers/ # Local evidence that may contain sensitive names docs/evidence/local/ + +# Generated bundled Java service +/desktop/resources/service/ diff --git a/AGENTS.md b/AGENTS.md index d25b822..d515f1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Purpose -PathLab Forge is a standalone Windows/macOS desktop application. It performs computationally expensive WSI work locally and connects to PathLab Viewer through a versioned API, resumable tus upload and the `.plslide` package. +PathLab Forge is a standalone Windows/macOS desktop application. It performs computationally expensive WSI work locally and connects to PathLab Viewer through a versioned desktop API, offset-aware transfer and verified OME artifacts; Teaching delivery explicitly uses static DZI packages. The separate PathLab Viewer repository owns the server library, storage accounting, package import, private preview, publication grants, folder/collection sharing, Trash, annotations and static delivery. @@ -17,11 +17,11 @@ For every Codex run: 5. `docs/integration/CURRENT_VIEWER_BASELINE.md` when a task depends on Viewer internals 6. `docs/compatibility/OS_MATRIX.md` when platform behavior is involved -Do not read or implement every future milestone. +The approved release plan covers all non-AI milestones. Follow its dependency order and assigned worktree ownership. ## Current Viewer integration rules -- New desktop APIs belong under `/api/v2/desktop`; tus remains `/api/v1/uploads/`. +- Reuse the existing offset-aware desktop upload. New desktop contracts belong under `/api/v2/desktop`; retain reviewed compatibility routes where required. - Forge local states do not mirror Viewer states one-to-one. - Viewer’s MVP prepared flow reuses `uploading → queued → validating → converting → ready_private` and distinguishes the path through `ingest_mode`. - Forge uses local `SERVER_PROCESSING` while polling the exact server state separately. @@ -52,16 +52,16 @@ PathLab Forge does not own: - public tile delivery; - Viewer library search, collections, sharing or Trash; - Viewer annotations; -- student/teacher workflows; +- Viewer course authority and learner records; Forge owns offline Study Pack authoring. - AI diagnosis; - fluorescence analysis; -- Z-stack or time-series navigation; +- Unsupported channel, Z-stack or time-series claims; qualified reader views are included. - PACS/DICOM integration; - automatic public publication by default. ## Working rules -- Work on exactly one active-plan task. +- Work on the assigned focused batch within the approved complete release plan. - Create a focused `codex/` branch. - Use test-driven development for behavior changes. - Prefer small interfaces and adapters over direct dependency coupling. @@ -73,7 +73,7 @@ PathLab Forge does not own: - Preserve completed local packages across upload failures. - Never reconvert solely because upload failed. - Do not invent a Viewer API, schema, thumbnail policy or state transition that conflicts with the reviewed baseline. -- Commit the completed task, return the required report, and stop. +- Return commits, focused/full checks and evidence; continue coordinated work until the approved plan is handled. ## Resource defaults diff --git a/CODEX_START_HERE.md b/CODEX_START_HERE.md index 7acb238..eef0bf3 100644 --- a/CODEX_START_HERE.md +++ b/CODEX_START_HERE.md @@ -1,35 +1,13 @@ -# Codex Start Here — PathLab Forge +# Start here: PathLab Forge desktop release -The private repository already exists: +Read `docs/plans/active/current.md` and `docs/plans/active/RELEASE_PROGRESS.md` first. The approved work is the complete non-AI Windows/macOS release, not the obsolete F1.1 foundation task. -```text -Enksodsoon/PathLab-Forge -``` +Use Java 17, React, SQLite and the existing streaming pipeline. Electron owns the desktop window and service lifecycle. Normal delivery retains verified OME; Teaching delivery explicitly produces independently identified static DZI. Viewer remains authoritative for identity, permissions, privacy, publication and learner records. -The integration plan has been re-based on the current PathLab Viewer library v2 architecture. +Build in isolated `codex/` worktrees, with one integration owner and at most three active workers. Integrate focused commits and run checks on the combined head. Preserve user data and historical evidence. Never treat unverified runtime rights, signing, actual-platform testing, deployment or production verification as complete. -## First Codex prompt +Development: `gradlew test` runs Java tests and the production interface build. Run frontend tests separately with `pnpm test` in `frontend`, desktop checks with `pnpm test` and `pnpm check` in `desktop`, and repository policy with `scripts/verify-repo.ps1`. Use JDK 17 explicitly on machines whose default Java differs. -Paste only this: +`gradlew run --args=--serve` retains the browser/headless development mode. The packaged Electron app uses `--desktop`, bundled resources, private readiness and one-time local authorization. Development runtime discovery does not qualify a clean-machine release. -```text -Open Enksodsoon/PathLab-Forge. - -Read AGENTS.md and docs/plans/active/current.md. Work on Task F1.1 only: Java 17 build foundation and batch domain state machine. - -The active task is intentionally independent from PathLab Viewer implementation. Do not add QuPath, Bio-Formats, OpenSlide, libvips, JavaFX, SQLite, package schemas, server upload, WSI conversion, installers or later tasks. - -Use test-driven development exactly as specified: -1. create the failing tests; -2. run them and confirm the expected failure; -3. implement the smallest complete solution; -4. run focused and full checks; -5. review scope and naming; -6. commit Task F1.1; -7. return the required TASK RESULT report; -8. stop. -``` - -## Why the first task remains small - -PathLab Viewer now has folders, collections, storage accounting, cached thumbnails, publication grants, sharing and annotations. Those are server-owned systems. The first Forge task builds only a stable local batch state model, allowing later conversion and upload adapters to integrate without coupling the core to Viewer internals. +Record acceptance receipts and remaining blockers in the progress register. No paid service, certificate purchase or cloud spend is automatic. diff --git a/LICENSE_STATUS.md b/LICENSE_STATUS.md index c65cb7d..f021c02 100644 --- a/LICENSE_STATUS.md +++ b/LICENSE_STATUS.md @@ -1,6 +1,6 @@ # License Status -No final distribution license is selected yet. +The approved target is GPL-3.0-or-later, subject to exact dependency and corresponding-source review. No final redistribution approval is granted yet. The application is intended to use open-source components that may impose redistribution or copyleft requirements. Before publishing source or installers, complete `docs/licensing/DEPENDENCY_REVIEW.md` and decide the compatible application license. diff --git a/README.md b/README.md index 85546b7..314a611 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PathLab Forge -**PathLab Forge** is the local desktop companion for PathLab Viewer. +**PathLab Forge** is the local desktop companion for PathLab Viewer. The active non-AI Windows/macOS release is in development; see `docs/plans/active/current.md` and `docs/plans/active/RELEASE_PROGRESS.md` for current implementation and qualification. It opens supported whole-slide image datasets on Windows and macOS, lets the user inspect the correct image series, crop or downsample, renders standardized 8-bit RGB through a temporary pyramidal OME-TIFF, generates a compact PathLab-compatible DZI, processes slides in batch, and uploads prepared packages resumably. @@ -9,10 +9,10 @@ PathLab Forge is deliberately separate from `PathLab-Viewer`. ```text PathLab Forge desktop -> local WSI read / view / crop / convert - -> temporary local OME-TIFF staging - -> verified .plslide with compact DZI + thumbnail - -> staging OME and loose DZI removed - -> HTTPS + tus + -> retained verified OME-TIFF artifact (normal delivery) + -> explicit Teaching delivery: separate verified DZI package + thumbnail + -> original sources and completed artifacts preserved + -> HTTPS + existing offset-aware desktop transfer PathLab Viewer server -> safe validation / import -> existing library folder or Unfiled @@ -33,7 +33,7 @@ The latest Viewer is a folder-aware library rather than a flat upload list. Forg Forge does not replace any of those systems. -## Run the viewer-first shell +## Development browser mode (desktop release uses Electron) The desktop shell binds only to a random `127.0.0.1` port, opens a one-time launch URL, and establishes an HttpOnly, diff --git a/build.gradle.kts b/build.gradle.kts index ef65209..bd945d9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,15 +1,22 @@ +import groovy.json.JsonOutput +import groovy.json.JsonSlurper import org.gradle.api.tasks.compile.JavaCompile import org.gradle.api.tasks.testing.Test import org.gradle.jvm.tasks.Jar +import java.net.URI import java.nio.file.Files import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.security.KeyFactory +import java.security.spec.X509EncodedKeySpec +import java.util.Base64 plugins { application } group = "org.pathlab" -version = "0.1.0-SNAPSHOT" +version = "1.0.0-rc.1" repositories { mavenCentral() @@ -25,16 +32,19 @@ dependencies { implementation("net.java.dev.jna:jna:5.18.1") implementation("com.fasterxml.jackson.core:jackson-databind:2.22.1") implementation("org.xerial:sqlite-jdbc:3.50.3.0") + implementation("org.apache.poi:poi:5.5.1") testImplementation(platform("org.junit:junit-bom:5.14.4")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } +val viewerOrigin = providers.gradleProperty("pathlab.forge.viewer.defaultOrigin") + .orElse(providers.environmentVariable("PATHLAB_FORGE_VIEWER_DEFAULT_ORIGIN")) +val featureCatalogPublicKey = providers.gradleProperty("pathlab.forge.featureCatalogPublicKey") + application { mainClass = "org.pathlab.forge.ForgeApp" - val viewerOrigin = providers.gradleProperty("pathlab.forge.viewer.defaultOrigin") - .orElse(providers.environmentVariable("PATHLAB_FORGE_VIEWER_DEFAULT_ORIGIN")) - applicationDefaultJvmArgs = listOf("-Xmx512m") + viewerOrigin.orNull + applicationDefaultJvmArgs = listOf("-Xmx512m", "--enable-native-access=ALL-UNNAMED") + viewerOrigin.orNull ?.let { listOf("-Dpathlab.forge.viewer.defaultOrigin=$it") } .orEmpty() } @@ -42,20 +52,32 @@ application { tasks.register("productionDist") { group = "distribution" description = "Builds a release distribution with an explicit official Viewer origin." - dependsOn(tasks.installDist) - doFirst { - val configured = providers.gradleProperty("pathlab.forge.viewer.defaultOrigin") - .orElse(providers.environmentVariable("PATHLAB_FORGE_VIEWER_DEFAULT_ORIGIN")) - .orNull - require(!configured.isNullOrBlank()) { - "Production packaging requires pathlab.forge.viewer.defaultOrigin" - } - require(configured.startsWith("https://")) { - "Production Viewer origin must use HTTPS" - } - } + dependsOn("verifyReaderRuntimeBundle", tasks.installDist) } +tasks.register("verifyReaderRuntimeBundle") { + group = "verification" + description = "Portable fail-closed licensed reader and architecture verification." + dependsOn(tasks.classes) + classpath = sourceSets.main.get().runtimeClasspath + mainClass = "org.pathlab.forge.runtime.ReaderRuntimeVerifier" + val runtimeRoot = providers.gradleProperty("pathlab.forge.readerRuntimeRoot").orElse("PENDING_REVIEW") + args(runtimeRoot.get(), layout.projectDirectory.file("reader-runtime.lock.properties").asFile.absolutePath) + inputs.files("reader-runtime.lock.properties") +} + +val hostWindows = System.getProperty("os.name").startsWith("Windows", ignoreCase = true) +val hostMac = System.getProperty("os.name").startsWith("Mac", ignoreCase = true) +val executableSuffix = if (hostWindows) ".exe" else "" +val hostArchitecture = when (System.getProperty("os.arch")) { + "amd64", "x86_64" -> "x86_64" + "aarch64", "arm64" -> "arm64" + else -> "unsupported" +} +val readerPlatform = "${if (hostWindows) "windows" else if (hostMac) "macos" else "unsupported"}-$hostArchitecture" +val packagedApp = if (hostMac) "PathLab Forge.app/Contents/Resources" else "PathLab Forge" +val packagedRuntime = if (hostMac) "PathLab Forge.app/Contents/runtime/Contents/Home" else "PathLab Forge/runtime" + val pnpmCommand = if (System.getProperty("os.name").startsWith("Windows", ignoreCase = true)) "pnpm.cmd" else "pnpm" val codexNodeBin = file( "${System.getProperty("user.home")}/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin") @@ -104,8 +126,13 @@ tasks.withType().configureEach { tasks.withType().configureEach { useJUnitPlatform() + testLogging { + events("failed") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } maxParallelForks = 1 systemProperty("file.encoding", "UTF-8") + systemProperty("java.io.tmpdir", file(System.getProperty("java.io.tmpdir")).canonicalPath) systemProperty("user.language", "en") systemProperty("user.country", "US") systemProperty("user.timezone", "UTC") @@ -125,6 +152,15 @@ tasks.withType().configureEach { "pathlab.forge.test.fullDzi", "pathlab.forge.test.heSource", "pathlab.forge.test.runtimeRoot", + "pathlab.forge.test.mds", + "pathlab.forge.test.sdpc", + "pathlab.forge.sdpcRuntime", + "pathlab.forge.test.isyntax", + "pathlab.forge.isyntaxRuntime", + "pathlab.forge.isyntaxPython", + "pathlab.forge.test.svsRoots", + "pathlab.forge.test.svsReport", + "pathlab.forge.test.omeMetadataTarget", ).forEach { name -> System.getProperty(name)?.let { value -> systemProperty(name, value) } } @@ -132,6 +168,7 @@ tasks.withType().configureEach { } tasks.withType().configureEach { + manifest.attributes["Implementation-Version"] = project.version.toString() isPreserveFileTimestamps = false isReproducibleFileOrder = true } @@ -162,3 +199,274 @@ val installVersionedRuntime = tasks.register("installVersionedRuntime") { logger.lifecycle("Installed PathLab Forge runtime ${runtimeVersion.get()} at ${destinationDir}") } } + +val internalReaderRoot = layout.buildDirectory.dir("internal-reader-dist") + +val cleanInternalReaderDist = tasks.register("cleanInternalReaderDist") { + delete(internalReaderRoot) +} + +val stageInternalReaderApp = tasks.register("stageInternalReaderApp") { + group = "distribution" + description = "Builds a host-native application image with its own Java 17 runtime." + dependsOn(cleanInternalReaderDist, tasks.installDist) + val jpackage = file("${System.getProperty("java.home")}/bin/jpackage$executableSuffix") + val input = layout.buildDirectory.dir("install/${project.name}/lib").get().asFile + inputs.dir(input) + doNotTrackState("jpackage requires its application-image destination not to exist") + commandLine( + jpackage, + "--type", "app-image", + "--input", input, + "--dest", internalReaderRoot.get().asFile, + "--name", "PathLab Forge", + "--main-jar", tasks.jar.get().archiveFileName.get(), + "--main-class", "org.pathlab.forge.ForgeApp", + "--add-modules", "ALL-MODULE-PATH", + "--java-options", "-Xmx512m", + "--java-options", "--enable-native-access=ALL-UNNAMED", + *if (hostWindows) arrayOf("--win-console") else emptyArray()) +} + +val stageInternalReaderChildJvm = tasks.register("stageInternalReaderChildJvm") { + group = "distribution" + description = "Adds the matching Java launcher required by the contained Bio-Formats child process." + dependsOn(stageInternalReaderApp) + from(file("${System.getProperty("java.home")}/bin/java$executableSuffix")) + into(internalReaderRoot.map { it.dir("$packagedRuntime/bin") }) +} + +val assembleInternalReaderRuntime = tasks.register("assembleInternalReaderRuntime") { + group = "distribution" + description = "Copies an owner-supplied reader runtime into an internal validation package." + dependsOn(stageInternalReaderChildJvm, tasks.classes) + classpath = sourceSets.main.get().runtimeClasspath + mainClass = "org.pathlab.forge.runtime.ReaderRuntimeAssembler" + val source = providers.gradleProperty("pathlab.forge.readerRuntimeRoot") + inputs.dir(source) + outputs.dir(internalReaderRoot.map { it.dir("$packagedApp/reader-data/runtime") }) + args(source.get(), internalReaderRoot.get().dir("$packagedApp/reader-data").asFile.absolutePath, + "INTERNAL", readerPlatform) +} + +tasks.register("internalReaderDist") { + group = "distribution" + description = "Builds a NON_REDISTRIBUTABLE host-native package with owner-supplied readers." + dependsOn(assembleInternalReaderRuntime) + outputs.dir(internalReaderRoot) +} + +val productionReaderRoot = layout.buildDirectory.dir("production-reader-dist") + +val cleanProductionReaderDist = tasks.register("cleanProductionReaderDist") { + delete(productionReaderRoot) +} + +val stageProductionReaderApp = tasks.register("stageProductionReaderApp") { + group = "distribution" + description = "Builds the approved host-native application image with its own Java 17 runtime." + dependsOn(cleanProductionReaderDist, tasks.installDist, "verifyReaderRuntimeBundle") + val jpackage = file("${System.getProperty("java.home")}/bin/jpackage$executableSuffix") + val input = layout.buildDirectory.dir("install/${project.name}/lib").get().asFile + val officialOrigin = viewerOrigin.orNull + require(!officialOrigin.isNullOrBlank()) { + "Production packaging requires pathlab.forge.viewer.defaultOrigin" + } + require(officialOrigin.startsWith("https://")) { + "Production Viewer origin must use HTTPS" + } + inputs.dir(input) + doNotTrackState("jpackage requires its application-image destination not to exist") + commandLine( + jpackage, + "--type", "app-image", + "--input", input, + "--dest", productionReaderRoot.get().asFile, + "--name", "PathLab Forge", + "--main-jar", tasks.jar.get().archiveFileName.get(), + "--main-class", "org.pathlab.forge.ForgeApp", + "--add-modules", "ALL-MODULE-PATH", + "--java-options", "-Xmx512m", + "--java-options", "--enable-native-access=ALL-UNNAMED", + "--java-options", "-Dpathlab.forge.viewer.defaultOrigin=$officialOrigin", + "--java-options", "-Dpathlab.forge.runtime.requireProduction=true") +} + +val stageProductionReaderChildJvm = tasks.register("stageProductionReaderChildJvm") { + group = "distribution" + description = "Adds the matching Java launcher for the approved Bio-Formats child process." + dependsOn(stageProductionReaderApp) + from(file("${System.getProperty("java.home")}/bin/java$executableSuffix")) + into(productionReaderRoot.map { it.dir("$packagedRuntime/bin") }) +} + +val assembleProductionReaderRuntime = tasks.register("assembleProductionReaderRuntime") { + group = "distribution" + description = "Assembles an approved, hash-locked production reader runtime." + dependsOn(stageProductionReaderChildJvm, tasks.classes) + classpath = sourceSets.main.get().runtimeClasspath + mainClass = "org.pathlab.forge.runtime.ReaderRuntimeAssembler" + val source = providers.gradleProperty("pathlab.forge.readerRuntimeRoot") + inputs.dir(source) + outputs.dir(productionReaderRoot.map { it.dir("$packagedApp/reader-data/runtime") }) + args(source.get(), productionReaderRoot.get().dir("$packagedApp/reader-data").asFile.absolutePath, + "PRODUCTION", readerPlatform) +} + +tasks.named("productionDist") { + setDependsOn(listOf(assembleProductionReaderRuntime)) + outputs.dir(productionReaderRoot) +} + +// The Electron launcher uses this self-contained service; no system Java lookup. +tasks.register("stageElectronService") { + group = "distribution" + dependsOn(tasks.installDist) + val servicePlatform = readerPlatform + val serviceWindows = hostWindows + val serviceArchitecture = hostArchitecture + val serviceOrigin = viewerOrigin.orNull + val serviceChannel = providers.gradleProperty("pathlab.forge.distributionChannel").orNull + val serviceVersion = project.version.toString() + val desktopMetadata = layout.projectDirectory.file("desktop/package.json").asFile + inputs.property("distributionChannel", serviceChannel ?: "MISSING") + inputs.property("viewerOrigin", serviceOrigin ?: "MISSING") + inputs.property("featureCatalogPublicKey", featureCatalogPublicKey.orNull ?: "MISSING") + inputs.property("serviceVersion", serviceVersion) + inputs.property("servicePlatform", servicePlatform) + if (serviceChannel == "PRODUCTION") dependsOn("verifyReaderRuntimeBundle") + into(layout.projectDirectory.dir("desktop/resources/service")) + from(layout.buildDirectory.dir("install/${project.name}/lib")) { into("lib") } + from(System.getProperty("java.home")) { into("runtime") } + doFirst { + require(servicePlatform in listOf("windows-x86_64", "macos-x86_64", "macos-arm64")) { + "Unsupported packaged service platform: $servicePlatform" + } + require(JavaVersion.current() == JavaVersion.VERSION_17) { "Package service with Java 17" } + val desktopVersion = (JsonSlurper().parse(desktopMetadata) as Map<*, *>)["version"] + require(serviceVersion == desktopVersion) { "Java and Electron release versions must match" } + require(serviceChannel in listOf("INTERNAL", "PRODUCTION")) { + "Explicit pathlab.forge.distributionChannel=INTERNAL or PRODUCTION is required" + } + if (serviceChannel == "PRODUCTION") { + require(!serviceOrigin.isNullOrBlank()) { "Production requires an explicit official Viewer origin" } + val encoded = featureCatalogPublicKey.orNull + require(!encoded.isNullOrBlank()) { "Production requires an approved feature catalog public key" } + KeyFactory.getInstance("Ed25519").generatePublic(X509EncodedKeySpec(Base64.getDecoder().decode(encoded))) + } + } + doLast { + val manifest = mutableMapOf( + "version" to serviceVersion, + "platform" to if (serviceWindows) "win32" else "darwin", + "arch" to if (serviceArchitecture == "arm64") "arm64" else "x64", + "internalValidation" to (serviceChannel == "INTERNAL"), + "distribution" to if (serviceChannel == "INTERNAL") "INTERNAL_NON_REDISTRIBUTABLE" else "PRODUCTION", + "requireProductionRuntime" to (serviceChannel == "PRODUCTION")) + serviceOrigin?.let { origin -> + val uri = URI(origin) + require(uri.scheme == "https" && uri.host != null && uri.userInfo == null) { + "Packaged Viewer origin requires an HTTPS host without credentials" + } + manifest["viewerOrigin"] = origin + } + featureCatalogPublicKey.orNull?.let { manifest["featureCatalogPublicKey"] = it } + destinationDir.resolve("runtime-manifest.json").writeText(JsonOutput.toJson(manifest) + "\n") + } +} + +tasks.register("distributionDependencies") { + group = "distribution" + description = "Inventories exact resolved Java artifacts and their embedded legal text; no license approval." + dependsOn(tasks.installDist) + doLast { + val output = layout.buildDirectory.dir("distribution-inputs").get().asFile + output.mkdirs() + val notices = output.resolve("java-notices") + notices.mkdirs() + val records = configurations.runtimeClasspath.get().resolvedConfiguration.resolvedArtifacts + .sortedBy { it.moduleVersion.id.toString() }.map { artifact -> + val digest = MessageDigest.getInstance("SHA-256").digest(artifact.file.readBytes()) + .joinToString("") { "%02x".format(it) } + val destination = notices.resolve(digest) + copy { + from(zipTree(artifact.file)) + include("**/LICENSE*", "**/NOTICE*", "**/COPYING*", "**/license*", "**/notice*") + into(destination) + } + mapOf("coordinate" to artifact.moduleVersion.id.toString(), "file" to artifact.file.name, + "sha256" to digest, "decision" to "PENDING_REVIEW", + "legalFiles" to destination.walkTopDown().filter { it.isFile }.map { it.relativeTo(output).invariantSeparatorsPath }.toList()) + } + output.resolve("java-dependencies.json").writeText(JsonOutput.prettyPrint(JsonOutput.toJson(records)) + "\n") + } +} + +val electronReaderSource = layout.buildDirectory.dir("electron-reader-source") +val electronService = layout.projectDirectory.dir("desktop/resources/service") +val electronChannel = providers.gradleProperty("pathlab.forge.distributionChannel").orElse("MISSING") +val electronOwnerReaders = providers.gradleProperty("pathlab.forge.readerRuntimeRoot").orElse("PENDING_REVIEW") +val electronOwnerReaderDirectory = file(electronOwnerReaders.get()) + +val stageElectronReaderSource = tasks.register("stageElectronReaderSource") { + group = "distribution" + description = "Copies the reviewed owner runtime into managed build staging; never signs owner source files." + if (electronChannel.get() == "PRODUCTION") dependsOn("verifyReaderRuntimeBundle") + from(electronOwnerReaders) + into(electronReaderSource) + onlyIf { electronOwnerReaderDirectory.isDirectory } +} + +val signElectronReaderPayload = tasks.register("signElectronReaderPayload") { + group = "distribution" + description = "Signs reader native payload before its manifest/fingerprint/current pointer are assembled." + dependsOn(stageElectronReaderSource) + onlyIf { electronChannel.get() == "PRODUCTION" } + environment("PATH", frontendPath) + commandLine("node", "scripts/sign-distribution-service.cjs", electronReaderSource.get().asFile, + "build/distribution-inputs/reader-payload-signature.json", "READERS") +} + +val cleanElectronReaders = tasks.register("cleanElectronReaders") { + dependsOn("stageElectronService") + delete(electronService.dir("reader-data"), electronService.file("reader-runtime-manifest.json"), + electronService.file("NON_REDISTRIBUTABLE")) +} + +val assembleElectronReaders = tasks.register("assembleElectronReaders") { + group = "distribution" + description = "Installs signed readers and creates usable matching manifest/fingerprint/pointer in Electron service." + dependsOn(cleanElectronReaders, signElectronReaderPayload, tasks.classes) + onlyIf { electronOwnerReaderDirectory.isDirectory } + classpath = sourceSets.main.get().runtimeClasspath + mainClass = "org.pathlab.forge.runtime.ReaderRuntimeAssembler" + args(electronReaderSource.get().asFile.absolutePath, electronService.dir("reader-data").asFile.absolutePath, + electronChannel.get(), readerPlatform) +} + +tasks.register("verifyElectronReaders") { + group = "verification" + dependsOn(assembleElectronReaders) + onlyIf { electronOwnerReaderDirectory.isDirectory || electronChannel.get() == "PRODUCTION" } + classpath = sourceSets.main.get().runtimeClasspath + mainClass = "org.pathlab.forge.runtime.PackagedReaderRuntimeVerifier" + args(electronService.asFile.absolutePath, electronChannel.get()) +} + +tasks.register("signElectronService") { + group = "distribution" + description = "Signs the native production service before final byte inventory; trusted identities required." + dependsOn("verifyElectronReaders") + environment("PATH", frontendPath) + commandLine("node", "scripts/sign-distribution-service.cjs", "desktop/resources/service", "build/distribution-inputs/service-signature.json") +} + +tasks.register("distributionInventory") { + group = "distribution" + description = "Creates clean-commit source and staged-byte receipts, explicitly NON_REDISTRIBUTABLE." + dependsOn("verifyElectronReaders", "distributionDependencies") + if (providers.gradleProperty("pathlab.forge.distributionChannel").orNull == "PRODUCTION") dependsOn("signElectronService") + environment("PATH", frontendPath) + commandLine("node", "scripts/distribution.cjs", "inventory", "build/distribution-inputs", + "desktop/resources/service", "${if (hostWindows) "win32" else "darwin"}-${if (hostArchitecture == "arm64") "arm64" else "x64"}") +} diff --git a/contracts/README.md b/contracts/README.md index bdf1a43..3e7cfef 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,27 +1,18 @@ -# Prepared-Slide Contract +# Forge / Viewer release contracts -Do not invent or privately fork the final JSON Schema before the public Viewer contract exists. +The approved non-AI release brief is `docs/plans/active/current.md`. Normal delivery is negotiated direct OME (`ome-dynamic-v1`), with structured private results transferred separately. Explicit Teaching delivery uses the existing prepared static-DZI ingest contract. These artifact identities must remain separate. -The canonical server acceptance contract will be: +Reuse the existing Viewer acceptance implementations and exact fixtures. Do not privately invent a replacement schema or infer support from a filename. Changes require matching producer/consumer checks and a recorded Viewer commit. -```text -PathLab-Viewer/contracts/prepared-slide-v1.schema.json -``` +Current Forge contract checks and fixtures: -After Viewer V1 is merged: +- `src/test/java/org/pathlab/forge/viewer/ViewerCapabilitiesTest.java`: negotiated formats, profiles, limits and ingest-creation idempotency. +- `src/test/resources/viewer-sync-v1`: snapshot, change, mutation, conflict and retained-content fixtures. +- `src/test/resources/study/viewer-v1.json` and `viewer-v1.checksum`: `pathlab.study-pack/1` canonical content and checksum. +- `src/test/resources/study/viewer-authoritative-v1.json`: byte-identical to the committed Viewer `tests/fixtures/study/viewer-v1.json` at scoped compatibility commit `a55cf3a0`, SHA-256 `c39c96744302120d336239ad839a506028e6df85513fba699f570d3d3c1e722f`. Explicit LF checkout preserves those source bytes on Windows. Java validates its exact canonical checksum alongside the additional manual/spatial fixture. +- `src/test/resources/study/canonical-numbers.json` and `canonical-numbers.txt`: cross-language numeric canonicalization. +- `PrivateResultsBundleBuilderTest`: immutable original run/ROI/review identity, geometry transforms and exact result hashes. -1. copy the exact schema into this directory; -2. record its schema version and source commit in a small machine-readable lock file; -3. add compatibility tests against the same canonical valid and invalid manifest fixtures; -4. fail the Forge build when its producer model no longer satisfies the pinned schema. +Viewer original default-branch baseline was `fac6476249ed287bb2afe8def1b0bcc9558104d7`; fresh main `81fcc80c5f2de81a7ff47f526aaf8f24742637fb` is integrated into the scoped compatibility branch. Its stable verification source/receipt commit `27c081886eac3d00a06b718fe57ec1aea93c4b4a` passed 1,481 backend tests (111 skips), 578 frontend tests, six browser contract journeys and 492 load checks (12 skips). Final evidence-only head is `57b12a1df5b3ac6889606198da757292799af05f`. The authoritative fixture remains unchanged from `a55cf3a0`. PostgreSQL, protected CI, deployment and authenticated production journeys remain open; the deployed commit is unverified. Publication, private libraries and release admission cannot be declared qualified from these local checks alone. -Package v1 is derivative-only: - -```text -manifest.json -derivative/slide.dzi -derivative/slide_files//_.jpg -derivative/thumbnail.jpg -``` - -The standardized OME-TIFF remains local. Breaking changes create a new schema version; never silently redefine version 1. +The earlier derivative-only package description is historical. It does not change the normal OME release default. Breaking wire changes use a new version; never silently redefine an existing version. diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 0000000..79efdbe --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,2 @@ +/resources/service/ +/out/ diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 0000000..22ef391 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,35 @@ +# Forge desktop + +The Java 17 service owns SQLite, streaming slide work and loopback API. Electron +owns one sandboxed window, native dialogs, menus and the service lifetime. The +renderer has no Node access. Startup secrets travel only over a private stdout +pipe; closing the owner's stdin requests service shutdown. + +Stage the host runtime with `gradlew stageElectronService`, then in this directory +run `pnpm install --frozen-lockfile`, `pnpm test`, `pnpm run check` and `pnpm start`. +`pnpm package` creates the app image and `pnpm make` creates the host installer. +For macOS use `pnpm make --arch=x64` or `--arch=arm64` on the matching host with +the matching staged Java runtime. Packaging rejects a mismatched runtime manifest. +The service resource layout is `resources/service/{lib,runtime}` with +`runtime-manifest.json` declaring Electron `platform`, `arch` and optional +configured HTTPS `viewerOrigin`. Runtime binaries and installers stay untracked. + +The narrow `window.forgeDesktop` bridge returns native selections to the existing +authenticated API client: `selectSources()`, `selectDirectory()`, +`selectExportDestination(name)`, `revealPath(path)`, `openExternal(url)` and +`onCommand(callback)`. Commands are `open-sources` and `open-directory`. +Native selections receive main-secret grants before reaching the renderer. +Reveal accepts only paths selected in a native dialog. External +destinations accept only HTTPS at the packaged Viewer origin or GitHub docs. + +Squirrel.Windows and DMG maker configurations follow the +[Forge maker documentation](https://www.electronforge.io/config/makers). +Signing, notarization, paid certificates and publishing are deliberately absent. +macOS artifacts require actual Intel/Apple Silicon validation before support claims. + +For an internal validation manifest (`internalValidation: true`), run +`node test/packaged-smoke.cjs ` after packaging. +This launches the actual artifact with an isolated temporary library, checks +authenticated API access and renderer sandbox/IPC rejection, captures a screenshot, +and asserts that the Java service exits with the desktop. Internal validation +artifacts are unsigned and NON_REDISTRIBUTABLE; they do not qualify production readers. diff --git a/desktop/forge.config.cjs b/desktop/forge.config.cjs new file mode 100644 index 0000000..b6ebaf1 --- /dev/null +++ b/desktop/forge.config.cjs @@ -0,0 +1,116 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const release = require('./release.cjs'); +const serviceRoot = path.join(__dirname, 'resources/service'); +const manifestFile = path.join(serviceRoot, 'runtime-manifest.json'); +const production = fs.existsSync(manifestFile) && JSON.parse(fs.readFileSync(manifestFile, 'utf8')).distribution === 'PRODUCTION'; +const signatures = production ? release.signing(process.platform) : {}; +const policies = new Map(); +if (signatures.windowsSign) { + signatures.windowsSign.hookModulePath = path.join(__dirname, 'windows-sign-hook.cjs'); +} +module.exports = { + packagerConfig: { + asar: true, + executableName: 'PathLabForge', + appBundleId: 'org.pathlab.forge', + ...signatures, + appVersion: require('./package.json').version, + extendInfo: { LSMinimumSystemVersion: '14.0' }, + extraResource: [path.join(__dirname, 'resources/service')], + afterExtract: [async ({ buildPath, platform, arch }) => { + const { flipFuses, getCurrentFuseWire, FuseState, FuseVersion, FuseV1Options } = await import('@electron/fuses'); + const binary = platform === 'darwin' ? path.join(buildPath, 'Electron.app') : path.join(buildPath, 'electron.exe'); + const values = { + [FuseV1Options.RunAsNode]: false, + [FuseV1Options.EnableCookieEncryption]: false, + [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, + [FuseV1Options.EnableNodeCliInspectArguments]: false, + [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, + [FuseV1Options.OnlyLoadAppFromAsar]: true, + [FuseV1Options.LoadBrowserProcessSpecificV8Snapshot]: false, + [FuseV1Options.GrantFileProtocolExtraPrivileges]: false, + [FuseV1Options.WasmTrapHandlers]: true, + }; + await flipFuses(binary, { version: FuseVersion.V1, strictlyRequireAllFuses: true, + resetAdHocDarwinSignature: platform === 'darwin' && arch === 'arm64', ...values }); + const actual = await getCurrentFuseWire(binary); + for (const [key, value] of Object.entries(values)) if (actual[key] !== (value ? FuseState.ENABLE : FuseState.DISABLE)) throw new Error('Electron fuse verification failed'); + }], + beforeAsar: [async ({ buildPath, platform, arch }) => { + if (release.preflight(serviceRoot, platform, arch) !== production) throw new Error('Distribution channel changed'); + const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8')); + const { execFileSync } = require('node:child_process'); + const repository = path.join(__dirname, '..'); + const policy = { schema: 'pathlab.forge.activation-policy/1', + version: require('./package.json').version, platform, arch, distribution: manifest.distribution, + commit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' }).trim(), + sourceDirty: !!execFileSync('git', ['status', '--porcelain', '--untracked-files=normal'], { cwd: repository, encoding: 'utf8' }).trim(), + manifest, files: release.inventory(serviceRoot) }; + policies.set(`${platform}-${arch}`, policy); + fs.writeFileSync(path.join(buildPath, 'release-policy.json'), JSON.stringify(policy) + '\n'); + }], + afterCopyExtraResources: [async ({ buildPath, platform, arch }) => { + const copied = platform === 'darwin' + ? path.join(buildPath, 'PathLab Forge.app/Contents/Resources/service') + : path.join(buildPath, 'resources/service'); + const policy = policies.get(`${platform}-${arch}`); + if (!policy) throw new Error('Missing pinned release policy'); + release.exactInventory(copied, policy.files); + }], + afterComplete: [async ({ buildPath, platform, arch }) => { + const appRoot = platform === 'darwin' ? path.join(buildPath, 'PathLab Forge.app') : buildPath; + const resources = platform === 'darwin' ? path.join(appRoot, 'Contents/Resources') : path.join(appRoot, 'resources'); + const { extractFile } = await import('@electron/asar'); + const policy = JSON.parse(extractFile(path.join(resources, 'app.asar'), 'release-policy.json').toString('utf8')); + require('./src/activation.cjs').activate(path.join(resources, 'service'), policy, platform, arch, require('./package.json').version); + const { getCurrentFuseWire, FuseV1Options, FuseState } = await import('@electron/fuses'); + const actual = await getCurrentFuseWire(platform === 'darwin' ? appRoot : path.join(appRoot, 'PathLabForge.exe')); + for (const key of [FuseV1Options.RunAsNode, FuseV1Options.EnableNodeOptionsEnvironmentVariable, FuseV1Options.EnableNodeCliInspectArguments]) { + if (actual[key] !== FuseState.DISABLE) throw new Error('Unsafe final Electron fuse'); + } + for (const key of [FuseV1Options.EnableEmbeddedAsarIntegrityValidation, FuseV1Options.OnlyLoadAppFromAsar]) { + if (actual[key] !== FuseState.ENABLE) throw new Error('Missing final Electron integrity fuse'); + } + // Read-only after signing: a changed service invalidates the pinned policy. + const destination = path.join(__dirname, '../build/distribution-inputs'); + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync(path.join(destination, `final-app-${platform}-${arch}.json`), JSON.stringify({ + schema: 'pathlab.forge.final-app-inventory/1', distribution: policy.distribution, + platform, arch, version: policy.version, commit: policy.commit, sourceDirty: policy.sourceDirty, files: release.inventory(appRoot) }) + '\n'); + }], + ignore: [/^\/resources(?:\/|$)/, /^\/test(?:\/|$)/, /^\/out(?:\/|$)/, /^\/release-policy\.json$/], + }, + makers: [ + { name: '@electron-forge/maker-squirrel', platforms: ['win32'], config: { name: 'PathLabForge', + noDelta: true, additionalFiles: [{ src: 'LICENSES.chromium.html', target: 'lib\\net45\\LICENSES.chromium.html' }, { src: 'version', target: 'lib\\net45\\version' }], ...signatures } }, + { name: '@electron-forge/maker-dmg', platforms: ['darwin'], config: { format: 'ULFO' } }, + ], + hooks: { + prePackage: async (_config, platform, arch) => { + if (release.preflight(serviceRoot, platform, arch) !== production) throw new Error('Staged distribution channel changed during packaging'); + }, + postMake: async (_config, results) => { + if (production && process.platform === 'darwin') { + const { execFileSync } = require('node:child_process'); + for (const result of results) for (const artifact of result.artifacts.filter(file => file.endsWith('.dmg'))) { + const submission = execFileSync('xcrun', ['notarytool', 'submit', artifact, '--keychain-profile', process.env.PATHLAB_FORGE_NOTARY_PROFILE, '--wait', '--output-format', 'json'], + { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }); + if (JSON.parse(submission).status !== 'Accepted') throw new Error('DMG notarization was not accepted'); + execFileSync('xcrun', ['stapler', 'staple', artifact], { stdio: ['ignore', 'pipe', 'pipe'] }); + execFileSync('xcrun', ['stapler', 'validate', artifact], { stdio: ['ignore', 'pipe', 'pipe'] }); + } + } + const { withPayload, comparePayload } = require('./installer-payload.cjs'); + for (const result of results) for (const artifact of result.artifacts.filter(file => /\.(exe|dmg)$/.test(file))) { + const file = path.join(__dirname, `../build/distribution-inputs/final-app-${result.platform}-${result.arch}.json`); + const expected = JSON.parse(fs.readFileSync(file, 'utf8')); + await withPayload(artifact, async payload => { + const files = comparePayload(payload, expected.files, result.platform === 'win32'); + fs.writeFileSync(file, JSON.stringify({ ...expected, files, payloadBound: true, artifactSha256: release.sha256(artifact) }) + '\n'); + }, result.platform); + } + return results; + }, + }, +}; diff --git a/desktop/installer-payload.cjs b/desktop/installer-payload.cjs new file mode 100644 index 0000000..4014115 --- /dev/null +++ b/desktop/installer-payload.cjs @@ -0,0 +1,39 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); +const { inventory } = require('./src/activation.cjs'); +function comparePayload(root, expected, allowSquirrel = false) { + const actual = inventory(root); + const extras = actual.filter(file => !expected.some(record => record.path === file.path)); + if (!expected.length || expected.some(record => !actual.some(file => JSON.stringify(file) === JSON.stringify(record))) + || extras.some(file => !allowSquirrel || !['squirrel.exe', 'PathLabForge_ExecutionStub.exe'].includes(file.path))) throw new Error('Installer payload does not match the reviewed application'); + return actual; +} +async function withPayload(artifact, action, platform = process.platform) { + const managed = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-artifact-verify-')); + const originalRoot = fs.realpathSync(managed); + let mounted = false; + const mount = path.join(managed, 'mounted'); + try { + if (platform === 'win32') { + const extracted = path.join(managed, 'extracted'); + execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-File', path.join(__dirname, '../scripts/windows-installer-payload.ps1'), + '-Installer', path.resolve(artifact), '-Destination', extracted], { stdio: ['ignore', 'pipe', 'pipe'] }); + return await action(path.join(extracted, 'package/lib/net45'), path.join(extracted, 'bootstrap')); + } + if (platform !== 'darwin') throw new Error('Unsupported artifact extraction platform'); + fs.mkdirSync(mount); + mounted = true; + execFileSync('hdiutil', ['attach', '-readonly', '-nobrowse', '-noautoopen', '-mountpoint', mount, path.resolve(artifact)], { stdio: ['ignore', 'pipe', 'pipe'] }); + const apps = fs.readdirSync(mount, { withFileTypes: true }).filter(entry => entry.isDirectory() && !entry.isSymbolicLink() && entry.name.endsWith('.app')); + if (apps.length !== 1 || apps[0].name !== 'PathLab Forge.app') throw new Error('DMG must contain exactly the matching Forge app'); + return await action(path.join(mount, apps[0].name), mount); + } finally { + if (mounted) { execFileSync('hdiutil', ['detach', mount], { stdio: ['ignore', 'pipe', 'pipe'] }); mounted = false; } + // Only this newly created, resolved managed root can be removed; never installation/user data. + if (fs.realpathSync(managed) !== originalRoot || path.dirname(originalRoot) !== fs.realpathSync(os.tmpdir())) throw new Error('Extraction cleanup root changed'); + fs.rmSync(originalRoot, { recursive: true, force: true }); + } +} +module.exports = { withPayload, comparePayload }; diff --git a/desktop/native-signing.cjs b/desktop/native-signing.cjs new file mode 100644 index 0000000..13d618f --- /dev/null +++ b/desktop/native-signing.cjs @@ -0,0 +1,81 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const { execFileSync, spawnSync } = require('node:child_process'); +const { inventory, sha256, signing } = require('./release.cjs'); +const run = (command, args) => execFileSync(command, args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }); +function machFiles(root) { + const magic = new Set(['feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'bebafeca', 'cafebabf', 'bfbafeca']); + return inventory(root).filter(file => { + if (!file.sha256 || file.bytes < 4) return false; + const fd = fs.openSync(path.join(root, file.path), 'r'); + const bytes = Buffer.alloc(4); + try { fs.readSync(fd, bytes, 0, 4, 0); } finally { fs.closeSync(fd); } + // Java class files share cafebabe, but are not native fat binaries. + return !/\.class$/.test(file.path) && magic.has(bytes.toString('hex')); + }).map(file => path.resolve(root, file.path)).sort((a, b) => b.split(path.sep).length - a.split(path.sep).length); +} +function verifyWindows(files) { + signing('win32'); + if (!files.length) throw new Error('No native Windows binaries found'); + const program = '$ErrorActionPreference="Stop"; $files=ConvertFrom-Json $env:PATHLAB_VERIFY_FILES; foreach($file in $files) { $s=Get-AuthenticodeSignature -LiteralPath $file; if($s.Status -ne "Valid" -or $s.SignerCertificate.Thumbprint -ne $env:PATHLAB_FORGE_WINDOWS_CERT_SHA1 -or $null -eq $s.TimeStamperCertificate) { throw "Unsigned, untrusted or untimestamped distribution binary" } }'; + execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', program], { + stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PATHLAB_VERIFY_FILES: JSON.stringify(files) } }); +} +function verifyMac(files) { + const options = signing('darwin'); + if (!files.length) throw new Error('No native macOS binaries found'); + for (const file of files) { + run('codesign', ['--verify', '--strict', file]); + const display = spawnSync('codesign', ['--display', '--verbose=4', file], { encoding: 'utf8' }); + if (display.status !== 0 || !display.stderr.includes(`Authority=${options.osxSign.identity}`) + || !/Timestamp=/.test(display.stderr) || !/flags=.*runtime/.test(display.stderr)) throw new Error('Untrusted or unhardened macOS binary'); + } +} +function nativeFiles(root) { + if (process.platform === 'darwin') return machFiles(root); + return inventory(root).filter(file => { + if (!file.sha256 || file.bytes < 64) return false; + const fd = fs.openSync(path.join(root, file.path), 'r'), header = Buffer.alloc(64); + try { + fs.readSync(fd, header, 0, header.length, 0); + if (header.toString('ascii', 0, 2) !== 'MZ') return false; + const offset = header.readUInt32LE(60), signature = Buffer.alloc(4); + if (offset > file.bytes - 4) return false; + fs.readSync(fd, signature, 0, 4, offset); + return signature.equals(Buffer.from('PE\0\0')); + } finally { fs.closeSync(fd); } + }).map(file => path.resolve(root, file.path)); +} +function verifyService(root) { + const files = nativeFiles(root); + if (process.platform === 'win32') verifyWindows(files); + else if (process.platform === 'darwin') verifyMac(files); + else throw new Error('Unsupported native signing host'); +} +async function signService(root) { + const manifest = JSON.parse(fs.readFileSync(path.join(root, 'runtime-manifest.json'), 'utf8')); + if (manifest.distribution !== 'PRODUCTION' || manifest.internalValidation !== false + || manifest.platform !== process.platform || manifest.arch !== process.arch) throw new Error('Sign only native staged production service'); + await signPayload(root); + return { schema: 'pathlab.forge.service-signature/1', target: `${process.platform}-${process.arch}`, + result: 'PASS', inventorySha256: require('node:crypto').createHash('sha256').update(JSON.stringify(inventory(root))).digest('hex') }; +} +async function signPayload(root) { + const options = signing(process.platform); + const files = nativeFiles(root); + if (!files.length) throw new Error('Native payload is empty'); + if (process.platform === 'win32') { + const { sign } = await import(pathToFileURL(require.resolve('@electron/windows-sign')).href); + const unsigned = files.filter(file => { try { verifyWindows([file]); return false; } catch { return true; } }); + if (unsigned.length) await sign({ ...options.windowsSign, files: unsigned }); + } else { + for (const file of files) { + try { verifyMac([file]); continue; } catch { /* Sign once before reader manifest assembly. */ } + run('codesign', ['--force', '--sign', options.osxSign.identity, '--timestamp', '--options', 'runtime', + '--entitlements', path.join(__dirname, 'service-entitlements.plist'), file]); + } + } + verifyService(root); +} +module.exports = { signService, signPayload, verifyService, verifyWindows, verifyMac, nativeFiles, machFiles, run }; diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000..e6bbe41 --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,25 @@ +{ + "name": "pathlab-forge-desktop", + "productName": "PathLab Forge", + "version": "1.0.0-rc.1", + "description": "Local whole-slide preparation desktop", + "author": "PathLab", + "private": true, + "packageManager": "pnpm@11.9.0", + "main": "src/main.cjs", + "scripts": { + "start": "electron-forge start", + "package": "electron-forge package", + "make": "electron-forge make", + "test": "node --test test/*.test.cjs", + "check": "node --check src/main.cjs && node --check src/preload.cjs && node --check src/policy.cjs && node --check src/activation.cjs && node --check release.cjs && node --check native-signing.cjs && node --check forge.config.cjs" + }, + "devDependencies": { + "@electron-forge/cli": "8.0.1", + "@electron-forge/maker-dmg": "8.0.1", + "@electron-forge/maker-squirrel": "8.0.1", + "@electron/fuses": "2.1.3", + "electron": "44.7.0", + "electron-winstaller": "5.4.4" + } +} diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml new file mode 100644 index 0000000..cdcb7bf --- /dev/null +++ b/desktop/pnpm-lock.yaml @@ -0,0 +1,1827 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@electron-forge/cli': + specifier: 8.0.1 + version: 8.0.1 + '@electron-forge/maker-dmg': + specifier: 8.0.1 + version: 8.0.1 + '@electron-forge/maker-squirrel': + specifier: 8.0.1 + version: 8.0.1 + '@electron/fuses': + specifier: 2.1.3 + version: 2.1.3 + electron: + specifier: 44.7.0 + version: 44.7.0 + electron-winstaller: + specifier: 5.4.4 + version: 5.4.4 + +packages: + + '@alcalzone/ansi-tokenize@0.3.1': + resolution: {integrity: sha512-cUD0PT0YX+rImRhfn7BfMRduP/tX/Zqt/N0dHlAyn2RlcJssCb5KsCJkhln61eh9vuJDkH8wKn3L8zJzzWXPFg==} + engines: {node: '>=18'} + + '@electron-forge/cli@8.0.1': + resolution: {integrity: sha512-zbkmgt7vtL8OBY7YDwVizhCfAHJLpmQtA35iOKGWPpLWRZbGADVyIUZz3QZrbc2V81/BkrBerURIBXdd/fnyhQ==} + engines: {node: '>= 22.13.0'} + hasBin: true + + '@electron-forge/core-utils@8.0.1': + resolution: {integrity: sha512-lSPxwPt4Yum64NFzCLmE3E4TKtYWp63y5NguMYmm3VDOxIiGfw7kbcBcEvzHly3avbZDACeZfvpjF8SuKoUcMQ==} + engines: {node: '>= 22.13.0'} + + '@electron-forge/core@8.0.1': + resolution: {integrity: sha512-8K46Qf6bd+VCUv5XZlKT/ippvSzHWRgEDTGc9SNXbf92N1mZ1kNk/qHscImOxpNR06KTXSNVzV4dJtUCbv4bRQ==} + engines: {node: '>= 22.13.0'} + + '@electron-forge/maker-base@8.0.1': + resolution: {integrity: sha512-DNuGxgICA8RisFlq1zfu1jUAPGsB/8DDM0pJd/iWlpMYtgDvJS4jc/QZVfnMDQopWIVlFaiYwsLzPApqkA9j1g==} + engines: {node: '>= 22.13.0'} + + '@electron-forge/maker-dmg@8.0.1': + resolution: {integrity: sha512-/FtNihspOpY+SKzyUClCsSgc+W4BJdHttA+eZh7mMs6DFNjJzO8xgJwOjLuHi1oVahrpRsw8UVRejs1wrk7YBw==} + engines: {node: '>= 22.13.0'} + + '@electron-forge/maker-squirrel@8.0.1': + resolution: {integrity: sha512-V6QrJvvmX2xbITXP1+/bXYe2hkMM7BYnQ8vzVHDOca2P6pn68MZsCSewqm8wiBIBSEeYuRhrztvV8aMlJ5n5Mg==} + engines: {node: '>= 22.13.0'} + + '@electron-forge/multi-logger@8.0.1': + resolution: {integrity: sha512-cgYcETMtlkcYiMC8YcaaW8jFDmVZ6mbNBL0Yi0ekjoOjcBecJS/uy7+9GvM9DWq3Ie5YCfe4+AWuVR3HF56/sQ==} + engines: {node: '>= 22.13.0'} + + '@electron-forge/plugin-base@8.0.1': + resolution: {integrity: sha512-lN0ZQyRmMNC/m/XV+Pc7mb/YnLlmSGzWb46McZrk5/vwDyS6t4ZqyA9LDpKs21chjEoQS7q7PLNDRuyh4Lb6KQ==} + engines: {node: '>= 22.13.0'} + + '@electron-forge/publisher-base@8.0.1': + resolution: {integrity: sha512-b+Rm5/RAyVRYRWzFgtsh2LDjuqYDZAUVnm/3LjvwNOmt2lM/ET0nUENdlGPsvjEf55SwRAlgRiKXxYKApeaT8Q==} + engines: {node: '>= 22.13.0'} + + '@electron-forge/shared-types@8.0.1': + resolution: {integrity: sha512-ErJ5an0HQnJhT3YkwbuogH+9YVi8icM7j2Lg1lkVaLjGa2JzKHCspMQ8rr2dUtPl6MSxhAWo6pAvRR9+b3nbbg==} + engines: {node: '>= 22.13.0'} + + '@electron-forge/tracer@8.0.1': + resolution: {integrity: sha512-vaUAFyD0QHlD90QdzW4hZh3oKCiTiPGmlWxvr3yBEgFFQr2iNw9t8l/dt0UrfbRMHnW9sGaoe6GzPGEpHbvImQ==} + engines: {node: '>= 22.13.0'} + + '@electron-internal/extract-zip@1.0.5': + resolution: {integrity: sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==} + engines: {node: '>=22.12.0'} + + '@electron/asar@3.4.1': + resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} + engines: {node: '>=10.12.0'} + hasBin: true + + '@electron/asar@4.3.1': + resolution: {integrity: sha512-6pI64z/tMSBkUTZLQFYhs2YiB7fS9sKA6gy7/2nH1Ju7GGwT32UPsj4Mxsws5NMmtR6C0zYdXVxEU/rGqJRCJA==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@electron/fuses@2.1.3': + resolution: {integrity: sha512-LoKJUXNiJ4JM8IIrUltSHI+8pkogaGj5wmJx81jE/Wk3g2w1/kfMbTEKNoY5kitGE8hiC12h32R/1SlywFtxXg==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@electron/get@5.1.0': + resolution: {integrity: sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==} + engines: {node: '>=22.12.0'} + + '@electron/notarize@3.1.1': + resolution: {integrity: sha512-uQQSlOiJnqRkTL1wlEBAxe90nVN/Fc/hEmk0bqpKk8nKjV1if/tXLHKUPePtv9Xsx90PtZU8aidx5lAiOpjkQQ==} + engines: {node: '>= 22.12.0'} + + '@electron/osx-sign@2.7.1': + resolution: {integrity: sha512-VZ7Ei2PpqBxPU0Kf1evYSiQKCaluIy6t/DSlNpYZQX0z27KwNNHuJR1bz3Pqj1DIL5EySD8RlH2PRrsrfLEdKg==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@electron/packager@20.3.0': + resolution: {integrity: sha512-3MvgJgy6YJ5ti0oGGBKrWKdYwpTaoRrhranMDgHSQ6i5t56yZV9IRDyoXTuqBp97LiKqnZeAMe2wTcF/9+fP5g==} + engines: {node: '>= 22.12.0'} + hasBin: true + + '@electron/rebuild@4.2.1': + resolution: {integrity: sha512-IS4pG3BysRrCLJ8xTAfvg+IQDbiUoAzdJy2SfjcYKnLDy5DpCdjnPdhS5Zf5UMfrkB24xu0KC+TqcOxg+eG+6w==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@electron/universal@3.0.6': + resolution: {integrity: sha512-MonS1kfkZdSEkLZI0pdR/TCx8ecxwRSFm7sORfwIkDI9UaIbHnk4Mgeqq+Ob9qDQRV8LZ9+hHCmimpA9BRcNxw==} + engines: {node: '>=22.12.0'} + + '@electron/windows-sign@1.2.2': + resolution: {integrity: sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==} + engines: {node: '>=14.14'} + hasBin: true + + '@electron/windows-sign@2.1.0': + resolution: {integrity: sha512-hyRE175pWNxU3R0k7B7Zwm4KpSgK/Ql+7yqUPRfnYc9K06uVPJDLvXH6oXpl/RAKbLu0Y114xuH6BdjDhF/pKA==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@malept/cross-spawn-promise@2.0.0': + resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} + engines: {node: '>= 12.13.0'} + + '@types/appdmg@0.5.5': + resolution: {integrity: sha512-G+n6DgZTZFOteITE30LnWj+HRVIGr7wMlAiLWOO02uJFWVEitaPU9JVXm9wJokkgshBawb2O1OykdcsmkkZfgg==} + + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} + + '@types/node@26.6.4': + resolution: {integrity: sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==} + + '@xmldom/xmldom@0.9.12': + resolution: {integrity: sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==} + engines: {node: '>=14.6'} + + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + engines: {node: '>=12'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + appdmg@0.6.6: + resolution: {integrity: sha512-GRmFKlCG+PWbcYF4LUNonTYmy0GjguDy6Jh9WP8mpd0T6j80XIJyXBiWlD0U+MLNhqV9Nhx49Gl9GpVToulpLg==} + engines: {node: '>=8.5'} + os: [darwin] + hasBin: true + + async@1.5.2: + resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==} + + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base32-encode@1.2.0: + resolution: {integrity: sha512-cHFU8XeRyx0GgmoWi5qHMCVRiqU6J3MHWxVgun7jggCBUpVzm1Ir7M9dYr2whjSNc3tFeXfQ/oZjQu/4u55h9A==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bplist-creator@0.0.8: + resolution: {integrity: sha512-Za9JKzD6fjLC16oX2wsXfc+qBEhJBJB1YPInoAQpMLhDuj5aVOv1baGeIQSq1Fr3OCqzvsoQcSBSwGId/Ja2PA==} + + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + cli-boxes@4.0.1: + resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} + engines: {node: '>=18.20 <19 || >=20.10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-truncate@6.1.1: + resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} + engines: {node: '>=22'} + + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + color-convert@0.5.3: + resolution: {integrity: sha512-RwBeO/B/vZR3dfKL1ye/vx8MHZ40ugzpyfeVG5GsiuGnrlMWe2o8wxBbLCpw9CsxV+wHuzYlCiWnybrIA0ling==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cross-dirname@0.1.0: + resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} + + cross-spawn@6.0.6: + resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} + engines: {node: '>=4.8'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + ds-store@0.1.6: + resolution: {integrity: sha512-kY21M6Lz+76OS3bnCzjdsJSF7LBpLYGCVfavW8TgQD2XkcqIZ86W0y9qUDZu6fp7SIZzqosMDW2zi7zVFfv4hw==} + + electron-installer-dmg@5.0.1: + resolution: {integrity: sha512-qOa1aAQdX57C+vzhDk3549dd/PRlNL4F8y736MTD1a43qptD+PvHY97Bo9gSf+OZ8iUWE7BrYSpk/FgLUe40EA==} + engines: {node: '>= 16'} + hasBin: true + + electron-winstaller@5.4.4: + resolution: {integrity: sha512-j9ETcBGJaXxAY/b6UBpR7LZfjdU4BAO+yvr4ifqHEdyuc3UNCy91PDGkWKY5UQ4coHNYfnwFggrqD6QPeFGAlg==} + engines: {node: '>=8.0.0'} + + electron@44.7.0: + resolution: {integrity: sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw==} + engines: {node: '>= 22.12.0'} + hasBin: true + + encode-utf8@1.0.3: + resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + execa@1.0.0: + resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} + engines: {node: '>=6'} + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + filename-reserved-regex@3.0.0: + resolution: {integrity: sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + filenamify@6.0.0: + resolution: {integrity: sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==} + engines: {node: '>=16'} + + flora-colossus@3.0.2: + resolution: {integrity: sha512-Jk78K/Tzt6saxQPGChlJw69xuFGpWyTSAS8EdU0h/FyXwD2K46yNOXmo6nRHcZ9ooekyBAzMkwmiGNt7wOC5zg==} + engines: {node: '>=22.12.0'} + + fmix@0.1.0: + resolution: {integrity: sha512-Y6hyofImk9JdzU8k5INtTXX1cu8LDlePWDFU5sftm9H+zKCr5SGrVjdhkvsim646cw5zD0nADj8oHyXMZmCZ9w==} + + fs-extra@11.4.1: + resolution: {integrity: sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==} + engines: {node: '>=14.14'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-temp@1.2.1: + resolution: {integrity: sha512-okTwLB7/Qsq82G6iN5zZJFsOfZtx2/pqrA7Hk/9fvy+c+eJS9CvgGXT2uNxwnI14BDY9L/jQPkaBgSvlKfSW9w==} + + fs-xattr@0.3.1: + resolution: {integrity: sha512-UVqkrEW0GfDabw4C3HOrFlxKfx0eeigfRne69FxSBdHIP8Qt5Sq6Pu3RM9KmMlkygtC4pPKkj5CiPO5USnj2GA==} + engines: {node: '>=8.6.0'} + os: ['!win32'] + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + galactus@2.0.2: + resolution: {integrity: sha512-HmKyTFGomdAchz4umx8MwBnrnfFmdpwiTyGA4ZOF7rya2Lmgbc9qate4yweInL+0gUBVImhaz12SBGpW3SY4Yg==} + engines: {node: '>=22.12.0'} + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + generate-object-property@1.2.0: + resolution: {integrity: sha512-TuOwZWgJ2VAMEGJvAyPWvpqxSANF0LDpmyHauMjFYzaACvn+QTT/AZomvPCzVBV7yDN3OmwHQ5OvHaeLKre3JQ==} + + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + + get-stream@4.1.0: + resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} + engines: {node: '>=6'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + image-size@0.7.5: + resolution: {integrity: sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==} + engines: {node: '>=6.9.0'} + hasBin: true + + imul@1.0.1: + resolution: {integrity: sha512-WFAgfwPLAjU66EKt6vRdTlKj4nAgIDQzh29JonLa4Bqtl6D8JrIMvWjCnx7xEjVNmP3U0fM5o8ZObk7d0f62bA==} + engines: {node: '>=0.10.0'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ink@7.1.1: + resolution: {integrity: sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==} + engines: {node: '>=22'} + peerDependencies: + '@types/react': '>=19.2.0' + react: '>=19.2.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + + is-my-ip-valid@1.0.1: + resolution: {integrity: sha512-jxc8cBcOWbNK2i2aTkCZP6i7wkHF1bqKFrwEHuN5Jtg5BSaZHUZQ/JTOJwoV41YvHnOaRyWWh72T/KvfNz9DJg==} + + is-my-json-valid@2.20.6: + resolution: {integrity: sha512-1JQwulVNjx8UqkPE/bqDaxtH4PXCe/2VRh/y3p99heOV87HG4Id5/VfDswd+YiAfHcRTfDlWgISycnHuhZq1aw==} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + is-stream@1.1.0: + resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} + engines: {node: '>=0.10.0'} + + isbinaryfile@4.0.10: + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + + junk@4.0.1: + resolution: {integrity: sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==} + engines: {node: '>=12.20'} + + listr2@11.1.1: + resolution: {integrity: sha512-zArGFXg2/nhA1vYBz789VpwzMQQXkGZiRxeguxFCsDrg4S+LOR22v4oQlSdTN7dR14XgOW+pW33UfvKf+i8nrQ==} + engines: {node: '>=22.13.0'} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-update@8.0.0: + resolution: {integrity: sha512-lddSgOt3bPASrylL54ZSpy8nBHns+vBVSoILlVOx+dei300pnLRN958rj/EdlVLKuWlSESU3qdnDZdAI7FXYGg==} + engines: {node: '>=22'} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + macos-alias@0.2.12: + resolution: {integrity: sha512-yiLHa7cfJcGRFq4FrR4tMlpNHb4Vy4mWnpajlSSIFM5k4Lv8/7BbbDLzCAVogWNl0LlLhizRp1drXv0hK9h0Yw==} + os: [darwin] + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + murmur-32@0.2.0: + resolution: {integrity: sha512-ZkcWZudylwF+ir3Ld1n7gL6bI2mQAzXvSobPwVtu8aYi2sbXeipeSkdcanRLzIofLcM5F53lGaKm2dk7orBi7Q==} + + nan@2.29.0: + resolution: {integrity: sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==} + + nice-try@1.0.5: + resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + + node-abi@4.36.0: + resolution: {integrity: sha512-zrvE1Qy5ElbZVqmJFSiNfwzNZzUCepasgp/4dw0Z0fsn40LJkAk9FA9oMh2SKMeNFRRe/3IfeOKvWwWr8u4SmQ==} + engines: {node: '>=22.12.0'} + + node-api-version@0.2.1: + resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + + node-gyp@12.4.0: + resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + npm-run-path@2.0.2: + resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} + engines: {node: '>=4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + parse-color@1.0.0: + resolution: {integrity: sha512-fuDHYgFHJGbpGMgw9skY/bj3HL/Jrn4l/5rSspy00DoT4RyLnDcRvPxdZ+r6OFwIsgAuhDh4I09tAId4mI12bw==} + + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@2.0.1: + resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} + engines: {node: '>=4'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + pe-library@1.0.1: + resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==} + engines: {node: '>=14', npm: '>=7'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + plist@3.1.1: + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} + + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + random-path@0.1.2: + resolution: {integrity: sha512-4jY0yoEaQ5v9StCl5kZbNIQlg1QheIDBrdkDn53EynpPb9FgO6//p3X/tgMnrC45XN6QZCzU1Xz/+pSSsJBpRw==} + + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + read-binary-file-arch@1.0.6: + resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} + hasBin: true + + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + + resedit@2.0.3: + resolution: {integrity: sha512-oTeemxwoMuxxTYxXUwjkrOPfngTQehlv0/HoYFNkB4uzsP1Un1A9nI8JQKGOFkxpqkC7qkMs0lUsGrvUlbLNUA==} + engines: {node: '>=14', npm: '>=7'} + + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + rimraf@2.6.3: + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@1.2.0: + resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} + engines: {node: '>=0.10.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@1.0.0: + resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} + engines: {node: '>=0.10.0'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slice-ansi@9.0.1: + resolution: {integrity: sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA==} + engines: {node: '>=22'} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stream-buffers@2.2.0: + resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} + engines: {node: '>= 0.10.0'} + + string-width@8.3.0: + resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} + engines: {node: '>=20'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-eof@1.0.0: + resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==} + engines: {node: '>=0.10.0'} + + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + + temp@0.9.4: + resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} + engines: {node: '>=6.0.0'} + + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tn1150@0.1.0: + resolution: {integrity: sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w==} + engines: {node: '>=0.12'} + + to-data-view@1.1.0: + resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==} + + type-fest@5.10.0: + resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} + engines: {node: '>=20'} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + undici@6.29.0: + resolution: {integrity: sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==} + engines: {node: '>=18.17'} + + undici@7.30.0: + resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} + engines: {node: '>=20.18.1'} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unorm@1.6.0: + resolution: {integrity: sha512-b2/KCUlYZUeA7JFUuRJZPUtr4gZvBh7tavtv4fvk4+KV9pfGiR6CQAQAWl49ZpR3ts2dk4FYkP7EIgDJoiOLDA==} + engines: {node: '>= 0.4.0'} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + + wrap-ansi@10.0.2: + resolution: {integrity: sha512-6OLRNZqRntVGBm26ghZ5eZhCuWWyOkRCSn8XsOIuq5stpCE/tjLbdozd93naILZig0lmD8ANfRwnhn0DLeXGlg==} + engines: {node: '>=20'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + +snapshots: + + '@alcalzone/ansi-tokenize@0.3.1': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + '@electron-forge/cli@8.0.1': + dependencies: + '@electron-forge/core': 8.0.1 + '@electron-forge/core-utils': 8.0.1 + '@electron-forge/shared-types': 8.0.1 + '@electron/get': 5.1.0 + commander: 11.1.0 + debug: 4.4.3 + listr2: 11.1.1 + semver: 7.8.5 + transitivePeerDependencies: + - '@types/react' + - bufferutil + - react-devtools-core + - supports-color + - utf-8-validate + + '@electron-forge/core-utils@8.0.1': + dependencies: + '@electron-forge/shared-types': 8.0.1 + '@electron/rebuild': 4.2.1 + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3 + graceful-fs: 4.2.11 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + '@electron-forge/core@8.0.1': + dependencies: + '@electron-forge/core-utils': 8.0.1 + '@electron-forge/maker-base': 8.0.1 + '@electron-forge/multi-logger': 8.0.1 + '@electron-forge/plugin-base': 8.0.1 + '@electron-forge/publisher-base': 8.0.1 + '@electron-forge/shared-types': 8.0.1 + '@electron-forge/tracer': 8.0.1 + '@electron/get': 5.1.0 + '@electron/packager': 20.3.0 + debug: 4.4.3 + graceful-fs: 4.2.11 + jiti: 2.7.0 + listr2: 11.1.1 + semver: 7.8.5 + transitivePeerDependencies: + - '@types/react' + - bufferutil + - react-devtools-core + - supports-color + - utf-8-validate + + '@electron-forge/maker-base@8.0.1': + dependencies: + '@electron-forge/shared-types': 8.0.1 + which: 6.0.1 + transitivePeerDependencies: + - supports-color + + '@electron-forge/maker-dmg@8.0.1': + dependencies: + '@electron-forge/maker-base': 8.0.1 + '@electron-forge/shared-types': 8.0.1 + optionalDependencies: + electron-installer-dmg: 5.0.1 + transitivePeerDependencies: + - supports-color + + '@electron-forge/maker-squirrel@8.0.1': + dependencies: + '@electron-forge/core-utils': 8.0.1 + '@electron-forge/maker-base': 8.0.1 + '@electron-forge/shared-types': 8.0.1 + optionalDependencies: + electron-winstaller: 5.4.4 + transitivePeerDependencies: + - supports-color + + '@electron-forge/multi-logger@8.0.1': + dependencies: + debug: 4.4.3 + ink: 7.1.1(react@19.3.0) + react: 19.3.0 + string-width: 8.3.0 + wrap-ansi: 10.0.2 + transitivePeerDependencies: + - '@types/react' + - bufferutil + - react-devtools-core + - supports-color + - utf-8-validate + + '@electron-forge/plugin-base@8.0.1': + dependencies: + '@electron-forge/shared-types': 8.0.1 + transitivePeerDependencies: + - supports-color + + '@electron-forge/publisher-base@8.0.1': + dependencies: + '@electron-forge/shared-types': 8.0.1 + transitivePeerDependencies: + - supports-color + + '@electron-forge/shared-types@8.0.1': + dependencies: + '@electron-forge/tracer': 8.0.1 + '@electron/packager': 20.3.0 + '@electron/rebuild': 4.2.1 + listr2: 11.1.1 + transitivePeerDependencies: + - supports-color + + '@electron-forge/tracer@8.0.1': + dependencies: + chrome-trace-event: 1.0.4 + + '@electron-internal/extract-zip@1.0.5': {} + + '@electron/asar@3.4.1': + dependencies: + commander: 5.1.0 + glob: 7.2.3 + minimatch: 3.1.5 + + '@electron/asar@4.3.1': + dependencies: + glob: 13.0.6 + minimatch: 10.2.6 + + '@electron/fuses@2.1.3': {} + + '@electron/get@5.1.0': + dependencies: + debug: 4.4.3 + env-paths: 3.0.0 + graceful-fs: 4.2.11 + progress: 2.0.3 + semver: 7.8.5 + sumchecker: 3.0.1 + optionalDependencies: + undici: 7.30.0 + transitivePeerDependencies: + - supports-color + + '@electron/notarize@3.1.1': + dependencies: + debug: 4.4.3 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@electron/osx-sign@2.7.1': + dependencies: + debug: 4.4.3 + isbinaryfile: 4.0.10 + plist: 3.1.1 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + '@electron/packager@20.3.0': + dependencies: + '@electron-internal/extract-zip': 1.0.5 + '@electron/asar': 4.3.1 + '@electron/get': 5.1.0 + '@electron/notarize': 3.1.1 + '@electron/osx-sign': 2.7.1 + '@electron/universal': 3.0.6 + '@electron/windows-sign': 2.1.0 + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3 + filenamify: 6.0.0 + galactus: 2.0.2 + graceful-fs: 4.2.11 + junk: 4.0.1 + plist: 3.1.1 + resedit: 2.0.3 + semver: 7.8.5 + yargs-parser: 22.0.0 + transitivePeerDependencies: + - supports-color + + '@electron/rebuild@4.2.1': + dependencies: + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3 + node-abi: 4.36.0 + node-api-version: 0.2.1 + node-gyp: 12.4.0 + read-binary-file-arch: 1.0.6 + transitivePeerDependencies: + - supports-color + + '@electron/universal@3.0.6': + dependencies: + '@electron/asar': 4.3.1 + debug: 4.4.3 + plist: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@electron/windows-sign@1.2.2': + dependencies: + cross-dirname: 0.1.0 + debug: 4.4.3 + fs-extra: 11.4.1 + minimist: 1.2.8 + postject: 1.0.0-alpha.6 + transitivePeerDependencies: + - supports-color + optional: true + + '@electron/windows-sign@2.1.0': + dependencies: + debug: 4.4.3 + graceful-fs: 4.2.11 + postject: 1.0.0-alpha.6 + transitivePeerDependencies: + - supports-color + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@malept/cross-spawn-promise@2.0.0': + dependencies: + cross-spawn: 7.0.6 + + '@types/appdmg@0.5.5': + dependencies: + '@types/node': 26.6.4 + optional: true + + '@types/node@24.19.1': + dependencies: + undici-types: 7.24.6 + + '@types/node@26.6.4': + dependencies: + undici-types: 8.9.0 + optional: true + + '@xmldom/xmldom@0.9.12': {} + + abbrev@4.0.0: {} + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@6.4.0: {} + + ansi-styles@6.2.3: {} + + appdmg@0.6.6: + dependencies: + async: 1.5.2 + ds-store: 0.1.6 + execa: 1.0.0 + fs-temp: 1.2.1 + fs-xattr: 0.3.1 + image-size: 0.7.5 + is-my-json-valid: 2.20.6 + minimist: 1.2.8 + parse-color: 1.0.0 + path-exists: 4.0.0 + repeat-string: 1.6.1 + optional: true + + async@1.5.2: + optional: true + + auto-bind@5.0.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base32-encode@1.2.0: + dependencies: + to-data-view: 1.1.0 + optional: true + + base64-js@1.5.1: {} + + bplist-creator@0.0.8: + dependencies: + stream-buffers: 2.2.0 + optional: true + + brace-expansion@1.1.21: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + chalk@5.6.2: {} + + chownr@3.0.0: {} + + chrome-trace-event@1.0.4: {} + + cli-boxes@4.0.1: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@6.1.1: + dependencies: + slice-ansi: 9.0.1 + string-width: 8.3.0 + + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + + color-convert@0.5.3: + optional: true + + commander@11.1.0: {} + + commander@5.1.0: {} + + commander@9.5.0: {} + + concat-map@0.0.1: {} + + convert-to-spaces@2.0.1: {} + + cross-dirname@0.1.0: + optional: true + + cross-spawn@6.0.6: + dependencies: + nice-try: 1.0.5 + path-key: 2.0.1 + semver: 5.7.2 + shebang-command: 1.2.0 + which: 1.3.1 + optional: true + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + ds-store@0.1.6: + dependencies: + bplist-creator: 0.0.8 + macos-alias: 0.2.12 + tn1150: 0.1.0 + optional: true + + electron-installer-dmg@5.0.1: + dependencies: + '@types/appdmg': 0.5.5 + debug: 4.4.3 + minimist: 1.2.8 + optionalDependencies: + appdmg: 0.6.6 + transitivePeerDependencies: + - supports-color + optional: true + + electron-winstaller@5.4.4: + dependencies: + '@electron/asar': 3.4.1 + debug: 4.4.3 + fs-extra: 7.0.1 + lodash: 4.18.1 + semver: 7.8.5 + temp: 0.9.4 + optionalDependencies: + '@electron/windows-sign': 1.2.2 + transitivePeerDependencies: + - supports-color + + electron@44.7.0: + dependencies: + '@electron-internal/extract-zip': 1.0.5 + '@electron/get': 5.1.0 + '@types/node': 24.19.1 + transitivePeerDependencies: + - supports-color + + encode-utf8@1.0.3: + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + optional: true + + env-paths@2.2.1: {} + + env-paths@3.0.0: {} + + environment@1.1.0: {} + + err-code@2.0.3: {} + + es-toolkit@1.52.0: {} + + escape-string-regexp@2.0.0: {} + + execa@1.0.0: + dependencies: + cross-spawn: 6.0.6 + get-stream: 4.1.0 + is-stream: 1.1.0 + npm-run-path: 2.0.2 + p-finally: 1.0.0 + signal-exit: 3.0.7 + strip-eof: 1.0.0 + optional: true + + exponential-backoff@3.1.3: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + filename-reserved-regex@3.0.0: {} + + filenamify@6.0.0: + dependencies: + filename-reserved-regex: 3.0.0 + + flora-colossus@3.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + fmix@0.1.0: + dependencies: + imul: 1.0.1 + optional: true + + fs-extra@11.4.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + optional: true + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-temp@1.2.1: + dependencies: + random-path: 0.1.2 + optional: true + + fs-xattr@0.3.1: + optional: true + + fs.realpath@1.0.0: {} + + galactus@2.0.2: + dependencies: + debug: 4.4.3 + flora-colossus: 3.0.2 + transitivePeerDependencies: + - supports-color + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + optional: true + + generate-object-property@1.2.0: + dependencies: + is-property: 1.0.2 + optional: true + + get-east-asian-width@1.7.0: {} + + get-stream@4.1.0: + dependencies: + pump: 3.0.4 + optional: true + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + graceful-fs@4.2.11: {} + + image-size@0.7.5: + optional: true + + imul@1.0.1: + optional: true + + indent-string@5.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ink@7.1.1(react@19.3.0): + dependencies: + '@alcalzone/ansi-tokenize': 0.3.1 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 4.0.1 + cli-cursor: 4.0.0 + cli-truncate: 6.1.1 + code-excerpt: 4.0.0 + es-toolkit: 1.52.0 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.3.0 + react-reconciler: 0.33.0(react@19.3.0) + scheduler: 0.27.0 + signal-exit: 3.0.7 + slice-ansi: 9.0.1 + stack-utils: 2.0.6 + string-width: 8.3.0 + terminal-size: 4.0.1 + type-fest: 5.10.0 + widest-line: 6.0.0 + wrap-ansi: 10.0.2 + ws: 8.22.0 + yoga-layout: 3.2.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.7.0 + + is-in-ci@2.0.0: {} + + is-my-ip-valid@1.0.1: + optional: true + + is-my-json-valid@2.20.6: + dependencies: + generate-function: 2.3.1 + generate-object-property: 1.2.0 + is-my-ip-valid: 1.0.1 + jsonpointer: 5.0.1 + xtend: 4.0.2 + optional: true + + is-property@1.0.2: + optional: true + + is-stream@1.1.0: + optional: true + + isbinaryfile@4.0.10: {} + + isexe@2.0.0: {} + + isexe@4.0.0: {} + + jiti@2.7.0: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + optional: true + + jsonpointer@5.0.1: + optional: true + + junk@4.0.1: {} + + listr2@11.1.1: + dependencies: + cli-truncate: 6.1.1 + log-update: 8.0.0 + wrap-ansi: 10.0.2 + + lodash@4.18.1: {} + + log-update@8.0.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 9.0.1 + string-width: 8.3.0 + strip-ansi: 7.2.0 + wrap-ansi: 10.0.2 + + lru-cache@11.5.3: {} + + macos-alias@0.2.12: + dependencies: + nan: 2.29.0 + optional: true + + mimic-fn@2.1.0: {} + + mimic-function@5.0.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.21 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + ms@2.1.3: {} + + murmur-32@0.2.0: + dependencies: + encode-utf8: 1.0.3 + fmix: 0.1.0 + imul: 1.0.1 + optional: true + + nan@2.29.0: + optional: true + + nice-try@1.0.5: + optional: true + + node-abi@4.36.0: + dependencies: + semver: 7.8.5 + + node-api-version@0.2.1: + dependencies: + semver: 7.8.5 + + node-gyp@12.4.0: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + nopt: 9.0.0 + proc-log: 6.1.0 + semver: 7.8.5 + tar: 7.5.22 + tinyglobby: 0.2.17 + undici: 6.29.0 + which: 6.0.1 + + nopt@9.0.0: + dependencies: + abbrev: 4.0.0 + + npm-run-path@2.0.2: + dependencies: + path-key: 2.0.1 + optional: true + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + p-finally@1.0.0: + optional: true + + parse-color@1.0.0: + dependencies: + color-convert: 0.5.3 + optional: true + + patch-console@2.0.0: {} + + path-exists@4.0.0: + optional: true + + path-is-absolute@1.0.1: {} + + path-key@2.0.1: + optional: true + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.3 + minipass: 7.1.3 + + pe-library@1.0.1: {} + + picomatch@4.0.7: {} + + plist@3.1.1: + dependencies: + '@xmldom/xmldom': 0.9.12 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + + proc-log@6.1.0: {} + + progress@2.0.3: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + optional: true + + random-path@0.1.2: + dependencies: + base32-encode: 1.2.0 + murmur-32: 0.2.0 + optional: true + + react-reconciler@0.33.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.27.0 + + react@19.3.0: {} + + read-binary-file-arch@1.0.6: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + repeat-string@1.6.1: + optional: true + + resedit@2.0.3: + dependencies: + pe-library: 1.0.1 + + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + retry@0.12.0: {} + + rimraf@2.6.3: + dependencies: + glob: 7.2.3 + + scheduler@0.27.0: {} + + semver@5.7.2: + optional: true + + semver@7.8.5: {} + + shebang-command@1.2.0: + dependencies: + shebang-regex: 1.0.0 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@1.0.0: + optional: true + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + slice-ansi@9.0.1: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stream-buffers@2.2.0: + optional: true + + string-width@8.3.0: + dependencies: + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.4.0 + + strip-eof@1.0.0: + optional: true + + sumchecker@3.0.1: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + tagged-tag@1.0.0: {} + + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + temp@0.9.4: + dependencies: + mkdirp: 0.5.6 + rimraf: 2.6.3 + + terminal-size@4.0.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tn1150@0.1.0: + dependencies: + unorm: 1.6.0 + optional: true + + to-data-view@1.1.0: + optional: true + + type-fest@5.10.0: + dependencies: + tagged-tag: 1.0.0 + + undici-types@7.24.6: {} + + undici-types@8.9.0: + optional: true + + undici@6.29.0: {} + + undici@7.30.0: + optional: true + + universalify@0.1.2: {} + + universalify@2.0.1: + optional: true + + unorm@1.6.0: + optional: true + + which@1.3.1: + dependencies: + isexe: 2.0.0 + optional: true + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@6.0.1: + dependencies: + isexe: 4.0.0 + + widest-line@6.0.0: + dependencies: + string-width: 8.3.0 + + wrap-ansi@10.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.3.0 + + wrappy@1.0.2: {} + + ws@8.22.0: {} + + xmlbuilder@15.1.1: {} + + xtend@4.0.2: + optional: true + + yallist@5.0.0: {} + + yargs-parser@22.0.0: {} + + yoga-layout@3.2.1: {} diff --git a/desktop/pnpm-workspace.yaml b/desktop/pnpm-workspace.yaml new file mode 100644 index 0000000..54a0b36 --- /dev/null +++ b/desktop/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +nodeLinker: hoisted +minimumReleaseAgeExclude: + - electron@44.7.0 +allowBuilds: + electron: true + electron-winstaller: true + fs-xattr: true + macos-alias: true diff --git a/desktop/release.cjs b/desktop/release.cjs new file mode 100644 index 0000000..4113b55 --- /dev/null +++ b/desktop/release.cjs @@ -0,0 +1,103 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { execFileSync } = require('node:child_process'); +const { inventory, digest: sha256 } = require('./src/activation.cjs'); +const read = file => JSON.parse(fs.readFileSync(file, 'utf8')); +const requireThat = (condition, message) => { if (!condition) throw new Error(message); }; +const targets = new Set(['win32-x64', 'darwin-x64', 'darwin-arm64']); +function exactInventory(root, expected) { + requireThat(JSON.stringify(inventory(root)) === JSON.stringify(expected), 'Bundle inventory changed'); +} +function publicKey(value) { + requireThat(typeof value === 'string' && /^[A-Za-z0-9+/]+={0,2}$/.test(value), 'Approved feature public key required'); + const key = crypto.createPublicKey({ key: Buffer.from(value, 'base64'), format: 'der', type: 'spki' }); + requireThat(key.asymmetricKeyType === 'ed25519', 'Feature key must be Ed25519 SPKI'); + return value; +} +function validateReview(review, receipt, directory) { + requireThat(receipt.schema === 'pathlab.forge.inventory/1' && /^[a-f0-9]{40}$/.test(receipt.commit) + && /^\d+\.\d+\.\d+(?:-rc\.[1-9][0-9]*)?$/.test(receipt.version) && targets.has(receipt.target), 'Invalid inventory identity'); + const source = path.resolve(directory, receipt.source.file); + requireThat(source.startsWith(path.resolve(directory) + path.sep) && sha256(source) === receipt.source.sha256, 'Source archive changed'); + for (const [file, digest] of [['java-dependencies.json', receipt.javaDependenciesSha256], ['npm-dependencies.json', receipt.npmDependenciesSha256]]) { + requireThat(/^[a-f0-9]{64}$/.test(digest || '') && sha256(path.join(directory, file)) === digest, 'Dependency inventory changed'); + } + requireThat(review.schema === 'pathlab.forge.distribution-review/1' && review.commit === receipt.commit + && review.inventorySha256 === sha256(path.join(directory, 'inventory.json')) + && review.sourceSha256 === receipt.source.sha256 && review.target === receipt.target + && review.applicationLicense === 'GPL-3.0-or-later' && review.decision === 'APPROVED' + && typeof review.reviewer === 'string' && review.reviewer.trim().length > 2, 'Exact distribution review required'); + for (const name of ['notices', 'correspondingSource', 'buildInstructions', 'licenseText']) { + const record = review[name]; + requireThat(record && /^[a-f0-9]{64}$/.test(record.sha256) && typeof record.file === 'string', `Missing reviewed ${name}`); + const file = path.resolve(directory, record.file); + requireThat(file.startsWith(path.resolve(directory) + path.sep) && sha256(file) === record.sha256, `Changed ${name}`); + } + requireThat(Array.isArray(review.components) && review.components.length > 0, 'Component reviews required'); + const dependencies = [ + ...read(path.join(directory, 'java-dependencies.json')).map(item => ({ path: item.coordinate, sha256: item.sha256 })), + ...read(path.join(directory, 'npm-dependencies.json')).map(item => ({ path: `${item.project}/${item.name}@${item.version}`, sha256: item.packageJsonSha256 })), + ]; + requireThat(dependencies.length > 0, 'Resolved dependency inventory required'); + for (const file of [...receipt.files.filter(file => file.sha256), ...dependencies]) { + requireThat(review.components.some(component => component.sha256 === file.sha256 + && component.decision === 'APPROVED' && typeof component.license === 'string' + && component.license.trim().length > 1 + && !/pending|unknown|placeholder/i.test(component.license)), `Unreviewed file: ${file.path}`); + } + requireThat(Array.isArray(receipt.legalFiles), 'Legal input inventory required'); + for (const record of receipt.legalFiles) { + const file = path.resolve(directory, record.path); + requireThat(file.startsWith(path.resolve(directory) + path.sep) && sha256(file) === record.sha256, 'Legal input changed'); + } +} +function preflight(serviceRoot, platform, arch, env = process.env) { + requireThat(targets.has(`${platform}-${arch}`), 'Unsupported distribution target'); + const manifest = read(path.join(serviceRoot, 'runtime-manifest.json')); + requireThat(manifest.platform === platform && manifest.arch === arch, 'Staged Java target mismatch'); + requireThat(fs.existsSync(path.join(serviceRoot, 'runtime/bin', platform === 'win32' ? 'java.exe' : 'java')) + && fs.readdirSync(path.join(serviceRoot, 'lib')).some(name => name.endsWith('.jar')), 'Stage the matching Java service'); + if (manifest.distribution === 'INTERNAL_NON_REDISTRIBUTABLE' && manifest.internalValidation === true + && manifest.requireProductionRuntime === false) return false; + requireThat(manifest.distribution === 'PRODUCTION' && manifest.internalValidation === false + && manifest.requireProductionRuntime === true, 'Explicit distribution channel required'); + publicKey(manifest.featureCatalogPublicKey); + requireThat(process.platform === platform && process.arch === arch, 'Production build requires native target host'); + requireThat(env.PATHLAB_FORGE_RELEASE_INPUT, 'Exact release inputs required'); + const directory = path.resolve(env.PATHLAB_FORGE_RELEASE_INPUT); + const receipt = read(path.join(directory, 'inventory.json')); + requireThat(receipt.schema === 'pathlab.forge.inventory/1' && receipt.target === `${platform}-${arch}` + && receipt.version === require('./package.json').version, 'Release version/target mismatch'); + const repository = path.join(__dirname, '..'); + requireThat(execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' }).trim() === receipt.commit + && !execFileSync('git', ['status', '--porcelain', '--untracked-files=normal'], { cwd: repository, encoding: 'utf8' }).trim(), 'Release requires clean exact commit'); + requireThat(Array.isArray(receipt.dependencyInputs) && receipt.dependencyInputs.length === 4 + && receipt.dependencyInputs.every(input => ['frontend/pnpm-lock.yaml', 'desktop/pnpm-lock.yaml', 'reader-runtime.lock.properties', 'gradle/wrapper/gradle-wrapper.properties'].includes(input.file) + && sha256(path.join(repository, input.file)) === input.sha256) + && new Set(receipt.dependencyInputs.map(input => input.file)).size === 4, 'Dependency lock inputs changed'); + exactInventory(serviceRoot, receipt.files); + validateReview(read(path.join(directory, 'review.json')), receipt, directory); + require('./native-signing.cjs').verifyService(serviceRoot); + execFileSync(path.join(serviceRoot, 'runtime/bin', platform === 'win32' ? 'java.exe' : 'java'), [ + '-cp', path.join(serviceRoot, 'lib/*'), 'org.pathlab.forge.runtime.PackagedReaderRuntimeVerifier', serviceRoot, 'PRODUCTION', + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + return true; +} +function signing(platform, env = process.env) { + if (platform === 'win32') { + requireThat(/^[A-Fa-f0-9]{40}$/.test(env.PATHLAB_FORGE_WINDOWS_CERT_SHA1 || ''), 'Trusted Windows store certificate required'); + const timestamp = new URL(env.PATHLAB_FORGE_TIMESTAMP_URL || ''); + requireThat(timestamp.protocol === 'https:' && !timestamp.username && !timestamp.password + && !/[\s"'<>]/.test(timestamp.href), 'HTTPS timestamp required'); + // Store/HSM identity: no private key or password enters a subprocess argument. + return { windowsSign: { signWithParams: `/sha1 ${env.PATHLAB_FORGE_WINDOWS_CERT_SHA1} /fd SHA256 /tr ${timestamp.href} /td SHA256`, hashes: ['sha256'] } }; + } + requireThat(platform === 'darwin' && /^Developer ID Application: .+ \([A-Z0-9]{10}\)$/.test(env.PATHLAB_FORGE_MAC_IDENTITY || '') + && /^[A-Za-z0-9._-]+$/.test(env.PATHLAB_FORGE_NOTARY_PROFILE || ''), 'Trusted macOS identity and keychain profile required'); + return { osxSign: { identity: env.PATHLAB_FORGE_MAC_IDENTITY, continueOnError: false, hardenedRuntime: true, + ignore: file => /[\\/]Resources[\\/]service(?:[\\/]|$)/.test(file), + optionsForFile: () => ({ hardenedRuntime: true }) }, + osxNotarize: { keychainProfile: env.PATHLAB_FORGE_NOTARY_PROFILE } }; +} +module.exports = { inventory, exactInventory, preflight, signing, validateReview, publicKey, sha256, targets }; diff --git a/desktop/service-entitlements.plist b/desktop/service-entitlements.plist new file mode 100644 index 0000000..e8d5435 --- /dev/null +++ b/desktop/service-entitlements.plist @@ -0,0 +1,6 @@ + + + + com.apple.security.cs.allow-jit + com.apple.security.cs.allow-unsigned-executable-memory + diff --git a/desktop/src/activation.cjs b/desktop/src/activation.cjs new file mode 100644 index 0000000..a5f19a8 --- /dev/null +++ b/desktop/src/activation.cjs @@ -0,0 +1,49 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +function digest(file) { + const hash = crypto.createHash('sha256'); + const buffer = Buffer.allocUnsafe(1024 * 1024); + const fd = fs.openSync(file, 'r'); + try { let length; while ((length = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, length)); } + finally { fs.closeSync(fd); } + return hash.digest('hex'); +} +function inventory(root) { + root = fs.realpathSync(root); + const files = []; + function visit(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const file = path.join(directory, entry.name); + const relative = path.relative(root, file).split(path.sep).join('/'); + if (entry.isSymbolicLink()) { + if (!fs.realpathSync(file).startsWith(root + path.sep)) throw new Error('Service symlink escapes bundle'); + files.push({ path: relative, symlink: fs.readlinkSync(file) }); + } else if (entry.isDirectory()) visit(file); + else if (entry.isFile()) files.push({ path: relative, bytes: fs.statSync(file).size, sha256: digest(file) }); + else throw new Error('Unsupported service file'); + } + } + visit(root); + if (!files.length) throw new Error('Empty service inventory'); + return files; +} +function activate(root, policy, platform, arch, version) { + if (!policy || policy.schema !== 'pathlab.forge.activation-policy/1' || policy.version !== version + || policy.platform !== platform || policy.arch !== arch || !Array.isArray(policy.files) + || !/^[a-f0-9]{40}$/.test(policy.commit || '') || typeof policy.sourceDirty !== 'boolean' + || !['PRODUCTION', 'INTERNAL_NON_REDISTRIBUTABLE'].includes(policy.distribution)) throw new Error('Installed release policy is invalid. Reinstall Forge.'); + const manifest = policy.manifest; + const production = policy.distribution === 'PRODUCTION'; + if (!manifest || manifest.platform !== platform || manifest.arch !== arch || manifest.distribution !== policy.distribution + || manifest.internalValidation !== !production || manifest.requireProductionRuntime !== production) throw new Error('Installed release policy channel mismatch.'); + if (production) { + if (policy.sourceDirty || manifest.version !== version) throw new Error('Production release version or source mismatch'); + const key = crypto.createPublicKey({ key: Buffer.from(manifest.featureCatalogPublicKey || '', 'base64'), format: 'der', type: 'spki' }); + if (key.asymmetricKeyType !== 'ed25519') throw new Error('Installed feature catalog trust key is invalid'); + } + // Policy lives in app.asar; Electron's integrity fuses pin it to the signed executable. + if (JSON.stringify(inventory(root)) !== JSON.stringify(policy.files)) throw new Error('Installed service failed integrity verification. Reinstall Forge.'); + return manifest; +} +module.exports = { activate, inventory, digest }; diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs new file mode 100644 index 0000000..3f4cb87 --- /dev/null +++ b/desktop/src/main.cjs @@ -0,0 +1,262 @@ +const { app, BrowserWindow, Menu, dialog, ipcMain, session, shell } = require('electron'); +const { spawn } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { localUrl, readiness, startupFailure, externalUrl, allowedPath, windowState, serviceEnvironment } = require('./policy.cjs'); +const { activate } = require('./activation.cjs'); + +// Squirrel invokes these during install/update; no service or data root is opened. +if (process.argv.some(value => /^--squirrel-(install|updated|uninstall|obsolete)$/.test(value))) { + const update = path.resolve(path.dirname(process.execPath), '..', 'Update.exe'); + const remove = process.argv.includes('--squirrel-uninstall'); + if (!process.argv.includes('--squirrel-obsolete') && fs.existsSync(update)) { + const child = spawn(update, [remove ? '--removeShortcut' : '--createShortcut', path.basename(process.execPath)], { windowsHide: true }); + child.once('exit', () => app.quit()); + child.once('error', () => app.quit()); + } else app.quit(); +} else if (!app.requestSingleInstanceLock()) { + app.quit(); +} else { + let window, service, origin, desktopSecret, quitting = false, stopped = false, checkingQuit = false; + const selected = new Set(); + const externalOrigins = []; + const smoke = process.argv.includes('--forge-smoke-test'); + const defaultDataRoot = process.platform === 'win32' + ? path.join(process.env.LOCALAPPDATA || path.join(app.getPath('home'), 'AppData/Local'), 'PathLab Forge') + : path.join(app.getPath('home'), 'Library/Application Support/PathLab Forge'); + const dataRoot = smoke ? fs.mkdtempSync(path.join(os.tmpdir(), 'forge-desktop-smoke-')) : defaultDataRoot; + // Chromium state is separate so it cannot create the legacy migration target. + const desktopRoot = smoke ? path.join(dataRoot, 'desktop') : `${dataRoot} Desktop`; + fs.mkdirSync(desktopRoot, { recursive: true }); + app.setPath('userData', desktopRoot); + const preferencesFile = path.join(desktopRoot, 'desktop-window.json'); + const focus = () => { if (window) { if (window.isMinimized()) window.restore(); window.show(); window.focus(); } }; + app.on('second-instance', focus); + app.on('activate', focus); + app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); + app.on('before-quit', event => { + if (!service || stopped) return; + event.preventDefault(); + if (!quitting) { void requestQuit(); return; } + }); + async function requestQuit() { + if (checkingQuit || quitting) return; + checkingQuit = true; + try { + if (origin && desktopSecret) { + const headers = { 'X-Forge-Desktop-Secret': desktopSecret }; + const response = await fetch(`${origin}/api/desktop/lifecycle`, { headers, signal: AbortSignal.timeout(5000) }); + if (!response.ok) throw new Error('Unable to check active work'); + const work = await response.json(); + if (work.active > 0) { + const answer = await dialog.showMessageBox(window, { type: 'question', title: 'Quit PathLab Forge?', + message: 'Work is active. Quit will stop it safely; unfinished items can be retried after restart.', + buttons: ['Stay', 'Stop safely and quit'], defaultId: 0, cancelId: 0 }); + if (answer.response !== 1) return; + } + const paused = await fetch(`${origin}/api/desktop/lifecycle`, { method: 'POST', headers, signal: AbortSignal.timeout(5000) }); + if (!paused.ok) throw new Error('Unable to preserve queue state'); + } + } catch (error) { + dialog.showErrorBox('Unable to quit safely', error.message); return; + } finally { checkingQuit = false; } + quitting = true; + // Closing stdin interrupts bounded work while preserving durable records. + service.stdin.end(); + const timer = setTimeout(() => { + if (process.platform === 'darwin') { + try { process.kill(-service.pid, 'SIGKILL'); } catch { service.kill('SIGKILL'); } + } else service.kill(); + }, 10000); + timer.unref(); + } + + const trusted = event => { + if (!window || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame + || !localUrl(event.senderFrame.url, origin)) throw new Error('Untrusted desktop caller'); + }; + const handle = (name, action) => ipcMain.handle(name, async (event, ...args) => { trusted(event); return action(...args); }); + const remember = async (values, purpose) => { + const response = await fetch(`${origin}/api/desktop/selections`, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Forge-Desktop-Secret': desktopSecret }, + body: JSON.stringify({ paths: values, purpose }), signal: AbortSignal.timeout(10000), + }); + if (!response.ok) throw new Error('Native selection could not be authorized'); + for (const value of values) selected.add(path.resolve(value)); + return values; + }; + handle('forge:select-sources', async () => { + const result = await dialog.showOpenDialog(window, { title: 'Open slide sources', properties: ['openFile', 'multiSelections'] }); + return result.canceled ? [] : remember(result.filePaths, 'import'); + }); + handle('forge:select-feature-files', async () => { + const catalog = await dialog.showOpenDialog(window, { title: 'Open signed feature catalog', properties: ['openFile'], filters: [{ name: 'Signed catalog', extensions: ['json'] }] }); + if (catalog.canceled || catalog.filePaths.length !== 1) return null; + const archive = await dialog.showOpenDialog(window, { title: 'Open feature pack', properties: ['openFile'], filters: [{ name: 'Feature pack', extensions: ['zip'] }] }); + if (archive.canceled || archive.filePaths.length !== 1) return null; + const paths = await remember([catalog.filePaths[0], archive.filePaths[0]], 'feature'); + return { catalogPath: paths[0], archivePath: paths[1] }; + }); + handle('forge:select-directory', async () => { + const result = await dialog.showOpenDialog(window, { title: 'Open slide folder', properties: ['openDirectory'] }); + return result.canceled ? null : (await remember(result.filePaths, 'directory'))[0]; + }); + handle('forge:export-destination', async name => { + if (typeof name !== 'string' || name.length > 200 || !name || /[\x00-\x1f/\\:]/.test(name)) throw new Error('Invalid export name'); + const result = await dialog.showSaveDialog(window, { title: 'Export', defaultPath: name }); + return result.canceled || !result.filePath ? null : (await remember([result.filePath], 'export'))[0]; + }); + handle('forge:reveal', value => { + if (!allowedPath(value, selected)) throw new Error('Path was not selected in a native dialog'); + shell.showItemInFolder(path.resolve(value)); + }); + handle('forge:external', async value => { + if (!externalUrl(value, externalOrigins)) throw new Error('External destination is not approved'); + await shell.openExternal(value); + }); + + function startService() { + const root = app.isPackaged ? path.join(process.resourcesPath, 'service') : path.join(__dirname, '../resources/service'); + const manifest = app.isPackaged + ? activate(root, require('../release-policy.json'), process.platform, process.arch, app.getVersion()) + : JSON.parse(fs.readFileSync(path.join(root, 'runtime-manifest.json'), 'utf8')); + if (smoke && manifest.internalValidation !== true) throw new Error('Smoke requires an internal validation package'); + if (manifest.platform !== process.platform || manifest.arch !== process.arch) throw new Error('Java runtime target mismatch'); + const java = path.join(root, 'runtime/bin', process.platform === 'win32' ? 'java.exe' : 'java'); + const args = ['-Xmx512m', '--enable-native-access=ALL-UNNAMED', + `-Dpathlab.forge.runtime.requireProduction=${manifest.internalValidation !== true}`, + `-Dpathlab.forge.readerDataRoot=${path.join(root, 'reader-data')}`]; + if (manifest.featureCatalogPublicKey) { + const key = require('node:crypto').createPublicKey({ key: Buffer.from(manifest.featureCatalogPublicKey, 'base64'), format: 'der', type: 'spki' }); + if (key.asymmetricKeyType !== 'ed25519') throw new Error('Installed feature catalog trust key is invalid'); + args.push(`-Dpathlab.forge.featureCatalogPublicKey=${manifest.featureCatalogPublicKey}`); + } else if (manifest.internalValidation !== true) throw new Error('Production Forge is missing its approved feature catalog trust key. Reinstall the matching release.'); + if (manifest.viewerOrigin) { + const viewer = new URL(manifest.viewerOrigin); + if (viewer.protocol !== 'https:' || viewer.username || viewer.password || viewer.origin !== manifest.viewerOrigin) throw new Error('Invalid Viewer origin'); + externalOrigins.push(viewer.origin); + args.push(`-Dpathlab.forge.viewer.defaultOrigin=${viewer.origin}`); + } + args.push('-cp', path.join(root, 'lib/*'), 'org.pathlab.forge.ForgeApp', '--desktop', '--data-root', dataRoot); + const environment = serviceEnvironment(process.env); + service = spawn(java, args, { cwd: root, env: environment, windowsHide: true, + detached: process.platform === 'darwin', stdio: ['pipe', 'pipe', 'pipe'] }); + service.stdin.on('error', () => {}); + // Never forward child logs: readiness contains a one-use bootstrap secret. + service.stderr.resume(); + return new Promise((resolve, reject) => { + let buffer = '', ready = false; + const timeout = setTimeout(() => { service.stdin.end(); reject(new Error('Local service startup timed out')); }, 60000); + service.once('error', () => { clearTimeout(timeout); reject(new Error('Unable to start the bundled Java service')); }); + service.once('exit', () => { + clearTimeout(timeout); stopped = true; + if (!ready) reject(new Error('Local service exited before readiness')); + else if (!quitting) { dialog.showErrorBox('PathLab Forge', 'The local service stopped. Reopen Forge to recover queued work.'); app.quit(); } + else app.quit(); + }); + service.stdout.on('data', chunk => { + if (ready) return; + buffer += chunk.toString('utf8'); + if (buffer.length > 65536) { clearTimeout(timeout); service.stdin.end(); reject(new Error('Invalid local service startup')); return; } + let end; + while ((end = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, end).trim(); buffer = buffer.slice(end + 1); + try { + const failure = startupFailure(line); + if (failure) { clearTimeout(timeout); service.stdin.end(); reject(new Error(failure)); return; } + const record = readiness(line); + if (record) { ready = true; clearTimeout(timeout); buffer = ''; resolve(record); return; } + } catch { clearTimeout(timeout); service.stdin.end(); reject(new Error('Invalid local service readiness')); return; } + } + }); + }); + } + + app.whenReady().then(async () => { + const record = await startService(); + origin = record.origin; + desktopSecret = record.desktopSecret; + const localSession = session.fromPartition('forge-desktop'); + localSession.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); + localSession.setPermissionCheckHandler(() => false); + // Desktop assets and API use one local origin. No renderer network access to external sites. + localSession.webRequest.onBeforeRequest((details, callback) => callback({ cancel: !localUrl(details.url, origin) })); + let state; + try { state = windowState(JSON.parse(fs.readFileSync(preferencesFile, 'utf8'))); } catch { state = windowState(null); } + window = new BrowserWindow({ ...state, minWidth: 640, minHeight: 480, show: false, title: 'PathLab Forge', + webPreferences: { preload: path.join(__dirname, 'preload.cjs'), partition: 'forge-desktop', + sandbox: true, contextIsolation: true, nodeIntegration: false, webSecurity: true, devTools: !app.isPackaged } }); + window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + window.webContents.on('will-navigate', (event, url) => { if (!localUrl(url, origin)) event.preventDefault(); }); + window.webContents.on('will-redirect', (event, url) => { if (!localUrl(url, origin)) event.preventDefault(); }); + window.webContents.on('will-attach-webview', event => event.preventDefault()); + window.webContents.on('render-process-gone', () => { if (!quitting) { dialog.showErrorBox('PathLab Forge', 'The desktop view stopped. Reopen Forge to recover your work.'); app.quit(); } }); + window.on('close', event => { + try { + fs.writeFileSync(`${preferencesFile}.partial`, JSON.stringify(windowState(window.getNormalBounds()))); + fs.renameSync(`${preferencesFile}.partial`, preferencesFile); + } catch { /* A preferences failure must not prevent service shutdown. */ } + if (!quitting) { + event.preventDefault(); + if (process.platform === 'darwin') window.hide(); + else app.quit(); + } + }); + app.setAboutPanelOptions({ applicationName: 'PathLab Forge', applicationVersion: app.getVersion(), + copyright: 'Non-AI local slide preparation and teaching tools' }); + const command = value => window.webContents.send('forge:command', value); + Menu.setApplicationMenu(Menu.buildFromTemplate([ + ...(process.platform === 'darwin' ? [{ role: 'appMenu' }] : []), + { label: 'File', submenu: [{ label: 'Open slides…', accelerator: 'CmdOrCtrl+O', click: () => command('open-sources') }, + { label: 'Open folder…', click: () => command('open-directory') }, { type: 'separator' }, { role: 'quit' }] }, + { role: 'editMenu' }, + { label: 'View', submenu: [{ role: 'reload' }, { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'togglefullscreen' }] }, + { role: 'windowMenu' }, + { label: 'Help', submenu: [{ label: 'Check for updates…', click: async () => { + if (!externalOrigins[0]) { dialog.showErrorBox('Updates unavailable', 'This installation has no approved Viewer destination. Install the matching Forge release.'); return; } + try { await shell.openExternal(`${externalOrigins[0]}/forge/downloads`); } + catch { dialog.showErrorBox('Unable to open downloads', 'Open your approved PathLab Viewer and select Forge downloads.'); } + } }, { label: 'About PathLab Forge', click: () => app.showAboutPanel() }] }, + ])); + await window.loadURL(record.launchUrl); + window.show(); + if (smoke) { + const renderer = await window.webContents.executeJavaScript(`(async () => { + const session = await fetch('/api/session'); + const datasets = await fetch('/api/datasets'); + const deadline = Date.now() + 10000; + while (!document.body.innerText.includes('Choose a slide to begin') && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + const unauthorizedGrant = await fetch('/api/desktop/selections', { method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Forge-Desktop-Secret': 'invalid' }, + body: JSON.stringify({ paths: ['/unselected-file'], purpose: 'import' }) }); + const rejected = async action => { try { await action(); return false; } catch { return true; } }; + return { + noNode: typeof process === 'undefined' && typeof require === 'undefined', + bridge: typeof window.forgeDesktop?.selectSources === 'function' + && typeof window.forgeDesktop?.selectFeatureFiles === 'function', + rendered: !!document.querySelector('[aria-label="PathLab Forge"]'), + authenticatedApi: session.ok && datasets.ok, + workspaceReady: document.body.innerText.includes('Choose a slide to begin'), + rendererGrantRejected: !unauthorizedGrant.ok, + invalidIpcRejected: await rejected(() => window.forgeDesktop.openExternal('file:///outside')) + && await rejected(() => window.forgeDesktop.revealPath('/unselected-file')) + && await rejected(() => window.forgeDesktop.selectExportDestination('../outside')) + }; + })()`); + const preferences = window.webContents.getLastWebPreferences(); + const screenshot = path.join(dataRoot, 'desktop-smoke.png'); + fs.writeFileSync(screenshot, (await window.webContents.capturePage()).toPNG()); + const result = { packaged: app.isPackaged, renderer, sandbox: preferences.sandbox, screenshot, + contextIsolation: preferences.contextIsolation, nodeIntegration: preferences.nodeIntegration, servicePid: service.pid }; + process.stdout.write(`PATHLAB_FORGE_SMOKE ${JSON.stringify(result)}\n`); + app.quit(); + } + }).catch(error => { + if (smoke) { process.stdout.write('PATHLAB_FORGE_SMOKE_FAILURE integrity-or-startup\n'); app.exit(1); return; } + dialog.showErrorBox('PathLab Forge', error.message || 'Forge could not start its bundled local service. Check that the desktop package includes the matching Java runtime and service.'); + app.quit(); + }); +} diff --git a/desktop/src/policy.cjs b/desktop/src/policy.cjs new file mode 100644 index 0000000..0704c37 --- /dev/null +++ b/desktop/src/policy.cjs @@ -0,0 +1,57 @@ +const path = require('node:path'); +function localUrl(value, origin) { + try { + const url = new URL(value); + return url.origin === origin && !url.username && !url.password; + } catch { return false; } +} +function readiness(line) { + if (!line.startsWith('PATHLAB_FORGE_READY ')) return null; + const record = JSON.parse(line.slice('PATHLAB_FORGE_READY '.length)); + const url = new URL(record.origin); + if (record.protocol !== 1 || url.protocol !== 'http:' || url.hostname !== '127.0.0.1' + || !url.port || url.username || url.password || url.pathname !== '/' || url.search || url.hash + || !localUrl(record.launchUrl, url.origin) + || typeof record.desktopSecret !== 'string' || !/^[A-Za-z0-9_-]{32,128}$/.test(record.desktopSecret)) throw new Error('Invalid private readiness record'); + return { origin: url.origin, launchUrl: record.launchUrl, desktopSecret: record.desktopSecret }; +} +function externalUrl(value, origins) { + if (typeof value !== 'string' || value.length > 4096) return false; + try { + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password && origins.includes(url.origin); + } catch { return false; } +} +function startupFailure(line) { + if (!line.startsWith('PATHLAB_FORGE_FAILED ')) return null; + const record = JSON.parse(line.slice('PATHLAB_FORGE_FAILED '.length)); + const messages = { + DATA_LOCKED: 'Another Forge process owns this library. Close it, then reopen Forge. Do not delete the library lock or data.', + DATA_DENIED: 'Forge cannot access its application-data folder. Restore read/write permission or free the drive, then reopen Forge.', + DATA_UPGRADE_BACKUP_FAILED: 'Forge could not create the required upgrade backup. Free disk space and restore write permission, then reopen Forge. Preserve the library and existing backups.', + DATA_DOWNGRADE_BLOCKED: 'This library requires a newer Forge version. Reinstall the matching newer version. A downgrade requires a separately restored compatible backup; preserve the current library.', + DATA_VERSION_REQUIRED: 'Forge cannot verify this library\'s data version. Preserve the library and backups, and use a release compatible with its recorded data version before upgrading.', + DATA_UPGRADE_RECOVERY_REQUIRED: 'An interrupted library upgrade requires recovery. Close all Forge processes and recover a complete compatible backup before reopening. Preserve the current library and backups.', + SERVICE_UNAVAILABLE: 'The local service could not initialize. Verify the installed runtime and available disk space. Reinstall the matching Forge version while preserving application data.' + }; + if (record.protocol !== 1 || !Object.hasOwn(messages, record.code)) throw new Error('Invalid private startup failure'); + return messages[record.code]; +} +function allowedPath(value, selected) { + if (typeof value !== 'string' || value.length > 32768 || value.includes('\0') || !path.isAbsolute(value)) return false; + return selected.has(path.resolve(value)); +} +function windowState(value) { + const state = { width: 1280, height: 840 }; + if (value && Number.isInteger(value.width) && value.width >= 640 && value.width <= 8192 + && Number.isInteger(value.height) && value.height >= 480 && value.height <= 8192) { + state.width = value.width; state.height = value.height; + } + return state; +} +function serviceEnvironment(environment) { + return Object.fromEntries(Object.entries(environment).filter(([key]) => + !/^(JAVA_TOOL_OPTIONS|JDK_JAVA_OPTIONS|_JAVA_OPTIONS|CLASSPATH|JAVA_HOME|LD_PRELOAD|DYLD_INSERT_LIBRARIES|DYLD_LIBRARY_PATH)$/i.test(key) + && !/^PATHLAB_FORGE_/i.test(key))); +} +module.exports = { localUrl, readiness, startupFailure, externalUrl, allowedPath, windowState, serviceEnvironment }; diff --git a/desktop/src/preload.cjs b/desktop/src/preload.cjs new file mode 100644 index 0000000..f1082f8 --- /dev/null +++ b/desktop/src/preload.cjs @@ -0,0 +1,15 @@ +const { contextBridge, ipcRenderer } = require('electron'); +contextBridge.exposeInMainWorld('forgeDesktop', Object.freeze({ + selectSources: () => ipcRenderer.invoke('forge:select-sources'), + selectFeatureFiles: () => ipcRenderer.invoke('forge:select-feature-files'), + selectDirectory: () => ipcRenderer.invoke('forge:select-directory'), + selectExportDestination: name => ipcRenderer.invoke('forge:export-destination', name), + revealPath: path => ipcRenderer.invoke('forge:reveal', path), + openExternal: url => ipcRenderer.invoke('forge:external', url), + onCommand: callback => { + if (typeof callback !== 'function') throw new TypeError('Expected callback'); + const listener = (_event, command) => callback(command); + ipcRenderer.on('forge:command', listener); + return () => ipcRenderer.removeListener('forge:command', listener); + }, +})); diff --git a/desktop/test/activation.test.cjs b/desktop/test/activation.test.cjs new file mode 100644 index 0000000..39beba7 --- /dev/null +++ b/desktop/test/activation.test.cjs @@ -0,0 +1,28 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const crypto = require('node:crypto'); +const { activate, inventory, digest } = require('../src/activation.cjs'); +test('pinned packaged policy rejects changed JAR/JVM/reader bytes and manifest downgrade before execution', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-activation-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const key = crypto.generateKeyPairSync('ed25519').publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); + const manifest = { version: '1.0.0-rc.1', platform: 'win32', arch: 'x64', distribution: 'PRODUCTION', internalValidation: false, requireProductionRuntime: true, featureCatalogPublicKey: key }; + fs.writeFileSync(path.join(root, 'runtime-manifest.json'), JSON.stringify(manifest)); + for (const file of ['app.jar', 'java.exe', 'codec.dll']) fs.writeFileSync(path.join(root, file), 'synthetic bytes'); + const policy = { schema: 'pathlab.forge.activation-policy/1', version: '1.0.0-rc.1', platform: 'win32', arch: 'x64', distribution: 'PRODUCTION', commit: 'a'.repeat(40), sourceDirty: false, manifest, files: inventory(root) }; + assert.equal(activate(root, policy, 'win32', 'x64', policy.version), manifest); + assert.throws(() => activate(root, undefined, 'win32', 'x64', policy.version)); + assert.throws(() => activate(root, policy, 'win32', 'arm64', policy.version)); + assert.throws(() => activate(root, policy, 'win32', 'x64', '1.0.0')); + for (const file of ['app.jar', 'java.exe', 'codec.dll']) { + fs.appendFileSync(path.join(root, file), 'tampered'); + assert.throws(() => activate(root, policy, 'win32', 'x64', policy.version), /integrity/); + fs.writeFileSync(path.join(root, file), 'synthetic bytes'); + } + fs.writeFileSync(path.join(root, 'runtime-manifest.json'), JSON.stringify({ ...manifest, distribution: 'INTERNAL_NON_REDISTRIBUTABLE', internalValidation: true, requireProductionRuntime: false })); + assert.throws(() => activate(root, policy, 'win32', 'x64', policy.version), /integrity/); + assert.equal(digest(path.join(root, 'app.jar')), crypto.createHash('sha256').update('synthetic bytes').digest('hex')); +}); diff --git a/desktop/test/dependency-inventory.test.cjs b/desktop/test/dependency-inventory.test.cjs new file mode 100644 index 0000000..98b082a --- /dev/null +++ b/desktop/test/dependency-inventory.test.cjs @@ -0,0 +1,26 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { collectNpm } = require('../../scripts/distribution.cjs'); +test('isolated pnpm links, scoped and transitive packages are inventoried once per real root', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-npm-inventory-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ dependencies: { '@example/direct': '1.0.0' } })); + const direct = path.join(root, 'node_modules/.pnpm/direct@1.0.0/node_modules/@example/direct'); + const transitive = path.join(root, 'node_modules/.pnpm/transitive@2.0.0/node_modules/transitive'); + for (const [directory, name, version] of [[direct, '@example/direct', '1.0.0'], [transitive, 'transitive', '2.0.0']]) { + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(path.join(directory, 'package.json'), JSON.stringify({ name, version, license: 'MIT' })); + fs.writeFileSync(path.join(directory, 'LICENSE'), 'Synthetic test notice'); + } + fs.mkdirSync(path.join(root, 'node_modules/@example')); + fs.symlinkSync(direct, path.join(root, 'node_modules/@example/direct'), 'junction'); + fs.symlinkSync(transitive, path.join(root, 'node_modules/.pnpm/direct@1.0.0/node_modules/transitive'), 'junction'); + const records = collectNpm(root, 'frontend', path.join(root, 'output')); + assert.deepEqual(records.map(record => [record.name, record.version]), [['@example/direct', '1.0.0'], ['transitive', '2.0.0']]); + assert.ok(records.every(record => record.notices.length === 1 && record.project === 'frontend' && record.decision === 'PENDING_REVIEW')); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ dependencies: { missing: '1.0.0' } })); + assert.throws(() => collectNpm(root, 'frontend', path.join(root, 'output')), /Incomplete/); +}); diff --git a/desktop/test/installer-payload.test.cjs b/desktop/test/installer-payload.test.cjs new file mode 100644 index 0000000..c977a61 --- /dev/null +++ b/desktop/test/installer-payload.test.cjs @@ -0,0 +1,36 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); +const { comparePayload } = require('../installer-payload.cjs'); +const { inventory } = require('../src/activation.cjs'); +test('installer payload rejects older same-name bytes and unexpected files', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-payload-test-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.writeFileSync(path.join(root, 'PathLabForge.exe'), 'reviewed application'); + const expected = inventory(root); + assert.deepEqual(comparePayload(root, expected), expected); + fs.writeFileSync(path.join(root, 'PathLabForge.exe'), 'older application'); + assert.throws(() => comparePayload(root, expected), /does not match/); + fs.writeFileSync(path.join(root, 'PathLabForge.exe'), 'reviewed application'); + fs.writeFileSync(path.join(root, 'extra.dll'), 'unreviewed native payload'); + assert.throws(() => comparePayload(root, expected), /does not match/); +}); +test('native bounded extraction rejects traversal, backslash and case collisions', { skip: process.platform !== 'win32' }, t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-safe-zip-test-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const create = '$ErrorActionPreference="Stop"; Add-Type -AssemblyName System.IO.Compression.FileSystem; Add-Type -AssemblyName System.IO.Compression; $z=[IO.Compression.ZipFile]::Open($env:FORGE_TEST_ZIP,[IO.Compression.ZipArchiveMode]::Create); try { foreach($n in (ConvertFrom-Json $env:FORGE_TEST_ENTRIES)) { $e=$z.CreateEntry($n); $s=[IO.StreamWriter]::new($e.Open()); try {$s.Write("synthetic test payload")} finally {$s.Dispose()} } } finally {$z.Dispose()}'; + const extract = path.resolve(__dirname, '../../scripts/windows-installer-payload.ps1'); + for (const [index, entries] of [['valid', ['lib/net45/PathLabForge.exe']], ['traversal', ['../escaped.txt']], ['backslash', ['lib\\escaped.txt']], ['collision', ['Case.txt', 'case.txt']]]) { + const archive = path.join(root, `${index}.zip`), destination = path.join(root, index); + execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', create], { + env: { ...process.env, FORGE_TEST_ZIP: archive, FORGE_TEST_ENTRIES: JSON.stringify(entries) }, stdio: 'pipe' }); + const run = () => execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-File', extract, + '-Installer', archive, '-Destination', destination, '-Mode', 'ZIP'], { stdio: 'pipe' }); + if (index === 'valid') { run(); assert.ok(fs.existsSync(path.join(destination, entries[0]))); } + else assert.throws(run); + } + assert.equal(fs.existsSync(path.join(root, 'escaped.txt')), false); +}); diff --git a/desktop/test/packaged-integrity.cjs b/desktop/test/packaged-integrity.cjs new file mode 100644 index 0000000..e0e35f7 --- /dev/null +++ b/desktop/test/packaged-integrity.cjs @@ -0,0 +1,32 @@ +// Internal test image only: restores every modified byte, never opens user data. +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const { createHash } = require('node:crypto'); +const executable = process.argv[2]; +assert.ok(executable && path.isAbsolute(executable), 'Pass the actual packaged executable'); +const resources = process.platform === 'darwin' ? path.resolve(path.dirname(executable), '../Resources') : path.join(path.dirname(executable), 'resources'); +const manifestFile = path.join(resources, 'service/runtime-manifest.json'); +assert.equal(JSON.parse(fs.readFileSync(manifestFile, 'utf8')).distribution, 'INTERNAL_NON_REDISTRIBUTABLE', 'Only mutate an internal test image'); +const files = [manifestFile, path.join(resources, 'service/lib', fs.readdirSync(path.join(resources, 'service/lib')).find(name => name.endsWith('.jar'))), + path.join(resources, 'service/runtime/bin', process.platform === 'win32' ? 'java.exe' : 'java'), path.join(resources, 'app.asar')]; +for (const file of files) { + const original = fs.readFileSync(file); + const hash = bytes => createHash('sha256').update(bytes).digest('hex'); + try { + if (file.endsWith('app.asar')) { + const modified = Buffer.from(original); + const offset = modified.indexOf(Buffer.from('INTERNAL_NON_REDISTRIBUTABLE')); + assert.ok(offset >= 0, 'Protected policy missing from archive'); + modified[offset] = 'X'.charCodeAt(0); + fs.writeFileSync(file, modified); + } else fs.appendFileSync(file, '\n'); + const result = spawnSync(executable, ['--forge-smoke-test', '--noerrdialogs'], { windowsHide: true, timeout: 30000, encoding: 'utf8' }); + assert.notEqual(result.status, 0, `Tampered ${path.basename(file)} launched successfully`); + assert.ok(!result.error, `Tampered ${path.basename(file)} did not exit promptly`); + assert.doesNotMatch(result.stdout || '', /PATHLAB_FORGE_SMOKE \{/); + } finally { fs.writeFileSync(file, original); } + assert.equal(hash(fs.readFileSync(file)), hash(original), 'Internal image restoration failed'); +} +console.log('Actual internal image rejected altered manifest, JAR, JVM and app archive. All bytes restored.'); diff --git a/desktop/test/packaged-smoke.cjs b/desktop/test/packaged-smoke.cjs new file mode 100644 index 0000000..ef44f18 --- /dev/null +++ b/desktop/test/packaged-smoke.cjs @@ -0,0 +1,32 @@ +// Run with node test/packaged-smoke.cjs . +const { spawn } = require('node:child_process'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const executable = process.argv[2]; +assert.ok(executable && path.isAbsolute(executable), 'Pass the actual packaged executable'); +const child = spawn(executable, ['--forge-smoke-test'], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); +let buffer = '', record; +const timeout = setTimeout(() => { child.kill(); process.exitCode = 1; }, 90000); +child.stderr.resume(); +child.stdout.on('data', chunk => { + buffer += chunk.toString('utf8'); + assert.ok(buffer.length <= 65536, 'Unexpected desktop output'); + for (const line of buffer.split(/\r?\n/)) { + if (line.startsWith('PATHLAB_FORGE_SMOKE ')) record = JSON.parse(line.slice('PATHLAB_FORGE_SMOKE '.length)); + } +}); +child.on('error', error => { clearTimeout(timeout); throw error; }); +child.on('exit', code => { + clearTimeout(timeout); + assert.equal(code, 0); + assert.ok(record, 'Packaged app did not report smoke evidence'); + assert.equal(record.packaged, true); + assert.deepEqual(record.renderer, { noNode: true, bridge: true, rendered: true, authenticatedApi: true, + workspaceReady: true, rendererGrantRejected: true, invalidIpcRejected: true }); + assert.equal(record.sandbox, true); + assert.equal(record.contextIsolation, true); + assert.equal(record.nodeIntegration, false); + assert.throws(() => process.kill(record.servicePid, 0), 'Java service survived desktop exit'); + console.log('Packaged desktop rendered; sandbox, bridge, private service and orderly shutdown passed.'); + console.log(`Screenshot: ${record.screenshot}`); +}); diff --git a/desktop/test/policy.test.cjs b/desktop/test/policy.test.cjs new file mode 100644 index 0000000..ba6433c --- /dev/null +++ b/desktop/test/policy.test.cjs @@ -0,0 +1,42 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { readiness, startupFailure, localUrl, externalUrl, allowedPath, windowState, serviceEnvironment } = require('../src/policy.cjs'); +test('startup failures display fixed actionable messages without service details', () => { + assert.equal(startupFailure('ordinary output'), null); + assert.match(startupFailure('PATHLAB_FORGE_FAILED {"protocol":1,"code":"DATA_LOCKED","detail":"secret"}'), /Another Forge process/); + for (const [code, action] of [['DATA_UPGRADE_BACKUP_FAILED', 'Free disk space'], ['DATA_DOWNGRADE_BLOCKED', 'Reinstall'], + ['DATA_VERSION_REQUIRED', 'Preserve'], ['DATA_UPGRADE_RECOVERY_REQUIRED', 'Close all Forge processes']]) { + const message = startupFailure(`PATHLAB_FORGE_FAILED ${JSON.stringify({ protocol: 1, code, detail: 'secret exception text' })}`); + assert.ok(message.includes(action)); + assert.ok(!message.includes('secret exception text')); + } + assert.throws(() => startupFailure('PATHLAB_FORGE_FAILED {"protocol":1,"code":"secret"}')); +}); +test('private readiness rejects remote, credentialed and mismatched origins', () => { + const line = value => 'PATHLAB_FORGE_READY ' + JSON.stringify({ desktopSecret: 'a'.repeat(43), ...value }); + assert.equal(readiness('ordinary service output'), null); + assert.equal(readiness(line({ protocol: 1, origin: 'http://127.0.0.1:1234/', launchUrl: 'http://127.0.0.1:1234/?launchToken=x' })).origin, 'http://127.0.0.1:1234'); + for (const origin of ['https://example.com/', 'http://localhost:1234/', 'http://x@127.0.0.1:1234/', 'http://127.0.0.1:1234/?x']) { + assert.throws(() => readiness(line({ protocol: 1, origin, launchUrl: origin }))); + } + assert.equal(localUrl('http://127.0.0.1:4321/app', 'http://127.0.0.1:1234'), false); +}); +test('bundled service ignores ambient JVM and reader overrides', () => { + assert.deepEqual(serviceEnvironment({ PATH: 'system', HOME: 'home', JAVA_TOOL_OPTIONS: '-javaagent:evil', + JDK_JAVA_OPTIONS: '-cp evil', _JAVA_OPTIONS: '-Xmx1m', CLASSPATH: 'evil', JAVA_HOME: 'external', + LD_PRELOAD: 'evil', DYLD_LIBRARY_PATH: 'evil', PATHLAB_FORGE_RUNTIME_ROOT: 'external', pathlab_forge_token: 'secret' }), + { PATH: 'system', HOME: 'home' }); +}); +test('external destinations and native reveal fail closed', () => { + assert.equal(externalUrl('https://viewer.example/app', ['https://viewer.example']), true); + for (const url of ['file:///tmp', 'javascript:alert(1)', 'https://viewer.example.evil/app', 'https://x@viewer.example']) { + assert.equal(externalUrl(url, ['https://viewer.example']), false); + } + const root = path.resolve('data'); + const file = path.join(root, 'exports', 'a'); + assert.equal(allowedPath(file, new Set([file])), true); + assert.equal(allowedPath(file, new Set()), false); + assert.equal(allowedPath('../outside', new Set()), false); + assert.deepEqual(windowState({ width: -1, height: Infinity }), { width: 1280, height: 840 }); +}); diff --git a/desktop/test/release.test.cjs b/desktop/test/release.test.cjs new file mode 100644 index 0000000..d52e569 --- /dev/null +++ b/desktop/test/release.test.cjs @@ -0,0 +1,89 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { inventory, exactInventory, preflight, signing, publicKey, validateReview, sha256 } = require('../release.cjs'); +const { validateCatalog } = require('../../scripts/distribution.cjs'); +test('inventory detects tamper and preflight rejects wrong target/ambiguous distribution', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-release-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, 'runtime/bin'), { recursive: true }); + fs.mkdirSync(path.join(root, 'lib')); + fs.writeFileSync(path.join(root, 'runtime/bin/java.exe'), 'synthetic'); + fs.writeFileSync(path.join(root, 'lib/app.jar'), 'synthetic'); + const manifest = { platform: 'win32', arch: 'x64', distribution: 'INTERNAL_NON_REDISTRIBUTABLE', internalValidation: true, requireProductionRuntime: false }; + fs.writeFileSync(path.join(root, 'runtime-manifest.json'), JSON.stringify(manifest)); + assert.equal(preflight(root, 'win32', 'x64', {}), false); + assert.throws(() => preflight(root, 'win32', 'arm64', {})); + assert.throws(() => preflight(root, 'darwin', 'x64', {})); + const before = inventory(root); + exactInventory(root, before); + fs.appendFileSync(path.join(root, 'lib/app.jar'), 'tampered'); + assert.throws(() => exactInventory(root, before), /changed/); + fs.writeFileSync(path.join(root, 'runtime-manifest.json'), JSON.stringify({ ...manifest, internalValidation: false })); + assert.throws(() => preflight(root, 'win32', 'x64', {}), /channel/); +}); +test('signing uses store identity/keychain with required timestamp and no passwords', () => { + assert.throws(() => signing('win32', {})); + assert.throws(() => signing('darwin', {})); + const options = signing('win32', { PATHLAB_FORGE_WINDOWS_CERT_SHA1: 'a'.repeat(40), PATHLAB_FORGE_TIMESTAMP_URL: 'https://timestamp.example.com' }); + assert.match(options.windowsSign.signWithParams, /\/fd SHA256.*\/tr https:.*\/td SHA256/); + assert.doesNotMatch(JSON.stringify(options), /password|certificateFile/); + assert.throws(() => signing('win32', { PATHLAB_FORGE_WINDOWS_CERT_SHA1: 'a'.repeat(40), PATHLAB_FORGE_TIMESTAMP_URL: 'http://timestamp.example.com' })); + assert.equal(signing('darwin', { PATHLAB_FORGE_MAC_IDENTITY: 'Developer ID Application: Example (ABCDEFGHIJ)', PATHLAB_FORGE_NOTARY_PROFILE: 'trusted-profile' }).osxNotarize.keychainProfile, 'trusted-profile'); + assert.equal(signing('darwin', { PATHLAB_FORGE_MAC_IDENTITY: 'Developer ID Application: Example (ABCDEFGHIJ)', PATHLAB_FORGE_NOTARY_PROFILE: 'trusted-profile' }).osxSign.continueOnError, false); +}); +test('feature catalog public key rejects missing and wrong algorithms', () => { + assert.throws(() => publicKey('PENDING_REVIEW')); + for (const algorithm of ['ed25519', 'x25519']) { + const value = crypto.generateKeyPairSync(algorithm).publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); + if (algorithm === 'ed25519') assert.equal(publicKey(value), value); + else assert.throws(() => publicKey(value)); + } +}); +test('review and catalog fail closed on incomplete or mismatched exact evidence', t => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-catalog-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const commit = 'a'.repeat(40); + fs.writeFileSync(path.join(directory, 'source.tar'), 'synthetic'); + fs.writeFileSync(path.join(directory, 'installer.exe'), 'synthetic installer'); + const sourceHash = sha256(path.join(directory, 'source.tar')); + fs.writeFileSync(path.join(directory, 'java-dependencies.json'), JSON.stringify([{ coordinate: 'test:synthetic:1', sha256: 'b'.repeat(64) }])); + fs.writeFileSync(path.join(directory, 'npm-dependencies.json'), '[]'); + const receipt = { schema: 'pathlab.forge.inventory/1', commit, target: 'win32-x64', version: '1.0.0-rc.1', source: { file: 'source.tar', sha256: sourceHash }, files: [{ path: 'app.jar', sha256: 'b'.repeat(64) }], + javaDependenciesSha256: sha256(path.join(directory, 'java-dependencies.json')), npmDependenciesSha256: sha256(path.join(directory, 'npm-dependencies.json')), legalFiles: [] }; + fs.writeFileSync(path.join(directory, 'inventory.json'), JSON.stringify(receipt)); + const reviewed = { file: 'source.tar', sha256: sourceHash }; + const review = { schema: 'pathlab.forge.distribution-review/1', commit, target: receipt.target, inventorySha256: sha256(path.join(directory, 'inventory.json')), + sourceSha256: sourceHash, decision: 'APPROVED', reviewer: 'synthetic test reviewer', applicationLicense: 'GPL-3.0-or-later', notices: reviewed, correspondingSource: reviewed, + buildInstructions: reviewed, licenseText: reviewed, components: [{ sha256: 'b'.repeat(64), decision: 'APPROVED', license: 'MIT' }] }; + validateReview(review, receipt, directory); + assert.throws(() => validateReview({ ...review, components: [] }, receipt, directory)); + assert.throws(() => validateReview({ ...review, commit: 'c'.repeat(40) }, receipt, directory)); + fs.writeFileSync(path.join(directory, 'review.json'), JSON.stringify(review)); + const artifact = { file: 'installer.exe', sha256: sha256(path.join(directory, 'installer.exe')), bytes: fs.statSync(path.join(directory, 'installer.exe')).size }; + fs.writeFileSync(path.join(directory, 'final-app.json'), JSON.stringify({ schema: 'pathlab.forge.final-app-inventory/1', commit, + platform: 'win32', arch: 'x64', version: receipt.version, distribution: 'PRODUCTION', sourceDirty: false, payloadBound: true, artifactSha256: artifact.sha256, files: receipt.files })); + const appInventory = { file: 'final-app.json', sha256: sha256(path.join(directory, 'final-app.json')) }; + const finalReview = { schema: 'pathlab.forge.final-distribution-review/1', commit, target: receipt.target, version: receipt.version, + artifactSha256: artifact.sha256, appInventorySha256: appInventory.sha256, distributionReviewSha256: sha256(path.join(directory, 'review.json')), + applicationLicense: 'GPL-3.0-or-later', decision: 'APPROVED', reviewer: 'synthetic reviewer', components: review.components }; + fs.writeFileSync(path.join(directory, 'final-review.json'), JSON.stringify(finalReview)); + const evidence = { schema: 'pathlab.forge.native-acceptance/1', commit, target: receipt.target, version: receipt.version, artifactSha256: artifact.sha256, result: 'PASS', platforms: ['Windows 10 22H2', 'Windows 11'], + dataPreserved: true, upgradeRollback: true, accessibility: true, journeys: true, timestampVerified: true, nestedVerified: true, + fusesVerified: true, policyVerified: true, payloadVerified: true, appInventorySha256: appInventory.sha256 }; + fs.writeFileSync(path.join(directory, 'acceptance.json'), JSON.stringify(evidence)); + fs.writeFileSync(path.join(directory, 'signature.json'), JSON.stringify({ ...evidence, schema: 'pathlab.forge.signature-verification/1' })); + const catalog = { schema: 'pathlab.forge.release/1', commit, version: receipt.version, target: receipt.target, channel: 'candidate', inventorySha256: review.inventorySha256, sourceSha256: sourceHash, + artifact, appInventory, nativeAcceptance: { file: 'acceptance.json', sha256: sha256(path.join(directory, 'acceptance.json')) }, signatureVerification: { file: 'signature.json', sha256: sha256(path.join(directory, 'signature.json')) } }; + assert.equal(validateCatalog(directory, catalog), catalog); + assert.throws(() => validateCatalog(directory, { ...catalog, target: 'darwin-arm64' })); + assert.throws(() => validateCatalog(directory, { ...catalog, channel: 'stable' })); + fs.writeFileSync(path.join(directory, 'final-review.json'), JSON.stringify({ ...finalReview, components: [] })); + assert.throws(() => validateCatalog(directory, catalog), /Unreviewed final/); + fs.writeFileSync(path.join(directory, 'final-review.json'), JSON.stringify(finalReview)); + fs.appendFileSync(path.join(directory, 'installer.exe'), 'tamper'); + assert.throws(() => validateCatalog(directory, catalog), /Changed artifact/); +}); diff --git a/desktop/windows-sign-hook.cjs b/desktop/windows-sign-hook.cjs new file mode 100644 index 0000000..5d35006 --- /dev/null +++ b/desktop/windows-sign-hook.cjs @@ -0,0 +1,10 @@ +// Module hook survives Squirrel's JSON/SEA boundary; a captured function does not. +const { pathToFileURL } = require('node:url'); +const { signing } = require('./release.cjs'); +const { verifyWindows } = require('./native-signing.cjs'); +module.exports = async file => { + try { verifyWindows([file]); return; } + catch { /* Sign new files once; keep reviewed signed payload bytes unchanged. */ } + const { sign } = await import(pathToFileURL(require.resolve('@electron/windows-sign')).href); + await sign({ ...signing('win32').windowsSign, files: [file] }); +}; diff --git a/docs/BRUSH_MASK_CONTRACT.md b/docs/BRUSH_MASK_CONTRACT.md new file mode 100644 index 0000000..f5175ef --- /dev/null +++ b/docs/BRUSH_MASK_CONTRACT.md @@ -0,0 +1,17 @@ +# Brush composition + +Brush Add/Subtract edits one explicitly selected closed ROI on its saved series, Z, T and view revision. It does not create a separate subtract ROI or assign historical strokes to a parent. Historical `brush_add` polygons remain valid independent ROIs. Historical standalone `brush_subtract` annotations remain stored but are rejected as analysis ROIs. + +The parent retains its ID, hierarchy, labels and style. `AnnotationRepository.composeBrush(datasetId, parentId, expectedRevision, operation, strokeGeometry, series, z, t, viewRevision)` performs a synchronized, atomic property-file update, changes the type to `roi_mask`, and increments its revision. Wrong scope, stale revision, zero-area strokes, unchanged results, empty results and interrupted work are rejected before writing. + +The geometry is `mask/1|x,y;x,y;...|x,y;x,y;...`, with an even-odd fill rule. Each contour has at least three vertices. Limits are 64 contours, 2,048 total vertices, 65,536 characters and finite nonnegative coordinates within signed integer source dimensions. A brush stroke has at most 1,024 vertices. Gesture capture cancels at its limit; it does not silently truncate the committed region. + +Java's existing `java.awt.geom.Area` performs union/subtraction. Its curve boundaries, including Java's cubic ellipse representation, are flattened at 0.01 source-pixel tolerance when the composite is serialized. The stored polygon contours thereafter define the mask, its area, its perimeter and pixel inclusion. Anisotropic calibration scales each segment in X/Y independently. No physical units are emitted without valid calibration. + +The renderer uses separate closed contours and SVG even-odd filling. Vertex and whole-object editing preserve contour boundaries. Crop export intersects the actual area with the crop and scales its contours, preserving holes and disconnected regions. Analysis snapshots retain the exact encoded mask, parent revision, plane/view, mask format and flattening tolerance; a later composition invalidates acceptance of previous or running analyses. + +`SlideViewer.onComposeBrush(parentId, operation, strokeGeometry, expectedRevision)` carries the revision captured by the gesture. The root HTTP handler must recheck current dataset/view and requested source dimensions, then call the repository method with this revision. Use `MaskContours.parse`/`shape` to validate every contour against source bounds; do not split this encoding as a legacy single polygon. The deterministic tool selector must include `roi_mask` and exclude standalone subtract strokes. + +Private result metadata preserves the complete `roi_mask` contours with `format: mask/1` and `fillRule: evenodd`. The legacy Viewer annotation contract currently rejects interior rings. Its batch route must fail closed for this type rather than silently filling holes. Viewer overlay rendering of this metadata is a separate qualification requirement. + +Local verification (2026-10-08): the full Java suite passed (313 tests, 9 existing real-reader fixture skips), all 87 frontend tests passed, and the TypeScript/production bundle budget passed. Tests cover real subtraction holes, disjoint additions, anisotropic measurements, persisted reload, clipped exports, exact analysis pixel inclusion, stale running/completed runs, cancellation, invalid scope/CAS, degenerate/unchanged/empty operations and complexity limits. The 1,025-vertex stress case reproduced a recursive whole-path regex stack overflow; bounded per-vertex validation fixes it. These are local deterministic fixture and component checks; actual native desktop gesture/pixel qualification and Viewer mask overlays remain separate evidence. diff --git a/docs/STUDY_AUTHORING_LOCAL.md b/docs/STUDY_AUTHORING_LOCAL.md new file mode 100644 index 0000000..daf691d --- /dev/null +++ b/docs/STUDY_AUTHORING_LOCAL.md @@ -0,0 +1,11 @@ +# Offline Study authoring + +Named SQLite drafts are editable and recoverable independently of immutable faculty-approved exports. Every saved edit, including local associations, clears the preview and reviewed-task list. Approval requires the checksum of the exact current content and explicit review of every task. Pack key/version collisions require a new version; an existing immutable export cannot be overwritten. + +The canonical checksum fixture comes from the current Viewer `study_pack_contract.py`, including UTF-8 text and numeric edge cases. Published definitions exclude Forge dataset/artifact/transform associations. The component takes persistence and publication callbacks; publication eligibility, source privacy, prepared teaching artifacts, authorization and live Viewer course integration are enforced by the server integration, not these callbacks alone. + +Question imports are bounded to 2 MiB and 500 tasks. CSV/JSON preserve authored content and explicit keys. Supported QTI is XML with a single identifier response and standard matching key; supported Moodle is a single-choice XML question with exactly one fully correct answer. Other XML forms remain incomplete for faculty conversion with imported scoring/source XML retained in local associations. Anki import supports text TSV exports; a two-field card remains incomplete until faculty supplies choices, a key and sources. Binary APKG files and QTI ZIP packages are not supported. HTML/script content is reduced to plain text. No medical prompts, keys or distractors are generated. + +`teachingTarget` maps an explicitly selected pixel ROI through an explicitly supplied registration into the selected teaching crop and normalized coordinates. Downsampling preserves normalized positions. Out-of-crop endpoints are rejected. A rotation or shear that changes an axis aligned rectangle requires manual target correction; the helper does not substitute its bounding box. The helper does not qualify anatomical correspondence. + +Focused tests cover checksum parity, immutable approval, stale revisions, recovery, question keys/sanitization, malformed draft shapes, autosave serialization, preview invalidation and normalized coordinate scoring. Native preview tiles and authenticated Viewer publication require separate integration evidence. diff --git a/docs/compatibility/DATA_UPGRADE_RECOVERY.md b/docs/compatibility/DATA_UPGRADE_RECOVERY.md new file mode 100644 index 0000000..1beb651 --- /dev/null +++ b/docs/compatibility/DATA_UPGRADE_RECOVERY.md @@ -0,0 +1,60 @@ +# Data upgrades and recovery + +The installed Java application's `Implementation-Version` supplies its semantic version. Under the held `forge.lock`, `ForgeApp` runs `DataUpgradeRecovery.prepare` before opening any SQLite store. The first installed launch on an unversioned root and each strictly newer version create a backup before schema/recovery writes. Same-version restarts reuse the already completed receipt. A receipt records `maximumAppVersion`, `minimumRollbackVersion`, `previousAppVersion`, UTC creation time, exact database paths/bytes/SHA-256 and successful SQLite integrity verification. + +The minimum rollback version conservatively equals the maximum version that reached the schema gate. An older binary is refused even if its schema might happen to work. A development/test build with no installed version may use an unversioned disposable root; it cannot open a root with installed-version receipts. Production startup requires an installed version. Reader self-test bypasses the gate and legacy Mac copying, and does not open or migrate data stores; it still acquires the root lock. Never point development server tests at an installed user root. + +## Backup scope and guarantees + +Only these databases are snapshotted, including every existing connection namespace: + +| Location | Contents | +| --- | --- | +| `forge.db` | Library, conversion/batch queue, base Viewer delivery store | +| `forge.db.<64 lowercase hex>.db` | Connection-scoped Viewer delivery jobs | +| `viewer-sync.db` and `viewer-sync.db.<64 lowercase hex>.db` | Library snapshots, edits, conflicts, retained-copy identities | +| `managed/analysis-runs.sqlite` | Deterministic analysis provenance/reviews | +| `managed/study-authoring.sqlite` | Study drafts, revision history and exports | + +The already installed sqlite-jdbc native backup API reads a consistent snapshot including committed WAL pages and preserves rowids. Source connections are read-only; no schema is written during backup. Sources and copies pass `PRAGMA integrity_check`; copies are flushed, hashed and verified. The helper uses a 256-page SQLite cache, 128-page backup steps and a 1 MiB hashing buffer. It refuses more than 512 databases, 2 GiB per database or 8 GiB total logical database size, insufficient free space, invalid receipts, symlinks/redirected paths and databases outside the canonical root. Busy retries are bounded. All Forge writers must obey the root lock; an unrelated SQLite editor is not covered by that lock. See the [SQLite backup API](https://www.sqlite.org/backup.html) and [pinned sqlite-jdbc API](https://github.com/xerial/sqlite-jdbc/blob/3.50.3.0/src/main/java/org/sqlite/core/DB.java). + +Snapshots are written into `upgrade-backups/snapshot-.partial`. Only after all copies and the receipt verify does an atomic directory rename publish `snapshot-`. `receipt.json` and `receipt.sha256` are flushed before publishing. Published receipts/backups are append-only from the application's perspective: it never replaces, edits or automatically removes them. Startup reads bounded receipts and their checksums to obtain the version floor; full snapshot hashing/integrity checks run at creation and explicit restore validation, not on every same-version startup. The helper stops at 1,024 history entries instead of deleting backups to free space. Interrupted `.partial` directories remain available for investigation and can be inspected separately; they do not authorize schema writes or rollback. + +A failed backup stops startup and leaves originals and any partial copies intact. Native filesystem/power-loss durability still requires actual target-platform acceptance. These metadata backups do not recursively copy WSI, OME, tile trees, offline copies, settings, JSON annotations, Study assets or other managed artifacts. Upgrades leave those existing files in place. They are required alongside the databases for a complete recovery; metadata snapshots are not full data-root backups. Existing legacy Mac migration retains its original root and happens before the new root's schema gate. + +## Actionable startup codes + +The private desktop startup pipe contains only protocol and one fixed code, never paths, exception text or credentials: + +| Code | Action | +| --- | --- | +| `DATA_UPGRADE_BACKUP_FAILED` | Keep Forge closed. Check available space, permissions, regular file paths and SQLite integrity. Retry after correcting the cause; retain partial backups. | +| `DATA_DOWNGRADE_BLOCKED` | Install the recorded version or a newer qualified version. Do not remove receipts to bypass the floor. | +| `DATA_VERSION_REQUIRED` | Use a correctly packaged versioned application, or a separate disposable development root. | +| `DATA_UPGRADE_RECOVERY_REQUIRED` | Backup history is damaged/incomplete. Preserve the complete root, investigate checksums and recover with a verified compatible copy. | + +## Explicit operator restore + +Restore is an operator action, never an automatic startup fallback. Stop the desktop and service first. Keep the entire current root and its WAL/SHM/journal files; preserve the original receipt history. Confirm that the selected completed backup's `previousAppVersion` identifies the desired pre-upgrade state; `unversioned` conveys no older-release compatibility claim. Compare the complete current database inventory with the receipt. Any newly created database or changed external annotation/artifact requires a deliberate recovery decision, not silent deletion. Restore every database in a selected receipt as one maintenance operation, not one account or draft database in isolation. + +Use the packaged Java libraries in an operator Java 17 program, with the root lock held for validation and **all** subsequent file operations: + +```java +import java.nio.file.*; +import org.pathlab.forge.runtime.*; + +try (var lock = DataRootLock.acquire(dataRoot)) { + DataUpgradeRecovery.verifyBackup(dataRoot, lock, receiptJson); + // Keep this lock open through the complete restore described below. +} +``` + +`verifyBackup` accepts only a receipt directly beneath this root's `upgrade-backups`, checks receipt SHA-256, each canonical database identity, exact bytes/SHA-256 and SQLite integrity. It performs no restoration. Run the operator program with the packaged library directory on its classpath, not with an older app's classes. Do not launch Forge between validation and restore. + +While holding that lock, create a uniquely named recovery directory **inside the same root**. Move each current database and its matching `-wal`, `-shm` and `-journal` sidecars into the recovery directory, preserving relative paths. Never copy an old main database on top of live newer WAL/SHM. Copy the verified snapshot to a new sibling `.restore.partial`, flush its file channel, verify its hash against the receipt, and atomically rename it into the now-vacant canonical database path. Do not replace or delete any original; preserve recovery files, receipts and external data. If a step fails, stop and repair the complete inventory under the lock before launching. The helper intentionally does not expose an automatic restore command because a metadata-only rollback can omit newer external work. + +After restoration, launch the same or a newer version than the root's maximum recorded version; normal schema upgrades run against the restored databases. Restoring a pre-upgrade database does **not** lower the floor. To run an older binary, use a separately preserved, complete compatible pre-upgrade root and qualified matching installer, with its original receipts, annotations, settings and artifacts. Do not delete newer version receipts or mix newer database sidecars into that root. Changes made after the snapshot remain only in the preserved newer root/recovery inventory until deliberately reconciled. + +## Evidence boundary + +`DataUpgradeRecoveryTest` exercises genuine SQLite databases with committed, uncheckpointed WAL, every namespace, upgrade/downgrade, versionless startup refusal before schema creation, receipt/snapshot tampering, corruption after an earlier successful copy, unavailable destination, orphaned WAL refusal, lock identity, path containment and real Windows junction rejection. Symbolic-link coverage is capability-gated on Windows hosts that prohibit link creation. These synthetic database checks do not qualify installer upgrade/rollback, power failure, Windows/macOS native artifact acceptance, signing or release publication. diff --git a/docs/compatibility/NON_AI_FEATURE_CENTER.md b/docs/compatibility/NON_AI_FEATURE_CENTER.md new file mode 100644 index 0000000..9a34cfe --- /dev/null +++ b/docs/compatibility/NON_AI_FEATURE_CENTER.md @@ -0,0 +1,58 @@ +# Non-AI Feature Center (M7) + +Only `pathology-tools` (PATHOLOGY) and `classical-analysis` (ANALYSIS) may occur +in a verified catalog. Pretrained AI and Training Lab remain unavailable. +No catalog URL or release public key is supplied by this change. No licensed pack, +release signing key or approved license decision is invented or committed. + +The existing Ed25519 envelope signs the complete JSON descriptor list: base64 +`payload` and `signature`. Pack signatures remain over UTF-8 `id`, `version` and +lowercase archive SHA-256 separated by newlines. Production descriptors now require +`platforms` (`windows-x86_64`, `macos-x86_64`, `macos-arm64`), SemVer +`minimumCoreVersion`, positive minimum memory/processor counts and +`licenseReviewStatus=APPROVED` with a nonempty license. Signed approval metadata +comes from the separately authorized catalog publisher. Missing metadata fails closed. +Core compatibility reads JAR Implementation-Version or explicit +`pathlab.forge.coreVersion`; unversioned development defaults to `0.2.0`. + +Catalog refresh is explicit. The original signed envelope persists for offline +inspection and is reverified after restart. Download begins only through install. +One installer runs at a time; `progress()` and `cancelInstall(id)` stay callable +while downloading/extracting/testing. Cancellation interrupts network work and kills +the active self-test, cleans partial files, and preserves the previous active version. + +Every version retains its verified archive and the signed catalog receipt. +Activation verifies the signature, complete archive hash and every extracted file +against the archive, then runs its Java 17 self-test with an adaptive heap and a +30-second deadline. Native/script entrypoints, unsafe/duplicate/device paths, +unmanifested files and symbolic links are rejected. Readers remain installed in +their independent core runtime. This is a capability gate, not a general plugin loader. + +A single atomically written active.json selects the exact active and previous +version plus enabled state. New versions never replace older version directories. +Failed activation keeps the old pointer. Restart revalidates active content and +self-test; failure restores the exact verified previous version, or leaves the pack +inactive if no verified fallback exists. Disable keeps installed versions; enable +revalidates the selected version; rollback selects the previous verified version. +Uninstall removes only the code-only feature-packs/ subtree. Settings, annotations, +analysis results and other user files outside that subtree are preserved. + +List responses expose available `version`, `activeVersion` and `installedVersions`; +version selection follows SemVer precedence, including prerelease numbers. +Backend helpers: install, enable, disable, activate(id, version), rollback, +uninstall, activeVersion, progress and cancelInstall. Root HTTP handlers own +explicit user actions and background execution. + +Validation uses synthetic Java self-test fixtures and ephemeral test signing keys; +these are not published packs, commercial licenses or release keys. Windows Java +contracts cover offline reload, signature/hash rejection, platform/memory/CPU/core +compatibility, safe extraction, cancellation, activation, disable/enable, exact +rollback, tamper recovery and preservation of settings/results. No real signed +catalog or downloaded licensed non-AI pack is qualified by these synthetic tests. + +Final Windows check: complete Java suite 244 tests, zero failures/errors, eight +existing real-fixture skips; nine Feature Center contract tests passed. Gradle +configuration cache reused, frontend production build/bundle budget passed, +repository policy and `git diff --check` passed. Windows staging handle sharing +was reproduced during upgrade; shared managed move/delete now has a two-second +retry ceiling and preserves the primary error if cleanup also fails. diff --git a/docs/compatibility/OS_MATRIX.md b/docs/compatibility/OS_MATRIX.md index c754d3a..5db6107 100644 --- a/docs/compatibility/OS_MATRIX.md +++ b/docs/compatibility/OS_MATRIX.md @@ -4,22 +4,17 @@ A platform is labelled supported only after a packaged artifact passes the liste | Platform | Architecture | Target channel | Status | |---|---|---|---| -| Windows 11 | x86-64 | Modern | Planned | -| Windows 10 | x86-64 | Modern | Planned | -| macOS 11+ | Apple Silicon | Modern | Planned | -| macOS 11+ | Intel | Modern | Planned | -| Windows 8.1 | x86-64 | Legacy | Investigation | -| Windows 7 SP1 | x86-64 | Legacy | Investigation | -| Windows Server 2012 R2 | x86-64 | Extended | Investigation | -| macOS 10.15 | Intel | Legacy | Investigation | -| macOS 10.14 | Intel | Legacy | Investigation | -| macOS 10.13 | Intel | Legacy | Investigation | -| macOS 10.11–10.12 | Intel | Extended | Not promised | +| Windows 11 | x86-64 | Modern | Internal package validated; release gated | +| Windows 10 22H2 | x86-64 | 1.0 | Actual-machine acceptance pending | +| macOS 14+ | Apple Silicon | 1.0 | Build, signing and actual-machine acceptance pending | +| macOS 14+ | Intel | 1.0 | Build, signing and actual-machine acceptance pending | Unsupported by design: - 32-bit Windows; - Windows XP/Vista; +- Windows versions before Windows 10 22H2; +- macOS versions before macOS 14; - PowerPC or 32-bit Intel Macs. ## Required platform tests @@ -37,6 +32,6 @@ Unsupported by design: 11. resumably upload; 12. recover queue after restart. -## Compatibility rule +## Release rule -Legacy support must not weaken HTTPS certificate validation, token storage, checksum verification or package validation. +Advertise only combinations qualified against the final signed artifact. An internal Windows 11 smoke test does not establish Windows 10 or macOS support. Earlier legacy investigation is historical and outside this release. diff --git a/docs/compatibility/PORTABLE_RUNTIME.md b/docs/compatibility/PORTABLE_RUNTIME.md new file mode 100644 index 0000000..6c0a239 --- /dev/null +++ b/docs/compatibility/PORTABLE_RUNTIME.md @@ -0,0 +1,48 @@ +# Portable runtime implementation (M3) + +The backend stays on Java 17. `stageElectronService` stages the application jars, +matching Java 17 runtime and host platform/architecture manifest under the ignored +`desktop/resources/service` tree. Package on the target OS/architecture; cross +packaging is rejected. Electron consumes this service without a system Java lookup. +Host-native jpackage reader images place mac reader data in +`PathLab Forge.app/Contents/Resources/reader-data`; Windows uses adjacent `reader-data`. + +Production reader assembly requires an approved redistribution status, notices for +every included component, exact platform, complete inventory fingerprint and pinned +Bio-Formats/libvips hashes. `reader-runtime.lock.properties` deliberately remains +pending. Approval is an external licensing decision; these checks do not grant rights. +The mandatory reader scope remains TIFF/SVS/VSI. SDPC remains optional, Windows-only +and blocked for production while its exact redistribution review is pending. + +Runtime staging rejects directory symlinks. Reviewed mac file symlinks require +`macos.file-symlinks.status=APPROVED` in the external runtime review and must resolve +inside the same component. Staging dereferences these file links into ordinary files +and verifies their hashes again; escaping links, directory links and unreviewed links +fail closed. Review native loader compatibility on the real mac package. + +macOS credentials use the login Keychain through the existing JNA dependency. +Credential values never enter CLI arguments or plaintext files. macOS defaults to +`~/Library/Application Support/PathLab Forge`. Before locking the new data root, +`ForgePaths.migrateLegacyMacData()` copies legacy `~/.pathlab-forge` under the legacy +ownership lock and a migration lock, then atomically renames staging. The original +remains intact. Existing target data is preserved; incomplete staging requires +recovery and is never silently discarded. + +Windows containment reports whether native Job assignment actually succeeded and +uses adaptive hardware limits. Other platforms report best-effort process tree +termination without claiming native memory enforcement. RSS samples contain measured +and total process counts; complete RSS is unavailable (`-1`) when any process could +not be measured. macOS uses native `proc_pid_rusage` resident size, never JVM heap as +process-tree RSS. + +Windows-only checks: Java compilation, portable unit contracts, repository policy, +full Java tests and staged service layout. Actual mac Keychain, native RSS, executable +permissions, dylib loading, signed/notarized app launch and TIFF/SVS/VSI fixtures are +unverified on this Windows host. Neither mac architecture is supported until the +OS_MATRIX artifact qualification passes on that actual platform. + +Native ABI references: [Apple Keychain lookup](https://developer.apple.com/documentation/security/seckeychainfindgenericpassword(_:_:_:_:_:_:_:_:)), +[Apple libproc declaration](https://github.com/apple-oss-distributions/xnu/blob/main/libsyscall/wrappers/libproc/libproc.h) +and [rusage structure](https://github.com/apple/darwin-xnu/blob/main/bsd/sys/resource.h). +The generic Keychain API is deprecated but retained for the scoped compatibility +range; real macOS credential round trips remain a release qualification requirement. diff --git a/docs/distribution/BUILD_AND_RELEASE.md b/docs/distribution/BUILD_AND_RELEASE.md new file mode 100644 index 0000000..e765981 --- /dev/null +++ b/docs/distribution/BUILD_AND_RELEASE.md @@ -0,0 +1,40 @@ +# Windows/macOS distribution inputs + +Target Forge license: GPL-3.0-or-later, subject to exact dependency and corresponding-source review. `LICENSE_STATUS.md` and the reader lock remain pending. No script writes an approval, supplies credentials, or publishes a catalog. Internal installers are NON_REDISTRIBUTABLE and confer no supported-platform claim. + +## Build matching source + +Use the exact commit in `inventory.json`, an existing Java 17 JDK of the target architecture, Node and pnpm, the committed Gradle wrapper, and both committed pnpm locks. Windows x64 and macOS x64/arm64 require separate native builds. Do not modify the release checkout after inventory creation. Keep output under ignored `build/`. + +1. `pnpm --dir desktop install --frozen-lockfile` +2. Run `gradlew test distributionInventory --no-configuration-cache -Ppathlab.forge.viewer.defaultOrigin=https://OFFICIAL_VIEWER_ORIGIN -Ppathlab.forge.readerRuntimeRoot=OWNER_APPROVED_RUNTIME -Ppathlab.forge.distributionChannel=INTERNAL` using Java 17. Replace the descriptive arguments with the existing authorized values; they are not release approval. +3. `pnpm --dir frontend test`; `pnpm --dir desktop test`; `pnpm --dir desktop check`; run the native `scripts/verify-repo.ps1` or `.sh`. +4. `pnpm --dir desktop make` creates an internal unsigned installer using the staged service. Never upload it as a release. + +`distributionDependencies` emits all resolved runtime JAR coordinates, hashes and embedded legal text. `distributionInventory` emits the exact staged service bytes, SHA-256 lock inputs, installed npm metadata and legal text, notice discovery and a `git archive` of the clean commit. Legal discovery is incomplete when upstream archives omit notices; review must obtain them. The project source archive alone is not full corresponding source for bundled third-party binaries: reviewer supplies exact third-party source, patches, build scripts and installation information as required. No private slides, databases, credentials, runtime bundle or ignored files enter the Git archive. + +## Production gate + +Restage with `PRODUCTION` and `-Ppathlab.forge.featureCatalogPublicKey=APPROVED_ED25519_SPKI_BASE64` only after runtime lock review. The real pinned public key is required; this document supplies none. Recreate inventory from the clean commit. Review inputs in `build/distribution-inputs/review.json` must have schema `pathlab.forge.distribution-review/1`, exact `commit`, `target`, `inventorySha256`, `sourceSha256`, applicationLicense `GPL-3.0-or-later`, named `reviewer`, and `decision: APPROVED`. Every staged file hash needs a `components` entry with a reviewed license and decision; group repeated licenses in the review tooling, not by skipping binaries. `notices`, `correspondingSource`, `buildInstructions`, `licenseText` each contain a relative `file` and SHA-256; files must reside inside the input directory. The source/build instruction/legal bundles must cover the exact final build, not a general upstream URL. Inventory hashes bind them to the reviewed inputs. Human review is a required trust boundary; the JSON validator cannot establish legal rights by itself. + +Set `PATHLAB_FORGE_RELEASE_INPUT` to that input directory. Windows uses the existing trusted certificate in the current user's certificate store/HSM: set public `PATHLAB_FORGE_WINDOWS_CERT_SHA1` and `PATHLAB_FORGE_TIMESTAMP_URL` (HTTPS RFC3161). SHA-256 signing and timestamping cover the packaged binaries and Squirrel full installer; no PFX password enters command arguments. macOS uses installed Developer ID Application identity `PATHLAB_FORGE_MAC_IDENTITY` and preconfigured `PATHLAB_FORGE_NOTARY_PROFILE`. Forge signs nested binaries with hardened runtime and notarizes/staples the app; postMake submits and staples the DMG using the same keychain profile. Neither debug credential logging nor private-key import is part of this workflow. No signing identity is presently evidenced, and no Mac host qualification is implied. + +## Final artifact and authenticated catalog + +Unpaired local users can install approved deterministic packs through Feature Center's **Import signed feature pack** action. Select the signed JSON catalog and matching ZIP in native dialogs. The pinned release key, catalog signature, exact archive size/hash, compatibility, safe extraction and self-test remain mandatory; local import grants no Viewer permissions. Obtain both files through authenticated Viewer feature downloads when online, then retain them for offline installation. No unsigned pack or automatically generated trust key is accepted. Disable/uninstall preserves user results and settings. + +Production `distributionInventory` verifies the approved owner reader lock, copies the reader source to managed build staging, signs its native payload, then assembles `service/reader-data/runtime/readers/FINGERPRINT`, its locked manifest and `readers-current.txt` from the final signed bytes. `verifyElectronReaders` and packaging preflight verify the exact usable pointer, target, required entry points and production manifest. Owner source files remain unchanged. Service signing preserves already verified reader signatures, signs remaining JVM native files, and precedes the outer byte inventory. Windows service binaries use the store identity and timestamp. macOS native service files are signed inside out with Developer ID, hardened runtime, timestamp and JVM JIT entitlements. No signing task runs for INTERNAL. Review the final signed service hashes. Forge verifies their native signatures again before packaging, embeds the complete inventory/channel/public key in `release-policy.json` inside app.asar, and verifies copied resources. A read-only final hook checks service integrity and writes the whole app inventory outside the signed application. Squirrel uses a module signing hook that survives its JSON/native-tool boundary and preserves already trusted, timestamped payload bytes. + +Pinned `@electron/fuses` 2.1.3 uses the [official native fuse API](https://www.electronjs.org/docs/latest/tutorial/fuses): RunAsNode, Node environment options and CLI inspection are disabled; embedded ASAR validation and ASAR-only loading are enabled. All nine known fuse values are supplied with strict unknown-fuse rejection and verified after writing and on the final executable. Packager embeds the ASAR digest in executable resources on Windows and app metadata/framework on macOS before final signing. Main requires this protected policy for every packaged channel and streams SHA-256 over all service JARs, JVM/readers/codecs, manifests and legal files before spawning Java. Altering external internalValidation, replacing a reader or adding files cannot downgrade the policy. Source/dev launches remain unqualified development. This installation invariant assumes the executable is the approved publisher's signed executable; final signing and native platform qualification remain required. + +macOS final app signing seals the outer bundle, signs nested Electron components, rejects signing errors, and notarizes/staples the app. postMake requires an Accepted DMG submission and staples/validates its ticket with the existing keychain profile. The official fuse tool restores Apple Silicon ad-hoc validity before final Developer ID signing. JVM JIT entitlements require actual architecture-specific qualification; no Mac execution is claimed. Windows unsigned-image checks can be repeated with `node desktop/test/packaged-smoke.cjs ABSOLUTE_EXECUTABLE` and `node desktop/test/packaged-integrity.cjs ABSOLUTE_EXECUTABLE`. The integrity check alters then restores only the internal image's manifest, JAR, JVM and app archive; it never opens user data. Do not run it on a signed production image. + +The independent acceptance receipt schema is `pathlab.forge.native-acceptance/1`; the native verifier emits `pathlab.forge.signature-verification/1`. `Help → Check for updates…` opens only the pinned Viewer origin's `/forge/downloads` in the existing browser session. It does not infer authentication or fetch an installer in the background. + +After signing, retain `final-app-TARGET.json` from the packaging hook with the inputs. It inventories every shipped Electron/service/resource byte, including Chromium/Electron legal text. Final signature verification binds its SHA-256 to the actual app and records `fusesVerified` and `policyVerified`; an internal image or dirty-source policy cannot obtain a production signature receipt. An additional `final-review.json` of schema `pathlab.forge.final-distribution-review/1` must bind `commit`, `version`, `target`, `artifactSha256`, `appInventorySha256` and `distributionReviewSha256` (the existing review file hash), require GPL-3.0-or-later target, named reviewer and approved decision, and contain a reviewed license/decision for every final app file hash in `components`. This review includes Chromium/Electron binaries, codecs and notices, not just Java/readers. Corresponding source and license text from the initial review must cover these final artifacts; do not approve upstream placeholders or merely the project's own Git archive. The catalog also includes `appInventory: {file,sha256}` and fails closed without this final exact-byte review. + +After make, the final app inventory includes `payloadBound: true` and `artifactSha256`: postMake extracted or mounted the final artifact and matched every prior app byte, including Chromium notices. Only known maker-added Squirrel files may augment the inventory, and final human review covers them too. Run `node scripts/verify-distribution-signatures.cjs inventory.json FINAL_INSTALLER PACKAGED_APP signature.json` on the native host. The legacy PACKAGED_APP argument supplies no provenance and is ignored. Windows first verifies the installer signature, maps its DATA/131 resource with LoadLibraryEx DATAFILE (never executes it), safely extracts the sole full nupkg to a fresh managed temporary directory, and verifies the actual extracted inventory, protected policy, fuses and all PE payload/bootstrap signatures against the trusted signer and timestamp. Extraction rejects traversal, symlinks, case collisions and oversized archives. macOS mounts the DMG read-only using hdiutil, requires the matching app, and verifies exact mounted bytes, nested signatures, pinned Developer ID/timestamp, Gatekeeper and app/DMG stapled tickets before detaching. Cleanup only removes the newly created managed verification directory after a successful unmount. `payloadVerified: true` is required in the receipt; a valid older installer signed by the same publisher fails byte/commit matching. Failure produces no PASS receipt. Installer verification does not install, upgrade, uninstall, or access user data. + +An independent native acceptance receipt must name exact `commit`, `version`, `target`, `artifactSha256`, `result: PASS`, `platforms` (Windows 10 22H2 and Windows 11, or macOS 14 on the named architecture), and set `dataPreserved`, `upgradeRollback`, `accessibility`, `journeys` true only from actual recorded tests. Include evidence for the active-plan journeys, clean machine startup, low-resource/process-tree measurements, NVDA/VoiceOver, keyboard/scaling, ten mixed slides, durable cancel/restart/disk-full, uncached offline use, tamper/wrong-architecture rejection, and user data preservation during upgrade/uninstall/rollback. The data root stays outside versioned installation directories; Squirrel lifecycle only changes shortcuts. Copying an older app may require a compatible data-schema backup; never delete data to make rollback pass. + +Prepare a local catalog input with schema `pathlab.forge.release/1`, `commit`, `version`, `target` (`win32-x64`, `darwin-x64`, `darwin-arm64`), `channel` (`candidate` for rc.N, otherwise `stable`), `inventorySha256`, `sourceSha256`, `artifact: {file,sha256,bytes}`, `nativeAcceptance: {file,sha256}`, `signatureVerification: {file,sha256}`. Run `node scripts/distribution.cjs validate INPUT_DIRECTORY CATALOG_JSON`. It rejects absent/tampered/mismatched input and incomplete native evidence. Reviewer approval and receipt files are local private inputs, not checked-in examples or invented PASS evidence. Viewer owns authenticated immutable storage/catalog publication, range responses and permissions; accepted input is passed to that separate workflow. No background updater or unsigned download fallback is added. diff --git a/docs/distribution/RELEASE_INPUT_SCHEMA.json b/docs/distribution/RELEASE_INPUT_SCHEMA.json new file mode 100644 index 0000000..63fb7a8 --- /dev/null +++ b/docs/distribution/RELEASE_INPUT_SCHEMA.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:pathlab:forge:release-input:1", + "description": "Local reviewed release inputs. Viewer maps admitted files to immutable private downloads; local receipt paths are not browser URLs.", + "type": "object", + "required": ["schema", "commit", "version", "target", "channel", "inventorySha256", "sourceSha256", "artifact", "appInventory", "nativeAcceptance", "signatureVerification"], + "properties": { + "schema": { "const": "pathlab.forge.release/1" }, + "commit": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(-rc\\.[1-9][0-9]*)?$" }, + "target": { "enum": ["win32-x64", "darwin-x64", "darwin-arm64"] }, + "channel": { "enum": ["candidate", "stable"] }, + "inventorySha256": { "$ref": "#/$defs/sha256" }, + "sourceSha256": { "$ref": "#/$defs/sha256" }, + "artifact": { "allOf": [{ "$ref": "#/$defs/file" }, { "type": "object", "required": ["bytes"], "properties": { "bytes": { "type": "integer", "minimum": 1 } } }] }, + "appInventory": { "$ref": "#/$defs/file" }, + "nativeAcceptance": { "$ref": "#/$defs/file" }, + "signatureVerification": { "$ref": "#/$defs/file" } + }, + "$defs": { + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "file": { "type": "object", "required": ["file", "sha256"], "properties": { + "file": { "type": "string", "minLength": 1 }, "sha256": { "$ref": "#/$defs/sha256" } + } } + } +} diff --git a/docs/evidence/2026-10-08-integrated-desktop-candidate.md b/docs/evidence/2026-10-08-integrated-desktop-candidate.md new file mode 100644 index 0000000..c331297 --- /dev/null +++ b/docs/evidence/2026-10-08-integrated-desktop-candidate.md @@ -0,0 +1,43 @@ +# Integrated desktop candidate — 2026-10-08 + +This is local engineering evidence for the non-AI release plan, not release admission. + +Forge code commit: `2a506dbe231c6190e9879cf7076308b5e81a179f` on `codex/forge-desktop-release`. Later documentation commits do not change which source built this installer. Forge remote main remains `7de0d9159ee0c999c52de51670b21c86609cf790`. No source or installer was publicly published by this work. + +| Check | Result | +|---|---| +| Full Java suite | 336 total: 327 passed, nine explicit real-fixture skips, zero failures/errors, 98 suites | +| Full frontend suite | 91 passed, 15 files | +| TypeScript, production Vite build and bundle budget | Passed; app bundle 199,045 bytes | +| Desktop policy tests | 12 passed | +| Desktop syntax and repository policy | Passed | +| Clean INTERNAL distribution inventory | Passed; `NON_REDISTRIBUTABLE_PENDING_REVIEW` | +| Windows x64 installer assembly | Passed using pnpm 11.9.0 | +| Extracted installer application | Rendered; sandbox/context isolation/no Node, native bridge, private service, rejected renderer grants/invalid IPC, orderly Java shutdown passed | + +Installer: `desktop/out/make/squirrel.windows/x64/PathLab Forge-1.0.0-rc.1 Setup.exe`, 367,686,656 bytes. SHA-256: `8357d2dced90c2ac6f2aa4dee17b947845223b08276a9e5179f7cd5b2424200e`. The final app receipt records `sourceDirty: false`, `payloadBound: true` and the same source commit/hash. It is unsigned and internal. Reader staging, reader signing and reader verification were explicitly skipped without approved inputs; this does not establish mandatory-format support in the shipped payload. + +Ignored exact-run evidence: + +- `build/integrated-offline-pack-final-check.txt` +- `build/frontend-integrated-feature-import.txt` +- `build/integrated-offline-pack-make.txt` +- `build/distribution-inputs/final-app-win32-x64.json` +- `build/distribution-inputs/integrated-internal-smoke-final.txt` +- Rendered screenshot: `C:\Users\enkso\AppData\Local\Temp\forge-desktop-smoke-gyEKmz\desktop-smoke.png`, visually inspected. + +## Final access and source-preservation corrections + +Fresh unpaired users previously could not install deterministic packs because installation required a privileged Viewer pairing. `e7cb83e` reuses the existing installer with native-selected signed catalog/archive inputs. `eebc2a9` adds distinct native feature-file grants and the bounded CSRF-protected import route/UI. Catalog signatures, pinned trust key, allowed non-AI identities, archive size/hash, copied-byte rehash, platform/resource compatibility, safe extraction, self-test, immutable receipt and activation checks remain required. No Viewer role, library scope or authority is granted by local import. + +Independent review executed unpaired import/restart/rollback, invalid-input rejection, activation/tamper recovery, cancellation and authenticated-origin transport checks. Root then identified input collisions with managed cleanup paths. A real signed-fixture probe reproduced deletion of the selected partial archive, staging archive and staging catalog. `2a506db` rejects both selected inputs within the managed feature directory, including normalized/canonical aliases, before mutation. The reviewer reran all three cases: both inputs remained byte-identical and no pack activated. The integrated regression and native-grant/CSRF/signature HTTP regression pass. + +## Open release gates + +Windows 10, Intel Mac and Apple Silicon Mac are not qualified. Native dialogs, accessibility, actual upgrade/uninstall/recovery, six-core/8-GB absolute resource limits and every mandatory real-slide journey need actual-artifact evidence. Windows signing identity, Apple signing/notarization capacity, approved exact runtime rights/readers, production catalog key/packs and corresponding third-party source remain absent or pending review. + +The M5 forced-termination suite observed one Windows SQLite `SQLITE_IOERR_TRUNCATE` on reopening WAL state. Subsequent passing runs do not close that recovery qualification issue; source/database files were preserved. See `2026-10-08-m5-inspection-intent.md`. + +Viewer ownership/download/study changes remain local until protected CI, PostgreSQL migration/isolation, backup/restore/rollback, protected deployment and authenticated production journeys pass. Git main is not proof of a deployed version. Release catalogs remain empty until immutable signed artifacts and real acceptance receipts qualify. AI/training/research functionality stays excluded and disabled. + +Viewer local verification completed on stable source/receipt commit `27c081886eac3d00a06b718fe57ec1aea93c4b4a`, integrating fresh main `81fcc80c5f2de81a7ff47f526aaf8f24742637fb`: 1,481 backend passed/111 skipped, 578 frontend passed, six browser journeys, 492 load passed/12 skipped. Final evidence-only head `57b12a1df5b3ac6889606198da757292799af05f` is clean. A source-traced authenticated-download membership restriction was fixed in the shared guard, without broadening library or pairing permissions. These local results do not close the deployment/platform gates above. diff --git a/docs/evidence/2026-10-08-m5-durable-batches.md b/docs/evidence/2026-10-08-m5-durable-batches.md new file mode 100644 index 0000000..72e7318 --- /dev/null +++ b/docs/evidence/2026-10-08-m5-durable-batches.md @@ -0,0 +1,25 @@ +# M5 durable batches - 2026-10-08 + +Engineering fixtures on Windows x64, Java 17, React 19; this document does not qualify real OME/SVS/VSI readers or an installer. + +`BatchStore` persists bounded batches (1-1000 unique dataset IDs, list pages of at most 100) in SQLite with WAL and synchronous FULL writes. The manifest contains each exact original `LocalDataset` before any conversion dispatch. `ConversionService.startExpected` checks source identity and all conversion settings, reserves the artifact/checkpoint, persists admission through the callback, and only then exposes the durable queue row. Failures in the manifest callback cannot dispatch work. Pending admissions and admitted records missing their queue row reuse their existing revision on recovery. + +Startup order is required: construct `ConversionService(repository, engine, derivative, managedRoot, true)`, construct `BatchService`, call `recoverPending()`, then `resumeQueueDispatch()`. This keeps persisted batch-wide cancellation requests ahead of every reader. It preserves the independent persisted user pause state. Root integration owns these calls and HTTP/App wiring. + +`BatchServiceTest` uses tiny synthetic TIFF-signature bytes, a synthetic reader/derivative, forced render/transfer/manifest failures, and persisted checkpoint interruption fixtures. Coverage: + +- Ten mixed items: seven successful synthetic local outputs, one forced render failure, two unconfigured source failures; retry exactly the failed render item, with nine total reader calls. +- SQLite snapshots and outcomes reopen; reports retain original settings and pinned artifact IDs after current dataset settings change or the dataset is removed. +- Failed scoped transfer reporting does not invoke conversion. +- Paused queue and pinned identity survive reopen; an interrupted manifest admission missing its queue row recovers once without duplicate entries. +- Cancellation and whole-batch cancellation intent survive reopen, including before scheduler activation; sources remain present and completed outputs remain present. +- SOURCE_VERIFIED, REGIONS_RENDERING, OME_VERIFIED and PACKAGE_COMMITTED restart fixtures retain identity. Verified OME is revalidated without invoking the reader again. +- Changed source during pause fails before rendering. Changed settings before admission fail closed. Manifest callback failure produces no queue row. +- CSV quotes/newlines and spreadsheet formula prefixes are escaped. +- Only explicit `startTeaching(id)` persists PREPARED_DZI_V2. Ordinary `start(id, format)` retains direct-OME coercion. Batch creation presently accepts direct OME only. + +`BatchReports.test.tsx` checks saved identities, exactly scoped per-item retry, original batch export and disabled cancellation for finished items. Production TypeScript build and existing bundle budget pass; full frontend suite: 80 tests passed (11 files). Existing App tests emit act warnings. + +Required release evidence remains open: real ten-slide OME/SVS/VSI batch, native process kill at inspection/rendering/validation/finalization, disk-full on actual managed storage, actual desktop UI/keyboard/scaling/assistive technology, packaged Windows/macOS artifacts, and authenticated scoped Viewer delivery. Checkpoint fixtures are not a native-process kill result. Teaching queue selection alone does not qualify actual package generation. + +Verification: `./gradlew.bat test` passed at the final worker tree; 7 focused M5 tests passed. `pnpm.cmd test` passed (80 frontend tests); `./gradlew.bat frontendBuild`, `scripts/verify-repo.ps1`, and `git diff --check` passed. Root must rerun integrated exact-head checks after wiring. diff --git a/docs/evidence/2026-10-08-m5-inspection-intent.md b/docs/evidence/2026-10-08-m5-inspection-intent.md new file mode 100644 index 0000000..6071c8f --- /dev/null +++ b/docs/evidence/2026-10-08-m5-inspection-intent.md @@ -0,0 +1,25 @@ +# M5 inspection intent and recovery - 2026-10-08 + +Worker base: `2d9bacf`; Windows x64, Java 17. These are engineering fixtures, not real OME/SVS/VSI or installer qualification. + +Batch creation persists every original source/settings record before metadata inspection. `BatchRun.Item.initialSnapshot` preserves that original intent; `snapshot` becomes the exact prepared configuration. Existing five-field JSON records default `initialSnapshot` to the old saved snapshot, without changing old settings. + +Unselected/unverified items run through the existing single conversion executor and maintenance scheduler. No extra pool is created. Queue pause prevents a new inspection; active conversions and metadata preparations share the same worker. Metadata inspection and streaming source hashing run outside the BatchService, ConversionService and dataset repository monitors. Full source identity is checked against the saved inventory before preparation; an upgraded background digest is accepted only for identical source metadata and matching content. The shared interactive series-selection logic supplies the deterministic initial RGB choice. A concurrent user source/series/crop/view change fails the item instead of being overwritten. + +The complete prepared snapshot is persisted atomically as PREPARED before the short dataset CAS or queue admission. Recovery can complete this checkpoint if the dataset CAS was not applied, or recognize an already-applied configuration. Existing expected admission still pins artifact/checkpoint identity before exposing the queue row. Batch cancellation is durable before interrupting an active inspection; late reader completion cannot admit a cancelled item. Preparation captures the saved attempt at scheduling and checks attempts, initial intent and snapshot identity at entry, success and failure, so an old cancelled reader cannot overwrite a newer retry even if it ignores interruption. Streaming digest cancellation checks between 1 MiB reads. BatchService.close unregisters its scheduler callback and interrupts its owned preparation. + +Root wiring: + +1. Remove the frontend per-slide `inspectDataset` loop before `createBatch`; send selected known IDs directly to `createBatch`. +2. Retain deferred ConversionService construction, `batchService.recoverPending()`, then `conversionService.resumeQueueDispatch()`. +3. Close BatchService before ConversionService and the dataset repository. No new API route is needed; creation returns a durable pending batch before reader completion. + +Focused fixtures cover blocked external inspection without blocked status/cancel, durable pause, concurrent user selection/CAS refusal, PREPARED-before-dataset-commit recovery, legacy JSON compatibility, source digest cancellation, cached-series source revalidation, delayed cancelled-reader failure/success after a retry, and a fresh ten-item batch with seven successes and three deliberate failures. Only the failed render is retried; successful artifacts retain their exact identity. Failures are labelled synthetic metadata/render errors and an unsupported synthetic channel plane. + +A forked Java fixture was actually terminated with Process.destroyForcibly() while its synthetic metadata reader was INSPECTING after the durable manifest commit. Reopening unchanged inputs completes the saved intent. Separate killed-process cases refuse modified input, missing input and an added VSI companion before a reader call. This exercises SQLite/WAL plus process interruption, but does not qualify Bio-Formats/QuPath/libvips or real native-reader process containment. + +Open qualification issue: one first combined forced-kill test run failed reopening the source SQLite library with SQLITE_IOERR_TRUNCATE at SqliteDatasetRepository.configure (`PRAGMA journal_mode=WAL`). Source/database files were preserved. The exact mutation test and the subsequent combined focused run passed without retries, sleeps, deletion or a database workaround. The intermittent truncate failure remains an explicit Windows database recovery qualification gap. + +`restoreSeries` now verifies the selected source before returning its in-memory cache, as requested by integration. This prevents stale geometry from bypassing source checks in brush/series callers. + +Final worker checks (base 2d9bacf plus this change): full Java `./gradlew.bat test --console=plain` passes 323 tests, 9 explicit skips, 0 failures/errors across 94 suites. The new delayed-unwind regression passes for late failure and swallowed-interrupt metadata success; successful source-keyed cached metadata can be reused without committing the obsolete attempt. The existing batch HTTP journey now polls the asynchronous durable outcome with a five-second bound. Full frontend `pnpm.cmd test` passes 87 tests in 15 files; production TypeScript/Vite/bundle build, repository policy and `git diff --check` pass. These counts precede root integration and do not claim installer or real-slide qualification. diff --git a/docs/evidence/DESKTOP_RELEASE_INTEGRATION_2026_10_08.md b/docs/evidence/DESKTOP_RELEASE_INTEGRATION_2026_10_08.md new file mode 100644 index 0000000..d4fa7c2 --- /dev/null +++ b/docs/evidence/DESKTOP_RELEASE_INTEGRATION_2026_10_08.md @@ -0,0 +1,13 @@ +# Integrated local checks, 2026-10-08 + +Baseline Forge main7de0d915 and reader32a4a36; Viewer mainfac64762. No deployed Viewer SHA verified. Historical reader artifacts are NON_REDISTRIBUTABLE. + +GitHub main Windows failure: run31576911883, ForgeLibraryApiTest.inspectsSelectsAndConvertsVsiInBackground line384, expected READY_TO_CONVERT after asynchronous verification. Reproduced deterministically on integration with ConversionVerificationRaceTest.verificationCompletingInsideInspectionCannotBeOverwrittenByTheOldRecord: verification finishes inside metadata inspection; old dataset save overwrites verified fingerprint/status. New test failed at fingerprint assertion. Fixed shared updateInspectedDataset with atomic repository.update using current identity and effective verified state. Both race tests and original library journey now pass. Full checks rerun after integration changes. + +Queued cancellation also reproduced: a paused queued dataset had no artifact revision; cancellation attempted to resolve an empty artifact ID. Shared cleanup guard now preserves valid artifacts and skips absent revisions. Pause persistence and paused queued cancellation tests pass. + +Native privileges: clean desktop /app cannot mint a session; renderer cannot authorize guessed source paths or quit lifecycle. Only private main-process secret authorizes native dialog grants/lifecycle. Loopback browser/headless compatibility retained. Desktop policy tests and syntax check pass. Final packaged artifact smoke must rerun after current integration. + +Export failure check: an altered artifact hash leaves existing completed destination intact; correct copy is verified and atomically replaces destination; source cannot be its own destination. One bounded active export, cancellation checked during copy/verification and locked finalization, no non-atomic fallback. + +Unverified: actual Mac Keychain/signing/process-tree telemetry, all four target-platform acceptance matrices, production owner isolation, authorized runtime rights, signed installers, authenticated production downloads, upgrade/restore acceptance. Unit tests and an unsigned internal installer do not close these gates. diff --git a/docs/evidence/UNIVERSAL_READER_WINDOWS_PACKAGE_ACCEPTANCE.md b/docs/evidence/UNIVERSAL_READER_WINDOWS_PACKAGE_ACCEPTANCE.md new file mode 100644 index 0000000..e29521b --- /dev/null +++ b/docs/evidence/UNIVERSAL_READER_WINDOWS_PACKAGE_ACCEPTANCE.md @@ -0,0 +1,41 @@ +# Universal Reader Windows Package Acceptance + +Date: 2026-08-14 + +## Decision + +The Windows x86-64 reader package is suitable for internal compatibility testing. It is explicitly `NON_REDISTRIBUTABLE`; it is not a signed release and must not be published while the redistribution review remains pending. + +## Package and runtime evidence + +- Application image: 3,799 files and 503,145,315 bytes. +- Reader manifest fingerprint: `4908c69a6abc52ae2d7b4072a9a8fe0d5b291173127c40844e2a019eccfc97ee`. +- Runtime catalog: 179 Bio-Formats scientific formats and 21 libvips loaders (200 advertised capabilities in this build). +- Clean application-data launch found the package-relative runtime without developer environment overrides. +- Bounded self-test decoded real center regions with Bio-Formats, libvips, the MDS reader, the SDPC native reader and libisyntax. +- The fixture matrix covered SVS, MDS, SDPC, iSyntax, JPEG-2000, OME-TIFF and PNG. + +The package contains owner-supplied runtime files copied from external staging. No runtime binary, installer or source slide is committed to the repository. + +## Local and Viewer workflow + +- A real SVS produced a 2,220 x 2,967 DZI and a readable JPEG tile. +- The selected-view conversion produced a 1,172,300-byte pyramidal RGB OME-TIFF with SHA-256 `51a2cb211550dff79e63d3800ca5b17baae74d2d66fdd77f9d4be9f28dc2facd`. +- Bio-Formats reopened the injected OME metadata at the correct dimensions and three channels. +- Repeating the conversion reused the verified artifact revision rather than rebuilding it. +- The authorized private Viewer accepted the OME pyramid, created private slide `f61b0fb5-f928-45c6-9f72-f135f5236832`, and completed the structured-results delivery. +- Restarting the packaged application with the same application-data directory recovered the dataset, saved view and approved artifact revision. + +## Broad SVS evidence + +The current packaged-runtime rescan of the user profile and WSI pool found 29 SVS paths: 25 structurally valid files opened and decoded a center tile, four had invalid or truncated TIFF structures, and zero structurally valid files had a reader failure. An earlier broader host snapshot found 162 paths (136 opened, 26 corrupt, zero reader failures); many duplicate or temporary paths from that snapshot are no longer present. Corrupt files remain reported as corrupt rather than being counted as reader defects. + +## Gates that remain closed + +- `productionDist` remains fail-closed until the authorized licensing review changes all required redistribution statuses to `APPROVED`. +- The internal application image is unsigned and has not been tested as a signed installer. +- Windows 10 and macOS x86-64/arm64 package matrices have not been run. +- SDPC remains owner-supplied and Windows-only pending separate decoder and FFmpeg redistribution approval. +- No push, merge, signing, publication or deployment is part of this evidence. + +Compatibility claims remain fixture- and capability-scoped. Recognized, pixel-decoded, exported, Viewer-rendered and uploaded are separate levels. diff --git a/docs/licensing/DEPENDENCY_REVIEW.md b/docs/licensing/DEPENDENCY_REVIEW.md index 473251e..fdf5531 100644 --- a/docs/licensing/DEPENDENCY_REVIEW.md +++ b/docs/licensing/DEPENDENCY_REVIEW.md @@ -7,11 +7,17 @@ Complete before public source release or installer distribution. | Java runtime | | | | | Pending | | JavaFX | | | | | Pending | | QuPath libraries/distribution | | | | | Pending | -| Bio-Formats | | | | | Pending | +| Bio-Formats | 8.5.0 | GPL-2.0-or-later/commercial | Contained child JVM | Exact approved license/notice required | Pending | | OpenSlide | | | | | Pending | -| libvips | | | | | Pending | +| libvips | 8.18.2 | LGPL-2.1-or-later plus codecs | Contained native child processes | Exact build and transitive notices required | Pending | | JPEG/TIFF codecs | | | | | Pending | | SQLite JDBC | | | | | Pending | +| Apache POI | 5.5.1 | Apache-2.0 | Linked | LICENSE and NOTICE | Pending | +| Sqray SDPC decoder and FFmpeg DLLs | Local runtime | Mixed/verify exact artifacts | Optional external runtime | License and notices required | Pending; do not redistribute | +| libisyntax | pyisyntax 0.1.6 wheel | BSD-2-Clause | Optional contained child runtime | LICENSE required | Pending packaged-platform review | +| pyisyntax | 0.1.6 | MIT | Optional contained child runtime | LICENSE required | Pending packaged-platform review | +| NumPy/Pillow/CFFI | 2.5.2 / 12.3.0 / 2.1.1 | Permissive; verify exact wheels | Optional contained child runtime | Licenses and notices required | Pending | +| Python runtime | 3.12.13 local proof | PSF | Optional contained child runtime | PSF license and notices | Pending | | tus client | | | | | Pending | Do not assume that because a component is open source, it can be bundled under any application license. diff --git a/docs/licensing/READER_RUNTIME_REVIEW.md b/docs/licensing/READER_RUNTIME_REVIEW.md new file mode 100644 index 0000000..60fbaaa --- /dev/null +++ b/docs/licensing/READER_RUNTIME_REVIEW.md @@ -0,0 +1,53 @@ +# Reader runtime redistribution gate + +Forge discovers Bio-Formats and libvips capabilities at runtime. “Supported” is a +best-effort probe result, not certification of every vendor variant. + +Release installers must not be produced until all of these are complete: + +1. commercial/GPL redistribution review for the exact Bio-Formats 8.5.x and + libvips builds; +2. signed upstream artifacts copied into an external, clean runtime root; +3. exact versions and SHA-256 hashes recorded in `reader-runtime.lock.properties`; +4. `redistribution.status=APPROVED` recorded after review; and +5. `verifyReaderRuntimeBundle` passes on Windows and macOS packaging hosts. + +Windows packaging has two deliberately separate tasks: + +- `internalReaderDist` creates an application image with a bundled Java runtime, + an owner-supplied reader runtime and a prominent `NON_REDISTRIBUTABLE` marker; +- `productionDist` uses the same manifest and application-image path, but requires + both this review gate and every included component in the external + `runtime-review.properties` file to be `APPROVED`. + +Reader files are installed under `runtime/readers/` and +activated through an atomically replaced `runtime/readers-current.txt` pointer. +This supports verified rollback and uninstall without replacing the application +runtime or touching source datasets. + +The repository does not contain reader binaries. Local development may use +`PATHLAB_FORGE_BFTOOLS`, `PATHLAB_FORGE_SDPC_RUNTIME`, +`PATHLAB_FORGE_ISYNTAX_RUNTIME`, and the existing libvips +discovery mechanism. This does not authorize redistribution. The SDPC adapter +must remain opt-in until the exact native decoder and its FFmpeg dependencies +have completed redistribution and notice review. Philips iSyntax uses the +independent BSD-2-Clause `libisyntax` decoder through the MIT `pyisyntax` +wrapper; exact wheels, Python runtime, transitive licenses and notices remain a +packaging gate even though local decoding does not require the Philips SDK. + +Expected external layout: + +```text +reader-runtime/ + bftools/bioformats_package.jar + vips/bin/vips.exe # Windows + vips/bin/vips # macOS + sdpc/DecodeSdpcDll.dll # optional Windows runtime after approval + isyntax/isyntax/ # pyisyntax/libisyntax runtime after approval + licenses// # exact license and notice files + runtime-review.properties +``` + +The external review file records exact component versions and decisions. It is +packaging input, not proof of approval by itself; release engineering must also +update the repository lock after the authorized review. diff --git a/docs/plans/active/FEATURE_INVENTORY.md b/docs/plans/active/FEATURE_INVENTORY.md new file mode 100644 index 0000000..1c068c1 --- /dev/null +++ b/docs/plans/active/FEATURE_INVENTORY.md @@ -0,0 +1,62 @@ +# Non-AI release feature inventory + +2026-10-08 integration inventory. Each required behavior has an implementation owner. Status refers to current code, not shipment; all actual Windows10/11 and Intel/Apple Silicon journeys remain unqualified unless separately receipted. + +| Visible capability / required behavior | Current state | Owner and release gate | +|---|---|---| +| Native launch, private local authorization, sandbox, second-instance focus | Implemented; unsigned Windows smoke | Integration M2; final image and all platforms | +| Native file/folder import | Implemented; focused security checks | Integration M2/M4; actual dialogs/accessibility | +| Dock close vs Quit, active work prompt, About and menus | Implemented; unverified native journey | Integration M2 | +| Missing runtime/backend/lock errors | Fixed actionable startup codes; native fault journeys pending | Integration M2/M3; actionable diagnostics | +| Window/theme/workspace preservation | Implemented but unverified artifact upgrade | Integration M2/M10 | +| Bundled Java/readers, portable manifest verifier | Implemented; production rights fail closed | Integration M3/Distribution M10 | +| Windows Credential Manager/macOS Keychain | Implemented; Mac unverified | Platform M3 and Sync M9 | +| Runtime channel/hash/architecture enforcement | Implemented; negative tests incomplete | Integration M3/M10 | +| OME/TIFF, SVS, complete VSI/ETS discovery/read/export | Implemented but actual release fixtures incomplete | Integration M0/M3/M4 | +| Optional iSyntax | Deliberately unavailable until approved/qualified | Distribution M3/M10 | +| SDPC | Windows-only; deliberately nonredistributable | Distribution M3/M10 | +| Series/channel/Z/T/projections | Capability scoped; acceptance incomplete | Integration M4; exact preview/export agreement | +| Source/view identity and stale preview rejection | Implemented; combined qualification pending | Integration M4 | +| Rectangle/ellipse/polygon/polyline/freehand/point/text/ruler/angle | Implemented with captured/rendered geometry | Integration M4; edit/save/reload real slides | +| Brush geometry and exact ROI mask | Implemented real even-odd Boolean masks; HTTP scope/bounds/CAS/source-change checks passed | Integration M4/M6 | +| Selection/edit/label/color/delete/hierarchy | Implemented; scoped tests | Integration M4; keyboard and subtree semantics | +| Anisotropic calibrated measurements / CSV | Implemented; native Save As and unscoped raw-coordinate export tested | Integration M4 | +| Measured storage capacity | Implemented; async managed-file scan with truthful incomplete bounds | Integration M4 | +| Native verified OME/package Save As/reveal/cancel | Implemented; failure checks, actual journey pending | Integration M4 | +| Result/measurement native export | Implemented; bounded copy/hash/atomic activation checks | Integration M4/M6 | +| Crop/downsample/verified artifact reuse | Implemented; actual fidelity gate pending | Integration M5 | +| Durable queue pause/resume | Implemented; persistence and queued cancel checks | Integration M5 | +| Cancellation/retry/mixed batch/restart/disk full | Durable manifest and synthetic failure/restart checks; actual ten-slide/disk-full journey open | Integration M5; ten-item forced failures | +| Per-slide batch report | Implemented; saved identity/outcome/delivery/next action, native CSV/JSON exports | Integration M5 | +| Truthful stages/resource waiting/unknown estimates | Implemented but incomplete actual telemetry qualification | Integration M3/M5 | +| Immutable bounded analysis runs/exact source/ROI/version/parameters | Implemented; SQLite/restart/cancel/source mutation tests | Tools M6 | +| H&E / stain-vector review / bounded normalization | Implemented; native end-to-end pending | Integration M6; qualified brightfield | +| TMA grid/edit/labels/missing/review | Implemented; reviewed hierarchy and selected-core analysis, current review provenance | Integration M6 | +| Tissue/QC masks/summaries | Implemented; exact source-mask/H&E lattice overlays tested; actual artifact qualification open | Integration M6 | +| Nucleus candidates/geometry/accept/reject | Implemented; approximate candidates only | Integration M6 | +| Registration manual transform | Implemented; saved manual landmarks, independent residuals and bounded overlay | Integration M6 | +| Accepted private deterministic result delivery | Implemented; immutable scope/review/transform, OME and Teaching package SHA binding | Sync M9 + Integration M6 | +| Non-AI signed Feature Center lifecycle | Implemented; paired downloads or unpaired native signed-file import, progress/cancel/enable/disable/uninstall/rollback and source preservation tested | Integration M7; production key/packs and platform journeys open | +| AI/training catalog entries | Deliberately unavailable and hidden | Integration M7; no model activation | +| Study drafts/autosave/version/import/export/manual keys/provenance | Implemented; local CAS/autosave/version/recovery tests | Teaching M8 | +| CSV/JSON/QTI/Moodle/Anki bounded import | Implemented text parsers; binary APKG/QTI archives unavailable | Teaching M8; no invented answers | +| Offline faculty preview/checksum/immutable approval | Exact immutable package binding, loaded-pixel review, local approval and separate Viewer resolution implemented; local HTTP journey passed | Teaching M8 | +| Explicit separate static-DZI Teaching delivery | Implemented generation and explicit prepared upload; Viewer privacy approval still required | Integration M8 | +| Desktop study authoring discovery/validation/publication | Local scoped proxy and Viewer shared publication helpers locally checked | Integration + Viewer M8; teaching authority | +| Viewer courses/invites/scoring/progress/retention/withdrawal | Existing Viewer; owner-aware local regressions passed | Viewer M1/M8 | +| Owned organization/user libraries and root/child scope | Viewer local implementation and SQLite checks complete; PostgreSQL/release open | Viewer M1; SQLite/Postgres/isolation/migrations | +| Reviewed grants/public content and revoked privileges | Viewer current authority/isolation regressions locally passed | Viewer M1; browser/worker/desktop regressions | +| Multi-organization explicit pairing selection | Viewer local browser journeys tested | Viewer M1 | +| Connection-scoped sync/cache/cursor/delivery | Implemented; captured immutable credentials and account-change UI isolation tested | Sync M9; private account changes | +| Complete remote snapshots/deletes/trash and atomic cursors | Implemented; local SQLite reconciliation tests | Sync M9 | +| Folder navigation/keyboard selection/conflict values | Implemented; current local UI tests | Integration UI + Sync M9 | +| Durable offline states/hash/atomic activation/uncached local rendering | Implemented; local OME route, poisoned partial retry and nonblocking cancel/status tests | Sync M9 + Integration renderer | +| Offset-aware transfer/idempotent create/reconciliation/results distinction | Implemented; synthetic fault/dedupe tests, Viewer compatible transport locally committed | Sync M9 + Viewer contracts | +| Exact licenses/transitives/notices/corresponding source | Incomplete; approval placeholders remain closed | Distribution M10 | +| Signed Windows and two signed/notarized/stapled DMGs | Incomplete; unsigned internal Windows installer exists | Distribution M10; identities and Mac capacity | +| Data-preserving upgrades/backup/migration/downgrade/uninstall | Locked WAL-safe snapshots and same-version orphan protection tested; actual installer upgrade/uninstall open | Distribution M10 | +| Private authenticated downloads/ranges/ETags/history/withdrawal | Viewer implementation locally checked; catalog empty until approved release | Viewer/Distribution M10 | +| Manual installer update link | Implemented approved Viewer browser link; actual authenticated download journey open | Integration M10 | +| Protected exact-head CI/deploy/live acceptance | Not done | Integration and independent acceptance reviewer | + +No dependency license decision, runtime redistribution permission, actual-platform result, production SHA or signing receipt is inferred from tests. All unchanged evidence under docs/evidence remains historical until qualified against the final integrated release commit. diff --git a/docs/plans/active/RELEASE_PROGRESS.md b/docs/plans/active/RELEASE_PROGRESS.md new file mode 100644 index 0000000..1c63877 --- /dev/null +++ b/docs/plans/active/RELEASE_PROGRESS.md @@ -0,0 +1,72 @@ +# Release integration register + +Baseline 2026-10-08: +- Forge origin/main: 7de0d9159ee0c999c52de51670b21c86609cf790. +- Selected reader baseline: 32a4a36 (six reader/runtime commits atop main). +- Integration branch codex/forge-desktop-release. +- Viewer origin/main: fac6476249ed287bb2afe8def1b0bcc9558104d7. +- Deployed Viewer: unverified, never inferred from Git. +- Explicit Adoptium JDK17 and bundled Node/pnpm for checks. +- AI/ADAPT/TRACE branches excluded; teaching/PIVOT selective references only. + +| Milestone | State | Required evidence | +|---|---|---| +| M0 baseline | Local baseline integrated | VSI failure repaired, feature inventory and shared fixtures recorded; deployed Viewer version unverified | +| M1 ownership | Viewer local qualification complete; release gates open | SQLite isolation/backfill/restore and independent review passed; PostgreSQL/protected CI/deployment pending | +| M2 Electron | Integrated; internal Windows packaged smoke passed | Native grants/quit/startup and extracted-app checks pass; actual signed/platform journeys pending | +| M3 runtime | Integrated, release blocked | Portable verifier/Keychain/process containment implemented; actual Mac and redistribution approval pending | +| M4 geometry | Integrated, qualification incomplete | Actual geometry/calibration/native exports implemented; final real-slide journeys pending | +| M5 batches | Durable intent/backend/HTTP/UI integrated | Persist-before-inspection, synthetic ten-item mixed batch, forced-process recovery and cancellation/retry generation checks pass; intermittent Windows SQLite truncate issue and actual ten-slide/disk-full acceptance open | +| M6 tools | Complete TMA/registration/mask result workflows integrated | Durable runs/reviews/core provenance/held-out residuals/real overlays tested; brush Boolean composition/HTTP conflicts tested; real-slide/native acceptance open | +| M7 packs | Integrated lifecycle, authenticated transport and unpaired signed-file import | Compatibility/cancel/rollback/offline catalog/native grants/source-preservation checks; production signed packs/platform journeys pending | +| M8 teaching | Drafts/imports/Teaching generation+upload/scoped publication integrated | Prepared package-SHA results tests pass; exact offline loaded-pixel association/preview locally passed; real Viewer publication open | +| M9 sync | Integrated recovery core and UI | Account binding/atomic snapshots/verified offline files/idempotency tested; Viewer negotiated creation locally passed; final offline/production journeys pending | +| M10 distribution | Producer/activation gates integrated; release blocked | Exact-byte inventory/source/signing pipeline exists; private downloads implemented; rights/signing/Mac/upgrade/native acceptance pending | + +No row complete merely because code exists. Record commits, checks and artifact receipts as available. Actual-platform, production, license and signing gates pending until evidenced. + +## Integrated evidence, 2026-10-08 + +- M0 original Windows VSI inspection failure reproduced with a forced source-verification race. Shared inspection update preserves the newer verified fingerprint and status; original regression and full Java suite pass. +- Geometry, runtime and Electron initial batches integrated through `3446f58`; full Java suite passed (261 tests, eight real-fixture skips), frontend 63 passed. +- Teaching backend/UI integrated as `f9dc24c`, `bc0b9a1`, `f2a2756`, `c1a40e2`; worker Java 267 tests (eight skips), frontend authoring/API checks passed. Local approval is deliberately gated until exact teaching pixels are loaded. +- Sync recovery and frozen deterministic-results transfer integrated as `010422f`, `e514dc9`; worker serialized Java 271 tests passed (eight skips). Root local retained-copy renderer and native export integration passed focused checks. This is not actual offline real-slide acceptance. +- Native private startup/lifecycle/feature/export routes integrated as `b78ebd9`; root full Java suite passed. Four desktop policy checks and Node syntax check passed. +- Viewer ownership local head `9b3305ba`: frontend 575 tests, explicit organization-selection browser journeys and independent review passed; full backend run plus two repaired-test reruns yielded 1,454 passing unique cases, 110 skipped. SQLite migration/backup/restore passed. PostgreSQL qualification is unavailable while Docker is stopped; this is not a single all-green backend run or deployment receipt. +- Windows 11 unsigned internal packaged smoke is historical pre-final integration evidence. Final Windows 10/11 and Intel/Apple Silicon Mac installers remain unqualified. +- No usable current-user Windows signing identity found. Runtime lock still says `PENDING_REVIEW`; do not bypass it. Apple signing/notarization, actual Mac capacity and deployed Viewer commit remain unverified. + +## Additional integrated checks + +- `7978a2f` captures one immutable credential for scope validation and HTTP dispatch; a real PATCH regression checks that account switching cannot send the old request using the new account's token. +- `7a77806` removes poisoned download partials after hash failure while retaining valid network-resume partials; transfer locking keeps status, cancellation and retained-copy viewing responsive. Synthetic failure/retry/isolation checks passed. +- `61684b2` renders reviewed TMA/candidate objects in the actual slide coordinate projection, in bounded 2,000-object pages. This remains read-only and preserves original analysis outputs. +- `41443f3` adds independent registration check pairs, target-pixel residuals and a bounded source/target overlay with opacity control. Saved exact-plane polyline/point annotations provide manually placed landmarks. Fit points cannot validate their own fit. Focused Java checks and production TypeScript/build/budget passed; frontend suite passed 80 tests before the final saved-landmark selector, then its focused three tests and TypeScript passed afterward. Real-slide registration acceptance remains open. +- Distribution producer batches integrated as `62c283c`, `3fdb04e`, `808e587`: pinned ASAR channel/trust key/service inventory, Electron integrity fuses, staged native signing and exact final-app/source/review binding. Worker unsigned Windows smoke/tamper receipts are internal-only and must be repeated on the final integration head. No production signing or rights approval is generated by these scripts. + +- M5 durable manifest, per-item source/configuration snapshots, retry/cancel/report and startup-before-dispatch recovery integrated as `5ffbcab`/`387c1f8`. Local batch HTTP/native CSV export/restart tests passed; real ten-slide failure qualification remains open. +- M6 TMA reviewed-core hierarchy, current-review core provenance, paged analysis history and actual tissue/H&E mask overlays integrated as `3800edd`/`cdb49b4`/`404c734`. Frontend full suite passed 85 cases, and focused HTTP/geometry/analysis tests passed. Actual qualified slide/tool journeys remain open. +- M8 explicit static-DZI generation and scoped study discovery/publication proxy integrated as `1c4246d`. `4af0a05` makes prepared upload reachable only through explicit Teaching delivery, retains direct OME defaults, and binds image/results to the package SHA. Both modes' synthetic HTTP transfers, structured results and completed-delivery dedupe passed. +- Independent packaging review's three concrete findings were repaired and integrated as `04ef244`: staged reader activation, actual final installer payload verification, and pnpm linked/transitive inventories. `b70a3c1` adds fixed actionable startup screens. Twelve desktop checks passed. Worker's actual unsigned installer extraction/smoke/tamper evidence is for its own earlier commit, not the combined release head. +- Locked WAL-safe metadata backup/downgrade gate and recovery instructions integrated as `01a6c3f`/`71294b4`. Independent review reproduced same-version orphan WAL data loss; `b8cd8e3` fixes the early return and its real committed-WAL regression passes. No schema writer runs before this gate in installed mode. Real native power-loss/upgrade/restore acceptance remains open. +- `7d332df` exposes actual volume capacity and async bounded managed regular-file usage, preserves unscoped annotations without guessed calibration, adds raw geometry to measurement export, and separates analysis paging cursors from displayed result count. Focused Java checks and App49 passed; bounded scans disclose partial totals explicitly. + +- Viewer scoped compatibility candidate `a55cf3a0` completed a full local run: 1,476 backend tests, 578 frontend tests, browser/owner-history checks and independent review passed. Negotiated create idempotency, shared native/browser study publication, signed catalog transport and authenticated immutable download routes are implemented. PostgreSQL qualification, protected CI, deployment and authenticated live journeys remain open. Default release catalog is empty. +- Exact combined `f26085f3b61e39541e83cf315e786bc3ece244f0` internal Windows installer SHA-256 `268bd4656bebc643ca9acd9814701531900579612a81c699005dbe13ab51d12e` was assembled, extracted and run. Packaged window rendered and verified sandbox/context isolation/no Node, private API, denied renderer grants/invalid IPC and orderly Java shutdown. Receipt: ignored `build/distribution-inputs/final-app-win32-x64.json`, log `integrated-internal-smoke.txt`. This is internal/nonredistributable, lacks qualified reader payloads, and predates later teaching/brush/batch changes; it is not a signed release acceptance receipt. +- `3fd6108` binds teaching tasks to immutable package metadata and actual loaded matching pixels. A real PreparedPackageBuilder regression caught the derivative index prefix defect; fixed previews reject redirected paths, bounded index/ranges and altered integrity before conditional 304. HTTP journey creates/associates/previews/reviews/approves an offline local-only pack; wrong pixel identity and tampered index are rejected. Actual slide/Viewer/native publication remains unqualified. +- `effa829`/`3ae4e11`/`b284c89` integrate real brush union/subtraction contours, holes/disjoint regions, anisotropic measurements, immutable masks/results and HTTP exact-plane/CAS/bounds/source conflicts. Full frontend89 and desktop12 passed, production build/budget passed; focused integrated HTTP/mask/study checks passed. `cd7208f` serializes final brush mutation with saved view changes and preflights every legacy annotation operation before transfer. `237dcc2` exposes older durable batch history using server offsets. + +## Integrated candidate qualification, 2026-10-08 + +- Forge candidate `480cac654de0f6f2fdefc109ef9e0982d3e4a66a`: Java 323 passed, nine explicit fixture skips (332 total), frontend 90 passed in 15 files, desktop 12 passed, production TypeScript/Vite/bundle budget and repository policy passed. Pinned pnpm 11.9.0 was used for final assembly. +- Its unsigned internal Windows x64 installer SHA-256 is `d6bdb5e6296bc1f5c86f9703ae84473eddee45730a300b1282bd1f7c279ec0fc`. The final receipt records clean source and exact extracted payload binding. The extracted installer application rendered and passed sandbox, context isolation, no Node, private service authorization, denied renderer file grants, invalid IPC and orderly Java shutdown. Evidence: ignored `build/distribution-inputs/final-app-win32-x64.json` and `integrated-internal-smoke-final.txt`. This is a candidate receipt, not redistribution/signing or real-reader qualification; subsequent changes require a new candidate. +- M5 integration `793bc3d`/`af765f9` persists source intent before inspection, recovers prepared checkpoints, cancels streaming digests and closes preparation before conversion shutdown. Independent delayed-reader reproduction now proves cancelled attempt one cannot overwrite retry attempt two. Full real-slide/disk-full/platform acceptance remains open. One observed SQLite/WAL `SQLITE_IOERR_TRUNCATE` after forced termination is retained as an unresolved Windows recovery qualification issue; subsequent passing checks do not erase it. See `docs/evidence/2026-10-08-m5-inspection-intent.md`. +- Fresh Viewer remote default branch advanced to `81fcc80c5f2de81a7ff47f526aaf8f24742637fb`; scoped candidate is incorporating that compatible backup-retention change and refreshing exact-head evidence. Deployment remains unverified. +- Exact authoritative Study Pack fixture source bytes are SHA-256 `c39c96744302120d336239ad839a506028e6df85513fba699f570d3d3c1e722f`, with LF checkout enforced. Old platform-dependent CRLF working-tree hashes are not the frozen contract hash. + +## Final local candidate for this implementation pass + +- Forge code/installer candidate `2a506dbe231c6190e9879cf7076308b5e81a179f`: 327 Java passed, nine explicit real-fixture skips, 91 frontend passed, 12 desktop passed, production build/budget, syntax and repository policy passed. Native signed-file import closes the fresh unpaired local-tool access gap without Viewer privilege changes. A reproduced managed-input deletion defect was fixed and independently retested; selected bytes survive all three cleanup-path collisions. +- Unsigned INTERNAL Windows x64 installer: 367,686,656 bytes, SHA-256 `8357d2dced90c2ac6f2aa4dee17b947845223b08276a9e5179f7cd5b2424200e`. Clean-source exact payload binding and the actual extracted-app security/render/shutdown smoke passed. No approved reader payload, signing receipt or supported-platform acceptance is implied. See `docs/evidence/2026-10-08-integrated-desktop-candidate.md`. Later documentation commits do not redefine this artifact's source commit. +- Viewer stable source/receipt `27c081886eac3d00a06b718fe57ec1aea93c4b4a` includes current main `81fcc80c5f2de81a7ff47f526aaf8f24742637fb`. Final full backend: 1,481 passed, 111 skipped; frontend 578, browser contracts six, load 492 passed/12 skipped. Final evidence-only head `57b12a1df5b3ac6889606198da757292799af05f` is clean. The authenticated download guard now admits valid browser sessions without requiring an active organizational membership; expired/revoked sessions and library/pairing privilege checks remain enforced. +- Release remains blocked on runtime/legal/corresponding-source approval, signing and Mac capacity, real-slide and actual-platform acceptance, the open Windows SQLite crash-recovery issue, PostgreSQL, protected CI/deployment/backup/rollback and authenticated production verification. Catalogs are empty and AI remains excluded. Neither a local passing branch nor this installer is a completed release. diff --git a/docs/plans/active/current.md b/docs/plans/active/current.md index 3ec5b45..15a45bb 100644 --- a/docs/plans/active/current.md +++ b/docs/plans/active/current.md @@ -1,38 +1,17 @@ -# Active Task — Modular Capability Release +# Active release — Windows/macOS non-AI Forge -Supersedes the completed direct-OME release-candidate brief by explicit -product-owner direction on 2026-08-11. +Product-owner approved 2026-10-08. Supersedes old milestone stop rules and AI/training plans. Historical measurements remain historical, not release acceptance. -## Fixed endpoint +Java17/React/SQLite streaming core; Electron desktop lifecycle; bundled approved Java/readers. Windows10 22H2/11 x64, macOS14+ Intel/arm; advertise only qualified platforms. 1.0.0-rc.N then 1.0.0; manual signed full-installer upgrades via authenticated Viewer. No background updater. -Deliver a lightweight Forge base plus a signed, offline-by-default Feature -Center. The base owns WSI inspection, direct preview, annotations, geometry -measurements, conversion, validation and upload. Optional Pathology Tools and -Classical Analysis packs are downloaded only after an explicit user action. +Include local discovery/viewing/qualified channels ZT, correct geometry/measurements, deterministic tools, durable batches, private owned libraries, secure sync/genuine offline copies, non-AI Feature Center and offline Study authoring. Normal direct OME; separately selected static-DZI Teaching delivery. Viewer owns identities, privacy, publication/courses/learner records. Exclude AI/models/training/Evidence Mentor/TRACE-SIM/diagnosis/research. Dormant Viewer AI stays disabled. -AI is not a core feature. Forge bundles no weights or training runtime. At most -one separately downloaded pretrained research model may be published after -license, provenance, resource and deterministic self-test gates pass. Training -Lab is a separate advanced pack using user-provided labelled data. +One active conversion/upload, bounded analysis/streaming. Preserve sources/artifacts/drafts/database. Owner-private org libraries require explicit grants. Keep existing role capabilities and Viewer Apache license; target Forge GPL3-or-later subject to exact dependency review. Unapproved binaries/placeholders confer no approval. -## Milestones +M0 baseline/contracts -> M1 ownership compatibility + M2 desktop + M3 runtime + M4 correctness -> M5 batch recovery/M6 tools/M7 packs/M8 teaching/M9 sync -> ownership activation -> M10 installers/downloads -> actual-artifact acceptance -> protected release/live verification. -1. Remove redundant preview work and finish adaptive runtime limits. -2. Add signed catalog, staged installation, self-test, rollback and uninstall. -3. Add bounded raw-region, PathObject, measurement and analysis-run contracts. -4. Add the Pathology Tools pack boundary: hierarchy, H&E, stains and TMA. -5. Add the Classical Analysis pack boundary: tissue/cells/classifier/QC/registration. -6. Add the optional pretrained-AI and Training Lab availability gates. -7. Add explicit private Viewer synchronization with conflict-safe verification. +Focused codex worktrees, three workers maximum plus integration. Shared schemas/server/startup/release contracts have one owner. Reproduce defects and run focused/full tests, UI production build, repository checks. Independent reviews for ownership/native privileges/migrations/transfers. Integrated exact-head checks required; worker green is insufficient. -## Stop rule +All advertised journeys need actual evidence: mandatory OME/SVS/VSI, correct calibrated geometry, ten-slide mixed batch, pause/cancel/restart/disk-full, immutable deterministic results, offline authoring/publication, uncached offline viewing, cross-owner denial, revoked permissions, safe transfers, tamper/wrong-architecture, upgrade/rollback/download ranges. Sign/timestamp Windows, nested Mac signing/notarization/staple, exact inventory/notices/corresponding source, immutable private authenticated catalog. -Stop after these seven milestones and their local validation report. Do not add -third-party plugins, arbitrary scripting, a dependency solver, cloud services, -microservices, distributed workers, additional models, merge or deployment. - -New conversions use only the measured `ome-dynamic-v1` direct path. The governed -same-slide release-candidate comparison found it 46.67% faster and 72.96% smaller -than prepared-v2. Existing prepared artifacts remain readable, but Forge does not -select or retry into that larger route. Image-quality gates, artifact reuse, -privacy and resumable upload remain regression contracts. +Clean Windows10/11, Intel/Apple Silicon artifact tests plus NVDA/VoiceOver/keyboard/scaling/process-tree measurements required. Signing identities/Mac capacity/lawful slides/runtime rights/deployment/backup access are blockers until verified. No purchases/new cloud spend automatic. Completion means shipped verified artifacts, not code or unit tests. RELEASE_PROGRESS.md records evidence. diff --git a/frontend/package.json b/frontend/package.json index b27e859..6a99ed9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@pathlab/forge-web", - "version": "0.1.0", + "version": "1.0.0-rc.1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/AnalysisMaskOverlay.tsx b/frontend/src/AnalysisMaskOverlay.tsx new file mode 100644 index 0000000..f3f065a --- /dev/null +++ b/frontend/src/AnalysisMaskOverlay.tsx @@ -0,0 +1,68 @@ +import { useId, useMemo } from 'react' +import { parseGeometry, shapePath, validGeometry } from './annotationGeometry' + +export type MaskChannel = 'tissue' | 'hematoxylin' | 'eosin' +type Point = { x: number; y: number } +const channels: Record = { + tissue: ['maskBitsetBase64', 40, 170, 100], + hematoxylin: ['hematoxylinMaskBitsetBase64', 90, 60, 210], + eosin: ['eosinMaskBitsetBase64', 235, 70, 135], +} + +export function maskRaster(outputs: Record, channel: MaskChannel) { + const [key, red, green, blue] = channels[channel] + const width = Number(outputs.maskWidth), height = Number(outputs.maskHeight) + const x = Number(outputs.maskX), y = Number(outputs.maskY) + const sourceWidth = Number(outputs.maskSourceWidth ?? width), sourceHeight = Number(outputs.maskSourceHeight ?? height) + if (![width, height].every((value) => Number.isInteger(value) && value > 0) + || width * height > 4_194_304 || ![x, y, sourceWidth, sourceHeight].every(Number.isFinite) + || sourceWidth <= 0 || sourceHeight <= 0 || typeof outputs[key] !== 'string' + || String(outputs[key]).length > 699_052) return null + let bits: string + try { bits = atob(String(outputs[key])) } catch { return null } + if (bits.length > Math.ceil(width * height / 8)) return null + const scale = Math.max(1, width / 512, height / 512) + const rasterWidth = Math.max(1, Math.floor(width / scale)), rasterHeight = Math.max(1, Math.floor(height / scale)) + const pixels = new Uint8ClampedArray(rasterWidth * rasterHeight * 4) + for (let row = 0; row < rasterHeight; row++) for (let column = 0; column < rasterWidth; column++) { + const bit = Math.floor(row * scale) * width + Math.floor(column * scale) + if (!(bits.charCodeAt(Math.floor(bit / 8)) & (1 << (bit % 8)))) continue + pixels.set([red, green, blue, 180], (row * rasterWidth + column) * 4) + } + return { width: rasterWidth, height: rasterHeight, pixels, x, y, sourceWidth, sourceHeight } +} + +/** Render inside the viewer annotation SVG using its exact source-coordinate projection. */ +export function AnalysisMaskOverlay({ outputs, channel, project, provenance }: { + outputs: Record; channel: MaskChannel; project: (point: Point) => Point + provenance: { annotationType: string; annotationGeometry: string } +}) { + const clipId = useId() + const image = useMemo(() => { + const raster = maskRaster(outputs, channel) + if (!raster) return null + const canvas = document.createElement('canvas') + canvas.width = raster.width; canvas.height = raster.height + const context = canvas.getContext('2d') + if (!context) return null + const data = context.createImageData(raster.width, raster.height) + data.data.set(raster.pixels); context.putImageData(data, 0, 0) + return { ...raster, url: canvas.toDataURL('image/png') } + }, [outputs, channel]) + if (!image) return null + const roi = parseGeometry(provenance.annotationGeometry) + if (!['rectangle', 'ellipse', 'polygon', 'freehand', 'brush_add', 'brush_subtract'].includes(provenance.annotationType) + || !validGeometry(provenance.annotationType, roi)) return null + const origin = project({ x: image.x, y: image.y }) + const right = project({ x: image.x + image.sourceWidth, y: image.y }) + const bottom = project({ x: image.x, y: image.y + image.sourceHeight }) + const matrix = [(right.x - origin.x) / image.sourceWidth, (right.y - origin.y) / image.sourceWidth, + (bottom.x - origin.x) / image.sourceHeight, (bottom.y - origin.y) / image.sourceHeight, origin.x, origin.y] + if (!matrix.every(Number.isFinite)) return null + return + ({ x: point.x - image.x, y: point.y - image.y })))} /> + 1 ? ' sampled lattice' : ''}`} + href={image.url} x={0} y={0} width={image.sourceWidth} height={image.sourceHeight} preserveAspectRatio="none" + clipPath={`url(#${clipId})`} style={{ imageRendering: 'pixelated' }} /> + +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f3cbb9..07494a6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,25 +5,38 @@ import { import { ArrowsOut, ArrowsClockwise, + ArrowUp, CaretDown, + CaretRight, CheckCircle, + CloudArrowDown, CloudArrowUp, Crosshair, + Database, + FileText, + Globe, Folder, FolderOpen, + HardDrive, House, + Info, Key, + LockKey, List, MagnifyingGlassMinus, MagnifyingGlassPlus, Moon, + PencilSimple, Plus, + Ruler, SidebarSimple, SignOut, Sun, + Tag, Trash, UploadSimple, Wrench, + X, } from '@phosphor-icons/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { FormEvent, ReactNode, Ref } from 'react' @@ -40,7 +53,13 @@ import type { ViewerPairing, } from './api' import { estimateCropOutput, isFullSlideCrop, type CropBox } from './crop' -import { SlideViewer } from './SlideViewer' +import { SlideViewer, type AnalysisOverlayShape } from './SlideViewer' +import { DeterministicTools, type DeterministicRun } from './DeterministicTools' +import { StudyAuthoring, type StudyDraftRecord, type StudySlide } from './StudyAuthoring' +import { TeachingSlidePreview } from './TeachingSlidePreview' +import { teachingAssociationFor, captureTeachingTarget } from './teachingAssociations' +import { BatchReports, type BatchSummary } from './BatchReports' +import type { MaskChannel } from './AnalysisMaskOverlay' import { DIRECT_PREVIEW_VERSION } from './viewerConfig' const ACTIVE_STATUSES = new Set(['VERIFYING_SOURCE', 'INSPECTING', 'QUEUED', 'WAITING_RESOURCES', 'CONVERTING', 'OPTIMIZING_OME', 'VALIDATING', 'GENERATING_DZI', 'DZI_READY']) @@ -49,6 +68,20 @@ const CANCELLABLE_STATUSES = new Set(['QUEUED', 'WAITING_RESOURCES', ...CONVERSI const QUEUEABLE_STATUSES = new Set(['READY', 'READY_TO_CONVERT', 'CONVERSION_READY', 'FAILED', 'CANCELLED']) const NO_ANNOTATIONS: AnnotationRecord[] = [] const DEFAULT_LOCAL_FOLDER = 'Unfiled' +const isCompactWorkspace = () => typeof window !== 'undefined' && window.innerWidth <= 1180 + +function viewerVisibility(slide?: Pick) { + if (slide?.visibility) return slide.visibility === 'published' ? 'Published' : 'Private' + const normalized = (slide?.state || '').trim().toLowerCase().replace(/[\s-]+/g, '_') + return ['published', 'public', 'shared'].includes(normalized) ? 'Published' : 'Private' +} + +function metadataText(value: unknown): string { + if (value === undefined || value === null || value === '') return '' + if (Array.isArray(value)) return value.map((entry) => metadataText(entry)).filter(Boolean).join(', ') + if (typeof value === 'object') return Object.values(value as Record).map((entry) => metadataText(entry)).filter(Boolean).join(', ') + return String(value) +} export function App() { const [datasets, setDatasets] = useState([]) @@ -63,6 +96,15 @@ export function App() { const [inspectorOpen, setInspectorOpen] = useState(true) const [railExpanded, setRailExpanded] = useState(false) const [activeTool, setActiveTool] = useState('pan') + const [selectedAnnotationId, setSelectedAnnotationId] = useState('') + const [analysisRuns, setAnalysisRuns] = useState([]) + const [analysisHasMore, setAnalysisHasMore] = useState(false) + const [analysisNextOffset, setAnalysisNextOffset] = useState(0) + const [analysisMask, setAnalysisMask] = useState<{ context: string; runId: string; channel: MaskChannel }>() + const [exportRunId, setExportRunId] = useState('') + const [analysisOverlay, setAnalysisOverlay] = useState<{ context: string; runId: string; shapes: AnalysisOverlayShape[] } | null>(null) + const [overlayPage, setOverlayPage] = useState(0) + const analysisContext = useRef('') const [cropDrafts, setCropDrafts] = useState>(() => { try { return JSON.parse(window.localStorage.getItem('pathlab-forge-crop-drafts-v1') || '{}') @@ -77,6 +119,7 @@ export function App() { const [importOpen, setImportOpen] = useState(false) const [importing, setImporting] = useState(false) const [importPath, setImportPath] = useState('') + const [importError, setImportError] = useState('') const [removeTarget, setRemoveTarget] = useState() const [pairingOpen, setPairingOpen] = useState(false) const [viewerUrl, setViewerUrl] = useState('http://127.0.0.1:5173') @@ -85,21 +128,60 @@ export function App() { const [viewerUpload, setViewerUpload] = useState() const [remoteLibrary, setRemoteLibrary] = useState({ items: [], folders: [], conflicts: [] }) const [remoteSyncReady, setRemoteSyncReady] = useState(false) + const [remoteLastChecked, setRemoteLastChecked] = useState() const [selectedRemoteId, setSelectedRemoteId] = useState('') const [remoteRename, setRemoteRename] = useState<{ id: string; current: string; value: string }>() const [annotationsByDataset, setAnnotationsByDataset] = useState>({}) + const [studyOpen, setStudyOpen] = useState(false) + const [studyLoaded, setStudyLoaded] = useState(false) + const [studyDrafts, setStudyDrafts] = useState([]) + const [teachingSlides, setTeachingSlides] = useState([]) + const [studyError, setStudyError] = useState('') + const [studyExport, setStudyExport] = useState() const [featureOpen, setFeatureOpen] = useState(false) const [features, setFeatures] = useState([]) const [featureLoading, setFeatureLoading] = useState(false) + const [featureProgress, setFeatureProgress] = useState() const [libraryMode, setLibraryMode] = useState<'local' | 'viewer'>('local') const [selectedDatasetIds, setSelectedDatasetIds] = useState([]) const [localFolders, setLocalFolders] = useState(() => readStored('pathlab-forge-folders-v1', [DEFAULT_LOCAL_FOLDER])) const [folderByDataset, setFolderByDataset] = useState>(() => readStored('pathlab-forge-folder-map-v1', {})) const [batchRemoveIds, setBatchRemoveIds] = useState([]) + const [savedBatches, setSavedBatches] = useState([]) + const [batchNextOffset, setBatchNextOffset] = useState(0) + const [batchHasOlder, setBatchHasOlder] = useState(false) + const [batchReportsOpen, setBatchReportsOpen] = useState(false) + const [batchExportRequest, setBatchExportRequest] = useState<{ id: string; format: 'csv' | 'json' }>() const [theme, setTheme] = useState<'light' | 'dark'>(() => document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light') const navigatorButtonRef = useRef(null) - + const remoteEpoch = useRef(0) + const connectionIdentity = useRef('') + + const clearRemote = () => { + remoteEpoch.current++ + setRemoteLibrary({ items: [], folders: [], conflicts: [] }); setSelectedRemoteId(''); setRemoteRename(undefined) + setRemoteSyncReady(false); setRemoteLastChecked(undefined); setViewer(null) + setTeachingSlides([]) + } + const adoptConnection = (next: ViewerConnection | undefined) => { + const identity = next?.connected ? JSON.stringify([next.viewerUrl, next.deviceName, next.connectionRevision || '']) : '' + if (identity !== connectionIdentity.current) { clearRemote(); connectionIdentity.current = identity } + setConnection(next) + } + const remoteAction = async (operation: () => Promise, synchronize = false, successNotice = '') => { + const epoch = remoteEpoch.current + setError('') + try { + await operation() + if (epoch !== remoteEpoch.current) return + const library = await (synchronize ? api.syncViewerLibrary() : api.viewerLibrary()) + if (epoch === remoteEpoch.current) { setRemoteLibrary(library); if (successNotice) setNotice(successNotice) } + } catch (cause) { if (epoch === remoteEpoch.current) setError(viewerConnectionMessage(cause)) } + } const selected = datasets.find((item) => item.id === selectedId) ?? datasets[0] + analysisContext.current = `${selected?.id}|${selected?.viewRevision}|${selected?.configurationRevision}` + const overlayRun = analysisRuns.find((run) => run.id === analysisOverlay?.runId) + const inspectorVisible = inspectorOpen && Boolean(libraryMode === 'viewer' ? remoteLibrary.items.length : selected) const selectedRemote = remoteLibrary.items.find((item) => item.id === selectedRemoteId) ?? remoteLibrary.items[0] const cropDraft = selected ? cropDrafts[selected.id] : undefined @@ -123,6 +205,34 @@ export function App() { const selectedAnnotations = selected ? annotationsByDataset[selected.id] ?? NO_ANNOTATIONS : NO_ANNOTATIONS + const overlayRoi = selectedAnnotations.find((annotation) => annotation.id === overlayRun?.annotationId) + const currentGeometryView = !viewingRevision || viewingRevision.configurationRevision === selected?.configurationRevision + const visibleAnalysisOverlays = currentGeometryView && analysisOverlay?.context === analysisContext.current && overlayRun?.status === 'SUCCEEDED' + && !overlayRun.stale && overlayRoi?.revision === overlayRun.provenance.annotationRevision + && overlayRoi.geometry === overlayRun.provenance.annotationGeometry ? analysisOverlay.shapes : [] + const maskRun = analysisRuns.find((run) => run.id === analysisMask?.runId) + const maskRoi = selectedAnnotations.find((annotation) => annotation.id === maskRun?.annotationId) + const visibleAnalysisMask = currentGeometryView && analysisMask?.context === analysisContext.current && maskRun?.status === 'SUCCEEDED' && !maskRun.stale + && maskRun.datasetId === selected?.id && maskRun.provenance.sourceFingerprint === selected.sourceFingerprint + && maskRun.provenance.series === selected.selectedSeries && maskRoi?.series === maskRun.provenance.series + && maskRoi.z === maskRun.provenance.z && maskRoi.t === maskRun.provenance.t && maskRoi.viewRevision === maskRun.provenance.viewRevision + && maskRoi.revision === maskRun.provenance.annotationRevision && maskRoi.geometry === maskRun.provenance.annotationGeometry + ? { run: maskRun, channel: analysisMask.channel } : undefined + const showAnalysisObjects = async (requested: DeterministicRun) => { + const context = analysisContext.current + try { + const [run, review] = await Promise.all([api.analysisRun(requested.id), api.analysisReview(requested.id)]) + if (analysisContext.current !== context) return + if (!run || run.status !== 'SUCCEEDED' || run.stale) throw new Error('Result changed; rerun against the current source and ROI.') + const shapes = review.objects.map((object) => ({ + id: `${run.id}:${object.id}`, type: object.kind === 'TMA_CORE' ? 'rectangle' : 'point', + geometry: object.geometry, label: `${object.classification || object.kind} ${object.properties.missing === 'true' ? '(missing)' : object.properties.accepted === 'false' ? '(rejected)' : '(candidate)'}`, + color: object.properties.missing === 'true' || object.properties.accepted === 'false' ? '#d75667' : '#39c7a3', + })) + setAnalysisRuns((current) => [run, ...current.filter((item) => item.id !== run.id)]); setAnalysisOverlay({ context, runId: run.id, shapes }); setOverlayPage(0); setActiveTool('pan') + setNotice(`Inspecting ${shapes.length} saved objects on the slide. Review corrections in Tools; exports retain every object.`) + } catch (cause) { if (analysisContext.current === context) setError(message(cause)) } + } useEffect(() => { if (!selected || selected.width <= 0 || selected.height <= 0) { @@ -162,6 +272,13 @@ export function App() { window.localStorage.setItem('pathlab-forge-folder-map-v1', JSON.stringify(folderByDataset)) }, [folderByDataset]) + useEffect(() => { + const inspector = document.querySelector('.pathlab-viewer-inspector') + if (!inspector) return + inspector.inert = !inspectorVisible + inspector.setAttribute('aria-hidden', inspectorVisible ? 'false' : 'true') + }, [inspectorVisible]) + const refresh = useCallback(async () => { try { const next = await api.datasets() @@ -174,14 +291,17 @@ export function App() { }, []) useEffect(() => { + const bootstrapEpoch = remoteEpoch.current void api.bootstrap() .then(([initialDatasets, initialCapabilities]) => { setDatasets(initialDatasets) setCapabilities(initialCapabilities) setSelectedId(initialDatasets[0]?.id || '') setNotice(initialDatasets.length ? 'Local workspace restored' : 'Choose a slide to begin') + void api.features().then((result) => setFeatures(result.features)).catch(() => undefined) void api.getViewerConnection().then((next) => { - setConnection(next) + if (bootstrapEpoch !== remoteEpoch.current) return + adoptConnection(next) if (next.viewerUrl) setViewerUrl(next.viewerUrl) }).catch(() => undefined) void refresh() @@ -243,13 +363,31 @@ export function App() { } } - const handleNativeImport = async () => { + const refreshAnalysis = useCallback(async () => { + const context = analysisContext.current + if (selected?.id) { const runs = await api.analysisRuns(selected.id); if (context === analysisContext.current) { setAnalysisRuns(runs); setAnalysisHasMore(runs.length === 100); setAnalysisNextOffset(runs.length) } } + }, [selected?.id]) + useEffect(() => { + let cancelled = false + setAnalysisRuns([]) + if (selected?.id) void api.analysisRuns(selected.id).then((runs) => { + if (!cancelled) { setAnalysisRuns(runs); setAnalysisHasMore(runs.length === 100); setAnalysisNextOffset(runs.length) } + }).catch((error) => { if (!cancelled) setError(message(error)) }) + return () => { cancelled = true } + }, [selected?.id]) + + const handleSelectedImport = async (paths: string[]) => { + if (!paths.length) return + setImportError('') setImporting(true) try { - const next = await api.chooseDatasets() - finishImport(next) + let next: { datasets: Dataset[] } | undefined + for (const selectedPath of paths) next = await api.importDataset(selectedPath) + if (next) finishImport(next) } catch (nextError) { - setError(message(nextError)) + const detail = message(nextError) + setImportError(detail) + setError(detail) } finally { setImporting(false) } @@ -257,20 +395,22 @@ export function App() { const handlePathImport = async () => { if (!importPath.trim()) return - setImportOpen(false) + setImportError('') setImporting(true) try { const next = await api.importDataset(importPath.trim()) finishImport(next) } catch (nextError) { - setError(message(nextError)) - setImportOpen(true) + const detail = message(nextError) + setImportError(detail) + setError(detail) } finally { setImporting(false) } } const handleProjectImport = async (path?: string) => { + setImportError('') setImporting(true) try { const next = await api.importProjectFolder(path) @@ -279,12 +419,23 @@ export function App() { ? `${next.project.imported} slides imported from project folder${next.project.failed ? ` · ${next.project.failed}` : ''}` : 'Project folder selection closed') } catch (nextError) { - setError(message(nextError)) + const detail = message(nextError) + setImportError(detail) + setError(detail) } finally { setImporting(false) } } + useEffect(() => window.forgeDesktop?.onCommand((command) => { + if (command === 'open-sources') setImportOpen(true) + if (command === 'open-directory') { + void window.forgeDesktop?.selectDirectory().then((path) => { + if (path) void handleProjectImport(path) + }).catch((error) => setError(message(error))) + } + })) + const removeDataset = async () => { if (!removeTarget) return try { @@ -336,6 +487,18 @@ export function App() { } } + const updateViewDefinition = async (view: api.ViewDefinition) => { + if (!selected) return + try { + const updated = await api.updateView(selected.id, view) + setDatasets((current) => current.map((item) => item.id === updated.id ? updated : item)) + setNotice('Series, axes, channels, and render profile saved') + } catch (nextError) { + setError(message(nextError)) + throw nextError + } + } + const beginConversion = async () => { if (!selected) return try { @@ -351,16 +514,23 @@ export function App() { const selectedIds = new Set(ids) const candidates = datasets.filter((item) => selectedIds.has(item.id) && QUEUEABLE_STATUSES.has(item.status)) if (!candidates.length) return - setNotice(`Preparing ${candidates.length} slides for the adaptive queue…`) - for (const candidate of candidates) { - try { - if (candidate.selectedSeries < 0) await api.inspectDataset(candidate.id) - await api.convert(candidate.id) - } catch (nextError) { - setError(`${candidate.displayName}: ${message(nextError)}`) - } - } - await refresh() + setNotice(`Saving ${candidates.length} slides to the durable queue…`) + try { + const batch = await api.createBatch(candidates.map((candidate) => candidate.id)) + setSavedBatches((current) => [batch, ...current.filter((item) => item.id !== batch.id)]) + setBatchReportsOpen(true); await refresh() + setNotice(`Saved ${batch.items.length}-slide batch; inspect per-slide outcomes and retry without replacing completed artifacts`) + } catch (cause) { setError(message(cause)) } + } + const readBatchReport = useCallback(api.batchReport, []) + const openBatchReports = async () => { + setBatchReportsOpen(true) + try { const page = await api.batches(); setSavedBatches(page); setBatchNextOffset(page.length); setBatchHasOlder(page.length === 50) } catch (cause) { setError(message(cause)) } + } + const loadOlderBatches = async () => { + const page = await api.batches(batchNextOffset) + setSavedBatches((current) => [...current, ...page.filter((item) => !current.some((existing) => existing.id === item.id))]) + setBatchNextOffset((current) => current + page.length); setBatchHasOlder(page.length === 50) } const removeSelectedDatasets = async () => { @@ -373,38 +543,47 @@ export function App() { setNotice('Selected slides were removed from the Forge library; originals and completed exports were preserved') } - const syncViewer = async () => { - setLibraryMode('viewer') - setNavigatorOpen(true) + const syncViewer = async (revealLibrary = true) => { + let epoch = remoteEpoch.current + if (revealLibrary) { setLibraryMode('viewer'); setNavigatorOpen(true); if (isCompactWorkspace()) setInspectorOpen(false) } try { const next = await api.getViewerConnection() - setConnection(next) + if (epoch !== remoteEpoch.current) return + adoptConnection(next) + epoch = remoteEpoch.current + const libraryEpoch = remoteEpoch.current if (next.connected) { - setRemoteLibrary(await api.syncViewerLibrary()) - setRemoteSyncReady(true) - } else { - setRemoteSyncReady(false) - } - setNotice(next.connected - ? 'Viewer library synchronized' - : 'Connect to PathLab Viewer before opening its private library') - if (!next.connected) connect() + const library = await api.syncViewerLibrary() + if (libraryEpoch !== remoteEpoch.current) return + setRemoteLibrary(library); setRemoteSyncReady(true); setRemoteLastChecked(Date.now()) + } else { clearRemote(); connect() } + setNotice(next.connected ? 'Viewer library synchronized' : 'Connect to PathLab Viewer before opening its private library') } catch (nextError) { - setRemoteSyncReady(false) - setError(viewerConnectionMessage(nextError)) + if (epoch !== remoteEpoch.current) return + setRemoteSyncReady(false); setError(viewerConnectionMessage(nextError)) } } - useEffect(() => { if (libraryMode !== 'viewer' || !connection?.connected) return + let epoch = remoteEpoch.current + let stopped = false, pending = false const timer = window.setInterval(() => { - void api.syncViewerLibrary().then((next) => { - setRemoteLibrary(next) - setRemoteSyncReady(true) - }).catch(() => setRemoteSyncReady(false)) + if (pending || stopped) return + pending = true + void (async () => { + const currentConnection = await api.getViewerConnection() + if (stopped || epoch !== remoteEpoch.current) return undefined + adoptConnection(currentConnection); epoch = remoteEpoch.current + if (!currentConnection.connected) return undefined + return api.syncViewerLibrary() + })().then((next) => { + if (!next || stopped || epoch !== remoteEpoch.current) return + setRemoteLibrary(next); setRemoteSyncReady(true); setRemoteLastChecked(Date.now()) + }).catch(() => { if (!stopped && epoch === remoteEpoch.current) setRemoteSyncReady(false) }).finally(() => { pending = false }) }, 5_000) - return () => window.clearInterval(timer) - }, [libraryMode, connection?.connected]) + return () => { stopped = true; window.clearInterval(timer) } + }, [libraryMode, connection?.connected, connection?.viewerUrl, connection?.deviceName, connection?.connectionRevision]) + useEffect(() => { setExportRunId(''); setAnalysisOverlay(null) }, [selected?.id, selected?.viewRevision, selected?.configurationRevision]) const toggleTheme = () => { const next = theme === 'dark' ? 'light' : 'dark' @@ -477,33 +656,37 @@ export function App() { const connect = () => setPairingOpen(true) const beginPairing = async () => { + clearRemote(); connectionIdentity.current = ''; setConnection(undefined) + const epoch = remoteEpoch.current try { setError('') const next = await api.startViewerPairing(viewerUrl) + if (epoch !== remoteEpoch.current) return setPairing(next) setNotice(`Waiting for Viewer approval of ${next.userCode}`) } catch (nextError) { - setError(viewerConnectionMessage(nextError)) + if (epoch === remoteEpoch.current) setError(viewerConnectionMessage(nextError)) } } useEffect(() => { if (!pairing) return undefined let stopped = false + const epoch = remoteEpoch.current let busy = false const poll = async () => { if (busy || stopped) return busy = true try { const next = await api.exchangeViewerPairing() - if (stopped) return - setConnection(next) + if (stopped || epoch !== remoteEpoch.current) return + adoptConnection(next) setViewerUrl(next.viewerUrl) setPairing(undefined) setPairingOpen(false) setNotice('PathLab Viewer connected with a revocable desktop credential') } catch (nextError) { - if (stopped) return + if (stopped || epoch !== remoteEpoch.current) return const normalized = message(nextError).toLowerCase() if (!normalized.includes('pairing_pending') && !normalized.includes('not approved yet')) { setError(viewerConnectionMessage(nextError)) @@ -524,9 +707,10 @@ export function App() { }, [pairing]) const disconnectViewer = async () => { + clearRemote(); connectionIdentity.current = ''; setConnection(undefined); setPairing(undefined); setViewerUpload(undefined) try { await api.revokeViewerConnection() - setConnection(undefined) + adoptConnection(undefined) setPairing(undefined) setViewerUpload(undefined) setPairingOpen(false) @@ -557,6 +741,7 @@ export function App() { const created = await api.createAnnotation(selected.id, { type: activeTool, geometry, + configurationRevision: selected.configurationRevision, label: activeTool === 'text' ? 'Text annotation' : '', }) setAnnotationsByDataset((current) => ({ @@ -585,6 +770,25 @@ export function App() { } } + const updateLocalAnnotation = async (id: string, values: { geometry?: string; label?: string; color?: string }) => { + if (!selected) return + const annotation = selectedAnnotations.find((item) => item.id === id) + if (!annotation) return + try { + const updated = await api.updateAnnotation(selected.id, annotation, values) + setAnnotationsByDataset((current) => ({ ...current, + [selected.id]: (current[selected.id] || []).map((item) => item.id === id ? updated : item) })) + } catch (failure) { setError(message(failure)) } + } + + const composeLocalBrush = async (parentId: string, operation: 'brush_add' | 'brush_subtract', geometry: string, revision: number) => { + if (!selected) return + try { + const updated = await api.composeBrush(selected.id, parentId, operation, geometry, revision, selected.configurationRevision) + setAnnotationsByDataset((current) => ({ ...current, [selected.id]: (current[selected.id] || []).map((item) => item.id === parentId ? updated : item) })) + } catch (failure) { setError(message(failure)) } + } + useEffect(() => { if (!viewerUpload || !['UPLOADING', 'VERIFYING_OME', 'SYNCING_RESULTS', 'RETRYING'] .includes(viewerUpload.state)) return @@ -598,9 +802,10 @@ export function App() { }, [viewerUpload?.state]) const storage = useMemo(() => ({ - usableBytes: Math.max(0, 512 * 1024 ** 3 - datasets.reduce((sum, item) => sum + item.sourceBytes, 0)), - effectiveCapacityBytes: 512 * 1024 ** 3, - }), [datasets]) + managedUsage: capabilities?.managedUsage, + usableBytes: capabilities?.usableBytes ?? 0, + effectiveCapacityBytes: capabilities?.effectiveCapacityBytes ?? 0, + }), [capabilities]) const loadFeatures = async (catalogRefresh = false) => { setFeatureLoading(true) @@ -618,12 +823,35 @@ export function App() { void loadFeatures() } - const changeFeature = async (feature: api.FeaturePack) => { + const importFeature = async () => { + setFeatureLoading(true) + try { + const files = await window.forgeDesktop?.selectFeatureFiles() + if (!files) return + await api.importFeature(files) + setFeatures((await api.features()).features) + } catch (cause) { + setError(message(cause)) + } finally { + setFeatureLoading(false) + } + } + + useEffect(() => { + if (!featureOpen || !featureLoading) return + const timer = window.setInterval(() => { + void api.featureProgress().then(setFeatureProgress).catch((error) => setError(message(error))) + }, 500) + return () => window.clearInterval(timer) + }, [featureOpen, featureLoading]) + + const changeFeature = async (feature: api.FeaturePack, action: api.FeatureAction, version = '') => { setFeatureLoading(true) try { - if (feature.state === 'INSTALLED') await api.disableFeature(feature.id) - else if (feature.state === 'DISABLED') await api.uninstallFeature(feature.id) - else await api.installFeature(feature.id) + if (action === 'disable') await api.disableFeature(feature.id) + else if (action === 'uninstall') await api.uninstallFeature(feature.id) + else if (action === 'install') await api.installFeature(feature.id) + else await api.featureAction(feature.id, action, version) setFeatures((await api.features()).features) } catch (nextError) { setError(message(nextError)) @@ -632,6 +860,34 @@ export function App() { } } + const rememberStudy = async (operation: Promise) => { + const saved = await operation + setStudyDrafts((current) => [saved, ...current.filter((value) => value.id !== saved.id)]) + return saved + } + const openStudy = async () => { + setStudyOpen(true); setStudyError('') + try { setStudyDrafts(await api.studyDrafts()); setStudyLoaded(true) } + catch (cause) { setStudyError(message(cause)) } + } + const exportAuthoredStudy = async (id: string, format: 'json' | 'csv' | 'approved', checksum = '') => { + setStudyError('') + try { + if (window.forgeDesktop) { + const destination = await window.forgeDesktop.selectExportDestination(`study-pack.${format === 'csv' ? 'csv' : 'json'}`) + if (destination) setStudyExport(await api.exportStudy(id, format, checksum, destination)) + } else { + const link = document.createElement('a'); link.href = api.studyExportUrl(id, format, checksum); link.download = `study-pack.${format === 'csv' ? 'csv' : 'json'}`; link.click() + } + } catch (cause) { setStudyError(message(cause)) } + } + useEffect(() => { + if (!studyExport || !['COPYING', 'VERIFYING'].includes(studyExport.status)) return + let stopped = false + const timer = window.setTimeout(() => { void api.exportState().then((next) => { if (stopped) return; if (next.id === studyExport.id) setStudyExport(next); else setStudyError('This export status was replaced by another job.') }).catch((cause) => setStudyError(message(cause))) }, 500) + return () => { stopped = true; window.clearTimeout(timer) } + }, [studyExport]) + const rail = ( { setLibraryMode('local') setNavigatorOpen(true) + if (isCompactWorkspace()) setInspectorOpen(false) }} onViewerLibrary={() => void syncViewer()} - onImport={() => setImportOpen(true)} + onImport={() => { setImportError(''); setImportOpen(true) }} onFeatures={openFeatures} + onStudy={() => void openStudy()} onTheme={toggleTheme} onSecurity={connect} onSignOut={connect} @@ -661,13 +919,13 @@ export function App() {
{ setSelectedId(id) - if (window.innerWidth <= 960) setNavigatorOpen(false) + if (isCompactWorkspace()) { + setNavigatorOpen(false) + setInspectorOpen(true) + } }} onChecked={setSelectedDatasetIds} onCreateFolder={(name) => setLocalFolders((current) => current.includes(name) ? current : [...current, name])} @@ -691,21 +953,18 @@ export function App() { }))} onQueue={(ids) => void queueReadySlides(ids)} onRemove={(ids) => setBatchRemoveIds(ids)} - onImport={() => setImportOpen(true)} + onImport={() => { setImportError(''); setImportOpen(true) }} onConnect={connect} - onSync={() => void syncViewer()} - onKeepOffline={(id) => void api.keepViewerSlideOffline(id).then(() => { - setNotice('Offline download started; verified activation will happen in the background') - }).catch((nextError) => setError(message(nextError)))} - onRemoveOffline={(id) => void api.removeViewerSlideOffline(id) - .then(() => api.viewerLibrary()).then(setRemoteLibrary) - .catch((nextError) => setError(message(nextError)))} + onSync={() => void syncViewer(false)} + onKeepOffline={(id) => void remoteAction(() => api.keepViewerSlideOffline(id), false, 'Offline download started; verified activation will happen in the background')} + onCancelOffline={(id) => void remoteAction(() => api.cancelViewerOfflineDownload(id))} + onRemoveOffline={(id) => void remoteAction(() => api.removeViewerSlideOffline(id))} onRenameRemote={(id, current) => setRemoteRename({ id, current, value: current })} selectedRemoteId={selectedRemote?.id || ''} - onSelectRemote={setSelectedRemoteId} - onResolveConflict={(id, field, resolution) => void api.resolveViewerConflict(id, field, resolution) - .then(() => api.syncViewerLibrary()).then(setRemoteLibrary) - .catch((nextError) => setError(message(nextError)))} + onSelectRemote={(id) => { + setSelectedRemoteId(id) + }} + onResolveConflict={(id, field, resolution) => void remoteAction(() => api.resolveViewerConflict(id, field, resolution), true)} onCollapse={() => { setNavigatorOpen(false) window.requestAnimationFrame(() => navigatorButtonRef.current?.focus()) @@ -717,7 +976,7 @@ export function App() { slide={selectedRemote} viewer={viewer} onViewer={setViewer} - inspectorOpen={inspectorOpen} + inspectorOpen={inspectorVisible} onInspector={() => setInspectorOpen((current) => !current)} /> ) : ( @@ -729,11 +988,20 @@ export function App() { cropEditing={cropEditing} onCropChange={setCropDraft} annotations={selectedAnnotations} + analysisOverlays={visibleAnalysisOverlays} + analysisMask={visibleAnalysisMask} + overlayPage={overlayPage} + onOverlayPage={setOverlayPage} + onClearAnalysis={() => { setAnalysisOverlay(null); setAnalysisMask(undefined) }} activeTool={activeTool} viewer={viewer} onViewer={setViewer} onCreateAnnotation={createLocalAnnotation} - inspectorOpen={inspectorOpen} + onComposeBrush={composeLocalBrush} + selectedAnnotationId={selectedAnnotationId} + onSelectAnnotation={(id) => { setSelectedAnnotationId(id); setActiveTool('select') }} + onUpdateAnnotation={(id, geometry) => void updateLocalAnnotation(id, { geometry })} + inspectorOpen={inspectorVisible} onInspector={() => setInspectorOpen((current) => !current)} /> )} @@ -742,12 +1010,10 @@ export function App() { slide={selectedRemote} folders={remoteLibrary.folders} onCollapse={() => setInspectorOpen(false)} - onKeepOffline={(id) => void api.keepViewerSlideOffline(id)} - onRemoveOffline={(id) => void api.removeViewerSlideOffline(id) - .then(() => api.viewerLibrary()).then(setRemoteLibrary)} - onMove={(id, folderId) => void api.updateViewerSlideMetadata(id, { folderId }) - .then(() => api.syncViewerLibrary()).then(setRemoteLibrary) - .catch((nextError) => setError(message(nextError)))} + onKeepOffline={(id) => void remoteAction(() => api.keepViewerSlideOffline(id))} + onCancelOffline={(id) => void remoteAction(() => api.cancelViewerOfflineDownload(id))} + onRemoveOffline={(id) => void remoteAction(() => api.removeViewerSlideOffline(id))} + onMove={(id, folderId) => void remoteAction(() => api.updateViewerSlideMetadata(id, { folderId }), true)} /> ) : ( setInspectorOpen(false)} onInspect={inspect} onConfigure={updateConfiguration} + onUpdateView={updateViewDefinition} onConvert={beginConversion} onCancel={() => selected && void api.cancel(selected.id).then(() => refresh())} onApprove={approveCurrent} @@ -780,11 +1047,53 @@ export function App() { onUpload={uploadApproved} onRemove={() => selected && setRemoveTarget(selected)} onDeleteAnnotation={deleteLocalAnnotation} + selectedAnnotationId={selectedAnnotationId} + onSelectAnnotation={(id) => { setSelectedAnnotationId(id); setActiveTool('select') }} + onUpdateAnnotation={updateLocalAnnotation} viewingRevisionId={viewingRevision?.id || ''} onViewRevision={viewRevision} onViewSource={viewSource} onRenameRevision={renameRevision} onDeleteRevision={deleteRevision} + tools={selected ? <> pack.state === 'INSTALLED').flatMap((pack) => pack.id === 'pathology-tools' + ? ['he', 'stain_vector', 'normalize_preview', 'tma'] : pack.id === 'classical-analysis' + ? ['tissue', 'qc', 'nucleus_candidates', 'registration'] : [])} + onSubmit={api.submitAnalysis} onCancel={api.cancelAnalysis} onRefresh={refreshAnalysis} + onLoadReview={api.analysisReview} onSaveReview={async (id, review) => { + const saved = await api.saveAnalysisReview(id, review) + const run = analysisRuns.find((item) => item.id === id) + if (run && analysisOverlay?.runId === id) await showAnalysisObjects(run) + return saved + }} + onShowObjects={(run) => void showAnalysisObjects(run)} + onShowMask={(requested, channel) => { + const context = analysisContext.current + void api.analysisRun(requested.id).then((run) => { + if (context !== analysisContext.current) return + if (run.status !== 'SUCCEEDED' || run.stale) throw new Error('Mask result changed; rerun against the current ROI') + setAnalysisRuns((current) => [run, ...current.filter((item) => item.id !== run.id)]) + setAnalysisMask({ context, runId: run.id, channel }); setActiveTool('pan') + }).catch((cause) => { if (context === analysisContext.current) setError(message(cause)) }) + }} + onPersistTma={async (id, revision) => { + const context = analysisContext.current; const saved = await api.persistTma(id, revision) + if (context === analysisContext.current) setAnnotationsByDataset((current) => ({ ...current, [selected.id]: [...(current[selected.id] || []).filter((item) => !saved.some((core) => core.id === item.id)), ...saved] })) + return saved + }} + onAnalyzeTmaCore={async (id, revision, coreId, tool, configuration) => { const run = await api.analyzeTmaCore(id, revision, coreId, tool, configuration); await refreshAnalysis(); return run }} + onLoadTargetAnnotations={api.annotations} + onExport={(id) => { + if (window.forgeDesktop) setExportRunId(id) + else window.location.href = `/api/analysis/runs/${encodeURIComponent(id)}/export` + }} + />{analysisHasMore ? : null}{exportRunId && window.forgeDesktop ? : null} : null} /> )} queue={( @@ -794,20 +1103,35 @@ export function App() { isError={Boolean(error)} onClearError={() => setError('')} onQueueReady={() => void queueReadySlides()} + onOpen={() => setInspectorOpen(false)} + paused={capabilities?.queuePaused === true} + onPause={() => { + void api.setQueuePaused(capabilities?.queuePaused !== true).then((state) => { + setCapabilities((current) => current && ({ ...current, queuePaused: state.paused })) + setNotice(state.paused ? 'Queue paused · current item finishes safely' : 'Queue resumed') + }).catch((failure) => setError(message(failure))) + }} /> )} />
+ + {batchReportsOpen ?
{ if (event.key === 'Escape') { event.stopPropagation(); setBatchReportsOpen(false) } }}> + + { + if (window.forgeDesktop) setBatchExportRequest({ id, format }) + else { const link = document.createElement('a'); link.href = `/api/batches/${encodeURIComponent(id)}/export?format=${format}`; link.download = `batch-${id}.${format}`; link.click() } + }} /> + {batchExportRequest ? : null} +
: null} {connection?.connected && viewerUpload?.viewerSlideId && ['IMAGE_READY', 'SYNCING_RESULTS', 'COMPLETE'].includes(viewerUpload.state) ? ( - Open private slide in Viewer - + ) : null} {viewerUpload && ['UPLOADING', 'VERIFYING_OME', 'RETRYING'].includes(viewerUpload.state) ? ( +

Associate each slide with its exact local Teaching DZI artifact. Local preview and approval work offline; resolve delivered Viewer identities and approve again before publication.

+ {selected ?

Teaching delivery creates a separate static-DZI artifact. Existing OME artifacts and original pixels are retained; Viewer privacy review remains required.

+ +
: null} + {studyError ?

{studyError}

: null} + {connection?.scopes.includes('study-packs:write') ? : null} + {!studyLoaded ? : ({ viewerSlideId: `local:${dataset.id}`, displayName: `${dataset.displayName} (local draft)`, sha256: '' }))]} + teachingArtifacts={Object.entries(artifactByDataset).flatMap(([datasetId, artifacts]) => artifacts.filter((artifact) => artifact.format === 'PREPARED_DZI_V2' && ['READY', 'APPROVED'].includes(artifact.status)).map((artifact) => ({ datasetId, artifactRevision: artifact.id, displayName: `${datasets.find((dataset) => dataset.id === datasetId)?.displayName || datasetId} · ${artifact.name}` })))} + onAssociateTeachingSlide={(id, revision, referenceId, datasetId, artifactRevision) => rememberStudy(api.associateTeachingSlide(id, revision, referenceId, datasetId, artifactRevision))} + renderSlide={(slide, onLocation, onPixelsLoaded, draft) => { + const association = teachingAssociationFor(slide, draft.associations) + return association && slide ? :

Select an exact local teaching artifact before reviewing these pixels.

+ }} + onCaptureSpatial={async (slide, draft) => { + const association = teachingAssociationFor(slide, draft.associations), roi = selectedAnnotations.find((annotation) => annotation.id === selectedAnnotationId) + if (!association || !selected || !roi) throw new Error('Select a saved source rectangle and associate the exact teaching artifact first.') + return captureTeachingTarget(roi, selected.id, selected.sourceFingerprint, association) + }} + onLoad={api.studyDraft} onCreate={(name) => rememberStudy(api.createStudyDraft(name))} + onSave={(draft, revision) => rememberStudy(api.saveStudyDraft(draft, revision))} + onDuplicate={(id, name, nextVersion) => rememberStudy(api.duplicateStudyDraft(id, name, nextVersion))} + onHistory={api.studyHistory} onRecover={(id, historical, revision) => rememberStudy(api.recoverStudyDraft(id, historical, revision))} + onPreview={(id, revision) => rememberStudy(api.previewStudyDraft(id, revision))} + onReviewTask={(id, revision, checksum, taskId, pixels) => rememberStudy(api.reviewStudyTask(id, revision, checksum, taskId, pixels))} + onApprove={(id, revision, checksum) => rememberStudy(api.approveStudyDraft(id, revision, checksum))} + onImport={(format, text) => rememberStudy(api.importStudyDraft(format, text))} + onImportQuestions={(id, revision, format, text, slideId) => rememberStudy(api.importStudyQuestions(id, revision, format, text, slideId))} + onExport={(id, format, checksum) => { void exportAuthoredStudy(id, format, checksum) }} + onPublish={connection?.scopes.includes('study-packs:write') ? async (checksum) => { + const draft = studyDrafts.find((item) => item.approvedChecksum === checksum) + if (!draft) throw new Error('Reload the exact approved draft before publishing') + return api.publishStudy(draft.id, draft.revision, checksum) + } : undefined} />} + {studyExport ?

{studyExport.detail}

+ {['COPYING', 'VERIFYING'].includes(studyExport.status) ? : null} + {studyExport.status === 'COMPLETE' ? : null}
: null} + + : null} {featureOpen ? ( void loadFeatures(true)} - onChange={(feature) => void changeFeature(feature)} + onImport={window.forgeDesktop?.selectFeatureFiles ? () => void importFeature() : undefined} + onChange={(feature, action, version) => void changeFeature(feature, action, version)} + progress={featureProgress} + onCancel={() => featureProgress?.id && void api.featureAction(featureProgress.id, 'cancel').catch((error) => setError(message(error)))} onClose={() => setFeatureOpen(false)} /> ) : null} @@ -940,6 +1317,7 @@ function ForgeProductRail({ onViewerLibrary, onImport, onFeatures, + onStudy, onTheme, onSecurity, onSignOut, @@ -949,7 +1327,7 @@ function ForgeProductRail({ expanded: boolean navigatorOpen: boolean navigatorButtonRef: Ref - storage: { usableBytes: number; effectiveCapacityBytes: number } + storage: { usableBytes: number; effectiveCapacityBytes: number; managedUsage?: { bytes: number; files: number; complete: boolean; measuredAt: number } | null } mode: 'local' | 'viewer' theme: 'light' | 'dark' onToggleExpanded: () => void @@ -957,6 +1335,7 @@ function ForgeProductRail({ onViewerLibrary: () => void onImport: () => void onFeatures: () => void + onStudy: () => void onTheme: () => void onSecurity: () => void onSignOut: () => void @@ -970,11 +1349,11 @@ function ForgeProductRail({