diff --git a/.github/scripts/smoke_frozen.py b/.github/scripts/smoke_frozen.py new file mode 100644 index 0000000..c3dcd18 --- /dev/null +++ b/.github/scripts/smoke_frozen.py @@ -0,0 +1,161 @@ +"""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 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 = 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" +WINDOW_PROCESS_MARK = "--multiprocessing-fork" + + +def processes(executable: str) -> list[dict]: + """Return pid, parent pid and command line of every running app process.""" + if WINDOWS: + listing = subprocess.run( + [ + "powershell", + "-NoProfile", + "-Command", + "Get-CimInstance Win32_Process -Filter \"name='gfl2logger.exe'\" | " + "Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json", + ], + capture_output=True, + text=True, + ).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( + ["ps", "-axo", "pid=,ppid=,command="], capture_output=True, text=True + ).stdout + found = [] + for line in listing.splitlines(): + pid, parent, command = line.split(None, 2) + # 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 + + +def describe(found: list[dict]) -> None: + for item in found: + print(f" pid={item['pid']} parent={item['parent']} {item['command']}") + + +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: + for item in processes(executable): + try: + os.kill(item["pid"], signal.SIGKILL) + except ProcessLookupError: + pass + process.wait(timeout=30) + + +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", + ], + capture_output=True, + text=True, + ) + print(report.stdout.strip() or report.stderr.strip()) + + +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 = processes(executable) + 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 <= len(started) <= MAX_PROCESSES: + print(f"FAIL: expected 2..{MAX_PROCESSES} processes") + 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_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) + if WINDOWS: + diagnose_windows() + force_stop(process, executable) + return 1 + + elapsed = time.monotonic() - closed_at + print(f"OK: window shown, and every process was gone {elapsed:.1f}s after closing") + 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..3f0d5db --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,72 @@ +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/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: | + test -e dist/gfl2logger/_internal/mitmproxy_windows/WinDivert64.sys + mkdir -p release + (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: | + 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..c5634e4 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.zip + 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/README.md b/README.md index 6237452..df482a3 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,142 @@ -# 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.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. + +## 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 `.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 + `.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. +4. Closing the window ends the program completely. Nothing keeps running in the + background. -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 `gfl2logger.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/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..26c33f1 100644 --- a/README_KR.md +++ b/README_KR.md @@ -1,110 +1,140 @@ -# 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.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) | -> [!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. `.zip`을 내려받아 쓰기 권한이 있는 폴더에 압축을 풉니다. `_internal` 폴더는 + `gfl2logger.exe`와 같은 위치에 그대로 둡니다. +2. `gfl2logger.exe`를 실행합니다. 코드 서명이 없어 Windows SmartScreen이 + **Windows의 PC 보호** 화면을 표시할 수 있습니다. 이 저장소의 Releases + 페이지에서 받은 파일이라면 **추가 정보 > 실행**을 선택합니다. +3. mitmproxy의 트래픽 리디렉터에 대해 Windows가 관리자 권한을 요청하면 + 허용합니다. -Redirector가 자동으로 활성화되지 않으면 **시스템 설정 > 일반 > 로그인 항목 및 확장 프로그램 > 네트워크 확장 프로그램**을 열고, -**Mitmproxy Redirector**를 활성화한 다음 `gfl2logger`를 다시 실행합니다. +내보낸 파일은 프로그램을 실행한 폴더에 저장됩니다. 나중에 `WinDivert64.sys`가 +사용 중이라는 이유로 폴더를 삭제하거나 교체할 수 없다면 Windows를 다시 시작한 뒤 +시도하십시오. 캡처 드라이버는 프로그램을 닫은 뒤에도 로드된 상태로 남을 수 +있습니다. -애플리케이션 자체는 현재 Developer ID로 서명 및 공증된 상태가 아니라 임시(ad-hoc) 서명된 상태입니다. -번들로 제공되는 네트워크 Redirector는 mitmproxy 프로젝트에서 별도로 서명하고 공증했습니다. +### macOS -## 사용법 +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`를 다시 + 실행합니다. -1. GF2보다 먼저 `gfl2logger`를 실행합니다. GF2가 이미 실행 중이라면 로거가 - 준비된 후 GF2를 다시 시작합니다. -2. 원하는 페이로드 체크박스를 활성화된 상태로 둡니다. -3. GF2를 실행하고 서클 페이지를 열어 관련 데이터를 요청합니다. +번들로 제공되는 네트워크 리디렉터는 mitmproxy 프로젝트가 별도로 서명하고 +공증했습니다. -내보낸 파일과 `gfl2logger.config.yaml`은 `~/gfl2logger`에 저장됩니다. +## 사용법 -체크박스는 두 그룹으로 구성됩니다. +1. 저장하려는 데이터의 체크박스를 켜 둡니다. **Save config**를 누르면 다음 + 실행에도 선택이 유지됩니다. +2. 게임을 실행해 로그인합니다. 서클 데이터는 서클 페이지를 열면 수신됩니다. +3. 게임이 지원 대상 응답을 받을 때마다 새 파일이 기록되고, 오른쪽 로그에 저장 + 경로가 표시됩니다. +4. 창을 닫으면 프로그램이 완전히 종료됩니다. 백그라운드에 남는 프로세스는 + 없습니다. -- **Platoon:** Platoon Profile, Members, Activity, Updates -- **Others:** Weapons, Attachments, Common Keys, Formations +| 플랫폼 | 내보낸 파일과 `gfl2logger.config.yaml`의 저장 위치 | +| --- | --- | +| Windows | 프로그램을 실행한 폴더(보통 `gfl2logger.exe`가 있는 폴더) | +| macOS | `~/gfl2logger` | ### 내보내는 데이터 -| 옵션 | 페이로드 | 설명 | 형식 | -| --- | ---: | --- | --- | -| 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 | +창에서 옵션은 두 그룹으로 나뉘어 표시됩니다. + +| 그룹 | 옵션 | 페이로드 | 설명 | 수신 시점 | 형식 | +| --- | --- | ---: | --- | --- | --- | +| 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 | + +파일 이름은 `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/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..a7e19e2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,34 +1,80 @@ -# 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.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. - 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. +- 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 +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`. +- The version number moves to v0.3.0 to match Windows. ## 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 `.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 +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.spec b/gfl2logger.spec index 755acbf..aba69d6 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,33 +57,34 @@ 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, }, ) 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/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 90deca3..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 @@ -31,16 +32,21 @@ 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: 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/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(): 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_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_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..8239c65 --- /dev/null +++ b/tests/test_release_consistency.py @@ -0,0 +1,86 @@ +import re +import unittest +from pathlib import Path + +from gfl2logger.utils.version import VERSION + +TAG = f"v{VERSION}" +ASSETS = ( + f"gfl2logger-{TAG}-windows-x64.zip", + 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) + + 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() diff --git a/tests/test_robustness.py b/tests/test_robustness.py index 9de48de..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)) @@ -324,9 +430,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"),