3232import secrets # Added for password generator
3333import string # Added for password generator
3434import hashlib # Added for simple key hashing
35+ # Lightweight bootstrap logger used during early startup (before full app logger exists)
36+ def _bootstrap_log (msg : str ):
37+ try :
38+ la = os .getenv ("LOCALAPPDATA" ) or os .getenv ("APPDATA" ) or os .path .expanduser ("~" )
39+ logdir = os .path .join (la , "NovaFoundry" )
40+ os .makedirs (logdir , exist_ok = True )
41+ logpath = os .path .join (logdir , "BlackHole_debug.log" )
42+ from datetime import datetime
43+ s = f"{ datetime .utcnow ().isoformat ()} Z - { msg } \n "
44+ with open (logpath , "a" , encoding = "utf-8" ) as lf :
45+ lf .write (s )
46+ lf .flush ()
47+ # Also print to stderr/console for immediate visibility
48+ try :
49+ print (s .strip (), file = sys .stderr , flush = True )
50+ except Exception :
51+ pass
52+ except Exception :
53+ try :
54+ print (f"BlackHole log: { msg } " , file = sys .stderr , flush = True )
55+ except Exception :
56+ pass
57+
3558if platform .system () == "Windows" :
3659 from ctypes import *
3760 from ctypes .wintypes import *
@@ -138,10 +161,13 @@ class SECURITY_ATTRIBUTES(Structure):
138161 mutex = kernel32 .CreateMutexW (None , True , mutex_name )
139162 err = kernel32 .GetLastError ()
140163 if err == ERROR_ALREADY_EXISTS :
164+ _bootstrap_log ("Detected existing single-instance mutex; attempting to locate running instance(s) and close them." )
141165 # Try to find the existing window. First try exact match, then enumerate windows by process exe name
142166 hwnd = None
143167 try :
144168 hwnd = user32 .FindWindowW (None , "Black Hole Password Manager" )
169+ if hwnd :
170+ _bootstrap_log (f"Found window by title: hwnd={ hwnd } " )
145171 except Exception :
146172 hwnd = None
147173 if not hwnd :
@@ -190,8 +216,10 @@ def _enum_proc(h, lparam):
190216 if kernel32 .QueryFullProcessImageNameW (ph , 0 , buf , byref (buf_len )):
191217 exe_path = buf .value
192218 exe_base = os .path .basename (exe_path ).lower ()
193- if our_exe and exe_base == our_exe :
219+ # Match either the actual exe name or the common packaged name 'blackhole.exe'
220+ if (our_exe and exe_base == our_exe ) or exe_base == 'blackhole.exe' or exe_base .endswith ('blackhole.exe' ):
194221 found ['hwnd' ] = h
222+ _bootstrap_log (f"Matched window by exe name: { exe_base } (pid={ p } ) hwnd={ h } " )
195223 kernel32 .CloseHandle (ph )
196224 return False
197225 try :
@@ -206,14 +234,141 @@ def _enum_proc(h, lparam):
206234 try :
207235 user32 .EnumWindows (EnumWindowsProc (_enum_proc ), 0 )
208236 hwnd = found .get ("hwnd" )
209- except Exception :
237+ if hwnd :
238+ _bootstrap_log (f"Found hwnd via enumeration: { hwnd } " )
239+ except Exception as e :
240+ _bootstrap_log (f"EnumWindows failed: { e } " )
210241 hwnd = None
211- if hwnd :
242+ # Collect candidate PIDs for instances of the same exe
243+ candidate_pids = set ()
244+ try :
245+ # If we have an hwnd, get its pid
246+ if hwnd :
247+ pid_holder = DWORD ()
248+ try :
249+ user32 .GetWindowThreadProcessId (hwnd , byref (pid_holder ))
250+ if pid_holder .value and pid_holder .value != os .getpid ():
251+ candidate_pids .add (pid_holder .value )
252+ _bootstrap_log (f"Added pid from hwnd: { pid_holder .value } " )
253+ except Exception as e :
254+ _bootstrap_log (f"Failed to get pid from hwnd: { e } " )
255+ # Also try to find running processes by name via tasklist as a fallback
212256 try :
213- user32 .PostMessageW (hwnd , WM_CLOSE , 0 , 0 )
214- except Exception :
215- pass
216- sys .exit (0 )
257+ import subprocess , csv
258+ out = subprocess .check_output (["tasklist" , "/FO" , "CSV" , "/NH" ], universal_newlines = True )
259+ for line in out .splitlines ():
260+ try :
261+ cols = list (csv .reader ([line ]))[0 ]
262+ exe_name = cols [0 ].strip ('"' ).lower ()
263+ pid = int (cols [1 ].strip ('"' ))
264+ # Match either our detected exe name or 'blackhole.exe' as seen in Task Manager
265+ if pid != os .getpid () and ( (our_exe and exe_name == our_exe ) or exe_name == 'blackhole.exe' or exe_name .startswith ('blackhole' )):
266+ candidate_pids .add (pid )
267+ except Exception :
268+ continue
269+ _bootstrap_log (f"Candidate pids from tasklist: { candidate_pids } " )
270+ except Exception as e :
271+ _bootstrap_log (f"tasklist enumeration failed: { e } " )
272+ except Exception as e :
273+ _bootstrap_log (f"candidate pid collection failed: { e } " )
274+ # For each candidate, attempt graceful WM_CLOSE via EnumWindows and then wait and terminate if needed
275+ try :
276+ EnumWindowsProc = WINFUNCTYPE (BOOL , HWND , LPARAM )
277+ def _post_close (h , lparam ):
278+ try :
279+ pid_local = DWORD ()
280+ user32 .GetWindowThreadProcessId (h , byref (pid_local ))
281+ if pid_local .value == lparam and user32 .IsWindowVisible (h ):
282+ try :
283+ user32 .PostMessageW (h , WM_CLOSE , 0 , 0 )
284+ _bootstrap_log (f"Posted WM_CLOSE to hwnd={ h } (pid={ lparam } )" )
285+ except Exception as e :
286+ _bootstrap_log (f"Failed to post WM_CLOSE to hwnd={ h } : { e } " )
287+ except Exception as e :
288+ _bootstrap_log (f"_post_close error: { e } " )
289+ return True
290+ for p in list (candidate_pids ):
291+ try :
292+ _bootstrap_log (f"Attempting graceful close for pid { p } " )
293+ # Post WM_CLOSE to windows belonging to that PID
294+ try :
295+ user32 .EnumWindows (EnumWindowsProc (_post_close ), p )
296+ except Exception as e :
297+ _bootstrap_log (f"EnumWindows during post_close failed for pid { p } : { e } " )
298+ # Try waiting for process to exit, otherwise terminate it
299+ PROCESS_TERMINATE = 0x0001
300+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
301+ SYNCHRONIZE = 0x00100000
302+ access = PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE
303+ ph = kernel32 .OpenProcess (access , False , p )
304+ if ph :
305+ _bootstrap_log (f"Opened process handle for pid { p } with access { access } " )
306+ # Wait up to 3s for exit
307+ kernel32 .WaitForSingleObject .argtypes = [HANDLE , DWORD ]
308+ kernel32 .WaitForSingleObject .restype = DWORD
309+ WAIT_OBJECT_0 = 0x00000000
310+ WAIT_TIMEOUT = 0x00000102
311+ WAIT_FAILED = 0xFFFFFFFF
312+ res = kernel32 .WaitForSingleObject (ph , 3000 )
313+ if res == WAIT_OBJECT_0 :
314+ _bootstrap_log (f"PID { p } exited after WM_CLOSE (WAIT_OBJECT_0)" )
315+ elif res == WAIT_TIMEOUT :
316+ _bootstrap_log (f"PID { p } did not exit in time; attempting TerminateProcess" )
317+ try :
318+ kernel32 .TerminateProcess .argtypes = [HANDLE , UINT ]
319+ kernel32 .TerminateProcess .restype = BOOL
320+ ok = kernel32 .TerminateProcess (ph , 1 )
321+ _bootstrap_log (f"TerminateProcess called on pid { p } , success={ bool (ok )} " )
322+ # wait briefly after termination
323+ res2 = kernel32 .WaitForSingleObject (ph , 2000 )
324+ if res2 == WAIT_OBJECT_0 :
325+ _bootstrap_log (f"PID { p } exited after TerminateProcess" )
326+ else :
327+ _bootstrap_log (f"After TerminateProcess, Wait returned { res2 } " )
328+ except Exception as e :
329+ _bootstrap_log (f"Failed to terminate pid { p } : { e } " )
330+ elif res == WAIT_FAILED :
331+ err = kernel32 .GetLastError ()
332+ _bootstrap_log (f"WaitForSingleObject failed for pid { p } , GetLastError={ err } " )
333+ # Try to inspect exit code
334+ try :
335+ kernel32 .GetExitCodeProcess .argtypes = [HANDLE , POINTER (DWORD )]
336+ kernel32 .GetExitCodeProcess .restype = BOOL
337+ exit_code = DWORD ()
338+ if kernel32 .GetExitCodeProcess (ph , byref (exit_code )):
339+ if exit_code .value != 259 : # STILL_ACTIVE
340+ _bootstrap_log (f"Process pid { p } is not active anymore, exit_code={ exit_code .value } " )
341+ else :
342+ _bootstrap_log (f"Process pid { p } still active (STILL_ACTIVE). Attempting TerminateProcess" )
343+ try :
344+ kernel32 .TerminateProcess .argtypes = [HANDLE , UINT ]
345+ kernel32 .TerminateProcess .restype = BOOL
346+ ok = kernel32 .TerminateProcess (ph , 1 )
347+ _bootstrap_log (f"TerminateProcess called on pid { p } , success={ bool (ok )} " )
348+ except Exception as e :
349+ _bootstrap_log (f"TerminateProcess exception for pid { p } : { e } " )
350+ else :
351+ err2 = kernel32 .GetLastError ()
352+ _bootstrap_log (f"GetExitCodeProcess failed for pid { p } , GetLastError={ err2 } " )
353+ except Exception as e :
354+ _bootstrap_log (f"GetExitCodeProcess error for pid { p } : { e } " )
355+ else :
356+ _bootstrap_log (f"Wait returned unexpected value { res } for pid { p } " )
357+ try :
358+ kernel32 .CloseHandle (ph )
359+ except Exception :
360+ pass
361+ except Exception as e :
362+ _bootstrap_log (f"Error handling pid { p } : { e } " )
363+ except Exception as e :
364+ _bootstrap_log (f"Error enumerating candidate windows/pids: { e } " )
365+ # Small pause to allow other instances to exit and file locks to clear
366+ try :
367+ import time
368+ time .sleep (0.3 )
369+ except Exception :
370+ pass
371+ _bootstrap_log ("Instance close/termination attempts complete; proceeding with startup." )
217372# Set working directory to the script/exe directory
218373SCRIPT_DIR = os .path .dirname (os .path .abspath (sys .argv [0 ]))
219374os .chdir (SCRIPT_DIR )
@@ -229,7 +384,7 @@ def _enum_proc(h, lparam):
229384FONT_ITALIC = os .path .join (SCRIPT_DIR , "Fonts" , "Nunito-Italic.ttf" )
230385FONT_SEMIBOLD = os .path .join (SCRIPT_DIR , "Fonts" , "Nunito-SemiBold.ttf" )
231386LICENSE_TEXT = os .path .join (SCRIPT_DIR , "LICENSE.txt" )
232- VERSION = "1.10.1 "
387+ VERSION = "1.10.2 "
233388# Load all the font files for Tkinter (on Windows)
234389if platform .system () == "Windows" :
235390 fonts = [FONT_REGULAR , FONT_MEDIUM , FONT_BOLD , FONT_LIGHT , FONT_ITALIC , FONT_SEMIBOLD ]
@@ -685,9 +840,9 @@ def _setup_new(self):
685840 self ._init_db ()
686841 sync_key = urlsafe_b64encode (self .salt ).decode ()
687842 self ._show_sync_key_display_popup (sync_key )
843+ # Persist settings (atomic write handled by helper)
688844 try :
689- with open (settings_path , "w" , encoding = "utf-8" ) as sf :
690- json .dump (self .settings , sf )
845+ self ._save_settings ()
691846 except Exception :
692847 pass
693848 return success
@@ -722,9 +877,9 @@ def _setup_import(self):
722877 self .settings ["salt" ] = urlsafe_b64encode (self .salt ).decode ()
723878 self .settings ["db_path" ] = self .db_path
724879 self .settings ["master_password_set" ] = True
880+ # Persist settings (atomic write handled by helper)
725881 try :
726- with open (settings_path , "w" , encoding = "utf-8" ) as sf :
727- json .dump (self .settings , sf )
882+ self ._save_settings ()
728883 except Exception :
729884 pass
730885 return True
@@ -1204,11 +1359,29 @@ def _save_pinned(self):
12041359 pass
12051360 # --- Save Settings ---
12061361 def _save_settings (self ):
1362+ """Persist settings atomically and ensure data is flushed to disk."""
12071363 try :
1208- with open (settings_path , "w" , encoding = "utf-8" ) as f :
1209- json .dump (self .settings , f )
1210- except Exception :
1211- pass
1364+ temp_path = settings_path + ".tmp"
1365+ # Write to a temp file first to avoid truncation / partial writes
1366+ with open (temp_path , "w" , encoding = "utf-8" ) as f :
1367+ json .dump (self .settings , f , indent = 2 )
1368+ f .flush ()
1369+ os .fsync (f .fileno ())
1370+ # Replace atomically
1371+ os .replace (temp_path , settings_path )
1372+ except Exception as e :
1373+ # Best-effort fallback and write a diagnostic to stderr for troubleshooting
1374+ try :
1375+ with open (settings_path , "w" , encoding = "utf-8" ) as f :
1376+ json .dump (self .settings , f , indent = 2 )
1377+ f .flush ()
1378+ os .fsync (f .fileno ())
1379+ except Exception :
1380+ pass
1381+ try :
1382+ print (f"Failed to save settings: { e } " , file = sys .stderr )
1383+ except Exception :
1384+ pass
12121385 # --- Apply Theme ---
12131386 def _apply_theme (self , theme_mode ):
12141387 """Apply theme based on mode: 'light', 'dark', or 'system'"""
@@ -1475,11 +1648,11 @@ def show_settings_popup(self):
14751648 if self .settings .get ("launch_with_windows" , False ):
14761649 launch_var .select ()
14771650 launch_var .configure (command = lambda : self .toggle_launch (launch_var .get ()))
1478- if platform .system () != "Windows" :
1479- tray_var .configure (state = "disabled" )
1480- Tooltip (launch_var , "Launch with Windows is only available on Windows OS" )
14811651 tray_var = ctk .CTkSwitch (frame , text = "Minimize to Tray" )
14821652 tray_var .pack (pady = 10 , padx = 10 )
1653+ if platform .system () != "Windows" :
1654+ tray_var .configure (state = "disabled" )
1655+ Tooltip (tray_var , "Minimize to Tray is only available on Windows OS" )
14831656 if self .settings .get ("minimize_to_tray" , False ):
14841657 tray_var .select ()
14851658 tray_var .configure (command = lambda : self .toggle_tray (tray_var .get ()))
@@ -1553,6 +1726,35 @@ def toggle_theme(self, theme_mode):
15531726 self ._apply_theme (theme_mode )
15541727 messagebox .showinfo ("Theme Changed" , f"Theme changed to { theme_mode .capitalize ()} . The app will fully apply the new theme on next launch." )
15551728 os ._exit (1 )
1729+ def toggle_startup (self , value ):
1730+ """Enable or disable startup registry entry on Windows."""
1731+ if platform .system () != "Windows" :
1732+ return
1733+ reg_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
1734+ app_name = "BlackHole"
1735+ try :
1736+ if value :
1737+ cmd = f'"{ sys .argv [0 ]} "'
1738+ if self .settings .get ("minimize_to_tray" , False ):
1739+ cmd += " --minimize"
1740+ key = winreg .OpenKey (winreg .HKEY_CURRENT_USER , reg_path , 0 , winreg .KEY_SET_VALUE )
1741+ winreg .SetValueEx (key , app_name , 0 , winreg .REG_SZ , cmd )
1742+ winreg .CloseKey (key )
1743+ _bootstrap_log (f"Added startup registry entry: { cmd } " )
1744+ else :
1745+ try :
1746+ key = winreg .OpenKey (winreg .HKEY_CURRENT_USER , reg_path , 0 , winreg .KEY_SET_VALUE )
1747+ winreg .DeleteValue (key , app_name )
1748+ winreg .CloseKey (key )
1749+ _bootstrap_log ("Removed startup registry entry" )
1750+ except FileNotFoundError :
1751+ _bootstrap_log ("Startup registry entry not present" )
1752+ except Exception as e :
1753+ _bootstrap_log (f"toggle_startup registry op failed: { e } " )
1754+ try :
1755+ messagebox .showerror ("Error" , f"Failed to update startup setting: { e } " )
1756+ except Exception :
1757+ pass
15561758 def _change_sort (self , event = None ):
15571759 val = self .sort_var .get ()
15581760 if val == "Title A-Z" :
0 commit comments