Skip to content
Closed
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
2 changes: 1 addition & 1 deletion apps/launcher/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ async function replaceToolShortcut() {
<div class="setting-group">
<label><span><strong>Enable Tools</strong><small>Build Management, Quick Travel, and Xunlai Storage.</small></span><input type="checkbox" :checked="snapshot.tools.configured" @change="runAction('The Tools setting could not be saved.', () => native?.tools.setMasterEnabled(checked($event)))" /></label>
<div v-for="(setting, tool) in snapshot.tools.features" :key="tool" class="tool-row">
<label><span><strong>{{ toolLabels[tool] }}</strong><small>{{ shortcutDisplay(setting.shortcut) }}</small></span><input type="checkbox" :checked="setting.enabled" :disabled="!snapshot.tools.configured" @change="setTool(tool, checked($event))" /></label>
<label><span><strong>{{ toolLabels[tool] }}</strong><small>{{ shortcutDisplay(setting.shortcut, snapshot.platform) }}</small></span><input type="checkbox" :checked="setting.enabled" :disabled="!snapshot.tools.configured" @change="setTool(tool, checked($event))" /></label>
<div><button class="secondary" @click="captureToolShortcut(tool)">Change shortcut</button><button class="text-link" @click="runAction('The default shortcut could not be restored.', () => native?.tools.restoreDefaultShortcut(tool))">Restore default</button></div>
</div>
<p v-if="shortcutMessage" class="inline-message" aria-live="polite">{{ shortcutMessage }}</p>
Expand Down
1 change: 1 addition & 0 deletions apps/launcher/src/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { LEGACY_PRIMARY_PROFILE_ID, parseProfileId } from "@shared/multiple-acco

export const fixtureSnapshot: LauncherSnapshot = {
revision: 1,
platform: "macos",
experience: {
installationKind: "migrated-single",
setup: "complete",
Expand Down
9 changes: 6 additions & 3 deletions forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,14 @@ function packagedExecutablePath(
if (platform !== "win32" && platform !== "linux") {
throw new Error(`unsupported package platform: ${platform}`);
}
const suffix = platform === "win32" ? ".exe" : "";
// packageAfterCopy runs before Electron Packager renames the executable.
// Its build path is resources/app, so both desktop targets reach the
// package root and flip the pre-rename Electron binary.
const executable = platform === "win32" ? "electron.exe" : "electron";
return path.resolve(
resourcesPath,
"..",
`${channelConfig.productName}${suffix}`,
"../..",
executable,
);
}

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"packageManager": "pnpm@11.13.1",
"type": "module",
"description": "Guild Wars Reforged for macOS",
"author": "gwonmac contributors",
"license": "GPL-3.0-only",
"repository": {
"type": "git",
Expand Down
3 changes: 3 additions & 0 deletions src/main/launcher-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
LauncherSnapshot,
} from "../shared/launcher-contracts.js";
import type { ProfileId } from "../shared/multiple-accounts.js";
import type { ShortcutPlatform } from "../shared/keyboard-shortcuts.js";
import type { LauncherStateStore } from "./core/launcher-state.js";
import { launcherToolSettings } from "./core/launcher-tools.js";

Expand All @@ -36,6 +37,7 @@ export interface LauncherOrchestratorOptions {
readonly getSettings: () => AppSettings;
readonly toolsLoaded: () => boolean;
readonly developmentFixtures: boolean;
readonly platform?: ShortcutPlatform;
/** Unpackaged Electron tests only: lets renderer-focused suites bypass client preparation. */
readonly allowUnreadyLaunch?: boolean;
readonly publish: (snapshot: LauncherSnapshot) => void;
Expand Down Expand Up @@ -66,6 +68,7 @@ export class LauncherOrchestrator {
const fixture = this.options.developmentFixtures ? "fixture" as const : "placeholder" as const;
return {
revision: this.revision,
platform: this.options.platform ?? "macos",
experience: {
installationKind: document.installationKind,
setup: document.setupVersion > 0 ? "complete" : "pending",
Expand Down
11 changes: 8 additions & 3 deletions src/main/launcher-shortcut-capture.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
/**
* Captures one macOS application shortcut at the launcher boundary. Main owns
* Captures one platform-native application shortcut at the launcher boundary. Main owns
* reserved keys and Tool conflicts, so renderer code cannot bypass the policy.
*/
import type { BrowserWindow, Event, Input } from "electron";
import type { AppSettings } from "../shared/contracts.js";
import type { GlobalTool, LauncherShortcutCaptureResult } from "../shared/launcher-contracts.js";
import { shortcutFromInput, shortcutReserved } from "../shared/keyboard-shortcuts.js";
import {
shortcutFromInput,
shortcutPlatform,
shortcutReserved,
} from "../shared/keyboard-shortcuts.js";
import { shortcutOwner } from "./core/launcher-tools.js";

const CAPTURE_TIMEOUT_MS = 30_000;
const PLATFORM = shortcutPlatform(process.platform);

export function captureLauncherShortcut(
win: BrowserWindow,
Expand All @@ -31,7 +36,7 @@ export function captureLauncherShortcut(
finish({ status: "cancelled" });
return;
}
const binding = shortcutFromInput(input);
const binding = shortcutFromInput(input, PLATFORM);
if (!binding) {
finish({ status: "invalid" });
return;
Expand Down
16 changes: 15 additions & 1 deletion src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ import { LAUNCHER_IPC } from "../shared/launcher-contracts.js";
import type { GlobalTool, LauncherSettingsPatch, ShortcutReplacement } from "../shared/launcher-contracts.js";
import {
DEFAULT_SHORTCUTS,
shortcutPlatform,
shortcutReserved,
withShortcutOverride,
} from "../shared/keyboard-shortcuts.js";
Expand All @@ -164,6 +165,7 @@ import {
loadWindowsNativeHost,
WindowsCredentialKeychain,
} from "./windows-native-host.js";
import { windowsAppUserModelId } from "./windows-shell.js";

const nativeHostLayout = {
packaged: app.isPackaged,
Expand All @@ -190,6 +192,9 @@ if (!explicitUserData && process.platform === "win32") {
app.setPath("userData", applicationStorageRoots.sessions);
app.setPath("sessionData", applicationStorageRoots.sessions);
}
if (process.platform === "win32") {
app.setAppUserModelId(windowsAppUserModelId(app.getName()));
}

const primaryInstance = app.requestSingleInstanceLock();
if (!primaryInstance) {
Expand Down Expand Up @@ -241,6 +246,9 @@ const windowCoordinator = new WindowCoordinator(
{
...(app.dock ? { dock: app.dock } : {}),
focus: (options) => app.focus(options),
...(process.platform === "win32"
? { requestAttention: (win: BrowserWindow) => win.flashFrame(true) }
: {}),
},
windowRegistry,
);
Expand Down Expand Up @@ -508,7 +516,12 @@ function buildWindowHost(
};
}

if (primaryInstance) app.on("second-instance", revealMainWindow);
if (primaryInstance) app.on("second-instance", () => {
if (
process.platform !== "win32"
|| !windowCoordinator.restoreMostRecentWindow()
) revealMainWindow();
});

if (primaryInstance) void app.whenReady().then(async () => {
if (INJECT_STARTUP_FAILURE) {
Expand Down Expand Up @@ -801,6 +814,7 @@ if (primaryInstance) void app.whenReady().then(async () => {
getSettings: () => currentSettings ?? settings,
toolsLoaded: () => enhancementSelection.tools,
developmentFixtures: !app.isPackaged || process.env.GW_LAUNCHER_FIXTURES === "1",
platform: shortcutPlatform(process.platform),
allowUnreadyLaunch,
publish: (snapshot) => {
const launcher = windowRegistry.launcherWindow();
Expand Down
16 changes: 10 additions & 6 deletions src/main/window-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* The one owner for presenting the launcher and profile game windows.
*
* The registry remains the source of window identity. This coordinator keeps
* macOS reveal, focus, hide, and final-game-close policy in one place without
* retaining another window map or changing normal BrowserWindow levels.
* reveal, focus, hide, attention, and final-game-close policy in one place
* without retaining another window map or changing normal BrowserWindow levels.
*/
import type { WindowRegistry, RegisteredWindow } from "./window-registry.js";

Expand All @@ -16,23 +16,24 @@ interface PresentableWindow extends RegisteredWindow {
focus(): void;
}

interface ApplicationPresentation {
interface ApplicationPresentation<Window> {
readonly dock?: { show(): void };
focus(options: { steal: true }): void;
requestAttention?(win: Window): void;
}

interface PreventableClose {
preventDefault(): void;
}

export class WindowCoordinator<Window extends PresentableWindow> {
readonly #application: ApplicationPresentation;
readonly #application: ApplicationPresentation<Window>;
readonly #registry: WindowRegistry<Window>;
#focusOrder: Window[] = [];
#activationTarget: Window | null = null;

constructor(
application: ApplicationPresentation,
application: ApplicationPresentation<Window>,
registry: WindowRegistry<Window>,
) {
this.#application = application;
Expand Down Expand Up @@ -116,7 +117,10 @@ export class WindowCoordinator<Window extends PresentableWindow> {
* then appear inactive before this explicit launch-owner check may focus one.
*/
revealAsyncGameIfLauncherFocused(win: Window): boolean {
if (!this.#registry.launcherWindow()?.isFocused()) return false;
if (!this.#registry.launcherWindow()?.isFocused()) {
this.#application.requestAttention?.(win);
return false;
}
return this.revealGame(win);
}

Expand Down
12 changes: 9 additions & 3 deletions src/main/window-shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
resolveShortcuts,
shortcutFromInput,
shortcutMatches,
shortcutPlatform,
type ShortcutPlatform,
type ShortcutAction,
type ShortcutCaptureResult,
} from "../shared/keyboard-shortcuts.js";
Expand Down Expand Up @@ -60,6 +62,7 @@ const textEditCommand = (input: Electron.Input): GameTextEditCommand | null => {

class WindowShortcuts {
readonly #actions: ShortcutActions;
readonly #platform: ShortcutPlatform;
#shortcuts = resolveShortcuts({
"tools.toggle": null,
"trade.toggle": null,
Expand All @@ -73,8 +76,10 @@ class WindowShortcuts {
constructor(
win: BrowserWindow,
actions: ShortcutActions,
platform: ShortcutPlatform,
) {
this.#actions = actions;
this.#platform = platform;
win.webContents.on("before-input-event", (event, input) => {
if (input.type === "keyUp") {
const decision = this.#claimedCodes.get(input.code);
Expand Down Expand Up @@ -140,7 +145,7 @@ class WindowShortcuts {
this.#finish({ status: "cleared" });
return;
}
const binding = shortcutFromInput(input);
const binding = shortcutFromInput(input, this.#platform);
this.#finish(binding
? { status: "captured", binding }
: { status: "invalid" });
Expand Down Expand Up @@ -223,7 +228,7 @@ class WindowShortcuts {
return;
}
for (const [action, binding] of Object.entries(this.#shortcuts)) {
if (binding && shortcutMatches(binding, input)) {
if (binding && shortcutMatches(binding, input, this.#platform)) {
recordMainInput(win, {
source: 'main', kind: 'native-key', phase: 'down',
key: tracedKey(input.key), repeat: input.isAutoRepeat, decision: 'shortcut',
Expand Down Expand Up @@ -343,8 +348,9 @@ const controllers = new WeakMap<BrowserWindow, WindowShortcuts>();
export function installWindowShortcuts(
win: BrowserWindow,
actions: ShortcutActions,
platform = shortcutPlatform(process.platform),
): void {
controllers.set(win, new WindowShortcuts(win, actions));
controllers.set(win, new WindowShortcuts(win, actions, platform));
}

export function updateWindowShortcuts(
Expand Down
26 changes: 26 additions & 0 deletions src/main/windows-shell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* The stable Squirrel.Windows identity shared by installer shortcuts and the
* running process. Windows groups launcher and game previews under this one
* identity; no per-window AppUserModelID creates a second taskbar product.
*/
import {
DISTRIBUTION_CHANNEL_CONFIG,
DISTRIBUTION_CHANNELS,
type DistributionChannel,
} from "../shared/distribution-channel.js";

export function windowsSquirrelPackageId(
channel: DistributionChannel,
): string {
if (channel === "release") return "GuildWarsReforged";
if (channel === "preview") return "GuildWarsReforgedPreview";
return "GuildWarsReforgedDev";
}

export function windowsAppUserModelId(productName: string): string {
const channel = DISTRIBUTION_CHANNELS.find(
(candidate) => DISTRIBUTION_CHANNEL_CONFIG[candidate].productName === productName,
);
if (!channel) throw new Error(`unknown Windows product identity: ${productName}`);
return `com.squirrel.${windowsSquirrelPackageId(channel)}.${productName}`;
}
46 changes: 37 additions & 9 deletions src/shared/keyboard-shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ export interface ShortcutInput {
alt: boolean;
}

export const SHORTCUT_PLATFORMS = ["macos", "windows", "linux"] as const;
export type ShortcutPlatform = (typeof SHORTCUT_PLATFORMS)[number];

export function shortcutPlatform(platform: string): ShortcutPlatform {
if (platform === "darwin") return "macos";
if (platform === "win32") return "windows";
if (platform === "linux") return "linux";
throw new Error(`unsupported shortcut platform: ${platform}`);
}

export function isShortcutBinding(value: unknown): value is ShortcutBinding {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return false;
Expand Down Expand Up @@ -111,17 +121,26 @@ export function shortcutEquals(
export function shortcutMatches(
binding: ShortcutBinding,
input: ShortcutInput,
platform: ShortcutPlatform,
): boolean {
return input.meta
&& !input.control
const primary = platform === "macos"
? input.meta && !input.control
: input.control && !input.meta && !input.alt;
return primary
&& shortcutKey(input.code) === binding.key
&& input.shift === binding.shift
&& input.alt === binding.option;
}

export function shortcutFromInput(input: ShortcutInput): ShortcutBinding | null {
export function shortcutFromInput(
input: ShortcutInput,
platform: ShortcutPlatform,
): ShortcutBinding | null {
const key = shortcutKey(input.code);
if (!input.meta || input.control || key === null) return null;
const primary = platform === "macos"
? input.meta && !input.control
: input.control && !input.meta && !input.alt;
if (!primary || key === null) return null;
return { key, shift: input.shift, option: input.alt };
}

Expand All @@ -140,7 +159,7 @@ const RESERVED_SHORTCUTS: readonly ShortcutBinding[] = [
})),
{ key: "z", shift: true, option: false },
{ key: "r", shift: false, option: false },
// Travel owns Command+1…9 for quick-destination assignment.
// Travel owns primary-modifier+1…9 for quick-destination assignment.
..."123456789".split("").map((key) => ({
key,
shift: false,
Expand Down Expand Up @@ -176,17 +195,26 @@ export function withShortcutOverride(
return next;
}

export function shortcutAccelerator(binding: ShortcutBinding | null): string | undefined {
export function shortcutAccelerator(
binding: ShortcutBinding | null,
platform: ShortcutPlatform,
): string | undefined {
if (!binding) return undefined;
return [
"Command",
platform === "macos" ? "Command" : "Control",
binding.option ? "Alt" : null,
binding.shift ? "Shift" : null,
binding.key.toUpperCase(),
].filter((part): part is string => part !== null).join("+");
}

export function shortcutDisplay(binding: ShortcutBinding | null): string {
export function shortcutDisplay(
binding: ShortcutBinding | null,
platform: ShortcutPlatform,
): string {
if (!binding) return "Not set";
return `⌘${binding.option ? "⌥" : ""}${binding.shift ? "⇧" : ""}${binding.key.toUpperCase()}`;
if (platform === "macos") {
return `⌘${binding.option ? "⌥" : ""}${binding.shift ? "⇧" : ""}${binding.key.toUpperCase()}`;
}
return `Ctrl+${binding.shift ? "Shift+" : ""}${binding.key.toUpperCase()}`;
}
3 changes: 2 additions & 1 deletion src/shared/launcher-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
normaliseCartographyPresetLibrary,
type CartographyPresetLibrary,
} from "./cartography-overlay.js";
import type { ShortcutBinding } from "./keyboard-shortcuts.js";
import type { ShortcutBinding, ShortcutPlatform } from "./keyboard-shortcuts.js";
import type { ProfileId } from "./multiple-accounts.js";

export const LAUNCHER_IPC = Object.freeze({
Expand Down Expand Up @@ -175,6 +175,7 @@ export type LauncherReadiness =

export interface LauncherSnapshot {
readonly revision: number;
readonly platform: ShortcutPlatform;
readonly experience: Readonly<{
installationKind: LauncherInstallationKind;
setup: "pending" | "complete";
Expand Down
Loading
Loading