Skip to content

Fix QAM fresh-open tab selection lifecycle - #522

Merged
onehoon merged 2 commits into
mainfrom
refactor/qam-open-selection-hotfix
Sep 12, 2026
Merged

onehoon merged 2 commits into
mainfrom
refactor/qam-open-selection-hotfix

Conversation

@onehoon

@onehoon onehoon commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Summary

  • Drive fresh-open selection from the verified outer QAM renderer visibility transition: visible false to true and true to false.
  • Select Device for no active AppId or Profile for an active AppId once per fresh QAM open, and re-arm only after the renderer becomes hidden.
  • Publish the descriptor context before invoking the guarded selection helper so visibility/context observation order is safe without timers or retries.
  • Use the live Steam QAM tab authority: MainWindowInstance.MenuStore.OpenQuickAccessMenu(key, false). The current native tab handler passes its descriptor sr.key to this method, including injected string identities.
  • Preserve stable Device/Profile tabs, native tab availability, descriptor cleanup, and shared invalidation behavior.

Validation

  • node --check src/SteamInputAddonforClaw.QamHost/Frontend/qam.js
  • dotnet build SteamInputAddonforClaw.slnx --configuration Debug --no-restore
  • dotnet build SteamInputAddonforClaw.slnx --configuration Release --no-restore
  • focused QAM contract tests: 34 passed
  • full Release test suite: 2,745 passed
  • git diff --check

Manual validation

  • MSI Claw/current Steam GamepadUI fresh-open, close/reopen, native-tab retention, AppId transition, and reinjection acceptance remain pending because this environment does not provide the target hardware session.

@onehoon onehoon left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking findings before merge:

  1. The hotfix still guesses the QAM-open lifecycle by requiring onFocusNavActivated.

The work order explicitly says not to infer an onFocusNavActivated twin merely because onFocusNavDeactivated is the discovery marker. This PR does exactly that in patchQamLifecycle() and the PR body still says real-device validation is pending, so there is no evidence that this callback pair is the current Windows GamepadUI open/close authority. If onFocusNavActivated is absent or does not represent QAM visibility, the whole hotfix fails open and we are back to Steam restoring its last native tab.

There is already a smaller lifecycle seam available at the outer QAM renderer: the current QAM renderer receives a visibility value in its render props. Use the exact live-verified visibility property (or instrument that bounded renderer first if the current build differs) and drive only the false -> true / true -> false transition from it. Do not patch an unverified callback pair.

Example shape:

function updateQamSurfaceVisibility(visible) {
  if (visible === true) {
    activateQamSurface();
  } else if (visible === false) {
    deactivateQamSurface();
  }
}

const patchedType = preservePatchedFunctionShape(function patchedType(...args) {
  const result = originalType.apply(this, args);
  if (!state.installed) return result;

  // Use the exact current-Steam visibility field verified on this renderer.
  updateQamSurfaceVisibility(args[0]?.visible);

  try {
    patchTabsProducer(result, React, native);
  } catch (error) {
    logOnce("outerAugmentationFailed", `QAM outer augmentation failed: ${String(error)}`);
  }
  return result;
}, originalType);

Also make the selection prerequisite order insensitive. At the moment trySelectAddonTabForFreshOpen() is called only from activation. If the open/visible transition is observed before ensureAddonTabs() publishes qamSelectionContext, that open is consumed without a selection attempt. Calling the same guarded helper after publishing the descriptor context is enough; the existing qamInitialSelectionRequested flag still guarantees at most one write:

state.qamSelectionContext = { descriptors };
trySelectAddonTabForFreshOpen();

This is especially relevant for an already-visible QAM during script/QamHost reinjection and does not require timers, polling, epochs, or retries.

  1. MenuStore.OpenQuickAccessMenu(key, false) is not a verified authority for the Addon's string descriptor keys.

The Addon descriptors use:

"steam-input-addon-device"
"steam-input-addon-profile"

but Steam/Decky exposes OpenQuickAccessMenu as taking a QuickAccessTab identifier; Decky's injected tab uses its own numeric tab id (Decky = 999) and passes that id to OpenQuickAccessMenu. There is no evidence in this PR that the current Steam store accepts arbitrary injected string descriptor keys. The extra false argument is also not part of the public current interface. This can make the new selection write a no-op even when lifecycle detection is correct.

Please use the exact current-Steam tab-selection seam that is verified to accept the Addon's descriptor identity. If the live tab owner exposes a key callback, keep the PR #521 string keys and call that exact verified callback rather than routing them through QuickAccessTab. Example shape (property names must match the live inspected owner; do not restore a broad candidate list):

function resolveNativeTabSelection(owner) {
  const props = owner?.props;
  if (!props || typeof props.onTabSelected !== "function") return null;

  return {
    set: key => {
      if (key !== ADDON_DEVICE_TAB_KEY && key !== ADDON_PROFILE_TAB_KEY) return;
      props.onTabSelected(key);
    },
  };
}

If the real supported build instead requires a numeric QuickAccessTab id, document and implement that exact contract consistently with descriptor identity before using OpenQuickAccessMenu; do not pass the existing string keys into an enum-oriented API and assume it will select them.

After these changes, the mandatory Claw acceptance from the work order still needs to be run: native tab selected -> close -> reopen must land on Device/Profile every time, while manual Steam-tab selection must remain untouched until the next close/reopen. The rest of the PR remains appropriately scoped and does not need controller/Full1902 changes.

