Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/portable-native-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ on:
- "scripts/build.mjs"
- "scripts/platform-storage-probe.ts"
- "scripts/windows-credential-probe.ts"
- "scripts/windows-icon.ts"
- "scripts/windows-package-probe.ts"
- "forge.config.ts"
- "src/**"
- "tests/integration/gw-dat-dimensions.test.ts"
- "package.json"
Expand Down Expand Up @@ -63,3 +66,21 @@ jobs:
run: >-
node --import ./scripts/ts-hook.mjs --test
tests/integration/gw-dat-dimensions.test.ts
- name: Build the unsigned Windows Squirrel package
if: runner.os == 'Windows'
env:
GW_PACKAGE_INTENT: local
run: pnpm make -- --platform=win32 --arch=x64
- name: Inspect the exact Windows package boundary
if: runner.os == 'Windows'
run: pnpm test:windows-package
- name: Prove the local package has no trusted distribution signature
if: runner.os == 'Windows'
shell: pwsh
run: |
$setup = Get-ChildItem "out/make/squirrel.windows/x64/*Setup.exe"
if ($setup.Count -ne 1) { throw "Expected one Setup executable" }
$signature = Get-AuthenticodeSignature $setup.FullName
if ($signature.Status -ne "NotSigned") {
throw "Local package unexpectedly has status $($signature.Status)"
}
13 changes: 8 additions & 5 deletions apps/launcher/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ const toolLabels: Readonly<Record<GlobalTool, string>> = {
"xunlai-storage": "Xunlai Storage",
};
const profileIcons = { swords: Swords, archive: Archive, map: MapIcon, scroll: ScrollText, shield: Shield, star: Star, crown: Crown, flame: Flame } as const;
const primaryModifierName = computed(() =>
snapshot.value.platform === "macos" ? "Command" : "Ctrl"
);

function checked(event: Event): boolean {
return (event.currentTarget as HTMLInputElement).checked;
Expand Down Expand Up @@ -301,12 +304,12 @@ async function captureToolShortcut(tool: GlobalTool) {
if (!result) return;
if (result.status === "captured") {
if (await runAction("The shortcut could not be saved.", () => native?.tools.replaceShortcut({ tool, binding: result.binding }))) shortcutMessage.value = "Shortcut saved.";
} else if (result.status === "reserved") shortcutMessage.value = "That shortcut is used by macOS or this application.";
} else if (result.status === "reserved") shortcutMessage.value = "That shortcut is used by your system or this application.";
else if (result.status === "conflict") {
pendingShortcutReplacement.value = { tool, binding: result.binding };
shortcutMessage.value = `That shortcut is already used by ${toolLabels[result.tool]}.`;
}
else if (result.status === "invalid") shortcutMessage.value = "Use Command with a letter or number.";
else if (result.status === "invalid") shortcutMessage.value = `Use ${primaryModifierName.value} with a letter or number.`;
else shortcutMessage.value = "Shortcut change cancelled.";
}

