Skip to content
This repository was archived by the owner on Jan 14, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 109 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# ============================== SECTION 1: IMPORTS / CONSTANTS / GLOBALS (START) ==============================
import os, sys, time, json, math, socket, threading, queue, subprocess, platform, re, hashlib, random, shutil
import os, sys, time, json, math, socket, threading, queue, subprocess, platform, re, hashlib, random, shutil, logging
from datetime import datetime
from ipaddress import IPv4Address
from concurrent.futures import ThreadPoolExecutor
from collections import deque
from typing import List
from pathlib import Path

from mcsmartscan.constants import (
Expand All @@ -21,6 +22,10 @@
PROTOCOL_CANDIDATES,
PROTOCOL_TO_VERSION_HINT,
SAVED_SERVERS_FILE,
PROXY_IO_THREADS,
PROXY_LOG_CAPACITY,
PROXY_LOG_DEBOUNCE_MS,
PROXY_UI_REFRESH_MS,
ping_to_bars,
)
from mcsmartscan.storage import StorageManager
Expand All @@ -35,6 +40,8 @@
ProxyHandshakeError,
ProxyPool,
ProxyTargetError,
emit_event,
get_event_buffer,
)

try:
Expand All @@ -58,6 +65,9 @@
except Exception:
_nmap_available = False

logger = logging.getLogger(__name__)
logger.addHandler(logging.NullHandler())

_gui_available = True
try:
import tkinter as tk
Expand Down Expand Up @@ -1050,6 +1060,11 @@ def _init_proxy_pool(self):
if pool:
self.proxy_pool = pool
self._proxy_enabled = True
try:
self.proxy_pool.prepare_for_run()
self._uiq_put(("proxy-log", "[PROXY] starting initial health probes...", "info"))
except Exception:
self._uiq_put(("proxy-log", "[PROXY] failed to start initial health probes", "warn"))
self._uiq_put(("log", f"[PROXY] Loaded {pool.total} Mullvad proxy endpoints.", "info"))
else:
self.proxy_pool = None
Expand Down Expand Up @@ -1697,6 +1712,38 @@ def stop_scan(self):
self._save_current_blob(immediate=True)
self._ctl_async("[SCAN] Stopped")

def on_close(self):
"""Gracefully unwind background activity before closing the window."""
if getattr(self, "_closing", False):
return
self._closing = True
try:
if self.scanning:
self.stop_scan()
else:
self._stop.set()
self._pause.clear()
self._save_current_blob(immediate=True)
except Exception:
pass
try:
self.vpn_manager.stop()
except Exception:
pass
try:
if self.proxy_pool:
self.proxy_pool.shutdown(wait=False)
except Exception:
pass
try:
self.root.quit()
except Exception:
pass
try:
self.root.destroy()
except Exception:
pass

