diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml
index 949ef9a5009..7b9407713b9 100644
--- a/.github/workflows/winget.yml
+++ b/.github/workflows/winget.yml
@@ -23,8 +23,8 @@ jobs:
run: |
# Get installer info from release event
$assets = '${{ toJSON(github.event.release.assets) }}' | ConvertFrom-Json
- $x64InstallerUrl = $assets | Where-Object -Property name -like '*x86_64-windows.zip' | Select-Object -ExpandProperty browser_download_url
- $arm64InstallerUrl = $assets | Where-Object -Property name -like '*aarch64-windows.zip' | Select-Object -ExpandProperty browser_download_url
+ $x64InstallerUrl = $assets | Where-Object -Property name -like '*x86_64-windows-setup.exe' | Select-Object -ExpandProperty browser_download_url
+ $arm64InstallerUrl = $assets | Where-Object -Property name -like '*aarch64-windows-setup.exe' | Select-Object -ExpandProperty browser_download_url
$packageVersion = (${{ toJSON(github.event.release.tag_name) }}).Trim('v')
# Update package using wingetcreate
diff --git a/.pipelines/nuget.config b/.pipelines/nuget.config
new file mode 100644
index 00000000000..e606bb1ab07
--- /dev/null
+++ b/.pipelines/nuget.config
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
diff --git a/.pipelines/packages.config b/.pipelines/packages.config
new file mode 100644
index 00000000000..0832ece7ea7
--- /dev/null
+++ b/.pipelines/packages.config
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/.pipelines/release.yml b/.pipelines/release.yml
index 704e6eaf887..83ff3912f73 100644
--- a/.pipelines/release.yml
+++ b/.pipelines/release.yml
@@ -48,6 +48,8 @@ extends:
parameters:
featureFlags:
autoBaseline: false
+ networkisolation:
+ policy: Good
WindowsHostVersion:
Version: 2022
Network: R1
@@ -107,6 +109,11 @@ extends:
CARGO_TARGET_DIR: C:\cargo_target_dir
# RUSTC_BOOTSTRAP=1 is required to make panic-immediate-abort work.
RUSTC_BOOTSTRAP: 1
+ # `nuget install -ExcludeVersion` (see packages.config)
+ # restores Inno Setup here.
+ InnoSetupRoot: "$(Build.SourcesDirectory)\\.tools\\Tools.InnoSetup\\tools"
+ # Holds the uninstaller .e32 that ISCC embeds into every installer.
+ SignedUninstallerDir: "$(Build.SourcesDirectory)\\.signing"
steps:
# NOTE: Step objects have ordered keys and you MUST have "task" as the first key.
# Objects with ordered keys... lol
@@ -133,6 +140,28 @@ extends:
# fetching entirely prior to the build, and perform the build with the network disabled.
- script: cargo fetch --config .cargo/release-windows-ms.toml
displayName: Fetch crates
+ # Pull Inno Setup from the internal Azure Artifacts NuGet feed.
+ - task: NuGetAuthenticate@1
+ displayName: Authenticate with Azure Artifacts (nuget)
+ - task: NuGetCommand@2
+ displayName: Restore Inno Setup
+ inputs:
+ command: custom
+ arguments: >-
+ install $(Build.SourcesDirectory)/.pipelines/packages.config
+ -ConfigFile $(Build.SourcesDirectory)/.pipelines/nuget.config
+ -OutputDirectory $(Build.SourcesDirectory)/.tools
+ -ExcludeVersion
+ -NonInteractive
+ # Used for `ob_createvpack_version`, the installer version and all output file names.
+ - pwsh: |-
+ $cargoToml = Get-Content -Raw -LiteralPath crates/edit/Cargo.toml
+ $m = [regex]::Match($cargoToml, '(?m)^version\s*=\s*"([^"]+)"')
+ if (-not $m.Success) { throw 'Failed to extract version from crates/edit/Cargo.toml' }
+ $version = $m.Groups[1].Value
+ Write-Host "Edit version: $version"
+ Write-Host "##vso[task.setvariable variable=EditVersion]$version"
+ displayName: Set EditVersion
- ${{ each platform in parameters.buildPlatforms }}:
- script: cargo build --config .cargo/release-windows-ms.toml --frozen --release --target ${{platform}}
displayName: Build ${{platform}} Release
@@ -149,14 +178,6 @@ extends:
contents: |
*.exe
*.pdb
- # Extract the version for `ob_createvpack_version`.
- - script: |-
- @echo off
- for /f "tokens=3 delims=- " %%x in ('findstr /c:"version = " crates\edit\Cargo.toml') do (
- echo ##vso[task.setvariable variable=EditVersion]%%~x
- goto :EOF
- )
- displayName: "Set EditVersion"
- task: onebranch.pipeline.signing@1
displayName: "Sign files"
inputs:
@@ -166,19 +187,102 @@ extends:
search_root: "$(ob_createvpack_vpackdirectory)"
use_testsign: false
in_container: true
-
- ${{ each platform in parameters.buildPlatforms }}:
- - pwsh: |-
- $Dest = New-Item -Type Directory "_staging/${env:RELEASE_NAME}"
- Write-Host "Staging files from ${env:VPACK_ROOT} at $Dest"
- Get-ChildItem "${env:VPACK_ROOT}\*" -Include *.exe, *.pdb | Copy-Item -Destination $Dest -Verbose
- tar.exe -c -v --format=zip -f "$(ob_outputDirectory)\${env:RELEASE_NAME}.zip" -C _staging $env:RELEASE_NAME
- env:
- RELEASE_NAME: edit-$(EditVersion)-${{ replace(platform, 'pc-windows-msvc', 'windows') }}
- ${{ if eq(platform, 'i686-pc-windows-msvc') }}:
- VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/i386"
- ${{ elseif eq(platform, 'x86_64-pc-windows-msvc') }}:
- VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/amd64"
- ${{ else }}: # aarch64-pc-windows-msvc
- VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/arm64"
- displayName: Produce ${{platform}} release archive
+ - pwsh: |-
+ $Dest = New-Item -Type Directory "_staging/${env:RELEASE_NAME}"
+ Write-Host "Staging files from ${env:VPACK_ROOT} at $Dest"
+ Get-ChildItem "${env:VPACK_ROOT}\*" -Include *.exe, *.pdb | Copy-Item -Destination $Dest -Verbose
+ tar.exe -c -v --format=zip -f "$(ob_outputDirectory)\${env:RELEASE_NAME}.zip" -C _staging $env:RELEASE_NAME
+ env:
+ RELEASE_NAME: edit-$(EditVersion)-${{ replace(platform, 'pc-windows-msvc', 'windows') }}
+ ${{ if eq(platform, 'i686-pc-windows-msvc') }}:
+ VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/i386"
+ ${{ elseif eq(platform, 'x86_64-pc-windows-msvc') }}:
+ VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/amd64"
+ ${{ else }}: # aarch64-pc-windows-msvc
+ VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/arm64"
+ displayName: Produce ${{platform}} release archive
+ # Run ISCC once to generate the uninstaller .e32 file.
+ # Rename .e32 → .exe so the signing task recognises the extension.
+ # The .e32 is arch-independent (confirmed: same hash for x64 and arm64).
+ # ISCC exits with code 1 because the file isn't signed yet.
+ - pwsh: |-
+ $iscc = Join-Path $env:InnoSetupRoot 'ISCC.exe'
+ if (-not (Test-Path -LiteralPath $iscc)) {
+ throw "ISCC.exe not found at $iscc"
+ }
+ New-Item -ItemType Directory -Force -Path $env:SIGNED_UNINSTALLER_DIR | Out-Null
+ $ErrorActionPreference = 'Continue'
+ & $iscc /Q `
+ "/DSignedUninstallerDir=$env:SIGNED_UNINSTALLER_DIR" `
+ "/DAppVersion=$env:EDIT_VERSION" `
+ "/DArchitecturesAllowed=x64os" `
+ "/DSource=$env:VPACK_ROOT\edit.exe" `
+ "/O$env:AGENT_TEMPDIRECTORY" `
+ /Fdummy `
+ assets\edit.iss
+ $ErrorActionPreference = 'Stop'
+ $e32 = Get-ChildItem "$env:SIGNED_UNINSTALLER_DIR\*.e32" | Select-Object -First 1
+ if (-not $e32) { throw 'ISCC did not produce an uninstaller; see its output above' }
+ Write-Host "Generated uninstaller: $($e32.Name)"
+ Rename-Item $e32.FullName ($e32.FullName -replace '\.e32$', '.exe')
+ exit 0
+ env:
+ EDIT_VERSION: $(EditVersion)
+ SIGNED_UNINSTALLER_DIR: $(SignedUninstallerDir)
+ VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/amd64"
+ displayName: Generate unsigned uninstaller
+ - task: onebranch.pipeline.signing@1
+ displayName: Sign uninstaller
+ inputs:
+ command: sign
+ signing_profile: external_distribution
+ files_to_sign: "*.exe"
+ search_root: "$(SignedUninstallerDir)"
+ use_testsign: false
+ in_container: true
+ # Run ISCC a second time to create the actual installers.
+ - ${{ each platform in parameters.buildPlatforms }}:
+ - pwsh: |-
+ # ISCC only accepts the signed uninstaller under its original .e32 extension.
+ Get-ChildItem "$env:SIGNED_UNINSTALLER_DIR\*.exe" | ForEach-Object {
+ Rename-Item $_.FullName ($_.FullName -replace '\.exe$', '.e32')
+ }
+
+ $iscc = Join-Path $env:InnoSetupRoot 'ISCC.exe'
+ New-Item -ItemType Directory -Force -Path $env:OB_OUTPUTDIRECTORY | Out-Null
+ & $iscc `
+ /Q `
+ "/DSignedUninstallerDir=$env:SIGNED_UNINSTALLER_DIR" `
+ "/DAppVersion=$env:EDIT_VERSION" `
+ "/DArchitecturesAllowed=$env:ARCH" `
+ "/DSource=$env:VPACK_ROOT\edit.exe" `
+ "/O$env:OB_OUTPUTDIRECTORY" `
+ "/F$env:OUTPUT_BASENAME" `
+ assets\edit.iss
+ if ($LASTEXITCODE -ne 0) { throw "ISCC failed with exit code $LASTEXITCODE" }
+ Write-Host "Produced $env:OB_OUTPUTDIRECTORY\$env:OUTPUT_BASENAME.exe"
+ env:
+ EDIT_VERSION: $(EditVersion)
+ OB_OUTPUTDIRECTORY: $(ob_outputDirectory)
+ OUTPUT_BASENAME: edit-$(EditVersion)-${{ replace(platform, 'pc-windows-msvc', 'windows') }}-setup
+ SIGNED_UNINSTALLER_DIR: $(SignedUninstallerDir)
+ ${{ if eq(platform, 'i686-pc-windows-msvc') }}:
+ ARCH: x86
+ VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/i386"
+ ${{ elseif eq(platform, 'x86_64-pc-windows-msvc') }}:
+ ARCH: x64os
+ VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/amd64"
+ ${{ else }}: # aarch64-pc-windows-msvc
+ ARCH: arm64
+ VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/arm64"
+ displayName: Build ${{platform}} installer
+ - task: onebranch.pipeline.signing@1
+ displayName: Sign installers
+ inputs:
+ command: sign
+ signing_profile: external_distribution
+ files_to_sign: "edit-*.exe"
+ search_root: "$(ob_outputDirectory)"
+ use_testsign: false
+ in_container: true
diff --git a/assets/build-windows-setup.ps1 b/assets/build-windows-setup.ps1
new file mode 100644
index 00000000000..450895a742d
--- /dev/null
+++ b/assets/build-windows-setup.ps1
@@ -0,0 +1,35 @@
+$ErrorActionPreference = "Stop"
+$PSNativeCommandUseErrorActionPreference = $true
+
+Set-Location (Split-Path -Parent $PSScriptRoot)
+
+$env:RUSTC_BOOTSTRAP = 1
+$config = "./.cargo/release.toml"
+
+if (Get-Command "msrustup" -ErrorAction SilentlyContinue) {
+ # The default C2/MSVC toolchain cannot compile this project.
+ $env:MSRUSTUP_TOOLCHAIN = "ms-prod@llvm"
+ $config = "./.cargo/release-windows-ms.toml"
+}
+
+# Extract the package version from Cargo.toml so we can stamp it into the installer.
+$cargoToml = Get-Content -Raw -LiteralPath "crates/edit/Cargo.toml"
+$versionMatch = [regex]::Match($cargoToml, '(?m)^version\s*=\s*"([^"]+)"')
+if (!$versionMatch.Success) {
+ throw "Failed to extract version from crates/edit/Cargo.toml"
+}
+$version = $versionMatch.Groups[1].Value
+
+cargo build --config $config --release --target aarch64-pc-windows-msvc
+cargo build --config $config --release --target x86_64-pc-windows-msvc
+
+$iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
+if (!(Test-Path $iscc)) {
+ $iscc = "$env:LocalAppData\Programs\Inno Setup 6\ISCC.exe"
+ if (!(Test-Path $iscc)) {
+ throw "Please install Inno Setup 6: https://jrsoftware.org/isdl.php"
+ }
+}
+
+& $iscc /DAppVersion=$version /DArchitecturesAllowed=arm64 /DSource=$PWD\target\aarch64-pc-windows-msvc\release\edit.exe /O$PWD\target /Fedit-$version-aarch64-windows-setup assets\edit.iss
+& $iscc /DAppVersion=$version /DArchitecturesAllowed=x64os /DSource=$PWD\target\x86_64-pc-windows-msvc\release\edit.exe /O$PWD\target /Fedit-$version-x86_64-windows-setup assets\edit.iss
diff --git a/assets/edit.iss b/assets/edit.iss
new file mode 100644
index 00000000000..68c0e3f2890
--- /dev/null
+++ b/assets/edit.iss
@@ -0,0 +1,177 @@
+#ifndef AppVersion
+ #define AppVersion "0.0.0"
+#endif
+
+[Setup]
+AppId={{1717C176-3A2F-4E01-83C4-916424E34160}
+AppName=Microsoft Edit
+DefaultGroupName=Microsoft Edit
+AppVersion={#AppVersion}
+AppPublisher=Microsoft Corporation
+AppPublisherURL=https://github.com/microsoft/edit
+AppSupportURL=https://github.com/microsoft/edit
+AppUpdatesURL=https://github.com/microsoft/edit
+SetupMutex=microsoft-edit-setup
+DefaultDirName={autopf}\Microsoft Edit
+DisableDirPage=yes
+DisableProgramGroupPage=yes
+SetupIconFile=edit.ico
+UninstallDisplayIcon={app}\msedit.exe
+MinVersion=10.0
+ArchitecturesAllowed={#ArchitecturesAllowed}
+#if ArchitecturesAllowed != "x86"
+ArchitecturesInstallIn64BitMode={#ArchitecturesAllowed}
+#endif
+PrivilegesRequired=admin
+ChangesEnvironment=yes
+SolidCompression=yes
+WizardStyle=modern dynamic
+OutputBaseFilename=edit
+
+#ifdef SignedUninstallerDir
+SignedUninstaller=yes
+SignedUninstallerDir={#SignedUninstallerDir}
+#endif
+
+[Tasks]
+Name: "path"; Description: "Add msedit to the system &PATH"
+Name: "path\edit"; Description: "Also provide it as &edit, taking precedence over the edit.exe shipped with Windows"
+
+[Files]
+Source: {#Source}; DestDir: "{app}"; DestName: "msedit.exe"; Flags: notimestamp ignoreversion
+
+; Just in case, ensure that the install dir is in a clean state.
+; This also ensures that edit is not currently being used. :)
+[InstallDelete]
+Type: filesandordirs; Name: "{app}"
+
+[UninstallDelete]
+Type: filesandordirs; Name: "{app}"
+
+[Code]
+function CreateHardLink(lpFileName, lpExistingFileName: String; lpSecurityAttributes: LongWord): Boolean;
+external 'CreateHardLinkW@kernel32.dll stdcall';
+
+const
+ ENV_KEY = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment';
+
+var
+ g_AppDirPath: String;
+ g_MseditExePath: String;
+ g_EditExePath: String;
+
+procedure InitializeGlobals;
+begin
+ g_AppDirPath := ExpandConstant('{app}');
+ g_MseditExePath := ExpandConstant('{app}\msedit.exe');
+ g_EditExePath := ExpandConstant('{app}\edit.exe');
+end;
+
+procedure CreateHardlinks;
+begin
+ if not CreateHardLink(g_EditExePath, g_MseditExePath, 0) then
+ RaiseException('Failed to create hardlink for edit.exe');
+end;
+
+// Install=False removes us from the PATH. Prepend=True places us ahead of
+// System32 so that our edit.exe wins over the one shipped with Windows.
+procedure ModifyPath(Install, Prepend: Boolean);
+var
+ PathsBefore, PathsAfter: TArrayOfString;
+ PathsStringBefore, System32Path: String;
+ I, Count, System32Index, AppPathIndex: Integer;
+begin
+ if not RegQueryStringValue(HKLM, ENV_KEY, 'Path', PathsStringBefore) then
+ RaiseException('Failed to read system PATH');
+
+ PathsBefore := StringSplit(PathsStringBefore, [';'], stExcludeEmpty);
+ if GetArrayLength(PathsBefore) = 0 then
+ RaiseException('Failed to parse system PATH');
+
+ System32Index := -1;
+ AppPathIndex := -1;
+ if Install and Prepend then
+ begin
+ System32Path := ExpandConstant('{sys}');
+
+ // Find the index of System32.
+ System32Index := 0;
+ for I := 0 to GetArrayLength(PathsBefore) - 1 do
+ begin
+ if PathStartsWith(PathsBefore[I], '%SystemRoot%\System32', True) or
+ PathStartsWith(PathsBefore[I], System32Path, True) then
+ begin
+ System32Index := I;
+ Break;
+ end;
+ end;
+
+ // Find the index of our app path, if any. We want to retain the same
+ // index between installations. Unless it was previously past System32.
+ // We want it to be always before System32.
+ AppPathIndex := 0;
+ for I := 0 to System32Index - 1 do
+ begin
+ if PathStartsWith(PathsBefore[I], g_AppDirPath, True) then
+ begin
+ AppPathIndex := I;
+ Break;
+ end;
+ end;
+ end;
+
+ // Remove any and all paths pointing to our app.
+ // This doubles as an uninstall path.
+ SetArrayLength(PathsAfter, GetArrayLength(PathsBefore) + 1);
+ Count := 0;
+ for I := 0 to GetArrayLength(PathsBefore) - 1 do
+ begin
+ if I = AppPathIndex then
+ begin
+ PathsAfter[Count] := g_AppDirPath;
+ Count := Count + 1;
+ end;
+ if not PathStartsWith(PathsBefore[I], g_AppDirPath, True) then
+ begin
+ PathsAfter[Count] := PathsBefore[I];
+ Count := Count + 1;
+ end;
+ end;
+
+ // Otherwise we're happy to sit at the very end of the PATH.
+ if Install and (not Prepend) then
+ begin
+ PathsAfter[Count] := g_AppDirPath;
+ Count := Count + 1;
+ end;
+
+ SetArrayLength(PathsAfter, Count);
+
+ if not RegWriteExpandStringValue(HKLM, ENV_KEY, 'Path', StringJoin(';', PathsAfter)) then
+ RaiseException('Failed to write system PATH');
+end;
+
+procedure CurStepChanged(CurStep: TSetupStep);
+var
+ Prepend: Boolean;
+begin
+ if CurStep = ssPostInstall then
+ begin
+ InitializeGlobals;
+
+ Prepend := WizardIsTaskSelected('path\edit');
+ if Prepend then
+ CreateHardlinks;
+
+ ModifyPath(WizardIsTaskSelected('path'), Prepend);
+ end;
+end;
+
+procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
+begin
+ if CurUninstallStep = usUninstall then
+ begin
+ InitializeGlobals;
+ ModifyPath(False, False);
+ end;
+end;