pico-store-lab 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pico_store_lab-0.1.2/.gitignore +19 -0
- pico_store_lab-0.1.2/PKG-INFO +51 -0
- pico_store_lab-0.1.2/README.md +36 -0
- pico_store_lab-0.1.2/pyproject.toml +36 -0
- pico_store_lab-0.1.2/src/pico_store_lab/__init__.py +79 -0
- pico_store_lab-0.1.2/src/pico_store_lab/__main__.py +5 -0
- pico_store_lab-0.1.2/src/pico_store_lab/cli.py +116 -0
- pico_store_lab-0.1.2/src/pico_store_lab/client.py +227 -0
- pico_store_lab-0.1.2/src/pico_store_lab/credentials.py +81 -0
- pico_store_lab-0.1.2/src/pico_store_lab/mirror.py +43 -0
- pico_store_lab-0.1.2/src/pico_store_lab/protocol.py +472 -0
- pico_store_lab-0.1.2/src/pico_store_lab/py.typed +1 -0
- pico_store_lab-0.1.2/tests/test_client.py +138 -0
- pico_store_lab-0.1.2/tests/test_protocol.py +106 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
apps/website/.local/
|
|
3
|
+
apps/website/.wrangler/
|
|
4
|
+
apps/android/.gradle/
|
|
5
|
+
apps/android/build/
|
|
6
|
+
apps/android/app/build/
|
|
7
|
+
packages/ts/dist/
|
|
8
|
+
packages/python/dist/
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.pyc
|
|
11
|
+
target/
|
|
12
|
+
packages/kotlin/build/
|
|
13
|
+
build/
|
|
14
|
+
dist/
|
|
15
|
+
*.apk
|
|
16
|
+
*.part
|
|
17
|
+
*.auth.json
|
|
18
|
+
*mirror-config.json
|
|
19
|
+
.DS_Store
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pico-store-lab
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: PICO app search, sign-in, and downloads for Python and the command line
|
|
5
|
+
Project-URL: Repository, https://github.com/nkanf-dev/pico-store-lab
|
|
6
|
+
Project-URL: Issues, https://github.com/nkanf-dev/pico-store-lab/issues
|
|
7
|
+
Author: PICO Store Lab contributors
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: pico,sdk,store,vr
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Requires-Dist: keyring<26,>=25.6
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# PICO Store Lab — Python SDK
|
|
17
|
+
|
|
18
|
+
PICO app search, sign-in, and downloads for Python, with a `pico-store-py` command-line tool for Windows, macOS, and Linux. This package is part of [PICO Store Lab](https://github.com/nkanf-dev/pico-store-lab).
|
|
19
|
+
|
|
20
|
+
Install with `python -m pip install pico-store-lab`.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from pico_store_lab import PicoStoreClient, StoreTarget
|
|
25
|
+
|
|
26
|
+
client = PicoStoreClient()
|
|
27
|
+
found = client.search("YouTube VR").items[0]
|
|
28
|
+
target = StoreTarget(found.item_id, found.package_name)
|
|
29
|
+
client.item(target)
|
|
30
|
+
client.send_code(email)
|
|
31
|
+
auth = client.login(email, code_from_user)
|
|
32
|
+
client.download(target, auth, Path("selected-app.apk"))
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Configure the request identity and region with `StoreConfig`:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from pico_store_lab import StoreConfig
|
|
39
|
+
|
|
40
|
+
client = PicoStoreClient(
|
|
41
|
+
config=StoreConfig(
|
|
42
|
+
device_name="YOUR_DEVICE", language="en", zone="Europe/London", web_region="uk"
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Other fields include `store_host`, `account_host`, `web_store_host`, `manifest_version_code`, `app_id`, `client_type`, `passport_aid`, and `device_platform`. The default profile reflects the observed A9210/Japanese-language overseas-store requests. Select any exact `StoreTarget` from search. `download()` confirms ownership, acquires an available free offer if necessary, then gets the APK metadata. Paid offers are purchased on the official store.
|
|
48
|
+
|
|
49
|
+
`email` and `code_from_user` come from your app's UI. `PicoStoreClient` performs the requests and verified download; pass a custom transport if you need different HTTP behavior. Low-level builders and parsers remain public. Use `pico-store-py --help` for the smaller CLI surface.
|
|
50
|
+
|
|
51
|
+
The [player guide](https://github.com/nkanf-dev/pico-store-lab#player-guide) gives the complete `search` → `status` → `send-code` → `login` → `download` command sequence, including device installation.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# PICO Store Lab — Python SDK
|
|
2
|
+
|
|
3
|
+
PICO app search, sign-in, and downloads for Python, with a `pico-store-py` command-line tool for Windows, macOS, and Linux. This package is part of [PICO Store Lab](https://github.com/nkanf-dev/pico-store-lab).
|
|
4
|
+
|
|
5
|
+
Install with `python -m pip install pico-store-lab`.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from pico_store_lab import PicoStoreClient, StoreTarget
|
|
10
|
+
|
|
11
|
+
client = PicoStoreClient()
|
|
12
|
+
found = client.search("YouTube VR").items[0]
|
|
13
|
+
target = StoreTarget(found.item_id, found.package_name)
|
|
14
|
+
client.item(target)
|
|
15
|
+
client.send_code(email)
|
|
16
|
+
auth = client.login(email, code_from_user)
|
|
17
|
+
client.download(target, auth, Path("selected-app.apk"))
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Configure the request identity and region with `StoreConfig`:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from pico_store_lab import StoreConfig
|
|
24
|
+
|
|
25
|
+
client = PicoStoreClient(
|
|
26
|
+
config=StoreConfig(
|
|
27
|
+
device_name="YOUR_DEVICE", language="en", zone="Europe/London", web_region="uk"
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Other fields include `store_host`, `account_host`, `web_store_host`, `manifest_version_code`, `app_id`, `client_type`, `passport_aid`, and `device_platform`. The default profile reflects the observed A9210/Japanese-language overseas-store requests. Select any exact `StoreTarget` from search. `download()` confirms ownership, acquires an available free offer if necessary, then gets the APK metadata. Paid offers are purchased on the official store.
|
|
33
|
+
|
|
34
|
+
`email` and `code_from_user` come from your app's UI. `PicoStoreClient` performs the requests and verified download; pass a custom transport if you need different HTTP behavior. Low-level builders and parsers remain public. Use `pico-store-py --help` for the smaller CLI surface.
|
|
35
|
+
|
|
36
|
+
The [player guide](https://github.com/nkanf-dev/pico-store-lab#player-guide) gives the complete `search` → `status` → `send-code` → `login` → `download` command sequence, including device installation.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pico-store-lab"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "PICO app search, sign-in, and downloads for Python and the command line"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = ["keyring>=25.6,<26"]
|
|
12
|
+
license = "MIT"
|
|
13
|
+
authors = [{ name = "PICO Store Lab contributors" }]
|
|
14
|
+
keywords = ["pico", "vr", "sdk", "store"]
|
|
15
|
+
classifiers = ["Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Repository = "https://github.com/nkanf-dev/pico-store-lab"
|
|
19
|
+
Issues = "https://github.com/nkanf-dev/pico-store-lab/issues"
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
pico-store-py = "pico_store_lab.cli:main"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/pico_store_lab"]
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
line-length = 100
|
|
29
|
+
target-version = "py311"
|
|
30
|
+
src = ["src"]
|
|
31
|
+
|
|
32
|
+
[tool.ruff.lint]
|
|
33
|
+
select = ["E", "F", "I", "D", "UP", "B", "ANN"]
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint.pydocstyle]
|
|
36
|
+
convention = "google"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Typed PICO store protocol and mirror policy SDK."""
|
|
2
|
+
|
|
3
|
+
from pico_store_lab.client import (
|
|
4
|
+
PicoStoreClient,
|
|
5
|
+
StoreResponse,
|
|
6
|
+
download_verified_apk,
|
|
7
|
+
send_request,
|
|
8
|
+
)
|
|
9
|
+
from pico_store_lab.mirror import (
|
|
10
|
+
DEFAULT_MIRROR_POLICY,
|
|
11
|
+
MirrorDecision,
|
|
12
|
+
MirrorPolicy,
|
|
13
|
+
mirror_decision,
|
|
14
|
+
)
|
|
15
|
+
from pico_store_lab.protocol import (
|
|
16
|
+
ACCOUNT_HOST,
|
|
17
|
+
DEFAULT_TARGET,
|
|
18
|
+
OFFICIAL_STORE_URL,
|
|
19
|
+
PICO_ITEM_ID,
|
|
20
|
+
PICO_PACKAGE,
|
|
21
|
+
STORE_HOST,
|
|
22
|
+
DownloadInfo,
|
|
23
|
+
PicoAuth,
|
|
24
|
+
PublicItem,
|
|
25
|
+
RequestSpec,
|
|
26
|
+
SearchItem,
|
|
27
|
+
SearchResults,
|
|
28
|
+
StoreConfig,
|
|
29
|
+
StoreTarget,
|
|
30
|
+
encode_account_field,
|
|
31
|
+
make_account_item_request,
|
|
32
|
+
make_account_request,
|
|
33
|
+
make_download_info_request,
|
|
34
|
+
make_free_acquisition_request,
|
|
35
|
+
make_public_item_request,
|
|
36
|
+
make_search_request,
|
|
37
|
+
parse_download_info,
|
|
38
|
+
parse_free_acquisition,
|
|
39
|
+
parse_official_json,
|
|
40
|
+
parse_public_item,
|
|
41
|
+
parse_search_results,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"ACCOUNT_HOST",
|
|
46
|
+
"DEFAULT_MIRROR_POLICY",
|
|
47
|
+
"OFFICIAL_STORE_URL",
|
|
48
|
+
"PICO_ITEM_ID",
|
|
49
|
+
"PICO_PACKAGE",
|
|
50
|
+
"STORE_HOST",
|
|
51
|
+
"DEFAULT_TARGET",
|
|
52
|
+
"DownloadInfo",
|
|
53
|
+
"MirrorDecision",
|
|
54
|
+
"MirrorPolicy",
|
|
55
|
+
"PicoAuth",
|
|
56
|
+
"PicoStoreClient",
|
|
57
|
+
"PublicItem",
|
|
58
|
+
"RequestSpec",
|
|
59
|
+
"StoreTarget",
|
|
60
|
+
"StoreConfig",
|
|
61
|
+
"make_account_item_request",
|
|
62
|
+
"make_free_acquisition_request",
|
|
63
|
+
"parse_free_acquisition",
|
|
64
|
+
"StoreResponse",
|
|
65
|
+
"SearchItem",
|
|
66
|
+
"SearchResults",
|
|
67
|
+
"download_verified_apk",
|
|
68
|
+
"encode_account_field",
|
|
69
|
+
"make_account_request",
|
|
70
|
+
"make_download_info_request",
|
|
71
|
+
"make_public_item_request",
|
|
72
|
+
"make_search_request",
|
|
73
|
+
"mirror_decision",
|
|
74
|
+
"parse_download_info",
|
|
75
|
+
"parse_official_json",
|
|
76
|
+
"parse_public_item",
|
|
77
|
+
"parse_search_results",
|
|
78
|
+
"send_request",
|
|
79
|
+
]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Small bilingual command-line client built on the Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import getpass
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pico_store_lab import credentials
|
|
12
|
+
from pico_store_lab.client import PicoStoreClient
|
|
13
|
+
from pico_store_lab.protocol import StoreTarget
|
|
14
|
+
|
|
15
|
+
MESSAGES = {
|
|
16
|
+
"en": {
|
|
17
|
+
"sent": "PICO verification email sent.",
|
|
18
|
+
"saved": "Signed in.",
|
|
19
|
+
"downloaded": "Downloaded: {path}",
|
|
20
|
+
"signed_out": "Signed out.",
|
|
21
|
+
"error": "Error: {message}",
|
|
22
|
+
},
|
|
23
|
+
"zh-CN": {
|
|
24
|
+
"sent": "PICO 验证码邮件已发送。",
|
|
25
|
+
"saved": "已登录。",
|
|
26
|
+
"downloaded": "已下载:{path}",
|
|
27
|
+
"signed_out": "已退出登录。",
|
|
28
|
+
"error": "错误:{message}",
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _message(locale: str, key: str, **values: str) -> str:
|
|
34
|
+
return MESSAGES[locale][key].format(**values)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
38
|
+
"""Create the stable CLI argument parser."""
|
|
39
|
+
parser = argparse.ArgumentParser(prog="pico-store-py", description="PICO Store Lab Python CLI")
|
|
40
|
+
parser.add_argument("--locale", choices=("en", "zh-CN"), default="en")
|
|
41
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
42
|
+
search = sub.add_parser("search", help="Search official PICO apps")
|
|
43
|
+
search.add_argument("word")
|
|
44
|
+
status = sub.add_parser("status", help="Show an official PICO item")
|
|
45
|
+
status.add_argument("--item-id", required=True)
|
|
46
|
+
status.add_argument("--package", required=True)
|
|
47
|
+
send = sub.add_parser("send-code", help="Send a PICO email code")
|
|
48
|
+
send.add_argument("--email", required=True)
|
|
49
|
+
login = sub.add_parser("login", help="Sign in to your PICO international account")
|
|
50
|
+
login.add_argument("--email", required=True)
|
|
51
|
+
sub.add_parser("logout", help="Sign out")
|
|
52
|
+
download = sub.add_parser("download", help="Download an app")
|
|
53
|
+
download.add_argument("--output", required=True, type=Path)
|
|
54
|
+
download.add_argument("--item-id", required=True)
|
|
55
|
+
download.add_argument("--package", required=True)
|
|
56
|
+
return parser
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main(argv: list[str] | None = None) -> int:
|
|
60
|
+
"""Run one command; return a process exit status without leaking credentials."""
|
|
61
|
+
args = build_parser().parse_args(argv)
|
|
62
|
+
client = PicoStoreClient()
|
|
63
|
+
try:
|
|
64
|
+
if args.command == "search":
|
|
65
|
+
result = client.search(args.word)
|
|
66
|
+
print(
|
|
67
|
+
json.dumps(
|
|
68
|
+
[
|
|
69
|
+
{
|
|
70
|
+
"name": item.name,
|
|
71
|
+
"itemId": item.item_id,
|
|
72
|
+
"packageName": item.package_name,
|
|
73
|
+
"versionCode": item.version_code,
|
|
74
|
+
}
|
|
75
|
+
for item in result.items
|
|
76
|
+
],
|
|
77
|
+
indent=2,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
elif args.command == "status":
|
|
81
|
+
target = StoreTarget(args.item_id, args.package)
|
|
82
|
+
item = client.item(target)
|
|
83
|
+
print(
|
|
84
|
+
json.dumps(
|
|
85
|
+
{
|
|
86
|
+
"name": item.name,
|
|
87
|
+
"versionCode": item.version_code,
|
|
88
|
+
"price": item.price,
|
|
89
|
+
"officialUrl": item.official_url,
|
|
90
|
+
},
|
|
91
|
+
indent=2,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
elif args.command == "send-code":
|
|
95
|
+
client.send_code(args.email)
|
|
96
|
+
print(_message(args.locale, "sent"))
|
|
97
|
+
elif args.command == "login":
|
|
98
|
+
code = getpass.getpass("PICO email code: ")
|
|
99
|
+
auth = client.login(args.email, code)
|
|
100
|
+
credentials.save(auth)
|
|
101
|
+
print(_message(args.locale, "saved"))
|
|
102
|
+
elif args.command == "logout":
|
|
103
|
+
credentials.clear()
|
|
104
|
+
print(_message(args.locale, "signed_out"))
|
|
105
|
+
elif args.command == "download":
|
|
106
|
+
target = StoreTarget(args.item_id, args.package)
|
|
107
|
+
client.download(target, credentials.load(), args.output)
|
|
108
|
+
print(_message(args.locale, "downloaded", path=str(args.output)))
|
|
109
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
110
|
+
print(_message(args.locale, "error", message=str(error)), file=sys.stderr)
|
|
111
|
+
return 1
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Composable network client for the complete PICO store acquisition flow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from email.message import Message
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.error import HTTPError, URLError
|
|
14
|
+
from urllib.request import Request, urlopen
|
|
15
|
+
|
|
16
|
+
from pico_store_lab.protocol import (
|
|
17
|
+
DEFAULT_CONFIG,
|
|
18
|
+
DownloadInfo,
|
|
19
|
+
PicoAuth,
|
|
20
|
+
PublicItem,
|
|
21
|
+
RequestSpec,
|
|
22
|
+
SearchResults,
|
|
23
|
+
StoreConfig,
|
|
24
|
+
StoreTarget,
|
|
25
|
+
make_account_item_request,
|
|
26
|
+
make_account_request,
|
|
27
|
+
make_download_info_request,
|
|
28
|
+
make_free_acquisition_request,
|
|
29
|
+
make_public_item_request,
|
|
30
|
+
make_search_request,
|
|
31
|
+
parse_download_info,
|
|
32
|
+
parse_free_acquisition,
|
|
33
|
+
parse_official_json,
|
|
34
|
+
parse_public_item,
|
|
35
|
+
parse_search_results,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class StoreResponse:
|
|
41
|
+
"""Decoded API payload and raw response headers."""
|
|
42
|
+
|
|
43
|
+
data: object
|
|
44
|
+
headers: Message
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
Transport = Callable[[RequestSpec, int], StoreResponse]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def send_request(spec: RequestSpec, retries: int = 3) -> StoreResponse:
|
|
51
|
+
"""Send a protocol request with bounded retries for transient failures."""
|
|
52
|
+
if retries < 1:
|
|
53
|
+
raise ValueError("at least one request attempt required")
|
|
54
|
+
last_error: Exception | None = None
|
|
55
|
+
for attempt in range(retries):
|
|
56
|
+
request = Request(
|
|
57
|
+
spec.url, data=spec.body.encode("utf-8"), headers=spec.headers, method=spec.method
|
|
58
|
+
)
|
|
59
|
+
try:
|
|
60
|
+
with urlopen(request, timeout=25) as response: # noqa: S310 - SDK selects official URLs
|
|
61
|
+
body = response.read(4 * 1024 * 1024 + 1)
|
|
62
|
+
if len(body) > 4 * 1024 * 1024:
|
|
63
|
+
raise RuntimeError("PICO response exceeds 4 MiB")
|
|
64
|
+
return StoreResponse(parse_official_json(body.decode()), response.headers)
|
|
65
|
+
except HTTPError as error:
|
|
66
|
+
if error.code < 500 and error.code != 429:
|
|
67
|
+
raise RuntimeError(f"PICO HTTP {error.code}") from error
|
|
68
|
+
last_error = error
|
|
69
|
+
except URLError as error:
|
|
70
|
+
last_error = error
|
|
71
|
+
if attempt + 1 < retries:
|
|
72
|
+
time.sleep(min(attempt + 1, 5))
|
|
73
|
+
raise RuntimeError(f"PICO request failed: {last_error}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _account_data(response: StoreResponse) -> dict[str, object]:
|
|
77
|
+
data = response.data
|
|
78
|
+
if not isinstance(data, dict) or data.get("message") != "success":
|
|
79
|
+
raise RuntimeError("PICO account request rejected")
|
|
80
|
+
body = data.get("data")
|
|
81
|
+
return body if isinstance(body, dict) else {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def download_verified_apk(info: DownloadInfo, output: Path, retries: int = 8) -> Path:
|
|
85
|
+
"""Download official APK bytes and verify the provided MD5 before publishing a file."""
|
|
86
|
+
if retries < 1:
|
|
87
|
+
raise ValueError("at least one download attempt required")
|
|
88
|
+
if output.suffix.lower() != ".apk" or output.exists():
|
|
89
|
+
raise ValueError("new .apk output path required")
|
|
90
|
+
temporary = output.with_name(f"{output.stem}.part.apk")
|
|
91
|
+
last_error: Exception | None = None
|
|
92
|
+
for attempt in range(retries):
|
|
93
|
+
digest = hashlib.md5() # noqa: S324 - official APK metadata supplies MD5
|
|
94
|
+
try:
|
|
95
|
+
with urlopen(info.url, timeout=60) as response: # noqa: S310 - validated HTTPS URL
|
|
96
|
+
with temporary.open("wb") as stream:
|
|
97
|
+
while chunk := response.read(65536):
|
|
98
|
+
digest.update(chunk)
|
|
99
|
+
stream.write(chunk)
|
|
100
|
+
if digest.hexdigest() != info.md5:
|
|
101
|
+
temporary.unlink(missing_ok=True)
|
|
102
|
+
raise ValueError("APK digest mismatch")
|
|
103
|
+
os.link(temporary, output)
|
|
104
|
+
temporary.unlink()
|
|
105
|
+
return output
|
|
106
|
+
except (OSError, URLError) as error:
|
|
107
|
+
last_error = error
|
|
108
|
+
if attempt + 1 < retries:
|
|
109
|
+
time.sleep(min(attempt + 1, 5))
|
|
110
|
+
raise RuntimeError(f"APK download failed: {last_error}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class PicoStoreClient:
|
|
114
|
+
"""High-level SDK interface; callers may replace transport at any point."""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self, transport: Transport = send_request, config: StoreConfig = DEFAULT_CONFIG
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Use the standard HTTP transport unless one is injected."""
|
|
120
|
+
self.transport = transport
|
|
121
|
+
self.config = config
|
|
122
|
+
|
|
123
|
+
def search(self, word: str, next_id: int = 1) -> SearchResults:
|
|
124
|
+
"""Search the official public app catalog."""
|
|
125
|
+
response = self.transport(
|
|
126
|
+
make_search_request(
|
|
127
|
+
word, next_id=next_id, language=self.config.language, config=self.config
|
|
128
|
+
),
|
|
129
|
+
3,
|
|
130
|
+
)
|
|
131
|
+
return parse_search_results(response.data)
|
|
132
|
+
|
|
133
|
+
def item(self, target: StoreTarget, auth: PicoAuth | None = None) -> PublicItem:
|
|
134
|
+
"""Read and validate public metadata for the selected app."""
|
|
135
|
+
request = (
|
|
136
|
+
make_account_item_request(auth, target, self.config)
|
|
137
|
+
if auth
|
|
138
|
+
else make_public_item_request(
|
|
139
|
+
target=target,
|
|
140
|
+
language=self.config.language,
|
|
141
|
+
zone=self.config.zone,
|
|
142
|
+
config=self.config,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
response = self.transport(request, 3)
|
|
146
|
+
return parse_public_item(response.data, target, self.config)
|
|
147
|
+
|
|
148
|
+
def acquire_free(self, item: PublicItem, auth: PicoAuth) -> str:
|
|
149
|
+
"""Request a free app for the signed-in account."""
|
|
150
|
+
return parse_free_acquisition(
|
|
151
|
+
self.transport(make_free_acquisition_request(auth, item, self.config), 1).data
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def ensure_entitlement(self, target: StoreTarget, auth: PicoAuth) -> PublicItem:
|
|
155
|
+
"""Confirm ownership or acquire a free offer before downloading."""
|
|
156
|
+
current = self.item(target, auth)
|
|
157
|
+
if current.entitlement_status == 1:
|
|
158
|
+
return current
|
|
159
|
+
if current.offer_exists is not True:
|
|
160
|
+
raise RuntimeError("PICO has no offer for this account region")
|
|
161
|
+
if not re.fullmatch(r"0(?:\.0+)?", current.price):
|
|
162
|
+
raise RuntimeError("PICO app is not free or already owned")
|
|
163
|
+
acquisition_error: Exception | None = None
|
|
164
|
+
try:
|
|
165
|
+
self.acquire_free(current, auth)
|
|
166
|
+
except Exception as error:
|
|
167
|
+
acquisition_error = error
|
|
168
|
+
for attempt in range(3):
|
|
169
|
+
try:
|
|
170
|
+
updated = self.item(target, auth)
|
|
171
|
+
except Exception as error:
|
|
172
|
+
if attempt == 2:
|
|
173
|
+
if acquisition_error is not None:
|
|
174
|
+
raise acquisition_error from error
|
|
175
|
+
raise
|
|
176
|
+
time.sleep(0.4)
|
|
177
|
+
continue
|
|
178
|
+
if updated.entitlement_status == 1:
|
|
179
|
+
return updated
|
|
180
|
+
if attempt < 2:
|
|
181
|
+
time.sleep(0.4)
|
|
182
|
+
if acquisition_error is not None:
|
|
183
|
+
raise acquisition_error
|
|
184
|
+
raise RuntimeError("PICO entitlement was not confirmed after free acquisition")
|
|
185
|
+
|
|
186
|
+
def send_code(self, email: str) -> None:
|
|
187
|
+
"""Send one PICO email verification code."""
|
|
188
|
+
_account_data(
|
|
189
|
+
self.transport(make_account_request("send-code", email, config=self.config), 1)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def login(self, email: str, code: str) -> PicoAuth:
|
|
193
|
+
"""Exchange the email code for the caller's PICO account session."""
|
|
194
|
+
response = self.transport(make_account_request("login", email, code, self.config), 1)
|
|
195
|
+
data = _account_data(response)
|
|
196
|
+
cookies: dict[str, str] = {}
|
|
197
|
+
for line in response.headers.get_all("Set-Cookie", []):
|
|
198
|
+
key, separator, value = line.split(";", 1)[0].partition("=")
|
|
199
|
+
if separator:
|
|
200
|
+
cookies[key] = value
|
|
201
|
+
auth = PicoAuth(
|
|
202
|
+
str(data.get("user_id_str") or data.get("user_id") or "0"),
|
|
203
|
+
str(response.headers.get("x-tt-token", "")),
|
|
204
|
+
cookies,
|
|
205
|
+
)
|
|
206
|
+
if not auth.x_tt_token and not auth.cookies:
|
|
207
|
+
raise RuntimeError("PICO login returned no usable session")
|
|
208
|
+
return auth
|
|
209
|
+
|
|
210
|
+
def download_info(self, target: StoreTarget, auth: PicoAuth) -> DownloadInfo:
|
|
211
|
+
"""Read download metadata authorized for the caller's own account."""
|
|
212
|
+
response = self.transport(
|
|
213
|
+
make_download_info_request(
|
|
214
|
+
auth,
|
|
215
|
+
target=target,
|
|
216
|
+
language=self.config.language,
|
|
217
|
+
zone=self.config.zone,
|
|
218
|
+
config=self.config,
|
|
219
|
+
),
|
|
220
|
+
3,
|
|
221
|
+
)
|
|
222
|
+
return parse_download_info(response.data, target)
|
|
223
|
+
|
|
224
|
+
def download(self, target: StoreTarget, auth: PicoAuth, output: Path) -> Path:
|
|
225
|
+
"""Get and verify an official APK in one call."""
|
|
226
|
+
self.ensure_entitlement(target, auth)
|
|
227
|
+
return download_verified_apk(self.download_info(target, auth), output)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Account persistence through the platform credential store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from keyring.backend import KeyringBackend
|
|
9
|
+
|
|
10
|
+
from pico_store_lab.protocol import PicoAuth
|
|
11
|
+
|
|
12
|
+
SERVICE = "dev.nkanf.picostore.python"
|
|
13
|
+
ACCOUNT = "current"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _backend() -> KeyringBackend:
|
|
17
|
+
# Select native stores directly: never load a configured plaintext/null backend.
|
|
18
|
+
if sys.platform == "darwin":
|
|
19
|
+
from keyring.backends.macOS import Keyring
|
|
20
|
+
|
|
21
|
+
return Keyring()
|
|
22
|
+
if sys.platform == "win32":
|
|
23
|
+
from keyring.backends.Windows import WinVaultKeyring
|
|
24
|
+
|
|
25
|
+
return WinVaultKeyring()
|
|
26
|
+
if sys.platform.startswith("linux"):
|
|
27
|
+
from keyring.backends.SecretService import Keyring
|
|
28
|
+
|
|
29
|
+
return Keyring()
|
|
30
|
+
raise RuntimeError("Sign-in requires Windows, macOS, or Linux.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def save(auth: PicoAuth) -> None:
|
|
34
|
+
"""Save a usable account session."""
|
|
35
|
+
if not auth.x_tt_token and not auth.cookies:
|
|
36
|
+
raise ValueError("Sign-in returned no usable session. Please sign in again.")
|
|
37
|
+
value = json.dumps({"uid": auth.uid, "x_tt_token": auth.x_tt_token, "cookies": auth.cookies})
|
|
38
|
+
try:
|
|
39
|
+
_backend().set_password(SERVICE, ACCOUNT, value)
|
|
40
|
+
except Exception:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
"Unable to save sign-in. Unlock the system credential store and try again."
|
|
43
|
+
) from None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load() -> PicoAuth:
|
|
47
|
+
"""Read the current account session."""
|
|
48
|
+
try:
|
|
49
|
+
raw = _backend().get_password(SERVICE, ACCOUNT)
|
|
50
|
+
except Exception:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
"Unable to read sign-in. Unlock the system credential store and try again."
|
|
53
|
+
) from None
|
|
54
|
+
if raw is None:
|
|
55
|
+
raise ValueError("Please run pico-store-py login first.")
|
|
56
|
+
try:
|
|
57
|
+
data = json.loads(raw)
|
|
58
|
+
if not isinstance(data, dict) or not isinstance(data.get("cookies", {}), dict):
|
|
59
|
+
raise ValueError
|
|
60
|
+
auth = PicoAuth(
|
|
61
|
+
str(data.get("uid", "0")),
|
|
62
|
+
str(data.get("x_tt_token", "")),
|
|
63
|
+
{str(key): str(value) for key, value in data.get("cookies", {}).items()},
|
|
64
|
+
)
|
|
65
|
+
if not auth.x_tt_token and not auth.cookies:
|
|
66
|
+
raise ValueError
|
|
67
|
+
except (ValueError, TypeError):
|
|
68
|
+
raise ValueError("Sign-in could not be read. Please sign in again.") from None
|
|
69
|
+
return auth
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def clear() -> None:
|
|
73
|
+
"""Remove the current account session."""
|
|
74
|
+
try:
|
|
75
|
+
backend = _backend()
|
|
76
|
+
if backend.get_password(SERVICE, ACCOUNT) is not None:
|
|
77
|
+
backend.delete_password(SERVICE, ACCOUNT)
|
|
78
|
+
except Exception:
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
"Could not sign out. Unlock the system credential store and try again."
|
|
81
|
+
) from None
|