Skip to content

Commit 8692050

Browse files
Fixed multi instance handling and fixed settings bugs
1 parent cfacce2 commit 8692050

File tree

6 files changed

+224
-22
lines changed

6 files changed

+224
-22
lines changed

BlackHole

-273 KB
Binary file not shown.

BlackHole.exe

-12.7 MB
Binary file not shown.

BlackHole.py

Lines changed: 221 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,29 @@
3232
import secrets # Added for password generator
3333
import string # Added for password generator
3434
import 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+
3558
if 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
218373
SCRIPT_DIR = os.path.dirname(os.path.abspath(sys.argv[0]))
219374
os.chdir(SCRIPT_DIR)
@@ -229,7 +384,7 @@ def _enum_proc(h, lparam):
229384
FONT_ITALIC = os.path.join(SCRIPT_DIR, "Fonts", "Nunito-Italic.ttf")
230385
FONT_SEMIBOLD = os.path.join(SCRIPT_DIR, "Fonts", "Nunito-SemiBold.ttf")
231386
LICENSE_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)
234389
if 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":

Extras/BlackHole.iss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
; Non-commercial use only
44

55
#define MyAppName "Black Hole"
6-
#define MyAppVersion "1.10.1"
6+
#define MyAppVersion "1.10.2"
77
#define MyAppPublisher "Nova Foundry"
88
#define MyAppURL "https://novafoundry.ca"
99
#define MyAppExeName "BlackHole.exe"

Extras/BlackHole.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<project>
22
<shortName>BlackHole</shortName>
33
<fullName>Black Hole Password Manager</fullName>
4-
<version>1.10.1</version>
4+
<version>1.10.2</version>
55
<readmeFile>C:/Users/jackp/Downloads/Linux/BlackHole/README.txt</readmeFile>
66
<licenseFile>C:/Users/jackp/Downloads/Linux/BlackHole/LICENSE.txt</licenseFile>
77
<logoImage>C:/Users/jackp/Downloads/Black Hole Installer.png</logoImage>

LICENSE.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
© Nova Foundry 2025. All rights reserved.
1+
© Nova Foundry 2025-2026. All rights reserved.
22

33
This work is licensed under a Creative Commons Attribution-NoDerivatives 4.0 International License (CC BY-ND 4.0).
44

0 commit comments

Comments
 (0)