mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-09-19 09:31:09 +08:00
feat: make NSLogger logs available through CLI-Anything
Add an in-repo NSLogger harness so iOS/macOS NSLogger sessions can be parsed, filtered, exported, and received live through CLI-Anything. The live path mirrors the desktop GUI with native macOS Bonjour/NetService support while keeping direct TCP/TLS available for manually configured clients. Constraint: CLI-Anything requires in-repo harnesses to provide registry metadata, canonical and packaged SKILL.md files, tests, README coverage, and an unmodified repl_skin.py copy. Rejected: Submit registry-only external package | user asked to contribute this repository and the harness is ready for in-repo inclusion. Confidence: high Scope-risk: moderate Directive: Do not replace the native Bonjour listener with dns-sd publication without validating against real NSLogger iOS clients; NSLogger.app uses listenForConnections semantics. Tested: python3.10 -m pytest cli_anything/nslogger/tests -q Tested: python3.10 -m cli_anything.nslogger.nslogger_cli -h Tested: python3.10 -m cli_anything.nslogger.nslogger_cli listen -h Tested: python3 -m json.tool registry.json
This commit is contained in:
@@ -68,6 +68,7 @@
|
||||
!/zotero/
|
||||
!/mubu/
|
||||
!/obs-studio/
|
||||
!/nslogger/
|
||||
!/kdenlive/
|
||||
!/shotcut/
|
||||
!/anygen/
|
||||
@@ -116,6 +117,8 @@
|
||||
/mubu/.*
|
||||
/obs-studio/*
|
||||
/obs-studio/.*
|
||||
/nslogger/*
|
||||
/nslogger/.*
|
||||
/kdenlive/*
|
||||
/kdenlive/.*
|
||||
/shotcut/*
|
||||
@@ -190,6 +193,7 @@
|
||||
!/zotero/agent-harness/
|
||||
!/mubu/agent-harness/
|
||||
!/obs-studio/agent-harness/
|
||||
!/nslogger/agent-harness/
|
||||
!/kdenlive/agent-harness/
|
||||
!/shotcut/agent-harness/
|
||||
!/anygen/agent-harness/
|
||||
|
||||
@@ -840,6 +840,13 @@ Each application received complete, production-ready CLI interfaces — not demo
|
||||
<td align="center">✅ 153</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>📱 <a href="nslogger/agent-harness/">NSLogger</a></strong></td>
|
||||
<td>iOS/macOS Log Capture</td>
|
||||
<td><code>cli-anything-nslogger</code></td>
|
||||
<td>NSLogger wire protocol + native macOS Bonjour</td>
|
||||
<td align="center">✅ 139</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>🎞️ Kdenlive</strong></td>
|
||||
<td>Video Editing</td>
|
||||
<td><code>cli-anything-kdenlive</code></td>
|
||||
@@ -988,11 +995,11 @@ Each application received complete, production-ready CLI interfaces — not demo
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="4"><strong>Total</strong></td>
|
||||
<td align="center"><strong>✅ 2,152</strong></td>
|
||||
<td align="center"><strong>✅ 2,291</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> **100% pass rate** across all 2,152 tests — 1,564 unit tests + 569 end-to-end tests + 19 Node.js tests.
|
||||
> **100% pass rate** across all 2,291 tests — 1,661 unit tests + 611 end-to-end tests + 19 Node.js tests.
|
||||
|
||||
---
|
||||
|
||||
@@ -1016,6 +1023,7 @@ audacity 161 passed ✅ (107 unit + 54 e2e)
|
||||
libreoffice 158 passed ✅ (89 unit + 69 e2e)
|
||||
mubu 96 passed ✅ (85 unit + 11 e2e)
|
||||
obs-studio 153 passed ✅ (116 unit + 37 e2e)
|
||||
nslogger 139 passed ✅ (97 unit + 42 e2e)
|
||||
kdenlive 155 passed ✅ (111 unit + 44 e2e)
|
||||
shotcut 154 passed ✅ (110 unit + 44 e2e)
|
||||
zoom 22 passed ✅ (22 unit + 0 e2e)
|
||||
@@ -1033,7 +1041,7 @@ cloudcompare 88 passed ✅ (49 unit + 39 e2e)
|
||||
openscreen 101 passed ✅ (78 unit + 23 e2e)
|
||||
cloudanalyzer 14 passed ✅ (7 unit + 7 e2e)
|
||||
──────────────────────────────────────────────────────────────────────────────
|
||||
TOTAL 2,120 passed ✅ 100% pass rate
|
||||
TOTAL 2,259 passed ✅ 100% pass rate
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1091,6 +1099,7 @@ cli-anything/
|
||||
├── 📚 zotero/agent-harness/ # Zotero CLI (new, write import support)
|
||||
├── 📝 mubu/agent-harness/ # Mubu CLI (96 tests)
|
||||
├── 📹 obs-studio/agent-harness/ # OBS Studio CLI (153 tests)
|
||||
├── 📱 nslogger/agent-harness/ # NSLogger CLI (139 tests)
|
||||
├── 🎞️ kdenlive/agent-harness/ # Kdenlive CLI (155 tests)
|
||||
├── 🎬 shotcut/agent-harness/ # Shotcut CLI (154 tests)
|
||||
├── 📞 zoom/agent-harness/ # Zoom CLI (22 tests)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# NSLogger CLI Harness — SOP
|
||||
|
||||
## What is NSLogger
|
||||
|
||||
NSLogger is a macOS application for receiving, viewing, and analyzing log messages
|
||||
sent by iOS/macOS applications using the NSLogger client library.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| `.nsloggerdata` | Saved binary-plist session file (opened/exported by the GUI) |
|
||||
| `.rawnsloggerdata` | Raw wire-protocol capture file |
|
||||
| Message | A single log entry with: sequence, timestamp, level, tag, threadID, type, text |
|
||||
| Message Types | `text`, `image`, `data` |
|
||||
| Log Level | 0=error, 1=warning, 2=info, 3=debug, 4=verbose (higher=noisier) |
|
||||
| Connection | A live client connection (by Bonjour or direct TCP on port 50000) |
|
||||
| Filter | A predicate that restricts which messages are displayed |
|
||||
|
||||
## Wire Protocol (rawnsloggerdata)
|
||||
|
||||
NSLogger uses a custom binary protocol over TCP:
|
||||
|
||||
```
|
||||
[4-byte big-endian total message length]
|
||||
[4-byte big-endian sequence number]
|
||||
[2-byte part count]
|
||||
for each part:
|
||||
[1-byte part key]
|
||||
[1-byte part type]
|
||||
[4-byte big-endian data length]
|
||||
[N bytes data]
|
||||
```
|
||||
|
||||
Part keys: `0=messageType`, `1=timestamp_s`, `2=timestamp_ms`, `3=timestamp_us`,
|
||||
`4=threadID`, `5=tag`, `6=level`, `7=message`, `8=imageWidth`,
|
||||
`9=imageHeight`, `10=messageSeq`, `11=filename`, `12=lineNumber`,
|
||||
`13=functionName`, `20=clientName`, `21=clientVersion`,
|
||||
`22=osName`, `23=osVersion`, `24=clientModel`, `25=uniqueID`
|
||||
|
||||
Part types: `0=string(UTF8)`, `1=binary`, `2=int16`, `3=int32`, `4=int64`,
|
||||
`5=image`
|
||||
|
||||
Message types: `0=log`, `1=blockStart`, `2=blockEnd`, `3=clientInfo`,
|
||||
`4=disconnect`, `255=marker`
|
||||
|
||||
## CLI Command Groups
|
||||
|
||||
```
|
||||
cli-anything-nslogger read # Parse and display .nsloggerdata / .rawnsloggerdata
|
||||
cli-anything-nslogger filter # Filter messages from a file (level, tag, thread, text, regex, type, time, seq)
|
||||
cli-anything-nslogger export # Export messages to text/JSON/CSV
|
||||
cli-anything-nslogger stats # Summary statistics for a file
|
||||
cli-anything-nslogger listen # Listen for live NSLogger connections
|
||||
cli-anything-nslogger generate # Generate sample .rawnsloggerdata for testing
|
||||
cli-anything-nslogger tail # Show last N messages from a file
|
||||
cli-anything-nslogger clients # List all client_info records in a file
|
||||
cli-anything-nslogger blocks # Show block start/end structure as indented tree
|
||||
cli-anything-nslogger merge # Merge multiple files sorted by timestamp
|
||||
```
|
||||
|
||||
## Typical Agent Workflow
|
||||
|
||||
```bash
|
||||
# Inspect a captured log file
|
||||
cli-anything-nslogger read session.rawnsloggerdata
|
||||
|
||||
# Find all errors
|
||||
cli-anything-nslogger filter --level 0 session.rawnsloggerdata
|
||||
|
||||
# Filter within a time window
|
||||
cli-anything-nslogger filter --after "10:30:00" --before "10:45:00" session.rawnsloggerdata
|
||||
|
||||
# Filter by sequence range
|
||||
cli-anything-nslogger filter --from-seq 100 --to-seq 200 session.rawnsloggerdata
|
||||
|
||||
# Show last 50 messages
|
||||
cli-anything-nslogger tail --count 50 session.rawnsloggerdata
|
||||
|
||||
# List connected clients
|
||||
cli-anything-nslogger clients session.rawnsloggerdata
|
||||
|
||||
# Show block/call structure
|
||||
cli-anything-nslogger blocks session.rawnsloggerdata
|
||||
|
||||
# Merge two capture files
|
||||
cli-anything-nslogger merge a.rawnsloggerdata b.rawnsloggerdata --format json
|
||||
|
||||
# Export for further analysis
|
||||
cli-anything-nslogger export --format json session.rawnsloggerdata > logs.json
|
||||
|
||||
# Get statistics
|
||||
cli-anything-nslogger stats session.rawnsloggerdata
|
||||
|
||||
# Listen for live iOS logs via Bonjour, matching NSLogger.app
|
||||
cli-anything-nslogger listen --bonjour --name bazinga --debug
|
||||
|
||||
# Mirror live logs to disk while still printing stdout
|
||||
cli-anything-nslogger listen --bonjour --name bazinga --output app.log
|
||||
|
||||
# Direct TCP/TLS mode for manually configured clients
|
||||
cli-anything-nslogger listen --port 50000 --ssl --debug
|
||||
```
|
||||
@@ -0,0 +1,85 @@
|
||||
# cli-anything-nslogger
|
||||
|
||||
CLI harness for [NSLogger](https://github.com/fpillet/NSLogger) — read, filter, export, and monitor NSLogger log files from the command line or from AI agents.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd agent-harness
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Generate a sample file
|
||||
cli-anything-nslogger generate sample.rawnsloggerdata --count 50
|
||||
|
||||
# Read and display all messages
|
||||
cli-anything-nslogger read sample.rawnsloggerdata
|
||||
|
||||
# Show only errors
|
||||
cli-anything-nslogger read sample.rawnsloggerdata --level 0
|
||||
|
||||
# Filter by tag
|
||||
cli-anything-nslogger filter sample.rawnsloggerdata --tag Network
|
||||
|
||||
# Export to JSON
|
||||
cli-anything-nslogger export sample.rawnsloggerdata --format json
|
||||
|
||||
# Statistics
|
||||
cli-anything-nslogger stats sample.rawnsloggerdata
|
||||
|
||||
# Listen for live iOS logs via Bonjour, matching the NSLogger.app GUI
|
||||
cli-anything-nslogger listen --bonjour --name bazinga --debug
|
||||
|
||||
# Direct TCP/TLS mode for manually configured clients
|
||||
cli-anything-nslogger listen --port 50000 --ssl --debug
|
||||
|
||||
# Listen and write received live logs to a file
|
||||
cli-anything-nslogger listen --bonjour --name bazinga --output app.log
|
||||
|
||||
# Write machine-readable live logs as JSON Lines
|
||||
cli-anything-nslogger listen --bonjour --name bazinga --output app.jsonl --output-format jsonl
|
||||
|
||||
# Interactive REPL
|
||||
cli-anything-nslogger repl sample.rawnsloggerdata
|
||||
```
|
||||
|
||||
## JSON Output (Agent Mode)
|
||||
|
||||
Every command accepts `--json` for machine-readable output:
|
||||
|
||||
```bash
|
||||
cli-anything-nslogger read sample.rawnsloggerdata --json
|
||||
cli-anything-nslogger stats sample.rawnsloggerdata --json
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|-----------|-------------|
|
||||
| `read` | Parse and display messages from a file |
|
||||
| `filter` | Advanced filtering (level, tag, thread, regex) |
|
||||
| `export` | Export to text / JSON / CSV |
|
||||
| `stats` | Summary statistics |
|
||||
| `listen` | Receive live NSLogger connections via Bonjour or TCP/TLS |
|
||||
| `generate`| Create sample `.rawnsloggerdata` for testing |
|
||||
| `repl` | Interactive Python REPL with loaded messages |
|
||||
|
||||
## Log Levels
|
||||
|
||||
| Level | Name |
|
||||
|-------|---------|
|
||||
| 0 | ERROR |
|
||||
| 1 | WARNING |
|
||||
| 2 | INFO |
|
||||
| 3 | DEBUG |
|
||||
| 4 | VERBOSE |
|
||||
|
||||
## File Formats
|
||||
|
||||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| `.rawnsloggerdata` | Raw NSLogger wire-protocol capture |
|
||||
| `.nsloggerdata` | Binary plist archive saved by NSLogger.app |
|
||||
@@ -0,0 +1,2 @@
|
||||
"""NSLogger CLI harness — cli_anything.nslogger package."""
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Block structure analysis for NSLogger files."""
|
||||
from __future__ import annotations
|
||||
from typing import Iterator, List, Dict, Any
|
||||
from .message import LogMessage, MSG_TYPE_BLOCK_START, MSG_TYPE_BLOCK_END, MSG_TYPE_CLIENT_INFO
|
||||
|
||||
|
||||
def iter_block_tree(messages: Iterator[LogMessage]):
|
||||
"""Yield (depth, msg) tuples representing the block-indented structure."""
|
||||
depth = 0
|
||||
for msg in messages:
|
||||
if msg.message_type == MSG_TYPE_BLOCK_END:
|
||||
depth = max(0, depth - 1)
|
||||
yield depth, msg
|
||||
if msg.message_type == MSG_TYPE_BLOCK_START:
|
||||
depth += 1
|
||||
|
||||
|
||||
def extract_clients(messages: Iterator[LogMessage]) -> List[Dict[str, Any]]:
|
||||
"""Return a list of dicts for every client_info message in the stream."""
|
||||
clients = []
|
||||
for msg in messages:
|
||||
if msg.message_type == MSG_TYPE_CLIENT_INFO:
|
||||
clients.append({
|
||||
"sequence": msg.sequence,
|
||||
"timestamp": msg.timestamp.isoformat() if msg.timestamp else None,
|
||||
"client_name": msg.client_name,
|
||||
"client_version": msg.client_version,
|
||||
"os_name": msg.os_name,
|
||||
"os_version": msg.os_version,
|
||||
"machine": msg.machine,
|
||||
})
|
||||
return clients
|
||||
|
||||
|
||||
def merge_files(paths: List[str]) -> List[LogMessage]:
|
||||
"""Load multiple rawnsloggerdata files and return messages sorted by timestamp."""
|
||||
from ..core.parser import parse_file
|
||||
all_msgs: List[LogMessage] = []
|
||||
for path in paths:
|
||||
all_msgs.extend(parse_file(path))
|
||||
all_msgs.sort(key=lambda m: (m.timestamp or __import__("datetime").datetime.min, m.sequence))
|
||||
return all_msgs
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Export LogMessages to various formats."""
|
||||
from __future__ import annotations
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from typing import Iterator, List
|
||||
from .message import LogMessage
|
||||
|
||||
|
||||
def export_text(messages: List[LogMessage]) -> str:
|
||||
lines = [m.to_text_line() for m in messages]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def export_json(messages: List[LogMessage]) -> str:
|
||||
return json.dumps([m.to_dict() for m in messages], indent=2, default=str)
|
||||
|
||||
|
||||
def export_csv(messages: List[LogMessage]) -> str:
|
||||
buf = io.StringIO()
|
||||
fields = ["sequence", "timestamp", "level", "level_name", "tag", "thread_id", "type", "text"]
|
||||
writer = csv.DictWriter(buf, fieldnames=fields, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for m in messages:
|
||||
d = m.to_dict()
|
||||
writer.writerow({f: d.get(f, "") for f in fields})
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def export_messages(messages: Iterator[LogMessage], fmt: str = "text") -> str:
|
||||
msgs = list(messages)
|
||||
if fmt == "json":
|
||||
return export_json(msgs)
|
||||
if fmt == "csv":
|
||||
return export_csv(msgs)
|
||||
return export_text(msgs)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Message filtering logic for NSLogger CLI."""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Iterator, Optional, List
|
||||
from .message import LogMessage
|
||||
|
||||
|
||||
def filter_messages(
|
||||
messages: Iterator[LogMessage],
|
||||
max_level: Optional[int] = None,
|
||||
min_level: Optional[int] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
text_search: Optional[str] = None,
|
||||
text_regex: Optional[str] = None,
|
||||
msg_types: Optional[List[str]] = None,
|
||||
limit: Optional[int] = None,
|
||||
after: Optional[datetime] = None,
|
||||
before: Optional[datetime] = None,
|
||||
from_seq: Optional[int] = None,
|
||||
to_seq: Optional[int] = None,
|
||||
) -> Iterator[LogMessage]:
|
||||
"""Yield messages matching all specified criteria."""
|
||||
pattern = re.compile(text_regex, re.IGNORECASE) if text_regex else None
|
||||
tag_set = {t.lower() for t in tags} if tags else None
|
||||
type_set = set(msg_types) if msg_types else None
|
||||
count = 0
|
||||
|
||||
for msg in messages:
|
||||
if max_level is not None and msg.level > max_level:
|
||||
continue
|
||||
if min_level is not None and msg.level < min_level:
|
||||
continue
|
||||
if tag_set and msg.tag.lower() not in tag_set:
|
||||
continue
|
||||
if thread_id and msg.thread_id != thread_id:
|
||||
continue
|
||||
if text_search and text_search.lower() not in msg.text.lower():
|
||||
continue
|
||||
if pattern and not pattern.search(msg.text):
|
||||
continue
|
||||
if type_set and msg.type_name not in type_set:
|
||||
continue
|
||||
if after is not None and (msg.timestamp is None or msg.timestamp < after):
|
||||
continue
|
||||
if before is not None and (msg.timestamp is None or msg.timestamp > before):
|
||||
continue
|
||||
if from_seq is not None and msg.sequence < from_seq:
|
||||
continue
|
||||
if to_seq is not None and msg.sequence > to_seq:
|
||||
continue
|
||||
yield msg
|
||||
count += 1
|
||||
if limit is not None and count >= limit:
|
||||
break
|
||||
@@ -0,0 +1,744 @@
|
||||
"""TCP listener that receives live NSLogger connections."""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import base64
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from importlib import resources
|
||||
from typing import Callable, Optional
|
||||
from .message import LogMessage
|
||||
from .parser import _parse_message, ParseError
|
||||
|
||||
# NSLogger Bonjour service types
|
||||
NSLOGGER_SERVICE_TYPE = "_nslogger._tcp.local."
|
||||
NSLOGGER_SSL_SERVICE_TYPE = "_nslogger-ssl._tcp.local."
|
||||
|
||||
|
||||
def _get_local_ip() -> str:
|
||||
"""Return the primary local IPv4 address."""
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
return ip
|
||||
except Exception:
|
||||
return "127.0.0.1"
|
||||
|
||||
|
||||
def _make_ssl_context() -> tuple[ssl.SSLContext, str]:
|
||||
"""Generate a temporary self-signed cert and return (SSLContext, tmp_dir)."""
|
||||
tmp_dir = tempfile.mkdtemp(prefix="nslogger_cli_")
|
||||
cert = os.path.join(tmp_dir, "server.crt")
|
||||
key = os.path.join(tmp_dir, "server.key")
|
||||
subprocess.run(
|
||||
[
|
||||
"openssl", "req", "-x509", "-newkey", "rsa:2048",
|
||||
"-keyout", key, "-out", cert,
|
||||
"-days", "1", "-nodes",
|
||||
"-subj", "/CN=nslogger-cli",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
if hasattr(ssl, "TLSVersion"):
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
ctx.maximum_version = ssl.TLSVersion.TLSv1_2
|
||||
try:
|
||||
ctx.set_ciphers("DEFAULT@SECLEVEL=1")
|
||||
except ssl.SSLError:
|
||||
pass
|
||||
ctx.load_cert_chain(cert, key)
|
||||
return ctx, tmp_dir
|
||||
|
||||
|
||||
def _make_pkcs12_identity() -> tuple[str, str, str]:
|
||||
"""Generate a temporary self-signed PKCS#12 identity for CFStream server SSL."""
|
||||
tmp_dir = tempfile.mkdtemp(prefix="nslogger_cli_")
|
||||
cert = os.path.join(tmp_dir, "server.crt")
|
||||
key = os.path.join(tmp_dir, "server.key")
|
||||
p12 = os.path.join(tmp_dir, "server.p12")
|
||||
password = "nslogger-cli"
|
||||
subprocess.run(
|
||||
[
|
||||
"openssl", "req", "-x509", "-newkey", "rsa:2048",
|
||||
"-keyout", key, "-out", cert,
|
||||
"-days", "1", "-nodes",
|
||||
"-subj", "/CN=nslogger-cli",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"openssl", "pkcs12", "-export",
|
||||
"-inkey", key,
|
||||
"-in", cert,
|
||||
"-out", p12,
|
||||
"-passout", f"pass:{password}",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return p12, password, tmp_dir
|
||||
|
||||
|
||||
def _swift_helper_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
cache_root = tempfile.gettempdir()
|
||||
env.setdefault("SWIFT_MODULE_CACHE_PATH", os.path.join(cache_root, "nslogger_cli_swift_module_cache"))
|
||||
env.setdefault("CLANG_MODULE_CACHE_PATH", os.path.join(cache_root, "nslogger_cli_clang_module_cache"))
|
||||
return env
|
||||
|
||||
|
||||
def _compiled_swift_helper(helper_name: str, on_debug: Callable[[str], None]) -> str:
|
||||
helper = resources.files("cli_anything.nslogger").joinpath(f"helpers/{helper_name}.swift")
|
||||
helper_path = str(helper)
|
||||
cache_dir = os.path.join(tempfile.gettempdir(), "nslogger_cli_swift_helpers")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
try:
|
||||
stamp = f"{int(os.path.getmtime(helper_path))}_{os.path.getsize(helper_path)}"
|
||||
except OSError:
|
||||
stamp = "unknown"
|
||||
executable = os.path.join(cache_dir, f"{helper_name}_{stamp}")
|
||||
if not os.path.exists(executable):
|
||||
on_debug(f"Compiling native helper {helper_name}")
|
||||
subprocess.run(
|
||||
["swiftc", helper_path, "-o", executable],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
env=_swift_helper_env(),
|
||||
)
|
||||
return executable
|
||||
|
||||
|
||||
def _peek(conn: socket.socket, size: int = 16) -> bytes:
|
||||
try:
|
||||
return conn.recv(size, socket.MSG_PEEK)
|
||||
except socket.timeout:
|
||||
raise
|
||||
except (AttributeError, OSError):
|
||||
return b""
|
||||
|
||||
|
||||
def _peek_hex(conn: socket.socket, size: int = 16) -> str:
|
||||
return _peek(conn, size).hex(" ")
|
||||
|
||||
|
||||
def _looks_like_tls_client_hello(conn: socket.socket) -> bool:
|
||||
"""Best-effort TLS ClientHello detection without consuming bytes."""
|
||||
header = _peek(conn, 5)
|
||||
if len(header) < 3:
|
||||
return False
|
||||
# TLS record header: 0x16, 0x03, version
|
||||
return header[0] == 0x16 and header[1] == 0x03
|
||||
|
||||
|
||||
def _classify_connection(conn: socket.socket) -> tuple[str, bytes]:
|
||||
"""Classify the first bytes without consuming them: tls, raw, or empty."""
|
||||
try:
|
||||
header = _peek(conn, 5)
|
||||
except socket.timeout:
|
||||
return "timeout", b""
|
||||
if not header:
|
||||
return "empty", header
|
||||
if len(header) >= 3 and header[0] == 0x16 and header[1] == 0x03:
|
||||
return "tls", header
|
||||
return "raw", header
|
||||
|
||||
|
||||
def _dns_sd_txt_args(service_name: str, filter_clients: bool = False) -> list[str]:
|
||||
"""Return TXT records matching NSLogger.app's named-service publishing."""
|
||||
return ["filterClients=1"] if service_name and filter_clients else []
|
||||
|
||||
|
||||
def _bonjour_service_types(use_ssl: bool, allow_plaintext: bool = False) -> tuple[str, ...]:
|
||||
"""Return the Bonjour service type advertised by NSLogger.app for this mode."""
|
||||
if use_ssl and allow_plaintext:
|
||||
return ("_nslogger._tcp", "_nslogger-ssl._tcp")
|
||||
return ("_nslogger-ssl._tcp",) if use_ssl else ("_nslogger._tcp",)
|
||||
|
||||
|
||||
class _ZeroconfBonjourPublisher:
|
||||
"""In-process Bonjour publisher so Ctrl-C cannot leave dns-sd children behind."""
|
||||
|
||||
def __init__(self, service_name: str, service_types: tuple[str, ...], port: int, local_ip: str, filter_clients: bool):
|
||||
from zeroconf import ServiceInfo, Zeroconf
|
||||
|
||||
self._zeroconf = Zeroconf()
|
||||
self._infos = []
|
||||
properties = {"filterClients": "1"} if service_name and filter_clients else {}
|
||||
addresses = [] if local_ip == "127.0.0.1" else [socket.inet_aton(local_ip)]
|
||||
hostname = socket.gethostname().split(".")[0]
|
||||
server = f"{hostname}.local."
|
||||
|
||||
for service_type in service_types:
|
||||
type_domain = f"{service_type}.local."
|
||||
info = ServiceInfo(
|
||||
type_domain,
|
||||
f"{service_name}.{type_domain}",
|
||||
addresses=addresses,
|
||||
port=port,
|
||||
properties=properties,
|
||||
server=server,
|
||||
)
|
||||
self._zeroconf.register_service(info)
|
||||
self._infos.append(info)
|
||||
|
||||
def close(self):
|
||||
for info in self._infos:
|
||||
try:
|
||||
self._zeroconf.unregister_service(info)
|
||||
except Exception:
|
||||
pass
|
||||
self._zeroconf.close()
|
||||
|
||||
|
||||
class _DnsSdBonjourPublisher:
|
||||
"""Fallback Bonjour publisher using macOS dns-sd."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service_name: str,
|
||||
service_types: tuple[str, ...],
|
||||
port: int,
|
||||
filter_clients: bool,
|
||||
on_debug: Callable[[str], None],
|
||||
):
|
||||
self._procs = []
|
||||
txt_args = _dns_sd_txt_args(service_name, filter_clients)
|
||||
for service_type in service_types:
|
||||
command = ["dns-sd", "-R", service_name, service_type, "local", str(port), *txt_args]
|
||||
proc = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
self._procs.append(proc)
|
||||
on_debug(f"Started dns-sd publisher pid={proc.pid} command={' '.join(command)}")
|
||||
threading.Thread(
|
||||
target=self._drain_output,
|
||||
args=(proc, service_type, on_debug),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
@staticmethod
|
||||
def _drain_output(proc: subprocess.Popen, service_type: str, on_debug: Callable[[str], None]):
|
||||
if proc.stdout is None:
|
||||
return
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
line = line.strip()
|
||||
if line:
|
||||
on_debug(f"dns-sd[{service_type}] {line}")
|
||||
finally:
|
||||
code = proc.poll()
|
||||
if code is not None:
|
||||
on_debug(f"dns-sd[{service_type}] exited with code {code}")
|
||||
|
||||
def close(self):
|
||||
for proc in self._procs:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2.0)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class _NativeBonjourPublisher:
|
||||
"""macOS Bonjour publisher backed by Foundation.NetService, matching NSLogger.app more closely."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service_name: str,
|
||||
service_types: tuple[str, ...],
|
||||
port: int,
|
||||
filter_clients: bool,
|
||||
on_debug: Callable[[str], None],
|
||||
):
|
||||
helper = _compiled_swift_helper("native_bonjour_publisher", on_debug)
|
||||
command = [
|
||||
helper,
|
||||
"--name",
|
||||
service_name,
|
||||
"--port",
|
||||
str(port),
|
||||
"--types",
|
||||
",".join(service_types),
|
||||
]
|
||||
if filter_clients:
|
||||
command.extend(["--txt", "filterClients=1"])
|
||||
|
||||
self._proc = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=_swift_helper_env(),
|
||||
)
|
||||
on_debug(f"Started native Bonjour publisher pid={self._proc.pid} command={' '.join(command)}")
|
||||
threading.Thread(
|
||||
target=self._drain_output,
|
||||
args=(self._proc, on_debug),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
@staticmethod
|
||||
def _drain_output(proc: subprocess.Popen, on_debug: Callable[[str], None]):
|
||||
if proc.stdout is None:
|
||||
return
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
line = line.strip()
|
||||
if line:
|
||||
on_debug(f"native-bonjour {line}")
|
||||
finally:
|
||||
code = proc.poll()
|
||||
if code is not None:
|
||||
on_debug(f"native-bonjour exited with code {code}")
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self._proc.terminate()
|
||||
self._proc.wait(timeout=2.0)
|
||||
except Exception:
|
||||
try:
|
||||
self._proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class _NativeBonjourListenerProcess:
|
||||
"""macOS NetService listener using NSNetServiceListenForConnections."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service_name: str,
|
||||
service_type: str,
|
||||
port: int,
|
||||
filter_clients: bool,
|
||||
secure: bool,
|
||||
p12_path: Optional[str],
|
||||
p12_password: Optional[str],
|
||||
on_debug: Callable[[str], None],
|
||||
):
|
||||
helper = _compiled_swift_helper("native_bonjour_listener", on_debug)
|
||||
command = [
|
||||
helper,
|
||||
"--name",
|
||||
service_name,
|
||||
"--port",
|
||||
str(port),
|
||||
"--type",
|
||||
service_type,
|
||||
]
|
||||
if filter_clients:
|
||||
command.extend(["--txt", "filterClients=1"])
|
||||
if secure:
|
||||
command.append("--secure")
|
||||
if p12_path:
|
||||
command.extend(["--p12", p12_path])
|
||||
if p12_password:
|
||||
command.extend(["--p12-pass", p12_password])
|
||||
|
||||
self._proc = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=_swift_helper_env(),
|
||||
start_new_session=True, # don't propagate terminal Ctrl-C SIGINT to this helper
|
||||
)
|
||||
on_debug(f"Started native Bonjour listener pid={self._proc.pid} command={' '.join(command)}")
|
||||
|
||||
@property
|
||||
def stdout(self):
|
||||
return self._proc.stdout
|
||||
|
||||
def poll(self):
|
||||
return self._proc.poll()
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self._proc.terminate()
|
||||
self._proc.wait(timeout=2.0)
|
||||
except Exception:
|
||||
try:
|
||||
self._proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class NSLoggerListener:
|
||||
"""Listen on a TCP port for NSLogger client connections."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: int = 50001,
|
||||
timeout: Optional[float] = None,
|
||||
on_message: Optional[Callable[[LogMessage], None]] = None,
|
||||
on_connect: Optional[Callable[[str, int], None]] = None,
|
||||
on_disconnect: Optional[Callable[[str, int], None]] = None,
|
||||
on_bonjour_ready: Optional[Callable[[str, int], None]] = None,
|
||||
on_parse_error: Optional[Callable[[str, int, bytes, Exception], None]] = None,
|
||||
on_debug: Optional[Callable[[str], None]] = None,
|
||||
use_ssl: Optional[bool] = None,
|
||||
allow_plaintext: Optional[bool] = None,
|
||||
bonjour: bool = False,
|
||||
bonjour_name: Optional[str] = None,
|
||||
filter_clients: Optional[bool] = None,
|
||||
bonjour_publisher: str = "native",
|
||||
advertise_host: Optional[str] = None,
|
||||
):
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
self.on_message = on_message or (lambda m: None)
|
||||
self.on_connect = on_connect or (lambda h, p: None)
|
||||
self.on_disconnect = on_disconnect or (lambda h, p: None)
|
||||
self.on_bonjour_ready = on_bonjour_ready or (lambda name, port: None)
|
||||
self.on_parse_error = on_parse_error or (lambda h, p, raw, e: None)
|
||||
self.on_debug = on_debug or (lambda message: None)
|
||||
# Bonjour mode mirrors NSLogger.app: publish the SSL service by default.
|
||||
self.use_ssl = bonjour if use_ssl is None else use_ssl
|
||||
self.allow_plaintext = False if allow_plaintext is None else allow_plaintext
|
||||
self.bonjour = bonjour
|
||||
self.bonjour_name = bonjour_name if bonjour_name is not None else ""
|
||||
self.filter_clients = bool(self.bonjour_name) if filter_clients is None else filter_clients
|
||||
self.bonjour_publisher = bonjour_publisher
|
||||
self.advertise_host = advertise_host
|
||||
self._stop = threading.Event()
|
||||
self._ssl_ctx: Optional[ssl.SSLContext] = None
|
||||
self.messages: list[LogMessage] = []
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
|
||||
def _handle_client(self, conn: socket.socket, addr: tuple):
|
||||
host, port = addr[0], addr[1]
|
||||
saw_first_byte = False
|
||||
if self._ssl_ctx:
|
||||
try:
|
||||
conn.settimeout(10.0)
|
||||
except OSError:
|
||||
pass
|
||||
while not self._stop.is_set():
|
||||
mode, initial = _classify_connection(conn)
|
||||
if mode == "timeout":
|
||||
self.on_debug(f"Waiting for first TLS/raw byte from {host}:{port}")
|
||||
continue
|
||||
break
|
||||
else:
|
||||
conn.close()
|
||||
return
|
||||
initial_hex = initial.hex(" ")
|
||||
if mode == "empty":
|
||||
self.on_debug(f"Ignoring connection closed before NSLogger data from {host}:{port}")
|
||||
conn.close()
|
||||
return
|
||||
if mode == "raw" and self.allow_plaintext:
|
||||
self.on_debug(f"Raw NSLogger connection from {host}:{port} first_bytes={initial_hex}")
|
||||
elif mode == "tls":
|
||||
self.on_debug(f"Starting TLS handshake for {host}:{port} client_hello={initial_hex}")
|
||||
try:
|
||||
conn = self._ssl_ctx.wrap_socket(conn, server_side=True)
|
||||
self.on_debug(
|
||||
f"TLS handshake completed for {host}:{port}"
|
||||
f" protocol={conn.version()} cipher={conn.cipher()}"
|
||||
)
|
||||
except (ssl.SSLError, OSError) as exc:
|
||||
self.on_debug(f"TLS handshake failed for {host}:{port}: {exc!r}")
|
||||
conn.close()
|
||||
return
|
||||
else:
|
||||
self.on_debug(
|
||||
f"Expected TLS ClientHello from {host}:{port}, got first_bytes={initial_hex}"
|
||||
)
|
||||
conn.close()
|
||||
return
|
||||
else:
|
||||
self.on_debug(f"Raw NSLogger connection from {host}:{port}")
|
||||
self.on_connect(host, port)
|
||||
try:
|
||||
conn.settimeout(2.0)
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
header = b""
|
||||
while len(header) < 4:
|
||||
chunk = conn.recv(4 - len(header))
|
||||
if not chunk:
|
||||
if not saw_first_byte:
|
||||
self.on_debug(f"No data before disconnect from {host}:{port}")
|
||||
return
|
||||
saw_first_byte = True
|
||||
header += chunk
|
||||
msg_len = struct.unpack(">I", header)[0]
|
||||
self.on_debug(f"Frame header from {host}:{port}: len={msg_len} bytes")
|
||||
if msg_len == 0:
|
||||
continue
|
||||
raw = b""
|
||||
while len(raw) < msg_len:
|
||||
chunk = conn.recv(msg_len - len(raw))
|
||||
if not chunk:
|
||||
return
|
||||
raw += chunk
|
||||
try:
|
||||
msg = _parse_message(raw)
|
||||
self.messages.append(msg)
|
||||
self.on_message(msg)
|
||||
except ParseError as exc:
|
||||
self.on_parse_error(host, port, raw, exc)
|
||||
except socket.timeout:
|
||||
if not saw_first_byte:
|
||||
self.on_debug(f"Waiting for first frame from {host}:{port}")
|
||||
continue
|
||||
except OSError as exc:
|
||||
self.on_debug(f"Socket error from {host}:{port}: {exc}")
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
self.on_disconnect(host, port)
|
||||
|
||||
def _start_bonjour(self, local_ip: str) -> object:
|
||||
"""Advertise NSLogger services via Bonjour/mDNS."""
|
||||
service_types = _bonjour_service_types(self.use_ssl, self.allow_plaintext)
|
||||
self.on_debug(
|
||||
"Bonjour service types: "
|
||||
f"{', '.join(service_types)} filter_clients={int(self.filter_clients)}"
|
||||
)
|
||||
if self.bonjour_publisher == "native" and sys.platform == "darwin":
|
||||
self.on_debug("Advertising Bonjour with macOS NetService")
|
||||
publisher = _NativeBonjourPublisher(
|
||||
self.bonjour_name,
|
||||
service_types,
|
||||
self.port,
|
||||
self.filter_clients,
|
||||
self.on_debug,
|
||||
)
|
||||
self.on_bonjour_ready(self.bonjour_name, self.port)
|
||||
return publisher
|
||||
|
||||
if self.bonjour_publisher == "dns-sd" and sys.platform == "darwin":
|
||||
self.on_debug("Advertising Bonjour with macOS dns-sd")
|
||||
publisher = _DnsSdBonjourPublisher(
|
||||
self.bonjour_name,
|
||||
service_types,
|
||||
self.port,
|
||||
self.filter_clients,
|
||||
self.on_debug,
|
||||
)
|
||||
self.on_bonjour_ready(self.bonjour_name, self.port)
|
||||
return publisher
|
||||
|
||||
try:
|
||||
self.on_debug(f"Advertising Bonjour with zeroconf address={local_ip}")
|
||||
publisher = _ZeroconfBonjourPublisher(
|
||||
self.bonjour_name,
|
||||
service_types,
|
||||
self.port,
|
||||
local_ip,
|
||||
self.filter_clients,
|
||||
)
|
||||
except ImportError:
|
||||
self.on_debug("zeroconf package unavailable; falling back to macOS dns-sd")
|
||||
publisher = _DnsSdBonjourPublisher(
|
||||
self.bonjour_name,
|
||||
service_types,
|
||||
self.port,
|
||||
self.filter_clients,
|
||||
self.on_debug,
|
||||
)
|
||||
self.on_bonjour_ready(self.bonjour_name, self.port)
|
||||
return publisher
|
||||
|
||||
def _should_use_native_bonjour_listener(self) -> bool:
|
||||
if not (self.bonjour and self.bonjour_publisher == "native" and sys.platform == "darwin"):
|
||||
return False
|
||||
# NSNetServiceListenForConnections owns the listening socket, so it can only
|
||||
# publish one service type on the port. The explicit "auto" mode still uses
|
||||
# Python's socket listener plus a publisher so it can advertise raw+SSL.
|
||||
return len(_bonjour_service_types(self.use_ssl, self.allow_plaintext)) == 1
|
||||
|
||||
def _listen_native_bonjour(self):
|
||||
"""Use macOS NetService as both Bonjour publisher and listener."""
|
||||
import select
|
||||
import shutil
|
||||
import time
|
||||
|
||||
p12_path = None
|
||||
tmp_dir = None
|
||||
if self.use_ssl:
|
||||
p12_path, p12_password, tmp_dir = _make_pkcs12_identity()
|
||||
else:
|
||||
p12_password = None
|
||||
|
||||
service_type = _bonjour_service_types(self.use_ssl, self.allow_plaintext)[0]
|
||||
listener = _NativeBonjourListenerProcess(
|
||||
self.bonjour_name,
|
||||
service_type,
|
||||
self.port,
|
||||
self.filter_clients,
|
||||
self.use_ssl,
|
||||
p12_path,
|
||||
p12_password,
|
||||
self.on_debug,
|
||||
)
|
||||
|
||||
deadline = None
|
||||
if self.timeout is not None:
|
||||
deadline = time.monotonic() + self.timeout
|
||||
|
||||
def _drain(stdout, timeout_s: float = 1.0):
|
||||
"""Read any frames already buffered in the pipe before closing."""
|
||||
drain_deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < drain_deadline:
|
||||
try:
|
||||
readable, _, _ = select.select([stdout], [], [], 0.1)
|
||||
except (OSError, ValueError):
|
||||
break
|
||||
if not readable:
|
||||
break
|
||||
line = stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
self._handle_native_bonjour_event(line)
|
||||
|
||||
try:
|
||||
stdout = listener.stdout
|
||||
while not self._stop.is_set():
|
||||
if deadline and time.monotonic() > deadline:
|
||||
break
|
||||
if stdout is None:
|
||||
break
|
||||
readable, _, _ = select.select([stdout], [], [], 0.2)
|
||||
if not readable:
|
||||
code = listener.poll()
|
||||
if code is not None:
|
||||
self.on_debug(f"native-bonjour listener exited with code {code} (no pending output)")
|
||||
break
|
||||
continue
|
||||
line = stdout.readline()
|
||||
if not line:
|
||||
code = listener.poll()
|
||||
if code is not None:
|
||||
self.on_debug(f"native-bonjour listener exited with code {code}")
|
||||
break
|
||||
continue
|
||||
self._handle_native_bonjour_event(line)
|
||||
except KeyboardInterrupt:
|
||||
self._stop.set()
|
||||
if stdout is not None:
|
||||
_drain(stdout, timeout_s=1.0)
|
||||
raise
|
||||
finally:
|
||||
listener.close()
|
||||
if tmp_dir:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
return self.messages
|
||||
|
||||
def _handle_native_bonjour_event(self, line: str):
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
self.on_debug(f"native-bonjour {line.strip()}")
|
||||
return
|
||||
|
||||
event_type = event.get("event")
|
||||
if event_type == "ready":
|
||||
self.port = int(event.get("port") or self.port)
|
||||
self.on_bonjour_ready(event.get("name", self.bonjour_name), self.port)
|
||||
elif event_type == "debug":
|
||||
self.on_debug(f"native-bonjour {event.get('message', '')}")
|
||||
elif event_type == "connect":
|
||||
self.on_connect("native-bonjour", 0)
|
||||
elif event_type == "disconnect":
|
||||
self.on_disconnect("native-bonjour", 0)
|
||||
elif event_type == "error":
|
||||
details = " ".join(
|
||||
str(event.get(key, ""))
|
||||
for key in ("message", "error", "status")
|
||||
if event.get(key, "") != ""
|
||||
)
|
||||
self.on_debug(f"native-bonjour error: {details}")
|
||||
elif event_type == "frame":
|
||||
try:
|
||||
raw = base64.b64decode(event.get("payload", ""), validate=True)
|
||||
self.on_debug(
|
||||
f"Frame #{len(self.messages) + 1}: {len(raw)} bytes"
|
||||
f" head={raw[:8].hex(' ')}"
|
||||
)
|
||||
msg = _parse_message(raw)
|
||||
self.messages.append(msg)
|
||||
self.on_message(msg)
|
||||
except (ValueError, ParseError) as exc:
|
||||
raw_bytes = base64.b64decode(event.get("payload", "") or "", validate=False)
|
||||
self.on_parse_error("native-bonjour", 0, raw_bytes, exc)
|
||||
|
||||
def listen(self):
|
||||
"""Block until timeout or stop() is called. Returns collected messages."""
|
||||
if self._should_use_native_bonjour_listener():
|
||||
return self._listen_native_bonjour()
|
||||
|
||||
tmp_dir = None
|
||||
if self.use_ssl:
|
||||
self._ssl_ctx, tmp_dir = _make_ssl_context()
|
||||
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(("0.0.0.0", self.port))
|
||||
server.listen(5)
|
||||
server.settimeout(1.0)
|
||||
|
||||
publisher = None
|
||||
if self.bonjour:
|
||||
local_ip = self.advertise_host or _get_local_ip()
|
||||
publisher = self._start_bonjour(local_ip)
|
||||
|
||||
deadline = None
|
||||
if self.timeout is not None:
|
||||
import time
|
||||
deadline = time.monotonic() + self.timeout
|
||||
|
||||
threads = []
|
||||
try:
|
||||
import time
|
||||
while not self._stop.is_set():
|
||||
if deadline and time.monotonic() > deadline:
|
||||
break
|
||||
try:
|
||||
conn, addr = server.accept()
|
||||
t = threading.Thread(
|
||||
target=self._handle_client,
|
||||
args=(conn, addr),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
except socket.timeout:
|
||||
continue
|
||||
finally:
|
||||
server.close()
|
||||
self._stop.set()
|
||||
for t in threads:
|
||||
t.join(timeout=2.0)
|
||||
if publisher:
|
||||
publisher.close()
|
||||
if tmp_dir:
|
||||
import shutil
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
return self.messages
|
||||
@@ -0,0 +1,135 @@
|
||||
"""NSLogger message model and constants."""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
# Message type constants (wire protocol)
|
||||
MSG_TYPE_LOG = 0
|
||||
MSG_TYPE_BLOCK_START = 1
|
||||
MSG_TYPE_BLOCK_END = 2
|
||||
MSG_TYPE_CLIENT_INFO = 3
|
||||
MSG_TYPE_DISCONNECT = 4
|
||||
MSG_TYPE_MARKER = 255
|
||||
|
||||
# Part key constants from the official NSLogger wire protocol.
|
||||
PART_KEY_MESSAGE_TYPE = 0
|
||||
PART_KEY_TIMESTAMP_S = 1
|
||||
PART_KEY_TIMESTAMP_MS = 2
|
||||
PART_KEY_TIMESTAMP_US = 3
|
||||
PART_KEY_THREAD_ID = 4
|
||||
PART_KEY_TAG = 5
|
||||
PART_KEY_LEVEL = 6
|
||||
PART_KEY_MESSAGE = 7
|
||||
PART_KEY_IMAGE_WIDTH = 8
|
||||
PART_KEY_IMAGE_HEIGHT = 9
|
||||
PART_KEY_MESSAGE_SEQ = 10
|
||||
PART_KEY_FILENAME = 11
|
||||
PART_KEY_LINENUMBER = 12
|
||||
PART_KEY_FUNCTIONNAME = 13
|
||||
PART_KEY_CLIENT_NAME = 20
|
||||
PART_KEY_CLIENT_VERSION = 21
|
||||
PART_KEY_OS_NAME = 22
|
||||
PART_KEY_OS_VERSION = 23
|
||||
PART_KEY_CLIENT_MODEL = 24
|
||||
PART_KEY_UNIQUEID = 25
|
||||
|
||||
# Part type constants
|
||||
PART_TYPE_STRING = 0
|
||||
PART_TYPE_BINARY = 1
|
||||
PART_TYPE_INT16 = 2
|
||||
PART_TYPE_INT32 = 3
|
||||
PART_TYPE_INT64 = 4
|
||||
PART_TYPE_IMAGE = 5
|
||||
|
||||
LEVEL_NAMES = {
|
||||
0: "ERROR",
|
||||
1: "WARNING",
|
||||
2: "INFO",
|
||||
3: "DEBUG",
|
||||
4: "VERBOSE",
|
||||
5: "NOISE",
|
||||
}
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class LogMessage:
|
||||
sequence: int = 0
|
||||
timestamp: Optional[datetime] = None
|
||||
timestamp_ms: int = 0
|
||||
thread_id: str = ""
|
||||
tag: str = ""
|
||||
level: int = 2
|
||||
message_type: int = MSG_TYPE_LOG
|
||||
text: str = ""
|
||||
image_width: int = 0
|
||||
image_height: int = 0
|
||||
image_data: Optional[bytes] = None
|
||||
binary_data: Optional[bytes] = None
|
||||
client_name: str = ""
|
||||
client_version: str = ""
|
||||
os_name: str = ""
|
||||
os_version: str = ""
|
||||
machine: str = ""
|
||||
|
||||
@property
|
||||
def level_name(self) -> str:
|
||||
return LEVEL_NAMES.get(self.level, f"LEVEL{self.level}")
|
||||
|
||||
@property
|
||||
def type_name(self) -> str:
|
||||
if self.message_type == MSG_TYPE_LOG:
|
||||
if self.image_data:
|
||||
return "image"
|
||||
if self.binary_data:
|
||||
return "data"
|
||||
return "text"
|
||||
type_map = {
|
||||
MSG_TYPE_BLOCK_START: "block_start",
|
||||
MSG_TYPE_BLOCK_END: "block_end",
|
||||
MSG_TYPE_CLIENT_INFO: "client_info",
|
||||
MSG_TYPE_DISCONNECT: "disconnect",
|
||||
MSG_TYPE_MARKER: "marker",
|
||||
}
|
||||
return type_map.get(self.message_type, f"type_{self.message_type}")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
ts = self.timestamp.isoformat() if self.timestamp else None
|
||||
return {
|
||||
"sequence": self.sequence,
|
||||
"timestamp": ts,
|
||||
"timestamp_ms": self.timestamp_ms,
|
||||
"thread_id": self.thread_id,
|
||||
"tag": self.tag,
|
||||
"level": self.level,
|
||||
"level_name": self.level_name,
|
||||
"type": self.type_name,
|
||||
"text": self.text,
|
||||
"image_width": self.image_width,
|
||||
"image_height": self.image_height,
|
||||
"client_name": self.client_name,
|
||||
"client_version": self.client_version,
|
||||
"os_name": self.os_name,
|
||||
"os_version": self.os_version,
|
||||
"machine": self.machine,
|
||||
}
|
||||
|
||||
def to_text_line(self) -> str:
|
||||
ts = self.timestamp.strftime("%H:%M:%S.") + f"{self.timestamp_ms:03d}" if self.timestamp else "??:??:??.???"
|
||||
tag_part = f"[{self.tag}] " if self.tag else ""
|
||||
thread_part = f"({self.thread_id}) " if self.thread_id else ""
|
||||
level_part = f"{self.level_name:<7} "
|
||||
if self.type_name == "image":
|
||||
content = f"<image {self.image_width}x{self.image_height}>"
|
||||
elif self.type_name == "data":
|
||||
size = len(self.binary_data) if self.binary_data else 0
|
||||
content = f"<binary data {size} bytes>"
|
||||
elif self.message_type == MSG_TYPE_CLIENT_INFO:
|
||||
content = f"CLIENT: {self.client_name} {self.client_version} on {self.os_name} {self.os_version} ({self.machine})"
|
||||
elif self.message_type == MSG_TYPE_BLOCK_START:
|
||||
content = f">>> {self.text}"
|
||||
elif self.message_type == MSG_TYPE_BLOCK_END:
|
||||
content = f"<<< {self.text}"
|
||||
else:
|
||||
content = self.text
|
||||
return f"{ts} {level_part}{thread_part}{tag_part}{content}"
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Parse NSLogger raw wire-protocol (.rawnsloggerdata) files."""
|
||||
from __future__ import annotations
|
||||
import struct
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterator, BinaryIO
|
||||
from .message import (
|
||||
LogMessage,
|
||||
PART_KEY_MESSAGE_TYPE, PART_KEY_TIMESTAMP_S, PART_KEY_TIMESTAMP_MS,
|
||||
PART_KEY_TIMESTAMP_US, PART_KEY_THREAD_ID, PART_KEY_TAG, PART_KEY_LEVEL, PART_KEY_MESSAGE,
|
||||
PART_KEY_IMAGE_WIDTH, PART_KEY_IMAGE_HEIGHT, PART_KEY_MESSAGE_SEQ,
|
||||
PART_KEY_CLIENT_NAME, PART_KEY_CLIENT_VERSION,
|
||||
PART_KEY_OS_NAME, PART_KEY_OS_VERSION, PART_KEY_CLIENT_MODEL,
|
||||
PART_TYPE_STRING, PART_TYPE_BINARY, PART_TYPE_INT16,
|
||||
PART_TYPE_INT32, PART_TYPE_INT64, PART_TYPE_IMAGE,
|
||||
MSG_TYPE_LOG,
|
||||
)
|
||||
|
||||
|
||||
class ParseError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _read_exactly(f: BinaryIO, n: int) -> bytes:
|
||||
data = f.read(n)
|
||||
if len(data) != n:
|
||||
raise ParseError(f"Expected {n} bytes, got {len(data)}")
|
||||
return data
|
||||
|
||||
|
||||
def _decode_part_value(part_type: int, data: bytes):
|
||||
if part_type == PART_TYPE_STRING:
|
||||
return data.decode("utf-8", errors="replace")
|
||||
if part_type == PART_TYPE_INT16:
|
||||
if len(data) != 2:
|
||||
raise ParseError(f"Expected 2 bytes for int16, got {len(data)}")
|
||||
return struct.unpack(">H", data)[0]
|
||||
if part_type == PART_TYPE_INT32:
|
||||
if len(data) != 4:
|
||||
raise ParseError(f"Expected 4 bytes for int32, got {len(data)}")
|
||||
return struct.unpack(">I", data)[0]
|
||||
if part_type == PART_TYPE_INT64:
|
||||
if len(data) != 8:
|
||||
raise ParseError(f"Expected 8 bytes for int64, got {len(data)}")
|
||||
return struct.unpack(">Q", data)[0]
|
||||
if part_type in (PART_TYPE_BINARY, PART_TYPE_IMAGE):
|
||||
return data
|
||||
return data
|
||||
|
||||
|
||||
def _part_data(raw: bytes, offset: int, part_type: int, *, implicit_int_sizes: bool) -> tuple[bytes, int]:
|
||||
if implicit_int_sizes and part_type in (PART_TYPE_INT16, PART_TYPE_INT32, PART_TYPE_INT64):
|
||||
part_len = {PART_TYPE_INT16: 2, PART_TYPE_INT32: 4, PART_TYPE_INT64: 8}[part_type]
|
||||
if offset + part_len > len(raw):
|
||||
raise ParseError("Truncated integer part")
|
||||
return raw[offset:offset + part_len], offset + part_len
|
||||
|
||||
if offset + 4 > len(raw):
|
||||
raise ParseError("Truncated variable-length part")
|
||||
part_len = struct.unpack(">I", raw[offset:offset + 4])[0]
|
||||
offset += 4
|
||||
if offset + part_len > len(raw):
|
||||
raise ParseError("Truncated part data")
|
||||
return raw[offset:offset + part_len], offset + part_len
|
||||
|
||||
|
||||
MESSAGE_TEXT_KEYS = {PART_KEY_MESSAGE, 6}
|
||||
IMAGE_WIDTH_KEYS = {PART_KEY_IMAGE_WIDTH, 7}
|
||||
IMAGE_HEIGHT_KEYS = {PART_KEY_IMAGE_HEIGHT, 8}
|
||||
CLIENT_NAME_KEYS = {PART_KEY_CLIENT_NAME, 9}
|
||||
CLIENT_VERSION_KEYS = {PART_KEY_CLIENT_VERSION, 10}
|
||||
OS_NAME_KEYS = {PART_KEY_OS_NAME, 11}
|
||||
OS_VERSION_KEYS = {PART_KEY_OS_VERSION, 12}
|
||||
CLIENT_MODEL_KEYS = {PART_KEY_CLIENT_MODEL, 13}
|
||||
|
||||
|
||||
def _is_int_value(value) -> bool:
|
||||
return isinstance(value, int)
|
||||
|
||||
|
||||
def _is_text_value(part_type: int) -> bool:
|
||||
return part_type in (PART_TYPE_STRING, PART_TYPE_BINARY, PART_TYPE_IMAGE)
|
||||
|
||||
|
||||
def _parse_message_payload(
|
||||
raw: bytes,
|
||||
offset: int,
|
||||
*,
|
||||
initial_sequence: int = 0,
|
||||
implicit_int_sizes: bool = True,
|
||||
) -> LogMessage:
|
||||
"""Parse a message payload starting at `offset`, where the payload begins with part_count."""
|
||||
if len(raw) - offset < 2:
|
||||
raise ParseError("Message too short")
|
||||
|
||||
part_count = struct.unpack(">H", raw[offset:offset + 2])[0]
|
||||
msg = LogMessage(sequence=initial_sequence, message_type=MSG_TYPE_LOG)
|
||||
ts_s = None
|
||||
ts_ms = 0
|
||||
offset += 2
|
||||
|
||||
for _ in range(part_count):
|
||||
if offset + 2 > len(raw):
|
||||
raise ParseError("Truncated part header")
|
||||
part_key = raw[offset]
|
||||
part_type = raw[offset + 1]
|
||||
offset += 2
|
||||
part_data, offset = _part_data(raw, offset, part_type, implicit_int_sizes=implicit_int_sizes)
|
||||
|
||||
value = _decode_part_value(part_type, part_data)
|
||||
|
||||
if part_key == PART_KEY_MESSAGE_TYPE:
|
||||
msg.message_type = value if isinstance(value, int) else int.from_bytes(part_data, "big")
|
||||
elif part_key == PART_KEY_TIMESTAMP_S:
|
||||
ts_s = value
|
||||
elif part_key == PART_KEY_TIMESTAMP_MS:
|
||||
ts_ms = value if isinstance(value, int) else 0
|
||||
elif part_key == PART_KEY_TIMESTAMP_US:
|
||||
ts_ms = (value // 1000) if isinstance(value, int) else 0
|
||||
elif part_key == PART_KEY_THREAD_ID:
|
||||
msg.thread_id = str(value) if not isinstance(value, str) else value
|
||||
elif part_key == PART_KEY_TAG:
|
||||
msg.tag = str(value) if not isinstance(value, str) else value
|
||||
elif part_key == PART_KEY_LEVEL:
|
||||
msg.level = value if isinstance(value, int) else 0
|
||||
elif part_key in MESSAGE_TEXT_KEYS and _is_text_value(part_type):
|
||||
if isinstance(value, bytes) and part_type in (PART_TYPE_BINARY, PART_TYPE_IMAGE):
|
||||
msg.image_data = value
|
||||
else:
|
||||
msg.text = str(value)
|
||||
elif part_key in IMAGE_WIDTH_KEYS and _is_int_value(value):
|
||||
msg.image_width = value
|
||||
elif part_key in IMAGE_HEIGHT_KEYS and _is_int_value(value):
|
||||
msg.image_height = value
|
||||
elif part_key == PART_KEY_MESSAGE_SEQ and _is_int_value(value):
|
||||
msg.sequence = value
|
||||
elif part_key in CLIENT_NAME_KEYS and part_type == PART_TYPE_STRING:
|
||||
msg.client_name = str(value)
|
||||
elif part_key in CLIENT_VERSION_KEYS and part_type == PART_TYPE_STRING:
|
||||
msg.client_version = str(value)
|
||||
elif part_key in OS_NAME_KEYS and part_type == PART_TYPE_STRING:
|
||||
msg.os_name = str(value)
|
||||
elif part_key in OS_VERSION_KEYS and part_type == PART_TYPE_STRING:
|
||||
msg.os_version = str(value)
|
||||
elif part_key in CLIENT_MODEL_KEYS and part_type == PART_TYPE_STRING:
|
||||
msg.machine = str(value)
|
||||
|
||||
if ts_s is not None:
|
||||
ts_s_int = ts_s if isinstance(ts_s, int) else int(ts_s)
|
||||
msg.timestamp = datetime.fromtimestamp(ts_s_int, tz=timezone.utc)
|
||||
msg.timestamp_ms = ts_ms if isinstance(ts_ms, int) else 0
|
||||
|
||||
# Detect binary data vs image
|
||||
if msg.image_data and not (msg.image_width or msg.image_height):
|
||||
msg.binary_data = msg.image_data
|
||||
msg.image_data = None
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def _parse_message(raw: bytes) -> LogMessage:
|
||||
"""Parse a single wire-protocol message from raw bytes.
|
||||
|
||||
NSLogger's native wire format is:
|
||||
[partCount][parts...]
|
||||
|
||||
Older local test fixtures in this repo used:
|
||||
[sequence][partCount][parts...]
|
||||
We keep a best-effort fallback for those fixtures.
|
||||
"""
|
||||
if len(raw) < 2:
|
||||
raise ParseError("Message too short")
|
||||
|
||||
# Native NSLogger format: payload begins with partCount.
|
||||
try:
|
||||
msg = _parse_message_payload(raw, 0, implicit_int_sizes=True)
|
||||
if msg.message_type != MSG_TYPE_LOG or msg.text or msg.client_name or msg.image_data or msg.binary_data:
|
||||
return msg
|
||||
except ParseError:
|
||||
pass
|
||||
|
||||
# Older generated fixtures used official part keys but still wrote a
|
||||
# redundant 4-byte size before integer values.
|
||||
try:
|
||||
msg = _parse_message_payload(raw, 0, implicit_int_sizes=False)
|
||||
if msg.message_type != MSG_TYPE_LOG or msg.text or msg.client_name or msg.image_data or msg.binary_data:
|
||||
return msg
|
||||
except ParseError:
|
||||
pass
|
||||
|
||||
# Backward-compatible fallback for historical local fixtures.
|
||||
if len(raw) < 6:
|
||||
raise ParseError("Message too short")
|
||||
seq = struct.unpack(">I", raw[0:4])[0]
|
||||
try:
|
||||
return _parse_message_payload(raw, 4, initial_sequence=seq, implicit_int_sizes=False)
|
||||
except ParseError:
|
||||
return _parse_message_payload(raw, 4, initial_sequence=seq, implicit_int_sizes=True)
|
||||
|
||||
|
||||
def parse_raw_file(path: str) -> Iterator[LogMessage]:
|
||||
"""Yield LogMessage objects from a .rawnsloggerdata file."""
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
header = f.read(4)
|
||||
if not header:
|
||||
break
|
||||
if len(header) < 4:
|
||||
raise ParseError(f"Truncated file: got {len(header)} bytes in length header")
|
||||
msg_len = struct.unpack(">I", header)[0]
|
||||
if msg_len == 0:
|
||||
continue
|
||||
raw = f.read(msg_len)
|
||||
if len(raw) < msg_len:
|
||||
break
|
||||
try:
|
||||
yield _parse_message(raw)
|
||||
except ParseError:
|
||||
continue
|
||||
|
||||
|
||||
def parse_file(path: str) -> Iterator[LogMessage]:
|
||||
"""Auto-detect format and parse .rawnsloggerdata or .nsloggerdata files."""
|
||||
if path.endswith(".rawnsloggerdata"):
|
||||
yield from parse_raw_file(path)
|
||||
else:
|
||||
# .nsloggerdata is a binary plist wrapping archived messages
|
||||
# Fall back to raw parser which handles both (raw starts with length)
|
||||
yield from _parse_nsloggerdata(path)
|
||||
|
||||
|
||||
def _parse_nsloggerdata(path: str) -> Iterator[LogMessage]:
|
||||
"""Parse .nsloggerdata binary plist files via Python plistlib."""
|
||||
import plistlib
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
data = plistlib.load(f)
|
||||
# NSLogger saves as a plist dict with 'messages' array
|
||||
messages = data if isinstance(data, list) else data.get("messages", [])
|
||||
for i, m in enumerate(messages):
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
msg = LogMessage(sequence=i)
|
||||
ts = m.get("timestamp")
|
||||
if ts is not None:
|
||||
try:
|
||||
msg.timestamp = datetime.fromtimestamp(float(ts), tz=timezone.utc)
|
||||
frac = float(ts) - int(float(ts))
|
||||
msg.timestamp_ms = int(frac * 1000)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
msg.tag = str(m.get("tag", ""))
|
||||
msg.level = int(m.get("level", 2))
|
||||
msg.thread_id = str(m.get("threadID", ""))
|
||||
msg.text = str(m.get("message", m.get("messageText", "")))
|
||||
yield msg
|
||||
except Exception:
|
||||
# Try raw protocol as fallback
|
||||
yield from parse_raw_file(path)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Compute statistics over a sequence of LogMessages."""
|
||||
from __future__ import annotations
|
||||
from collections import Counter
|
||||
from typing import Iterator, Dict, Any
|
||||
from .message import LogMessage, LEVEL_NAMES
|
||||
|
||||
|
||||
def compute_stats(messages: Iterator[LogMessage]) -> Dict[str, Any]:
|
||||
msgs = list(messages)
|
||||
if not msgs:
|
||||
return {"total": 0}
|
||||
|
||||
level_counts: Counter = Counter()
|
||||
tag_counts: Counter = Counter()
|
||||
thread_counts: Counter = Counter()
|
||||
type_counts: Counter = Counter()
|
||||
client_names: set = set()
|
||||
|
||||
first_ts = None
|
||||
last_ts = None
|
||||
|
||||
for m in msgs:
|
||||
level_counts[m.level] += 1
|
||||
if m.tag:
|
||||
tag_counts[m.tag] += 1
|
||||
if m.thread_id:
|
||||
thread_counts[m.thread_id] += 1
|
||||
type_counts[m.type_name] += 1
|
||||
if m.client_name:
|
||||
client_names.add(m.client_name)
|
||||
if m.timestamp:
|
||||
if first_ts is None or m.timestamp < first_ts:
|
||||
first_ts = m.timestamp
|
||||
if last_ts is None or m.timestamp > last_ts:
|
||||
last_ts = m.timestamp
|
||||
|
||||
duration_s = None
|
||||
if first_ts and last_ts:
|
||||
duration_s = (last_ts - first_ts).total_seconds()
|
||||
|
||||
return {
|
||||
"total": len(msgs),
|
||||
"by_level": {
|
||||
LEVEL_NAMES.get(k, f"level_{k}"): v
|
||||
for k, v in sorted(level_counts.items())
|
||||
},
|
||||
"by_tag": dict(tag_counts.most_common(20)),
|
||||
"by_thread": dict(thread_counts.most_common(10)),
|
||||
"by_type": dict(type_counts),
|
||||
"clients": sorted(client_names),
|
||||
"first_timestamp": first_ts.isoformat() if first_ts else None,
|
||||
"last_timestamp": last_ts.isoformat() if last_ts else None,
|
||||
"duration_seconds": duration_s,
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
func emit(_ fields: [String: Any]) {
|
||||
if let data = try? JSONSerialization.data(withJSONObject: fields, options: []),
|
||||
let line = String(data: data, encoding: .utf8) {
|
||||
print(line)
|
||||
fflush(stdout)
|
||||
}
|
||||
}
|
||||
|
||||
struct Arguments {
|
||||
var name = ""
|
||||
var port: Int32 = 50000
|
||||
var type = "_nslogger-ssl._tcp."
|
||||
var txt: [String: Data] = [:]
|
||||
var secure = false
|
||||
var p12Path: String?
|
||||
var p12Password = ""
|
||||
}
|
||||
|
||||
func normalizeServiceType(_ type: String) -> String {
|
||||
if type.isEmpty {
|
||||
return type
|
||||
}
|
||||
return type.hasSuffix(".") ? type : "\(type)."
|
||||
}
|
||||
|
||||
func parseArguments(_ args: [String]) -> Arguments {
|
||||
var parsed = Arguments()
|
||||
var index = 1
|
||||
while index < args.count {
|
||||
let arg = args[index]
|
||||
switch arg {
|
||||
case "--name":
|
||||
index += 1
|
||||
if index < args.count {
|
||||
parsed.name = args[index]
|
||||
}
|
||||
case "--port":
|
||||
index += 1
|
||||
if index < args.count, let port = Int32(args[index]) {
|
||||
parsed.port = port
|
||||
}
|
||||
case "--type":
|
||||
index += 1
|
||||
if index < args.count {
|
||||
parsed.type = normalizeServiceType(args[index])
|
||||
}
|
||||
case "--txt":
|
||||
index += 1
|
||||
if index < args.count {
|
||||
let parts = args[index].split(separator: "=", maxSplits: 1).map(String.init)
|
||||
let key = parts.first ?? ""
|
||||
let value = parts.count > 1 ? parts[1] : ""
|
||||
if !key.isEmpty {
|
||||
parsed.txt[key] = value.data(using: .utf8) ?? Data()
|
||||
}
|
||||
}
|
||||
case "--secure":
|
||||
parsed.secure = true
|
||||
case "--p12":
|
||||
index += 1
|
||||
if index < args.count {
|
||||
parsed.p12Path = args[index]
|
||||
}
|
||||
case "--p12-pass":
|
||||
index += 1
|
||||
if index < args.count {
|
||||
parsed.p12Password = args[index]
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func loadIdentity(path: String?, password: String) -> SecIdentity? {
|
||||
guard let path else {
|
||||
return nil
|
||||
}
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
|
||||
emit(["event": "error", "message": "failed to read pkcs12 identity"])
|
||||
return nil
|
||||
}
|
||||
let options = [kSecImportExportPassphrase as String: password]
|
||||
var items: CFArray?
|
||||
let status = SecPKCS12Import(data as CFData, options as CFDictionary, &items)
|
||||
guard status == errSecSuccess,
|
||||
let imported = items as? [[String: Any]],
|
||||
let identityValue = imported.first?[kSecImportItemIdentity as String] else {
|
||||
emit(["event": "error", "message": "failed to import pkcs12 identity", "status": Int(status)])
|
||||
return nil
|
||||
}
|
||||
return (identityValue as! SecIdentity)
|
||||
}
|
||||
|
||||
final class Connection: NSObject, StreamDelegate {
|
||||
private let input: InputStream
|
||||
private let output: OutputStream
|
||||
private let secure: Bool
|
||||
private let identity: SecIdentity?
|
||||
private var buffer = Data()
|
||||
private var connected = false
|
||||
|
||||
init(input: InputStream, output: OutputStream, secure: Bool, identity: SecIdentity?) {
|
||||
self.input = input
|
||||
self.output = output
|
||||
self.secure = secure
|
||||
self.identity = identity
|
||||
}
|
||||
|
||||
func open() {
|
||||
if secure {
|
||||
guard let identity else {
|
||||
emit(["event": "error", "message": "missing ssl identity"])
|
||||
return
|
||||
}
|
||||
let settings: [String: Any] = [
|
||||
kCFStreamSSLLevel as String: kCFStreamSocketSecurityLevelNegotiatedSSL,
|
||||
kCFStreamSSLValidatesCertificateChain as String: false,
|
||||
kCFStreamSSLIsServer as String: true,
|
||||
kCFStreamSSLCertificates as String: [identity],
|
||||
]
|
||||
input.setProperty(settings, forKey: Stream.PropertyKey(rawValue: kCFStreamPropertySSLSettings as String))
|
||||
}
|
||||
|
||||
input.delegate = self
|
||||
output.delegate = self
|
||||
input.schedule(in: .current, forMode: .default)
|
||||
output.schedule(in: .current, forMode: .default)
|
||||
input.open()
|
||||
output.open()
|
||||
}
|
||||
|
||||
func close() {
|
||||
input.close()
|
||||
output.close()
|
||||
input.remove(from: .current, forMode: .default)
|
||||
output.remove(from: .current, forMode: .default)
|
||||
}
|
||||
|
||||
func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
|
||||
switch eventCode {
|
||||
case .openCompleted:
|
||||
if !connected {
|
||||
connected = true
|
||||
emit(["event": "connect"])
|
||||
}
|
||||
case .hasBytesAvailable:
|
||||
readAvailableBytes()
|
||||
case .endEncountered:
|
||||
emit(["event": "disconnect"])
|
||||
close()
|
||||
case .errorOccurred:
|
||||
let message = aStream.streamError?.localizedDescription ?? "stream error"
|
||||
emit(["event": "error", "message": message])
|
||||
close()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func readAvailableBytes() {
|
||||
var chunk = [UInt8](repeating: 0, count: 64 * 1024)
|
||||
while input.hasBytesAvailable {
|
||||
let count = input.read(&chunk, maxLength: chunk.count)
|
||||
if count <= 0 {
|
||||
break
|
||||
}
|
||||
buffer.append(chunk, count: count)
|
||||
processFrames()
|
||||
}
|
||||
}
|
||||
|
||||
private func processFrames() {
|
||||
while buffer.count >= 4 {
|
||||
let headerStart = buffer.startIndex
|
||||
let headerEnd = buffer.index(headerStart, offsetBy: 4)
|
||||
let length = buffer[headerStart..<headerEnd].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
let frameLength = Int(length)
|
||||
if frameLength == 0 {
|
||||
buffer.removeSubrange(headerStart..<headerEnd)
|
||||
continue
|
||||
}
|
||||
if buffer.count < 4 + frameLength {
|
||||
return
|
||||
}
|
||||
let frameEnd = buffer.index(headerEnd, offsetBy: frameLength)
|
||||
let frame = buffer.subdata(in: headerEnd..<frameEnd)
|
||||
buffer.removeSubrange(headerStart..<frameEnd)
|
||||
emit(["event": "frame", "payload": frame.base64EncodedString()])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ListenerDelegate: NSObject, NetServiceDelegate {
|
||||
private let secure: Bool
|
||||
private let identity: SecIdentity?
|
||||
private var connections: [Connection] = []
|
||||
|
||||
init(secure: Bool, identity: SecIdentity?) {
|
||||
self.secure = secure
|
||||
self.identity = identity
|
||||
}
|
||||
|
||||
func netServiceWillPublish(_ sender: NetService) {
|
||||
emit(["event": "debug", "message": "publishing type=\(sender.type) name=\(sender.name) port=\(sender.port)"])
|
||||
}
|
||||
|
||||
func netServiceDidPublish(_ sender: NetService) {
|
||||
emit(["event": "ready", "name": sender.name, "port": sender.port, "type": sender.type])
|
||||
}
|
||||
|
||||
func netService(_ sender: NetService, didNotPublish errorDict: [String: NSNumber]) {
|
||||
emit(["event": "error", "message": "publish failed", "error": errorDict.description])
|
||||
}
|
||||
|
||||
func netService(_ sender: NetService, didAcceptConnectionWith inputStream: InputStream, outputStream: OutputStream) {
|
||||
emit(["event": "debug", "message": "accepted native NetService connection"])
|
||||
let connection = Connection(input: inputStream, output: outputStream, secure: secure, identity: identity)
|
||||
connections.append(connection)
|
||||
connection.open()
|
||||
}
|
||||
}
|
||||
|
||||
signal(SIGTERM) { _ in
|
||||
exit(0)
|
||||
}
|
||||
signal(SIGINT) { _ in
|
||||
exit(0)
|
||||
}
|
||||
// Ignore SIGPIPE so a broken stdout pipe doesn't kill the process
|
||||
signal(SIGPIPE, SIG_IGN)
|
||||
|
||||
setvbuf(stdout, nil, _IONBF, 0)
|
||||
|
||||
let arguments = parseArguments(CommandLine.arguments)
|
||||
let identity = arguments.secure ? loadIdentity(path: arguments.p12Path, password: arguments.p12Password) : nil
|
||||
if arguments.secure && identity == nil {
|
||||
exit(3)
|
||||
}
|
||||
|
||||
let delegate = ListenerDelegate(secure: arguments.secure, identity: identity)
|
||||
let service = NetService(domain: "", type: arguments.type, name: arguments.name, port: arguments.port)
|
||||
service.includesPeerToPeer = true
|
||||
service.delegate = delegate
|
||||
if !arguments.txt.isEmpty {
|
||||
service.setTXTRecord(NetService.data(fromTXTRecord: arguments.txt))
|
||||
}
|
||||
service.publish(options: .listenForConnections)
|
||||
|
||||
// A repeating Timer keeps the RunLoop alive in .default mode even after all
|
||||
// NSStream sources are removed (e.g., after the iOS app disconnects).
|
||||
// Without a source, RunLoop.main.run()'s internal run(mode:before:) returns
|
||||
// false immediately and the process exits.
|
||||
// Port() is NOT used because NSMessagePort relies on Mach bootstrap, which
|
||||
// is unavailable in a new-session subprocess (start_new_session=True) and
|
||||
// causes SIGTRAP at runtime.
|
||||
// The timer closure captures delegate and service strongly, ensuring ARC
|
||||
// keeps them alive for the lifetime of the RunLoop regardless of optimizer.
|
||||
let _keepAliveRefs: [AnyObject] = [delegate, service]
|
||||
Timer.scheduledTimer(withTimeInterval: 1e8, repeats: true) { _ in
|
||||
_ = _keepAliveRefs
|
||||
}
|
||||
|
||||
RunLoop.main.run()
|
||||
@@ -0,0 +1,111 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
final class PublisherDelegate: NSObject, NetServiceDelegate {
|
||||
func netServiceDidPublish(_ sender: NetService) {
|
||||
print("published type=\(sender.type) name=\(sender.name) port=\(sender.port)")
|
||||
fflush(stdout)
|
||||
}
|
||||
|
||||
func netService(_ sender: NetService, didNotPublish errorDict: [String: NSNumber]) {
|
||||
print("publish-failed type=\(sender.type) name=\(sender.name) error=\(errorDict)")
|
||||
fflush(stdout)
|
||||
}
|
||||
|
||||
func netServiceWillPublish(_ sender: NetService) {
|
||||
print("publishing type=\(sender.type) name=\(sender.name) port=\(sender.port)")
|
||||
fflush(stdout)
|
||||
}
|
||||
|
||||
func netServiceDidStop(_ sender: NetService) {
|
||||
print("stopped type=\(sender.type) name=\(sender.name)")
|
||||
fflush(stdout)
|
||||
}
|
||||
}
|
||||
|
||||
struct Arguments {
|
||||
var name = ""
|
||||
var port: Int32 = 50000
|
||||
var types: [String] = []
|
||||
var txt: [String: Data] = [:]
|
||||
}
|
||||
|
||||
func parseArguments(_ args: [String]) -> Arguments {
|
||||
var parsed = Arguments()
|
||||
var index = 1
|
||||
while index < args.count {
|
||||
let arg = args[index]
|
||||
switch arg {
|
||||
case "--name":
|
||||
index += 1
|
||||
if index < args.count {
|
||||
parsed.name = args[index]
|
||||
}
|
||||
case "--port":
|
||||
index += 1
|
||||
if index < args.count, let port = Int32(args[index]) {
|
||||
parsed.port = port
|
||||
}
|
||||
case "--types":
|
||||
index += 1
|
||||
if index < args.count {
|
||||
parsed.types = args[index]
|
||||
.split(separator: ",")
|
||||
.map { normalizeServiceType(String($0)) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
case "--txt":
|
||||
index += 1
|
||||
if index < args.count {
|
||||
let parts = args[index].split(separator: "=", maxSplits: 1).map(String.init)
|
||||
let key = parts.first ?? ""
|
||||
let value = parts.count > 1 ? parts[1] : ""
|
||||
if !key.isEmpty {
|
||||
parsed.txt[key] = value.data(using: .utf8) ?? Data()
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func normalizeServiceType(_ type: String) -> String {
|
||||
if type.isEmpty {
|
||||
return type
|
||||
}
|
||||
return type.hasSuffix(".") ? type : "\(type)."
|
||||
}
|
||||
|
||||
signal(SIGTERM) { _ in
|
||||
exit(0)
|
||||
}
|
||||
signal(SIGINT) { _ in
|
||||
exit(0)
|
||||
}
|
||||
|
||||
setvbuf(stdout, nil, _IONBF, 0)
|
||||
|
||||
let arguments = parseArguments(CommandLine.arguments)
|
||||
let delegate = PublisherDelegate()
|
||||
var services: [NetService] = []
|
||||
|
||||
for type in arguments.types {
|
||||
let service = NetService(domain: "", type: type, name: arguments.name, port: arguments.port)
|
||||
service.includesPeerToPeer = true
|
||||
service.delegate = delegate
|
||||
if !arguments.txt.isEmpty {
|
||||
service.setTXTRecord(NetService.data(fromTXTRecord: arguments.txt))
|
||||
}
|
||||
service.publish()
|
||||
services.append(service)
|
||||
}
|
||||
|
||||
if services.isEmpty {
|
||||
print("publish-failed error=no-service-types")
|
||||
exit(2)
|
||||
}
|
||||
|
||||
RunLoop.main.run()
|
||||
@@ -0,0 +1,555 @@
|
||||
"""cli-anything-nslogger — CLI harness for NSLogger."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from .core.parser import parse_file
|
||||
from .core.filter import filter_messages
|
||||
from .core.stats import compute_stats
|
||||
from .core.exporter import export_messages
|
||||
from .core.message import LEVEL_NAMES, MSG_TYPE_CLIENT_INFO
|
||||
|
||||
|
||||
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
||||
|
||||
|
||||
def _level_option():
|
||||
return click.option(
|
||||
"--level", "-l",
|
||||
type=int, default=None,
|
||||
help="Maximum log level to show (0=error … 4=verbose)",
|
||||
)
|
||||
|
||||
|
||||
def _json_option():
|
||||
return click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
||||
|
||||
|
||||
def _parse_dt(value: Optional[str]) -> Optional[datetime]:
|
||||
"""Parse ISO-like datetime string to aware UTC datetime."""
|
||||
if value is None:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%H:%M:%S"):
|
||||
try:
|
||||
dt = datetime.strptime(value, fmt)
|
||||
if dt.year == 1900:
|
||||
today = datetime.now(tz=timezone.utc).date()
|
||||
dt = dt.replace(year=today.year, month=today.month, day=today.day)
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
continue
|
||||
raise click.BadParameter(f"Cannot parse datetime: {value!r}. Use HH:MM:SS or YYYY-MM-DDTHH:MM:SS")
|
||||
|
||||
|
||||
def _time_range_options():
|
||||
def decorator(f):
|
||||
f = click.option("--after", default=None,
|
||||
help="Show messages after this time (HH:MM:SS or YYYY-MM-DDTHH:MM:SS)")(f)
|
||||
f = click.option("--before", default=None,
|
||||
help="Show messages before this time (HH:MM:SS or YYYY-MM-DDTHH:MM:SS)")(f)
|
||||
return f
|
||||
return decorator
|
||||
|
||||
|
||||
def _listen_waiting_message(port: int, bonjour: bool) -> str:
|
||||
if bonjour:
|
||||
return f"[Bonjour] Waiting for an iOS client to connect on port {port}…"
|
||||
return f"Waiting for a client connection on port {port}…"
|
||||
|
||||
|
||||
def _format_live_output_message(msg, fmt: str) -> str:
|
||||
if fmt == "jsonl":
|
||||
return json.dumps(msg.to_dict(), default=str)
|
||||
return msg.to_text_line()
|
||||
|
||||
|
||||
def _open_live_output_file(path: str, append: bool):
|
||||
parent = os.path.dirname(os.path.abspath(path))
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
mode = "a" if append else "w"
|
||||
return open(path, mode, encoding="utf-8", buffering=1)
|
||||
|
||||
|
||||
@click.group(context_settings=CONTEXT_SETTINGS)
|
||||
@click.version_option(package_name="cli-anything-nslogger")
|
||||
def cli():
|
||||
"""NSLogger CLI — read, filter, export, and monitor NSLogger log files.
|
||||
|
||||
\b
|
||||
Use COMMAND -h to see command-specific options, for example:
|
||||
cli-anything-nslogger listen -h
|
||||
|
||||
\b
|
||||
Live logs can be mirrored to a file with:
|
||||
cli-anything-nslogger listen --bonjour --name bazinga --output app.log
|
||||
|
||||
\b
|
||||
Live listen file output options:
|
||||
-o, --output FILE Write live logs to FILE while printing stdout
|
||||
--output-format text|jsonl Write text lines or JSON Lines
|
||||
--append Append instead of replacing FILE on startup
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# read
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@cli.command()
|
||||
@click.argument("file", type=click.Path(exists=True))
|
||||
@_level_option()
|
||||
@click.option("--tag", "-t", multiple=True, help="Filter by tag (repeatable)")
|
||||
@click.option("--thread", help="Filter by thread ID")
|
||||
@click.option("--search", "-s", help="Text search (case-insensitive)")
|
||||
@click.option("--limit", "-n", type=int, default=None, help="Max messages to show")
|
||||
@click.option("--after", default=None, help="Show messages after this time (HH:MM:SS or YYYY-MM-DDTHH:MM:SS)")
|
||||
@click.option("--before", default=None, help="Show messages before this time (HH:MM:SS or YYYY-MM-DDTHH:MM:SS)")
|
||||
@_json_option()
|
||||
def read(file, level, tag, thread, search, limit, after, before, as_json):
|
||||
"""Parse and display messages from a .rawnsloggerdata or .nsloggerdata file."""
|
||||
msgs = parse_file(file)
|
||||
msgs = filter_messages(
|
||||
msgs,
|
||||
max_level=level,
|
||||
tags=list(tag) if tag else None,
|
||||
thread_id=thread,
|
||||
text_search=search,
|
||||
limit=limit,
|
||||
after=_parse_dt(after),
|
||||
before=_parse_dt(before),
|
||||
)
|
||||
result = list(msgs)
|
||||
if as_json:
|
||||
click.echo(json.dumps([m.to_dict() for m in result], indent=2, default=str))
|
||||
else:
|
||||
for m in result:
|
||||
click.echo(m.to_text_line())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@cli.command(name="filter")
|
||||
@click.argument("file", type=click.Path(exists=True))
|
||||
@_level_option()
|
||||
@click.option("--min-level", type=int, default=None, help="Minimum log level")
|
||||
@click.option("--tag", "-t", multiple=True, help="Filter by tag")
|
||||
@click.option("--thread", help="Filter by thread ID")
|
||||
@click.option("--search", "-s", help="Substring search in message text")
|
||||
@click.option("--regex", "-r", help="Regex search in message text")
|
||||
@click.option("--type", "msg_type", multiple=True,
|
||||
type=click.Choice(["text", "image", "data", "client_info", "block_start", "block_end"]),
|
||||
help="Filter by message type")
|
||||
@click.option("--limit", "-n", type=int, default=None, help="Max messages")
|
||||
@click.option("--after", default=None, help="Show messages after this time (HH:MM:SS or YYYY-MM-DDTHH:MM:SS)")
|
||||
@click.option("--before", default=None, help="Show messages before this time")
|
||||
@click.option("--from-seq", type=int, default=None, help="Start from sequence number (inclusive)")
|
||||
@click.option("--to-seq", type=int, default=None, help="End at sequence number (inclusive)")
|
||||
@_json_option()
|
||||
def filter_cmd(file, level, min_level, tag, thread, search, regex, msg_type, limit,
|
||||
after, before, from_seq, to_seq, as_json):
|
||||
"""Filter messages from a file with advanced criteria."""
|
||||
msgs = parse_file(file)
|
||||
msgs = filter_messages(
|
||||
msgs,
|
||||
max_level=level,
|
||||
min_level=min_level,
|
||||
tags=list(tag) if tag else None,
|
||||
thread_id=thread,
|
||||
text_search=search,
|
||||
text_regex=regex,
|
||||
msg_types=list(msg_type) if msg_type else None,
|
||||
limit=limit,
|
||||
after=_parse_dt(after),
|
||||
before=_parse_dt(before),
|
||||
from_seq=from_seq,
|
||||
to_seq=to_seq,
|
||||
)
|
||||
result = list(msgs)
|
||||
if as_json:
|
||||
click.echo(json.dumps([m.to_dict() for m in result], indent=2, default=str))
|
||||
else:
|
||||
for m in result:
|
||||
click.echo(m.to_text_line())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@cli.command()
|
||||
@click.argument("file", type=click.Path(exists=True))
|
||||
@click.option("--format", "-f", "fmt",
|
||||
type=click.Choice(["text", "json", "csv"]), default="text",
|
||||
show_default=True, help="Output format")
|
||||
@click.option("--output", "-o", type=click.Path(), default=None,
|
||||
help="Output file (default: stdout)")
|
||||
@_level_option()
|
||||
@click.option("--tag", "-t", multiple=True, help="Filter by tag before export")
|
||||
@click.option("--search", "-s", help="Filter by text before export")
|
||||
@click.option("--limit", "-n", type=int, default=None, help="Max messages")
|
||||
def export(file, fmt, output, level, tag, search, limit):
|
||||
"""Export messages to text, JSON, or CSV."""
|
||||
msgs = parse_file(file)
|
||||
msgs = filter_messages(
|
||||
msgs,
|
||||
max_level=level,
|
||||
tags=list(tag) if tag else None,
|
||||
text_search=search,
|
||||
limit=limit,
|
||||
)
|
||||
result_str = export_messages(msgs, fmt=fmt)
|
||||
if output:
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(result_str)
|
||||
click.echo(f"Exported to {output}", err=True)
|
||||
else:
|
||||
click.echo(result_str, nl=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@cli.command()
|
||||
@click.argument("file", type=click.Path(exists=True))
|
||||
@_json_option()
|
||||
def stats(file, as_json):
|
||||
"""Show statistics for a NSLogger file."""
|
||||
msgs = parse_file(file)
|
||||
s = compute_stats(msgs)
|
||||
if as_json:
|
||||
click.echo(json.dumps(s, indent=2, default=str))
|
||||
return
|
||||
|
||||
click.echo(f"Total messages : {s['total']}")
|
||||
if s.get("first_timestamp"):
|
||||
click.echo(f"First message : {s['first_timestamp']}")
|
||||
click.echo(f"Last message : {s['last_timestamp']}")
|
||||
if s.get("duration_seconds") is not None:
|
||||
click.echo(f"Duration : {s['duration_seconds']:.1f}s")
|
||||
if s.get("clients"):
|
||||
click.echo(f"Clients : {', '.join(s['clients'])}")
|
||||
click.echo("")
|
||||
click.echo("By level:")
|
||||
for name, count in s.get("by_level", {}).items():
|
||||
click.echo(f" {name:<10} {count}")
|
||||
click.echo("")
|
||||
click.echo("By type:")
|
||||
for name, count in s.get("by_type", {}).items():
|
||||
click.echo(f" {name:<15} {count}")
|
||||
if s.get("by_tag"):
|
||||
click.echo("")
|
||||
click.echo("Top tags:")
|
||||
for tag, count in list(s["by_tag"].items())[:10]:
|
||||
click.echo(f" {tag:<20} {count}")
|
||||
if s.get("by_thread"):
|
||||
click.echo("")
|
||||
click.echo("Top threads:")
|
||||
for thread, count in list(s["by_thread"].items())[:5]:
|
||||
click.echo(f" {thread:<25} {count}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# listen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@cli.command(short_help="Listen for live logs; use --output FILE to mirror them to disk.")
|
||||
@click.option("--port", "-p", type=int, default=50000, show_default=True,
|
||||
help="TCP port to listen on")
|
||||
@click.option("--timeout", "-t", type=float, default=None,
|
||||
help="Stop after N seconds (default: run until Ctrl-C)")
|
||||
@click.option("--level", "-l", type=int, default=None,
|
||||
help="Maximum level to display while listening")
|
||||
@click.option("--bonjour", "-b", is_flag=True, default=False,
|
||||
help="Advertise via Bonjour/mDNS (iOS app auto-discovers, no IP config needed)")
|
||||
@click.option("--name", "-n", default=None,
|
||||
help="Bonjour service name (default: system-selected name)")
|
||||
@click.option("--ssl", "force_ssl", is_flag=True,
|
||||
help="Use SSL/TLS for direct TCP mode; Bonjour uses SSL by default")
|
||||
@click.option("--no-ssl", is_flag=True, help="Advertise/use the legacy non-SSL NSLogger Bonjour service")
|
||||
@click.option("--bonjour-mode", type=click.Choice(["auto", "ssl", "raw"]), default="ssl", show_default=True,
|
||||
help="Bonjour service mode: ssl matches NSLogger GUI default, auto publishes raw+SSL, raw publishes legacy raw only")
|
||||
@click.option("--bonjour-publisher", type=click.Choice(["native", "dns-sd", "zeroconf"]), default="native", show_default=True,
|
||||
help="Bonjour publisher backend")
|
||||
@click.option("--advertise-host", default=None,
|
||||
help="IP address to publish when using --bonjour-publisher zeroconf")
|
||||
@click.option("--filter-clients/--no-filter-clients", default=None,
|
||||
help="Advertise filterClients=1; defaults to on when --name is non-empty, matching NSLogger GUI")
|
||||
@click.option("--output", "-o", type=click.Path(dir_okay=False, path_type=str),
|
||||
help="Write received live logs to this file while still printing to stdout")
|
||||
@click.option("--output-format", type=click.Choice(["text", "jsonl"]), default="text", show_default=True,
|
||||
help="Format used for --output")
|
||||
@click.option("--append", is_flag=True,
|
||||
help="Append to --output instead of replacing it at listener startup")
|
||||
@click.option("--debug", is_flag=True, help="Print live frame diagnostics to stderr")
|
||||
@_json_option()
|
||||
def listen(
|
||||
port, timeout, level, bonjour, name, force_ssl, no_ssl, bonjour_mode,
|
||||
bonjour_publisher, advertise_host, filter_clients, output, output_format,
|
||||
append, debug, as_json
|
||||
):
|
||||
"""Listen for live NSLogger connections.
|
||||
|
||||
\b
|
||||
TCP mode (default):
|
||||
cli-anything-nslogger listen --port 50000
|
||||
|
||||
Bonjour mode (iOS auto-discovers on same WiFi):
|
||||
cli-anything-nslogger listen --bonjour --name bazinga
|
||||
"""
|
||||
from .core.listener import NSLoggerListener
|
||||
|
||||
collected = []
|
||||
output_file = _open_live_output_file(output, append) if output else None
|
||||
|
||||
def on_message(msg):
|
||||
if level is not None and msg.level > level:
|
||||
return
|
||||
collected.append(msg)
|
||||
if output_file:
|
||||
output_file.write(_format_live_output_message(msg, output_format) + "\n")
|
||||
if as_json:
|
||||
click.echo(json.dumps(msg.to_dict(), default=str))
|
||||
else:
|
||||
click.echo(msg.to_text_line())
|
||||
|
||||
def on_connect(host, p):
|
||||
click.echo(f"[+] Client connected: {host}:{p}", err=True)
|
||||
|
||||
def on_disconnect(host, p):
|
||||
click.echo(f"[-] Client disconnected: {host}:{p}", err=True)
|
||||
|
||||
def on_bonjour_ready(svc_name, svc_port):
|
||||
click.echo(f"[Bonjour] Advertising as '{svc_name}' on port {svc_port}", err=True)
|
||||
click.echo(f"[Bonjour] iOS app will auto-discover — no IP config needed", err=True)
|
||||
|
||||
def on_parse_error(host, p, raw, exc):
|
||||
if not debug:
|
||||
return
|
||||
head = raw[:32].hex(" ")
|
||||
click.echo(
|
||||
f"[debug] Dropped frame from {host}:{p}: len={len(raw)} head={head} error={exc}",
|
||||
err=True,
|
||||
)
|
||||
|
||||
def on_debug(message):
|
||||
if debug:
|
||||
click.echo(f"[debug] {message}", err=True)
|
||||
|
||||
if no_ssl:
|
||||
bonjour_mode = "raw"
|
||||
use_ssl = force_ssl
|
||||
if bonjour:
|
||||
use_ssl = False if bonjour_mode == "raw" else None
|
||||
allow_plaintext = bonjour_mode != "ssl"
|
||||
|
||||
listener = NSLoggerListener(
|
||||
port=port,
|
||||
timeout=timeout,
|
||||
on_message=on_message,
|
||||
on_connect=on_connect,
|
||||
on_disconnect=on_disconnect,
|
||||
on_bonjour_ready=on_bonjour_ready,
|
||||
on_parse_error=on_parse_error,
|
||||
on_debug=on_debug,
|
||||
use_ssl=use_ssl,
|
||||
allow_plaintext=allow_plaintext,
|
||||
bonjour=bonjour,
|
||||
bonjour_name=name,
|
||||
filter_clients=filter_clients,
|
||||
bonjour_publisher=bonjour_publisher,
|
||||
advertise_host=advertise_host,
|
||||
)
|
||||
|
||||
if bonjour:
|
||||
click.echo(f"Starting Bonjour listener on port {port}… (Ctrl-C to stop)", err=True)
|
||||
else:
|
||||
click.echo(f"Listening on TCP port {port}… (Ctrl-C to stop)", err=True)
|
||||
click.echo(_listen_waiting_message(port, bonjour), err=True)
|
||||
if output:
|
||||
action = "Appending" if append else "Writing"
|
||||
click.echo(f"[output] {action} live logs to {output} ({output_format})", err=True)
|
||||
|
||||
try:
|
||||
listener.listen()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
if output_file:
|
||||
output_file.close()
|
||||
click.echo(f"\nCaptured {len(collected)} messages.", err=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@cli.command()
|
||||
@click.argument("output", type=click.Path())
|
||||
@click.option("--count", "-n", type=int, default=20, show_default=True,
|
||||
help="Number of log messages to generate")
|
||||
def generate(output, count):
|
||||
"""Generate a sample .rawnsloggerdata file for testing."""
|
||||
from .utils.generate import generate_sample_file
|
||||
generate_sample_file(output, count=count)
|
||||
click.echo(f"Generated {count} messages → {output}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@cli.command()
|
||||
@click.argument("file", type=click.Path(exists=True))
|
||||
@click.option("--count", "-n", type=int, default=20, show_default=True,
|
||||
help="Number of messages from the end to show")
|
||||
@_level_option()
|
||||
@click.option("--tag", "-t", multiple=True, help="Filter by tag before tailing")
|
||||
@_json_option()
|
||||
def tail(file, count, level, tag, as_json):
|
||||
"""Show the last N messages from a file (reverse of --limit in read)."""
|
||||
msgs = parse_file(file)
|
||||
msgs = filter_messages(
|
||||
msgs,
|
||||
max_level=level,
|
||||
tags=list(tag) if tag else None,
|
||||
)
|
||||
all_msgs = list(msgs)
|
||||
result = all_msgs[-count:]
|
||||
if as_json:
|
||||
click.echo(json.dumps([m.to_dict() for m in result], indent=2, default=str))
|
||||
else:
|
||||
for m in result:
|
||||
click.echo(m.to_text_line())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# clients
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@cli.command()
|
||||
@click.argument("file", type=click.Path(exists=True))
|
||||
@_json_option()
|
||||
def clients(file, as_json):
|
||||
"""List all client connections recorded in a NSLogger file."""
|
||||
from .core.blocks import extract_clients
|
||||
msgs = parse_file(file)
|
||||
client_list = extract_clients(msgs)
|
||||
if as_json:
|
||||
click.echo(json.dumps(client_list, indent=2, default=str))
|
||||
else:
|
||||
if not client_list:
|
||||
click.echo("No client_info messages found.")
|
||||
return
|
||||
for c in client_list:
|
||||
ts = c.get("timestamp") or "?"
|
||||
name = c.get("client_name") or "unknown"
|
||||
ver = c.get("client_version") or ""
|
||||
os_ = f"{c.get('os_name', '')} {c.get('os_version', '')}".strip()
|
||||
machine = c.get("machine") or ""
|
||||
click.echo(f"[{ts}] {name} {ver} {os_} {machine}".strip())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# blocks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@cli.command()
|
||||
@click.argument("file", type=click.Path(exists=True))
|
||||
@click.option("--indent", type=int, default=2, show_default=True,
|
||||
help="Spaces per indent level")
|
||||
@_json_option()
|
||||
def blocks(file, indent, as_json):
|
||||
"""Show the block start/end structure from a NSLogger file as an indented tree."""
|
||||
from .core.blocks import iter_block_tree
|
||||
msgs = parse_file(file)
|
||||
entries = list(iter_block_tree(msgs))
|
||||
if as_json:
|
||||
result = [
|
||||
{"depth": depth, **m.to_dict()}
|
||||
for depth, m in entries
|
||||
]
|
||||
click.echo(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
for depth, m in entries:
|
||||
prefix = " " * (depth * indent)
|
||||
click.echo(f"{prefix}{m.to_text_line()}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# merge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@cli.command()
|
||||
@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True))
|
||||
@click.option("--output", "-o", type=click.Path(), default=None,
|
||||
help="Write merged output to file (default: stdout)")
|
||||
@click.option("--format", "-f", "fmt",
|
||||
type=click.Choice(["text", "json", "csv"]), default="text",
|
||||
show_default=True)
|
||||
@_level_option()
|
||||
def merge(files, output, fmt, level):
|
||||
"""Merge multiple NSLogger files, sorted by timestamp."""
|
||||
from .core.blocks import merge_files
|
||||
all_msgs = merge_files(list(files))
|
||||
if level is not None:
|
||||
all_msgs = [m for m in all_msgs if m.level <= level]
|
||||
from .core.exporter import export_messages
|
||||
result_str = export_messages(iter(all_msgs), fmt=fmt)
|
||||
if output:
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(result_str)
|
||||
click.echo(f"Merged {len(files)} files → {output}", err=True)
|
||||
else:
|
||||
click.echo(result_str, nl=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# repl (interactive shell)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@cli.command()
|
||||
@click.argument("file", type=click.Path(exists=True), required=False)
|
||||
def repl(file):
|
||||
"""Start an interactive REPL for exploring NSLogger files."""
|
||||
try:
|
||||
import code
|
||||
import readline # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
context = {}
|
||||
if file:
|
||||
msgs = list(parse_file(file))
|
||||
context["messages"] = msgs
|
||||
context["file"] = file
|
||||
click.echo(f"Loaded {len(msgs)} messages from {file}")
|
||||
click.echo("Available: messages, filter_messages, compute_stats, export_messages")
|
||||
else:
|
||||
click.echo("NSLogger REPL — no file loaded. Use: messages = list(parse_file('x.rawnsloggerdata'))")
|
||||
|
||||
context.update({
|
||||
"parse_file": parse_file,
|
||||
"filter_messages": filter_messages,
|
||||
"compute_stats": compute_stats,
|
||||
"export_messages": export_messages,
|
||||
})
|
||||
|
||||
import code as _code
|
||||
_code.interact(local=context, banner="")
|
||||
|
||||
|
||||
def main():
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,188 @@
|
||||
---
|
||||
name: cli-anything-nslogger
|
||||
description: CLI harness for NSLogger — parse, filter, export, and monitor NSLogger log files (.rawnsloggerdata / .nsloggerdata)
|
||||
version: 0.1.0
|
||||
install: pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=nslogger/agent-harness
|
||||
binary: cli-anything-nslogger
|
||||
tags: [logging, ios, macos, debugging, nslogger]
|
||||
---
|
||||
|
||||
# cli-anything-nslogger
|
||||
|
||||
A complete CLI harness for [NSLogger](https://github.com/fpillet/NSLogger), the macOS log viewer for iOS/macOS apps.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd nslogger/agent-harness
|
||||
pip install -e .
|
||||
# Verify
|
||||
cli-anything-nslogger --help
|
||||
```
|
||||
|
||||
## Command Reference
|
||||
|
||||
### `generate` — Create sample files for testing
|
||||
|
||||
```bash
|
||||
cli-anything-nslogger generate sample.rawnsloggerdata --count 50
|
||||
```
|
||||
|
||||
### `read` — Display messages from a file
|
||||
|
||||
```bash
|
||||
# All messages
|
||||
cli-anything-nslogger read session.rawnsloggerdata
|
||||
|
||||
# Errors only (level 0)
|
||||
cli-anything-nslogger read session.rawnsloggerdata --level 0
|
||||
|
||||
# Filter by tag and text search
|
||||
cli-anything-nslogger read session.rawnsloggerdata --tag Network --search "timeout"
|
||||
|
||||
# First 20 messages as JSON
|
||||
cli-anything-nslogger read session.rawnsloggerdata --limit 20 --json
|
||||
```
|
||||
|
||||
### `filter` — Advanced filtering
|
||||
|
||||
```bash
|
||||
# Errors and warnings only
|
||||
cli-anything-nslogger filter session.rawnsloggerdata --level 1
|
||||
|
||||
# By tag
|
||||
cli-anything-nslogger filter session.rawnsloggerdata --tag Auth --tag Network
|
||||
|
||||
# Regex search
|
||||
cli-anything-nslogger filter session.rawnsloggerdata --regex "(timeout|failed|error)"
|
||||
|
||||
# By thread
|
||||
cli-anything-nslogger filter session.rawnsloggerdata --thread "main"
|
||||
|
||||
# JSON output
|
||||
cli-anything-nslogger filter session.rawnsloggerdata --level 0 --json
|
||||
```
|
||||
|
||||
### `export` — Export to text/JSON/CSV
|
||||
|
||||
```bash
|
||||
# JSON to stdout
|
||||
cli-anything-nslogger export session.rawnsloggerdata --format json
|
||||
|
||||
# CSV to file
|
||||
cli-anything-nslogger export session.rawnsloggerdata --format csv --output logs.csv
|
||||
|
||||
# Filtered text export
|
||||
cli-anything-nslogger export session.rawnsloggerdata --format text --level 1 --tag Network
|
||||
```
|
||||
|
||||
### `stats` — Summary statistics
|
||||
|
||||
```bash
|
||||
# Human-readable summary
|
||||
cli-anything-nslogger stats session.rawnsloggerdata
|
||||
|
||||
# JSON for agent consumption
|
||||
cli-anything-nslogger stats session.rawnsloggerdata --json
|
||||
```
|
||||
|
||||
JSON output shape:
|
||||
```json
|
||||
{
|
||||
"total": 342,
|
||||
"by_level": {"ERROR": 12, "WARNING": 34, "INFO": 200, "DEBUG": 96},
|
||||
"by_tag": {"Network": 89, "Auth": 45, "UI": 120},
|
||||
"by_thread": {"main": 200, "bg-queue": 142},
|
||||
"by_type": {"text": 340, "client_info": 1, "disconnect": 1},
|
||||
"clients": ["MyApp"],
|
||||
"first_timestamp": "2024-01-01T10:00:00+00:00",
|
||||
"last_timestamp": "2024-01-01T10:05:30+00:00",
|
||||
"duration_seconds": 330.0
|
||||
}
|
||||
```
|
||||
|
||||
### `listen` — Receive live connections
|
||||
|
||||
```bash
|
||||
# Match the NSLogger.app GUI Bonjour behavior for iOS auto-discovery
|
||||
cli-anything-nslogger listen --bonjour --name bazinga --debug
|
||||
|
||||
# Mirror live logs to a text file while still printing stdout
|
||||
cli-anything-nslogger listen --bonjour --name bazinga --output app.log
|
||||
|
||||
# Write machine-readable JSON Lines
|
||||
cli-anything-nslogger listen --bonjour --name bazinga --output app.jsonl --output-format jsonl
|
||||
|
||||
# Direct TCP/TLS mode for manually configured clients
|
||||
cli-anything-nslogger listen --port 50000 --ssl --debug
|
||||
|
||||
# Show only errors while listening, output as JSON stream
|
||||
cli-anything-nslogger listen --bonjour --name bazinga --level 0 --json
|
||||
|
||||
# Run until Ctrl-C
|
||||
cli-anything-nslogger listen --bonjour --name bazinga
|
||||
```
|
||||
|
||||
Use Bonjour mode first for iOS apps because it matches the desktop NSLogger GUI:
|
||||
the CLI publishes a native macOS `NetService` with `_nslogger-ssl._tcp` and
|
||||
accepts TLS NSLogger frames. Use direct TCP/TLS only when the app is manually
|
||||
configured with the Mac host and port.
|
||||
|
||||
### `repl` — Interactive Python REPL
|
||||
|
||||
```bash
|
||||
cli-anything-nslogger repl session.rawnsloggerdata
|
||||
# Available: messages, parse_file, filter_messages, compute_stats, export_messages
|
||||
```
|
||||
|
||||
## Log Levels
|
||||
|
||||
| Value | Name | Use for |
|
||||
|-------|---------|---------|
|
||||
| 0 | ERROR | Unrecoverable failures |
|
||||
| 1 | WARNING | Recoverable issues |
|
||||
| 2 | INFO | Normal operation |
|
||||
| 3 | DEBUG | Developer details |
|
||||
| 4 | VERBOSE | Trace-level noise |
|
||||
|
||||
## Message JSON Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"sequence": 42,
|
||||
"timestamp": "2024-01-01T10:01:23+00:00",
|
||||
"timestamp_ms": 456,
|
||||
"thread_id": "main",
|
||||
"tag": "Network",
|
||||
"level": 0,
|
||||
"level_name": "ERROR",
|
||||
"type": "text",
|
||||
"text": "Connection timed out after 30s",
|
||||
"image_width": 0,
|
||||
"image_height": 0,
|
||||
"client_name": "MyApp",
|
||||
"client_version": "2.1.0",
|
||||
"os_name": "iOS",
|
||||
"os_version": "17.0",
|
||||
"machine": "iPhone15,2"
|
||||
}
|
||||
```
|
||||
|
||||
## Agent Workflow Examples
|
||||
|
||||
```bash
|
||||
# 1. Inspect a captured crash session
|
||||
cli-anything-nslogger stats crash.rawnsloggerdata --json
|
||||
|
||||
# 2. Find all errors in the 5 minutes before crash
|
||||
cli-anything-nslogger filter crash.rawnsloggerdata --level 0 --json
|
||||
|
||||
# 3. Get network failures only
|
||||
cli-anything-nslogger filter crash.rawnsloggerdata --tag Network --regex "fail|timeout|error" --json
|
||||
|
||||
# 4. Export full log for offline analysis
|
||||
cli-anything-nslogger export crash.rawnsloggerdata --format json --output crash_log.json
|
||||
|
||||
# 5. Monitor an iOS app live and keep a local copy
|
||||
cli-anything-nslogger listen --bonjour --name bazinga --output app.log --debug
|
||||
```
|
||||
@@ -0,0 +1,137 @@
|
||||
# TEST.md — cli-anything-nslogger
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Unit Tests (`test_core.py`)
|
||||
|
||||
All tests use synthetic in-memory data — no external files or network required.
|
||||
|
||||
| Class | Coverage |
|
||||
|-------|----------|
|
||||
| `TestLogMessage` | `to_dict()`, `to_text_line()`, level/type name derivation for all types |
|
||||
| `TestFilterMessages` | level, min-level, tag (case-insensitive), thread, text search, regex, limit, combined |
|
||||
| `TestComputeStats` | totals, by_level, by_tag, by_thread, by_type, duration, timestamps, empty input |
|
||||
| `TestExporter` | text, JSON (shape + validity), CSV (header + rows), `export_messages()` dispatcher |
|
||||
| `TestWireProtocol` | encode→decode round-trip for text, timestamp, client-info; length prefix format |
|
||||
| `TestGenerateSampleFile` | file creation, parseability, level variety |
|
||||
| `TestParseRawFile` | single message, multiple messages, empty file |
|
||||
|
||||
### E2E Tests (`test_full_e2e.py`)
|
||||
|
||||
All tests use real files created by `generate_sample_file()` and real subprocess invocations.
|
||||
|
||||
| Class | Coverage |
|
||||
|-------|----------|
|
||||
| `TestGenerateCommand` | file creation, count in output, parseable result |
|
||||
| `TestReadCommand` | output lines, `--json` shape, `--level` filter, `--limit`, `--search` |
|
||||
| `TestFilterCommand` | `--level`, no-results, `--regex` |
|
||||
| `TestExportCommand` | text/JSON/CSV stdout, `--output` file, `--level` pre-filter |
|
||||
| `TestStatsCommand` | text summary, JSON shape, `by_level`, `by_tag` |
|
||||
| `TestWorkflow` | generate→filter→export pipeline, stats on generated file, help output |
|
||||
| `TestCLISubprocess` | installed entrypoint via `_resolve_cli()`: help, generate+read, stats JSON, export CSV |
|
||||
|
||||
### Scenarios NOT covered by automated tests
|
||||
|
||||
- `listen` command (requires live TCP client; integration test would need a network fixture)
|
||||
- `repl` command (interactive terminal; tested manually)
|
||||
- `.nsloggerdata` binary-plist format (requires a real NSLogger.app saved file)
|
||||
- SSL/TLS listener mode
|
||||
|
||||
---
|
||||
|
||||
## Test Results
|
||||
|
||||
Run: `python3 -m pytest cli_anything/nslogger/tests/ -v --tb=no`
|
||||
|
||||
Platform: darwin / Python 3.13.2 / pytest 9.0.3
|
||||
|
||||
```
|
||||
============================= test session starts ==============================
|
||||
platform darwin -- Python 3.13.2, pytest-9.0.3, pluggy-1.6.0
|
||||
collected 80 items
|
||||
|
||||
cli_anything/nslogger/tests/test_core.py::TestLogMessage::test_level_name_known PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestLogMessage::test_level_name_unknown PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestLogMessage::test_type_name_text PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestLogMessage::test_type_name_image PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestLogMessage::test_type_name_data PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestLogMessage::test_type_name_client_info PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestLogMessage::test_to_dict_keys PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestLogMessage::test_to_dict_timestamp_isoformat PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestLogMessage::test_to_text_line_contains_text PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestLogMessage::test_to_text_line_contains_level PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestLogMessage::test_to_text_line_contains_tag PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestLogMessage::test_to_text_line_no_timestamp PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestLogMessage::test_to_text_line_image PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestLogMessage::test_to_text_line_binary PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestFilterMessages::test_no_filter_passes_all PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestFilterMessages::test_max_level PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestFilterMessages::test_min_level PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestFilterMessages::test_tag_filter PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestFilterMessages::test_tag_case_insensitive PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestFilterMessages::test_thread_filter PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestFilterMessages::test_text_search PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestFilterMessages::test_text_search_case_insensitive PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestFilterMessages::test_regex_filter PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestFilterMessages::test_limit PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestFilterMessages::test_combined_filters PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestFilterMessages::test_empty_input PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestComputeStats::test_total PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestComputeStats::test_by_level PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestComputeStats::test_by_tag PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestComputeStats::test_by_thread PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestComputeStats::test_duration PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestComputeStats::test_timestamps PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestComputeStats::test_empty PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestComputeStats::test_by_type PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestExporter::test_export_text PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestExporter::test_export_json_valid PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestExporter::test_export_json_has_all_fields PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestExporter::test_export_csv_has_header PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestExporter::test_export_csv_has_data PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestExporter::test_export_messages_text PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestExporter::test_export_messages_json PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestExporter::test_export_messages_csv PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestWireProtocol::test_round_trip_text PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestWireProtocol::test_round_trip_timestamp PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestWireProtocol::test_round_trip_client_info PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestWireProtocol::test_encode_message_has_length_prefix PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestGenerateSampleFile::test_creates_file PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestGenerateSampleFile::test_parseable PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestGenerateSampleFile::test_varied_levels PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestParseRawFile::test_single_message PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestParseRawFile::test_multiple_messages PASSED
|
||||
cli_anything/nslogger/tests/test_core.py::TestParseRawFile::test_empty_file PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestGenerateCommand::test_generate_creates_file PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestGenerateCommand::test_generate_output_mentions_count PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestGenerateCommand::test_generate_parseable PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestReadCommand::test_read_outputs_messages PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestReadCommand::test_read_json_valid PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestReadCommand::test_read_json_message_shape PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestReadCommand::test_read_level_filter PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestReadCommand::test_read_limit PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestReadCommand::test_read_search PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestFilterCommand::test_filter_by_level PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestFilterCommand::test_filter_no_results PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestFilterCommand::test_filter_regex PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestExportCommand::test_export_text_stdout PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestExportCommand::test_export_json_stdout PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestExportCommand::test_export_csv_stdout PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestExportCommand::test_export_to_file PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestExportCommand::test_export_with_level_filter PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestStatsCommand::test_stats_text_output PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestStatsCommand::test_stats_json_output PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestStatsCommand::test_stats_json_has_by_level PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestStatsCommand::test_stats_json_has_by_tag PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestWorkflow::test_generate_filter_export_pipeline PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestWorkflow::test_stats_on_generated_file PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestWorkflow::test_cli_help_shows_commands PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestCLISubprocess::test_installed_cli_help PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestCLISubprocess::test_installed_generate_and_read PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestCLISubprocess::test_installed_stats_json PASSED
|
||||
cli_anything/nslogger/tests/test_full_e2e.py::TestCLISubprocess::test_installed_export_csv PASSED
|
||||
|
||||
============================== 80 passed in 3.55s ==============================
|
||||
```
|
||||
|
||||
**Result: 80 passed, 0 failed (100% pass rate)**
|
||||
@@ -0,0 +1,886 @@
|
||||
"""Unit tests for NSLogger CLI core modules (no external deps, synthetic data)."""
|
||||
import io
|
||||
import json
|
||||
import struct
|
||||
import tempfile
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cli_anything.nslogger.core.message import (
|
||||
LogMessage, MSG_TYPE_LOG, MSG_TYPE_CLIENT_INFO, MSG_TYPE_BLOCK_START, MSG_TYPE_BLOCK_END,
|
||||
LEVEL_NAMES,
|
||||
)
|
||||
from cli_anything.nslogger.core.filter import filter_messages
|
||||
from cli_anything.nslogger.core.stats import compute_stats
|
||||
from cli_anything.nslogger.core.exporter import export_text, export_json, export_csv, export_messages
|
||||
from cli_anything.nslogger.core.blocks import iter_block_tree, extract_clients, merge_files
|
||||
from cli_anything.nslogger.core.listener import (
|
||||
NSLoggerListener, _bonjour_service_types, _dns_sd_txt_args, _looks_like_tls_client_hello,
|
||||
_classify_connection, _swift_helper_env,
|
||||
)
|
||||
from cli_anything.nslogger.nslogger_cli import (
|
||||
_format_live_output_message,
|
||||
_listen_waiting_message,
|
||||
_open_live_output_file,
|
||||
)
|
||||
from cli_anything.nslogger.utils.generate import encode_message, generate_sample_file
|
||||
from cli_anything.nslogger.core.parser import _parse_message, parse_raw_file
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_msg(**kwargs) -> LogMessage:
|
||||
defaults = dict(
|
||||
sequence=0,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
timestamp_ms=500,
|
||||
level=2,
|
||||
tag="Test",
|
||||
thread_id="main",
|
||||
text="hello world",
|
||||
message_type=MSG_TYPE_LOG,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return LogMessage(**defaults)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LogMessage model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLogMessage:
|
||||
def test_level_name_known(self):
|
||||
msg = make_msg(level=0)
|
||||
assert msg.level_name == "ERROR"
|
||||
|
||||
def test_level_name_unknown(self):
|
||||
msg = make_msg(level=99)
|
||||
assert "99" in msg.level_name
|
||||
|
||||
def test_type_name_text(self):
|
||||
msg = make_msg(text="hello")
|
||||
assert msg.type_name == "text"
|
||||
|
||||
def test_type_name_image(self):
|
||||
msg = make_msg(image_data=b"\xff\xd8\xff", image_width=100, image_height=200)
|
||||
assert msg.type_name == "image"
|
||||
|
||||
def test_type_name_data(self):
|
||||
msg = make_msg(binary_data=b"\x00\x01")
|
||||
assert msg.type_name == "data"
|
||||
|
||||
def test_type_name_client_info(self):
|
||||
msg = make_msg(message_type=MSG_TYPE_CLIENT_INFO)
|
||||
assert msg.type_name == "client_info"
|
||||
|
||||
def test_to_dict_keys(self):
|
||||
msg = make_msg()
|
||||
d = msg.to_dict()
|
||||
assert "sequence" in d
|
||||
assert "timestamp" in d
|
||||
assert "level_name" in d
|
||||
assert "type" in d
|
||||
assert "text" in d
|
||||
|
||||
def test_to_dict_timestamp_isoformat(self):
|
||||
msg = make_msg()
|
||||
d = msg.to_dict()
|
||||
assert "2024-01-01" in d["timestamp"]
|
||||
|
||||
def test_to_text_line_contains_text(self):
|
||||
msg = make_msg(text="SAMPLE_TEXT")
|
||||
line = msg.to_text_line()
|
||||
assert "SAMPLE_TEXT" in line
|
||||
|
||||
def test_to_text_line_contains_level(self):
|
||||
msg = make_msg(level=0)
|
||||
line = msg.to_text_line()
|
||||
assert "ERROR" in line
|
||||
|
||||
def test_to_text_line_contains_tag(self):
|
||||
msg = make_msg(tag="NetworkOps")
|
||||
line = msg.to_text_line()
|
||||
assert "NetworkOps" in line
|
||||
|
||||
def test_to_text_line_no_timestamp(self):
|
||||
msg = make_msg(timestamp=None)
|
||||
line = msg.to_text_line()
|
||||
assert "??" in line
|
||||
|
||||
def test_to_text_line_image(self):
|
||||
msg = make_msg(image_data=b"x", image_width=320, image_height=240)
|
||||
line = msg.to_text_line()
|
||||
assert "image" in line
|
||||
assert "320" in line
|
||||
|
||||
def test_to_text_line_binary(self):
|
||||
msg = make_msg(binary_data=b"\x00" * 10)
|
||||
line = msg.to_text_line()
|
||||
assert "10" in line
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# filter_messages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFilterMessages:
|
||||
def _msgs(self):
|
||||
return [
|
||||
make_msg(sequence=1, level=0, tag="Auth", thread_id="main", text="error occurred"),
|
||||
make_msg(sequence=2, level=2, tag="Network", thread_id="bg", text="request sent"),
|
||||
make_msg(sequence=3, level=3, tag="Auth", thread_id="main", text="token refreshed"),
|
||||
make_msg(sequence=4, level=4, tag="UI", thread_id="main", text="view loaded"),
|
||||
]
|
||||
|
||||
def test_no_filter_passes_all(self):
|
||||
result = list(filter_messages(iter(self._msgs())))
|
||||
assert len(result) == 4
|
||||
|
||||
def test_max_level(self):
|
||||
result = list(filter_messages(iter(self._msgs()), max_level=1))
|
||||
assert all(m.level <= 1 for m in result)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_min_level(self):
|
||||
result = list(filter_messages(iter(self._msgs()), min_level=3))
|
||||
assert all(m.level >= 3 for m in result)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_tag_filter(self):
|
||||
result = list(filter_messages(iter(self._msgs()), tags=["auth"]))
|
||||
assert all(m.tag == "Auth" for m in result)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_tag_case_insensitive(self):
|
||||
result = list(filter_messages(iter(self._msgs()), tags=["AUTH"]))
|
||||
assert len(result) == 2
|
||||
|
||||
def test_thread_filter(self):
|
||||
result = list(filter_messages(iter(self._msgs()), thread_id="bg"))
|
||||
assert len(result) == 1
|
||||
assert result[0].sequence == 2
|
||||
|
||||
def test_text_search(self):
|
||||
result = list(filter_messages(iter(self._msgs()), text_search="token"))
|
||||
assert len(result) == 1
|
||||
assert "token" in result[0].text.lower()
|
||||
|
||||
def test_text_search_case_insensitive(self):
|
||||
result = list(filter_messages(iter(self._msgs()), text_search="ERROR"))
|
||||
assert len(result) == 1
|
||||
|
||||
def test_regex_filter(self):
|
||||
result = list(filter_messages(iter(self._msgs()), text_regex=r"re(quest|freshed)"))
|
||||
assert len(result) == 2
|
||||
|
||||
def test_limit(self):
|
||||
result = list(filter_messages(iter(self._msgs()), limit=2))
|
||||
assert len(result) == 2
|
||||
|
||||
def test_combined_filters(self):
|
||||
result = list(filter_messages(iter(self._msgs()), max_level=2, tags=["auth"]))
|
||||
assert len(result) == 1
|
||||
assert result[0].level == 0
|
||||
|
||||
def test_empty_input(self):
|
||||
result = list(filter_messages(iter([])))
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComputeStats:
|
||||
def _msgs(self):
|
||||
return [
|
||||
make_msg(level=0, tag="Auth", thread_id="main",
|
||||
timestamp=datetime(2024, 1, 1, 10, 0, tzinfo=timezone.utc)),
|
||||
make_msg(level=2, tag="Network", thread_id="bg",
|
||||
timestamp=datetime(2024, 1, 1, 10, 1, tzinfo=timezone.utc)),
|
||||
make_msg(level=2, tag="Auth", thread_id="main",
|
||||
timestamp=datetime(2024, 1, 1, 10, 2, tzinfo=timezone.utc)),
|
||||
]
|
||||
|
||||
def test_total(self):
|
||||
s = compute_stats(iter(self._msgs()))
|
||||
assert s["total"] == 3
|
||||
|
||||
def test_by_level(self):
|
||||
s = compute_stats(iter(self._msgs()))
|
||||
assert s["by_level"]["ERROR"] == 1
|
||||
assert s["by_level"]["INFO"] == 2
|
||||
|
||||
def test_by_tag(self):
|
||||
s = compute_stats(iter(self._msgs()))
|
||||
assert s["by_tag"]["Auth"] == 2
|
||||
assert s["by_tag"]["Network"] == 1
|
||||
|
||||
def test_by_thread(self):
|
||||
s = compute_stats(iter(self._msgs()))
|
||||
assert s["by_thread"]["main"] == 2
|
||||
|
||||
def test_duration(self):
|
||||
s = compute_stats(iter(self._msgs()))
|
||||
assert s["duration_seconds"] == 120.0
|
||||
|
||||
def test_timestamps(self):
|
||||
s = compute_stats(iter(self._msgs()))
|
||||
assert "2024-01-01T10:00:00" in s["first_timestamp"]
|
||||
assert "2024-01-01T10:02:00" in s["last_timestamp"]
|
||||
|
||||
def test_empty(self):
|
||||
s = compute_stats(iter([]))
|
||||
assert s["total"] == 0
|
||||
|
||||
def test_by_type(self):
|
||||
s = compute_stats(iter(self._msgs()))
|
||||
assert "text" in s["by_type"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# exporter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExporter:
|
||||
def _msgs(self):
|
||||
return [
|
||||
make_msg(sequence=1, level=0, tag="A", text="first message"),
|
||||
make_msg(sequence=2, level=2, tag="B", text="second message"),
|
||||
]
|
||||
|
||||
def test_export_text(self):
|
||||
out = export_text(self._msgs())
|
||||
assert "first message" in out
|
||||
assert "second message" in out
|
||||
|
||||
def test_export_json_valid(self):
|
||||
out = export_json(self._msgs())
|
||||
data = json.loads(out)
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 2
|
||||
assert data[0]["sequence"] == 1
|
||||
|
||||
def test_export_json_has_all_fields(self):
|
||||
out = export_json(self._msgs())
|
||||
data = json.loads(out)
|
||||
for key in ("sequence", "timestamp", "level", "level_name", "tag", "text", "type"):
|
||||
assert key in data[0]
|
||||
|
||||
def test_export_csv_has_header(self):
|
||||
out = export_csv(self._msgs())
|
||||
assert "sequence" in out.splitlines()[0]
|
||||
|
||||
def test_export_csv_has_data(self):
|
||||
out = export_csv(self._msgs())
|
||||
lines = out.strip().splitlines()
|
||||
assert len(lines) == 3 # header + 2 rows
|
||||
|
||||
def test_export_messages_text(self):
|
||||
out = export_messages(iter(self._msgs()), fmt="text")
|
||||
assert "first message" in out
|
||||
|
||||
def test_export_messages_json(self):
|
||||
out = export_messages(iter(self._msgs()), fmt="json")
|
||||
json.loads(out) # must be valid JSON
|
||||
|
||||
def test_export_messages_csv(self):
|
||||
out = export_messages(iter(self._msgs()), fmt="csv")
|
||||
assert "," in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wire protocol encoder / parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWireProtocol:
|
||||
def _encode_and_parse(self, **kwargs):
|
||||
raw_file_bytes = encode_message(sequence=1, **kwargs)
|
||||
# raw_file_bytes = [4-byte length][body]
|
||||
body = raw_file_bytes[4:]
|
||||
return _parse_message(body)
|
||||
|
||||
def test_round_trip_text(self):
|
||||
msg = self._encode_and_parse(text="hello", level=2, tag="TAG", thread_id="main")
|
||||
assert msg.text == "hello"
|
||||
assert msg.level == 2
|
||||
assert msg.tag == "TAG"
|
||||
assert msg.thread_id == "main"
|
||||
assert msg.sequence == 1
|
||||
|
||||
def test_round_trip_timestamp(self):
|
||||
ts = 1700000000.5
|
||||
msg = self._encode_and_parse(timestamp=ts, text="x")
|
||||
assert msg.timestamp is not None
|
||||
assert abs(msg.timestamp.timestamp() - int(ts)) < 1
|
||||
|
||||
def test_round_trip_client_info(self):
|
||||
msg = self._encode_and_parse(
|
||||
msg_type=3,
|
||||
client_name="TestApp",
|
||||
client_version="2.0",
|
||||
os_name="iOS",
|
||||
os_version="17.0",
|
||||
machine="iPhone15,2",
|
||||
)
|
||||
assert msg.client_name == "TestApp"
|
||||
assert msg.os_name == "iOS"
|
||||
|
||||
def test_encode_message_has_length_prefix(self):
|
||||
raw = encode_message(sequence=0, text="x")
|
||||
declared_len = struct.unpack(">I", raw[:4])[0]
|
||||
assert declared_len == len(raw) - 4
|
||||
|
||||
def test_official_integer_parts_do_not_have_length_fields(self):
|
||||
body = encode_message(sequence=7, text="official", level=1)[4:]
|
||||
msg = _parse_message(body)
|
||||
assert msg.sequence == 7
|
||||
assert msg.level == 1
|
||||
assert msg.text == "official"
|
||||
|
||||
def test_legacy_lengthful_integer_parts_still_parse(self):
|
||||
parts = b""
|
||||
parts += bytes([0, 3]) + struct.pack(">I", 4) + struct.pack(">I", 0)
|
||||
parts += bytes([10, 3]) + struct.pack(">I", 4) + struct.pack(">I", 8)
|
||||
text = b"legacy"
|
||||
parts += bytes([7, 0]) + struct.pack(">I", len(text)) + text
|
||||
body = struct.pack(">H", 3) + parts
|
||||
msg = _parse_message(body)
|
||||
assert msg.sequence == 8
|
||||
assert msg.text == "legacy"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate_sample_file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGenerateSampleFile:
|
||||
def test_creates_file(self, tmp_path):
|
||||
path = str(tmp_path / "sample.rawnsloggerdata")
|
||||
generate_sample_file(path, count=5)
|
||||
assert os.path.exists(path)
|
||||
assert os.path.getsize(path) > 0
|
||||
|
||||
def test_parseable(self, tmp_path):
|
||||
path = str(tmp_path / "sample.rawnsloggerdata")
|
||||
generate_sample_file(path, count=10)
|
||||
msgs = list(parse_raw_file(path))
|
||||
assert len(msgs) >= 10 # 10 log + 1 client_info
|
||||
|
||||
def test_varied_levels(self, tmp_path):
|
||||
path = str(tmp_path / "sample.rawnsloggerdata")
|
||||
generate_sample_file(path, count=50)
|
||||
msgs = list(parse_raw_file(path))
|
||||
levels = {m.level for m in msgs}
|
||||
assert len(levels) > 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_raw_file (file I/O)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseRawFile:
|
||||
def _write_file(self, tmp_path, *messages_kwargs):
|
||||
path = str(tmp_path / "test.rawnsloggerdata")
|
||||
with open(path, "wb") as f:
|
||||
for i, kw in enumerate(messages_kwargs):
|
||||
f.write(encode_message(sequence=i, **kw))
|
||||
return path
|
||||
|
||||
def test_single_message(self, tmp_path):
|
||||
path = self._write_file(tmp_path, {"text": "only one"})
|
||||
msgs = list(parse_raw_file(path))
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0].text == "only one"
|
||||
|
||||
def test_multiple_messages(self, tmp_path):
|
||||
path = self._write_file(
|
||||
tmp_path,
|
||||
{"text": "first", "level": 0},
|
||||
{"text": "second", "level": 2},
|
||||
{"text": "third", "level": 4},
|
||||
)
|
||||
msgs = list(parse_raw_file(path))
|
||||
assert len(msgs) == 3
|
||||
assert msgs[0].text == "first"
|
||||
assert msgs[2].text == "third"
|
||||
|
||||
def test_empty_file(self, tmp_path):
|
||||
path = str(tmp_path / "empty.rawnsloggerdata")
|
||||
open(path, "wb").close()
|
||||
msgs = list(parse_raw_file(path))
|
||||
assert msgs == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# filter_messages — new time-range and sequence-range options
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFilterMessagesExtended:
|
||||
def _msgs_with_timestamps(self):
|
||||
t = lambda h, m: datetime(2024, 1, 1, h, m, 0, tzinfo=timezone.utc)
|
||||
return [
|
||||
make_msg(sequence=10, timestamp=t(10, 0), level=0, text="early error"),
|
||||
make_msg(sequence=20, timestamp=t(10, 30), level=2, text="mid info"),
|
||||
make_msg(sequence=30, timestamp=t(11, 0), level=3, text="late debug"),
|
||||
]
|
||||
|
||||
def test_after_filter(self):
|
||||
after = datetime(2024, 1, 1, 10, 15, tzinfo=timezone.utc)
|
||||
result = list(filter_messages(iter(self._msgs_with_timestamps()), after=after))
|
||||
assert len(result) == 2
|
||||
assert result[0].sequence == 20
|
||||
|
||||
def test_before_filter(self):
|
||||
before = datetime(2024, 1, 1, 10, 45, tzinfo=timezone.utc)
|
||||
result = list(filter_messages(iter(self._msgs_with_timestamps()), before=before))
|
||||
assert len(result) == 2
|
||||
assert result[-1].sequence == 20
|
||||
|
||||
def test_after_and_before_window(self):
|
||||
after = datetime(2024, 1, 1, 10, 15, tzinfo=timezone.utc)
|
||||
before = datetime(2024, 1, 1, 10, 45, tzinfo=timezone.utc)
|
||||
result = list(filter_messages(iter(self._msgs_with_timestamps()), after=after, before=before))
|
||||
assert len(result) == 1
|
||||
assert result[0].sequence == 20
|
||||
|
||||
def test_from_seq(self):
|
||||
result = list(filter_messages(iter(self._msgs_with_timestamps()), from_seq=20))
|
||||
assert len(result) == 2
|
||||
assert result[0].sequence == 20
|
||||
|
||||
def test_to_seq(self):
|
||||
result = list(filter_messages(iter(self._msgs_with_timestamps()), to_seq=20))
|
||||
assert len(result) == 2
|
||||
assert result[-1].sequence == 20
|
||||
|
||||
def test_seq_range(self):
|
||||
result = list(filter_messages(iter(self._msgs_with_timestamps()), from_seq=20, to_seq=20))
|
||||
assert len(result) == 1
|
||||
assert result[0].sequence == 20
|
||||
|
||||
def test_no_timestamp_excluded_by_after(self):
|
||||
msgs = [make_msg(sequence=1, timestamp=None, text="no ts")]
|
||||
after = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||
result = list(filter_messages(iter(msgs), after=after))
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# blocks module
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIterBlockTree:
|
||||
def _block_msgs(self):
|
||||
return [
|
||||
make_msg(sequence=1, message_type=MSG_TYPE_LOG, text="before block"),
|
||||
make_msg(sequence=2, message_type=MSG_TYPE_BLOCK_START, text="enter"),
|
||||
make_msg(sequence=3, message_type=MSG_TYPE_LOG, text="inside"),
|
||||
make_msg(sequence=4, message_type=MSG_TYPE_BLOCK_END, text="exit"),
|
||||
make_msg(sequence=5, message_type=MSG_TYPE_LOG, text="after block"),
|
||||
]
|
||||
|
||||
def test_top_level_messages_have_depth_zero(self):
|
||||
pairs = list(iter_block_tree(iter(self._block_msgs())))
|
||||
assert pairs[0][0] == 0 # before block
|
||||
assert pairs[4][0] == 0 # after block
|
||||
|
||||
def test_block_start_at_zero_before_increment(self):
|
||||
pairs = list(iter_block_tree(iter(self._block_msgs())))
|
||||
# block_start emitted at depth 0, then depth increments
|
||||
assert pairs[1][0] == 0
|
||||
assert pairs[1][1].message_type == MSG_TYPE_BLOCK_START
|
||||
|
||||
def test_inside_block_has_depth_one(self):
|
||||
pairs = list(iter_block_tree(iter(self._block_msgs())))
|
||||
assert pairs[2][0] == 1
|
||||
|
||||
def test_block_end_has_depth_zero_after_decrement(self):
|
||||
pairs = list(iter_block_tree(iter(self._block_msgs())))
|
||||
# block_end decrements first, so emitted at depth 0
|
||||
assert pairs[3][0] == 0
|
||||
|
||||
def test_empty_input(self):
|
||||
assert list(iter_block_tree(iter([]))) == []
|
||||
|
||||
|
||||
class TestExtractClients:
|
||||
def _msgs_with_client(self):
|
||||
client = LogMessage(
|
||||
sequence=0,
|
||||
message_type=MSG_TYPE_CLIENT_INFO,
|
||||
client_name="MyApp",
|
||||
client_version="3.1",
|
||||
os_name="iOS",
|
||||
os_version="17.0",
|
||||
machine="iPhone16,1",
|
||||
)
|
||||
log = make_msg(sequence=1, text="regular log")
|
||||
return [client, log]
|
||||
|
||||
def test_returns_only_client_info(self):
|
||||
result = extract_clients(iter(self._msgs_with_client()))
|
||||
assert len(result) == 1
|
||||
|
||||
def test_client_fields(self):
|
||||
result = extract_clients(iter(self._msgs_with_client()))
|
||||
c = result[0]
|
||||
assert c["client_name"] == "MyApp"
|
||||
assert c["client_version"] == "3.1"
|
||||
assert c["os_name"] == "iOS"
|
||||
assert c["machine"] == "iPhone16,1"
|
||||
|
||||
def test_no_clients_returns_empty(self):
|
||||
result = extract_clients(iter([make_msg(text="log")]))
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestMergeFiles:
|
||||
def _write(self, tmp_path, name, *messages_kwargs):
|
||||
path = str(tmp_path / name)
|
||||
with open(path, "wb") as f:
|
||||
for i, kw in enumerate(messages_kwargs):
|
||||
f.write(encode_message(sequence=i, **kw))
|
||||
return path
|
||||
|
||||
def test_merge_two_files_sorted(self, tmp_path):
|
||||
ts_early = 1700000000.0
|
||||
ts_late = 1700000100.0
|
||||
path_a = self._write(tmp_path, "a.rawnsloggerdata",
|
||||
{"text": "late msg", "timestamp": ts_late})
|
||||
path_b = self._write(tmp_path, "b.rawnsloggerdata",
|
||||
{"text": "early msg", "timestamp": ts_early})
|
||||
result = merge_files([path_a, path_b])
|
||||
assert len(result) == 2
|
||||
assert result[0].text == "early msg"
|
||||
assert result[1].text == "late msg"
|
||||
|
||||
def test_merge_single_file(self, tmp_path):
|
||||
path = self._write(tmp_path, "c.rawnsloggerdata",
|
||||
{"text": "only msg"})
|
||||
result = merge_files([path])
|
||||
assert len(result) == 1
|
||||
|
||||
def test_merge_preserves_all_messages(self, tmp_path):
|
||||
path_a = self._write(tmp_path, "d.rawnsloggerdata",
|
||||
{"text": "a1"}, {"text": "a2"})
|
||||
path_b = self._write(tmp_path, "e.rawnsloggerdata",
|
||||
{"text": "b1"}, {"text": "b2"})
|
||||
result = merge_files([path_a, path_b])
|
||||
assert len(result) == 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# listener
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNSLoggerListener:
|
||||
def test_swift_helper_env_uses_temp_module_caches(self):
|
||||
env = _swift_helper_env()
|
||||
|
||||
assert env["SWIFT_MODULE_CACHE_PATH"].startswith(tempfile.gettempdir())
|
||||
assert env["CLANG_MODULE_CACHE_PATH"].startswith(tempfile.gettempdir())
|
||||
|
||||
def test_named_bonjour_service_uses_filter_txt_record(self):
|
||||
assert _dns_sd_txt_args("bazinga", filter_clients=True) == ["filterClients=1"]
|
||||
|
||||
def test_named_bonjour_service_does_not_filter_by_default(self):
|
||||
assert _dns_sd_txt_args("bazinga") == []
|
||||
|
||||
def test_empty_bonjour_service_has_no_filter_txt_record(self):
|
||||
assert _dns_sd_txt_args("") == []
|
||||
|
||||
def test_ssl_bonjour_advertises_only_ssl_service(self):
|
||||
assert _bonjour_service_types(True) == ("_nslogger-ssl._tcp",)
|
||||
|
||||
def test_auto_bonjour_advertises_raw_and_ssl_services(self):
|
||||
assert _bonjour_service_types(True, allow_plaintext=True) == ("_nslogger._tcp", "_nslogger-ssl._tcp")
|
||||
|
||||
def test_non_ssl_bonjour_advertises_only_legacy_service(self):
|
||||
assert _bonjour_service_types(False) == ("_nslogger._tcp",)
|
||||
|
||||
def test_macos_bonjour_prefers_native_netservice(self):
|
||||
listener = NSLoggerListener(bonjour=True, bonjour_name="bazinga")
|
||||
|
||||
class DummyNativePublisher:
|
||||
def __init__(self, service_name, service_types, port, filter_clients, on_debug):
|
||||
self.service_name = service_name
|
||||
self.service_types = service_types
|
||||
self.port = port
|
||||
self.filter_clients = filter_clients
|
||||
self.on_debug = on_debug
|
||||
|
||||
with patch("cli_anything.nslogger.core.listener.sys.platform", "darwin"), \
|
||||
patch("cli_anything.nslogger.core.listener._NativeBonjourPublisher", DummyNativePublisher), \
|
||||
patch("cli_anything.nslogger.core.listener._DnsSdBonjourPublisher") as dns_sd_publisher, \
|
||||
patch("cli_anything.nslogger.core.listener._ZeroconfBonjourPublisher") as zeroconf_publisher:
|
||||
publisher = listener._start_bonjour("192.168.10.1")
|
||||
|
||||
assert isinstance(publisher, DummyNativePublisher)
|
||||
assert publisher.service_name == "bazinga"
|
||||
assert publisher.service_types == ("_nslogger-ssl._tcp",)
|
||||
assert publisher.filter_clients is True
|
||||
assert dns_sd_publisher.call_count == 0
|
||||
assert zeroconf_publisher.call_count == 0
|
||||
|
||||
def test_bonjour_auto_mode_can_publish_raw_and_ssl_services(self):
|
||||
listener = NSLoggerListener(
|
||||
bonjour=True,
|
||||
bonjour_name="bazinga",
|
||||
allow_plaintext=True,
|
||||
)
|
||||
|
||||
class DummyNativePublisher:
|
||||
def __init__(self, service_name, service_types, port, filter_clients, on_debug):
|
||||
self.service_types = service_types
|
||||
|
||||
with patch("cli_anything.nslogger.core.listener.sys.platform", "darwin"), \
|
||||
patch("cli_anything.nslogger.core.listener._NativeBonjourPublisher", DummyNativePublisher):
|
||||
publisher = listener._start_bonjour("192.168.10.1")
|
||||
|
||||
assert publisher.service_types == ("_nslogger._tcp", "_nslogger-ssl._tcp")
|
||||
|
||||
def test_dns_sd_publisher_can_be_forced_on_macos(self):
|
||||
listener = NSLoggerListener(
|
||||
bonjour=True,
|
||||
bonjour_name="bazinga",
|
||||
bonjour_publisher="dns-sd",
|
||||
)
|
||||
|
||||
class DummyDnsSdPublisher:
|
||||
def __init__(self, service_name, service_types, port, filter_clients, on_debug):
|
||||
self.service_name = service_name
|
||||
self.service_types = service_types
|
||||
self.port = port
|
||||
self.filter_clients = filter_clients
|
||||
|
||||
with patch("cli_anything.nslogger.core.listener.sys.platform", "darwin"), \
|
||||
patch("cli_anything.nslogger.core.listener._NativeBonjourPublisher") as native_publisher, \
|
||||
patch("cli_anything.nslogger.core.listener._DnsSdBonjourPublisher", DummyDnsSdPublisher):
|
||||
publisher = listener._start_bonjour("192.168.10.1")
|
||||
|
||||
assert isinstance(publisher, DummyDnsSdPublisher)
|
||||
assert native_publisher.call_count == 0
|
||||
|
||||
def test_zeroconf_publisher_can_be_forced_on_macos(self):
|
||||
listener = NSLoggerListener(
|
||||
bonjour=True,
|
||||
bonjour_name="bazinga",
|
||||
bonjour_publisher="zeroconf",
|
||||
)
|
||||
|
||||
class DummyZeroconfPublisher:
|
||||
def __init__(self, service_name, service_types, port, local_ip, filter_clients):
|
||||
self.service_name = service_name
|
||||
self.service_types = service_types
|
||||
self.port = port
|
||||
self.local_ip = local_ip
|
||||
self.filter_clients = filter_clients
|
||||
|
||||
with patch("cli_anything.nslogger.core.listener.sys.platform", "darwin"), \
|
||||
patch("cli_anything.nslogger.core.listener._ZeroconfBonjourPublisher", DummyZeroconfPublisher), \
|
||||
patch("cli_anything.nslogger.core.listener._DnsSdBonjourPublisher") as dns_sd_publisher:
|
||||
publisher = listener._start_bonjour("192.168.10.5")
|
||||
|
||||
assert isinstance(publisher, DummyZeroconfPublisher)
|
||||
assert publisher.local_ip == "192.168.10.5"
|
||||
assert dns_sd_publisher.call_count == 0
|
||||
|
||||
def test_ssl_handshake_failure_is_silent(self):
|
||||
class DummySSLContext:
|
||||
def wrap_socket(self, conn, server_side=True):
|
||||
raise OSError(22, "Invalid argument")
|
||||
|
||||
class DummyConn:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
self.peeked = False
|
||||
|
||||
def recv(self, n, flags=0):
|
||||
if flags:
|
||||
self.peeked = True
|
||||
return b"\x16\x03\x01\x00\x2a"
|
||||
return b""
|
||||
|
||||
def settimeout(self, timeout):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
connect_calls = []
|
||||
disconnect_calls = []
|
||||
|
||||
listener = NSLoggerListener(
|
||||
on_connect=lambda host, port: connect_calls.append((host, port)),
|
||||
on_disconnect=lambda host, port: disconnect_calls.append((host, port)),
|
||||
)
|
||||
listener._ssl_ctx = DummySSLContext()
|
||||
conn = DummyConn()
|
||||
|
||||
listener._handle_client(conn, ("127.0.0.1", 50000))
|
||||
|
||||
assert conn.closed is True
|
||||
assert connect_calls == []
|
||||
assert disconnect_calls == []
|
||||
|
||||
def test_raw_connection_without_ssl_context_is_not_wrapped(self):
|
||||
class DummyConn:
|
||||
def __init__(self, payload: bytes):
|
||||
self.payload = payload
|
||||
self.closed = False
|
||||
|
||||
def recv(self, n, flags=0):
|
||||
if flags == getattr(__import__("socket"), "MSG_PEEK", 0):
|
||||
return self.payload[:n]
|
||||
return b""
|
||||
|
||||
def settimeout(self, timeout):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
listener = NSLoggerListener(use_ssl=False)
|
||||
conn = DummyConn(b"\x00\x00\x00\x10\x00")
|
||||
|
||||
assert _looks_like_tls_client_hello(conn) is False
|
||||
listener._handle_client(conn, ("127.0.0.1", 50000))
|
||||
assert conn.closed is True
|
||||
|
||||
def test_tls_client_hello_is_detected(self):
|
||||
class DummyConn:
|
||||
def recv(self, n, flags=0):
|
||||
if flags == getattr(__import__("socket"), "MSG_PEEK", 0):
|
||||
return b"\x16\x03\x01\x00\x2a"
|
||||
return b""
|
||||
|
||||
assert _looks_like_tls_client_hello(DummyConn()) is True
|
||||
|
||||
def test_connection_classifier_distinguishes_tls_raw_and_empty(self):
|
||||
class DummyConn:
|
||||
def __init__(self, payload: bytes):
|
||||
self.payload = payload
|
||||
|
||||
def recv(self, n, flags=0):
|
||||
return self.payload[:n]
|
||||
|
||||
assert _classify_connection(DummyConn(b"\x16\x03\x01\x00\x2a"))[0] == "tls"
|
||||
assert _classify_connection(DummyConn(b"\x00\x00\x00\x10\x00"))[0] == "raw"
|
||||
assert _classify_connection(DummyConn(b""))[0] == "empty"
|
||||
|
||||
def test_connection_classifier_distinguishes_timeout_from_empty_probe(self):
|
||||
class DummyConn:
|
||||
def recv(self, n, flags=0):
|
||||
raise __import__("socket").timeout()
|
||||
|
||||
assert _classify_connection(DummyConn())[0] == "timeout"
|
||||
|
||||
def test_auto_ssl_context_accepts_plaintext_live_packet(self):
|
||||
class DummySSLContext:
|
||||
def wrap_socket(self, conn, server_side=True):
|
||||
raise AssertionError("raw packet should not be TLS-wrapped")
|
||||
|
||||
server, client = __import__("socket").socketpair()
|
||||
messages = []
|
||||
listener = NSLoggerListener(
|
||||
on_message=messages.append,
|
||||
use_ssl=True,
|
||||
allow_plaintext=True,
|
||||
)
|
||||
listener._ssl_ctx = DummySSLContext()
|
||||
thread = __import__("threading").Thread(
|
||||
target=listener._handle_client,
|
||||
args=(server, ("127.0.0.1", 50000)),
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
thread.start()
|
||||
client.sendall(encode_message(sequence=43, text="auto raw packet", tag="Network", level=1))
|
||||
client.close()
|
||||
thread.join(timeout=2.0)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0].sequence == 43
|
||||
assert messages[0].text == "auto raw packet"
|
||||
|
||||
def test_handle_client_parses_official_live_packet(self):
|
||||
server, client = __import__("socket").socketpair()
|
||||
messages = []
|
||||
listener = NSLoggerListener(on_message=messages.append)
|
||||
thread = __import__("threading").Thread(
|
||||
target=listener._handle_client,
|
||||
args=(server, ("127.0.0.1", 50000)),
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
thread.start()
|
||||
client.sendall(encode_message(sequence=42, text="live packet", tag="Network", level=1))
|
||||
client.close()
|
||||
thread.join(timeout=2.0)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0].sequence == 42
|
||||
assert messages[0].text == "live packet"
|
||||
assert messages[0].tag == "Network"
|
||||
|
||||
def test_handle_client_reports_parse_errors(self):
|
||||
server, client = __import__("socket").socketpair()
|
||||
errors = []
|
||||
listener = NSLoggerListener(on_parse_error=lambda host, port, raw, exc: errors.append((raw, exc)))
|
||||
thread = __import__("threading").Thread(
|
||||
target=listener._handle_client,
|
||||
args=(server, ("127.0.0.1", 50000)),
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
thread.start()
|
||||
client.sendall(struct.pack(">I", 2) + b"\x00\x01")
|
||||
client.close()
|
||||
thread.join(timeout=2.0)
|
||||
|
||||
assert len(errors) == 1
|
||||
assert errors[0][0] == b"\x00\x01"
|
||||
|
||||
|
||||
class TestListenWaitingMessage:
|
||||
def test_non_bonjour_message(self):
|
||||
assert _listen_waiting_message(50000, False) == "Waiting for a client connection on port 50000…"
|
||||
|
||||
def test_bonjour_message(self):
|
||||
assert _listen_waiting_message(50000, True) == "[Bonjour] Waiting for an iOS client to connect on port 50000…"
|
||||
|
||||
|
||||
class TestListenOutputFile:
|
||||
def test_format_live_output_text(self):
|
||||
assert _format_live_output_message(make_msg(text="live text"), "text").endswith("live text")
|
||||
|
||||
def test_format_live_output_jsonl(self):
|
||||
line = _format_live_output_message(make_msg(text="live json"), "jsonl")
|
||||
data = json.loads(line)
|
||||
|
||||
assert data["text"] == "live json"
|
||||
|
||||
def test_open_live_output_file_creates_parent_and_replaces(self, tmp_path):
|
||||
path = tmp_path / "nested" / "live.log"
|
||||
|
||||
with _open_live_output_file(str(path), append=False) as f:
|
||||
f.write("new\n")
|
||||
|
||||
assert path.read_text(encoding="utf-8") == "new\n"
|
||||
|
||||
def test_open_live_output_file_appends(self, tmp_path):
|
||||
path = tmp_path / "nested" / "live.log"
|
||||
|
||||
with _open_live_output_file(str(path), append=False) as f:
|
||||
f.write("one\n")
|
||||
with _open_live_output_file(str(path), append=True) as f:
|
||||
f.write("two\n")
|
||||
|
||||
assert path.read_text(encoding="utf-8") == "one\ntwo\n"
|
||||
@@ -0,0 +1,408 @@
|
||||
"""End-to-end tests for cli-anything-nslogger using real files and subprocess."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from cli_anything.nslogger.utils.generate import generate_sample_file
|
||||
from cli_anything.nslogger.core.parser import parse_raw_file
|
||||
from cli_anything.nslogger.core.filter import filter_messages
|
||||
from cli_anything.nslogger.core.stats import compute_stats
|
||||
from cli_anything.nslogger.core.exporter import export_messages
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_cli(name: str) -> list[str]:
|
||||
"""Return argv prefix for the CLI, respecting test-mode env var."""
|
||||
if os.environ.get("CLI_ANYTHING_FORCE_INSTALLED"):
|
||||
return [name]
|
||||
# When not installed, run via python -m
|
||||
return [sys.executable, "-m", f"cli_anything.nslogger.nslogger_cli"]
|
||||
|
||||
|
||||
def run_cli(*args, expect_ok=True) -> subprocess.CompletedProcess:
|
||||
cmd = _resolve_cli("cli-anything-nslogger") + list(args)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if expect_ok and result.returncode != 0:
|
||||
pytest.fail(f"CLI failed:\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}")
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sample_file(tmp_path_factory):
|
||||
path = str(tmp_path_factory.mktemp("data") / "sample.rawnsloggerdata")
|
||||
generate_sample_file(path, count=30)
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGenerateCommand:
|
||||
def test_generate_creates_file(self, tmp_path):
|
||||
out = str(tmp_path / "gen.rawnsloggerdata")
|
||||
result = run_cli("generate", out, "--count", "10")
|
||||
assert os.path.exists(out)
|
||||
assert os.path.getsize(out) > 0
|
||||
|
||||
def test_generate_output_mentions_count(self, tmp_path):
|
||||
out = str(tmp_path / "gen.rawnsloggerdata")
|
||||
result = run_cli("generate", out, "--count", "10")
|
||||
assert "10" in result.stdout
|
||||
|
||||
def test_generate_parseable(self, tmp_path):
|
||||
out = str(tmp_path / "gen.rawnsloggerdata")
|
||||
run_cli("generate", out, "--count", "15")
|
||||
msgs = list(parse_raw_file(out))
|
||||
assert len(msgs) >= 15
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# read command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReadCommand:
|
||||
def test_read_outputs_messages(self, sample_file):
|
||||
result = run_cli("read", sample_file)
|
||||
lines = [l for l in result.stdout.strip().splitlines() if l]
|
||||
assert len(lines) > 0
|
||||
|
||||
def test_read_json_valid(self, sample_file):
|
||||
result = run_cli("read", sample_file, "--json")
|
||||
data = json.loads(result.stdout)
|
||||
assert isinstance(data, list)
|
||||
assert len(data) > 0
|
||||
|
||||
def test_read_json_message_shape(self, sample_file):
|
||||
result = run_cli("read", sample_file, "--json")
|
||||
data = json.loads(result.stdout)
|
||||
msg = data[0]
|
||||
for key in ("sequence", "level", "level_name", "type", "text"):
|
||||
assert key in msg
|
||||
|
||||
def test_read_level_filter(self, sample_file):
|
||||
result = run_cli("read", sample_file, "--level", "0", "--json")
|
||||
data = json.loads(result.stdout)
|
||||
assert all(m["level"] <= 0 for m in data)
|
||||
|
||||
def test_read_limit(self, sample_file):
|
||||
result = run_cli("read", sample_file, "--limit", "5", "--json")
|
||||
data = json.loads(result.stdout)
|
||||
assert len(data) <= 5
|
||||
|
||||
def test_read_search(self, sample_file):
|
||||
result = run_cli("read", sample_file, "--search", "error", "--json")
|
||||
data = json.loads(result.stdout)
|
||||
for m in data:
|
||||
assert "error" in m["text"].lower() or m["level"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# filter command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFilterCommand:
|
||||
def test_filter_by_level(self, sample_file):
|
||||
result = run_cli("filter", sample_file, "--level", "1", "--json")
|
||||
data = json.loads(result.stdout)
|
||||
assert all(m["level"] <= 1 for m in data)
|
||||
|
||||
def test_filter_no_results(self, sample_file, tmp_path):
|
||||
# Generate file with no level-99 messages
|
||||
out = str(tmp_path / "g.rawnsloggerdata")
|
||||
generate_sample_file(out, count=5)
|
||||
result = run_cli("filter", out, "--search", "XYZZY_NEVER_MATCHES_ANYTHING")
|
||||
assert result.stdout.strip() == ""
|
||||
|
||||
def test_filter_regex(self, sample_file):
|
||||
result = run_cli("filter", sample_file, "--regex", r"(error|failed)", "--json")
|
||||
data = json.loads(result.stdout)
|
||||
import re
|
||||
pattern = re.compile(r"(error|failed)", re.IGNORECASE)
|
||||
for m in data:
|
||||
assert pattern.search(m["text"]), f"No match in: {m['text']!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# export command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExportCommand:
|
||||
def test_export_text_stdout(self, sample_file):
|
||||
result = run_cli("export", sample_file, "--format", "text")
|
||||
assert len(result.stdout) > 0
|
||||
|
||||
def test_export_json_stdout(self, sample_file):
|
||||
result = run_cli("export", sample_file, "--format", "json")
|
||||
data = json.loads(result.stdout)
|
||||
assert isinstance(data, list)
|
||||
|
||||
def test_export_csv_stdout(self, sample_file):
|
||||
result = run_cli("export", sample_file, "--format", "csv")
|
||||
lines = result.stdout.strip().splitlines()
|
||||
assert "sequence" in lines[0]
|
||||
assert len(lines) > 1
|
||||
|
||||
def test_export_to_file(self, sample_file, tmp_path):
|
||||
out = str(tmp_path / "export.json")
|
||||
run_cli("export", sample_file, "--format", "json", "--output", out)
|
||||
assert os.path.exists(out)
|
||||
with open(out) as f:
|
||||
data = json.load(f)
|
||||
assert isinstance(data, list)
|
||||
|
||||
def test_export_with_level_filter(self, sample_file):
|
||||
result = run_cli("export", sample_file, "--format", "json", "--level", "1")
|
||||
data = json.loads(result.stdout)
|
||||
assert all(m["level"] <= 1 for m in data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stats command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStatsCommand:
|
||||
def test_stats_text_output(self, sample_file):
|
||||
result = run_cli("stats", sample_file)
|
||||
assert "Total" in result.stdout or "total" in result.stdout.lower()
|
||||
|
||||
def test_stats_json_output(self, sample_file):
|
||||
result = run_cli("stats", sample_file, "--json")
|
||||
data = json.loads(result.stdout)
|
||||
assert "total" in data
|
||||
assert data["total"] > 0
|
||||
|
||||
def test_stats_json_has_by_level(self, sample_file):
|
||||
result = run_cli("stats", sample_file, "--json")
|
||||
data = json.loads(result.stdout)
|
||||
assert "by_level" in data
|
||||
|
||||
def test_stats_json_has_by_tag(self, sample_file):
|
||||
result = run_cli("stats", sample_file, "--json")
|
||||
data = json.loads(result.stdout)
|
||||
assert "by_tag" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full pipeline workflow test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWorkflow:
|
||||
def test_generate_filter_export_pipeline(self, tmp_path):
|
||||
"""Generate → filter errors → export JSON."""
|
||||
log_file = str(tmp_path / "app.rawnsloggerdata")
|
||||
generate_sample_file(log_file, count=50)
|
||||
|
||||
msgs = list(parse_raw_file(log_file))
|
||||
assert len(msgs) > 0
|
||||
|
||||
errors = list(filter_messages(iter(msgs), max_level=0))
|
||||
assert isinstance(errors, list)
|
||||
|
||||
out = export_messages(iter(errors), fmt="json")
|
||||
data = json.loads(out)
|
||||
for m in data:
|
||||
assert m["level"] <= 0
|
||||
|
||||
def test_stats_on_generated_file(self, tmp_path):
|
||||
log_file = str(tmp_path / "app.rawnsloggerdata")
|
||||
generate_sample_file(log_file, count=40)
|
||||
msgs = list(parse_raw_file(log_file))
|
||||
s = compute_stats(iter(msgs))
|
||||
assert s["total"] >= 40
|
||||
assert "by_level" in s
|
||||
assert "by_tag" in s
|
||||
|
||||
def test_cli_help_shows_commands(self):
|
||||
result = run_cli("--help")
|
||||
for cmd in ("read", "filter", "export", "stats", "listen", "generate"):
|
||||
assert cmd in result.stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCLISubprocess — installed entrypoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCLISubprocess:
|
||||
def test_installed_cli_help(self):
|
||||
cmd = _resolve_cli("cli-anything-nslogger") + ["--help"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert result.returncode == 0
|
||||
assert "NSLogger" in result.stdout
|
||||
|
||||
def test_installed_generate_and_read(self, tmp_path):
|
||||
out = str(tmp_path / "sub.rawnsloggerdata")
|
||||
run_cli("generate", out, "--count", "5")
|
||||
result = run_cli("read", out, "--json")
|
||||
data = json.loads(result.stdout)
|
||||
assert len(data) >= 5
|
||||
|
||||
def test_installed_stats_json(self, tmp_path):
|
||||
out = str(tmp_path / "sub2.rawnsloggerdata")
|
||||
run_cli("generate", out, "--count", "8")
|
||||
result = run_cli("stats", out, "--json")
|
||||
data = json.loads(result.stdout)
|
||||
assert data["total"] >= 8
|
||||
|
||||
def test_installed_export_csv(self, tmp_path):
|
||||
out = str(tmp_path / "sub3.rawnsloggerdata")
|
||||
run_cli("generate", out, "--count", "5")
|
||||
result = run_cli("export", out, "--format", "csv")
|
||||
assert "sequence" in result.stdout.splitlines()[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tail command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTailCommand:
|
||||
def test_tail_returns_last_n(self, tmp_path):
|
||||
out = str(tmp_path / "tail.rawnsloggerdata")
|
||||
run_cli("generate", out, "--count", "20")
|
||||
result_all = run_cli("read", out, "--json")
|
||||
result_tail = run_cli("tail", out, "--count", "5", "--json")
|
||||
all_msgs = json.loads(result_all.stdout)
|
||||
tail_msgs = json.loads(result_tail.stdout)
|
||||
assert len(tail_msgs) == 5
|
||||
# Last 5 of all should match tail
|
||||
assert [m["sequence"] for m in tail_msgs] == [m["sequence"] for m in all_msgs[-5:]]
|
||||
|
||||
def test_tail_default_count(self, tmp_path):
|
||||
out = str(tmp_path / "tail2.rawnsloggerdata")
|
||||
run_cli("generate", out, "--count", "30")
|
||||
result = run_cli("tail", out, "--json")
|
||||
data = json.loads(result.stdout)
|
||||
assert len(data) == 20 # default is 20
|
||||
|
||||
def test_tail_text_output(self, tmp_path):
|
||||
out = str(tmp_path / "tail3.rawnsloggerdata")
|
||||
run_cli("generate", out, "--count", "5")
|
||||
result = run_cli("tail", out)
|
||||
assert len(result.stdout.strip().splitlines()) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# clients command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestClientsCommand:
|
||||
def test_clients_json_output(self, sample_file):
|
||||
result = run_cli("clients", sample_file, "--json")
|
||||
data = json.loads(result.stdout)
|
||||
assert isinstance(data, list)
|
||||
# sample file should have at least one client_info
|
||||
if data:
|
||||
assert "client_name" in data[0]
|
||||
|
||||
def test_clients_text_output(self, sample_file):
|
||||
result = run_cli("clients", sample_file)
|
||||
assert result.returncode == 0 # may output "No client_info" or real clients
|
||||
|
||||
def test_clients_empty_file(self, tmp_path):
|
||||
# File with only plain log messages, no client_info
|
||||
from cli_anything.nslogger.utils.generate import encode_message
|
||||
path = str(tmp_path / "no_clients.rawnsloggerdata")
|
||||
with open(path, "wb") as f:
|
||||
f.write(encode_message(sequence=0, text="plain log"))
|
||||
result = run_cli("clients", path)
|
||||
assert result.returncode == 0
|
||||
assert "No client_info" in result.stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# blocks command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBlocksCommand:
|
||||
def test_blocks_text_output(self, sample_file):
|
||||
result = run_cli("blocks", sample_file)
|
||||
assert result.returncode == 0
|
||||
assert len(result.stdout.strip()) > 0
|
||||
|
||||
def test_blocks_json_output(self, sample_file):
|
||||
result = run_cli("blocks", sample_file, "--json")
|
||||
data = json.loads(result.stdout)
|
||||
assert isinstance(data, list)
|
||||
for entry in data:
|
||||
assert "depth" in entry
|
||||
assert "sequence" in entry
|
||||
|
||||
def test_blocks_indent_applied(self, tmp_path):
|
||||
from cli_anything.nslogger.utils.generate import encode_message
|
||||
from cli_anything.nslogger.core.message import MSG_TYPE_BLOCK_START, MSG_TYPE_BLOCK_END
|
||||
path = str(tmp_path / "blk.rawnsloggerdata")
|
||||
with open(path, "wb") as f:
|
||||
f.write(encode_message(sequence=0, text="before"))
|
||||
f.write(encode_message(sequence=1, msg_type=MSG_TYPE_BLOCK_START, text="enter"))
|
||||
f.write(encode_message(sequence=2, text="inside"))
|
||||
f.write(encode_message(sequence=3, msg_type=MSG_TYPE_BLOCK_END, text="exit"))
|
||||
result = run_cli("blocks", path, "--indent", "4")
|
||||
lines = result.stdout.splitlines()
|
||||
# "inside" should be indented (4 spaces)
|
||||
inside_lines = [l for l in lines if "inside" in l]
|
||||
assert inside_lines and inside_lines[0].startswith(" ")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# merge command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMergeCommand:
|
||||
def test_merge_two_files(self, tmp_path):
|
||||
a = str(tmp_path / "a.rawnsloggerdata")
|
||||
b = str(tmp_path / "b.rawnsloggerdata")
|
||||
run_cli("generate", a, "--count", "5")
|
||||
run_cli("generate", b, "--count", "5")
|
||||
result = run_cli("merge", a, b, "--format", "json")
|
||||
data = json.loads(result.stdout)
|
||||
assert len(data) >= 10
|
||||
|
||||
def test_merge_to_file(self, tmp_path):
|
||||
a = str(tmp_path / "c.rawnsloggerdata")
|
||||
b = str(tmp_path / "d.rawnsloggerdata")
|
||||
out = str(tmp_path / "merged.json")
|
||||
run_cli("generate", a, "--count", "5")
|
||||
run_cli("generate", b, "--count", "5")
|
||||
run_cli("merge", a, b, "--format", "json", "--output", out)
|
||||
assert os.path.exists(out)
|
||||
with open(out) as f:
|
||||
data = json.load(f)
|
||||
assert len(data) >= 10
|
||||
|
||||
def test_merge_csv_format(self, tmp_path):
|
||||
a = str(tmp_path / "e.rawnsloggerdata")
|
||||
run_cli("generate", a, "--count", "5")
|
||||
result = run_cli("merge", a, "--format", "csv")
|
||||
assert "sequence" in result.stdout.splitlines()[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# filter — new options (time-range, seq-range)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFilterExtendedOptions:
|
||||
def test_filter_from_seq(self, tmp_path):
|
||||
out = str(tmp_path / "seqtest.rawnsloggerdata")
|
||||
run_cli("generate", out, "--count", "10")
|
||||
all_data = json.loads(run_cli("read", out, "--json").stdout)
|
||||
mid_seq = all_data[5]["sequence"]
|
||||
result = run_cli("filter", out, "--from-seq", str(mid_seq), "--json")
|
||||
data = json.loads(result.stdout)
|
||||
assert all(m["sequence"] >= mid_seq for m in data)
|
||||
|
||||
def test_filter_to_seq(self, tmp_path):
|
||||
out = str(tmp_path / "seqtest2.rawnsloggerdata")
|
||||
run_cli("generate", out, "--count", "10")
|
||||
all_data = json.loads(run_cli("read", out, "--json").stdout)
|
||||
mid_seq = all_data[5]["sequence"]
|
||||
result = run_cli("filter", out, "--to-seq", str(mid_seq), "--json")
|
||||
data = json.loads(result.stdout)
|
||||
assert all(m["sequence"] <= mid_seq for m in data)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Generate sample .rawnsloggerdata files for testing."""
|
||||
from __future__ import annotations
|
||||
import struct
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
from ..core.message import (
|
||||
PART_KEY_MESSAGE_TYPE, PART_KEY_TIMESTAMP_S, PART_KEY_TIMESTAMP_MS,
|
||||
PART_KEY_THREAD_ID, PART_KEY_TAG, PART_KEY_LEVEL, PART_KEY_MESSAGE,
|
||||
PART_KEY_IMAGE_WIDTH, PART_KEY_IMAGE_HEIGHT, PART_KEY_MESSAGE_SEQ,
|
||||
PART_KEY_CLIENT_NAME, PART_KEY_CLIENT_VERSION,
|
||||
PART_KEY_OS_NAME, PART_KEY_OS_VERSION, PART_KEY_CLIENT_MODEL,
|
||||
PART_TYPE_STRING, PART_TYPE_INT16, PART_TYPE_INT32,
|
||||
)
|
||||
|
||||
|
||||
def _encode_string_part(key: int, value: str) -> bytes:
|
||||
encoded = value.encode("utf-8")
|
||||
return bytes([key, PART_TYPE_STRING]) + struct.pack(">I", len(encoded)) + encoded
|
||||
|
||||
|
||||
def _encode_int32_part(key: int, value: int) -> bytes:
|
||||
return bytes([key, PART_TYPE_INT32]) + struct.pack(">I", value & 0xFFFFFFFF)
|
||||
|
||||
|
||||
def _encode_int16_part(key: int, value: int) -> bytes:
|
||||
return bytes([key, PART_TYPE_INT16]) + struct.pack(">H", value & 0xFFFF)
|
||||
|
||||
|
||||
def encode_message(
|
||||
sequence: int,
|
||||
msg_type: int = 0,
|
||||
timestamp: Optional[float] = None,
|
||||
thread_id: str = "main",
|
||||
tag: str = "",
|
||||
level: int = 2,
|
||||
text: str = "",
|
||||
client_name: str = "",
|
||||
client_version: str = "",
|
||||
os_name: str = "",
|
||||
os_version: str = "",
|
||||
machine: str = "",
|
||||
) -> bytes:
|
||||
if timestamp is None:
|
||||
timestamp = time.time()
|
||||
|
||||
parts = b""
|
||||
parts += _encode_int32_part(PART_KEY_MESSAGE_TYPE, msg_type) # message type
|
||||
parts += _encode_int32_part(PART_KEY_TIMESTAMP_S, int(timestamp)) # timestamp seconds
|
||||
ms = int((timestamp - int(timestamp)) * 1000)
|
||||
parts += _encode_int16_part(PART_KEY_TIMESTAMP_MS, ms) # timestamp ms
|
||||
if thread_id:
|
||||
parts += _encode_string_part(PART_KEY_THREAD_ID, thread_id)
|
||||
if tag:
|
||||
parts += _encode_string_part(PART_KEY_TAG, tag)
|
||||
parts += _encode_int16_part(PART_KEY_LEVEL, level) # level
|
||||
parts += _encode_int32_part(PART_KEY_MESSAGE_SEQ, sequence)
|
||||
if text:
|
||||
parts += _encode_string_part(PART_KEY_MESSAGE, text)
|
||||
if client_name:
|
||||
parts += _encode_string_part(PART_KEY_CLIENT_NAME, client_name)
|
||||
if client_version:
|
||||
parts += _encode_string_part(PART_KEY_CLIENT_VERSION, client_version)
|
||||
if os_name:
|
||||
parts += _encode_string_part(PART_KEY_OS_NAME, os_name)
|
||||
if os_version:
|
||||
parts += _encode_string_part(PART_KEY_OS_VERSION, os_version)
|
||||
if machine:
|
||||
parts += _encode_string_part(PART_KEY_CLIENT_MODEL, machine)
|
||||
|
||||
# Count parts actually encoded. Integer part sizes are implicit in the
|
||||
# official NSLogger protocol, variable-size parts carry a 4-byte length.
|
||||
part_count = 0
|
||||
offset = 0
|
||||
temp = parts
|
||||
while offset < len(temp):
|
||||
if offset + 2 > len(temp):
|
||||
break
|
||||
part_type = temp[offset + 1]
|
||||
offset += 2
|
||||
if part_type == PART_TYPE_INT16:
|
||||
offset += 2
|
||||
elif part_type == PART_TYPE_INT32:
|
||||
offset += 4
|
||||
else:
|
||||
if offset + 4 > len(temp):
|
||||
break
|
||||
part_len = struct.unpack(">I", temp[offset:offset + 4])[0]
|
||||
offset += 4 + part_len
|
||||
part_count += 1
|
||||
|
||||
body = struct.pack(">H", part_count) + parts
|
||||
return struct.pack(">I", len(body)) + body
|
||||
|
||||
|
||||
def generate_sample_file(path: str, count: int = 20):
|
||||
"""Write a sample .rawnsloggerdata file with synthetic messages."""
|
||||
import random
|
||||
|
||||
tags = ["Network", "UI", "Database", "Auth", "Cache", ""]
|
||||
levels = [0, 0, 1, 1, 2, 2, 2, 3, 3, 4]
|
||||
threads = ["main", "main", "background", "network-queue", "io-queue"]
|
||||
base_ts = time.time() - count
|
||||
|
||||
messages_text = [
|
||||
"Starting application",
|
||||
"Fetching user data from API",
|
||||
"User authenticated successfully",
|
||||
"Cache miss for key: user_profile",
|
||||
"Database query took 45ms",
|
||||
"Network request failed: timeout",
|
||||
"Retry attempt 1/3",
|
||||
"View did appear: HomeViewController",
|
||||
"Decoding JSON response",
|
||||
"Background sync completed",
|
||||
"Memory warning received",
|
||||
"Connection pool exhausted",
|
||||
"SSL handshake completed",
|
||||
"Pushing notification",
|
||||
"Saving to CoreData",
|
||||
"Failed to parse response body",
|
||||
"Token refreshed successfully",
|
||||
"WebSocket connected",
|
||||
"User tapped login button",
|
||||
"App did enter background",
|
||||
]
|
||||
|
||||
with open(path, "wb") as f:
|
||||
# Write client info message first
|
||||
f.write(encode_message(
|
||||
sequence=0,
|
||||
msg_type=3, # client info
|
||||
timestamp=base_ts,
|
||||
client_name="SampleApp",
|
||||
client_version="1.0.0",
|
||||
os_name="iOS",
|
||||
os_version="17.0",
|
||||
machine="iPhone15,2",
|
||||
))
|
||||
for i in range(1, count + 1):
|
||||
ts = base_ts + i * 0.5 + random.uniform(0, 0.4)
|
||||
text = messages_text[i % len(messages_text)]
|
||||
f.write(encode_message(
|
||||
sequence=i,
|
||||
msg_type=0,
|
||||
timestamp=ts,
|
||||
thread_id=random.choice(threads),
|
||||
tag=random.choice(tags),
|
||||
level=random.choice(levels),
|
||||
text=text,
|
||||
))
|
||||
@@ -0,0 +1,567 @@
|
||||
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
|
||||
|
||||
Copy this file into your CLI package at:
|
||||
cli_anything/<software>/utils/repl_skin.py
|
||||
|
||||
Usage:
|
||||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("shotcut", version="1.0.0")
|
||||
skin.print_banner() # auto-detects repo-root or packaged SKILL.md
|
||||
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
|
||||
skin.success("Project saved")
|
||||
skin.error("File not found")
|
||||
skin.warning("Unsaved changes")
|
||||
skin.info("Processing 24 clips...")
|
||||
skin.status("Track 1", "3 clips, 00:02:30")
|
||||
skin.table(headers, rows)
|
||||
skin.print_goodbye()
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── ANSI color codes (no external deps for core styling) ──────────────
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_BOLD = "\033[1m"
|
||||
_DIM = "\033[2m"
|
||||
_ITALIC = "\033[3m"
|
||||
_UNDERLINE = "\033[4m"
|
||||
|
||||
# Brand colors
|
||||
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||||
_CYAN_BG = "\033[48;5;80m"
|
||||
_WHITE = "\033[97m"
|
||||
_GRAY = "\033[38;5;245m"
|
||||
_DARK_GRAY = "\033[38;5;240m"
|
||||
_LIGHT_GRAY = "\033[38;5;250m"
|
||||
|
||||
# Software accent colors — each software gets a unique accent
|
||||
_ACCENT_COLORS = {
|
||||
"gimp": "\033[38;5;214m", # warm orange
|
||||
"blender": "\033[38;5;208m", # deep orange
|
||||
"inkscape": "\033[38;5;39m", # bright blue
|
||||
"audacity": "\033[38;5;33m", # navy blue
|
||||
"libreoffice": "\033[38;5;40m", # green
|
||||
"obs_studio": "\033[38;5;55m", # purple
|
||||
"kdenlive": "\033[38;5;69m", # slate blue
|
||||
"shotcut": "\033[38;5;35m", # teal green
|
||||
}
|
||||
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||
|
||||
# Status colors
|
||||
_GREEN = "\033[38;5;78m"
|
||||
_YELLOW = "\033[38;5;220m"
|
||||
_RED = "\033[38;5;196m"
|
||||
_BLUE = "\033[38;5;75m"
|
||||
_MAGENTA = "\033[38;5;176m"
|
||||
|
||||
_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything")
|
||||
|
||||
# ── Brand icon ────────────────────────────────────────────────────────
|
||||
|
||||
# The cli-anything icon: a small colored diamond/chevron mark
|
||||
_ICON = f"{_CYAN}{_BOLD}◆{_RESET}"
|
||||
_ICON_SMALL = f"{_CYAN}▸{_RESET}"
|
||||
|
||||
# ── Box drawing characters ────────────────────────────────────────────
|
||||
|
||||
_H_LINE = "─"
|
||||
_V_LINE = "│"
|
||||
_TL = "╭"
|
||||
_TR = "╮"
|
||||
_BL = "╰"
|
||||
_BR = "╯"
|
||||
_T_DOWN = "┬"
|
||||
_T_UP = "┴"
|
||||
_T_RIGHT = "├"
|
||||
_T_LEFT = "┤"
|
||||
_CROSS = "┼"
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes for length calculation."""
|
||||
import re
|
||||
return re.sub(r"\033\[[^m]*m", "", text)
|
||||
|
||||
|
||||
def _visible_len(text: str) -> int:
|
||||
"""Get visible length of text (excluding ANSI codes)."""
|
||||
return len(_strip_ansi(text))
|
||||
|
||||
|
||||
def _display_home_path(path: str) -> str:
|
||||
"""Display a path relative to the home directory when possible."""
|
||||
expanded = Path(path).expanduser().resolve()
|
||||
home = Path.home().resolve()
|
||||
try:
|
||||
relative = expanded.relative_to(home)
|
||||
return f"~/{relative.as_posix()}"
|
||||
except ValueError:
|
||||
return str(expanded)
|
||||
|
||||
|
||||
class ReplSkin:
|
||||
"""Unified REPL skin for cli-anything CLIs.
|
||||
|
||||
Provides consistent branding, prompts, and message formatting
|
||||
across all CLI harnesses built with the cli-anything methodology.
|
||||
"""
|
||||
|
||||
def __init__(self, software: str, version: str = "1.0.0",
|
||||
history_file: str | None = None, skill_path: str | None = None):
|
||||
"""Initialize the REPL skin.
|
||||
|
||||
Args:
|
||||
software: Software name (e.g., "gimp", "shotcut", "blender").
|
||||
version: CLI version string.
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/history
|
||||
skill_path: Path to the SKILL.md file for agent discovery.
|
||||
Auto-detected from the repo-root skills/ tree when present,
|
||||
otherwise from the package's skills/ directory.
|
||||
Displayed in banner for AI agents to know where to read skill info.
|
||||
"""
|
||||
self.software = software.lower().replace("-", "_")
|
||||
self.display_name = software.replace("_", " ").title()
|
||||
self.version = version
|
||||
software_aliases = {"iterm2_ctl": "iterm2"}
|
||||
self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-")
|
||||
self.skill_id = f"cli-anything-{self.skill_slug}"
|
||||
self.skill_install_cmd = (
|
||||
f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y"
|
||||
)
|
||||
global_skill_root = Path(
|
||||
os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills"))
|
||||
).expanduser()
|
||||
self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md")
|
||||
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/skills/SKILL.md for installed harnesses.
|
||||
if skill_path is None:
|
||||
package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
|
||||
repo_skill = None
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
candidate = parent / "skills" / self.skill_id / "SKILL.md"
|
||||
if candidate.is_file():
|
||||
repo_skill = candidate
|
||||
break
|
||||
if repo_skill and repo_skill.is_file():
|
||||
skill_path = str(repo_skill)
|
||||
elif package_skill.is_file():
|
||||
skill_path = str(package_skill)
|
||||
self.skill_path = skill_path
|
||||
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||||
|
||||
# History file
|
||||
if history_file is None:
|
||||
hist_dir = Path.home() / f".cli-anything-{self.software}"
|
||||
hist_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.history_file = str(hist_dir / "history")
|
||||
else:
|
||||
self.history_file = history_file
|
||||
|
||||
# Detect terminal capabilities
|
||||
self._color = self._detect_color_support()
|
||||
|
||||
def _detect_color_support(self) -> bool:
|
||||
"""Check if terminal supports color."""
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
|
||||
return False
|
||||
if not hasattr(sys.stdout, "isatty"):
|
||||
return False
|
||||
return sys.stdout.isatty()
|
||||
|
||||
def _c(self, code: str, text: str) -> str:
|
||||
"""Apply color code if colors are supported."""
|
||||
if not self._color:
|
||||
return text
|
||||
return f"{code}{text}{_RESET}"
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────────
|
||||
|
||||
def print_banner(self):
|
||||
"""Print the startup banner with branding."""
|
||||
import textwrap
|
||||
|
||||
inner = 72
|
||||
|
||||
def _box_line(content: str) -> str:
|
||||
"""Wrap content in box drawing, padding to inner width."""
|
||||
pad = inner - _visible_len(content)
|
||||
vl = self._c(_DARK_GRAY, _V_LINE)
|
||||
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
|
||||
|
||||
def _meta_lines(label: str, value: str) -> list[str]:
|
||||
"""Wrap a metadata line for the banner box."""
|
||||
icon = self._c(_MAGENTA, "◇")
|
||||
label_text = self._c(_DARK_GRAY, label)
|
||||
prefix = f" {icon} {label_text} "
|
||||
available = max(12, inner - _visible_len(prefix))
|
||||
wrapped = textwrap.wrap(
|
||||
value,
|
||||
width=available,
|
||||
break_long_words=True,
|
||||
break_on_hyphens=False,
|
||||
) or [""]
|
||||
lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"]
|
||||
continuation_prefix = " " * _visible_len(prefix)
|
||||
for chunk in wrapped[1:]:
|
||||
lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}")
|
||||
return lines
|
||||
|
||||
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
|
||||
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
|
||||
|
||||
# Title: ◆ cli-anything · Shotcut
|
||||
icon = self._c(_CYAN + _BOLD, "◆")
|
||||
brand = self._c(_CYAN + _BOLD, "cli-anything")
|
||||
dot = self._c(_DARK_GRAY, "·")
|
||||
name = self._c(self.accent + _BOLD, self.display_name)
|
||||
title = f" {icon} {brand} {dot} {name}"
|
||||
|
||||
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
|
||||
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
|
||||
empty = ""
|
||||
|
||||
meta_lines: list[str] = []
|
||||
meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd))
|
||||
meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path)))
|
||||
print(top)
|
||||
print(_box_line(title))
|
||||
print(_box_line(ver))
|
||||
for line in meta_lines:
|
||||
print(_box_line(line))
|
||||
print(_box_line(empty))
|
||||
print(_box_line(tip))
|
||||
print(bot)
|
||||
print()
|
||||
|
||||
# ── Prompt ────────────────────────────────────────────────────────
|
||||
|
||||
def prompt(self, project_name: str = "", modified: bool = False,
|
||||
context: str = "") -> str:
|
||||
"""Build a styled prompt string for prompt_toolkit or input().
|
||||
|
||||
Args:
|
||||
project_name: Current project name (empty if none open).
|
||||
modified: Whether the project has unsaved changes.
|
||||
context: Optional extra context to show in prompt.
|
||||
|
||||
Returns:
|
||||
Formatted prompt string.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Icon
|
||||
if self._color:
|
||||
parts.append(f"{_CYAN}◆{_RESET} ")
|
||||
else:
|
||||
parts.append("> ")
|
||||
|
||||
# Software name
|
||||
parts.append(self._c(self.accent + _BOLD, self.software))
|
||||
|
||||
# Project context
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
parts.append(f" {self._c(_DARK_GRAY, '[')}")
|
||||
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
|
||||
parts.append(self._c(_DARK_GRAY, ']'))
|
||||
|
||||
parts.append(self._c(_GRAY, " ❯ "))
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def prompt_tokens(self, project_name: str = "", modified: bool = False,
|
||||
context: str = ""):
|
||||
"""Build prompt_toolkit formatted text tokens for the prompt.
|
||||
|
||||
Use with prompt_toolkit's FormattedText for proper ANSI handling.
|
||||
|
||||
Returns:
|
||||
list of (style, text) tuples for prompt_toolkit.
|
||||
"""
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
tokens = []
|
||||
|
||||
tokens.append(("class:icon", "◆ "))
|
||||
tokens.append(("class:software", self.software))
|
||||
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
tokens.append(("class:bracket", " ["))
|
||||
tokens.append(("class:context", f"{ctx}{mod}"))
|
||||
tokens.append(("class:bracket", "]"))
|
||||
|
||||
tokens.append(("class:arrow", " ❯ "))
|
||||
|
||||
return tokens
|
||||
|
||||
def get_prompt_style(self):
|
||||
"""Get a prompt_toolkit Style object matching the skin.
|
||||
|
||||
Returns:
|
||||
prompt_toolkit.styles.Style
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.styles import Style
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
|
||||
return Style.from_dict({
|
||||
"icon": "#5fdfdf bold", # cyan brand color
|
||||
"software": f"{accent_hex} bold",
|
||||
"bracket": "#585858",
|
||||
"context": "#bcbcbc",
|
||||
"arrow": "#808080",
|
||||
# Completion menu
|
||||
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||||
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||||
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||||
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||||
# Auto-suggest
|
||||
"auto-suggest": "#585858",
|
||||
# Bottom toolbar
|
||||
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||||
"bottom-toolbar.text": "#808080",
|
||||
})
|
||||
|
||||
# ── Messages ──────────────────────────────────────────────────────
|
||||
|
||||
def success(self, message: str):
|
||||
"""Print a success message with green checkmark."""
|
||||
icon = self._c(_GREEN + _BOLD, "✓")
|
||||
print(f" {icon} {self._c(_GREEN, message)}")
|
||||
|
||||
def error(self, message: str):
|
||||
"""Print an error message with red cross."""
|
||||
icon = self._c(_RED + _BOLD, "✗")
|
||||
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
|
||||
|
||||
def warning(self, message: str):
|
||||
"""Print a warning message with yellow triangle."""
|
||||
icon = self._c(_YELLOW + _BOLD, "⚠")
|
||||
print(f" {icon} {self._c(_YELLOW, message)}")
|
||||
|
||||
def info(self, message: str):
|
||||
"""Print an info message with blue dot."""
|
||||
icon = self._c(_BLUE, "●")
|
||||
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
|
||||
|
||||
def hint(self, message: str):
|
||||
"""Print a subtle hint message."""
|
||||
print(f" {self._c(_DARK_GRAY, message)}")
|
||||
|
||||
def section(self, title: str):
|
||||
"""Print a section header."""
|
||||
print()
|
||||
print(f" {self._c(self.accent + _BOLD, title)}")
|
||||
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
|
||||
|
||||
# ── Status display ────────────────────────────────────────────────
|
||||
|
||||
def status(self, label: str, value: str):
|
||||
"""Print a key-value status line."""
|
||||
lbl = self._c(_GRAY, f" {label}:")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def status_block(self, items: dict[str, str], title: str = ""):
|
||||
"""Print a block of status key-value pairs.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs.
|
||||
title: Optional title for the block.
|
||||
"""
|
||||
if title:
|
||||
self.section(title)
|
||||
|
||||
max_key = max(len(k) for k in items) if items else 0
|
||||
for label, value in items.items():
|
||||
lbl = self._c(_GRAY, f" {label:<{max_key}}")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def progress(self, current: int, total: int, label: str = ""):
|
||||
"""Print a simple progress indicator.
|
||||
|
||||
Args:
|
||||
current: Current step number.
|
||||
total: Total number of steps.
|
||||
label: Optional label for the progress.
|
||||
"""
|
||||
pct = int(current / total * 100) if total > 0 else 0
|
||||
bar_width = 20
|
||||
filled = int(bar_width * current / total) if total > 0 else 0
|
||||
bar = "█" * filled + "░" * (bar_width - filled)
|
||||
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
|
||||
if label:
|
||||
text += f" {self._c(_LIGHT_GRAY, label)}"
|
||||
print(text)
|
||||
|
||||
# ── Table display ─────────────────────────────────────────────────
|
||||
|
||||
def table(self, headers: list[str], rows: list[list[str]],
|
||||
max_col_width: int = 40):
|
||||
"""Print a formatted table with box-drawing characters.
|
||||
|
||||
Args:
|
||||
headers: Column header strings.
|
||||
rows: List of rows, each a list of cell strings.
|
||||
max_col_width: Maximum column width before truncation.
|
||||
"""
|
||||
if not headers:
|
||||
return
|
||||
|
||||
# Calculate column widths
|
||||
col_widths = [min(len(h), max_col_width) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
col_widths[i] = min(
|
||||
max(col_widths[i], len(str(cell))), max_col_width
|
||||
)
|
||||
|
||||
def pad(text: str, width: int) -> str:
|
||||
t = str(text)[:width]
|
||||
return t + " " * (width - len(t))
|
||||
|
||||
# Header
|
||||
header_cells = [
|
||||
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
|
||||
for i, h in enumerate(headers)
|
||||
]
|
||||
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
header_line = f" {sep.join(header_cells)}"
|
||||
print(header_line)
|
||||
|
||||
# Separator
|
||||
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
|
||||
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
|
||||
print(sep_line)
|
||||
|
||||
# Rows
|
||||
for row in rows:
|
||||
cells = []
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
|
||||
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
print(f" {row_sep.join(cells)}")
|
||||
|
||||
# ── Help display ──────────────────────────────────────────────────
|
||||
|
||||
def help(self, commands: dict[str, str]):
|
||||
"""Print a formatted help listing.
|
||||
|
||||
Args:
|
||||
commands: Dict of command -> description pairs.
|
||||
"""
|
||||
self.section("Commands")
|
||||
max_cmd = max(len(c) for c in commands) if commands else 0
|
||||
for cmd, desc in commands.items():
|
||||
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
|
||||
desc_styled = self._c(_GRAY, f" {desc}")
|
||||
print(f"{cmd_styled}{desc_styled}")
|
||||
print()
|
||||
|
||||
# ── Goodbye ───────────────────────────────────────────────────────
|
||||
|
||||
def print_goodbye(self):
|
||||
"""Print a styled goodbye message."""
|
||||
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
|
||||
|
||||
# ── Prompt toolkit session factory ────────────────────────────────
|
||||
|
||||
def create_prompt_session(self):
|
||||
"""Create a prompt_toolkit PromptSession with skin styling.
|
||||
|
||||
Returns:
|
||||
A configured PromptSession, or None if prompt_toolkit unavailable.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
style = self.get_prompt_style()
|
||||
|
||||
session = PromptSession(
|
||||
history=FileHistory(self.history_file),
|
||||
auto_suggest=AutoSuggestFromHistory(),
|
||||
style=style,
|
||||
enable_history_search=True,
|
||||
)
|
||||
return session
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def get_input(self, pt_session, project_name: str = "",
|
||||
modified: bool = False, context: str = "") -> str:
|
||||
"""Get input from user using prompt_toolkit or fallback.
|
||||
|
||||
Args:
|
||||
pt_session: A prompt_toolkit PromptSession (or None).
|
||||
project_name: Current project name.
|
||||
modified: Whether project has unsaved changes.
|
||||
context: Optional context string.
|
||||
|
||||
Returns:
|
||||
User input string (stripped).
|
||||
"""
|
||||
if pt_session is not None:
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
tokens = self.prompt_tokens(project_name, modified, context)
|
||||
return pt_session.prompt(FormattedText(tokens)).strip()
|
||||
else:
|
||||
raw_prompt = self.prompt(project_name, modified, context)
|
||||
return input(raw_prompt).strip()
|
||||
|
||||
# ── Toolbar builder ───────────────────────────────────────────────
|
||||
|
||||
def bottom_toolbar(self, items: dict[str, str]):
|
||||
"""Create a bottom toolbar callback for prompt_toolkit.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs to show in toolbar.
|
||||
|
||||
Returns:
|
||||
A callable that returns FormattedText for the toolbar.
|
||||
"""
|
||||
def toolbar():
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
parts = []
|
||||
for i, (k, v) in enumerate(items.items()):
|
||||
if i > 0:
|
||||
parts.append(("class:bottom-toolbar.text", " │ "))
|
||||
parts.append(("class:bottom-toolbar.text", f" {k}: "))
|
||||
parts.append(("class:bottom-toolbar", v))
|
||||
return FormattedText(parts)
|
||||
return toolbar
|
||||
|
||||
|
||||
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
|
||||
|
||||
_ANSI_256_TO_HEX = {
|
||||
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||||
"\033[38;5;35m": "#00af5f", # shotcut teal
|
||||
"\033[38;5;39m": "#00afff", # inkscape bright blue
|
||||
"\033[38;5;40m": "#00d700", # libreoffice green
|
||||
"\033[38;5;55m": "#5f00af", # obs purple
|
||||
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
|
||||
"\033[38;5;75m": "#5fafff", # default sky blue
|
||||
"\033[38;5;80m": "#5fd7d7", # brand cyan
|
||||
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=64", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
@@ -0,0 +1,31 @@
|
||||
"""PyPI setup for cli-anything-nslogger."""
|
||||
from setuptools import setup, find_namespace_packages
|
||||
|
||||
setup(
|
||||
name="cli-anything-nslogger",
|
||||
version="0.1.0",
|
||||
description="CLI harness for NSLogger — read, filter, export, and monitor NSLogger log files",
|
||||
long_description=open("cli_anything/nslogger/README.md", encoding="utf-8").read(),
|
||||
long_description_content_type="text/markdown",
|
||||
author="cli-anything",
|
||||
python_requires=">=3.10",
|
||||
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||
package_data={
|
||||
"cli_anything.nslogger": ["helpers/*.swift"],
|
||||
},
|
||||
install_requires=[
|
||||
"click>=8.0",
|
||||
"rich>=13.0",
|
||||
"zeroconf>=0.38.0",
|
||||
],
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"cli-anything-nslogger=cli_anything.nslogger.nslogger_cli:main",
|
||||
],
|
||||
},
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: MacOS",
|
||||
],
|
||||
)
|
||||
@@ -24,6 +24,25 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "nslogger",
|
||||
"display_name": "NSLogger",
|
||||
"version": "0.1.0",
|
||||
"description": "Capture, parse, filter, export, and mirror NSLogger iOS/macOS logs from the CLI",
|
||||
"requires": "macOS for native Bonjour live capture; Python 3.10+",
|
||||
"homepage": "https://github.com/fpillet/NSLogger",
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=nslogger/agent-harness",
|
||||
"entry_point": "cli-anything-nslogger",
|
||||
"skill_md": "skills/cli-anything-nslogger/SKILL.md",
|
||||
"category": "devops",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "bazinga8023",
|
||||
"url": "https://github.com/bazinga8023"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "anygen",
|
||||
"display_name": "AnyGen",
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
---
|
||||
name: cli-anything-nslogger
|
||||
description: CLI harness for NSLogger — parse, filter, export, and monitor NSLogger log files (.rawnsloggerdata / .nsloggerdata)
|
||||
version: 0.1.0
|
||||
install: pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=nslogger/agent-harness
|
||||
binary: cli-anything-nslogger
|
||||
tags: [logging, ios, macos, debugging, nslogger]
|
||||
---
|
||||
|
||||
# cli-anything-nslogger
|
||||
|
||||
A complete CLI harness for [NSLogger](https://github.com/fpillet/NSLogger), the macOS log viewer for iOS/macOS apps.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd nslogger/agent-harness
|
||||
pip install -e .
|
||||
# Verify
|
||||
cli-anything-nslogger --help
|
||||
```
|
||||
|
||||
## Command Reference
|
||||
|
||||
### `generate` — Create sample files for testing
|
||||
|
||||
```bash
|
||||
cli-anything-nslogger generate sample.rawnsloggerdata --count 50
|
||||
```
|
||||
|
||||
### `read` — Display messages from a file
|
||||
|
||||
```bash
|
||||
# All messages
|
||||
cli-anything-nslogger read session.rawnsloggerdata
|
||||
|
||||
# Errors only (level 0)
|
||||
cli-anything-nslogger read session.rawnsloggerdata --level 0
|
||||
|
||||
# Filter by tag and text search
|
||||
cli-anything-nslogger read session.rawnsloggerdata --tag Network --search "timeout"
|
||||
|
||||
# First 20 messages as JSON
|
||||
cli-anything-nslogger read session.rawnsloggerdata --limit 20 --json
|
||||
```
|
||||
|
||||
### `filter` — Advanced filtering
|
||||
|
||||
```bash
|
||||
# Errors and warnings only
|
||||
cli-anything-nslogger filter session.rawnsloggerdata --level 1
|
||||
|
||||
# By tag
|
||||
cli-anything-nslogger filter session.rawnsloggerdata --tag Auth --tag Network
|
||||
|
||||
# Regex search
|
||||
cli-anything-nslogger filter session.rawnsloggerdata --regex "(timeout|failed|error)"
|
||||
|
||||
# By thread
|
||||
cli-anything-nslogger filter session.rawnsloggerdata --thread "main"
|
||||
|
||||
# JSON output
|
||||
cli-anything-nslogger filter session.rawnsloggerdata --level 0 --json
|
||||
```
|
||||
|
||||
### `export` — Export to text/JSON/CSV
|
||||
|
||||
```bash
|
||||
# JSON to stdout
|
||||
cli-anything-nslogger export session.rawnsloggerdata --format json
|
||||
|
||||
# CSV to file
|
||||
cli-anything-nslogger export session.rawnsloggerdata --format csv --output logs.csv
|
||||
|
||||
# Filtered text export
|
||||
cli-anything-nslogger export session.rawnsloggerdata --format text --level 1 --tag Network
|
||||
```
|
||||
|
||||
### `stats` — Summary statistics
|
||||
|
||||
```bash
|
||||
# Human-readable summary
|
||||
cli-anything-nslogger stats session.rawnsloggerdata
|
||||
|
||||
# JSON for agent consumption
|
||||
cli-anything-nslogger stats session.rawnsloggerdata --json
|
||||
```
|
||||
|
||||
JSON output shape:
|
||||
```json
|
||||
{
|
||||
"total": 342,
|
||||
"by_level": {"ERROR": 12, "WARNING": 34, "INFO": 200, "DEBUG": 96},
|
||||
"by_tag": {"Network": 89, "Auth": 45, "UI": 120},
|
||||
"by_thread": {"main": 200, "bg-queue": 142},
|
||||
"by_type": {"text": 340, "client_info": 1, "disconnect": 1},
|
||||
"clients": ["MyApp"],
|
||||
"first_timestamp": "2024-01-01T10:00:00+00:00",
|
||||
"last_timestamp": "2024-01-01T10:05:30+00:00",
|
||||
"duration_seconds": 330.0
|
||||
}
|
||||
```
|
||||
|
||||
### `listen` — Receive live connections
|
||||
|
||||
```bash
|
||||
# Match the NSLogger.app GUI Bonjour behavior for iOS auto-discovery
|
||||
cli-anything-nslogger listen --bonjour --name bazinga --debug
|
||||
|
||||
# Mirror live logs to a text file while still printing stdout
|
||||
cli-anything-nslogger listen --bonjour --name bazinga --output app.log
|
||||
|
||||
# Write machine-readable JSON Lines
|
||||
cli-anything-nslogger listen --bonjour --name bazinga --output app.jsonl --output-format jsonl
|
||||
|
||||
# Direct TCP/TLS mode for manually configured clients
|
||||
cli-anything-nslogger listen --port 50000 --ssl --debug
|
||||
|
||||
# Show only errors while listening, output as JSON stream
|
||||
cli-anything-nslogger listen --bonjour --name bazinga --level 0 --json
|
||||
|
||||
# Run until Ctrl-C
|
||||
cli-anything-nslogger listen --bonjour --name bazinga
|
||||
```
|
||||
|
||||
Use Bonjour mode first for iOS apps because it matches the desktop NSLogger GUI:
|
||||
the CLI publishes a native macOS `NetService` with `_nslogger-ssl._tcp` and
|
||||
accepts TLS NSLogger frames. Use direct TCP/TLS only when the app is manually
|
||||
configured with the Mac host and port.
|
||||
|
||||
### `repl` — Interactive Python REPL
|
||||
|
||||
```bash
|
||||
cli-anything-nslogger repl session.rawnsloggerdata
|
||||
# Available: messages, parse_file, filter_messages, compute_stats, export_messages
|
||||
```
|
||||
|
||||
## Log Levels
|
||||
|
||||
| Value | Name | Use for |
|
||||
|-------|---------|---------|
|
||||
| 0 | ERROR | Unrecoverable failures |
|
||||
| 1 | WARNING | Recoverable issues |
|
||||
| 2 | INFO | Normal operation |
|
||||
| 3 | DEBUG | Developer details |
|
||||
| 4 | VERBOSE | Trace-level noise |
|
||||
|
||||
## Message JSON Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"sequence": 42,
|
||||
"timestamp": "2024-01-01T10:01:23+00:00",
|
||||
"timestamp_ms": 456,
|
||||
"thread_id": "main",
|
||||
"tag": "Network",
|
||||
"level": 0,
|
||||
"level_name": "ERROR",
|
||||
"type": "text",
|
||||
"text": "Connection timed out after 30s",
|
||||
"image_width": 0,
|
||||
"image_height": 0,
|
||||
"client_name": "MyApp",
|
||||
"client_version": "2.1.0",
|
||||
"os_name": "iOS",
|
||||
"os_version": "17.0",
|
||||
"machine": "iPhone15,2"
|
||||
}
|
||||
```
|
||||
|
||||
## Agent Workflow Examples
|
||||
|
||||
```bash
|
||||
# 1. Inspect a captured crash session
|
||||
cli-anything-nslogger stats crash.rawnsloggerdata --json
|
||||
|
||||
# 2. Find all errors in the 5 minutes before crash
|
||||
cli-anything-nslogger filter crash.rawnsloggerdata --level 0 --json
|
||||
|
||||
# 3. Get network failures only
|
||||
cli-anything-nslogger filter crash.rawnsloggerdata --tag Network --regex "fail|timeout|error" --json
|
||||
|
||||
# 4. Export full log for offline analysis
|
||||
cli-anything-nslogger export crash.rawnsloggerdata --format json --output crash_log.json
|
||||
|
||||
# 5. Monitor an iOS app live and keep a local copy
|
||||
cli-anything-nslogger listen --bonjour --name bazinga --output app.log --debug
|
||||
```
|
||||
Reference in New Issue
Block a user