Expand Down Expand Up @@ -342,7 +345,7 @@ async function replaceToolShortcut() {
<AlertTriangle /><div><strong>{{ playableNotice.title }}</strong><span>{{ playableNotice.detail }}</span></div>
<button @click="openSettings('game-files')">View game files</button>
</section>
<section v-else-if="snapshot.readiness.state === 'offline-playable'" class="priority-banner"><AlertTriangle /><div><strong>You are offline</strong><span>You can play with the game files already on this Mac.</span></div></section>
<section v-else-if="snapshot.readiness.state === 'offline-playable'" class="priority-banner"><AlertTriangle /><div><strong>You are offline</strong><span>You can play with the game files already on this computer.</span></div></section>
<section v-else-if="backgroundDownloadBanner" class="priority-banner"><Clock3 /><div><strong>{{ backgroundDownloadBanner.title }}</strong><span>{{ backgroundDownloadBanner.detail }}</span></div><button @click="openSettings('game-files')">View download</button></section>
<section v-else class="funding-banner">
<div><strong>Help cover the yearly costs</strong><span>Apple Developer Program, domain, and hosting</span></div>
Expand Down Expand Up @@ -395,7 +398,7 @@ async function replaceToolShortcut() {
<div v-if="snapshot.tools.restartRequired" class="restart-row"><span><strong>Restart needed</strong><small>Your change is saved.</small></span><button v-if="!visibleProfiles.some(profile => profile.state === 'running')" class="primary" @click="runAction('The application could not restart.', () => native?.tools.restartToApply())">Restart application</button><span v-else>Applies after your next normal restart.</span></div>
</div>
</template>
<MapsSettings v-else-if="settingsRoute === 'maps'" :settings="snapshot.settings" :save="updateLauncherSettings" />
<MapsSettings v-else-if="settingsRoute === 'maps'" :settings="snapshot.settings" :platform="snapshot.platform" :save="updateLauncherSettings" />
<GameFilesSettings
v-else-if="settingsRoute === 'game-files'"
:readiness="snapshot.readiness"
Expand Down Expand Up @@ -437,7 +440,7 @@ async function replaceToolShortcut() {

<BaseModal v-if="snapshot.experience.setup === 'pending'" labelledby="setup-title" :dismissible="false" wide>
<div class="setup-card">
<template v-if="setupStep === 1"><span class="eyebrow">Welcome</span><h2 id="setup-title">Welcome to Guild Wars Reforged</h2><p>Guild Wars Reforged runs Guild Wars on your Mac. It is an unofficial community project and is not affiliated with ArenaNet or NCSOFT.</p><div class="form-actions"><button class="primary" @click="setupStep = 2">Continue</button></div></template>
<template v-if="setupStep === 1"><span class="eyebrow">Welcome</span><h2 id="setup-title">Welcome to Guild Wars Reforged</h2><p>Guild Wars Reforged runs Guild Wars on your computer. It is an unofficial community project and is not affiliated with ArenaNet or NCSOFT.</p><div class="form-actions"><button class="primary" @click="setupStep = 2">Continue</button></div></template>
<template v-else><span class="eyebrow">Optional</span><h2 id="setup-title">Optional Tools</h2><p>Build Management saves team builds. Quick Travel opens a map search. Xunlai Storage opens storage in supported outposts.</p><p><strong>Tools apply to every account.</strong></p><p class="setup-note">If you enable Tools, the app restarts once to finish setup.</p><div class="form-actions spread"><button class="secondary" @click="setupStep = 1">Back</button><span /><button class="secondary" @click="completeSetup(false)">Not now</button><button class="primary" @click="completeSetup(true)">Enable Tools</button></div></template>
</div>
</BaseModal>
Expand Down
4 changes: 2 additions & 2 deletions apps/launcher/src/components/KnownIssuesView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ const emit = defineEmits<{ external: [kind: LauncherExternalLink] }>();

<template>
<section class="page">
<div class="page-head"><div><span class="eyebrow">Support</span><h1>Known issues</h1><p>Current game and macOS issues, with workarounds when we have one.</p></div></div>
<div class="page-head"><div><span class="eyebrow">Support</span><h1>Known issues</h1><p>Current game and launcher issues, with workarounds when we have one.</p></div></div>
<div v-if="availability === 'placeholder'" class="empty-state"><AlertTriangle /><h3>Known issues are not connected yet.</h3><p>Check GitHub or Discord for current reports and workarounds.</p><div class="form-actions"><button class="secondary" @click="emit('external', 'discord')">Open Discord</button><button class="primary" @click="emit('external', 'github')">Open GitHub</button></div></div>
<div v-else class="issue-list">
<article><AlertTriangle /><div><h3>Some textures may appear black</h3><p>Restart the affected game window. Your saved data is not affected.</p><button class="text-link" @click="emit('external', 'github')">View workaround <ExternalLink /></button></div><span>Game</span></article>
<article><AlertTriangle /><div><h3>Long sessions can use too much memory</h3><p>Close and reopen the game window when macOS shows a memory warning.</p></div><span>Game</span></article>
<article><AlertTriangle /><div><h3>Long sessions can use too much memory</h3><p>Close and reopen the game window when your system shows a memory warning.</p></div><span>Game</span></article>
<article class="resolved"><Check /><div><h3>Map reveal crash</h3><p>Fixed in the current launcher version.</p></div><span>Resolved</span></article>
</div>
</section>
Expand Down
4 changes: 2 additions & 2 deletions apps/launcher/src/components/MapsSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ describe("Maps settings", () => {
it("keeps the global map controls and their style editor in the launcher", async () => {
const save = vi.fn(async () => undefined);
const wrapper = mount(MapsSettings, {
props: { settings: fixtureSnapshot.settings, save },
props: { settings: fixtureSnapshot.settings, platform: "macos", save },
});

expect(wrapper.text()).toContain("These settings apply to every account");
Expand All @@ -28,7 +28,7 @@ describe("Maps settings", () => {
it("refuses invalid imported style text without writing settings", async () => {
const save = vi.fn(async () => undefined);
const wrapper = mount(MapsSettings, {
props: { settings: fixtureSnapshot.settings, save },
props: { settings: fixtureSnapshot.settings, platform: "macos", save },
});
const textarea = wrapper.get("textarea");
await textarea.setValue("not a map style");
Expand Down
5 changes: 4 additions & 1 deletion apps/launcher/src/components/MapsSettings.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { LauncherSettings, LauncherSettingsPatch } from "@shared/launcher-contracts";
import type { ShortcutPlatform } from "@shared/keyboard-shortcuts";
import {
CARTOGRAPHY_BUILTIN_PRESETS,
CARTOGRAPHY_LINE_PATTERNS,
Expand All @@ -23,6 +24,7 @@ import {

const props = defineProps<{
settings: LauncherSettings;
platform: ShortcutPlatform;
save: (patch: LauncherSettingsPatch) => Promise<void>;
}>();

Expand All @@ -31,6 +33,7 @@ const importValue = ref("");
const editing = ref(false);
const active = computed(() => resolveCartographyPresetEntry(props.settings.cartographyPresetLibrary));
const exportValue = computed(() => active.value ? encodeCartographyPreset({ name: active.value.name, style: active.value.style }) : "");
const copyShortcut = computed(() => props.platform === "macos" ? "Command-C" : "Ctrl-C");
const lineLabels = {
lattice: "Grid lines",
current: "Current cell",
Expand Down Expand Up @@ -174,7 +177,7 @@ async function updateUnseen(field: "color" | "marker", value: string) {
<label><span><strong>Style name</strong></span><input :value="active.name" maxlength="40" @change="rename(($event.currentTarget as HTMLInputElement).value)" /></label>
<button class="text-link" @click="remove">Delete custom style</button>
</template>
<details><summary>Import or share a style</summary><label>Style text<textarea rows="4" :value="importValue || exportValue" @input="importValue = ($event.currentTarget as HTMLTextAreaElement).value" /></label><button class="secondary" :disabled="!importValue" @click="importPreset">Import as new style</button><p>Select the text and press Command-C to share the current style. Paste another style here to import it.</p></details>
<details><summary>Import or share a style</summary><label>Style text<textarea rows="4" :value="importValue || exportValue" @input="importValue = ($event.currentTarget as HTMLTextAreaElement).value" /></label><button class="secondary" :disabled="!importValue" @click="importPreset">Import as new style</button><p>Select the text and press {{ copyShortcut }} to share the current style. Paste another style here to import it.</p></details>
</div>

<div v-if="editing && active?.custom" class="setting-group map-style-editor">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ describe("Game Files settings", () => {
reset: noOp,
},
});
expect(wrapper.text()).toContain("stopped because this Mac is offline");
expect(wrapper.text()).toContain("stopped because this computer is offline");
await wrapper.get(".download-card button").trigger("click");
expect(resume).toHaveBeenCalledOnce();
});
Expand Down
4 changes: 2 additions & 2 deletions apps/launcher/src/update-game-files-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ function failedApplicationUpdate(

const DOWNLOAD_FAILURE: Partial<Record<ErrorCode, string>> = {
disk_full: "There is not enough free space to finish downloading. Free some space, then try again.",
net_offline: "The download stopped because this Mac is offline. Reconnect, then try again.",
net_offline: "The download stopped because this computer is offline. Reconnect, then try again.",
dns_timeout: "The download could not reach ArenaNet. Check your connection, then try again.",
http_status: "ArenaNet could not serve a game file. This is usually temporary; try again in a few minutes.",
};
Expand All @@ -118,7 +118,7 @@ export function backgroundDownloadFailure(reason: ErrorCode): string {

const REPAIR_REASON: Partial<Record<ErrorCode, string>> = {
disk_full: "There is not enough free space to prepare Guild Wars. Free some space, then repair the game files.",
net_offline: "This Mac is offline and no usable game client is available. Reconnect, then repair the game files.",
net_offline: "This computer is offline and no usable game client is available. Reconnect, then repair the game files.",
dns_timeout: "ArenaNet could not be reached. Check your connection, then repair the game files.",
http_status: "ArenaNet could not provide the required game files. Try the repair again in a few minutes.",
artifact_unverified: "A downloaded game file did not pass verification. Repair will download a clean copy.",
Expand Down
Binary file added assets/AppIcon.ico
Binary file not shown.
44 changes: 42 additions & 2 deletions forge.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { MakerDMG } from "@electron-forge/maker-dmg";
import { MakerSquirrel } from "@electron-forge/maker-squirrel";
import { MakerZIP } from "@electron-forge/maker-zip";
import type { ForgeConfig } from "@electron-forge/shared-types";
import { flipFuses, FuseV1Options, FuseVersion } from "@electron/fuses";
Expand All @@ -15,6 +16,10 @@ import {
DISTRIBUTION_CHANNEL_CONFIG,
distributionMarker,
} from "./src/shared/distribution-channel.js";
import {
releaseAssetUrl,
releaseUpdateArtifactName,
} from "./src/shared/project-identity.js";

const packageVersion = (
JSON.parse(
Expand All @@ -27,6 +32,7 @@ const macOSVersion = macOSBundleVersions(packageVersion);
const packageMode = resolvePackageMode(process.env.GW_PACKAGE_INTENT);
const channelConfig = DISTRIBUTION_CHANNEL_CONFIG[packageMode.productChannel];
const buildingDarwin = process.platform === "darwin";
const buildingWindows = process.platform === "win32";

function packagedExecutablePath(
resourcesPath: string,
Expand Down Expand Up @@ -80,6 +86,16 @@ const releaseNotarization = buildingDarwin && packageMode.intent === "release"
}
: undefined;

const windowsSigning = buildingWindows && packageMode.kind === "signed"
? {
certificateFile: requiredSigningEnvironment("WINDOWS_CERTIFICATE_FILE"),
certificatePassword: requiredSigningEnvironment("WINDOWS_CERTIFICATE_PASSWORD"),
timestampServer: "https://timestamp.digicert.com",
description: channelConfig.productName,
website: "https://gwonmac.com",
}
: undefined;

const config: ForgeConfig = {
packagerConfig: {
// Both are executable code that cannot run from inside the archive: a
Expand All @@ -92,7 +108,7 @@ const config: ForgeConfig = {
executableName: channelConfig.productName,
appVersion: macOSVersion.appVersion,
buildVersion: macOSVersion.buildVersion,
icon: path.resolve("assets/AppIcon.icns"),
icon: path.resolve(buildingWindows ? "assets/AppIcon.ico" : "assets/AppIcon.icns"),
appBundleId: channelConfig.bundleId,
appCategoryType: "public.app-category.games",
darwinDarkModeSupport: true,
Expand Down Expand Up @@ -123,6 +139,27 @@ const config: ForgeConfig = {
rebuildConfig: {},
makers: [
new MakerZIP({}, ["darwin"]),
...(buildingWindows
? [
new MakerSquirrel({
usePackageJson: false,
name: channelConfig.windowsPackageId,
title: channelConfig.productName,
exe: `${channelConfig.productName}.exe`,
version: packageVersion,
authors: "gwonmac contributors",
owners: "gwonmac contributors",
copyright: "© 2026 gwonmac contributors. Guild Wars © ArenaNet LLC.",
description: "Run Guild Wars with native profiles, updates, and optional Tools.",
setupExe: releaseUpdateArtifactName(packageVersion, "win32-x64"),
setupIcon: path.resolve("assets/AppIcon.ico"),
iconUrl: releaseAssetUrl(`v${packageVersion}`, "AppIcon.ico"),
noMsi: true,
noDelta: true,
...(windowsSigning ? { windowsSign: windowsSigning } : {}),
}),
]
: []),
...(buildingDarwin && packageMode.intent === "release"
? [
new MakerDMG({
Expand Down Expand Up @@ -161,7 +198,10 @@ const config: ForgeConfig = {
platform,
arch,
) => {
if (platform === "darwin" && packageMode.kind === "signed") {
if (
(platform === "darwin" || platform === "win32")
&& packageMode.kind === "signed"
) {
writeFileSync(
path.resolve(resourcesPath, "..", "distribution-channel.json"),
`${JSON.stringify(distributionMarker(packageMode.channel))}\n`,
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"private": true,
"packageManager": "pnpm@11.13.1",
"type": "module",
"description": "Guild Wars Reforged for macOS",
"description": "Guild Wars Reforged desktop launcher",
"author": "gwonmac contributors",
"license": "GPL-3.0-only",
"repository": {
Expand All @@ -27,6 +27,7 @@
"launcher:fixture": "pnpm build && node --import ./scripts/ts-hook.mjs scripts/launcher-fixture.ts",
"storage:probe": "node --import ./scripts/ts-hook.mjs scripts/platform-storage-probe.ts",
"test:windows-credentials": "node --import ./scripts/ts-hook.mjs scripts/windows-credential-probe.ts",
"test:windows-package": "node --import ./scripts/ts-hook.mjs scripts/windows-package-probe.ts",
"test:signed-dev": "node --import ./scripts/ts-hook.mjs scripts/run-signed-dev.ts --test-keychain",
"tools:dev": "pnpm --filter @gwonmac/tools-ui dev",
"tools:test": "pnpm --filter @gwonmac/tools-ui test",
Expand Down Expand Up @@ -76,6 +77,7 @@
"devDependencies": {
"@electron-forge/cli": "^7.11.2",
"@electron-forge/maker-dmg": "^7.11.2",
"@electron-forge/maker-squirrel": "^7.11.2",
"@electron-forge/maker-zip": "^7.11.2",
"@electron-forge/shared-types": "^7.11.2",
"@electron/asar": "^3.4.1",
Expand Down
Loading
Loading