feat: add in-app update flow and native e2e verification (#1112)

* feat(app-update): add in-app update flow with native e2e coverage

* docs(ai-workflow): infer closed-issue labels automatically

* fix(app-update): address native updater review findings

* fix(android): validate updater redirect hosts

* fix(electron): harden updater file names
This commit is contained in:
Tommaso Casaburi
2026-03-20 13:31:52 +08:00
committed by GitHub
parent dd4451c8c5
commit 5ecf8fbea8
11 changed files with 321 additions and 42 deletions
+5 -1
View File
@@ -16,7 +16,9 @@ Creates a GitHub issue, commits relevant changes on a review branch, pushes the
### 1. Determine label(s)
Ask the user using AskQuestion (multi-select):
The agent should choose the issue label(s) itself from the conversation context and diff. Do **not** ask the user to pick labels unless the work is genuinely ambiguous after reviewing both.
Default mapping:
| Option | When |
|--------|------|
@@ -25,6 +27,8 @@ Ask the user using AskQuestion (multi-select):
| `bug` + `enhancement` | New feature that also fixes a bug |
| `documentation` | README, AGENTS.md, docs-only changes |
When the classification is ambiguous, make the best reasonable choice and note the reasoning in the final summary. Only ask the user if the ambiguity would materially affect tracking or triage.
### 2. Resolve the current GitHub assignee
Before creating or editing any issue assignee, determine the current contributor's GitHub username from the authenticated `gh` session.
+5 -1
View File
@@ -16,7 +16,9 @@ Creates a GitHub issue, commits relevant changes on a review branch, pushes the
### 1. Determine label(s)
Ask the user using AskQuestion (multi-select):
The agent should choose the issue label(s) itself from the conversation context and diff. Do **not** ask the user to pick labels unless the work is genuinely ambiguous after reviewing both.
Default mapping:
| Option | When |
|--------|------|
@@ -25,6 +27,8 @@ Ask the user using AskQuestion (multi-select):
| `bug` + `enhancement` | New feature that also fixes a bug |
| `documentation` | README, AGENTS.md, docs-only changes |
When the classification is ambiguous, make the best reasonable choice and note the reasoning in the final summary. Only ask the user if the ambiguity would materially affect tracking or triage.
### 2. Resolve the current GitHub assignee
Before creating or editing any issue assignee, determine the current contributor's GitHub username from the authenticated `gh` session.
@@ -20,6 +20,8 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.util.Locale;
import okhttp3.OkHttpClient;
import okhttp3.Request;
@@ -29,6 +31,8 @@ import okhttp3.Response;
public class AppUpdaterPlugin extends Plugin {
private static final String TAG = "AppUpdaterPlugin";
private static final String EMULATOR_HOST = "10.0.2.2";
private static final String GITHUB_HOST = "github.com";
private static final String GITHUB_CONTENT_HOST = "githubusercontent.com";
@PluginMethod
public void downloadAndInstallUpdate(PluginCall call) {
@@ -63,36 +67,59 @@ public class AppUpdaterPlugin extends Plugin {
return (getContext().getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
}
private String sanitizeUrl(String value) {
static boolean isAllowedDownloadUrl(
String value, boolean allowGitHubContentHosts, boolean allowDebugHosts) {
if (value == null) {
return null;
return false;
}
String sanitized = value.trim();
Uri uri = Uri.parse(sanitized);
if (sanitized.isEmpty()) {
return false;
}
final URI uri;
try {
uri = URI.create(sanitized);
} catch (IllegalArgumentException exception) {
return false;
}
String scheme = uri.getScheme();
String host = uri.getHost();
if (scheme == null || host == null) {
return null;
return false;
}
String normalizedScheme = scheme.toLowerCase();
String normalizedHost = host.toLowerCase();
String normalizedScheme = scheme.toLowerCase(Locale.ROOT);
String normalizedHost = host.toLowerCase(Locale.ROOT);
if ("https".equals(normalizedScheme) && "github.com".equals(normalizedHost)) {
return sanitized;
if ("https".equals(normalizedScheme)
&& (GITHUB_HOST.equals(normalizedHost)
|| (allowGitHubContentHosts
&& (GITHUB_CONTENT_HOST.equals(normalizedHost)
|| normalizedHost.endsWith("." + GITHUB_CONTENT_HOST))))) {
return true;
}
if (isDebugBuild()
if (allowDebugHosts
&& ("http".equals(normalizedScheme) || "https".equals(normalizedScheme))
&& (EMULATOR_HOST.equals(normalizedHost)
|| "127.0.0.1".equals(normalizedHost)
|| "localhost".equals(normalizedHost))) {
return sanitized;
return true;
}
return null;
return false;
}
private String sanitizeUrl(String value) {
if (!isAllowedDownloadUrl(value, false, isDebugBuild())) {
return null;
}
return value.trim();
}
private String sanitizeFileName(String value) {
@@ -126,6 +153,11 @@ public class AppUpdaterPlugin extends Plugin {
throw new IOException("Unexpected response " + response.code());
}
String finalUrl = response.request().url().toString();
if (!isAllowedDownloadUrl(finalUrl, true, isDebugBuild())) {
throw new IOException("Unexpected redirected download host");
}
try (InputStream inputStream = response.body().byteStream();
OutputStream outputStream = new java.io.FileOutputStream(tempFile)) {
byte[] buffer = new byte[8192];
@@ -0,0 +1,69 @@
package fivechan.android;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class AppUpdaterPluginTest {
@Test
public void initialDownload_requiresGithubHost() {
assertTrue(
AppUpdaterPlugin.isAllowedDownloadUrl(
"https://github.com/bitsocialnet/5chan/releases/download/v0.7.3/5chan.apk",
false,
false));
assertFalse(
AppUpdaterPlugin.isAllowedDownloadUrl(
"https://objects.githubusercontent.com/github-production-release-asset.apk",
false,
false));
assertFalse(
AppUpdaterPlugin.isAllowedDownloadUrl(
"https://evil.example/5chan.apk", false, false));
}
@Test
public void redirectedDownload_allowsGithubContentHostsOnly() {
assertTrue(
AppUpdaterPlugin.isAllowedDownloadUrl(
"https://objects.githubusercontent.com/github-production-release-asset.apk",
true,
false));
assertTrue(
AppUpdaterPlugin.isAllowedDownloadUrl(
"https://release-assets.githubusercontent.com/file.apk", true, false));
assertFalse(
AppUpdaterPlugin.isAllowedDownloadUrl(
"https://githubusercontent.evil.example/file.apk", true, false));
assertFalse(
AppUpdaterPlugin.isAllowedDownloadUrl(
"https://evil.example/file.apk", true, false));
}
@Test
public void debugDownloads_allowOnlyLocalHosts() {
assertTrue(
AppUpdaterPlugin.isAllowedDownloadUrl(
"http://10.0.2.2:56405/5chan.apk", false, true));
assertTrue(
AppUpdaterPlugin.isAllowedDownloadUrl(
"https://localhost:56405/5chan.apk", false, true));
assertFalse(
AppUpdaterPlugin.isAllowedDownloadUrl(
"http://192.168.1.8:56405/5chan.apk", false, true));
assertFalse(
AppUpdaterPlugin.isAllowedDownloadUrl(
"http://10.0.2.2:56405/5chan.apk", false, false));
}
@Test
public void malformedOrUnsupportedUrls_areRejected() {
assertFalse(AppUpdaterPlugin.isAllowedDownloadUrl(null, true, true));
assertFalse(AppUpdaterPlugin.isAllowedDownloadUrl("not a url", true, true));
assertFalse(
AppUpdaterPlugin.isAllowedDownloadUrl(
"ftp://github.com/bitsocialnet/5chan/file.apk", true, true));
}
}
+49 -7
View File
@@ -5,6 +5,8 @@ import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { spawn } from 'node:child_process';
const isTrustedGitHubHost = (hostname) => hostname === 'github.com' || hostname === 'githubusercontent.com' || hostname.endsWith('.githubusercontent.com');
const LOCAL_TEST_HOSTS = new Set(
`${process.env.APP_UPDATE_ALLOWED_DOWNLOAD_HOSTS || ''}`
.split(',')
@@ -12,9 +14,28 @@ const LOCAL_TEST_HOSTS = new Set(
.filter(Boolean),
);
const logUpdateDebug = (...args) => {
if (process.env.APP_UPDATE_DEBUG === '1') {
const message = `[app-updater] ${args.map((value) => (typeof value === 'string' ? value : JSON.stringify(value))).join(' ')}`;
console.log(message);
const debugLogPath = process.env.APP_UPDATE_DEBUG_LOG_PATH;
if (debugLogPath) {
try {
fs.appendFileSync(debugLogPath, `${message}\n`, 'utf8');
} catch {
// Ignore debug log write failures so they never affect the updater flow.
}
}
}
};
const sanitizeFileName = (fileName) => {
const safeName = `${fileName || ''}`.split(/[\\/]/).pop()?.trim();
return safeName || '5chan-update';
if (!safeName || safeName === '.' || safeName === '..') {
return '5chan-update';
}
return safeName;
};
const runDetachedCommand = (command, args) => {
@@ -49,7 +70,7 @@ const runCommand = (command, args) =>
const isAllowedDownloadHost = (parsedUrl) => {
const hostname = parsedUrl.hostname.toLowerCase();
if (parsedUrl.protocol === 'https:' && hostname === 'github.com') {
if (parsedUrl.protocol === 'https:' && isTrustedGitHubHost(hostname)) {
return true;
}
@@ -67,6 +88,7 @@ const validateDownloadUrl = (url) => {
};
const downloadReleaseAsset = async ({ url, fileName }) => {
logUpdateDebug('starting asset download', { url, fileName });
validateDownloadUrl(url);
const updatesDirectory = path.join(app.getPath('temp'), '5chan-updates');
@@ -79,6 +101,7 @@ const downloadReleaseAsset = async ({ url, fileName }) => {
const response = await fetch(url, {
redirect: 'follow',
});
validateDownloadUrl(response.url);
if (!response.ok || !response.body) {
throw new Error(`Failed to download update (${response.status})`);
@@ -87,6 +110,7 @@ const downloadReleaseAsset = async ({ url, fileName }) => {
await pipeline(Readable.fromWeb(response.body), createWriteStream(tempPath));
await fs.promises.rm(targetPath, { force: true });
await fs.promises.rename(tempPath, targetPath);
logUpdateDebug('downloaded release asset', { url: response.url, targetPath });
return targetPath;
};
@@ -136,12 +160,14 @@ const scheduleMacAppBundleInstall = async (zipPath) => {
const stagingRoot = path.join(app.getPath('temp'), '5chan-updates', `staged-mac-${Date.now()}`);
await fs.promises.mkdir(stagingRoot, { recursive: true });
logUpdateDebug('extracting mac update zip', { zipPath, stagingRoot });
await runCommand('/usr/bin/ditto', ['-x', '-k', zipPath, stagingRoot]);
const stagedAppBundlePath = await findExtractedAppBundle(stagingRoot);
if (!stagedAppBundlePath) {
throw new Error('Downloaded update does not contain a macOS app bundle');
}
logUpdateDebug('resolved staged mac app bundle', { stagedAppBundlePath });
const installerScriptPath = path.join(stagingRoot, 'install-update.sh');
const script = `#!/bin/sh
@@ -157,15 +183,28 @@ for _ in $(seq 1 120); do
sleep 1
done
rm -rf "$TARGET_APP"
/usr/bin/ditto "$SOURCE_APP" "$TARGET_APP"
STAGED_TARGET="${TARGET_APP}.new"
PREVIOUS_TARGET="${TARGET_APP}.old"
rm -rf "$STAGED_TARGET" "$PREVIOUS_TARGET"
/usr/bin/ditto "$SOURCE_APP" "$STAGED_TARGET"
if [ -e "$TARGET_APP" ]; then
mv "$TARGET_APP" "$PREVIOUS_TARGET"
fi
if ! mv "$STAGED_TARGET" "$TARGET_APP"; then
if [ -e "$PREVIOUS_TARGET" ]; then
mv "$PREVIOUS_TARGET" "$TARGET_APP"
fi
exit 1
fi
/usr/bin/open -n "$TARGET_APP"
rm -rf "$(dirname "$SOURCE_APP")"
rm -rf "$PREVIOUS_TARGET" "$(dirname "$SOURCE_APP")"
`;
await fs.promises.writeFile(installerScriptPath, script, 'utf8');
await fs.promises.chmod(installerScriptPath, 0o755);
logUpdateDebug('wrote mac installer script', { installerScriptPath });
runDetachedCommand('/bin/sh', [installerScriptPath, currentAppBundlePath, stagedAppBundlePath, `${process.pid}`]);
logUpdateDebug('spawned mac installer helper', { currentAppBundlePath, stagedAppBundlePath, currentPid: process.pid });
};
const resolveCurrentLinuxAppImagePath = () => {
@@ -229,6 +268,7 @@ const openDownloadedUpdate = async (installerPath) => {
};
const downloadAndInstallUpdate = async ({ url, fileName }) => {
logUpdateDebug('received update install request', { url, fileName });
if (typeof url !== 'string' || url.trim().length === 0) {
throw new Error('Update url is required');
}
@@ -239,12 +279,14 @@ const downloadAndInstallUpdate = async ({ url, fileName }) => {
});
const installMode = await openDownloadedUpdate(installerPath);
logUpdateDebug('prepared update install', { installMode, installerPath });
if (installMode === 'quit-and-relaunch') {
setTimeout(() => {
app.exit(0);
logUpdateDebug('quitting app for update');
app.quit();
}, 200);
}
};
export { downloadAndInstallUpdate };
export { downloadAndInstallUpdate, sanitizeFileName };
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('electron', () => ({
app: {},
shell: {
openPath: vi.fn(),
},
}));
const { sanitizeFileName } = await import('./app-updater.js');
describe('app-updater', () => {
it('falls back for empty and parent-directory file names', () => {
expect(sanitizeFileName('')).toBe('5chan-update');
expect(sanitizeFileName('.')).toBe('5chan-update');
expect(sanitizeFileName('..')).toBe('5chan-update');
expect(sanitizeFileName('../..')).toBe('5chan-update');
expect(sanitizeFileName('nested/..')).toBe('5chan-update');
});
it('keeps a normal asset name', () => {
expect(sanitizeFileName('5chan-darwin-arm64-v0.7.3.zip')).toBe('5chan-darwin-arm64-v0.7.3.zip');
});
});
+16 -1
View File
@@ -3,6 +3,7 @@ import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
@@ -179,7 +180,19 @@ const startFixtureServer = async () => {
'Content-Type': 'application/octet-stream',
'Cache-Control': 'no-store',
});
fs.createReadStream(matchedAsset.filePath).pipe(response);
try {
await pipeline(fs.createReadStream(matchedAsset.filePath), response);
} catch (error) {
logStep(`fixture asset stream failed for ${matchedAsset.filePath}: ${error instanceof Error ? error.message : String(error)}`);
if (!response.headersSent) {
response.writeHead(500, {
'Content-Type': 'text/plain; charset=utf-8',
});
}
if (!response.writableEnded) {
response.end('asset stream failed');
}
}
return;
}
@@ -199,6 +212,8 @@ const startFixtureServer = async () => {
throw new Error('Could not resolve fixture server port');
}
logStep(`fixture server listening at http://127.0.0.1:${address.port} (android emulator: http://10.0.2.2:${address.port})`);
return {
port: address.port,
setRelease(version, assets) {
+1
View File
@@ -233,6 +233,7 @@ const main = async () => {
} finally {
await sleep(1000);
await fixtureServer.close();
await fs.promises.rm(workspace, { recursive: true, force: true }).catch(() => undefined);
}
};
+13 -2
View File
@@ -6,6 +6,7 @@ import { copyPath, createTempWorkspace, findFirstMatchingPath, logStep, repoRoot
const OLD_VERSION = '0.7.1';
const NEW_VERSION = '0.7.3';
const SETTINGS_HASH = '#/all/settings#interface-settings';
const MAC_ARCH = process.arch === 'x64' ? 'x64' : 'arm64';
const findPackagedMacApp = async () => {
const outDirectory = path.join(repoRoot, 'out');
@@ -57,7 +58,7 @@ const main = async () => {
fixturePort: fixtureServer.port,
});
const zippedNewAppPath = path.join(workspace, `5chan-darwin-arm64-v${NEW_VERSION}.zip`);
const zippedNewAppPath = path.join(workspace, `5chan-darwin-${MAC_ARCH}-v${NEW_VERSION}.zip`);
await zipMacApp(packagedNewAppPath, zippedNewAppPath);
fixtureServer.setRelease(NEW_VERSION, [
@@ -69,6 +70,7 @@ const main = async () => {
const versionMetadataPath = path.join(installedAppPath, 'Contents', 'Resources', 'app', 'build', 'version.json');
const sandboxHome = path.join(workspace, 'home');
const updaterDebugLogPath = path.join(workspace, 'app-updater.log');
const plebbitDataPath = path.join(sandboxHome, 'Library', 'Application Support', 'plebbit');
await fs.promises.mkdir(plebbitDataPath, { recursive: true });
await fs.promises.writeFile(path.join(plebbitDataPath, 'auth-key'), 'e2e-auth-key', 'utf8');
@@ -78,11 +80,17 @@ const main = async () => {
env: {
...process.env,
APP_UPDATE_ALLOWED_DOWNLOAD_HOSTS: '127.0.0.1',
APP_UPDATE_DEBUG: '1',
APP_UPDATE_DEBUG_LOG_PATH: updaterDebugLogPath,
HOME: sandboxHome,
},
});
const firstWindow = await electronApp.firstWindow();
firstWindow.on('dialog', async (dialog) => {
logStep(`electron app dialog: ${dialog.message()}`);
await dialog.dismiss();
});
await firstWindow.waitForLoadState('domcontentloaded');
await firstWindow.getByRole('button', { name: 'Check' }).waitFor({
timeout: 120000,
@@ -94,7 +102,9 @@ const main = async () => {
await firstWindow.getByText(`v${NEW_VERSION}`).waitFor({
timeout: 120000,
});
await firstWindow.getByRole('button', { name: 'Download' }).click();
await firstWindow.getByRole('button', { name: 'Download' }).evaluate((button) => {
button.click();
});
await waitFor(
async () => {
@@ -130,6 +140,7 @@ const main = async () => {
} finally {
await sleep(2000);
await fixtureServer.close();
await fs.promises.rm(workspace, { recursive: true, force: true }).catch(() => undefined);
}
};
+72
View File
@@ -173,6 +173,78 @@ describe('app-update', () => {
});
});
it('prefers a matching mac dmg over an incompatible zip fallback', async () => {
window.electronApi = {
isElectron: true,
getPlatform: () => testState.electronGetPlatformMock(),
downloadAndInstallUpdate: (options) => testState.electronDownloadAndInstallUpdateMock(options),
copyToClipboard: vi.fn(),
automateUploadMedia: vi.fn(),
} as Window['electronApi'];
testState.electronGetPlatformMock.mockResolvedValue({
platform: 'darwin',
arch: 'arm64',
version: 'v20.0.0',
});
testState.fetchMock.mockResolvedValueOnce(
createFetchResponse({
tag_name: 'v9.9.9',
assets: [
{
name: '5chan-9.9.9-x64.zip',
browser_download_url: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9-x64.zip',
},
{
name: '5chan-9.9.9-arm64.dmg',
browser_download_url: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9-arm64.dmg',
},
],
}),
);
const { resolveAvailableAppUpdate } = await loadModule();
const result = await resolveAvailableAppUpdate();
expect(result).toEqual({
runtime: 'electron',
targetVersion: '9.9.9',
assetName: '5chan-9.9.9-arm64.dmg',
downloadUrl: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9-arm64.dmg',
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
});
});
it('returns no mac update when the only zip asset targets the wrong architecture', async () => {
window.electronApi = {
isElectron: true,
getPlatform: () => testState.electronGetPlatformMock(),
downloadAndInstallUpdate: (options) => testState.electronDownloadAndInstallUpdateMock(options),
copyToClipboard: vi.fn(),
automateUploadMedia: vi.fn(),
} as Window['electronApi'];
testState.electronGetPlatformMock.mockResolvedValue({
platform: 'darwin',
arch: 'x64',
version: 'v20.0.0',
});
testState.fetchMock.mockResolvedValueOnce(
createFetchResponse({
tag_name: 'v9.9.9',
assets: [
{
name: '5chan-9.9.9-arm64.zip',
browser_download_url: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9-arm64.zip',
},
],
}),
);
const { resolveAvailableAppUpdate } = await loadModule();
const result = await resolveAvailableAppUpdate();
expect(result).toBeNull();
});
it('selects the latest android apk release asset', async () => {
testState.capacitorPlatform = 'android';
testState.fetchMock.mockResolvedValueOnce(
+24 -19
View File
@@ -103,7 +103,25 @@ const hasArmArchitecture = (value: string): boolean => {
return normalized.includes('arm64') || normalized.includes('aarch64');
};
const hasX64Architecture = (value: string): boolean => !hasArmArchitecture(value);
const hasX64Architecture = (value: string): boolean => {
const normalized = value.toLowerCase();
return normalized.includes('x64') || normalized.includes('x86_64') || normalized.includes('amd64');
};
const isArchitectureAgnostic = (value: string): boolean => !hasArmArchitecture(value) && !hasX64Architecture(value);
const matchesPreferredArchitecture = (value: string, prefersArm: boolean, prefersX64: boolean): boolean =>
(prefersArm && hasArmArchitecture(value)) || (prefersX64 && hasX64Architecture(value));
const findCompatibleAsset = (
assets: GitHubReleaseAsset[],
predicate: (asset: GitHubReleaseAsset) => boolean,
prefersArm: boolean,
prefersX64: boolean,
): GitHubReleaseAsset | null =>
assets.find((asset) => predicate(asset) && matchesPreferredArchitecture(asset.name, prefersArm, prefersX64)) ||
assets.find((asset) => predicate(asset) && isArchitectureAgnostic(asset.name)) ||
null;
const getReleaseUrl = (version: string): string => getDefaultReleaseUrl(normalizeVersion(version));
@@ -118,30 +136,17 @@ const findMatchingElectronAsset = async (assets: GitHubReleaseAsset[]): Promise<
const prefersX64 = hasX64Architecture(arch);
if (platform === 'darwin') {
return (
assets.find((asset) => asset.name.endsWith('.zip') && ((prefersArm && hasArmArchitecture(asset.name)) || (prefersX64 && hasX64Architecture(asset.name)))) ||
assets.find((asset) => asset.name.endsWith('.zip')) ||
assets.find((asset) => asset.name.endsWith('.dmg') && ((prefersArm && hasArmArchitecture(asset.name)) || (prefersX64 && hasX64Architecture(asset.name)))) ||
null
);
const matchingZip = findCompatibleAsset(assets, (asset) => asset.name.endsWith('.zip'), prefersArm, prefersX64);
const matchingDmg = findCompatibleAsset(assets, (asset) => asset.name.endsWith('.dmg'), prefersArm, prefersX64);
return matchingZip || matchingDmg;
}
if (platform === 'linux') {
return (
assets.find((asset) => asset.name.endsWith('.AppImage') && ((prefersArm && hasArmArchitecture(asset.name)) || (prefersX64 && hasX64Architecture(asset.name)))) ||
null
);
return findCompatibleAsset(assets, (asset) => asset.name.endsWith('.AppImage'), prefersArm, prefersX64);
}
if (platform === 'win32') {
return (
assets.find(
(asset) =>
asset.name.toLowerCase().endsWith('.exe') &&
asset.name.toLowerCase().includes('setup') &&
((prefersArm && hasArmArchitecture(asset.name)) || (prefersX64 && hasX64Architecture(asset.name))),
) || null
);
return findCompatibleAsset(assets, (asset) => asset.name.toLowerCase().endsWith('.exe') && asset.name.toLowerCase().includes('setup'), prefersArm, prefersX64);
}
return null;