using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using WandEnhancer.Core.Js;
using WandEnhancer.Models;
namespace WandEnhancer.Core
{
///
/// Patch definitions. Each entry anchors on something Wand does not rename between builds -
/// an API endpoint, an IPC channel name or a public method name - and then navigates the
/// delimiter structure to the edit site. Minified identifiers are read out of the located
/// region rather than baked into a pattern, so a rebuild does not invalidate a patch.
///
internal static class EnhancerConfig
{
/// Locates the edits a patch must make, or null when the anchor is absent from this file.
public delegate JsEdit[] PatchLocator(JsCursor js);
public sealed class PatchEntry
{
public string Name { get; set; }
public PatchLocator Locate { get; set; }
public string[] CandidateFileNames { get; set; }
public string[] SearchHints { get; set; }
/// Marks the patch optional: builds without these strings lack the feature entirely.
public string[] CapabilityHints { get; set; }
public bool Applied { get; set; }
public bool CapabilityDetected { get; set; }
public bool IsOptional => CapabilityHints != null && CapabilityHints.Length > 0;
/// True once the patch is applied, or once a scan proved the feature is absent.
public bool IsResolved => Applied || (IsOptional && !CapabilityDetected);
}
public static Dictionary GetInstance()
{
return new Dictionary
{
{
EPatchType.ActivatePro,
new[]
{
new PatchEntry
{
Name = "getUserAccount",
SearchHints = new[] { "getUserAccount(" },
Locate = js => ForceProSubscription(js, "getUserAccount")
},
new PatchEntry
{
Name = "setAccountWandBrandExperience",
SearchHints = new[] { "setAccountWandBrandExperience(" },
CapabilityHints = new[] { "/v3/account/brand_experience_wand" },
Locate = js => ForceProSubscription(js, "setAccountWandBrandExperience")
},
new PatchEntry
{
// Changing language returns a fresh account object that would otherwise
// overwrite the patched subscription in the store.
Name = "setAccountLanguage",
SearchHints = new[] { "setAccountLanguage(" },
Locate = js => ForceProSubscription(js, "setAccountLanguage")
},
new PatchEntry
{
// Catches every path that dispatches ACTION_SET_ACCOUNT without going
// through the account API methods above (refresh, push, profile edits).
Name = "setAccountReducer",
SearchHints = new[] { "ACTION_SET_ACCOUNT" },
Locate = LocateAccountReducer
},
new PatchEntry
{
// Wand's own phone pairing performs a server-side device handoff that
// signs this desktop session out. The injected panel does not use it.
Name = "disableNativeRemotePairing",
SearchHints = new[] { "requestRemoteAuthCode" },
Locate = js => Edits(js.FindFunction("requestRemoteAuthCode")?
.ReplaceBody(PatchPayload.Load("disable-native-pairing")))
}
}
},
{
EPatchType.DisableUpdates,
new[]
{
new PatchEntry
{
Name = "disableUpdateCheck",
CandidateFileNames = new[] { "index.js" },
SearchHints = new[] { "ACTION_CHECK_FOR_UPDATE" },
Locate = LocateUpdateHandler
}
}
},
{
EPatchType.DevToolsOnF12,
new[]
{
new PatchEntry
{
// Hooked in the main process: the renderer's keydown dispatcher is
// reshaped on every Wand release, the Electron app API is not.
Name = "devToolsBeforeInputEvent",
CandidateFileNames = new[] { "index.js" },
SearchHints = new[] { "whenReady().then(" },
Locate = LocateDevToolsHook
}
}
},
{
EPatchType.RemoteWebPanelPreview,
new[]
{
new PatchEntry
{
Name = "remoteBridgeMainBoot",
CandidateFileNames = new[] { "index.js" },
SearchHints = new[] { "whenReady().then(run)" },
Locate = LocateBridgeBoot
},
new PatchEntry
{
Name = "remoteBridgeReset",
SearchHints = new[] { "client-state" },
Locate = LocateBridgeReset
},
new PatchEntry
{
Name = "remoteBridgeSyncSnapshot",
SearchHints = new[] { "client-state" },
Locate = LocateBridgeSync
},
new PatchEntry
{
Name = "remoteBridgeBindHandler",
SearchHints = new[] { "setCurrentTrainer(" },
Locate = LocateBridgeBindHandler
},
new PatchEntry
{
Name = "remoteBridgeValueDelta",
SearchHints = new[] { "client-value-changed" },
Locate = LocateBridgeValueDelta
}
}
}
};
}
/// Wraps the account-returning promise so the resolved account always reports an active subscription.
private static JsEdit[] ForceProSubscription(JsCursor js, string methodName)
{
return Edits(js.FindFunction(methodName)?.WrapReturn(PatchPayload.Load("pro-subscription")));
}
private static JsEdit[] LocateAccountReducer(JsCursor js)
{
int anchor = js.IndexOf("\"ACTION_SET_ACCOUNT\"");
var reducer = anchor < 0 ? null : js.FindFunctionAfter(anchor);
if (reducer == null)
{
return null;
}
// The payload's ${account} survives PatchPayload untouched and is resolved by the
// regex replacement below, which is what carries the original identifier through.
return Edits(reducer.ReplaceInBody(
@"account:\s*(?[\w$]+)",
PatchPayload.Load("pro-account-reducer")));
}
private static JsEdit[] LocateUpdateHandler(JsCursor js)
{
int callOpen = js.FindCall("registerHandler", "\"ACTION_CHECK_FOR_UPDATE\"");
if (callOpen < 0)
{
return null;
}
return Edits(new JsEdit(callOpen + 1, js.MatchClose(callOpen), PatchPayload.Load("disable-updates")));
}
private static JsEdit[] LocateDevToolsHook(JsCursor js)
{
var match = WhenReady.Match(js.Text);
if (!match.Success)
{
return null;
}
var payload = PatchPayload.Load("devtools-f12", "app", match.Groups["app"].Value);
return Edits(new JsEdit(match.Index, match.Index, payload));
}
private static JsEdit[] LocateBridgeBoot(JsCursor js)
{
var match = WhenReadyThenRun.Match(js.Text);
if (!match.Success)
{
return null;
}
var payload = PatchPayload.Load("remote-bridge-boot", "app", match.Groups["app"].Value);
return Edits(new JsEdit(match.Index, match.Index + match.Length, payload));
}
/// Clears the bridge alongside the session fields the reset method already nulls out.
private static JsEdit[] LocateBridgeReset(JsCursor js)
{
var sync = FindClientStateMethod(js);
var reset = sync == null ? null : js.FunctionEndingAt(js.SkipWhitespaceBack(sync.Start - 1));
if (reset == null || reset.Body.IndexOf("Date.now()", StringComparison.Ordinal) < 0)
{
return null;
}
return Edits(reset.InsertAtEnd(PatchPayload.Load("remote-bridge-reset")));
}
///
/// Mirrors Wand's own client-state payload to the bridge by copying the object literal
/// verbatim, so fields Wand adds or drops between builds carry over untouched.
///
private static JsEdit[] LocateBridgeSync(JsCursor js)
{
int sendOpen = js.FindCall("send", "\"client-state\"");
if (sendOpen < 0)
{
return null;
}
var method = js.EnclosingFunction(sendOpen);
int snapshotOpen = js.IndexOf("{", sendOpen);
int snapshotClose = js.MatchClose(snapshotOpen);
if (method == null || snapshotOpen < 0 || snapshotClose < 0)
{
throw new Exception("client-state payload object could not be located");
}
// Prettified builds leave a trailing comma inside the literal; appending after it
// would produce an illegal hole.
string snapshot = js.Text.Substring(snapshotOpen + 1, snapshotClose - snapshotOpen - 1)
.Trim()
.TrimEnd(',');
var payload = PatchPayload.Load(
"remote-bridge-sync",
"snapshot", snapshot,
"trainer", method.Resolve(@"this\.(?#[\w$]+)\s*\?\.\s*getMetadata", "trainer"),
"metadata", method.Resolve(@"getMetadata\(\s*(?[\w$]+\.[\w$]+)\s*\)", "metadata"));
var edits = new List { new JsEdit(js.MatchClose(sendOpen) + 1, payload) };
edits.AddRange(HoistConnectedGuard(js, sendOpen));
return edits.ToArray();
}
///
/// Some builds wrap the whole snapshot method in if (status === Connected). The bridge
/// must publish regardless of Wand's own remote status, so the guard is moved onto the send
/// itself, leaving the block - and the locals the payload reads - intact.
///
private static IEnumerable HoistConnectedGuard(JsCursor js, int sendOpen)
{
int blockOpen = js.EnclosingOpener(sendOpen, '{');
int closeParen = blockOpen < 0 ? -1 : js.SkipWhitespaceBack(blockOpen - 1);
if (closeParen < 0 || js.Text[closeParen] != ')')
{
yield break;
}
var stack = js.OpenerStack(closeParen);
if (stack.Count == 0 || js.NameBefore(stack[0]) != "if")
{
yield break;
}
int openParen = stack[0];
string test = js.Text.Substring(openParen + 1, closeParen - openParen - 1);
// Only the connection guard may be hoisted. A nested unrelated `if` would otherwise
// have its condition moved onto the send, and an `else` branch would be orphaned by
// turning the block into a bare one.
if (test.IndexOf("this.status", StringComparison.Ordinal) < 0 || HasElseBranch(js, blockOpen))
{
yield break;
}
int guardStart = js.SkipWhitespaceBack(openParen - 1) - 1;
int calleeStart = sendOpen;
while (calleeStart > 0 && IsCalleeChar(js.Text[calleeStart - 1]))
{
calleeStart--;
}
yield return new JsEdit(calleeStart, calleeStart, $"({test})&&");
yield return new JsEdit(guardStart, blockOpen, string.Empty);
}
private static bool HasElseBranch(JsCursor js, int blockOpen)
{
int afterBlock = js.SkipWhitespaceForward(js.MatchClose(blockOpen) + 1);
return string.CompareOrdinal(js.Text, afterBlock, "else", 0, 4) == 0;
}
private static JsEdit[] LocateBridgeBindHandler(JsCursor js)
{
var method = js.FindFunction("setCurrentTrainer");
if (method == null)
{
return null;
}
// The same call reveals both the active-trainer field and the numeric or enum value
// Wand uses for a remote-originated write. Wand has sibling call sites for other
// sources (Overlay), so an ambiguous match would silently bind the wrong one.
var setValue = MatchExactlyOnce(RemoteSetValue, js.Text, "Remote setValue call");
return Edits(method.InsertAtStart(PatchPayload.Load(
"remote-bridge-renderer",
"trainer", setValue.Groups["trainer"].Value,
"remoteSource", setValue.Groups["source"].Value)));
}
private static JsEdit[] LocateBridgeValueDelta(JsCursor js)
{
int sendOpen = js.FindCall("send", "\"client-value-changed\"");
if (sendOpen < 0)
{
return null;
}
int sendClose = js.MatchClose(sendOpen);
return Edits(new JsEdit(sendClose + 1, PatchPayload.Load("remote-bridge-value-delta")));
}
private static JsFunction FindClientStateMethod(JsCursor js)
{
int sendOpen = js.FindCall("send", "\"client-state\"");
return sendOpen < 0 ? null : js.EnclosingFunction(sendOpen);
}
private static JsEdit[] Edits(JsEdit edit)
{
return edit == null ? null : new[] { edit };
}
private static bool IsCalleeChar(char value)
{
return char.IsLetterOrDigit(value) || value == '_' || value == '$' || value == '#'
|| value == '.' || value == '?';
}
/// Match that must be unambiguous: zero or several hits mean an unsupported build.
private static Match MatchExactlyOnce(Regex pattern, string text, string what)
{
var match = pattern.Match(text);
if (!match.Success)
{
throw new Exception($"{what} could not be located");
}
if (match.NextMatch().Success)
{
throw new Exception($"{what} matched more than once; cannot tell which call site is the right one");
}
return match;
}
private static readonly Regex WhenReady = new Regex(@"(?[\w$]+)\.whenReady\(\)\.then\(");
private static readonly Regex WhenReadyThenRun = new Regex(@"(?[\w$]+)\.whenReady\(\)\.then\(run\)");
private static readonly Regex RemoteSetValue =
new Regex(@"this\.(?#[\w$]+)\.setValue\(\s*e\.name\s*,\s*e\.value\s*,\s*(?[^,]+?)\s*,");
}
}