1616from tkinter import filedialog , scrolledtext , ttk
1717import json
1818from typing import Callable
19+ from itertools import chain
20+ import time
1921
2022try :
2123 import winreg # type: ignore
@@ -76,12 +78,15 @@ def __init__(self, root: tk.Tk) -> None:
7678 self ._detect_cache_max = 64
7779 self ._shell_marker_detail = {}
7880 self ._tool_config_detail = {}
81+ self ._registry_cache : dict [tuple [str , ...], list [Path ]] = {}
82+ self ._shortcut_cache : dict [tuple [str , ...], list [Path ]] = {}
83+ self ._detecting = False
7984
8085 self ._build_layout ()
8186 self ._apply_window_position ()
8287 # 先记录应用启动,再进行 Shell 路径检测,保证日志顺序符合直觉
8388 self ._log ("应用已启动,准备检测 Shell 路径" , "info" )
84- self ._detect_all_paths (log = True )
89+ self ._detect_all_paths_in_thread (log = True )
8590 self ._refresh_env_tool_labels ()
8691 self ._update_restore_button_state ()
8792 self ._refresh_start_button_state ()
@@ -245,14 +250,12 @@ def _build_layout(self) -> None:
245250 row_idx = self ._build_shell_row (
246251 parent = path_frame ,
247252 key = "vscode" ,
248- label_text = "Visual Studio Code settings " ,
253+ label_text = "Visual Studio Code" ,
249254 var = self .vscode_path_var ,
250255 open_cmd = lambda : self ._open_path ("vscode" ),
251256 start_row = row_idx ,
252257 readonly = True ,
253258 )
254- # Visual Studio Code 状态提示
255- self ._row_widgets ["vscode" ]["status_full" ].config (textvariable = self .tool_info_var )
256259
257260 console_frame = ttk .LabelFrame (self .root , text = "控制台配置" , padding = "8" )
258261 console_frame .pack (fill = "x" , padx = 12 , pady = (2 , 4 ))
@@ -263,7 +266,7 @@ def _build_layout(self) -> None:
263266 status_frame .pack (fill = "x" , padx = 0 , pady = (0 , 4 ))
264267 self .admin_label = ttk .Label (status_frame , textvariable = self .status_var , foreground = "#0063b1" )
265268 self .admin_label .pack (side = "left" , anchor = "w" )
266- ttk .Button (status_frame , text = "重新检测" , command = self ._detect_all_paths ).pack (side = "right" )
269+ ttk .Button (status_frame , text = "重新检测" , command = lambda : self ._detect_all_paths_in_thread ( log = True ) ).pack (side = "right" )
267270
268271 progress_frame = ttk .Frame (self .root , padding = "12 2 12 0" )
269272 progress_frame .pack (fill = "x" )
@@ -1479,8 +1482,32 @@ def _update_restore_button_state(self, buttons_enabled: bool | None = None) -> N
14791482 state = "normal" if enabled else "disabled"
14801483 self .restore_btn .config (state = state )
14811484
1485+ # --------- 检测调度 ---------
1486+ def _detect_all_paths_in_thread (self , log : bool = True ) -> None :
1487+ """后台线程执行检测,避免启动/重新检测阻塞 UI。"""
1488+ if self .is_running or getattr (self , "_detecting" , False ):
1489+ return
1490+ self ._detecting = True
1491+ self .status_var .set ("正在检测工具与编码状态..." )
1492+ self ._set_buttons_state (False )
1493+ threading .Thread (
1494+ target = self ._run_detect_all_paths_safe , args = (log ,), daemon = True
1495+ ).start ()
1496+
1497+ def _run_detect_all_paths_safe (self , log : bool ) -> None :
1498+ try :
1499+ self ._detect_all_paths (log = log )
1500+ finally :
1501+ self ._ui_call (self ._on_detect_done )
1502+
1503+ def _on_detect_done (self ) -> None :
1504+ self ._detecting = False
1505+ self ._set_buttons_state (True )
1506+ self ._refresh_start_button_state ()
1507+
14821508 def _detect_all_paths (self , log : bool = True ) -> None :
14831509 # 如果上一条日志是检测分隔线,先清理本次检测段落,避免重复追加
1510+ t0 = time .perf_counter ()
14841511 if log :
14851512 self ._trim_last_detection_block ()
14861513 self ._log_separator ("检测开始" )
@@ -1492,6 +1519,7 @@ def _detect_all_paths(self, log: bool = True) -> None:
14921519 # 在检测块中输出“哪些内容被手动更改”的差异提示
14931520 self ._log_config_drift_report ()
14941521 if log :
1522+ self ._log (f"检测耗时 { time .perf_counter () - t0 :.2f} s" , "info" )
14951523 self ._log_separator ("检测结束" )
14961524 # 先刷新编码/环境,再基于结果刷新汇总与按钮状态
14971525 self ._update_console_state_label ()
@@ -1501,8 +1529,18 @@ def _detect_all_paths(self, log: bool = True) -> None:
15011529 self ._refresh_reset_default_button_state ()
15021530
15031531 def _detect_ps5 (self , log : bool = True ) -> None :
1504- sys_root = os .environ .get ("SystemRoot" , r"C:\Windows" )
1505- candidate = Path (sys_root ) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
1532+ candidate = None
1533+ which_ps = shutil .which ("powershell" )
1534+ if which_ps :
1535+ candidate = Path (which_ps ).resolve ()
1536+ if candidate is None :
1537+ sys_root = os .environ .get ("SystemRoot" , r"C:\Windows" )
1538+ candidate = Path (sys_root ) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
1539+ if candidate is None or not candidate .exists ():
1540+ for target in self ._shortcut_targets (["**/Windows PowerShell*.lnk" ]):
1541+ if target .exists ():
1542+ candidate = target
1543+ break
15061544 if candidate .exists ():
15071545 self ._ps5_exe = candidate
15081546 self ._ps5_available = True
@@ -1527,14 +1565,36 @@ def _detect_ps5(self, log: bool = True) -> None:
15271565 self ._log ("未检测到 Windows PowerShell 5.1" , "warning" )
15281566
15291567 def _detect_ps7 (self , log : bool = True ) -> None :
1530- pf = os .environ .get ("ProgramFiles" , r"C:\Program Files" )
1531- candidates = sorted (Path (pf ).glob ("PowerShell/*/pwsh.exe" ), reverse = True )
15321568 path = None
1533- for c in candidates :
1534- if c .exists ():
1535- path = c
1536- break
1537- if path :
1569+ which_pwsh = shutil .which ("pwsh" )
1570+ if which_pwsh :
1571+ path = Path (which_pwsh ).resolve ()
1572+ else :
1573+ pf = os .environ .get ("ProgramFiles" , r"C:\Program Files" )
1574+ pf86 = os .environ .get ("ProgramFiles(x86)" , r"C:\Program Files (x86)" )
1575+ pf64 = os .environ .get ("ProgramW6432" , pf )
1576+ search_roots = [pf , pf86 , pf64 ]
1577+ candidates : list [Path ] = []
1578+ for root in search_roots :
1579+ if not root :
1580+ continue
1581+ candidates .extend (sorted (Path (root ).glob ("PowerShell/*/pwsh.exe" ), reverse = True ))
1582+ for c in candidates :
1583+ if c .exists ():
1584+ path = c
1585+ break
1586+ if path is None :
1587+ for loc in self ._registry_install_locations (["powershell 7" ]):
1588+ candidate = Path (loc ) / "pwsh.exe"
1589+ if candidate .exists ():
1590+ path = candidate
1591+ break
1592+ if path is None :
1593+ for target in self ._shortcut_targets (["**/PowerShell 7*.lnk" ]):
1594+ if target .exists ():
1595+ path = target
1596+ break
1597+ if path and path .exists ():
15381598 self ._ps7_exe = path
15391599 self ._ps7_available = True
15401600 profile_exists = self ._ps7_profile_path .exists ()
@@ -1768,38 +1828,196 @@ def _trim_last_detection_block(self) -> None:
17681828 self .log_text .insert ("end" , new_content + "\n " )
17691829 self .log_text .configure (state = "disabled" )
17701830
1831+ # --------- 通用候选路径收集工具 ---------
1832+ def _registry_install_locations (self , keywords : list [str ]) -> list [Path ]:
1833+ """从卸载注册表读取 InstallLocation,关键词大小写不敏感。"""
1834+ key_tuple = tuple (sorted (k .lower () for k in keywords ))
1835+ if key_tuple in self ._registry_cache :
1836+ return list (self ._registry_cache [key_tuple ])
1837+ if winreg is None :
1838+ return []
1839+ locations : list [Path ] = []
1840+ hives = [
1841+ (winreg .HKEY_LOCAL_MACHINE , "HKLM" ),
1842+ (winreg .HKEY_CURRENT_USER , "HKCU" ),
1843+ ]
1844+ views = [0 ]
1845+ if hasattr (winreg , "KEY_WOW64_64KEY" ):
1846+ views = [winreg .KEY_WOW64_64KEY , winreg .KEY_WOW64_32KEY ]
1847+ for hive , _ in hives :
1848+ for view in views :
1849+ try :
1850+ key = winreg .OpenKey (
1851+ hive ,
1852+ r"Software\Microsoft\Windows\CurrentVersion\Uninstall" ,
1853+ 0 ,
1854+ winreg .KEY_READ | view ,
1855+ )
1856+ except OSError :
1857+ continue
1858+ try :
1859+ i = 0
1860+ while True :
1861+ try :
1862+ subkey_name = winreg .EnumKey (key , i )
1863+ except OSError :
1864+ break
1865+ i += 1
1866+ try :
1867+ subkey = winreg .OpenKey (key , subkey_name )
1868+ display_name , _ = winreg .QueryValueEx (subkey , "DisplayName" )
1869+ except OSError :
1870+ continue
1871+ name_lower = str (display_name ).lower ()
1872+ if not any (k .lower () in name_lower for k in keywords ):
1873+ continue
1874+ try :
1875+ loc , _ = winreg .QueryValueEx (subkey , "InstallLocation" )
1876+ except OSError :
1877+ loc = ""
1878+ if loc :
1879+ p = Path (loc ).expanduser ()
1880+ if p .exists ():
1881+ locations .append (p )
1882+ finally :
1883+ try :
1884+ winreg .CloseKey (key )
1885+ except Exception :
1886+ pass
1887+ seen = set ()
1888+ uniq : list [Path ] = []
1889+ for loc in locations :
1890+ if loc not in seen :
1891+ seen .add (loc )
1892+ uniq .append (loc )
1893+ self ._registry_cache [key_tuple ] = uniq
1894+ return uniq
1895+
1896+ def _shortcut_targets (self , patterns : list [str ]) -> list [Path ]:
1897+ """解析开始菜单快捷方式目标路径(最佳努力,依赖 PowerShell COM)。"""
1898+ pat_tuple = tuple (sorted (patterns ))
1899+ if pat_tuple in self ._shortcut_cache :
1900+ return list (self ._shortcut_cache [pat_tuple ])
1901+ start_roots = [
1902+ Path (os .environ .get ("ProgramData" , r"C:\ProgramData" ))
1903+ / "Microsoft"
1904+ / "Windows"
1905+ / "Start Menu"
1906+ / "Programs" ,
1907+ Path (os .environ .get ("APPDATA" , Path .home ()))
1908+ / "Microsoft"
1909+ / "Windows"
1910+ / "Start Menu"
1911+ / "Programs" ,
1912+ ]
1913+ pwsh = shutil .which ("powershell" ) or shutil .which ("pwsh" )
1914+ if not pwsh :
1915+ return []
1916+
1917+ existing_roots = [str (r ) for r in start_roots if r .exists ()]
1918+ if not existing_roots :
1919+ self ._shortcut_cache [pat_tuple ] = []
1920+ return []
1921+
1922+ # 使用单次 PowerShell 批量解析,减少进程开销
1923+ pattern_clause = " -or " .join ([f"($_.Name -like '{ p } ')" for p in patterns ])
1924+ roots_literal = "," .join ([f"'{ r } '" for r in existing_roots ])
1925+ ps_script = ";" .join (
1926+ [
1927+ "$ErrorActionPreference='SilentlyContinue'" ,
1928+ f"$roots=@({ roots_literal } )" ,
1929+ "$ws=New-Object -ComObject WScript.Shell" ,
1930+ "$res=@()" ,
1931+ "foreach($r in $roots){" ,
1932+ " if(Test-Path $r){" ,
1933+ " Get-ChildItem -LiteralPath $r -Filter *.lnk -Recurse | Where-Object {"
1934+ f" { pattern_clause } }} | ForEach-Object {{"
1935+ " $s=$ws.CreateShortcut($_.FullName);"
1936+ " if($s -and $s.TargetPath){ $res += $s.TargetPath }"
1937+ " }" ,
1938+ " }" ,
1939+ "}" ,
1940+ "$res | Sort-Object -Unique" ,
1941+ ]
1942+ )
1943+ targets : list [Path ] = []
1944+ try :
1945+ proc = subprocess .run (
1946+ [pwsh , "-NoProfile" , "-Command" , ps_script ],
1947+ capture_output = True ,
1948+ text = True ,
1949+ timeout = 3 ,
1950+ )
1951+ if proc .stdout :
1952+ for line in proc .stdout .splitlines ():
1953+ p = Path (line .strip ()).expanduser ()
1954+ if p .exists ():
1955+ targets .append (p .resolve ())
1956+ except Exception :
1957+ pass
1958+
1959+ seen = set ()
1960+ uniq : list [Path ] = []
1961+ for t in targets :
1962+ if t not in seen :
1963+ seen .add (t )
1964+ uniq .append (t )
1965+ self ._shortcut_cache [pat_tuple ] = uniq
1966+ return uniq
1967+
17711968 def _detect_git_paths (self , log : bool = True ) -> None :
1772- candidates = []
1969+ primary_paths : list [ Path ] = []
17731970 env_paths = [
17741971 os .environ .get ("ProgramFiles" ),
17751972 os .environ .get ("ProgramFiles(x86)" ),
1973+ os .environ .get ("ProgramW6432" ),
17761974 os .environ .get ("USERPROFILE" ),
17771975 ]
17781976 for base in env_paths :
17791977 if not base :
17801978 continue
1781- p = Path (base ) / "Git"
1782- candidates .append (p )
1783- local = Path (base ) / "AppData" / "Local" / "Programs" / "Git"
1784- candidates .append (local )
1785- candidates .append (Path ("D:/Programs/Git" ))
1979+ base_path = Path (base )
1980+ primary_paths .append (base_path / "Git" )
1981+ primary_paths .append (base_path / "AppData" / "Local" / "Programs" / "Git" )
17861982
17871983 bash_in_path = shutil .which ("bash" )
17881984 if bash_in_path :
1789- candidates .append (Path (bash_in_path ).resolve ().parents [1 ])
1790-
1791- unique_paths = []
1792- for path in candidates :
1793- if path and path not in unique_paths :
1794- unique_paths .append (path )
1985+ primary_paths .append (Path (bash_in_path ).resolve ().parents [1 ])
1986+ git_in_path = shutil .which ("git" )
1987+ if git_in_path :
1988+ git_root = Path (git_in_path ).resolve ().parent .parent
1989+ primary_paths .append (git_root )
1990+
1991+ program_data = os .environ .get ("ProgramData" , r"C:\ProgramData" )
1992+ secondary_paths = [
1993+ Path (program_data ) / "chocolatey" / "lib" / "git" / "tools" ,
1994+ Path .home () / "scoop" / "apps" / "git" / "current" ,
1995+ Path ("D:/Programs/Git" ),
1996+ Path .home () / "AppData" / "Local" / "Programs" / "Git" ,
1997+ ]
17951998
1796- found = None
1797- for path in unique_paths :
1999+ found : Path | None = None
2000+ for path in primary_paths :
17982001 bash = path / "bin" / "bash.exe"
17992002 if bash .exists ():
18002003 found = bash
18012004 break
18022005
2006+ if not found :
2007+ # 仅在快速路径未命中时再做重扫描(注册表/快捷方式),避免启动慢
2008+ for loc in self ._registry_install_locations (["git for windows" , "git version" , "git" ]):
2009+ secondary_paths .append (loc )
2010+ for target in self ._shortcut_targets (["Git Bash*.lnk" , "Git*.lnk" ]):
2011+ if target .name .lower ().startswith ("git-bash" ) or target .name .lower () == "bash.exe" :
2012+ secondary_paths .append (target .parent .parent )
2013+ else :
2014+ secondary_paths .append (target .parent )
2015+ for path in secondary_paths :
2016+ bash = path / "bin" / "bash.exe"
2017+ if bash .exists ():
2018+ found = bash
2019+ break
2020+
18032021 if found :
18042022 self ._git_exe = found
18052023 self ._git_bashrc_path = Path .home () / ".bashrc"
@@ -1828,6 +2046,27 @@ def _detect_vscode(self, log: bool = True) -> None:
18282046 settings_path = Path (appdata ) / "Code" / "User" / "settings.json" if appdata else None
18292047 exe_path = shutil .which ("code" ) or shutil .which ("code.cmd" )
18302048 exe_resolved = Path (exe_path ).resolve () if exe_path else None
2049+ if not exe_resolved :
2050+ local_app = os .environ .get ("LOCALAPPDATA" )
2051+ candidates = []
2052+ if local_app :
2053+ candidates .append (Path (local_app ) / "Programs" / "Microsoft VS Code" / "Code.exe" )
2054+ program_files = [
2055+ os .environ .get ("ProgramFiles" ),
2056+ os .environ .get ("ProgramFiles(x86)" ),
2057+ os .environ .get ("ProgramW6432" ),
2058+ ]
2059+ for root in program_files :
2060+ if root :
2061+ candidates .append (Path (root ) / "Microsoft VS Code" / "Code.exe" )
2062+ for loc in self ._registry_install_locations (["visual studio code" , "microsoft visual studio code" ]):
2063+ candidates .append (Path (loc ) / "Code.exe" )
2064+ for target in self ._shortcut_targets (["**/Visual Studio Code*.lnk" ]):
2065+ candidates .append (target )
2066+ for c in candidates :
2067+ if c and c .exists ():
2068+ exe_resolved = c .resolve ()
2069+ break
18312070 self ._vscode_available = bool (exe_resolved )
18322071 display_exe = None
18332072 if exe_resolved :
0 commit comments