diff --git a/ee/maintained-apps/ingesters/winget/ingester.go b/ee/maintained-apps/ingesters/winget/ingester.go index 783b1520a1a..2992044618f 100644 --- a/ee/maintained-apps/ingesters/winget/ingester.go +++ b/ee/maintained-apps/ingesters/winget/ingester.go @@ -8,6 +8,7 @@ import ( "fmt" "log/slog" "net/http" + "net/url" "os" "path" "path/filepath" @@ -418,7 +419,7 @@ func (i *wingetIngester) ingestOne(ctx context.Context, input inputApp) (*mainta out.Name = input.Name out.Slug = input.Slug - out.InstallerURL = selectedInstaller.InstallerURL + out.InstallerURL = directDownloadURL(selectedInstaller.InstallerURL) out.UniqueIdentifier = input.UniqueIdentifier out.DefaultCategories = input.DefaultCategories out.SHA256 = "no_check" @@ -496,6 +497,38 @@ func firstDisplayVersion(entries []appsAndFeaturesEntries) string { return "" } +// directDownloadURL rewrites a SourceForge project browse URL to the +// downloads.sourceforge.net form. The browse URL is a web page that redirects +// through a mirror, and that hop intermittently serves an HTML interstitial with +// HTTP 200 instead of the installer, which then fails the SHA256 check. The +// downloads host serves the file directly. Non-SourceForge URLs are returned +// unchanged. +func directDownloadURL(installerURL string) string { + u, err := url.Parse(installerURL) + if err != nil || (u.Host != "sourceforge.net" && u.Host != "www.sourceforge.net") { + return installerURL + } + + // Expected shape: /projects//files/[/download] + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) < 4 || parts[0] != "projects" || parts[2] != "files" { + return installerURL + } + project, filePath := parts[1], parts[3:] + if filePath[len(filePath)-1] == "download" { + filePath = filePath[:len(filePath)-1] + } + if len(filePath) == 0 { + return installerURL + } + + return (&url.URL{ + Scheme: u.Scheme, + Host: "downloads.sourceforge.net", + Path: "/project/" + project + "/" + strings.Join(filePath, "/"), + }).String() +} + func setUpExistsQuery(fuzzy fuzzyMatch, name string, publisher string) maintained_apps.FMAQueries { // TODO - consider UpgradeCode here? return maintained_apps.FMAQueries{ diff --git a/ee/maintained-apps/ingesters/winget/ingester_test.go b/ee/maintained-apps/ingesters/winget/ingester_test.go index 1013e1438bc..c87d29ed5eb 100644 --- a/ee/maintained-apps/ingesters/winget/ingester_test.go +++ b/ee/maintained-apps/ingesters/winget/ingester_test.go @@ -660,3 +660,41 @@ func TestIngestOneVersionWalk(t *testing.T) { require.True(t, isTransientGitHubError(err), "the caller must recognize this error and skip the app") }) } + +func TestDirectDownloadURL(t *testing.T) { + for _, tc := range []struct { + name string + in string + want string + }{ + { + name: "sourceforge browse URL", + in: "https://sourceforge.net/projects/crystaldiskmark/files/9.0.3/CrystalDiskMark9_0_3.exe", + want: "https://downloads.sourceforge.net/project/crystaldiskmark/9.0.3/CrystalDiskMark9_0_3.exe", + }, + { + name: "sourceforge browse URL with /download suffix", + in: "https://sourceforge.net/projects/winscp/files/WinSCP/6.5.6/WinSCP-6.5.6-Setup.exe/download", + want: "https://downloads.sourceforge.net/project/winscp/WinSCP/6.5.6/WinSCP-6.5.6-Setup.exe", + }, + { + name: "already a direct download URL", + in: "https://downloads.sourceforge.net/project/winscp/WinSCP/6.5.6/WinSCP-6.5.6-Setup.exe", + want: "https://downloads.sourceforge.net/project/winscp/WinSCP/6.5.6/WinSCP-6.5.6-Setup.exe", + }, + { + name: "non-sourceforge URL is untouched", + in: "https://github.com/owner/repo/releases/download/v1.0/app.exe", + want: "https://github.com/owner/repo/releases/download/v1.0/app.exe", + }, + { + name: "sourceforge URL with an unexpected shape is untouched", + in: "https://sourceforge.net/projects/someproject/", + want: "https://sourceforge.net/projects/someproject/", + }, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, directDownloadURL(tc.in)) + }) + } +} diff --git a/ee/maintained-apps/inputs/winget/crystaldiskmark.json b/ee/maintained-apps/inputs/winget/crystaldiskmark.json new file mode 100644 index 00000000000..13589ce7f5d --- /dev/null +++ b/ee/maintained-apps/inputs/winget/crystaldiskmark.json @@ -0,0 +1,13 @@ +{ + "name": "CrystalDiskMark", + "slug": "crystaldiskmark/windows", + "package_identifier": "CrystalDewWorld.CrystalDiskMark", + "unique_identifier": "CrystalDiskMark", + "exists_query": "SELECT 1 FROM programs WHERE name LIKE 'CrystalDiskMark %' AND name NOT LIKE '%Edition%';", + "install_script_path": "ee/maintained-apps/inputs/winget/scripts/crystaldiskmark_install.ps1", + "uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/crystaldiskmark_uninstall.ps1", + "installer_arch": "x64", + "installer_type": "exe", + "installer_scope": "machine", + "default_categories": ["Developer tools"] +} diff --git a/ee/maintained-apps/inputs/winget/scripts/crystaldiskmark_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/crystaldiskmark_install.ps1 new file mode 100644 index 00000000000..b7e8721845a --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/crystaldiskmark_install.ps1 @@ -0,0 +1,68 @@ +# Learn more about .exe install scripts: +# http://fleetdm.com/learn-more-about/exe-install-scripts + +$exeFilePath = "${env:INSTALLER_PATH}" + +$installTimeoutSeconds = 420 +$registrationTimeoutSeconds = 120 + +$machineKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' +$machineKey32on64 = 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' + +function Get-CrystalDiskMarkEntry { + Get-ChildItem -Path @($machineKey, $machineKey32on64) -ErrorAction SilentlyContinue | + ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } | + Where-Object { $_.DisplayName -like "CrystalDiskMark*" -and $_.DisplayName -notlike "*Edition*" } | + Select-Object -First 1 +} + +try { + +# -Wait also waits on descendants, so wait on the installer process alone. +$process = Start-Process -FilePath "$exeFilePath" ` + -ArgumentList "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" ` + -PassThru +# Keeps .ExitCode readable after the process ends. +$null = $process.Handle + +$killed = $false +if (-not $process.WaitForExit($installTimeoutSeconds * 1000)) { + Write-Host "Installer process did not exit within ${installTimeoutSeconds}s, stopping it." + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + $null = $process.WaitForExit(30 * 1000) + $killed = $true +} + +$exitCode = $null +if ($process.HasExited) { + $exitCode = $process.ExitCode + Write-Host "Install exit code: $exitCode" +} + +# The installer can return before the ARP entry is written. +$elapsed = 0 +while (-not (Get-CrystalDiskMarkEntry) -and ($elapsed -lt $registrationTimeoutSeconds)) { + Start-Sleep -Seconds 5 + $elapsed += 5 + Write-Host "Waiting for CrystalDiskMark to register... ($elapsed seconds)" +} + +$entry = Get-CrystalDiskMarkEntry +if (-not $entry) { + Write-Host "CrystalDiskMark did not register in Add/Remove Programs." + Exit 1 +} +Write-Host "Registered '$($entry.DisplayName)' by '$($entry.Publisher)', version $($entry.DisplayVersion)." + +# Registration above is the success signal; a killed process's code means nothing. +if ($killed -or $null -eq $exitCode) { Exit 0 } + +# 3010 (reboot required) and 1641 (reboot initiated) are successful installs. +if ($exitCode -eq 3010 -or $exitCode -eq 1641) { Exit 0 } + +Exit $exitCode + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/ee/maintained-apps/inputs/winget/scripts/crystaldiskmark_uninstall.ps1 b/ee/maintained-apps/inputs/winget/scripts/crystaldiskmark_uninstall.ps1 new file mode 100644 index 00000000000..80ac1f5547f --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/crystaldiskmark_uninstall.ps1 @@ -0,0 +1,36 @@ +$softwareName = "CrystalDiskMark" +$softwareNameLike = "$softwareName*" +$uninstallArgs = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" + +$machineKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' +$machineKey32on64 = 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' +$exitCode = 0 + +try { + [array]$uninstallKeys = Get-ChildItem -Path @($machineKey, $machineKey32on64) -ErrorAction SilentlyContinue | + ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } + + $foundUninstaller = $false + foreach ($key in $uninstallKeys) { + # The Aoi and Shizuku editions are separate products that share the prefix. + if ($key.DisplayName -like $softwareNameLike -and $key.DisplayName -notlike "*Edition*") { + $foundUninstaller = $true + $uninstallCommand = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString } + if ($uninstallCommand -match '^\s*"([^"]+)"\s*(.*)$') { + $uninstallCommand = $Matches[1]; if ($Matches[2]) { $uninstallArgs = "$($Matches[2]) $uninstallArgs".Trim() } + } elseif ($uninstallCommand -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { + $uninstallCommand = $Matches[1]; if ($Matches[2]) { $uninstallArgs = "$($Matches[2]) $uninstallArgs".Trim() } + } elseif ($uninstallCommand -match '^\s*(\S+)\s*(.*)$') { + $uninstallCommand = $Matches[1]; if ($Matches[2]) { $uninstallArgs = "$($Matches[2]) $uninstallArgs".Trim() } + } + Write-Host "Uninstall command: $uninstallCommand"; Write-Host "Uninstall args: $uninstallArgs" + $processOptions = @{ FilePath = $uninstallCommand; PassThru = $true; Wait = $true } + if ($uninstallArgs -ne '') { $processOptions.ArgumentList = $uninstallArgs } + $process = Start-Process @processOptions + $exitCode = $process.ExitCode; Write-Host "Uninstall exit code: $exitCode"; break + } + } + if (-not $foundUninstaller) { Write-Host "Uninstall entry not found for '$softwareName'."; Exit 0 } +} catch { Write-Host "Error: $_"; Exit 1 } + +Exit $exitCode diff --git a/ee/maintained-apps/outputs/apps.json b/ee/maintained-apps/outputs/apps.json index ddc87a1c6a7..365b8c0c4e8 100644 --- a/ee/maintained-apps/outputs/apps.json +++ b/ee/maintained-apps/outputs/apps.json @@ -2087,6 +2087,13 @@ "unique_identifier": "Cryptomator", "description": "Cryptomator is a multi-platform client-side cloud file encryption tool." }, + { + "name": "CrystalDiskMark", + "slug": "crystaldiskmark/windows", + "platform": "windows", + "unique_identifier": "CrystalDiskMark", + "description": "CrystalDiskMark is a disk benchmark tool that measures sequential and random read and write speeds." + }, { "name": "Crystalfetch", "slug": "crystalfetch/darwin", diff --git a/ee/maintained-apps/outputs/crystaldiskmark/windows.json b/ee/maintained-apps/outputs/crystaldiskmark/windows.json new file mode 100644 index 00000000000..2e0eb6daa1a --- /dev/null +++ b/ee/maintained-apps/outputs/crystaldiskmark/windows.json @@ -0,0 +1,22 @@ +{ + "versions": [ + { + "version": "9.0.3", + "queries": { + "exists": "SELECT 1 FROM programs WHERE name LIKE 'CrystalDiskMark %' AND name NOT LIKE '%Edition%';", + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name LIKE 'CrystalDiskMark %' AND name NOT LIKE '%Edition%' AND version_compare(version, '9.0.3') < 0);" + }, + "installer_url": "https://downloads.sourceforge.net/project/crystaldiskmark/9.0.3/CrystalDiskMark9_0_3.exe", + "install_script_ref": "0842a653", + "uninstall_script_ref": "d7453dc1", + "sha256": "1a255154e116a533f86535bc362f101e32d6171604cde9b6b554335856917e5e", + "default_categories": [ + "Developer tools" + ] + } + ], + "refs": { + "0842a653": "# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\n$installTimeoutSeconds = 420\n$registrationTimeoutSeconds = 120\n\n$machineKey = 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$machineKey32on64 = 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n\nfunction Get-CrystalDiskMarkEntry {\n Get-ChildItem -Path @($machineKey, $machineKey32on64) -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } |\n Where-Object { $_.DisplayName -like \"CrystalDiskMark*\" -and $_.DisplayName -notlike \"*Edition*\" } |\n Select-Object -First 1\n}\n\ntry {\n\n# -Wait also waits on descendants, so wait on the installer process alone.\n$process = Start-Process -FilePath \"$exeFilePath\" `\n -ArgumentList \"/VERYSILENT /SUPPRESSMSGBOXES /NORESTART\" `\n -PassThru\n# Keeps .ExitCode readable after the process ends.\n$null = $process.Handle\n\n$killed = $false\nif (-not $process.WaitForExit($installTimeoutSeconds * 1000)) {\n Write-Host \"Installer process did not exit within ${installTimeoutSeconds}s, stopping it.\"\n Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue\n $null = $process.WaitForExit(30 * 1000)\n $killed = $true\n}\n\n$exitCode = $null\nif ($process.HasExited) {\n $exitCode = $process.ExitCode\n Write-Host \"Install exit code: $exitCode\"\n}\n\n# The installer can return before the ARP entry is written.\n$elapsed = 0\nwhile (-not (Get-CrystalDiskMarkEntry) -and ($elapsed -lt $registrationTimeoutSeconds)) {\n Start-Sleep -Seconds 5\n $elapsed += 5\n Write-Host \"Waiting for CrystalDiskMark to register... ($elapsed seconds)\"\n}\n\n$entry = Get-CrystalDiskMarkEntry\nif (-not $entry) {\n Write-Host \"CrystalDiskMark did not register in Add/Remove Programs.\"\n Exit 1\n}\nWrite-Host \"Registered '$($entry.DisplayName)' by '$($entry.Publisher)', version $($entry.DisplayVersion).\"\n\n# Registration above is the success signal; a killed process's code means nothing.\nif ($killed -or $null -eq $exitCode) { Exit 0 }\n\n# 3010 (reboot required) and 1641 (reboot initiated) are successful installs.\nif ($exitCode -eq 3010 -or $exitCode -eq 1641) { Exit 0 }\n\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "d7453dc1": "$softwareName = \"CrystalDiskMark\"\n$softwareNameLike = \"$softwareName*\"\n$uninstallArgs = \"/VERYSILENT /SUPPRESSMSGBOXES /NORESTART\"\n\n$machineKey = 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$machineKey32on64 = 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$exitCode = 0\n\ntry {\n [array]$uninstallKeys = Get-ChildItem -Path @($machineKey, $machineKey32on64) -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue }\n\n $foundUninstaller = $false\n foreach ($key in $uninstallKeys) {\n # The Aoi and Shizuku editions are separate products that share the prefix.\n if ($key.DisplayName -like $softwareNameLike -and $key.DisplayName -notlike \"*Edition*\") {\n $foundUninstaller = $true\n $uninstallCommand = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }\n if ($uninstallCommand -match '^\\s*\"([^\"]+)\"\\s*(.*)$') {\n $uninstallCommand = $Matches[1]; if ($Matches[2]) { $uninstallArgs = \"$($Matches[2]) $uninstallArgs\".Trim() }\n } elseif ($uninstallCommand -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') {\n $uninstallCommand = $Matches[1]; if ($Matches[2]) { $uninstallArgs = \"$($Matches[2]) $uninstallArgs\".Trim() }\n } elseif ($uninstallCommand -match '^\\s*(\\S+)\\s*(.*)$') {\n $uninstallCommand = $Matches[1]; if ($Matches[2]) { $uninstallArgs = \"$($Matches[2]) $uninstallArgs\".Trim() }\n }\n Write-Host \"Uninstall command: $uninstallCommand\"; Write-Host \"Uninstall args: $uninstallArgs\"\n $processOptions = @{ FilePath = $uninstallCommand; PassThru = $true; Wait = $true }\n if ($uninstallArgs -ne '') { $processOptions.ArgumentList = $uninstallArgs }\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode; Write-Host \"Uninstall exit code: $exitCode\"; break\n }\n }\n if (-not $foundUninstaller) { Write-Host \"Uninstall entry not found for '$softwareName'.\"; Exit 0 }\n} catch { Write-Host \"Error: $_\"; Exit 1 }\n\nExit $exitCode\n" + } +} diff --git a/ee/maintained-apps/outputs/winscp/windows.json b/ee/maintained-apps/outputs/winscp/windows.json index 795e1aa354e..c3cb78a7880 100644 --- a/ee/maintained-apps/outputs/winscp/windows.json +++ b/ee/maintained-apps/outputs/winscp/windows.json @@ -6,7 +6,7 @@ "exists": "SELECT 1 FROM programs WHERE name LIKE 'WinSCP %' AND publisher = 'Martin Prikryl';", "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name LIKE 'WinSCP %' AND publisher = 'Martin Prikryl' AND version_compare(version, '6.5.6') < 0);" }, - "installer_url": "https://sourceforge.net/projects/winscp/files/WinSCP/6.5.6/WinSCP-6.5.6-Setup.exe/download", + "installer_url": "https://downloads.sourceforge.net/project/winscp/WinSCP/6.5.6/WinSCP-6.5.6-Setup.exe", "install_script_ref": "d07f6d6d", "uninstall_script_ref": "24fcb8df", "sha256": "4488c493bafca6af4e7ae54ed39cb71479e65dc192c4d1a471647bf9cb9d6db0", diff --git a/frontend/pages/SoftwarePage/components/icons/Crystaldiskmark.tsx b/frontend/pages/SoftwarePage/components/icons/Crystaldiskmark.tsx new file mode 100644 index 00000000000..40625482c14 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/Crystaldiskmark.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; + +import type { SVGProps } from "react"; + +const Crystaldiskmark = (props: SVGProps) => ( + + + +); +export default Crystaldiskmark; diff --git a/frontend/pages/SoftwarePage/components/icons/index.ts b/frontend/pages/SoftwarePage/components/icons/index.ts index 442e31e641d..c92d18c76ae 100644 --- a/frontend/pages/SoftwarePage/components/icons/index.ts +++ b/frontend/pages/SoftwarePage/components/icons/index.ts @@ -241,6 +241,7 @@ import CriblEdge from "./CriblEdge"; import Crisisgo from "./Crisisgo"; import Crossover from "./Crossover"; import Cryptomator from "./Cryptomator"; +import Crystaldiskmark from "./Crystaldiskmark"; import Crystalfetch from "./Crystalfetch"; import CubeBrowser from "./CubeBrowser"; import Cursor from "./Cursor"; @@ -1390,6 +1391,7 @@ export const SOFTWARE_NAME_TO_ICON_MAP = { crisisgo: Crisisgo, crossover: Crossover, cryptomator: Cryptomator, + crystaldiskmark: Crystaldiskmark, crystalfetch: Crystalfetch, "cube browser": CubeBrowser, cursor: Cursor, diff --git a/website/assets/images/app-icon-crystaldiskmark-60x60@2x.png b/website/assets/images/app-icon-crystaldiskmark-60x60@2x.png new file mode 100644 index 00000000000..9f278c60a28 Binary files /dev/null and b/website/assets/images/app-icon-crystaldiskmark-60x60@2x.png differ