def _submitter(self, ip_iter, timeout):
try:
if not self.executor:
Expand Down Expand Up @@ -2451,6 +2498,32 @@ def _handle_proxy_event_ui(self, event: dict) -> None:
elif kind == "target-failure":
error = event.get("error") or "target failure"
self._proxy_log(f"[PROXY] {label} target error: {error}", "warn")
elif kind == "health":
ok = bool(event.get("ok"))
latency = event.get("latency_ms")
exit_ip = event.get("exit_ip")
country = event.get("country")
server_type = event.get("server_type")
pieces = []
if latency is not None:
pieces.append(f"{latency:.1f} ms")
if country:
pieces.append(str(country))
if server_type:
pieces.append(str(server_type))
if exit_ip:
pieces.append(str(exit_ip))
detail = " | ".join(pieces) if pieces else "no telemetry"
if ok:
self._proxy_log(f"[HEALTH] {label} OK ({detail})", "success")
if latency is not None:
self.var_proxy_health_hint.set(f"{label} health OK {latency:.1f} ms")
else:
self.var_proxy_health_hint.set(f"{label} health OK")
else:
error = event.get("error") or "health check failed"
self._proxy_log(f"[HEALTH] {label} failed ({error})", "error")
self.var_proxy_health_hint.set(f"{label} health error")
elif kind == "success":
latency = event.get("latency_ms")
if latency is not None:
Expand Down Expand Up @@ -2504,6 +2577,21 @@ def _refresh_proxy_health_ui(self):
and (item.get("disabled") or 0) <= 0.05
)
)
healthy_count = sum(
1
for item in snapshot
if item.get("health_ok") and item.get("health_fresh")
)
unhealthy_count = sum(
1
for item in snapshot
if item.get("health_fresh") and not item.get("health_ok")
)
stale_count = sum(
1
for item in snapshot
if item.get("health_last_probe_ts") and not item.get("health_fresh")
)
idle = max(0, total - in_use - cooling - quarantine_count - disabled_count)
summary_parts = [f"Proxies {total}", f"in use {in_use}"]
if disabled_count:
Expand All @@ -2512,6 +2600,12 @@ def _refresh_proxy_health_ui(self):
summary_parts.append(f"quarantine {quarantine_count}")
if cooling:
summary_parts.append(f"cooling {cooling}")
if healthy_count:
summary_parts.append(f"health ok {healthy_count}")
if unhealthy_count:
summary_parts.append(f"health fail {unhealthy_count}")
if stale_count:
summary_parts.append(f"health stale {stale_count}")
summary_parts.append(f"idle {idle}")
summary = " | ".join(summary_parts)
if not self._proxy_enabled:
Expand Down Expand Up @@ -2543,11 +2637,24 @@ def _refresh_proxy_health_ui(self):
status = f"Cooling {cooldown:.1f}s"
else:
status = "Idle"
health_label = None
health_fresh = entry.get("health_fresh")
health_ok = entry.get("health_ok")
if health_fresh:
health_label = "health ok" if health_ok else "health fail"
elif entry.get("health_last_probe_ts"):
health_label = "health stale"
if status == "Idle" and health_label:
status = health_label.title()
elif health_label:
status = f"{status} ({health_label})"
last_stage = entry.get("last_stage")
last_error = entry.get("last_error")
if last_error and not in_use_flag:
status = f"{status} ({last_stage or 'error'})"
latency = entry.get("last_latency_ms")
if latency is None:
latency = entry.get("health_latency_ms")
latency_str = f"{latency:.1f}" if latency is not None else "-"
self.proxy_tree.insert(
"",
Expand Down Expand Up @@ -3273,6 +3380,7 @@ def main():
try:
root = tk.Tk()
app = ScannerAppGUI(root)
root.protocol("WM_DELETE_WINDOW", app.on_close)

# Ensure loops run
try: root.after(0, app._pump_ui)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: Build Windows Release

on:
workflow_dispatch:
push:
tags:
- 'v*'

permissions:
contents: write

jobs:
build-and-release:
runs-on: windows-latest

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'

- name: Install build dependencies
shell: pwsh
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pyinstaller

- name: Build standalone executable
shell: pwsh
run: |
pyinstaller --clean --noconfirm minecraft_server_finder.spec

- name: Determine release version
shell: pwsh
run: |
$ref = '${{ github.ref }}'
$refName = '${{ github.ref_name }}'

if ($ref -like 'refs/tags/*' -and -not [string]::IsNullOrWhiteSpace($refName)) {
$version = $refName
}
else {
$version = 'v' + '${{ github.run_number }}'
}

$safeLabel = $version -replace '[^0-9A-Za-z._-]', '_'

"RELEASE_VERSION=$version" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append
"BUILD_LABEL=$safeLabel" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append

- name: Prepare release artifact
shell: pwsh
run: |
$distDir = Join-Path (Get-Location) 'dist'
$sourceExe = Join-Path $distDir 'MinecraftServerFinder.exe'
if (-not (Test-Path $sourceExe)) {
throw "Expected executable not found: $sourceExe"
}

$releaseDir = Join-Path (Get-Location) 'release'
New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null

$targetExe = Join-Path $releaseDir ("MinecraftServerFinder-${{ env.BUILD_LABEL }}.exe")
Copy-Item $sourceExe -Destination $targetExe -Force

- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: MinecraftServerFinder-${{ env.BUILD_LABEL }}
path: release/MinecraftServerFinder-${{ env.BUILD_LABEL }}.exe
if-no-files-found: error

- name: Publish GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.RELEASE_VERSION }}
name: MinecraftServerFinder ${{ env.RELEASE_VERSION }}
files: release/MinecraftServerFinder-${{ env.BUILD_LABEL }}.exe
generate_release_notes: true
draft: false
prerelease: false
make_latest: true
fail_on_unmatched_files: true
allow_updates: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: Python application

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

permissions:
contents: read

jobs:
build:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pytest
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Simple workflow for deploying static content to GitHub Pages
name: Deploy static content to Pages

on:
# Runs on pushes targeting the default branch
push:
branches: ["main"]

# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:

# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write

# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false

jobs:
# Single deploy job since we're just deploying
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
# Upload entire repository
path: '.'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
Loading
Loading