Compare commits

..
Author SHA1 Message Date
aclist e7cef215c6 drop: resolved comment
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-11-02 12:19:10 +09:00
aclist 90ef6cbdbb chore: add tests 2026-11-02 12:17:04 +09:00
aclist 0ec1c157f4 chore: clear typehinting errors 2026-11-01 23:45:01 +09:00
aclist 0cf6439538 chore: remove unused imports 2026-11-01 23:43:12 +09:00
aclist e3c1ef59ee feat: get last updated timestamp 2026-11-01 23:42:06 +09:00
14 changed files with 226 additions and 172 deletions

No files matched your search

+23
View File
@@ -2,6 +2,7 @@ import logging
import re
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
from typing import Any
from warnings import deprecated
@@ -69,6 +70,28 @@ class ACF:
except StopIteration:
return acf
def search_key(self, key: str) -> str:
return str(self.dict["AppState"][key])
def date_to_str(self, epoch: int) -> str | None:
if epoch == 0:
return None
return datetime.fromtimestamp(epoch).isoformat(" ")
def get_date_by_key(self, key: str) -> str | None:
try:
stamp = int(self.search_key(key))
return self.date_to_str(stamp)
except Exception as e:
logger.critical(e)
return None
def get_last_updated(self) -> str | None:
return self.get_date_by_key("LastUpdated")
def get_last_played(self) -> str | None:
return self.get_date_by_key("LastPlayed")
def get_allows_downloads(self) -> int | None:
try:
# TODO: consider coercing strs to ints on initial import
+12
View File
@@ -389,6 +389,18 @@ def get_running_app() -> int | None:
return None
def get_last_updated(path: Path, appid: int) -> str | None:
root_path = get_app_path(path, appid)
acf = root_path.joinpath(f"steamapps/appmanifest_{appid}.acf")
return ACF(acf).get_last_updated()
def get_last_played(path: Path, appid: int) -> str | None:
root_path = get_app_path(path, appid)
acf = root_path.joinpath(f"steamapps/appmanifest_{appid}.acf")
return ACF(acf).get_last_played()
def get_app_allows_downloads(path: Path, appid: int) -> bool:
try:
root_path = get_app_path(path, appid)
+3 -102
View File
@@ -3,9 +3,6 @@ import logging
import re
import requests
import shutil
import socket
import sqlite3
import time
from pathlib import Path
@@ -25,16 +22,15 @@ def find_download_url(url: str) -> str | None:
return str(urls[0])
def get_ipdb(db_path: Path) -> None:
def get_ipdb(ips_path: Path) -> None:
try:
url = find_download_url(DB_IP)
if url is None:
return
ips_path = db_path.with_suffix(".csv")
month_file = db_path.parent / ".month"
date = find_date(url)
if db_path.exists() and month_file.exists():
month_file = ips_path.parent / ".month"
if ips_path.exists() and month_file.exists():
old_date = month_file.read_text().rstrip("\n")
if old_date == date:
logger.info(f"IP DB date matches: {date}")
@@ -49,9 +45,6 @@ def get_ipdb(db_path: Path) -> None:
logger.info("Stripping IPv6 records")
strip_ipv6(ips_path)
create_table(db_path, ips_path)
ips_path.unlink()
with open(month_file, "w") as f:
f.write(date)
logger.info(f"Wrote {date} to {month_file}")
@@ -115,95 +108,3 @@ def strip_ipv6(path: Path) -> None:
out.write(merged)
path.unlink()
alt_path.rename(path)
def ip_to_int(ip: str) -> int:
return int.from_bytes(socket.inet_aton(ip), "big")
def marshal_rows(ip_file: Path) -> list[tuple[int, int, str, str]]:
YIELD = 0.003
SLEEP = 0.001
with open(ip_file, "r") as f:
rows: list[tuple[int, int, str, str]] = []
# NOTE: yield to GIL periodically to reduce load on spinner
last_yield = time.monotonic()
for row in f:
start, end, lat, lon = row.rstrip("\n").split(",")
int_start = ip_to_int(start)
int_end = ip_to_int(end)
rows.append((int_start, int_end, lat, lon))
now = time.monotonic()
if (now - last_yield) >= YIELD:
time.sleep(SLEEP)
last_yield = time.monotonic()
return rows
def create_table(db_file: Path, ip_file: Path) -> None:
create = """
CREATE TABLE IF NOT EXISTS ips (
start_range INTEGER PRIMARY KEY,
end_range INTEGER,
latitude REAL,
longitude REAL
) WITHOUT ROWID;
"""
insert = """
INSERT INTO ips
(start_range, end_range, latitude, longitude)
values(?, ?, ?, ?)
"""
if db_file.exists():
db_file.unlink()
conn = sqlite3.connect(":memory:")
c = conn.cursor()
try:
c.execute(create)
rows = marshal_rows(ip_file)
c.executemany(insert, rows)
logger.info(f"Inserted {len(rows)} IP records into database")
conn.commit()
with sqlite3.connect(db_file) as dest:
conn.backup(dest)
logger.info(f"Serialized IP records to database: '{db_file}'")
conn.close()
except Exception as e:
logger.critical(e)
def read_from_table(db_path: Path, ip: str) -> tuple[float, float]:
if not db_path.exists():
raise FileNotFoundError(f"No file found at the location '{db_path}")
conn = sqlite3.connect(db_path)
c = conn.cursor()
int_ip = ip_to_int(ip)
query = """
SELECT end_range, latitude, longitude
FROM ips
WHERE start_range <= ?
ORDER BY start_range DESC
LIMIT 1
"""
index = """
CREATE INDEX IF NOT EXISTS start_index
ON ips(start_range);
"""
try:
c.execute(index)
conn.commit()
c.execute(query, (int_ip,))
row = c.fetchone()
end, lat, lon = row
conn.close()
return lat, lon
except Exception as e:
logger.critical(e)
raise e
+3 -3
View File
@@ -22,7 +22,7 @@ class Xdg:
version: Path
system: Path
debug: Path
db: Path
ips: Path
filters: Path
shortcut: Path
@@ -88,7 +88,7 @@ def parse_filepaths(xdg: dict) -> Xdg:
resolution = state / "dzg.res.json"
history = state / "dzg.history"
versions = state / "dzg.versions"
db = state / "ips.db"
ips = state / "ips.csv"
filters = state / "dzg.filters.json"
shortcut = share / APP_NAME_LOWER
@@ -102,7 +102,7 @@ def parse_filepaths(xdg: dict) -> Xdg:
versions,
system,
debug,
db,
ips,
filters,
shortcut,
)
+2 -2
View File
@@ -10,10 +10,10 @@ if TYPE_CHECKING:
logger = logging.getLogger(APP_NAME)
def get_local_coords(db_path: "Path") -> Coords | None:
def get_local_coords(path: "Path") -> Coords | None:
try:
my_ip = get_local_ip()
return get_coords(db_path, my_ip)
return get_coords(path, my_ip)
except Exception as e:
logger.warn(e)
return None
+2 -3
View File
@@ -80,9 +80,8 @@ class CalcDist:
if local is None:
return None
try:
remote = get_coords(prefs.paths.db, remote_ip)
except Exception as e:
logger.critical(e)
remote = get_coords(prefs.paths.ips, remote_ip)
except Exception:
return None
haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon)
+43 -12
View File
@@ -1,5 +1,4 @@
import a2s
from ipaddress import ip_address
import requests
import shutil
import subprocess
@@ -8,7 +7,6 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING
from dzgui.api.servers import Record
from dzgui.config.ipdb import read_from_table
from dzgui.const.constants import REQUEST_TIMEOUT
from dzgui.const.endpoints import IP_ECHO
@@ -35,20 +33,53 @@ def ip2list(ip: str) -> list:
return [int(digit) for digit in ip.split(".")]
def is_usable(ip: str) -> bool:
address = ip_address(ip)
if address.is_reserved or address.is_private:
return False
# TODO: tests
def is_reserved(digits: list) -> bool:
reserved = [0, 10, 127]
if digits[0] in reserved:
return True
if digits[0] == 192 and digits[1] == 168:
return True
return False
def get_coords(db_path: "Path", ip: str) -> Coords:
if not is_usable(ip):
def get_coords(ips: "Path", ip: str) -> Coords:
split = ip2list(ip)
if is_reserved(split):
raise ReservedIPError(f"{ip} is a reserved IP address.")
try:
lat, lon = read_from_table(db_path, ip)
return Coords(lat, lon)
except Exception:
prefix = f"^{split[0]}.{split[1]}."
if shutil.which("rg") is not None:
args = ["/usr/bin/rg", prefix, str(ips)]
else:
args = ["/usr/bin/grep", "-E", prefix, str(ips)]
proc = subprocess.run(args, capture_output=True, text=True)
if proc.returncode != 0:
raise GeolocationError("Failed to split records")
ip_list = proc.stdout.splitlines()
for address in ip_list:
fields = address.split(",")
upper = fields[1]
upper_digits = ip2list(upper)
if ip == upper:
return Coords(float(fields[-2]), float(fields[-1]))
if split[2] > upper_digits[2]:
continue
if split[2] == upper_digits[2]:
if split[3] > upper_digits[3]:
continue
if split[2] <= upper_digits[2]:
return Coords(float(fields[-2]), float(fields[-1]))
raise GeolocationError("No matching records found")
+2 -2
View File
@@ -132,9 +132,9 @@ class BootDialog(ColorAwareApp, Gtk.Dialog): # type: ignore
preboot.signatures,
False,
),
(StoredFunc(get_ipdb, self.xdg.db), preboot.geo, False),
(StoredFunc(get_ipdb, self.xdg.ips), preboot.geo, False),
# TODO: handle IP DB failure and use coords fallback
(StoredFunc(get_local_coords, self.xdg.db), preboot.coords, True),
(StoredFunc(get_local_coords, self.xdg.ips), preboot.coords, True),
(StoredFunc(check_updates, self.version), preboot.updates, True),
]
self.results: list[Any] = []
+13 -2
View File
@@ -2,6 +2,7 @@ from pathlib import Path
from typing import Callable, TYPE_CHECKING
from dzgui.api import pefile as PeFile
from dzgui.api.steam import get_last_updated
from dzgui.config import query
from dzgui.const.constants import (
@@ -340,8 +341,10 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
[LeftLabel(options.flatpak_label), self.flatpak_toggle],
)
self.dayz_version_label = Gtk.Label(label=strings.null)
self.dayz_exp_version_label = Gtk.Label(label=strings.null)
self.dayz_version_label = Gtk.Label(label=strings.null, halign=Gtk.Align.START)
self.dayz_exp_version_label = Gtk.Label(
label=strings.null, halign=Gtk.Align.START
)
version_rows = [
[LeftLabel(APPNAME_DAYZ), self.dayz_version_label],
@@ -467,10 +470,18 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ)
if dayz_version is None:
dayz_version = strings.null
else:
last_updated = get_last_updated(steam_path, APPID_DAYZ)
if last_updated is not None:
dayz_version += f" (updated at {last_updated})"
dayz_exp_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP)
if dayz_exp_version is None:
dayz_exp_version = strings.null
else:
last_updated = get_last_updated(steam_path, APPID_DAYZ_EXP)
if last_updated is not None:
dayz_exp_version += f" (updated at {last_updated})"
self.dayz_version_label.set_text(dayz_version)
self.dayz_exp_version_label.set_text(dayz_exp_version)
+53
View File
@@ -0,0 +1,53 @@
"AppState"
{
"appid" "444"
"Universe" "1"
"name" "TEST APP"
"StateFlags" "4"
"installdir" "TESTAPP"
"LastUpdated" "1792595373"
"LastPlayed" "0"
"SizeOnDisk" "0"
"StagingSize" "0"
"buildid" "0"
"LastOwner" "0"
"DownloadType" "2"
"UpdateResult" "0"
"BytesToDownload" "0"
"BytesDownloaded" "0"
"BytesToStage" "0"
"BytesStaged" "0"
"TargetBuildID" "0"
"AutoUpdateBehavior" "0"
"AllowOtherDownloadsWhileRunning" "0"
"ScheduledAutoUpdate" "0"
"FullValidateAfterNextUpdate" "1"
"InstalledDepots"
{
"3330"
{
"manifest" "0"
"size" "0"
}
}
"SharedDepots"
{
"228983" "228980"
"228985" "228980"
"228988" "228980"
"228990" "228980"
"229004" "228980"
}
"UserConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
"MountedConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
}
+53
View File
@@ -0,0 +1,53 @@
"AppState"
{
"appid" "555"
"Universe" "1"
"name" "TEST APP"
"StateFlags" "4"
"installdir" "TESTAPP"
"LastUpdated" "0"
"LastPlayed" "0"
"SizeOnDisk" "0"
"StagingSize" "0"
"buildid" "0"
"LastOwner" "0"
"DownloadType" "2"
"UpdateResult" "0"
"BytesToDownload" "0"
"BytesDownloaded" "0"
"BytesToStage" "0"
"BytesStaged" "0"
"TargetBuildID" "0"
"AutoUpdateBehavior" "0"
"AllowOtherDownloadsWhileRunning" "0"
"ScheduledAutoUpdate" "0"
"FullValidateAfterNextUpdate" "1"
"InstalledDepots"
{
"3330"
{
"manifest" "0"
"size" "0"
}
}
"SharedDepots"
{
"228983" "228980"
"228985" "228980"
"228988" "228980"
"228990" "228980"
"229004" "228980"
}
"UserConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
"MountedConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
}
+9 -1
View File
@@ -3,7 +3,7 @@ import pytest
from _pytest.monkeypatch import MonkeyPatch
from pathlib import Path
from dzgui.api.steam import get_app_allows_downloads
from dzgui.api.steam import get_app_allows_downloads, get_last_updated
from tests.fixtures import fixture_path
pytestmark = pytest.mark.apitest
@@ -43,6 +43,14 @@ def patch_api() -> None:
mp.undo()
def test_no_last_update(steam_path) -> None:
assert get_last_updated(steam_path, 555) is None
def test_last_update(steam_path) -> None:
assert get_last_updated(steam_path, 444) == "2026-10-22 00:09:33"
def test_app_allows_downloads(steam_path) -> None:
assert get_app_allows_downloads(steam_path, 111) is True
+7 -44
View File
@@ -1,40 +1,16 @@
from dzgui.config.ipdb import get_ipdb
from pathlib import Path
import os
import sqlite3
import pytest
import tempfile
from pathlib import Path
from dzgui.const.endpoints import DB_IP
from dzgui.config.ipdb import (
create_table,
find_download_url,
serialize,
strip_ipv6,
unzip,
)
def get_db_len(path: Path) -> int:
conn = sqlite3.connect(path)
c = conn.cursor()
c.execute("SELECT COUNT(*) from ips")
db_len = c.fetchone()[0]
return db_len
@pytest.fixture
def csv() -> Path:
csv = Path(tempfile.NamedTemporaryFile().name)
url = find_download_url(DB_IP)
tmp = serialize(url)
unzip(tmp, csv)
strip_ipv6(csv)
return csv
@pytest.mark.slow
def test_stripping(csv):
with open(csv, "rb") as f:
def test_stripping():
d = tempfile.TemporaryDirectory()
path = Path(d.name).joinpath("ips.csv")
get_ipdb(path)
with open(path, "rb") as f:
first = f.readline().decode().rstrip()
try:
f.seek(-2, os.SEEK_END)
@@ -49,16 +25,3 @@ def test_stripping(csv):
assert first_els[0] == "0.0.0.0"
assert last_els[0] == "224.0.0.0"
@pytest.mark.slow
def test_row_parity(csv) -> None:
rows = csv.read_text().splitlines()
db = Path(tempfile.NamedTemporaryFile().name)
create_table(db, csv)
csv_len = len(rows)
db_len = get_db_len(db)
assert csv_len == db_len