Skip to content
Closed
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
11 changes: 8 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ name: Publish to PyPI
# Add "[skip ci]" to a commit message to skip a release.

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

Expand Down Expand Up @@ -38,6 +40,7 @@ jobs:
twine check dist/*

release:
if: github.event_name == 'push'
needs: test
runs-on: ubuntu-latest
permissions:
Expand Down Expand Up @@ -68,18 +71,20 @@ jobs:
# PyPI's JSON can be CDN-stale right after a release; the last tag is not
LATEST_TAG=$(git tag -l 'v*' | sed 's/^v//' | sort -V | tail -n 1)
NEXT=$(python3 - "$LATEST" "$LATEST_TAG" <<'EOF'
import re, sys
import re, sys, tomllib

def parse(v):
m = re.match(r"(\d+)\.(\d+)\.(\d+)", v or "")
return tuple(int(x) for x in m.groups()) if m else None

versions = [p for p in (parse(a) for a in sys.argv[1:3]) if p]
declared = parse(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])
if versions:
major, minor, patch = max(versions)
print(f"{major}.{minor}.{patch + 1}")
version = max(declared, (major, minor, patch + 1))
else:
print("0.1.0") # first release ever
version = declared
print('.'.join(map(str, version)))
EOF
)
echo "next=$NEXT" >> "$GITHUB_OUTPUT"
Expand Down
27 changes: 18 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ This library is its Python API.
```python
from nomix_clicker import Clicker, Agent, open_app, DEVICE_ID

clicker = Clicker(DEVICE_ID)
open_app(clicker, "Notes")
clicker.type("Hello from Python")
with Clicker(DEVICE_ID) as clicker:
open_app(clicker, "Notes")
clicker.type("Hello from Python")

# or hand the whole task to the AI agent
Agent(DEVICE_ID).run("Open Settings and enable Dark Mode")
Expand Down Expand Up @@ -62,14 +62,23 @@ export NOMIX_DEVICE_ID="your-device-id"
```python
from nomix_clicker import Clicker, parse_screen, open_app, DEVICE_ID

clicker = Clicker(DEVICE_ID)
open_app(clicker, "Calculator")

screen = parse_screen(clicker) # returns None on network/timeout errors
if screen:
print(screen.description) # what the AI recognizer sees on screen
with Clicker(DEVICE_ID) as clicker:
open_app(clicker, "Calculator")
screen = parse_screen(clicker)
if screen:
print(screen.description)
```

### Control sessions

`Clicker(device_id).click((x, y))` and the existing `api_helper` command functions use HTTP: no connection setup is required. The server claims the device for each command and returns 409 when another controller owns it. Commands use the same names, parameters and execution service as the live panel.

For a multi-step workflow, `with Clicker(device_id)` (or explicit `connect()` / `close()`) holds exclusive control over WebSocket until exit. This prevents another client from moving the phone between steps. `connect(takeover=True)` is an explicit takeover; a failed session never falls back to HTTP or replays input.

Commands return `{"status": "sent", "device_acknowledged": false}`. This confirms dispatch, not execution on the phone. Failures raise `ControlError` with `code` and `outcome` (`not_sent`, `partial`, or `unknown`). An interrupted session never reconnects or replays input automatically; create a new session only after checking the phone. Closing releases control and cancels unsent input. Device input is best effort without execution acknowledgments; input already transmitted may continue or overlap later actions on the phone.

The multi-step examples below use an exclusive `clicker` context. For direct protocol access use `clicker.control.request(method, params)`. Discover the endpoint with `GET /clicker/v1/{device_id}/control`; method schemas are at `GET /clicker/v1/control-schema`. HTTP command endpoints and the WebSocket contract are both available. SDK 0.2 raises errors instead of returning an unchecked failure dictionary, so a failed command stops the caller’s workflow. Typing is bounded to 180 seconds per command.

### Double tap

Use `double_tap` when an app requires two taps close enough together to like,
Expand Down
36 changes: 18 additions & 18 deletions examples/notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,24 @@

def main():
text = " ".join(sys.argv[1:]) or DEFAULT_NOTE
clicker = Clicker(DEVICE_ID)

screen = open_app(clicker, "Notes")
if not screen:
print("Could not open the Notes app")
return
random_sleep(0.8, 1.5)

# Start a new note. The compose control is an icon, so try a few labels.
if not screen.find_and_click(clicker, "new note", "compose", "create note"):
print("Could not find the New Note button")
return
random_sleep(0.8, 1.5)

if not clicker.type(text).get("success"):
print("Could not type the note")
return
print(f"Note written:\n {text}")
with Clicker(DEVICE_ID) as clicker:

screen = open_app(clicker, "Notes")
if not screen:
print("Could not open the Notes app")
return
random_sleep(0.8, 1.5)

# Start a new note. The compose control is an icon, so try a few labels.
if not screen.find_and_click(clicker, "new note", "compose", "create note"):
print("Could not find the New Note button")
return
random_sleep(0.8, 1.5)

if not clicker.type(text).get("status") == "sent":
print("Could not type the note")
return
print(f"Note written:\n {text}")


if __name__ == "__main__":
Expand Down
16 changes: 8 additions & 8 deletions examples/upload_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,16 @@
def main():
if len(sys.argv) != 2:
sys.exit("Usage: python3 examples/upload_media.py <photo-or-video>")
clicker = Clicker(DEVICE_ID)
with Clicker(DEVICE_ID) as clicker:

item = upload_media(clicker, sys.argv[1])
if not item:
print("Upload failed")
return
print(f"Saved {item['kind']} '{item['filename']}' ({item['size']} bytes)")
item = upload_media(clicker, sys.argv[1])
if not item:
print("Upload failed")
return
print(f"Saved {item['kind']} '{item['filename']}' ({item['size']} bytes)")

# The new file is now the newest item in the library
open_app(clicker, "Photos")
# The new file is now the newest item in the library
open_app(clicker, "Photos")


if __name__ == "__main__":
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"

[project]
name = "nomix-clicker"
version = "0.1.0"
version = "0.2.0"
description = "Python automation for real iPhones — direct control, AI screen recognition, and autonomous agents."
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
authors = [{ name = "Nixon - FZCO" }]
keywords = ["automation", "ios", "clicker", "nomix", "ui-automation", "device-automation"]
dependencies = ["requests>=2.32"]
dependencies = ["requests>=2.32", "websocket-client==1.9.2"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
Expand All @@ -24,7 +24,7 @@ classifiers = [
]

[project.optional-dependencies]
dev = ["pytest>=8.0", "build>=1.0"]
dev = ["pytest>=8.0", "build>=1.0", "websockets==16.1"]

[project.urls]
Homepage = "https://nomixclicker.com"
Expand Down
9 changes: 6 additions & 3 deletions src/nomix_clicker/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

from nomix_clicker import Clicker, parse_screen, open_app

clicker = Clicker("your-device-id")
open_app(clicker, "Calculator")
screen = parse_screen(clicker)
with Clicker("your-device-id") as clicker:
open_app(clicker, "Calculator")
screen = parse_screen(clicker)
"""

from importlib.metadata import PackageNotFoundError, version as _dist_version
Expand All @@ -25,6 +25,7 @@
)
from .agent import Agent
from .clicker import Clicker
from .control import ControlError, ControlSession
from .environment import API_KEY, API_URL, DEVICE_ID
from .recognition import Element, Screen, parse_screen

Expand All @@ -36,6 +37,8 @@
__all__ = [
# core
"Clicker",
"ControlSession",
"ControlError",
"Agent",
"Screen",
"Element",
Expand Down
12 changes: 6 additions & 6 deletions src/nomix_clicker/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,12 @@ def post_comment(
submit_keyword = [submit_keyword]

if cached_coords and "comment_input" in cached_coords and "comment_submit" in cached_coords:
if not clicker.click(cached_coords["comment_input"]).get("success"):
if not clicker.click(cached_coords["comment_input"]).get("status") == "sent":
return False
if not clicker.type(text).get("success"):
if not clicker.type(text).get("status") == "sent":
return False
sleep(0.5)
return bool(clicker.click(cached_coords["comment_submit"]).get("success"))
return bool(clicker.click(cached_coords["comment_submit"]).get("status") == "sent")

screen = parse_screen(clicker)
if not screen:
Expand All @@ -159,9 +159,9 @@ def post_comment(
print("WARNING: Comment input not found")
return False

if not clicker.click(input_coords).get("success"):
if not clicker.click(input_coords).get("status") == "sent":
return False
if not clicker.type(text).get("success"):
if not clicker.type(text).get("status") == "sent":
return False
sleep(1)

Expand All @@ -179,7 +179,7 @@ def post_comment(
cached_coords["comment_submit"] = submit_coords
print(f"Cached comment_input at {input_coords}, comment_submit at {submit_coords}")

return bool(clicker.click(submit_coords).get("success"))
return bool(clicker.click(submit_coords).get("status") == "sent")


def _error_detail(e: requests.RequestException) -> str:
Expand Down
Loading
Loading