@onehoon

onehoon commented Sep 12, 2026

Copy link
Copy Markdown
Owner Author

Addressed both blocking findings in commit 78a8363.

  • QAM open/close is now driven only by the live outer renderer visible prop: false to true activates one selection request, true to false re-arms it. The descriptor context calls the same guarded helper after publication, so either observation order is safe without timers, polling, or retries. The unverified onFocusNavActivated/onFocusNavDeactivated callback patch was removed.
  • Live current-Steam inspection verified the selection path: the QAM tab handler derives mt = sr.key and calls MenuStore.OpenQuickAccessMenu(mt). The same store exposes OpenQuickAccessMenu(z, S = true), and current Steam code uses false for selection without reopening the side menu. The Addon therefore passes its stable string descriptor key through the exact native tab-selection path rather than a guessed React prop. Unsupported store shapes still fail open.

Validation: JavaScript check passed; Debug/Release builds passed with 0 warnings and 0 errors; focused QAM tests 34/34; full Release tests 2745/2745; git diff --check passed. MSI Claw hardware acceptance remains pending in this environment.

@onehoon

onehoon commented Sep 12, 2026

Copy link
Copy Markdown
Owner Author

Follow-up: the latest head 78a8363 now has a passing GitHub Actions build-and-test run (34726123761). All CI build, test, release metadata, publish verification, and startup smoke steps passed. The PR remains Draft only because the mandatory MSI Claw/current Steam hardware acceptance is still pending.

@onehoon
onehoon marked this pull request as ready for review September 12, 2026 23:49
@onehoon
onehoon merged commit 2f2f5ca into main Sep 12, 2026
1 check passed
@onehoon
onehoon deleted the refactor/qam-open-selection-hotfix branch September 12, 2026 23:49

onehoon commented Sep 13, 2026

Copy link
Copy Markdown
Owner Author

Post-merge real-device acceptance failed on MSI Claw (Google Drive Addon/Log/0913).

Observed behavior:

  • Device/Profile tabs are injected and usable.
  • Fresh QAM open does not select the Addon tab.
  • Reopen restores whichever tab was last selected, Steam-native or Addon.

The runtime log proves OEM1 -> SteamQuickAccess was invoked repeatedly (for example 09:11:40.976, 09:11:44.118, 09:11:44.972, 09:11:54.346, 09:11:55.171), so the product is generating real QAM open/close interactions.

However the QamHost log never emits QAM surface activated., QAM surface deactivated., or QAM open selection: during those real interactions. The stable Addon tabs are injected successfully, and Device/Profile page state later loads, but the PR #522 args[0]?.visible transition never arms the open-time selection path. Therefore the current Windows Steam QAM renderer does not expose the required open/close lifecycle through that assumed boolean in this patched path.

This means the current root cause is before OpenQuickAccessMenu(key, false): the fresh-open selection code is not reached at all. Do not replace visible with another guessed prop/callback yet.

Please make the next change a bounded diagnostic / follow-up hotfix that discovers the actual supported current-Steam open signal. A useful diagnostic is to log only visibility/open/focus/nav-related outer-render props once per distinct shape, and separately probe the already-verified MenuStore.OpenQuickAccessMenu call arguments during (a) physical QAM open, (b) manual native-tab selection, and (c) close/reopen. Example:

function logQamOuterLifecycleShape(props) {
  if (!props || typeof props !== "object") return;
  const entries = Object.keys(props)
    .filter(key => /visible|open|active|focus|nav/i.test(key))
    .map(key => {
      const value = props[key];
      const suffix = typeof value === "boolean" ? `=${value}` : "";
      return `${key}:${typeof value}${suffix}`;
    });
  logStateChange("qamOuterLifecycleShape", entries.join("|"),
    `QAM outer lifecycle props: ${entries.join(", ") || "none"}`);
}

const patchedType = preservePatchedFunctionShape(function patchedType(...args) {
  const result = originalType.apply(this, args);
  if (!state.installed) return result;

  logQamOuterLifecycleShape(args[0]);
  // Do not call updateQamSurfaceVisibility until the real boolean seam is proven.

  try {
    patchTabsProducer(result, React, native);
  } catch (error) {
    logOnce("outerAugmentationFailed", `QAM outer augmentation failed: ${String(error)}`);
  }
  return result;
}, originalType);

Also probe the existing native MenuStore authority instead of assuming visibility is the only usable lifecycle source:

function installOpenQuickAccessMenuProbe(menuStore) {
  const original = menuStore.OpenQuickAccessMenu;
  if (typeof original !== "function") return null;

  function probed(...args) {
    log(`QAM MenuStore call: key=${String(args[0])} open=${String(args[1])} argc=${args.length}`);
    return original.apply(this, args);
  }

  menuStore.OpenQuickAccessMenu = probed;
  return () => {
    if (menuStore.OpenQuickAccessMenu === probed) {
      menuStore.OpenQuickAccessMenu = original;
    }
  };
}

If live evidence confirms a deterministic difference such as open=true/default for fresh menu opening versus false for in-menu tab selection, that exact native call can become the one open authority and would be preferable to adding more React lifecycle state. Keep the final production code minimal and remove temporary probe logging after the exact seam is proven.

A follow-up PR should not be merged until the exact regression passes on hardware:
Steam-native tab -> close -> reopen -> Device/Profile selected, repeated several times, while manual Steam-tab selection remains respected until the next close/reopen.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant