fix(repatch): eliminate pop-up cmd windows, race conditions, and update hook crashes
This commit is contained in:
parent
c20abd8790
commit
945302a3cb
3 changed files with 155 additions and 102 deletions
|
|
@ -266,45 +266,3 @@ ipcMain.handle('bcf:info', () => ({
|
||||||
|
|
||||||
console.log('[BetterCF] Main process hooks installed.');
|
console.log('[BetterCF] Main process hooks installed.');
|
||||||
|
|
||||||
// ── Auto-Repatch on Update Interceptor ────────────────────────
|
|
||||||
try {
|
|
||||||
const { spawn } = await import('node:child_process');
|
|
||||||
const patcherExe = join(process.env.LOCALAPPDATA || '', 'BetterCurseForge', 'BetterCurseForge.exe');
|
|
||||||
|
|
||||||
const onUpdateTriggered = () => {
|
|
||||||
try {
|
|
||||||
if (existsSync(patcherExe)) {
|
|
||||||
spawn(patcherExe, ['--wait-for-update', process.pid.toString()], {
|
|
||||||
detached: true,
|
|
||||||
stdio: 'ignore'
|
|
||||||
}).unref();
|
|
||||||
console.log('[BetterCF] Update pending. Spawned patcher --wait-for-update');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[BetterCF] Failed to spawn update patcher:', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. Check if curseforge-updater has a pending update when quitting
|
|
||||||
app.on('before-quit', () => {
|
|
||||||
const pendingInstaller = join(process.env.LOCALAPPDATA || '', 'curseforge-updater', 'pending', 'setup.exe');
|
|
||||||
const directInstaller = join(process.env.LOCALAPPDATA || '', 'curseforge-updater', 'installer.exe');
|
|
||||||
if (existsSync(pendingInstaller) || existsSync(directInstaller)) {
|
|
||||||
onUpdateTriggered();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Ensure watcher daemon is running
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
if (existsSync(patcherExe)) {
|
|
||||||
spawn(patcherExe, ['--ensure-watcher'], {
|
|
||||||
detached: true,
|
|
||||||
stdio: 'ignore'
|
|
||||||
}).unref();
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}, 4000);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[BetterCF] Auto-repatch hook initialization error:', err);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,62 @@ import struct
|
||||||
import shutil
|
import shutil
|
||||||
import hashlib
|
import hashlib
|
||||||
import winreg
|
import winreg
|
||||||
|
import time
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
||||||
RUN_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
RUN_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
||||||
WATCHER_VALUE_NAME = "BetterCurseForgeWatcher"
|
WATCHER_VALUE_NAME = "BetterCurseForgeWatcher"
|
||||||
WATCHER_MUTEX_NAME = "BetterCurseForgeWatcherMutex"
|
WATCHER_MUTEX_NAME = "BetterCurseForgeWatcherMutex"
|
||||||
|
PATCH_MUTEX_NAME = "BetterCurseForgePatchMutex"
|
||||||
PERMANENT_DIR = Path(os.environ.get("LOCALAPPDATA", "")) / "BetterCurseForge"
|
PERMANENT_DIR = Path(os.environ.get("LOCALAPPDATA", "")) / "BetterCurseForge"
|
||||||
PERMANENT_EXE = PERMANENT_DIR / "BetterCurseForge.exe"
|
PERMANENT_EXE = PERMANENT_DIR / "BetterCurseForge.exe"
|
||||||
PERMANENT_BCF = PERMANENT_DIR / "bettercf"
|
PERMANENT_BCF = PERMANENT_DIR / "bettercf"
|
||||||
PERMANENT_VENDOR = PERMANENT_DIR / "vendor"
|
PERMANENT_VENDOR = PERMANENT_DIR / "vendor"
|
||||||
|
|
||||||
|
class GlobalNamedMutex:
|
||||||
|
def __init__(self, name: str, timeout_ms: int = 5000):
|
||||||
|
self.name = name
|
||||||
|
self.timeout_ms = timeout_ms
|
||||||
|
self.handle = None
|
||||||
|
self.acquired = False
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
if os.name != "nt":
|
||||||
|
self.acquired = True
|
||||||
|
return self
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
kernel32.CreateMutexW.restype = ctypes.c_void_p
|
||||||
|
kernel32.CreateMutexW.argtypes = [ctypes.c_void_p, ctypes.c_bool, ctypes.c_wchar_p]
|
||||||
|
kernel32.WaitForSingleObject.restype = ctypes.c_uint
|
||||||
|
kernel32.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_uint]
|
||||||
|
kernel32.ReleaseMutex.argtypes = [ctypes.c_void_p]
|
||||||
|
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
|
||||||
|
|
||||||
|
self.handle = kernel32.CreateMutexW(None, False, self.name)
|
||||||
|
if self.handle:
|
||||||
|
res = kernel32.WaitForSingleObject(self.handle, self.timeout_ms)
|
||||||
|
if res in (0, 0x80): # WAIT_OBJECT_0 or WAIT_ABANDONED
|
||||||
|
self.acquired = True
|
||||||
|
except Exception:
|
||||||
|
self.acquired = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
if os.name == "nt" and self.handle:
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
if self.acquired:
|
||||||
|
kernel32.ReleaseMutex(self.handle)
|
||||||
|
kernel32.CloseHandle(self.handle)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def get_curseforge_dir() -> Path | None:
|
def get_curseforge_dir() -> Path | None:
|
||||||
# 1. Registry lookup
|
# 1. Registry lookup
|
||||||
try:
|
try:
|
||||||
|
|
@ -67,7 +112,11 @@ def get_curseforge_dir() -> Path | None:
|
||||||
|
|
||||||
def kill_curseforge():
|
def kill_curseforge():
|
||||||
try:
|
try:
|
||||||
subprocess.run(["taskkill", "/f", "/im", "CurseForge.exe"], capture_output=True, timeout=10)
|
subprocess.run(
|
||||||
|
["taskkill", "/f", "/im", "CurseForge.exe"],
|
||||||
|
capture_output=True, timeout=10, creationflags=CREATE_NO_WINDOW
|
||||||
|
)
|
||||||
|
time.sleep(0.5)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -97,8 +146,18 @@ def read_exe_hash(exe_path: Path) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_exe_hash(exe_path: Path, new_hash: str) -> bool:
|
def update_exe_hash(exe_path: Path, new_hash: str) -> bool:
|
||||||
with open(exe_path, "rb") as f:
|
"""
|
||||||
data = bytearray(f.read())
|
Safely updates the 64-character ASAR header SHA256 hash inside CurseForge.exe
|
||||||
|
in-place with automatic retry handling for temporary file locks.
|
||||||
|
"""
|
||||||
|
if len(new_hash) != 64:
|
||||||
|
raise ValueError(f"Invalid ASAR hash length: {len(new_hash)}")
|
||||||
|
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(12):
|
||||||
|
try:
|
||||||
|
with open(exe_path, "r+b") as f:
|
||||||
|
data = f.read()
|
||||||
sig = "OWEASARSIG".encode("utf-16le")
|
sig = "OWEASARSIG".encode("utf-16le")
|
||||||
idx = data.rfind(sig)
|
idx = data.rfind(sig)
|
||||||
if idx == -1:
|
if idx == -1:
|
||||||
|
|
@ -110,10 +169,23 @@ def update_exe_hash(exe_path: Path, new_hash: str) -> bool:
|
||||||
raise ValueError('"value":" field not found in OWEASARSIG resource')
|
raise ValueError('"value":" field not found in OWEASARSIG resource')
|
||||||
hash_start = val_idx + 9
|
hash_start = val_idx + 9
|
||||||
old_hash = data[hash_start:hash_start+64].decode("ascii", errors="replace")
|
old_hash = data[hash_start:hash_start+64].decode("ascii", errors="replace")
|
||||||
data[hash_start:hash_start+64] = new_hash.encode("ascii")
|
if old_hash == new_hash:
|
||||||
with open(exe_path, "wb") as f:
|
return True # Already up to date!
|
||||||
f.write(data)
|
f.seek(hash_start)
|
||||||
|
f.write(new_hash.encode("ascii"))
|
||||||
|
f.flush()
|
||||||
return True
|
return True
|
||||||
|
except PermissionError as e:
|
||||||
|
last_err = e
|
||||||
|
kill_curseforge()
|
||||||
|
time.sleep(0.5)
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if last_err:
|
||||||
|
raise last_err
|
||||||
|
return False
|
||||||
|
|
||||||
def get_user_data_dir() -> Path:
|
def get_user_data_dir() -> Path:
|
||||||
appdata = Path(os.environ.get("APPDATA", ""))
|
appdata = Path(os.environ.get("APPDATA", ""))
|
||||||
|
|
@ -231,7 +303,8 @@ def run_asar_tool(args: list[str], cf_dir: Path | None = None) -> tuple[bool, st
|
||||||
text=True,
|
text=True,
|
||||||
shell=True,
|
shell=True,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
timeout=60
|
timeout=60,
|
||||||
|
creationflags=CREATE_NO_WINDOW
|
||||||
)
|
)
|
||||||
if r.returncode == 0:
|
if r.returncode == 0:
|
||||||
return (True, r.stdout.strip())
|
return (True, r.stdout.strip())
|
||||||
|
|
@ -257,9 +330,16 @@ def is_curseforge_patched(cf_dir: Path) -> bool:
|
||||||
return is_asar_patched(asar_path)
|
return is_asar_patched(asar_path)
|
||||||
|
|
||||||
def patch_curseforge(log_callback=print) -> bool:
|
def patch_curseforge(log_callback=print) -> bool:
|
||||||
|
patch_lock = GlobalNamedMutex(PATCH_MUTEX_NAME, timeout_ms=8000)
|
||||||
|
patch_lock.__enter__()
|
||||||
|
if not patch_lock.acquired:
|
||||||
|
log_callback("[!] Another patch operation is already in progress. Skipping.")
|
||||||
|
return False
|
||||||
|
|
||||||
cf_dir = get_curseforge_dir()
|
cf_dir = get_curseforge_dir()
|
||||||
if not cf_dir:
|
if not cf_dir:
|
||||||
log_callback("CurseForge installation directory not found!")
|
log_callback("CurseForge installation directory not found!")
|
||||||
|
patch_lock.__exit__(None, None, None)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
resources_dir = cf_dir / "resources"
|
resources_dir = cf_dir / "resources"
|
||||||
|
|
@ -268,6 +348,7 @@ def patch_curseforge(log_callback=print) -> bool:
|
||||||
|
|
||||||
if not asar_path.exists() or not exe_path.exists():
|
if not asar_path.exists() or not exe_path.exists():
|
||||||
log_callback("CurseForge resources/app.asar or CurseForge.exe missing!")
|
log_callback("CurseForge resources/app.asar or CurseForge.exe missing!")
|
||||||
|
patch_lock.__exit__(None, None, None)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
asar_bak = resources_dir / "app.asar.vanilla"
|
asar_bak = resources_dir / "app.asar.vanilla"
|
||||||
|
|
@ -316,6 +397,7 @@ def patch_curseforge(log_callback=print) -> bool:
|
||||||
ok, err = run_asar_tool(["extract", str(source_asar), str(temp_extract)], cf_dir=cf_dir)
|
ok, err = run_asar_tool(["extract", str(source_asar), str(temp_extract)], cf_dir=cf_dir)
|
||||||
if not ok:
|
if not ok:
|
||||||
log_callback(f"Failed to extract asar: {err}")
|
log_callback(f"Failed to extract asar: {err}")
|
||||||
|
patch_lock.__exit__(None, None, None)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -557,10 +639,10 @@ try {
|
||||||
log_callback(f"Failed to pack asar: {err}")
|
log_callback(f"Failed to pack asar: {err}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 7. Compute new hash and update CurseForge.exe
|
# 7. Compute new hash and update CurseForge.exe directly in-place
|
||||||
new_hash = calc_asar_header_hash(asar_path)
|
new_hash = calc_asar_header_hash(asar_path)
|
||||||
log_callback(f"New ASAR header hash: {new_hash}")
|
log_callback(f"New ASAR header hash: {new_hash}")
|
||||||
# Always restore clean vanilla exe before patching hash
|
if not exe_path.exists() and exe_bak.exists():
|
||||||
shutil.copy2(exe_bak, exe_path)
|
shutil.copy2(exe_bak, exe_path)
|
||||||
update_exe_hash(exe_path, new_hash)
|
update_exe_hash(exe_path, new_hash)
|
||||||
log_callback("[+] Updated CurseForge.exe integrity signature")
|
log_callback("[+] Updated CurseForge.exe integrity signature")
|
||||||
|
|
@ -568,6 +650,7 @@ try {
|
||||||
finally:
|
finally:
|
||||||
if temp_extract.exists():
|
if temp_extract.exists():
|
||||||
shutil.rmtree(temp_extract, ignore_errors=True)
|
shutil.rmtree(temp_extract, ignore_errors=True)
|
||||||
|
patch_lock.__exit__(None, None, None)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ensure_permanent_installation()
|
ensure_permanent_installation()
|
||||||
|
|
@ -626,7 +709,7 @@ def is_curseforge_running() -> bool:
|
||||||
try:
|
try:
|
||||||
out = subprocess.check_output(
|
out = subprocess.check_output(
|
||||||
["tasklist", "/FI", "IMAGENAME eq CurseForge.exe", "/NH"],
|
["tasklist", "/FI", "IMAGENAME eq CurseForge.exe", "/NH"],
|
||||||
text=True, stderr=subprocess.DEVNULL
|
text=True, stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW
|
||||||
)
|
)
|
||||||
return "CurseForge.exe" in out
|
return "CurseForge.exe" in out
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -636,7 +719,7 @@ def is_updater_running() -> bool:
|
||||||
try:
|
try:
|
||||||
out = subprocess.check_output(
|
out = subprocess.check_output(
|
||||||
["tasklist", "/FO", "CSV", "/NH"],
|
["tasklist", "/FO", "CSV", "/NH"],
|
||||||
text=True, stderr=subprocess.DEVNULL
|
text=True, stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW
|
||||||
)
|
)
|
||||||
for line in out.splitlines():
|
for line in out.splitlines():
|
||||||
line = line.strip().lower()
|
line = line.strip().lower()
|
||||||
|
|
@ -717,7 +800,7 @@ def set_autostart_watcher(enable: bool) -> bool:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["schtasks", "/Create", "/TN", "BetterCurseForgeWatcher",
|
["schtasks", "/Create", "/TN", "BetterCurseForgeWatcher",
|
||||||
"/TR", cmd, "/SC", "ONLOGON", "/RL", "LIMITED", "/F"],
|
"/TR", cmd, "/SC", "ONLOGON", "/RL", "LIMITED", "/F"],
|
||||||
capture_output=True, timeout=5
|
capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -728,7 +811,7 @@ def set_autostart_watcher(enable: bool) -> bool:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
subprocess.run(["schtasks", "/Delete", "/TN", "BetterCurseForgeWatcher", "/F"],
|
subprocess.run(["schtasks", "/Delete", "/TN", "BetterCurseForgeWatcher", "/F"],
|
||||||
capture_output=True, timeout=5)
|
capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
winreg.CloseKey(key)
|
winreg.CloseKey(key)
|
||||||
|
|
@ -766,7 +849,7 @@ def start_background_watcher():
|
||||||
try:
|
try:
|
||||||
ps_cmd = f"Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{{CommandLine = '{cmd_str}'}}"
|
ps_cmd = f"Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{{CommandLine = '{cmd_str}'}}"
|
||||||
subprocess.run(["powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_cmd],
|
subprocess.run(["powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_cmd],
|
||||||
capture_output=True, timeout=5)
|
capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
creation_flags = 0x08000000 | 0x00000200
|
creation_flags = 0x08000000 | 0x00000200
|
||||||
|
|
@ -777,9 +860,9 @@ def start_background_watcher():
|
||||||
def stop_background_watcher():
|
def stop_background_watcher():
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["powershell", "-NoProfile", "-Command",
|
["powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command",
|
||||||
"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*--watch*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"],
|
"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*--watch*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"],
|
||||||
capture_output=True, timeout=10
|
capture_output=True, timeout=10, creationflags=CREATE_NO_WINDOW
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -860,7 +943,7 @@ def wait_for_update_and_repatch(pid_to_wait: int | None = None, log_fn=None) ->
|
||||||
try:
|
try:
|
||||||
out = subprocess.check_output(
|
out = subprocess.check_output(
|
||||||
["tasklist", "/FI", f"PID eq {pid_to_wait}", "/NH"],
|
["tasklist", "/FI", f"PID eq {pid_to_wait}", "/NH"],
|
||||||
text=True, stderr=subprocess.DEVNULL
|
text=True, stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW
|
||||||
)
|
)
|
||||||
if str(pid_to_wait) not in out:
|
if str(pid_to_wait) not in out:
|
||||||
break
|
break
|
||||||
|
|
@ -890,7 +973,7 @@ def wait_for_update_and_repatch(pid_to_wait: int | None = None, log_fn=None) ->
|
||||||
if success:
|
if success:
|
||||||
log_fn("★ CurseForge successfully re-patched after update!")
|
log_fn("★ CurseForge successfully re-patched after update!")
|
||||||
log_fn("Launching modded CurseForge...")
|
log_fn("Launching modded CurseForge...")
|
||||||
subprocess.Popen([str(cf_dir / "CurseForge.exe")])
|
subprocess.Popen([str(cf_dir / "CurseForge.exe")], creationflags=CREATE_NO_WINDOW)
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
log_fn("Failed to re-patch CurseForge after update.")
|
log_fn("Failed to re-patch CurseForge after update.")
|
||||||
|
|
@ -957,7 +1040,7 @@ def run_watcher_loop():
|
||||||
wlog("★ CurseForge successfully re-patched after update!")
|
wlog("★ CurseForge successfully re-patched after update!")
|
||||||
if was_running:
|
if was_running:
|
||||||
wlog("Relaunching modded CurseForge...")
|
wlog("Relaunching modded CurseForge...")
|
||||||
subprocess.Popen([str(cf_dir / "CurseForge.exe")])
|
subprocess.Popen([str(cf_dir / "CurseForge.exe")], creationflags=CREATE_NO_WINDOW)
|
||||||
else:
|
else:
|
||||||
wlog("Failed to re-patch CurseForge after update.")
|
wlog("Failed to re-patch CurseForge after update.")
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -396,10 +396,10 @@ class BetterCFApp:
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
arg = sys.argv[1].lower()
|
arg = sys.argv[1].lower()
|
||||||
if arg == "--watch":
|
if arg == "--watch":
|
||||||
# Silent background watcher mode
|
|
||||||
patch_core.run_watcher_loop()
|
patch_core.run_watcher_loop()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
elif arg == "--wait-for-update":
|
elif arg == "--wait-for-update":
|
||||||
|
|
@ -425,3 +425,15 @@ if __name__ == "__main__":
|
||||||
|
|
||||||
app = BetterCFApp()
|
app = BetterCFApp()
|
||||||
app.run()
|
app.run()
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1].lower() in ("--watch", "--wait-for-update", "--ensure-watcher"):
|
||||||
|
try:
|
||||||
|
log_dir = patch_core.get_user_data_dir()
|
||||||
|
with open(log_dir / "watcher.log", "a", encoding="utf-8") as f:
|
||||||
|
f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] [BackgroundError] {e}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue