import os import re import ssl import time import shutil import ctypes import urllib2 import _winreg from subprocess import Popen, PIPE class disable_file_system_redirection: _disable = ctypes.windll.kernel32.Wow64DisableWow64FsRedirection _revert = ctypes.windll.kernel32.Wow64RevertWow64FsRedirection def __enter__(self): self.old_value = ctypes.c_long() self.success = self._disable(ctypes.byref(self.old_value)) def __exit__(self, type, value, traceback): if self.success: self._revert(self.old_value) APP_NAME = 'Charles' TARGET_VER = '5.2.1' PRODUCT_CODE = '{CA4C5892-6C28-4628-9DDC-DEEFBD0EC3A0}' URL = "https://www.charlesproxy.com/assets/release/5.2.1/charles-proxy-5.2.1-win-x86_64.msi" TEMP_DIR = os.environ['TEMP'] DOWNLOAD_TO = os.path.join(TEMP_DIR, URL.split('/')[-1]) MSI_LOG = os.path.join(TEMP_DIR, 'charles_msi.log') SPM_LOG = os.path.join(os.environ.get('SystemRoot', 'C:\\Windows'), 'Temp', 'charles_spm.log') GUID_RE = re.compile(r'\{[0-9A-Fa-f]{8}-(?:[0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}\}') SUCCESS_CODES = (0, 1605, 3010) # 1605 = not installed, 3010 = reboot required UNINST_ROOTS = [ r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', r'SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall', ] UDATA = r'SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData' CLASSES_PROD = r'SOFTWARE\Classes\Installer\Products' CLASSES_FEAT = r'SOFTWARE\Classes\Installer\Features' V64 = _winreg.KEY_WOW64_64KEY HKLM = _winreg.HKEY_LOCAL_MACHINE HKU = _winreg.HKEY_USERS # ================================================================ helpers def clean(s): # remove NUL / non-printable chars (msiexec UTF-16 text truncates the ITSM log) return ''.join(c for c in str(s) if 32 <= ord(c) < 127) def log(msg): line = ' '.join(clean(msg).split())[:300] print(line) try: with open(SPM_LOG, 'a') as f: f.write(time.strftime('%Y-%m-%d %H:%M:%S ') + line + '\n') except Exception: pass def marker(code): print('retcode%sretcode' % code) def run(command): with disable_file_system_redirection(): p = Popen(command, shell=True, stdout=PIPE, stderr=PIPE) out, err = p.communicate() return p.returncode, ' '.join(clean(out or err or '').split()) def refresh_shell(): try: ctypes.windll.shell32.SHChangeNotify(0x08000000, 0, None, None) except Exception: pass def download_file(dest, url): headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36'} context = ssl._create_unverified_context() try: req = urllib2.urlopen(urllib2.Request(url, headers=headers), context=context) with open(dest, 'wb') as f: while True: chunk = req.read(1024 * 1024) if not chunk: break f.write(chunk) if os.path.isfile(dest) and os.path.getsize(dest) > 0: return True, 'Downloaded %s KB' % (os.path.getsize(dest) / 1024) return False, 'Downloaded file is empty' except Exception as e: return False, 'Download failed: %s' % e def pack_guid(code): """{CA4C5892-...} -> packed form used under Installer\\Products and UserData.""" g = code.strip('{}').replace('-', '').upper() rest = g[16:] swapped = ''.join(rest[i + 1] + rest[i] for i in range(0, 16, 2)) return g[0:8][::-1] + g[8:12][::-1] + g[12:16][::-1] + swapped # ================================================================ registry def _subkeys(key): i = 0 while True: try: yield _winreg.EnumKey(key, i) except WindowsError: return i += 1 def _val(key, name): try: return _winreg.QueryValueEx(key, name)[0] except WindowsError: return '' def _open(path, hive=HKLM): try: return _winreg.OpenKey(hive, path, 0, _winreg.KEY_READ | V64) except WindowsError: return None def _hive(name): return HKU if name == 'HKU' else HKLM def key_exists(h, path): k = _open(path, _hive(h)) if k: _winreg.CloseKey(k) return True return False def user_sids(): k = _open('', HKU) sids = [] if k: sids = [s for s in _subkeys(k) if not s.endswith('_Classes')] _winreg.CloseKey(k) return sids def _scan_uninstall(found, h, root): base = _open(root, _hive(h)) if not base: return for sub in list(_subkeys(base)): k = _open(root + '\\' + sub, _hive(h)) if not k: continue name = _val(k, 'DisplayName') if name and name.lower().startswith(APP_NAME.lower()): m = GUID_RE.search('%s %s %s' % (_val(k, 'UninstallString'), _val(k, 'ModifyPath'), sub)) if m: code = m.group(0).upper() found[code] = (name, str(_val(k, 'DisplayVersion')), code) _winreg.CloseKey(k) _winreg.CloseKey(base) def find_installed(hklm_only=False): """List of (name, version, product_code) for Charles. hklm_only=True -> only what Control Panel can see (HKLM Uninstall).""" found = {} for root in UNINST_ROOTS: _scan_uninstall(found, 'HKLM', root) if not hklm_only: # MSI UserData (HKLM) ud = _open(UDATA) if ud: for sid in list(_subkeys(ud)): prods = _open(UDATA + '\\' + sid + '\\Products') if not prods: continue for pc in list(_subkeys(prods)): k = _open(UDATA + '\\' + sid + '\\Products\\' + pc + '\\InstallProperties') if k: name = _val(k, 'DisplayName') if name and name.lower().startswith(APP_NAME.lower()): m = GUID_RE.search('%s %s' % (_val(k, 'UninstallString'), _val(k, 'ModifyPath'))) if m: code = m.group(0).upper() found[code] = (name, str(_val(k, 'DisplayVersion')), code) _winreg.CloseKey(k) _winreg.CloseKey(prods) _winreg.CloseKey(ud) # per-user hives incl. .DEFAULT (SYSTEM installs land here) for sid in user_sids(): for root in UNINST_ROOTS: _scan_uninstall(found, 'HKU', sid + '\\' + root) return list(found.values()) def trace_paths(code): """Every registry location Windows Installer / Uninstall may use for this product.""" packed = pack_guid(code) paths = [] for root in UNINST_ROOTS: paths.append(('HKLM', root + '\\' + code)) paths.append(('HKLM', CLASSES_PROD + '\\' + packed)) paths.append(('HKLM', CLASSES_FEAT + '\\' + packed)) ud = _open(UDATA) if ud: for sid in list(_subkeys(ud)): paths.append(('HKLM', UDATA + '\\' + sid + '\\Products\\' + packed)) _winreg.CloseKey(ud) for sid in user_sids(): base = sid + '\\Software\\Microsoft\\Installer\\' paths.append(('HKU', base + 'Products\\' + packed)) paths.append(('HKU', base + 'Features\\' + packed)) for root in UNINST_ROOTS: paths.append(('HKU', sid + '\\' + root + '\\' + code)) return paths def existing_traces(code): return [(h, p) for h, p in trace_paths(code) if key_exists(h, p)] def msi_registered(code): try: return ctypes.windll.msi.MsiQueryProductStateW(unicode(code)) != -1 except Exception: return False def is_machine_install(code): """True only if product is registered in the machine (all-users) context.""" try: buf = ctypes.create_unicode_buffer(256) size = ctypes.c_ulong(256) r = ctypes.windll.msi.MsiGetProductInfoExW(unicode(code), None, 4, u'VersionString', buf, ctypes.byref(size)) return r == 0 except Exception: return False def still_present(code): return bool(existing_traces(code)) or msi_registered(code) def force_cleanup(): """Remove every registry trace (HKLM + all HKU incl .DEFAULT) and the install folder.""" codes = set([PRODUCT_CODE]) for _, _, c in find_installed(): codes.add(c) for code in codes: for h, p in existing_traces(code): ret, out = run('reg delete "%s\\%s" /f /reg:64' % (h, p)) log('Delete %s\\%s -> %s' % (h, p, ret)) run('rmdir /s /q "C:\\Program Files\\Charles"') run('rmdir /s /q "C:\\Program Files (x86)\\Charles"') run('del /f /q "C:\\ProgramData\\Microsoft\\Windows\\Start Menu\\Programs\\Charles Web Debugging Proxy.lnk"') def promote_to_hklm(): """Same idea as the Signal .reg file: make sure HKLM Uninstall has the entry (Control Panel reads it). Copy from HKU if present, else write it.""" if find_installed(hklm_only=True): return True dst = UNINST_ROOTS[0] + '\\' + PRODUCT_CODE for h, p in existing_traces(PRODUCT_CODE): if h == 'HKU' and p.lower().endswith(('uninstall\\' + PRODUCT_CODE).lower()): ret, out = run('reg copy "HKU\\%s" "HKLM\\%s" /s /f' % (p, dst)) log('Promote HKU -> HKLM: %s %s' % (ret, out)) run('reg delete "HKLM\\%s" /v WindowsInstaller /f' % dst) break if not find_installed(hklm_only=True): try: k = _winreg.CreateKeyEx(HKLM, dst, 0, _winreg.KEY_WRITE | V64) vals = { 'DisplayName': 'Charles %s' % TARGET_VER, 'DisplayVersion': '5.2.1.2', 'Publisher': 'XK72 Ltd', 'DisplayIcon': 'C:\\Program Files\\Charles\\Charles.exe,0', 'InstallLocation': 'C:\\Program Files\\Charles\\', 'UninstallString': 'MsiExec.exe /X%s' % PRODUCT_CODE, } for n, v in vals.items(): _winreg.SetValueEx(k, n, 0, _winreg.REG_SZ, v) _winreg.SetValueEx(k, 'NoModify', 0, _winreg.REG_DWORD, 1) _winreg.CloseKey(k) log('Wrote HKLM Uninstall entry directly') except Exception as e: log('Write HKLM entry failed: %s' % e) return bool(find_installed(hklm_only=True)) # ================================================================ 1612 fix def fix_source(code): """Repair the product's source list so msiexec can find an MSI (fixes 1612).""" try: msi = ctypes.windll.msi pkg = unicode(os.path.basename(DOWNLOAD_TO)) src = unicode(os.path.dirname(DOWNLOAD_TO)) + u'\\' # (sid, context): machine, then SYSTEM per-user unmanaged / managed for sid, ctx in ((None, 4), (u'S-1-5-18', 2), (u'S-1-5-18', 1)): r1 = msi.MsiSourceListSetInfoW(unicode(code), sid, ctx, 1, u'PackageName', pkg) r2 = msi.MsiSourceListAddSourceExW(unicode(code), sid, ctx, 1, src, 1) log('fix_source ctx=%s: %s %s' % (ctx, r1, r2)) if r1 == 0 and r2 == 0: break except Exception as e: log('fix_source API error: %s' % e) # copy downloaded MSI to the exact path msiexec expects (SourceList in HKLM or HKU) packed = pack_guid(code) bases = [('HKLM', CLASSES_PROD)] + [('HKU', s + '\\Software\\Microsoft\\Installer\\Products') for s in user_sids()] for h, base in bases: try: k = _open(base + '\\' + packed + '\\SourceList', _hive(h)) if not k: continue pkg_name = _val(k, 'PackageName') last = _val(k, 'LastUsedSource') # e.g. n;1;C:\WINDOWS\TEMP\ _winreg.CloseKey(k) if pkg_name and last: folder = last.split(';')[-1] if folder and os.path.isdir(folder): target = os.path.join(folder, pkg_name) if not os.path.isfile(target): shutil.copyfile(DOWNLOAD_TO, target) log('Copied MSI to expected source %s' % target) except Exception as e: log('fix_source copy error: %s' % e) # ================================================================ uninstall def _uninstall_one(code): ok, msg = download_file(DOWNLOAD_TO, URL) log(msg) if ok: fix_source(code) # 1) by product code ret, out = run('msiexec.exe /x %s /qn /norestart /L*v "%s"' % (code, MSI_LOG)) log('Method1 uninstall by code: %s %s' % (ret, out)) if ret in SUCCESS_CODES or not ok: return ret # 2) by MSI file ret, out = run('msiexec.exe /x "%s" /qn /norestart /L*v "%s"' % (DOWNLOAD_TO, MSI_LOG)) log('Method2 uninstall by MSI file: %s %s' % (ret, out)) return ret def spm_uninstall(dir, args): run('taskkill /F /IM Charles.exe') codes = set(c for _, _, c in find_installed()) if still_present(PRODUCT_CODE): codes.add(PRODUCT_CODE) if not codes: log('%s not found - nothing to uninstall' % APP_NAME) marker(0) return 0 for code in codes: log('Uninstalling %s' % code) _uninstall_one(code) time.sleep(5) if find_installed() or still_present(PRODUCT_CODE): log('Normal uninstall failed - running force cleanup') force_cleanup() try: os.remove(DOWNLOAD_TO) except Exception: pass refresh_shell() remaining = find_installed() if remaining or still_present(PRODUCT_CODE): log('UNINSTALL FAILED - still present: %s %s' % (remaining, existing_traces(PRODUCT_CODE))) marker(1) return 1 log('UNINSTALL SUCCESS') marker(0) return 0 # ================================================================ install def spm_install(dir, args): # already a proper machine-wide install of the target version? for name, ver, code in find_installed(hklm_only=True): if ver.startswith(TARGET_VER) and is_machine_install(code): log('%s %s already installed %s' % (name, ver, code)) marker(0) return 0 # any leftover / per-user (SYSTEM) registration must go first, # otherwise msiexec treats /i as maintenance and looks for the old source (1612) if still_present(PRODUCT_CODE) or find_installed(): log('Existing registration found - cleaning before install') run('taskkill /F /IM Charles.exe') _uninstall_one(PRODUCT_CODE) time.sleep(5) if still_present(PRODUCT_CODE): force_cleanup() if still_present(PRODUCT_CODE): log('INSTALL FAILED - could not remove old registration: %s' % existing_traces(PRODUCT_CODE)) marker(1) return 1 ok, msg = download_file(DOWNLOAD_TO, URL) log(msg) if not ok: log('INSTALL FAILED - download error') marker(1) return 1 # ALLUSERS=1 -> machine-wide install, registered in HKLM ret, out = run('msiexec.exe /i "%s" /qn /norestart ALLUSERS=1 /L*v "%s"' % (DOWNLOAD_TO, MSI_LOG)) log('Install exit code: %s %s' % (ret, out)) try: os.remove(DOWNLOAD_TO) except Exception: pass if ret not in (0, 3010): log('INSTALL FAILED - exit code %s' % ret) marker(ret or 1) return ret or 1 # make sure Control Panel can see it (HKLM Uninstall), like the Signal reg import if not promote_to_hklm(): log('INSTALL FAILED - HKLM Uninstall entry missing') marker(1) return 1 refresh_shell() log('INSTALL SUCCESS machine_wide=%s' % is_machine_install(PRODUCT_CODE)) marker(0) return 0 # ================================================================ update def spm_update(dir, args): for name, ver, code in find_installed(hklm_only=True): if ver.startswith(TARGET_VER) and is_machine_install(code): log('Already on %s - no update needed' % TARGET_VER) marker(0) return 0 if spm_uninstall(None, None) != 0: log('Update aborted - uninstall failed') marker(1) return 1 time.sleep(20) return spm_install(None, None)