Skip to content
Merged
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
32 changes: 15 additions & 17 deletions openflow/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion openflow/patch/asar_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions openflow/patch/inject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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"'),
]


Expand Down
83 changes: 69 additions & 14 deletions openflow/patch/patch_asr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down
50 changes: 46 additions & 4 deletions openflow/patch/patch_offline_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 onboardingmain",
"skip onboarding -> main",
),
(
b"if(Tn||!1===p)return(0,Y.jsx)(Hs,{})",
Expand All @@ -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")',
"Hsnull",
"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"*/",
"WelcomeBasicmain",
"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).
Expand Down Expand Up @@ -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():
Expand Down
21 changes: 18 additions & 3 deletions openflow/patch/rebrand.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}")
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion tests/test_openflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading