From 09674e14d56c97cfd5135bd8059e095209137f23 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 8 Oct 2026 11:02:31 +0900 Subject: [PATCH 1/6] feat: build and release Windows and macOS from one codebase Use one product name on both platforms, make the protoc script work from sh and cmd.exe, and split the workflows into a reusable build that tests and packages on Windows and macOS, a CI run for pull requests, and a tag release that publishes both platforms together. CI also starts the packaged app, checks that it does not multiply its processes, and closes it to confirm every process exits. --- .github/scripts/smoke_frozen.py | 87 +++++++++++++++++++++++++++++++ .github/workflows/build.yml | 73 ++++++++++++++++++++++++++ .github/workflows/ci.yml | 21 ++++++++ .github/workflows/release.yml | 56 ++++++++++++-------- gfl2logger.spec | 8 +-- gfl2logger/gui/window.py | 2 +- pyproject.toml | 10 ++-- tests/test_gui_smoke.py | 42 +++++++++++++++ tests/test_payload_coverage.py | 51 ++++++++++++++++++ tests/test_release_consistency.py | 79 ++++++++++++++++++++++++++++ tests/test_robustness.py | 4 +- 11 files changed, 398 insertions(+), 35 deletions(-) create mode 100644 .github/scripts/smoke_frozen.py create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml create mode 100644 tests/test_gui_smoke.py create mode 100644 tests/test_payload_coverage.py create mode 100644 tests/test_release_consistency.py diff --git a/.github/scripts/smoke_frozen.py b/.github/scripts/smoke_frozen.py new file mode 100644 index 0000000..5134337 --- /dev/null +++ b/.github/scripts/smoke_frozen.py @@ -0,0 +1,87 @@ +"""Start the packaged app, check that it stays up without multiplying, then close it. + +Used by CI. On Windows the window is asked to close the way a user would close it; +elsewhere the app receives SIGTERM. Either way every process has to be gone afterwards. +""" + +import subprocess +import sys +import tempfile +import time + +STARTUP_SECONDS = 25 +SHUTDOWN_SECONDS = 60 +# The proxy, the window process and their launcher/helper processes. A frozen build that +# re-launches itself during multiprocessing start-up blows far past this. +MAX_PROCESSES = 8 +WINDOWS = sys.platform == "win32" + + +def running_processes() -> int: + if WINDOWS: + listing = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq gfl2logger.exe", "/FO", "CSV", "/NH"], + capture_output=True, + text=True, + ).stdout + return sum( + line.lower().startswith('"gfl2logger.exe"') for line in listing.splitlines() + ) + listing = subprocess.run( + ["pgrep", "-f", "gfl2logger.app/Contents/MacOS/gfl2logger"], + capture_output=True, + text=True, + ).stdout + return len(listing.split()) + + +def force_stop(process: subprocess.Popen) -> None: + if WINDOWS: + subprocess.run( + ["taskkill", "/F", "/T", "/IM", "gfl2logger.exe"], capture_output=True + ) + else: + subprocess.run( + ["pkill", "-9", "-f", "gfl2logger.app/Contents/MacOS/gfl2logger"], + capture_output=True, + ) + process.wait(timeout=30) + + +def main(executable: str) -> int: + # Windows builds write exports and settings to the working directory. + workdir = tempfile.mkdtemp(prefix="gfl2logger-smoke-") + process = subprocess.Popen([executable], cwd=workdir) + + time.sleep(STARTUP_SECONDS) + started = running_processes() + print(f"processes after {STARTUP_SECONDS}s: {started}") + if process.poll() is not None: + print(f"FAIL: exited during start-up with code {process.returncode}") + return 1 + if not 2 <= started <= MAX_PROCESSES: + print(f"FAIL: expected 2..{MAX_PROCESSES} processes") + force_stop(process) + return 1 + + if WINDOWS: + # Without /F this posts WM_CLOSE to the window, like clicking its close button. + subprocess.run(["taskkill", "/IM", "gfl2logger.exe"], capture_output=True) + else: + process.terminate() + + deadline = time.monotonic() + SHUTDOWN_SECONDS + while time.monotonic() < deadline and running_processes(): + time.sleep(1) + remaining = running_processes() + if remaining: + print(f"FAIL: {remaining} processes still running after closing") + force_stop(process) + return 1 + + print("OK: started, stayed bounded, and exited completely when closed") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1])) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..f71d28a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,73 @@ +name: build + +# Reusable build: runs the tests and packages the app on every supported platform. +on: + workflow_call: + inputs: + version-label: + description: Text placed in artifact file names, such as a release tag. + required: true + type: string + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + platform: windows-x64 + - os: macos-15 + platform: macos-arm64 + runs-on: ${{ matrix.os }} + env: + VERSION_LABEL: ${{ inputs.version-label }} + steps: + - uses: actions/checkout@v4 + - uses: pdm-project/setup-pdm@v4 + with: + cache: true + - run: pdm install + # The tests import the generated protobuf modules, which are not checked in. + - run: pdm run protoc + - name: Run tests + run: pdm run python -m unittest discover -s tests -v + env: + # Hosted runners have a desktop session, so the real window can be exercised. + GFL2LOGGER_GUI_TESTS: "1" + - run: pdm run pyinstaller + - name: Start and close the packaged app + shell: bash + run: | + if [ "$RUNNER_OS" = "Windows" ]; then + pdm run python .github/scripts/smoke_frozen.py dist/gfl2logger.exe + else + pdm run python .github/scripts/smoke_frozen.py dist/gfl2logger.app/Contents/MacOS/gfl2logger + fi + - name: Package Windows artifact + if: runner.os == 'Windows' + shell: bash + run: | + pdm run pyi-archive_viewer -l dist/gfl2logger.exe > archive-contents.txt + grep -qi "mitmproxy_windows" archive-contents.txt + mkdir -p release + cp dist/gfl2logger.exe "release/gfl2logger-${VERSION_LABEL}-windows-x64.exe" + - name: Package macOS artifacts + if: runner.os == 'macOS' + run: | + test -e "dist/gfl2logger.app/Contents/Resources/mitmproxy_macos/Mitmproxy Redirector.app.tar" + codesign --verify --deep --strict dist/gfl2logger.app + mkdir -p release dist/dmg-root + (cd dist && COPYFILE_DISABLE=1 zip -qry "../release/gfl2logger-${VERSION_LABEL}-macos-arm64.zip" gfl2logger.app -x '*.DS_Store' -x '__MACOSX/*') + cp -R dist/gfl2logger.app dist/dmg-root/ + ln -s /Applications dist/dmg-root/Applications + hdiutil create -volname gfl2logger -srcfolder dist/dmg-root -ov -format UDZO "release/gfl2logger-${VERSION_LABEL}-macos-arm64.dmg" + - uses: actions/upload-artifact@v4 + with: + name: gfl2logger-${{ matrix.platform }} + path: release/* + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dfd57f7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: ci + +on: + pull_request: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + uses: ./.github/workflows/build.yml + with: + version-label: ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90807a6..7d142f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,37 +6,47 @@ on: - "v*.*.*" permissions: - contents: write + contents: read jobs: - release-macos: - runs-on: macos-15 + verify-version: + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pdm-project/setup-pdm@v4 + # The app reports the version from version.py, so it has to match the tag. + - run: | + tag="${GITHUB_REF_NAME}" + grep -qx "VERSION = \"${tag#v}\"" gfl2logger/utils/version.py + + build: + needs: verify-version + uses: ./.github/workflows/build.yml + with: + version-label: ${{ github.ref_name }} + + publish: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: - cache: true - - run: pdm install - # The tests import the generated protobuf modules, which are not checked in. - - run: pdm run protoc - - run: pdm run python -m unittest discover -s tests -v - - run: pdm run pyinstaller - - name: Package macOS artifacts - run: | - cd dist - COPYFILE_DISABLE=1 zip -qry "gf2logger-${GITHUB_REF_NAME}-macos-arm64.zip" gf2logger.app -x '*.DS_Store' -x '__MACOSX/*' - cd .. - mkdir -p dist/dmg-root - cp -R dist/gf2logger.app dist/dmg-root/ - ln -s /Applications dist/dmg-root/Applications - hdiutil create -volname gf2logger -srcfolder dist/dmg-root -ov -format UDZO "dist/gf2logger-${GITHUB_REF_NAME}-macos-arm64.dmg" + path: release + merge-multiple: true + # RELEASE_NOTES.md links to the downloads relative to the repository so the file + # works in any fork; a release body needs absolute links. + - name: Point release note links at this repository + run: sed "s#](\.\./\.\./#](https://github.com/${GITHUB_REPOSITORY}/#g" RELEASE_NOTES.md > release-body.md - uses: softprops/action-gh-release@v2 with: - name: gfl2logger for macOS ${{ github.ref_name }} + name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }} - body_path: RELEASE_NOTES.md + body_path: release-body.md files: | - dist/gf2logger-${{ github.ref_name }}-macos-arm64.zip - dist/gf2logger-${{ github.ref_name }}-macos-arm64.dmg + release/gfl2logger-${{ github.ref_name }}-windows-x64.exe + release/gfl2logger-${{ github.ref_name }}-macos-arm64.zip + release/gfl2logger-${{ github.ref_name }}-macos-arm64.dmg overwrite_files: false fail_on_unmatched_files: true diff --git a/gfl2logger.spec b/gfl2logger.spec index 755acbf..f3d2161 100644 --- a/gfl2logger.spec +++ b/gfl2logger.spec @@ -37,7 +37,7 @@ if is_macos: a.scripts, [], exclude_binaries=True, - name='gf2logger', + name='gfl2logger', debug=False, bootloader_ignore_signals=False, strip=False, @@ -57,15 +57,15 @@ if is_macos: strip=False, upx=True, upx_exclude=[], - name='gf2logger', + name='gfl2logger', ) app = BUNDLE( coll, - name='gf2logger.app', + name='gfl2logger.app', icon='embed/icon.png', bundle_identifier='org.gf2logger.app', info_plist={ - 'CFBundleDisplayName': 'gf2logger', + 'CFBundleDisplayName': 'gfl2logger', 'CFBundleShortVersionString': version, 'CFBundleVersion': version, }, diff --git a/gfl2logger/gui/window.py b/gfl2logger/gui/window.py index 90deca3..f69d760 100644 --- a/gfl2logger/gui/window.py +++ b/gfl2logger/gui/window.py @@ -31,7 +31,7 @@ def __init__(self, from_gui: "multiprocessing.Queue[Command]"): pass super().__init__() - self.title("gf2logger") + self.title("gfl2logger") self.minsize(600, 240) if ICON_PNG is not None: diff --git a/pyproject.toml b/pyproject.toml index 4ff5b38..ae7e3c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,11 +18,11 @@ source = "call" getter = "gfl2logger.utils.version:get_version" [tool.pdm.scripts] -_.env = { PROTO_DIR = "proto", GENERATED_DIR = "generated", PROTO_FILES = "proto/*.proto" } -pre_protoc.call = "os:makedirs('${GENERATED_DIR}', exist_ok=True)" -# Run through a shell: protoc only expands the proto/*.proto wildcard itself on Windows, -# so on macOS the shell has to do it. -protoc = { shell = "python -m grpc_tools.protoc --proto_path=${PROTO_DIR} --python_out=${GENERATED_DIR} --pyi_out=${GENERATED_DIR} ${PROTO_FILES}" } +pre_protoc.call = "os:makedirs('generated', exist_ok=True)" +# Run through a shell with literal paths. POSIX shells expand the wildcard; cmd.exe +# passes it through and protoc expands it itself on Windows. PDM does not substitute +# ${VAR} in shell scripts, so the paths are written out instead of using env entries. +protoc = { shell = "python -m grpc_tools.protoc --proto_path=proto --python_out=generated --pyi_out=generated proto/*.proto" } pre_pyinstaller = "pdm run protoc" pyinstaller = "pyinstaller --clean gfl2logger.spec" diff --git a/tests/test_gui_smoke.py b/tests/test_gui_smoke.py new file mode 100644 index 0000000..00e8650 --- /dev/null +++ b/tests/test_gui_smoke.py @@ -0,0 +1,42 @@ +import multiprocessing +import os +import time +import unittest + +from gfl2logger.gui import window +from gfl2logger.gui.command import Command, CommandType + + +@unittest.skipUnless( + os.environ.get("GFL2LOGGER_GUI_TESTS") == "1", + "opens a real window; set GFL2LOGGER_GUI_TESTS=1 to run", +) +class WindowProcessTests(unittest.TestCase): + def test_window_process_applies_commands_and_exits_on_shutdown(self) -> None: + from_gui: multiprocessing.Queue = multiprocessing.Queue() + to_gui: multiprocessing.Queue = multiprocessing.Queue(maxsize=2048) + process = multiprocessing.Process( + target=window.draw_window, + args=(from_gui, to_gui), + daemon=True, + ) + process.start() + try: + for index in range(300): + to_gui.put(Command(CommandType.LOG, f"line {index}\nsecond row")) + to_gui.put(Command(CommandType.OPTIONS, {"gfl2_weapons": False})) + time.sleep(3) + self.assertTrue(process.is_alive(), "window process exited early") + + to_gui.put(Command(CommandType.SHUTDOWN, None)) + process.join(20) + + self.assertEqual(process.exitcode, 0) + finally: + if process.is_alive(): + process.terminate() + process.join(5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_payload_coverage.py b/tests/test_payload_coverage.py new file mode 100644 index 0000000..43189be --- /dev/null +++ b/tests/test_payload_coverage.py @@ -0,0 +1,51 @@ +import unittest + +from gfl2logger.gfl2.data import DATA_TYPES, get_options +from gfl2logger.proxy.capture import PROCESS_NAMES + +# Payload type -> (option name, group shown in the window, export format). +EXPECTED = { + 21905: ("gfl2_platoonprofile", "Platoon", "json"), + 21917: ("gfl2_guildmembers", "Platoon", "csv"), + 21935: ("gfl2_platoonactivity", "Platoon", "json"), + 21960: ("gfl2_platoonupdates", "Platoon", "json"), + 11021: ("gfl2_weapons", "Others", "csv"), + 11061: ("gfl2_attachments", "Others", "csv"), + 11138: ("gfl2_commonkeys", "Others", "csv"), + 23201: ("gfl2_formations", "Others", "json"), +} + + +class PayloadCoverageTests(unittest.TestCase): + """The same payloads, options and groups must exist on every platform.""" + + def test_every_supported_payload_is_registered(self) -> None: + self.assertEqual(set(DATA_TYPES), set(EXPECTED)) + + def test_each_payload_has_its_option_and_group(self) -> None: + for payload_type, (name, category, _format) in EXPECTED.items(): + with self.subTest(payload_type=payload_type): + (option,) = DATA_TYPES[payload_type].OPTIONS + self.assertEqual(option["name"], name) + self.assertEqual(option["category"], category) + self.assertIs(option["default"], True) + + def test_each_payload_exports_in_its_documented_format(self) -> None: + for payload_type, (_name, _category, export_format) in EXPECTED.items(): + with self.subTest(payload_type=payload_type): + self.assertTrue( + callable(getattr(DATA_TYPES[payload_type], f"to_{export_format}")) + ) + + def test_window_lists_platoon_options_before_the_others(self) -> None: + categories = [option["category"] for option in get_options()] + self.assertEqual(categories, ["Platoon"] * 4 + ["Others"] * 4) + + def test_capture_is_scoped_to_the_game_process_on_both_platforms(self) -> None: + self.assertEqual( + PROCESS_NAMES, {"Windows": "GF2_Exilium", "Darwin": "SnqxExilium"} + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_consistency.py b/tests/test_release_consistency.py new file mode 100644 index 0000000..1e768d8 --- /dev/null +++ b/tests/test_release_consistency.py @@ -0,0 +1,79 @@ +import re +import unittest +from pathlib import Path + +from gfl2logger.utils.version import VERSION + +TAG = f"v{VERSION}" +ASSETS = ( + f"gfl2logger-{TAG}-windows-x64.exe", + f"gfl2logger-{TAG}-macos-arm64.dmg", + f"gfl2logger-{TAG}-macos-arm64.zip", +) + + +def read(path: str) -> str: + return Path(path).read_text(encoding="utf-8") + + +class ReleaseConsistencyTests(unittest.TestCase): + def test_documents_link_every_asset_of_the_current_version(self) -> None: + for document in ("README.md", "README_KR.md", "RELEASE_NOTES.md"): + text = read(document) + for asset in ASSETS: + with self.subTest(document=document, asset=asset): + self.assertIn( + f"(../../releases/download/{TAG}/{asset})", + text, + ) + + def test_documents_do_not_link_assets_of_other_versions(self) -> None: + for document in ("README.md", "README_KR.md", "RELEASE_NOTES.md"): + linked = set(re.findall(r"releases/download/(v[\d.]+)/", read(document))) + with self.subTest(document=document): + self.assertEqual(linked, {TAG}) + + def test_release_notes_are_for_the_current_version(self) -> None: + self.assertTrue(read("RELEASE_NOTES.md").startswith(f"# gfl2logger {TAG}\n")) + + def test_workflows_build_the_documented_asset_names(self) -> None: + build = read(".github/workflows/build.yml") + release = read(".github/workflows/release.yml") + for asset in ASSETS: + suffix = asset.removeprefix(f"gfl2logger-{TAG}") + with self.subTest(asset=asset): + self.assertIn(f"gfl2logger-${{VERSION_LABEL}}{suffix}", build) + self.assertIn( + f"gfl2logger-${{{{ github.ref_name }}}}{suffix}", release + ) + + def test_workflow_expressions_never_reach_a_shell_script(self) -> None: + # Values such as the tag name are passed through inputs and environment + # variables; expanding ${{ }} inside a run script would allow injection. + allowed = ( + "runs-on:", + "name:", + "tag_name:", + "version-label:", + "VERSION_LABEL:", + "group:", + "release/gfl2logger-", + ) + for workflow in ("build.yml", "ci.yml", "release.yml"): + for line in read(f".github/workflows/{workflow}").splitlines(): + if "${{" in line: + with self.subTest(workflow=workflow, line=line.strip()): + self.assertTrue(line.strip().startswith(allowed)) + + def test_both_platforms_are_built_from_one_product_name(self) -> None: + spec = read("gfl2logger.spec") + self.assertNotIn("gf2logger'", spec.replace("org.gf2logger.app", "")) + self.assertIn("name='gfl2logger.app'", spec) + + build = read(".github/workflows/build.yml") + self.assertIn("windows-latest", build) + self.assertIn("macos-15", build) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_robustness.py b/tests/test_robustness.py index 9de48de..06cce7f 100644 --- a/tests/test_robustness.py +++ b/tests/test_robustness.py @@ -324,9 +324,9 @@ def is_alive(self) -> bool: ctx.master.shutdown.assert_called_once() -class ReleaseWorkflowOrderTests(unittest.TestCase): +class BuildWorkflowOrderTests(unittest.TestCase): def test_protobuf_modules_are_generated_before_the_tests_run(self) -> None: - workflow = Path(".github/workflows/release.yml").read_text(encoding="utf-8") + workflow = Path(".github/workflows/build.yml").read_text(encoding="utf-8") self.assertLess( workflow.index("pdm run protoc"), From 982162bee778e57f213c578be00f5b4af8b1de44 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 8 Oct 2026 11:02:31 +0900 Subject: [PATCH 2/6] chore: prepare v0.3.0 release Rewrite the READMEs for the combined Windows and macOS project and align the macOS version with the Windows numbering. --- README.md | 196 +++++++++++++++++++----------------- README_KR.md | 157 +++++++++++++++++------------ RELEASE_NOTES.md | 76 +++++++++----- gfl2logger/utils/version.py | 2 +- 4 files changed, 245 insertions(+), 186 deletions(-) diff --git a/README.md b/README.md index 6237452..96937cc 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,135 @@ -# gfl2logger for macOS +# gfl2logger English | [한국어](README_KR.md) -> [!IMPORTANT] -> This repository is a macOS-only fork of -> [blead/gfl2logger](https://github.com/blead/gfl2logger). Windows users should -> download and use the [original Windows version](https://github.com/blead/gfl2logger/releases). - -`gfl2logger` captures Girls' Frontline 2: Exilium network payloads and exports -platoon-management data to local CSV and JSON files. This fork supports the -iPhone/iPad App Store version of GF2 running on an Apple-silicon Mac. - -## Notice - -> [!WARNING] -> `gf2logger` is currently ad-hoc signed and is not yet notarized with an Apple -> Developer ID. On first launch, macOS may display a **“gf2logger” Not Opened** -> alert stating that Apple could not verify the app is free of malware. Only -> override this warning if you downloaded the app from this repository's -> [official Releases page](https://github.com/1window2/gf2logger/releases) and -> trust the source. -> -> To allow the app to run: -> -> 1. Try to open `gf2logger.app`, then click **Done** when the warning appears. -> 2. Open **Apple menu > System Settings > Privacy & Security**. -> 3. Scroll down to **Security**, find the message that `gf2logger` was blocked, -> and click **Open Anyway**. -> 4. Authenticate with your login password or Touch ID, then click **Open** when -> macOS asks for confirmation again. -> -> The **Open Anyway** button is available for about one hour after the blocked -> launch attempt. Once approved, macOS saves the app as an exception and future -> launches work normally. See -> [Apple's official instructions](https://support.apple.com/guide/mac-help/MCHLEAB3A043/26/mac/26.6.2) -> for additional details. - -## Requirements - -- An Apple-silicon Mac -- The iPhone/iPad version of Girls' Frontline 2: Exilium installed from the Mac - App Store -- Permission to enable the bundled Mitmproxy Redirector Network Extension - -## Installation - -1. Download `gf2logger-v0.1.2-macos-arm64.zip` or - `gf2logger-v0.1.2-macos-arm64.dmg` from - [Releases](https://github.com/1window2/gf2logger/releases). -2. For the ZIP, unzip it and Control-click `gf2logger.app`. For the DMG, open - it, drag `gf2logger.app` to **Applications**, and Control-click the installed - app. -3. Choose **Open**, then confirm **Open** in the Gatekeeper dialog. -4. Approve **Mitmproxy Redirector** when macOS requests Network Extension - permission. - -If the redirector is not enabled automatically, open **System Settings > -General > Login Items & Extensions > Network Extensions**, enable **Mitmproxy -Redirector**, and relaunch `gfl2logger`. - -The application itself is currently ad-hoc signed rather than Developer -ID-signed and notarized. Its bundled network redirector is separately signed -and notarized by the mitmproxy project. +`gfl2logger` reads the data that Girls' Frontline 2: Exilium receives from its +servers and saves the parts worth keeping, such as Platoon rosters, activity, +and your inventory, to local CSV and JSON files. It runs on **Windows** and +**macOS** from one codebase, with the same features, options, and window on +both. + +## Download + +| Platform | Game client | Download (v0.3.0) | +| --- | --- | --- | +| **Windows** (x64) | PC client | [`gfl2logger-v0.3.0-windows-x64.exe`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-windows-x64.exe) | +| **macOS** (Apple silicon) | iPhone/iPad version from the Mac App Store | [`gfl2logger-v0.3.0-macos-arm64.dmg`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-macos-arm64.dmg) · [`gfl2logger-v0.3.0-macos-arm64.zip`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-macos-arm64.zip) | + +Older versions and release notes are on the [Releases](../../releases) page. + +## Getting Started + +Start `gfl2logger` **before** the game. If the game is already running, restart +it once the logger window is open. + +### Windows + +1. Download the `.exe` and place it in a folder you can write to. Exported + files are saved in the folder the program is started from. +2. Run it. The executable is not code-signed, so Windows SmartScreen may show + **Windows protected your PC**. If you downloaded it from this repository's + Releases page, choose **More info > Run anyway**. +3. If Windows asks for administrator permission for mitmproxy's traffic + redirector, allow it. + +### macOS + +1. Open the `.dmg` and drag `gfl2logger.app` to **Applications**, or unzip the + `.zip`. +2. Open the app. It is ad-hoc signed and not notarized with an Apple Developer + ID, so the first launch is blocked with a **“gfl2logger” Not Opened** alert. + Click **Done**, open **System Settings > Privacy & Security**, scroll to + **Security**, and click **Open Anyway**. The button is available for about + an hour after the blocked attempt, and macOS remembers the exception + afterwards. See + [Apple's instructions](https://support.apple.com/guide/mac-help/MCHLEAB3A043/26/mac/26.6.2) + for details. Only do this for a copy downloaded from this repository's + Releases page. +3. Approve **Mitmproxy Redirector** when macOS asks for Network Extension + permission. If it is not enabled automatically, turn it on under **System + Settings > General > Login Items & Extensions > Network Extensions** and + relaunch `gfl2logger`. + +The bundled network redirector is signed and notarized separately by the +mitmproxy project. ## Usage -1. Start `gfl2logger` before GF2. If GF2 is already running, restart it after - the logger is ready. -2. Leave the desired payload checkboxes enabled. -3. Start GF2 and open its Platoon pages to request the relevant data. +1. Leave the checkboxes for the data you want enabled. **Save config** keeps + the selection for the next launch. +2. Start the game and log in. Open the Platoon pages to request Platoon data. +3. Each time the game receives one of the supported responses, a new file is + written and the log on the right shows its path. -Exports and `gfl2logger.config.yaml` are written to `~/gfl2logger`. +| Platform | Exports and `gfl2logger.config.yaml` are saved in | +| --- | --- | +| Windows | The folder the program is started from, normally the one containing the `.exe` | +| macOS | `~/gfl2logger` | -The checkboxes are organized into two groups: +### Exported Data -- **Platoon:** Platoon Profile, Members, Activity, Updates -- **Others:** Weapons, Attachments, Common Keys, Formations +The options are organized into two groups in the window. -### Exported Data +| Group | Option | Payload | Description | Received on | Format | +| --- | --- | ---: | --- | --- | --- | +| Platoon | Platoon Profile | `21905` | Platoon identity, level, member count, announcements, and recruitment information | Platoon pages | JSON | +| Platoon | Members | `21917` | Member names, UIDs, levels, merit points, scores, and login times | Login, reconnection, Platoon pages | CSV | +| Platoon | Activity | `21935` | Platoon objectives and recent member activity | Platoon pages | JSON | +| Platoon | Updates | `21960` | The Updates tab: member joins, withdrawals, removals, and Daily supply reward triggers | Updates tab | JSON | +| Others | Weapons | `11021` | Weapons owned by the current account | Login | CSV | +| Others | Attachments | `11061` | Attachments owned by the current account | Login | CSV | +| Others | Common Keys | `11138` | Common Keys owned by the current account | Login | CSV | +| Others | Formations | `23201` | Saved formations | Login, reconnection | JSON | -| Option | Payload | Description | Format | -| --- | ---: | --- | --- | -| Platoon Profile | `21905` | Platoon identity, level, member count, announcements, and recruitment information | JSON | -| Members | `21917` | Member names, UIDs, levels, merit points, scores, and login times | CSV | -| Activity | `21935` | Platoon objectives and recent member activity | JSON | -| Updates | `21960` | Records the Updates tab, including member joins/withdrawals/removals, and Daily supply rewards trigger | JSON | -| Weapons | `11021` | Weapons owned by the current account | CSV | -| Attachments | `11061` | Attachments owned by the current account | CSV | -| Common Keys | `11138` | Common Keys owned by the current account | CSV | -| Formations | `23201` | Saved formations | JSON | +Files are named `gfl2logger__.csv` or `.json`. Updates are +recorded losslessly by protobuf field number and raw hex. ## How It Works -Mitmproxy's local capture mode restricts interception to the GF2 executable, -`SnqxExilium`. The logger reads server-to-client payloads without initiating or -modifying game connections. TLS connections are passed through without -decryption, so no mitmproxy certificate needs to be installed. +mitmproxy's local capture mode limits interception to the game executable: +`GF2_Exilium` on Windows and `SnqxExilium` on macOS. The logger only reads what +the server sends to the client. It does not start or change any connection, and +TLS connections pass through without being decrypted, so no certificate has to +be installed. + +## Supported Clients + +- **Windows:** the PC client, confirmed to work in the Darkwinter and HaoPlay + regions. +- **macOS:** the iPhone/iPad App Store version running on an Apple-silicon Mac. + +Other platforms and regions are untested. Suggestions about additional data, +usage, and formats are welcome. ## Build from Source -The project uses [PDM](https://pdm-project.org/) and Python 3.13: +The project uses [PDM](https://pdm-project.org/) and Python 3.13. From a clone +of this repository: ```sh -git clone https://github.com/1window2/gf2logger.git -cd gf2logger pdm install pdm run pyinstaller ``` -The Apple-silicon build is created at `dist/gf2logger.app`. To run directly -from source: +This creates `dist/gfl2logger.exe` on Windows and `dist/gfl2logger.app` on +macOS. To run from source or run the tests: ```sh pdm run protoc pdm run python main.py +pdm run python -m unittest discover -s tests ``` +Pushing a `v*.*.*` tag builds both platforms and publishes them as one release. + ## Credits -The packet parser, data exporters, and original Windows application were -created by [blead](https://github.com/blead) in -[blead/gfl2logger](https://github.com/blead/gfl2logger). This fork adds the -macOS capture path, packaging, platform-specific UI fixes, and expanded platoon -payload support. +`gfl2logger` was created by [blead](https://github.com/blead): the packet +parser, the data exporters, and the original Windows application. macOS +support, the Platoon Profile, Activity, and Updates exports, and the parser and +export hardening were developed in the +[1window2/gf2logger](https://github.com/1window2/gf2logger) fork. ## License -This project retains the original MIT license. See [LICENSE](LICENSE). +MIT, as declared in `pyproject.toml`. diff --git a/README_KR.md b/README_KR.md index d3a0ef7..f5f89c7 100644 --- a/README_KR.md +++ b/README_KR.md @@ -1,110 +1,133 @@ -# macOS용 gfl2logger +# gfl2logger [English](README.md) | 한국어 -> [!IMPORTANT] -> 이 리포지토리는 [blead/gfl2logger](https://github.com/blead/gfl2logger)의 macOS 전용 포크입니다. -> Windows 사용자는 [원본 Windows 버전](https://github.com/blead/gfl2logger/releases)을 다운로드하여 사용해야 합니다. +`gfl2logger`는 소녀전선 2: 망명(Girls' Frontline 2: Exilium)이 서버에서 받는 +데이터를 읽어, 서클 멤버 목록·활동 기록·보유 장비처럼 보관할 가치가 있는 부분을 +로컬 CSV 및 JSON 파일로 저장합니다. **Windows**와 **macOS**를 하나의 코드로 +지원하며, 두 플랫폼에서 기능·옵션·창 구성이 동일합니다. -`gfl2logger`는 소녀전선 2: 망명(Girls' Frontline 2: Exilium)의 네트워크 페이로드를 캡처하고 서클 관리 데이터를 로컬 CSV 및 JSON 파일로 내보냅니다. -이 포크는 Apple Silicon Mac에서 실행되는 iPhone/iPad App Store 버전의 GF2를 지원합니다. +## 다운로드 -## 안내 +| 플랫폼 | 게임 클라이언트 | 다운로드 (v0.3.0) | +| --- | --- | --- | +| **Windows** (x64) | PC 클라이언트 | [`gfl2logger-v0.3.0-windows-x64.exe`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-windows-x64.exe) | +| **macOS** (Apple Silicon) | Mac App Store의 iPhone/iPad 버전 | [`gfl2logger-v0.3.0-macos-arm64.dmg`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-macos-arm64.dmg) · [`gfl2logger-v0.3.0-macos-arm64.zip`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-macos-arm64.zip) | -> [!WARNING] -> `gf2logger`는 현재 임시 서명되어 있으며 아직 Apple Developer ID로 공증되지 않았기 때문에, 처음 실행할 때 macOS에서 Apple이 앱에 악성 소프트웨어가 없는지 확인할 수 없다는 **“gf2logger”이(가) 열리지 않음** 경고가 표시될 수 있습니다. -> 이 저장소의 [공식 Releases 페이지](https://github.com/1window2/gf2logger/releases)에서 앱을 다운로드했고 해당 출처를 신뢰하는 경우에만 이 경고를 우회하십시오. -> -> 앱 실행을 허용하는 방법은 다음과 같습니다. -> -> 1. `gf2logger.app`을 열어 본 다음 경고가 나타나면 **완료**를 클릭합니다. -> 2. **Apple 메뉴 > 시스템 설정 > 개인정보 보호 및 보안**을 엽니다. -> 3. **보안**까지 아래로 스크롤하고 `gf2logger`가 차단되었다는 메시지를 찾은 다음 **그래도 열기**를 클릭합니다. -> 4. 로그인 암호 또는 Touch ID로 인증한 다음, macOS에서 다시 확인을 요청하면 **열기**를 클릭합니다. -> -> **그래도 열기** 버튼은 차단된 실행을 시도한 후 약 1시간 동안 사용할 수 있습니다. -> 승인하면 macOS가 이 앱을 예외로 저장하므로 이후에는 정상적으로 실행할 수 있습니다. -> 자세한 내용은 [Apple 공식 안내](https://support.apple.com/ko-kr/guide/mac-help/mchleab3a043/mac)를 참조하십시오. +이전 버전과 릴리스 노트는 [Releases](../../releases) 페이지에 있습니다. -## 요구 사항 +## 시작하기 -- Apple Silicon Mac -- Mac App Store에서 설치한 iPhone/iPad 버전의 `소녀전선 2: 망명` -- 번들로 제공되는 Mitmproxy Redirector 네트워크 확장 프로그램을 활성화할 권한 +게임보다 **먼저** `gfl2logger`를 실행합니다. 게임이 이미 실행 중이라면 로거 +창이 열린 뒤 게임을 다시 시작합니다. -## 설치 +### Windows -1. [Releases](https://github.com/1window2/gf2logger/releases)에서 - `gf2logger-v0.1.2-macos-arm64.zip` 또는 `gf2logger-v0.1.2-macos-arm64.dmg`를 다운로드합니다. -2. ZIP의 경우 압축을 푼 다음 `gf2logger.app`을 Control-클릭합니다. - DMG의 경우 파일을 열고 `gf2logger.app`을 **응용 프로그램**으로 드래그한 다음, 설치된 앱을 Control-클릭합니다. -3. **열기**를 선택한 다음 Gatekeeper 대화상자에서 **열기**를 다시 확인합니다. -4. macOS에서 네트워크 확장 프로그램 권한을 요청하면 **Mitmproxy Redirector**를 승인합니다. +1. `.exe`를 내려받아 쓰기 권한이 있는 폴더에 둡니다. 내보낸 파일은 프로그램을 + 실행한 폴더에 저장됩니다. +2. 실행합니다. 실행 파일에 코드 서명이 없어 Windows SmartScreen이 **Windows의 + PC 보호** 화면을 표시할 수 있습니다. 이 저장소의 Releases 페이지에서 받은 + 파일이라면 **추가 정보 > 실행**을 선택합니다. +3. mitmproxy의 트래픽 리디렉터에 대해 Windows가 관리자 권한을 요청하면 + 허용합니다. -Redirector가 자동으로 활성화되지 않으면 **시스템 설정 > 일반 > 로그인 항목 및 확장 프로그램 > 네트워크 확장 프로그램**을 열고, -**Mitmproxy Redirector**를 활성화한 다음 `gfl2logger`를 다시 실행합니다. +### macOS -애플리케이션 자체는 현재 Developer ID로 서명 및 공증된 상태가 아니라 임시(ad-hoc) 서명된 상태입니다. -번들로 제공되는 네트워크 Redirector는 mitmproxy 프로젝트에서 별도로 서명하고 공증했습니다. +1. `.dmg`를 열어 `gfl2logger.app`을 **응용 프로그램**으로 드래그하거나, `.zip`의 + 압축을 풉니다. +2. 앱을 엽니다. 임시(ad-hoc) 서명 상태이고 Apple Developer ID 공증을 받지 않아, + 처음 실행하면 **“gfl2logger”이(가) 열리지 않음** 경고와 함께 차단됩니다. + **완료**를 클릭한 뒤 **시스템 설정 > 개인정보 보호 및 보안**을 열고 + **보안**까지 스크롤하여 **그래도 열기**를 클릭합니다. 이 버튼은 차단된 실행 + 시도 후 약 1시간 동안 표시되며, 한 번 승인하면 macOS가 예외로 기억합니다. + 자세한 내용은 + [Apple 공식 안내](https://support.apple.com/ko-kr/guide/mac-help/mchleab3a043/mac)를 + 참조하십시오. 이 저장소의 Releases 페이지에서 받은 파일에만 적용하십시오. +3. macOS가 네트워크 확장 프로그램 권한을 요청하면 **Mitmproxy Redirector**를 + 승인합니다. 자동으로 활성화되지 않으면 **시스템 설정 > 일반 > 로그인 항목 및 + 확장 프로그램 > 네트워크 확장 프로그램**에서 켠 다음 `gfl2logger`를 다시 + 실행합니다. + +번들로 제공되는 네트워크 리디렉터는 mitmproxy 프로젝트가 별도로 서명하고 +공증했습니다. ## 사용법 -1. GF2보다 먼저 `gfl2logger`를 실행합니다. GF2가 이미 실행 중이라면 로거가 - 준비된 후 GF2를 다시 시작합니다. -2. 원하는 페이로드 체크박스를 활성화된 상태로 둡니다. -3. GF2를 실행하고 서클 페이지를 열어 관련 데이터를 요청합니다. +1. 저장하려는 데이터의 체크박스를 켜 둡니다. **Save config**를 누르면 다음 + 실행에도 선택이 유지됩니다. +2. 게임을 실행해 로그인합니다. 서클 데이터는 서클 페이지를 열면 수신됩니다. +3. 게임이 지원 대상 응답을 받을 때마다 새 파일이 기록되고, 오른쪽 로그에 저장 + 경로가 표시됩니다. -내보낸 파일과 `gfl2logger.config.yaml`은 `~/gfl2logger`에 저장됩니다. +| 플랫폼 | 내보낸 파일과 `gfl2logger.config.yaml`의 저장 위치 | +| --- | --- | +| Windows | 프로그램을 실행한 폴더(보통 `.exe`가 있는 폴더) | +| macOS | `~/gfl2logger` | -체크박스는 두 그룹으로 구성됩니다. +### 내보내는 데이터 -- **Platoon:** Platoon Profile, Members, Activity, Updates -- **Others:** Weapons, Attachments, Common Keys, Formations +창에서 옵션은 두 그룹으로 나뉘어 표시됩니다. -### 내보내는 데이터 +| 그룹 | 옵션 | 페이로드 | 설명 | 수신 시점 | 형식 | +| --- | --- | ---: | --- | --- | --- | +| Platoon | Platoon Profile | `21905` | 서클 식별 정보, 레벨, 멤버 수, 공지 및 모집 안내 | 서클 페이지 | JSON | +| Platoon | Members | `21917` | 멤버 이름, UID, 레벨, 공적치, 점수 및 로그인 시간 | 로그인, 재접속, 서클 페이지 | CSV | +| Platoon | Activity | `21935` | 서클 목표 및 최근 멤버 활동 | 서클 페이지 | JSON | +| Platoon | Updates | `21960` | 동향 탭의 멤버 가입/탈퇴/추방 및 과업 보급 생성 기록 | 동향 탭 | JSON | +| Others | Weapons | `11021` | 현재 계정이 보유한 무기 | 로그인 | CSV | +| Others | Attachments | `11061` | 현재 계정이 보유한 파츠 | 로그인 | CSV | +| Others | Common Keys | `11138` | 현재 계정이 보유한 공용키 | 로그인 | CSV | +| Others | Formations | `23201` | 저장된 편성 | 로그인, 재접속 | JSON | -| 옵션 | 페이로드 | 설명 | 형식 | -| --- | ---: | --- | --- | -| Platoon Profile | `21905` | 서클 식별 정보, 레벨, 멤버 수, 공지 및 모집 안내 | JSON | -| Members | `21917` | 멤버 이름, UID, 레벨, 공적치, 점수 및 로그인 시간 | CSV | -| Activity | `21935` | 서클 목표 및 최근 멤버 활동 | JSON | -| Updates | `21960` | 동향 탭의 멤버 가입/탈퇴/추방 및 과업 보급 생성 기록 | JSON | -| Weapons | `11021` | 현재 계정이 보유한 무기 | CSV | -| Attachments | `11061` | 현재 계정이 보유한 파츠 | CSV | -| Common Keys | `11138` | 현재 계정이 보유한 공용키 | CSV | -| Formations | `23201` | 저장된 편성 | JSON | +파일 이름은 `gfl2logger_<종류>_.csv` 또는 `.json` 형식입니다. Updates는 +protobuf 필드 번호와 원본 hex로 손실 없이 기록됩니다. ## 작동 방식 -Mitmproxy의 로컬 캡처 모드는 가로채기 대상을 GF2 실행 파일인 `SnqxExilium`으로 제한합니다. -로거는 게임 연결을 시작하거나 수정하지 않고 서버에서 클라이언트로 전송되는 페이로드를 읽습니다. -TLS 연결은 복호화하지 않고 그대로 통과시키므로 mitmproxy 인증서를 설치할 필요가 없습니다. +mitmproxy의 로컬 캡처 모드로 가로채기 대상을 게임 실행 파일로 제한합니다. +Windows에서는 `GF2_Exilium`, macOS에서는 `SnqxExilium`입니다. 로거는 서버가 +클라이언트로 보내는 데이터만 읽습니다. 연결을 새로 만들거나 변경하지 않으며, +TLS 연결은 복호화하지 않고 그대로 통과시키므로 인증서를 설치할 필요가 +없습니다. + +## 지원 클라이언트 + +- **Windows:** PC 클라이언트. Darkwinter 및 HaoPlay 지역에서 동작이 + 확인되었습니다. +- **macOS:** Apple Silicon Mac에서 실행되는 iPhone/iPad App Store 버전. + +그 외 플랫폼과 지역은 테스트되지 않았습니다. 추가 데이터, 사용 방식, 형식에 대한 +제안을 환영합니다. ## 소스에서 빌드하기 -이 프로젝트는 [PDM](https://pdm-project.org/)과 Python 3.13을 사용합니다. +이 프로젝트는 [PDM](https://pdm-project.org/)과 Python 3.13을 사용합니다. 이 +저장소를 클론한 폴더에서 다음을 실행합니다. ```sh -git clone https://github.com/1window2/gf2logger.git -cd gf2logger pdm install pdm run pyinstaller ``` -Apple Silicon 빌드는 `dist/gf2logger.app`에 생성됩니다. 소스에서 직접 -실행하려면 다음 명령을 사용합니다. +Windows에서는 `dist/gfl2logger.exe`, macOS에서는 `dist/gfl2logger.app`이 +생성됩니다. 소스에서 직접 실행하거나 테스트를 돌리려면 다음 명령을 사용합니다. ```sh pdm run protoc pdm run python main.py +pdm run python -m unittest discover -s tests ``` +`v*.*.*` 태그를 푸시하면 두 플랫폼을 모두 빌드해 하나의 릴리스로 게시합니다. + ## 크레딧 -패킷 파서, 데이터 내보내기 기능 및 원본 Windows 애플리케이션은 -[blead](https://github.com/blead)가 [blead/gfl2logger](https://github.com/blead/gfl2logger)에서 제작했습니다. -이 포크는 macOS 캡처 경로, 패키징, 플랫폼별 UI 수정 및 확장된 서클 페이로드 지원을 추가합니다. +`gfl2logger`는 [blead](https://github.com/blead)가 만들었습니다. 패킷 파서, 데이터 +내보내기 기능, 원본 Windows 애플리케이션이 여기에 해당합니다. macOS 지원, +Platoon Profile·Activity·Updates 내보내기, 파서와 내보내기의 안정성 보강은 +[1window2/gf2logger](https://github.com/1window2/gf2logger) 포크에서 +개발되었습니다. ## 라이선스 -이 프로젝트는 원본 MIT 라이선스를 유지합니다. [LICENSE](LICENSE)를 참조하십시오. +MIT (`pyproject.toml`에 명시). diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 583fabc..54c82f7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,34 +1,64 @@ -# gfl2logger for macOS v0.1.2 +# gfl2logger v0.3.0 -This patch release fixes lost captures, unbounded memory growth during long -sessions, and several ways the app could keep running without a window. -Updating from v0.1.1 is recommended. +This release brings the Windows and macOS versions together. Both are now built +from the same code, share one version number, and have the same features, +options, and window. Windows continues from v0.2.5; the macOS line, previously +published as v0.1.x, joins the same numbering. -## Fixes +## Downloads -- Kept message bytes that arrive split across TCP chunks, and finished parsing +| Platform | Game client | Download | +| --- | --- | --- | +| **Windows** (x64) | PC client | [`gfl2logger-v0.3.0-windows-x64.exe`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-windows-x64.exe) | +| **macOS** (Apple silicon) | iPhone/iPad version from the Mac App Store | [`gfl2logger-v0.3.0-macos-arm64.dmg`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-macos-arm64.dmg) · [`gfl2logger-v0.3.0-macos-arm64.zip`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-macos-arm64.zip) | + +## Changes + +### Windows, since v0.2.5 + +- Added Platoon Profile (`21905`), Activity (`21935`), and Updates (`21960`) + exports alongside Members, and grouped the options into **Platoon** and + **Others**. +- Kept message bytes that arrive split across TCP chunks and finished parsing data already received when a connection closes, so responses are no longer dropped or misread at chunk boundaries. - Released forwarded TCP messages instead of keeping every one for the life of the game connection, which grew memory for as long as the logger ran. -- Wrote CSV and JSON exports to a temporary file and published them only when - complete, so a malformed response can no longer leave a truncated file. - Responses with no entries now export an empty table instead of failing. -- Shut the proxy down when the window process exits unexpectedly, and closed - the window when the proxy is gone, instead of leaving a hidden process - capturing in the background. -- Started with default options when `gfl2logger.config.yaml` is unreadable - rather than failing to open, and kept the window able to quit after a failed - **Save config**. -- Shortened the log entry for a malformed payload, which previously printed - the entire message in hexadecimal. +- Bounded packet queues and payload reassembly by size, fragment count, and + timeout. +- Wrote exports to a temporary file and published them only when complete, and + neutralized formula-leading member names in CSV exports. +- Shut the proxy down when the window process exits unexpectedly, and started + with default options when `gfl2logger.config.yaml` is unreadable. +- Updated mitmproxy to 12.2.3. + +The capture path on Windows is unchanged from v0.2.5. This build was verified +by automated tests, packaging, and a window start-up check on Windows, and has +not yet been run against a live game session. Please report any problems. + +### macOS, since v0.1.2 + +- Renamed the app from `gf2logger.app` to `gfl2logger.app` to match the + project. Replace the old app with the new one; exports and settings stay in + `~/gfl2logger`. +- No functional changes. The version number moves to v0.3.0 to match Windows. + +### Both + +- Every change is now tested and built on Windows and macOS, and a release + publishes both platforms together. ## Installation -Download `gf2logger-v0.1.2-macos-arm64.zip` for the portable app, or -`gf2logger-v0.1.2-macos-arm64.dmg` for drag-and-drop installation. Start -`gf2logger` before GF2. +**Windows:** download the `.exe`, place it in a folder you can write to, and +run it. If SmartScreen shows **Windows protected your PC**, choose **More +info > Run anyway**. Allow the administrator prompt for mitmproxy's traffic +redirector if Windows shows one. + +**macOS:** open the `.dmg` and drag `gfl2logger.app` to **Applications**, or +unzip the `.zip`. The application remains ad-hoc signed and is not yet +Developer ID-notarized. If macOS blocks the first launch, follow the **Open +Anyway** instructions in the [README](../../#macos). Approve **Mitmproxy +Redirector** when macOS requests Network Extension permission. -The application remains ad-hoc signed and is not yet Developer ID-notarized. If -macOS blocks the first launch, follow the **Open Anyway** instructions in the -[README](https://github.com/1window2/gf2logger#notice). +Start `gfl2logger` before the game on either platform. diff --git a/gfl2logger/utils/version.py b/gfl2logger/utils/version.py index 5b9b705..aa4bf5a 100644 --- a/gfl2logger/utils/version.py +++ b/gfl2logger/utils/version.py @@ -1,4 +1,4 @@ -VERSION = "0.1.2" +VERSION = "0.3.0" def get_version(): From 3e1d4137a7cf38562a967ca41d9d24b59553cb2c Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 8 Oct 2026 11:07:58 +0900 Subject: [PATCH 3/6] test: identify the processes of the packaged app in the start and close check --- .github/scripts/smoke_frozen.py | 96 +++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 28 deletions(-) diff --git a/.github/scripts/smoke_frozen.py b/.github/scripts/smoke_frozen.py index 5134337..677108c 100644 --- a/.github/scripts/smoke_frozen.py +++ b/.github/scripts/smoke_frozen.py @@ -1,9 +1,12 @@ """Start the packaged app, check that it stays up without multiplying, then close it. -Used by CI. On Windows the window is asked to close the way a user would close it; -elsewhere the app receives SIGTERM. Either way every process has to be gone afterwards. +Used by CI. On Windows the window process is asked to close the way a user would close +it; elsewhere the app receives SIGTERM. Either way every process has to be gone +afterwards. """ +import json +import os import subprocess import sys import tempfile @@ -15,24 +18,50 @@ # re-launches itself during multiprocessing start-up blows far past this. MAX_PROCESSES = 8 WINDOWS = sys.platform == "win32" +MACOS_PATTERN = "gfl2logger.app/Contents/MacOS/gfl2logger" -def running_processes() -> int: +def processes() -> list[dict]: + """Return pid, parent pid and command line of every running app process.""" if WINDOWS: listing = subprocess.run( - ["tasklist", "/FI", "IMAGENAME eq gfl2logger.exe", "/FO", "CSV", "/NH"], + [ + "powershell", + "-NoProfile", + "-Command", + "Get-CimInstance Win32_Process -Filter \"name='gfl2logger.exe'\" | " + "Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json", + ], capture_output=True, text=True, - ).stdout - return sum( - line.lower().startswith('"gfl2logger.exe"') for line in listing.splitlines() - ) + ).stdout.strip() + if not listing: + return [] + found = json.loads(listing) + found = [found] if isinstance(found, dict) else found + return [ + { + "pid": item["ProcessId"], + "parent": item["ParentProcessId"], + "command": item["CommandLine"] or "", + } + for item in found + ] + listing = subprocess.run( - ["pgrep", "-f", "gfl2logger.app/Contents/MacOS/gfl2logger"], - capture_output=True, - text=True, + ["ps", "-axo", "pid=,ppid=,command="], capture_output=True, text=True ).stdout - return len(listing.split()) + found = [] + for line in listing.splitlines(): + pid, parent, command = line.split(None, 2) + if MACOS_PATTERN in command: + found.append({"pid": int(pid), "parent": int(parent), "command": command}) + return found + + +def describe(found: list[dict]) -> None: + for item in found: + print(f" pid={item['pid']} parent={item['parent']} {item['command']}") def force_stop(process: subprocess.Popen) -> None: @@ -41,41 +70,52 @@ def force_stop(process: subprocess.Popen) -> None: ["taskkill", "/F", "/T", "/IM", "gfl2logger.exe"], capture_output=True ) else: - subprocess.run( - ["pkill", "-9", "-f", "gfl2logger.app/Contents/MacOS/gfl2logger"], - capture_output=True, - ) + subprocess.run(["pkill", "-9", "-f", MACOS_PATTERN], capture_output=True) process.wait(timeout=30) +def close(process: subprocess.Popen, found: list[dict]) -> None: + if not WINDOWS: + process.terminate() + return + # The window lives in the multiprocessing child. Without /F, taskkill posts WM_CLOSE + # to that process, which is what clicking the window's close button does. + windows = [item for item in found if "--multiprocessing-fork" in item["command"]] + for item in windows: + result = subprocess.run( + ["taskkill", "/PID", str(item["pid"])], capture_output=True, text=True + ) + print(f"close request to pid={item['pid']}: {result.stdout.strip()}") + if not windows: + print("no window process found to close") + + def main(executable: str) -> int: + executable = os.path.abspath(executable) # Windows builds write exports and settings to the working directory. workdir = tempfile.mkdtemp(prefix="gfl2logger-smoke-") process = subprocess.Popen([executable], cwd=workdir) time.sleep(STARTUP_SECONDS) - started = running_processes() - print(f"processes after {STARTUP_SECONDS}s: {started}") + started = processes() + print(f"processes after {STARTUP_SECONDS}s: {len(started)}") + describe(started) if process.poll() is not None: print(f"FAIL: exited during start-up with code {process.returncode}") return 1 - if not 2 <= started <= MAX_PROCESSES: + if not 2 <= len(started) <= MAX_PROCESSES: print(f"FAIL: expected 2..{MAX_PROCESSES} processes") force_stop(process) return 1 - if WINDOWS: - # Without /F this posts WM_CLOSE to the window, like clicking its close button. - subprocess.run(["taskkill", "/IM", "gfl2logger.exe"], capture_output=True) - else: - process.terminate() - + close(process, started) deadline = time.monotonic() + SHUTDOWN_SECONDS - while time.monotonic() < deadline and running_processes(): + while time.monotonic() < deadline and processes(): time.sleep(1) - remaining = running_processes() + remaining = processes() if remaining: - print(f"FAIL: {remaining} processes still running after closing") + print(f"FAIL: {len(remaining)} processes still running after closing") + describe(remaining) force_stop(process) return 1 From e1856f4f4c3f5c28b331932c16dfef17050a9508 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 8 Oct 2026 11:16:10 +0900 Subject: [PATCH 4/6] fix: end the program whenever its window is gone Show the window before mitmproxy sets up the proxy servers, since that step can stall and previously left the app running with nothing on screen. Close the window at once when the user closes it, route Cmd+Q through the same path on macOS, and add a watchdog that exits the proxy process shortly after the window process ends for any reason. The packaged-app check now requires the window process to be present and every process to be gone after the window closes. --- .github/scripts/smoke_frozen.py | 100 ++++++++++++++++++++---------- gfl2logger/gui/manager.py | 87 ++++++++++++++++++++++++-- gfl2logger/gui/window.py | 17 +++-- gfl2logger/proxy/master.py | 8 ++- tests/test_entrypoint.py | 3 + tests/test_robustness.py | 106 ++++++++++++++++++++++++++++++++ 6 files changed, 279 insertions(+), 42 deletions(-) diff --git a/.github/scripts/smoke_frozen.py b/.github/scripts/smoke_frozen.py index 677108c..bb187c3 100644 --- a/.github/scripts/smoke_frozen.py +++ b/.github/scripts/smoke_frozen.py @@ -1,27 +1,28 @@ """Start the packaged app, check that it stays up without multiplying, then close it. -Used by CI. On Windows the window process is asked to close the way a user would close -it; elsewhere the app receives SIGTERM. Either way every process has to be gone -afterwards. +Used by CI. On Windows the window is asked to close the way its close button does; on +macOS the window process is killed outright, like a crash. Either way every process has +to be gone shortly afterwards: the program must never keep running without a window. """ import json import os +import signal import subprocess import sys import tempfile import time STARTUP_SECONDS = 25 -SHUTDOWN_SECONDS = 60 +SHUTDOWN_SECONDS = 30 # The proxy, the window process and their launcher/helper processes. A frozen build that # re-launches itself during multiprocessing start-up blows far past this. MAX_PROCESSES = 8 WINDOWS = sys.platform == "win32" -MACOS_PATTERN = "gfl2logger.app/Contents/MacOS/gfl2logger" +WINDOW_PROCESS_MARK = "--multiprocessing-fork" -def processes() -> list[dict]: +def processes(executable: str) -> list[dict]: """Return pid, parent pid and command line of every running app process.""" if WINDOWS: listing = subprocess.run( @@ -54,7 +55,8 @@ def processes() -> list[dict]: found = [] for line in listing.splitlines(): pid, parent, command = line.split(None, 2) - if MACOS_PATTERN in command: + # Match the program itself, not tools that merely mention its path. + if command.startswith(executable): found.append({"pid": int(pid), "parent": int(parent), "command": command}) return found @@ -64,30 +66,56 @@ def describe(found: list[dict]) -> None: print(f" pid={item['pid']} parent={item['parent']} {item['command']}") -def force_stop(process: subprocess.Popen) -> None: +def window_processes(found: list[dict]) -> list[dict]: + # The window lives in the multiprocessing child. + return [item for item in found if WINDOW_PROCESS_MARK in item["command"]] + + +def force_stop(process: subprocess.Popen, executable: str) -> None: if WINDOWS: subprocess.run( ["taskkill", "/F", "/T", "/IM", "gfl2logger.exe"], capture_output=True ) else: - subprocess.run(["pkill", "-9", "-f", MACOS_PATTERN], capture_output=True) + for item in processes(executable): + try: + os.kill(item["pid"], signal.SIGKILL) + except ProcessLookupError: + pass process.wait(timeout=30) -def close(process: subprocess.Popen, found: list[dict]) -> None: - if not WINDOWS: - process.terminate() - return - # The window lives in the multiprocessing child. Without /F, taskkill posts WM_CLOSE - # to that process, which is what clicking the window's close button does. - windows = [item for item in found if "--multiprocessing-fork" in item["command"]] - for item in windows: - result = subprocess.run( - ["taskkill", "/PID", str(item["pid"])], capture_output=True, text=True - ) - print(f"close request to pid={item['pid']}: {result.stdout.strip()}") - if not windows: - print("no window process found to close") +def close_window(found: list[dict]) -> None: + for item in window_processes(found): + if WINDOWS: + # Without /F, taskkill posts WM_CLOSE, which is what the close button does. + result = subprocess.run( + ["taskkill", "/PID", str(item["pid"])], capture_output=True, text=True + ) + print(f"close request to pid={item['pid']}: {result.stdout.strip()}") + else: + os.kill(item["pid"], signal.SIGKILL) + print(f"killed window process pid={item['pid']}") + + +def diagnose_windows() -> None: + report = subprocess.run( + [ + "powershell", + "-NoProfile", + "-Command", + "Get-Process gfl2logger -ErrorAction SilentlyContinue | " + "Select-Object Id,MainWindowTitle,Responding,StartTime | Format-List; " + "Get-CimInstance Win32_Process | " + "Where-Object { $_.Name -match 'redirector|divert' } | " + "Select-Object ProcessId,ParentProcessId,Name,ExecutablePath | Format-List; " + "Get-ChildItem $env:TEMP -Filter '_MEI*' -ErrorAction SilentlyContinue | " + "Select-Object FullName | Format-List", + ], + capture_output=True, + text=True, + ) + print(report.stdout.strip() or report.stderr.strip()) def main(executable: str) -> int: @@ -97,7 +125,7 @@ def main(executable: str) -> int: process = subprocess.Popen([executable], cwd=workdir) time.sleep(STARTUP_SECONDS) - started = processes() + started = processes(executable) print(f"processes after {STARTUP_SECONDS}s: {len(started)}") describe(started) if process.poll() is not None: @@ -105,21 +133,29 @@ def main(executable: str) -> int: return 1 if not 2 <= len(started) <= MAX_PROCESSES: print(f"FAIL: expected 2..{MAX_PROCESSES} processes") - force_stop(process) + force_stop(process, executable) + return 1 + if not window_processes(started): + print("FAIL: the window process is not running") + force_stop(process, executable) return 1 - close(process, started) - deadline = time.monotonic() + SHUTDOWN_SECONDS - while time.monotonic() < deadline and processes(): - time.sleep(1) - remaining = processes() + close_window(started) + closed_at = time.monotonic() + deadline = closed_at + SHUTDOWN_SECONDS + while time.monotonic() < deadline and processes(executable): + time.sleep(0.5) + remaining = processes(executable) if remaining: print(f"FAIL: {len(remaining)} processes still running after closing") describe(remaining) - force_stop(process) + if WINDOWS: + diagnose_windows() + force_stop(process, executable) return 1 - print("OK: started, stayed bounded, and exited completely when closed") + elapsed = time.monotonic() - closed_at + print(f"OK: window shown, and every process was gone {elapsed:.1f}s after closing") return 0 diff --git a/gfl2logger/gui/manager.py b/gfl2logger/gui/manager.py index 07b14a0..76b792d 100644 --- a/gfl2logger/gui/manager.py +++ b/gfl2logger/gui/manager.py @@ -1,7 +1,12 @@ import asyncio import logging import multiprocessing +import multiprocessing.connection +import os import queue +import threading +import time +from collections.abc import Awaitable, Callable from pathlib import Path from mitmproxy import ctx, log, optmanager @@ -16,6 +21,43 @@ GUI_POLL_SECONDS = 1.0 MAX_PENDING_GUI_COMMANDS = 2048 +EXIT_GRACE_SECONDS = 3.0 + + +def wait_for_exit(process: multiprocessing.Process) -> None: + # Waiting on the sentinel does not reap the process, so it cannot race with the + # join() and is_alive() calls made from the event loop thread. + multiprocessing.connection.wait([process.sentinel]) + + +def exit_when_window_is_gone( + process: multiprocessing.Process, + *, + grace: float = EXIT_GRACE_SECONDS, + wait: Callable[[multiprocessing.Process], None] = wait_for_exit, + terminate: Callable[[int], None] = os._exit, +) -> None: + """End this process shortly after the window process ends, whatever the reason. + + The program must never keep capturing without a window. Normal shutdown finishes + well inside the grace period; this is the backstop for a shutdown that hangs, a + proxy still stuck in start-up, or a command loop that has stopped. + """ + wait(process) + time.sleep(grace) + terminate(0) + + +def start_exit_watchdog(process: multiprocessing.Process) -> threading.Thread: + # A plain thread rather than a task: it has to work when the event loop is blocked. + watchdog = threading.Thread( + target=exit_when_window_is_gone, + args=(process,), + name="WindowExitWatchdog", + daemon=True, + ) + watchdog.start() + return watchdog def send_nowait(target: "multiprocessing.Queue[Command]", command: Command) -> bool: @@ -27,7 +69,28 @@ def send_nowait(target: "multiprocessing.Queue[Command]", command: Command) -> b return True +async def run_with_window( + gui: "GUIManager", run: Callable[[], Awaitable[None]] +) -> None: + """Show the window before the proxy starts and take it down when the proxy ends. + + mitmproxy brings its proxy servers up before it fires the running hook, and the + local redirector can take a while or stall there, for example while macOS waits for + the Network Extension to be approved. Tying the window to that hook left the app + running with nothing on screen, so the window is started first instead. + """ + gui.start() + try: + await run() + finally: + gui.close() + + class GUIManager: + _loop_task: asyncio.Task | None = None + _closed = False + start_exit_watchdog = staticmethod(start_exit_watchdog) + def __init__(self): self.from_gui: "multiprocessing.Queue[Command]" = multiprocessing.Queue() # Bounded so a window that stops reading cannot make log records pile up in memory. @@ -45,6 +108,7 @@ def __init__(self): async def loop(self) -> None: self.subprocess.start() + self.start_exit_watchdog(self.subprocess) try: # Wake up periodically: if the window process dies without sending SHUTDOWN, a # plain blocking read would leave the proxy capturing with no window to quit it. @@ -99,18 +163,33 @@ def configure(self, updated: set[str]) -> None: def load(self, _) -> None: self.optmanager_wrapper = GFL2OptManagerWrapper(ctx.options) + def start(self) -> None: + """Start the window process and the command loop once.""" + if self._loop_task is None: + self._loop_task = asyncio_utils.create_task(self.loop()) + async def running(self) -> None: - asyncio_utils.create_task(self.loop()) + self.start() - async def done(self) -> None: + def close(self) -> None: + """Ask the window to close and stop waiting for it. Safe to call twice.""" + if self._closed: + return + self._closed = True send_nowait(self.to_gui, Command(CommandType.SHUTDOWN, None)) - # Signal-driven shutdown can reach done() while loop() is blocked in + # Signal-driven shutdown can reach here while loop() is blocked in # from_gui.get(). Wake that read so asyncio can close its worker thread - # and the frozen macOS app can exit cleanly. + # and the frozen app can exit cleanly. self.from_gui.put(Command(CommandType.SHUTDOWN, None)) self.subprocess.join(timeout=5) self.subprocess.terminate() self.log_handler.uninstall() + # Nothing reads this queue any more; do not let undelivered log records hold + # up interpreter exit. + self.to_gui.cancel_join_thread() + + async def done(self) -> None: + self.close() class GuiLogHandler(log.MitmLogHandler): diff --git a/gfl2logger/gui/window.py b/gfl2logger/gui/window.py index f69d760..b2a5d8f 100644 --- a/gfl2logger/gui/window.py +++ b/gfl2logger/gui/window.py @@ -2,6 +2,7 @@ import multiprocessing import queue import signal +import sys import tkinter import ctypes from idlelib import tooltip @@ -37,10 +38,15 @@ def __init__(self, from_gui: "multiprocessing.Queue[Command]"): if ICON_PNG is not None: self.iconphoto(True, tkinter.PhotoImage(data=ICON_PNG)) - # exit flow: (tk) self.quit -> (tk) from_gui:SHUTDOWN - # -> (main) master.shutdown -> (main) manager.done -> (main) to_gui:SHUTDOWN - # -> (tk) pump -> (tk) self.destroy + # Closing the window ends the program: quit() tells the proxy process and then + # closes at once without waiting for an answer. The proxy shuts down on that + # message, or on noticing this process is gone, and force-exits if that stalls. + # A shutdown started by the proxy arrives through pump() as SHUTDOWN instead. self.protocol("WM_DELETE_WINDOW", self.quit) + if sys.platform == "darwin": + # Cmd+Q and the Dock's Quit make Tk exit on the spot by default, which skips + # the shutdown exchange with the proxy process. Route them through quit(). + self.createcommand("::tk::mac::Quit", self.quit) opt_frame = ttk.Frame(self, padding=10) opt_frame.pack(side="left", fill="y") @@ -111,8 +117,11 @@ def rpc_set_options(self, options: dict[str, bool]) -> None: self.options[opt].set(options[opt]) def quit(self) -> None: - self.withdraw() + if not self.active: + return + self.active = False self.from_gui.put(Command(CommandType.SHUTDOWN, None)) + self.destroy() def destroy(self, *_) -> None: super().destroy() diff --git a/gfl2logger/proxy/master.py b/gfl2logger/proxy/master.py index 83303ed..5c30c74 100644 --- a/gfl2logger/proxy/master.py +++ b/gfl2logger/proxy/master.py @@ -2,11 +2,11 @@ from collections.abc import Sequence from pathlib import Path -from mitmproxy import exceptions, master, options, optmanager +from mitmproxy import exceptions, log, master, options, optmanager from mitmproxy.addons import next_layer, proxyserver from gfl2logger.gfl2.logger import GFL2Logger -from gfl2logger.gui.manager import GUIManager +from gfl2logger.gui.manager import GUIManager, run_with_window from gfl2logger.proxy.capture import default_capture_mode from gfl2logger.proxy.ignore_tls import IgnoreTls from gfl2logger.utils.paths import CONFIG_FILENAME, default_data_dir @@ -45,3 +45,7 @@ def __init__(self): # The app has no console; refusing to start over a damaged config file would # look like the app silently failing to open. Fall back to the defaults. logger.error(f"Ignoring unreadable configuration {config}, error={error}") + + async def run(self) -> None: + logger.log(log.ALERT, f"Starting capture: {', '.join(self.options.mode)}") + await run_with_window(self.addons.get("guimanager"), super().run) diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py index d9bcad7..abed1a0 100644 --- a/tests/test_entrypoint.py +++ b/tests/test_entrypoint.py @@ -17,6 +17,9 @@ def put(self, item) -> None: def put_nowait(self, item) -> None: self.items.append(item) + def cancel_join_thread(self) -> None: + self.join_cancelled = True + class FakeProcess: def __init__(self) -> None: diff --git a/tests/test_robustness.py b/tests/test_robustness.py index 06cce7f..5f18933 100644 --- a/tests/test_robustness.py +++ b/tests/test_robustness.py @@ -252,6 +252,26 @@ def test_shutdown_destroys_the_window_and_stops_polling(self) -> None: self.assertEqual(window.scheduled, 0) self.assertEqual(window.logs, []) + def test_closing_the_window_notifies_the_proxy_and_closes_immediately(self) -> None: + class ClosableWindow: + def __init__(self) -> None: + self.active = True + self.from_gui: queue.Queue = queue.Queue() + self.destroyed = 0 + + def destroy(self) -> None: + self.destroyed += 1 + + closing = ClosableWindow() + + gui_window.TkWindow.quit(closing) + gui_window.TkWindow.quit(closing) + + self.assertEqual(closing.from_gui.get_nowait().type, CommandType.SHUTDOWN) + self.assertTrue(closing.from_gui.empty()) + self.assertEqual(closing.destroyed, 1) + self.assertFalse(closing.active) + def test_window_closes_itself_when_the_proxy_process_is_gone(self) -> None: window = FakeWindow() @@ -288,6 +308,7 @@ def is_alive(self) -> bool: manager = gui_manager.GUIManager.__new__(gui_manager.GUIManager) manager.from_gui = queue.Queue() manager.subprocess = DyingProcess() + manager.start_exit_watchdog = lambda process: None with ( mock.patch.object(gui_manager, "GUI_POLL_SECONDS", 0.01), @@ -297,6 +318,90 @@ def is_alive(self) -> bool: ctx.master.shutdown.assert_called_once() + def test_process_exits_once_the_window_process_is_gone(self) -> None: + events: list[object] = [] + window_process = object() + + gui_manager.exit_when_window_is_gone( + window_process, + grace=0, + wait=lambda process: events.append(("waited for", process)), + terminate=lambda code: events.append(("exit", code)), + ) + + self.assertEqual(events, [("waited for", window_process), ("exit", 0)]) + + async def test_watchdog_is_armed_when_the_window_starts(self) -> None: + class DeadProcess: + def start(self) -> None: + pass + + def is_alive(self) -> bool: + return False + + manager = gui_manager.GUIManager.__new__(gui_manager.GUIManager) + manager.from_gui = queue.Queue() + manager.subprocess = DeadProcess() + watched: list[object] = [] + manager.start_exit_watchdog = watched.append + + with mock.patch.object(gui_manager, "ctx"): + await asyncio.wait_for(manager.loop(), timeout=5) + + self.assertEqual(watched, [manager.subprocess]) + + async def test_window_starts_before_the_proxy_and_closes_after_it(self) -> None: + events: list[str] = [] + + class Gui: + def start(self) -> None: + events.append("window started") + + def close(self) -> None: + events.append("window closed") + + async def run_proxy() -> None: + # Stands in for mitmproxy's run(), which may sit in server set-up for a + # long time before the running hook fires. + events.append("proxy running") + + await gui_manager.run_with_window(Gui(), run_proxy) + + self.assertEqual(events, ["window started", "proxy running", "window closed"]) + + async def test_window_is_closed_even_if_the_proxy_fails(self) -> None: + events: list[str] = [] + + class Gui: + def start(self) -> None: + events.append("window started") + + def close(self) -> None: + events.append("window closed") + + async def run_proxy() -> None: + raise RuntimeError("redirector failed") + + with self.assertRaises(RuntimeError): + await gui_manager.run_with_window(Gui(), run_proxy) + + self.assertEqual(events, ["window started", "window closed"]) + + async def test_window_is_started_only_once(self) -> None: + manager = gui_manager.GUIManager.__new__(gui_manager.GUIManager) + started: list[object] = [] + + def create_task(coroutine): + coroutine.close() + started.append(coroutine) + return object() + + with mock.patch.object(gui_manager.asyncio_utils, "create_task", create_task): + manager.start() + await manager.running() + + self.assertEqual(len(started), 1) + async def test_failed_command_keeps_the_window_able_to_quit(self) -> None: class AliveProcess: def start(self) -> None: @@ -308,6 +413,7 @@ def is_alive(self) -> bool: manager = gui_manager.GUIManager.__new__(gui_manager.GUIManager) manager.from_gui = queue.Queue() manager.subprocess = AliveProcess() + manager.start_exit_watchdog = lambda process: None manager.from_gui.put(Command(CommandType.SAVE_OPTIONS, None)) manager.from_gui.put(Command(CommandType.SHUTDOWN, None)) From 0632bb13151e1eb2401b32999f6519414b6bd0b2 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 8 Oct 2026 11:21:10 +0900 Subject: [PATCH 5/6] test: report which files block temporary-directory cleanup on Windows --- .github/scripts/smoke_frozen.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/scripts/smoke_frozen.py b/.github/scripts/smoke_frozen.py index bb187c3..ac57e50 100644 --- a/.github/scripts/smoke_frozen.py +++ b/.github/scripts/smoke_frozen.py @@ -110,7 +110,11 @@ def diagnose_windows() -> None: "Where-Object { $_.Name -match 'redirector|divert' } | " "Select-Object ProcessId,ParentProcessId,Name,ExecutablePath | Format-List; " "Get-ChildItem $env:TEMP -Filter '_MEI*' -ErrorAction SilentlyContinue | " - "Select-Object FullName | Format-List", + "ForEach-Object { Get-ChildItem $_.FullName -Recurse -File } | " + "ForEach-Object { $path = $_.FullName; " + "try { Remove-Item $path -Force -ErrorAction Stop; " + "Write-Output \"left behind, deletable now: $path\" } " + "catch { Write-Output \"left behind, still locked: $path\" } }", ], capture_output=True, text=True, From 3290ed547b60b2d025fdcb31ffbd8f1c07520279 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 8 Oct 2026 11:26:29 +0900 Subject: [PATCH 6/6] fix: package the Windows build as a folder instead of a single file A single-file build unpacks itself into a temporary directory on every start and removes it on exit. The WinDivert driver file loaded from that directory stays in use after the program closes, so the removal failed and the launcher was left showing a warning dialog. A folder build has nothing to clean up. The Windows download becomes a zip of that folder, and the documents and release notes describe the new close behavior. --- .github/scripts/smoke_frozen.py | 8 +----- .github/workflows/build.yml | 7 ++--- .github/workflows/release.yml | 2 +- README.md | 21 +++++++++----- README_KR.md | 25 +++++++++++------ RELEASE_NOTES.md | 46 +++++++++++++++++++++---------- gfl2logger.spec | 18 +++++++++--- tests/test_release_consistency.py | 9 +++++- 8 files changed, 88 insertions(+), 48 deletions(-) diff --git a/.github/scripts/smoke_frozen.py b/.github/scripts/smoke_frozen.py index ac57e50..c3dcd18 100644 --- a/.github/scripts/smoke_frozen.py +++ b/.github/scripts/smoke_frozen.py @@ -108,13 +108,7 @@ def diagnose_windows() -> None: "Select-Object Id,MainWindowTitle,Responding,StartTime | Format-List; " "Get-CimInstance Win32_Process | " "Where-Object { $_.Name -match 'redirector|divert' } | " - "Select-Object ProcessId,ParentProcessId,Name,ExecutablePath | Format-List; " - "Get-ChildItem $env:TEMP -Filter '_MEI*' -ErrorAction SilentlyContinue | " - "ForEach-Object { Get-ChildItem $_.FullName -Recurse -File } | " - "ForEach-Object { $path = $_.FullName; " - "try { Remove-Item $path -Force -ErrorAction Stop; " - "Write-Output \"left behind, deletable now: $path\" } " - "catch { Write-Output \"left behind, still locked: $path\" } }", + "Select-Object ProcessId,ParentProcessId,Name,ExecutablePath | Format-List", ], capture_output=True, text=True, diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f71d28a..3f0d5db 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,7 +43,7 @@ jobs: shell: bash run: | if [ "$RUNNER_OS" = "Windows" ]; then - pdm run python .github/scripts/smoke_frozen.py dist/gfl2logger.exe + pdm run python .github/scripts/smoke_frozen.py dist/gfl2logger/gfl2logger.exe else pdm run python .github/scripts/smoke_frozen.py dist/gfl2logger.app/Contents/MacOS/gfl2logger fi @@ -51,10 +51,9 @@ jobs: if: runner.os == 'Windows' shell: bash run: | - pdm run pyi-archive_viewer -l dist/gfl2logger.exe > archive-contents.txt - grep -qi "mitmproxy_windows" archive-contents.txt + test -e dist/gfl2logger/_internal/mitmproxy_windows/WinDivert64.sys mkdir -p release - cp dist/gfl2logger.exe "release/gfl2logger-${VERSION_LABEL}-windows-x64.exe" + (cd dist && pdm run python -m zipfile -c "../release/gfl2logger-${VERSION_LABEL}-windows-x64.zip" gfl2logger) - name: Package macOS artifacts if: runner.os == 'macOS' run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d142f7..c5634e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: tag_name: ${{ github.ref_name }} body_path: release-body.md files: | - release/gfl2logger-${{ github.ref_name }}-windows-x64.exe + release/gfl2logger-${{ github.ref_name }}-windows-x64.zip release/gfl2logger-${{ github.ref_name }}-macos-arm64.zip release/gfl2logger-${{ github.ref_name }}-macos-arm64.dmg overwrite_files: false diff --git a/README.md b/README.md index 96937cc..df482a3 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ both. | Platform | Game client | Download (v0.3.0) | | --- | --- | --- | -| **Windows** (x64) | PC client | [`gfl2logger-v0.3.0-windows-x64.exe`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-windows-x64.exe) | +| **Windows** (x64) | PC client | [`gfl2logger-v0.3.0-windows-x64.zip`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-windows-x64.zip) | | **macOS** (Apple silicon) | iPhone/iPad version from the Mac App Store | [`gfl2logger-v0.3.0-macos-arm64.dmg`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-macos-arm64.dmg) · [`gfl2logger-v0.3.0-macos-arm64.zip`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-macos-arm64.zip) | Older versions and release notes are on the [Releases](../../releases) page. @@ -24,14 +24,19 @@ it once the logger window is open. ### Windows -1. Download the `.exe` and place it in a folder you can write to. Exported - files are saved in the folder the program is started from. -2. Run it. The executable is not code-signed, so Windows SmartScreen may show +1. Download the `.zip` and extract it to a folder you can write to. Keep the + `_internal` folder next to `gfl2logger.exe`. +2. Run `gfl2logger.exe`. It is not code-signed, so Windows SmartScreen may show **Windows protected your PC**. If you downloaded it from this repository's Releases page, choose **More info > Run anyway**. 3. If Windows asks for administrator permission for mitmproxy's traffic redirector, allow it. +Exported files are saved in the folder the program is started from. If Windows +refuses to delete or replace the folder later because `WinDivert64.sys` is in +use, restart Windows first; the capture driver can stay loaded after the +program has closed. + ### macOS 1. Open the `.dmg` and drag `gfl2logger.app` to **Applications**, or unzip the @@ -60,10 +65,12 @@ mitmproxy project. 2. Start the game and log in. Open the Platoon pages to request Platoon data. 3. Each time the game receives one of the supported responses, a new file is written and the log on the right shows its path. +4. Closing the window ends the program completely. Nothing keeps running in the + background. | Platform | Exports and `gfl2logger.config.yaml` are saved in | | --- | --- | -| Windows | The folder the program is started from, normally the one containing the `.exe` | +| Windows | The folder the program is started from, normally the one containing `gfl2logger.exe` | | macOS | `~/gfl2logger` | ### Exported Data @@ -111,8 +118,8 @@ pdm install pdm run pyinstaller ``` -This creates `dist/gfl2logger.exe` on Windows and `dist/gfl2logger.app` on -macOS. To run from source or run the tests: +This creates `dist/gfl2logger/gfl2logger.exe` on Windows and +`dist/gfl2logger.app` on macOS. To run from source or run the tests: ```sh pdm run protoc diff --git a/README_KR.md b/README_KR.md index f5f89c7..26c33f1 100644 --- a/README_KR.md +++ b/README_KR.md @@ -11,7 +11,7 @@ | 플랫폼 | 게임 클라이언트 | 다운로드 (v0.3.0) | | --- | --- | --- | -| **Windows** (x64) | PC 클라이언트 | [`gfl2logger-v0.3.0-windows-x64.exe`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-windows-x64.exe) | +| **Windows** (x64) | PC 클라이언트 | [`gfl2logger-v0.3.0-windows-x64.zip`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-windows-x64.zip) | | **macOS** (Apple Silicon) | Mac App Store의 iPhone/iPad 버전 | [`gfl2logger-v0.3.0-macos-arm64.dmg`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-macos-arm64.dmg) · [`gfl2logger-v0.3.0-macos-arm64.zip`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-macos-arm64.zip) | 이전 버전과 릴리스 노트는 [Releases](../../releases) 페이지에 있습니다. @@ -23,14 +23,19 @@ ### Windows -1. `.exe`를 내려받아 쓰기 권한이 있는 폴더에 둡니다. 내보낸 파일은 프로그램을 - 실행한 폴더에 저장됩니다. -2. 실행합니다. 실행 파일에 코드 서명이 없어 Windows SmartScreen이 **Windows의 - PC 보호** 화면을 표시할 수 있습니다. 이 저장소의 Releases 페이지에서 받은 - 파일이라면 **추가 정보 > 실행**을 선택합니다. +1. `.zip`을 내려받아 쓰기 권한이 있는 폴더에 압축을 풉니다. `_internal` 폴더는 + `gfl2logger.exe`와 같은 위치에 그대로 둡니다. +2. `gfl2logger.exe`를 실행합니다. 코드 서명이 없어 Windows SmartScreen이 + **Windows의 PC 보호** 화면을 표시할 수 있습니다. 이 저장소의 Releases + 페이지에서 받은 파일이라면 **추가 정보 > 실행**을 선택합니다. 3. mitmproxy의 트래픽 리디렉터에 대해 Windows가 관리자 권한을 요청하면 허용합니다. +내보낸 파일은 프로그램을 실행한 폴더에 저장됩니다. 나중에 `WinDivert64.sys`가 +사용 중이라는 이유로 폴더를 삭제하거나 교체할 수 없다면 Windows를 다시 시작한 뒤 +시도하십시오. 캡처 드라이버는 프로그램을 닫은 뒤에도 로드된 상태로 남을 수 +있습니다. + ### macOS 1. `.dmg`를 열어 `gfl2logger.app`을 **응용 프로그램**으로 드래그하거나, `.zip`의 @@ -58,10 +63,12 @@ 2. 게임을 실행해 로그인합니다. 서클 데이터는 서클 페이지를 열면 수신됩니다. 3. 게임이 지원 대상 응답을 받을 때마다 새 파일이 기록되고, 오른쪽 로그에 저장 경로가 표시됩니다. +4. 창을 닫으면 프로그램이 완전히 종료됩니다. 백그라운드에 남는 프로세스는 + 없습니다. | 플랫폼 | 내보낸 파일과 `gfl2logger.config.yaml`의 저장 위치 | | --- | --- | -| Windows | 프로그램을 실행한 폴더(보통 `.exe`가 있는 폴더) | +| Windows | 프로그램을 실행한 폴더(보통 `gfl2logger.exe`가 있는 폴더) | | macOS | `~/gfl2logger` | ### 내보내는 데이터 @@ -109,8 +116,8 @@ pdm install pdm run pyinstaller ``` -Windows에서는 `dist/gfl2logger.exe`, macOS에서는 `dist/gfl2logger.app`이 -생성됩니다. 소스에서 직접 실행하거나 테스트를 돌리려면 다음 명령을 사용합니다. +Windows에서는 `dist/gfl2logger/gfl2logger.exe`, macOS에서는 +`dist/gfl2logger.app`이 생성됩니다. 소스에서 직접 실행하거나 테스트를 돌리려면 다음 명령을 사용합니다. ```sh pdm run protoc diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 54c82f7..a7e19e2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -9,16 +9,34 @@ published as v0.1.x, joins the same numbering. | Platform | Game client | Download | | --- | --- | --- | -| **Windows** (x64) | PC client | [`gfl2logger-v0.3.0-windows-x64.exe`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-windows-x64.exe) | +| **Windows** (x64) | PC client | [`gfl2logger-v0.3.0-windows-x64.zip`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-windows-x64.zip) | | **macOS** (Apple silicon) | iPhone/iPad version from the Mac App Store | [`gfl2logger-v0.3.0-macos-arm64.dmg`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-macos-arm64.dmg) · [`gfl2logger-v0.3.0-macos-arm64.zip`](../../releases/download/v0.3.0/gfl2logger-v0.3.0-macos-arm64.zip) | ## Changes +### Both platforms + +- Closing the window now ends the program completely and at once. The window no + longer waits for the capture process to answer, **Cmd+Q** on macOS takes the + same path as the close button, and the capture process exits within a few + seconds of the window going away for any reason. Nothing is left running in + the background. +- The window opens before the capture set-up starts instead of after it. When + that set-up was slow or stalled, the program previously ran with nothing on + screen. +- Every change is now tested and built on Windows and macOS, including starting + and closing the packaged program, and a release publishes both platforms + together. + ### Windows, since v0.2.5 - Added Platoon Profile (`21905`), Activity (`21935`), and Updates (`21960`) exports alongside Members, and grouped the options into **Platoon** and **Others**. +- The download is now a `.zip` containing a folder instead of a single `.exe`. + The single-file form unpacks itself to a temporary folder on every start and + could not remove it on exit, because the capture driver file inside stays in + use; it ended with a warning dialog and left the folder behind. - Kept message bytes that arrive split across TCP chunks and finished parsing data already received when a connection closes, so responses are no longer dropped or misread at chunk boundaries. @@ -28,32 +46,30 @@ published as v0.1.x, joins the same numbering. timeout. - Wrote exports to a temporary file and published them only when complete, and neutralized formula-leading member names in CSV exports. -- Shut the proxy down when the window process exits unexpectedly, and started - with default options when `gfl2logger.config.yaml` is unreadable. +- Started with default options when `gfl2logger.config.yaml` is unreadable. - Updated mitmproxy to 12.2.3. The capture path on Windows is unchanged from v0.2.5. This build was verified -by automated tests, packaging, and a window start-up check on Windows, and has -not yet been run against a live game session. Please report any problems. +on Windows by automated tests and by starting and closing the packaged program; +it has not yet been run against a live game session. Please report any +problems. ### macOS, since v0.1.2 +- Fixed the app sometimes not showing a window when its icon was clicked. An + earlier session whose window had been closed with **Cmd+Q** kept running + without a window, so the next click only activated that leftover process. - Renamed the app from `gf2logger.app` to `gfl2logger.app` to match the project. Replace the old app with the new one; exports and settings stay in `~/gfl2logger`. -- No functional changes. The version number moves to v0.3.0 to match Windows. - -### Both - -- Every change is now tested and built on Windows and macOS, and a release - publishes both platforms together. +- The version number moves to v0.3.0 to match Windows. ## Installation -**Windows:** download the `.exe`, place it in a folder you can write to, and -run it. If SmartScreen shows **Windows protected your PC**, choose **More -info > Run anyway**. Allow the administrator prompt for mitmproxy's traffic -redirector if Windows shows one. +**Windows:** download the `.zip`, extract it to a folder you can write to, and +run `gfl2logger.exe` inside it. If SmartScreen shows **Windows protected your +PC**, choose **More info > Run anyway**. Allow the administrator prompt for +mitmproxy's traffic redirector if Windows shows one. **macOS:** open the `.dmg` and drag `gfl2logger.app` to **Applications**, or unzip the `.zip`. The application remains ad-hoc signed and is not yet diff --git a/gfl2logger.spec b/gfl2logger.spec index f3d2161..aba69d6 100644 --- a/gfl2logger.spec +++ b/gfl2logger.spec @@ -71,19 +71,20 @@ if is_macos: }, ) else: + # A folder build rather than a single file. A single-file build unpacks itself into + # a temporary directory on every start and removes it on exit, but the WinDivert + # driver file loaded from there stays in use, so the removal fails and the launcher + # is left showing a warning. A folder build has nothing to clean up. exe = EXE( pyz, a.scripts, - a.binaries, - a.datas, [('O', None, 'OPTION'), ('O', None, 'OPTION')], + exclude_binaries=True, name='gfl2logger', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, - upx_exclude=[], - runtime_tmpdir=None, console=False, disable_windowed_traceback=False, argv_emulation=False, @@ -92,3 +93,12 @@ else: entitlements_file=None, icon=icon, ) + coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='gfl2logger', + ) diff --git a/tests/test_release_consistency.py b/tests/test_release_consistency.py index 1e768d8..8239c65 100644 --- a/tests/test_release_consistency.py +++ b/tests/test_release_consistency.py @@ -6,7 +6,7 @@ TAG = f"v{VERSION}" ASSETS = ( - f"gfl2logger-{TAG}-windows-x64.exe", + f"gfl2logger-{TAG}-windows-x64.zip", f"gfl2logger-{TAG}-macos-arm64.dmg", f"gfl2logger-{TAG}-macos-arm64.zip", ) @@ -74,6 +74,13 @@ def test_both_platforms_are_built_from_one_product_name(self) -> None: self.assertIn("windows-latest", build) self.assertIn("macos-15", build) + def test_windows_is_not_built_as_a_self_extracting_single_file(self) -> None: + # The WinDivert driver stays loaded from the program's own files; a single-file + # build could never remove its temporary directory on exit. + spec = read("gfl2logger.spec") + self.assertEqual(spec.count("exclude_binaries=True"), 2) + self.assertEqual(spec.count("COLLECT("), 2) + if __name__ == "__main__": unittest.main()