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
2 changes: 1 addition & 1 deletion .github/workflows/client-canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ concurrency:

jobs:
live-client:
runs-on: macos-15
runs-on: macos-26
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/client-recertification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ jobs:
derive:
needs: detect
if: needs.detect.outputs.needed == 'true'
runs-on: macos-15
runs-on: macos-26
timeout-minutes: 45
permissions:
attestations: write
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/macos-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ permissions:

jobs:
verify:
runs-on: macos-15
runs-on: macos-26
timeout-minutes: 45
env:
GW_PACKAGE_INTENT: ${{ inputs.package-intent }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ jobs:

release-build:
needs: source
runs-on: macos-15
runs-on: macos-26
timeout-minutes: 60
environment: release
permissions:
Expand Down
20 changes: 20 additions & 0 deletions docs/process-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,26 @@ sends Guild Wars Control-A, so a following Cut can export the same selection.
Password text never crosses the renderer bridge. Physical Control stays
available to Guild Wars unchanged.

Optional dictation stays outside Chromium's renderer media permissions. Main
owns recognition and commits a final transcript to the still-focused Guild
Wars editor with complete trusted key-down, character, and key-up sequences.
The mic is eligible only while ArenaNet owns that exact text proxy. Dictation
never reads or changes the pasteboard, never simulates Paste, and never sends
Return or another control character. The renderer owns eligibility, placement,
and visible state. Main requests microphone access only after the player
selects the visible mic and
owns one native Apple Speech session across all game windows. On macOS 26 or
newer, an explicit Settings setup uses AssetInventory to install the selected
language's system-managed model before enabling the feature. Older macOS does
not load a fallback recognizer. SpeechAnalyzer sends bounded partial transcripts
only to the owning renderer. The final transcript stays in Main only long
enough to insert it; no layer logs it. The renderer shows
the control only for the ordinary text and multiline Guild Wars proxies, and
cancels when that proxy closes or changes. Password, email, and number proxies
are never eligible. A certified `EditMessage` frame rectangle anchors the mic to the
movable Guild Wars chat input; missing or uncertified geometry hides it.
Insertion edits the draft only: no layer presses Return or sends chat.

Before the first window exists, Electron writes the bundle-specific persistent
`ApplePressAndHoldEnabled = false` preference. This makes macOS send physical
repeat keydowns to hidden text proxies for printable characters, Backspace,
Expand Down
19 changes: 19 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,25 @@ Main letters, the number row, and ANSI punctuation keep the same physical game
binding when the macOS input source changes. Text fields still use the active
input source. An old custom binding can need one manual rebind.

Choose **Settings → Tools → In-game dictation → Set up** on macOS 26 or
newer. GWonMac asks Apple to prepare the on-device model for your current
language, then asks you to restart once so it can attach dictation to the
certified Guild Wars chat frame. This is optional; when it is off, GWonMac does
not download a model or request microphone access.

After setup, a small microphone appears beside the movable chat input only when
Guild Wars opens a normal text field. Select the microphone, speak, then select
it again to finish. A blue status bubble updates while Apple recognizes your
speech. The final words are added to the current draft. Review them and press Return yourself.
GWonMac never sends chat automatically.

The first recording asks for macOS Microphone access. Apple manages and may
share the downloaded language model with other apps. Audio and transcripts are
not sent to GWonMac services. The mic does not appear for email, password, or
number fields, and GWonMac does not save or include transcripts in diagnostics. If access was
denied, allow **Guild Wars Reforged** under **System Settings → Privacy &
Security → Microphone**.

The official client includes browser gamepad support. This project does not have
a dedicated controller release test.

Expand Down
4 changes: 4 additions & 0 deletions forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ const config: ForgeConfig = {
...(releaseNotarization ? { osxNotarize: releaseNotarization } : {}),
extendInfo: {
NSAppTransportSecurity: { NSAllowsArbitraryLoads: false },
NSMicrophoneUsageDescription:
"Guild Wars Reforged uses the microphone only when you start in-game dictation.",
NSSpeechRecognitionUsageDescription:
"Guild Wars Reforged converts speech to a local Guild Wars text draft.",
},
// Forge's own packaged output is out/; compiled JS lives in build/.
// The explicit filter below is the package boundary. Forge's npm pruner
Expand Down
2 changes: 2 additions & 0 deletions packaging/entitlements.development.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.application-identifier</key>
<string>9NN976MFZ4.io.github.mat4m0.gwonmac.dev</string>
<key>com.apple.developer.team-identifier</key>
Expand Down
2 changes: 2 additions & 0 deletions packaging/entitlements.preview.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.application-identifier</key>
<string>9NN976MFZ4.io.github.mat4m0.gwonmac.preview</string>
<key>com.apple.developer.team-identifier</key>
Expand Down
2 changes: 2 additions & 0 deletions packaging/entitlements.release.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.application-identifier</key>
<string>9NN976MFZ4.io.github.mat4m0.gwonmac</string>
<key>com.apple.developer.team-identifier</key>
Expand Down
1 change: 1 addition & 0 deletions scripts/apple-signing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export function approvedDistributionEntitlements(
): Readonly<Record<string, unknown>> {
return {
"com.apple.security.cs.allow-jit": true,
"com.apple.security.device.audio-input": true,
"com.apple.application-identifier": applicationIdentifier(channel),
"com.apple.developer.team-identifier": APPLE_TEAM_ID,
};
Expand Down
77 changes: 70 additions & 7 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,19 +96,33 @@ export const BUILD_STEPS = [
// directory, so it cannot disturb the emits above and its position here is
// for readability rather than correctness.
["pnpm", ["--filter", "@gwonmac/tools-ui", "build:embedded"]],
// The only native addon. It uses raw Node-API version 8, whose ABI remains
// stable across the Node and Electron upgrades this project takes. The
// framework APIs resolve at runtime from the Electron host, so the bundle
// deliberately leaves Node-API symbols undefined here.
// The only native addon. Swift owns Apple's modern, on-device transcription
// API; Objective-C++ owns the Node-API boundary. They meet at a
// four-function C ABI, then swiftc links both objects while deliberately
// leaving Node-API symbols for the Electron host to resolve at runtime.
[
"xcrun",
[
"swiftc",
"-module-cache-path",
"build/.swift-module-cache",
"-parse-as-library",
"-target",
`${nativeArchitecture}-apple-macosx12.0`,
"-O",
"-c",
"src/native/host/dictation-modern.swift",
"-o",
"build/native/dictation-modern.o",
],
],
[
"xcrun",
[
"clang++",
"-std=c++20",
"-fobjc-arc",
"-bundle",
"-undefined",
"dynamic_lookup",
"-c",
"-DNAPI_VERSION=8",
"-I",
"node_modules/node-api-headers/include",
Expand All @@ -121,6 +135,51 @@ export const BUILD_STEPS = [
"-Werror",
"-fvisibility=hidden",
"src/native/host/host.mm",
"-o",
"build/native/host.o",
],
],
[
"xcrun",
[
"clang++",
"-std=c++20",
"-fobjc-arc",
"-c",
"-DNAPI_VERSION=8",
"-I",
"node_modules/node-api-headers/include",
"-mmacosx-version-min=12.0",
"-arch",
nativeArchitecture,
"-O2",
"-Wall",
"-Wextra",
"-Werror",
"-fvisibility=hidden",
"src/native/host/dictation-host.mm",
"-o",
"build/native/dictation-host.o",
],
],
[
"xcrun",
[
"swiftc",
"-module-cache-path",
"build/.swift-module-cache",
"-target",
`${nativeArchitecture}-apple-macosx12.0`,
"-emit-library",
"build/native/host.o",
"build/native/dictation-host.o",
"build/native/dictation-modern.o",
"-Xlinker",
"-undefined",
"-Xlinker",
"dynamic_lookup",
"-Xlinker",
"-lc++",
"-framework",
"AppKit",
"-framework",
Expand All @@ -129,6 +188,10 @@ export const BUILD_STEPS = [
"LocalAuthentication",
"-framework",
"Security",
"-framework",
"AVFoundation",
"-framework",
"Speech",
"-o",
"build/native/host.node",
],
Expand Down
1 change: 1 addition & 0 deletions scripts/verify-stable-beta-roundtrip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ const candidateSettingsDomains = Array.from(
uiCustomTheme: cycle(customThemeValues, index),
uiFont: cycle(UI_FONTS, index),
controllerPromptStyle: cycle(CONTROLLER_PROMPT_STYLES, index),
dictationEnabled: cycle(booleanValues, index),
uiPanelOpacity: cycle(opacityValues, index),
cartographyOverlayEnabled: cycle(booleanValues, index),
cartographyGridEnabled: cycle(booleanValues, index + 1),
Expand Down
7 changes: 5 additions & 2 deletions src/companion-kernel/abi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pub(crate) const FLAG_PLAY_REGION_UNLOCKS: u32 = 1 << 3;

pub(crate) const SKILL_SLOT_BYTES: u32 = size_of::<SkillSlotSnapshot>() as u32;
pub(crate) const SKILL_SLOT_MAGIC: u32 = 0x534b_5747;
pub(crate) const SKILL_SLOT_ABI_AND_SIZE: u32 = (SKILL_SLOT_BYTES << 16) | 2;
pub(crate) const SKILL_SLOT_ABI_AND_SIZE: u32 = (SKILL_SLOT_BYTES << 16) | 3;
pub(crate) const FLAG_SKILL_SLOTS_READY: u32 = 1 << 0;

pub(crate) const SKILL_COOLDOWN_BYTES: u32 = size_of::<SkillCooldownSnapshot>() as u32;
Expand Down Expand Up @@ -482,6 +482,9 @@ pub(crate) struct SkillSlotSnapshot {
pub(crate) viewport_width: f32,
pub(crate) viewport_height: f32,
pub(crate) slots: [SkillSlotRect; SKILL_SLOTS],
pub(crate) chat_frame_id: u32,
pub(crate) chat_outcome: u32,
pub(crate) chat_input: SkillSlotRect,
}

#[repr(C)]
Expand Down Expand Up @@ -568,5 +571,5 @@ const _: [(); 148] = [(); size_of::<PlayRegionSnapshot>()];
const _: [(); 4160] = [(); size_of::<CursorSnapshot>()];
const _: [(); 64] = [(); size_of::<ToolboxSnapshot>()];
const _: [(); 16] = [(); size_of::<SkillSlotRect>()];
const _: [(); 164] = [(); size_of::<SkillSlotSnapshot>()];
const _: [(); 188] = [(); size_of::<SkillSlotSnapshot>()];
const _: [(); 60] = [(); size_of::<SkillCooldownSnapshot>()];
4 changes: 2 additions & 2 deletions src/companion-kernel/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -809,7 +809,7 @@ pub unsafe extern "C" fn companion_init(
}

#[no_mangle]
pub unsafe extern "C" fn companion_dispatch(kind: u32, a: u32, b: u32, c: u32, _d: u32, _e: u32) {
pub unsafe extern "C" fn companion_dispatch(kind: u32, a: u32, b: u32, c: u32, d: u32, _e: u32) {
match kind {
DISPATCH_TICK => {
if !unsafe { INITIALIZED } {
Expand All @@ -835,7 +835,7 @@ pub unsafe extern "C" fn companion_dispatch(kind: u32, a: u32, b: u32, c: u32, _
unsafe { cursor::tick(layout) };
}
if active & FEATURE_SKILL_SLOT_GEOMETRY != 0 {
unsafe { skill_slots::tick(layout, b) };
unsafe { skill_slots::tick(layout, b, d) };
}
if active & FEATURE_SKILL_COOLDOWN_OBSERVATION != 0 {
unsafe { skill_cooldowns::tick(layout, c) };
Expand Down
62 changes: 56 additions & 6 deletions src/companion-kernel/skill_slots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const MAX_FRAMES: u32 = 16_384;
const CACHE_AUDIT_TICKS: u32 = 30;
const CREATED: u32 = 0x4;
const HIDDEN: u32 = 0x200;
const FLAG_CHAT_INPUT_READY: u32 = 1 << 1;
#[derive(Clone, Copy)]
#[repr(u32)]
enum Outcome {
Expand Down Expand Up @@ -279,10 +280,33 @@ unsafe fn collect(layout: Layout, skill_bar_id: u32) -> Result<Observation, Refu
Ok(observed)
}

unsafe fn publish(frame_id: u32, observed: Result<Observation, Refusal>) {
unsafe fn collect_chat(
layout: Layout,
chat_frame_id: u32,
) -> Result<SkillSlotRect, Refusal> {
if chat_frame_id == 0 || !valid_layout(layout) {
return Err(refuse(Outcome::InvalidInput));
}
let (array, count) = unsafe { frame_table(layout) }
.ok_or_else(|| refuse(Outcome::FrameTable))?;
let frame = unsafe { frame_at(layout, array, count, chat_frame_id) }
.ok_or_else(|| refuse(Outcome::ParentMissing))?;
if !unsafe { visible(layout, frame) } {
return Err(refuse(Outcome::ParentHidden));
}
let (bounds, _, _) = unsafe { rect(layout, frame) }.map_err(refuse)?;
Ok(bounds)
}

unsafe fn publish(
frame_id: u32,
observed: Result<Observation, Refusal>,
chat_frame_id: u32,
chat_observed: Result<SkillSlotRect, Refusal>,
) {
let next = unsafe { SEQUENCE }.wrapping_add(2) & !1;
let snapshot = unsafe { POINTER as *mut SkillSlotSnapshot };
let (flags, outcome, candidate_count, viewport_width, viewport_height, slots) = match observed {
let (mut flags, outcome, candidate_count, viewport_width, viewport_height, slots) = match observed {
Ok((width, height, slots)) => (FLAG_SKILL_SLOTS_READY, 0, 0, width, height, slots),
Err(refusal) => (
0,
Expand All @@ -293,17 +317,35 @@ unsafe fn publish(frame_id: u32, observed: Result<Observation, Refusal>) {
[EMPTY_RECT; 8],
),
};
// Chat frames can carry a local clipping viewport while their screen
// bounds remain in the global interface coordinate space. The certified
// skill viewport is the projection source; requiring the local sizes to
// match discarded the real movable chat editor.
let (published_chat_id, chat_outcome, chat_input) = match chat_observed {
Ok(bounds) if flags & FLAG_SKILL_SLOTS_READY != 0 => {
flags |= FLAG_CHAT_INPUT_READY;
(chat_frame_id, 0, bounds)
}
Ok(_) => (0, Outcome::ViewportMismatch as u32, EMPTY_RECT),
Err(refusal) => (0, refusal.outcome as u32, EMPTY_RECT),
};
unsafe {
write_volatile(&mut (*snapshot).sequence, next.wrapping_sub(1));
write_volatile(&mut (*snapshot).magic, SKILL_SLOT_MAGIC);
write_volatile(&mut (*snapshot).abi_and_size, SKILL_SLOT_ABI_AND_SIZE);
write_volatile(&mut (*snapshot).flags, flags);
write_volatile(&mut (*snapshot).frame_id, if flags == 0 { 0 } else { frame_id });
write_volatile(
&mut (*snapshot).frame_id,
if flags & FLAG_SKILL_SLOTS_READY == 0 { 0 } else { frame_id },
);
write_volatile(&mut (*snapshot).outcome, outcome);
write_volatile(&mut (*snapshot).candidate_count, candidate_count);
write_volatile(&mut (*snapshot).viewport_width, viewport_width);
write_volatile(&mut (*snapshot).viewport_height, viewport_height);
write_volatile(&mut (*snapshot).slots, slots);
write_volatile(&mut (*snapshot).chat_frame_id, published_chat_id);
write_volatile(&mut (*snapshot).chat_outcome, chat_outcome);
write_volatile(&mut (*snapshot).chat_input, chat_input);
write_volatile(&mut (*snapshot).sequence, next);
SEQUENCE = next;
}
Expand All @@ -321,10 +363,18 @@ pub(crate) unsafe fn initialize(pointer: u32) {
for index in 0..SKILL_SLOT_BYTES / 4 {
unsafe { write_volatile((pointer + index * 4) as *mut u32, 0) };
}
unsafe { publish(0, Err(refuse(Outcome::Inactive))) };
unsafe {
publish(
0,
Err(refuse(Outcome::Inactive)),
0,
Err(refuse(Outcome::Inactive)),
)
};
}

pub(crate) unsafe fn tick(layout: Layout, skill_bar_id: u32) {
pub(crate) unsafe fn tick(layout: Layout, skill_bar_id: u32, chat_frame_id: u32) {
let observed = unsafe { collect(layout, skill_bar_id) };
unsafe { publish(skill_bar_id, observed) };
let chat_observed = unsafe { collect_chat(layout, chat_frame_id) };
unsafe { publish(skill_bar_id, observed, chat_frame_id, chat_observed) };
}
Loading
Loading