import os import re import gc import ssl import time import ctypes import urllib2 import _winreg from ctypes import wintypes 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 = 'SourceTree' TARGET_VER = '3.4.32' URL = "https://product-downloads.atlassian.com/software/sourcetree/windows/ga/SourceTreeSetup-3.4.32.exe" SILENT_ARGS = '-s' WAIT_FOR_USER = 300 # seconds to wait for someone to log in (0 = do not wait) PENDING_RETCODE = 1 # retcode when install is only scheduled for next logon (0 = show success) SYS_ROOT = os.environ.get('SystemRoot', 'C:\\Windows') WORK_DIR = os.path.join(os.environ.get('ProgramData', 'C:\\ProgramData'), 'SPM_SourceTree') DOWNLOAD_TO = os.path.join(WORK_DIR, URL.split('/')[-1]) SPM_LOG = os.path.join(SYS_ROOT, 'Temp', 'sourcetree_spm.log') SYSTEM_PROFILE = os.path.join(SYS_ROOT, 'System32', 'config', 'systemprofile') SYSTEM_DIRS = [ os.path.join(SYS_ROOT, 'System32', 'config', 'systemprofile', 'AppData', 'Local', 'SourceTree'), os.path.join(SYS_ROOT, 'SysWOW64', 'config', 'systemprofile', 'AppData', 'Local', 'SourceTree'), ] # relative to a user hive (HKU\\...) UNINST_ROOTS = [ r'Software\Microsoft\Windows\CurrentVersion\Uninstall', r'Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall', ] PROFILELIST = r'SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' V64 = _winreg.KEY_WOW64_64KEY HKLM = _winreg.HKEY_LOCAL_MACHINE HKU = _winreg.HKEY_USERS SESSION_STATES = {0: 'Active', 1: 'Connected', 2: 'ConnectQuery', 3: 'Shadow', 4: 'Disconnected', 5: 'Idle', 6: 'Listen', 7: 'Reset', 8: 'Down', 9: 'Init'} # ================================================================ win32 structures class WTS_SESSION_INFO(ctypes.Structure): _fields_ = [('SessionId', wintypes.DWORD), ('pWinStationName', wintypes.LPWSTR), ('State', wintypes.DWORD)] class STARTUPINFO(ctypes.Structure): _fields_ = [('cb', wintypes.DWORD), ('lpReserved', wintypes.LPWSTR), ('lpDesktop', wintypes.LPWSTR), ('lpTitle', wintypes.LPWSTR), ('dwX', wintypes.DWORD), ('dwY', wintypes.DWORD), ('dwXSize', wintypes.DWORD), ('dwYSize', wintypes.DWORD), ('dwXCountChars', wintypes.DWORD), ('dwYCountChars', wintypes.DWORD), ('dwFillAttribute', wintypes.DWORD), ('dwFlags', wintypes.DWORD), ('wShowWindow', wintypes.WORD), ('cbReserved2', wintypes.WORD), ('lpReserved2', ctypes.POINTER(ctypes.c_byte)), ('hStdInput', wintypes.HANDLE), ('hStdOutput', wintypes.HANDLE), ('hStdError', wintypes.HANDLE)] class PROCESS_INFORMATION(ctypes.Structure): _fields_ = [('hProcess', wintypes.HANDLE), ('hThread', wintypes.HANDLE), ('dwProcessId', wintypes.DWORD), ('dwThreadId', wintypes.DWORD)] kernel32 = ctypes.windll.kernel32 advapi32 = ctypes.windll.advapi32 wtsapi32 = ctypes.windll.wtsapi32 userenv = ctypes.windll.userenv kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] kernel32.WaitForSingleObject.restype = wintypes.DWORD kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT] kernel32.CloseHandle.argtypes = [wintypes.HANDLE] kernel32.LocalFree.argtypes = [ctypes.c_void_p] wtsapi32.WTSQueryUserToken.argtypes = [wintypes.ULONG, ctypes.POINTER(wintypes.HANDLE)] wtsapi32.WTSFreeMemory.argtypes = [ctypes.c_void_p] wtsapi32.WTSEnumerateSessionsW.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(ctypes.POINTER(WTS_SESSION_INFO)), ctypes.POINTER(wintypes.DWORD)] advapi32.GetTokenInformation.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)] advapi32.ConvertSidToStringSidW.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)] advapi32.CreateProcessAsUserW.argtypes = [wintypes.HANDLE, wintypes.LPCWSTR, wintypes.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, wintypes.BOOL, wintypes.DWORD, ctypes.c_void_p, wintypes.LPCWSTR, ctypes.POINTER(STARTUPINFO), ctypes.POINTER(PROCESS_INFORMATION)] userenv.CreateEnvironmentBlock.argtypes = [ctypes.POINTER(ctypes.c_void_p), wintypes.HANDLE, wintypes.BOOL] userenv.DestroyEnvironmentBlock.argtypes = [ctypes.c_void_p] def clean(s): # remove NUL / non-printable chars (keeps the ITSM log readable) if not isinstance(s, basestring): s = str(s) return ''.join(c for c in 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: if not os.path.isdir(os.path.dirname(dest)): os.makedirs(os.path.dirname(dest)) 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 _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 profile_of(sid): k = _open(PROFILELIST + '\\' + sid, HKLM) if not k: return '' p = _val(k, 'ProfileImagePath') _winreg.CloseKey(k) return os.path.expandvars(p) if p else '' def load_users(): """Every user hive (loaded ones + offline NTUSER.DAT loaded temporarily) plus the SYSTEM hive. Returns (users, loaded_hives). Call unload_users(loaded) when finished.""" users = [] loaded = [] for name in ('.DEFAULT', 'S-1-5-18'): if key_exists('HKU', name): users.append({'sid': name, 'hku': name, 'profile': SYSTEM_PROFILE, 'system': True}) base = _open(PROFILELIST, HKLM) if base: for sid in list(_subkeys(base)): if not (sid.startswith('S-1-5-21-') or sid.startswith('S-1-12-1-')): continue profile = profile_of(sid) if not profile: continue if key_exists('HKU', sid): hku = sid else: ntuser = os.path.join(profile, 'NTUSER.DAT') if not os.path.isfile(ntuser): continue hku = 'SPM_' + sid ret, out = run('reg load "HKU\\%s" "%s"' % (hku, ntuser)) if ret != 0: log('Cannot load hive %s: %s' % (ntuser, out)) continue loaded.append(hku) users.append({'sid': sid, 'hku': hku, 'profile': profile, 'system': False}) _winreg.CloseKey(base) return users, loaded def unload_users(loaded): gc.collect() for hku in loaded: for _ in range(5): ret, out = run('reg unload "HKU\\%s"' % hku) if ret == 0: break time.sleep(1) def scan_hku(hku): """SourceTree entries in one user hive: list of dict(key, name, ver, cmd, loc).""" entries = [] for root in UNINST_ROOTS: path = hku + '\\' + root base = _open(path, HKU) if not base: continue for sub in list(_subkeys(base)): k = _open(path + '\\' + sub, HKU) if not k: continue name = _val(k, 'DisplayName') if name and name.lower().startswith(APP_NAME.lower()): entries.append({ 'key': path + '\\' + sub, 'name': name, 'ver': str(_val(k, 'DisplayVersion')).strip(), 'cmd': _val(k, 'QuietUninstallString') or _val(k, 'UninstallString'), 'loc': _val(k, 'InstallLocation'), }) _winreg.CloseKey(k) _winreg.CloseKey(base) return entries def find_installed(users): """List of (user, entry) for SourceTree in every user hive.""" res = [] for u in users: for e in scan_hku(u['hku']): res.append((u, e)) return res def loc_ok(e): loc = e['loc'] return bool(loc) and (os.path.isfile(os.path.join(loc, 'Update.exe')) or os.path.isfile(os.path.join(loc, 'SourceTree.exe'))) def user_good(sid): """Target version registered for this user AND files really exist.""" for e in scan_hku(sid): if e['ver'] == TARGET_VER and loc_ok(e): return True return False def wait_installed(sid, timeout=180): end = time.time() + timeout while time.time() < end: if user_good(sid): return True time.sleep(5) return user_good(sid) def force_cleanup(u, e): """Remove registry key, install folder and shortcuts of one SourceTree entry.""" if key_exists('HKU', e['key']): ret, out = run('reg delete "HKU\\%s" /f' % e['key']) log('Delete HKU\\%s -> %s' % (e['key'], ret)) loc = (e['loc'] or '').rstrip('\\') if not loc: loc = os.path.join(u['profile'], 'AppData', 'Local', 'SourceTree') if 'sourcetree' in loc.lower(): run('rmdir /s /q "%s"' % loc) prof = u['profile'] if prof: run('del /f /q "%s\\Desktop\\SourceTree*.lnk"' % prof) run('del /f /s /q "%s\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\SourceTree*.lnk"' % prof) run('del /f /s /q "%s\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\Atlassian\\SourceTree*.lnk"' % prof) def cleanup_system_installs(): """Remove installs that earlier runs put in the SYSTEM profile (wrong location).""" for hku in ('.DEFAULT', 'S-1-5-18'): for e in scan_hku(hku): log('Removing SYSTEM-profile install %s %s' % (e['name'], e['ver'])) force_cleanup({'sid': hku, 'hku': hku, 'profile': SYSTEM_PROFILE, 'system': True}, e) for d in SYSTEM_DIRS: with disable_file_system_redirection(): exists = os.path.isdir(d) if exists: run('rmdir /s /q "%s"' % d) log('Removed folder %s' % d) def token_sid(token): size = wintypes.DWORD(0) advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(size)) # 1 = TokenUser buf = ctypes.create_string_buffer(size.value) if not advapi32.GetTokenInformation(token, 1, buf, size, ctypes.byref(size)): return '' psid = ctypes.cast(buf, ctypes.POINTER(ctypes.c_void_p))[0] out = ctypes.c_void_p() if not advapi32.ConvertSidToStringSidW(psid, ctypes.byref(out)): return '' sid = ctypes.wstring_at(out.value) kernel32.LocalFree(out) return sid def get_sessions(verbose=False): """Logged-on interactive users: list of dict(session, sid, token).""" res = {} pinfo = ctypes.POINTER(WTS_SESSION_INFO)() count = wintypes.DWORD(0) if not wtsapi32.WTSEnumerateSessionsW(0, 0, 1, ctypes.byref(pinfo), ctypes.byref(count)): log('WTSEnumerateSessions failed: %s' % ctypes.GetLastError()) return [] for i in range(count.value): s = pinfo[i] state = SESSION_STATES.get(s.State, s.State) if s.SessionId == 0 or s.State not in (0, 1, 4): # active / connected / disconnected if verbose: log('Session %s state=%s skipped' % (s.SessionId, state)) continue token = wintypes.HANDLE() if not wtsapi32.WTSQueryUserToken(s.SessionId, ctypes.byref(token)): if verbose: log('Session %s state=%s has no user token (err %s)' % (s.SessionId, state, ctypes.GetLastError())) continue sid = token_sid(token) if not sid or sid in res: kernel32.CloseHandle(token) continue if verbose: log('Session %s state=%s user=%s' % (s.SessionId, state, sid)) res[sid] = {'session': s.SessionId, 'sid': sid, 'token': token} wtsapi32.WTSFreeMemory(ctypes.cast(pinfo, ctypes.c_void_p)) return list(res.values()) def wait_for_sessions(timeout): """Return logged-on sessions; wait up to `timeout` seconds for someone to log in.""" end = time.time() + timeout first = True while True: sessions = get_sessions(verbose=first) first = False if sessions: return sessions if time.time() >= end: return [] log('No logged-on user yet - waiting (%s s left)' % int(end - time.time())) time.sleep(15) def release_sessions(sessions): for s in sessions: try: kernel32.CloseHandle(s['token']) except Exception: pass def run_as_user(token, cmdline, timeout=600): """Run a command in the logged-on user's context. Returns exit code, or None if it could not start.""" if isinstance(cmdline, str): cmdline = cmdline.decode('mbcs') env = ctypes.c_void_p() have_env = userenv.CreateEnvironmentBlock(ctypes.byref(env), token, False) si = STARTUPINFO() si.cb = ctypes.sizeof(si) si.lpDesktop = u'winsta0\\default' pi = PROCESS_INFORMATION() buf = ctypes.create_unicode_buffer(cmdline) flags = 0x08000000 | 0x00000400 # CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT ok = advapi32.CreateProcessAsUserW(token, None, buf, None, None, False, flags, env if have_env else None, None, ctypes.byref(si), ctypes.byref(pi)) if not ok: log('CreateProcessAsUser failed: %s' % ctypes.GetLastError()) if have_env: userenv.DestroyEnvironmentBlock(env) return None res = kernel32.WaitForSingleObject(pi.hProcess, timeout * 1000) code = wintypes.DWORD(0) if res != 0: log('Process timeout - terminating') kernel32.TerminateProcess(pi.hProcess, 1) code.value = 1460 else: kernel32.GetExitCodeProcess(pi.hProcess, ctypes.byref(code)) kernel32.CloseHandle(pi.hProcess) kernel32.CloseHandle(pi.hThread) if have_env: userenv.DestroyEnvironmentBlock(env) return code.value def _uninstall_one(u, e, token): log('Uninstalling %s %s for %s (%s)' % (e['name'], e['ver'], u['sid'], e['loc'])) if not u['system'] and e['cmd']: if token is not None: rc = run_as_user(token, e['cmd'], 300) log('Uninstall (as user) exit code: %s' % rc) else: ret, out = run(e['cmd']) log('Uninstall (as SYSTEM, user not logged on) exit code: %s %s' % (ret, out)) for _ in range(12): if not key_exists('HKU', e['key']): break time.sleep(5) # always make sure nothing is left behind force_cleanup(u, e) def _uninstall_all(): run('taskkill /F /IM SourceTree.exe') sessions = get_sessions() tokens = dict((s['sid'], s['token']) for s in sessions) users, loaded = load_users() try: entries = find_installed(users) if not entries: log('%s not found - nothing to uninstall' % APP_NAME) return 0 for u, e in entries: _uninstall_one(u, e, tokens.get(u['sid'])) time.sleep(3) left = find_installed(users) finally: unload_users(loaded) release_sessions(sessions) for d in SYSTEM_DIRS: run('rmdir /s /q "%s"' % d) refresh_shell() if left: log('UNINSTALL FAILED - still present: %s' % [(u['sid'], e['key']) for u, e in left]) return 1 log('UNINSTALL SUCCESS') return 0 def spm_uninstall(dir, args): rc = _uninstall_all() marker(rc) return rc def _install_one(s): sid = s['sid'] if user_good(sid): log('%s %s already installed for %s' % (APP_NAME, TARGET_VER, sid)) return True # remove old / broken version of this user first u = {'sid': sid, 'hku': sid, 'profile': profile_of(sid), 'system': False} for e in scan_hku(sid): _uninstall_one(u, e, s['token']) rc = run_as_user(s['token'], '"%s" %s' % (DOWNLOAD_TO, SILENT_ARGS), 600) log('Installer (as user %s) exit code: %s' % (sid, rc)) if rc is None: return False if wait_installed(sid): for e in scan_hku(sid): if e['ver'] == TARGET_VER: log('INSTALL SUCCESS user=%s ver=%s loc=%s' % (sid, e['ver'], e['loc'])) break return True log('INSTALL FAILED for %s - target version not registered (exit code %s)' % (sid, rc)) return False def _schedule_logon_install(update): """No user is logged on: create a one-time 'at logon' task per profile user that runs the installer in that user's own context at next login, then deletes itself.""" names = [] users, loaded = load_users() try: candidates = [u for u in users if not u['system'] and u['profile']] pending = [u for u in candidates if not user_good(u['hku'])] if update: with_app = [u for u in pending if scan_hku(u['hku'])] if with_app: pending = with_app for u in pending: name = os.path.basename(u['profile'].rstrip('\\')) if name: names.append(name) finally: unload_users(loaded) if not names: log('No user profiles need %s - nothing to schedule' % APP_NAME) return [] scheduled = [] for name in names: task = 'SPM_SourceTree_%s' % re.sub(r'[^A-Za-z0-9_]', '_', name) cmd_file = os.path.join(WORK_DIR, task + '.cmd') try: with open(cmd_file, 'w') as f: f.write('@echo off\r\n') f.write('"%s" %s\r\n' % (DOWNLOAD_TO, SILENT_ARGS)) f.write('schtasks /delete /tn "%s" /f\r\n' % task) except Exception as e: log('Cannot write %s: %s' % (cmd_file, e)) continue ret, out = run('schtasks /create /tn "%s" /tr "\\"%s\\"" /sc onlogon /ru "%s" /it /rl limited /f' % (task, cmd_file, name)) log('Logon task %s for user %s: %s %s' % (task, name, ret, out)) if ret == 0: scheduled.append(name) run('icacls "%s" /grant "*S-1-5-32-545:(OI)(CI)RX" /T /C /Q' % WORK_DIR) return scheduled def _install_all(update=False): sessions = wait_for_sessions(WAIT_FOR_USER) keep_files = False try: if not sessions: # nobody logged in - install at next logon instead ok, msg = download_file(DOWNLOAD_TO, URL) log(msg) if not ok: log('INSTALL FAILED - download error') return 1 cleanup_system_installs() scheduled = _schedule_logon_install(update) if scheduled: keep_files = True log('INSTALL PENDING - no logged-on user, scheduled for next logon of: %s' % ', '.join(scheduled)) return PENDING_RETCODE log('INSTALL FAILED - no logged-on user and could not schedule logon install') return 1 targets = sessions if update: # update only users that already have SourceTree; if nobody has it, install for all with_app = [s for s in sessions if scan_hku(s['sid'])] if with_app: targets = with_app if all(user_good(s['sid']) for s in targets): log('%s %s already installed for all logged-on users' % (APP_NAME, TARGET_VER)) cleanup_system_installs() return 0 run('taskkill /F /IM SourceTree.exe') cleanup_system_installs() ok, msg = download_file(DOWNLOAD_TO, URL) log(msg) if not ok: log('INSTALL FAILED - download error') return 1 run('icacls "%s" /grant "*S-1-5-32-545:(OI)(CI)RX" /T /C /Q' % WORK_DIR) result = 0 for s in targets: if not _install_one(s): result = 1 refresh_shell() return result finally: release_sessions(sessions) if not keep_files: try: os.remove(DOWNLOAD_TO) os.rmdir(WORK_DIR) except Exception: pass def spm_install(dir, args): rc = _install_all(update=False) marker(rc) return rc def spm_update(dir, args): rc = _install_all(update=True) marker(rc) return rc