From 570e51920c7f1fc7768574846493b6bba132bff6 Mon Sep 17 00:00:00 2001 From: PristineStream <129161368+PristineStream@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:21:28 +0800 Subject: [PATCH 1/5] fix: make ffmpeg replacement verifiable and recoverable --- reaplace-ffmpeg.py | 417 +++++++++++++++++++++++++++++++-------------- 1 file changed, 290 insertions(+), 127 deletions(-) diff --git a/reaplace-ffmpeg.py b/reaplace-ffmpeg.py index de55281..02a798a 100644 --- a/reaplace-ffmpeg.py +++ b/reaplace-ffmpeg.py @@ -1,142 +1,305 @@ -#!/usr/bin/python +#!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -@author: Nzix -""" +"""Replace VS Code's trimmed FFmpeg library with the matching Electron build.""" -import os, shutil, platform, subprocess, time -import re, zipfile, json -import ssl +import argparse +import hashlib +import json +import os +import platform +import re +import shutil +import subprocess +import tempfile +import urllib.request +import zipfile +from datetime import datetime -ssl._create_default_https_context = ssl._create_unverified_context -try: - import urllib.request as urllib -except: - import urllib +SYSTEM_NAMES = {"Windows": "win32", "Linux": "linux", "Darwin": "darwin"} +ARCHIVE_LIBS = { + "win32": "ffmpeg.dll", + "linux": "libffmpeg.so", + "darwin": ( + "Electron.app/Contents/Frameworks/Electron Framework.framework/" + "Versions/A/Libraries/libffmpeg.dylib" + ), +} +LOCAL_LIBS = { + "win32": "ffmpeg.dll", + "linux": "libffmpeg.so", + "darwin": ( + "Contents/Frameworks/Electron Framework.framework/" + "Versions/A/Libraries/libffmpeg.dylib" + ), +} -shell = lambda command, cwd = None: subprocess.Popen(command, shell = True, stdout = subprocess.PIPE, cwd = cwd).stdout.read().decode(errors='ignore').strip() +def sha256(path): + digest = hashlib.sha256() + with open(path, "rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def unique_existing_paths(paths): + result = [] + for path in paths: + if path and os.path.exists(path) and path not in result: + result.append(path) + return result + + +def find_installation(system): + override = os.environ.get("VSCODE_INSTALLATION") + possibilities = [override] if override else [] + + if system == "win32": + roots = [ + os.environ.get("PROGRAMW6432"), + os.environ.get("PROGRAMFILES(X86)"), + os.environ.get("PROGRAMFILES"), + ] + possibilities.extend( + os.path.join(root, "Microsoft VS Code") for root in roots if root + ) + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + possibilities.append( + os.path.join(local_app_data, "Programs", "Microsoft VS Code") + ) + elif system == "linux": + code_path = shutil.which("code") + if code_path: + possibilities.append( + os.path.abspath( + os.path.join(os.path.realpath(code_path), os.pardir, os.pardir) + ) + ) + possibilities.extend(["/usr/share/code", "/opt/visual-studio-code"]) + else: + possibilities.extend( + [ + "/Applications/Visual Studio Code.app", + os.path.expanduser("~/Applications/Visual Studio Code.app"), + ] + ) + + installations = unique_existing_paths(possibilities) + if not installations: + raise RuntimeError( + "Visual Studio Code installation was not found. Set " + "VSCODE_INSTALLATION to the editor installation path and retry." + ) + return installations[0] + + +def package_path(installation, system): + if system == "darwin": + return os.path.join( + installation, "Contents", "Resources", "app", "package.json" + ) + return os.path.join(installation, "resources", "app", "package.json") + + +def normalize_electron_version(value): + match = re.search(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", str(value)) + if not match: + raise RuntimeError("Could not determine the Electron version.") + return match.group(0) + + +def detect_architecture(installation, system): + cli_paths = { + "win32": os.path.join(installation, "bin", "code.cmd"), + "linux": os.path.join(installation, "bin", "code"), + "darwin": os.path.join( + installation, "Contents", "Resources", "app", "bin", "code" + ), + } + cli_path = cli_paths[system] + if os.path.exists(cli_path): + command = ( + ["cmd", "/c", cli_path, "--version"] + if system == "win32" + else [cli_path, "--version"] + ) + result = subprocess.run( + command, capture_output=True, text=True, check=False, timeout=20 + ) + for line in reversed(result.stdout.splitlines()): + if line.strip() in ("arm64", "x64", "ia32"): + return line.strip() + + machine = platform.machine().lower() + architecture_map = { + "aarch64": "arm64", + "arm64": "arm64", + "amd64": "x64", + "x86_64": "x64", + "i386": "ia32", + "i686": "ia32", + } + if machine not in architecture_map: + raise RuntimeError("Unsupported architecture: {0}".format(machine)) + return architecture_map[machine] + + +def editor_is_running(installation, system): + if system == "win32": + return False + executable_dir = ( + os.path.join(installation, "Contents", "MacOS") + if system == "darwin" + else installation + ) + result = subprocess.run( + ["pgrep", "-f", executable_dir], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return result.returncode == 0 -def resign_macos_app(application): - """Ad-hoc sign the whole app after replacing a signed dylib.""" - print('re-signing Visual Studio Code for macOS...') - print('warning: this replaces the official app signature with an ad-hoc signature') - command = ['codesign', '--deep', '--force', '--sign', '-', application] - if hasattr(os, 'geteuid') and os.geteuid() != 0: - command.insert(0, 'sudo') +def create_backup(local_lib, product_name, vscode_version): + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + safe_product = re.sub(r"[^0-9A-Za-z._-]+", "-", product_name).strip("-") + backup_dir = os.path.join( + os.path.expanduser("~"), ".touchfish", "ffmpeg-backups" + ) + os.makedirs(backup_dir, exist_ok=True) + backup_path = os.path.join( + backup_dir, + "{0}-{1}-{2}-{3}".format( + safe_product or "vscode", + vscode_version, + timestamp, + os.path.basename(local_lib), + ), + ) + shutil.copy2(local_lib, backup_path) + return backup_path - result = subprocess.run(command) + +def resign_macos_app(application): + print("Re-signing the macOS app with an ad-hoc signature...") + print("Warning: this replaces the editor's official application signature.") + result = subprocess.run( + ["codesign", "--deep", "--force", "--sign", "-", application], + check=False, + ) if result.returncode != 0: raise RuntimeError( - 'codesign failed; run manually: sudo codesign --deep --force ' - '--sign - "{application}"'.format(application=application) + "codesign failed. Check that the editor is closed and that your " + "account can modify the application." ) + subprocess.run( + ["codesign", "--verify", "--deep", "--strict", application], + check=True, + ) + - verify_result = subprocess.run( - ['codesign', '--verify', '--deep', '--strict', application] +def main(): + parser = argparse.ArgumentParser( + description="Install the FFmpeg library matching VS Code's Electron version." ) - if verify_result.returncode != 0: - raise RuntimeError('codesign verification failed') - - print('codesign done') - - -installation = '' -possibilities = [] -electron_temp = 'electron.temp.zip' -system = {'Windows': 'win32', 'Linux': 'linux', 'Darwin': 'darwin'}[platform.system()] -cli = {'win32': 'bin', 'linux': 'bin', 'darwin': 'Contents/Resources/app/bin'} -lib = {'win32': 'ffmpeg.dll', 'linux': 'libffmpeg.so', 'darwin': 'Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Libraries/libffmpeg.dylib'} - -if system == 'win32': - if 'PROGRAMW6432' in os.environ: - possibilities.append(os.environ['PROGRAMW6432']) - if 'PROGRAMFILES(X86)' in os.environ: - possibilities.append(os.environ['PROGRAMFILES(X86)']) - if 'PROGRAMFILES' in os.environ: - possibilities.append(os.environ['PROGRAMFILES']) - if 'LOCALAPPDATA' in os.environ: - possibilities.append(os.path.join(os.environ['LOCALAPPDATA'], 'Programs')) - possibilities = [os.path.join(path, 'Microsoft VS Code') for path in possibilities] - where_code = shell('where code 2> nul').split('\r\n')[0] - if where_code: - possibilities.append(os.path.abspath(os.path.join(os.path.realpath(where_code), os.path.pardir, os.path.pardir))) -elif system == 'linux': - which_code = shell('which code') - if which_code: - possibilities.append(os.path.abspath(os.path.join(os.path.realpath(which_code), os.path.pardir, os.path.pardir))) -elif system == 'darwin': - application = '/Applications/Visual Studio Code.app' - if os.path.exists(application): - possibilities.append(application) - -if not installation: - possibilities = list(set(possibilities)) - for path in possibilities: - if os.path.exists(path): - installation = path - break -assert installation - -vscode_version = shell(('./' if system != 'win32' else '') + 'code -v --user-data-dir="."', os.path.join(installation, cli[system])).split() -print('vscode {version} {arch}'.format(version = vscode_version[0], arch = vscode_version[-1])) - -package_path = os.path.join(installation, 'resources', 'app', 'package.json') -if not os.path.exists(package_path): - for item in os.listdir(installation): - sub_path = os.path.join(installation, item, 'resources', 'app', 'package.json') - if os.path.exists(sub_path): - package_path = sub_path - if system == 'darwin': - installation = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(package_path)))) - else: - installation = os.path.dirname(os.path.dirname(os.path.dirname(package_path))) - break - -try: - with open(package_path, 'r') as f: package_json = json.loads(f.read()) - electron_version = package_json['devDependencies']['electron'] -except: - yarnrc = urllib.urlopen('https://raw.githubusercontent.com/Microsoft/vscode/{version}/.yarnrc'.format(version = vscode_version[0])).read().decode() - electron_version = re.search(r'target "([^"]+)"', yarnrc).group(1) -print('electron {version}'.format(version = electron_version)) - -urllib.urlretrieve('https://cdn.npmmirror.com/binaries/electron/v{version}/electron-v{version}-{system}-{arch}.zip'.format(version = electron_version, system = system, arch = vscode_version[-1]), electron_temp) -print('download well') - -if system == 'win32': - shell('taskkill /f /im code.exe 2> nul') - print('killing code.exe processes...') - time.sleep(2) # Wait for processes to release file handles - -local_lib = os.path.join(installation, lib[system].replace('Electron.app', '.')) - -# Retry loop for file deletion -for i in range(5): + parser.add_argument( + "--check", + action="store_true", + help="compare hashes without modifying the editor", + ) + args = parser.parse_args() + + platform_name = platform.system() + if platform_name not in SYSTEM_NAMES: + raise RuntimeError("Unsupported operating system: {0}".format(platform_name)) + system = SYSTEM_NAMES[platform_name] + installation = find_installation(system) + + metadata_path = package_path(installation, system) + if not os.path.exists(metadata_path): + raise RuntimeError("VS Code package.json was not found at " + metadata_path) + with open(metadata_path, "r", encoding="utf-8") as file: + package_json = json.load(file) + + vscode_version = package_json.get("version", "unknown") + product_name = package_json.get("productName") or package_json.get("name") or "Code" + electron_version = normalize_electron_version( + package_json.get("devDependencies", {}).get("electron", "") + ) + architecture = detect_architecture(installation, system) + local_lib = os.path.join(installation, LOCAL_LIBS[system]) + + if not os.path.exists(local_lib): + raise RuntimeError("Installed FFmpeg library was not found at " + local_lib) + + print("Editor: {0} {1}".format(product_name, vscode_version)) + print("Electron: {0} ({1})".format(electron_version, architecture)) + print("Installation: " + installation) + + download_url = ( + "https://cdn.npmmirror.com/binaries/electron/v{version}/" + "electron-v{version}-{system}-{arch}.zip" + ).format(version=electron_version, system=system, arch=architecture) + + with tempfile.TemporaryDirectory(prefix="touchfish-ffmpeg-") as temp_dir: + archive_path = os.path.join(temp_dir, "electron.zip") + extracted_lib = os.path.join(temp_dir, os.path.basename(local_lib)) + print("Downloading the matching Electron build...") + urllib.request.urlretrieve(download_url, archive_path) + + with zipfile.ZipFile(archive_path) as archive: + with archive.open(ARCHIVE_LIBS[system]) as source, open( + extracted_lib, "wb" + ) as destination: + shutil.copyfileobj(source, destination) + + installed_hash = sha256(local_lib) + expected_hash = sha256(extracted_lib) + print("Installed SHA-256: " + installed_hash) + print("Expected SHA-256: " + expected_hash) + + if installed_hash == expected_hash: + print("FFmpeg is already the matching Electron build; no change needed.") + return 0 + if args.check: + print("FFmpeg does not match. Run the script without --check to replace it.") + return 2 + if editor_is_running(installation, system): + raise RuntimeError( + "The editor is still running. Fully quit all editor windows and " + "background processes, then retry." + ) + + backup_path = create_backup(local_lib, product_name, vscode_version) + print("Backup: " + backup_path) + + try: + shutil.copyfile(extracted_lib, local_lib) + if sha256(local_lib) != expected_hash: + raise RuntimeError("Post-installation hash verification failed.") + if system == "darwin": + resign_macos_app(installation) + except Exception: + print("Replacement failed; restoring the backup...") + shutil.copyfile(backup_path, local_lib) + if system == "darwin": + resign_macos_app(installation) + raise + + print("Replacement and hash verification succeeded.") + print( + "Important: editor updates may restore the bundled FFmpeg. " + "Run this script again if audio disappears after an update." + ) + return 0 + + +if __name__ == "__main__": try: - if os.path.exists(local_lib): - os.remove(local_lib) - break - except PermissionError: - print(f"File locked, retrying ({i+1}/5)...") - time.sleep(1) -else: - print("Could not delete file after retries.") - exit(1) - -try: - with zipfile.ZipFile(electron_temp) as z: - with z.open(lib[system]) as src, open(local_lib, 'wb') as dst: - shutil.copyfileobj(src, dst) - print('replace done') - if system == 'darwin': - resign_macos_app(installation) -except Exception as error: - print(error) - exit(1) -finally: - if os.path.exists(electron_temp): - os.remove(electron_temp) - print('remove temp') + raise SystemExit(main()) + except (OSError, RuntimeError, subprocess.SubprocessError, zipfile.BadZipFile) as error: + print("Error: {0}".format(error)) + raise SystemExit(1) From 915ef631f976ec423b630a415069e9de1a1d5a6a Mon Sep 17 00:00:00 2001 From: PristineStream <129161368+PristineStream@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:21:28 +0800 Subject: [PATCH 2/5] docs: add ffmpeg diagnosis and recovery guide --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e043de2..9656ce7 100644 --- a/README.md +++ b/README.md @@ -143,21 +143,57 @@ https://github.com/user-attachments/assets/9e7ca4b3-e428-4ebf-abed-bbb6bb04530f - 小宇宙支持手机短信验证码登录,无需手动配置 Cookie。 - **X (Twitter)** 需要科学上网,并需配置 Cookie 和 csrf-token 才能使用。 - **标签页切换**: 支持 **Linux.do**(最新/热门/排行榜)、**V2EX**(全部/技术/创意等)、**虎扑**(步行街热帖/主干道等)、**NGA**(网事杂谈/晴风村等)多个平台的栏目切换。 -- **视频播放无声音**:编辑器 内置的 Electron/Chromium 精简版不含部分专利音频解码器,出现“有画面没声音”。 - **一键脚本(替换 ffmpeg.dll)—使用前请确认 Electron 版本并自担风险**: +- **视频有画面但没有声音**:编辑器内置的 Electron/Chromium 精简版可能不含视频所需的 AAC 等专利音频解码器。替换插件本身或调节播放器音量不能解决该问题,需要给**实际运行 TouchFish 的编辑器**安装与其 Electron 版本完全一致的 FFmpeg。 - > Windows PowerShell + > [!WARNING] + > 操作前请完全退出编辑器(包括后台进程)。脚本会修改编辑器安装目录;macOS 还会用 ad-hoc 签名替换应用的官方签名。请确认你理解风险后再执行。重新安装编辑器可以恢复官方文件和签名。 + + **先检查,不修改文件** + + Windows PowerShell: + + ```powershell + Invoke-RestMethod https://raw.githubusercontent.com/ylw1997/touchFish/refs/heads/main/reaplace-ffmpeg.py | python - --check + ``` + + Linux / macOS: + + ```bash + curl https://raw.githubusercontent.com/ylw1997/touchFish/refs/heads/main/reaplace-ffmpeg.py | python3 - --check + ``` + + 检查结果中的 `Installed SHA-256` 和 `Expected SHA-256` 不一致,表示当前媒体库不是与 Electron 匹配的版本。 + + **执行替换** + + Windows PowerShell: ```powershell Invoke-RestMethod https://raw.githubusercontent.com/ylw1997/touchFish/refs/heads/main/reaplace-ffmpeg.py | python ``` - > Linux / macOS + Linux / macOS: ```bash - curl https://raw.githubusercontent.com/ylw1997/touchFish/refs/heads/main/reaplace-ffmpeg.py | python + curl https://raw.githubusercontent.com/ylw1997/touchFish/refs/heads/main/reaplace-ffmpeg.py | python3 ``` + 脚本会自动读取编辑器和 Electron 版本、下载对应架构的官方 Electron 构建、备份原媒体库、替换并比较 SHA-256;macOS 会在替换后重新签名并验证应用。看到 `Replacement and hash verification succeeded.` 才表示替换完成。备份保存在用户目录下的 `.touchfish/ffmpeg-backups`。 + + **替换后仍然没有声音** + + 1. 确认脚本打印的 `Installation` 就是当前打开的编辑器。终端中的 `code` 命令可能指向 Cursor 或其他编辑器,不能据此判断。 + 2. 完全退出并重新启动编辑器,仅执行“重新加载窗口”不够。 + 3. 再运行一次 `--check`;如果两个哈希不同,说明替换没有成功或已失效。 + 4. **编辑器升级会重新写入自带的 FFmpeg**。如果声音在升级后再次消失,请针对新 Electron 版本重新运行脚本。 + 5. 非标准安装路径可在运行脚本时指定。例如 macOS: + + ```bash + curl https://raw.githubusercontent.com/ylw1997/touchFish/refs/heads/main/reaplace-ffmpeg.py | VSCODE_INSTALLATION="/Applications/Visual Studio Code.app" python3 + ``` + + 如果替换或签名失败,脚本会自动恢复备份。无法启动编辑器时,建议重新安装官方版本以恢复原始签名,然后再重新执行上述步骤。 + - **问题反馈**: 如果遇到任何 Bug 或有功能建议,欢迎在 [GitHub Issues](https://github.com/ylw1997/touchFish/issues) 中提出。 - ## 友链 From 7b79b9ebde6d4b16bb382b666f342e44df22d2ad Mon Sep 17 00:00:00 2001 From: PristineStream <129161368+PristineStream@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:14:15 +0800 Subject: [PATCH 3/5] fix: validate editor before replacing ffmpeg --- reaplace-ffmpeg.py | 57 +++++++++++++++++++- tests/test_reaplace_ffmpeg.py | 98 +++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 tests/test_reaplace_ffmpeg.py diff --git a/reaplace-ffmpeg.py b/reaplace-ffmpeg.py index 02a798a..2e39604 100644 --- a/reaplace-ffmpeg.py +++ b/reaplace-ffmpeg.py @@ -3,6 +3,7 @@ """Replace VS Code's trimmed FFmpeg library with the matching Electron build.""" import argparse +import csv import hashlib import json import os @@ -52,8 +53,21 @@ def unique_existing_paths(paths): def find_installation(system): - override = os.environ.get("VSCODE_INSTALLATION") - possibilities = [override] if override else [] + if "VSCODE_INSTALLATION" in os.environ: + override = os.path.abspath( + os.path.expandvars( + os.path.expanduser(os.environ["VSCODE_INSTALLATION"].strip()) + ) + ) + if not os.environ["VSCODE_INSTALLATION"].strip(): + raise RuntimeError("VSCODE_INSTALLATION is set but empty.") + if not os.path.exists(override): + raise RuntimeError( + "VSCODE_INSTALLATION does not exist: {0}".format(override) + ) + return override + + possibilities = [] if system == "win32": roots = [ @@ -148,6 +162,45 @@ def detect_architecture(installation, system): def editor_is_running(installation, system): if system == "win32": + executable_names = [] + try: + executable_names = [ + name + for name in os.listdir(installation) + if name.lower().endswith(".exe") + and name.lower().startswith(("code", "cursor", "vscodium")) + ] + except OSError: + pass + if not executable_names: + executable_names = ["Code.exe"] + + for executable_name in executable_names: + result = subprocess.run( + [ + "tasklist", + "/FI", + "IMAGENAME eq {0}".format(executable_name), + "/FO", + "CSV", + "/NH", + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + "Could not check whether the editor is running: {0}".format( + result.stderr.strip() or "tasklist failed" + ) + ) + rows = csv.reader(result.stdout.splitlines()) + if any( + row and row[0].lower() == executable_name.lower() + for row in rows + ): + return True return False executable_dir = ( os.path.join(installation, "Contents", "MacOS") diff --git a/tests/test_reaplace_ffmpeg.py b/tests/test_reaplace_ffmpeg.py new file mode 100644 index 0000000..de3cfaf --- /dev/null +++ b/tests/test_reaplace_ffmpeg.py @@ -0,0 +1,98 @@ +import importlib.util +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "reaplace-ffmpeg.py" +SPEC = importlib.util.spec_from_file_location("reaplace_ffmpeg", SCRIPT_PATH) +reaplace_ffmpeg = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(reaplace_ffmpeg) + + +class FindInstallationTests(unittest.TestCase): + def test_invalid_explicit_installation_does_not_fall_back(self): + with mock.patch.dict( + os.environ, + {"VSCODE_INSTALLATION": "/does/not/exist"}, + clear=True, + ): + with self.assertRaisesRegex( + RuntimeError, "VSCODE_INSTALLATION does not exist" + ): + reaplace_ffmpeg.find_installation("darwin") + + def test_empty_explicit_installation_is_rejected(self): + with mock.patch.dict( + os.environ, {"VSCODE_INSTALLATION": " "}, clear=True + ): + with self.assertRaisesRegex(RuntimeError, "set but empty"): + reaplace_ffmpeg.find_installation("linux") + + def test_existing_explicit_installation_is_returned(self): + with tempfile.TemporaryDirectory() as installation: + with mock.patch.dict( + os.environ, + {"VSCODE_INSTALLATION": installation}, + clear=True, + ): + self.assertEqual( + reaplace_ffmpeg.find_installation("win32"), + installation, + ) + + +class EditorRunningTests(unittest.TestCase): + def test_windows_detects_running_editor(self): + completed = mock.Mock( + returncode=0, + stdout='"Code.exe","123","Console","1","100,000 K"\n', + stderr="", + ) + with mock.patch.object( + reaplace_ffmpeg.os, "listdir", return_value=["Code.exe"] + ), mock.patch.object( + reaplace_ffmpeg.subprocess, "run", return_value=completed + ) as run: + self.assertTrue( + reaplace_ffmpeg.editor_is_running( + r"C:\Program Files\Microsoft VS Code", "win32" + ) + ) + run.assert_called_once() + self.assertIn("IMAGENAME eq Code.exe", run.call_args.args[0]) + + def test_windows_reports_stopped_editor(self): + completed = mock.Mock( + returncode=0, + stdout="INFO: No tasks are running which match the specified criteria.\n", + stderr="", + ) + with mock.patch.object( + reaplace_ffmpeg.os, "listdir", return_value=["Code.exe"] + ), mock.patch.object( + reaplace_ffmpeg.subprocess, "run", return_value=completed + ): + self.assertFalse( + reaplace_ffmpeg.editor_is_running( + r"C:\Program Files\Microsoft VS Code", "win32" + ) + ) + + def test_windows_tasklist_failure_stops_replacement(self): + completed = mock.Mock(returncode=1, stdout="", stderr="Access denied") + with mock.patch.object( + reaplace_ffmpeg.os, "listdir", return_value=["Code.exe"] + ), mock.patch.object( + reaplace_ffmpeg.subprocess, "run", return_value=completed + ): + with self.assertRaisesRegex(RuntimeError, "Access denied"): + reaplace_ffmpeg.editor_is_running( + r"C:\Program Files\Microsoft VS Code", "win32" + ) + + +if __name__ == "__main__": + unittest.main() From ded20b57aef2b2c8618930016ec8d3e5cd495d5f Mon Sep 17 00:00:00 2001 From: PristineStream <129161368+PristineStream@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:15:15 +0800 Subject: [PATCH 4/5] fix: verify macOS signing and rollback --- reaplace-ffmpeg.py | 53 +++++++++++++---- tests/test_reaplace_ffmpeg.py | 103 ++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 12 deletions(-) diff --git a/reaplace-ffmpeg.py b/reaplace-ffmpeg.py index 2e39604..527e696 100644 --- a/reaplace-ffmpeg.py +++ b/reaplace-ffmpeg.py @@ -239,19 +239,33 @@ def create_backup(local_lib, product_name, vscode_version): def resign_macos_app(application): print("Re-signing the macOS app with an ad-hoc signature...") print("Warning: this replaces the editor's official application signature.") - result = subprocess.run( - ["codesign", "--deep", "--force", "--sign", "-", application], - check=False, - ) + command = ["codesign", "--deep", "--force", "--sign", "-", application] + if hasattr(os, "geteuid") and os.geteuid() != 0: + command.insert(0, "sudo") + + result = subprocess.run(command, check=False) if result.returncode != 0: raise RuntimeError( - "codesign failed. Check that the editor is closed and that your " - "account can modify the application." + "codesign failed. Check that the editor is closed and retry with " + "an account that can use sudo." ) - subprocess.run( + verify_result = subprocess.run( ["codesign", "--verify", "--deep", "--strict", application], - check=True, + check=False, ) + if verify_result.returncode != 0: + raise RuntimeError("codesign verification failed.") + print("Code signature verification succeeded.") + + +def restore_backup(backup_path, local_lib, backup_hash, system, installation): + shutil.copyfile(backup_path, local_lib) + if sha256(local_lib) != backup_hash: + raise RuntimeError("Rollback hash verification failed.") + if system == "darwin": + resign_macos_app(installation) + if sha256(local_lib) != backup_hash: + raise RuntimeError("Rollback hash changed after signing.") def main(): @@ -335,11 +349,26 @@ def main(): raise RuntimeError("Post-installation hash verification failed.") if system == "darwin": resign_macos_app(installation) - except Exception: + except Exception as replacement_error: print("Replacement failed; restoring the backup...") - shutil.copyfile(backup_path, local_lib) - if system == "darwin": - resign_macos_app(installation) + try: + restore_backup( + backup_path, + local_lib, + installed_hash, + system, + installation, + ) + except Exception as rollback_error: + raise RuntimeError( + "Replacement failed ({0}); rollback also failed ({1}). " + "The original backup remains at {2}".format( + replacement_error, + rollback_error, + backup_path, + ) + ) from rollback_error + print("Backup restoration and verification succeeded.") raise print("Replacement and hash verification succeeded.") diff --git a/tests/test_reaplace_ffmpeg.py b/tests/test_reaplace_ffmpeg.py index de3cfaf..c2e9df4 100644 --- a/tests/test_reaplace_ffmpeg.py +++ b/tests/test_reaplace_ffmpeg.py @@ -94,5 +94,108 @@ def test_windows_tasklist_failure_stops_replacement(self): ) +class MacOSSigningTests(unittest.TestCase): + def test_non_root_signing_uses_sudo_and_verifies_signature(self): + success = mock.Mock(returncode=0) + with mock.patch.object( + reaplace_ffmpeg.os, "geteuid", return_value=501, create=True + ), mock.patch.object( + reaplace_ffmpeg.subprocess, + "run", + side_effect=[success, success], + ) as run: + reaplace_ffmpeg.resign_macos_app( + "/Applications/Visual Studio Code.app" + ) + + self.assertEqual(run.call_args_list[0].args[0][0], "sudo") + self.assertEqual( + run.call_args_list[1].args[0][:3], + ["codesign", "--verify", "--deep"], + ) + + def test_root_signing_does_not_use_sudo(self): + success = mock.Mock(returncode=0) + with mock.patch.object( + reaplace_ffmpeg.os, "geteuid", return_value=0, create=True + ), mock.patch.object( + reaplace_ffmpeg.subprocess, + "run", + side_effect=[success, success], + ) as run: + reaplace_ffmpeg.resign_macos_app( + "/Applications/Visual Studio Code.app" + ) + + self.assertEqual(run.call_args_list[0].args[0][0], "codesign") + + def test_signature_verification_failure_is_reported(self): + success = mock.Mock(returncode=0) + failure = mock.Mock(returncode=1) + with mock.patch.object( + reaplace_ffmpeg.os, "geteuid", return_value=501, create=True + ), mock.patch.object( + reaplace_ffmpeg.subprocess, + "run", + side_effect=[success, failure], + ): + with self.assertRaisesRegex( + RuntimeError, "codesign verification failed" + ): + reaplace_ffmpeg.resign_macos_app( + "/Applications/Visual Studio Code.app" + ) + + +class RollbackTests(unittest.TestCase): + def test_rollback_restores_hash_and_resigns_macos_app(self): + with tempfile.TemporaryDirectory() as directory: + backup = os.path.join(directory, "backup.dylib") + installed = os.path.join(directory, "libffmpeg.dylib") + with open(backup, "wb") as file: + file.write(b"original") + with open(installed, "wb") as file: + file.write(b"replacement") + + with mock.patch.object( + reaplace_ffmpeg, "resign_macos_app" + ) as resign: + reaplace_ffmpeg.restore_backup( + backup, + installed, + reaplace_ffmpeg.sha256(backup), + "darwin", + "/Applications/Visual Studio Code.app", + ) + + self.assertEqual( + reaplace_ffmpeg.sha256(installed), + reaplace_ffmpeg.sha256(backup), + ) + resign.assert_called_once_with( + "/Applications/Visual Studio Code.app" + ) + + def test_rollback_hash_mismatch_is_reported(self): + with tempfile.TemporaryDirectory() as directory: + backup = os.path.join(directory, "backup.dll") + installed = os.path.join(directory, "ffmpeg.dll") + with open(backup, "wb") as file: + file.write(b"original") + with open(installed, "wb") as file: + file.write(b"replacement") + + with self.assertRaisesRegex( + RuntimeError, "Rollback hash verification failed" + ): + reaplace_ffmpeg.restore_backup( + backup, + installed, + "not-the-original-hash", + "win32", + r"C:\Program Files\Microsoft VS Code", + ) + + if __name__ == "__main__": unittest.main() From 0ecd4365875eb3f30f1c366ef8cdecdfb1d036be Mon Sep 17 00:00:00 2001 From: PristineStream <129161368+PristineStream@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:47:26 +0800 Subject: [PATCH 5/5] fix: support versioned VS Code layouts on Windows --- reaplace-ffmpeg.py | 37 +++++++++++++++++++++++++++++++++-- tests/test_reaplace_ffmpeg.py | 32 ++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/reaplace-ffmpeg.py b/reaplace-ffmpeg.py index 527e696..0d4c069 100644 --- a/reaplace-ffmpeg.py +++ b/reaplace-ffmpeg.py @@ -65,7 +65,7 @@ def find_installation(system): raise RuntimeError( "VSCODE_INSTALLATION does not exist: {0}".format(override) ) - return override + return resolve_installation_layout(override, system) possibilities = [] @@ -106,7 +106,14 @@ def find_installation(system): "Visual Studio Code installation was not found. Set " "VSCODE_INSTALLATION to the editor installation path and retry." ) - return installations[0] + resolved_installations = [ + resolve_installation_layout(installation, system) + for installation in installations + ] + for installation in resolved_installations: + if os.path.isfile(package_path(installation, system)): + return installation + return resolved_installations[0] def package_path(installation, system): @@ -117,6 +124,32 @@ def package_path(installation, system): return os.path.join(installation, "resources", "app", "package.json") +def resolve_installation_layout(installation, system): + if system != "win32" or os.path.isfile(package_path(installation, system)): + return installation + + try: + children = [ + os.path.join(installation, name) + for name in os.listdir(installation) + ] + except OSError: + return installation + + version_installations = [ + child + for child in children + if os.path.isfile(package_path(child, system)) + and os.path.isfile(os.path.join(child, LOCAL_LIBS[system])) + ] + if not version_installations: + return installation + return max( + version_installations, + key=lambda path: os.path.getmtime(package_path(path, system)), + ) + + def normalize_electron_version(value): match = re.search(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", str(value)) if not match: diff --git a/tests/test_reaplace_ffmpeg.py b/tests/test_reaplace_ffmpeg.py index c2e9df4..6984a56 100644 --- a/tests/test_reaplace_ffmpeg.py +++ b/tests/test_reaplace_ffmpeg.py @@ -43,6 +43,38 @@ def test_existing_explicit_installation_is_returned(self): installation, ) + def test_windows_version_subdirectory_is_resolved_as_installation(self): + with tempfile.TemporaryDirectory() as installation: + version_installation = os.path.join(installation, "1b6a188127") + metadata = os.path.join( + version_installation, "resources", "app", "package.json" + ) + os.makedirs(os.path.dirname(metadata)) + with open(metadata, "w", encoding="utf-8") as file: + file.write("{}") + local_lib = os.path.join(version_installation, "ffmpeg.dll") + with open(local_lib, "wb") as file: + file.write(b"ffmpeg") + + with mock.patch.dict( + os.environ, + {"VSCODE_INSTALLATION": installation}, + clear=True, + ): + resolved = reaplace_ffmpeg.find_installation("win32") + + self.assertEqual(resolved, version_installation) + self.assertEqual( + reaplace_ffmpeg.package_path(resolved, "win32"), + metadata, + ) + self.assertEqual( + os.path.join( + resolved, reaplace_ffmpeg.LOCAL_LIBS["win32"] + ), + local_lib, + ) + class EditorRunningTests(unittest.TestCase): def test_windows_detects_running_editor(self):