invilabs 0.1.0__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.
- invilabs-0.1.0/MANIFEST.in +7 -0
- invilabs-0.1.0/PKG-INFO +85 -0
- invilabs-0.1.0/PUBLIC_README.md +69 -0
- invilabs-0.1.0/pyproject.toml +29 -0
- invilabs-0.1.0/setup.cfg +4 -0
- invilabs-0.1.0/src/invilabs/__init__.py +8 -0
- invilabs-0.1.0/src/invilabs/client.py +310 -0
- invilabs-0.1.0/src/invilabs/client.pyi +36 -0
- invilabs-0.1.0/src/invilabs/contracts.py +71 -0
- invilabs-0.1.0/src/invilabs/errors.py +68 -0
- invilabs-0.1.0/src/invilabs/errors.pyi +36 -0
- invilabs-0.1.0/src/invilabs/py.typed +0 -0
- invilabs-0.1.0/src/invilabs/response_schemas.json +1287 -0
- invilabs-0.1.0/src/invilabs/types.py +150 -0
- invilabs-0.1.0/src/invilabs.egg-info/PKG-INFO +85 -0
- invilabs-0.1.0/src/invilabs.egg-info/SOURCES.txt +16 -0
- invilabs-0.1.0/src/invilabs.egg-info/dependency_links.txt +1 -0
- invilabs-0.1.0/src/invilabs.egg-info/top_level.txt +1 -0
invilabs-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: invilabs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Blocking Python SDK for Invi Labs phone automation
|
|
5
|
+
Author: Invi Labs
|
|
6
|
+
Project-URL: Homepage, https://invilabs.io
|
|
7
|
+
Project-URL: Documentation, https://sdk.invilabs.io
|
|
8
|
+
Project-URL: Changelog, https://sdk.invilabs.io/downloads/
|
|
9
|
+
Keywords: iphone,automation,sdk,ai-agent,ocr
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Invi Labs Python SDK
|
|
18
|
+
|
|
19
|
+
Automate real iPhones through the Invi Labs API using your API key and phone ID.
|
|
20
|
+
Non-jailbroken iPhones require [Invi Dongle](https://invilabs.io/connect).
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
python -m pip install invilabs
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Requires Python 3.10 or newer. No third-party runtime dependencies.
|
|
29
|
+
|
|
30
|
+
## Connect and run an action
|
|
31
|
+
|
|
32
|
+
Set `INVI_API_KEY` and `INVI_PHONE_ID` in your environment. Keep your API key private.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import os
|
|
36
|
+
from invilabs import Client
|
|
37
|
+
|
|
38
|
+
client = Client(os.environ["INVI_API_KEY"])
|
|
39
|
+
phone = client.phone(os.environ["INVI_PHONE_ID"])
|
|
40
|
+
|
|
41
|
+
print(phone.details())
|
|
42
|
+
phone.tap(100, 200)
|
|
43
|
+
phone.save_screenshot("screen.png")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Coordinates use full-resolution screenshot pixels. Calls block until completion;
|
|
47
|
+
the SDK waits and retries when the phone reports `device_busy`. Uncertain execution
|
|
48
|
+
is not automatically repeated. Use the operation key to look up its status.
|
|
49
|
+
|
|
50
|
+
## AI generation
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
result = client.generate(
|
|
54
|
+
"Generate an email address using example.com",
|
|
55
|
+
result_type="email",
|
|
56
|
+
)
|
|
57
|
+
print(result["data"]["text"])
|
|
58
|
+
print(client.balance())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Generation supports text, usernames and email address strings, optionally using a
|
|
62
|
+
local PNG/JPEG image. It does not create mailboxes. OCR, AI prompts and generation
|
|
63
|
+
use your shared Account AI balance. No AI-provider key is required.
|
|
64
|
+
|
|
65
|
+
## Features
|
|
66
|
+
|
|
67
|
+
- Tap, swipe, scroll, type, explicit key presses and opening installed apps.
|
|
68
|
+
- Screenshots as PNG bytes or saved to your computer.
|
|
69
|
+
- Grouped OCR with text and bounding boxes.
|
|
70
|
+
- Single-action AI prompts and structured screen answers.
|
|
71
|
+
- Image/video import through Invi Helper on supported dongle phones.
|
|
72
|
+
- Device details, balance and operation recovery.
|
|
73
|
+
|
|
74
|
+
All network operations use the public HTTPS API with your API key. The package
|
|
75
|
+
contains client code and response contracts; phone control and AI processing run
|
|
76
|
+
on Invi Labs services. API keys remain on your computer and are sent only as an
|
|
77
|
+
authentication header to the configured API. HTTP redirects are not followed.
|
|
78
|
+
|
|
79
|
+
This is an initial release. Device support and feature verification limits are
|
|
80
|
+
listed in the [release notes](https://sdk.invilabs.io/downloads/).
|
|
81
|
+
|
|
82
|
+
[Documentation](https://sdk.invilabs.io/) ·
|
|
83
|
+
[Quickstart](https://sdk.invilabs.io/quickstart/) ·
|
|
84
|
+
[Generation and outputs](https://sdk.invilabs.io/reference/generation/) ·
|
|
85
|
+
[Errors and recovery](https://sdk.invilabs.io/reference/credits-and-recovery/)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Invi Labs Python SDK
|
|
2
|
+
|
|
3
|
+
Automate real iPhones through the Invi Labs API using your API key and phone ID.
|
|
4
|
+
Non-jailbroken iPhones require [Invi Dongle](https://invilabs.io/connect).
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
python -m pip install invilabs
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Requires Python 3.10 or newer. No third-party runtime dependencies.
|
|
13
|
+
|
|
14
|
+
## Connect and run an action
|
|
15
|
+
|
|
16
|
+
Set `INVI_API_KEY` and `INVI_PHONE_ID` in your environment. Keep your API key private.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import os
|
|
20
|
+
from invilabs import Client
|
|
21
|
+
|
|
22
|
+
client = Client(os.environ["INVI_API_KEY"])
|
|
23
|
+
phone = client.phone(os.environ["INVI_PHONE_ID"])
|
|
24
|
+
|
|
25
|
+
print(phone.details())
|
|
26
|
+
phone.tap(100, 200)
|
|
27
|
+
phone.save_screenshot("screen.png")
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Coordinates use full-resolution screenshot pixels. Calls block until completion;
|
|
31
|
+
the SDK waits and retries when the phone reports `device_busy`. Uncertain execution
|
|
32
|
+
is not automatically repeated. Use the operation key to look up its status.
|
|
33
|
+
|
|
34
|
+
## AI generation
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
result = client.generate(
|
|
38
|
+
"Generate an email address using example.com",
|
|
39
|
+
result_type="email",
|
|
40
|
+
)
|
|
41
|
+
print(result["data"]["text"])
|
|
42
|
+
print(client.balance())
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Generation supports text, usernames and email address strings, optionally using a
|
|
46
|
+
local PNG/JPEG image. It does not create mailboxes. OCR, AI prompts and generation
|
|
47
|
+
use your shared Account AI balance. No AI-provider key is required.
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
- Tap, swipe, scroll, type, explicit key presses and opening installed apps.
|
|
52
|
+
- Screenshots as PNG bytes or saved to your computer.
|
|
53
|
+
- Grouped OCR with text and bounding boxes.
|
|
54
|
+
- Single-action AI prompts and structured screen answers.
|
|
55
|
+
- Image/video import through Invi Helper on supported dongle phones.
|
|
56
|
+
- Device details, balance and operation recovery.
|
|
57
|
+
|
|
58
|
+
All network operations use the public HTTPS API with your API key. The package
|
|
59
|
+
contains client code and response contracts; phone control and AI processing run
|
|
60
|
+
on Invi Labs services. API keys remain on your computer and are sent only as an
|
|
61
|
+
authentication header to the configured API. HTTP redirects are not followed.
|
|
62
|
+
|
|
63
|
+
This is an initial release. Device support and feature verification limits are
|
|
64
|
+
listed in the [release notes](https://sdk.invilabs.io/downloads/).
|
|
65
|
+
|
|
66
|
+
[Documentation](https://sdk.invilabs.io/) ·
|
|
67
|
+
[Quickstart](https://sdk.invilabs.io/quickstart/) ·
|
|
68
|
+
[Generation and outputs](https://sdk.invilabs.io/reference/generation/) ·
|
|
69
|
+
[Errors and recovery](https://sdk.invilabs.io/reference/credits-and-recovery/)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "invilabs"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Blocking Python SDK for Invi Labs phone automation"
|
|
9
|
+
readme = "PUBLIC_README.md"
|
|
10
|
+
authors = [{name = "Invi Labs"}]
|
|
11
|
+
keywords = ["iphone", "automation", "sdk", "ai-agent", "ocr"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Typing :: Typed",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
]
|
|
18
|
+
requires-python = ">=3.10"
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://invilabs.io"
|
|
22
|
+
Documentation = "https://sdk.invilabs.io"
|
|
23
|
+
Changelog = "https://sdk.invilabs.io/downloads/"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.package-data]
|
|
29
|
+
invilabs = ["response_schemas.json", "*.pyi", "py.typed"]
|
invilabs-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from .client import Client, Phone
|
|
2
|
+
from .errors import (InviError, DeviceBusyError, DeviceOfflineError,
|
|
3
|
+
InsufficientCreditsError, TargetNotFoundError, InvalidArgumentError,
|
|
4
|
+
ExecutionUncertainError, OperationInProgressError, IdempotencyConflictError,
|
|
5
|
+
IdempotencyExpiredError, AuthenticationError, PermissionDeniedError,
|
|
6
|
+
OperationNotSupportedError, AppNotFoundError, AmbiguousAppError, ScreenshotUnavailableError, ScreenChangedError, OrientationNotSupportedError, BillingPendingError, OCRUnavailableError, ResponseValidationError, MultiStepRequestError, UnsupportedPromptError, PromptUnavailableError, GenerationUnavailableError)
|
|
7
|
+
|
|
8
|
+
__all__ = ['Client', 'Phone', 'InviError', 'DeviceBusyError', 'DeviceOfflineError', 'InsufficientCreditsError', 'TargetNotFoundError', 'InvalidArgumentError', 'ExecutionUncertainError', 'OperationInProgressError', 'IdempotencyConflictError', 'IdempotencyExpiredError', 'AuthenticationError', 'PermissionDeniedError', 'OperationNotSupportedError', 'AppNotFoundError', 'AmbiguousAppError', 'ScreenshotUnavailableError', 'ScreenChangedError', 'OrientationNotSupportedError', 'BillingPendingError', 'OCRUnavailableError', 'ResponseValidationError', 'MultiStepRequestError', 'UnsupportedPromptError', 'PromptUnavailableError', 'GenerationUnavailableError']
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Blocking transport. Public device methods are added with their backend milestones."""
|
|
2
|
+
import base64
|
|
3
|
+
import binascii
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import struct
|
|
7
|
+
import tempfile
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import socket
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from urllib.error import HTTPError, URLError
|
|
14
|
+
from urllib.parse import quote, urlsplit
|
|
15
|
+
from urllib.request import Request, build_opener, HTTPRedirectHandler
|
|
16
|
+
|
|
17
|
+
from .errors import (ERRORS, InviError, AuthenticationError, PermissionDeniedError,
|
|
18
|
+
DeviceBusyError, ExecutionUncertainError)
|
|
19
|
+
|
|
20
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
21
|
+
# Never forward an API key to a redirect target, or turn POST into GET.
|
|
22
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
class _HTTPTransport:
|
|
26
|
+
def __init__(self):
|
|
27
|
+
self.opener = build_opener(_NoRedirect())
|
|
28
|
+
|
|
29
|
+
def request(self, method, url, headers, body, timeout):
|
|
30
|
+
req = Request(url, data=body, headers=headers, method=method)
|
|
31
|
+
try:
|
|
32
|
+
with self.opener.open(req, timeout=timeout) as response:
|
|
33
|
+
return response.status, response.read()
|
|
34
|
+
except HTTPError as error:
|
|
35
|
+
return error.code, error.read()
|
|
36
|
+
|
|
37
|
+
class Client:
|
|
38
|
+
def __init__(self, api_key, *, base_url="https://api.invilabs.io",
|
|
39
|
+
busy_timeout=60.0, request_timeout=70.0, _transport=None,
|
|
40
|
+
_sleep=time.sleep, _monotonic=time.monotonic, _time=time.time):
|
|
41
|
+
if not isinstance(api_key, str) or not api_key.strip():
|
|
42
|
+
raise ValueError("api_key is required")
|
|
43
|
+
parts = urlsplit(base_url)
|
|
44
|
+
if parts.scheme != "https" and not (parts.scheme == "http" and parts.hostname in ("localhost", "127.0.0.1", "::1")):
|
|
45
|
+
raise ValueError("base_url must use HTTPS (HTTP allowed only for localhost)")
|
|
46
|
+
if not parts.hostname or parts.username or parts.password or parts.query or parts.fragment:
|
|
47
|
+
raise ValueError("invalid base_url")
|
|
48
|
+
if busy_timeout is not None and (not math.isfinite(busy_timeout) or busy_timeout < 0):
|
|
49
|
+
raise ValueError("busy_timeout must be non-negative or None for unlimited")
|
|
50
|
+
if not math.isfinite(request_timeout) or request_timeout <= 0:
|
|
51
|
+
raise ValueError("request_timeout must be positive")
|
|
52
|
+
self._api_key = api_key
|
|
53
|
+
self._base_url = base_url.rstrip("/")
|
|
54
|
+
self._busy_timeout = busy_timeout
|
|
55
|
+
self._request_timeout = request_timeout
|
|
56
|
+
self._transport = _transport or _HTTPTransport()
|
|
57
|
+
self._sleep, self._monotonic, self._time = _sleep, _monotonic, _time
|
|
58
|
+
|
|
59
|
+
def balance(self):
|
|
60
|
+
"""Return remaining shared Account AI credits (a snapshot)."""
|
|
61
|
+
return self._request("GET", "/v1/sdk/balance")["balance_remaining"]
|
|
62
|
+
|
|
63
|
+
def generate(self, prompt, *, result_type="text", length=None, image=None):
|
|
64
|
+
"""Answer or generate text, optionally using an uploaded PNG/JPEG.
|
|
65
|
+
|
|
66
|
+
No phone required. Optional length is an exact Unicode character count.
|
|
67
|
+
A new call requests a new variation; usernames are not guaranteed unique.
|
|
68
|
+
"""
|
|
69
|
+
from .errors import InvalidArgumentError
|
|
70
|
+
if not isinstance(prompt, str) or not prompt.strip() or any(0xD800 <= ord(c) <= 0xDFFF for c in prompt) or len(prompt.encode("utf-8")) > 4096:
|
|
71
|
+
raise InvalidArgumentError("prompt must contain 1–4096 UTF-8 bytes", code="invalid_argument")
|
|
72
|
+
if result_type not in ("text", "username", "email"):
|
|
73
|
+
raise InvalidArgumentError("result_type must be text, username, or email", code="invalid_argument")
|
|
74
|
+
maximum = {"username": 64, "email": 254}.get(result_type, 4000)
|
|
75
|
+
minimum = 5 if result_type == "email" else 1
|
|
76
|
+
if length is not None and (type(length) is not int or not minimum <= length <= maximum):
|
|
77
|
+
raise InvalidArgumentError(f"length must be an integer from {minimum} to {maximum}", code="invalid_argument")
|
|
78
|
+
payload = {"prompt": prompt, "result_type": result_type}
|
|
79
|
+
if length is not None:
|
|
80
|
+
payload["length"] = length
|
|
81
|
+
if image is not None:
|
|
82
|
+
if isinstance(image, bytes):
|
|
83
|
+
data = image
|
|
84
|
+
elif isinstance(image, (str, os.PathLike)):
|
|
85
|
+
if isinstance(image, str) and image.startswith(("https://", "http://", "data:")):
|
|
86
|
+
raise InvalidArgumentError("image must be a local file or bytes, not a URL", code="invalid_argument")
|
|
87
|
+
with Path(image).open("rb") as source:
|
|
88
|
+
data = source.read((5 << 20) + 1)
|
|
89
|
+
else:
|
|
90
|
+
raise InvalidArgumentError("image must be a local file path or bytes", code="invalid_argument")
|
|
91
|
+
if not data or len(data) > 5 << 20 or not (data.startswith(b"\x89PNG\r\n\x1a\n") or data.startswith(b"\xff\xd8\xff")):
|
|
92
|
+
raise InvalidArgumentError("image must be a PNG/JPEG of at most 5 MiB", code="invalid_argument")
|
|
93
|
+
payload["image_base64"] = base64.b64encode(data).decode("ascii")
|
|
94
|
+
return self._request("POST", "/v1/sdk/generate", payload, idempotent=True)
|
|
95
|
+
|
|
96
|
+
def operation(self, key):
|
|
97
|
+
"""Look up account-only generation without generating or charging again."""
|
|
98
|
+
return self._request("GET", f"/v1/sdk/operations?key={quote(key, safe='')}")
|
|
99
|
+
|
|
100
|
+
def devices(self):
|
|
101
|
+
"""List phones accessible to this API key. Use phone.details() for status."""
|
|
102
|
+
return self._request("GET", "/v1/sdk/devices")["devices"]
|
|
103
|
+
|
|
104
|
+
def phone(self, phone_id):
|
|
105
|
+
return Phone(self, str(uuid.UUID(str(phone_id))))
|
|
106
|
+
|
|
107
|
+
def _request(self, method, path, payload=None, *, idempotent=False, timeout=None):
|
|
108
|
+
body = None if payload is None else json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
|
109
|
+
headers = {"Authorization": "Bearer " + self._api_key, "Accept": "application/json", "User-Agent": "invilabs-python/0.1.0"}
|
|
110
|
+
if body is not None:
|
|
111
|
+
headers["Content-Type"] = "application/json"
|
|
112
|
+
created = self._time()
|
|
113
|
+
if idempotent:
|
|
114
|
+
headers["Idempotency-Key"] = f"{int(created)}.{uuid.uuid4()}"
|
|
115
|
+
started = self._monotonic()
|
|
116
|
+
delay = 0.25
|
|
117
|
+
while True:
|
|
118
|
+
if idempotent and self._time() - created >= 86400:
|
|
119
|
+
from .errors import IdempotencyExpiredError
|
|
120
|
+
raise IdempotencyExpiredError("Operation key expired; request was not resent", code="idempotency_expired")
|
|
121
|
+
try:
|
|
122
|
+
status, raw = self._transport.request(method, self._base_url + path, headers.copy(), body, self._request_timeout if timeout is None else timeout)
|
|
123
|
+
except (URLError, OSError, socket.timeout) as exc:
|
|
124
|
+
# A transport failure does not establish whether input executed.
|
|
125
|
+
err = ExecutionUncertainError("Response unavailable; operation was not repeated", code="execution_uncertain")
|
|
126
|
+
err.idempotency_key = headers.get("Idempotency-Key")
|
|
127
|
+
raise err from exc
|
|
128
|
+
try:
|
|
129
|
+
result = json.loads(raw)
|
|
130
|
+
except (ValueError, TypeError) as exc:
|
|
131
|
+
err = ExecutionUncertainError("Invalid response; operation was not repeated", code="execution_uncertain")
|
|
132
|
+
err.idempotency_key = headers.get("Idempotency-Key")
|
|
133
|
+
raise err from exc
|
|
134
|
+
from .contracts import validate_response
|
|
135
|
+
validate_response(method, path, payload, result, status, headers.get("Idempotency-Key"))
|
|
136
|
+
if 200 <= status < 300:
|
|
137
|
+
return result
|
|
138
|
+
if not isinstance(result, dict):
|
|
139
|
+
raise InviError("Invalid error response", status=status)
|
|
140
|
+
code = result.get("error", "http_error")
|
|
141
|
+
if code == "device_busy":
|
|
142
|
+
elapsed = self._monotonic() - started
|
|
143
|
+
remaining = None if self._busy_timeout is None else self._busy_timeout - elapsed
|
|
144
|
+
if remaining is None or remaining > 0:
|
|
145
|
+
self._sleep(delay if remaining is None else min(delay, remaining))
|
|
146
|
+
if remaining is not None and self._monotonic() - started >= self._busy_timeout:
|
|
147
|
+
raise DeviceBusyError("Phone remained busy until wait timeout", code=code, status=status)
|
|
148
|
+
delay = min(delay * 2, 2.0)
|
|
149
|
+
continue
|
|
150
|
+
cls = ERRORS.get(code, AuthenticationError if status == 401 else PermissionDeniedError if status == 403 else InviError)
|
|
151
|
+
details = dict(result.get("details") or {})
|
|
152
|
+
for field in ("credits_charged", "balance_remaining"):
|
|
153
|
+
if field in result:
|
|
154
|
+
details[field] = result[field]
|
|
155
|
+
error = cls(result.get("message", code), code=code, status=status, details=details)
|
|
156
|
+
error.idempotency_key = headers.get("Idempotency-Key")
|
|
157
|
+
raise error
|
|
158
|
+
|
|
159
|
+
class Phone:
|
|
160
|
+
def __init__(self, client, phone_id):
|
|
161
|
+
self._client = client
|
|
162
|
+
self.id = phone_id
|
|
163
|
+
|
|
164
|
+
def upload_media(self, path):
|
|
165
|
+
"""Save PNG/JPEG or MP4/MOV through Helper, then delete the Library copy.
|
|
166
|
+
|
|
167
|
+
Images: 10 MiB / 40 MP. H.264/HEVC video: 40 MiB. Dongle phones only.
|
|
168
|
+
Requires device.control and device.upload. A cleanup_failed result means
|
|
169
|
+
the phone saved the media; do not import it again. No AI credit debit.
|
|
170
|
+
"""
|
|
171
|
+
from .errors import InvalidArgumentError
|
|
172
|
+
source = Path(path)
|
|
173
|
+
ext = source.suffix.lower()
|
|
174
|
+
if ext not in (".png", ".jpg", ".jpeg", ".mp4", ".mov"):
|
|
175
|
+
raise InvalidArgumentError("media must be PNG/JPEG or MP4/MOV", code="invalid_argument")
|
|
176
|
+
limit = (40 if ext in (".mp4", ".mov") else 10) * 1024 * 1024
|
|
177
|
+
with source.open("rb") as handle:
|
|
178
|
+
data = handle.read(limit + 1)
|
|
179
|
+
if not data or len(data) > limit:
|
|
180
|
+
raise InvalidArgumentError("media exceeds size limit (images 10 MiB; videos 40 MiB)", code="invalid_argument")
|
|
181
|
+
return self._client._request(
|
|
182
|
+
"POST", f"/v1/sdk/devices/{self.id}/operations/upload_media",
|
|
183
|
+
{"filename": source.name, "media_base64": base64.b64encode(data).decode("ascii")},
|
|
184
|
+
idempotent=True, timeout=max(170.0, self._client._request_timeout))
|
|
185
|
+
|
|
186
|
+
def prompt(self, prompt, *, result_type="action"):
|
|
187
|
+
"""One action, or read-only target/answer using fixed SDK-owned schemas.
|
|
188
|
+
|
|
189
|
+
Be specific about visible text and screen location. Target and answer
|
|
190
|
+
never send input. No custom schema, navigation, or action repetition.
|
|
191
|
+
"""
|
|
192
|
+
from .errors import InvalidArgumentError
|
|
193
|
+
if not isinstance(prompt, str) or not prompt.strip() or any(0xD800 <= ord(c) <= 0xDFFF for c in prompt) or len(prompt.encode("utf-8")) > 4096:
|
|
194
|
+
raise InvalidArgumentError("prompt must contain 1–4096 UTF-8 bytes", code="invalid_argument")
|
|
195
|
+
if result_type not in ("action", "target", "answer"):
|
|
196
|
+
raise InvalidArgumentError("result_type must be action, target, or answer", code="invalid_argument")
|
|
197
|
+
return self._operation("prompt", {"prompt": prompt, "result_type": result_type})
|
|
198
|
+
|
|
199
|
+
def ocr(self):
|
|
200
|
+
"""Full-screen grouped text and PNG-pixel boxes, plus charge and balance."""
|
|
201
|
+
return self._operation("ocr", {})
|
|
202
|
+
|
|
203
|
+
def details(self):
|
|
204
|
+
"""Read online/busy state and screenshot-pixel dimensions (screen may be None)."""
|
|
205
|
+
return self._client._request("GET", f"/v1/sdk/devices/{self.id}")
|
|
206
|
+
|
|
207
|
+
def screenshot(self):
|
|
208
|
+
"""Return a fresh full-screen PNG as bytes, in the same pixels as tap/swipe.
|
|
209
|
+
|
|
210
|
+
Busy waits reuse one request key. A replay of an existing operation
|
|
211
|
+
returns its original image; a new screenshot() call captures again.
|
|
212
|
+
"""
|
|
213
|
+
from .errors import ScreenshotUnavailableError
|
|
214
|
+
result = self._operation("screenshot", {})
|
|
215
|
+
try:
|
|
216
|
+
data = base64.b64decode(result["image_base64"], validate=True)
|
|
217
|
+
width, height = struct.unpack(">II", data[16:24])
|
|
218
|
+
if (result["format"] != "png" or data[:8] != b"\x89PNG\r\n\x1a\n"
|
|
219
|
+
or data[8:16] != b"\x00\x00\x00\rIHDR"
|
|
220
|
+
or data[-12:] != b"\x00\x00\x00\x00IEND\xaeB`\x82" or width < 2 or height < 2
|
|
221
|
+
or width != result["width"] or height != result["height"]):
|
|
222
|
+
raise ValueError("invalid PNG dimensions")
|
|
223
|
+
except (KeyError, TypeError, ValueError, binascii.Error, struct.error) as exc:
|
|
224
|
+
raise ScreenshotUnavailableError("Invalid screenshot response", code="screenshot_unavailable") from exc
|
|
225
|
+
return data
|
|
226
|
+
|
|
227
|
+
def save_screenshot(self, path):
|
|
228
|
+
"""Capture and atomically save PNG to path; return Path. Local errors raise OSError.
|
|
229
|
+
|
|
230
|
+
Existing files are replaced only after the complete PNG has been written.
|
|
231
|
+
Parent directories must exist. No second capture on a local write error.
|
|
232
|
+
"""
|
|
233
|
+
target = Path(path)
|
|
234
|
+
temporary = None
|
|
235
|
+
try:
|
|
236
|
+
with tempfile.NamedTemporaryFile(dir=target.parent, prefix=".invilabs-", suffix=".png", delete=False) as f:
|
|
237
|
+
temporary = Path(f.name)
|
|
238
|
+
f.write(self.screenshot())
|
|
239
|
+
f.flush()
|
|
240
|
+
os.fsync(f.fileno())
|
|
241
|
+
os.replace(temporary, target)
|
|
242
|
+
return target
|
|
243
|
+
finally:
|
|
244
|
+
if temporary is not None:
|
|
245
|
+
temporary.unlink(missing_ok=True)
|
|
246
|
+
|
|
247
|
+
def tap(self, x, y):
|
|
248
|
+
"""Tap once in native screen pixels; wait for execution acknowledgment."""
|
|
249
|
+
if isinstance(x, bool) or isinstance(y, bool) or not isinstance(x, int) or not isinstance(y, int) or x < 0 or y < 0:
|
|
250
|
+
from .errors import InvalidArgumentError
|
|
251
|
+
raise InvalidArgumentError("x and y must be non-negative integers", code="invalid_argument")
|
|
252
|
+
return self._operation("tap", {"x": x, "y": y})
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _integer(value, name, minimum=0, maximum=None):
|
|
256
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < minimum or (maximum is not None and value > maximum):
|
|
257
|
+
from .errors import InvalidArgumentError
|
|
258
|
+
raise InvalidArgumentError(f"Invalid {name}", code="invalid_argument")
|
|
259
|
+
|
|
260
|
+
def swipe(self, x1, y1, x2, y2, *, duration_ms=500):
|
|
261
|
+
"""One swipe between native-pixel endpoints; duration 1–5000 ms."""
|
|
262
|
+
for name, value in (("x1",x1),("y1",y1),("x2",x2),("y2",y2)):
|
|
263
|
+
self._integer(value, name)
|
|
264
|
+
self._integer(duration_ms, "duration_ms", 1, 5000)
|
|
265
|
+
return self._operation("swipe", dict(x1=x1,y1=y1,x2=x2,y2=y2,duration_ms=duration_ms))
|
|
266
|
+
|
|
267
|
+
def scroll(self, direction, *, distance=None, duration_ms=500):
|
|
268
|
+
"""One gesture: down moves the finger upward; up moves it downward.
|
|
269
|
+
|
|
270
|
+
Distance is native pixels (default 60% of screen height), not the
|
|
271
|
+
resulting distance the content moves.
|
|
272
|
+
"""
|
|
273
|
+
from .errors import InvalidArgumentError
|
|
274
|
+
if direction not in ("up", "down"):
|
|
275
|
+
raise InvalidArgumentError("direction must be up or down", code="invalid_argument")
|
|
276
|
+
self._integer(duration_ms, "duration_ms", 1, 5000)
|
|
277
|
+
args = dict(direction=direction,duration_ms=duration_ms)
|
|
278
|
+
if distance is not None:
|
|
279
|
+
self._integer(distance,"distance",1)
|
|
280
|
+
args["distance"] = distance
|
|
281
|
+
return self._operation("scroll",args)
|
|
282
|
+
|
|
283
|
+
def type(self, text):
|
|
284
|
+
"""Insert text without clearing or submitting. Use press('enter') explicitly."""
|
|
285
|
+
from .errors import InvalidArgumentError
|
|
286
|
+
import unicodedata
|
|
287
|
+
if not isinstance(text,str) or not text or any(unicodedata.category(c) in ("Cc", "Cs") for c in text) or len(text.encode("utf-8")) > 1024:
|
|
288
|
+
raise InvalidArgumentError("text must be 1–1024 UTF-8 bytes without control characters",code="invalid_argument")
|
|
289
|
+
return self._operation("type",{"text":text})
|
|
290
|
+
|
|
291
|
+
def press(self, key):
|
|
292
|
+
"""Press enter, backspace, home, or app_switcher."""
|
|
293
|
+
from .errors import InvalidArgumentError
|
|
294
|
+
if key not in ("enter","backspace","home","app_switcher"):
|
|
295
|
+
raise InvalidArgumentError("unsupported key",code="invalid_argument")
|
|
296
|
+
return self._operation("press",{"key":key})
|
|
297
|
+
|
|
298
|
+
def open_app(self, app):
|
|
299
|
+
"""Open an installed app by name or bundle ID; never installs an app."""
|
|
300
|
+
from .errors import InvalidArgumentError
|
|
301
|
+
if not isinstance(app,str) or not app.strip() or any(ord(c)<32 or 127<=ord(c)<=159 or 0xD800<=ord(c)<=0xDFFF for c in app) or len(app.encode("utf-8")) > 255:
|
|
302
|
+
raise InvalidArgumentError("app name or bundle ID required",code="invalid_argument")
|
|
303
|
+
return self._operation("open_app",{"app":app.strip()})
|
|
304
|
+
|
|
305
|
+
def operation(self, key):
|
|
306
|
+
"""Read a prior operation's status without repeating its device action."""
|
|
307
|
+
return self._client._request("GET", f"/v1/sdk/devices/{self.id}/operations?key={quote(key, safe='')}")
|
|
308
|
+
|
|
309
|
+
def _operation(self, name, payload):
|
|
310
|
+
return self._client._request("POST", f"/v1/sdk/devices/{self.id}/operations/{quote(name, safe='')}", payload, idempotent=True)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from os import PathLike
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Literal, overload
|
|
4
|
+
from .types import (DeviceDetails, DevicesResultDevicesItem, TapResult, SwipeResult,
|
|
5
|
+
ScrollResult, TypeResult, PressResult, OpenAppResult, OcrResult, PromptActionResult,
|
|
6
|
+
PromptTargetResult, PromptAnswerResult, OperationResult, UploadMediaResult, GenerateResult)
|
|
7
|
+
|
|
8
|
+
class Client:
|
|
9
|
+
def __init__(self, api_key: str, *, base_url: str = ..., busy_timeout: float | None = ..., request_timeout: float = ...) -> None: ...
|
|
10
|
+
def balance(self) -> float: ...
|
|
11
|
+
def generate(self, prompt: str, *, result_type: Literal['text', 'username', 'email'] = ..., length: int | None = ..., image: str | PathLike[str] | bytes | None = ...) -> GenerateResult: ...
|
|
12
|
+
def operation(self, key: str) -> OperationResult: ...
|
|
13
|
+
def devices(self) -> list[DevicesResultDevicesItem]: ...
|
|
14
|
+
def phone(self, phone_id: str) -> Phone: ...
|
|
15
|
+
|
|
16
|
+
class Phone:
|
|
17
|
+
id: str
|
|
18
|
+
def __init__(self, client: Client, phone_id: str) -> None: ...
|
|
19
|
+
def details(self) -> DeviceDetails: ...
|
|
20
|
+
def tap(self, x: int, y: int) -> TapResult: ...
|
|
21
|
+
def swipe(self, x1: int, y1: int, x2: int, y2: int, *, duration_ms: int = ...) -> SwipeResult: ...
|
|
22
|
+
def scroll(self, direction: Literal['up', 'down'], *, distance: int | None = ..., duration_ms: int = ...) -> ScrollResult: ...
|
|
23
|
+
def type(self, text: str) -> TypeResult: ...
|
|
24
|
+
def press(self, key: Literal['enter', 'backspace', 'home', 'app_switcher']) -> PressResult: ...
|
|
25
|
+
def open_app(self, app: str) -> OpenAppResult: ...
|
|
26
|
+
def screenshot(self) -> bytes: ...
|
|
27
|
+
def save_screenshot(self, path: str | PathLike[str]) -> Path: ...
|
|
28
|
+
def ocr(self) -> OcrResult: ...
|
|
29
|
+
@overload
|
|
30
|
+
def prompt(self, prompt: str, *, result_type: Literal['action'] = ...) -> PromptActionResult: ...
|
|
31
|
+
@overload
|
|
32
|
+
def prompt(self, prompt: str, *, result_type: Literal['target']) -> PromptTargetResult: ...
|
|
33
|
+
@overload
|
|
34
|
+
def prompt(self, prompt: str, *, result_type: Literal['answer']) -> PromptAnswerResult: ...
|
|
35
|
+
def operation(self, key: str) -> OperationResult: ...
|
|
36
|
+
def upload_media(self, path: str | PathLike[str]) -> UploadMediaResult: ...
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""SDK-owned v1 wire contracts. Invalid responses never cause a resend."""
|
|
2
|
+
import json
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
from .errors import ResponseValidationError
|
|
7
|
+
|
|
8
|
+
EMAIL = re.compile('^[A-Za-z0-9_%+-]+(?:\\.[A-Za-z0-9_%+-]+)*@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$')
|
|
9
|
+
|
|
10
|
+
def _valid_email(value):
|
|
11
|
+
return len(value) <= 254 and value.find('@') <= 64 and EMAIL.fullmatch(value) is not None
|
|
12
|
+
|
|
13
|
+
SCHEMAS = json.loads(files('invilabs').joinpath('response_schemas.json').read_text())['schemas']
|
|
14
|
+
|
|
15
|
+
def _matches(value, schema):
|
|
16
|
+
if 'anyOf' in schema:
|
|
17
|
+
return any(_matches(value, candidate) for candidate in schema['anyOf'])
|
|
18
|
+
kind = schema.get('type')
|
|
19
|
+
if kind == 'object':
|
|
20
|
+
if not isinstance(value, dict): return False
|
|
21
|
+
props = schema.get('properties', {})
|
|
22
|
+
if not all(key in value for key in schema.get('required', [])): return False
|
|
23
|
+
if schema.get('additionalProperties') is False and set(value) - set(props): return False
|
|
24
|
+
return all(_matches(value[key], spec) for key, spec in props.items() if key in value)
|
|
25
|
+
if kind == 'array':
|
|
26
|
+
return isinstance(value, list) and all(_matches(item, schema['items']) for item in value)
|
|
27
|
+
if kind == 'string': valid = isinstance(value, str)
|
|
28
|
+
elif kind == 'boolean': valid = isinstance(value, bool)
|
|
29
|
+
elif kind == 'null': valid = value is None
|
|
30
|
+
elif kind == 'integer': valid = type(value) is int
|
|
31
|
+
elif kind == 'number': valid = type(value) in (int, float) and math.isfinite(value)
|
|
32
|
+
else: return False
|
|
33
|
+
if not valid: return False
|
|
34
|
+
if 'minimum' in schema and value < schema['minimum']: return False
|
|
35
|
+
if 'maximum' in schema and value > schema['maximum']: return False
|
|
36
|
+
if 'minLength' in schema and len(value) < schema['minLength']: return False
|
|
37
|
+
if 'maxLength' in schema and len(value) > schema['maxLength']: return False
|
|
38
|
+
return 'enum' not in schema or value in schema['enum']
|
|
39
|
+
|
|
40
|
+
def validate_response(method, path, payload, result, status, key):
|
|
41
|
+
if status >= 400:
|
|
42
|
+
name = 'error'
|
|
43
|
+
elif '/operations?' in path: name = 'operation'
|
|
44
|
+
elif '/operations/' in path:
|
|
45
|
+
name = path.rsplit('/', 1)[-1]
|
|
46
|
+
if name == 'prompt': name += '_' + (payload or {}).get('result_type', 'action')
|
|
47
|
+
elif path.endswith('/generate'): name = 'generate'
|
|
48
|
+
elif path.endswith('/balance'): name = 'balance'
|
|
49
|
+
elif path.endswith('/devices'): name = 'devices'
|
|
50
|
+
else: name = 'details'
|
|
51
|
+
valid = name in SCHEMAS and _matches(result, SCHEMAS[name])
|
|
52
|
+
generated = result.get('result') if valid and name == 'operation' else result
|
|
53
|
+
if valid and isinstance(generated, dict) and generated.get('operation') == 'generate':
|
|
54
|
+
data, mode = generated['data'], generated['result_type']
|
|
55
|
+
text = data['text']
|
|
56
|
+
valid = bool(text.strip()) and data['length'] == len(text) and not any(0xD800 <= ord(c) <= 0xDFFF for c in text)
|
|
57
|
+
if mode == 'username': valid = valid and len(text) <= 64 and re.fullmatch(r'[A-Za-z0-9_]+', text) is not None
|
|
58
|
+
if mode == 'email': valid = valid and _valid_email(text)
|
|
59
|
+
if name == 'generate':
|
|
60
|
+
valid = valid and mode == (payload or {}).get('result_type', 'text')
|
|
61
|
+
expected = (payload or {}).get('length')
|
|
62
|
+
if expected is not None: valid = valid and len(text) == expected
|
|
63
|
+
if not valid:
|
|
64
|
+
details = {}
|
|
65
|
+
if isinstance(result, dict):
|
|
66
|
+
details = {k: result[k] for k in ('credits_charged', 'balance_remaining')
|
|
67
|
+
if k in result and _matches(result[k], {'type':'number','minimum':0})}
|
|
68
|
+
error = ResponseValidationError('Response violates the SDK v1 contract; request was not repeated', code='response_validation_failed', status=status, details=details)
|
|
69
|
+
error.idempotency_key = key
|
|
70
|
+
raise error
|
|
71
|
+
return result
|