diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 5f676174c..ac007c2db 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -504,22 +504,21 @@ jobs: $releaseVersion = $releaseVersion.Trim() $stage = Join-Path $env:RUNNER_TEMP "OpenHCS-Windows-Smoke" $installRoot = Join-Path $env:RUNNER_TEMP "OpenHCS Installed" + $contractPath = Join-Path $env:RUNNER_TEMP "installer_contract.json" New-Item -ItemType Directory -Path $stage | Out-Null - Copy-Item ` - packaging/installers/windows/Install-OpenHCS.ps1 ` - -Destination $stage + python scripts/render_installer_contract.py ` + --version $releaseVersion ` + --output $contractPath & packaging/installers/windows/Build-InstallerLauncher.ps1 ` - -OutputDirectory $stage + -OutputDirectory $stage ` + -ContractPath $contractPath if ($LASTEXITCODE -ne 0) { throw "Windows GUI installer launcher build failed." } - python scripts/render_installer_contract.py ` - --version $releaseVersion ` - --output (Join-Path $stage "installer_contract.json") - $launcher = Join-Path $stage "Install-OpenHCS.exe" + $launcher = Join-Path $stage "OpenHCS-Windows-Installer.exe" if (-not (Test-Path -LiteralPath $launcher -PathType Leaf)) { - throw "Windows GUI installer launcher was not staged." + throw "Single-file Windows GUI installer was not staged." } $subsystem = python -c "import struct,sys; from pathlib import Path; data=Path(sys.argv[1]).read_bytes(); pe=struct.unpack_from('`_, extract it, -and open the included installer. Re-running the same installer updates the +Download the installer for your operating system from the matching +`GitHub release `_. On Windows, +download and run ``OpenHCS-Windows-Installer.exe``. On macOS, open +``OpenHCS-macOS-Installer.dmg`` and then open ``OpenHCS Installer``. Neither +platform requires ZIP extraction. Re-running the same installer updates the isolated environment. Installation details and failures are retained in the OpenHCS user log directory shown by the installer. The initial bootstrap installers are not code-signed or notarized. Windows SmartScreen or macOS Gatekeeper may therefore ask you to confirm that you trust -the downloaded release. The release archive is only the bootstrap interface; -Python and OpenHCS remain managed by uv and PyPI in the dedicated environment. +the downloaded release. The downloaded installer is only the bootstrap +interface; Python and OpenHCS remain managed by uv and PyPI in the dedicated +environment. Manual installation ------------------- diff --git a/openhcs/__init__.py b/openhcs/__init__.py index fa0323171..fcb203f2c 100644 --- a/openhcs/__init__.py +++ b/openhcs/__init__.py @@ -14,7 +14,7 @@ from openhcs._source_dependencies import ensure_source_checkout_external_paths -__version__ = "0.6.1" +__version__ = "0.6.2" # Configure polystore defaults for OpenHCS integration os.environ.setdefault("POLYSTORE_METADATA_FILENAME", "openhcs_metadata.json") diff --git a/packaging/codex/openhcs/.codex-plugin/plugin.json b/packaging/codex/openhcs/.codex-plugin/plugin.json index f26d91aca..2ae3afd23 100644 --- a/packaging/codex/openhcs/.codex-plugin/plugin.json +++ b/packaging/codex/openhcs/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "openhcs", - "version": "0.6.1", + "version": "0.6.2", "description": "Inspect, author, validate, and run OpenHCS microscopy workflows through the local OpenHCS MCP server.", "author": { "name": "OpenHCSDev", diff --git a/packaging/codex/openhcs/.mcp.json b/packaging/codex/openhcs/.mcp.json index 7f1107a2f..785a9ff83 100644 --- a/packaging/codex/openhcs/.mcp.json +++ b/packaging/codex/openhcs/.mcp.json @@ -4,7 +4,7 @@ "command": "uvx", "args": [ "--from", - "openhcs[gui,mcp]==0.6.1", + "openhcs[gui,mcp]==0.6.2", "openhcs-mcp" ] } diff --git a/packaging/installers/README.md b/packaging/installers/README.md index 53c88b9d7..319df07b4 100644 --- a/packaging/installers/README.md +++ b/packaging/installers/README.md @@ -36,9 +36,10 @@ entry point remain authoritative. ## What users see -The release archives are intended for users who do not want to work in a -terminal. After extracting the downloaded archive, installation stays inside a -small native window: +The release assets are intended for users who do not want to work in a +terminal. No archive extraction is required: Windows users run one executable, +and macOS users open one disk image. Installation stays inside a small native +window: - Windows presents Welcome, installation-folder, progress, and Finish pages. The final page can launch OpenHCS immediately. @@ -56,7 +57,7 @@ From the repository environment: ```bash python -m pytest -q tests/installer python scripts/render_installer_contract.py \ - --version 0.6.1 \ + --version 0.6.2 \ --output /tmp/openhcs-installer-contract.json ``` @@ -66,14 +67,15 @@ each adapter. ## Release assets Tag publication renders a contract pinned to the tag version and attaches two -archives to the GitHub release: +directly usable files to the GitHub release: -- `OpenHCS-Windows-Installer.zip` contains a small GUI-subsystem - `Install-OpenHCS.exe`, its internal PowerShell worker, and the pinned - contract. Extract the archive and double-click `Install-OpenHCS.exe`. -- `OpenHCS-macOS-Installer.zip` contains a compiled `OpenHCS Installer.app` - with the bootstrap and pinned contract embedded as application resources. - Extract the archive and double-click the application. +- `OpenHCS-Windows-Installer.exe` is a small GUI-subsystem executable with its + PowerShell worker and pinned contract embedded. Double-click the downloaded + file. +- `OpenHCS-macOS-Installer.dmg` contains the compiled + `OpenHCS Installer.app`, whose bootstrap and pinned contract are embedded as + application resources. Open the downloaded disk image, then open the + application. Pull-request CI parses the Windows PowerShell source and compiles the GUI-subsystem launcher on Windows, and compiles the universal Swift/AppKit @@ -85,7 +87,7 @@ MCP generates a two-channel synthetic plate, the packaged neurite preset runs through the real execution server, Napari receives the result, MCP validates mounted nonzero viewer payloads, and the smoke shuts down only its dynamically allocated TCP runtime/viewer endpoints. The tag workflow repeats the source -gates before making either archive a release asset. +gates before making either file a release asset. Users can run the same portable acceptance after installation: diff --git a/packaging/installers/windows/Build-InstallerLauncher.ps1 b/packaging/installers/windows/Build-InstallerLauncher.ps1 index 2ed700297..47009a797 100644 --- a/packaging/installers/windows/Build-InstallerLauncher.ps1 +++ b/packaging/installers/windows/Build-InstallerLauncher.ps1 @@ -1,7 +1,9 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$OutputDirectory + [string]$OutputDirectory, + [Parameter(Mandatory = $true)] + [string]$ContractPath ) Set-StrictMode -Version Latest @@ -9,19 +11,36 @@ $ErrorActionPreference = "Stop" $resolvedOutput = [IO.Path]::GetFullPath($OutputDirectory) [IO.Directory]::CreateDirectory($resolvedOutput) | Out-Null - -$projectPath = [IO.Path]::Combine( - $PSScriptRoot, - "InstallerLauncher.csproj" -) +$resolvedContract = [IO.Path]::GetFullPath($ContractPath) +if (-not (Test-Path -LiteralPath $resolvedContract -PathType Leaf)) { + throw "Rendered installer contract not found: $resolvedContract" +} $temporaryRoot = [IO.Path]::Combine( [IO.Path]::GetTempPath(), "openhcs-installer-launcher-$([Guid]::NewGuid().ToString('N'))" ) +$sourceRoot = [IO.Path]::Combine($temporaryRoot, "source") +$windowsSourceRoot = [IO.Path]::Combine($sourceRoot, "windows") $buildRoot = [IO.Path]::Combine($temporaryRoot, "build") $artifactsRoot = [IO.Path]::Combine($temporaryRoot, "artifacts") try { + [IO.Directory]::CreateDirectory($windowsSourceRoot) | Out-Null + foreach ($sourceName in @( + "InstallerLauncher.cs", + "InstallerLauncher.csproj", + "Install-OpenHCS.ps1" + )) { + Copy-Item -LiteralPath ([IO.Path]::Combine($PSScriptRoot, $sourceName)) ` + -Destination ([IO.Path]::Combine($windowsSourceRoot, $sourceName)) + } + Copy-Item -LiteralPath $resolvedContract -Destination ( + [IO.Path]::Combine($sourceRoot, "installer_contract.json") + ) + $projectPath = [IO.Path]::Combine( + $windowsSourceRoot, + "InstallerLauncher.csproj" + ) & dotnet build $projectPath ` --configuration Release ` --output $buildRoot ` @@ -31,12 +50,13 @@ try { throw "The Windows installer launcher build failed with exit code $LASTEXITCODE." } - $launcherPath = [IO.Path]::Combine($buildRoot, "Install-OpenHCS.exe") + $launcherName = "OpenHCS-Windows-Installer.exe" + $launcherPath = [IO.Path]::Combine($buildRoot, $launcherName) if (-not (Test-Path -LiteralPath $launcherPath -PathType Leaf)) { - throw "The Windows installer launcher build did not produce Install-OpenHCS.exe." + throw "The Windows installer build did not produce $launcherName." } Copy-Item -LiteralPath $launcherPath -Destination ( - [IO.Path]::Combine($resolvedOutput, "Install-OpenHCS.exe") + [IO.Path]::Combine($resolvedOutput, $launcherName) ) -Force } finally { diff --git a/packaging/installers/windows/InstallerLauncher.cs b/packaging/installers/windows/InstallerLauncher.cs index e084fe1bc..1f3a5868c 100644 --- a/packaging/installers/windows/InstallerLauncher.cs +++ b/packaging/installers/windows/InstallerLauncher.cs @@ -2,12 +2,17 @@ using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; +using System.Reflection; using System.Text; internal static class InstallerLauncher { private const uint MessageBoxOk = 0x00000000; private const uint MessageBoxIconError = 0x00000010; + private const string WorkerResourceName = + "OpenHCS.Installer.Install-OpenHCS.ps1"; + private const string ContractResourceName = + "OpenHCS.Installer.installer_contract.json"; [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern int MessageBoxW( @@ -18,21 +23,33 @@ uint type ); [STAThread] - private static int Main() + private static int Main(string[] arguments) { + string temporaryDirectory = null; try { - string launcherDirectory = AppContext.BaseDirectory; + if (Environment.Is64BitOperatingSystem && !Environment.Is64BitProcess) + { + throw new PlatformNotSupportedException( + "The OpenHCS installer must use native 64-bit Windows PowerShell." + ); + } + temporaryDirectory = Path.Combine( + Path.GetTempPath(), + "OpenHCS Installer", + Guid.NewGuid().ToString("N") + ); + Directory.CreateDirectory(temporaryDirectory); string installerScript = Path.Combine( - launcherDirectory, + temporaryDirectory, "Install-OpenHCS.ps1" ); string installerContract = Path.Combine( - launcherDirectory, + temporaryDirectory, "installer_contract.json" ); - RequireSiblingFile(installerScript); - RequireSiblingFile(installerContract); + ExtractEmbeddedFile(WorkerResourceName, installerScript); + ExtractEmbeddedFile(ContractResourceName, installerContract); string windowsDirectory = Environment.GetFolderPath( Environment.SpecialFolder.Windows @@ -55,15 +72,25 @@ private static int Main() ProcessStartInfo startInfo = new ProcessStartInfo { FileName = powerShell, - WorkingDirectory = launcherDirectory, + WorkingDirectory = temporaryDirectory, UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden, }; - startInfo.Arguments = ( + // A launcher started from PowerShell 7 can inherit PS7-only module + // paths. Windows PowerShell reconstructs its correct user, system, + // and built-in module paths when the inherited override is absent. + startInfo.EnvironmentVariables.Remove("PSModulePath"); + StringBuilder powerShellArguments = new StringBuilder( "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File " - + QuoteWindowsArgument(installerScript) ); + powerShellArguments.Append(QuoteWindowsArgument(installerScript)); + foreach (string argument in arguments) + { + powerShellArguments.Append(' '); + powerShellArguments.Append(QuoteWindowsArgument(argument)); + } + startInfo.Arguments = powerShellArguments.ToString(); Process process = Process.Start(startInfo); if (process == null) @@ -72,8 +99,11 @@ private static int Main() "Windows could not start the setup wizard." ); } - process.Dispose(); - return 0; + using (process) + { + process.WaitForExit(); + return process.ExitCode; + } } catch (Exception exception) { @@ -85,16 +115,54 @@ private static int Main() ); return 1; } + finally + { + TryDeleteTemporaryDirectory(temporaryDirectory); + } } - private static void RequireSiblingFile(string path) + private static void ExtractEmbeddedFile(string resourceName, string outputPath) { - if (!File.Exists(path)) + Assembly assembly = Assembly.GetExecutingAssembly(); + using (Stream input = assembly.GetManifestResourceStream(resourceName)) { - throw new FileNotFoundException( - "The installer archive is incomplete. Extract all files together.", - path - ); + if (input == null) + { + throw new InvalidDataException( + "The installer is missing embedded resource " + resourceName + "." + ); + } + using ( + FileStream output = new FileStream( + outputPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None + ) + ) + { + input.CopyTo(output); + } + } + } + + private static void TryDeleteTemporaryDirectory(string path) + { + if (string.IsNullOrEmpty(path)) + { + return; + } + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + } + catch + { + // A scanner can briefly retain an extracted file. The random + // user-temporary directory contains no credentials or user data. } } diff --git a/packaging/installers/windows/InstallerLauncher.csproj b/packaging/installers/windows/InstallerLauncher.csproj index 155b1b4a7..b73a50369 100644 --- a/packaging/installers/windows/InstallerLauncher.csproj +++ b/packaging/installers/windows/InstallerLauncher.csproj @@ -2,8 +2,17 @@ WinExe net48 - Install-OpenHCS + OpenHCS-Windows-Installer OpenHCS.Installer latest + false + + + OpenHCS.Installer.Install-OpenHCS.ps1 + + + OpenHCS.Installer.installer_contract.json + + diff --git a/packaging/mcpb/openhcs/manifest.json b/packaging/mcpb/openhcs/manifest.json index 7835ae07e..fba3e43f7 100644 --- a/packaging/mcpb/openhcs/manifest.json +++ b/packaging/mcpb/openhcs/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.4", "name": "openhcs", "display_name": "OpenHCS", - "version": "0.6.1", + "version": "0.6.2", "description": "Operate local OpenHCS microscopy workflows through MCP.", "long_description": "Installs a pinned OpenHCS environment with the MCP server and PyQt user interface. The MCP process runs over stdio and attaches to an authenticated, separately running OpenHCS UI when GUI tools are requested.", "author": { diff --git a/packaging/mcpb/openhcs/pyproject.toml b/packaging/mcpb/openhcs/pyproject.toml index 190125541..42c58ba10 100644 --- a/packaging/mcpb/openhcs/pyproject.toml +++ b/packaging/mcpb/openhcs/pyproject.toml @@ -1,8 +1,8 @@ [project] name = "openhcs-mcpb-runtime" -version = "0.6.1" +version = "0.6.2" description = "Managed runtime for the OpenHCS MCP Bundle" requires-python = ">=3.11,<3.14" dependencies = [ - "openhcs[gui,mcp]==0.6.1", + "openhcs[gui,mcp]==0.6.2", ] diff --git a/server.json b/server.json index 7af81a4f4..cc7052154 100644 --- a/server.json +++ b/server.json @@ -8,19 +8,19 @@ "url": "https://github.com/OpenHCSDev/OpenHCS", "source": "github" }, - "version": "0.6.1", + "version": "0.6.2", "packages": [ { "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "openhcs", - "version": "0.6.1", + "version": "0.6.2", "runtimeHint": "uvx", "runtimeArguments": [ { "type": "named", "name": "--with", - "value": "openhcs[gui,mcp]==0.6.1", + "value": "openhcs[gui,mcp]==0.6.2", "description": "Install the matching OpenHCS GUI and MCP dependencies." } ], diff --git a/tests/installer/test_macos_simple_installer.py b/tests/installer/test_macos_simple_installer.py index 5ccce4b20..558f9cd4c 100644 --- a/tests/installer/test_macos_simple_installer.py +++ b/tests/installer/test_macos_simple_installer.py @@ -21,6 +21,7 @@ APP_SOURCE_PATH = MACOS_ROOT / "OpenHCSInstaller.swift" BUILD_PATH = MACOS_ROOT / "build-installer.sh" CONTRACT_PATH = INSTALLER_ROOT / "installer_contract.json" +PUBLISH_WORKFLOW_PATH = REPOSITORY_ROOT / ".github" / "workflows" / "publish.yml" def _bootstrap() -> str: @@ -184,6 +185,24 @@ def test_macos_installer_builds_a_universal_native_app_with_embedded_contract() assert "Contents/Resources/install-openhcs.sh" in build +def test_macos_release_is_one_verified_disk_image() -> None: + workflow = PUBLISH_WORKFLOW_PATH.read_text(encoding="utf-8") + macos_job = workflow[ + workflow.index(" build-macos-installer:") : workflow.index( + " build-and-publish:" + ) + ] + + assert "OpenHCS-macOS-Installer.dmg" in macos_job + assert "hdiutil create" in macos_job + assert '-volname "OpenHCS Installer"' in macos_job + assert "-format UDZO" in macos_job + assert "hdiutil verify" in macos_job + assert "path: OpenHCS-macOS-Installer.dmg" in macos_job + assert "OpenHCS-macOS-Installer.zip" not in macos_job + assert "ditto -c -k" not in macos_job + + def test_macos_app_has_responsive_welcome_progress_and_finish_states() -> None: source = APP_SOURCE_PATH.read_text(encoding="utf-8") diff --git a/tests/installer/test_windows_simple_installer.py b/tests/installer/test_windows_simple_installer.py index a1a3c598f..1b2f547e3 100644 --- a/tests/installer/test_windows_simple_installer.py +++ b/tests/installer/test_windows_simple_installer.py @@ -18,6 +18,7 @@ INTEGRATION_WORKFLOW_PATH = ( REPOSITORY_ROOT / ".github" / "workflows" / "integration-tests.yml" ) +PUBLISH_WORKFLOW_PATH = REPOSITORY_ROOT / ".github" / "workflows" / "publish.yml" def _source() -> str: @@ -41,26 +42,47 @@ def test_windows_installer_has_stable_double_click_entrypoint() -> None: assert 'start ""' in cmd assert "WinExe" in project assert "net48" in project + assert "false" in project assert "" not in project assert "" not in project assert "" not in project - assert "Install-OpenHCS" in project + assert "OpenHCS-Windows-Installer" in project + assert 'EmbeddedResource Include="Install-OpenHCS.ps1"' in project + assert 'EmbeddedResource Include="..\\installer_contract.json"' in project + assert "OpenHCS.Installer.Install-OpenHCS.ps1" in project + assert "OpenHCS.Installer.installer_contract.json" in project assert "dotnet build $projectPath" in build assert "--runtime" not in build assert "--self-contained" not in build - assert '"Install-OpenHCS.exe"' in build - - assert "AppContext.BaseDirectory" in launcher + assert "[string]$ContractPath" in build + assert '"OpenHCS-Windows-Installer.exe"' in build + assert '"installer_contract.json"' in build + + assert "Assembly.GetExecutingAssembly()" in launcher + assert "Environment.Is64BitOperatingSystem" in launcher + assert "!Environment.Is64BitProcess" in launcher + assert "native 64-bit Windows PowerShell" in launcher + assert "GetManifestResourceStream(resourceName)" in launcher + assert 'Guid.NewGuid().ToString("N")' in launcher + assert "Path.GetTempPath()" in launcher assert '"Install-OpenHCS.ps1"' in launcher assert '"installer_contract.json"' in launcher - assert "RequireSiblingFile(installerScript)" in launcher - assert "RequireSiblingFile(installerContract)" in launcher + assert "AppContext.BaseDirectory" not in launcher + assert "RequireSiblingFile" not in launcher + assert "ExtractEmbeddedFile(WorkerResourceName, installerScript)" in launcher + assert "ExtractEmbeddedFile(ContractResourceName, installerContract)" in launcher assert "UseShellExecute = false" in launcher assert "CreateNoWindow = true" in launcher assert "WindowStyle = ProcessWindowStyle.Hidden" in launcher + assert 'startInfo.EnvironmentVariables.Remove("PSModulePath")' in launcher assert "startInfo.ArgumentList" not in launcher - assert "startInfo.Arguments = (" in launcher - assert "+ QuoteWindowsArgument(installerScript)" in launcher + assert ( + "powerShellArguments.Append(QuoteWindowsArgument(installerScript))" in launcher + ) + assert "foreach (string argument in arguments)" in launcher + assert "process.WaitForExit()" in launcher + assert "return process.ExitCode" in launcher + assert "TryDeleteTemporaryDirectory(temporaryDirectory)" in launcher assert "private static string QuoteWindowsArgument(string value)" in launcher assert "pendingBackslashes * 2" in launcher assert "(pendingBackslashes * 2) + 1" in launcher @@ -320,13 +342,30 @@ def test_windows_installer_ci_has_an_absolute_safety_ceiling() -> None: ] assert " timeout-minutes: 20" in smoke_step - assert "Install-OpenHCS.ps1" in smoke_step assert "Build-InstallerLauncher.ps1" in smoke_step - assert '"Install-OpenHCS.exe"' in smoke_step + assert '"OpenHCS-Windows-Installer.exe"' in smoke_step assert "GUI-subsystem executable" in smoke_step assert "Length -gt 2MB" in smoke_step assert '"openhcs-installer-cancel-{0}.marker"' in smoke_step - assert "-CancellationPath $cancellationPath" in smoke_step + assert '"-CancellationPath", $cancellationPath' in smoke_step + assert "$installerStartInfo.ArgumentList.Add([string]$argument)" in smoke_step + assert "$installerProcess.WaitForExit()" in smoke_step + assert "$installerProcess.ExitCode" in smoke_step + + +def test_windows_release_is_one_directly_runnable_file() -> None: + workflow = PUBLISH_WORKFLOW_PATH.read_text(encoding="utf-8") + windows_job = workflow[ + workflow.index(" build-windows-installer:") : workflow.index( + " build-macos-installer:" + ) + ] + + assert "Build release-pinned single-file Windows installer" in windows_job + assert "OpenHCS-Windows-Installer.exe" in windows_job + assert "path: OpenHCS-Windows-Installer.exe" in windows_job + assert "Compress-Archive" not in windows_job + assert "OpenHCS-Windows-Installer.zip" not in windows_job def test_windows_installer_ci_uses_napari_tested_software_opengl() -> None: diff --git a/tests/unit/test_build_website.py b/tests/unit/test_build_website.py index bcf1d380c..bcedde20b 100644 --- a/tests/unit/test_build_website.py +++ b/tests/unit/test_build_website.py @@ -80,9 +80,15 @@ def test_shipping_copy_projects_current_release_and_keeps_boundaries_explicit( ) assert len(installer_assets) == 2 assert all(asset_name in publish_workflow for asset_name in installer_assets) + assert installer_assets == [ + "OpenHCS-Windows-Installer.exe", + "OpenHCS-macOS-Installer.dmg", + ] + assert all(not asset_name.endswith(".zip") for asset_name in installer_assets) assert "Download for Windows" in html assert "Download for macOS" in html - assert "Extract, then run Install-OpenHCS.exe" in html + assert "Download and run — no ZIP to extract" in html + assert "Open the DMG, then open OpenHCS Installer" in html assert "Install-OpenHCS.cmd" not in html assert ( "User-scoped, CPU-only installation with CellProfiler compatibility, local MCP" diff --git a/website/index.html b/website/index.html index c0db20aaa..a104ee294 100644 --- a/website/index.html +++ b/website/index.html @@ -319,18 +319,18 @@

Install your way.