diff --git a/openflow/host.py b/openflow/host.py index e4571db..fb4aa27 100644 --- a/openflow/host.py +++ b/openflow/host.py @@ -23,6 +23,8 @@ import urllib.request from pathlib import Path +from openflow.patch.asar_api import newest_app_dir + ROOT = Path(__file__).resolve().parent.parent # install root (parent of openflow/) LOG_DIR = ROOT / "logs" HOST = os.environ.get("OPENFLOW_HOST", os.environ.get("WISPR_GROK_HOST", "127.0.0.1")) @@ -172,25 +174,21 @@ def wait_health(seconds: float = 8.0) -> bool: def find_desktop_exe() -> Path | None: - """Locate the user's installed Wispr Flow Electron shell (not bundled here).""" + """Locate the user-installed Wispr Flow Electron shell (not bundled here).""" local = Path(os.environ.get("LOCALAPPDATA", "")) - candidates: list[Path] = [] root = local / "WisprFlow" - if root.is_dir(): - apps = sorted( - (p for p in root.glob("app-*") if p.is_dir()), - key=lambda p: p.name, - ) - for app in reversed(apps): - candidates.append(app / "Wispr Flow.exe") - candidates.append(root / "Wispr Flow.exe") - for c in candidates: - try: - if c.is_file(): - return c - except OSError: - continue - return None + if not root.is_dir(): + return None + try: + app = newest_app_dir(root) + exe = app / "Wispr Flow.exe" + if exe.is_file(): + return exe + except Exception: + pass + # Fallback to root stub if present. + fallback = root / "Wispr Flow.exe" + return fallback if fallback.is_file() else None def ui_running() -> bool: diff --git a/openflow/patch/asar_api.py b/openflow/patch/asar_api.py index b13bec9..69ebc59 100644 --- a/openflow/patch/asar_api.py +++ b/openflow/patch/asar_api.py @@ -11,7 +11,7 @@ "local HTTP": b"http://127.0.0.1:18765/environments/production/run_remote", "local gRPC override": b"Using local gRPC route override", "timeout 60s": b"TRANSCRIPTION_TIMEOUT=6e4", - "processing timeout 120s": b"te=12e4", + "processing timeout 120s": b"=12e4}", "csp connect-src": b"openflow-csp-shim", "csp frame-src": b"openflow-csp-frames", "auto-updater disabled": b"openflow-disable-updates", diff --git a/openflow/patch/inject.py b/openflow/patch/inject.py index 835ce78..5389642 100755 --- a/openflow/patch/inject.py +++ b/openflow/patch/inject.py @@ -173,6 +173,22 @@ def verify_injected(extract: Path) -> dict[str, bool]: b"general:Object.values(q.$t).filter(e=>e===q.$t.General||e===q.$t.System)/*openflow-local-settings*/,account:Object.values(q.EK).filter(e=>false)/*openflow-local-settings*/", b"openflow-local-settings", ), + # app-1.6.288 variants + ( + b"if(k)return null;if(!re(Di.Eg.BasicUpgradeCTA)||I||T||$)return null;", + b"return null/*grok-flow-hide-quota*/;if(k)return null;if(!re(Di.Eg.BasicUpgradeCTA)||I||T||$)return null;", + b"grok-flow-hide-quota", + ), + ( + b"if((!r||e.user.dictationOnboardingCompleted)&&!e.user.postOnboardingCompleted&&e.user.onboardingCompletedTime&&!he.current)return he.current=!0,void bn();", + b"if((!r||e.user.dictationOnboardingCompleted)&&false/*openflow-hide-post-onboarding*/&&e.user.onboardingCompletedTime&&!he.current)return he.current=!0,void bn();", + b"openflow-hide-post-onboarding", + ), + ( + b"general:Object.values(q.$t).filter(e=>!(e===q.$t.Connectors&&!i||e===q.$t.Mcp&&!o||e===q.$t.Notetaker&&!t||e===q.$t.Internal&&!s||e===q.$t.Experimental&&!l||e===q.$t.Testing&&!r)),account:Object.values(q.EK).filter(e=>!(e===q.EK.Team&&!n))", + b"general:Object.values(q.$t).filter(e=>e===q.$t.General||e===q.$t.System)/*openflow-local-settings*/,account:Object.values(q.EK).filter(e=>false)/*openflow-local-settings*/", + b"openflow-local-settings", + ), ] _HUB_BRAND_REPLACEMENTS = [ @@ -185,6 +201,7 @@ def verify_injected(extract: Path) -> dict[str, bool]: (b'"Welcome back to Flow,"', b'"Welcome back,"'), (b"You get {{weeklyCap}} words per week. Upgrade for unlimited access.", b"Unlimited local dictation."), + (b'document.title="Hub"', b'document.title="OpenFlow"'), ] diff --git a/openflow/patch/patch_asr.py b/openflow/patch/patch_asr.py index fcba22b..9e0a504 100755 --- a/openflow/patch/patch_asr.py +++ b/openflow/patch/patch_asr.py @@ -109,23 +109,22 @@ def patch_asr(text: bytes) -> bytes: -# Stock processing timeout te=24e3 (24s) - too short for Grok STT+format. +# Stock processing timeout is 24e3 (24s) - too short for Grok STT+format. +# Variable names change between builds, so we target the constant pair near the +# "Pre-Login Feedback" label and bump the 24e3 value to 12e4 (120s). PROC_TIMEOUT_RE = re.compile( - rb',V=3e4,G=3e4,Y=12e4,K=6e4,Z=3145728,X=20971520,J="Pre-Login Feedback",ee=200,te=24e3' + rb'"Pre-Login Feedback",[A-Za-z_$][\w$]{0,3}=200,[A-Za-z_$][\w$]{0,3}=24e3\}' ) -PROC_TIMEOUT_BYPASS = ( - b',V=3e4,G=3e4,Y=12e4,K=6e4,Z=3145728,X=20971520,J="Pre-Login Feedback",ee=200,te=12e4' -) -PROC_TIMEOUT_MARKER = b"te=12e4" # paired with unique pre-login context when patching +PROC_TIMEOUT_MARKER = b"=12e4}" # paired with unique pre-login context when patching def patch_processing_timeout(text: bytes) -> bytes: """Raise in-app processing timeout 24s -> 120s (TranscriptionError toast).""" if PROC_TIMEOUT_RE.search(text): - text = PROC_TIMEOUT_RE.sub(PROC_TIMEOUT_BYPASS, text, count=1) + text = PROC_TIMEOUT_RE.sub(lambda m: m.group(0).replace(b"24e3", b"12e4"), text, count=1) print("raised processing timeout 24s -> 120s") return text - if b',ee=200,te=12e4}' in text or b',ee=200,te=12e4},' in text: + if PROC_TIMEOUT_MARKER in text: print("processing timeout: already 120s") return text # looser fallback @@ -190,15 +189,72 @@ def patch_csp_localhost(text: bytes) -> bytes: _UPDATER_NEW = b"async checkForUpdates(e=!1){return;/*openflow-disable-updates*/if(this.updateState!==d.D9.AVAILABLE)" +def _replace_method_body(text: bytes, signature: bytes) -> bytes: + """Replace the { ... } body of the method identified by signature with a no-op.""" + idx = text.find(signature) + if idx == -1: + return text + open_brace = text.find(b"{", idx) + if open_brace == -1: + return text + depth = 0 + in_string: bytes | None = None + escape = False + i = open_brace + while i < len(text): + c = text[i:i+1] + if in_string: + if escape: + escape = False + elif c == b"\\": + escape = True + elif c == in_string: + in_string = None + i += 1 + continue + if c in (b'"', b"'", b"`"): + in_string = c + i += 1 + continue + if c == b"{": + depth += 1 + elif c == b"}": + depth -= 1 + if depth == 0: + return text[:open_brace] + b"{/*openflow-disable-updates*/}" + text[i + 1:] + i += 1 + return text + + def patch_disable_updates(text: bytes) -> bytes: """Disable Wispr auto-updater to prevent auto-update to unpatched versions.""" - if UPDATER_MARKER in text: - print("auto-updater: already disabled") - elif _UPDATER_OLD in text: + if _UPDATER_OLD in text: text = text.replace(_UPDATER_OLD, _UPDATER_NEW, 1) print("auto-updater: disabled checkForUpdates") + elif UPDATER_MARKER in text: + print("auto-updater: checkForUpdates already disabled") + else: + print("WARN: checkForUpdates pattern not found - updater may auto-update", file=sys.stderr) + + # Nerf the rest of the update manager so menu/manual update flows cannot + # trigger a download, install, or UI prompt either. + if b"openflow-disable-listeners" not in text: + text = _replace_method_body( + text, + b'setupListeners(){r.autoUpdater.on("checking-for-update",()=>this.setUpdateState(d.D9.CHECKING))', + ) + text = _replace_method_body( + text, + b'handleDownloadAvailable(){const e=v.RA.prefs?.updateRetry', + ) + text = _replace_method_body( + text, + b'async applyUpdate(){const e=r.app.getVersion(),t=v.RA.prefs?.updateRetry?.retryCount??0', + ) + if b"{/*openflow-disable-updates*/}" in text: + print("auto-updater: disabled listeners / download / install flow") else: - print("WARN: checkForUpdates pattern not found — updater may auto-update", file=sys.stderr) + print("auto-updater: listeners already disabled") return text def patch(index_js: Path) -> None: @@ -219,8 +275,7 @@ def verify_bytes(data: bytes) -> dict[str, bool]: "local gRPC override": b"Using local gRPC route override" in data or b"FLOW_GRPC_URL_OVERRIDE" in data, "timeout 60s": b"TRANSCRIPTION_TIMEOUT=6e4" in data, - "processing timeout 120s": b",ee=200,te=12e4" in data - or b"te=12e4" in data, + "processing timeout 120s": b"=12e4}" in data, "csp allows shim": CSP_MARKER in data or b'"http://127.0.0.1:18765"' in data, "csp allows frames": CSP_FRAME_MARKER in data, diff --git a/openflow/patch/patch_offline_local.py b/openflow/patch/patch_offline_local.py index e7c3ee4..e184737 100644 --- a/openflow/patch/patch_offline_local.py +++ b/openflow/patch/patch_offline_local.py @@ -49,13 +49,34 @@ b"flowState:(0,i.Y)(),authSignedIn:!0/*" + MARKER + b"*/,enterpriseIpBlocked", "default authSignedIn true (2)", ), + # app-1.6.288 variants + ( + b'Qr=async()=>{const e=await Fr();return e?e.accessToken:(f.RA.authSignedIn&&i().error("No session found when getting access token, after user signed in"),null)}', + b'Qr=async()=>{try{const e=await Fr();if(e&&e.accessToken)return e.accessToken}catch(e){}return"grok-flow-local-offline-token"/*' + MARKER + b'*/}', + "getAccessToken local (app-1.6.288)", + ), + ( + b'$r=async()=>{const e=await Fr();return e?e.identity:(f.RA.authSignedIn&&i().error("No session found when getting user, after user signed in"),null)}', + b'$r=async()=>{try{const e=await Fr();if(e&&e.identity)return e.identity}catch(e){}return{id:"grok-flow-local",email:"local@openflow.local",firstName:"OpenFlow",lastName:"Local",avatarUrl:null,subscription:{status:"active",daysLeft:null,plan:"FLOW_PRO_YEARLY",credits:999999,isSubscribed:!0,renewalTimestamp:4102444800,teamDomainStatus:"forbidden",isStudent:!1,sourceOfSubscription:"local",gracePeriodEndsAt:null,cancelAt:null,cancelAtPeriodEnd:!1,priceResolved:!0}}/*' + MARKER + b'*/}', + "getUser local (app-1.6.288)", + ), + ( + b"O=e=>{v.authSignedIn=e,(0,d.vY)({authSignedIn:e}),(0,l.MP)(e)}", + b"O=e=>{e=!0/*" + FORCE + b"*/;v.authSignedIn=e,(0,d.vY)({authSignedIn:e}),(0,l.MP)(e)}", + "authSignedIn setter always true (app-1.6.288)", + ), + ( + b'if(!J.RA.authSignedIn)return pe(_.Eg.SignedOut),void(0,g.BD)("not_signed_in");', + b'if(false/*' + FORCE + b'*/)return pe(_.Eg.SignedOut),void(0,g.BD)("not_signed_in");', + "dictation never SignedOut (app-1.6.288)", + ), ] _PATCHES_HUB = [ ( b"if(e.user.onboardingCompleted&&!mn)return(0,Y.jsx)(tme,{initialPage:n})", b"if(true/*grok-flow-skip-onboarding*/)return(0,Y.jsx)(tme,{initialPage:n})", - "skip onboarding→main", + "skip onboarding -> main", ), ( b"if(Tn||!1===p)return(0,Y.jsx)(Hs,{})", @@ -75,18 +96,39 @@ ( b'Hs=()=>{const{t:e}=(0,v.Bd)("onboarding")', b'Hs=()=>{return null/*' + NOLOGIN + b'*/;const{t:e}=(0,v.Bd)("onboarding")', - "Hs→null", + "Hs -> null", ), ( b"case k.PW.WelcomeBasic:return(0,Y.jsx)(XD,{})", b"case k.PW.WelcomeBasic:return(0,Y.jsx)(tme,{initialPage:void 0})/*" + NOLOGIN + b"*/", - "WelcomeBasic→main", + "WelcomeBasic -> main", ), ( b'm(!1),kn.m.info("User is not signed in")', b'm(!0)/*' + MARKER + b'*/,kn.m.info("Grok Flow offline local auth")', "force supabaseSignedIn true", ), + # app-1.6.288 variants + ( + b"if(e.user.onboardingCompleted&&!kn)return(0,Y.jsx)(Rbe,{initialPage:n})", + b"if(true/*grok-flow-skip-onboarding*/)return(0,Y.jsx)(Rbe,{initialPage:n})", + "skip onboarding to main (app-1.6.288)", + ), + ( + b"if(Wn||!1===g)return(0,Y.jsx)($l,{})", + b"if(false/*" + NOLOGIN + b"*/)return(0,Y.jsx)($l,{})", + "never login shell (app-1.6.288)", + ), + ( + b"case k.PW.WelcomeBasic:return(0,Y.jsx)(XI,{})", + b"case k.PW.WelcomeBasic:return(0,Y.jsx)(Rbe,{initialPage:void 0})/*" + NOLOGIN + b"*/", + "WelcomeBasic to main (app-1.6.288)", + ), + ( + b'f(!1),Ue.m.info("User is not signed in")', + b'f(!0)/*' + MARKER + b'*/,Ue.m.info("OpenFlow offline local auth")', + "force supabaseSignedIn true (app-1.6.288)", + ), ] # Public aliases (byte patterns are load-bearing; do not edit). @@ -183,7 +225,7 @@ def patch_extract(extract: Path) -> None: if a in h: c = h.count(a) h = h.replace(a, b) - print(f" OK: hub {a.decode()}→{b.decode()} x{c}") + print(f" OK: hub {a.decode()}->{b.decode()} x{c}") hub.write_bytes(h) print("wrote", hub) if status.is_file(): diff --git a/openflow/patch/rebrand.py b/openflow/patch/rebrand.py index 0074654..23e2704 100644 --- a/openflow/patch/rebrand.py +++ b/openflow/patch/rebrand.py @@ -118,6 +118,21 @@ def _rebrand_js(path: Path) -> dict[str, int]: if count: protected = protected.replace(old_b, new_b) stats["product"] += count + # Extra user-facing window / phrase strings that are not the full product name. + for old, new in ( + (b'title:"Flow Hub"', b'title:"OpenFlow"'), + (b'title:"Flow Status Indicator"', b'title:"OpenFlow"'), + (b'document.title="Hub"', b'document.title="OpenFlow"'), + (b'document.title="Status"', b'document.title="OpenFlow"'), + (b'Hello from Flow', b'Hello from OpenFlow'), + (b'Welcome to Flow', b'Welcome to OpenFlow'), + (b'You just did your first Flow', b'You just did your first OpenFlow'), + (b'Move Flow', b'Move OpenFlow'), + ): + count = protected.count(old) + if count: + protected = protected.replace(old, new) + stats["product"] += count protected = _unprotect(protected, restore) protected, c = _replace_colors(protected) @@ -195,7 +210,7 @@ def rebrand(extract_root: Path, rename_package: bool = False) -> int: except json.JSONDecodeError: pass - print(f"rebrand root: {extract_root} → {PRODUCT}") + print(f"rebrand root: {extract_root} -> {PRODUCT}") pkg_changes = _rebrand_package_json(pkg, rename_package=rename_package) if pkg_changes: print(f"package.json: {pkg_changes}") @@ -220,8 +235,8 @@ def rebrand(extract_root: Path, rename_package: bool = False) -> int: total_colors += st["colors"] rel = path.relative_to(extract_root) print( - f" {rel}: product×{st['product']} colors×{st['colors']} " - f"Δbytes={st['bytes_delta']}" + f" {rel}: product x{st['product']} colors x{st['colors']} " + f"bytes_delta={st['bytes_delta']}" ) print( diff --git a/tests/test_openflow.py b/tests/test_openflow.py index 5b3d5f4..6879157 100644 --- a/tests/test_openflow.py +++ b/tests/test_openflow.py @@ -195,7 +195,7 @@ def _build_stock_extract(self, extract: Path) -> None: b"{modelId:t,environment:n,url:e}}}", b"TRANSCRIPTION_TIMEOUT=1e4", b',V=3e4,G=3e4,Y=12e4,K=6e4,Z=3145728,X=20971520,' - b'J="Pre-Login Feedback",ee=200,te=24e3', + b'J="Pre-Login Feedback",ee=200,te=24e3}', patch_asr.CSP_CONNECT_END, patch_asr.CSP_FRAME_END, patch_asr._UPDATER_OLD,