mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 18:19:44 +00:00
Compare commits
29
Commits
v1.0.1
...
6f1be7a4e1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f1be7a4e1 | ||
|
|
f88ee513d4 | ||
|
|
5ae776d921 | ||
|
|
dcf5588d55 | ||
|
|
df9f711a3e | ||
|
|
9ce864afa5 | ||
|
|
22a2c8b5c6 | ||
|
|
b090400604 | ||
|
|
4b6df665f3 | ||
|
|
c309fbaa98 | ||
|
|
7e2e723880 | ||
|
|
d2db86ec7f | ||
|
|
2ad85f6009 | ||
|
|
19a6bb7cda | ||
|
|
e28dcc1592 | ||
|
|
3e70669a27 | ||
|
|
b070a6f6a2 | ||
|
|
349f460e15 | ||
|
|
ce68d57a49 | ||
|
|
b8c271cce1 | ||
|
|
a0049b7f8a | ||
|
|
494ed17fe2 | ||
|
|
12f312421b | ||
|
|
a558f5e67e | ||
|
|
0be833bba2 | ||
|
|
1911f8f058 | ||
|
|
23e24aa4c2 | ||
|
|
e671ae9d1c | ||
|
|
a7a13c9614 |
No files matched your search
@@ -0,0 +1,227 @@
|
|||||||
|
---
|
||||||
|
name: ios-simulator-skill
|
||||||
|
version: 1.3.0
|
||||||
|
description: 21 production-ready scripts for iOS app testing, building, and automation. Provides semantic UI navigation, build automation, accessibility testing, and simulator lifecycle management. Optimized for AI agents with minimal token output.
|
||||||
|
---
|
||||||
|
|
||||||
|
# iOS Simulator Skill
|
||||||
|
|
||||||
|
Build, test, and automate iOS applications using accessibility-driven navigation and structured data instead of pixel coordinates.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Check environment
|
||||||
|
bash scripts/sim_health_check.sh
|
||||||
|
|
||||||
|
# 2. Launch app
|
||||||
|
python scripts/app_launcher.py --launch com.example.app
|
||||||
|
|
||||||
|
# 3. Map screen to see elements
|
||||||
|
python scripts/screen_mapper.py
|
||||||
|
|
||||||
|
# 4. Tap button
|
||||||
|
python scripts/navigator.py --find-text "Login" --tap
|
||||||
|
|
||||||
|
# 5. Enter text
|
||||||
|
python scripts/navigator.py --find-type TextField --enter-text "user@example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
All scripts support `--help` for detailed options and `--json` for machine-readable output.
|
||||||
|
|
||||||
|
## 21 Production Scripts
|
||||||
|
|
||||||
|
### Build & Development (2 scripts)
|
||||||
|
|
||||||
|
1. **build_and_test.py** - Build Xcode projects, run tests, parse results with progressive disclosure
|
||||||
|
- Build with live result streaming
|
||||||
|
- Parse errors and warnings from xcresult bundles
|
||||||
|
- Retrieve detailed build logs on demand
|
||||||
|
- Options: `--project`, `--scheme`, `--clean`, `--test`, `--verbose`, `--json`
|
||||||
|
|
||||||
|
2. **log_monitor.py** - Real-time log monitoring with intelligent filtering
|
||||||
|
- Stream logs or capture by duration
|
||||||
|
- Filter by severity (error/warning/info/debug)
|
||||||
|
- Deduplicate repeated messages
|
||||||
|
- Options: `--app`, `--severity`, `--follow`, `--duration`, `--output`, `--json`
|
||||||
|
|
||||||
|
### Navigation & Interaction (5 scripts)
|
||||||
|
|
||||||
|
3. **screen_mapper.py** - Analyze current screen and list interactive elements
|
||||||
|
- Element type breakdown
|
||||||
|
- Interactive button list
|
||||||
|
- Text field status
|
||||||
|
- Options: `--verbose`, `--hints`, `--json`
|
||||||
|
|
||||||
|
4. **navigator.py** - Find and interact with elements semantically
|
||||||
|
- Find by text (fuzzy matching)
|
||||||
|
- Find by element type
|
||||||
|
- Find by accessibility ID
|
||||||
|
- Enter text or tap elements
|
||||||
|
- Options: `--find-text`, `--find-type`, `--find-id`, `--tap`, `--enter-text`, `--json`
|
||||||
|
|
||||||
|
5. **gesture.py** - Perform swipes, scrolls, pinches, and complex gestures
|
||||||
|
- Directional swipes (up/down/left/right)
|
||||||
|
- Multi-swipe scrolling
|
||||||
|
- Pinch zoom
|
||||||
|
- Long press
|
||||||
|
- Pull to refresh
|
||||||
|
- Options: `--swipe`, `--scroll`, `--pinch`, `--long-press`, `--refresh`, `--json`
|
||||||
|
|
||||||
|
6. **keyboard.py** - Text input and hardware button control
|
||||||
|
- Type text (fast or slow)
|
||||||
|
- Special keys (return, delete, tab, space, arrows)
|
||||||
|
- Hardware buttons (home, lock, volume, screenshot)
|
||||||
|
- Key combinations
|
||||||
|
- Options: `--type`, `--key`, `--button`, `--slow`, `--clear`, `--dismiss`, `--json`
|
||||||
|
|
||||||
|
7. **app_launcher.py** - App lifecycle management
|
||||||
|
- Launch apps by bundle ID
|
||||||
|
- Terminate apps
|
||||||
|
- Install/uninstall from .app bundles
|
||||||
|
- Deep link navigation
|
||||||
|
- List installed apps
|
||||||
|
- Check app state
|
||||||
|
- Options: `--launch`, `--terminate`, `--install`, `--uninstall`, `--open-url`, `--list`, `--state`, `--json`
|
||||||
|
|
||||||
|
### Testing & Analysis (5 scripts)
|
||||||
|
|
||||||
|
8. **accessibility_audit.py** - Check WCAG compliance on current screen
|
||||||
|
- Critical issues (missing labels, empty buttons, no alt text)
|
||||||
|
- Warnings (missing hints, small touch targets)
|
||||||
|
- Info (missing IDs, deep nesting)
|
||||||
|
- Options: `--verbose`, `--output`, `--json`
|
||||||
|
|
||||||
|
9. **visual_diff.py** - Compare two screenshots for visual changes
|
||||||
|
- Pixel-by-pixel comparison
|
||||||
|
- Threshold-based pass/fail
|
||||||
|
- Generate diff images
|
||||||
|
- Options: `--threshold`, `--output`, `--details`, `--json`
|
||||||
|
|
||||||
|
10. **test_recorder.py** - Automatically document test execution
|
||||||
|
- Capture screenshots and accessibility trees per step
|
||||||
|
- Generate markdown reports with timing data
|
||||||
|
- Options: `--test-name`, `--output`, `--verbose`, `--json`
|
||||||
|
|
||||||
|
11. **app_state_capture.py** - Create comprehensive debugging snapshots
|
||||||
|
- Screenshot, UI hierarchy, app logs, device info
|
||||||
|
- Markdown summary for bug reports
|
||||||
|
- Options: `--app-bundle-id`, `--output`, `--log-lines`, `--json`
|
||||||
|
|
||||||
|
12. **sim_health_check.sh** - Verify environment is properly configured
|
||||||
|
- Check macOS, Xcode, simctl, IDB, Python
|
||||||
|
- List available and booted simulators
|
||||||
|
- Verify Python packages (Pillow)
|
||||||
|
|
||||||
|
### Advanced Testing & Permissions (4 scripts)
|
||||||
|
|
||||||
|
13. **clipboard.py** - Manage simulator clipboard for paste testing
|
||||||
|
- Copy text to clipboard
|
||||||
|
- Test paste flows without manual entry
|
||||||
|
- Options: `--copy`, `--test-name`, `--expected`, `--json`
|
||||||
|
|
||||||
|
14. **status_bar.py** - Override simulator status bar appearance
|
||||||
|
- Presets: clean (9:41, 100% battery), testing (11:11, 50%), low-battery (20%), airplane (offline)
|
||||||
|
- Custom time, network, battery, WiFi settings
|
||||||
|
- Options: `--preset`, `--time`, `--data-network`, `--battery-level`, `--clear`, `--json`
|
||||||
|
|
||||||
|
15. **push_notification.py** - Send simulated push notifications
|
||||||
|
- Simple mode (title + body + badge)
|
||||||
|
- Custom JSON payloads
|
||||||
|
- Test notification handling and deep links
|
||||||
|
- Options: `--bundle-id`, `--title`, `--body`, `--badge`, `--payload`, `--json`
|
||||||
|
|
||||||
|
16. **privacy_manager.py** - Grant, revoke, and reset app permissions
|
||||||
|
- 13 supported services (camera, microphone, location, contacts, photos, calendar, health, etc.)
|
||||||
|
- Batch operations (comma-separated services)
|
||||||
|
- Audit trail with test scenario tracking
|
||||||
|
- Options: `--bundle-id`, `--grant`, `--revoke`, `--reset`, `--list`, `--json`
|
||||||
|
|
||||||
|
### Device Lifecycle Management (5 scripts)
|
||||||
|
|
||||||
|
17. **simctl_boot.py** - Boot simulators with optional readiness verification
|
||||||
|
- Boot by UDID or device name
|
||||||
|
- Wait for device ready with timeout
|
||||||
|
- Batch boot operations (--all, --type)
|
||||||
|
- Performance timing
|
||||||
|
- Options: `--udid`, `--name`, `--wait-ready`, `--timeout`, `--all`, `--type`, `--json`
|
||||||
|
|
||||||
|
18. **simctl_shutdown.py** - Gracefully shutdown simulators
|
||||||
|
- Shutdown by UDID or device name
|
||||||
|
- Optional verification of shutdown completion
|
||||||
|
- Batch shutdown operations
|
||||||
|
- Options: `--udid`, `--name`, `--verify`, `--timeout`, `--all`, `--type`, `--json`
|
||||||
|
|
||||||
|
19. **simctl_create.py** - Create simulators dynamically
|
||||||
|
- Create by device type and iOS version
|
||||||
|
- List available device types and runtimes
|
||||||
|
- Custom device naming
|
||||||
|
- Returns UDID for CI/CD integration
|
||||||
|
- Options: `--device`, `--runtime`, `--name`, `--list-devices`, `--list-runtimes`, `--json`
|
||||||
|
|
||||||
|
20. **simctl_delete.py** - Permanently delete simulators
|
||||||
|
- Delete by UDID or device name
|
||||||
|
- Safety confirmation by default (skip with --yes)
|
||||||
|
- Batch delete operations
|
||||||
|
- Smart deletion (--old N to keep N per device type)
|
||||||
|
- Options: `--udid`, `--name`, `--yes`, `--all`, `--type`, `--old`, `--json`
|
||||||
|
|
||||||
|
21. **simctl_erase.py** - Factory reset simulators without deletion
|
||||||
|
- Preserve device UUID (faster than delete+create)
|
||||||
|
- Erase all, by type, or booted simulators
|
||||||
|
- Optional verification
|
||||||
|
- Options: `--udid`, `--name`, `--verify`, `--timeout`, `--all`, `--type`, `--booted`, `--json`
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
**Auto-UDID Detection**: Most scripts auto-detect the booted simulator if --udid is not provided.
|
||||||
|
|
||||||
|
**Device Name Resolution**: Use device names (e.g., "iPhone 16 Pro") instead of UDIDs - scripts resolve automatically.
|
||||||
|
|
||||||
|
**Batch Operations**: Many scripts support `--all` for all simulators or `--type iPhone` for device type filtering.
|
||||||
|
|
||||||
|
**Output Formats**: Default is concise human-readable output. Use `--json` for machine-readable output in CI/CD.
|
||||||
|
|
||||||
|
**Help**: All scripts support `--help` for detailed options and examples.
|
||||||
|
|
||||||
|
## Typical Workflow
|
||||||
|
|
||||||
|
1. Verify environment: `bash scripts/sim_health_check.sh`
|
||||||
|
2. Launch app: `python scripts/app_launcher.py --launch com.example.app`
|
||||||
|
3. Analyze screen: `python scripts/screen_mapper.py`
|
||||||
|
4. Interact: `python scripts/navigator.py --find-text "Button" --tap`
|
||||||
|
5. Verify: `python scripts/accessibility_audit.py`
|
||||||
|
6. Debug if needed: `python scripts/app_state_capture.py --app-bundle-id com.example.app`
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- macOS 12+
|
||||||
|
- Xcode Command Line Tools
|
||||||
|
- Python 3
|
||||||
|
- IDB (optional, for interactive features)
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **SKILL.md** (this file) - Script reference and quick start
|
||||||
|
- **README.md** - Installation and examples
|
||||||
|
- **CLAUDE.md** - Architecture and implementation details
|
||||||
|
- **references/** - Deep documentation on specific topics
|
||||||
|
- **examples/** - Complete automation workflows
|
||||||
|
|
||||||
|
## Key Design Principles
|
||||||
|
|
||||||
|
**Semantic Navigation**: Find elements by meaning (text, type, ID) not pixel coordinates. Survives UI changes.
|
||||||
|
|
||||||
|
**Token Efficiency**: Concise default output (3-5 lines) with optional verbose and JSON modes for detailed results.
|
||||||
|
|
||||||
|
**Accessibility-First**: Built on standard accessibility APIs for reliability and compatibility.
|
||||||
|
|
||||||
|
**Zero Configuration**: Works immediately on any macOS with Xcode. No setup required.
|
||||||
|
|
||||||
|
**Structured Data**: Scripts output JSON or formatted text, not raw logs. Easy to parse and integrate.
|
||||||
|
|
||||||
|
**Auto-Learning**: Build system remembers your device preference. Configuration stored per-project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Use these scripts directly or let Claude Code invoke them automatically when your request matches the skill description.
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
iOS Simulator Accessibility Audit
|
||||||
|
|
||||||
|
Scans the current simulator screen for accessibility compliance issues.
|
||||||
|
Optimized for minimal token output while maintaining functionality.
|
||||||
|
|
||||||
|
Usage: python scripts/accessibility_audit.py [options]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from common import flatten_tree, get_accessibility_tree, resolve_udid
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Issue:
|
||||||
|
"""Represents an accessibility issue."""
|
||||||
|
|
||||||
|
severity: str # critical, warning, info
|
||||||
|
rule: str
|
||||||
|
element_type: str
|
||||||
|
issue: str
|
||||||
|
fix: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for JSON serialization."""
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class AccessibilityAuditor:
|
||||||
|
"""Performs accessibility audits on iOS simulator screens."""
|
||||||
|
|
||||||
|
# Critical rules that block users
|
||||||
|
CRITICAL_RULES = {
|
||||||
|
"missing_label": lambda e: e.get("type") in ["Button", "Link"] and not e.get("AXLabel"),
|
||||||
|
"empty_button": lambda e: e.get("type") == "Button"
|
||||||
|
and not (e.get("AXLabel") or e.get("AXValue")),
|
||||||
|
"image_no_alt": lambda e: e.get("type") == "Image" and not e.get("AXLabel"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Warnings that degrade UX
|
||||||
|
WARNING_RULES = {
|
||||||
|
"missing_hint": lambda e: e.get("type") in ["Slider", "TextField"] and not e.get("help"),
|
||||||
|
"missing_traits": lambda e: e.get("type") and not e.get("traits"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Info level suggestions
|
||||||
|
INFO_RULES = {
|
||||||
|
"no_identifier": lambda e: not e.get("AXUniqueId"),
|
||||||
|
"deep_nesting": lambda e: e.get("depth", 0) > 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, udid: str | None = None):
|
||||||
|
"""Initialize auditor with optional device UDID."""
|
||||||
|
self.udid = udid
|
||||||
|
|
||||||
|
def get_accessibility_tree(self) -> dict:
|
||||||
|
"""Fetch accessibility tree from simulator using shared utility."""
|
||||||
|
return get_accessibility_tree(self.udid, nested=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_small_target(element: dict) -> bool:
|
||||||
|
"""Check if touch target is too small (< 44x44 points)."""
|
||||||
|
frame = element.get("frame", {})
|
||||||
|
width = frame.get("width", 0)
|
||||||
|
height = frame.get("height", 0)
|
||||||
|
return width < 44 or height < 44
|
||||||
|
|
||||||
|
def _flatten_tree(self, node: dict, depth: int = 0) -> list[dict]:
|
||||||
|
"""Flatten nested accessibility tree for easier processing using shared utility."""
|
||||||
|
return flatten_tree(node, depth)
|
||||||
|
|
||||||
|
def audit_element(self, element: dict) -> list[Issue]:
|
||||||
|
"""Audit a single element for accessibility issues."""
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
# Check critical rules
|
||||||
|
for rule_name, rule_func in self.CRITICAL_RULES.items():
|
||||||
|
if rule_func(element):
|
||||||
|
issues.append(
|
||||||
|
Issue(
|
||||||
|
severity="critical",
|
||||||
|
rule=rule_name,
|
||||||
|
element_type=element.get("type", "Unknown"),
|
||||||
|
issue=self._get_issue_description(rule_name),
|
||||||
|
fix=self._get_fix_suggestion(rule_name),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check warnings (skip if critical issues found)
|
||||||
|
if not issues:
|
||||||
|
for rule_name, rule_func in self.WARNING_RULES.items():
|
||||||
|
if rule_func(element):
|
||||||
|
issues.append(
|
||||||
|
Issue(
|
||||||
|
severity="warning",
|
||||||
|
rule=rule_name,
|
||||||
|
element_type=element.get("type", "Unknown"),
|
||||||
|
issue=self._get_issue_description(rule_name),
|
||||||
|
fix=self._get_fix_suggestion(rule_name),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check info level (only if verbose or no other issues)
|
||||||
|
if not issues:
|
||||||
|
for rule_name, rule_func in self.INFO_RULES.items():
|
||||||
|
if rule_func(element):
|
||||||
|
issues.append(
|
||||||
|
Issue(
|
||||||
|
severity="info",
|
||||||
|
rule=rule_name,
|
||||||
|
element_type=element.get("type", "Unknown"),
|
||||||
|
issue=self._get_issue_description(rule_name),
|
||||||
|
fix=self._get_fix_suggestion(rule_name),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
def _get_issue_description(self, rule: str) -> str:
|
||||||
|
"""Get human-readable issue description."""
|
||||||
|
descriptions = {
|
||||||
|
"missing_label": "Interactive element missing accessibility label",
|
||||||
|
"empty_button": "Button has no text or label",
|
||||||
|
"image_no_alt": "Image missing alternative text",
|
||||||
|
"missing_hint": "Complex control missing hint",
|
||||||
|
"small_touch_target": "Touch target smaller than 44x44pt",
|
||||||
|
"missing_traits": "Element missing accessibility traits",
|
||||||
|
"no_identifier": "Missing accessibility identifier",
|
||||||
|
"deep_nesting": "Deeply nested (>5 levels)",
|
||||||
|
}
|
||||||
|
return descriptions.get(rule, "Accessibility issue")
|
||||||
|
|
||||||
|
def _get_fix_suggestion(self, rule: str) -> str:
|
||||||
|
"""Get fix suggestion for issue."""
|
||||||
|
fixes = {
|
||||||
|
"missing_label": "Add accessibilityLabel",
|
||||||
|
"empty_button": "Set button title or accessibilityLabel",
|
||||||
|
"image_no_alt": "Add accessibilityLabel with description",
|
||||||
|
"missing_hint": "Add accessibilityHint",
|
||||||
|
"small_touch_target": "Increase to minimum 44x44pt",
|
||||||
|
"missing_traits": "Set appropriate accessibilityTraits",
|
||||||
|
"no_identifier": "Add accessibilityIdentifier for testing",
|
||||||
|
"deep_nesting": "Simplify view hierarchy",
|
||||||
|
}
|
||||||
|
return fixes.get(rule, "Review accessibility")
|
||||||
|
|
||||||
|
def audit(self, verbose: bool = False) -> dict[str, Any]:
|
||||||
|
"""Perform full accessibility audit."""
|
||||||
|
# Get accessibility tree
|
||||||
|
tree = self.get_accessibility_tree()
|
||||||
|
|
||||||
|
# Flatten for processing
|
||||||
|
elements = self._flatten_tree(tree)
|
||||||
|
|
||||||
|
# Audit each element
|
||||||
|
all_issues = []
|
||||||
|
for element in elements:
|
||||||
|
issues = self.audit_element(element)
|
||||||
|
for issue in issues:
|
||||||
|
issue_dict = issue.to_dict()
|
||||||
|
# Add minimal element info for context
|
||||||
|
issue_dict["element"] = {
|
||||||
|
"type": element.get("type", "Unknown"),
|
||||||
|
"label": element.get("AXLabel", "")[:30] if element.get("AXLabel") else None,
|
||||||
|
}
|
||||||
|
all_issues.append(issue_dict)
|
||||||
|
|
||||||
|
# Count by severity
|
||||||
|
critical = len([i for i in all_issues if i["severity"] == "critical"])
|
||||||
|
warning = len([i for i in all_issues if i["severity"] == "warning"])
|
||||||
|
info = len([i for i in all_issues if i["severity"] == "info"])
|
||||||
|
|
||||||
|
# Build result (token-optimized)
|
||||||
|
result = {
|
||||||
|
"summary": {
|
||||||
|
"total": len(elements),
|
||||||
|
"issues": len(all_issues),
|
||||||
|
"critical": critical,
|
||||||
|
"warning": warning,
|
||||||
|
"info": info,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
# Full details only if requested
|
||||||
|
result["issues"] = all_issues
|
||||||
|
else:
|
||||||
|
# Default: top issues only (token-efficient)
|
||||||
|
result["top_issues"] = self._get_top_issues(all_issues)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _get_top_issues(self, issues: list[dict]) -> list[dict]:
|
||||||
|
"""Get top 3 issues grouped by type (token-efficient)."""
|
||||||
|
if not issues:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Group by rule
|
||||||
|
grouped = {}
|
||||||
|
for issue in issues:
|
||||||
|
rule = issue["rule"]
|
||||||
|
if rule not in grouped:
|
||||||
|
grouped[rule] = {
|
||||||
|
"severity": issue["severity"],
|
||||||
|
"rule": rule,
|
||||||
|
"count": 0,
|
||||||
|
"fix": issue["fix"],
|
||||||
|
}
|
||||||
|
grouped[rule]["count"] += 1
|
||||||
|
|
||||||
|
# Sort by severity and count
|
||||||
|
severity_order = {"critical": 0, "warning": 1, "info": 2}
|
||||||
|
sorted_issues = sorted(
|
||||||
|
grouped.values(), key=lambda x: (severity_order[x["severity"]], -x["count"])
|
||||||
|
)
|
||||||
|
|
||||||
|
return sorted_issues[:3]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Audit iOS simulator screen for accessibility issues"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--udid",
|
||||||
|
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--output", help="Save JSON report to file")
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose", action="store_true", help="Include all issue details (increases output)"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Resolve UDID with auto-detection
|
||||||
|
try:
|
||||||
|
udid = resolve_udid(args.udid)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Perform audit
|
||||||
|
auditor = AccessibilityAuditor(udid=udid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = auditor.audit(verbose=args.verbose)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Output results
|
||||||
|
if args.output:
|
||||||
|
# Save to file
|
||||||
|
with open(args.output, "w") as f:
|
||||||
|
json.dump(result, f, indent=2)
|
||||||
|
# Print minimal summary
|
||||||
|
summary = result["summary"]
|
||||||
|
print(f"Audit complete: {summary['issues']} issues ({summary['critical']} critical)")
|
||||||
|
print(f"Report saved to: {args.output}")
|
||||||
|
# Print to stdout (token-optimized by default)
|
||||||
|
elif args.verbose:
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
else:
|
||||||
|
# Ultra-compact output
|
||||||
|
summary = result["summary"]
|
||||||
|
print(f"Elements: {summary['total']}, Issues: {summary['issues']}")
|
||||||
|
print(
|
||||||
|
f"Critical: {summary['critical']}, Warning: {summary['warning']}, Info: {summary['info']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get("top_issues"):
|
||||||
|
print("\nTop issues:")
|
||||||
|
for issue in result["top_issues"]:
|
||||||
|
print(
|
||||||
|
f" [{issue['severity']}] {issue['rule']} ({issue['count']}x) - {issue['fix']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exit with error if critical issues found
|
||||||
|
if result["summary"]["critical"] > 0:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+322
@@ -0,0 +1,322 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
iOS App Launcher - App Lifecycle Control
|
||||||
|
|
||||||
|
Launches, terminates, and manages iOS apps in the simulator.
|
||||||
|
Handles deep links and app switching.
|
||||||
|
|
||||||
|
Usage: python scripts/app_launcher.py --launch com.example.app
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from common import build_simctl_command, resolve_udid
|
||||||
|
|
||||||
|
|
||||||
|
class AppLauncher:
|
||||||
|
"""Controls app lifecycle on iOS simulator."""
|
||||||
|
|
||||||
|
def __init__(self, udid: str | None = None):
|
||||||
|
"""Initialize app launcher."""
|
||||||
|
self.udid = udid
|
||||||
|
|
||||||
|
def launch(self, bundle_id: str, wait_for_debugger: bool = False) -> tuple[bool, int | None]:
|
||||||
|
"""
|
||||||
|
Launch an app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bundle_id: App bundle identifier
|
||||||
|
wait_for_debugger: Wait for debugger attachment
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, pid) tuple
|
||||||
|
"""
|
||||||
|
cmd = build_simctl_command("launch", self.udid, bundle_id)
|
||||||
|
|
||||||
|
if wait_for_debugger:
|
||||||
|
cmd.insert(3, "--wait-for-debugger") # Insert after "launch" operation
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
# Parse PID from output if available
|
||||||
|
pid = None
|
||||||
|
if result.stdout:
|
||||||
|
# Output format: "com.example.app: <PID>"
|
||||||
|
parts = result.stdout.strip().split(":")
|
||||||
|
if len(parts) > 1:
|
||||||
|
with contextlib.suppress(ValueError):
|
||||||
|
pid = int(parts[1].strip())
|
||||||
|
return (True, pid)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return (False, None)
|
||||||
|
|
||||||
|
def terminate(self, bundle_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Terminate an app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bundle_id: App bundle identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
cmd = build_simctl_command("terminate", self.udid, bundle_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def install(self, app_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Install an app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_path: Path to .app bundle
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
cmd = build_simctl_command("install", self.udid, app_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def uninstall(self, bundle_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Uninstall an app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bundle_id: App bundle identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
cmd = build_simctl_command("uninstall", self.udid, bundle_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def open_url(self, url: str) -> bool:
|
||||||
|
"""
|
||||||
|
Open URL (for deep linking).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to open (http://, myapp://, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
cmd = build_simctl_command("openurl", self.udid, url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def list_apps(self) -> list[dict[str, str]]:
|
||||||
|
"""
|
||||||
|
List installed apps.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of app info dictionaries
|
||||||
|
"""
|
||||||
|
cmd = build_simctl_command("listapps", self.udid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
# Parse plist output using plutil to convert to JSON
|
||||||
|
plist_data = result.stdout
|
||||||
|
|
||||||
|
# Use plutil to convert plist to JSON
|
||||||
|
convert_cmd = ["plutil", "-convert", "json", "-o", "-", "-"]
|
||||||
|
convert_result = subprocess.run(
|
||||||
|
convert_cmd, check=False, input=plist_data, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
apps = []
|
||||||
|
if convert_result.returncode == 0:
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(convert_result.stdout)
|
||||||
|
for bundle_id, app_info in data.items():
|
||||||
|
# Skip system internal apps that are hidden
|
||||||
|
if app_info.get("ApplicationType") == "Hidden":
|
||||||
|
continue
|
||||||
|
|
||||||
|
apps.append(
|
||||||
|
{
|
||||||
|
"bundle_id": bundle_id,
|
||||||
|
"name": app_info.get(
|
||||||
|
"CFBundleDisplayName", app_info.get("CFBundleName", bundle_id)
|
||||||
|
),
|
||||||
|
"path": app_info.get("Path", ""),
|
||||||
|
"version": app_info.get("CFBundleVersion", "Unknown"),
|
||||||
|
"type": app_info.get("ApplicationType", "User"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return apps
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_app_state(self, bundle_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Get app state (running, suspended, etc.).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bundle_id: App bundle identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
State string or 'unknown'
|
||||||
|
"""
|
||||||
|
# Check if app is running by trying to get its PID
|
||||||
|
cmd = build_simctl_command("spawn", self.udid, "launchctl", "list")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
if bundle_id in result.stdout:
|
||||||
|
return "running"
|
||||||
|
return "not running"
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
def restart_app(self, bundle_id: str, delay: float = 1.0) -> bool:
|
||||||
|
"""
|
||||||
|
Restart an app (terminate then launch).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bundle_id: App bundle identifier
|
||||||
|
delay: Delay between terminate and launch
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
# Terminate
|
||||||
|
self.terminate(bundle_id)
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
# Launch
|
||||||
|
success, _ = self.launch(bundle_id)
|
||||||
|
return success
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Control iOS app lifecycle")
|
||||||
|
|
||||||
|
# Actions
|
||||||
|
parser.add_argument("--launch", help="Launch app by bundle ID")
|
||||||
|
parser.add_argument("--terminate", help="Terminate app by bundle ID")
|
||||||
|
parser.add_argument("--restart", help="Restart app by bundle ID")
|
||||||
|
parser.add_argument("--install", help="Install app from .app path")
|
||||||
|
parser.add_argument("--uninstall", help="Uninstall app by bundle ID")
|
||||||
|
parser.add_argument("--open-url", help="Open URL (deep link)")
|
||||||
|
parser.add_argument("--list", action="store_true", help="List installed apps")
|
||||||
|
parser.add_argument("--state", help="Get app state by bundle ID")
|
||||||
|
|
||||||
|
# Options
|
||||||
|
parser.add_argument(
|
||||||
|
"--wait-for-debugger", action="store_true", help="Wait for debugger when launching"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--udid",
|
||||||
|
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Resolve UDID with auto-detection
|
||||||
|
try:
|
||||||
|
udid = resolve_udid(args.udid)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
launcher = AppLauncher(udid=udid)
|
||||||
|
|
||||||
|
# Execute requested action
|
||||||
|
if args.launch:
|
||||||
|
success, pid = launcher.launch(args.launch, args.wait_for_debugger)
|
||||||
|
if success:
|
||||||
|
if pid:
|
||||||
|
print(f"Launched {args.launch} (PID: {pid})")
|
||||||
|
else:
|
||||||
|
print(f"Launched {args.launch}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to launch {args.launch}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.terminate:
|
||||||
|
if launcher.terminate(args.terminate):
|
||||||
|
print(f"Terminated {args.terminate}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to terminate {args.terminate}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.restart:
|
||||||
|
if launcher.restart_app(args.restart):
|
||||||
|
print(f"Restarted {args.restart}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to restart {args.restart}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.install:
|
||||||
|
if launcher.install(args.install):
|
||||||
|
print(f"Installed {args.install}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to install {args.install}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.uninstall:
|
||||||
|
if launcher.uninstall(args.uninstall):
|
||||||
|
print(f"Uninstalled {args.uninstall}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to uninstall {args.uninstall}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.open_url:
|
||||||
|
if launcher.open_url(args.open_url):
|
||||||
|
print(f"Opened URL: {args.open_url}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to open URL: {args.open_url}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.list:
|
||||||
|
apps = launcher.list_apps()
|
||||||
|
if apps:
|
||||||
|
print(f"Installed apps ({len(apps)}):")
|
||||||
|
for app in apps[:10]: # Limit for token efficiency
|
||||||
|
print(f" {app['bundle_id']}: {app['name']} (v{app['version']})")
|
||||||
|
if len(apps) > 10:
|
||||||
|
print(f" ... and {len(apps) - 10} more")
|
||||||
|
else:
|
||||||
|
print("No apps found or failed to list")
|
||||||
|
|
||||||
|
elif args.state:
|
||||||
|
state = launcher.get_app_state(args.state)
|
||||||
|
print(f"{args.state}: {state}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
App State Capture for iOS Simulator
|
||||||
|
|
||||||
|
Captures complete app state including screenshot, accessibility tree, and logs.
|
||||||
|
Optimized for minimal token output.
|
||||||
|
|
||||||
|
Usage: python scripts/app_state_capture.py [options]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from common import (
|
||||||
|
capture_screenshot,
|
||||||
|
count_elements,
|
||||||
|
get_accessibility_tree,
|
||||||
|
resolve_udid,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AppStateCapture:
|
||||||
|
"""Captures comprehensive app state for debugging."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app_bundle_id: str | None = None,
|
||||||
|
udid: str | None = None,
|
||||||
|
inline: bool = False,
|
||||||
|
screenshot_size: str = "half",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize state capture.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_bundle_id: Optional app bundle ID for log filtering
|
||||||
|
udid: Optional device UDID (uses booted if not specified)
|
||||||
|
inline: If True, return screenshots as base64 (for vision-based automation)
|
||||||
|
screenshot_size: 'full', 'half', 'quarter', 'thumb' (default: 'half')
|
||||||
|
"""
|
||||||
|
self.app_bundle_id = app_bundle_id
|
||||||
|
self.udid = udid
|
||||||
|
self.inline = inline
|
||||||
|
self.screenshot_size = screenshot_size
|
||||||
|
|
||||||
|
def capture_screenshot(self, output_path: Path) -> bool:
|
||||||
|
"""Capture screenshot of current screen."""
|
||||||
|
cmd = ["xcrun", "simctl", "io"]
|
||||||
|
|
||||||
|
if self.udid:
|
||||||
|
cmd.append(self.udid)
|
||||||
|
else:
|
||||||
|
cmd.append("booted")
|
||||||
|
|
||||||
|
cmd.extend(["screenshot", str(output_path)])
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def capture_accessibility_tree(self, output_path: Path) -> dict:
|
||||||
|
"""Capture accessibility tree using shared utility."""
|
||||||
|
try:
|
||||||
|
# Use shared utility to fetch tree
|
||||||
|
tree = get_accessibility_tree(self.udid, nested=True)
|
||||||
|
|
||||||
|
# Save tree
|
||||||
|
with open(output_path, "w") as f:
|
||||||
|
json.dump(tree, f, indent=2)
|
||||||
|
|
||||||
|
# Return summary using shared utility
|
||||||
|
return {"captured": True, "element_count": count_elements(tree)}
|
||||||
|
except Exception as e:
|
||||||
|
return {"captured": False, "error": str(e)}
|
||||||
|
|
||||||
|
def capture_logs(self, output_path: Path, line_limit: int = 100) -> dict:
|
||||||
|
"""Capture recent app logs."""
|
||||||
|
if not self.app_bundle_id:
|
||||||
|
# Can't capture logs without app ID
|
||||||
|
return {"captured": False, "reason": "No app bundle ID specified"}
|
||||||
|
|
||||||
|
# Get app name from bundle ID (simplified)
|
||||||
|
app_name = self.app_bundle_id.split(".")[-1]
|
||||||
|
|
||||||
|
cmd = ["xcrun", "simctl", "spawn"]
|
||||||
|
|
||||||
|
if self.udid:
|
||||||
|
cmd.append(self.udid)
|
||||||
|
else:
|
||||||
|
cmd.append("booted")
|
||||||
|
|
||||||
|
cmd.extend(
|
||||||
|
[
|
||||||
|
"log",
|
||||||
|
"show",
|
||||||
|
"--predicate",
|
||||||
|
f'process == "{app_name}"',
|
||||||
|
"--last",
|
||||||
|
"1m", # Last 1 minute
|
||||||
|
"--style",
|
||||||
|
"compact",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=5)
|
||||||
|
logs = result.stdout
|
||||||
|
|
||||||
|
# Limit lines for token efficiency
|
||||||
|
lines = logs.split("\n")
|
||||||
|
if len(lines) > line_limit:
|
||||||
|
lines = lines[-line_limit:]
|
||||||
|
|
||||||
|
# Save logs
|
||||||
|
with open(output_path, "w") as f:
|
||||||
|
f.write("\n".join(lines))
|
||||||
|
|
||||||
|
# Analyze for issues
|
||||||
|
warning_count = sum(1 for line in lines if "warning" in line.lower())
|
||||||
|
error_count = sum(1 for line in lines if "error" in line.lower())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"captured": True,
|
||||||
|
"lines": len(lines),
|
||||||
|
"warnings": warning_count,
|
||||||
|
"errors": error_count,
|
||||||
|
}
|
||||||
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||||
|
return {"captured": False, "error": str(e)}
|
||||||
|
|
||||||
|
def capture_device_info(self) -> dict:
|
||||||
|
"""Get device information."""
|
||||||
|
cmd = ["xcrun", "simctl", "list", "devices", "booted"]
|
||||||
|
|
||||||
|
if self.udid:
|
||||||
|
# Specific device info
|
||||||
|
cmd = ["xcrun", "simctl", "list", "devices"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
# Parse output for device info (simplified)
|
||||||
|
lines = result.stdout.split("\n")
|
||||||
|
device_info = {}
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if "iPhone" in line or "iPad" in line:
|
||||||
|
# Extract device name and state
|
||||||
|
parts = line.strip().split("(")
|
||||||
|
if parts:
|
||||||
|
device_info["name"] = parts[0].strip()
|
||||||
|
if len(parts) > 2:
|
||||||
|
device_info["udid"] = parts[1].replace(")", "").strip()
|
||||||
|
device_info["state"] = parts[2].replace(")", "").strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
return device_info
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def capture_all(
|
||||||
|
self, output_dir: str, log_lines: int = 100, app_name: str | None = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Capture complete app state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Directory to save artifacts
|
||||||
|
log_lines: Number of log lines to capture
|
||||||
|
app_name: App name for semantic naming (for inline mode)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary of captured state
|
||||||
|
"""
|
||||||
|
# Create output directory (only if not in inline mode)
|
||||||
|
output_path = Path(output_dir)
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
if not self.inline:
|
||||||
|
capture_dir = output_path / f"app-state-{timestamp}"
|
||||||
|
capture_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
else:
|
||||||
|
capture_dir = None
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"screenshot_mode": "inline" if self.inline else "file",
|
||||||
|
}
|
||||||
|
|
||||||
|
if capture_dir:
|
||||||
|
summary["output_dir"] = str(capture_dir)
|
||||||
|
|
||||||
|
# Capture screenshot using new unified utility
|
||||||
|
screenshot_result = capture_screenshot(
|
||||||
|
self.udid,
|
||||||
|
size=self.screenshot_size,
|
||||||
|
inline=self.inline,
|
||||||
|
app_name=app_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.inline:
|
||||||
|
# Inline mode: store base64
|
||||||
|
summary["screenshot"] = {
|
||||||
|
"mode": "inline",
|
||||||
|
"base64": screenshot_result["base64_data"],
|
||||||
|
"width": screenshot_result["width"],
|
||||||
|
"height": screenshot_result["height"],
|
||||||
|
"size_preset": self.screenshot_size,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# File mode: save to disk
|
||||||
|
screenshot_path = capture_dir / "screenshot.png"
|
||||||
|
# Move temp file to target location
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.move(screenshot_result["file_path"], screenshot_path)
|
||||||
|
summary["screenshot"] = {
|
||||||
|
"mode": "file",
|
||||||
|
"file": "screenshot.png",
|
||||||
|
"size_bytes": screenshot_result["size_bytes"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Capture accessibility tree
|
||||||
|
if not self.inline or capture_dir:
|
||||||
|
accessibility_path = (capture_dir or output_path) / "accessibility-tree.json"
|
||||||
|
else:
|
||||||
|
accessibility_path = None
|
||||||
|
|
||||||
|
if accessibility_path:
|
||||||
|
tree_info = self.capture_accessibility_tree(accessibility_path)
|
||||||
|
summary["accessibility"] = tree_info
|
||||||
|
|
||||||
|
# Capture logs (if app ID provided)
|
||||||
|
if self.app_bundle_id:
|
||||||
|
if not self.inline or capture_dir:
|
||||||
|
logs_path = (capture_dir or output_path) / "app-logs.txt"
|
||||||
|
else:
|
||||||
|
logs_path = None
|
||||||
|
|
||||||
|
if logs_path:
|
||||||
|
log_info = self.capture_logs(logs_path, log_lines)
|
||||||
|
summary["logs"] = log_info
|
||||||
|
|
||||||
|
# Get device info
|
||||||
|
device_info = self.capture_device_info()
|
||||||
|
if device_info:
|
||||||
|
summary["device"] = device_info
|
||||||
|
# Save device info (file mode only)
|
||||||
|
if capture_dir:
|
||||||
|
with open(capture_dir / "device-info.json", "w") as f:
|
||||||
|
json.dump(device_info, f, indent=2)
|
||||||
|
|
||||||
|
# Save summary (file mode only)
|
||||||
|
if capture_dir:
|
||||||
|
with open(capture_dir / "summary.json", "w") as f:
|
||||||
|
json.dump(summary, f, indent=2)
|
||||||
|
|
||||||
|
# Create markdown summary
|
||||||
|
self._create_summary_md(capture_dir, summary)
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
def _create_summary_md(self, capture_dir: Path, summary: dict) -> None:
|
||||||
|
"""Create markdown summary file."""
|
||||||
|
md_path = capture_dir / "summary.md"
|
||||||
|
|
||||||
|
with open(md_path, "w") as f:
|
||||||
|
f.write("# App State Capture\n\n")
|
||||||
|
f.write(f"**Timestamp:** {summary['timestamp']}\n\n")
|
||||||
|
|
||||||
|
if "device" in summary:
|
||||||
|
f.write("## Device\n")
|
||||||
|
device = summary["device"]
|
||||||
|
f.write(f"- Name: {device.get('name', 'Unknown')}\n")
|
||||||
|
f.write(f"- UDID: {device.get('udid', 'N/A')}\n")
|
||||||
|
f.write(f"- State: {device.get('state', 'Unknown')}\n\n")
|
||||||
|
|
||||||
|
f.write("## Screenshot\n")
|
||||||
|
f.write("\n\n")
|
||||||
|
|
||||||
|
if "accessibility" in summary:
|
||||||
|
acc = summary["accessibility"]
|
||||||
|
f.write("## Accessibility\n")
|
||||||
|
if acc.get("captured"):
|
||||||
|
f.write(f"- Elements: {acc.get('element_count', 0)}\n")
|
||||||
|
else:
|
||||||
|
f.write(f"- Error: {acc.get('error', 'Unknown')}\n")
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
if "logs" in summary:
|
||||||
|
logs = summary["logs"]
|
||||||
|
f.write("## Logs\n")
|
||||||
|
if logs.get("captured"):
|
||||||
|
f.write(f"- Lines: {logs.get('lines', 0)}\n")
|
||||||
|
f.write(f"- Warnings: {logs.get('warnings', 0)}\n")
|
||||||
|
f.write(f"- Errors: {logs.get('errors', 0)}\n")
|
||||||
|
else:
|
||||||
|
f.write(f"- {logs.get('reason', logs.get('error', 'Not captured'))}\n")
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
f.write("## Files\n")
|
||||||
|
f.write("- `screenshot.png` - Current screen\n")
|
||||||
|
f.write("- `accessibility-tree.json` - Full UI hierarchy\n")
|
||||||
|
if self.app_bundle_id:
|
||||||
|
f.write("- `app-logs.txt` - Recent app logs\n")
|
||||||
|
f.write("- `device-info.json` - Device details\n")
|
||||||
|
f.write("- `summary.json` - Complete capture metadata\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Capture complete app state for debugging")
|
||||||
|
parser.add_argument(
|
||||||
|
"--app-bundle-id", help="App bundle ID for log filtering (e.g., com.example.app)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", default=".", help="Output directory (default: current directory)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log-lines", type=int, default=100, help="Number of log lines to capture (default: 100)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--udid",
|
||||||
|
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--inline",
|
||||||
|
action="store_true",
|
||||||
|
help="Return screenshots as base64 (inline mode for vision-based automation)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--size",
|
||||||
|
choices=["full", "half", "quarter", "thumb"],
|
||||||
|
default="half",
|
||||||
|
help="Screenshot size for token optimization (default: half)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--app-name", help="App name for semantic screenshot naming")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Resolve UDID with auto-detection
|
||||||
|
try:
|
||||||
|
udid = resolve_udid(args.udid)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Create capturer
|
||||||
|
capturer = AppStateCapture(
|
||||||
|
app_bundle_id=args.app_bundle_id,
|
||||||
|
udid=udid,
|
||||||
|
inline=args.inline,
|
||||||
|
screenshot_size=args.size,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Capture state
|
||||||
|
try:
|
||||||
|
summary = capturer.capture_all(
|
||||||
|
output_dir=args.output, log_lines=args.log_lines, app_name=args.app_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Token-efficient output
|
||||||
|
if "output_dir" in summary:
|
||||||
|
print(f"State captured: {summary['output_dir']}/")
|
||||||
|
else:
|
||||||
|
# Inline mode
|
||||||
|
print(
|
||||||
|
f"State captured (inline mode): {summary['screenshot']['width']}x{summary['screenshot']['height']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Report any issues found
|
||||||
|
if "logs" in summary and summary["logs"].get("captured"):
|
||||||
|
logs = summary["logs"]
|
||||||
|
if logs["errors"] > 0 or logs["warnings"] > 0:
|
||||||
|
print(f"Issues found: {logs['errors']} errors, {logs['warnings']} warnings")
|
||||||
|
|
||||||
|
if "accessibility" in summary and summary["accessibility"].get("captured"):
|
||||||
|
print(f"Elements: {summary['accessibility']['element_count']}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Build and Test Automation for Xcode Projects
|
||||||
|
|
||||||
|
Ultra token-efficient build automation with progressive disclosure via xcresult bundles.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Minimal default output (5-10 tokens)
|
||||||
|
- Progressive disclosure for error/warning/log details
|
||||||
|
- Native xcresult bundle support
|
||||||
|
- Clean modular architecture
|
||||||
|
|
||||||
|
Usage Examples:
|
||||||
|
# Build (minimal output)
|
||||||
|
python scripts/build_and_test.py --project MyApp.xcodeproj
|
||||||
|
# Output: Build: SUCCESS (0 errors, 3 warnings) [xcresult-20251018-143052]
|
||||||
|
|
||||||
|
# Get error details
|
||||||
|
python scripts/build_and_test.py --get-errors xcresult-20251018-143052
|
||||||
|
|
||||||
|
# Get warnings
|
||||||
|
python scripts/build_and_test.py --get-warnings xcresult-20251018-143052
|
||||||
|
|
||||||
|
# Get build log
|
||||||
|
python scripts/build_and_test.py --get-log xcresult-20251018-143052
|
||||||
|
|
||||||
|
# Get everything as JSON
|
||||||
|
python scripts/build_and_test.py --get-all xcresult-20251018-143052 --json
|
||||||
|
|
||||||
|
# List recent builds
|
||||||
|
python scripts/build_and_test.py --list-xcresults
|
||||||
|
|
||||||
|
# Verbose mode (for debugging)
|
||||||
|
python scripts/build_and_test.py --project MyApp.xcodeproj --verbose
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Import our modular components
|
||||||
|
from xcode import BuildRunner, OutputFormatter, XCResultCache, XCResultParser
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Build and test Xcode projects with progressive disclosure",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
# Build project (minimal output)
|
||||||
|
python scripts/build_and_test.py --project MyApp.xcodeproj
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
python scripts/build_and_test.py --project MyApp.xcodeproj --test
|
||||||
|
|
||||||
|
# Get error details from previous build
|
||||||
|
python scripts/build_and_test.py --get-errors xcresult-20251018-143052
|
||||||
|
|
||||||
|
# Get all details as JSON
|
||||||
|
python scripts/build_and_test.py --get-all xcresult-20251018-143052 --json
|
||||||
|
|
||||||
|
# List recent builds
|
||||||
|
python scripts/build_and_test.py --list-xcresults
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build/test mode arguments
|
||||||
|
build_group = parser.add_argument_group("Build/Test Options")
|
||||||
|
project_group = build_group.add_mutually_exclusive_group()
|
||||||
|
project_group.add_argument("--project", help="Path to .xcodeproj file")
|
||||||
|
project_group.add_argument("--workspace", help="Path to .xcworkspace file")
|
||||||
|
|
||||||
|
build_group.add_argument("--scheme", help="Build scheme (auto-detected if not specified)")
|
||||||
|
build_group.add_argument(
|
||||||
|
"--configuration",
|
||||||
|
default="Debug",
|
||||||
|
choices=["Debug", "Release"],
|
||||||
|
help="Build configuration (default: Debug)",
|
||||||
|
)
|
||||||
|
build_group.add_argument("--simulator", help="Simulator name (default: iPhone 15)")
|
||||||
|
build_group.add_argument("--clean", action="store_true", help="Clean before building")
|
||||||
|
build_group.add_argument("--test", action="store_true", help="Run tests")
|
||||||
|
build_group.add_argument("--suite", help="Specific test suite to run")
|
||||||
|
|
||||||
|
# Progressive disclosure arguments
|
||||||
|
disclosure_group = parser.add_argument_group("Progressive Disclosure Options")
|
||||||
|
disclosure_group.add_argument(
|
||||||
|
"--get-errors", metavar="XCRESULT_ID", help="Get error details from xcresult"
|
||||||
|
)
|
||||||
|
disclosure_group.add_argument(
|
||||||
|
"--get-warnings", metavar="XCRESULT_ID", help="Get warning details from xcresult"
|
||||||
|
)
|
||||||
|
disclosure_group.add_argument(
|
||||||
|
"--get-log", metavar="XCRESULT_ID", help="Get build log from xcresult"
|
||||||
|
)
|
||||||
|
disclosure_group.add_argument(
|
||||||
|
"--get-all", metavar="XCRESULT_ID", help="Get all details from xcresult"
|
||||||
|
)
|
||||||
|
disclosure_group.add_argument(
|
||||||
|
"--list-xcresults", action="store_true", help="List recent xcresult bundles"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Output options
|
||||||
|
output_group = parser.add_argument_group("Output Options")
|
||||||
|
output_group.add_argument("--verbose", action="store_true", help="Show detailed output")
|
||||||
|
output_group.add_argument("--json", action="store_true", help="Output as JSON")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Initialize cache
|
||||||
|
cache = XCResultCache()
|
||||||
|
|
||||||
|
# Handle list mode
|
||||||
|
if args.list_xcresults:
|
||||||
|
xcresults = cache.list()
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(json.dumps(xcresults, indent=2))
|
||||||
|
elif not xcresults:
|
||||||
|
print("No xcresult bundles found")
|
||||||
|
else:
|
||||||
|
print(f"Recent XCResult bundles ({len(xcresults)}):")
|
||||||
|
print()
|
||||||
|
for xc in xcresults:
|
||||||
|
print(f" {xc['id']}")
|
||||||
|
print(f" Created: {xc['created']}")
|
||||||
|
print(f" Size: {xc['size_mb']} MB")
|
||||||
|
print()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Handle retrieval modes
|
||||||
|
xcresult_id = args.get_errors or args.get_warnings or args.get_log or args.get_all
|
||||||
|
|
||||||
|
if xcresult_id:
|
||||||
|
xcresult_path = cache.get_path(xcresult_id)
|
||||||
|
|
||||||
|
if not xcresult_path or not xcresult_path.exists():
|
||||||
|
print(f"Error: XCResult bundle not found: {xcresult_id}", file=sys.stderr)
|
||||||
|
print("Use --list-xcresults to see available bundles", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Load cached stderr for progressive disclosure
|
||||||
|
cached_stderr = cache.get_stderr(xcresult_id)
|
||||||
|
parser = XCResultParser(xcresult_path, stderr=cached_stderr)
|
||||||
|
|
||||||
|
# Get errors
|
||||||
|
if args.get_errors:
|
||||||
|
errors = parser.get_errors()
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(json.dumps(errors, indent=2))
|
||||||
|
else:
|
||||||
|
print(OutputFormatter.format_errors(errors))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Get warnings
|
||||||
|
if args.get_warnings:
|
||||||
|
warnings = parser.get_warnings()
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(json.dumps(warnings, indent=2))
|
||||||
|
else:
|
||||||
|
print(OutputFormatter.format_warnings(warnings))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Get log
|
||||||
|
if args.get_log:
|
||||||
|
log = parser.get_build_log()
|
||||||
|
if log:
|
||||||
|
print(OutputFormatter.format_log(log))
|
||||||
|
else:
|
||||||
|
print("No build log available", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Get all
|
||||||
|
if args.get_all:
|
||||||
|
error_count, warning_count = parser.count_issues()
|
||||||
|
errors = parser.get_errors()
|
||||||
|
warnings = parser.get_warnings()
|
||||||
|
build_log = parser.get_build_log()
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"xcresult_id": xcresult_id,
|
||||||
|
"error_count": error_count,
|
||||||
|
"warning_count": warning_count,
|
||||||
|
"errors": errors,
|
||||||
|
"warnings": warnings,
|
||||||
|
"log_preview": build_log[:1000] if build_log else None,
|
||||||
|
}
|
||||||
|
print(json.dumps(data, indent=2))
|
||||||
|
else:
|
||||||
|
print(f"XCResult: {xcresult_id}")
|
||||||
|
print(f"Errors: {error_count}, Warnings: {warning_count}")
|
||||||
|
print()
|
||||||
|
if errors:
|
||||||
|
print(OutputFormatter.format_errors(errors, limit=10))
|
||||||
|
print()
|
||||||
|
if warnings:
|
||||||
|
print(OutputFormatter.format_warnings(warnings, limit=10))
|
||||||
|
print()
|
||||||
|
if build_log:
|
||||||
|
print("Build Log (last 30 lines):")
|
||||||
|
print(OutputFormatter.format_log(build_log, lines=30))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Build/test mode
|
||||||
|
if not args.project and not args.workspace:
|
||||||
|
# Try to auto-detect in current directory
|
||||||
|
cwd = Path.cwd()
|
||||||
|
projects = list(cwd.glob("*.xcodeproj"))
|
||||||
|
workspaces = list(cwd.glob("*.xcworkspace"))
|
||||||
|
|
||||||
|
if workspaces:
|
||||||
|
args.workspace = str(workspaces[0])
|
||||||
|
elif projects:
|
||||||
|
args.project = str(projects[0])
|
||||||
|
else:
|
||||||
|
parser.error("No project or workspace specified and none found in current directory")
|
||||||
|
|
||||||
|
# Initialize builder
|
||||||
|
builder = BuildRunner(
|
||||||
|
project_path=args.project,
|
||||||
|
workspace_path=args.workspace,
|
||||||
|
scheme=args.scheme,
|
||||||
|
configuration=args.configuration,
|
||||||
|
simulator=args.simulator,
|
||||||
|
cache=cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute build or test
|
||||||
|
if args.test:
|
||||||
|
success, xcresult_id, stderr = builder.test(test_suite=args.suite)
|
||||||
|
else:
|
||||||
|
success, xcresult_id, stderr = builder.build(clean=args.clean)
|
||||||
|
|
||||||
|
if not xcresult_id and not stderr:
|
||||||
|
print("Error: Build/test failed without creating xcresult or error output", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Save stderr to cache for progressive disclosure
|
||||||
|
if xcresult_id and stderr:
|
||||||
|
cache.save_stderr(xcresult_id, stderr)
|
||||||
|
|
||||||
|
# Parse results
|
||||||
|
xcresult_path = cache.get_path(xcresult_id) if xcresult_id else None
|
||||||
|
parser = XCResultParser(xcresult_path, stderr=stderr)
|
||||||
|
error_count, warning_count = parser.count_issues()
|
||||||
|
|
||||||
|
# Format output
|
||||||
|
status = "SUCCESS" if success else "FAILED"
|
||||||
|
|
||||||
|
# Generate hints for failed builds
|
||||||
|
hints = None
|
||||||
|
if not success:
|
||||||
|
errors = parser.get_errors()
|
||||||
|
hints = OutputFormatter.generate_hints(errors)
|
||||||
|
|
||||||
|
if args.verbose:
|
||||||
|
# Verbose mode with error/warning details
|
||||||
|
errors = parser.get_errors() if error_count > 0 else None
|
||||||
|
warnings = parser.get_warnings() if warning_count > 0 else None
|
||||||
|
|
||||||
|
output = OutputFormatter.format_verbose(
|
||||||
|
status=status,
|
||||||
|
error_count=error_count,
|
||||||
|
warning_count=warning_count,
|
||||||
|
xcresult_id=xcresult_id or "N/A",
|
||||||
|
errors=errors,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
print(output)
|
||||||
|
elif args.json:
|
||||||
|
# JSON mode
|
||||||
|
data = {
|
||||||
|
"success": success,
|
||||||
|
"xcresult_id": xcresult_id or None,
|
||||||
|
"error_count": error_count,
|
||||||
|
"warning_count": warning_count,
|
||||||
|
}
|
||||||
|
if hints:
|
||||||
|
data["hints"] = hints
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(json.dumps(data, indent=2))
|
||||||
|
else:
|
||||||
|
# Minimal mode (default)
|
||||||
|
output = OutputFormatter.format_minimal(
|
||||||
|
status=status,
|
||||||
|
error_count=error_count,
|
||||||
|
warning_count=warning_count,
|
||||||
|
xcresult_id=xcresult_id or "N/A",
|
||||||
|
hints=hints,
|
||||||
|
)
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
# Exit with appropriate code
|
||||||
|
return 0 if success else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
iOS Simulator Clipboard Manager
|
||||||
|
|
||||||
|
Copy text to simulator clipboard for testing paste flows.
|
||||||
|
Optimized for minimal token output.
|
||||||
|
|
||||||
|
Usage: python scripts/clipboard.py --copy "text to copy"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from common import resolve_udid
|
||||||
|
|
||||||
|
|
||||||
|
class ClipboardManager:
|
||||||
|
"""Manages clipboard operations on iOS simulator."""
|
||||||
|
|
||||||
|
def __init__(self, udid: str | None = None):
|
||||||
|
"""Initialize clipboard manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
udid: Optional device UDID (auto-detects booted simulator if None)
|
||||||
|
"""
|
||||||
|
self.udid = udid
|
||||||
|
|
||||||
|
def copy(self, text: str) -> bool:
|
||||||
|
"""
|
||||||
|
Copy text to simulator clipboard.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to copy to clipboard
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
cmd = ["xcrun", "simctl", "pbcopy"]
|
||||||
|
|
||||||
|
if self.udid:
|
||||||
|
cmd.append(self.udid)
|
||||||
|
else:
|
||||||
|
cmd.append("booted")
|
||||||
|
|
||||||
|
cmd.append(text)
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Copy text to iOS simulator clipboard")
|
||||||
|
parser.add_argument("--copy", required=True, help="Text to copy to clipboard")
|
||||||
|
parser.add_argument(
|
||||||
|
"--udid",
|
||||||
|
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--test-name", help="Test scenario name for tracking")
|
||||||
|
parser.add_argument("--expected", help="Expected behavior after paste")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Resolve UDID with auto-detection
|
||||||
|
try:
|
||||||
|
udid = resolve_udid(args.udid)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Create manager and copy text
|
||||||
|
manager = ClipboardManager(udid=udid)
|
||||||
|
|
||||||
|
if manager.copy(args.copy):
|
||||||
|
# Token-efficient output
|
||||||
|
output = f'Copied: "{args.copy}"'
|
||||||
|
|
||||||
|
if args.test_name:
|
||||||
|
output += f" (test: {args.test_name})"
|
||||||
|
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
# Provide usage guidance
|
||||||
|
if args.expected:
|
||||||
|
print(f"Expected: {args.expected}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Next steps:")
|
||||||
|
print("1. Tap text field with: python scripts/navigator.py --find-type TextField --tap")
|
||||||
|
print("2. Paste with: python scripts/keyboard.py --key return")
|
||||||
|
print(" Or use Cmd+V gesture with: python scripts/keyboard.py --key cmd+v")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Failed to copy text to clipboard")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Common utilities shared across iOS simulator scripts.
|
||||||
|
|
||||||
|
This module centralizes genuinely reused code patterns to eliminate duplication
|
||||||
|
while respecting Jackson's Law - no over-abstraction, only truly shared logic.
|
||||||
|
|
||||||
|
Organization:
|
||||||
|
- device_utils: Device detection, command building, coordinate transformation
|
||||||
|
- idb_utils: IDB-specific operations (accessibility tree, element manipulation)
|
||||||
|
- cache_utils: Progressive disclosure caching for large outputs
|
||||||
|
- screenshot_utils: Screenshot capture with file and inline modes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .cache_utils import ProgressiveCache, get_cache
|
||||||
|
from .device_utils import (
|
||||||
|
build_idb_command,
|
||||||
|
build_simctl_command,
|
||||||
|
get_booted_device_udid,
|
||||||
|
get_device_screen_size,
|
||||||
|
resolve_udid,
|
||||||
|
transform_screenshot_coords,
|
||||||
|
)
|
||||||
|
from .idb_utils import (
|
||||||
|
count_elements,
|
||||||
|
flatten_tree,
|
||||||
|
get_accessibility_tree,
|
||||||
|
get_screen_size,
|
||||||
|
)
|
||||||
|
from .screenshot_utils import (
|
||||||
|
capture_screenshot,
|
||||||
|
format_screenshot_result,
|
||||||
|
generate_screenshot_name,
|
||||||
|
get_size_preset,
|
||||||
|
resize_screenshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# cache_utils
|
||||||
|
"ProgressiveCache",
|
||||||
|
# device_utils
|
||||||
|
"build_idb_command",
|
||||||
|
"build_simctl_command",
|
||||||
|
# screenshot_utils
|
||||||
|
"capture_screenshot",
|
||||||
|
# idb_utils
|
||||||
|
"count_elements",
|
||||||
|
"flatten_tree",
|
||||||
|
"format_screenshot_result",
|
||||||
|
"generate_screenshot_name",
|
||||||
|
"get_accessibility_tree",
|
||||||
|
"get_booted_device_udid",
|
||||||
|
"get_cache",
|
||||||
|
"get_device_screen_size",
|
||||||
|
"get_screen_size",
|
||||||
|
"get_size_preset",
|
||||||
|
"resize_screenshot",
|
||||||
|
"resolve_udid",
|
||||||
|
"transform_screenshot_coords",
|
||||||
|
]
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Progressive disclosure cache for large outputs.
|
||||||
|
|
||||||
|
Implements cache system to support progressive disclosure pattern:
|
||||||
|
- Return concise summary with cache_id for large outputs
|
||||||
|
- User retrieves full details on demand via cache_id
|
||||||
|
- Reduces token usage by 96% for common queries
|
||||||
|
|
||||||
|
Cache directory: ~/.ios-simulator-skill/cache/
|
||||||
|
Cache expiration: Configurable per cache type (default 1 hour)
|
||||||
|
|
||||||
|
Used by:
|
||||||
|
- sim_list.py - Simulator listing progressive disclosure
|
||||||
|
- Future: build logs, UI trees, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressiveCache:
|
||||||
|
"""Cache for progressive disclosure pattern.
|
||||||
|
|
||||||
|
Stores large outputs with timestamped IDs for on-demand retrieval.
|
||||||
|
Automatically cleans up expired entries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cache_dir: str | None = None, max_age_hours: int = 1):
|
||||||
|
"""Initialize cache system.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_dir: Cache directory path (default: ~/.ios-simulator-skill/cache/)
|
||||||
|
max_age_hours: Max age for cache entries before expiration (default: 1 hour)
|
||||||
|
"""
|
||||||
|
if cache_dir is None:
|
||||||
|
cache_dir = str(Path("~/.ios-simulator-skill/cache").expanduser())
|
||||||
|
|
||||||
|
self.cache_dir = Path(cache_dir)
|
||||||
|
self.max_age_hours = max_age_hours
|
||||||
|
|
||||||
|
# Create cache directory if needed
|
||||||
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def save(self, data: dict[str, Any], cache_type: str) -> str:
|
||||||
|
"""Save data to cache and return cache_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Dictionary data to cache
|
||||||
|
cache_type: Type of cache ('simulator-list', 'build-log', 'ui-tree', etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cache ID like 'sim-20251028-143052' for use in progressive disclosure
|
||||||
|
|
||||||
|
Example:
|
||||||
|
cache_id = cache.save({'devices': [...]}, 'simulator-list')
|
||||||
|
# Returns: 'sim-20251028-143052'
|
||||||
|
"""
|
||||||
|
# Generate cache_id with timestamp
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
cache_prefix = cache_type.split("-")[0] # e.g., 'sim' from 'simulator-list'
|
||||||
|
cache_id = f"{cache_prefix}-{timestamp}"
|
||||||
|
|
||||||
|
# Save to file
|
||||||
|
cache_file = self.cache_dir / f"{cache_id}.json"
|
||||||
|
with open(cache_file, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"cache_id": cache_id,
|
||||||
|
"cache_type": cache_type,
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"data": data,
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
return cache_id
|
||||||
|
|
||||||
|
def get(self, cache_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Retrieve data from cache by cache_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_id: Cache ID from save() or list_entries()
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cached data dictionary, or None if not found/expired
|
||||||
|
|
||||||
|
Example:
|
||||||
|
data = cache.get('sim-20251028-143052')
|
||||||
|
if data:
|
||||||
|
print(f"Found {len(data)} devices")
|
||||||
|
"""
|
||||||
|
cache_file = self.cache_dir / f"{cache_id}.json"
|
||||||
|
|
||||||
|
if not cache_file.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if expired
|
||||||
|
if self._is_expired(cache_file):
|
||||||
|
cache_file.unlink() # Delete expired file
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(cache_file) as f:
|
||||||
|
entry = json.load(f)
|
||||||
|
return entry.get("data")
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_entries(self, cache_type: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""List available cache entries with metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_type: Filter by type (e.g., 'simulator-list'), or None for all
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of cache entries with id, type, created_at, age_seconds
|
||||||
|
|
||||||
|
Example:
|
||||||
|
entries = cache.list_entries('simulator-list')
|
||||||
|
for entry in entries:
|
||||||
|
print(f"{entry['id']} - {entry['age_seconds']}s old")
|
||||||
|
"""
|
||||||
|
entries = []
|
||||||
|
|
||||||
|
for cache_file in sorted(self.cache_dir.glob("*.json"), reverse=True):
|
||||||
|
# Check if expired
|
||||||
|
if self._is_expired(cache_file):
|
||||||
|
cache_file.unlink()
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(cache_file) as f:
|
||||||
|
entry = json.load(f)
|
||||||
|
|
||||||
|
# Filter by type if specified
|
||||||
|
if cache_type and entry.get("cache_type") != cache_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
created_at = datetime.fromisoformat(entry.get("created_at", ""))
|
||||||
|
age_seconds = (datetime.now() - created_at).total_seconds()
|
||||||
|
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"id": entry.get("cache_id"),
|
||||||
|
"type": entry.get("cache_type"),
|
||||||
|
"created_at": entry.get("created_at"),
|
||||||
|
"age_seconds": int(age_seconds),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (OSError, json.JSONDecodeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def cleanup(self, max_age_hours: int | None = None) -> int:
|
||||||
|
"""Remove expired cache entries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_age_hours: Age threshold (default: uses instance max_age_hours)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of entries deleted
|
||||||
|
|
||||||
|
Example:
|
||||||
|
deleted = cache.cleanup()
|
||||||
|
print(f"Deleted {deleted} expired cache entries")
|
||||||
|
"""
|
||||||
|
if max_age_hours is None:
|
||||||
|
max_age_hours = self.max_age_hours
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
|
||||||
|
for cache_file in self.cache_dir.glob("*.json"):
|
||||||
|
if self._is_expired(cache_file, max_age_hours):
|
||||||
|
cache_file.unlink()
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def clear(self, cache_type: str | None = None) -> int:
|
||||||
|
"""Clear all cache entries of a type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_type: Type to clear (e.g., 'simulator-list'), or None to clear all
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of entries deleted
|
||||||
|
|
||||||
|
Example:
|
||||||
|
cleared = cache.clear('simulator-list')
|
||||||
|
print(f"Cleared {cleared} simulator list entries")
|
||||||
|
"""
|
||||||
|
deleted = 0
|
||||||
|
|
||||||
|
for cache_file in self.cache_dir.glob("*.json"):
|
||||||
|
if cache_type is None:
|
||||||
|
# Clear all
|
||||||
|
cache_file.unlink()
|
||||||
|
deleted += 1
|
||||||
|
else:
|
||||||
|
# Clear by type
|
||||||
|
try:
|
||||||
|
with open(cache_file) as f:
|
||||||
|
entry = json.load(f)
|
||||||
|
if entry.get("cache_type") == cache_type:
|
||||||
|
cache_file.unlink()
|
||||||
|
deleted += 1
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def _is_expired(self, cache_file: Path, max_age_hours: int | None = None) -> bool:
|
||||||
|
"""Check if cache file is expired.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_file: Path to cache file
|
||||||
|
max_age_hours: Age threshold (default: uses instance max_age_hours)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if file is older than max_age_hours
|
||||||
|
"""
|
||||||
|
if max_age_hours is None:
|
||||||
|
max_age_hours = self.max_age_hours
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(cache_file) as f:
|
||||||
|
entry = json.load(f)
|
||||||
|
created_at = datetime.fromisoformat(entry.get("created_at", ""))
|
||||||
|
age = datetime.now() - created_at
|
||||||
|
return age > timedelta(hours=max_age_hours)
|
||||||
|
except (OSError, json.JSONDecodeError, ValueError):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level cache instances (lazy-loaded)
|
||||||
|
_cache_instances: dict[str, ProgressiveCache] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_cache(cache_dir: str | None = None) -> ProgressiveCache:
|
||||||
|
"""Get or create global cache instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_dir: Custom cache directory (uses default if None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProgressiveCache instance
|
||||||
|
"""
|
||||||
|
# Use cache_dir as key, or 'default' if None
|
||||||
|
key = cache_dir or "default"
|
||||||
|
|
||||||
|
if key not in _cache_instances:
|
||||||
|
_cache_instances[key] = ProgressiveCache(cache_dir)
|
||||||
|
|
||||||
|
return _cache_instances[key]
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Shared device and simulator utilities.
|
||||||
|
|
||||||
|
Common patterns for interacting with simulators via xcrun simctl and IDB.
|
||||||
|
Standardizes command building and device targeting to prevent errors.
|
||||||
|
|
||||||
|
Follows Jackson's Law - only extracts genuinely reused patterns.
|
||||||
|
|
||||||
|
Used by:
|
||||||
|
- app_launcher.py (8 call sites) - App lifecycle commands
|
||||||
|
- Multiple scripts (15+ locations) - IDB command building
|
||||||
|
- navigator.py, gesture.py - Coordinate transformation
|
||||||
|
- test_recorder.py, app_state_capture.py - Auto-UDID detection
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def build_simctl_command(
|
||||||
|
operation: str,
|
||||||
|
udid: str | None = None,
|
||||||
|
*args,
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Build xcrun simctl command with proper device handling.
|
||||||
|
|
||||||
|
Standardizes command building to prevent device targeting bugs.
|
||||||
|
Automatically uses "booted" if no UDID provided.
|
||||||
|
|
||||||
|
Used by:
|
||||||
|
- app_launcher.py: launch, terminate, install, uninstall, openurl, listapps, spawn
|
||||||
|
- Multiple scripts: generic simctl operations
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation: simctl operation (launch, terminate, install, etc.)
|
||||||
|
udid: Device UDID (uses 'booted' if None)
|
||||||
|
*args: Additional command arguments
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Complete command list ready for subprocess.run()
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Launch app on booted simulator
|
||||||
|
cmd = build_simctl_command("launch", None, "com.app.bundle")
|
||||||
|
# Returns: ["xcrun", "simctl", "launch", "booted", "com.app.bundle"]
|
||||||
|
|
||||||
|
# Launch on specific device
|
||||||
|
cmd = build_simctl_command("launch", "ABC123", "com.app.bundle")
|
||||||
|
# Returns: ["xcrun", "simctl", "launch", "ABC123", "com.app.bundle"]
|
||||||
|
|
||||||
|
# Install app on specific device
|
||||||
|
cmd = build_simctl_command("install", "ABC123", "/path/to/app.app")
|
||||||
|
# Returns: ["xcrun", "simctl", "install", "ABC123", "/path/to/app.app"]
|
||||||
|
"""
|
||||||
|
cmd = ["xcrun", "simctl", operation]
|
||||||
|
|
||||||
|
# Add device (booted or specific UDID)
|
||||||
|
cmd.append(udid if udid else "booted")
|
||||||
|
|
||||||
|
# Add remaining arguments
|
||||||
|
cmd.extend(str(arg) for arg in args)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def build_idb_command(
|
||||||
|
operation: str,
|
||||||
|
udid: str | None = None,
|
||||||
|
*args,
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Build IDB command with proper device targeting.
|
||||||
|
|
||||||
|
Standardizes IDB command building across all scripts using IDB.
|
||||||
|
Handles device UDID consistently.
|
||||||
|
|
||||||
|
Used by:
|
||||||
|
- navigator.py: ui tap, ui text, ui describe-all
|
||||||
|
- gesture.py: ui swipe, ui tap
|
||||||
|
- keyboard.py: ui key, ui text, ui tap
|
||||||
|
- And more: 15+ locations
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation: IDB operation path (e.g., "ui tap", "ui text", "ui describe-all")
|
||||||
|
udid: Device UDID (omits --udid flag if None, IDB uses booted by default)
|
||||||
|
*args: Additional command arguments
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Complete command list ready for subprocess.run()
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Tap on booted simulator
|
||||||
|
cmd = build_idb_command("ui tap", None, "200", "400")
|
||||||
|
# Returns: ["idb", "ui", "tap", "200", "400"]
|
||||||
|
|
||||||
|
# Tap on specific device
|
||||||
|
cmd = build_idb_command("ui tap", "ABC123", "200", "400")
|
||||||
|
# Returns: ["idb", "ui", "tap", "200", "400", "--udid", "ABC123"]
|
||||||
|
|
||||||
|
# Get accessibility tree
|
||||||
|
cmd = build_idb_command("ui describe-all", "ABC123", "--json", "--nested")
|
||||||
|
# Returns: ["idb", "ui", "describe-all", "--json", "--nested", "--udid", "ABC123"]
|
||||||
|
|
||||||
|
# Enter text
|
||||||
|
cmd = build_idb_command("ui text", None, "hello world")
|
||||||
|
# Returns: ["idb", "ui", "text", "hello world"]
|
||||||
|
"""
|
||||||
|
# Split operation into parts (e.g., "ui tap" -> ["ui", "tap"])
|
||||||
|
cmd = ["idb"] + operation.split()
|
||||||
|
|
||||||
|
# Add arguments
|
||||||
|
cmd.extend(str(arg) for arg in args)
|
||||||
|
|
||||||
|
# Add device targeting if specified (optional for IDB, uses booted by default)
|
||||||
|
if udid:
|
||||||
|
cmd.extend(["--udid", udid])
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def get_booted_device_udid() -> str | None:
|
||||||
|
"""
|
||||||
|
Auto-detect currently booted simulator UDID.
|
||||||
|
|
||||||
|
Queries xcrun simctl for booted devices and returns first match.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UDID of booted simulator, or None if no simulator is booted.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
udid = get_booted_device_udid()
|
||||||
|
if udid:
|
||||||
|
print(f"Booted simulator: {udid}")
|
||||||
|
else:
|
||||||
|
print("No simulator is currently booted")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["xcrun", "simctl", "list", "devices", "booted"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse output to find UDID
|
||||||
|
# Format: " iPhone 16 Pro (ABC123-DEF456) (Booted)"
|
||||||
|
for line in result.stdout.split("\n"):
|
||||||
|
# Look for UUID pattern in parentheses
|
||||||
|
match = re.search(r"\(([A-F0-9\-]{36})\)", line)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
return None
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_udid(udid_arg: str | None) -> str:
|
||||||
|
"""
|
||||||
|
Resolve device UDID with auto-detection fallback.
|
||||||
|
|
||||||
|
If udid_arg is provided, returns it immediately.
|
||||||
|
If None, attempts to auto-detect booted simulator.
|
||||||
|
Raises error if neither is available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
udid_arg: Explicit UDID from command line, or None
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Valid UDID string
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If no UDID provided and no booted simulator found
|
||||||
|
|
||||||
|
Example:
|
||||||
|
try:
|
||||||
|
udid = resolve_udid(args.udid) # args.udid might be None
|
||||||
|
print(f"Using device: {udid}")
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
"""
|
||||||
|
if udid_arg:
|
||||||
|
return udid_arg
|
||||||
|
|
||||||
|
booted_udid = get_booted_device_udid()
|
||||||
|
if booted_udid:
|
||||||
|
return booted_udid
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
"No device UDID provided and no simulator is currently booted.\n"
|
||||||
|
"Boot a simulator or provide --udid explicitly:\n"
|
||||||
|
" xcrun simctl boot <device-name>\n"
|
||||||
|
" python scripts/script_name.py --udid <device-udid>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_device_screen_size(udid: str) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Get actual screen dimensions for device via accessibility tree.
|
||||||
|
|
||||||
|
Queries IDB accessibility tree to determine actual device resolution.
|
||||||
|
Falls back to iPhone 14 defaults (390x844) if detection fails.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
udid: Device UDID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (width, height) in pixels
|
||||||
|
|
||||||
|
Example:
|
||||||
|
width, height = get_device_screen_size("ABC123")
|
||||||
|
print(f"Device screen: {width}x{height}")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cmd = build_idb_command("ui describe-all", udid, "--json")
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
# Parse JSON response
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
tree = data[0] if isinstance(data, list) and len(data) > 0 else data
|
||||||
|
|
||||||
|
# Get frame size from root element
|
||||||
|
if tree and "frame" in tree:
|
||||||
|
frame = tree["frame"]
|
||||||
|
width = int(frame.get("width", 390))
|
||||||
|
height = int(frame.get("height", 844))
|
||||||
|
return (width, height)
|
||||||
|
|
||||||
|
# Fallback
|
||||||
|
return (390, 844)
|
||||||
|
except Exception:
|
||||||
|
# Graceful fallback to iPhone 14 Pro defaults
|
||||||
|
return (390, 844)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_device_identifier(identifier: str) -> str:
|
||||||
|
"""
|
||||||
|
Resolve device name or partial UDID to full UDID.
|
||||||
|
|
||||||
|
Supports multiple identifier formats:
|
||||||
|
- Full UDID: "ABC-123-DEF456..." (36 character UUID)
|
||||||
|
- Device name: "iPhone 16 Pro" (matches full name)
|
||||||
|
- Partial match: "iPhone 16" (matches first device containing this string)
|
||||||
|
- Special: "booted" (resolves to currently booted device)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
identifier: Device UDID, name, or special value "booted"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full device UDID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If identifier cannot be resolved
|
||||||
|
|
||||||
|
Example:
|
||||||
|
udid = resolve_device_identifier("iPhone 16 Pro")
|
||||||
|
# Returns: "ABC123DEF456..."
|
||||||
|
|
||||||
|
udid = resolve_device_identifier("booted")
|
||||||
|
# Returns UDID of booted simulator
|
||||||
|
"""
|
||||||
|
# Handle "booted" special case
|
||||||
|
if identifier.lower() == "booted":
|
||||||
|
booted = get_booted_device_udid()
|
||||||
|
if booted:
|
||||||
|
return booted
|
||||||
|
raise RuntimeError(
|
||||||
|
"No simulator is currently booted. "
|
||||||
|
"Boot a simulator first: xcrun simctl boot <device-udid>"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if already a full UDID (36 character UUID format)
|
||||||
|
if re.match(r"^[A-F0-9\-]{36}$", identifier, re.IGNORECASE):
|
||||||
|
return identifier.upper()
|
||||||
|
|
||||||
|
# Try to match by device name
|
||||||
|
simulators = list_simulators(state=None)
|
||||||
|
exact_matches = [s for s in simulators if s["name"].lower() == identifier.lower()]
|
||||||
|
if exact_matches:
|
||||||
|
return exact_matches[0]["udid"]
|
||||||
|
|
||||||
|
# Try partial match
|
||||||
|
partial_matches = [s for s in simulators if identifier.lower() in s["name"].lower()]
|
||||||
|
if partial_matches:
|
||||||
|
return partial_matches[0]["udid"]
|
||||||
|
|
||||||
|
# No match found
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Device '{identifier}' not found. "
|
||||||
|
f"Use 'xcrun simctl list devices' to see available simulators."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_simulators(state: str | None = None) -> list[dict]:
|
||||||
|
"""
|
||||||
|
List iOS simulators with optional state filtering.
|
||||||
|
|
||||||
|
Queries xcrun simctl and returns structured list of simulators.
|
||||||
|
Optionally filters by state (available, booted, all).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: Optional filter - "available", "booted", or None for all
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of simulator dicts with keys:
|
||||||
|
- "name": Device name (e.g., "iPhone 16 Pro")
|
||||||
|
- "udid": Device UDID (36 char UUID)
|
||||||
|
- "state": Device state ("Booted", "Shutdown", "Unavailable")
|
||||||
|
- "runtime": iOS version (e.g., "iOS 18.0", "unavailable")
|
||||||
|
- "type": Device type ("iPhone", "iPad", "Apple Watch", etc.)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
# List all simulators
|
||||||
|
all_sims = list_simulators()
|
||||||
|
print(f"Total simulators: {len(all_sims)}")
|
||||||
|
|
||||||
|
# List only available simulators
|
||||||
|
available = list_simulators(state="available")
|
||||||
|
for sim in available:
|
||||||
|
print(f"{sim['name']} ({sim['state']}) - {sim['udid']}")
|
||||||
|
|
||||||
|
# List only booted simulators
|
||||||
|
booted = list_simulators(state="booted")
|
||||||
|
for sim in booted:
|
||||||
|
print(f"Booted: {sim['name']}")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Query simctl for device list
|
||||||
|
cmd = ["xcrun", "simctl", "list", "devices", "-j"]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
simulators = []
|
||||||
|
|
||||||
|
# Parse JSON response
|
||||||
|
# Format: {"devices": {"iOS 18.0": [{...}, {...}], "iOS 17.0": [...], ...}}
|
||||||
|
for ios_version, devices in data.get("devices", {}).items():
|
||||||
|
for device in devices:
|
||||||
|
sim = {
|
||||||
|
"name": device.get("name", "Unknown"),
|
||||||
|
"udid": device.get("udid", ""),
|
||||||
|
"state": device.get("state", "Unknown"),
|
||||||
|
"runtime": ios_version,
|
||||||
|
"type": _extract_device_type(device.get("name", "")),
|
||||||
|
}
|
||||||
|
simulators.append(sim)
|
||||||
|
|
||||||
|
# Apply state filtering
|
||||||
|
if state == "booted":
|
||||||
|
return [s for s in simulators if s["state"] == "Booted"]
|
||||||
|
if state == "available":
|
||||||
|
return [s for s in simulators if s["state"] == "Shutdown"] # Available to boot
|
||||||
|
if state is None:
|
||||||
|
return simulators
|
||||||
|
return [s for s in simulators if s["state"].lower() == state.lower()]
|
||||||
|
|
||||||
|
except (subprocess.CalledProcessError, json.JSONDecodeError, KeyError) as e:
|
||||||
|
raise RuntimeError(f"Failed to list simulators: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_device_type(device_name: str) -> str:
|
||||||
|
"""
|
||||||
|
Extract device type from device name.
|
||||||
|
|
||||||
|
Parses device name to determine type (iPhone, iPad, Watch, etc.).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_name: Full device name (e.g., "iPhone 16 Pro")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Device type string
|
||||||
|
|
||||||
|
Example:
|
||||||
|
_extract_device_type("iPhone 16 Pro") # Returns "iPhone"
|
||||||
|
_extract_device_type("iPad Air") # Returns "iPad"
|
||||||
|
_extract_device_type("Apple Watch Series 9") # Returns "Watch"
|
||||||
|
"""
|
||||||
|
if "iPhone" in device_name:
|
||||||
|
return "iPhone"
|
||||||
|
if "iPad" in device_name:
|
||||||
|
return "iPad"
|
||||||
|
if "Watch" in device_name or "Apple Watch" in device_name:
|
||||||
|
return "Watch"
|
||||||
|
if "TV" in device_name or "Apple TV" in device_name:
|
||||||
|
return "TV"
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def transform_screenshot_coords(
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
screenshot_width: int,
|
||||||
|
screenshot_height: int,
|
||||||
|
device_width: int,
|
||||||
|
device_height: int,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Transform screenshot coordinates to device coordinates.
|
||||||
|
|
||||||
|
Handles the case where a screenshot was downscaled (e.g., to 'half' size)
|
||||||
|
and needs to be transformed back to actual device pixel coordinates
|
||||||
|
for accurate tapping.
|
||||||
|
|
||||||
|
The transformation is linear:
|
||||||
|
device_x = (screenshot_x / screenshot_width) * device_width
|
||||||
|
device_y = (screenshot_y / screenshot_height) * device_height
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x, y: Coordinates in the screenshot
|
||||||
|
screenshot_width, screenshot_height: Screenshot dimensions (e.g., 195, 422)
|
||||||
|
device_width, device_height: Actual device dimensions (e.g., 390, 844)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (device_x, device_y) in device pixels
|
||||||
|
|
||||||
|
Example:
|
||||||
|
# Screenshot taken at 'half' size: 195x422 (from 390x844 device)
|
||||||
|
device_x, device_y = transform_screenshot_coords(
|
||||||
|
100, 200, # Tap point in screenshot
|
||||||
|
195, 422, # Screenshot dimensions
|
||||||
|
390, 844 # Device dimensions
|
||||||
|
)
|
||||||
|
print(f"Tap at device coords: ({device_x}, {device_y})")
|
||||||
|
# Output: Tap at device coords: (200, 400)
|
||||||
|
"""
|
||||||
|
device_x = int((x / screenshot_width) * device_width)
|
||||||
|
device_y = int((y / screenshot_height) * device_height)
|
||||||
|
return (device_x, device_y)
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Shared IDB utility functions.
|
||||||
|
|
||||||
|
This module provides common IDB operations used across multiple scripts.
|
||||||
|
Follows Jackson's Law - only shared code that's truly reused, not speculative.
|
||||||
|
|
||||||
|
Used by:
|
||||||
|
- navigator.py - Accessibility tree navigation
|
||||||
|
- screen_mapper.py - UI element analysis
|
||||||
|
- accessibility_audit.py - WCAG compliance checking
|
||||||
|
- test_recorder.py - Test documentation
|
||||||
|
- app_state_capture.py - State snapshots
|
||||||
|
- gesture.py - Touch gesture operations
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def get_accessibility_tree(udid: str | None = None, nested: bool = True) -> dict:
|
||||||
|
"""
|
||||||
|
Fetch accessibility tree from IDB.
|
||||||
|
|
||||||
|
The accessibility tree represents the complete UI hierarchy of the current
|
||||||
|
screen, with all element properties needed for semantic navigation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
udid: Device UDID (uses booted simulator if None)
|
||||||
|
nested: Include nested structure (default True). If False, returns flat array.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Root element of accessibility tree as dict.
|
||||||
|
Structure: {
|
||||||
|
"type": "Window",
|
||||||
|
"AXLabel": "App Name",
|
||||||
|
"frame": {"x": 0, "y": 0, "width": 390, "height": 844},
|
||||||
|
"children": [...]
|
||||||
|
}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If IDB command fails or returns invalid JSON
|
||||||
|
|
||||||
|
Example:
|
||||||
|
tree = get_accessibility_tree("UDID123")
|
||||||
|
# Root is Window element with all children nested
|
||||||
|
"""
|
||||||
|
cmd = ["idb", "ui", "describe-all", "--json"]
|
||||||
|
if nested:
|
||||||
|
cmd.append("--nested")
|
||||||
|
if udid:
|
||||||
|
cmd.extend(["--udid", udid])
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
tree_data = json.loads(result.stdout)
|
||||||
|
|
||||||
|
# IDB returns array format, extract first element (root)
|
||||||
|
if isinstance(tree_data, list) and len(tree_data) > 0:
|
||||||
|
return tree_data[0]
|
||||||
|
return tree_data
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error: Failed to get accessibility tree: {e.stderr}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print("Error: Invalid JSON from idb", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_tree(node: dict, depth: int = 0, elements: list[dict] | None = None) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Flatten nested accessibility tree into list of elements.
|
||||||
|
|
||||||
|
Converts the hierarchical accessibility tree into a flat list where each
|
||||||
|
element includes its depth for context.
|
||||||
|
|
||||||
|
Used by:
|
||||||
|
- navigator.py - Element finding
|
||||||
|
- screen_mapper.py - Element analysis
|
||||||
|
- accessibility_audit.py - Audit scanning
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node: Root node of tree (typically from get_accessibility_tree)
|
||||||
|
depth: Current depth (used internally, start at 0)
|
||||||
|
elements: Accumulator list (used internally, start as None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Flat list of elements, each with "depth" key indicating nesting level.
|
||||||
|
Structure of each element: {
|
||||||
|
"type": "Button",
|
||||||
|
"AXLabel": "Login",
|
||||||
|
"frame": {...},
|
||||||
|
"depth": 2,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
Example:
|
||||||
|
tree = get_accessibility_tree()
|
||||||
|
flat = flatten_tree(tree)
|
||||||
|
for elem in flat:
|
||||||
|
print(f"{' ' * elem['depth']}{elem.get('type')}: {elem.get('AXLabel')}")
|
||||||
|
"""
|
||||||
|
if elements is None:
|
||||||
|
elements = []
|
||||||
|
|
||||||
|
# Add current node with depth tracking
|
||||||
|
node_copy = node.copy()
|
||||||
|
node_copy["depth"] = depth
|
||||||
|
elements.append(node_copy)
|
||||||
|
|
||||||
|
# Process children recursively
|
||||||
|
for child in node.get("children", []):
|
||||||
|
flatten_tree(child, depth + 1, elements)
|
||||||
|
|
||||||
|
return elements
|
||||||
|
|
||||||
|
|
||||||
|
def count_elements(node: dict) -> int:
|
||||||
|
"""
|
||||||
|
Count total elements in tree (recursive).
|
||||||
|
|
||||||
|
Traverses entire tree counting all elements for reporting purposes.
|
||||||
|
|
||||||
|
Used by:
|
||||||
|
- test_recorder.py - Element counting per step
|
||||||
|
- screen_mapper.py - Summary statistics
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node: Root node of tree
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Total element count including root and all descendants
|
||||||
|
|
||||||
|
Example:
|
||||||
|
tree = get_accessibility_tree()
|
||||||
|
total = count_elements(tree)
|
||||||
|
print(f"Screen has {total} elements")
|
||||||
|
"""
|
||||||
|
count = 1
|
||||||
|
for child in node.get("children", []):
|
||||||
|
count += count_elements(child)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def get_screen_size(udid: str | None = None) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Get screen dimensions from accessibility tree.
|
||||||
|
|
||||||
|
Extracts the screen size from the root element's frame. Useful for
|
||||||
|
gesture calculations and coordinate normalization.
|
||||||
|
|
||||||
|
Used by:
|
||||||
|
- gesture.py - Gesture positioning
|
||||||
|
- Potentially: screenshot positioning, screen-aware scaling
|
||||||
|
|
||||||
|
Args:
|
||||||
|
udid: Device UDID (uses booted if None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(width, height) tuple. Defaults to (390, 844) if detection fails
|
||||||
|
or tree cannot be accessed.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
width, height = get_screen_size()
|
||||||
|
center_x = width // 2
|
||||||
|
center_y = height // 2
|
||||||
|
"""
|
||||||
|
DEFAULT_WIDTH = 390 # iPhone 14
|
||||||
|
DEFAULT_HEIGHT = 844
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = get_accessibility_tree(udid, nested=False)
|
||||||
|
frame = tree.get("frame", {})
|
||||||
|
width = int(frame.get("width", DEFAULT_WIDTH))
|
||||||
|
height = int(frame.get("height", DEFAULT_HEIGHT))
|
||||||
|
return (width, height)
|
||||||
|
except Exception:
|
||||||
|
# Silently fall back to defaults if tree access fails
|
||||||
|
return (DEFAULT_WIDTH, DEFAULT_HEIGHT)
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Screenshot utilities with dual-mode support.
|
||||||
|
|
||||||
|
Provides unified screenshot handling with:
|
||||||
|
- File-based mode: Persistent artifacts for test documentation
|
||||||
|
- Inline base64 mode: Vision-based automation for agent analysis
|
||||||
|
- Size presets: Token optimization (full/half/quarter/thumb)
|
||||||
|
- Semantic naming: {appName}_{screenName}_{state}_{timestamp}.png
|
||||||
|
|
||||||
|
Supports resize operations via PIL (optional dependency).
|
||||||
|
|
||||||
|
Used by:
|
||||||
|
- test_recorder.py - Step-based screenshot recording
|
||||||
|
- app_state_capture.py - State snapshot captures
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Try to import PIL for resizing, but make it optional
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
HAS_PIL = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_PIL = False
|
||||||
|
|
||||||
|
|
||||||
|
def generate_screenshot_name(
|
||||||
|
app_name: str | None = None,
|
||||||
|
screen_name: str | None = None,
|
||||||
|
state: str | None = None,
|
||||||
|
timestamp: str | None = None,
|
||||||
|
extension: str = "png",
|
||||||
|
) -> str:
|
||||||
|
"""Generate semantic screenshot filename.
|
||||||
|
|
||||||
|
Format: {appName}_{screenName}_{state}_{timestamp}.{ext}
|
||||||
|
Falls back to: screenshot_{timestamp}.{ext}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_name: Application name (e.g., 'MyApp')
|
||||||
|
screen_name: Screen name (e.g., 'Login')
|
||||||
|
state: State description (e.g., 'Empty', 'Filled', 'Error')
|
||||||
|
timestamp: ISO timestamp (uses current time if None)
|
||||||
|
extension: File extension (default: 'png')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Semantic filename ready for safe file creation
|
||||||
|
|
||||||
|
Example:
|
||||||
|
name = generate_screenshot_name('MyApp', 'Login', 'Empty')
|
||||||
|
# Returns: 'MyApp_Login_Empty_20251028-143052.png'
|
||||||
|
|
||||||
|
name = generate_screenshot_name()
|
||||||
|
# Returns: 'screenshot_20251028-143052.png'
|
||||||
|
"""
|
||||||
|
if timestamp is None:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
|
||||||
|
# Build semantic name
|
||||||
|
if app_name or screen_name or state:
|
||||||
|
parts = [app_name, screen_name, state]
|
||||||
|
parts = [p for p in parts if p] # Filter None/empty
|
||||||
|
name = "_".join(parts) + f"_{timestamp}"
|
||||||
|
else:
|
||||||
|
name = f"screenshot_{timestamp}"
|
||||||
|
|
||||||
|
return f"{name}.{extension}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_size_preset(size: str = "half") -> tuple[float, float]:
|
||||||
|
"""Get scale factors for size preset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size: 'full', 'half', 'quarter', 'thumb'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (scale_x, scale_y) for resizing
|
||||||
|
|
||||||
|
Example:
|
||||||
|
scale_x, scale_y = get_size_preset('half')
|
||||||
|
# Returns: (0.5, 0.5)
|
||||||
|
"""
|
||||||
|
presets = {
|
||||||
|
"full": (1.0, 1.0),
|
||||||
|
"half": (0.5, 0.5),
|
||||||
|
"quarter": (0.25, 0.25),
|
||||||
|
"thumb": (0.1, 0.1),
|
||||||
|
}
|
||||||
|
return presets.get(size, (0.5, 0.5))
|
||||||
|
|
||||||
|
|
||||||
|
def resize_screenshot(
|
||||||
|
input_path: str,
|
||||||
|
output_path: str | None = None,
|
||||||
|
size: str = "half",
|
||||||
|
quality: int = 85,
|
||||||
|
) -> tuple[str, int, int]:
|
||||||
|
"""Resize screenshot for token optimization.
|
||||||
|
|
||||||
|
Requires PIL (Pillow). Falls back gracefully without it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path: Path to original screenshot
|
||||||
|
output_path: Output path (uses input_path if None)
|
||||||
|
size: 'full', 'half', 'quarter', 'thumb'
|
||||||
|
quality: JPEG quality (1-100, default: 85)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (output_path, width, height) of resized image
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If input file doesn't exist
|
||||||
|
ValueError: If PIL not installed and size != 'full'
|
||||||
|
|
||||||
|
Example:
|
||||||
|
output, w, h = resize_screenshot(
|
||||||
|
'screenshot.png',
|
||||||
|
'screenshot_half.png',
|
||||||
|
'half'
|
||||||
|
)
|
||||||
|
print(f"Resized to {w}x{h}")
|
||||||
|
"""
|
||||||
|
input_file = Path(input_path)
|
||||||
|
if not input_file.exists():
|
||||||
|
raise FileNotFoundError(f"Screenshot not found: {input_path}")
|
||||||
|
|
||||||
|
# If full size, just copy
|
||||||
|
if size == "full":
|
||||||
|
if output_path:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.copy(input_path, output_path)
|
||||||
|
output_file = Path(output_path)
|
||||||
|
else:
|
||||||
|
output_file = input_file
|
||||||
|
|
||||||
|
# Get original dimensions
|
||||||
|
if HAS_PIL:
|
||||||
|
img = Image.open(str(output_file))
|
||||||
|
return (str(output_file), img.width, img.height)
|
||||||
|
return (str(output_file), 0, 0) # Dimensions unknown without PIL
|
||||||
|
|
||||||
|
# Need PIL to resize
|
||||||
|
if not HAS_PIL:
|
||||||
|
raise ValueError(
|
||||||
|
f"Size preset '{size}' requires PIL (Pillow). " "Install with: pip3 install pillow"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Open original image
|
||||||
|
img = Image.open(str(input_file))
|
||||||
|
orig_w, orig_h = img.size
|
||||||
|
|
||||||
|
# Calculate new size
|
||||||
|
scale_x, scale_y = get_size_preset(size)
|
||||||
|
new_w = int(orig_w * scale_x)
|
||||||
|
new_h = int(orig_h * scale_y)
|
||||||
|
|
||||||
|
# Resize with high-quality resampling
|
||||||
|
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Determine output path
|
||||||
|
if output_path is None:
|
||||||
|
# Insert size marker before extension
|
||||||
|
stem = input_file.stem
|
||||||
|
suffix = input_file.suffix
|
||||||
|
output_path = str(input_file.parent / f"{stem}_{size}{suffix}")
|
||||||
|
|
||||||
|
# Save resized image
|
||||||
|
resized.save(output_path, quality=quality, optimize=True)
|
||||||
|
|
||||||
|
return (output_path, new_w, new_h)
|
||||||
|
|
||||||
|
|
||||||
|
def capture_screenshot(
|
||||||
|
udid: str,
|
||||||
|
output_path: str | None = None,
|
||||||
|
size: str = "half",
|
||||||
|
inline: bool = False,
|
||||||
|
app_name: str | None = None,
|
||||||
|
screen_name: str | None = None,
|
||||||
|
state: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Capture screenshot with flexible output modes.
|
||||||
|
|
||||||
|
Supports both file-based (persistent artifacts) and inline base64 modes
|
||||||
|
(for vision-based automation).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
udid: Device UDID
|
||||||
|
output_path: File path for file mode (generates semantic name if None)
|
||||||
|
size: 'full', 'half', 'quarter', 'thumb' (default: 'half')
|
||||||
|
inline: If True, returns base64 data instead of saving to file
|
||||||
|
app_name: App name for semantic naming
|
||||||
|
screen_name: Screen name for semantic naming
|
||||||
|
state: State description for semantic naming
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with mode-specific fields:
|
||||||
|
|
||||||
|
File mode:
|
||||||
|
{
|
||||||
|
'mode': 'file',
|
||||||
|
'file_path': str,
|
||||||
|
'size_bytes': int,
|
||||||
|
'width': int,
|
||||||
|
'height': int,
|
||||||
|
'size_preset': str
|
||||||
|
}
|
||||||
|
|
||||||
|
Inline mode:
|
||||||
|
{
|
||||||
|
'mode': 'inline',
|
||||||
|
'base64_data': str,
|
||||||
|
'mime_type': 'image/png',
|
||||||
|
'width': int,
|
||||||
|
'height': int,
|
||||||
|
'size_preset': str
|
||||||
|
}
|
||||||
|
|
||||||
|
Example:
|
||||||
|
# File mode
|
||||||
|
result = capture_screenshot('ABC123', app_name='MyApp')
|
||||||
|
print(f"Saved to: {result['file_path']}")
|
||||||
|
|
||||||
|
# Inline mode
|
||||||
|
result = capture_screenshot('ABC123', inline=True, size='half')
|
||||||
|
print(f"Screenshot: {result['width']}x{result['height']}")
|
||||||
|
print(f"Base64: {result['base64_data'][:50]}...")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Capture raw screenshot to temp file
|
||||||
|
temp_path = "/tmp/ios_simulator_screenshot.png"
|
||||||
|
cmd = ["xcrun", "simctl", "io", udid, "screenshot", temp_path]
|
||||||
|
|
||||||
|
subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
if inline:
|
||||||
|
# Inline mode: resize and convert to base64
|
||||||
|
# Resize if needed
|
||||||
|
if size != "full" and HAS_PIL:
|
||||||
|
resized_path, width, height = resize_screenshot(temp_path, size=size)
|
||||||
|
else:
|
||||||
|
resized_path = temp_path
|
||||||
|
# Get dimensions via PIL if available
|
||||||
|
if HAS_PIL:
|
||||||
|
img = Image.open(resized_path)
|
||||||
|
width, height = img.size
|
||||||
|
else:
|
||||||
|
width, height = 390, 844 # Fallback to common device size
|
||||||
|
|
||||||
|
# Read and encode as base64
|
||||||
|
with open(resized_path, "rb") as f:
|
||||||
|
base64_data = base64.b64encode(f.read()).decode("utf-8")
|
||||||
|
|
||||||
|
# Clean up temp files
|
||||||
|
Path(temp_path).unlink(missing_ok=True)
|
||||||
|
if resized_path != temp_path:
|
||||||
|
Path(resized_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": "inline",
|
||||||
|
"base64_data": base64_data,
|
||||||
|
"mime_type": "image/png",
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"size_preset": size,
|
||||||
|
}
|
||||||
|
|
||||||
|
# File mode: save to output path with semantic naming
|
||||||
|
if output_path is None:
|
||||||
|
output_path = generate_screenshot_name(app_name, screen_name, state)
|
||||||
|
|
||||||
|
# Resize if needed
|
||||||
|
if size != "full" and HAS_PIL:
|
||||||
|
final_path, width, height = resize_screenshot(temp_path, output_path, size)
|
||||||
|
else:
|
||||||
|
# Just move temp to output
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.move(temp_path, output_path)
|
||||||
|
final_path = output_path
|
||||||
|
|
||||||
|
# Get dimensions via PIL if available
|
||||||
|
if HAS_PIL:
|
||||||
|
img = Image.open(final_path)
|
||||||
|
width, height = img.size
|
||||||
|
else:
|
||||||
|
width, height = 390, 844 # Fallback
|
||||||
|
|
||||||
|
# Get file size
|
||||||
|
size_bytes = Path(final_path).stat().st_size
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": "file",
|
||||||
|
"file_path": final_path,
|
||||||
|
"size_bytes": size_bytes,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"size_preset": size,
|
||||||
|
}
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise RuntimeError(f"Failed to capture screenshot: {e.stderr}") from e
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Screenshot capture error: {e!s}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def format_screenshot_result(result: dict[str, Any]) -> str:
|
||||||
|
"""Format screenshot result for human-readable output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
result: Result dictionary from capture_screenshot()
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string for printing
|
||||||
|
|
||||||
|
Example:
|
||||||
|
result = capture_screenshot('ABC123', inline=True)
|
||||||
|
print(format_screenshot_result(result))
|
||||||
|
"""
|
||||||
|
if result["mode"] == "file":
|
||||||
|
return (
|
||||||
|
f"Screenshot: {result['file_path']}\n"
|
||||||
|
f"Dimensions: {result['width']}x{result['height']}\n"
|
||||||
|
f"Size: {result['size_bytes']} bytes"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"Screenshot (inline): {result['width']}x{result['height']}\n"
|
||||||
|
f"Base64 length: {len(result['base64_data'])} chars"
|
||||||
|
)
|
||||||
+394
@@ -0,0 +1,394 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
iOS Gesture Controller - Swipes and Complex Gestures
|
||||||
|
|
||||||
|
Performs navigation gestures like swipes, scrolls, and pinches.
|
||||||
|
Token-efficient output for common navigation patterns.
|
||||||
|
|
||||||
|
This script handles touch gestures for iOS simulator automation. It provides
|
||||||
|
directional swipes, multi-swipe scrolling, pull-to-refresh, and pinch gestures.
|
||||||
|
Automatically detects screen size from the device for accurate gesture positioning.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Directional swipes (up, down, left, right)
|
||||||
|
- Multi-swipe scrolling with customizable amount
|
||||||
|
- Pull-to-refresh gesture
|
||||||
|
- Pinch to zoom (in/out)
|
||||||
|
- Custom swipe between any two points
|
||||||
|
- Drag and drop simulation
|
||||||
|
- Auto-detects screen dimensions from device
|
||||||
|
|
||||||
|
Usage Examples:
|
||||||
|
# Simple directional swipe
|
||||||
|
python scripts/gesture.py --swipe up --udid <device-id>
|
||||||
|
|
||||||
|
# Scroll down multiple times
|
||||||
|
python scripts/gesture.py --scroll down --scroll-amount 3 --udid <device-id>
|
||||||
|
|
||||||
|
# Pull to refresh
|
||||||
|
python scripts/gesture.py --refresh --udid <device-id>
|
||||||
|
|
||||||
|
# Custom swipe coordinates
|
||||||
|
python scripts/gesture.py --swipe-from 100,500 --swipe-to 100,100 --udid <device-id>
|
||||||
|
|
||||||
|
# Pinch to zoom
|
||||||
|
python scripts/gesture.py --pinch out --udid <device-id>
|
||||||
|
|
||||||
|
# Long press at coordinates
|
||||||
|
python scripts/gesture.py --long-press 200,300 --duration 2.0 --udid <device-id>
|
||||||
|
|
||||||
|
Output Format:
|
||||||
|
Swiped up
|
||||||
|
Scrolled down (3x)
|
||||||
|
Performed pull to refresh
|
||||||
|
|
||||||
|
Gesture Details:
|
||||||
|
- Swipes use 70% of screen by default (configurable)
|
||||||
|
- Scrolls are multiple small 30% swipes with delays
|
||||||
|
- Start points are offset from edges for reliability
|
||||||
|
- Screen size auto-detected from accessibility tree root element
|
||||||
|
- Falls back to iPhone 14 dimensions (390x844) if detection fails
|
||||||
|
|
||||||
|
Technical Details:
|
||||||
|
- Uses `idb ui swipe x1 y1 x2 y2` for gesture execution
|
||||||
|
- Duration parameter converts to milliseconds for IDB
|
||||||
|
- Automatically fetches screen size on initialization
|
||||||
|
- Parses IDB accessibility tree to get root frame dimensions
|
||||||
|
- All coordinates calculated as fractions of screen size for device independence
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from common import (
|
||||||
|
get_device_screen_size,
|
||||||
|
get_screen_size,
|
||||||
|
resolve_udid,
|
||||||
|
transform_screenshot_coords,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GestureController:
|
||||||
|
"""Performs gestures on iOS simulator."""
|
||||||
|
|
||||||
|
# Standard screen dimensions (will be detected if possible)
|
||||||
|
DEFAULT_WIDTH = 390 # iPhone 14
|
||||||
|
DEFAULT_HEIGHT = 844
|
||||||
|
|
||||||
|
def __init__(self, udid: str | None = None):
|
||||||
|
"""Initialize gesture controller."""
|
||||||
|
self.udid = udid
|
||||||
|
self.screen_size = self._get_screen_size()
|
||||||
|
|
||||||
|
def _get_screen_size(self) -> tuple[int, int]:
|
||||||
|
"""Try to detect screen size from device using shared utility."""
|
||||||
|
return get_screen_size(self.udid)
|
||||||
|
|
||||||
|
def swipe(self, direction: str, distance_ratio: float = 0.7) -> bool:
|
||||||
|
"""
|
||||||
|
Perform directional swipe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
direction: up, down, left, right
|
||||||
|
distance_ratio: How far to swipe (0.0-1.0 of screen)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
width, height = self.screen_size
|
||||||
|
center_x = width // 2
|
||||||
|
center_y = height // 2
|
||||||
|
|
||||||
|
# Calculate swipe coordinates based on direction
|
||||||
|
if direction == "up":
|
||||||
|
start = (center_x, int(height * 0.7))
|
||||||
|
end = (center_x, int(height * (1 - distance_ratio + 0.3)))
|
||||||
|
elif direction == "down":
|
||||||
|
start = (center_x, int(height * 0.3))
|
||||||
|
end = (center_x, int(height * (distance_ratio - 0.3 + 0.3)))
|
||||||
|
elif direction == "left":
|
||||||
|
start = (int(width * 0.8), center_y)
|
||||||
|
end = (int(width * (1 - distance_ratio + 0.2)), center_y)
|
||||||
|
elif direction == "right":
|
||||||
|
start = (int(width * 0.2), center_y)
|
||||||
|
end = (int(width * (distance_ratio - 0.2 + 0.2)), center_y)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self.swipe_between(start, end)
|
||||||
|
|
||||||
|
def swipe_between(
|
||||||
|
self, start: tuple[int, int], end: tuple[int, int], duration: float = 0.3
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Swipe between two points.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start: Starting coordinates (x, y)
|
||||||
|
end: Ending coordinates (x, y)
|
||||||
|
duration: Swipe duration in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
cmd = ["idb", "ui", "swipe"]
|
||||||
|
cmd.extend([str(start[0]), str(start[1]), str(end[0]), str(end[1])])
|
||||||
|
|
||||||
|
# IDB doesn't support duration directly, but we can add delay
|
||||||
|
if duration != 0.3:
|
||||||
|
cmd.extend(["--duration", str(int(duration * 1000))])
|
||||||
|
|
||||||
|
if self.udid:
|
||||||
|
cmd.extend(["--udid", self.udid])
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def scroll(self, direction: str, amount: int = 3) -> bool:
|
||||||
|
"""
|
||||||
|
Perform multiple small swipes to scroll.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
direction: up, down
|
||||||
|
amount: Number of small swipes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
for _ in range(amount):
|
||||||
|
if not self.swipe(direction, distance_ratio=0.3):
|
||||||
|
return False
|
||||||
|
time.sleep(0.2) # Small delay between swipes
|
||||||
|
return True
|
||||||
|
|
||||||
|
def tap_and_hold(self, x: int, y: int, duration: float = 2.0) -> bool:
|
||||||
|
"""
|
||||||
|
Long press at coordinates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x, y: Coordinates
|
||||||
|
duration: Hold duration in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
# IDB doesn't have native long press, simulate with tap
|
||||||
|
# In real implementation, might need to use different approach
|
||||||
|
cmd = ["idb", "ui", "tap", str(x), str(y)]
|
||||||
|
|
||||||
|
if self.udid:
|
||||||
|
cmd.extend(["--udid", self.udid])
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
# Simulate hold with delay
|
||||||
|
time.sleep(duration)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def pinch(self, direction: str = "out", center: tuple[int, int] | None = None) -> bool:
|
||||||
|
"""
|
||||||
|
Perform pinch gesture (zoom in/out).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
direction: 'in' (zoom out) or 'out' (zoom in)
|
||||||
|
center: Center point for pinch
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
if not center:
|
||||||
|
width, height = self.screen_size
|
||||||
|
center = (width // 2, height // 2)
|
||||||
|
|
||||||
|
# Calculate pinch points
|
||||||
|
offset = 100 if direction == "out" else 50
|
||||||
|
|
||||||
|
if direction == "out":
|
||||||
|
# Zoom in - fingers move apart
|
||||||
|
start1 = (center[0] - 20, center[1] - 20)
|
||||||
|
end1 = (center[0] - offset, center[1] - offset)
|
||||||
|
start2 = (center[0] + 20, center[1] + 20)
|
||||||
|
end2 = (center[0] + offset, center[1] + offset)
|
||||||
|
else:
|
||||||
|
# Zoom out - fingers move together
|
||||||
|
start1 = (center[0] - offset, center[1] - offset)
|
||||||
|
end1 = (center[0] - 20, center[1] - 20)
|
||||||
|
start2 = (center[0] + offset, center[1] + offset)
|
||||||
|
end2 = (center[0] + 20, center[1] + 20)
|
||||||
|
|
||||||
|
# Perform two swipes simultaneously (simulated)
|
||||||
|
success1 = self.swipe_between(start1, end1)
|
||||||
|
success2 = self.swipe_between(start2, end2)
|
||||||
|
|
||||||
|
return success1 and success2
|
||||||
|
|
||||||
|
def drag_and_drop(self, start: tuple[int, int], end: tuple[int, int]) -> bool:
|
||||||
|
"""
|
||||||
|
Drag element from one position to another.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start: Starting coordinates
|
||||||
|
end: Ending coordinates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
# Use slow swipe to simulate drag
|
||||||
|
return self.swipe_between(start, end, duration=1.0)
|
||||||
|
|
||||||
|
def refresh(self) -> bool:
|
||||||
|
"""Pull to refresh gesture."""
|
||||||
|
width, _ = self.screen_size
|
||||||
|
start = (width // 2, 100)
|
||||||
|
end = (width // 2, 400)
|
||||||
|
return self.swipe_between(start, end)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Perform gestures on iOS simulator")
|
||||||
|
|
||||||
|
# Gesture options
|
||||||
|
parser.add_argument(
|
||||||
|
"--swipe", choices=["up", "down", "left", "right"], help="Perform directional swipe"
|
||||||
|
)
|
||||||
|
parser.add_argument("--swipe-from", help="Custom swipe start coordinates (x,y)")
|
||||||
|
parser.add_argument("--swipe-to", help="Custom swipe end coordinates (x,y)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--scroll", choices=["up", "down"], help="Scroll in direction (multiple small swipes)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--scroll-amount", type=int, default=3, help="Number of scroll swipes (default: 3)"
|
||||||
|
)
|
||||||
|
parser.add_argument("--long-press", help="Long press at coordinates (x,y)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--duration", type=float, default=2.0, help="Duration for long press in seconds"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--pinch", choices=["in", "out"], help="Pinch gesture (in=zoom out, out=zoom in)"
|
||||||
|
)
|
||||||
|
parser.add_argument("--refresh", action="store_true", help="Pull to refresh gesture")
|
||||||
|
|
||||||
|
# Coordinate transformation
|
||||||
|
parser.add_argument(
|
||||||
|
"--screenshot-coords",
|
||||||
|
action="store_true",
|
||||||
|
help="Interpret swipe coordinates as from a screenshot (requires --screenshot-width/height)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--screenshot-width",
|
||||||
|
type=int,
|
||||||
|
help="Screenshot width for coordinate transformation",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--screenshot-height",
|
||||||
|
type=int,
|
||||||
|
help="Screenshot height for coordinate transformation",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--udid",
|
||||||
|
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Resolve UDID with auto-detection
|
||||||
|
try:
|
||||||
|
udid = resolve_udid(args.udid)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
controller = GestureController(udid=udid)
|
||||||
|
|
||||||
|
# Execute requested gesture
|
||||||
|
if args.swipe:
|
||||||
|
if controller.swipe(args.swipe):
|
||||||
|
print(f"Swiped {args.swipe}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to swipe {args.swipe}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.swipe_from and args.swipe_to:
|
||||||
|
# Custom swipe
|
||||||
|
start = tuple(map(int, args.swipe_from.split(",")))
|
||||||
|
end = tuple(map(int, args.swipe_to.split(",")))
|
||||||
|
|
||||||
|
# Handle coordinate transformation if requested
|
||||||
|
if args.screenshot_coords:
|
||||||
|
if not args.screenshot_width or not args.screenshot_height:
|
||||||
|
print(
|
||||||
|
"Error: --screenshot-coords requires --screenshot-width and --screenshot-height"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
device_w, device_h = get_device_screen_size(udid)
|
||||||
|
start = transform_screenshot_coords(
|
||||||
|
start[0],
|
||||||
|
start[1],
|
||||||
|
args.screenshot_width,
|
||||||
|
args.screenshot_height,
|
||||||
|
device_w,
|
||||||
|
device_h,
|
||||||
|
)
|
||||||
|
end = transform_screenshot_coords(
|
||||||
|
end[0],
|
||||||
|
end[1],
|
||||||
|
args.screenshot_width,
|
||||||
|
args.screenshot_height,
|
||||||
|
device_w,
|
||||||
|
device_h,
|
||||||
|
)
|
||||||
|
print("Transformed screenshot coords to device coords")
|
||||||
|
|
||||||
|
if controller.swipe_between(start, end):
|
||||||
|
print(f"Swiped from {start} to {end}")
|
||||||
|
else:
|
||||||
|
print("Failed to swipe")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.scroll:
|
||||||
|
if controller.scroll(args.scroll, args.scroll_amount):
|
||||||
|
print(f"Scrolled {args.scroll} ({args.scroll_amount}x)")
|
||||||
|
else:
|
||||||
|
print(f"Failed to scroll {args.scroll}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.long_press:
|
||||||
|
coords = tuple(map(int, args.long_press.split(",")))
|
||||||
|
if controller.tap_and_hold(coords[0], coords[1], args.duration):
|
||||||
|
print(f"Long pressed at {coords} for {args.duration}s")
|
||||||
|
else:
|
||||||
|
print("Failed to long press")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.pinch:
|
||||||
|
if controller.pinch(args.pinch):
|
||||||
|
action = "Zoomed in" if args.pinch == "out" else "Zoomed out"
|
||||||
|
print(action)
|
||||||
|
else:
|
||||||
|
print(f"Failed to pinch {args.pinch}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.refresh:
|
||||||
|
if controller.refresh():
|
||||||
|
print("Performed pull to refresh")
|
||||||
|
else:
|
||||||
|
print("Failed to refresh")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+391
@@ -0,0 +1,391 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
iOS Keyboard Controller - Text Entry and Hardware Buttons
|
||||||
|
|
||||||
|
Handles keyboard input, special keys, and hardware button simulation.
|
||||||
|
Token-efficient text entry and navigation control.
|
||||||
|
|
||||||
|
This script provides text input and hardware button control for iOS simulator
|
||||||
|
automation. It handles both typing text strings and pressing special keys like
|
||||||
|
return, delete, tab, etc. Also controls hardware buttons like home and lock.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Type text strings into focused elements
|
||||||
|
- Press special keys (return, delete, tab, space, arrows)
|
||||||
|
- Hardware button simulation (home, lock, volume, screenshot)
|
||||||
|
- Character-by-character typing with delays (for animations)
|
||||||
|
- Multiple key press support
|
||||||
|
- iOS HID key code mapping for reliability
|
||||||
|
|
||||||
|
Usage Examples:
|
||||||
|
# Type text into focused field
|
||||||
|
python scripts/keyboard.py --type "hello@example.com" --udid <device-id>
|
||||||
|
|
||||||
|
# Press return key to submit
|
||||||
|
python scripts/keyboard.py --key return --udid <device-id>
|
||||||
|
|
||||||
|
# Press delete 3 times
|
||||||
|
python scripts/keyboard.py --key delete --key delete --key delete --udid <device-id>
|
||||||
|
|
||||||
|
# Press home button
|
||||||
|
python scripts/keyboard.py --button home --udid <device-id>
|
||||||
|
|
||||||
|
# Press lock button
|
||||||
|
python scripts/keyboard.py --button lock --udid <device-id>
|
||||||
|
|
||||||
|
# Type with delay between characters (for animations)
|
||||||
|
python scripts/keyboard.py --type "slow typing" --delay 0.1 --udid <device-id>
|
||||||
|
|
||||||
|
Output Format:
|
||||||
|
Typed: "hello@example.com"
|
||||||
|
Pressed return
|
||||||
|
Pressed home button
|
||||||
|
|
||||||
|
Special Keys Supported:
|
||||||
|
- return/enter: Submit forms, new lines (HID code 40)
|
||||||
|
- delete/backspace: Remove characters (HID code 42)
|
||||||
|
- tab: Navigate between fields (HID code 43)
|
||||||
|
- space: Space character (HID code 44)
|
||||||
|
- escape: Cancel/dismiss (HID code 41)
|
||||||
|
- up/down/left/right: Arrow keys (HID codes 82/81/80/79)
|
||||||
|
|
||||||
|
Hardware Buttons Supported:
|
||||||
|
- home: Return to home screen
|
||||||
|
- lock/power: Lock device
|
||||||
|
- volume-up/volume-down: Volume control
|
||||||
|
- ringer: Toggle mute
|
||||||
|
- screenshot: Capture screen
|
||||||
|
|
||||||
|
Technical Details:
|
||||||
|
- Uses `idb ui text` for typing text strings
|
||||||
|
- Uses `idb ui key <code>` for special keys with iOS HID codes
|
||||||
|
- HID codes from Apple's UIKeyboardHIDUsage specification
|
||||||
|
- Hardware buttons use `xcrun simctl` button actions
|
||||||
|
- Text entry works on currently focused element
|
||||||
|
- Special keys are integers (40=Return, 42=Delete, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from common import resolve_udid
|
||||||
|
|
||||||
|
|
||||||
|
class KeyboardController:
|
||||||
|
"""Controls keyboard and hardware buttons on iOS simulator."""
|
||||||
|
|
||||||
|
# Special key mappings to iOS HID key codes
|
||||||
|
# See: https://developer.apple.com/documentation/uikit/uikeyboardhidusage
|
||||||
|
SPECIAL_KEYS = {
|
||||||
|
"return": 40,
|
||||||
|
"enter": 40,
|
||||||
|
"delete": 42,
|
||||||
|
"backspace": 42,
|
||||||
|
"tab": 43,
|
||||||
|
"space": 44,
|
||||||
|
"escape": 41,
|
||||||
|
"up": 82,
|
||||||
|
"down": 81,
|
||||||
|
"left": 80,
|
||||||
|
"right": 79,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hardware button mappings
|
||||||
|
HARDWARE_BUTTONS = {
|
||||||
|
"home": "HOME",
|
||||||
|
"lock": "LOCK",
|
||||||
|
"volume-up": "VOLUME_UP",
|
||||||
|
"volume-down": "VOLUME_DOWN",
|
||||||
|
"ringer": "RINGER",
|
||||||
|
"power": "LOCK", # Alias
|
||||||
|
"screenshot": "SCREENSHOT",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, udid: str | None = None):
|
||||||
|
"""Initialize keyboard controller."""
|
||||||
|
self.udid = udid
|
||||||
|
|
||||||
|
def type_text(self, text: str, delay: float = 0.0) -> bool:
|
||||||
|
"""
|
||||||
|
Type text into current focus.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to type
|
||||||
|
delay: Delay between characters (for slow typing effect)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
if delay > 0:
|
||||||
|
# Type character by character with delay
|
||||||
|
for char in text:
|
||||||
|
if not self._type_single(char):
|
||||||
|
return False
|
||||||
|
time.sleep(delay)
|
||||||
|
return True
|
||||||
|
# Type all at once (efficient)
|
||||||
|
return self._type_single(text)
|
||||||
|
|
||||||
|
def _type_single(self, text: str) -> bool:
|
||||||
|
"""Type text using IDB."""
|
||||||
|
cmd = ["idb", "ui", "text", text]
|
||||||
|
if self.udid:
|
||||||
|
cmd.extend(["--udid", self.udid])
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def press_key(self, key: str, count: int = 1) -> bool:
|
||||||
|
"""
|
||||||
|
Press a special key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Key name (return, delete, tab, etc.)
|
||||||
|
count: Number of times to press
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
# Map key name to IDB key code
|
||||||
|
key_code = self.SPECIAL_KEYS.get(key.lower())
|
||||||
|
if not key_code:
|
||||||
|
# Try as literal integer key code
|
||||||
|
try:
|
||||||
|
key_code = int(key)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
cmd = ["idb", "ui", "key", str(key_code)]
|
||||||
|
if self.udid:
|
||||||
|
cmd.extend(["--udid", self.udid])
|
||||||
|
|
||||||
|
try:
|
||||||
|
for _ in range(count):
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
if count > 1:
|
||||||
|
time.sleep(0.1) # Small delay for multiple presses
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def press_key_sequence(self, keys: list[str]) -> bool:
|
||||||
|
"""
|
||||||
|
Press a sequence of keys.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keys: List of key names
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
cmd_base = ["idb", "ui", "key-sequence"]
|
||||||
|
|
||||||
|
# Map keys to codes
|
||||||
|
mapped_keys = []
|
||||||
|
for key in keys:
|
||||||
|
mapped = self.SPECIAL_KEYS.get(key.lower())
|
||||||
|
if mapped is None:
|
||||||
|
# Try as integer
|
||||||
|
try:
|
||||||
|
mapped = int(key)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
mapped_keys.append(str(mapped))
|
||||||
|
|
||||||
|
cmd = cmd_base + mapped_keys
|
||||||
|
|
||||||
|
if self.udid:
|
||||||
|
cmd.extend(["--udid", self.udid])
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def press_hardware_button(self, button: str) -> bool:
|
||||||
|
"""
|
||||||
|
Press hardware button.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
button: Button name (home, lock, volume-up, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
button_code = self.HARDWARE_BUTTONS.get(button.lower())
|
||||||
|
if not button_code:
|
||||||
|
return False
|
||||||
|
|
||||||
|
cmd = ["idb", "ui", "button", button_code]
|
||||||
|
if self.udid:
|
||||||
|
cmd.extend(["--udid", self.udid])
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear_text(self, select_all: bool = True) -> bool:
|
||||||
|
"""
|
||||||
|
Clear text in current field.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
select_all: Use Cmd+A to select all first
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
if select_all:
|
||||||
|
# Select all then delete
|
||||||
|
# Note: This might need adjustment for iOS keyboard shortcuts
|
||||||
|
success = self.press_key_combo(["cmd", "a"])
|
||||||
|
if success:
|
||||||
|
return self.press_key("delete")
|
||||||
|
else:
|
||||||
|
# Just delete multiple times
|
||||||
|
return self.press_key("delete", count=50)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def press_key_combo(self, keys: list[str]) -> bool:
|
||||||
|
"""
|
||||||
|
Press key combination (like Cmd+A).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keys: List of keys to press together
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
# IDB doesn't directly support key combos
|
||||||
|
# This is a workaround - may need platform-specific handling
|
||||||
|
if "cmd" in keys or "command" in keys:
|
||||||
|
# Handle common shortcuts
|
||||||
|
if "a" in keys:
|
||||||
|
# Select all - might work with key sequence
|
||||||
|
return self.press_key_sequence(["command", "a"])
|
||||||
|
if "c" in keys:
|
||||||
|
return self.press_key_sequence(["command", "c"])
|
||||||
|
if "v" in keys:
|
||||||
|
return self.press_key_sequence(["command", "v"])
|
||||||
|
if "x" in keys:
|
||||||
|
return self.press_key_sequence(["command", "x"])
|
||||||
|
|
||||||
|
# Try as sequence
|
||||||
|
return self.press_key_sequence(keys)
|
||||||
|
|
||||||
|
def dismiss_keyboard(self) -> bool:
|
||||||
|
"""Dismiss on-screen keyboard."""
|
||||||
|
# Common ways to dismiss keyboard on iOS
|
||||||
|
# Try Done button first, then Return
|
||||||
|
success = self.press_key("return")
|
||||||
|
if not success:
|
||||||
|
# Try tapping outside (would need coordinate)
|
||||||
|
pass
|
||||||
|
return success
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Control keyboard and hardware buttons")
|
||||||
|
|
||||||
|
# Text input
|
||||||
|
parser.add_argument("--type", help="Type text into current focus")
|
||||||
|
parser.add_argument("--slow", action="store_true", help="Type slowly (character by character)")
|
||||||
|
|
||||||
|
# Special keys
|
||||||
|
parser.add_argument("--key", help="Press special key (return, delete, tab, space, etc.)")
|
||||||
|
parser.add_argument("--key-sequence", help="Press key sequence (comma-separated)")
|
||||||
|
parser.add_argument("--count", type=int, default=1, help="Number of times to press key")
|
||||||
|
|
||||||
|
# Hardware buttons
|
||||||
|
parser.add_argument(
|
||||||
|
"--button",
|
||||||
|
choices=["home", "lock", "volume-up", "volume-down", "ringer", "screenshot"],
|
||||||
|
help="Press hardware button",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Other operations
|
||||||
|
parser.add_argument("--clear", action="store_true", help="Clear current text field")
|
||||||
|
parser.add_argument("--dismiss", action="store_true", help="Dismiss keyboard")
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--udid",
|
||||||
|
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Resolve UDID with auto-detection
|
||||||
|
try:
|
||||||
|
udid = resolve_udid(args.udid)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
controller = KeyboardController(udid=udid)
|
||||||
|
|
||||||
|
# Execute requested action
|
||||||
|
if args.type:
|
||||||
|
delay = 0.1 if args.slow else 0.0
|
||||||
|
if controller.type_text(args.type, delay):
|
||||||
|
if args.slow:
|
||||||
|
print(f'Typed: "{args.type}" (slowly)')
|
||||||
|
else:
|
||||||
|
print(f'Typed: "{args.type}"')
|
||||||
|
else:
|
||||||
|
print("Failed to type text")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.key:
|
||||||
|
if controller.press_key(args.key, args.count):
|
||||||
|
if args.count > 1:
|
||||||
|
print(f"Pressed {args.key} ({args.count}x)")
|
||||||
|
else:
|
||||||
|
print(f"Pressed {args.key}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to press {args.key}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.key_sequence:
|
||||||
|
keys = args.key_sequence.split(",")
|
||||||
|
if controller.press_key_sequence(keys):
|
||||||
|
print(f"Pressed sequence: {' -> '.join(keys)}")
|
||||||
|
else:
|
||||||
|
print("Failed to press key sequence")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.button:
|
||||||
|
if controller.press_hardware_button(args.button):
|
||||||
|
print(f"Pressed {args.button} button")
|
||||||
|
else:
|
||||||
|
print(f"Failed to press {args.button}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.clear:
|
||||||
|
if controller.clear_text():
|
||||||
|
print("Cleared text field")
|
||||||
|
else:
|
||||||
|
print("Failed to clear text")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.dismiss:
|
||||||
|
if controller.dismiss_keyboard():
|
||||||
|
print("Dismissed keyboard")
|
||||||
|
else:
|
||||||
|
print("Failed to dismiss keyboard")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+486
@@ -0,0 +1,486 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
iOS Simulator Log Monitoring and Analysis
|
||||||
|
|
||||||
|
Real-time log streaming from iOS simulators with intelligent filtering, error detection,
|
||||||
|
and token-efficient summarization. Enhanced version of app_state_capture.py's log capture.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Real-time log streaming from booted simulators
|
||||||
|
- Smart filtering by app bundle ID, subsystem, category, severity
|
||||||
|
- Error/warning classification and deduplication
|
||||||
|
- Duration-based or continuous follow mode
|
||||||
|
- Token-efficient summaries with full logs saved to file
|
||||||
|
- Integration with test_recorder and app_state_capture
|
||||||
|
|
||||||
|
Usage Examples:
|
||||||
|
# Monitor app logs in real-time (follow mode)
|
||||||
|
python scripts/log_monitor.py --app com.myapp.MyApp --follow
|
||||||
|
|
||||||
|
# Capture logs for specific duration
|
||||||
|
python scripts/log_monitor.py --app com.myapp.MyApp --duration 30s
|
||||||
|
|
||||||
|
# Extract errors and warnings only from last 5 minutes
|
||||||
|
python scripts/log_monitor.py --severity error,warning --last 5m
|
||||||
|
|
||||||
|
# Save logs to file
|
||||||
|
python scripts/log_monitor.py --app com.myapp.MyApp --duration 1m --output logs/
|
||||||
|
|
||||||
|
# Verbose output with full log lines
|
||||||
|
python scripts/log_monitor.py --app com.myapp.MyApp --verbose
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class LogMonitor:
|
||||||
|
"""Monitor and analyze iOS simulator logs with intelligent filtering."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app_bundle_id: str | None = None,
|
||||||
|
device_udid: str | None = None,
|
||||||
|
severity_filter: list[str] | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize log monitor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_bundle_id: Filter logs by app bundle ID
|
||||||
|
device_udid: Device UDID (uses booted if not specified)
|
||||||
|
severity_filter: List of severities to include (error, warning, info, debug)
|
||||||
|
"""
|
||||||
|
self.app_bundle_id = app_bundle_id
|
||||||
|
self.device_udid = device_udid or "booted"
|
||||||
|
self.severity_filter = severity_filter or ["error", "warning", "info", "debug"]
|
||||||
|
|
||||||
|
# Log storage
|
||||||
|
self.log_lines: list[str] = []
|
||||||
|
self.errors: list[str] = []
|
||||||
|
self.warnings: list[str] = []
|
||||||
|
self.info_messages: list[str] = []
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
self.error_count = 0
|
||||||
|
self.warning_count = 0
|
||||||
|
self.info_count = 0
|
||||||
|
self.debug_count = 0
|
||||||
|
self.total_lines = 0
|
||||||
|
|
||||||
|
# Deduplication
|
||||||
|
self.seen_messages: set[str] = set()
|
||||||
|
|
||||||
|
# Process control
|
||||||
|
self.log_process: subprocess.Popen | None = None
|
||||||
|
self.interrupted = False
|
||||||
|
|
||||||
|
def parse_time_duration(self, duration_str: str) -> float:
|
||||||
|
"""
|
||||||
|
Parse duration string to seconds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
duration_str: Duration like "30s", "5m", "1h"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Duration in seconds
|
||||||
|
"""
|
||||||
|
match = re.match(r"(\d+)([smh])", duration_str.lower())
|
||||||
|
if not match:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid duration format: {duration_str}. Use format like '30s', '5m', '1h'"
|
||||||
|
)
|
||||||
|
|
||||||
|
value, unit = match.groups()
|
||||||
|
value = int(value)
|
||||||
|
|
||||||
|
if unit == "s":
|
||||||
|
return value
|
||||||
|
if unit == "m":
|
||||||
|
return value * 60
|
||||||
|
if unit == "h":
|
||||||
|
return value * 3600
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def classify_log_line(self, line: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Classify log line by severity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line: Log line to classify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Severity level (error, warning, info, debug) or None
|
||||||
|
"""
|
||||||
|
line_lower = line.lower()
|
||||||
|
|
||||||
|
# Error patterns
|
||||||
|
error_patterns = [
|
||||||
|
r"\berror\b",
|
||||||
|
r"\bfault\b",
|
||||||
|
r"\bfailed\b",
|
||||||
|
r"\bexception\b",
|
||||||
|
r"\bcrash\b",
|
||||||
|
r"❌",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Warning patterns
|
||||||
|
warning_patterns = [r"\bwarning\b", r"\bwarn\b", r"\bdeprecated\b", r"⚠️"]
|
||||||
|
|
||||||
|
# Info patterns
|
||||||
|
info_patterns = [r"\binfo\b", r"\bnotice\b", r"ℹ️"]
|
||||||
|
|
||||||
|
for pattern in error_patterns:
|
||||||
|
if re.search(pattern, line_lower):
|
||||||
|
return "error"
|
||||||
|
|
||||||
|
for pattern in warning_patterns:
|
||||||
|
if re.search(pattern, line_lower):
|
||||||
|
return "warning"
|
||||||
|
|
||||||
|
for pattern in info_patterns:
|
||||||
|
if re.search(pattern, line_lower):
|
||||||
|
return "info"
|
||||||
|
|
||||||
|
return "debug"
|
||||||
|
|
||||||
|
def deduplicate_message(self, line: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if message is duplicate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line: Log line
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if this is a new message, False if duplicate
|
||||||
|
"""
|
||||||
|
# Create signature by removing timestamps and process IDs
|
||||||
|
signature = re.sub(r"\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}", "", line)
|
||||||
|
signature = re.sub(r"\[\d+\]", "", signature)
|
||||||
|
signature = re.sub(r"\s+", " ", signature).strip()
|
||||||
|
|
||||||
|
if signature in self.seen_messages:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.seen_messages.add(signature)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def process_log_line(self, line: str):
|
||||||
|
"""
|
||||||
|
Process a single log line.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line: Log line to process
|
||||||
|
"""
|
||||||
|
if not line.strip():
|
||||||
|
return
|
||||||
|
|
||||||
|
self.total_lines += 1
|
||||||
|
self.log_lines.append(line)
|
||||||
|
|
||||||
|
# Classify severity
|
||||||
|
severity = self.classify_log_line(line)
|
||||||
|
|
||||||
|
# Skip if not in filter
|
||||||
|
if severity not in self.severity_filter:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Deduplicate (for errors and warnings)
|
||||||
|
if severity in ["error", "warning"] and not self.deduplicate_message(line):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store by severity
|
||||||
|
if severity == "error":
|
||||||
|
self.error_count += 1
|
||||||
|
self.errors.append(line)
|
||||||
|
elif severity == "warning":
|
||||||
|
self.warning_count += 1
|
||||||
|
self.warnings.append(line)
|
||||||
|
elif severity == "info":
|
||||||
|
self.info_count += 1
|
||||||
|
if len(self.info_messages) < 20: # Keep only recent info
|
||||||
|
self.info_messages.append(line)
|
||||||
|
else: # debug
|
||||||
|
self.debug_count += 1
|
||||||
|
|
||||||
|
def stream_logs(
|
||||||
|
self,
|
||||||
|
follow: bool = False,
|
||||||
|
duration: float | None = None,
|
||||||
|
last_minutes: float | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Stream logs from simulator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
follow: Follow mode (continuous streaming)
|
||||||
|
duration: Capture duration in seconds
|
||||||
|
last_minutes: Show logs from last N minutes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
# Build log stream command
|
||||||
|
cmd = ["xcrun", "simctl", "spawn", self.device_udid, "log", "stream"]
|
||||||
|
|
||||||
|
# Add filters
|
||||||
|
if self.app_bundle_id:
|
||||||
|
# Filter by process name (extracted from bundle ID)
|
||||||
|
app_name = self.app_bundle_id.split(".")[-1]
|
||||||
|
cmd.extend(["--predicate", f'processImagePath CONTAINS "{app_name}"'])
|
||||||
|
|
||||||
|
# Add time filter for historical logs
|
||||||
|
if last_minutes:
|
||||||
|
start_time = datetime.now() - timedelta(minutes=last_minutes)
|
||||||
|
time_str = start_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
cmd.extend(["--start", time_str])
|
||||||
|
|
||||||
|
# Setup signal handler for graceful interruption
|
||||||
|
def signal_handler(sig, frame):
|
||||||
|
self.interrupted = True
|
||||||
|
if self.log_process:
|
||||||
|
self.log_process.terminate()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start log streaming process
|
||||||
|
self.log_process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
bufsize=1, # Line buffered
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track start time for duration
|
||||||
|
start_time = datetime.now()
|
||||||
|
|
||||||
|
# Process log lines
|
||||||
|
for line in iter(self.log_process.stdout.readline, ""):
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Process the line
|
||||||
|
self.process_log_line(line.rstrip())
|
||||||
|
|
||||||
|
# Print in follow mode
|
||||||
|
if follow:
|
||||||
|
severity = self.classify_log_line(line)
|
||||||
|
if severity in self.severity_filter:
|
||||||
|
print(line.rstrip())
|
||||||
|
|
||||||
|
# Check duration
|
||||||
|
if duration and (datetime.now() - start_time).total_seconds() >= duration:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if interrupted
|
||||||
|
if self.interrupted:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Wait for process to finish
|
||||||
|
self.log_process.wait()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error streaming logs: {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if self.log_process:
|
||||||
|
self.log_process.terminate()
|
||||||
|
|
||||||
|
def get_summary(self, verbose: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Get log summary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
verbose: Include full log details
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted summary string
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Header
|
||||||
|
if self.app_bundle_id:
|
||||||
|
lines.append(f"Logs for: {self.app_bundle_id}")
|
||||||
|
else:
|
||||||
|
lines.append("Logs for: All processes")
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
lines.append(f"Total lines: {self.total_lines}")
|
||||||
|
lines.append(
|
||||||
|
f"Errors: {self.error_count}, Warnings: {self.warning_count}, Info: {self.info_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Top issues
|
||||||
|
if self.errors:
|
||||||
|
lines.append(f"\nTop Errors ({len(self.errors)}):")
|
||||||
|
for error in self.errors[:5]: # Show first 5
|
||||||
|
lines.append(f" ❌ {error[:120]}") # Truncate long lines
|
||||||
|
|
||||||
|
if self.warnings:
|
||||||
|
lines.append(f"\nTop Warnings ({len(self.warnings)}):")
|
||||||
|
for warning in self.warnings[:5]: # Show first 5
|
||||||
|
lines.append(f" ⚠️ {warning[:120]}")
|
||||||
|
|
||||||
|
# Verbose output
|
||||||
|
if verbose and self.log_lines:
|
||||||
|
lines.append("\n=== Recent Log Lines ===")
|
||||||
|
for line in self.log_lines[-50:]: # Last 50 lines
|
||||||
|
lines.append(line)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def get_json_output(self) -> dict:
|
||||||
|
"""Get log results as JSON."""
|
||||||
|
return {
|
||||||
|
"app_bundle_id": self.app_bundle_id,
|
||||||
|
"device_udid": self.device_udid,
|
||||||
|
"statistics": {
|
||||||
|
"total_lines": self.total_lines,
|
||||||
|
"errors": self.error_count,
|
||||||
|
"warnings": self.warning_count,
|
||||||
|
"info": self.info_count,
|
||||||
|
"debug": self.debug_count,
|
||||||
|
},
|
||||||
|
"errors": self.errors[:20], # Limit to 20
|
||||||
|
"warnings": self.warnings[:20],
|
||||||
|
"sample_logs": self.log_lines[-50:], # Last 50 lines
|
||||||
|
}
|
||||||
|
|
||||||
|
def save_logs(self, output_dir: str) -> str:
|
||||||
|
"""
|
||||||
|
Save logs to file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Directory to save logs
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to saved log file
|
||||||
|
"""
|
||||||
|
# Create output directory
|
||||||
|
output_path = Path(output_dir)
|
||||||
|
output_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate filename with timestamp
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
app_name = self.app_bundle_id.split(".")[-1] if self.app_bundle_id else "simulator"
|
||||||
|
log_file = output_path / f"{app_name}-{timestamp}.log"
|
||||||
|
|
||||||
|
# Write all log lines
|
||||||
|
with open(log_file, "w") as f:
|
||||||
|
f.write("\n".join(self.log_lines))
|
||||||
|
|
||||||
|
# Also save JSON summary
|
||||||
|
json_file = output_path / f"{app_name}-{timestamp}-summary.json"
|
||||||
|
with open(json_file, "w") as f:
|
||||||
|
json.dump(self.get_json_output(), f, indent=2)
|
||||||
|
|
||||||
|
return str(log_file)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Monitor and analyze iOS simulator logs",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
# Monitor app in real-time
|
||||||
|
python scripts/log_monitor.py --app com.myapp.MyApp --follow
|
||||||
|
|
||||||
|
# Capture logs for 30 seconds
|
||||||
|
python scripts/log_monitor.py --app com.myapp.MyApp --duration 30s
|
||||||
|
|
||||||
|
# Show errors/warnings from last 5 minutes
|
||||||
|
python scripts/log_monitor.py --severity error,warning --last 5m
|
||||||
|
|
||||||
|
# Save logs to file
|
||||||
|
python scripts/log_monitor.py --app com.myapp.MyApp --duration 1m --output logs/
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filtering options
|
||||||
|
parser.add_argument(
|
||||||
|
"--app", dest="app_bundle_id", help="App bundle ID to filter logs (e.g., com.myapp.MyApp)"
|
||||||
|
)
|
||||||
|
parser.add_argument("--device-udid", help="Device UDID (uses booted if not specified)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--severity", help="Comma-separated severity levels (error,warning,info,debug)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Time options
|
||||||
|
time_group = parser.add_mutually_exclusive_group()
|
||||||
|
time_group.add_argument(
|
||||||
|
"--follow", action="store_true", help="Follow mode (continuous streaming)"
|
||||||
|
)
|
||||||
|
time_group.add_argument("--duration", help="Capture duration (e.g., 30s, 5m, 1h)")
|
||||||
|
time_group.add_argument(
|
||||||
|
"--last", dest="last_minutes", help="Show logs from last N minutes (e.g., 5m)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Output options
|
||||||
|
parser.add_argument("--output", help="Save logs to directory")
|
||||||
|
parser.add_argument("--verbose", action="store_true", help="Show detailed output")
|
||||||
|
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Parse severity filter
|
||||||
|
severity_filter = None
|
||||||
|
if args.severity:
|
||||||
|
severity_filter = [s.strip().lower() for s in args.severity.split(",")]
|
||||||
|
|
||||||
|
# Initialize monitor
|
||||||
|
monitor = LogMonitor(
|
||||||
|
app_bundle_id=args.app_bundle_id,
|
||||||
|
device_udid=args.device_udid,
|
||||||
|
severity_filter=severity_filter,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse duration
|
||||||
|
duration = None
|
||||||
|
if args.duration:
|
||||||
|
duration = monitor.parse_time_duration(args.duration)
|
||||||
|
|
||||||
|
# Parse last minutes
|
||||||
|
last_minutes = None
|
||||||
|
if args.last_minutes:
|
||||||
|
last_minutes = monitor.parse_time_duration(args.last_minutes) / 60
|
||||||
|
|
||||||
|
# Stream logs
|
||||||
|
print("Monitoring logs...", file=sys.stderr)
|
||||||
|
if args.app_bundle_id:
|
||||||
|
print(f"App: {args.app_bundle_id}", file=sys.stderr)
|
||||||
|
|
||||||
|
success = monitor.stream_logs(follow=args.follow, duration=duration, last_minutes=last_minutes)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Save logs if requested
|
||||||
|
if args.output:
|
||||||
|
log_file = monitor.save_logs(args.output)
|
||||||
|
print(f"\nLogs saved to: {log_file}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Output results
|
||||||
|
if not args.follow: # Don't show summary in follow mode
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(monitor.get_json_output(), indent=2))
|
||||||
|
else:
|
||||||
|
print("\n" + monitor.get_summary(verbose=args.verbose))
|
||||||
|
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+453
@@ -0,0 +1,453 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
iOS Simulator Navigator - Smart Element Finder and Interactor
|
||||||
|
|
||||||
|
Finds and interacts with UI elements using accessibility data.
|
||||||
|
Prioritizes structured navigation over pixel-based interaction.
|
||||||
|
|
||||||
|
This script is the core automation tool for iOS simulator navigation. It finds
|
||||||
|
UI elements by text, type, or accessibility ID and performs actions on them
|
||||||
|
(tap, enter text). Uses semantic element finding instead of fragile pixel coordinates.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Find elements by text (fuzzy or exact matching)
|
||||||
|
- Find elements by type (Button, TextField, etc.)
|
||||||
|
- Find elements by accessibility identifier
|
||||||
|
- Tap elements at their center point
|
||||||
|
- Enter text into text fields
|
||||||
|
- List all tappable elements on screen
|
||||||
|
- Automatic element caching for performance
|
||||||
|
|
||||||
|
Usage Examples:
|
||||||
|
# Find and tap a button by text
|
||||||
|
python scripts/navigator.py --find-text "Login" --tap --udid <device-id>
|
||||||
|
|
||||||
|
# Enter text into first text field
|
||||||
|
python scripts/navigator.py --find-type TextField --index 0 --enter-text "username" --udid <device-id>
|
||||||
|
|
||||||
|
# Tap element by accessibility ID
|
||||||
|
python scripts/navigator.py --find-id "submitButton" --tap --udid <device-id>
|
||||||
|
|
||||||
|
# List all interactive elements
|
||||||
|
python scripts/navigator.py --list --udid <device-id>
|
||||||
|
|
||||||
|
# Tap at specific coordinates (fallback)
|
||||||
|
python scripts/navigator.py --tap-at 200,400 --udid <device-id>
|
||||||
|
|
||||||
|
Output Format:
|
||||||
|
Tapped: Button "Login" at (320, 450)
|
||||||
|
Entered text in: TextField "Username"
|
||||||
|
Not found: text='Submit'
|
||||||
|
|
||||||
|
Navigation Priority (best to worst):
|
||||||
|
1. Find by accessibility label/text (most reliable)
|
||||||
|
2. Find by element type + index (good for forms)
|
||||||
|
3. Find by accessibility ID (precise but app-specific)
|
||||||
|
4. Tap at coordinates (last resort, fragile)
|
||||||
|
|
||||||
|
Technical Details:
|
||||||
|
- Uses IDB's accessibility tree via `idb ui describe-all --json --nested`
|
||||||
|
- Caches tree for multiple operations (call with force_refresh to update)
|
||||||
|
- Finds elements by parsing tree recursively
|
||||||
|
- Calculates tap coordinates from element frame center
|
||||||
|
- Uses `idb ui tap` for tapping, `idb ui text` for text entry
|
||||||
|
- Extracts data from AXLabel, AXValue, and AXUniqueId fields
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from common import (
|
||||||
|
flatten_tree,
|
||||||
|
get_accessibility_tree,
|
||||||
|
get_device_screen_size,
|
||||||
|
resolve_udid,
|
||||||
|
transform_screenshot_coords,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Element:
|
||||||
|
"""Represents a UI element from accessibility tree."""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
label: str | None
|
||||||
|
value: str | None
|
||||||
|
identifier: str | None
|
||||||
|
frame: dict[str, float]
|
||||||
|
traits: list[str]
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def center(self) -> tuple[int, int]:
|
||||||
|
"""Calculate center point for tapping."""
|
||||||
|
x = int(self.frame["x"] + self.frame["width"] / 2)
|
||||||
|
y = int(self.frame["y"] + self.frame["height"] / 2)
|
||||||
|
return (x, y)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
"""Human-readable description."""
|
||||||
|
label = self.label or self.value or self.identifier or "Unnamed"
|
||||||
|
return f'{self.type} "{label}"'
|
||||||
|
|
||||||
|
|
||||||
|
class Navigator:
|
||||||
|
"""Navigates iOS apps using accessibility data."""
|
||||||
|
|
||||||
|
def __init__(self, udid: str | None = None):
|
||||||
|
"""Initialize navigator with optional device UDID."""
|
||||||
|
self.udid = udid
|
||||||
|
self._tree_cache = None
|
||||||
|
|
||||||
|
def get_accessibility_tree(self, force_refresh: bool = False) -> dict:
|
||||||
|
"""Get accessibility tree (cached for efficiency)."""
|
||||||
|
if self._tree_cache and not force_refresh:
|
||||||
|
return self._tree_cache
|
||||||
|
|
||||||
|
# Delegate to shared utility
|
||||||
|
self._tree_cache = get_accessibility_tree(self.udid, nested=True)
|
||||||
|
return self._tree_cache
|
||||||
|
|
||||||
|
def _flatten_tree(self, node: dict, elements: list[Element] | None = None) -> list[Element]:
|
||||||
|
"""Flatten accessibility tree into list of elements."""
|
||||||
|
if elements is None:
|
||||||
|
elements = []
|
||||||
|
|
||||||
|
# Create element from node
|
||||||
|
if node.get("type"):
|
||||||
|
element = Element(
|
||||||
|
type=node.get("type", "Unknown"),
|
||||||
|
label=node.get("AXLabel"),
|
||||||
|
value=node.get("AXValue"),
|
||||||
|
identifier=node.get("AXUniqueId"),
|
||||||
|
frame=node.get("frame", {}),
|
||||||
|
traits=node.get("traits", []),
|
||||||
|
enabled=node.get("enabled", True),
|
||||||
|
)
|
||||||
|
elements.append(element)
|
||||||
|
|
||||||
|
# Process children
|
||||||
|
for child in node.get("children", []):
|
||||||
|
self._flatten_tree(child, elements)
|
||||||
|
|
||||||
|
return elements
|
||||||
|
|
||||||
|
def find_element(
|
||||||
|
self,
|
||||||
|
text: str | None = None,
|
||||||
|
element_type: str | None = None,
|
||||||
|
identifier: str | None = None,
|
||||||
|
index: int = 0,
|
||||||
|
fuzzy: bool = True,
|
||||||
|
) -> Element | None:
|
||||||
|
"""
|
||||||
|
Find element by various criteria.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to search in label/value
|
||||||
|
element_type: Type of element (Button, TextField, etc.)
|
||||||
|
identifier: Accessibility identifier
|
||||||
|
index: Which matching element to return (0-based)
|
||||||
|
fuzzy: Use fuzzy matching for text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Element if found, None otherwise
|
||||||
|
"""
|
||||||
|
tree = self.get_accessibility_tree()
|
||||||
|
elements = self._flatten_tree(tree)
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
|
||||||
|
for elem in elements:
|
||||||
|
# Skip disabled elements
|
||||||
|
if not elem.enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check type
|
||||||
|
if element_type and elem.type != element_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check identifier (exact match)
|
||||||
|
if identifier and elem.identifier != identifier:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check text (in label or value)
|
||||||
|
if text:
|
||||||
|
elem_text = (elem.label or "") + " " + (elem.value or "")
|
||||||
|
if fuzzy:
|
||||||
|
if text.lower() not in elem_text.lower():
|
||||||
|
continue
|
||||||
|
elif text not in (elem.label, elem.value):
|
||||||
|
continue
|
||||||
|
|
||||||
|
matches.append(elem)
|
||||||
|
|
||||||
|
if matches and index < len(matches):
|
||||||
|
return matches[index]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def tap(self, element: Element) -> bool:
|
||||||
|
"""Tap on an element."""
|
||||||
|
x, y = element.center
|
||||||
|
return self.tap_at(x, y)
|
||||||
|
|
||||||
|
def tap_at(self, x: int, y: int) -> bool:
|
||||||
|
"""Tap at specific coordinates."""
|
||||||
|
cmd = ["idb", "ui", "tap", str(x), str(y)]
|
||||||
|
if self.udid:
|
||||||
|
cmd.extend(["--udid", self.udid])
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def enter_text(self, text: str, element: Element | None = None) -> bool:
|
||||||
|
"""
|
||||||
|
Enter text into element or current focus.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to enter
|
||||||
|
element: Optional element to tap first
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
# Tap element if provided
|
||||||
|
if element:
|
||||||
|
if not self.tap(element):
|
||||||
|
return False
|
||||||
|
# Small delay for focus
|
||||||
|
import time
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Enter text
|
||||||
|
cmd = ["idb", "ui", "text", text]
|
||||||
|
if self.udid:
|
||||||
|
cmd.extend(["--udid", self.udid])
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def find_and_tap(
|
||||||
|
self,
|
||||||
|
text: str | None = None,
|
||||||
|
element_type: str | None = None,
|
||||||
|
identifier: str | None = None,
|
||||||
|
index: int = 0,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Find element and tap it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, message) tuple
|
||||||
|
"""
|
||||||
|
element = self.find_element(text, element_type, identifier, index)
|
||||||
|
|
||||||
|
if not element:
|
||||||
|
criteria = []
|
||||||
|
if text:
|
||||||
|
criteria.append(f"text='{text}'")
|
||||||
|
if element_type:
|
||||||
|
criteria.append(f"type={element_type}")
|
||||||
|
if identifier:
|
||||||
|
criteria.append(f"id={identifier}")
|
||||||
|
return (False, f"Not found: {', '.join(criteria)}")
|
||||||
|
|
||||||
|
if self.tap(element):
|
||||||
|
return (True, f"Tapped: {element.description} at {element.center}")
|
||||||
|
return (False, f"Failed to tap: {element.description}")
|
||||||
|
|
||||||
|
def find_and_enter_text(
|
||||||
|
self,
|
||||||
|
text_to_enter: str,
|
||||||
|
find_text: str | None = None,
|
||||||
|
element_type: str | None = "TextField",
|
||||||
|
identifier: str | None = None,
|
||||||
|
index: int = 0,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Find element and enter text into it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, message) tuple
|
||||||
|
"""
|
||||||
|
element = self.find_element(find_text, element_type, identifier, index)
|
||||||
|
|
||||||
|
if not element:
|
||||||
|
return (False, "TextField not found")
|
||||||
|
|
||||||
|
if self.enter_text(text_to_enter, element):
|
||||||
|
return (True, f"Entered text in: {element.description}")
|
||||||
|
return (False, "Failed to enter text")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Navigate iOS apps using accessibility data")
|
||||||
|
|
||||||
|
# Finding options
|
||||||
|
parser.add_argument("--find-text", help="Find element by text (fuzzy match)")
|
||||||
|
parser.add_argument("--find-exact", help="Find element by exact text")
|
||||||
|
parser.add_argument("--find-type", help="Element type (Button, TextField, etc.)")
|
||||||
|
parser.add_argument("--find-id", help="Accessibility identifier")
|
||||||
|
parser.add_argument("--index", type=int, default=0, help="Which match to use (0-based)")
|
||||||
|
|
||||||
|
# Action options
|
||||||
|
parser.add_argument("--tap", action="store_true", help="Tap the found element")
|
||||||
|
parser.add_argument("--tap-at", help="Tap at coordinates (x,y)")
|
||||||
|
parser.add_argument("--enter-text", help="Enter text into element")
|
||||||
|
|
||||||
|
# Coordinate transformation
|
||||||
|
parser.add_argument(
|
||||||
|
"--screenshot-coords",
|
||||||
|
action="store_true",
|
||||||
|
help="Interpret tap coordinates as from a screenshot (requires --screenshot-width/height)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--screenshot-width",
|
||||||
|
type=int,
|
||||||
|
help="Screenshot width for coordinate transformation",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--screenshot-height",
|
||||||
|
type=int,
|
||||||
|
help="Screenshot height for coordinate transformation",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Other options
|
||||||
|
parser.add_argument(
|
||||||
|
"--udid",
|
||||||
|
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--list", action="store_true", help="List all tappable elements")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Resolve UDID with auto-detection
|
||||||
|
try:
|
||||||
|
udid = resolve_udid(args.udid)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
navigator = Navigator(udid=udid)
|
||||||
|
|
||||||
|
# List mode
|
||||||
|
if args.list:
|
||||||
|
tree = navigator.get_accessibility_tree()
|
||||||
|
elements = navigator._flatten_tree(tree)
|
||||||
|
|
||||||
|
# Filter to tappable elements
|
||||||
|
tappable = [
|
||||||
|
e
|
||||||
|
for e in elements
|
||||||
|
if e.enabled and e.type in ["Button", "Link", "Cell", "TextField", "SecureTextField"]
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"Tappable elements ({len(tappable)}):")
|
||||||
|
for elem in tappable[:10]: # Limit output for tokens
|
||||||
|
print(f" {elem.type}: \"{elem.label or elem.value or 'Unnamed'}\" {elem.center}")
|
||||||
|
|
||||||
|
if len(tappable) > 10:
|
||||||
|
print(f" ... and {len(tappable) - 10} more")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Direct tap at coordinates
|
||||||
|
if args.tap_at:
|
||||||
|
coords = args.tap_at.split(",")
|
||||||
|
if len(coords) != 2:
|
||||||
|
print("Error: --tap-at requires x,y format")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
x, y = int(coords[0]), int(coords[1])
|
||||||
|
|
||||||
|
# Handle coordinate transformation if requested
|
||||||
|
if args.screenshot_coords:
|
||||||
|
if not args.screenshot_width or not args.screenshot_height:
|
||||||
|
print(
|
||||||
|
"Error: --screenshot-coords requires --screenshot-width and --screenshot-height"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
device_w, device_h = get_device_screen_size(udid)
|
||||||
|
x, y = transform_screenshot_coords(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
args.screenshot_width,
|
||||||
|
args.screenshot_height,
|
||||||
|
device_w,
|
||||||
|
device_h,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"Transformed screenshot coords ({coords[0]}, {coords[1]}) "
|
||||||
|
f"to device coords ({x}, {y})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if navigator.tap_at(x, y):
|
||||||
|
print(f"Tapped at ({x}, {y})")
|
||||||
|
else:
|
||||||
|
print(f"Failed to tap at ({x}, {y})")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Find and tap
|
||||||
|
elif args.tap:
|
||||||
|
text = args.find_text or args.find_exact
|
||||||
|
fuzzy = args.find_text is not None
|
||||||
|
|
||||||
|
success, message = navigator.find_and_tap(
|
||||||
|
text=text, element_type=args.find_type, identifier=args.find_id, index=args.index
|
||||||
|
)
|
||||||
|
|
||||||
|
print(message)
|
||||||
|
if not success:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Find and enter text
|
||||||
|
elif args.enter_text:
|
||||||
|
text = args.find_text or args.find_exact
|
||||||
|
|
||||||
|
success, message = navigator.find_and_enter_text(
|
||||||
|
text_to_enter=args.enter_text,
|
||||||
|
find_text=text,
|
||||||
|
element_type=args.find_type or "TextField",
|
||||||
|
identifier=args.find_id,
|
||||||
|
index=args.index,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(message)
|
||||||
|
if not success:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Just find (no action)
|
||||||
|
else:
|
||||||
|
text = args.find_text or args.find_exact
|
||||||
|
fuzzy = args.find_text is not None
|
||||||
|
|
||||||
|
element = navigator.find_element(
|
||||||
|
text=text,
|
||||||
|
element_type=args.find_type,
|
||||||
|
identifier=args.find_id,
|
||||||
|
index=args.index,
|
||||||
|
fuzzy=fuzzy,
|
||||||
|
)
|
||||||
|
|
||||||
|
if element:
|
||||||
|
print(f"Found: {element.description} at {element.center}")
|
||||||
|
else:
|
||||||
|
print("Element not found")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
iOS Privacy & Permissions Manager
|
||||||
|
|
||||||
|
Grant/revoke app permissions for testing permission flows.
|
||||||
|
Supports 13+ services with audit trail tracking.
|
||||||
|
|
||||||
|
Usage: python scripts/privacy_manager.py --grant camera --bundle-id com.app
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from common import resolve_udid
|
||||||
|
|
||||||
|
|
||||||
|
class PrivacyManager:
|
||||||
|
"""Manages iOS app privacy and permissions."""
|
||||||
|
|
||||||
|
# Supported services
|
||||||
|
SUPPORTED_SERVICES = {
|
||||||
|
"camera": "Camera access",
|
||||||
|
"microphone": "Microphone access",
|
||||||
|
"location": "Location services",
|
||||||
|
"contacts": "Contacts access",
|
||||||
|
"photos": "Photos library access",
|
||||||
|
"calendar": "Calendar access",
|
||||||
|
"health": "Health data access",
|
||||||
|
"reminders": "Reminders access",
|
||||||
|
"motion": "Motion & fitness",
|
||||||
|
"keyboard": "Keyboard access",
|
||||||
|
"mediaLibrary": "Media library",
|
||||||
|
"calls": "Call history",
|
||||||
|
"siri": "Siri access",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, udid: str | None = None):
|
||||||
|
"""Initialize privacy manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
udid: Optional device UDID (auto-detects booted simulator if None)
|
||||||
|
"""
|
||||||
|
self.udid = udid
|
||||||
|
|
||||||
|
def grant_permission(
|
||||||
|
self,
|
||||||
|
bundle_id: str,
|
||||||
|
service: str,
|
||||||
|
scenario: str | None = None,
|
||||||
|
step: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Grant permission for app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bundle_id: App bundle ID
|
||||||
|
service: Service name (camera, microphone, location, etc.)
|
||||||
|
scenario: Test scenario name for audit trail
|
||||||
|
step: Step number in test scenario
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
if service not in self.SUPPORTED_SERVICES:
|
||||||
|
print(f"Error: Unknown service '{service}'")
|
||||||
|
print(f"Supported: {', '.join(self.SUPPORTED_SERVICES.keys())}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
cmd = ["xcrun", "simctl", "privacy"]
|
||||||
|
|
||||||
|
if self.udid:
|
||||||
|
cmd.append(self.udid)
|
||||||
|
else:
|
||||||
|
cmd.append("booted")
|
||||||
|
|
||||||
|
cmd.extend(["grant", service, bundle_id])
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
|
||||||
|
# Log audit entry
|
||||||
|
self._log_audit("grant", bundle_id, service, scenario, step)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def revoke_permission(
|
||||||
|
self,
|
||||||
|
bundle_id: str,
|
||||||
|
service: str,
|
||||||
|
scenario: str | None = None,
|
||||||
|
step: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Revoke permission for app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bundle_id: App bundle ID
|
||||||
|
service: Service name
|
||||||
|
scenario: Test scenario name for audit trail
|
||||||
|
step: Step number in test scenario
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
if service not in self.SUPPORTED_SERVICES:
|
||||||
|
print(f"Error: Unknown service '{service}'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
cmd = ["xcrun", "simctl", "privacy"]
|
||||||
|
|
||||||
|
if self.udid:
|
||||||
|
cmd.append(self.udid)
|
||||||
|
else:
|
||||||
|
cmd.append("booted")
|
||||||
|
|
||||||
|
cmd.extend(["revoke", service, bundle_id])
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
|
||||||
|
# Log audit entry
|
||||||
|
self._log_audit("revoke", bundle_id, service, scenario, step)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def reset_permission(
|
||||||
|
self,
|
||||||
|
bundle_id: str,
|
||||||
|
service: str,
|
||||||
|
scenario: str | None = None,
|
||||||
|
step: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Reset permission to default.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bundle_id: App bundle ID
|
||||||
|
service: Service name
|
||||||
|
scenario: Test scenario name for audit trail
|
||||||
|
step: Step number in test scenario
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
if service not in self.SUPPORTED_SERVICES:
|
||||||
|
print(f"Error: Unknown service '{service}'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
cmd = ["xcrun", "simctl", "privacy"]
|
||||||
|
|
||||||
|
if self.udid:
|
||||||
|
cmd.append(self.udid)
|
||||||
|
else:
|
||||||
|
cmd.append("booted")
|
||||||
|
|
||||||
|
cmd.extend(["reset", service, bundle_id])
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
|
||||||
|
# Log audit entry
|
||||||
|
self._log_audit("reset", bundle_id, service, scenario, step)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _log_audit(
|
||||||
|
action: str,
|
||||||
|
bundle_id: str,
|
||||||
|
service: str,
|
||||||
|
scenario: str | None = None,
|
||||||
|
step: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Log permission change to audit trail (for test tracking).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action: grant, revoke, or reset
|
||||||
|
bundle_id: App bundle ID
|
||||||
|
service: Service name
|
||||||
|
scenario: Test scenario name
|
||||||
|
step: Step number
|
||||||
|
"""
|
||||||
|
# Could write to file, but for now just log to stdout for transparency
|
||||||
|
timestamp = datetime.now().isoformat()
|
||||||
|
location = f" (step {step})" if step else ""
|
||||||
|
scenario_info = f" in {scenario}" if scenario else ""
|
||||||
|
print(
|
||||||
|
f"[Audit] {timestamp}: {action.upper()} {service} for {bundle_id}{scenario_info}{location}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Manage iOS app privacy and permissions")
|
||||||
|
|
||||||
|
# Required
|
||||||
|
parser.add_argument("--bundle-id", required=True, help="App bundle ID (e.g., com.example.app)")
|
||||||
|
|
||||||
|
# Action (mutually exclusive)
|
||||||
|
action_group = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
action_group.add_argument(
|
||||||
|
"--grant",
|
||||||
|
help="Grant permission (service name or comma-separated list)",
|
||||||
|
)
|
||||||
|
action_group.add_argument(
|
||||||
|
"--revoke", help="Revoke permission (service name or comma-separated list)"
|
||||||
|
)
|
||||||
|
action_group.add_argument(
|
||||||
|
"--reset",
|
||||||
|
help="Reset permission to default (service name or comma-separated list)",
|
||||||
|
)
|
||||||
|
action_group.add_argument(
|
||||||
|
"--list",
|
||||||
|
action="store_true",
|
||||||
|
help="List all supported services",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test tracking
|
||||||
|
parser.add_argument(
|
||||||
|
"--scenario",
|
||||||
|
help="Test scenario name for audit trail",
|
||||||
|
)
|
||||||
|
parser.add_argument("--step", type=int, help="Step number in test scenario")
|
||||||
|
|
||||||
|
# Device
|
||||||
|
parser.add_argument(
|
||||||
|
"--udid",
|
||||||
|
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# List supported services
|
||||||
|
if args.list:
|
||||||
|
print("Supported Privacy Services:\n")
|
||||||
|
for service, description in PrivacyManager.SUPPORTED_SERVICES.items():
|
||||||
|
print(f" {service:<15} - {description}")
|
||||||
|
print()
|
||||||
|
print("Examples:")
|
||||||
|
print(" python scripts/privacy_manager.py --grant camera --bundle-id com.app")
|
||||||
|
print(" python scripts/privacy_manager.py --revoke location --bundle-id com.app")
|
||||||
|
print(" python scripts/privacy_manager.py --grant camera,photos --bundle-id com.app")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Resolve UDID with auto-detection
|
||||||
|
try:
|
||||||
|
udid = resolve_udid(args.udid)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
manager = PrivacyManager(udid=udid)
|
||||||
|
|
||||||
|
# Parse service names (support comma-separated list)
|
||||||
|
if args.grant:
|
||||||
|
services = [s.strip() for s in args.grant.split(",")]
|
||||||
|
action = "grant"
|
||||||
|
action_fn = manager.grant_permission
|
||||||
|
elif args.revoke:
|
||||||
|
services = [s.strip() for s in args.revoke.split(",")]
|
||||||
|
action = "revoke"
|
||||||
|
action_fn = manager.revoke_permission
|
||||||
|
else: # reset
|
||||||
|
services = [s.strip() for s in args.reset.split(",")]
|
||||||
|
action = "reset"
|
||||||
|
action_fn = manager.reset_permission
|
||||||
|
|
||||||
|
# Execute action for each service
|
||||||
|
all_success = True
|
||||||
|
for service in services:
|
||||||
|
if service not in PrivacyManager.SUPPORTED_SERVICES:
|
||||||
|
print(f"Error: Unknown service '{service}'")
|
||||||
|
all_success = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
success = action_fn(
|
||||||
|
args.bundle_id,
|
||||||
|
service,
|
||||||
|
scenario=args.scenario,
|
||||||
|
step=args.step,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
description = PrivacyManager.SUPPORTED_SERVICES[service]
|
||||||
|
print(f"✓ {action.capitalize()} {service}: {description}")
|
||||||
|
else:
|
||||||
|
print(f"✗ Failed to {action} {service}")
|
||||||
|
all_success = False
|
||||||
|
|
||||||
|
if not all_success:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
if len(services) > 1:
|
||||||
|
print(f"\nPermissions {action}ed: {', '.join(services)}")
|
||||||
|
|
||||||
|
if args.scenario:
|
||||||
|
print(f"Test scenario: {args.scenario}" + (f" (step {args.step})" if args.step else ""))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
iOS Push Notification Simulator
|
||||||
|
|
||||||
|
Send simulated push notifications to test notification handling.
|
||||||
|
Supports custom payloads and test tracking.
|
||||||
|
|
||||||
|
Usage: python scripts/push_notification.py --bundle-id com.app --title "Alert" --body "Message"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from common import resolve_udid
|
||||||
|
|
||||||
|
|
||||||
|
class PushNotificationSender:
|
||||||
|
"""Sends simulated push notifications to iOS simulator."""
|
||||||
|
|
||||||
|
def __init__(self, udid: str | None = None):
|
||||||
|
"""Initialize push notification sender.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
udid: Optional device UDID (auto-detects booted simulator if None)
|
||||||
|
"""
|
||||||
|
self.udid = udid
|
||||||
|
|
||||||
|
def send(
|
||||||
|
self,
|
||||||
|
bundle_id: str,
|
||||||
|
payload: dict | str,
|
||||||
|
_test_name: str | None = None,
|
||||||
|
_expected_behavior: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Send push notification to app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bundle_id: Target app bundle ID
|
||||||
|
payload: Push payload (dict or JSON string) or path to JSON file
|
||||||
|
test_name: Test scenario name for tracking
|
||||||
|
expected_behavior: Expected behavior after notification arrives
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
# Handle different payload formats
|
||||||
|
if isinstance(payload, str):
|
||||||
|
# Check if it's a file path
|
||||||
|
payload_path = Path(payload)
|
||||||
|
if payload_path.exists():
|
||||||
|
with open(payload_path) as f:
|
||||||
|
payload_data = json.load(f)
|
||||||
|
else:
|
||||||
|
# Try to parse as JSON string
|
||||||
|
try:
|
||||||
|
payload_data = json.loads(payload)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(f"Error: Invalid JSON payload: {payload}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
payload_data = payload
|
||||||
|
|
||||||
|
# Ensure payload has aps dictionary
|
||||||
|
if "aps" not in payload_data:
|
||||||
|
payload_data = {"aps": payload_data}
|
||||||
|
|
||||||
|
# Create temp file with payload
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
json.dump(payload_data, f)
|
||||||
|
temp_payload_path = f.name
|
||||||
|
|
||||||
|
# Build simctl command
|
||||||
|
cmd = ["xcrun", "simctl", "push"]
|
||||||
|
|
||||||
|
if self.udid:
|
||||||
|
cmd.append(self.udid)
|
||||||
|
else:
|
||||||
|
cmd.append("booted")
|
||||||
|
|
||||||
|
cmd.extend([bundle_id, temp_payload_path])
|
||||||
|
|
||||||
|
# Send notification
|
||||||
|
subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
# Clean up temp file
|
||||||
|
Path(temp_payload_path).unlink()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error sending push notification: {e.stderr}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_simple(
|
||||||
|
self,
|
||||||
|
bundle_id: str,
|
||||||
|
title: str | None = None,
|
||||||
|
body: str | None = None,
|
||||||
|
badge: int | None = None,
|
||||||
|
sound: bool = True,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Send simple push notification with common parameters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bundle_id: Target app bundle ID
|
||||||
|
title: Alert title
|
||||||
|
body: Alert body
|
||||||
|
badge: Badge number
|
||||||
|
sound: Whether to play sound
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
if title or body:
|
||||||
|
alert = {}
|
||||||
|
if title:
|
||||||
|
alert["title"] = title
|
||||||
|
if body:
|
||||||
|
alert["body"] = body
|
||||||
|
payload["alert"] = alert
|
||||||
|
|
||||||
|
if badge is not None:
|
||||||
|
payload["badge"] = badge
|
||||||
|
|
||||||
|
if sound:
|
||||||
|
payload["sound"] = "default"
|
||||||
|
|
||||||
|
# Wrap in aps
|
||||||
|
full_payload = {"aps": payload}
|
||||||
|
|
||||||
|
return self.send(bundle_id, full_payload)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Send simulated push notification to iOS app")
|
||||||
|
|
||||||
|
# Required
|
||||||
|
parser.add_argument(
|
||||||
|
"--bundle-id", required=True, help="Target app bundle ID (e.g., com.example.app)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simple payload options
|
||||||
|
parser.add_argument("--title", help="Alert title (for simple notifications)")
|
||||||
|
parser.add_argument("--body", help="Alert body message")
|
||||||
|
parser.add_argument("--badge", type=int, help="Badge number")
|
||||||
|
parser.add_argument("--no-sound", action="store_true", help="Don't play notification sound")
|
||||||
|
|
||||||
|
# Custom payload
|
||||||
|
parser.add_argument(
|
||||||
|
"--payload",
|
||||||
|
help="Custom JSON payload file or inline JSON string",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test tracking
|
||||||
|
parser.add_argument("--test-name", help="Test scenario name for tracking")
|
||||||
|
parser.add_argument(
|
||||||
|
"--expected",
|
||||||
|
help="Expected behavior after notification",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Device
|
||||||
|
parser.add_argument(
|
||||||
|
"--udid",
|
||||||
|
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Resolve UDID with auto-detection
|
||||||
|
try:
|
||||||
|
udid = resolve_udid(args.udid)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
sender = PushNotificationSender(udid=udid)
|
||||||
|
|
||||||
|
# Send notification
|
||||||
|
if args.payload:
|
||||||
|
# Custom payload mode
|
||||||
|
success = sender.send(args.bundle_id, args.payload)
|
||||||
|
else:
|
||||||
|
# Simple notification mode
|
||||||
|
success = sender.send_simple(
|
||||||
|
args.bundle_id,
|
||||||
|
title=args.title,
|
||||||
|
body=args.body,
|
||||||
|
badge=args.badge,
|
||||||
|
sound=not args.no_sound,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
# Token-efficient output
|
||||||
|
output = "Push notification sent"
|
||||||
|
|
||||||
|
if args.test_name:
|
||||||
|
output += f" (test: {args.test_name})"
|
||||||
|
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
if args.expected:
|
||||||
|
print(f"Expected: {args.expected}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Notification details:")
|
||||||
|
if args.title:
|
||||||
|
print(f" Title: {args.title}")
|
||||||
|
if args.body:
|
||||||
|
print(f" Body: {args.body}")
|
||||||
|
if args.badge:
|
||||||
|
print(f" Badge: {args.badge}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Verify notification handling:")
|
||||||
|
print("1. Check app log output: python scripts/log_monitor.py --app " + args.bundle_id)
|
||||||
|
print(
|
||||||
|
"2. Capture state: python scripts/app_state_capture.py --app-bundle-id "
|
||||||
|
+ args.bundle_id
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Failed to send push notification")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+292
@@ -0,0 +1,292 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
iOS Screen Mapper - Current Screen Analyzer
|
||||||
|
|
||||||
|
Maps the current screen's UI elements for navigation decisions.
|
||||||
|
Provides token-efficient summaries of available interactions.
|
||||||
|
|
||||||
|
This script analyzes the iOS simulator screen using IDB's accessibility tree
|
||||||
|
and provides a compact, actionable summary of what's currently visible and
|
||||||
|
interactive on the screen. Perfect for AI agents making navigation decisions.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Token-efficient output (5-7 lines by default)
|
||||||
|
- Identifies buttons, text fields, navigation elements
|
||||||
|
- Counts interactive and focusable elements
|
||||||
|
- Progressive detail with --verbose flag
|
||||||
|
- Navigation hints with --hints flag
|
||||||
|
|
||||||
|
Usage Examples:
|
||||||
|
# Quick summary (default)
|
||||||
|
python scripts/screen_mapper.py --udid <device-id>
|
||||||
|
|
||||||
|
# Detailed element breakdown
|
||||||
|
python scripts/screen_mapper.py --udid <device-id> --verbose
|
||||||
|
|
||||||
|
# Include navigation suggestions
|
||||||
|
python scripts/screen_mapper.py --udid <device-id> --hints
|
||||||
|
|
||||||
|
# Full JSON output for parsing
|
||||||
|
python scripts/screen_mapper.py --udid <device-id> --json
|
||||||
|
|
||||||
|
Output Format (default):
|
||||||
|
Screen: LoginViewController (45 elements, 7 interactive)
|
||||||
|
Buttons: "Login", "Cancel", "Forgot Password"
|
||||||
|
TextFields: 2 (0 filled)
|
||||||
|
Navigation: NavBar: "Sign In"
|
||||||
|
Focusable: 7 elements
|
||||||
|
|
||||||
|
Technical Details:
|
||||||
|
- Uses IDB's accessibility tree via `idb ui describe-all --json --nested`
|
||||||
|
- Parses IDB's array format: [{ root element with children }]
|
||||||
|
- Identifies element types: Button, TextField, NavigationBar, TabBar, etc.
|
||||||
|
- Extracts labels from AXLabel, AXValue, and AXUniqueId fields
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from common import get_accessibility_tree, resolve_udid
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenMapper:
|
||||||
|
"""
|
||||||
|
Analyzes current screen for navigation decisions.
|
||||||
|
|
||||||
|
This class fetches the iOS accessibility tree from IDB and analyzes it
|
||||||
|
to provide actionable summaries for navigation. It categorizes elements
|
||||||
|
by type, counts interactive elements, and identifies key UI patterns.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
udid (Optional[str]): Device UDID to target, or None for booted device
|
||||||
|
INTERACTIVE_TYPES (Set[str]): Element types that users can interact with
|
||||||
|
|
||||||
|
Design Philosophy:
|
||||||
|
- Token efficiency: Provide minimal but complete information
|
||||||
|
- Progressive disclosure: Summary by default, details on request
|
||||||
|
- Navigation-focused: Highlight elements relevant for automation
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Element types we care about for navigation
|
||||||
|
# These are the accessibility element types that indicate user interaction points
|
||||||
|
INTERACTIVE_TYPES = {
|
||||||
|
"Button",
|
||||||
|
"Link",
|
||||||
|
"TextField",
|
||||||
|
"SecureTextField",
|
||||||
|
"Cell",
|
||||||
|
"Switch",
|
||||||
|
"Slider",
|
||||||
|
"Stepper",
|
||||||
|
"SegmentedControl",
|
||||||
|
"TabBar",
|
||||||
|
"NavigationBar",
|
||||||
|
"Toolbar",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, udid: str | None = None):
|
||||||
|
"""
|
||||||
|
Initialize screen mapper.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
udid: Optional device UDID. If None, uses booted simulator.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
mapper = ScreenMapper(udid="656DC652-1C9F-4AB2-AD4F-F38E65976BDA")
|
||||||
|
mapper = ScreenMapper() # Uses booted device
|
||||||
|
"""
|
||||||
|
self.udid = udid
|
||||||
|
|
||||||
|
def get_accessibility_tree(self) -> dict:
|
||||||
|
"""
|
||||||
|
Fetch accessibility tree from iOS simulator via IDB.
|
||||||
|
|
||||||
|
Delegates to shared utility for consistent tree fetching across all scripts.
|
||||||
|
"""
|
||||||
|
return get_accessibility_tree(self.udid, nested=True)
|
||||||
|
|
||||||
|
def analyze_tree(self, node: dict, depth: int = 0) -> dict:
|
||||||
|
"""Analyze accessibility tree for navigation info."""
|
||||||
|
analysis = {
|
||||||
|
"elements_by_type": defaultdict(list),
|
||||||
|
"total_elements": 0,
|
||||||
|
"interactive_elements": 0,
|
||||||
|
"text_fields": [],
|
||||||
|
"buttons": [],
|
||||||
|
"navigation": {},
|
||||||
|
"screen_name": None,
|
||||||
|
"focusable": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
self._analyze_recursive(node, analysis, depth)
|
||||||
|
|
||||||
|
# Post-process for clean output
|
||||||
|
analysis["elements_by_type"] = dict(analysis["elements_by_type"])
|
||||||
|
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
def _analyze_recursive(self, node: dict, analysis: dict, depth: int):
|
||||||
|
"""Recursively analyze tree nodes."""
|
||||||
|
elem_type = node.get("type")
|
||||||
|
label = node.get("AXLabel", "")
|
||||||
|
value = node.get("AXValue", "")
|
||||||
|
identifier = node.get("AXUniqueId", "")
|
||||||
|
|
||||||
|
# Count element
|
||||||
|
if elem_type:
|
||||||
|
analysis["total_elements"] += 1
|
||||||
|
|
||||||
|
# Track by type
|
||||||
|
if elem_type in self.INTERACTIVE_TYPES:
|
||||||
|
analysis["interactive_elements"] += 1
|
||||||
|
|
||||||
|
# Store concise info (label only, not full node)
|
||||||
|
elem_info = label or value or identifier or "Unnamed"
|
||||||
|
analysis["elements_by_type"][elem_type].append(elem_info)
|
||||||
|
|
||||||
|
# Special handling for common types
|
||||||
|
if elem_type == "Button":
|
||||||
|
analysis["buttons"].append(elem_info)
|
||||||
|
elif elem_type in ("TextField", "SecureTextField"):
|
||||||
|
analysis["text_fields"].append(
|
||||||
|
{"type": elem_type, "label": elem_info, "has_value": bool(value)}
|
||||||
|
)
|
||||||
|
elif elem_type == "NavigationBar":
|
||||||
|
analysis["navigation"]["nav_title"] = label or "Navigation"
|
||||||
|
elif elem_type == "TabBar":
|
||||||
|
# Count tab items
|
||||||
|
tab_count = len(node.get("children", []))
|
||||||
|
analysis["navigation"]["tab_count"] = tab_count
|
||||||
|
|
||||||
|
# Track focusable elements
|
||||||
|
if node.get("enabled", False) and elem_type in self.INTERACTIVE_TYPES:
|
||||||
|
analysis["focusable"] += 1
|
||||||
|
|
||||||
|
# Try to identify screen name from view controller
|
||||||
|
if not analysis["screen_name"] and identifier:
|
||||||
|
if "ViewController" in identifier or "Screen" in identifier:
|
||||||
|
analysis["screen_name"] = identifier
|
||||||
|
|
||||||
|
# Process children
|
||||||
|
for child in node.get("children", []):
|
||||||
|
self._analyze_recursive(child, analysis, depth + 1)
|
||||||
|
|
||||||
|
def format_summary(self, analysis: dict, verbose: bool = False) -> str:
|
||||||
|
"""Format analysis as token-efficient summary."""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Screen identification (1 line)
|
||||||
|
screen = analysis["screen_name"] or "Unknown Screen"
|
||||||
|
total = analysis["total_elements"]
|
||||||
|
interactive = analysis["interactive_elements"]
|
||||||
|
lines.append(f"Screen: {screen} ({total} elements, {interactive} interactive)")
|
||||||
|
|
||||||
|
# Buttons summary (1 line)
|
||||||
|
if analysis["buttons"]:
|
||||||
|
button_list = ", ".join(f'"{b}"' for b in analysis["buttons"][:5])
|
||||||
|
if len(analysis["buttons"]) > 5:
|
||||||
|
button_list += f" +{len(analysis['buttons']) - 5} more"
|
||||||
|
lines.append(f"Buttons: {button_list}")
|
||||||
|
|
||||||
|
# Text fields summary (1 line)
|
||||||
|
if analysis["text_fields"]:
|
||||||
|
field_count = len(analysis["text_fields"])
|
||||||
|
[f["type"] for f in analysis["text_fields"]]
|
||||||
|
filled = sum(1 for f in analysis["text_fields"] if f["has_value"])
|
||||||
|
lines.append(f"TextFields: {field_count} ({filled} filled)")
|
||||||
|
|
||||||
|
# Navigation summary (1 line)
|
||||||
|
nav_parts = []
|
||||||
|
if "nav_title" in analysis["navigation"]:
|
||||||
|
nav_parts.append(f"NavBar: \"{analysis['navigation']['nav_title']}\"")
|
||||||
|
if "tab_count" in analysis["navigation"]:
|
||||||
|
nav_parts.append(f"TabBar: {analysis['navigation']['tab_count']} tabs")
|
||||||
|
if nav_parts:
|
||||||
|
lines.append(f"Navigation: {', '.join(nav_parts)}")
|
||||||
|
|
||||||
|
# Focusable count (1 line)
|
||||||
|
lines.append(f"Focusable: {analysis['focusable']} elements")
|
||||||
|
|
||||||
|
# Verbose mode adds element type breakdown
|
||||||
|
if verbose:
|
||||||
|
lines.append("\nElements by type:")
|
||||||
|
for elem_type, items in analysis["elements_by_type"].items():
|
||||||
|
if items: # Only show types that exist
|
||||||
|
lines.append(f" {elem_type}: {len(items)}")
|
||||||
|
for item in items[:3]: # Show first 3
|
||||||
|
lines.append(f" - {item}")
|
||||||
|
if len(items) > 3:
|
||||||
|
lines.append(f" ... +{len(items) - 3} more")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def get_navigation_hints(self, analysis: dict) -> list[str]:
|
||||||
|
"""Generate navigation hints based on screen analysis."""
|
||||||
|
hints = []
|
||||||
|
|
||||||
|
# Check for common patterns
|
||||||
|
if "Login" in str(analysis.get("buttons", [])):
|
||||||
|
hints.append("Login screen detected - find TextFields for credentials")
|
||||||
|
|
||||||
|
if analysis["text_fields"]:
|
||||||
|
unfilled = [f for f in analysis["text_fields"] if not f["has_value"]]
|
||||||
|
if unfilled:
|
||||||
|
hints.append(f"{len(unfilled)} empty text field(s) - may need input")
|
||||||
|
|
||||||
|
if not analysis["buttons"] and not analysis["text_fields"]:
|
||||||
|
hints.append("No interactive elements - try swiping or going back")
|
||||||
|
|
||||||
|
if "tab_count" in analysis.get("navigation", {}):
|
||||||
|
hints.append(f"Tab bar available with {analysis['navigation']['tab_count']} tabs")
|
||||||
|
|
||||||
|
return hints
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Map current screen UI elements")
|
||||||
|
parser.add_argument("--verbose", action="store_true", help="Show detailed element breakdown")
|
||||||
|
parser.add_argument("--json", action="store_true", help="Output raw JSON analysis")
|
||||||
|
parser.add_argument("--hints", action="store_true", help="Include navigation hints")
|
||||||
|
parser.add_argument(
|
||||||
|
"--udid",
|
||||||
|
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Resolve UDID with auto-detection
|
||||||
|
try:
|
||||||
|
udid = resolve_udid(args.udid)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Create mapper and analyze
|
||||||
|
mapper = ScreenMapper(udid=udid)
|
||||||
|
tree = mapper.get_accessibility_tree()
|
||||||
|
analysis = mapper.analyze_tree(tree)
|
||||||
|
|
||||||
|
# Output based on format
|
||||||
|
if args.json:
|
||||||
|
# Full JSON (verbose)
|
||||||
|
print(json.dumps(analysis, indent=2, default=str))
|
||||||
|
else:
|
||||||
|
# Token-efficient summary (default)
|
||||||
|
summary = mapper.format_summary(analysis, verbose=args.verbose)
|
||||||
|
print(summary)
|
||||||
|
|
||||||
|
# Add hints if requested
|
||||||
|
if args.hints:
|
||||||
|
hints = mapper.get_navigation_hints(analysis)
|
||||||
|
if hints:
|
||||||
|
print("\nHints:")
|
||||||
|
for hint in hints:
|
||||||
|
print(f" - {hint}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# iOS Simulator Testing Environment Health Check
|
||||||
|
#
|
||||||
|
# Verifies that all required tools and dependencies are properly installed
|
||||||
|
# and configured for iOS simulator testing.
|
||||||
|
#
|
||||||
|
# Usage: bash scripts/sim_health_check.sh [--help]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Color codes for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Check flags
|
||||||
|
SHOW_HELP=false
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
for arg in "$@"; do
|
||||||
|
case $arg in
|
||||||
|
--help|-h)
|
||||||
|
SHOW_HELP=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$SHOW_HELP" = true ]; then
|
||||||
|
cat <<EOF
|
||||||
|
iOS Simulator Testing - Environment Health Check
|
||||||
|
|
||||||
|
Verifies that your environment is properly configured for iOS simulator testing.
|
||||||
|
|
||||||
|
Usage: bash scripts/sim_health_check.sh [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--help, -h Show this help message
|
||||||
|
|
||||||
|
This script checks for:
|
||||||
|
- Xcode Command Line Tools installation
|
||||||
|
- iOS Simulator availability
|
||||||
|
- IDB (iOS Development Bridge) installation
|
||||||
|
- Available simulator devices
|
||||||
|
- Python 3 installation (for scripts)
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 - All checks passed
|
||||||
|
1 - One or more checks failed (see output for details)
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e "${BLUE} iOS Simulator Testing - Environment Health Check${NC}"
|
||||||
|
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
CHECKS_PASSED=0
|
||||||
|
CHECKS_FAILED=0
|
||||||
|
|
||||||
|
# Function to print check status
|
||||||
|
check_passed() {
|
||||||
|
echo -e "${GREEN}✓${NC} $1"
|
||||||
|
((CHECKS_PASSED++))
|
||||||
|
}
|
||||||
|
|
||||||
|
check_failed() {
|
||||||
|
echo -e "${RED}✗${NC} $1"
|
||||||
|
((CHECKS_FAILED++))
|
||||||
|
}
|
||||||
|
|
||||||
|
check_warning() {
|
||||||
|
echo -e "${YELLOW}⚠${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check 1: macOS
|
||||||
|
echo -e "${BLUE}[1/8]${NC} Checking operating system..."
|
||||||
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
OS_VERSION=$(sw_vers -productVersion)
|
||||||
|
check_passed "macOS detected (version $OS_VERSION)"
|
||||||
|
else
|
||||||
|
check_failed "Not running on macOS (detected: $OSTYPE)"
|
||||||
|
echo " iOS Simulator testing requires macOS"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check 2: Xcode Command Line Tools
|
||||||
|
echo -e "${BLUE}[2/8]${NC} Checking Xcode Command Line Tools..."
|
||||||
|
if command -v xcrun &> /dev/null; then
|
||||||
|
XCODE_PATH=$(xcode-select -p 2>/dev/null || echo "not found")
|
||||||
|
if [ "$XCODE_PATH" != "not found" ]; then
|
||||||
|
XCODE_VERSION=$(xcodebuild -version 2>/dev/null | head -n 1 || echo "Unknown")
|
||||||
|
check_passed "Xcode Command Line Tools installed"
|
||||||
|
echo " Path: $XCODE_PATH"
|
||||||
|
echo " Version: $XCODE_VERSION"
|
||||||
|
else
|
||||||
|
check_failed "Xcode Command Line Tools path not set"
|
||||||
|
echo " Run: xcode-select --install"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
check_failed "xcrun command not found"
|
||||||
|
echo " Install Xcode Command Line Tools: xcode-select --install"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check 3: simctl availability
|
||||||
|
echo -e "${BLUE}[3/8]${NC} Checking simctl (Simulator Control)..."
|
||||||
|
if command -v xcrun &> /dev/null && xcrun simctl help &> /dev/null; then
|
||||||
|
check_passed "simctl is available"
|
||||||
|
else
|
||||||
|
check_failed "simctl not available"
|
||||||
|
echo " simctl comes with Xcode Command Line Tools"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check 4: IDB installation
|
||||||
|
echo -e "${BLUE}[4/8]${NC} Checking IDB (iOS Development Bridge)..."
|
||||||
|
if command -v idb &> /dev/null; then
|
||||||
|
IDB_PATH=$(which idb)
|
||||||
|
IDB_VERSION=$(idb --version 2>/dev/null || echo "Unknown")
|
||||||
|
check_passed "IDB is installed"
|
||||||
|
echo " Path: $IDB_PATH"
|
||||||
|
echo " Version: $IDB_VERSION"
|
||||||
|
else
|
||||||
|
check_warning "IDB not found in PATH"
|
||||||
|
echo " IDB is optional but provides advanced UI automation"
|
||||||
|
echo " Install: https://fbidb.io/docs/installation"
|
||||||
|
echo " Recommended: brew tap facebook/fb && brew install idb-companion"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check 5: Python 3 installation
|
||||||
|
echo -e "${BLUE}[5/8]${NC} Checking Python 3..."
|
||||||
|
if command -v python3 &> /dev/null; then
|
||||||
|
PYTHON_VERSION=$(python3 --version | cut -d' ' -f2)
|
||||||
|
check_passed "Python 3 is installed (version $PYTHON_VERSION)"
|
||||||
|
else
|
||||||
|
check_failed "Python 3 not found"
|
||||||
|
echo " Python 3 is required for testing scripts"
|
||||||
|
echo " Install: brew install python3"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check 6: Available simulators
|
||||||
|
echo -e "${BLUE}[6/8]${NC} Checking available iOS Simulators..."
|
||||||
|
if command -v xcrun &> /dev/null; then
|
||||||
|
SIMULATOR_COUNT=$(xcrun simctl list devices available 2>/dev/null | grep -c "iPhone\|iPad" || echo "0")
|
||||||
|
|
||||||
|
if [ "$SIMULATOR_COUNT" -gt 0 ]; then
|
||||||
|
check_passed "Found $SIMULATOR_COUNT available simulator(s)"
|
||||||
|
|
||||||
|
# Show first 5 simulators
|
||||||
|
echo ""
|
||||||
|
echo " Available simulators (showing up to 5):"
|
||||||
|
xcrun simctl list devices available 2>/dev/null | grep "iPhone\|iPad" | head -5 | while read -r line; do
|
||||||
|
echo " - $line"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
check_warning "No simulators found"
|
||||||
|
echo " Create simulators via Xcode or simctl"
|
||||||
|
echo " Example: xcrun simctl create 'iPhone 15' 'iPhone 15'"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
check_failed "Cannot check simulators (simctl not available)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check 7: Booted simulators
|
||||||
|
echo -e "${BLUE}[7/8]${NC} Checking booted simulators..."
|
||||||
|
if command -v xcrun &> /dev/null; then
|
||||||
|
BOOTED_SIMS=$(xcrun simctl list devices booted 2>/dev/null | grep -c "iPhone\|iPad" || echo "0")
|
||||||
|
|
||||||
|
if [ "$BOOTED_SIMS" -gt 0 ]; then
|
||||||
|
check_passed "$BOOTED_SIMS simulator(s) currently booted"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Booted simulators:"
|
||||||
|
xcrun simctl list devices booted 2>/dev/null | grep "iPhone\|iPad" | while read -r line; do
|
||||||
|
echo " - $line"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
check_warning "No simulators currently booted"
|
||||||
|
echo " Boot a simulator to begin testing"
|
||||||
|
echo " Example: xcrun simctl boot <device-udid>"
|
||||||
|
echo " Or: open -a Simulator"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
check_failed "Cannot check booted simulators (simctl not available)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check 8: Required Python packages (optional check)
|
||||||
|
echo -e "${BLUE}[8/8]${NC} Checking Python packages..."
|
||||||
|
if command -v python3 &> /dev/null; then
|
||||||
|
MISSING_PACKAGES=()
|
||||||
|
|
||||||
|
# Check for PIL/Pillow (for visual_diff.py)
|
||||||
|
if python3 -c "import PIL" 2>/dev/null; then
|
||||||
|
check_passed "Pillow (PIL) installed - visual diff available"
|
||||||
|
else
|
||||||
|
MISSING_PACKAGES+=("pillow")
|
||||||
|
check_warning "Pillow (PIL) not installed - visual diff won't work"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#MISSING_PACKAGES[@]} -gt 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo " Install missing packages:"
|
||||||
|
echo " pip3 install ${MISSING_PACKAGES[*]}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
check_warning "Cannot check Python packages (Python 3 not available)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e "${BLUE} Summary${NC}"
|
||||||
|
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "Checks passed: ${GREEN}$CHECKS_PASSED${NC}"
|
||||||
|
if [ "$CHECKS_FAILED" -gt 0 ]; then
|
||||||
|
echo -e "Checks failed: ${RED}$CHECKS_FAILED${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Action required:${NC} Fix the failed checks above before testing"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✓ Environment is ready for iOS simulator testing${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. Boot a simulator: open -a Simulator"
|
||||||
|
echo " 2. Launch your app: xcrun simctl launch booted <bundle-id>"
|
||||||
|
echo " 3. Run accessibility audit: python scripts/accessibility_audit.py"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
iOS Simulator Listing with Progressive Disclosure
|
||||||
|
|
||||||
|
Lists available simulators with token-efficient summaries.
|
||||||
|
Full details available on demand via cache IDs.
|
||||||
|
|
||||||
|
Achieves 96% token reduction (57k→2k tokens) for common queries.
|
||||||
|
|
||||||
|
Usage Examples:
|
||||||
|
# Concise summary (default)
|
||||||
|
python scripts/sim_list.py
|
||||||
|
|
||||||
|
# Get full details for cached list
|
||||||
|
python scripts/sim_list.py --get-details <cache-id>
|
||||||
|
|
||||||
|
# Get recommendations
|
||||||
|
python scripts/sim_list.py --suggest
|
||||||
|
|
||||||
|
# Filter by device type
|
||||||
|
python scripts/sim_list.py --device-type iPhone
|
||||||
|
|
||||||
|
Output (default):
|
||||||
|
Simulator Summary [cache-sim-20251028-143052]
|
||||||
|
├─ Total: 47 devices
|
||||||
|
├─ Available: 31
|
||||||
|
└─ Booted: 1
|
||||||
|
|
||||||
|
✓ iPhone 16 Pro (iOS 18.1) [ABC-123...]
|
||||||
|
|
||||||
|
Use --get-details cache-sim-20251028-143052 for full list
|
||||||
|
|
||||||
|
Technical Details:
|
||||||
|
- Uses xcrun simctl list devices
|
||||||
|
- Caches results with 1-hour TTL
|
||||||
|
- Reduces output by 96% by default
|
||||||
|
- Token efficiency: summary = ~30 tokens, full list = ~1500 tokens
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from common import get_cache
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatorLister:
|
||||||
|
"""Lists iOS simulators with progressive disclosure."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize lister with cache."""
|
||||||
|
self.cache = get_cache()
|
||||||
|
|
||||||
|
def list_simulators(self) -> dict:
|
||||||
|
"""
|
||||||
|
Get list of all simulators.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with structure:
|
||||||
|
{
|
||||||
|
"devices": [...],
|
||||||
|
"runtimes": [...],
|
||||||
|
"total_devices": int,
|
||||||
|
"available_devices": int,
|
||||||
|
"booted_devices": [...]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["xcrun", "simctl", "list", "devices", "--json"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
except (subprocess.CalledProcessError, json.JSONDecodeError):
|
||||||
|
return {"devices": {}, "runtimes": []}
|
||||||
|
|
||||||
|
def parse_devices(self, sim_data: dict) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Parse simulator data into flat list.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of device dicts with runtime info
|
||||||
|
"""
|
||||||
|
devices = []
|
||||||
|
|
||||||
|
devices_by_runtime = sim_data.get("devices", {})
|
||||||
|
|
||||||
|
for runtime_str, device_list in devices_by_runtime.items():
|
||||||
|
# Extract iOS version from runtime string
|
||||||
|
# Format: "iOS 18.1", "tvOS 18", etc.
|
||||||
|
runtime_name = runtime_str.replace(" Simulator", "").strip()
|
||||||
|
|
||||||
|
for device in device_list:
|
||||||
|
devices.append(
|
||||||
|
{
|
||||||
|
"name": device.get("name"),
|
||||||
|
"udid": device.get("udid"),
|
||||||
|
"state": device.get("state"),
|
||||||
|
"runtime": runtime_name,
|
||||||
|
"is_available": device.get("isAvailable", False),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return devices
|
||||||
|
|
||||||
|
def get_concise_summary(self, devices: list[dict]) -> dict:
|
||||||
|
"""
|
||||||
|
Generate concise summary with cache ID.
|
||||||
|
|
||||||
|
Returns 96% fewer tokens than full list.
|
||||||
|
"""
|
||||||
|
booted = [d for d in devices if d["state"] == "Booted"]
|
||||||
|
available = [d for d in devices if d["is_available"]]
|
||||||
|
iphone = [d for d in available if "iPhone" in d["name"]]
|
||||||
|
|
||||||
|
# Cache full list for later retrieval
|
||||||
|
cache_id = self.cache.save(
|
||||||
|
{
|
||||||
|
"devices": devices,
|
||||||
|
"timestamp": __import__("datetime").datetime.now().isoformat(),
|
||||||
|
},
|
||||||
|
"simulator-list",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cache_id": cache_id,
|
||||||
|
"summary": {
|
||||||
|
"total_devices": len(devices),
|
||||||
|
"available_devices": len(available),
|
||||||
|
"booted_devices": len(booted),
|
||||||
|
},
|
||||||
|
"quick_access": {
|
||||||
|
"booted": booted[:3] if booted else [],
|
||||||
|
"recommended_iphone": iphone[:3] if iphone else [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_full_list(
|
||||||
|
self,
|
||||||
|
cache_id: str,
|
||||||
|
device_type: str | None = None,
|
||||||
|
runtime: str | None = None,
|
||||||
|
) -> list[dict] | None:
|
||||||
|
"""
|
||||||
|
Retrieve full simulator list from cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_id: Cache ID from concise summary
|
||||||
|
device_type: Filter by type (iPhone, iPad, etc.)
|
||||||
|
runtime: Filter by iOS version
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of devices matching filters
|
||||||
|
"""
|
||||||
|
data = self.cache.get(cache_id)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
devices = data.get("devices", [])
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if device_type:
|
||||||
|
devices = [d for d in devices if device_type in d["name"]]
|
||||||
|
if runtime:
|
||||||
|
devices = [d for d in devices if runtime.lower() in d["runtime"].lower()]
|
||||||
|
|
||||||
|
return devices
|
||||||
|
|
||||||
|
def suggest_simulators(self, limit: int = 4) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Get simulator recommendations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of recommended simulators (best candidates for building)
|
||||||
|
"""
|
||||||
|
all_sims = self.list_simulators()
|
||||||
|
devices = self.parse_devices(all_sims)
|
||||||
|
|
||||||
|
# Score devices for recommendations
|
||||||
|
scored = []
|
||||||
|
for device in devices:
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
# Prefer booted
|
||||||
|
if device["state"] == "Booted":
|
||||||
|
score += 10
|
||||||
|
# Prefer available
|
||||||
|
if device["is_available"]:
|
||||||
|
score += 5
|
||||||
|
# Prefer recent iOS versions
|
||||||
|
ios_version = device["runtime"]
|
||||||
|
if "18" in ios_version:
|
||||||
|
score += 3
|
||||||
|
elif "17" in ios_version:
|
||||||
|
score += 2
|
||||||
|
# Prefer iPhones over other types
|
||||||
|
if "iPhone" in device["name"]:
|
||||||
|
score += 1
|
||||||
|
|
||||||
|
scored.append({"device": device, "score": score})
|
||||||
|
|
||||||
|
# Sort by score and return top N
|
||||||
|
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||||
|
return [s["device"] for s in scored[:limit]]
|
||||||
|
|
||||||
|
|
||||||
|
def format_device(device: dict) -> str:
|
||||||
|
"""Format device for display."""
|
||||||
|
state_icon = "✓" if device["state"] == "Booted" else " "
|
||||||
|
avail_icon = "●" if device["is_available"] else "○"
|
||||||
|
name = device["name"]
|
||||||
|
runtime = device["runtime"]
|
||||||
|
udid_short = device["udid"][:8] + "..."
|
||||||
|
return f"{state_icon} {avail_icon} {name} ({runtime}) [{udid_short}]"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="List iOS simulators with progressive disclosure")
|
||||||
|
parser.add_argument(
|
||||||
|
"--get-details",
|
||||||
|
metavar="CACHE_ID",
|
||||||
|
help="Get full details for cached simulator list",
|
||||||
|
)
|
||||||
|
parser.add_argument("--suggest", action="store_true", help="Get simulator recommendations")
|
||||||
|
parser.add_argument(
|
||||||
|
"--device-type",
|
||||||
|
help="Filter by device type (iPhone, iPad, Apple Watch, etc.)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--runtime", help="Filter by iOS version (e.g., iOS-18, iOS-17)")
|
||||||
|
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
lister = SimulatorLister()
|
||||||
|
|
||||||
|
# Get full list with details
|
||||||
|
if args.get_details:
|
||||||
|
devices = lister.get_full_list(
|
||||||
|
args.get_details, device_type=args.device_type, runtime=args.runtime
|
||||||
|
)
|
||||||
|
|
||||||
|
if devices is None:
|
||||||
|
print(f"Error: Cache ID not found or expired: {args.get_details}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(devices, indent=2))
|
||||||
|
else:
|
||||||
|
print(f"Simulators ({len(devices)}):\n")
|
||||||
|
for device in devices:
|
||||||
|
print(f" {format_device(device)}")
|
||||||
|
|
||||||
|
# Get recommendations
|
||||||
|
elif args.suggest:
|
||||||
|
suggestions = lister.suggest_simulators()
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(suggestions, indent=2))
|
||||||
|
else:
|
||||||
|
print("Recommended Simulators:\n")
|
||||||
|
for i, device in enumerate(suggestions, 1):
|
||||||
|
print(f"{i}. {format_device(device)}")
|
||||||
|
|
||||||
|
# Default: concise summary
|
||||||
|
else:
|
||||||
|
all_sims = lister.list_simulators()
|
||||||
|
devices = lister.parse_devices(all_sims)
|
||||||
|
summary = lister.get_concise_summary(devices)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(summary, indent=2))
|
||||||
|
else:
|
||||||
|
# Human-readable concise output
|
||||||
|
cache_id = summary["cache_id"]
|
||||||
|
s = summary["summary"]
|
||||||
|
q = summary["quick_access"]
|
||||||
|
|
||||||
|
print(f"Simulator Summary [{cache_id}]")
|
||||||
|
print(f"├─ Total: {s['total_devices']} devices")
|
||||||
|
print(f"├─ Available: {s['available_devices']}")
|
||||||
|
print(f"└─ Booted: {s['booted_devices']}")
|
||||||
|
|
||||||
|
if q["booted"]:
|
||||||
|
print()
|
||||||
|
for device in q["booted"]:
|
||||||
|
print(f" {format_device(device)}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"Use --get-details {cache_id} for full list")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+297
@@ -0,0 +1,297 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Boot iOS simulators and wait for readiness.
|
||||||
|
|
||||||
|
This script boots one or more simulators and optionally waits for them to reach
|
||||||
|
a ready state. It measures boot time and provides progress feedback.
|
||||||
|
|
||||||
|
Key features:
|
||||||
|
- Boot by UDID or device name
|
||||||
|
- Wait for device readiness with configurable timeout
|
||||||
|
- Measure boot performance
|
||||||
|
- Batch boot operations (boot all, boot by type)
|
||||||
|
- Progress reporting for CI/CD pipelines
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from common.device_utils import (
|
||||||
|
get_booted_device_udid,
|
||||||
|
list_simulators,
|
||||||
|
resolve_device_identifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatorBooter:
|
||||||
|
"""Boot iOS simulators with optional readiness waiting."""
|
||||||
|
|
||||||
|
def __init__(self, udid: str | None = None):
|
||||||
|
"""Initialize booter with optional device UDID."""
|
||||||
|
self.udid = udid
|
||||||
|
|
||||||
|
def boot(self, wait_ready: bool = False, timeout_seconds: int = 120) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Boot simulator and optionally wait for readiness.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wait_ready: Wait for device to be ready before returning
|
||||||
|
timeout_seconds: Maximum seconds to wait for readiness
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, message) tuple
|
||||||
|
"""
|
||||||
|
if not self.udid:
|
||||||
|
return False, "Error: Device UDID not specified"
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Check if already booted
|
||||||
|
try:
|
||||||
|
booted = get_booted_device_udid()
|
||||||
|
if booted == self.udid:
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
return True, (f"Device already booted: {self.udid} " f"[checked in {elapsed:.1f}s]")
|
||||||
|
except RuntimeError:
|
||||||
|
pass # No booted device, proceed with boot
|
||||||
|
|
||||||
|
# Execute boot command
|
||||||
|
try:
|
||||||
|
cmd = ["xcrun", "simctl", "boot", self.udid]
|
||||||
|
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=30)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
error = result.stderr.strip()
|
||||||
|
return False, f"Boot failed: {error}"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "Boot command timed out"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Boot error: {e}"
|
||||||
|
|
||||||
|
# Optionally wait for readiness
|
||||||
|
if wait_ready:
|
||||||
|
ready, wait_message = self._wait_for_ready(timeout_seconds)
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
if ready:
|
||||||
|
return True, (f"Device booted and ready: {self.udid} " f"[{elapsed:.1f}s total]")
|
||||||
|
return False, wait_message
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
return True, (
|
||||||
|
f"Device booted: {self.udid} [boot in {elapsed:.1f}s] "
|
||||||
|
"(use --wait-ready to wait for availability)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _wait_for_ready(self, timeout_seconds: int = 120) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Wait for device to reach ready state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout_seconds: Maximum seconds to wait
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, message) tuple
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
poll_interval = 0.5
|
||||||
|
checks = 0
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout_seconds:
|
||||||
|
try:
|
||||||
|
checks += 1
|
||||||
|
# Check if device responds to simctl commands
|
||||||
|
result = subprocess.run(
|
||||||
|
["xcrun", "simctl", "spawn", self.udid, "launchctl", "list"],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
return True, (
|
||||||
|
f"Device ready: {self.udid} " f"[{elapsed:.1f}s, {checks} checks]"
|
||||||
|
)
|
||||||
|
except (subprocess.TimeoutExpired, RuntimeError):
|
||||||
|
pass # Not ready yet
|
||||||
|
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
return False, (
|
||||||
|
f"Boot timeout: Device did not reach ready state "
|
||||||
|
f"within {elapsed:.1f}s ({checks} checks)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def boot_all() -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Boot all available simulators.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(succeeded, failed) tuple with counts
|
||||||
|
"""
|
||||||
|
simulators = list_simulators(state="available")
|
||||||
|
succeeded = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for sim in simulators:
|
||||||
|
booter = SimulatorBooter(udid=sim["udid"])
|
||||||
|
success, _message = booter.boot(wait_ready=False)
|
||||||
|
if success:
|
||||||
|
succeeded += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return succeeded, failed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def boot_by_type(device_type: str) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Boot all simulators of a specific type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_type: Device type filter (e.g., "iPhone", "iPad")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(succeeded, failed) tuple with counts
|
||||||
|
"""
|
||||||
|
simulators = list_simulators(state="available")
|
||||||
|
succeeded = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for sim in simulators:
|
||||||
|
if device_type.lower() in sim["name"].lower():
|
||||||
|
booter = SimulatorBooter(udid=sim["udid"])
|
||||||
|
success, _message = booter.boot(wait_ready=False)
|
||||||
|
if success:
|
||||||
|
succeeded += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return succeeded, failed
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Boot iOS simulators and wait for readiness")
|
||||||
|
parser.add_argument(
|
||||||
|
"--udid",
|
||||||
|
help="Device UDID or name (required unless using --all or --type)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--name",
|
||||||
|
help="Device name (alternative to --udid)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--wait-ready",
|
||||||
|
action="store_true",
|
||||||
|
help="Wait for device to reach ready state",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timeout",
|
||||||
|
type=int,
|
||||||
|
default=120,
|
||||||
|
help="Timeout for --wait-ready in seconds (default: 120)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--all",
|
||||||
|
action="store_true",
|
||||||
|
help="Boot all available simulators",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--type",
|
||||||
|
help="Boot all simulators of a specific type (e.g., iPhone, iPad)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="Output as JSON",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Handle batch operations
|
||||||
|
if args.all:
|
||||||
|
succeeded, failed = SimulatorBooter.boot_all()
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"action": "boot_all",
|
||||||
|
"succeeded": succeeded,
|
||||||
|
"failed": failed,
|
||||||
|
"total": succeeded + failed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total = succeeded + failed
|
||||||
|
print(f"Boot summary: {succeeded}/{total} succeeded, " f"{failed} failed")
|
||||||
|
sys.exit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
if args.type:
|
||||||
|
succeeded, failed = SimulatorBooter.boot_by_type(args.type)
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"action": "boot_by_type",
|
||||||
|
"type": args.type,
|
||||||
|
"succeeded": succeeded,
|
||||||
|
"failed": failed,
|
||||||
|
"total": succeeded + failed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total = succeeded + failed
|
||||||
|
print(f"Boot {args.type} summary: {succeeded}/{total} succeeded, " f"{failed} failed")
|
||||||
|
sys.exit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
# Resolve device identifier
|
||||||
|
device_id = args.udid or args.name
|
||||||
|
if not device_id:
|
||||||
|
print("Error: Specify --udid, --name, --all, or --type", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
udid = resolve_device_identifier(device_id)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Boot device
|
||||||
|
booter = SimulatorBooter(udid=udid)
|
||||||
|
success, message = booter.boot(wait_ready=args.wait_ready, timeout_seconds=args.timeout)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"action": "boot",
|
||||||
|
"device_id": device_id,
|
||||||
|
"udid": udid,
|
||||||
|
"success": success,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(message)
|
||||||
|
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+316
@@ -0,0 +1,316 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Create iOS simulators dynamically.
|
||||||
|
|
||||||
|
This script creates new simulators with specified device type and iOS version.
|
||||||
|
Useful for CI/CD pipelines that need on-demand test device provisioning.
|
||||||
|
|
||||||
|
Key features:
|
||||||
|
- Create by device type (iPhone 16 Pro, iPad Air, etc.)
|
||||||
|
- Specify iOS version (17.0, 18.0, etc.)
|
||||||
|
- Custom device naming
|
||||||
|
- Return newly created device UDID
|
||||||
|
- List available device types and runtimes
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from common.device_utils import list_simulators
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatorCreator:
|
||||||
|
"""Create iOS simulators with specified configurations."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize simulator creator."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
device_type: str,
|
||||||
|
ios_version: str | None = None,
|
||||||
|
custom_name: str | None = None,
|
||||||
|
) -> tuple[bool, str, str | None]:
|
||||||
|
"""
|
||||||
|
Create new iOS simulator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_type: Device type (e.g., "iPhone 16 Pro", "iPad Air")
|
||||||
|
ios_version: iOS version (e.g., "18.0"). If None, uses latest.
|
||||||
|
custom_name: Custom device name. If None, uses default.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, message, new_udid) tuple
|
||||||
|
"""
|
||||||
|
# Get available device types and runtimes
|
||||||
|
available_types = self._get_device_types()
|
||||||
|
if not available_types:
|
||||||
|
return False, "Failed to get available device types", None
|
||||||
|
|
||||||
|
# Normalize device type
|
||||||
|
device_type_id = None
|
||||||
|
for dt in available_types:
|
||||||
|
if device_type.lower() in dt["name"].lower():
|
||||||
|
device_type_id = dt["identifier"]
|
||||||
|
break
|
||||||
|
|
||||||
|
if not device_type_id:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"Device type '{device_type}' not found. "
|
||||||
|
f"Use --list-devices for available types.",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get available runtimes
|
||||||
|
available_runtimes = self._get_runtimes()
|
||||||
|
if not available_runtimes:
|
||||||
|
return False, "Failed to get available runtimes", None
|
||||||
|
|
||||||
|
# Resolve iOS version
|
||||||
|
runtime_id = None
|
||||||
|
if ios_version:
|
||||||
|
for rt in available_runtimes:
|
||||||
|
if ios_version in rt["name"]:
|
||||||
|
runtime_id = rt["identifier"]
|
||||||
|
break
|
||||||
|
|
||||||
|
if not runtime_id:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"iOS version '{ios_version}' not found. "
|
||||||
|
f"Use --list-runtimes for available versions.",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
# Use latest runtime
|
||||||
|
elif available_runtimes:
|
||||||
|
runtime_id = available_runtimes[-1]["identifier"]
|
||||||
|
|
||||||
|
if not runtime_id:
|
||||||
|
return False, "No iOS runtime available", None
|
||||||
|
|
||||||
|
# Create device
|
||||||
|
try:
|
||||||
|
# Build device name
|
||||||
|
device_name = (
|
||||||
|
custom_name or f"{device_type_id.split('.')[-1]}-{ios_version or 'latest'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"xcrun",
|
||||||
|
"simctl",
|
||||||
|
"create",
|
||||||
|
device_name,
|
||||||
|
device_type_id,
|
||||||
|
runtime_id,
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
error = result.stderr.strip() or result.stdout.strip()
|
||||||
|
return False, f"Creation failed: {error}", None
|
||||||
|
|
||||||
|
# Extract UDID from output
|
||||||
|
new_udid = result.stdout.strip()
|
||||||
|
|
||||||
|
return (
|
||||||
|
True,
|
||||||
|
f"Device created: {device_name} ({device_type}) iOS {ios_version or 'latest'} "
|
||||||
|
f"UDID: {new_udid}",
|
||||||
|
new_udid,
|
||||||
|
)
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "Creation command timed out", None
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Creation error: {e}", None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_device_types() -> list[dict]:
|
||||||
|
"""
|
||||||
|
Get available device types.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of device type dicts with "name" and "identifier" keys
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cmd = ["xcrun", "simctl", "list", "devicetypes", "-j"]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
devices = []
|
||||||
|
|
||||||
|
for device in data.get("devicetypes", []):
|
||||||
|
devices.append(
|
||||||
|
{
|
||||||
|
"name": device.get("name", ""),
|
||||||
|
"identifier": device.get("identifier", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return devices
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_runtimes() -> list[dict]:
|
||||||
|
"""
|
||||||
|
Get available iOS runtimes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of runtime dicts with "name" and "identifier" keys
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cmd = ["xcrun", "simctl", "list", "runtimes", "-j"]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
runtimes = []
|
||||||
|
|
||||||
|
for runtime in data.get("runtimes", []):
|
||||||
|
# Only include iOS runtimes (skip watchOS, tvOS, etc.)
|
||||||
|
identifier = runtime.get("identifier", "")
|
||||||
|
if "iOS" in identifier or "iOS" in runtime.get("name", ""):
|
||||||
|
runtimes.append(
|
||||||
|
{
|
||||||
|
"name": runtime.get("name", ""),
|
||||||
|
"identifier": runtime.get("identifier", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by version number (latest first)
|
||||||
|
runtimes.sort(key=lambda r: r.get("identifier", ""), reverse=True)
|
||||||
|
|
||||||
|
return runtimes
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_device_types() -> list[dict]:
|
||||||
|
"""
|
||||||
|
List all available device types.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of device types with name and identifier
|
||||||
|
"""
|
||||||
|
return SimulatorCreator._get_device_types()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_runtimes() -> list[dict]:
|
||||||
|
"""
|
||||||
|
List all available iOS runtimes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of runtimes with name and identifier
|
||||||
|
"""
|
||||||
|
return SimulatorCreator._get_runtimes()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Create iOS simulators dynamically")
|
||||||
|
parser.add_argument(
|
||||||
|
"--device",
|
||||||
|
required=False,
|
||||||
|
help="Device type (e.g., 'iPhone 16 Pro', 'iPad Air')",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--runtime",
|
||||||
|
help="iOS version (e.g., '18.0', '17.0'). Defaults to latest.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--name",
|
||||||
|
help="Custom device name. Defaults to auto-generated.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--list-devices",
|
||||||
|
action="store_true",
|
||||||
|
help="List all available device types",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--list-runtimes",
|
||||||
|
action="store_true",
|
||||||
|
help="List all available iOS runtimes",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="Output as JSON",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
creator = SimulatorCreator()
|
||||||
|
|
||||||
|
# Handle info queries
|
||||||
|
if args.list_devices:
|
||||||
|
devices = creator.list_device_types()
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(json.dumps({"devices": devices}))
|
||||||
|
else:
|
||||||
|
print(f"Available device types ({len(devices)}):")
|
||||||
|
for dev in devices[:20]: # Show first 20
|
||||||
|
print(f" - {dev['name']}")
|
||||||
|
if len(devices) > 20:
|
||||||
|
print(f" ... and {len(devices) - 20} more")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if args.list_runtimes:
|
||||||
|
runtimes = creator.list_runtimes()
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(json.dumps({"runtimes": runtimes}))
|
||||||
|
else:
|
||||||
|
print(f"Available iOS runtimes ({len(runtimes)}):")
|
||||||
|
for rt in runtimes:
|
||||||
|
print(f" - {rt['name']}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Create device
|
||||||
|
if not args.device:
|
||||||
|
print(
|
||||||
|
"Error: Specify --device, --list-devices, or --list-runtimes",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
success, message, new_udid = creator.create(
|
||||||
|
device_type=args.device,
|
||||||
|
ios_version=args.runtime,
|
||||||
|
custom_name=args.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"action": "create",
|
||||||
|
"device_type": args.device,
|
||||||
|
"runtime": args.runtime,
|
||||||
|
"success": success,
|
||||||
|
"message": message,
|
||||||
|
"new_udid": new_udid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(message)
|
||||||
|
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+357
@@ -0,0 +1,357 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Delete iOS simulators permanently.
|
||||||
|
|
||||||
|
This script permanently removes simulators and frees disk space.
|
||||||
|
Includes safety confirmation to prevent accidental deletion.
|
||||||
|
|
||||||
|
Key features:
|
||||||
|
- Delete by UDID or device name
|
||||||
|
- Confirmation required for safety
|
||||||
|
- Batch delete operations
|
||||||
|
- Report freed disk space estimate
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from common.device_utils import (
|
||||||
|
list_simulators,
|
||||||
|
resolve_device_identifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatorDeleter:
|
||||||
|
"""Delete iOS simulators with safety confirmation."""
|
||||||
|
|
||||||
|
def __init__(self, udid: str | None = None):
|
||||||
|
"""Initialize with optional device UDID."""
|
||||||
|
self.udid = udid
|
||||||
|
|
||||||
|
def delete(self, confirm: bool = False) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Delete simulator permanently.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
confirm: Skip confirmation prompt (for batch operations)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, message) tuple
|
||||||
|
"""
|
||||||
|
if not self.udid:
|
||||||
|
return False, "Error: Device UDID not specified"
|
||||||
|
|
||||||
|
# Safety confirmation
|
||||||
|
if not confirm:
|
||||||
|
try:
|
||||||
|
response = input(
|
||||||
|
f"Permanently delete simulator {self.udid}? " f"(type 'yes' to confirm): "
|
||||||
|
)
|
||||||
|
if response.lower() != "yes":
|
||||||
|
return False, "Deletion cancelled by user"
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return False, "Deletion cancelled"
|
||||||
|
|
||||||
|
# Execute delete command
|
||||||
|
try:
|
||||||
|
cmd = ["xcrun", "simctl", "delete", self.udid]
|
||||||
|
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
error = result.stderr.strip() or result.stdout.strip()
|
||||||
|
return False, f"Deletion failed: {error}"
|
||||||
|
|
||||||
|
return True, f"Device deleted: {self.udid} [disk space freed]"
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "Deletion command timed out"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Deletion error: {e}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_all(confirm: bool = False) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Delete all simulators permanently.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
confirm: Skip confirmation prompt
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(succeeded, failed) tuple with counts
|
||||||
|
"""
|
||||||
|
simulators = list_simulators(state=None)
|
||||||
|
|
||||||
|
if not confirm:
|
||||||
|
count = len(simulators)
|
||||||
|
try:
|
||||||
|
response = input(
|
||||||
|
f"Permanently delete ALL {count} simulators? " f"(type 'yes' to confirm): "
|
||||||
|
)
|
||||||
|
if response.lower() != "yes":
|
||||||
|
return 0, count
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 0, count
|
||||||
|
|
||||||
|
succeeded = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for sim in simulators:
|
||||||
|
deleter = SimulatorDeleter(udid=sim["udid"])
|
||||||
|
success, _message = deleter.delete(confirm=True)
|
||||||
|
if success:
|
||||||
|
succeeded += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return succeeded, failed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_by_type(device_type: str, confirm: bool = False) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Delete all simulators of a specific type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_type: Device type filter (e.g., "iPhone", "iPad")
|
||||||
|
confirm: Skip confirmation prompt
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(succeeded, failed) tuple with counts
|
||||||
|
"""
|
||||||
|
simulators = list_simulators(state=None)
|
||||||
|
matching = [s for s in simulators if device_type.lower() in s["name"].lower()]
|
||||||
|
|
||||||
|
if not matching:
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
if not confirm:
|
||||||
|
count = len(matching)
|
||||||
|
try:
|
||||||
|
response = input(
|
||||||
|
f"Permanently delete {count} {device_type} simulators? "
|
||||||
|
f"(type 'yes' to confirm): "
|
||||||
|
)
|
||||||
|
if response.lower() != "yes":
|
||||||
|
return 0, count
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 0, count
|
||||||
|
|
||||||
|
succeeded = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for sim in matching:
|
||||||
|
deleter = SimulatorDeleter(udid=sim["udid"])
|
||||||
|
success, _message = deleter.delete(confirm=True)
|
||||||
|
if success:
|
||||||
|
succeeded += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return succeeded, failed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_old(keep_count: int = 3, confirm: bool = False) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Delete older simulators, keeping most recent versions.
|
||||||
|
|
||||||
|
Useful for cleanup after testing multiple iOS versions.
|
||||||
|
Keeps the most recent N simulators of each type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keep_count: Number of recent simulators to keep per type (default: 3)
|
||||||
|
confirm: Skip confirmation prompt
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(succeeded, failed) tuple with counts
|
||||||
|
"""
|
||||||
|
simulators = list_simulators(state=None)
|
||||||
|
|
||||||
|
# Group by device type
|
||||||
|
by_type: dict[str, list] = {}
|
||||||
|
for sim in simulators:
|
||||||
|
dev_type = sim["type"]
|
||||||
|
if dev_type not in by_type:
|
||||||
|
by_type[dev_type] = []
|
||||||
|
by_type[dev_type].append(sim)
|
||||||
|
|
||||||
|
# Find candidates for deletion (older ones)
|
||||||
|
to_delete = []
|
||||||
|
for _dev_type, sims in by_type.items():
|
||||||
|
# Sort by runtime (iOS version) - keep newest
|
||||||
|
sorted_sims = sorted(sims, key=lambda s: s["runtime"], reverse=True)
|
||||||
|
# Mark older ones for deletion
|
||||||
|
to_delete.extend(sorted_sims[keep_count:])
|
||||||
|
|
||||||
|
if not to_delete:
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
if not confirm:
|
||||||
|
count = len(to_delete)
|
||||||
|
try:
|
||||||
|
response = input(
|
||||||
|
f"Delete {count} older simulators, keeping {keep_count} per type? "
|
||||||
|
f"(type 'yes' to confirm): "
|
||||||
|
)
|
||||||
|
if response.lower() != "yes":
|
||||||
|
return 0, count
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 0, count
|
||||||
|
|
||||||
|
succeeded = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for sim in to_delete:
|
||||||
|
deleter = SimulatorDeleter(udid=sim["udid"])
|
||||||
|
success, _message = deleter.delete(confirm=True)
|
||||||
|
if success:
|
||||||
|
succeeded += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return succeeded, failed
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Delete iOS simulators permanently")
|
||||||
|
parser.add_argument(
|
||||||
|
"--udid",
|
||||||
|
help="Device UDID or name (required unless using batch options)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--name",
|
||||||
|
help="Device name (alternative to --udid)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--yes",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip confirmation prompt",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--all",
|
||||||
|
action="store_true",
|
||||||
|
help="Delete all simulators",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--type",
|
||||||
|
help="Delete all simulators of a specific type (e.g., iPhone)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--old",
|
||||||
|
type=int,
|
||||||
|
metavar="KEEP_COUNT",
|
||||||
|
help="Delete older simulators, keeping this many per type (e.g., --old 3)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="Output as JSON",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Handle batch operations
|
||||||
|
if args.all:
|
||||||
|
succeeded, failed = SimulatorDeleter.delete_all(confirm=args.yes)
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"action": "delete_all",
|
||||||
|
"succeeded": succeeded,
|
||||||
|
"failed": failed,
|
||||||
|
"total": succeeded + failed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total = succeeded + failed
|
||||||
|
print(f"Delete summary: {succeeded}/{total} succeeded, " f"{failed} failed")
|
||||||
|
sys.exit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
if args.type:
|
||||||
|
succeeded, failed = SimulatorDeleter.delete_by_type(args.type, confirm=args.yes)
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"action": "delete_by_type",
|
||||||
|
"type": args.type,
|
||||||
|
"succeeded": succeeded,
|
||||||
|
"failed": failed,
|
||||||
|
"total": succeeded + failed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total = succeeded + failed
|
||||||
|
print(f"Delete {args.type} summary: {succeeded}/{total} succeeded, " f"{failed} failed")
|
||||||
|
sys.exit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
if args.old is not None:
|
||||||
|
succeeded, failed = SimulatorDeleter.delete_old(keep_count=args.old, confirm=args.yes)
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"action": "delete_old",
|
||||||
|
"keep_count": args.old,
|
||||||
|
"succeeded": succeeded,
|
||||||
|
"failed": failed,
|
||||||
|
"total": succeeded + failed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total = succeeded + failed
|
||||||
|
print(
|
||||||
|
f"Delete old summary: {succeeded}/{total} succeeded, "
|
||||||
|
f"{failed} failed (kept {args.old} per type)"
|
||||||
|
)
|
||||||
|
sys.exit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
# Delete single device
|
||||||
|
device_id = args.udid or args.name
|
||||||
|
if not device_id:
|
||||||
|
print("Error: Specify --udid, --name, --all, --type, or --old", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
udid = resolve_device_identifier(device_id)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Delete device
|
||||||
|
deleter = SimulatorDeleter(udid=udid)
|
||||||
|
success, message = deleter.delete(confirm=args.yes)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"action": "delete",
|
||||||
|
"device_id": device_id,
|
||||||
|
"udid": udid,
|
||||||
|
"success": success,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(message)
|
||||||
|
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+342
@@ -0,0 +1,342 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Erase iOS simulators (factory reset).
|
||||||
|
|
||||||
|
This script performs a factory reset on simulators, returning them to
|
||||||
|
a clean state while preserving the device UUID. Much faster than
|
||||||
|
delete + create for CI/CD cleanup.
|
||||||
|
|
||||||
|
Key features:
|
||||||
|
- Erase by UDID or device name
|
||||||
|
- Preserve device UUID (faster than delete)
|
||||||
|
- Verify erase completion
|
||||||
|
- Batch erase operations (all, by type)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from common.device_utils import (
|
||||||
|
list_simulators,
|
||||||
|
resolve_device_identifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatorEraser:
|
||||||
|
"""Erase iOS simulators with optional verification."""
|
||||||
|
|
||||||
|
def __init__(self, udid: str | None = None):
|
||||||
|
"""Initialize with optional device UDID."""
|
||||||
|
self.udid = udid
|
||||||
|
|
||||||
|
def erase(self, verify: bool = True, timeout_seconds: int = 30) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Erase simulator and optionally verify completion.
|
||||||
|
|
||||||
|
Performs a factory reset, clearing all app data and settings
|
||||||
|
while preserving the simulator UUID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
verify: Wait for erase to complete and verify state
|
||||||
|
timeout_seconds: Maximum seconds to wait for verification
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, message) tuple
|
||||||
|
"""
|
||||||
|
if not self.udid:
|
||||||
|
return False, "Error: Device UDID not specified"
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Execute erase command
|
||||||
|
try:
|
||||||
|
cmd = ["xcrun", "simctl", "erase", self.udid]
|
||||||
|
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
error = result.stderr.strip()
|
||||||
|
return False, f"Erase failed: {error}"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "Erase command timed out"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Erase error: {e}"
|
||||||
|
|
||||||
|
# Optionally verify erase completion
|
||||||
|
if verify:
|
||||||
|
ready, verify_message = self._verify_erase(timeout_seconds)
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
if ready:
|
||||||
|
return True, (
|
||||||
|
f"Device erased: {self.udid} " f"[factory reset complete, {elapsed:.1f}s]"
|
||||||
|
)
|
||||||
|
return False, verify_message
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
return True, (
|
||||||
|
f"Device erase initiated: {self.udid} [{elapsed:.1f}s] "
|
||||||
|
"(use --verify to wait for completion)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _verify_erase(self, timeout_seconds: int = 30) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Verify erase has completed.
|
||||||
|
|
||||||
|
Polls device state to confirm erase finished successfully.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout_seconds: Maximum seconds to wait
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, message) tuple
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
poll_interval = 0.5
|
||||||
|
checks = 0
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout_seconds:
|
||||||
|
try:
|
||||||
|
checks += 1
|
||||||
|
# Check if device can be queried (indicates boot status)
|
||||||
|
result = subprocess.run(
|
||||||
|
["xcrun", "simctl", "spawn", self.udid, "launchctl", "list"],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Device responding = erase likely complete
|
||||||
|
if result.returncode == 0:
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
return True, (
|
||||||
|
f"Erase verified: {self.udid} " f"[{elapsed:.1f}s, {checks} checks]"
|
||||||
|
)
|
||||||
|
except (subprocess.TimeoutExpired, RuntimeError):
|
||||||
|
pass # Not ready yet, keep polling
|
||||||
|
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
return False, (
|
||||||
|
f"Erase verification timeout: Device did not respond "
|
||||||
|
f"within {elapsed:.1f}s ({checks} checks)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def erase_all() -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Erase all simulators (factory reset).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(succeeded, failed) tuple with counts
|
||||||
|
"""
|
||||||
|
simulators = list_simulators(state=None)
|
||||||
|
succeeded = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for sim in simulators:
|
||||||
|
eraser = SimulatorEraser(udid=sim["udid"])
|
||||||
|
success, _message = eraser.erase(verify=False)
|
||||||
|
if success:
|
||||||
|
succeeded += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return succeeded, failed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def erase_by_type(device_type: str) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Erase all simulators of a specific type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_type: Device type filter (e.g., "iPhone", "iPad")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(succeeded, failed) tuple with counts
|
||||||
|
"""
|
||||||
|
simulators = list_simulators(state=None)
|
||||||
|
succeeded = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for sim in simulators:
|
||||||
|
if device_type.lower() in sim["name"].lower():
|
||||||
|
eraser = SimulatorEraser(udid=sim["udid"])
|
||||||
|
success, _message = eraser.erase(verify=False)
|
||||||
|
if success:
|
||||||
|
succeeded += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return succeeded, failed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def erase_booted() -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Erase all currently booted simulators.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(succeeded, failed) tuple with counts
|
||||||
|
"""
|
||||||
|
simulators = list_simulators(state="booted")
|
||||||
|
succeeded = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for sim in simulators:
|
||||||
|
eraser = SimulatorEraser(udid=sim["udid"])
|
||||||
|
success, _message = eraser.erase(verify=False)
|
||||||
|
if success:
|
||||||
|
succeeded += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return succeeded, failed
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Erase iOS simulators (factory reset)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--udid",
|
||||||
|
help="Device UDID or name (required unless using --all, --type, or --booted)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--name",
|
||||||
|
help="Device name (alternative to --udid)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verify",
|
||||||
|
action="store_true",
|
||||||
|
help="Wait for erase to complete and verify state",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timeout",
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
help="Timeout for --verify in seconds (default: 30)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--all",
|
||||||
|
action="store_true",
|
||||||
|
help="Erase all simulators (factory reset)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--type",
|
||||||
|
help="Erase all simulators of a specific type (e.g., iPhone)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--booted",
|
||||||
|
action="store_true",
|
||||||
|
help="Erase all currently booted simulators",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="Output as JSON",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Handle batch operations
|
||||||
|
if args.all:
|
||||||
|
succeeded, failed = SimulatorEraser.erase_all()
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"action": "erase_all",
|
||||||
|
"succeeded": succeeded,
|
||||||
|
"failed": failed,
|
||||||
|
"total": succeeded + failed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total = succeeded + failed
|
||||||
|
print(f"Erase summary: {succeeded}/{total} succeeded, " f"{failed} failed")
|
||||||
|
sys.exit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
if args.type:
|
||||||
|
succeeded, failed = SimulatorEraser.erase_by_type(args.type)
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"action": "erase_by_type",
|
||||||
|
"type": args.type,
|
||||||
|
"succeeded": succeeded,
|
||||||
|
"failed": failed,
|
||||||
|
"total": succeeded + failed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total = succeeded + failed
|
||||||
|
print(f"Erase {args.type} summary: {succeeded}/{total} succeeded, " f"{failed} failed")
|
||||||
|
sys.exit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
if args.booted:
|
||||||
|
succeeded, failed = SimulatorEraser.erase_booted()
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"action": "erase_booted",
|
||||||
|
"succeeded": succeeded,
|
||||||
|
"failed": failed,
|
||||||
|
"total": succeeded + failed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total = succeeded + failed
|
||||||
|
print(f"Erase booted summary: {succeeded}/{total} succeeded, " f"{failed} failed")
|
||||||
|
sys.exit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
# Erase single device
|
||||||
|
device_id = args.udid or args.name
|
||||||
|
if not device_id:
|
||||||
|
print("Error: Specify --udid, --name, --all, --type, or --booted", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
udid = resolve_device_identifier(device_id)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Erase device
|
||||||
|
eraser = SimulatorEraser(udid=udid)
|
||||||
|
success, message = eraser.erase(verify=args.verify, timeout_seconds=args.timeout)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"action": "erase",
|
||||||
|
"device_id": device_id,
|
||||||
|
"udid": udid,
|
||||||
|
"success": success,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(message)
|
||||||
|
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Shutdown iOS simulators with optional state verification.
|
||||||
|
|
||||||
|
This script shuts down one or more running simulators and optionally
|
||||||
|
verifies completion. Supports batch operations for efficient cleanup.
|
||||||
|
|
||||||
|
Key features:
|
||||||
|
- Shutdown by UDID or device name
|
||||||
|
- Verify shutdown completion with timeout
|
||||||
|
- Batch shutdown operations (all, by type)
|
||||||
|
- Progress reporting for CI/CD pipelines
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from common.device_utils import (
|
||||||
|
list_simulators,
|
||||||
|
resolve_device_identifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatorShutdown:
|
||||||
|
"""Shutdown iOS simulators with optional verification."""
|
||||||
|
|
||||||
|
def __init__(self, udid: str | None = None):
|
||||||
|
"""Initialize with optional device UDID."""
|
||||||
|
self.udid = udid
|
||||||
|
|
||||||
|
def shutdown(self, verify: bool = True, timeout_seconds: int = 30) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Shutdown simulator and optionally verify completion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
verify: Wait for shutdown to complete
|
||||||
|
timeout_seconds: Maximum seconds to wait for shutdown
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, message) tuple
|
||||||
|
"""
|
||||||
|
if not self.udid:
|
||||||
|
return False, "Error: Device UDID not specified"
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Check if already shutdown
|
||||||
|
simulators = list_simulators(state="booted")
|
||||||
|
if not any(s["udid"] == self.udid for s in simulators):
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
return True, (f"Device already shutdown: {self.udid} " f"[checked in {elapsed:.1f}s]")
|
||||||
|
|
||||||
|
# Execute shutdown command
|
||||||
|
try:
|
||||||
|
cmd = ["xcrun", "simctl", "shutdown", self.udid]
|
||||||
|
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=30)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
error = result.stderr.strip()
|
||||||
|
return False, f"Shutdown failed: {error}"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "Shutdown command timed out"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Shutdown error: {e}"
|
||||||
|
|
||||||
|
# Optionally verify shutdown
|
||||||
|
if verify:
|
||||||
|
ready, verify_message = self._verify_shutdown(timeout_seconds)
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
if ready:
|
||||||
|
return True, (f"Device shutdown confirmed: {self.udid} " f"[{elapsed:.1f}s total]")
|
||||||
|
return False, verify_message
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
return True, (
|
||||||
|
f"Device shutdown: {self.udid} [{elapsed:.1f}s] "
|
||||||
|
"(use --verify to wait for confirmation)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _verify_shutdown(self, timeout_seconds: int = 30) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Verify device has fully shutdown.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout_seconds: Maximum seconds to wait
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, message) tuple
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
poll_interval = 0.5
|
||||||
|
checks = 0
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout_seconds:
|
||||||
|
try:
|
||||||
|
checks += 1
|
||||||
|
# Check booted devices
|
||||||
|
simulators = list_simulators(state="booted")
|
||||||
|
if not any(s["udid"] == self.udid for s in simulators):
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
return True, (
|
||||||
|
f"Device shutdown verified: {self.udid} "
|
||||||
|
f"[{elapsed:.1f}s, {checks} checks]"
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Error checking, retry
|
||||||
|
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
return False, (
|
||||||
|
f"Shutdown verification timeout: Device did not fully shutdown "
|
||||||
|
f"within {elapsed:.1f}s ({checks} checks)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def shutdown_all() -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Shutdown all booted simulators.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(succeeded, failed) tuple with counts
|
||||||
|
"""
|
||||||
|
simulators = list_simulators(state="booted")
|
||||||
|
succeeded = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for sim in simulators:
|
||||||
|
shutdown = SimulatorShutdown(udid=sim["udid"])
|
||||||
|
success, _message = shutdown.shutdown(verify=False)
|
||||||
|
if success:
|
||||||
|
succeeded += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return succeeded, failed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def shutdown_by_type(device_type: str) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Shutdown all booted simulators of a specific type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_type: Device type filter (e.g., "iPhone", "iPad")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(succeeded, failed) tuple with counts
|
||||||
|
"""
|
||||||
|
simulators = list_simulators(state="booted")
|
||||||
|
succeeded = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for sim in simulators:
|
||||||
|
if device_type.lower() in sim["name"].lower():
|
||||||
|
shutdown = SimulatorShutdown(udid=sim["udid"])
|
||||||
|
success, _message = shutdown.shutdown(verify=False)
|
||||||
|
if success:
|
||||||
|
succeeded += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return succeeded, failed
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Shutdown iOS simulators with optional verification"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--udid",
|
||||||
|
help="Device UDID or name (required unless using --all or --type)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--name",
|
||||||
|
help="Device name (alternative to --udid)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verify",
|
||||||
|
action="store_true",
|
||||||
|
help="Wait for shutdown to complete and verify state",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timeout",
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
help="Timeout for --verify in seconds (default: 30)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--all",
|
||||||
|
action="store_true",
|
||||||
|
help="Shutdown all booted simulators",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--type",
|
||||||
|
help="Shutdown all booted simulators of a specific type (e.g., iPhone)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="Output as JSON",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Handle batch operations
|
||||||
|
if args.all:
|
||||||
|
succeeded, failed = SimulatorShutdown.shutdown_all()
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"action": "shutdown_all",
|
||||||
|
"succeeded": succeeded,
|
||||||
|
"failed": failed,
|
||||||
|
"total": succeeded + failed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total = succeeded + failed
|
||||||
|
print(f"Shutdown summary: {succeeded}/{total} succeeded, " f"{failed} failed")
|
||||||
|
sys.exit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
if args.type:
|
||||||
|
succeeded, failed = SimulatorShutdown.shutdown_by_type(args.type)
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"action": "shutdown_by_type",
|
||||||
|
"type": args.type,
|
||||||
|
"succeeded": succeeded,
|
||||||
|
"failed": failed,
|
||||||
|
"total": succeeded + failed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total = succeeded + failed
|
||||||
|
print(
|
||||||
|
f"Shutdown {args.type} summary: {succeeded}/{total} succeeded, " f"{failed} failed"
|
||||||
|
)
|
||||||
|
sys.exit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
# Resolve device identifier
|
||||||
|
device_id = args.udid or args.name
|
||||||
|
if not device_id:
|
||||||
|
print("Error: Specify --udid, --name, --all, or --type", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
udid = resolve_device_identifier(device_id)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Shutdown device
|
||||||
|
shutdown = SimulatorShutdown(udid=udid)
|
||||||
|
success, message = shutdown.shutdown(verify=args.verify, timeout_seconds=args.timeout)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"action": "shutdown",
|
||||||
|
"device_id": device_id,
|
||||||
|
"udid": udid,
|
||||||
|
"success": success,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(message)
|
||||||
|
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Intelligent Simulator Selector
|
||||||
|
|
||||||
|
Suggests the best available iOS simulators based on:
|
||||||
|
- Recently used (from config)
|
||||||
|
- Latest iOS version
|
||||||
|
- Common models for testing
|
||||||
|
- Boot status
|
||||||
|
|
||||||
|
Usage Examples:
|
||||||
|
# Get suggestions for user selection
|
||||||
|
python scripts/simulator_selector.py --suggest
|
||||||
|
|
||||||
|
# List all available simulators
|
||||||
|
python scripts/simulator_selector.py --list
|
||||||
|
|
||||||
|
# Boot a specific simulator
|
||||||
|
python scripts/simulator_selector.py --boot "67A99DF0-27BD-4507-A3DE-B7D8C38F764A"
|
||||||
|
|
||||||
|
# Get suggestions as JSON for programmatic use
|
||||||
|
python scripts/simulator_selector.py --suggest --json
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Try to import config from build_and_test if available
|
||||||
|
try:
|
||||||
|
from xcode.config import Config
|
||||||
|
except ImportError:
|
||||||
|
Config = None
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatorInfo:
|
||||||
|
"""Information about an iOS simulator."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
udid: str,
|
||||||
|
ios_version: str,
|
||||||
|
status: str,
|
||||||
|
):
|
||||||
|
"""Initialize simulator info."""
|
||||||
|
self.name = name
|
||||||
|
self.udid = udid
|
||||||
|
self.ios_version = ios_version
|
||||||
|
self.status = status
|
||||||
|
self.reasons: list[str] = []
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary."""
|
||||||
|
return {
|
||||||
|
"device": self.name,
|
||||||
|
"udid": self.udid,
|
||||||
|
"ios": self.ios_version,
|
||||||
|
"status": self.status,
|
||||||
|
"reasons": self.reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatorSelector:
|
||||||
|
"""Intelligent simulator selection."""
|
||||||
|
|
||||||
|
# Common iPhone models ranked by testing priority
|
||||||
|
COMMON_MODELS = [
|
||||||
|
"iPhone 16 Pro",
|
||||||
|
"iPhone 16",
|
||||||
|
"iPhone 15 Pro",
|
||||||
|
"iPhone 15",
|
||||||
|
"iPhone SE (3rd generation)",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize selector."""
|
||||||
|
self.simulators: list[SimulatorInfo] = []
|
||||||
|
self.config: dict | None = None
|
||||||
|
self.last_used_simulator: str | None = None
|
||||||
|
|
||||||
|
# Load config if available
|
||||||
|
if Config:
|
||||||
|
try:
|
||||||
|
config = Config.load()
|
||||||
|
self.last_used_simulator = config.get_preferred_simulator()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def list_simulators(self) -> list[SimulatorInfo]:
|
||||||
|
"""
|
||||||
|
List all available simulators.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of SimulatorInfo objects
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["xcrun", "simctl", "list", "devices", "--json"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
simulators = []
|
||||||
|
|
||||||
|
# Parse devices by iOS version
|
||||||
|
for runtime, devices in data.get("devices", {}).items():
|
||||||
|
# Extract iOS version from runtime (e.g., "com.apple.CoreSimulator.SimRuntime.iOS-18-0")
|
||||||
|
ios_version_match = re.search(r"iOS-(\d+-\d+)", runtime)
|
||||||
|
if not ios_version_match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ios_version = ios_version_match.group(1).replace("-", ".")
|
||||||
|
|
||||||
|
for device in devices:
|
||||||
|
name = device.get("name", "")
|
||||||
|
udid = device.get("udid", "")
|
||||||
|
is_available = device.get("isAvailable", False)
|
||||||
|
|
||||||
|
if not is_available or "iPhone" not in name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = device.get("state", "").capitalize()
|
||||||
|
sim_info = SimulatorInfo(name, udid, ios_version, status)
|
||||||
|
simulators.append(sim_info)
|
||||||
|
|
||||||
|
self.simulators = simulators
|
||||||
|
return simulators
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error listing simulators: {e.stderr}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"Error parsing simulator list: {e}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_suggestions(self, count: int = 4) -> list[SimulatorInfo]:
|
||||||
|
"""
|
||||||
|
Get top N suggested simulators.
|
||||||
|
|
||||||
|
Ranking factors:
|
||||||
|
1. Recently used (from config)
|
||||||
|
2. Latest iOS version
|
||||||
|
3. Common models
|
||||||
|
4. Boot status (Booted preferred)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
count: Number of suggestions to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of suggested SimulatorInfo objects
|
||||||
|
"""
|
||||||
|
if not self.simulators:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Score each simulator
|
||||||
|
scored = []
|
||||||
|
for sim in self.simulators:
|
||||||
|
score = self._score_simulator(sim)
|
||||||
|
scored.append((score, sim))
|
||||||
|
|
||||||
|
# Sort by score (descending)
|
||||||
|
scored.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
|
||||||
|
# Return top N
|
||||||
|
suggestions = [sim for _, sim in scored[:count]]
|
||||||
|
|
||||||
|
# Add reasons to each suggestion
|
||||||
|
for i, sim in enumerate(suggestions, 1):
|
||||||
|
if i == 1:
|
||||||
|
sim.reasons.append("Recommended")
|
||||||
|
|
||||||
|
# Check if recently used
|
||||||
|
if self.last_used_simulator and self.last_used_simulator == sim.name:
|
||||||
|
sim.reasons.append("Recently used")
|
||||||
|
|
||||||
|
# Check if latest iOS
|
||||||
|
latest_ios = max(s.ios_version for s in self.simulators)
|
||||||
|
if sim.ios_version == latest_ios:
|
||||||
|
sim.reasons.append("Latest iOS")
|
||||||
|
|
||||||
|
# Check if common model
|
||||||
|
for j, model in enumerate(self.COMMON_MODELS):
|
||||||
|
if model in sim.name:
|
||||||
|
sim.reasons.append(f"#{j+1} common model")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if booted
|
||||||
|
if sim.status == "Booted":
|
||||||
|
sim.reasons.append("Currently running")
|
||||||
|
|
||||||
|
return suggestions
|
||||||
|
|
||||||
|
def _score_simulator(self, sim: SimulatorInfo) -> float:
|
||||||
|
"""
|
||||||
|
Score a simulator for ranking.
|
||||||
|
|
||||||
|
Higher score = better recommendation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sim: Simulator to score
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Score value
|
||||||
|
"""
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
# Recently used gets highest priority (100 points)
|
||||||
|
if self.last_used_simulator and self.last_used_simulator == sim.name:
|
||||||
|
score += 100
|
||||||
|
|
||||||
|
# Latest iOS version (50 points)
|
||||||
|
latest_ios = max(s.ios_version for s in self.simulators)
|
||||||
|
if sim.ios_version == latest_ios:
|
||||||
|
score += 50
|
||||||
|
|
||||||
|
# Common models (30-20 points based on ranking)
|
||||||
|
for i, model in enumerate(self.COMMON_MODELS):
|
||||||
|
if model in sim.name:
|
||||||
|
score += 30 - (i * 2) # Higher ranking models get more points
|
||||||
|
break
|
||||||
|
|
||||||
|
# Currently booted (10 points)
|
||||||
|
if sim.status == "Booted":
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
# iOS version number (minor factor for breaking ties)
|
||||||
|
ios_numeric = float(sim.ios_version.replace(".", ""))
|
||||||
|
score += ios_numeric * 0.1
|
||||||
|
|
||||||
|
return score
|
||||||
|
|
||||||
|
def boot_simulator(self, udid: str) -> bool:
|
||||||
|
"""
|
||||||
|
Boot a simulator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
udid: Simulator UDID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["xcrun", "simctl", "boot", udid],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error booting simulator: {e.stderr}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def format_suggestions(suggestions: list[SimulatorInfo], json_format: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Format suggestions for output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
suggestions: List of suggestions
|
||||||
|
json_format: If True, output as JSON
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string
|
||||||
|
"""
|
||||||
|
if json_format:
|
||||||
|
data = {"suggestions": [s.to_dict() for s in suggestions]}
|
||||||
|
return json.dumps(data, indent=2)
|
||||||
|
|
||||||
|
if not suggestions:
|
||||||
|
return "No simulators available"
|
||||||
|
|
||||||
|
lines = ["Available Simulators:\n"]
|
||||||
|
for i, sim in enumerate(suggestions, 1):
|
||||||
|
lines.append(f"{i}. {sim.name} (iOS {sim.ios_version})")
|
||||||
|
if sim.reasons:
|
||||||
|
lines.append(f" {', '.join(sim.reasons)}")
|
||||||
|
lines.append(f" UDID: {sim.udid}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Intelligent iOS simulator selector",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
# Get suggestions for user selection
|
||||||
|
python scripts/simulator_selector.py --suggest
|
||||||
|
|
||||||
|
# List all available simulators
|
||||||
|
python scripts/simulator_selector.py --list
|
||||||
|
|
||||||
|
# Boot a specific simulator
|
||||||
|
python scripts/simulator_selector.py --boot <UDID>
|
||||||
|
|
||||||
|
# Get suggestions as JSON
|
||||||
|
python scripts/simulator_selector.py --suggest --json
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--suggest",
|
||||||
|
action="store_true",
|
||||||
|
help="Get top simulator suggestions",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--list",
|
||||||
|
action="store_true",
|
||||||
|
help="List all available simulators",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--boot",
|
||||||
|
metavar="UDID",
|
||||||
|
help="Boot specific simulator by UDID",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="Output as JSON",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--count",
|
||||||
|
type=int,
|
||||||
|
default=4,
|
||||||
|
help="Number of suggestions (default: 4)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
selector = SimulatorSelector()
|
||||||
|
|
||||||
|
if args.boot:
|
||||||
|
# Boot specific simulator
|
||||||
|
success = selector.boot_simulator(args.boot)
|
||||||
|
if success:
|
||||||
|
print(f"Booted simulator: {args.boot}")
|
||||||
|
return 0
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
# List all simulators
|
||||||
|
simulators = selector.list_simulators()
|
||||||
|
output = format_suggestions(simulators, args.json)
|
||||||
|
print(output)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.suggest:
|
||||||
|
# Get suggestions
|
||||||
|
selector.list_simulators()
|
||||||
|
suggestions = selector.get_suggestions(args.count)
|
||||||
|
output = format_suggestions(suggestions, args.json)
|
||||||
|
print(output)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Default: show suggestions
|
||||||
|
selector.list_simulators()
|
||||||
|
suggestions = selector.get_suggestions(args.count)
|
||||||
|
output = format_suggestions(suggestions, args.json)
|
||||||
|
print(output)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
iOS Status Bar Controller
|
||||||
|
|
||||||
|
Override simulator status bar for clean screenshots and testing.
|
||||||
|
Control time, network, wifi, battery display.
|
||||||
|
|
||||||
|
Usage: python scripts/status_bar.py --preset clean
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from common import resolve_udid
|
||||||
|
|
||||||
|
|
||||||
|
class StatusBarController:
|
||||||
|
"""Controls iOS simulator status bar appearance."""
|
||||||
|
|
||||||
|
# Preset configurations
|
||||||
|
PRESETS = {
|
||||||
|
"clean": {
|
||||||
|
"time": "9:41",
|
||||||
|
"data_network": "5g",
|
||||||
|
"wifi_mode": "active",
|
||||||
|
"battery_state": "charged",
|
||||||
|
"battery_level": 100,
|
||||||
|
},
|
||||||
|
"testing": {
|
||||||
|
"time": "11:11",
|
||||||
|
"data_network": "4g",
|
||||||
|
"wifi_mode": "active",
|
||||||
|
"battery_state": "discharging",
|
||||||
|
"battery_level": 50,
|
||||||
|
},
|
||||||
|
"low_battery": {
|
||||||
|
"time": "9:41",
|
||||||
|
"data_network": "5g",
|
||||||
|
"wifi_mode": "active",
|
||||||
|
"battery_state": "discharging",
|
||||||
|
"battery_level": 20,
|
||||||
|
},
|
||||||
|
"airplane": {
|
||||||
|
"time": "9:41",
|
||||||
|
"data_network": "none",
|
||||||
|
"wifi_mode": "failed",
|
||||||
|
"battery_state": "charged",
|
||||||
|
"battery_level": 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, udid: str | None = None):
|
||||||
|
"""Initialize status bar controller.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
udid: Optional device UDID (auto-detects booted simulator if None)
|
||||||
|
"""
|
||||||
|
self.udid = udid
|
||||||
|
|
||||||
|
def override(
|
||||||
|
self,
|
||||||
|
time: str | None = None,
|
||||||
|
data_network: str | None = None,
|
||||||
|
wifi_mode: str | None = None,
|
||||||
|
battery_state: str | None = None,
|
||||||
|
battery_level: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Override status bar appearance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
time: Time in HH:MM format (e.g., "9:41")
|
||||||
|
data_network: Network type (none, 1x, 3g, 4g, 5g, lte, lte-a)
|
||||||
|
wifi_mode: WiFi state (active, searching, failed)
|
||||||
|
battery_state: Battery state (charging, charged, discharging)
|
||||||
|
battery_level: Battery percentage (0-100)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
cmd = ["xcrun", "simctl", "status_bar"]
|
||||||
|
|
||||||
|
if self.udid:
|
||||||
|
cmd.append(self.udid)
|
||||||
|
else:
|
||||||
|
cmd.append("booted")
|
||||||
|
|
||||||
|
cmd.append("override")
|
||||||
|
|
||||||
|
# Add parameters if provided
|
||||||
|
if time:
|
||||||
|
cmd.extend(["--time", time])
|
||||||
|
if data_network:
|
||||||
|
cmd.extend(["--dataNetwork", data_network])
|
||||||
|
if wifi_mode:
|
||||||
|
cmd.extend(["--wifiMode", wifi_mode])
|
||||||
|
if battery_state:
|
||||||
|
cmd.extend(["--batteryState", battery_state])
|
||||||
|
if battery_level is not None:
|
||||||
|
cmd.extend(["--batteryLevel", str(battery_level)])
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear(self) -> bool:
|
||||||
|
"""
|
||||||
|
Clear status bar override and restore defaults.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
cmd = ["xcrun", "simctl", "status_bar"]
|
||||||
|
|
||||||
|
if self.udid:
|
||||||
|
cmd.append(self.udid)
|
||||||
|
else:
|
||||||
|
cmd.append("booted")
|
||||||
|
|
||||||
|
cmd.append("clear")
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Override iOS simulator status bar for screenshots and testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Preset option
|
||||||
|
parser.add_argument(
|
||||||
|
"--preset",
|
||||||
|
choices=list(StatusBarController.PRESETS.keys()),
|
||||||
|
help="Use preset configuration (clean, testing, low-battery, airplane)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Custom options
|
||||||
|
parser.add_argument(
|
||||||
|
"--time",
|
||||||
|
help="Override time (HH:MM format, e.g., '9:41')",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--data-network",
|
||||||
|
choices=["none", "1x", "3g", "4g", "5g", "lte", "lte-a"],
|
||||||
|
help="Data network type",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--wifi-mode",
|
||||||
|
choices=["active", "searching", "failed"],
|
||||||
|
help="WiFi state",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--battery-state",
|
||||||
|
choices=["charging", "charged", "discharging"],
|
||||||
|
help="Battery state",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--battery-level",
|
||||||
|
type=int,
|
||||||
|
help="Battery level 0-100",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Other options
|
||||||
|
parser.add_argument(
|
||||||
|
"--clear",
|
||||||
|
action="store_true",
|
||||||
|
help="Clear status bar override and restore defaults",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--udid",
|
||||||
|
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Resolve UDID with auto-detection
|
||||||
|
try:
|
||||||
|
udid = resolve_udid(args.udid)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
controller = StatusBarController(udid=udid)
|
||||||
|
|
||||||
|
# Clear mode
|
||||||
|
if args.clear:
|
||||||
|
if controller.clear():
|
||||||
|
print("Status bar override cleared - defaults restored")
|
||||||
|
else:
|
||||||
|
print("Failed to clear status bar override")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Preset mode
|
||||||
|
elif args.preset:
|
||||||
|
preset = StatusBarController.PRESETS[args.preset]
|
||||||
|
if controller.override(**preset):
|
||||||
|
print(f"Status bar: {args.preset} preset applied")
|
||||||
|
print(
|
||||||
|
f" Time: {preset['time']}, "
|
||||||
|
f"Network: {preset['data_network']}, "
|
||||||
|
f"Battery: {preset['battery_level']}%"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f"Failed to apply {args.preset} preset")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Custom mode
|
||||||
|
elif any(
|
||||||
|
[
|
||||||
|
args.time,
|
||||||
|
args.data_network,
|
||||||
|
args.wifi_mode,
|
||||||
|
args.battery_state,
|
||||||
|
args.battery_level is not None,
|
||||||
|
]
|
||||||
|
):
|
||||||
|
if controller.override(
|
||||||
|
time=args.time,
|
||||||
|
data_network=args.data_network,
|
||||||
|
wifi_mode=args.wifi_mode,
|
||||||
|
battery_state=args.battery_state,
|
||||||
|
battery_level=args.battery_level,
|
||||||
|
):
|
||||||
|
output = "Status bar override applied:"
|
||||||
|
if args.time:
|
||||||
|
output += f" Time={args.time}"
|
||||||
|
if args.data_network:
|
||||||
|
output += f" Network={args.data_network}"
|
||||||
|
if args.battery_level is not None:
|
||||||
|
output += f" Battery={args.battery_level}%"
|
||||||
|
print(output)
|
||||||
|
else:
|
||||||
|
print("Failed to override status bar")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+323
@@ -0,0 +1,323 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test Recorder for iOS Simulator Testing
|
||||||
|
|
||||||
|
Records test execution with automatic screenshots and documentation.
|
||||||
|
Optimized for minimal token output during execution.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
As a script: python scripts/test_recorder.py --test-name "Test Name" --output dir/
|
||||||
|
As a module: from scripts.test_recorder import TestRecorder
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from common import (
|
||||||
|
capture_screenshot,
|
||||||
|
count_elements,
|
||||||
|
generate_screenshot_name,
|
||||||
|
get_accessibility_tree,
|
||||||
|
resolve_udid,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecorder:
|
||||||
|
"""Records test execution with screenshots and accessibility snapshots."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
test_name: str,
|
||||||
|
output_dir: str = "test-artifacts",
|
||||||
|
udid: str | None = None,
|
||||||
|
inline: bool = False,
|
||||||
|
screenshot_size: str = "half",
|
||||||
|
app_name: str | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize test recorder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
test_name: Name of the test being recorded
|
||||||
|
output_dir: Directory for test artifacts
|
||||||
|
udid: Optional device UDID (uses booted if not specified)
|
||||||
|
inline: If True, return screenshots as base64 (for vision-based automation)
|
||||||
|
screenshot_size: 'full', 'half', 'quarter', 'thumb' (default: 'half')
|
||||||
|
app_name: App name for semantic screenshot naming
|
||||||
|
"""
|
||||||
|
self.test_name = test_name
|
||||||
|
self.udid = udid
|
||||||
|
self.inline = inline
|
||||||
|
self.screenshot_size = screenshot_size
|
||||||
|
self.app_name = app_name
|
||||||
|
self.start_time = time.time()
|
||||||
|
self.steps: list[dict] = []
|
||||||
|
self.current_step = 0
|
||||||
|
|
||||||
|
# Create timestamped output directory
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
safe_name = test_name.lower().replace(" ", "-")
|
||||||
|
self.output_dir = Path(output_dir) / f"{safe_name}-{timestamp}"
|
||||||
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create subdirectories (only if not in inline mode)
|
||||||
|
if not inline:
|
||||||
|
self.screenshots_dir = self.output_dir / "screenshots"
|
||||||
|
self.screenshots_dir.mkdir(exist_ok=True)
|
||||||
|
else:
|
||||||
|
self.screenshots_dir = None
|
||||||
|
|
||||||
|
self.accessibility_dir = self.output_dir / "accessibility"
|
||||||
|
self.accessibility_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Token-efficient output
|
||||||
|
mode_str = "(inline mode)" if inline else ""
|
||||||
|
print(f"Recording: {test_name} {mode_str}")
|
||||||
|
print(f"Output: {self.output_dir}/")
|
||||||
|
|
||||||
|
def step(
|
||||||
|
self,
|
||||||
|
description: str,
|
||||||
|
screen_name: str | None = None,
|
||||||
|
state: str | None = None,
|
||||||
|
assertion: str | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Record a test step with automatic screenshot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
description: Step description
|
||||||
|
screen_name: Screen name for semantic naming
|
||||||
|
state: State description for semantic naming
|
||||||
|
assertion: Optional assertion to verify
|
||||||
|
metadata: Optional metadata for the step
|
||||||
|
"""
|
||||||
|
self.current_step += 1
|
||||||
|
step_time = time.time() - self.start_time
|
||||||
|
|
||||||
|
# Capture screenshot using new utility
|
||||||
|
screenshot_result = capture_screenshot(
|
||||||
|
self.udid,
|
||||||
|
size=self.screenshot_size,
|
||||||
|
inline=self.inline,
|
||||||
|
app_name=self.app_name,
|
||||||
|
screen_name=screen_name or description,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Capture accessibility tree
|
||||||
|
accessibility_path = (
|
||||||
|
self.accessibility_dir
|
||||||
|
/ f"{self.current_step:03d}-{description.lower().replace(' ', '-')[:20]}.json"
|
||||||
|
)
|
||||||
|
element_count = self._capture_accessibility(accessibility_path)
|
||||||
|
|
||||||
|
# Store step data
|
||||||
|
step_data = {
|
||||||
|
"number": self.current_step,
|
||||||
|
"description": description,
|
||||||
|
"timestamp": step_time,
|
||||||
|
"element_count": element_count,
|
||||||
|
"accessibility": accessibility_path.name,
|
||||||
|
"screenshot_mode": screenshot_result["mode"],
|
||||||
|
"screenshot_size": self.screenshot_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle screenshot data based on mode
|
||||||
|
if screenshot_result["mode"] == "file":
|
||||||
|
step_data["screenshot"] = screenshot_result["file_path"]
|
||||||
|
step_data["screenshot_name"] = Path(screenshot_result["file_path"]).name
|
||||||
|
else:
|
||||||
|
# Inline mode
|
||||||
|
step_data["screenshot_base64"] = screenshot_result["base64_data"]
|
||||||
|
step_data["screenshot_dimensions"] = (
|
||||||
|
screenshot_result["width"],
|
||||||
|
screenshot_result["height"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if assertion:
|
||||||
|
step_data["assertion"] = assertion
|
||||||
|
step_data["assertion_passed"] = True
|
||||||
|
|
||||||
|
if metadata:
|
||||||
|
step_data["metadata"] = metadata
|
||||||
|
|
||||||
|
self.steps.append(step_data)
|
||||||
|
|
||||||
|
# Token-efficient output (single line)
|
||||||
|
status = "✓" if not assertion or step_data.get("assertion_passed") else "✗"
|
||||||
|
screenshot_info = (
|
||||||
|
f" [{screenshot_result['width']}x{screenshot_result['height']}]" if self.inline else ""
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"{status} Step {self.current_step}: {description} ({step_time:.1f}s){screenshot_info}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _capture_screenshot(self, output_path: Path) -> bool:
|
||||||
|
"""Capture screenshot using simctl."""
|
||||||
|
cmd = ["xcrun", "simctl", "io"]
|
||||||
|
|
||||||
|
if self.udid:
|
||||||
|
cmd.append(self.udid)
|
||||||
|
else:
|
||||||
|
cmd.append("booted")
|
||||||
|
|
||||||
|
cmd.extend(["screenshot", str(output_path)])
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _capture_accessibility(self, output_path: Path) -> int:
|
||||||
|
"""Capture accessibility tree and return element count."""
|
||||||
|
try:
|
||||||
|
# Use shared utility to fetch tree
|
||||||
|
tree = get_accessibility_tree(self.udid, nested=True)
|
||||||
|
|
||||||
|
# Save tree
|
||||||
|
with open(output_path, "w") as f:
|
||||||
|
json.dump(tree, f, indent=2)
|
||||||
|
|
||||||
|
# Count elements using shared utility
|
||||||
|
return count_elements(tree)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def generate_report(self) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Generate markdown test report.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with paths to generated files
|
||||||
|
"""
|
||||||
|
duration = time.time() - self.start_time
|
||||||
|
report_path = self.output_dir / "report.md"
|
||||||
|
|
||||||
|
# Generate markdown
|
||||||
|
with open(report_path, "w") as f:
|
||||||
|
f.write(f"# Test Report: {self.test_name}\n\n")
|
||||||
|
f.write(f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||||
|
f.write(f"**Duration:** {duration:.1f} seconds\n")
|
||||||
|
f.write(f"**Steps:** {len(self.steps)}\n\n")
|
||||||
|
|
||||||
|
# Steps section
|
||||||
|
f.write("## Test Steps\n\n")
|
||||||
|
for step in self.steps:
|
||||||
|
f.write(
|
||||||
|
f"### Step {step['number']}: {step['description']} ({step['timestamp']:.1f}s)\n\n"
|
||||||
|
)
|
||||||
|
f.write(f"\n\n")
|
||||||
|
|
||||||
|
if step.get("assertion"):
|
||||||
|
status = "✓" if step.get("assertion_passed") else "✗"
|
||||||
|
f.write(f"**Assertion:** {step['assertion']} {status}\n\n")
|
||||||
|
|
||||||
|
if step.get("metadata"):
|
||||||
|
f.write("**Metadata:**\n")
|
||||||
|
for key, value in step["metadata"].items():
|
||||||
|
f.write(f"- {key}: {value}\n")
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
f.write(f"**Accessibility Elements:** {step['element_count']}\n\n")
|
||||||
|
f.write("---\n\n")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
f.write("## Summary\n\n")
|
||||||
|
f.write(f"- Total steps: {len(self.steps)}\n")
|
||||||
|
f.write(f"- Duration: {duration:.1f}s\n")
|
||||||
|
f.write(f"- Screenshots: {len(self.steps)}\n")
|
||||||
|
f.write(f"- Accessibility snapshots: {len(self.steps)}\n")
|
||||||
|
|
||||||
|
# Save metadata JSON
|
||||||
|
metadata_path = self.output_dir / "metadata.json"
|
||||||
|
with open(metadata_path, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"test_name": self.test_name,
|
||||||
|
"duration": duration,
|
||||||
|
"steps": self.steps,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Token-efficient output
|
||||||
|
print(f"Report: {report_path}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"markdown_path": str(report_path),
|
||||||
|
"metadata_path": str(metadata_path),
|
||||||
|
"output_dir": str(self.output_dir),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point for command-line usage."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Record test execution with screenshots and documentation"
|
||||||
|
)
|
||||||
|
parser.add_argument("--test-name", required=True, help="Name of the test being recorded")
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", default="test-artifacts", help="Output directory for test artifacts"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--udid",
|
||||||
|
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--inline",
|
||||||
|
action="store_true",
|
||||||
|
help="Return screenshots as base64 (inline mode for vision-based automation)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--size",
|
||||||
|
choices=["full", "half", "quarter", "thumb"],
|
||||||
|
default="half",
|
||||||
|
help="Screenshot size for token optimization (default: half)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--app-name", help="App name for semantic screenshot naming")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Resolve UDID with auto-detection
|
||||||
|
try:
|
||||||
|
udid = resolve_udid(args.udid)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Create recorder
|
||||||
|
TestRecorder(
|
||||||
|
test_name=args.test_name,
|
||||||
|
output_dir=args.output,
|
||||||
|
udid=udid,
|
||||||
|
inline=args.inline,
|
||||||
|
screenshot_size=args.size,
|
||||||
|
app_name=args.app_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Test recorder initialized. Use the following methods:")
|
||||||
|
print(' recorder.step("description") - Record a test step')
|
||||||
|
print(" recorder.generate_report() - Generate final report")
|
||||||
|
print()
|
||||||
|
print("Example:")
|
||||||
|
print(' recorder.step("Launch app", screen_name="Splash")')
|
||||||
|
print(
|
||||||
|
' recorder.step("Enter credentials", screen_name="Login", state="Empty", metadata={"user": "test"})'
|
||||||
|
)
|
||||||
|
print(' recorder.step("Verify login", assertion="Home screen visible")')
|
||||||
|
print(" recorder.generate_report()")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Visual Diff Tool for iOS Simulator Screenshots
|
||||||
|
|
||||||
|
Compares two screenshots pixel-by-pixel to detect visual changes.
|
||||||
|
Optimized for minimal token output.
|
||||||
|
|
||||||
|
Usage: python scripts/visual_diff.py baseline.png current.png [options]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageChops, ImageDraw
|
||||||
|
except ImportError:
|
||||||
|
print("Error: Pillow not installed. Run: pip3 install pillow")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
class VisualDiffer:
|
||||||
|
"""Performs visual comparison between screenshots."""
|
||||||
|
|
||||||
|
def __init__(self, threshold: float = 0.01):
|
||||||
|
"""
|
||||||
|
Initialize differ with threshold.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
threshold: Maximum acceptable difference ratio (0.01 = 1%)
|
||||||
|
"""
|
||||||
|
self.threshold = threshold
|
||||||
|
|
||||||
|
def compare(self, baseline_path: str, current_path: str) -> dict:
|
||||||
|
"""
|
||||||
|
Compare two images and return difference metrics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
baseline_path: Path to baseline image
|
||||||
|
current_path: Path to current image
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with comparison results
|
||||||
|
"""
|
||||||
|
# Load images
|
||||||
|
try:
|
||||||
|
baseline = Image.open(baseline_path)
|
||||||
|
current = Image.open(current_path)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
print(f"Error: Image not found - {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: Failed to load image - {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Verify dimensions match
|
||||||
|
if baseline.size != current.size:
|
||||||
|
return {
|
||||||
|
"error": "Image dimensions do not match",
|
||||||
|
"baseline_size": baseline.size,
|
||||||
|
"current_size": current.size,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Convert to RGB if needed
|
||||||
|
if baseline.mode != "RGB":
|
||||||
|
baseline = baseline.convert("RGB")
|
||||||
|
if current.mode != "RGB":
|
||||||
|
current = current.convert("RGB")
|
||||||
|
|
||||||
|
# Calculate difference
|
||||||
|
diff = ImageChops.difference(baseline, current)
|
||||||
|
|
||||||
|
# Calculate metrics
|
||||||
|
total_pixels = baseline.size[0] * baseline.size[1]
|
||||||
|
diff_pixels = self._count_different_pixels(diff)
|
||||||
|
diff_percentage = (diff_pixels / total_pixels) * 100
|
||||||
|
|
||||||
|
# Determine pass/fail
|
||||||
|
passed = diff_percentage <= (self.threshold * 100)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dimensions": baseline.size,
|
||||||
|
"total_pixels": total_pixels,
|
||||||
|
"different_pixels": diff_pixels,
|
||||||
|
"difference_percentage": round(diff_percentage, 2),
|
||||||
|
"threshold_percentage": self.threshold * 100,
|
||||||
|
"passed": passed,
|
||||||
|
"verdict": "PASS" if passed else "FAIL",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _count_different_pixels(self, diff_image: Image.Image) -> int:
|
||||||
|
"""Count number of pixels that are different."""
|
||||||
|
# Convert to grayscale for easier processing
|
||||||
|
diff_gray = diff_image.convert("L")
|
||||||
|
|
||||||
|
# Count non-zero pixels (different)
|
||||||
|
pixels = diff_gray.getdata()
|
||||||
|
return sum(1 for pixel in pixels if pixel > 10) # Threshold for noise
|
||||||
|
|
||||||
|
def generate_diff_image(self, baseline_path: str, current_path: str, output_path: str) -> None:
|
||||||
|
"""Generate highlighted difference image."""
|
||||||
|
baseline = Image.open(baseline_path).convert("RGB")
|
||||||
|
current = Image.open(current_path).convert("RGB")
|
||||||
|
|
||||||
|
# Create difference image
|
||||||
|
diff = ImageChops.difference(baseline, current)
|
||||||
|
|
||||||
|
# Enhance differences with red overlay
|
||||||
|
diff_enhanced = Image.new("RGB", baseline.size)
|
||||||
|
for x in range(baseline.size[0]):
|
||||||
|
for y in range(baseline.size[1]):
|
||||||
|
diff_pixel = diff.getpixel((x, y))
|
||||||
|
if sum(diff_pixel) > 30: # Threshold for visibility
|
||||||
|
# Highlight in red
|
||||||
|
diff_enhanced.putpixel((x, y), (255, 0, 0))
|
||||||
|
else:
|
||||||
|
# Keep original
|
||||||
|
diff_enhanced.putpixel((x, y), current.getpixel((x, y)))
|
||||||
|
|
||||||
|
diff_enhanced.save(output_path)
|
||||||
|
|
||||||
|
def generate_side_by_side(
|
||||||
|
self, baseline_path: str, current_path: str, output_path: str
|
||||||
|
) -> None:
|
||||||
|
"""Generate side-by-side comparison image."""
|
||||||
|
baseline = Image.open(baseline_path)
|
||||||
|
current = Image.open(current_path)
|
||||||
|
|
||||||
|
# Create combined image
|
||||||
|
width = baseline.size[0] * 2 + 10 # 10px separator
|
||||||
|
height = max(baseline.size[1], current.size[1])
|
||||||
|
combined = Image.new("RGB", (width, height), color=(128, 128, 128))
|
||||||
|
|
||||||
|
# Paste images
|
||||||
|
combined.paste(baseline, (0, 0))
|
||||||
|
combined.paste(current, (baseline.size[0] + 10, 0))
|
||||||
|
|
||||||
|
combined.save(output_path)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Compare screenshots for visual differences")
|
||||||
|
parser.add_argument("baseline", help="Path to baseline screenshot")
|
||||||
|
parser.add_argument("current", help="Path to current screenshot")
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
default=".",
|
||||||
|
help="Output directory for diff artifacts (default: current directory)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--threshold",
|
||||||
|
type=float,
|
||||||
|
default=0.01,
|
||||||
|
help="Acceptable difference threshold (0.01 = 1%%, default: 0.01)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--details", action="store_true", help="Show detailed output (increases tokens)"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Create output directory if needed
|
||||||
|
output_dir = Path(args.output)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Initialize differ
|
||||||
|
differ = VisualDiffer(threshold=args.threshold)
|
||||||
|
|
||||||
|
# Perform comparison
|
||||||
|
result = differ.compare(args.baseline, args.current)
|
||||||
|
|
||||||
|
# Handle dimension mismatch
|
||||||
|
if "error" in result:
|
||||||
|
print(f"Error: {result['error']}")
|
||||||
|
print(f"Baseline: {result['baseline_size']}")
|
||||||
|
print(f"Current: {result['current_size']}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Generate artifacts
|
||||||
|
diff_image_path = output_dir / "diff.png"
|
||||||
|
comparison_image_path = output_dir / "side-by-side.png"
|
||||||
|
|
||||||
|
try:
|
||||||
|
differ.generate_diff_image(args.baseline, args.current, str(diff_image_path))
|
||||||
|
differ.generate_side_by_side(args.baseline, args.current, str(comparison_image_path))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not generate images - {e}")
|
||||||
|
|
||||||
|
# Output results (token-optimized)
|
||||||
|
if args.details:
|
||||||
|
# Detailed output
|
||||||
|
report = {
|
||||||
|
"summary": {
|
||||||
|
"baseline": args.baseline,
|
||||||
|
"current": args.current,
|
||||||
|
"threshold": args.threshold,
|
||||||
|
"passed": result["passed"],
|
||||||
|
},
|
||||||
|
"results": result,
|
||||||
|
"artifacts": {
|
||||||
|
"diff_image": str(diff_image_path),
|
||||||
|
"comparison_image": str(comparison_image_path),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
print(json.dumps(report, indent=2))
|
||||||
|
else:
|
||||||
|
# Minimal output (default)
|
||||||
|
print(f"Difference: {result['difference_percentage']}% ({result['verdict']})")
|
||||||
|
if result["different_pixels"] > 0:
|
||||||
|
print(f"Changed pixels: {result['different_pixels']:,}")
|
||||||
|
print(f"Artifacts saved to: {output_dir}/")
|
||||||
|
|
||||||
|
# Save JSON report
|
||||||
|
report_path = output_dir / "diff-report.json"
|
||||||
|
with open(report_path, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"baseline": os.path.basename(args.baseline),
|
||||||
|
"current": os.path.basename(args.current),
|
||||||
|
"results": result,
|
||||||
|
"artifacts": {"diff": "diff.png", "comparison": "side-by-side.png"},
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exit with error if test failed
|
||||||
|
sys.exit(0 if result["passed"] else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""
|
||||||
|
Xcode build automation module.
|
||||||
|
|
||||||
|
Provides structured, modular access to xcodebuild and xcresult functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .builder import BuildRunner
|
||||||
|
from .cache import XCResultCache
|
||||||
|
from .config import Config
|
||||||
|
from .reporter import OutputFormatter
|
||||||
|
from .xcresult import XCResultParser
|
||||||
|
|
||||||
|
__all__ = ["BuildRunner", "Config", "OutputFormatter", "XCResultCache", "XCResultParser"]
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
"""
|
||||||
|
Xcode build execution.
|
||||||
|
|
||||||
|
Handles xcodebuild command construction and execution with xcresult generation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .cache import XCResultCache
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
|
||||||
|
class BuildRunner:
|
||||||
|
"""
|
||||||
|
Execute xcodebuild commands with xcresult bundle generation.
|
||||||
|
|
||||||
|
Handles scheme auto-detection, command construction, and build/test execution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
project_path: str | None = None,
|
||||||
|
workspace_path: str | None = None,
|
||||||
|
scheme: str | None = None,
|
||||||
|
configuration: str = "Debug",
|
||||||
|
simulator: str | None = None,
|
||||||
|
cache: XCResultCache | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize build runner.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_path: Path to .xcodeproj
|
||||||
|
workspace_path: Path to .xcworkspace
|
||||||
|
scheme: Build scheme (auto-detected if not provided)
|
||||||
|
configuration: Build configuration (Debug/Release)
|
||||||
|
simulator: Simulator name
|
||||||
|
cache: XCResult cache (creates default if not provided)
|
||||||
|
"""
|
||||||
|
self.project_path = project_path
|
||||||
|
self.workspace_path = workspace_path
|
||||||
|
self.scheme = scheme
|
||||||
|
self.configuration = configuration
|
||||||
|
self.simulator = simulator
|
||||||
|
self.cache = cache or XCResultCache()
|
||||||
|
|
||||||
|
def auto_detect_scheme(self) -> str | None:
|
||||||
|
"""
|
||||||
|
Auto-detect build scheme from project/workspace.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Detected scheme name or None
|
||||||
|
"""
|
||||||
|
cmd = ["xcodebuild", "-list"]
|
||||||
|
|
||||||
|
if self.workspace_path:
|
||||||
|
cmd.extend(["-workspace", self.workspace_path])
|
||||||
|
elif self.project_path:
|
||||||
|
cmd.extend(["-project", self.project_path])
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
# Parse schemes from output
|
||||||
|
in_schemes_section = False
|
||||||
|
for line in result.stdout.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
|
||||||
|
if "Schemes:" in line:
|
||||||
|
in_schemes_section = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_schemes_section and line and not line.startswith("Build"):
|
||||||
|
# First scheme in list
|
||||||
|
return line
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error auto-detecting scheme: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_simulator_destination(self) -> str:
|
||||||
|
"""
|
||||||
|
Get xcodebuild destination string.
|
||||||
|
|
||||||
|
Uses config preferences with fallback to auto-detection.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. --simulator CLI flag (self.simulator)
|
||||||
|
2. Config preferred_simulator
|
||||||
|
3. Config last_used_simulator
|
||||||
|
4. Auto-detect first iPhone
|
||||||
|
5. Generic iOS Simulator
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Destination string for -destination flag
|
||||||
|
"""
|
||||||
|
# Priority 1: CLI flag
|
||||||
|
if self.simulator:
|
||||||
|
return f"platform=iOS Simulator,name={self.simulator}"
|
||||||
|
|
||||||
|
# Priority 2-3: Config preferences
|
||||||
|
try:
|
||||||
|
# Determine project directory from project/workspace path
|
||||||
|
project_dir = None
|
||||||
|
if self.project_path:
|
||||||
|
project_dir = Path(self.project_path).parent
|
||||||
|
elif self.workspace_path:
|
||||||
|
project_dir = Path(self.workspace_path).parent
|
||||||
|
|
||||||
|
config = Config.load(project_dir=project_dir)
|
||||||
|
preferred = config.get_preferred_simulator()
|
||||||
|
|
||||||
|
if preferred:
|
||||||
|
# Check if preferred simulator exists
|
||||||
|
if self._simulator_exists(preferred):
|
||||||
|
return f"platform=iOS Simulator,name={preferred}"
|
||||||
|
print(f"Warning: Preferred simulator '{preferred}' not available", file=sys.stderr)
|
||||||
|
if config.should_fallback_to_any_iphone():
|
||||||
|
print("Falling back to auto-detection...", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
# Strict mode: don't fallback
|
||||||
|
return f"platform=iOS Simulator,name={preferred}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not load config: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Priority 4-5: Auto-detect
|
||||||
|
return self._auto_detect_simulator()
|
||||||
|
|
||||||
|
def _simulator_exists(self, name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if simulator with given name exists and is available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Simulator name (e.g., "iPhone 16 Pro")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if simulator exists and is available
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["xcrun", "simctl", "list", "devices", "available", "iOS"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if simulator name appears in available devices
|
||||||
|
return any(name in line and "(" in line for line in result.stdout.split("\n"))
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _extract_simulator_name_from_destination(self, destination: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Extract simulator name from destination string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
destination: Destination string (e.g., "platform=iOS Simulator,name=iPhone 16 Pro")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Simulator name or None
|
||||||
|
"""
|
||||||
|
# Pattern: name=<simulator name>
|
||||||
|
match = re.search(r"name=([^,]+)", destination)
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _auto_detect_simulator(self) -> str:
|
||||||
|
"""
|
||||||
|
Auto-detect best available iOS simulator.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Destination string for -destination flag
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["xcrun", "simctl", "list", "devices", "available", "iOS"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse available simulators, prefer latest iPhone
|
||||||
|
# Looking for lines like: "iPhone 16 Pro (12345678-1234-1234-1234-123456789012) (Shutdown)"
|
||||||
|
for line in result.stdout.split("\n"):
|
||||||
|
if "iPhone" in line and "(" in line:
|
||||||
|
# Extract device name
|
||||||
|
name = line.split("(")[0].strip()
|
||||||
|
if name:
|
||||||
|
return f"platform=iOS Simulator,name={name}"
|
||||||
|
|
||||||
|
# Fallback to generic iOS Simulator if no iPhone found
|
||||||
|
return "generic/platform=iOS Simulator"
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Warning: Could not auto-detect simulator: {e}", file=sys.stderr)
|
||||||
|
return "generic/platform=iOS Simulator"
|
||||||
|
|
||||||
|
def build(self, clean: bool = False) -> tuple[bool, str, str]:
|
||||||
|
"""
|
||||||
|
Build the project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
clean: Perform clean build
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success: bool, xcresult_id: str, stderr: str)
|
||||||
|
"""
|
||||||
|
# Auto-detect scheme if needed
|
||||||
|
if not self.scheme:
|
||||||
|
self.scheme = self.auto_detect_scheme()
|
||||||
|
if not self.scheme:
|
||||||
|
print("Error: Could not auto-detect scheme. Use --scheme", file=sys.stderr)
|
||||||
|
return (False, "", "")
|
||||||
|
|
||||||
|
# Generate xcresult ID and path
|
||||||
|
xcresult_id = self.cache.generate_id()
|
||||||
|
xcresult_path = self.cache.get_path(xcresult_id)
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
cmd = ["xcodebuild", "-quiet"] # Suppress verbose output
|
||||||
|
|
||||||
|
if clean:
|
||||||
|
cmd.append("clean")
|
||||||
|
|
||||||
|
cmd.append("build")
|
||||||
|
|
||||||
|
if self.workspace_path:
|
||||||
|
cmd.extend(["-workspace", self.workspace_path])
|
||||||
|
elif self.project_path:
|
||||||
|
cmd.extend(["-project", self.project_path])
|
||||||
|
else:
|
||||||
|
print("Error: No project or workspace specified", file=sys.stderr)
|
||||||
|
return (False, "", "")
|
||||||
|
|
||||||
|
cmd.extend(
|
||||||
|
[
|
||||||
|
"-scheme",
|
||||||
|
self.scheme,
|
||||||
|
"-configuration",
|
||||||
|
self.configuration,
|
||||||
|
"-destination",
|
||||||
|
self.get_simulator_destination(),
|
||||||
|
"-resultBundlePath",
|
||||||
|
str(xcresult_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute build
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd, capture_output=True, text=True, check=False # Don't raise on non-zero exit
|
||||||
|
)
|
||||||
|
|
||||||
|
success = result.returncode == 0
|
||||||
|
|
||||||
|
# xcresult bundle should be created even on failure
|
||||||
|
if not xcresult_path.exists():
|
||||||
|
print("Warning: xcresult bundle was not created", file=sys.stderr)
|
||||||
|
return (success, "", result.stderr)
|
||||||
|
|
||||||
|
# Auto-update config with last used simulator (on success only)
|
||||||
|
if success:
|
||||||
|
try:
|
||||||
|
# Determine project directory from project/workspace path
|
||||||
|
project_dir = None
|
||||||
|
if self.project_path:
|
||||||
|
project_dir = Path(self.project_path).parent
|
||||||
|
elif self.workspace_path:
|
||||||
|
project_dir = Path(self.workspace_path).parent
|
||||||
|
|
||||||
|
config = Config.load(project_dir=project_dir)
|
||||||
|
destination = self.get_simulator_destination()
|
||||||
|
simulator_name = self._extract_simulator_name_from_destination(destination)
|
||||||
|
|
||||||
|
if simulator_name:
|
||||||
|
config.update_last_used_simulator(simulator_name)
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Don't fail build if config update fails
|
||||||
|
print(f"Warning: Could not update config: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
return (success, xcresult_id, result.stderr)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error executing build: {e}", file=sys.stderr)
|
||||||
|
return (False, "", str(e))
|
||||||
|
|
||||||
|
def test(self, test_suite: str | None = None) -> tuple[bool, str, str]:
|
||||||
|
"""
|
||||||
|
Run tests.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
test_suite: Specific test suite to run
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success: bool, xcresult_id: str, stderr: str)
|
||||||
|
"""
|
||||||
|
# Auto-detect scheme if needed
|
||||||
|
if not self.scheme:
|
||||||
|
self.scheme = self.auto_detect_scheme()
|
||||||
|
if not self.scheme:
|
||||||
|
print("Error: Could not auto-detect scheme. Use --scheme", file=sys.stderr)
|
||||||
|
return (False, "", "")
|
||||||
|
|
||||||
|
# Generate xcresult ID and path
|
||||||
|
xcresult_id = self.cache.generate_id()
|
||||||
|
xcresult_path = self.cache.get_path(xcresult_id)
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
cmd = ["xcodebuild", "-quiet", "test"]
|
||||||
|
|
||||||
|
if self.workspace_path:
|
||||||
|
cmd.extend(["-workspace", self.workspace_path])
|
||||||
|
elif self.project_path:
|
||||||
|
cmd.extend(["-project", self.project_path])
|
||||||
|
else:
|
||||||
|
print("Error: No project or workspace specified", file=sys.stderr)
|
||||||
|
return (False, "", "")
|
||||||
|
|
||||||
|
cmd.extend(
|
||||||
|
[
|
||||||
|
"-scheme",
|
||||||
|
self.scheme,
|
||||||
|
"-destination",
|
||||||
|
self.get_simulator_destination(),
|
||||||
|
"-resultBundlePath",
|
||||||
|
str(xcresult_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if test_suite:
|
||||||
|
cmd.extend(["-only-testing", test_suite])
|
||||||
|
|
||||||
|
# Execute tests
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||||
|
|
||||||
|
success = result.returncode == 0
|
||||||
|
|
||||||
|
# xcresult bundle should be created even on failure
|
||||||
|
if not xcresult_path.exists():
|
||||||
|
print("Warning: xcresult bundle was not created", file=sys.stderr)
|
||||||
|
return (success, "", result.stderr)
|
||||||
|
|
||||||
|
# Auto-update config with last used simulator (on success only)
|
||||||
|
if success:
|
||||||
|
try:
|
||||||
|
# Determine project directory from project/workspace path
|
||||||
|
project_dir = None
|
||||||
|
if self.project_path:
|
||||||
|
project_dir = Path(self.project_path).parent
|
||||||
|
elif self.workspace_path:
|
||||||
|
project_dir = Path(self.workspace_path).parent
|
||||||
|
|
||||||
|
config = Config.load(project_dir=project_dir)
|
||||||
|
destination = self.get_simulator_destination()
|
||||||
|
simulator_name = self._extract_simulator_name_from_destination(destination)
|
||||||
|
|
||||||
|
if simulator_name:
|
||||||
|
config.update_last_used_simulator(simulator_name)
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Don't fail test if config update fails
|
||||||
|
print(f"Warning: Could not update config: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
return (success, xcresult_id, result.stderr)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error executing tests: {e}", file=sys.stderr)
|
||||||
|
return (False, "", str(e))
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""
|
||||||
|
XCResult cache management.
|
||||||
|
|
||||||
|
Handles storage, retrieval, and lifecycle of xcresult bundles for progressive disclosure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class XCResultCache:
|
||||||
|
"""
|
||||||
|
Manage xcresult bundle cache for progressive disclosure.
|
||||||
|
|
||||||
|
Stores xcresult bundles with timestamp-based IDs and provides
|
||||||
|
retrieval and cleanup operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Default cache directory
|
||||||
|
DEFAULT_CACHE_DIR = Path.home() / ".ios-simulator-skill" / "xcresults"
|
||||||
|
|
||||||
|
def __init__(self, cache_dir: Path | None = None):
|
||||||
|
"""
|
||||||
|
Initialize cache manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_dir: Custom cache directory (uses default if not specified)
|
||||||
|
"""
|
||||||
|
self.cache_dir = cache_dir or self.DEFAULT_CACHE_DIR
|
||||||
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def generate_id(self, prefix: str = "xcresult") -> str:
|
||||||
|
"""
|
||||||
|
Generate timestamped xcresult ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prefix: ID prefix (default: "xcresult")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ID string like "xcresult-20251018-143052"
|
||||||
|
"""
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
return f"{prefix}-{timestamp}"
|
||||||
|
|
||||||
|
def get_path(self, xcresult_id: str) -> Path:
|
||||||
|
"""
|
||||||
|
Get full path for xcresult ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xcresult_id: XCResult ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to xcresult bundle
|
||||||
|
"""
|
||||||
|
# Handle both with and without .xcresult extension
|
||||||
|
if xcresult_id.endswith(".xcresult"):
|
||||||
|
return self.cache_dir / xcresult_id
|
||||||
|
return self.cache_dir / f"{xcresult_id}.xcresult"
|
||||||
|
|
||||||
|
def exists(self, xcresult_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if xcresult bundle exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xcresult_id: XCResult ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if bundle exists
|
||||||
|
"""
|
||||||
|
return self.get_path(xcresult_id).exists()
|
||||||
|
|
||||||
|
def save(self, source_path: Path, xcresult_id: str | None = None) -> str:
|
||||||
|
"""
|
||||||
|
Save xcresult bundle to cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_path: Source xcresult bundle path
|
||||||
|
xcresult_id: Optional custom ID (generates if not provided)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
xcresult ID
|
||||||
|
"""
|
||||||
|
if not source_path.exists():
|
||||||
|
raise FileNotFoundError(f"Source xcresult not found: {source_path}")
|
||||||
|
|
||||||
|
# Generate ID if not provided
|
||||||
|
if not xcresult_id:
|
||||||
|
xcresult_id = self.generate_id()
|
||||||
|
|
||||||
|
# Get destination path
|
||||||
|
dest_path = self.get_path(xcresult_id)
|
||||||
|
|
||||||
|
# Copy xcresult bundle (it's a directory)
|
||||||
|
if dest_path.exists():
|
||||||
|
shutil.rmtree(dest_path)
|
||||||
|
|
||||||
|
shutil.copytree(source_path, dest_path)
|
||||||
|
|
||||||
|
return xcresult_id
|
||||||
|
|
||||||
|
def list(self, limit: int = 10) -> list[dict]:
|
||||||
|
"""
|
||||||
|
List recent xcresult bundles.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of xcresult metadata dicts
|
||||||
|
"""
|
||||||
|
if not self.cache_dir.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for path in sorted(
|
||||||
|
self.cache_dir.glob("*.xcresult"), key=lambda p: p.stat().st_mtime, reverse=True
|
||||||
|
)[:limit]:
|
||||||
|
# Calculate bundle size
|
||||||
|
size_bytes = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"id": path.stem,
|
||||||
|
"path": str(path),
|
||||||
|
"created": datetime.fromtimestamp(path.stat().st_mtime).isoformat(),
|
||||||
|
"size_mb": round(size_bytes / (1024 * 1024), 2),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def cleanup(self, keep_recent: int = 20) -> int:
|
||||||
|
"""
|
||||||
|
Clean up old xcresult bundles.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keep_recent: Number of recent bundles to keep
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of bundles removed
|
||||||
|
"""
|
||||||
|
if not self.cache_dir.exists():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Get all bundles sorted by modification time
|
||||||
|
all_bundles = sorted(
|
||||||
|
self.cache_dir.glob("*.xcresult"), key=lambda p: p.stat().st_mtime, reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove old bundles
|
||||||
|
removed = 0
|
||||||
|
for bundle_path in all_bundles[keep_recent:]:
|
||||||
|
shutil.rmtree(bundle_path)
|
||||||
|
removed += 1
|
||||||
|
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def get_size_mb(self, xcresult_id: str) -> float:
|
||||||
|
"""
|
||||||
|
Get size of xcresult bundle in MB.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xcresult_id: XCResult ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Size in MB
|
||||||
|
"""
|
||||||
|
path = self.get_path(xcresult_id)
|
||||||
|
if not path.exists():
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
size_bytes = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
||||||
|
return round(size_bytes / (1024 * 1024), 2)
|
||||||
|
|
||||||
|
def save_stderr(self, xcresult_id: str, stderr: str) -> None:
|
||||||
|
"""
|
||||||
|
Save stderr output alongside xcresult bundle.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xcresult_id: XCResult ID
|
||||||
|
stderr: stderr output from xcodebuild
|
||||||
|
"""
|
||||||
|
if not stderr:
|
||||||
|
return
|
||||||
|
|
||||||
|
stderr_path = self.cache_dir / f"{xcresult_id}.stderr"
|
||||||
|
stderr_path.write_text(stderr, encoding="utf-8")
|
||||||
|
|
||||||
|
def get_stderr(self, xcresult_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Retrieve cached stderr output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xcresult_id: XCResult ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
stderr content or empty string if not found
|
||||||
|
"""
|
||||||
|
stderr_path = self.cache_dir / f"{xcresult_id}.stderr"
|
||||||
|
if not stderr_path.exists():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return stderr_path.read_text(encoding="utf-8")
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
Configuration management for iOS Simulator Skill.
|
||||||
|
|
||||||
|
Handles loading, validation, and auto-updating of project-local config files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""
|
||||||
|
Project-local configuration with auto-learning.
|
||||||
|
|
||||||
|
Config file location: .claude/skills/<skill-directory-name>/config.json
|
||||||
|
|
||||||
|
The skill directory name is auto-detected from the installation location,
|
||||||
|
so configs work regardless of what users name the skill directory.
|
||||||
|
|
||||||
|
Auto-updates last_used_simulator after successful builds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
"device": {
|
||||||
|
"preferred_simulator": None,
|
||||||
|
"preferred_os_version": None,
|
||||||
|
"fallback_to_any_iphone": True,
|
||||||
|
"last_used_simulator": None,
|
||||||
|
"last_used_at": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, data: dict[str, Any], config_path: Path):
|
||||||
|
"""
|
||||||
|
Initialize config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Config data dict
|
||||||
|
config_path: Path to config file
|
||||||
|
"""
|
||||||
|
self.data = data
|
||||||
|
self.config_path = config_path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load(project_dir: Path | None = None) -> "Config":
|
||||||
|
"""
|
||||||
|
Load config from project directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_dir: Project root (defaults to cwd)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Config instance (creates default if not found)
|
||||||
|
|
||||||
|
Note:
|
||||||
|
The skill directory name is auto-detected from the installation location,
|
||||||
|
so configs work regardless of what users name the skill directory.
|
||||||
|
"""
|
||||||
|
if project_dir is None:
|
||||||
|
project_dir = Path.cwd()
|
||||||
|
|
||||||
|
# Auto-detect skill directory name from actual installation location
|
||||||
|
# This file is at: skill/scripts/xcode/config.py
|
||||||
|
# Navigate up to skill/ directory and use its name
|
||||||
|
skill_root = Path(__file__).parent.parent.parent # xcode/ -> scripts/ -> skill/
|
||||||
|
skill_name = skill_root.name
|
||||||
|
|
||||||
|
config_path = project_dir / ".claude" / "skills" / skill_name / "config.json"
|
||||||
|
|
||||||
|
# Load existing config
|
||||||
|
if config_path.exists():
|
||||||
|
try:
|
||||||
|
with open(config_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Merge with defaults (in case new fields added)
|
||||||
|
merged = Config._merge_with_defaults(data)
|
||||||
|
return Config(merged, config_path)
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"Warning: Invalid JSON in {config_path}: {e}", file=sys.stderr)
|
||||||
|
print("Using default config", file=sys.stderr)
|
||||||
|
return Config(Config.DEFAULT_CONFIG.copy(), config_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not load config: {e}", file=sys.stderr)
|
||||||
|
return Config(Config.DEFAULT_CONFIG.copy(), config_path)
|
||||||
|
|
||||||
|
# Return default config (will be created on first save)
|
||||||
|
return Config(Config.DEFAULT_CONFIG.copy(), config_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_with_defaults(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Merge user config with defaults.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: User config data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Merged config with all default fields
|
||||||
|
"""
|
||||||
|
merged = Config.DEFAULT_CONFIG.copy()
|
||||||
|
|
||||||
|
# Deep merge device section
|
||||||
|
if "device" in data:
|
||||||
|
merged["device"].update(data["device"])
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
"""
|
||||||
|
Save config to file atomically.
|
||||||
|
|
||||||
|
Uses temp file + rename for atomic writes.
|
||||||
|
Creates parent directories if needed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create parent directories
|
||||||
|
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Atomic write: temp file + rename
|
||||||
|
temp_path = self.config_path.with_suffix(".tmp")
|
||||||
|
|
||||||
|
with open(temp_path, "w") as f:
|
||||||
|
json.dump(self.data, f, indent=2)
|
||||||
|
f.write("\n") # Trailing newline
|
||||||
|
|
||||||
|
# Atomic rename
|
||||||
|
temp_path.replace(self.config_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not save config: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
def update_last_used_simulator(self, name: str) -> None:
|
||||||
|
"""
|
||||||
|
Update last used simulator and timestamp.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Simulator name (e.g., "iPhone 16 Pro")
|
||||||
|
"""
|
||||||
|
self.data["device"]["last_used_simulator"] = name
|
||||||
|
self.data["device"]["last_used_at"] = datetime.utcnow().isoformat() + "Z"
|
||||||
|
|
||||||
|
def get_preferred_simulator(self) -> str | None:
|
||||||
|
"""
|
||||||
|
Get preferred simulator.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Simulator name or None
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. preferred_simulator (manual preference)
|
||||||
|
2. last_used_simulator (auto-learned)
|
||||||
|
3. None (use auto-detection)
|
||||||
|
"""
|
||||||
|
device = self.data.get("device", {})
|
||||||
|
|
||||||
|
# Manual preference takes priority
|
||||||
|
if device.get("preferred_simulator"):
|
||||||
|
return device["preferred_simulator"]
|
||||||
|
|
||||||
|
# Auto-learned preference
|
||||||
|
if device.get("last_used_simulator"):
|
||||||
|
return device["last_used_simulator"]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def should_fallback_to_any_iphone(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if fallback to any iPhone is enabled.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if should fallback, False otherwise
|
||||||
|
"""
|
||||||
|
return self.data.get("device", {}).get("fallback_to_any_iphone", True)
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"""
|
||||||
|
Build/test output formatting.
|
||||||
|
|
||||||
|
Provides multiple output formats with progressive disclosure support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class OutputFormatter:
|
||||||
|
"""
|
||||||
|
Format build/test results for display.
|
||||||
|
|
||||||
|
Supports ultra-minimal default output, verbose mode, and JSON output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_minimal(
|
||||||
|
status: str,
|
||||||
|
error_count: int,
|
||||||
|
warning_count: int,
|
||||||
|
xcresult_id: str,
|
||||||
|
test_info: dict | None = None,
|
||||||
|
hints: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Format ultra-minimal output (5-10 tokens).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: Build status (SUCCESS/FAILED)
|
||||||
|
error_count: Number of errors
|
||||||
|
warning_count: Number of warnings
|
||||||
|
xcresult_id: XCResult bundle ID
|
||||||
|
test_info: Optional test results dict
|
||||||
|
hints: Optional list of actionable hints
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Minimal formatted string
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Build: SUCCESS (0 errors, 3 warnings) [xcresult-20251018-143052]
|
||||||
|
Tests: PASS (12/12 passed, 4.2s) [xcresult-20251018-143052]
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
if test_info:
|
||||||
|
# Test mode
|
||||||
|
total = test_info.get("total", 0)
|
||||||
|
passed = test_info.get("passed", 0)
|
||||||
|
failed = test_info.get("failed", 0)
|
||||||
|
duration = test_info.get("duration", 0.0)
|
||||||
|
|
||||||
|
test_status = "PASS" if failed == 0 else "FAIL"
|
||||||
|
lines.append(
|
||||||
|
f"Tests: {test_status} ({passed}/{total} passed, {duration:.1f}s) [{xcresult_id}]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Build mode
|
||||||
|
lines.append(
|
||||||
|
f"Build: {status} ({error_count} errors, {warning_count} warnings) [{xcresult_id}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add hints if provided and build failed
|
||||||
|
if hints and status == "FAILED":
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(hints)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_errors(errors: list[dict], limit: int = 10) -> str:
|
||||||
|
"""
|
||||||
|
Format error details.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
errors: List of error dicts
|
||||||
|
limit: Maximum errors to show
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted error list
|
||||||
|
"""
|
||||||
|
if not errors:
|
||||||
|
return "No errors found."
|
||||||
|
|
||||||
|
lines = [f"Errors ({len(errors)}):"]
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for i, error in enumerate(errors[:limit], 1):
|
||||||
|
message = error.get("message", "Unknown error")
|
||||||
|
location = error.get("location", {})
|
||||||
|
|
||||||
|
# Format location
|
||||||
|
loc_parts = []
|
||||||
|
if location.get("file"):
|
||||||
|
file_path = location["file"].replace("file://", "")
|
||||||
|
loc_parts.append(file_path)
|
||||||
|
if location.get("line"):
|
||||||
|
loc_parts.append(f"line {location['line']}")
|
||||||
|
|
||||||
|
location_str = ":".join(loc_parts) if loc_parts else "unknown location"
|
||||||
|
|
||||||
|
lines.append(f"{i}. {message}")
|
||||||
|
lines.append(f" Location: {location_str}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if len(errors) > limit:
|
||||||
|
lines.append(f"... and {len(errors) - limit} more errors")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_warnings(warnings: list[dict], limit: int = 10) -> str:
|
||||||
|
"""
|
||||||
|
Format warning details.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
warnings: List of warning dicts
|
||||||
|
limit: Maximum warnings to show
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted warning list
|
||||||
|
"""
|
||||||
|
if not warnings:
|
||||||
|
return "No warnings found."
|
||||||
|
|
||||||
|
lines = [f"Warnings ({len(warnings)}):"]
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for i, warning in enumerate(warnings[:limit], 1):
|
||||||
|
message = warning.get("message", "Unknown warning")
|
||||||
|
location = warning.get("location", {})
|
||||||
|
|
||||||
|
# Format location
|
||||||
|
loc_parts = []
|
||||||
|
if location.get("file"):
|
||||||
|
file_path = location["file"].replace("file://", "")
|
||||||
|
loc_parts.append(file_path)
|
||||||
|
if location.get("line"):
|
||||||
|
loc_parts.append(f"line {location['line']}")
|
||||||
|
|
||||||
|
location_str = ":".join(loc_parts) if loc_parts else "unknown location"
|
||||||
|
|
||||||
|
lines.append(f"{i}. {message}")
|
||||||
|
lines.append(f" Location: {location_str}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if len(warnings) > limit:
|
||||||
|
lines.append(f"... and {len(warnings) - limit} more warnings")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_log(log: str, lines: int = 50) -> str:
|
||||||
|
"""
|
||||||
|
Format build log (show last N lines).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log: Full build log
|
||||||
|
lines: Number of lines to show
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted log excerpt
|
||||||
|
"""
|
||||||
|
if not log:
|
||||||
|
return "No build log available."
|
||||||
|
|
||||||
|
log_lines = log.strip().split("\n")
|
||||||
|
|
||||||
|
if len(log_lines) <= lines:
|
||||||
|
return log
|
||||||
|
|
||||||
|
# Show last N lines
|
||||||
|
excerpt = log_lines[-lines:]
|
||||||
|
return f"... (showing last {lines} lines of {len(log_lines)})\n\n" + "\n".join(excerpt)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_json(data: dict) -> str:
|
||||||
|
"""
|
||||||
|
Format data as JSON.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Data to format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Pretty-printed JSON string
|
||||||
|
"""
|
||||||
|
return json.dumps(data, indent=2)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_hints(errors: list[dict]) -> list[str]:
|
||||||
|
"""
|
||||||
|
Generate actionable hints based on error types.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
errors: List of error dicts
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of hint strings
|
||||||
|
"""
|
||||||
|
hints = []
|
||||||
|
error_types: set[str] = set()
|
||||||
|
|
||||||
|
# Collect error types
|
||||||
|
for error in errors:
|
||||||
|
error_type = error.get("type", "unknown")
|
||||||
|
error_types.add(error_type)
|
||||||
|
|
||||||
|
# Generate hints based on error types
|
||||||
|
if "provisioning" in error_types:
|
||||||
|
hints.append("Provisioning profile issue detected:")
|
||||||
|
hints.append(" • Ensure you have a valid provisioning profile for iOS Simulator")
|
||||||
|
hints.append(
|
||||||
|
' • For simulator builds, use CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO'
|
||||||
|
)
|
||||||
|
hints.append(" • Or specify simulator explicitly: --simulator 'iPhone 16 Pro'")
|
||||||
|
|
||||||
|
if "signing" in error_types:
|
||||||
|
hints.append("Code signing issue detected:")
|
||||||
|
hints.append(" • For simulator builds, code signing is not required")
|
||||||
|
hints.append(" • Ensure build settings target iOS Simulator, not physical device")
|
||||||
|
hints.append(" • Check destination: platform=iOS Simulator,name=<device>")
|
||||||
|
|
||||||
|
if not error_types or "build" in error_types:
|
||||||
|
# Generic hints when error type is unknown
|
||||||
|
if any("destination" in error.get("message", "").lower() for error in errors):
|
||||||
|
hints.append("Device selection issue detected:")
|
||||||
|
hints.append(" • List available simulators: xcrun simctl list devices available")
|
||||||
|
hints.append(" • Specify simulator: --simulator 'iPhone 16 Pro'")
|
||||||
|
|
||||||
|
return hints
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_verbose(
|
||||||
|
status: str,
|
||||||
|
error_count: int,
|
||||||
|
warning_count: int,
|
||||||
|
xcresult_id: str,
|
||||||
|
errors: list[dict] | None = None,
|
||||||
|
warnings: list[dict] | None = None,
|
||||||
|
test_info: dict | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Format verbose output with error/warning details.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: Build status
|
||||||
|
error_count: Error count
|
||||||
|
warning_count: Warning count
|
||||||
|
xcresult_id: XCResult ID
|
||||||
|
errors: Optional error list
|
||||||
|
warnings: Optional warning list
|
||||||
|
test_info: Optional test results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Verbose formatted output
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Header
|
||||||
|
if test_info:
|
||||||
|
total = test_info.get("total", 0)
|
||||||
|
passed = test_info.get("passed", 0)
|
||||||
|
failed = test_info.get("failed", 0)
|
||||||
|
duration = test_info.get("duration", 0.0)
|
||||||
|
|
||||||
|
test_status = "PASS" if failed == 0 else "FAIL"
|
||||||
|
lines.append(f"Tests: {test_status}")
|
||||||
|
lines.append(f" Total: {total}")
|
||||||
|
lines.append(f" Passed: {passed}")
|
||||||
|
lines.append(f" Failed: {failed}")
|
||||||
|
lines.append(f" Duration: {duration:.1f}s")
|
||||||
|
else:
|
||||||
|
lines.append(f"Build: {status}")
|
||||||
|
|
||||||
|
lines.append(f"XCResult: {xcresult_id}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Errors
|
||||||
|
if errors and len(errors) > 0:
|
||||||
|
lines.append(OutputFormatter.format_errors(errors, limit=5))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Warnings
|
||||||
|
if warnings and len(warnings) > 0:
|
||||||
|
lines.append(OutputFormatter.format_warnings(warnings, limit=5))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
lines.append(f"Summary: {error_count} errors, {warning_count} warnings")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
"""
|
||||||
|
XCResult bundle parser.
|
||||||
|
|
||||||
|
Extracts structured data from xcresult bundles using xcresulttool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class XCResultParser:
|
||||||
|
"""
|
||||||
|
Parse xcresult bundles to extract build/test data.
|
||||||
|
|
||||||
|
Uses xcresulttool to extract structured JSON data from Apple's
|
||||||
|
xcresult bundle format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, xcresult_path: Path, stderr: str = ""):
|
||||||
|
"""
|
||||||
|
Initialize parser.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xcresult_path: Path to xcresult bundle
|
||||||
|
stderr: Optional stderr output for fallback parsing
|
||||||
|
"""
|
||||||
|
self.xcresult_path = xcresult_path
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
if xcresult_path and not xcresult_path.exists():
|
||||||
|
raise FileNotFoundError(f"XCResult bundle not found: {xcresult_path}")
|
||||||
|
|
||||||
|
def get_build_results(self) -> dict | None:
|
||||||
|
"""
|
||||||
|
Get build results as JSON.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON dict or None on error
|
||||||
|
"""
|
||||||
|
return self._run_xcresulttool(["get", "build-results"])
|
||||||
|
|
||||||
|
def get_test_results(self) -> dict | None:
|
||||||
|
"""
|
||||||
|
Get test results summary as JSON.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON dict or None on error
|
||||||
|
"""
|
||||||
|
return self._run_xcresulttool(["get", "test-results", "summary"])
|
||||||
|
|
||||||
|
def get_build_log(self) -> str | None:
|
||||||
|
"""
|
||||||
|
Get build log as plain text.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Build log string or None on error
|
||||||
|
"""
|
||||||
|
result = self._run_xcresulttool(["get", "log", "--type", "build"], parse_json=False)
|
||||||
|
return result if result else None
|
||||||
|
|
||||||
|
def count_issues(self) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Count errors and warnings from build results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (error_count, warning_count)
|
||||||
|
"""
|
||||||
|
error_count = 0
|
||||||
|
warning_count = 0
|
||||||
|
|
||||||
|
build_results = self.get_build_results()
|
||||||
|
|
||||||
|
if build_results:
|
||||||
|
try:
|
||||||
|
# Try top-level errors/warnings first (newer xcresult format)
|
||||||
|
if "errors" in build_results and isinstance(build_results.get("errors"), list):
|
||||||
|
error_count = len(build_results["errors"])
|
||||||
|
if "warnings" in build_results and isinstance(build_results.get("warnings"), list):
|
||||||
|
warning_count = len(build_results["warnings"])
|
||||||
|
|
||||||
|
# If not found, try legacy format: actions[0].buildResult.issues
|
||||||
|
if error_count == 0 and warning_count == 0:
|
||||||
|
actions = build_results.get("actions", {}).get("_values", [])
|
||||||
|
if actions:
|
||||||
|
build_result = actions[0].get("buildResult", {})
|
||||||
|
issues = build_result.get("issues", {})
|
||||||
|
|
||||||
|
# Count errors
|
||||||
|
error_summaries = issues.get("errorSummaries", {}).get("_values", [])
|
||||||
|
error_count = len(error_summaries)
|
||||||
|
|
||||||
|
# Count warnings
|
||||||
|
warning_summaries = issues.get("warningSummaries", {}).get("_values", [])
|
||||||
|
warning_count = len(warning_summaries)
|
||||||
|
|
||||||
|
except (KeyError, IndexError, TypeError) as e:
|
||||||
|
print(f"Warning: Could not parse issue counts from xcresult: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
# If no errors found in xcresult but stderr available, count stderr errors
|
||||||
|
if error_count == 0 and self.stderr:
|
||||||
|
stderr_errors = self._parse_stderr_errors()
|
||||||
|
error_count = len(stderr_errors)
|
||||||
|
|
||||||
|
return (error_count, warning_count)
|
||||||
|
|
||||||
|
def get_errors(self) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Get detailed error information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of error dicts with message, file, line info
|
||||||
|
"""
|
||||||
|
build_results = self.get_build_results()
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Try to get errors from xcresult
|
||||||
|
if build_results:
|
||||||
|
try:
|
||||||
|
# Try top-level errors first (newer xcresult format)
|
||||||
|
if "errors" in build_results and isinstance(build_results.get("errors"), list):
|
||||||
|
for error in build_results["errors"]:
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"message": error.get("message", "Unknown error"),
|
||||||
|
"type": error.get("issueType", "error"),
|
||||||
|
"location": self._extract_location_from_url(error.get("sourceURL")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# If not found, try legacy format: actions[0].buildResult.issues
|
||||||
|
if not errors:
|
||||||
|
actions = build_results.get("actions", {}).get("_values", [])
|
||||||
|
if actions:
|
||||||
|
build_result = actions[0].get("buildResult", {})
|
||||||
|
issues = build_result.get("issues", {})
|
||||||
|
error_summaries = issues.get("errorSummaries", {}).get("_values", [])
|
||||||
|
|
||||||
|
for error in error_summaries:
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"message": error.get("message", {}).get(
|
||||||
|
"_value", "Unknown error"
|
||||||
|
),
|
||||||
|
"type": error.get("issueType", {}).get("_value", "error"),
|
||||||
|
"location": self._extract_location(error),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except (KeyError, IndexError, TypeError) as e:
|
||||||
|
print(f"Warning: Could not parse errors from xcresult: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
# If no errors found in xcresult but stderr available, parse stderr
|
||||||
|
if not errors and self.stderr:
|
||||||
|
errors = self._parse_stderr_errors()
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def get_warnings(self) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Get detailed warning information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of warning dicts with message, file, line info
|
||||||
|
"""
|
||||||
|
build_results = self.get_build_results()
|
||||||
|
if not build_results:
|
||||||
|
return []
|
||||||
|
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try top-level warnings first (newer xcresult format)
|
||||||
|
if "warnings" in build_results and isinstance(build_results.get("warnings"), list):
|
||||||
|
for warning in build_results["warnings"]:
|
||||||
|
warnings.append(
|
||||||
|
{
|
||||||
|
"message": warning.get("message", "Unknown warning"),
|
||||||
|
"type": warning.get("issueType", "warning"),
|
||||||
|
"location": self._extract_location_from_url(warning.get("sourceURL")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# If not found, try legacy format: actions[0].buildResult.issues
|
||||||
|
if not warnings:
|
||||||
|
actions = build_results.get("actions", {}).get("_values", [])
|
||||||
|
if not actions:
|
||||||
|
return []
|
||||||
|
|
||||||
|
build_result = actions[0].get("buildResult", {})
|
||||||
|
issues = build_result.get("issues", {})
|
||||||
|
warning_summaries = issues.get("warningSummaries", {}).get("_values", [])
|
||||||
|
|
||||||
|
for warning in warning_summaries:
|
||||||
|
warnings.append(
|
||||||
|
{
|
||||||
|
"message": warning.get("message", {}).get("_value", "Unknown warning"),
|
||||||
|
"type": warning.get("issueType", {}).get("_value", "warning"),
|
||||||
|
"location": self._extract_location(warning),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except (KeyError, IndexError, TypeError) as e:
|
||||||
|
print(f"Warning: Could not parse warnings: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
def _extract_location(self, issue: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Extract file location from issue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
issue: Issue dict from xcresult
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Location dict with file, line, column
|
||||||
|
"""
|
||||||
|
location = {"file": None, "line": None, "column": None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc_location = issue.get("documentLocationInCreatingWorkspace", {})
|
||||||
|
location["file"] = doc_location.get("url", {}).get("_value")
|
||||||
|
location["line"] = doc_location.get("startingLineNumber", {}).get("_value")
|
||||||
|
location["column"] = doc_location.get("startingColumnNumber", {}).get("_value")
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return location
|
||||||
|
|
||||||
|
def _extract_location_from_url(self, source_url: str | None) -> dict:
|
||||||
|
"""
|
||||||
|
Extract file location from sourceURL (newer xcresult format).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_url: Source URL like "file:///path/to/file.swift#StartingLineNumber=134&..."
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Location dict with file, line, column
|
||||||
|
"""
|
||||||
|
location = {"file": None, "line": None, "column": None}
|
||||||
|
|
||||||
|
if not source_url:
|
||||||
|
return location
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Split URL and fragment
|
||||||
|
if "#" in source_url:
|
||||||
|
file_part, fragment = source_url.split("#", 1)
|
||||||
|
|
||||||
|
# Extract file path
|
||||||
|
location["file"] = file_part.replace("file://", "")
|
||||||
|
|
||||||
|
# Parse fragment parameters
|
||||||
|
params = {}
|
||||||
|
for param in fragment.split("&"):
|
||||||
|
if "=" in param:
|
||||||
|
key, value = param.split("=", 1)
|
||||||
|
params[key] = value
|
||||||
|
|
||||||
|
# Extract line and column
|
||||||
|
location["line"] = (
|
||||||
|
int(params.get("StartingLineNumber", 0)) + 1
|
||||||
|
if "StartingLineNumber" in params
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
location["column"] = (
|
||||||
|
int(params.get("StartingColumnNumber", 0)) + 1
|
||||||
|
if "StartingColumnNumber" in params
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No fragment, just file path
|
||||||
|
location["file"] = source_url.replace("file://", "")
|
||||||
|
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return location
|
||||||
|
|
||||||
|
def _run_xcresulttool(self, args: list[str], parse_json: bool = True) -> Any | None:
|
||||||
|
"""
|
||||||
|
Run xcresulttool command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: Command arguments (after 'xcresulttool')
|
||||||
|
parse_json: Whether to parse output as JSON
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON dict, plain text, or None on error
|
||||||
|
"""
|
||||||
|
if not self.xcresult_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cmd = ["xcrun", "xcresulttool"] + args + ["--path", str(self.xcresult_path)]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
if parse_json:
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error running xcresulttool: {e}", file=sys.stderr)
|
||||||
|
print(f"stderr: {e.stderr}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"Error parsing JSON from xcresulttool: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_stderr_errors(self) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Parse common errors from stderr output as fallback.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of error dicts parsed from stderr
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if not self.stderr:
|
||||||
|
return errors
|
||||||
|
|
||||||
|
# Pattern 0: Swift/Clang compilation errors (e.g., "/path/file.swift:135:59: error: message")
|
||||||
|
compilation_error_pattern = (
|
||||||
|
r"^(?P<file>[^:]+):(?P<line>\d+):(?P<column>\d+):\s*error:\s*(?P<message>.+?)$"
|
||||||
|
)
|
||||||
|
for match in re.finditer(compilation_error_pattern, self.stderr, re.MULTILINE):
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"message": match.group("message").strip(),
|
||||||
|
"type": "compilation",
|
||||||
|
"location": {
|
||||||
|
"file": match.group("file"),
|
||||||
|
"line": int(match.group("line")),
|
||||||
|
"column": int(match.group("column")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pattern 1: xcodebuild top-level errors (e.g., "xcodebuild: error: Unable to find...")
|
||||||
|
xcodebuild_error_pattern = r"xcodebuild:\s*error:\s*(?P<message>.*?)(?:\n\n|\Z)"
|
||||||
|
for match in re.finditer(xcodebuild_error_pattern, self.stderr, re.DOTALL):
|
||||||
|
message = match.group("message").strip()
|
||||||
|
# Clean up multi-line messages
|
||||||
|
message = " ".join(line.strip() for line in message.split("\n") if line.strip())
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"message": message,
|
||||||
|
"type": "build",
|
||||||
|
"location": {"file": None, "line": None, "column": None},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pattern 2: Provisioning profile errors
|
||||||
|
provisioning_pattern = r"error:.*?provisioning profile.*?(?:doesn't|does not|cannot).*?(?P<message>.*?)(?:\n|$)"
|
||||||
|
for match in re.finditer(provisioning_pattern, self.stderr, re.IGNORECASE):
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"message": f"Provisioning profile error: {match.group('message').strip()}",
|
||||||
|
"type": "provisioning",
|
||||||
|
"location": {"file": None, "line": None, "column": None},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pattern 3: Code signing errors
|
||||||
|
signing_pattern = r"error:.*?(?:code sign|signing).*?(?P<message>.*?)(?:\n|$)"
|
||||||
|
for match in re.finditer(signing_pattern, self.stderr, re.IGNORECASE):
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"message": f"Code signing error: {match.group('message').strip()}",
|
||||||
|
"type": "signing",
|
||||||
|
"location": {"file": None, "line": None, "column": None},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pattern 4: Generic compilation errors (but not if already captured)
|
||||||
|
if not errors:
|
||||||
|
generic_error_pattern = r"^(?:\*\*\s)?(?:error|❌):\s*(?P<message>.*?)(?:\n|$)"
|
||||||
|
for match in re.finditer(generic_error_pattern, self.stderr, re.MULTILINE):
|
||||||
|
message = match.group("message").strip()
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"message": message,
|
||||||
|
"type": "build",
|
||||||
|
"location": {"file": None, "line": None, "column": None},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pattern 5: Specific "No profiles" error
|
||||||
|
if "No profiles for" in self.stderr:
|
||||||
|
no_profile_pattern = r"No profiles for '(?P<bundle_id>.*?)' were found"
|
||||||
|
for match in re.finditer(no_profile_pattern, self.stderr):
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"message": f"No provisioning profile found for bundle ID '{match.group('bundle_id')}'",
|
||||||
|
"type": "provisioning",
|
||||||
|
"location": {"file": None, "line": None, "column": None},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
name: Deploy Supabase to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- 'apps/supabase/**'
|
||||||
|
- 'terraform/**'
|
||||||
|
- '.github/workflows/deploy-supabase.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-database:
|
||||||
|
name: Deploy Migrations & Functions
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
|
||||||
|
SUPABASE_PROJECT_REF: ckjcrkjpmhqhiucifukx
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Supabase CLI
|
||||||
|
uses: supabase/setup-cli@v1
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- name: Link to Production Project
|
||||||
|
run: supabase link --project-ref $SUPABASE_PROJECT_REF --workdir apps
|
||||||
|
|
||||||
|
- name: Apply Database Migrations
|
||||||
|
run: supabase db push --workdir apps
|
||||||
|
|
||||||
|
- name: Sync Edge Function Secrets
|
||||||
|
env:
|
||||||
|
# Add secrets from GitHub Secrets
|
||||||
|
REVENUECAT_WEBHOOK_SECRET: ${{ secrets.REVENUECAT_WEBHOOK_SECRET }}
|
||||||
|
REVENUECAT_API_KEY: ${{ secrets.REVENUECAT_API_KEY }}
|
||||||
|
REVENUECAT_ENTITLEMENT_ID: ${{ secrets.REVENUECAT_ENTITLEMENT_ID }}
|
||||||
|
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||||
|
ADMIN_PRIVATE_KEY_JWK: ${{ secrets.ADMIN_PRIVATE_KEY_JWK }}
|
||||||
|
ADMIN_KEYS_JSON: ${{ secrets.ADMIN_KEYS_JSON }}
|
||||||
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
run: |
|
||||||
|
# Sync secrets to Supabase (only if they're set in GitHub)
|
||||||
|
secrets_to_set=""
|
||||||
|
|
||||||
|
[ -n "$REVENUECAT_WEBHOOK_SECRET" ] && secrets_to_set="$secrets_to_set REVENUECAT_WEBHOOK_SECRET=$REVENUECAT_WEBHOOK_SECRET"
|
||||||
|
[ -n "$REVENUECAT_API_KEY" ] && secrets_to_set="$secrets_to_set REVENUECAT_API_KEY=$REVENUECAT_API_KEY"
|
||||||
|
[ -n "$REVENUECAT_ENTITLEMENT_ID" ] && secrets_to_set="$secrets_to_set REVENUECAT_ENTITLEMENT_ID=$REVENUECAT_ENTITLEMENT_ID"
|
||||||
|
[ -n "$OPENROUTER_API_KEY" ] && secrets_to_set="$secrets_to_set OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
|
||||||
|
[ -n "$ADMIN_PRIVATE_KEY_JWK" ] && secrets_to_set="$secrets_to_set ADMIN_PRIVATE_KEY_JWK=$ADMIN_PRIVATE_KEY_JWK"
|
||||||
|
[ -n "$ADMIN_KEYS_JSON" ] && secrets_to_set="$secrets_to_set ADMIN_KEYS_JSON=$ADMIN_KEYS_JSON"
|
||||||
|
[ -n "$DISCORD_WEBHOOK_URL" ] && secrets_to_set="$secrets_to_set DISCORD_WEBHOOK_URL=$DISCORD_WEBHOOK_URL"
|
||||||
|
|
||||||
|
if [ -n "$secrets_to_set" ]; then
|
||||||
|
echo "Syncing secrets to Supabase..."
|
||||||
|
supabase secrets set $secrets_to_set --project-ref $SUPABASE_PROJECT_REF
|
||||||
|
else
|
||||||
|
echo "No secrets to sync"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy Edge Functions
|
||||||
|
run: |
|
||||||
|
# Deploy all edge functions (excluding _shared directory)
|
||||||
|
for dir in apps/supabase/functions/*/; do
|
||||||
|
function_name=$(basename "$dir")
|
||||||
|
|
||||||
|
# Skip _shared directory (it's a shared module, not a function)
|
||||||
|
if [ "$function_name" = "_shared" ]; then
|
||||||
|
echo "Skipping _shared (shared module)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deploying function: $function_name"
|
||||||
|
supabase functions deploy "$function_name" --no-verify-jwt --project-ref $SUPABASE_PROJECT_REF --workdir apps
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Remove Deleted Functions
|
||||||
|
run: |
|
||||||
|
# Get list of functions deployed on Supabase (skip header lines, extract slug column)
|
||||||
|
deployed=$(supabase functions list --project-ref $SUPABASE_PROJECT_REF | grep -E '^[a-z]' | awk '{print $1}')
|
||||||
|
|
||||||
|
# Get list of functions in the repo (excluding _shared)
|
||||||
|
local_functions=$(ls -d apps/supabase/functions/*/ 2>/dev/null | xargs -I {} basename {} | grep -v '^_shared$' || true)
|
||||||
|
|
||||||
|
# Delete any deployed functions that no longer exist in the repo
|
||||||
|
for func in $deployed; do
|
||||||
|
# Skip empty or invalid function names
|
||||||
|
[ -z "$func" ] && continue
|
||||||
|
[[ "$func" =~ ^[^a-z] ]] && continue
|
||||||
|
|
||||||
|
if ! echo "$local_functions" | grep -q "^${func}$"; then
|
||||||
|
echo "Deleting removed function: $func"
|
||||||
|
supabase functions delete "$func" --project-ref $SUPABASE_PROJECT_REF
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
deploy-infrastructure:
|
||||||
|
name: Deploy Infrastructure (Terraform)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
TF_VAR_supabase_access_token: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Terraform
|
||||||
|
uses: hashicorp/setup-terraform@v3
|
||||||
|
with:
|
||||||
|
terraform_version: "1.6"
|
||||||
|
|
||||||
|
# Non-Production Environment
|
||||||
|
- name: Terraform Init (Non-Prod)
|
||||||
|
working-directory: terraform/environments/non-prod
|
||||||
|
run: terraform init
|
||||||
|
|
||||||
|
- name: Terraform Format Check
|
||||||
|
working-directory: terraform/modules/supabase
|
||||||
|
run: terraform fmt -check
|
||||||
|
|
||||||
|
- name: Terraform Validate (Non-Prod)
|
||||||
|
working-directory: terraform/environments/non-prod
|
||||||
|
run: terraform validate
|
||||||
|
|
||||||
|
- name: Terraform Plan (Non-Prod)
|
||||||
|
working-directory: terraform/environments/non-prod
|
||||||
|
run: terraform plan -out=tfplan
|
||||||
|
|
||||||
|
- name: Terraform Apply (Non-Prod)
|
||||||
|
working-directory: terraform/environments/non-prod
|
||||||
|
run: terraform apply -auto-approve tfplan
|
||||||
|
|
||||||
|
# Production Environment
|
||||||
|
- name: Terraform Init (Prod)
|
||||||
|
working-directory: terraform/environments/prod
|
||||||
|
run: terraform init
|
||||||
|
|
||||||
|
- name: Terraform Validate (Prod)
|
||||||
|
working-directory: terraform/environments/prod
|
||||||
|
run: terraform validate
|
||||||
|
|
||||||
|
- name: Terraform Plan (Prod)
|
||||||
|
working-directory: terraform/environments/prod
|
||||||
|
run: terraform plan -out=tfplan
|
||||||
|
|
||||||
|
- name: Terraform Apply (Prod)
|
||||||
|
working-directory: terraform/environments/prod
|
||||||
|
run: terraform apply -auto-approve tfplan
|
||||||
@@ -17,6 +17,9 @@ build/
|
|||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
apps/mobile/.env.prod
|
||||||
|
.secrets
|
||||||
|
|
||||||
|
|
||||||
# Supabase
|
# Supabase
|
||||||
.supabase/
|
.supabase/
|
||||||
|
|||||||
@@ -65,7 +65,38 @@ There are two Supabase projects configured via MCP servers:
|
|||||||
When using Claude Code with MCP tools:
|
When using Claude Code with MCP tools:
|
||||||
- Use `mcp__sauci-prod__*` tools for production database operations
|
- Use `mcp__sauci-prod__*` tools for production database operations
|
||||||
- Use `mcp__sauci-non-prod__*` tools for development/staging database operations
|
- Use `mcp__sauci-non-prod__*` tools for development/staging database operations
|
||||||
- **Always verify which environment you're targeting before running migrations or destructive operations**
|
- **Always verify which environment you're targeting before running destructive operations**
|
||||||
|
|
||||||
|
### Database Migrations - IMPORTANT
|
||||||
|
|
||||||
|
**NEVER use MCP tools (`apply_migration`) for database migrations.** Always use the Supabase CLI instead.
|
||||||
|
|
||||||
|
**Why:** MCP migrations are tracked in the database but do NOT create local migration files. This means:
|
||||||
|
- Migration history is not committed to git
|
||||||
|
- Other developers won't get the schema changes
|
||||||
|
- `supabase db reset` won't include MCP-applied migrations
|
||||||
|
- Changes cannot be reproduced from scratch
|
||||||
|
|
||||||
|
**Correct workflow for schema changes:**
|
||||||
|
```bash
|
||||||
|
# 1. Create migration file locally
|
||||||
|
supabase migration new <descriptive_name>
|
||||||
|
|
||||||
|
# 2. Edit the migration file in apps/supabase/migrations/
|
||||||
|
|
||||||
|
# 3. Test locally
|
||||||
|
supabase db reset
|
||||||
|
|
||||||
|
# 4. Deploy to remote (link to correct project first)
|
||||||
|
supabase link --project-ref <project_ref>
|
||||||
|
supabase db push
|
||||||
|
```
|
||||||
|
|
||||||
|
**MCP tools ARE appropriate for:**
|
||||||
|
- Reading data (`execute_sql` for SELECT queries)
|
||||||
|
- Exploring schema (`list_tables`, `list_migrations`)
|
||||||
|
- Deploying edge functions (`deploy_edge_function`)
|
||||||
|
- Generating types (`generate_typescript_types`)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -179,3 +210,87 @@ backgroundColor: 'rgba(18, 18, 18, 0.85)',
|
|||||||
The `BlurView` in `tabBarBackground` is unreliable and can fail during re-renders/transitions, causing a fully transparent tab bar. The solid rgba background acts as a fallback while still allowing the blur effect on top.
|
The `BlurView` in `tabBarBackground` is unreliable and can fail during re-renders/transitions, causing a fully transparent tab bar. The solid rgba background acts as a fallback while still allowing the blur effect on top.
|
||||||
|
|
||||||
**Location:** `apps/mobile/app/(app)/_layout.tsx` - `tabBarStyle`
|
**Location:** `apps/mobile/app/(app)/_layout.tsx` - `tabBarStyle`
|
||||||
|
|
||||||
|
## Infrastructure as Code (Terraform)
|
||||||
|
|
||||||
|
Supabase project configuration is managed via Terraform in the `terraform/` directory. This includes auth settings, OAuth providers, email templates, SMTP config, rate limits, and more.
|
||||||
|
|
||||||
|
**Directory structure:**
|
||||||
|
```
|
||||||
|
terraform/
|
||||||
|
├── modules/
|
||||||
|
│ └── supabase/ # Shared configuration module
|
||||||
|
│ ├── main.tf
|
||||||
|
│ ├── variables.tf
|
||||||
|
│ ├── outputs.tf
|
||||||
|
│ └── templates/ # Email templates
|
||||||
|
└── environments/
|
||||||
|
├── prod/ # Production (ckjcrkjpmhqhiucifukx)
|
||||||
|
└── non-prod/ # Non-production (itbzhrvlgvdmzbnhzhyx)
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to update Terraform:**
|
||||||
|
- Changing auth settings (OAuth providers, JWT expiry, MFA settings)
|
||||||
|
- Modifying email templates or subjects
|
||||||
|
- Updating rate limits or security settings
|
||||||
|
- Changing SMTP configuration
|
||||||
|
- Modifying storage or API settings
|
||||||
|
|
||||||
|
**Workflow (always test in non-prod first):**
|
||||||
|
```bash
|
||||||
|
# 1. Apply to non-prod first
|
||||||
|
cd terraform/environments/non-prod
|
||||||
|
export TF_VAR_supabase_access_token=sbp_xxxxx
|
||||||
|
terraform plan
|
||||||
|
terraform apply
|
||||||
|
|
||||||
|
# 2. Verify changes work in non-prod
|
||||||
|
|
||||||
|
# 3. Apply to prod
|
||||||
|
cd terraform/environments/prod
|
||||||
|
terraform plan
|
||||||
|
terraform apply
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** After making Terraform changes, remind the user to:
|
||||||
|
1. Commit the updated `terraform/` files
|
||||||
|
2. The GitHub Action will auto-apply on merge to master (non-prod first, then prod)
|
||||||
|
|
||||||
|
**Do NOT use Terraform for:**
|
||||||
|
- Database schema changes (use migrations)
|
||||||
|
- Edge function deployment (use CLI or MCP)
|
||||||
|
- Storage bucket creation (use migrations)
|
||||||
|
|
||||||
|
## Edge Function Secrets
|
||||||
|
|
||||||
|
Edge functions use secrets stored in Supabase. These are synced from GitHub Secrets during CI/CD deployment.
|
||||||
|
|
||||||
|
**Current secrets:**
|
||||||
|
| Secret | Used By | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `REVENUECAT_WEBHOOK_SECRET` | revenuecat-webhook | Webhook signature validation |
|
||||||
|
| `REVENUECAT_API_KEY` | sync-subscription | RevenueCat API access |
|
||||||
|
| `REVENUECAT_ENTITLEMENT_ID` | sync-subscription | Entitlement identifier |
|
||||||
|
| `OPENROUTER_API_KEY` | classify-message | AI classification API |
|
||||||
|
| `ADMIN_PRIVATE_KEY_JWK` | admin-decrypt-* | E2EE admin decryption |
|
||||||
|
| `ADMIN_KEYS_JSON` | admin-decrypt-*, migrate-e2ee | Multiple admin keys |
|
||||||
|
| `DISCORD_WEBHOOK_URL` | send-discord-notification | Discord notifications |
|
||||||
|
|
||||||
|
**Auto-provided by Supabase (don't set):**
|
||||||
|
- `SUPABASE_URL`
|
||||||
|
- `SUPABASE_ANON_KEY`
|
||||||
|
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||||
|
|
||||||
|
**When adding a new edge function that needs secrets:**
|
||||||
|
1. Add the secret to the edge function code: `Deno.env.get("SECRET_NAME")`
|
||||||
|
2. Update `.github/workflows/deploy-supabase.yml` to include the new secret in the sync step
|
||||||
|
3. Update `apps/supabase/functions/.secrets.example` with the new secret
|
||||||
|
4. **Remind the user** to add the secret to GitHub Secrets (Settings → Secrets → Actions)
|
||||||
|
5. Update this table in CLAUDE.md
|
||||||
|
|
||||||
|
**Local development:**
|
||||||
|
```bash
|
||||||
|
cp apps/supabase/functions/.secrets.example apps/supabase/functions/.secrets
|
||||||
|
# Fill in values
|
||||||
|
supabase functions serve --env-file apps/supabase/functions/.secrets
|
||||||
|
```
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@sauci/shared": "workspace:*",
|
"@sauci/shared": "file:../../packages/shared",
|
||||||
"@supabase/supabase-js": "^2.39.0",
|
"@supabase/supabase-js": "^2.39.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export interface PackFormData {
|
|||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
is_explicit: boolean;
|
is_explicit: boolean;
|
||||||
category_id: string;
|
category_id: string;
|
||||||
|
scheduled_release_at?: string | null; // Optional for backwards compatibility
|
||||||
|
skip_notification?: boolean; // If true, sets release_notified=true to skip notifications
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PackFormDialogProps {
|
export interface PackFormDialogProps {
|
||||||
@@ -200,10 +202,63 @@ export function PackFormDialog({
|
|||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={formData.is_public}
|
checked={formData.is_public}
|
||||||
onCheckedChange={(checked) => setField('is_public', checked)}
|
onCheckedChange={(checked) => {
|
||||||
|
setField('is_public', checked);
|
||||||
|
// Clear scheduled release if making public immediately
|
||||||
|
if (checked) {
|
||||||
|
setField('scheduled_release_at', null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scheduled Release - only show when not public */}
|
||||||
|
{!formData.is_public && (
|
||||||
|
<div className="space-y-4 p-3 rounded-md bg-muted/50 border border-dashed">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="scheduled_release_at">Scheduled Release</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Automatically make this pack public at a specific time. Users will be notified when it becomes available.
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
id="scheduled_release_at"
|
||||||
|
type="datetime-local"
|
||||||
|
value={formData.scheduled_release_at ? new Date(formData.scheduled_release_at).toISOString().slice(0, 16) : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setField('scheduled_release_at', value ? new Date(value).toISOString() : null);
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
{formData.scheduled_release_at && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setField('scheduled_release_at', null)}
|
||||||
|
className="mt-1"
|
||||||
|
>
|
||||||
|
Clear scheduled release
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skip Notification Toggle */}
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Skip release notification</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Release without notifying users
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={formData.skip_notification ?? false}
|
||||||
|
onCheckedChange={(checked) => setField('skip_notification', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Explicit Toggle */}
|
{/* Explicit Toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const PERMISSION_KEYS = {
|
|||||||
VIEW_RESPONSES: 'view_responses',
|
VIEW_RESPONSES: 'view_responses',
|
||||||
VIEW_ACTIVITY: 'view_activity',
|
VIEW_ACTIVITY: 'view_activity',
|
||||||
MANAGE_FEEDBACK: 'manage_feedback',
|
MANAGE_FEEDBACK: 'manage_feedback',
|
||||||
|
GIFT_PREMIUM: 'gift_premium',
|
||||||
// System permissions
|
// System permissions
|
||||||
MANAGE_CODES: 'manage_codes',
|
MANAGE_CODES: 'manage_codes',
|
||||||
MANAGE_ADMINS: 'manage_admins',
|
MANAGE_ADMINS: 'manage_admins',
|
||||||
@@ -42,6 +43,7 @@ export const PERMISSION_METADATA: Record<PermissionKey, { label: string; descrip
|
|||||||
[PERMISSION_KEYS.VIEW_RESPONSES]: { label: 'Can view responses', description: 'View user question responses', group: 'users' },
|
[PERMISSION_KEYS.VIEW_RESPONSES]: { label: 'Can view responses', description: 'View user question responses', group: 'users' },
|
||||||
[PERMISSION_KEYS.VIEW_ACTIVITY]: { label: 'Can view activity', description: 'View user activity feed (responses, matches, messages, signups)', group: 'users' },
|
[PERMISSION_KEYS.VIEW_ACTIVITY]: { label: 'Can view activity', description: 'View user activity feed (responses, matches, messages, signups)', group: 'users' },
|
||||||
[PERMISSION_KEYS.MANAGE_FEEDBACK]: { label: 'Can manage feedback', description: 'View and respond to user feedback', group: 'users' },
|
[PERMISSION_KEYS.MANAGE_FEEDBACK]: { label: 'Can manage feedback', description: 'View and respond to user feedback', group: 'users' },
|
||||||
|
[PERMISSION_KEYS.GIFT_PREMIUM]: { label: 'Can gift premium', description: 'Manually upgrade users to premium status', group: 'users' },
|
||||||
[PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' },
|
[PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' },
|
||||||
[PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' },
|
[PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' },
|
||||||
[PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' },
|
[PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface AppConfig {
|
|||||||
id: string;
|
id: string;
|
||||||
answer_gap_threshold: number;
|
answer_gap_threshold: number;
|
||||||
daily_response_limit: number;
|
daily_response_limit: number;
|
||||||
|
couple_intensity_gate_enabled: boolean | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
updated_by: string | null;
|
updated_by: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
@@ -29,6 +30,7 @@ export function AppSettingsPage() {
|
|||||||
setFormData({
|
setFormData({
|
||||||
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
||||||
daily_response_limit: config.daily_response_limit ?? 0,
|
daily_response_limit: config.daily_response_limit ?? 0,
|
||||||
|
couple_intensity_gate_enabled: config.couple_intensity_gate_enabled ?? false,
|
||||||
});
|
});
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
}
|
}
|
||||||
@@ -61,6 +63,7 @@ export function AppSettingsPage() {
|
|||||||
setFormData({
|
setFormData({
|
||||||
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
||||||
daily_response_limit: config.daily_response_limit ?? 0,
|
daily_response_limit: config.daily_response_limit ?? 0,
|
||||||
|
couple_intensity_gate_enabled: config.couple_intensity_gate_enabled ?? false,
|
||||||
});
|
});
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
}
|
}
|
||||||
@@ -157,6 +160,19 @@ export function AppSettingsPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Couple Comfort Zone Gate</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
When enabled, both recommended questions and answer-gap blocking use the lower of the two partners' comfort zones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(formData.couple_intensity_gate_enabled)}
|
||||||
|
onCheckedChange={(checked) => handleChange('couple_intensity_gate_enabled', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="threshold">Answer Gap Threshold</Label>
|
<Label htmlFor="threshold">Answer Gap Threshold</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ interface ResponseActivity {
|
|||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
pack: {
|
pack: {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
} | null;
|
} | null;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -155,7 +156,7 @@ export function UserActivityPage() {
|
|||||||
profile:profiles!responses_user_id_fkey(id, name, email),
|
profile:profiles!responses_user_id_fkey(id, name, email),
|
||||||
question:questions(
|
question:questions(
|
||||||
id, text,
|
id, text,
|
||||||
pack:question_packs(name)
|
pack:question_packs(id, name)
|
||||||
)
|
)
|
||||||
`, { count: 'exact' })
|
`, { count: 'exact' })
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
@@ -317,18 +318,22 @@ export function UserActivityPage() {
|
|||||||
const fetchSignups = useCallback(async () => {
|
const fetchSignups = useCallback(async () => {
|
||||||
setLoading(prev => ({ ...prev, signups: true }));
|
setLoading(prev => ({ ...prev, signups: true }));
|
||||||
try {
|
try {
|
||||||
const from = (signupsPage - 1) * signupsPageSize;
|
// Use RPC to get profiles with auth info (includes email from auth.users)
|
||||||
const to = from + signupsPageSize - 1;
|
const { data: allData, error } = await supabase
|
||||||
|
.rpc('get_profiles_with_auth_info');
|
||||||
const { data, error, count } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id', { count: 'exact' })
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.range(from, to);
|
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setSignups(data || []);
|
|
||||||
setSignupsTotal(count || 0);
|
// Sort by created_at descending and apply pagination
|
||||||
|
const sorted = (allData || []).sort((a: SignupActivity, b: SignupActivity) =>
|
||||||
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const from = (signupsPage - 1) * signupsPageSize;
|
||||||
|
const to = from + signupsPageSize;
|
||||||
|
|
||||||
|
setSignups(sorted.slice(from, to));
|
||||||
|
setSignupsTotal(sorted.length);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load signups:', error);
|
console.error('Failed to load signups:', error);
|
||||||
toast.error("Failed to load signups");
|
toast.error("Failed to load signups");
|
||||||
@@ -502,14 +507,31 @@ export function UserActivityPage() {
|
|||||||
<TableRow key={item.id}>
|
<TableRow key={item.id}>
|
||||||
<TableCell>{renderUserLink(item.profile)}</TableCell>
|
<TableCell>{renderUserLink(item.profile)}</TableCell>
|
||||||
<TableCell className="max-w-[300px]">
|
<TableCell className="max-w-[300px]">
|
||||||
|
{item.question?.pack?.id ? (
|
||||||
|
<Link
|
||||||
|
to={`/packs/${item.question.pack.id}/questions`}
|
||||||
|
className="truncate block text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{item.question?.text || 'Unknown question'}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
<span className="truncate block">
|
<span className="truncate block">
|
||||||
{item.question?.text || 'Unknown question'}
|
{item.question?.text || 'Unknown question'}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
{item.question?.pack?.id ? (
|
||||||
|
<Link to={`/packs/${item.question.pack.id}/questions`}>
|
||||||
|
<Badge variant="outline" className="hover:bg-primary/10 cursor-pointer">
|
||||||
|
{item.question.pack.name}
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{item.question?.pack?.name || 'Unknown'}
|
{item.question?.pack?.name || 'Unknown'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{renderAnswerBadge(item.answer)}</TableCell>
|
<TableCell>{renderAnswerBadge(item.answer)}</TableCell>
|
||||||
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { PaginationControls } from '@/components/ui/pagination';
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown } from 'lucide-react';
|
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown, Clock, BellOff } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||||
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
|
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
|
||||||
@@ -41,6 +41,8 @@ interface QuestionPack {
|
|||||||
sort_order: number | null;
|
sort_order: number | null;
|
||||||
category_id: string | null;
|
category_id: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
|
scheduled_release_at?: string | null; // Optional for backwards compatibility
|
||||||
|
release_notified?: boolean; // True if notifications have been sent or should be skipped
|
||||||
question_count?: number;
|
question_count?: number;
|
||||||
topics?: Topic[];
|
topics?: Topic[];
|
||||||
}
|
}
|
||||||
@@ -76,6 +78,8 @@ export function PacksPage() {
|
|||||||
is_public: false,
|
is_public: false,
|
||||||
is_explicit: false,
|
is_explicit: false,
|
||||||
category_id: categoryId ?? '',
|
category_id: categoryId ?? '',
|
||||||
|
scheduled_release_at: null,
|
||||||
|
skip_notification: false,
|
||||||
},
|
},
|
||||||
(pack) => ({
|
(pack) => ({
|
||||||
name: pack.name,
|
name: pack.name,
|
||||||
@@ -85,6 +89,8 @@ export function PacksPage() {
|
|||||||
is_public: pack.is_public,
|
is_public: pack.is_public,
|
||||||
is_explicit: pack.is_explicit,
|
is_explicit: pack.is_explicit,
|
||||||
category_id: pack.category_id || '',
|
category_id: pack.category_id || '',
|
||||||
|
scheduled_release_at: pack.scheduled_release_at,
|
||||||
|
skip_notification: false, // Always start unchecked when editing
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -223,6 +229,13 @@ export function PacksPage() {
|
|||||||
let packId: string;
|
let packId: string;
|
||||||
|
|
||||||
if (form.editingItem) {
|
if (form.editingItem) {
|
||||||
|
// Determine release_notified value
|
||||||
|
// If skip_notification is checked, set release_notified=true to prevent notifications
|
||||||
|
// Otherwise, reset to false when scheduling a new release
|
||||||
|
const releaseNotifiedValue = form.formData.skip_notification
|
||||||
|
? true
|
||||||
|
: (form.formData.scheduled_release_at ? false : undefined);
|
||||||
|
|
||||||
const { error } = await auditedSupabase.update('question_packs', form.editingItem.id, {
|
const { error } = await auditedSupabase.update('question_packs', form.editingItem.id, {
|
||||||
name: form.formData.name,
|
name: form.formData.name,
|
||||||
description: form.formData.description || null,
|
description: form.formData.description || null,
|
||||||
@@ -231,6 +244,12 @@ export function PacksPage() {
|
|||||||
is_public: form.formData.is_public,
|
is_public: form.formData.is_public,
|
||||||
is_explicit: form.formData.is_explicit,
|
is_explicit: form.formData.is_explicit,
|
||||||
category_id: form.formData.category_id || null,
|
category_id: form.formData.category_id || null,
|
||||||
|
// Only include scheduled_release_at if the field exists in form data (backwards compatible)
|
||||||
|
...('scheduled_release_at' in form.formData && {
|
||||||
|
scheduled_release_at: form.formData.scheduled_release_at,
|
||||||
|
// Set release_notified based on skip_notification toggle
|
||||||
|
...(releaseNotifiedValue !== undefined && { release_notified: releaseNotifiedValue }),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
packId = form.editingItem.id;
|
packId = form.editingItem.id;
|
||||||
@@ -246,6 +265,12 @@ export function PacksPage() {
|
|||||||
is_explicit: form.formData.is_explicit,
|
is_explicit: form.formData.is_explicit,
|
||||||
category_id: form.formData.category_id || null,
|
category_id: form.formData.category_id || null,
|
||||||
sort_order: totalCount,
|
sort_order: totalCount,
|
||||||
|
// Only include scheduled_release_at if the field exists in form data (backwards compatible)
|
||||||
|
...('scheduled_release_at' in form.formData && {
|
||||||
|
scheduled_release_at: form.formData.scheduled_release_at,
|
||||||
|
// If skip_notification is checked, set release_notified=true
|
||||||
|
...(form.formData.skip_notification && { release_notified: true }),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.select('id')
|
.select('id')
|
||||||
.single();
|
.single();
|
||||||
@@ -305,6 +330,8 @@ export function PacksPage() {
|
|||||||
is_public: false,
|
is_public: false,
|
||||||
is_explicit: false,
|
is_explicit: false,
|
||||||
category_id: categoryId ?? '',
|
category_id: categoryId ?? '',
|
||||||
|
scheduled_release_at: null,
|
||||||
|
skip_notification: false,
|
||||||
});
|
});
|
||||||
setAiDialogOpen(false);
|
setAiDialogOpen(false);
|
||||||
setSelectedTopicIds(new Set());
|
setSelectedTopicIds(new Set());
|
||||||
@@ -497,6 +524,18 @@ export function PacksPage() {
|
|||||||
<Eye className="h-3 w-3 mr-1" />
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
Published
|
Published
|
||||||
</Badge>
|
</Badge>
|
||||||
|
) : pack.scheduled_release_at ? (
|
||||||
|
<>
|
||||||
|
<Badge variant="outline" className="border-blue-500 text-blue-400" title={`Releases: ${new Date(pack.scheduled_release_at).toLocaleString()}`}>
|
||||||
|
<Clock className="h-3 w-3 mr-1" />
|
||||||
|
Scheduled
|
||||||
|
</Badge>
|
||||||
|
{pack.release_notified && (
|
||||||
|
<Badge variant="outline" className="border-gray-500 text-gray-400" title="Notifications skipped">
|
||||||
|
<BellOff className="h-3 w-3" />
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
<EyeOff className="h-3 w-3 mr-1" />
|
<EyeOff className="h-3 w-3 mr-1" />
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { toast } from 'sonner';
|
|||||||
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||||
import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog';
|
import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog';
|
||||||
|
import { IconPreview } from '@/components/ui/icon-picker';
|
||||||
|
|
||||||
interface QuestionPack {
|
interface QuestionPack {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -525,7 +526,7 @@ export function QuestionsPage() {
|
|||||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
{pack && (
|
{pack && (
|
||||||
<>
|
<>
|
||||||
<span className="text-3xl">{pack.icon}</span>
|
<IconPreview value={pack.icon} fallback="heart-outline" className="text-3xl" />
|
||||||
{pack.name}
|
{pack.name}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { supabase, supabaseConfig } from '@/config';
|
import { supabase, supabaseConfig } from '@/config';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||||
@@ -38,14 +37,17 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle } from 'lucide-react';
|
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Heart, Sparkles, Flame, Trophy, Calendar } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { IconPreview } from '@/components/ui/icon-picker';
|
||||||
|
|
||||||
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
||||||
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
||||||
|
|
||||||
// ... (interfaces and constants are the same)
|
// ... (interfaces and constants are the same)
|
||||||
|
|
||||||
|
type IntensityLevel = 1 | 2 | 3 | 4 | 5;
|
||||||
|
|
||||||
interface Profile {
|
interface Profile {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -58,6 +60,7 @@ interface Profile {
|
|||||||
usage_reason: UsageReason | null;
|
usage_reason: UsageReason | null;
|
||||||
show_explicit_content: boolean | null;
|
show_explicit_content: boolean | null;
|
||||||
onboarding_completed: boolean | null;
|
onboarding_completed: boolean | null;
|
||||||
|
max_intensity: IntensityLevel | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
|
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
|
||||||
@@ -75,6 +78,14 @@ const genderLabels: Record<Gender, string> = {
|
|||||||
'prefer-not-to-say': 'Prefer not to say',
|
'prefer-not-to-say': 'Prefer not to say',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const intensityLabels: Record<IntensityLevel, { label: string; emoji: string }> = {
|
||||||
|
1: { label: 'Gentle', emoji: '💭' },
|
||||||
|
2: { label: 'Warm', emoji: '💕' },
|
||||||
|
3: { label: 'Playful', emoji: '😏' },
|
||||||
|
4: { label: 'Steamy', emoji: '🔥' },
|
||||||
|
5: { label: 'Intense', emoji: '🌶️' },
|
||||||
|
};
|
||||||
|
|
||||||
interface Partner {
|
interface Partner {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -88,6 +99,7 @@ interface Response {
|
|||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
pack: {
|
pack: {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -105,6 +117,41 @@ interface Match {
|
|||||||
message_count?: number;
|
message_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EnabledPack {
|
||||||
|
pack_id: string;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: string | null;
|
||||||
|
pack: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
is_premium: boolean;
|
||||||
|
is_explicit: boolean;
|
||||||
|
category: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeatureInterest {
|
||||||
|
id: string;
|
||||||
|
feature_name: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoupleStreak {
|
||||||
|
id: string;
|
||||||
|
couple_id: string;
|
||||||
|
current_streak: number;
|
||||||
|
longest_streak: number;
|
||||||
|
last_active_date: string | null;
|
||||||
|
last_milestone_celebrated: number | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
const answerIcons = {
|
const answerIcons = {
|
||||||
yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
|
yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
|
||||||
no: <ThumbsDown className="h-4 w-4 text-red-500" />,
|
no: <ThumbsDown className="h-4 w-4 text-red-500" />,
|
||||||
@@ -238,6 +285,9 @@ export function UserDetailPage() {
|
|||||||
const [responses, setResponses] = useState<Response[]>([]);
|
const [responses, setResponses] = useState<Response[]>([]);
|
||||||
const [matches, setMatches] = useState<Match[]>([]);
|
const [matches, setMatches] = useState<Match[]>([]);
|
||||||
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
|
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
|
||||||
|
const [enabledPacks, setEnabledPacks] = useState<EnabledPack[]>([]);
|
||||||
|
const [featureInterests, setFeatureInterests] = useState<FeatureInterest[]>([]);
|
||||||
|
const [streak, setStreak] = useState<CoupleStreak | null>(null);
|
||||||
const [responsesPage, setResponsesPage] = useState(1);
|
const [responsesPage, setResponsesPage] = useState(1);
|
||||||
const [responsesPageSize, setResponsesPageSize] = useState(10);
|
const [responsesPageSize, setResponsesPageSize] = useState(10);
|
||||||
const [responsesTotal, setResponsesTotal] = useState(0);
|
const [responsesTotal, setResponsesTotal] = useState(0);
|
||||||
@@ -282,17 +332,13 @@ export function UserDetailPage() {
|
|||||||
}
|
}
|
||||||
expiresAt = date.toISOString();
|
expiresAt = date.toISOString();
|
||||||
}
|
}
|
||||||
const { error } = await auditedSupabase.insert('subscriptions', {
|
// Use RPC function which handles permissions internally
|
||||||
user_id: userId,
|
const { error } = await supabase.rpc('admin_gift_premium', {
|
||||||
revenuecat_app_user_id: 'admin_grant',
|
target_user_id: userId,
|
||||||
product_id: 'admin_premium',
|
expires_at_param: expiresAt,
|
||||||
status: 'active',
|
|
||||||
store: 'manual',
|
|
||||||
purchased_at: new Date().toISOString(),
|
|
||||||
expires_at: expiresAt,
|
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
const { data: updatedProfile } = await supabase.from('profiles').select('*').eq('id', userId).single();
|
const { data: updatedProfile } = await supabase.rpc('get_profile_with_auth_info', { user_id: userId }).single<Profile>();
|
||||||
if (updatedProfile) setProfile(updatedProfile);
|
if (updatedProfile) setProfile(updatedProfile);
|
||||||
setUpgradeOpen(false);
|
setUpgradeOpen(false);
|
||||||
alert('User upgraded to premium successfully!');
|
alert('User upgraded to premium successfully!');
|
||||||
@@ -308,14 +354,31 @@ export function UserDetailPage() {
|
|||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data: profileData } = await supabase.from('profiles').select('*').eq('id', userId).single();
|
const { data: profileData } = await supabase.rpc('get_profile_with_auth_info', { user_id: userId }).single<Profile>();
|
||||||
setProfile(profileData);
|
setProfile(profileData);
|
||||||
|
|
||||||
|
// Fetch feature interests for the user
|
||||||
|
const { data: featureInterestsData } = await supabase
|
||||||
|
.from('feature_interests')
|
||||||
|
.select('id, feature_name, created_at')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
setFeatureInterests((featureInterestsData || []) as FeatureInterest[]);
|
||||||
|
|
||||||
if (profileData?.couple_id) {
|
if (profileData?.couple_id) {
|
||||||
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
||||||
setPartner(partnerData);
|
setPartner(partnerData);
|
||||||
|
|
||||||
|
// Fetch streak data
|
||||||
|
const { data: streakData } = await supabase
|
||||||
|
.from('couple_streaks')
|
||||||
|
.select('*')
|
||||||
|
.eq('couple_id', profileData.couple_id)
|
||||||
|
.maybeSingle();
|
||||||
|
setStreak(streakData);
|
||||||
} else {
|
} else {
|
||||||
setPartner(null);
|
setPartner(null);
|
||||||
|
setStreak(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
||||||
@@ -323,7 +386,7 @@ export function UserDetailPage() {
|
|||||||
|
|
||||||
const { data: responseData, count: responsesCount, error: responsesError } = await supabase
|
const { data: responseData, count: responsesCount, error: responsesError } = await supabase
|
||||||
.from('responses')
|
.from('responses')
|
||||||
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))', { count: 'exact' })
|
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(id, name))', { count: 'exact' })
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.range(responsesFrom, responsesTo);
|
.range(responsesFrom, responsesTo);
|
||||||
@@ -388,11 +451,39 @@ export function UserDetailPage() {
|
|||||||
setMediaMessages([]);
|
setMediaMessages([]);
|
||||||
setMediaTotal(0);
|
setMediaTotal(0);
|
||||||
}
|
}
|
||||||
|
// Fetch enabled packs for the couple
|
||||||
|
const { data: enabledPacksData, error: packsError } = await supabase
|
||||||
|
.from('couple_packs')
|
||||||
|
.select(`
|
||||||
|
pack_id,
|
||||||
|
enabled,
|
||||||
|
created_at,
|
||||||
|
pack:question_packs(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
is_premium,
|
||||||
|
is_explicit,
|
||||||
|
category:categories(id, name)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('couple_id', profileData.couple_id)
|
||||||
|
.eq('enabled', true)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (packsError) {
|
||||||
|
console.error("Error fetching enabled packs:", packsError);
|
||||||
|
setEnabledPacks([]);
|
||||||
|
} else {
|
||||||
|
setEnabledPacks((enabledPacksData || []) as unknown as EnabledPack[]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setMatches([]);
|
setMatches([]);
|
||||||
setMatchesTotal(0);
|
setMatchesTotal(0);
|
||||||
setMediaMessages([]);
|
setMediaMessages([]);
|
||||||
setMediaTotal(0);
|
setMediaTotal(0);
|
||||||
|
setEnabledPacks([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load user data:', error);
|
console.error('Failed to load user data:', error);
|
||||||
@@ -446,30 +537,117 @@ export function UserDetailPage() {
|
|||||||
<h1 className="text-2xl font-bold">{profile.name || 'Unnamed User'}</h1>
|
<h1 className="text-2xl font-bold">{profile.name || 'Unnamed User'}</h1>
|
||||||
<RealtimeStatusIndicator status={profileStatus === 'SUBSCRIBED' && responsesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profileStatus} showLabel />
|
<RealtimeStatusIndicator status={profileStatus === 'SUBSCRIBED' && responsesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profileStatus} showLabel />
|
||||||
{profile.is_premium && <Badge className="bg-amber-500"><Crown className="h-3 w-3 mr-1" />Premium</Badge>}
|
{profile.is_premium && <Badge className="bg-amber-500"><Crown className="h-3 w-3 mr-1" />Premium</Badge>}
|
||||||
{!profile.is_premium && <Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}><DialogTrigger asChild><Button size="sm" variant="outline" className="ml-2 gap-1 h-7"><Gift className="h-3.5 w-3.5" />Gift Premium</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Gift Premium Access</DialogTitle><DialogDescription>Manually upgrade this user to premium status.</DialogDescription></DialogHeader><div className="grid gap-4 py-4"><div className="grid gap-2"><Label htmlFor="expiry-type">Duration</Label><Select value={expiryType} onValueChange={setExpiryType}><SelectTrigger id="expiry-type"><SelectValue placeholder="Select duration" /></SelectTrigger><SelectContent><SelectItem value="forever">Lifetime (No Expiry)</SelectItem><SelectItem value="1_month">1 Month</SelectItem><SelectItem value="1_year">1 Year</SelectItem><SelectItem value="custom">Custom Date</SelectItem></SelectContent></Select></div>{expiryType === 'custom' && (<div className="grid gap-2"><Label htmlFor="custom-date">Expiry Date</Label><Input id="custom-date" type="datetime-local" value={customDate} onChange={(e) => setCustomDate(e.target.value)} /></div>)}</div><DialogFooter><Button variant="outline" onClick={() => setUpgradeOpen(false)}>Cancel</Button><Button onClick={handleUpgrade} disabled={upgrading}>{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}</Button></DialogFooter></DialogContent></Dialog>}
|
{!profile.is_premium && hasPermission(PERMISSION_KEYS.GIFT_PREMIUM) && <Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}><DialogTrigger asChild><Button size="sm" variant="outline" className="ml-2 gap-1 h-7"><Gift className="h-3.5 w-3.5" />Gift Premium</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Gift Premium Access</DialogTitle><DialogDescription>Manually upgrade this user to premium status.</DialogDescription></DialogHeader><div className="grid gap-4 py-4"><div className="grid gap-2"><Label htmlFor="expiry-type">Duration</Label><Select value={expiryType} onValueChange={setExpiryType}><SelectTrigger id="expiry-type"><SelectValue placeholder="Select duration" /></SelectTrigger><SelectContent><SelectItem value="forever">Lifetime (No Expiry)</SelectItem><SelectItem value="1_month">1 Month</SelectItem><SelectItem value="1_year">1 Year</SelectItem><SelectItem value="custom">Custom Date</SelectItem></SelectContent></Select></div>{expiryType === 'custom' && (<div className="grid gap-2"><Label htmlFor="custom-date">Expiry Date</Label><Input id="custom-date" type="datetime-local" value={customDate} onChange={(e) => setCustomDate(e.target.value)} /></div>)}</div><DialogFooter><Button variant="outline" onClick={() => setUpgradeOpen(false)}>Cancel</Button><Button onClick={handleUpgrade} disabled={upgrading}>{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}</Button></DialogFooter></DialogContent></Dialog>}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground mb-4 flex items-center gap-2">
|
||||||
|
{profile.email ? (
|
||||||
|
<span>{profile.email}</span>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">Guest</Badge>
|
||||||
|
)}
|
||||||
|
{!profile.email && (
|
||||||
|
<span className="text-xs">Not recoverable unless saved in-app</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mb-4">{profile.email || 'No email'}</p>
|
|
||||||
<div className="flex gap-6 text-sm">
|
<div className="flex gap-6 text-sm">
|
||||||
<div><span className="text-muted-foreground">Joined: </span><span>{profile.created_at ? format(new Date(profile.created_at), 'MMMM d, yyyy') : '—'}</span></div>
|
<div><span className="text-muted-foreground">Joined: </span><span>{profile.created_at ? format(new Date(profile.created_at), 'MMMM d, yyyy') : '—'}</span></div>
|
||||||
{partner ? <div className="flex items-center gap-1"><Users className="h-4 w-4 text-muted-foreground" /><span className="text-muted-foreground">Paired with: </span><Link to={`/users/${partner.id}`} className="text-primary hover:underline">{partner.name || 'Unnamed Partner'}</Link></div> : <div className="text-muted-foreground">Not paired</div>}
|
{partner ? <div className="flex items-center gap-1"><Users className="h-4 w-4 text-muted-foreground" /><span className="text-muted-foreground">Paired with: </span><Link to={`/users/${partner.id}`} className="text-primary hover:underline">{partner.name || 'Unnamed Partner'}</Link></div> : profile.couple_id ? <div className="text-amber-600 dark:text-amber-400 flex items-center gap-1"><Users className="h-4 w-4" />Waiting for partner</div> : <div className="text-muted-foreground">Not paired</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div></CardContent></Card>
|
</div></CardContent></Card>
|
||||||
{/* Onboarding Details */}
|
{/* Onboarding Details */}
|
||||||
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
|
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
|
||||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
|
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
|
||||||
|
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-pink-100 dark:bg-pink-900"><Heart className="h-4 w-4 text-pink-600 dark:text-pink-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Comfort Zone</p><p className="font-medium">{profile.max_intensity ? `${intensityLabels[profile.max_intensity].emoji} ${intensityLabels[profile.max_intensity].label} (${profile.max_intensity})` : 'Not set'}</p></div></div>
|
||||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>{profile.show_explicit_content ? <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" /> : <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Explicit Content</p><p className="font-medium">{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}</p></div></div>
|
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>{profile.show_explicit_content ? <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" /> : <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Explicit Content</p><p className="font-medium">{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}</p></div></div>
|
||||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
|
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
|
||||||
</div></CardContent></Card>
|
</div></CardContent></Card>
|
||||||
|
{/* Streak Info */}
|
||||||
|
{profile.couple_id && streak && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Flame className="h-5 w-5 text-orange-500" />
|
||||||
|
Couple Streak
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Activity streak for this couple</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-orange-100 dark:bg-orange-900">
|
||||||
|
<Flame className="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Current Streak</p>
|
||||||
|
<p className="font-medium text-xl">{streak.current_streak} day{streak.current_streak !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-yellow-100 dark:bg-yellow-900">
|
||||||
|
<Trophy className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Longest Streak</p>
|
||||||
|
<p className="font-medium text-xl">{streak.longest_streak} day{streak.longest_streak !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||||
|
<Calendar className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Last Active</p>
|
||||||
|
<p className="font-medium">{streak.last_active_date ? format(new Date(streak.last_active_date), 'MMM d, yyyy') : 'Never'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900">
|
||||||
|
<Sparkles className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Last Milestone</p>
|
||||||
|
<p className="font-medium">{streak.last_milestone_celebrated ? `${streak.last_milestone_celebrated} days` : 'None yet'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{/* Feature Opt-Ins */}
|
||||||
|
{featureInterests.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
Feature Opt-Ins
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Features the user has expressed interest in</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{featureInterests.map((interest) => (
|
||||||
|
<Badge key={interest.id} variant="secondary" className="capitalize">
|
||||||
|
{interest.feature_name}
|
||||||
|
<span className="ml-1 text-xs text-muted-foreground">
|
||||||
|
({format(new Date(interest.created_at), 'MMM d')})
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
{canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
|
{canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
|
||||||
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
|
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
|
||||||
{canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
|
{canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
|
||||||
|
<TabsTrigger value="packs" disabled={!profile.couple_id}>Enabled Packs ({enabledPacks.length})</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
|
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
|
||||||
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
|
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
|
||||||
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
|
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
|
||||||
{message.media_type === 'video' ? (
|
{message.media_type === 'video' ? (
|
||||||
@@ -478,6 +656,59 @@ export function UserDetailPage() {
|
|||||||
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
|
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
|
||||||
)}
|
)}
|
||||||
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>}
|
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>}
|
||||||
|
<TabsContent value="packs" className="mt-4">
|
||||||
|
{enabledPacks.length === 0 ? (
|
||||||
|
<Card className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground">No packs enabled</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{enabledPacks.map((ep) => (
|
||||||
|
<Link key={ep.pack_id} to={`/packs/${ep.pack.id}/questions`}>
|
||||||
|
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconPreview value={ep.pack.icon} fallback="heart-outline" className="text-2xl" />
|
||||||
|
<CardTitle className="text-base">{ep.pack.name}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{ep.pack.is_premium && (
|
||||||
|
<Badge className="bg-amber-500 text-xs">
|
||||||
|
<Crown className="h-3 w-3 mr-1" />
|
||||||
|
Premium
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{ep.pack.is_explicit && (
|
||||||
|
<Badge variant="destructive" className="text-xs">
|
||||||
|
18+
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ep.pack.description && (
|
||||||
|
<CardDescription className="line-clamp-2 mt-2">
|
||||||
|
{ep.pack.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{ep.pack.category?.name || 'Uncategorized'}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Enabled {ep.created_at ? format(new Date(ep.created_at), 'MMM d, yyyy') : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Search, Crown, Users, ChevronRight, HardDrive, MailWarning } from 'lucide-react';
|
import { Search, Crown, Users, ChevronRight, HardDrive, MailWarning, Hourglass } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
interface Profile {
|
interface Profile {
|
||||||
@@ -32,6 +32,7 @@ interface Profile {
|
|||||||
email_confirmed_at: string | null;
|
email_confirmed_at: string | null;
|
||||||
storage_bytes?: number;
|
storage_bytes?: number;
|
||||||
partner_is_premium?: boolean;
|
partner_is_premium?: boolean;
|
||||||
|
partner_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
@@ -87,11 +88,13 @@ export function UsersPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Merge storage data and partner premium status with profiles
|
// Merge storage data, partner premium status, and partner count with profiles
|
||||||
const profilesWithExtras = profilesData.map(profile => {
|
const profilesWithExtras = profilesData.map(profile => {
|
||||||
// Check if partner has premium
|
// Check if partner has premium and count partners in couple
|
||||||
let partnerIsPremium = false;
|
let partnerIsPremium = false;
|
||||||
|
let partnerCount = 0;
|
||||||
if (profile.couple_id && couplePartners[profile.couple_id]) {
|
if (profile.couple_id && couplePartners[profile.couple_id]) {
|
||||||
|
partnerCount = couplePartners[profile.couple_id].length;
|
||||||
const partner = couplePartners[profile.couple_id].find(p => p.id !== profile.id);
|
const partner = couplePartners[profile.couple_id].find(p => p.id !== profile.id);
|
||||||
partnerIsPremium = partner?.is_premium || false;
|
partnerIsPremium = partner?.is_premium || false;
|
||||||
}
|
}
|
||||||
@@ -99,6 +102,7 @@ export function UsersPage() {
|
|||||||
...profile,
|
...profile,
|
||||||
storage_bytes: storageMap[profile.id] || 0,
|
storage_bytes: storageMap[profile.id] || 0,
|
||||||
partner_is_premium: partnerIsPremium,
|
partner_is_premium: partnerIsPremium,
|
||||||
|
partner_count: partnerCount,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,7 +132,10 @@ export function UsersPage() {
|
|||||||
}, []),
|
}, []),
|
||||||
insertToast: {
|
insertToast: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
message: (payload) => `New user registered: ${payload.name || payload.email || 'Unknown'}`,
|
message: (payload) => {
|
||||||
|
const label = payload.name || payload.email || 'Guest';
|
||||||
|
return payload.email ? `New user registered: ${label}` : `New guest user: ${label}`;
|
||||||
|
},
|
||||||
type: 'success',
|
type: 'success',
|
||||||
},
|
},
|
||||||
debounceMs: 1000, // Debounce to avoid excessive refetches
|
debounceMs: 1000, // Debounce to avoid excessive refetches
|
||||||
@@ -191,7 +198,15 @@ export function UsersPage() {
|
|||||||
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{profiles.length} registered user{profiles.length !== 1 ? 's' : ''}
|
{profiles.length} account{profiles.length !== 1 ? 's' : ''}
|
||||||
|
{(() => {
|
||||||
|
const guestCount = profiles.filter(p => !p.email).length;
|
||||||
|
const savedCount = profiles.length - guestCount;
|
||||||
|
if (guestCount === 0) return null;
|
||||||
|
return (
|
||||||
|
<span> • {savedCount} saved • {guestCount} guest</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative w-full sm:w-64">
|
<div className="relative w-full sm:w-64">
|
||||||
@@ -245,12 +260,20 @@ export function UsersPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell className="text-muted-foreground">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{profile.email || '—'}
|
{profile.email ? (
|
||||||
{profile.email && !profile.email_confirmed_at && (
|
<>
|
||||||
|
{profile.email}
|
||||||
|
{!profile.email_confirmed_at && (
|
||||||
<span title="Email not verified">
|
<span title="Email not verified">
|
||||||
<MailWarning className="h-3.5 w-3.5 text-amber-500" />
|
<MailWarning className="h-3.5 w-3.5 text-amber-500" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">
|
||||||
|
Guest
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -272,10 +295,17 @@ export function UsersPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{profile.couple_id ? (
|
{profile.couple_id ? (
|
||||||
<Badge variant="secondary">
|
profile.partner_count && profile.partner_count >= 2 ? (
|
||||||
|
<Badge variant="secondary" className="bg-green-100 text-green-700 hover:bg-green-200">
|
||||||
<Users className="h-3 w-3 mr-1" />
|
<Users className="h-3 w-3 mr-1" />
|
||||||
Paired
|
Paired
|
||||||
</Badge>
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-amber-100 text-amber-700 hover:bg-amber-200">
|
||||||
|
<Hourglass className="h-3 w-3 mr-1" />
|
||||||
|
Pairing
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-sm">—</span>
|
<span className="text-muted-foreground text-sm">—</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['react-icons/io5'],
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'react-icons': ['react-icons/io5'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
port: 3001,
|
port: 3001,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: web-design-guidelines
|
||||||
|
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
|
||||||
|
metadata:
|
||||||
|
author: vercel
|
||||||
|
version: "1.0.0"
|
||||||
|
argument-hint: <file-or-pattern>
|
||||||
|
---
|
||||||
|
|
||||||
|
# Web Interface Guidelines
|
||||||
|
|
||||||
|
Review files for compliance with Web Interface Guidelines.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. Fetch the latest guidelines from the source URL below
|
||||||
|
2. Read the specified files (or prompt user for files/pattern)
|
||||||
|
3. Check against all rules in the fetched guidelines
|
||||||
|
4. Output findings in the terse `file:line` format
|
||||||
|
|
||||||
|
## Guidelines Source
|
||||||
|
|
||||||
|
Fetch fresh guidelines before each review:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
When a user provides a file or pattern argument:
|
||||||
|
1. Fetch guidelines from the source URL above
|
||||||
|
2. Read the specified files
|
||||||
|
3. Apply all rules from the fetched guidelines
|
||||||
|
4. Output findings using the format specified in the guidelines
|
||||||
|
|
||||||
|
If no files specified, ask the user which files to review.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: web-design-guidelines
|
||||||
|
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
|
||||||
|
metadata:
|
||||||
|
author: vercel
|
||||||
|
version: "1.0.0"
|
||||||
|
argument-hint: <file-or-pattern>
|
||||||
|
---
|
||||||
|
|
||||||
|
# Web Interface Guidelines
|
||||||
|
|
||||||
|
Review files for compliance with Web Interface Guidelines.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. Fetch the latest guidelines from the source URL below
|
||||||
|
2. Read the specified files (or prompt user for files/pattern)
|
||||||
|
3. Check against all rules in the fetched guidelines
|
||||||
|
4. Output findings in the terse `file:line` format
|
||||||
|
|
||||||
|
## Guidelines Source
|
||||||
|
|
||||||
|
Fetch fresh guidelines before each review:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
When a user provides a file or pattern argument:
|
||||||
|
1. Fetch guidelines from the source URL above
|
||||||
|
2. Read the specified files
|
||||||
|
3. Apply all rules from the fetched guidelines
|
||||||
|
4. Output findings using the format specified in the guidelines
|
||||||
|
|
||||||
|
If no files specified, ask the user which files to review.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: web-design-guidelines
|
||||||
|
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
|
||||||
|
metadata:
|
||||||
|
author: vercel
|
||||||
|
version: "1.0.0"
|
||||||
|
argument-hint: <file-or-pattern>
|
||||||
|
---
|
||||||
|
|
||||||
|
# Web Interface Guidelines
|
||||||
|
|
||||||
|
Review files for compliance with Web Interface Guidelines.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. Fetch the latest guidelines from the source URL below
|
||||||
|
2. Read the specified files (or prompt user for files/pattern)
|
||||||
|
3. Check against all rules in the fetched guidelines
|
||||||
|
4. Output findings in the terse `file:line` format
|
||||||
|
|
||||||
|
## Guidelines Source
|
||||||
|
|
||||||
|
Fetch fresh guidelines before each review:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
When a user provides a file or pattern argument:
|
||||||
|
1. Fetch guidelines from the source URL above
|
||||||
|
2. Read the specified files
|
||||||
|
3. Apply all rules from the fetched guidelines
|
||||||
|
4. Output findings using the format specified in the guidelines
|
||||||
|
|
||||||
|
If no files specified, ask the user which files to review.
|
||||||
@@ -6,3 +6,6 @@ expo-env.d.ts
|
|||||||
# @end expo-cli
|
# @end expo-cli
|
||||||
*.aab
|
*.aab
|
||||||
*.ipa
|
*.ipa
|
||||||
|
*.apk
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<key>IS_ADS_ENABLED</key>
|
<key>IS_ADS_ENABLED</key>
|
||||||
<false></false>
|
<false></false>
|
||||||
<key>IS_ANALYTICS_ENABLED</key>
|
<key>IS_ANALYTICS_ENABLED</key>
|
||||||
<false></false>
|
<true></true>
|
||||||
<key>IS_APPINVITE_ENABLED</key>
|
<key>IS_APPINVITE_ENABLED</key>
|
||||||
<true></true>
|
<true></true>
|
||||||
<key>IS_GCM_ENABLED</key>
|
<key>IS_GCM_ENABLED</key>
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ android {
|
|||||||
applicationId 'com.sauci.app'
|
applicationId 'com.sauci.app'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 21
|
versionCode 34
|
||||||
versionName "1.0.0"
|
versionName "1.0.2"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
|
|||||||
@@ -13,6 +13,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oauth_client": [
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "764866133492-03k7q0mq6nqrv0phunncqjn85cgj6sep.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.sauci.app",
|
||||||
|
"certificate_hash": "f06026c754b046f280feac438a6910bd9a62b459"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com",
|
"client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com",
|
||||||
"client_type": 1,
|
"client_type": 1,
|
||||||
|
|||||||
+4
@@ -12,3 +12,7 @@
|
|||||||
-keep class com.facebook.react.turbomodule.** { *; }
|
-keep class com.facebook.react.turbomodule.** { *; }
|
||||||
|
|
||||||
# Add any project specific keep options here:
|
# Add any project specific keep options here:
|
||||||
|
|
||||||
|
# @generated begin expo-build-properties - expo prebuild (DO NOT MODIFY)
|
||||||
|
-keep class com.facebook.hermes.unicode.** { *; }
|
||||||
|
# @generated end expo-build-properties
|
||||||
@@ -18,7 +18,7 @@ buildscript {
|
|||||||
classpath 'com.google.gms:google-services:4.4.1'
|
classpath 'com.google.gms:google-services:4.4.1'
|
||||||
classpath('com.android.tools.build:gradle')
|
classpath('com.android.tools.build:gradle')
|
||||||
classpath('com.facebook.react:react-native-gradle-plugin')
|
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||||
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,4 +56,5 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
|
|||||||
expo.useLegacyPackaging=false
|
expo.useLegacyPackaging=false
|
||||||
|
|
||||||
android.targetSdkVersion=35
|
android.targetSdkVersion=35
|
||||||
|
android.kotlinVersion=1.9.25
|
||||||
android.extraMavenRepos=[]
|
android.extraMavenRepos=[]
|
||||||
+17
-5
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Sauci",
|
"name": "Sauci",
|
||||||
"slug": "sauci",
|
"slug": "sauci",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"scheme": "app.sauci",
|
"scheme": "app.sauci",
|
||||||
@@ -16,8 +16,9 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": false,
|
"supportsTablet": false,
|
||||||
"bundleIdentifier": "com.sauci.app",
|
"bundleIdentifier": "com.sauci.app",
|
||||||
"buildNumber": "25",
|
"buildNumber": "31",
|
||||||
"usesAppleSignIn": true,
|
"usesAppleSignIn": true,
|
||||||
|
"googleServicesFile": "./GoogleService-Info.plist",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"UIBackgroundModes": [
|
"UIBackgroundModes": [
|
||||||
"remote-notification"
|
"remote-notification"
|
||||||
@@ -176,7 +177,7 @@
|
|||||||
"backgroundColor": "#1a1a2e"
|
"backgroundColor": "#1a1a2e"
|
||||||
},
|
},
|
||||||
"package": "com.sauci.app",
|
"package": "com.sauci.app",
|
||||||
"versionCode": 21,
|
"versionCode": 34,
|
||||||
"googleServicesFile": "./google-services.json",
|
"googleServicesFile": "./google-services.json",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
@@ -192,11 +193,21 @@
|
|||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"@react-native-firebase/app",
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"android": {
|
"android": {
|
||||||
"targetSdkVersion": 35
|
"targetSdkVersion": 35,
|
||||||
|
"kotlinVersion": "1.9.25",
|
||||||
|
"extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }",
|
||||||
|
"useLegacyPackaging": false,
|
||||||
|
"manifestPlaceholders": {
|
||||||
|
"firebaseAnalyticsWithoutAdId": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"useFrameworks": "static"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -231,7 +242,8 @@
|
|||||||
{
|
{
|
||||||
"iosUrlScheme": "com.googleusercontent.apps.861972684340-0u2as8i5equfhmhmvjihottkf0ig7237"
|
"iosUrlScheme": "com.googleusercontent.apps.861972684340-0u2as8i5equfhmhmvjihottkf0ig7237"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"./plugins/withKotlinVersion.js"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animate
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore } from "../../src/store";
|
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore, useStreakStore } from "../../src/store";
|
||||||
import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme";
|
import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme";
|
||||||
import { supabase } from "../../src/lib/supabase";
|
import { supabase } from "../../src/lib/supabase";
|
||||||
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
|
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
|
||||||
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
|
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
|
||||||
|
import { syncBadgeCount } from "../../src/lib/badge";
|
||||||
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
|
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
|
||||||
import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal";
|
import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal";
|
||||||
import { Events } from "../../src/lib/analytics";
|
import { Events } from "../../src/lib/analytics";
|
||||||
@@ -139,11 +140,12 @@ function PlayTabButton({ accessibilityState }: PlayTabButtonProps) {
|
|||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { isAuthenticated, isLoading, user, signOut } = useAuthStore();
|
const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
|
||||||
const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore();
|
const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore();
|
||||||
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
|
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
|
||||||
const { fetchEnabledPacks } = usePacksStore();
|
const { fetchEnabledPacks } = usePacksStore();
|
||||||
const { initializeRevenueCat } = useSubscriptionStore();
|
const { initializeRevenueCat } = useSubscriptionStore();
|
||||||
|
const { fetchStreak } = useStreakStore();
|
||||||
const [matchNotification, setMatchNotification] = useState<MatchNotificationData | null>(null);
|
const [matchNotification, setMatchNotification] = useState<MatchNotificationData | null>(null);
|
||||||
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
||||||
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -208,7 +210,8 @@ export default function AppLayout() {
|
|||||||
const isOnOnboarding = segmentStrings.includes("onboarding");
|
const isOnOnboarding = segmentStrings.includes("onboarding");
|
||||||
const isOnPairing = segmentStrings.includes("pairing");
|
const isOnPairing = segmentStrings.includes("pairing");
|
||||||
const isOnChat = segmentStrings.includes("chat");
|
const isOnChat = segmentStrings.includes("chat");
|
||||||
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat;
|
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
|
||||||
|
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnSettingsSubscreen;
|
||||||
|
|
||||||
// Redirect to login when not authenticated, or to onboarding if not completed
|
// Redirect to login when not authenticated, or to onboarding if not completed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -232,17 +235,22 @@ export default function AppLayout() {
|
|||||||
// Check immediately
|
// Check immediately
|
||||||
checkSession();
|
checkSession();
|
||||||
|
|
||||||
|
// Update last active timestamp on mount
|
||||||
|
updateLastActive();
|
||||||
|
|
||||||
// Check when app comes to foreground
|
// Check when app comes to foreground
|
||||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||||
if (nextAppState === 'active') {
|
if (nextAppState === 'active') {
|
||||||
checkSession();
|
checkSession();
|
||||||
|
// Update last active timestamp when app comes to foreground
|
||||||
|
updateLastActive();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
};
|
};
|
||||||
}, [signOut]);
|
}, [signOut, updateLastActive]);
|
||||||
|
|
||||||
// Handle biometric lock when app goes to background/foreground
|
// Handle biometric lock when app goes to background/foreground
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -301,6 +309,18 @@ export default function AppLayout() {
|
|||||||
}
|
}
|
||||||
}, [user?.id, fetchUnreadCount]);
|
}, [user?.id, fetchUnreadCount]);
|
||||||
|
|
||||||
|
// Fetch streak data when user is in a couple
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.couple_id) {
|
||||||
|
fetchStreak();
|
||||||
|
}
|
||||||
|
}, [user?.couple_id, fetchStreak]);
|
||||||
|
|
||||||
|
// Sync app icon badge count whenever matches or messages change
|
||||||
|
useEffect(() => {
|
||||||
|
syncBadgeCount(newMatchesCount, unreadCount);
|
||||||
|
}, [newMatchesCount, unreadCount]);
|
||||||
|
|
||||||
// Initialize RevenueCat for subscription management (iOS and Android only)
|
// Initialize RevenueCat for subscription management (iOS and Android only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.id && Platform.OS !== "web") {
|
if (user?.id && Platform.OS !== "web") {
|
||||||
@@ -372,7 +392,7 @@ export default function AppLayout() {
|
|||||||
.eq("id", payload.new.id)
|
.eq("id", payload.new.id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (matchWithQuestion) {
|
if (matchWithQuestion && matchWithQuestion.question) {
|
||||||
// Track milestone events before adding (so count is accurate)
|
// Track milestone events before adding (so count is accurate)
|
||||||
const currentMatchCount = matches.length;
|
const currentMatchCount = matches.length;
|
||||||
if (currentMatchCount === 0) {
|
if (currentMatchCount === 0) {
|
||||||
@@ -692,6 +712,12 @@ export default function AppLayout() {
|
|||||||
href: null, // Hide from tab bar
|
href: null, // Hide from tab bar
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="settings"
|
||||||
|
options={{
|
||||||
|
href: null, // Hide from tab bar
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Radial Menu Overlay */}
|
{/* Radial Menu Overlay */}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
Image,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -17,10 +18,8 @@ import Animated, {
|
|||||||
FadeIn,
|
FadeIn,
|
||||||
FadeInUp,
|
FadeInUp,
|
||||||
FadeOut,
|
FadeOut,
|
||||||
FadeOutLeft,
|
|
||||||
SlideInRight,
|
SlideInRight,
|
||||||
SlideOutLeft,
|
SlideOutLeft,
|
||||||
Layout,
|
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { GradientBackground } from '../../src/components/ui/GradientBackground';
|
import { GradientBackground } from '../../src/components/ui/GradientBackground';
|
||||||
import { GlassCard } from '../../src/components/ui/GlassCard';
|
import { GlassCard } from '../../src/components/ui/GlassCard';
|
||||||
@@ -32,6 +31,7 @@ import { supabase } from '../../src/lib/supabase';
|
|||||||
import { getProfileError } from '../../src/lib/errors';
|
import { getProfileError } from '../../src/lib/errors';
|
||||||
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
||||||
import { Events } from '../../src/lib/analytics';
|
import { Events } from '../../src/lib/analytics';
|
||||||
|
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
|
||||||
import type { Gender, IntensityLevel } from '../../src/types';
|
import type { Gender, IntensityLevel } from '../../src/types';
|
||||||
|
|
||||||
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
||||||
@@ -64,12 +64,12 @@ const getInitialIntensity = (profile?: { max_intensity?: number | null; show_exp
|
|||||||
return profile?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY;
|
return profile?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Stage = 'name' | 'gender' | 'purpose' | 'explicit' | 'notifications';
|
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'explicit' | 'notifications';
|
||||||
|
|
||||||
export default function OnboardingScreen() {
|
export default function OnboardingScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, fetchUser } = useAuthStore();
|
const { user, fetchUser } = useAuthStore();
|
||||||
const [stage, setStage] = useState<Stage>('name');
|
const [stage, setStage] = useState<Stage>('avatar');
|
||||||
const [name, setName] = useState(user?.name || '');
|
const [name, setName] = useState(user?.name || '');
|
||||||
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
|
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
|
||||||
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
|
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
|
||||||
@@ -78,10 +78,25 @@ export default function OnboardingScreen() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
avatarUri,
|
||||||
|
isUploading: isUploadingAvatar,
|
||||||
|
showPicker: handleAvatarPress,
|
||||||
|
uploadAvatar,
|
||||||
|
} = useAvatarPicker({
|
||||||
|
userId: user?.id,
|
||||||
|
onSelect: () => setError(null),
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Events.onboardingStart();
|
Events.onboardingStart();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleAvatarContinue = () => {
|
||||||
|
setStage('name');
|
||||||
|
Events.onboardingStageComplete('avatar');
|
||||||
|
};
|
||||||
|
|
||||||
const handleNameSubmit = () => {
|
const handleNameSubmit = () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError('Please enter your name');
|
setError('Please enter your name');
|
||||||
@@ -153,11 +168,19 @@ export default function OnboardingScreen() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const showExplicitContent = maxIntensity >= 3;
|
// Upload avatar if one was selected
|
||||||
|
let avatarUrl: string | null = null;
|
||||||
|
if (avatarUri) {
|
||||||
|
avatarUrl = await uploadAvatar();
|
||||||
|
if (avatarUrl) {
|
||||||
|
Events.avatarUploaded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { error: updateError } = await supabase
|
const showExplicitContent = maxIntensity >= 3;
|
||||||
.from('profiles')
|
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content"];
|
||||||
.update({
|
|
||||||
|
const updateData: Record<string, any> = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
gender,
|
gender,
|
||||||
usage_reason: usageReason,
|
usage_reason: usageReason,
|
||||||
@@ -165,7 +188,16 @@ export default function OnboardingScreen() {
|
|||||||
show_explicit_content: showExplicitContent,
|
show_explicit_content: showExplicitContent,
|
||||||
onboarding_completed: true,
|
onboarding_completed: true,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
};
|
||||||
|
|
||||||
|
if (avatarUrl) {
|
||||||
|
updateData.avatar_url = avatarUrl;
|
||||||
|
updatedFields.push("avatar_url");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.update(updateData)
|
||||||
.eq('id', user.id);
|
.eq('id', user.id);
|
||||||
|
|
||||||
if (updateError) {
|
if (updateError) {
|
||||||
@@ -176,7 +208,7 @@ export default function OnboardingScreen() {
|
|||||||
console.log('[Onboarding] Profile updated successfully, fetching user...');
|
console.log('[Onboarding] Profile updated successfully, fetching user...');
|
||||||
await fetchUser();
|
await fetchUser();
|
||||||
console.log('[Onboarding] User fetched, navigating to home...');
|
console.log('[Onboarding] User fetched, navigating to home...');
|
||||||
Events.profileUpdated(["name", "gender", "usage_reason", "max_intensity", "show_explicit_content"]);
|
Events.profileUpdated(updatedFields);
|
||||||
Events.onboardingComplete();
|
Events.onboardingComplete();
|
||||||
router.replace('/');
|
router.replace('/');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -189,10 +221,10 @@ export default function OnboardingScreen() {
|
|||||||
|
|
||||||
const renderStage = () => {
|
const renderStage = () => {
|
||||||
switch (stage) {
|
switch (stage) {
|
||||||
case 'name':
|
case 'avatar':
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
key="name"
|
key="avatar"
|
||||||
entering={FadeInUp.duration(500)}
|
entering={FadeInUp.duration(500)}
|
||||||
exiting={SlideOutLeft.duration(300)}
|
exiting={SlideOutLeft.duration(300)}
|
||||||
style={styles.stageContainer}
|
style={styles.stageContainer}
|
||||||
@@ -206,12 +238,73 @@ export default function OnboardingScreen() {
|
|||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<Text style={styles.title}>Welcome to Sauci</Text>
|
<Text style={styles.title}>Welcome to Sauci</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>
|
||||||
Let's get to know you
|
Add a profile photo so your partner knows it's you
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<GlassCard style={styles.card}>
|
||||||
|
<View style={styles.avatarSection}>
|
||||||
|
<Pressable onPress={handleAvatarPress} style={styles.avatarTouchable}>
|
||||||
|
{avatarUri ? (
|
||||||
|
<Image source={{ uri: avatarUri }} style={styles.avatarImage} />
|
||||||
|
) : (
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.glass.background, colors.glass.backgroundLight]}
|
||||||
|
style={styles.avatarPlaceholder}
|
||||||
|
>
|
||||||
|
<Ionicons name="camera" size={40} color={colors.textSecondary} />
|
||||||
|
</LinearGradient>
|
||||||
|
)}
|
||||||
|
<View style={styles.avatarBadge}>
|
||||||
|
<Ionicons name={avatarUri ? "pencil" : "add"} size={16} color={colors.text} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.avatarHint}>
|
||||||
|
{avatarUri ? 'Tap to change photo' : 'Tap to add a photo'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{error && (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Ionicons name="alert-circle" size={16} color={colors.error} />
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<GlassButton onPress={handleAvatarContinue} fullWidth size="lg">
|
||||||
|
{avatarUri ? 'Continue' : 'Skip for Now'}
|
||||||
|
</GlassButton>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'name':
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
key="name"
|
||||||
|
entering={SlideInRight.duration(400)}
|
||||||
|
exiting={SlideOutLeft.duration(300)}
|
||||||
|
style={styles.stageContainer}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
{avatarUri ? (
|
||||||
|
<Image source={{ uri: avatarUri }} style={styles.headerAvatar} />
|
||||||
|
) : (
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.primary as [string, string]}
|
||||||
|
style={styles.iconContainer}
|
||||||
|
>
|
||||||
|
<Ionicons name="person" size={40} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
)}
|
||||||
|
<Text style={styles.title}>What can we call you?</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
This is how your partner will see you
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<GlassCard style={styles.card}>
|
<GlassCard style={styles.card}>
|
||||||
<Text style={styles.label}>What's your name?</Text>
|
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="person-outline"
|
name="person-outline"
|
||||||
@@ -224,7 +317,7 @@ export default function OnboardingScreen() {
|
|||||||
style={styles.input}
|
style={styles.input}
|
||||||
value={name}
|
value={name}
|
||||||
onChangeText={setName}
|
onChangeText={setName}
|
||||||
placeholder="Enter your name"
|
placeholder="Your name or nickname"
|
||||||
placeholderTextColor={colors.textTertiary}
|
placeholderTextColor={colors.textTertiary}
|
||||||
autoCapitalize="words"
|
autoCapitalize="words"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
@@ -515,14 +608,16 @@ export default function OnboardingScreen() {
|
|||||||
>
|
>
|
||||||
{/* Header with back button and progress dots */}
|
{/* Header with back button and progress dots */}
|
||||||
<Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}>
|
<Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}>
|
||||||
{stage !== 'name' ? (
|
{stage !== 'avatar' ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.backButton}
|
style={styles.backButton}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
setError(null);
|
||||||
if (stage === 'notifications') setStage('explicit');
|
if (stage === 'notifications') setStage('explicit');
|
||||||
else if (stage === 'explicit') setStage('purpose');
|
else if (stage === 'explicit') setStage('purpose');
|
||||||
else if (stage === 'purpose') setStage('gender');
|
else if (stage === 'purpose') setStage('gender');
|
||||||
else setStage('name');
|
else if (stage === 'gender') setStage('name');
|
||||||
|
else if (stage === 'name') setStage('avatar');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||||
@@ -531,6 +626,7 @@ export default function OnboardingScreen() {
|
|||||||
<View style={styles.backButtonPlaceholder} />
|
<View style={styles.backButtonPlaceholder} />
|
||||||
)}
|
)}
|
||||||
<View style={styles.progressContainer}>
|
<View style={styles.progressContainer}>
|
||||||
|
<View style={[styles.progressDot, stage === 'avatar' && styles.progressDotActive]} />
|
||||||
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
|
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
|
||||||
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
|
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
|
||||||
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
|
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
|
||||||
@@ -611,6 +707,56 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: spacing.lg,
|
marginBottom: spacing.lg,
|
||||||
...shadows.lg,
|
...shadows.lg,
|
||||||
},
|
},
|
||||||
|
headerAvatar: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
avatarSection: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
},
|
||||||
|
avatarTouchable: {
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
avatarImage: {
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 60,
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
avatarPlaceholder: {
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 60,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
},
|
||||||
|
avatarBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 4,
|
||||||
|
right: 4,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.background,
|
||||||
|
},
|
||||||
|
avatarHint: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
...typography.largeTitle,
|
...typography.largeTitle,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import Animated, {
|
|||||||
Extrapolation,
|
Extrapolation,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { GradientBackground } from "../../../src/components/ui";
|
import { GradientBackground, GlassToggle } from "../../../src/components/ui";
|
||||||
import { Paywall } from "../../../src/components/paywall";
|
import { Paywall } from "../../../src/components/paywall";
|
||||||
import { PackTeaser } from "../../../src/components/PackTeaser";
|
import { PackTeaser } from "../../../src/components/PackTeaser";
|
||||||
import { colors, spacing, radius, typography } from "../../../src/theme";
|
import { colors, spacing, radius, typography } from "../../../src/theme";
|
||||||
@@ -258,7 +258,7 @@ function CategorySection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function QuestionPacksScreen() {
|
export default function QuestionPacksScreen() {
|
||||||
const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
|
const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks, showAllIntensities, setShowAllIntensities } = usePacksStore();
|
||||||
const { user, partner } = useAuthStore();
|
const { user, partner } = useAuthStore();
|
||||||
const { subscription } = useSubscriptionStore();
|
const { subscription } = useSubscriptionStore();
|
||||||
|
|
||||||
@@ -335,6 +335,10 @@ export default function QuestionPacksScreen() {
|
|||||||
setTeaserPack(null);
|
setTeaserPack(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleComfortZone = useCallback((value: boolean) => {
|
||||||
|
setShowAllIntensities(value);
|
||||||
|
}, [setShowAllIntensities]);
|
||||||
|
|
||||||
const enabledPackCount = enabledPackIds.length;
|
const enabledPackCount = enabledPackIds.length;
|
||||||
const totalQuestions = packs
|
const totalQuestions = packs
|
||||||
.filter(p => enabledPackIds.includes(p.id))
|
.filter(p => enabledPackIds.includes(p.id))
|
||||||
@@ -442,6 +446,26 @@ export default function QuestionPacksScreen() {
|
|||||||
{totalQuestions} {totalQuestions === 1 ? "question" : "questions"} from {enabledPackCount} {enabledPackCount === 1 ? "pack" : "packs"}
|
{totalQuestions} {totalQuestions === 1 ? "question" : "questions"} from {enabledPackCount} {enabledPackCount === 1 ? "pack" : "packs"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={styles.comfortZoneToggle}
|
||||||
|
onPress={() => handleToggleComfortZone(!showAllIntensities)}
|
||||||
|
>
|
||||||
|
<View style={styles.comfortZoneToggleLeft}>
|
||||||
|
<Ionicons
|
||||||
|
name={showAllIntensities ? "flame" : "flame-outline"}
|
||||||
|
size={16}
|
||||||
|
color={showAllIntensities ? colors.primary : colors.textSecondary}
|
||||||
|
/>
|
||||||
|
<Text style={styles.comfortZoneToggleText}>
|
||||||
|
Show all intensities
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<GlassToggle
|
||||||
|
value={showAllIntensities}
|
||||||
|
onValueChange={handleToggleComfortZone}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Categories */}
|
{/* Categories */}
|
||||||
@@ -610,6 +634,30 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
},
|
},
|
||||||
|
comfortZoneToggle: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 280,
|
||||||
|
},
|
||||||
|
comfortZoneToggleLeft: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
comfortZoneToggleText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
|
||||||
// Scroll Content
|
// Scroll Content
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function SettingsLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { AccountScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default AccountScreen;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { NotificationsScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default NotificationsScreen;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { PartnerScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default PartnerScreen;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { PreferencesScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default PreferencesScreen;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { ProfileSettingsScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default ProfileSettingsScreen;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { SaveAccountScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default SaveAccountScreen;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { SubscriptionScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default SubscriptionScreen;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { SupportScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default SupportScreen;
|
||||||
@@ -61,6 +61,7 @@ export default function LoginScreen() {
|
|||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isGuestLoading, setIsGuestLoading] = useState(false);
|
||||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||||
const [pendingVerification, setPendingVerification] = useState<PendingVerification | null>(null);
|
const [pendingVerification, setPendingVerification] = useState<PendingVerification | null>(null);
|
||||||
const [isCheckingVerification, setIsCheckingVerification] = useState(false);
|
const [isCheckingVerification, setIsCheckingVerification] = useState(false);
|
||||||
@@ -364,6 +365,20 @@ export default function LoginScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGuestSignIn = async () => {
|
||||||
|
clearError();
|
||||||
|
setIsGuestLoading(true);
|
||||||
|
const { error: authError } = await supabase.auth.signInAnonymously();
|
||||||
|
setIsGuestLoading(false);
|
||||||
|
|
||||||
|
if (authError) {
|
||||||
|
showError(getAuthError(authError));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Events.signIn("guest");
|
||||||
|
};
|
||||||
|
|
||||||
if (isMagicLinkSent) {
|
if (isMagicLinkSent) {
|
||||||
return (
|
return (
|
||||||
<GradientBackground showAccent>
|
<GradientBackground showAccent>
|
||||||
@@ -663,6 +678,23 @@ export default function LoginScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(400).duration(600).springify()}
|
||||||
|
style={styles.guestContainer}
|
||||||
|
>
|
||||||
|
<GlassButton
|
||||||
|
variant="ghost"
|
||||||
|
onPress={handleGuestSignIn}
|
||||||
|
loading={isGuestLoading}
|
||||||
|
disabled={isGuestLoading}
|
||||||
|
icon={<Ionicons name="person-outline" size={20} color={colors.primary} />}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Continue without Login
|
||||||
|
</GlassButton>
|
||||||
|
<Text style={styles.guestHint}>Save your account anytime in Settings.</Text>
|
||||||
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
@@ -821,4 +853,14 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
height: 48,
|
height: 48,
|
||||||
},
|
},
|
||||||
|
guestContainer: {
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
guestHint: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
+180
-10
@@ -1,17 +1,12 @@
|
|||||||
// Polyfills must be imported FIRST before any other React Native imports
|
// Polyfills must be imported FIRST before any other React Native imports
|
||||||
import "../src/polyfills/web";
|
import "../src/polyfills/web";
|
||||||
import "../src/polyfills/crypto";
|
|
||||||
|
|
||||||
// Initialize Sentry early to capture all errors
|
// Initialize Sentry early to capture all errors
|
||||||
import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry";
|
import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry";
|
||||||
initSentry();
|
initSentry();
|
||||||
|
|
||||||
// Initialize Analytics
|
// Analytics imports (initialization deferred until app mounts)
|
||||||
import { initAnalytics, setUserId, clearUserId, logScreenView, Events } from "../src/lib/analytics";
|
import { initAnalytics, setUserId, clearUserId, logScreenView, Events } from "../src/lib/analytics";
|
||||||
initAnalytics();
|
|
||||||
|
|
||||||
// Track cold start immediately
|
|
||||||
Events.appOpened("cold");
|
|
||||||
|
|
||||||
// Import push notification utilities
|
// Import push notification utilities
|
||||||
import {
|
import {
|
||||||
@@ -21,8 +16,8 @@ import {
|
|||||||
handleNotificationResponse,
|
handleNotificationResponse,
|
||||||
} from "../src/lib/notifications";
|
} from "../src/lib/notifications";
|
||||||
|
|
||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Platform, View, AppState, AppStateStatus } from "react-native";
|
import { Platform, View, AppState, AppStateStatus, Modal, Text, StyleSheet, Pressable, TouchableOpacity } from "react-native";
|
||||||
|
|
||||||
// Suppress useLayoutEffect warning on server-side rendering
|
// Suppress useLayoutEffect warning on server-side rendering
|
||||||
if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
|
if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
|
||||||
@@ -39,6 +34,7 @@ if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
|
|||||||
|
|
||||||
import { Stack, router, usePathname } from "expo-router";
|
import { Stack, router, usePathname } from "expo-router";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
// GestureHandlerRootView is only needed on native platforms
|
// GestureHandlerRootView is only needed on native platforms
|
||||||
// On web, we use a simple View to avoid findNodeHandle errors
|
// On web, we use a simple View to avoid findNodeHandle errors
|
||||||
const GestureHandlerRootView = Platform.OS === "web"
|
const GestureHandlerRootView = Platform.OS === "web"
|
||||||
@@ -48,13 +44,34 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import { useAuthStore } from "../src/store";
|
import { useAuthStore } from "../src/store";
|
||||||
import { supabase } from "../src/lib/supabase";
|
import { supabase } from "../src/lib/supabase";
|
||||||
|
import { colors, spacing, radius, typography } from "../src/theme";
|
||||||
|
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const { fetchUser, setUser } = useAuthStore();
|
const { fetchUser, setUser, isAuthenticated, isAnonymous } = useAuthStore();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const appState = useRef(AppState.currentState);
|
const appState = useRef(AppState.currentState);
|
||||||
|
const analyticsInitialized = useRef(false);
|
||||||
|
|
||||||
|
const [showGuestWarning, setShowGuestWarning] = useState(false);
|
||||||
|
const [checkingGuestWarning, setCheckingGuestWarning] = useState(false);
|
||||||
|
|
||||||
|
const dismissGuestWarning = async () => {
|
||||||
|
await markGuestAccountWarningSeen();
|
||||||
|
setShowGuestWarning(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Initialize analytics after native modules are ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (!analyticsInitialized.current) {
|
||||||
|
analyticsInitialized.current = true;
|
||||||
|
initAnalytics();
|
||||||
|
Events.appOpened("cold");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Track screen views
|
// Track screen views
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,6 +80,22 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Warn guest users (first time only) that accounts are not recoverable
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated || !isAnonymous) return;
|
||||||
|
if (checkingGuestWarning) return;
|
||||||
|
if (pathname?.startsWith("/(auth)")) return;
|
||||||
|
|
||||||
|
setCheckingGuestWarning(true);
|
||||||
|
hasSeenGuestAccountWarning()
|
||||||
|
.then((seen) => {
|
||||||
|
if (!seen) {
|
||||||
|
setShowGuestWarning(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setCheckingGuestWarning(false));
|
||||||
|
}, [isAuthenticated, isAnonymous, pathname, checkingGuestWarning]);
|
||||||
|
|
||||||
// Track warm starts when app comes back from background
|
// Track warm starts when app comes back from background
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => {
|
const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => {
|
||||||
@@ -176,7 +209,7 @@ export default function RootLayout() {
|
|||||||
const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange(
|
const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange(
|
||||||
async (event, session) => {
|
async (event, session) => {
|
||||||
console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id);
|
console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id);
|
||||||
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") {
|
||||||
console.log('[Auth] Calling fetchUser...');
|
console.log('[Auth] Calling fetchUser...');
|
||||||
fetchUser();
|
fetchUser();
|
||||||
// Set Sentry user context for error tracking
|
// Set Sentry user context for error tracking
|
||||||
@@ -218,7 +251,144 @@ export default function RootLayout() {
|
|||||||
contentStyle: { backgroundColor: "#1a1a2e" },
|
contentStyle: { backgroundColor: "#1a1a2e" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={showGuestWarning}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={dismissGuestWarning}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={styles.guestWarningOverlay}
|
||||||
|
onPress={dismissGuestWarning}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.guestWarningCard} onPress={() => { }}>
|
||||||
|
<View style={styles.guestWarningHeader}>
|
||||||
|
<View style={styles.guestWarningIcon}>
|
||||||
|
<Ionicons name="warning" size={22} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={dismissGuestWarning}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
style={styles.guestWarningClose}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={20} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.guestWarningTitle}>Your account isn't saved</Text>
|
||||||
|
<Text style={styles.guestWarningBody}>
|
||||||
|
If you delete the app or switch devices, you won't be able to recover this account.
|
||||||
|
Save your account now to protect your couple, matches, and purchases.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.guestWarningActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.guestWarningPrimary}
|
||||||
|
onPress={async () => {
|
||||||
|
await markGuestAccountWarningSeen();
|
||||||
|
setShowGuestWarning(false);
|
||||||
|
router.push("/(app)/settings/save-account" as any);
|
||||||
|
}}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.guestWarningPrimaryText}>Save account</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.guestWarningSecondary}
|
||||||
|
onPress={async () => {
|
||||||
|
await markGuestAccountWarningSeen();
|
||||||
|
setShowGuestWarning(false);
|
||||||
|
}}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.guestWarningSecondaryText}>Not now</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
guestWarningOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.75)",
|
||||||
|
},
|
||||||
|
guestWarningCard: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderRadius: radius.xl,
|
||||||
|
padding: spacing.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
guestWarningHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
guestWarningIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
guestWarningClose: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
guestWarningTitle: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
guestWarningBody: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 22,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
guestWarningActions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
guestWarningPrimary: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
guestWarningPrimaryText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
guestWarningSecondary: {
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
guestWarningSecondaryText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -5,13 +5,16 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
|
"node": "20.10.0",
|
||||||
"developmentClient": true,
|
"developmentClient": true,
|
||||||
"distribution": "internal"
|
"distribution": "internal"
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
|
"node": "20.10.0",
|
||||||
"distribution": "internal"
|
"distribution": "internal"
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
|
"node": "20.10.0",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
|
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"react-native": {
|
||||||
|
"analytics_auto_collection_enabled": true,
|
||||||
|
"google_analytics_adid_collection_enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oauth_client": [
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "764866133492-03k7q0mq6nqrv0phunncqjn85cgj6sep.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.sauci.app",
|
||||||
|
"certificate_hash": "f06026c754b046f280feac438a6910bd9a62b459"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com",
|
"client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com",
|
||||||
"client_type": 1,
|
"client_type": 1,
|
||||||
|
|||||||
+187
-81
@@ -164,6 +164,7 @@ PODS:
|
|||||||
- React-jsinspector
|
- React-jsinspector
|
||||||
- React-NativeModulesApple
|
- React-NativeModulesApple
|
||||||
- React-RCTFabric
|
- React-RCTFabric
|
||||||
|
- React-rendererconsistency
|
||||||
- React-rendererdebug
|
- React-rendererdebug
|
||||||
- React-utils
|
- React-utils
|
||||||
- ReactCodegen
|
- ReactCodegen
|
||||||
@@ -303,19 +304,81 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- fast_float (6.1.4)
|
- fast_float (6.1.4)
|
||||||
- FBLazyVector (0.76.9)
|
- FBLazyVector (0.76.9)
|
||||||
|
- Firebase/CoreOnly (12.8.0):
|
||||||
|
- FirebaseCore (~> 12.8.0)
|
||||||
|
- FirebaseAnalytics/Core (12.8.0):
|
||||||
|
- FirebaseCore (~> 12.8.0)
|
||||||
|
- FirebaseInstallations (~> 12.8.0)
|
||||||
|
- GoogleAppMeasurement/Core (= 12.8.0)
|
||||||
|
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||||
|
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||||
|
- GoogleUtilities/Network (~> 8.1)
|
||||||
|
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||||
|
- nanopb (~> 3.30910.0)
|
||||||
|
- FirebaseAnalytics/IdentitySupport (12.8.0):
|
||||||
|
- FirebaseCore (~> 12.8.0)
|
||||||
|
- FirebaseInstallations (~> 12.8.0)
|
||||||
|
- GoogleAppMeasurement/IdentitySupport (= 12.8.0)
|
||||||
|
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||||
|
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||||
|
- GoogleUtilities/Network (~> 8.1)
|
||||||
|
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||||
|
- nanopb (~> 3.30910.0)
|
||||||
|
- FirebaseCore (12.8.0):
|
||||||
|
- FirebaseCoreInternal (~> 12.8.0)
|
||||||
|
- GoogleUtilities/Environment (~> 8.1)
|
||||||
|
- GoogleUtilities/Logger (~> 8.1)
|
||||||
|
- FirebaseCoreInternal (12.8.0):
|
||||||
|
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||||
|
- FirebaseInstallations (12.8.0):
|
||||||
|
- FirebaseCore (~> 12.8.0)
|
||||||
|
- GoogleUtilities/Environment (~> 8.1)
|
||||||
|
- GoogleUtilities/UserDefaults (~> 8.1)
|
||||||
|
- PromisesObjC (~> 2.4)
|
||||||
- fmt (11.0.2)
|
- fmt (11.0.2)
|
||||||
- glog (0.3.5)
|
- glog (0.3.5)
|
||||||
- GoogleSignIn (9.0.0):
|
- GoogleAppMeasurement/Core (12.8.0):
|
||||||
|
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||||
|
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||||
|
- GoogleUtilities/Network (~> 8.1)
|
||||||
|
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||||
|
- nanopb (~> 3.30910.0)
|
||||||
|
- GoogleAppMeasurement/IdentitySupport (12.8.0):
|
||||||
|
- GoogleAppMeasurement/Core (= 12.8.0)
|
||||||
|
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||||
|
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||||
|
- GoogleUtilities/Network (~> 8.1)
|
||||||
|
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||||
|
- nanopb (~> 3.30910.0)
|
||||||
|
- GoogleSignIn (9.1.0):
|
||||||
- AppAuth (~> 2.0)
|
- AppAuth (~> 2.0)
|
||||||
- AppCheckCore (~> 11.0)
|
- AppCheckCore (~> 11.0)
|
||||||
- GTMAppAuth (~> 5.0)
|
- GTMAppAuth (~> 5.0)
|
||||||
- GTMSessionFetcher/Core (~> 3.3)
|
- GTMSessionFetcher/Core (~> 3.3)
|
||||||
|
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
|
||||||
|
- GoogleUtilities/Environment
|
||||||
|
- GoogleUtilities/Logger
|
||||||
|
- GoogleUtilities/Network
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
- GoogleUtilities/Environment (8.1.0):
|
- GoogleUtilities/Environment (8.1.0):
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Privacy
|
||||||
- GoogleUtilities/Logger (8.1.0):
|
- GoogleUtilities/Logger (8.1.0):
|
||||||
- GoogleUtilities/Environment
|
- GoogleUtilities/Environment
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Privacy
|
||||||
|
- GoogleUtilities/MethodSwizzler (8.1.0):
|
||||||
|
- GoogleUtilities/Logger
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
|
- GoogleUtilities/Network (8.1.0):
|
||||||
|
- GoogleUtilities/Logger
|
||||||
|
- "GoogleUtilities/NSData+zlib"
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
|
- GoogleUtilities/Reachability
|
||||||
|
- "GoogleUtilities/NSData+zlib (8.1.0)":
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
- GoogleUtilities/Privacy (8.1.0)
|
- GoogleUtilities/Privacy (8.1.0)
|
||||||
|
- GoogleUtilities/Reachability (8.1.0):
|
||||||
|
- GoogleUtilities/Logger
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
- GoogleUtilities/UserDefaults (8.1.0):
|
- GoogleUtilities/UserDefaults (8.1.0):
|
||||||
- GoogleUtilities/Logger
|
- GoogleUtilities/Logger
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Privacy
|
||||||
@@ -331,10 +394,14 @@ PODS:
|
|||||||
- libavif/core
|
- libavif/core
|
||||||
- libdav1d (>= 0.6.0)
|
- libdav1d (>= 0.6.0)
|
||||||
- libdav1d (1.2.0)
|
- libdav1d (1.2.0)
|
||||||
- OpenSSL-Universal (3.3.3001)
|
- nanopb (3.30910.0):
|
||||||
|
- nanopb/decode (= 3.30910.0)
|
||||||
|
- nanopb/encode (= 3.30910.0)
|
||||||
|
- nanopb/decode (3.30910.0)
|
||||||
|
- nanopb/encode (3.30910.0)
|
||||||
- PromisesObjC (2.4.0)
|
- PromisesObjC (2.4.0)
|
||||||
- PurchasesHybridCommon (17.25.0):
|
- PurchasesHybridCommon (17.28.0):
|
||||||
- RevenueCat (= 5.51.1)
|
- RevenueCat (= 5.55.1)
|
||||||
- RCT-Folly (2024.10.14.00):
|
- RCT-Folly (2024.10.14.00):
|
||||||
- boost
|
- boost
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
@@ -1613,7 +1680,7 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- Yoga
|
- Yoga
|
||||||
- react-native-compressor (1.13.0):
|
- react-native-compressor (1.16.0):
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
- glog
|
- glog
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
@@ -1634,29 +1701,6 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- Yoga
|
- Yoga
|
||||||
- react-native-quick-crypto (0.7.17):
|
|
||||||
- DoubleConversion
|
|
||||||
- glog
|
|
||||||
- hermes-engine
|
|
||||||
- OpenSSL-Universal
|
|
||||||
- RCT-Folly (= 2024.10.14.00)
|
|
||||||
- RCTRequired
|
|
||||||
- RCTTypeSafety
|
|
||||||
- React
|
|
||||||
- React-Core
|
|
||||||
- React-debug
|
|
||||||
- React-Fabric
|
|
||||||
- React-featureflags
|
|
||||||
- React-graphics
|
|
||||||
- React-ImageManager
|
|
||||||
- React-NativeModulesApple
|
|
||||||
- React-RCTFabric
|
|
||||||
- React-rendererdebug
|
|
||||||
- React-utils
|
|
||||||
- ReactCodegen
|
|
||||||
- ReactCommon/turbomodule/bridging
|
|
||||||
- ReactCommon/turbomodule/core
|
|
||||||
- Yoga
|
|
||||||
- react-native-safe-area-context (4.12.0):
|
- react-native-safe-area-context (4.12.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- React-nativeconfig (0.76.9)
|
- React-nativeconfig (0.76.9)
|
||||||
@@ -1931,9 +1975,55 @@ PODS:
|
|||||||
- React-logger
|
- React-logger
|
||||||
- React-perflogger
|
- React-perflogger
|
||||||
- React-utils (= 0.76.9)
|
- React-utils (= 0.76.9)
|
||||||
- RevenueCat (5.51.1)
|
- RevenueCat (5.55.1)
|
||||||
- RNCAsyncStorage (1.23.1):
|
- RNCAsyncStorage (1.23.1):
|
||||||
- React-Core
|
- React-Core
|
||||||
|
- RNFBAnalytics (23.8.3):
|
||||||
|
- DoubleConversion
|
||||||
|
- FirebaseAnalytics/Core (= 12.8.0)
|
||||||
|
- FirebaseAnalytics/IdentitySupport (= 12.8.0)
|
||||||
|
- glog
|
||||||
|
- hermes-engine
|
||||||
|
- RCT-Folly (= 2024.10.14.00)
|
||||||
|
- RCTRequired
|
||||||
|
- RCTTypeSafety
|
||||||
|
- React-Core
|
||||||
|
- React-debug
|
||||||
|
- React-Fabric
|
||||||
|
- React-featureflags
|
||||||
|
- React-graphics
|
||||||
|
- React-ImageManager
|
||||||
|
- React-NativeModulesApple
|
||||||
|
- React-RCTFabric
|
||||||
|
- React-rendererdebug
|
||||||
|
- React-utils
|
||||||
|
- ReactCodegen
|
||||||
|
- ReactCommon/turbomodule/bridging
|
||||||
|
- ReactCommon/turbomodule/core
|
||||||
|
- RNFBApp
|
||||||
|
- Yoga
|
||||||
|
- RNFBApp (23.8.3):
|
||||||
|
- DoubleConversion
|
||||||
|
- Firebase/CoreOnly (= 12.8.0)
|
||||||
|
- glog
|
||||||
|
- hermes-engine
|
||||||
|
- RCT-Folly (= 2024.10.14.00)
|
||||||
|
- RCTRequired
|
||||||
|
- RCTTypeSafety
|
||||||
|
- React-Core
|
||||||
|
- React-debug
|
||||||
|
- React-Fabric
|
||||||
|
- React-featureflags
|
||||||
|
- React-graphics
|
||||||
|
- React-ImageManager
|
||||||
|
- React-NativeModulesApple
|
||||||
|
- React-RCTFabric
|
||||||
|
- React-rendererdebug
|
||||||
|
- React-utils
|
||||||
|
- ReactCodegen
|
||||||
|
- ReactCommon/turbomodule/bridging
|
||||||
|
- ReactCommon/turbomodule/core
|
||||||
|
- Yoga
|
||||||
- RNGestureHandler (2.20.2):
|
- RNGestureHandler (2.20.2):
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
- glog
|
- glog
|
||||||
@@ -1958,8 +2048,8 @@ PODS:
|
|||||||
- RNGoogleSignin (16.1.1):
|
- RNGoogleSignin (16.1.1):
|
||||||
- GoogleSignIn (~> 9.0)
|
- GoogleSignIn (~> 9.0)
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNPurchases (9.6.12):
|
- RNPurchases (9.7.1):
|
||||||
- PurchasesHybridCommon (= 17.25.0)
|
- PurchasesHybridCommon (= 17.28.0)
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNReanimated (3.16.7):
|
- RNReanimated (3.16.7):
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
@@ -2176,7 +2266,6 @@ DEPENDENCIES:
|
|||||||
- React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`)
|
- React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`)
|
||||||
- React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
|
- React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
|
||||||
- react-native-compressor (from `../../../node_modules/react-native-compressor`)
|
- react-native-compressor (from `../../../node_modules/react-native-compressor`)
|
||||||
- react-native-quick-crypto (from `../../../node_modules/react-native-quick-crypto`)
|
|
||||||
- react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
|
- react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
|
||||||
- React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`)
|
- React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`)
|
||||||
- React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
|
- React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
|
||||||
@@ -2206,6 +2295,8 @@ DEPENDENCIES:
|
|||||||
- ReactCodegen (from `build/generated/ios`)
|
- ReactCodegen (from `build/generated/ios`)
|
||||||
- ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`)
|
- ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`)
|
||||||
- "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)"
|
- "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)"
|
||||||
|
- "RNFBAnalytics (from `../../../node_modules/@react-native-firebase/analytics`)"
|
||||||
|
- "RNFBApp (from `../../../node_modules/@react-native-firebase/app`)"
|
||||||
- RNGestureHandler (from `../../../node_modules/react-native-gesture-handler`)
|
- RNGestureHandler (from `../../../node_modules/react-native-gesture-handler`)
|
||||||
- "RNGoogleSignin (from `../../../node_modules/@react-native-google-signin/google-signin`)"
|
- "RNGoogleSignin (from `../../../node_modules/@react-native-google-signin/google-signin`)"
|
||||||
- RNPurchases (from `../../../node_modules/react-native-purchases`)
|
- RNPurchases (from `../../../node_modules/react-native-purchases`)
|
||||||
@@ -2218,13 +2309,19 @@ SPEC REPOS:
|
|||||||
trunk:
|
trunk:
|
||||||
- AppAuth
|
- AppAuth
|
||||||
- AppCheckCore
|
- AppCheckCore
|
||||||
|
- Firebase
|
||||||
|
- FirebaseAnalytics
|
||||||
|
- FirebaseCore
|
||||||
|
- FirebaseCoreInternal
|
||||||
|
- FirebaseInstallations
|
||||||
|
- GoogleAppMeasurement
|
||||||
- GoogleSignIn
|
- GoogleSignIn
|
||||||
- GoogleUtilities
|
- GoogleUtilities
|
||||||
- GTMAppAuth
|
- GTMAppAuth
|
||||||
- GTMSessionFetcher
|
- GTMSessionFetcher
|
||||||
- libavif
|
- libavif
|
||||||
- libdav1d
|
- libdav1d
|
||||||
- OpenSSL-Universal
|
- nanopb
|
||||||
- PromisesObjC
|
- PromisesObjC
|
||||||
- PurchasesHybridCommon
|
- PurchasesHybridCommon
|
||||||
- RevenueCat
|
- RevenueCat
|
||||||
@@ -2372,8 +2469,6 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
|
:path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
|
||||||
react-native-compressor:
|
react-native-compressor:
|
||||||
:path: "../../../node_modules/react-native-compressor"
|
:path: "../../../node_modules/react-native-compressor"
|
||||||
react-native-quick-crypto:
|
|
||||||
:path: "../../../node_modules/react-native-quick-crypto"
|
|
||||||
react-native-safe-area-context:
|
react-native-safe-area-context:
|
||||||
:path: "../../../node_modules/react-native-safe-area-context"
|
:path: "../../../node_modules/react-native-safe-area-context"
|
||||||
React-nativeconfig:
|
React-nativeconfig:
|
||||||
@@ -2432,6 +2527,10 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../../../node_modules/react-native/ReactCommon"
|
:path: "../../../node_modules/react-native/ReactCommon"
|
||||||
RNCAsyncStorage:
|
RNCAsyncStorage:
|
||||||
:path: "../../../node_modules/@react-native-async-storage/async-storage"
|
:path: "../../../node_modules/@react-native-async-storage/async-storage"
|
||||||
|
RNFBAnalytics:
|
||||||
|
:path: "../../../node_modules/@react-native-firebase/analytics"
|
||||||
|
RNFBApp:
|
||||||
|
:path: "../../../node_modules/@react-native-firebase/app"
|
||||||
RNGestureHandler:
|
RNGestureHandler:
|
||||||
:path: "../../../node_modules/react-native-gesture-handler"
|
:path: "../../../node_modules/react-native-gesture-handler"
|
||||||
RNGoogleSignin:
|
RNGoogleSignin:
|
||||||
@@ -2461,8 +2560,8 @@ SPEC CHECKSUMS:
|
|||||||
EXNotifications: 9d3d17e52c95f377750d4a2ae553a716313dd4aa
|
EXNotifications: 9d3d17e52c95f377750d4a2ae553a716313dd4aa
|
||||||
Expo: 3889933926fade17d8607f449c57b87c4022433f
|
Expo: 3889933926fade17d8607f449c57b87c4022433f
|
||||||
expo-dev-client: db44302cdbe0ec55b0ef1849c9a23a76dec6dbac
|
expo-dev-client: db44302cdbe0ec55b0ef1849c9a23a76dec6dbac
|
||||||
expo-dev-launcher: 82de1bf29e91f4794bcc65a697e7066d9a826bee
|
expo-dev-launcher: c67c6a87f8a8ff54abda104abdc057ee4565a3f4
|
||||||
expo-dev-menu: 3fda3eb2b3394dc92955f6ff60cb278f843641be
|
expo-dev-menu: 1426660b67d2d594f70ec1d8a85bc26cafcf1bce
|
||||||
expo-dev-menu-interface: 00dc42302a72722fdecec3fa048de84a9133bcc4
|
expo-dev-menu-interface: 00dc42302a72722fdecec3fa048de84a9133bcc4
|
||||||
ExpoAdapterGoogleSignIn: 21ff1daeef3d21cb0b45772d8029102cc728d6b1
|
ExpoAdapterGoogleSignIn: 21ff1daeef3d21cb0b45772d8029102cc728d6b1
|
||||||
ExpoAppleAuthentication: 52631ed9dcb71c65712a447bbb9a5667bb8fcf0c
|
ExpoAppleAuthentication: 52631ed9dcb71c65712a447bbb9a5667bb8fcf0c
|
||||||
@@ -2480,23 +2579,29 @@ SPEC CHECKSUMS:
|
|||||||
ExpoLinking: 8d12bee174ba0cdf31239706578e29e74a417402
|
ExpoLinking: 8d12bee174ba0cdf31239706578e29e74a417402
|
||||||
ExpoLocalAuthentication: eb2be9c7bcdc68e9434d4be4bb5aa5cf7943e816
|
ExpoLocalAuthentication: eb2be9c7bcdc68e9434d4be4bb5aa5cf7943e816
|
||||||
ExpoMediaLibrary: 2709fc2e4fcf87d28767714abcc603c4bec228c6
|
ExpoMediaLibrary: 2709fc2e4fcf87d28767714abcc603c4bec228c6
|
||||||
ExpoModulesCore: 725faec070d590810d2ea5983d9f78f7cf6a38ec
|
ExpoModulesCore: 2d8d1c8e238bd5e1448b06c284ac896b162fb856
|
||||||
ExpoSecureStore: 9a3665d7161dd1031be386e278bffd623658f316
|
ExpoSecureStore: 9a3665d7161dd1031be386e278bffd623658f316
|
||||||
EXUpdatesInterface: 7c977640bdd8b85833c19e3959ba46145c5719db
|
EXUpdatesInterface: 7c977640bdd8b85833c19e3959ba46145c5719db
|
||||||
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
|
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
|
||||||
FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45
|
FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45
|
||||||
|
Firebase: 9a58fdbc9d8655ed7b79a19cf9690bb007d3d46d
|
||||||
|
FirebaseAnalytics: f20bbad8cb7f65d8a5eaefeb424ae8800a31bdfc
|
||||||
|
FirebaseCore: 0dbad74bda10b8fb9ca34ad8f375fb9dd3ebef7c
|
||||||
|
FirebaseCoreInternal: fe5fa466aeb314787093a7dce9f0beeaad5a2a21
|
||||||
|
FirebaseInstallations: 6a14ab3d694ebd9f839c48d330da5547e9ca9dc0
|
||||||
fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6
|
fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6
|
||||||
glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
|
glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
|
||||||
GoogleSignIn: c7f09cfbc85a1abf69187be091997c317cc33b77
|
GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17
|
||||||
|
GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d
|
||||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||||
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
|
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
|
||||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||||
hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11
|
hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11
|
||||||
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
|
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
|
||||||
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
|
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
|
||||||
OpenSSL-Universal: 6082b0bf950e5636fe0d78def171184e2b3899c2
|
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428
|
PurchasesHybridCommon: c2f8f5fcd2e472c585fccd1ab2fe317a3dc1171e
|
||||||
RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17
|
RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17
|
||||||
RCTDeprecation: ebe712bb05077934b16c6bf25228bdec34b64f83
|
RCTDeprecation: ebe712bb05077934b16c6bf25228bdec34b64f83
|
||||||
RCTRequired: ca91e5dd26b64f577b528044c962baf171c6b716
|
RCTRequired: ca91e5dd26b64f577b528044c962baf171c6b716
|
||||||
@@ -2506,70 +2611,71 @@ SPEC CHECKSUMS:
|
|||||||
React-Core: a68cea3e762814e60ecc3fa521c7f14c36c99245
|
React-Core: a68cea3e762814e60ecc3fa521c7f14c36c99245
|
||||||
React-CoreModules: d81b1eaf8066add66299bab9d23c9f00c9484c7c
|
React-CoreModules: d81b1eaf8066add66299bab9d23c9f00c9484c7c
|
||||||
React-cxxreact: 984f8b1feeca37181d4e95301fcd6f5f6501c6ab
|
React-cxxreact: 984f8b1feeca37181d4e95301fcd6f5f6501c6ab
|
||||||
React-debug: 817160c07dc8d24d020fbd1eac7b3558ffc08964
|
React-debug: f1956672cf545729afb71ef838a9789c2a9088f5
|
||||||
React-defaultsnativemodule: 21f216e8db975897eb32b5f13247f5bbfaa97f41
|
React-defaultsnativemodule: 30db2698d8b37831cf97098978ddc8ea97251898
|
||||||
React-domnativemodule: 19270ad4b8d33312838d257f24731a0026809d49
|
React-domnativemodule: 1c018f599b60aa292d764bcee1c52716919940c6
|
||||||
React-Fabric: f6dade7007533daeb785ba5925039d83f343be4b
|
React-Fabric: f637bbb636e3348a66d269f179cd39be1d31ee3d
|
||||||
React-FabricComponents: b0655cc3e1b5ae12a4a1119aa7d8308f0ad33520
|
React-FabricComponents: 9542268e7cc59a49f0c2c1906d8ab9729a979b5a
|
||||||
React-FabricImage: 9b157c4c01ac2bf433f834f0e1e5fe234113a576
|
React-FabricImage: e2f72efe22e23ff3740502553614f1c2d86132c9
|
||||||
React-featureflags: f2792b067a351d86fdc7bec23db3b9a2f2c8d26c
|
React-featureflags: dc9c91fcd6a75cc95bd7fec184d0be8ad0f26db5
|
||||||
React-featureflagsnativemodule: 3a8731d8fd9f755be57e00d9fa8a7f92aa77e87d
|
React-featureflagsnativemodule: 1c6ca282bd47de71a16420e29e7de28d46e59110
|
||||||
React-graphics: 68969e4e49d73f89da7abef4116c9b5f466aa121
|
React-graphics: 85866d95589b775bcb5f02946a96e948f53088f1
|
||||||
React-hermes: ac0bcba26a5d288ebc99b500e1097da2d0297ddf
|
React-hermes: ac0bcba26a5d288ebc99b500e1097da2d0297ddf
|
||||||
React-idlecallbacksnativemodule: 9a2c5b5c174c0c476f039bedc1b9497a8272133e
|
React-idlecallbacksnativemodule: d8e52f9e2bbc15f57714a6def9ba7ecea0f59f9f
|
||||||
React-ImageManager: e906eec93a9eb6102a06576b89d48d80a4683020
|
React-ImageManager: 5b3843bea322e4884400f1d78cfde7c435c374bc
|
||||||
React-jserrorhandler: ac5dde01104ff444e043cad8f574ca02756e20d6
|
React-jserrorhandler: 23161cf9fef2da6d56bf5cfda14f4abc8d62b286
|
||||||
React-jsi: 496fa2b9d63b726aeb07d0ac800064617d71211d
|
React-jsi: 496fa2b9d63b726aeb07d0ac800064617d71211d
|
||||||
React-jsiexecutor: dd22ab48371b80f37a0a30d0e8915b6d0f43a893
|
React-jsiexecutor: dd22ab48371b80f37a0a30d0e8915b6d0f43a893
|
||||||
React-jsinspector: 4629ac376f5765e684d19064f2093e55c97fd086
|
React-jsinspector: 3f8d148785a57f082e71382657c5fbe1be75bcdf
|
||||||
React-jsitracing: 7a1c9cd484248870cf660733cd3b8114d54c035f
|
React-jsitracing: 69456514c86311eb042d824e4cf69f9b524b5b97
|
||||||
React-logger: c4052eb941cca9a097ef01b59543a656dc088559
|
React-logger: c4052eb941cca9a097ef01b59543a656dc088559
|
||||||
React-Mapbuffer: 33546a3ebefbccb8770c33a1f8a5554fa96a54de
|
React-Mapbuffer: 9343a5c14536d4463c80f09a960653d754daae21
|
||||||
React-microtasksnativemodule: 5c3d795318c22ab8df55100e50b151384a4a60b3
|
React-microtasksnativemodule: 338bfa0a7d8cd1ed212b055f2143a6789252137a
|
||||||
react-native-compressor: 2c3a41768bdd367f6e121f555dff9d3eda1810e5
|
react-native-compressor: 40b3e2b942aebac3daaae9549b23a13510c67a6f
|
||||||
react-native-quick-crypto: 7bb26af9b3705dbb9277c09305104d37ede74916
|
|
||||||
react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448
|
react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448
|
||||||
React-nativeconfig: 8efdb1ef1e9158c77098a93085438f7e7b463678
|
React-nativeconfig: 415626a63057638759bcc75e0a96e2e07771a479
|
||||||
React-NativeModulesApple: cebca2e5320a3d66e123cade23bd90a167ffce5e
|
React-NativeModulesApple: d33b55553c6957ff94835574636838d78121a1c6
|
||||||
React-perflogger: 72e653eb3aba9122f9e57cf012d22d2486f33358
|
React-perflogger: 72e653eb3aba9122f9e57cf012d22d2486f33358
|
||||||
React-performancetimeline: cd6a9374a72001165995d2ab632f672df04076dc
|
React-performancetimeline: f831af5af289407b87cdfbceafb7623e5960f9f3
|
||||||
React-RCTActionSheet: aacf2375084dea6e7c221f4a727e579f732ff342
|
React-RCTActionSheet: aacf2375084dea6e7c221f4a727e579f732ff342
|
||||||
React-RCTAnimation: 395ab53fd064dff81507c15efb781c8684d9a585
|
React-RCTAnimation: 395ab53fd064dff81507c15efb781c8684d9a585
|
||||||
React-RCTAppDelegate: 1e5b43833e3e36e9fa34eec20be98174bc0e14a2
|
React-RCTAppDelegate: 1e5b43833e3e36e9fa34eec20be98174bc0e14a2
|
||||||
React-RCTBlob: 13311e554c1a367de063c10ee7c5e6573b2dd1d6
|
React-RCTBlob: 13311e554c1a367de063c10ee7c5e6573b2dd1d6
|
||||||
React-RCTFabric: bd906861a4e971e21d8df496c2d8f3ca6956f840
|
React-RCTFabric: 07e1434e6bd8124460c4b4eb3bdb82b58bfafaee
|
||||||
React-RCTImage: 1b1f914bcc12187c49ba5d949dac38c2eb9f5cc8
|
React-RCTImage: 1b1f914bcc12187c49ba5d949dac38c2eb9f5cc8
|
||||||
React-RCTLinking: 4ac7c42beb65e36fba0376f3498f3cd8dd0be7fa
|
React-RCTLinking: 4ac7c42beb65e36fba0376f3498f3cd8dd0be7fa
|
||||||
React-RCTNetwork: 938902773add4381e84426a7aa17a2414f5f94f7
|
React-RCTNetwork: 938902773add4381e84426a7aa17a2414f5f94f7
|
||||||
React-RCTSettings: e848f1ba17a7a18479cf5a31d28145f567da8223
|
React-RCTSettings: e848f1ba17a7a18479cf5a31d28145f567da8223
|
||||||
React-RCTText: 7e98fafdde7d29e888b80f0b35544e0cb07913cf
|
React-RCTText: 7e98fafdde7d29e888b80f0b35544e0cb07913cf
|
||||||
React-RCTVibration: cd7d80affd97dc7afa62f9acd491419558b64b78
|
React-RCTVibration: cd7d80affd97dc7afa62f9acd491419558b64b78
|
||||||
React-rendererconsistency: b4917053ecbaa91469c67a4319701c9dc0d40be6
|
React-rendererconsistency: b89c074e22ca2b9eaa8013e1310c69db85364893
|
||||||
React-rendererdebug: aa181c36dd6cf5b35511d1ed875d6638fd38f0ec
|
React-rendererdebug: 25290691d85200d9d229f2c41c24b285a9f04e4b
|
||||||
React-rncore: 120d21715c9b4ba8f798bffe986cb769b988dd74
|
React-rncore: 2a6efd857e10a3b299f8a7fe912fd332aa7e515a
|
||||||
React-RuntimeApple: d033becbbd1eba6f9f6e3af6f1893030ce203edd
|
React-RuntimeApple: 383f3b59440644f555d0a475379c5ee97c3173f6
|
||||||
React-RuntimeCore: 38af280bb678e66ba000a3c3d42920b2a138eebb
|
React-RuntimeCore: 753cb0bcae285509787d465d176c074ef7a6c537
|
||||||
React-runtimeexecutor: 877596f82f5632d073e121cba2d2084b76a76899
|
React-runtimeexecutor: 877596f82f5632d073e121cba2d2084b76a76899
|
||||||
React-RuntimeHermes: 37aad735ff21ca6de2d8450a96de1afe9f86c385
|
React-RuntimeHermes: bad4e404e8a6c25bc6ebcfd5eccb671bed76a67d
|
||||||
React-runtimescheduler: 8ec34cc885281a34696ea16c4fd86892d631f38d
|
React-runtimescheduler: a352af9ab3939273ee0e02650cfc1c8ee6e4d0c9
|
||||||
React-timing: 331cbf9f2668c67faddfd2e46bb7f41cbd9320b9
|
React-timing: a693c531e5627dcc200fc7286cbbebf73d73469d
|
||||||
React-utils: ed818f19ab445000d6b5c4efa9d462449326cc9f
|
React-utils: 59c5bbbc0e72be22c9d6eceb40afadf9be872819
|
||||||
ReactCodegen: 2a46abb2e345dc8efaff0a724f5f8639230eb974
|
ReactCodegen: 52b654894b5771d0085b24d4b1b897ebeaf939dc
|
||||||
ReactCommon: 300d8d9c5cb1a6cd79a67cf5d8f91e4d477195f9
|
ReactCommon: b2eb96a61b826ff327a773a74357b302cf6da678
|
||||||
RevenueCat: eab035bbab271faccfef5c36eaff2a1ffef14dc0
|
RevenueCat: 347221fad5ab0ed5636546867deae2c32c64749f
|
||||||
RNCAsyncStorage: aa75595c1aefa18f868452091fa0c411a516ce11
|
RNCAsyncStorage: aa75595c1aefa18f868452091fa0c411a516ce11
|
||||||
RNGestureHandler: 32336458ef842088a459ccfcc10b583186de2633
|
RNFBAnalytics: d1c906bbb01b3a6f6b99d6eaa977f04b1ae5d279
|
||||||
|
RNFBApp: fecce94543a693bcc73807a83ce5e9d70ff68725
|
||||||
|
RNGestureHandler: 2dd124adb171aab16629e9387cf1830957d242c2
|
||||||
RNGoogleSignin: 5782dd7dad559608a06f3c6b1d4142c395fb5308
|
RNGoogleSignin: 5782dd7dad559608a06f3c6b1d4142c395fb5308
|
||||||
RNPurchases: cc3333cede6c0f040545a95c38ae627d0174c058
|
RNPurchases: e265e2ed27349b567b91b89d8712aa8c9c57f5b0
|
||||||
RNReanimated: 82bf292e3c590d2d9886039f83e1baf0c1b2cdc4
|
RNReanimated: 38e033fd1790c817678bc8a075277cdc49d6817c
|
||||||
RNScreens: 295d9c0aaeb7f680d03d7e9b476569a4959aae89
|
RNScreens: ad2d0cfb3f15c9ed56ffb43651366ec0dcd70c36
|
||||||
RNSentry: 41eab31e46a191a4f9296d64a318b2a6bf0f98c5
|
RNSentry: a8f0d86b88675719ce96cb31bd9258dbd793e535
|
||||||
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
|
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
|
||||||
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
||||||
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
||||||
Sentry: 1ca8405451040482877dcd344dfa3ef80b646631
|
Sentry: 1ca8405451040482877dcd344dfa3ef80b646631
|
||||||
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
|
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
|
||||||
Yoga: feb4910aba9742cfedc059e2b2902e22ffe9954a
|
Yoga: 1259c7a8cbaccf7b4c3ddf8ee36ca11be9dee407
|
||||||
|
|
||||||
PODFILE CHECKSUM: 085b90662c05f70d43685ff8bec1ea714a98abb3
|
PODFILE CHECKSUM: 085b90662c05f70d43685ff8bec1ea714a98abb3
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"expo.jsEngine": "hermes",
|
"expo.jsEngine": "hermes",
|
||||||
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
|
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
|
||||||
"newArchEnabled": "false",
|
"newArchEnabled": "false",
|
||||||
|
"ios.useFrameworks": "static",
|
||||||
"apple.extraPods": "[]",
|
"apple.extraPods": "[]",
|
||||||
"apple.ccacheEnabled": "false",
|
"apple.ccacheEnabled": "false",
|
||||||
"apple.privacyManifestAggregationEnabled": "true"
|
"apple.privacyManifestAggregationEnabled": "true"
|
||||||
|
|||||||
@@ -3,36 +3,38 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 54;
|
objectVersion = 46;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
|
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
|
||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||||
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
|
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
|
||||||
|
1AFB24071BC9A2F5B90C1951 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 787323E49260E99741AD6413 /* PrivacyInfo.xcprivacy */; };
|
||||||
|
1F3D2CE02DD8CC2E2BA9F93A /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 27B40580D18D29CBD94DD2C3 /* Pods_Sauci.framework */; };
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||||
656E142A44D7042462875BC2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 0FE9A3D4025061824A4C95F7 /* PrivacyInfo.xcprivacy */; };
|
47690AB7B3224CD3B1655332 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2EDE49AC9AAC445892628B2B /* GoogleService-Info.plist */; };
|
||||||
96905EF65AED1B983A6B3ABC /* libPods-Sauci.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */; };
|
|
||||||
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
|
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
|
||||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||||
BBC1EC435C914364BBC9058D /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 157EFF60C4BA4BA694688B2F /* noop-file.swift */; };
|
E52FFD661AC14FC08C4F239D /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31F85F9C76D34B859549F3A7 /* noop-file.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
0FE9A3D4025061824A4C95F7 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
|
||||||
13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Sauci/AppDelegate.h; sourceTree = "<group>"; };
|
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Sauci/AppDelegate.h; sourceTree = "<group>"; };
|
||||||
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Sauci/AppDelegate.mm; sourceTree = "<group>"; };
|
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Sauci/AppDelegate.mm; sourceTree = "<group>"; };
|
||||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; };
|
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; };
|
||||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
|
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
|
||||||
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; };
|
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; };
|
||||||
157EFF60C4BA4BA694688B2F /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
|
27B40580D18D29CBD94DD2C3 /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Sauci.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
2EDE49AC9AAC445892628B2B /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||||
|
31F85F9C76D34B859549F3A7 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
|
||||||
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; };
|
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
787323E49260E99741AD6413 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; };
|
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
A9934E72B0154500A352F0F1 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; };
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||||
DFD02E18A6174627BE6CBF41 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
|
|
||||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||||
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -42,7 +44,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
96905EF65AED1B983A6B3ABC /* libPods-Sauci.a in Frameworks */,
|
1F3D2CE02DD8CC2E2BA9F93A /* Pods_Sauci.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -59,9 +61,10 @@
|
|||||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||||
13B07FB71A68108700A75B9A /* main.m */,
|
13B07FB71A68108700A75B9A /* main.m */,
|
||||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
||||||
157EFF60C4BA4BA694688B2F /* noop-file.swift */,
|
2EDE49AC9AAC445892628B2B /* GoogleService-Info.plist */,
|
||||||
DFD02E18A6174627BE6CBF41 /* Sauci-Bridging-Header.h */,
|
31F85F9C76D34B859549F3A7 /* noop-file.swift */,
|
||||||
0FE9A3D4025061824A4C95F7 /* PrivacyInfo.xcprivacy */,
|
A9934E72B0154500A352F0F1 /* Sauci-Bridging-Header.h */,
|
||||||
|
787323E49260E99741AD6413 /* PrivacyInfo.xcprivacy */,
|
||||||
);
|
);
|
||||||
name = Sauci;
|
name = Sauci;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -70,7 +73,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||||
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */,
|
27B40580D18D29CBD94DD2C3 /* Pods_Sauci.framework */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -147,14 +150,15 @@
|
|||||||
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */;
|
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
|
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
|
||||||
B4F8858AB365CBF283E38EB6 /* [Expo] Configure project */,
|
DB0F43D5EFF9CB0780FDD6A5 /* [Expo] Configure project */,
|
||||||
13B07F871A680F5B00A75B9A /* Sources */,
|
13B07F871A680F5B00A75B9A /* Sources */,
|
||||||
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
||||||
13B07F8E1A680F5B00A75B9A /* Resources */,
|
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
|
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
|
||||||
92A7F2AE90734CFDBD5EA084 /* Upload Debug Symbols to Sentry */,
|
F051EF99F56144029D3DFD83 /* Upload Debug Symbols to Sentry */,
|
||||||
EE1F9249669B846739AA38F5 /* [CP] Embed Pods Frameworks */,
|
D1E774350CB6864AD97A24B4 /* [CP] Embed Pods Frameworks */,
|
||||||
|
658D10E2FBC1B3A1C410A9EB /* [CP-User] [RNFB] Core Configuration */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -175,6 +179,8 @@
|
|||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
13B07F861A680F5B00A75B9A = {
|
13B07F861A680F5B00A75B9A = {
|
||||||
LastSwiftMigration = 1250;
|
LastSwiftMigration = 1250;
|
||||||
|
DevelopmentTeam = "BXVPBATDZY";
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -204,7 +210,8 @@
|
|||||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||||
656E142A44D7042462875BC2 /* PrivacyInfo.xcprivacy in Resources */,
|
47690AB7B3224CD3B1655332 /* GoogleService-Info.plist in Resources */,
|
||||||
|
1AFB24071BC9A2F5B90C1951 /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -248,6 +255,19 @@
|
|||||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
showEnvVarsInLog = 0;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
|
658D10E2FBC1B3A1C410A9EB /* [CP-User] [RNFB] Core Configuration */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
|
||||||
|
);
|
||||||
|
name = "[CP-User] [RNFB] Core Configuration";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"note: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"note: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"note: -> RNFB build script started\"\necho \"note: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"note: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"note: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n if ! _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\"); then\n echo \"error: Failed to parse firebase.json, check for syntax errors.\"\n exit 1\n fi\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"error: python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACLine truncated
|
||||||
|
};
|
||||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
|
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -262,6 +282,9 @@
|
|||||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCore/FirebaseCore_Privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreInternal/FirebaseCoreInternal_Privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseInstallations/FirebaseInstallations_Privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/GTMAppAuth/GTMAppAuth_Privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/GTMAppAuth/GTMAppAuth_Privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/GTMSessionFetcher/GTMSessionFetcher_Core_Privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/GTMSessionFetcher/GTMSessionFetcher_Core_Privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle",
|
||||||
@@ -279,6 +302,7 @@
|
|||||||
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/nanopb/nanopb_Privacy.bundle",
|
||||||
);
|
);
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
@@ -289,6 +313,9 @@
|
|||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCore_Privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCoreInternal_Privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseInstallations_Privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMAppAuth_Privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMAppAuth_Privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMSessionFetcher_Core_Privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMSessionFetcher_Core_Privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle",
|
||||||
@@ -306,27 +333,32 @@
|
|||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/nanopb_Privacy.bundle",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n";
|
||||||
showEnvVarsInLog = 0;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
92A7F2AE90734CFDBD5EA084 /* Upload Debug Symbols to Sentry */ = {
|
D1E774350CB6864AD97A24B4 /* [CP] Embed Pods Frameworks */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
inputPaths = (
|
inputPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh",
|
||||||
|
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
|
||||||
);
|
);
|
||||||
name = "Upload Debug Symbols to Sentry";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
B4F8858AB365CBF283E38EB6 /* [Expo] Configure project */ = {
|
DB0F43D5EFF9CB0780FDD6A5 /* [Expo] Configure project */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -345,25 +377,19 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n";
|
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n";
|
||||||
};
|
};
|
||||||
EE1F9249669B846739AA38F5 /* [CP] Embed Pods Frameworks */ = {
|
F051EF99F56144029D3DFD83 /* Upload Debug Symbols to Sentry */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
inputPaths = (
|
inputPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh",
|
|
||||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL",
|
|
||||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
|
|
||||||
);
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "Upload Debug Symbols to Sentry";
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenSSL.framework",
|
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n";
|
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
|
||||||
showEnvVarsInLog = 0;
|
|
||||||
};
|
};
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
@@ -375,7 +401,7 @@
|
|||||||
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
|
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
|
||||||
13B07FC11A68108700A75B9A /* main.m in Sources */,
|
13B07FC11A68108700A75B9A /* main.m in Sources */,
|
||||||
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
|
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
|
||||||
BBC1EC435C914364BBC9058D /* noop-file.swift in Sources */,
|
E52FFD661AC14FC08C4F239D /* noop-file.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -389,8 +415,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 18;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -398,10 +423,7 @@
|
|||||||
);
|
);
|
||||||
INFOPLIST_FILE = Sauci/Info.plist;
|
INFOPLIST_FILE = Sauci/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -416,6 +438,9 @@
|
|||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1";
|
TARGETED_DEVICE_FAMILY = "1";
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
DEVELOPMENT_TEAM = "BXVPBATDZY";
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -426,14 +451,10 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 18;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
|
||||||
INFOPLIST_FILE = Sauci/Info.plist;
|
INFOPLIST_FILE = Sauci/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -447,6 +468,9 @@
|
|||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1";
|
TARGETED_DEVICE_FAMILY = "1";
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
DEVELOPMENT_TEAM = "BXVPBATDZY";
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@@ -497,11 +521,19 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
HEADER_SEARCH_PATHS = (
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
/usr/lib/swift,
|
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
|
||||||
);
|
);
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
|
||||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
@@ -556,11 +588,19 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
HEADER_SEARCH_PATHS = (
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
/usr/lib/swift,
|
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
|
||||||
);
|
);
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
|
||||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#import "AppDelegate.h"
|
#import "AppDelegate.h"
|
||||||
|
#import <Firebase/Firebase.h>
|
||||||
|
|
||||||
#import <React/RCTBundleURLProvider.h>
|
#import <React/RCTBundleURLProvider.h>
|
||||||
#import <React/RCTLinkingManager.h>
|
#import <React/RCTLinkingManager.h>
|
||||||
@@ -7,6 +8,9 @@
|
|||||||
|
|
||||||
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
|
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
|
||||||
{
|
{
|
||||||
|
// @generated begin @react-native-firebase/app-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-ecd111c37e49fdd1ed6354203cd6b1e2a38cccda
|
||||||
|
[FIRApp configure];
|
||||||
|
// @generated end @react-native-firebase/app-didFinishLaunchingWithOptions
|
||||||
self.moduleName = @"main";
|
self.moduleName = @"main";
|
||||||
|
|
||||||
// You can add your custom initial props in the dictionary below.
|
// You can add your custom initial props in the dictionary below.
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>API_KEY</key>
|
||||||
|
<string>AIzaSyDlJoqvK9ONUVAKxDBFTGZg1I7slm2DuJ4</string>
|
||||||
|
<key>GCM_SENDER_ID</key>
|
||||||
|
<string>764866133492</string>
|
||||||
|
<key>PLIST_VERSION</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>BUNDLE_ID</key>
|
||||||
|
<string>com.sauci.app</string>
|
||||||
|
<key>PROJECT_ID</key>
|
||||||
|
<string>sauci-6840e</string>
|
||||||
|
<key>STORAGE_BUCKET</key>
|
||||||
|
<string>sauci-6840e.firebasestorage.app</string>
|
||||||
|
<key>IS_ADS_ENABLED</key>
|
||||||
|
<false></false>
|
||||||
|
<key>IS_ANALYTICS_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_APPINVITE_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_GCM_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_SIGNIN_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>GOOGLE_APP_ID</key>
|
||||||
|
<string>1:764866133492:ios:0290bfbe613a4f48fe4da1</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.0</string>
|
<string>1.0.2</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>25</string>
|
<string>31</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0</string>
|
<string>12.0</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
<array>
|
<array>
|
||||||
<string>CA92.1</string>
|
<string>CA92.1</string>
|
||||||
|
<string>1C8F.1</string>
|
||||||
<string>C56D.1</string>
|
<string>C56D.1</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -25,11 +25,9 @@ config.resolver.nodeModulesPaths = [
|
|||||||
// of the workspace root, unless they are installed in the project root.
|
// of the workspace root, unless they are installed in the project root.
|
||||||
config.resolver.disableHierarchicalLookup = true;
|
config.resolver.disableHierarchicalLookup = true;
|
||||||
|
|
||||||
// 4. E2EE crypto polyfill: resolve 'crypto' to react-native-quick-crypto
|
// 4. Keep extraNodeModules in case we need to add monorepo overrides later
|
||||||
// This enables Web Crypto API for encryption operations
|
|
||||||
config.resolver.extraNodeModules = {
|
config.resolver.extraNodeModules = {
|
||||||
...config.resolver.extraNodeModules,
|
...config.resolver.extraNodeModules,
|
||||||
crypto: require.resolve('react-native-quick-crypto'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
@@ -14,12 +14,16 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:ci": "jest --runInBand --ci",
|
"test:ci": "jest --runInBand --ci",
|
||||||
"release:ios": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile production --local",
|
"release:ios": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile production --local",
|
||||||
"release:android": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile production --local"
|
"release:ios:preview": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile preview --local",
|
||||||
|
"release:android": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile production --local",
|
||||||
|
"release:android:preview": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile preview --local"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/ngrok": "^4.1.0",
|
"@expo/ngrok": "^4.1.0",
|
||||||
"@expo/vector-icons": "~14.0.4",
|
"@expo/vector-icons": "~14.0.4",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
|
"@react-native-firebase/analytics": "^23.8.2",
|
||||||
|
"@react-native-firebase/app": "^23.8.2",
|
||||||
"@react-native-google-signin/google-signin": "^16.1.1",
|
"@react-native-google-signin/google-signin": "^16.1.1",
|
||||||
"@react-navigation/bottom-tabs": "^7.0.0",
|
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||||
"@react-navigation/native": "^7.0.0",
|
"@react-navigation/native": "^7.0.0",
|
||||||
@@ -51,14 +55,12 @@
|
|||||||
"expo-router": "~4.0.0",
|
"expo-router": "~4.0.0",
|
||||||
"expo-secure-store": "~14.0.0",
|
"expo-secure-store": "~14.0.0",
|
||||||
"expo-status-bar": "~2.0.0",
|
"expo-status-bar": "~2.0.0",
|
||||||
"posthog-react-native": "^4.17.1",
|
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-native": "0.76.9",
|
"react-native": "0.76.9",
|
||||||
"react-native-compressor": "^1.13.0",
|
"react-native-compressor": "^1.13.0",
|
||||||
"react-native-gesture-handler": "~2.20.0",
|
"react-native-gesture-handler": "~2.20.0",
|
||||||
"react-native-purchases": "^9.6.12",
|
"react-native-purchases": "^9.6.12",
|
||||||
"react-native-quick-crypto": "^0.7.11",
|
|
||||||
"react-native-reanimated": "~3.16.0",
|
"react-native-reanimated": "~3.16.0",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
"react-native-screens": "~4.4.0",
|
"react-native-screens": "~4.4.0",
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const { withProjectBuildGradle } = require("expo/config-plugins");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config plugin to ensure Kotlin 1.9.x is used in the Android build.
|
||||||
|
* This provides a safety net in case the default version causes issues.
|
||||||
|
*/
|
||||||
|
function withKotlinVersion(config, kotlinVersion = "1.9.25") {
|
||||||
|
return withProjectBuildGradle(config, (config) => {
|
||||||
|
if (config.modResults.language === "groovy") {
|
||||||
|
// Ensure Kotlin version is set correctly
|
||||||
|
config.modResults.contents = config.modResults.contents.replace(
|
||||||
|
/kotlinVersion = findProperty\('android\.kotlinVersion'\) \?: ['"][\d.]+['"]/,
|
||||||
|
`kotlinVersion = findProperty('android.kotlinVersion') ?: '${kotlinVersion}'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = withKotlinVersion;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Sauci Mobile Release Notes
|
||||||
|
|
||||||
|
## v1.0.1 (Build 30) - January 2025
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- **Match Archiving** - Archive old matches to keep your active list focused
|
||||||
|
- **Daily Swipe Limits** - Pace your discovery with daily question limits
|
||||||
|
- **Intensity Preferences** - Set your comfort level for question intensity (1-5 scale)
|
||||||
|
- **Infinite Scroll** - Smoother browsing through Matches and Answers with infinite scroll
|
||||||
|
- **Chat Date Separators** - Easily see when conversations happened
|
||||||
|
- **Lifecycle Notifications** - Get reminded to check in with your partner
|
||||||
|
- **Message Reporting** - Report inappropriate messages for review
|
||||||
|
- **Account Deletion** - Full account deletion option in settings
|
||||||
|
- **Reset Progress** - Start fresh by resetting your question progress
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- **Enhanced Swipe UI** - Smoother swiping experience with visual refinements
|
||||||
|
- **Improved Chat Layout** - Match card moved into message list for cleaner design
|
||||||
|
- **Keyboard Behavior** - Chat header collapses when keyboard opens for more screen space
|
||||||
|
- **Profile Settings** - New appearance and notification preferences
|
||||||
|
- **Premium Design** - Updated subscription card with gold styling
|
||||||
|
- **Version Display** - App version now visible in settings
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Fixed subscription status incorrectly showing "Via partner's" after direct purchase
|
||||||
|
- Fixed Android navigation issues
|
||||||
|
- Various performance improvements and stability fixes
|
||||||
|
|
||||||
|
### Privacy
|
||||||
|
- Removed partner answered indicator for enhanced privacy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.0.0 (Build 25) - January 8, 2025
|
||||||
|
|
||||||
|
Initial release.
|
||||||
@@ -15,6 +15,7 @@ describe('authStore', () => {
|
|||||||
partner: null,
|
partner: null,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
isAnonymous: false,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
useMatchStore.setState({ matches: [{ id: 'm1' }], newMatchesCount: 1 } as any);
|
useMatchStore.setState({ matches: [{ id: 'm1' }], newMatchesCount: 1 } as any);
|
||||||
@@ -31,9 +32,35 @@ describe('authStore', () => {
|
|||||||
const state = useAuthStore.getState();
|
const state = useAuthStore.getState();
|
||||||
expect(state.user).toBeNull();
|
expect(state.user).toBeNull();
|
||||||
expect(state.isAuthenticated).toBe(false);
|
expect(state.isAuthenticated).toBe(false);
|
||||||
|
expect(state.isAnonymous).toBe(false);
|
||||||
expect(state.isLoading).toBe(false);
|
expect(state.isLoading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sets isAnonymous from auth user', async () => {
|
||||||
|
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
||||||
|
data: { session: { user: { id: 'me' } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
(supabase.auth.getUser as jest.Mock).mockResolvedValueOnce({
|
||||||
|
data: { user: { id: 'me', is_anonymous: true } },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileQuery: any = {
|
||||||
|
select: jest.fn(() => profileQuery),
|
||||||
|
eq: jest.fn(() => profileQuery),
|
||||||
|
maybeSingle: jest.fn(async () => ({ data: { id: 'me', couple_id: null } })),
|
||||||
|
};
|
||||||
|
|
||||||
|
(supabase.from as jest.Mock).mockReturnValue(profileQuery);
|
||||||
|
|
||||||
|
await useAuthStore.getState().fetchUser();
|
||||||
|
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
expect(state.isAuthenticated).toBe(true);
|
||||||
|
expect(state.isAnonymous).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('clears stores and signs out when session invalid', async () => {
|
it('clears stores and signs out when session invalid', async () => {
|
||||||
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
||||||
data: { session: { user: { id: 'me' } } },
|
data: { session: { user: { id: 'me' } } },
|
||||||
@@ -50,51 +77,9 @@ describe('authStore', () => {
|
|||||||
|
|
||||||
expect(useAuthStore.getState().user).toBeNull();
|
expect(useAuthStore.getState().user).toBeNull();
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
|
expect(useAuthStore.getState().isAnonymous).toBe(false);
|
||||||
|
|
||||||
expect(useMatchStore.getState().matches).toEqual([]);
|
expect(useAuthStore.getState().isAnonymous).toBe(false);
|
||||||
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
|
|
||||||
expect(useMessageStore.getState().unreadCount).toBe(0);
|
|
||||||
expect(useMessageStore.getState().activeMatchId).toBeNull();
|
|
||||||
expect(useSubscriptionStore.getState().subscription.isProUser).toBe(false);
|
|
||||||
|
|
||||||
expect(supabase.auth.signOut).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches couple and partner when couple_id exists', async () => {
|
|
||||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1' } } as any);
|
|
||||||
|
|
||||||
const couplesQuery: any = {
|
|
||||||
select: jest.fn(() => couplesQuery),
|
|
||||||
eq: jest.fn(() => couplesQuery),
|
|
||||||
maybeSingle: jest.fn(async () => ({ data: { id: 'c1' } })),
|
|
||||||
};
|
|
||||||
|
|
||||||
const partnerQuery: any = {
|
|
||||||
select: jest.fn(() => partnerQuery),
|
|
||||||
eq: jest.fn(() => partnerQuery),
|
|
||||||
neq: jest.fn(() => partnerQuery),
|
|
||||||
maybeSingle: jest.fn(async () => ({ data: { id: 'partner' } })),
|
|
||||||
};
|
|
||||||
|
|
||||||
(supabase.from as jest.Mock).mockImplementation((table: string) => {
|
|
||||||
if (table === 'couples') return couplesQuery;
|
|
||||||
if (table === 'profiles') return partnerQuery;
|
|
||||||
return couplesQuery;
|
|
||||||
});
|
|
||||||
|
|
||||||
await useAuthStore.getState().fetchCouple();
|
|
||||||
|
|
||||||
expect(useAuthStore.getState().couple).toEqual({ id: 'c1' });
|
|
||||||
expect(useAuthStore.getState().partner).toEqual({ id: 'partner' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('signOut clears local state even if Supabase fails', async () => {
|
|
||||||
(supabase.auth.signOut as jest.Mock).mockRejectedValueOnce(new Error('network'));
|
|
||||||
|
|
||||||
await useAuthStore.getState().signOut();
|
|
||||||
|
|
||||||
expect(useAuthStore.getState().user).toBeNull();
|
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
|
||||||
expect(useAuthStore.getState().isLoading).toBe(false);
|
expect(useAuthStore.getState().isLoading).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -163,7 +163,7 @@ describe('subscriptionStore', () => {
|
|||||||
|
|
||||||
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
const state = useSubscriptionStore.getState();
|
const state = useSubscriptionStore.getState();
|
||||||
expect(state.subscription.isProUser).toBe(true);
|
expect(state.subscription.isProUser).toBe(true);
|
||||||
expect(state.isPurchasing).toBe(false);
|
expect(state.isPurchasing).toBe(false);
|
||||||
@@ -178,7 +178,8 @@ describe('subscriptionStore', () => {
|
|||||||
|
|
||||||
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.cancelled).toBe(true);
|
||||||
const state = useSubscriptionStore.getState();
|
const state = useSubscriptionStore.getState();
|
||||||
expect(state.error).toBe('Purchase was cancelled');
|
expect(state.error).toBe('Purchase was cancelled');
|
||||||
expect(state.isPurchasing).toBe(false);
|
expect(state.isPurchasing).toBe(false);
|
||||||
@@ -192,7 +193,8 @@ describe('subscriptionStore', () => {
|
|||||||
|
|
||||||
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.cancelled).toBe(false);
|
||||||
const state = useSubscriptionStore.getState();
|
const state = useSubscriptionStore.getState();
|
||||||
expect(state.error).toBe('Purchase failed. Please try again.');
|
expect(state.error).toBe('Purchase failed. Please try again.');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { StyleSheet, View, Text, TouchableOpacity, Switch, Platform } from 'react-native';
|
import { StyleSheet, View, Text, TouchableOpacity, Platform } from 'react-native';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -10,6 +10,7 @@ import Animated, {
|
|||||||
FadeIn,
|
FadeIn,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { ShimmerEffect } from './ui/ShimmerEffect';
|
import { ShimmerEffect } from './ui/ShimmerEffect';
|
||||||
|
import { GlassToggle } from './ui/GlassToggle';
|
||||||
import { colors, gradients, spacing, radius, typography, blur, shadows, animations } from '../theme';
|
import { colors, gradients, spacing, radius, typography, blur, shadows, animations } from '../theme';
|
||||||
import type { QuestionPack } from '../types';
|
import type { QuestionPack } from '../types';
|
||||||
|
|
||||||
@@ -150,16 +151,10 @@ export function BoutiquePackCard({
|
|||||||
<Text style={styles.questionLabel}>questions</Text>
|
<Text style={styles.questionLabel}>questions</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Switch
|
<GlassToggle
|
||||||
value={isEnabled}
|
value={isEnabled}
|
||||||
onValueChange={onToggle}
|
onValueChange={onToggle}
|
||||||
disabled={isPremiumLocked}
|
disabled={isPremiumLocked}
|
||||||
trackColor={{
|
|
||||||
false: 'rgba(255, 255, 255, 0.15)',
|
|
||||||
true: pack.is_premium ? colors.premium.gold : colors.primary,
|
|
||||||
}}
|
|
||||||
thumbColor={colors.text}
|
|
||||||
ios_backgroundColor="rgba(255, 255, 255, 0.15)"
|
|
||||||
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
|
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
Pressable,
|
Pressable,
|
||||||
Animated,
|
Animated,
|
||||||
|
Keyboard,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
@@ -106,6 +107,9 @@ export const MatchNotificationModal: React.FC<Props> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
|
// Dismiss keyboard so it doesn't block the modal buttons
|
||||||
|
Keyboard.dismiss();
|
||||||
|
|
||||||
// Reset animations
|
// Reset animations
|
||||||
modalScale.setValue(0.8);
|
modalScale.setValue(0.8);
|
||||||
modalOpacity.setValue(0);
|
modalOpacity.setValue(0);
|
||||||
@@ -173,7 +177,7 @@ export const MatchNotificationModal: React.FC<Props> = ({
|
|||||||
]).start(() => onChat());
|
]).start(() => onChat());
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!visible || !match) return null;
|
if (!visible || !match || !match.question) return null;
|
||||||
|
|
||||||
// Determine responses based on match_type if not explicitly provided
|
// Determine responses based on match_type if not explicitly provided
|
||||||
const getUserResponse = (): AnswerType => {
|
const getUserResponse = (): AnswerType => {
|
||||||
@@ -262,13 +266,15 @@ export const MatchNotificationModal: React.FC<Props> = ({
|
|||||||
{/* Question text and responses */}
|
{/* Question text and responses */}
|
||||||
{match.question.partner_text ? (
|
{match.question.partner_text ? (
|
||||||
// Two-part question - show what each person saw
|
// Two-part question - show what each person saw
|
||||||
|
// The current user triggered the match (answered second), so they saw partner_text
|
||||||
|
// The partner answered first, so they saw text
|
||||||
<View style={styles.twoPartContainer}>
|
<View style={styles.twoPartContainer}>
|
||||||
<View style={styles.responseSection}>
|
<View style={styles.responseSection}>
|
||||||
<View style={styles.responseLabelRow}>
|
<View style={styles.responseLabelRow}>
|
||||||
<Text style={styles.responsePersonLabel}>You said</Text>
|
<Text style={styles.responsePersonLabel}>You said</Text>
|
||||||
<ResponseBadge response={userResponse} />
|
<ResponseBadge response={userResponse} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.questionTextSmall}>"{match.question.text}"</Text>
|
<Text style={styles.questionTextSmall}>"{match.question.partner_text}"</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.matchConnector}>
|
<View style={styles.matchConnector}>
|
||||||
@@ -284,7 +290,7 @@ export const MatchNotificationModal: React.FC<Props> = ({
|
|||||||
<Text style={styles.responsePersonLabel}>{partnerDisplayName} said</Text>
|
<Text style={styles.responsePersonLabel}>{partnerDisplayName} said</Text>
|
||||||
<ResponseBadge response={partnerResponse} isPartner />
|
<ResponseBadge response={partnerResponse} isPartner />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.questionTextSmall}>"{match.question.partner_text}"</Text>
|
<Text style={styles.questionTextSmall}>"{match.question.text}"</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useStreakStore } from '../store';
|
||||||
|
import { colors, spacing, radius, typography } from '../theme';
|
||||||
|
|
||||||
|
interface StreakDisplayProps {
|
||||||
|
/** Whether to show the "longest streak" indicator */
|
||||||
|
showLongest?: boolean;
|
||||||
|
/** Animation delay for entry animation */
|
||||||
|
delay?: number;
|
||||||
|
/** Compact mode for smaller displays */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the couple's current streak with a flame icon.
|
||||||
|
* The flame pulses when there's an active streak.
|
||||||
|
*/
|
||||||
|
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
|
||||||
|
const { streak, isLoading, fetchStreak } = useStreakStore();
|
||||||
|
|
||||||
|
// Fetch streak on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStreak();
|
||||||
|
}, [fetchStreak]);
|
||||||
|
|
||||||
|
// Flame pulse animation
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (streak && streak.current_streak > 0) {
|
||||||
|
scale.value = withRepeat(
|
||||||
|
withSequence(
|
||||||
|
withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
|
||||||
|
withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) })
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
scale.value = 1;
|
||||||
|
}
|
||||||
|
}, [streak?.current_streak, scale]);
|
||||||
|
|
||||||
|
const flameStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: scale.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Don't show if loading or no couple
|
||||||
|
if (isLoading || !streak) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasStreak = streak.current_streak > 0;
|
||||||
|
const currentStreak = streak.current_streak;
|
||||||
|
const longestStreak = streak.longest_streak;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Animated.View entering={FadeInDown.delay(delay).duration(400)}>
|
||||||
|
<View style={styles.compactContainer}>
|
||||||
|
<Animated.View style={flameStyle}>
|
||||||
|
<Ionicons
|
||||||
|
name="flame"
|
||||||
|
size={16}
|
||||||
|
color={hasStreak ? colors.warning : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
<Text style={[styles.compactCount, !hasStreak && styles.inactiveText]}>
|
||||||
|
{currentStreak}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View entering={FadeInDown.delay(delay).duration(500)}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Main streak display */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={hasStreak ? ['rgba(243, 156, 18, 0.2)', 'rgba(233, 69, 96, 0.1)'] : ['rgba(255, 255, 255, 0.05)', 'rgba(255, 255, 255, 0.02)']}
|
||||||
|
style={styles.card}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* Flame icon */}
|
||||||
|
<Animated.View style={[styles.iconContainer, flameStyle]}>
|
||||||
|
<Ionicons
|
||||||
|
name="flame"
|
||||||
|
size={32}
|
||||||
|
color={hasStreak ? colors.warning : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Streak info */}
|
||||||
|
<View style={styles.info}>
|
||||||
|
<Text style={[styles.count, !hasStreak && styles.inactiveText]}>
|
||||||
|
{currentStreak}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
day{currentStreak !== 1 ? 's' : ''} together
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Longest streak */}
|
||||||
|
{showLongest && longestStreak > 0 && (
|
||||||
|
<View style={styles.longest}>
|
||||||
|
<Ionicons name="trophy-outline" size={12} color={colors.textTertiary} />
|
||||||
|
<Text style={styles.longestText}>
|
||||||
|
Best: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
...typography.title1,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
inactiveText: {
|
||||||
|
color: colors.textTertiary,
|
||||||
|
},
|
||||||
|
longest: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
longestText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
},
|
||||||
|
// Compact styles
|
||||||
|
compactContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
},
|
||||||
|
compactCount: {
|
||||||
|
...typography.caption1,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.warning,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default StreakDisplay;
|
||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { useSubscriptionStore } from "../../store";
|
import { router } from "expo-router";
|
||||||
|
import { useAuthStore, useSubscriptionStore } from "../../store";
|
||||||
import { colors, gradients, spacing, radius, typography } from "../../theme";
|
import { colors, gradients, spacing, radius, typography } from "../../theme";
|
||||||
import type { PurchasesPackage } from "../../lib/revenuecat";
|
import type { PurchasesPackage } from "../../lib/revenuecat";
|
||||||
import { Events } from "../../lib/analytics";
|
import { Events } from "../../lib/analytics";
|
||||||
@@ -43,6 +44,12 @@ const FEATURES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
||||||
|
const { isAnonymous } = useAuthStore();
|
||||||
|
|
||||||
|
const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => {
|
||||||
|
Events.paywallClosed("paywall", reason);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
const {
|
const {
|
||||||
offerings,
|
offerings,
|
||||||
isLoadingOfferings,
|
isLoadingOfferings,
|
||||||
@@ -56,6 +63,8 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
const [selectedPackage, setSelectedPackage] = useState<PurchasesPackage | null>(
|
const [selectedPackage, setSelectedPackage] = useState<PurchasesPackage | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
const [showGuestConfirm, setShowGuestConfirm] = useState(false);
|
||||||
|
const [guestConfirmed, setGuestConfirmed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible && !offerings) {
|
if (visible && !offerings) {
|
||||||
@@ -63,6 +72,8 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
}
|
}
|
||||||
if (visible) {
|
if (visible) {
|
||||||
Events.paywallShown("paywall");
|
Events.paywallShown("paywall");
|
||||||
|
setGuestConfirmed(false);
|
||||||
|
setShowGuestConfirm(false);
|
||||||
}
|
}
|
||||||
}, [visible, offerings, fetchOfferings]);
|
}, [visible, offerings, fetchOfferings]);
|
||||||
|
|
||||||
@@ -76,16 +87,37 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
}
|
}
|
||||||
}, [offerings]);
|
}, [offerings]);
|
||||||
|
|
||||||
|
const doPurchase = async () => {
|
||||||
|
if (!selectedPackage) return;
|
||||||
|
|
||||||
|
const packageType = selectedPackage.packageType || "unknown";
|
||||||
|
Events.purchaseInitiated(packageType);
|
||||||
|
|
||||||
|
const result = await purchasePackage(selectedPackage);
|
||||||
|
if (result.success) {
|
||||||
|
Events.purchaseCompleted(packageType);
|
||||||
|
onSuccess?.();
|
||||||
|
handleClose("success");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.cancelled) {
|
||||||
|
Events.purchaseCancelled(packageType, result.errorCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Events.purchaseFailed(packageType, result.errorCode, result.errorMessage);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePurchase = async () => {
|
const handlePurchase = async () => {
|
||||||
if (!selectedPackage) return;
|
if (!selectedPackage) return;
|
||||||
|
|
||||||
Events.purchaseInitiated(selectedPackage.packageType || "unknown");
|
if (isAnonymous && !guestConfirmed) {
|
||||||
const success = await purchasePackage(selectedPackage);
|
setShowGuestConfirm(true);
|
||||||
if (success) {
|
return;
|
||||||
Events.purchaseCompleted(selectedPackage.packageType || "unknown");
|
|
||||||
onSuccess?.();
|
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await doPurchase();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestore = async () => {
|
const handleRestore = async () => {
|
||||||
@@ -93,7 +125,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
if (restored) {
|
if (restored) {
|
||||||
Events.purchaseRestored();
|
Events.purchaseRestored();
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
onClose();
|
handleClose("restore");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,7 +165,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
visible={visible}
|
visible={visible}
|
||||||
transparent
|
transparent
|
||||||
animationType="slide"
|
animationType="slide"
|
||||||
onRequestClose={onClose}
|
onRequestClose={() => handleClose("system")}
|
||||||
>
|
>
|
||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
@@ -157,7 +189,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
{/* Close Button */}
|
{/* Close Button */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.closeButton}
|
style={styles.closeButton}
|
||||||
onPress={onClose}
|
onPress={() => handleClose("dismiss")}
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
>
|
>
|
||||||
<Ionicons name="close" size={24} color={colors.textSecondary} />
|
<Ionicons name="close" size={24} color={colors.textSecondary} />
|
||||||
@@ -384,6 +416,59 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
{showGuestConfirm && (
|
||||||
|
<View style={styles.guestConfirmOverlay}>
|
||||||
|
<Pressable
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
onPress={() => setShowGuestConfirm(false)}
|
||||||
|
/>
|
||||||
|
<View style={styles.guestConfirmCard}>
|
||||||
|
<View style={styles.guestConfirmHeader}>
|
||||||
|
<View style={styles.guestConfirmIcon}>
|
||||||
|
<Ionicons name="shield-checkmark" size={22} color={colors.premium.gold} />
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowGuestConfirm(false)}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
style={styles.guestConfirmClose}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={18} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.guestConfirmTitle}>Save your account first?</Text>
|
||||||
|
<Text style={styles.guestConfirmBody}>
|
||||||
|
Unsaved accounts can't be recovered if you delete the app or switch devices.
|
||||||
|
Saving your account protects your access to Pro.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.guestConfirmActions}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.guestConfirmPrimary}
|
||||||
|
onPress={async () => {
|
||||||
|
setShowGuestConfirm(false);
|
||||||
|
handleClose("dismiss");
|
||||||
|
router.push("/(app)/settings/save-account" as any);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.guestConfirmPrimaryText}>Save account</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={styles.guestConfirmSecondary}
|
||||||
|
onPress={async () => {
|
||||||
|
setGuestConfirmed(true);
|
||||||
|
setShowGuestConfirm(false);
|
||||||
|
await doPurchase();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.guestConfirmSecondaryText}>Continue purchase</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -746,6 +831,86 @@ const styles = StyleSheet.create({
|
|||||||
...typography.caption1,
|
...typography.caption1,
|
||||||
color: colors.textTertiary,
|
color: colors.textTertiary,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
guestConfirmOverlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
guestConfirmCard: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
backgroundColor: "rgba(22, 33, 62, 0.98)",
|
||||||
|
borderRadius: radius.xl,
|
||||||
|
padding: spacing.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(212, 175, 55, 0.2)",
|
||||||
|
},
|
||||||
|
guestConfirmHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
guestConfirmIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: "rgba(212, 175, 55, 0.12)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
guestConfirmClose: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||||
|
},
|
||||||
|
guestConfirmTitle: {
|
||||||
|
...typography.title3,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
guestConfirmBody: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
guestConfirmActions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
guestConfirmPrimary: {
|
||||||
|
backgroundColor: colors.premium.gold,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
guestConfirmPrimaryText: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.background,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
guestConfirmSecondary: {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.06)",
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.12)",
|
||||||
|
},
|
||||||
|
guestConfirmSecondaryText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default Paywall;
|
export default Paywall;
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { StyleSheet, Pressable, ViewStyle, Platform } from 'react-native';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withSpring,
|
||||||
|
interpolateColor,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { colors, gradients, radius, animations } from '../../theme';
|
||||||
|
|
||||||
|
// Haptics is not supported on web
|
||||||
|
const triggerHaptic = async () => {
|
||||||
|
if (Platform.OS === 'web') return;
|
||||||
|
const Haptics = await import('expo-haptics');
|
||||||
|
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GlassToggleProps {
|
||||||
|
value: boolean;
|
||||||
|
onValueChange: (value: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
style?: ViewStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRACK_WIDTH = 52;
|
||||||
|
const TRACK_HEIGHT = 30;
|
||||||
|
const THUMB_SIZE = 26;
|
||||||
|
const PADDING = 2;
|
||||||
|
|
||||||
|
export function GlassToggle({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
disabled = false,
|
||||||
|
style,
|
||||||
|
}: GlassToggleProps) {
|
||||||
|
// Shared values for animation
|
||||||
|
const progress = useSharedValue(value ? 1 : 0);
|
||||||
|
|
||||||
|
// Sync shared value when prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
progress.value = withSpring(value ? 1 : 0, {
|
||||||
|
mass: 0.8,
|
||||||
|
damping: 15,
|
||||||
|
stiffness: 120,
|
||||||
|
});
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handlePress = async () => {
|
||||||
|
if (disabled) return;
|
||||||
|
await triggerHaptic();
|
||||||
|
onValueChange(!value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const thumbStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateX: progress.value * (TRACK_WIDTH - THUMB_SIZE - (PADDING * 2)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const trackStyle = useAnimatedStyle(() => {
|
||||||
|
const backgroundColor = interpolateColor(
|
||||||
|
progress.value,
|
||||||
|
[0, 1],
|
||||||
|
['rgba(255, 255, 255, 0.1)', 'transparent'] // We use LinearGradient for active state
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeGradientStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
opacity: progress.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
disabled={disabled}
|
||||||
|
style={[styles.container, style, disabled && styles.disabled]}
|
||||||
|
>
|
||||||
|
<Animated.View style={[styles.track, trackStyle]}>
|
||||||
|
{/* Active Gradient Background (fades in) */}
|
||||||
|
<Animated.View style={[StyleSheet.absoluteFill, activeGradientStyle]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.primary as [string, string, ...string[]]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Thumb */}
|
||||||
|
<Animated.View style={[styles.thumb, thumbStyle]} />
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
width: TRACK_WIDTH,
|
||||||
|
height: TRACK_HEIGHT,
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: radius.full,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: PADDING,
|
||||||
|
},
|
||||||
|
thumb: {
|
||||||
|
width: THUMB_SIZE,
|
||||||
|
height: THUMB_SIZE,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -249,7 +249,7 @@ export function IntensitySlider({
|
|||||||
)}
|
)}
|
||||||
<Text style={styles.modalTitle}>
|
<Text style={styles.modalTitle}>
|
||||||
{levelsDiffer
|
{levelsDiffer
|
||||||
? (userIsHigher ? "Different Comfort Zones" : "You're in Sync")
|
? (userIsHigher ? "Different Comfort Zones" : "Room to Explore")
|
||||||
: "You're Perfectly Matched"
|
: "You're Perfectly Matched"
|
||||||
}
|
}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -306,29 +306,27 @@ export function IntensitySlider({
|
|||||||
) : userIsHigher ? (
|
) : userIsHigher ? (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.modalText}>
|
<Text style={styles.modalText}>
|
||||||
Your comfort zone is set higher than {displayPartnerName}'s.
|
You're both seeing questions up to {partnerLevel?.label} level because that's {displayPartnerName}'s comfort zone.
|
||||||
This means some questions you see won't appear for them.
|
|
||||||
</Text>
|
</Text>
|
||||||
<View style={[styles.modalTip, styles.modalTipWarning]}>
|
<View style={[styles.modalTip, styles.modalTipWarning]}>
|
||||||
<Ionicons name="information-circle" size={16} color={colors.warning} />
|
<Ionicons name="information-circle" size={16} color={colors.warning} />
|
||||||
<Text style={[styles.modalTipText, styles.modalTipTextWarning]}>
|
<Text style={[styles.modalTipText, styles.modalTipTextWarning]}>
|
||||||
Swipes on {current.label} content won't match since {displayPartnerName} is at {partnerLevel?.label}
|
You won't see {current.label} content until {displayPartnerName} raises their level
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.modalSubtext}>
|
<Text style={styles.modalSubtext}>
|
||||||
Consider aligning your levels, or enjoy different paces — it's your choice!
|
The lower setting between you determines what you both see.
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.modalText}>
|
<Text style={styles.modalText}>
|
||||||
{displayPartnerName}'s comfort zone is higher than yours.
|
You're both seeing questions up to {current.label} level based on your comfort zone.
|
||||||
They might swipe on questions you don't see.
|
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.modalTip}>
|
<View style={styles.modalTip}>
|
||||||
<Ionicons name="shield-checkmark" size={16} color={colors.secondary} />
|
<Ionicons name="arrow-up-circle" size={16} color={colors.secondary} />
|
||||||
<Text style={styles.modalTipText}>
|
<Text style={styles.modalTipText}>
|
||||||
You'll only see content within your comfort zone
|
{displayPartnerName} is at {partnerLevel?.label} — raise yours to unlock more content together
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ export { LoadingOverlay } from './LoadingOverlay';
|
|||||||
export type { LoadingOverlayProps, LoadingVariant } from './LoadingOverlay';
|
export type { LoadingOverlayProps, LoadingVariant } from './LoadingOverlay';
|
||||||
export { ReadReceipt, getReceiptStatus } from './ReadReceipt';
|
export { ReadReceipt, getReceiptStatus } from './ReadReceipt';
|
||||||
export type { ReadReceiptProps, ReceiptStatus } from './ReadReceipt';
|
export type { ReadReceiptProps, ReceiptStatus } from './ReadReceipt';
|
||||||
|
export { GlassToggle } from './GlassToggle';
|
||||||
export { AmbientOrbs } from './AmbientOrbs';
|
export { AmbientOrbs } from './AmbientOrbs';
|
||||||
export type { AmbientOrbsProps, OrbVariant, OrbPosition } from './AmbientOrbs';
|
export type { AmbientOrbsProps, OrbVariant, OrbPosition } from './AmbientOrbs';
|
||||||
@@ -139,6 +139,7 @@ const ChatMessagesComponent: React.FC<ChatMessagesProps> = ({
|
|||||||
style={styles.messageList}
|
style={styles.messageList}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
// Performance optimizations
|
// Performance optimizations
|
||||||
removeClippedSubviews={true}
|
removeClippedSubviews={true}
|
||||||
|
|||||||
@@ -1,62 +1,42 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert, Linking } from 'react-native';
|
import { View, Text, StyleSheet, ScrollView, useWindowDimensions, Platform, Alert, TouchableOpacity } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
import Animated, {
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
FadeInDown,
|
import { Image } from 'expo-image';
|
||||||
useSharedValue,
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
useAnimatedScrollHandler,
|
|
||||||
useAnimatedStyle,
|
|
||||||
interpolate,
|
|
||||||
Extrapolation
|
|
||||||
} from 'react-native-reanimated';
|
|
||||||
import { BlurView } from 'expo-blur';
|
|
||||||
|
|
||||||
import { GradientBackground } from '../../components/ui';
|
import { GradientBackground } from '../../components/ui';
|
||||||
import { FeedbackModal } from '../../components/feedback';
|
|
||||||
import { Paywall } from '../../components/paywall';
|
import { Paywall } from '../../components/paywall';
|
||||||
import { colors, featureColors, spacing, typography, radius } from '../../theme';
|
import { colors, featureColors, spacing, typography, radius } from '../../theme';
|
||||||
import { useAuthStore, useSubscriptionStore } from '../../store';
|
import { useAuthStore, useSubscriptionStore } from '../../store';
|
||||||
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
|
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
|
||||||
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
|
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
|
||||||
import { supabase } from '../../lib/supabase'; // Needed for debug reset
|
import { supabase } from '../../lib/supabase';
|
||||||
|
|
||||||
// Hooks
|
|
||||||
import { useProfileSettings, useCoupleManagement } from './hooks';
|
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings, DangerZone, SettingsSection, MenuItem, SubscriptionCard } from './components';
|
import { SettingsSection, MenuItem, SubscriptionCard } from './components';
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import { useProfileSettings } from './hooks';
|
||||||
|
|
||||||
const MAX_CONTENT_WIDTH = 500;
|
const MAX_CONTENT_WIDTH = 500;
|
||||||
const NAV_BAR_HEIGHT = 44;
|
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
||||||
const STATUS_BAR_HEIGHT = 60;
|
|
||||||
const HEADER_SCROLL_DISTANCE = 100;
|
|
||||||
|
|
||||||
const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView);
|
|
||||||
|
|
||||||
export function ProfileScreen() {
|
export function ProfileScreen() {
|
||||||
|
const router = useRouter();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||||
|
|
||||||
const { user, partner, couple, fetchUser } = useAuthStore();
|
const { user, partner, fetchUser, isAnonymous } = useAuthStore();
|
||||||
const { subscription } = useSubscriptionStore();
|
const { subscription } = useSubscriptionStore();
|
||||||
|
|
||||||
// Hooks
|
|
||||||
const settings = useProfileSettings();
|
const settings = useProfileSettings();
|
||||||
const {
|
|
||||||
handleUnpair,
|
|
||||||
handleDeleteRelationship,
|
|
||||||
handleResetProgress,
|
|
||||||
handleSignOut,
|
|
||||||
handleDeleteAccount,
|
|
||||||
navigateToPairing
|
|
||||||
} = useCoupleManagement();
|
|
||||||
|
|
||||||
const [showFeedbackModal, setShowFeedbackModal] = React.useState(false);
|
// Paywall state
|
||||||
|
const [showPaywall, setShowPaywall] = useState(false);
|
||||||
|
|
||||||
// Derived formatting
|
// Derived state
|
||||||
// Use OR: if RevenueCat says they're subscribed OR database says premium, it's their own
|
|
||||||
// This handles the race condition after purchase (RevenueCat updates before webhook syncs DB)
|
|
||||||
const isOwnSubscription = subscription.isProUser || user?.is_premium;
|
const isOwnSubscription = subscription.isProUser || user?.is_premium;
|
||||||
const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser;
|
const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser;
|
||||||
|
|
||||||
@@ -79,81 +59,20 @@ export function ProfileScreen() {
|
|||||||
});
|
});
|
||||||
const versionString = buildNumber ? `v${version} (${buildNumber})` : `v${version}`;
|
const versionString = buildNumber ? `v${version} (${buildNumber})` : `v${version}`;
|
||||||
|
|
||||||
// Scroll animation
|
|
||||||
const scrollY = useSharedValue(0);
|
|
||||||
const scrollHandler = useAnimatedScrollHandler({
|
|
||||||
onScroll: (event) => {
|
|
||||||
scrollY.value = event.contentOffset.y;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const heroStyle = useAnimatedStyle(() => {
|
|
||||||
const opacity = interpolate(
|
|
||||||
scrollY.value,
|
|
||||||
[0, HEADER_SCROLL_DISTANCE * 0.7],
|
|
||||||
[1, 0],
|
|
||||||
Extrapolation.CLAMP
|
|
||||||
);
|
|
||||||
const scale = interpolate(
|
|
||||||
scrollY.value,
|
|
||||||
[0, HEADER_SCROLL_DISTANCE],
|
|
||||||
[1, 0.95],
|
|
||||||
Extrapolation.CLAMP
|
|
||||||
);
|
|
||||||
return { opacity, transform: [{ scale }] };
|
|
||||||
});
|
|
||||||
|
|
||||||
const compactHeaderStyle = useAnimatedStyle(() => {
|
|
||||||
const opacity = interpolate(
|
|
||||||
scrollY.value,
|
|
||||||
[HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE],
|
|
||||||
[0, 1],
|
|
||||||
Extrapolation.CLAMP
|
|
||||||
);
|
|
||||||
return { opacity };
|
|
||||||
});
|
|
||||||
|
|
||||||
const navBarBackgroundStyle = useAnimatedStyle(() => {
|
|
||||||
const opacity = interpolate(
|
|
||||||
scrollY.value,
|
|
||||||
[0, HEADER_SCROLL_DISTANCE * 0.8],
|
|
||||||
[0, 1],
|
|
||||||
Extrapolation.CLAMP
|
|
||||||
);
|
|
||||||
return { opacity };
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
{/* Fixed Nav Bar */}
|
<ScrollView
|
||||||
<View style={styles.navBar}>
|
|
||||||
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
|
|
||||||
{Platform.OS === "ios" ? (
|
|
||||||
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
|
||||||
) : (
|
|
||||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]} numberOfLines={1}>
|
|
||||||
Settings
|
|
||||||
</Animated.Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<AnimatedScrollView
|
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[
|
||||||
styles.contentContainer,
|
styles.contentContainer,
|
||||||
isWideScreen && styles.contentContainerWide,
|
isWideScreen && styles.contentContainerWide,
|
||||||
]}
|
]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onScroll={scrollHandler}
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
>
|
>
|
||||||
{/* Header title */}
|
{/* Header with user info (compact) */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(100).duration(500)}
|
entering={FadeInDown.delay(100).duration(500)}
|
||||||
style={[styles.header, heroStyle]}
|
style={styles.header}
|
||||||
>
|
>
|
||||||
<Text style={styles.headerLabel}>YOUR SAUCI</Text>
|
<Text style={styles.headerLabel}>YOUR SAUCI</Text>
|
||||||
<Text style={styles.title}>Settings</Text>
|
<Text style={styles.title}>Settings</Text>
|
||||||
@@ -164,98 +83,136 @@ export function ProfileScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Profile Header */}
|
{/* Compact Profile Preview */}
|
||||||
<AppearanceSettings
|
<Animated.View
|
||||||
user={user}
|
entering={FadeInDown.delay(150).duration(500)}
|
||||||
isUploadingAvatar={settings.isUploadingAvatar}
|
style={styles.profilePreview}
|
||||||
isEditingName={settings.isEditingName}
|
>
|
||||||
newName={settings.newName}
|
<TouchableOpacity
|
||||||
isUpdatingName={settings.isUpdatingName}
|
style={styles.profilePreviewContent}
|
||||||
onAvatarPress={settings.handleAvatarPress}
|
onPress={() => router.push('/(app)/settings/profile')}
|
||||||
onNewNameChange={settings.setNewName}
|
activeOpacity={0.7}
|
||||||
onUpdateName={settings.handleUpdateName}
|
>
|
||||||
onCancelEditName={settings.handleCancelEditName}
|
<LinearGradient
|
||||||
onStartEditingName={() => settings.setIsEditingName(true)}
|
colors={ACCENT_GRADIENT}
|
||||||
|
style={styles.avatarGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
{user?.avatar_url ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: user.avatar_url }}
|
||||||
|
style={styles.avatar}
|
||||||
|
cachePolicy="disk"
|
||||||
|
transition={200}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.avatarInner}>
|
||||||
|
<Text style={styles.avatarText}>
|
||||||
|
{user?.name?.[0]?.toUpperCase() || "U"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</LinearGradient>
|
||||||
|
<View style={styles.profileInfo}>
|
||||||
|
<Text style={styles.userName}>{user?.name || "User"}</Text>
|
||||||
|
<Text style={styles.userEmail}>
|
||||||
|
{isAnonymous ? "Unsaved account" : (user?.email || "")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.profileChevron}>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
{/* Partner */}
|
{/* Subscription CTA - prominent upsell */}
|
||||||
<CoupleStatus
|
|
||||||
partner={partner}
|
|
||||||
couple={couple}
|
|
||||||
onUnpair={handleUnpair}
|
|
||||||
onPairingPress={navigateToPairing}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Subscription Section */}
|
|
||||||
<SubscriptionCard
|
<SubscriptionCard
|
||||||
hasPremiumAccess={hasPremiumAccess}
|
hasPremiumAccess={hasPremiumAccess}
|
||||||
isOwnSubscription={!!isOwnSubscription}
|
isOwnSubscription={!!isOwnSubscription}
|
||||||
expirationDate={formatExpirationDate(subscription.expirationDate)}
|
expirationDate={formatExpirationDate(subscription.expirationDate)}
|
||||||
onUpgradePress={() => settings.setShowPaywall(true)}
|
onUpgradePress={() => setShowPaywall(true)}
|
||||||
onManagePress={settings.handleManageSubscription}
|
onManagePress={settings.handleManageSubscription}
|
||||||
onRestorePress={settings.handleRestorePurchases}
|
onRestorePress={settings.handleRestorePurchases}
|
||||||
isRestoring={settings.isPurchasing}
|
isRestoring={settings.isPurchasing}
|
||||||
delay={350}
|
delay={200}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Settings Navigation */}
|
||||||
<NotificationSettings
|
<SettingsSection title="Settings" delay={250}>
|
||||||
pushEnabled={settings.pushEnabled}
|
|
||||||
isUpdatingPush={settings.isUpdatingPush}
|
|
||||||
onPushToggle={settings.handlePushToggle}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Preferences */}
|
|
||||||
<PrivacySettings
|
|
||||||
maxIntensity={settings.maxIntensity}
|
|
||||||
isUpdatingIntensity={settings.isUpdatingIntensity}
|
|
||||||
onIntensityChange={settings.handleIntensityChange}
|
|
||||||
biometricAvailable={settings.biometricAvailable}
|
|
||||||
biometricEnabled={settings.biometricEnabled}
|
|
||||||
biometricType={settings.biometricType}
|
|
||||||
isUpdatingBiometric={settings.isUpdatingBiometric}
|
|
||||||
onBiometricToggle={settings.handleBiometricToggle}
|
|
||||||
partnerIntensity={settings.partnerIntensity}
|
|
||||||
partnerName={settings.partnerName}
|
|
||||||
partnerAvatar={settings.partnerAvatar}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Account */}
|
|
||||||
<SettingsSection title="Account" delay={425}>
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="log-out-outline"
|
icon="person-circle-outline"
|
||||||
label="Sign Out"
|
label="Profile"
|
||||||
onPress={handleSignOut}
|
description="Avatar and display name"
|
||||||
variant="danger"
|
onPress={() => router.push('/(app)/settings/profile')}
|
||||||
showChevron={true}
|
/>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="notifications-outline"
|
||||||
|
label="Notifications"
|
||||||
|
description="Push notification preferences"
|
||||||
|
onPress={() => router.push('/(app)/settings/notifications')}
|
||||||
|
/>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="options-outline"
|
||||||
|
label="Preferences"
|
||||||
|
description="Comfort zone and security"
|
||||||
|
onPress={() => router.push('/(app)/settings/preferences')}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Support */}
|
{/* Relationship Section */}
|
||||||
<SettingsSection title="Support" delay={475}>
|
<SettingsSection title="Relationship" delay={300}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="chatbubble-ellipses-outline"
|
icon="heart-outline"
|
||||||
label="Send Feedback"
|
label="Partner"
|
||||||
description="Report bugs or request features"
|
rightText={partner?.name || (user?.couple_id ? 'Waiting...' : undefined)}
|
||||||
onPress={() => setShowFeedbackModal(true)}
|
onPress={() => router.push('/(app)/settings/partner')}
|
||||||
/>
|
/>
|
||||||
<View style={styles.preferencesDivider} />
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Other Section */}
|
||||||
|
<SettingsSection title="Other" delay={350}>
|
||||||
|
{isAnonymous && (
|
||||||
|
<>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="shield-checkmark-outline"
|
icon="shield-checkmark-outline"
|
||||||
label="Privacy Policy"
|
label="Save Account"
|
||||||
onPress={() => Linking.openURL('https://sauci.app/privacy')}
|
description="Protect access across devices"
|
||||||
|
onPress={() => router.push('/(app)/settings/save-account' as any)}
|
||||||
/>
|
/>
|
||||||
<View style={styles.preferencesDivider} />
|
<View style={styles.divider} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="document-text-outline"
|
icon="help-circle-outline"
|
||||||
label="Terms of Service"
|
label="Help & Support"
|
||||||
onPress={() => Linking.openURL('https://sauci.app/terms')}
|
onPress={() => router.push('/(app)/settings/support')}
|
||||||
|
/>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="settings-outline"
|
||||||
|
label="Account"
|
||||||
|
onPress={() => router.push('/(app)/settings/account')}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Debug Section */}
|
{/* Debug Section - Dev only */}
|
||||||
{__DEV__ && (
|
{__DEV__ && (
|
||||||
<SettingsSection title="Debug" delay={550}>
|
<SettingsSection title="Debug" delay={400}>
|
||||||
|
<MenuItem
|
||||||
|
icon="server-outline"
|
||||||
|
label="Supabase Environment"
|
||||||
|
rightText={
|
||||||
|
process.env.EXPO_PUBLIC_SUPABASE_URL?.includes('ckjcrkjpmhqhiucifukx')
|
||||||
|
? 'Production'
|
||||||
|
: 'Non-Production'
|
||||||
|
}
|
||||||
|
onPress={() => {}}
|
||||||
|
showChevron={false}
|
||||||
|
/>
|
||||||
|
<View style={styles.divider} />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="refresh-outline"
|
icon="refresh-outline"
|
||||||
label="Reset Swipe Tutorial"
|
label="Reset Swipe Tutorial"
|
||||||
@@ -265,7 +222,7 @@ export function ProfileScreen() {
|
|||||||
Alert.alert("Success", "Swipe tutorial has been reset.");
|
Alert.alert("Success", "Swipe tutorial has been reset.");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View style={styles.preferencesDivider} />
|
<View style={styles.divider} />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="heart-outline"
|
icon="heart-outline"
|
||||||
label="Reset Matches Tutorial"
|
label="Reset Matches Tutorial"
|
||||||
@@ -275,7 +232,7 @@ export function ProfileScreen() {
|
|||||||
Alert.alert("Success", "Matches tutorial has been reset.");
|
Alert.alert("Success", "Matches tutorial has been reset.");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View style={styles.preferencesDivider} />
|
<View style={styles.divider} />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="school-outline"
|
icon="school-outline"
|
||||||
label="Reset Onboarding"
|
label="Reset Onboarding"
|
||||||
@@ -298,17 +255,9 @@ export function ProfileScreen() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Danger Zone */}
|
|
||||||
<DangerZone
|
|
||||||
onResetProgress={couple ? handleResetProgress : undefined}
|
|
||||||
onDeleteRelationship={couple ? handleDeleteRelationship : undefined}
|
|
||||||
onDeleteAccount={handleDeleteAccount}
|
|
||||||
hasRelationship={!!couple}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Version */}
|
{/* Version */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(couple ? 650 : 525).duration(500)}
|
entering={FadeInDown.delay(__DEV__ ? 450 : 400).duration(500)}
|
||||||
style={styles.versionContainer}
|
style={styles.versionContainer}
|
||||||
>
|
>
|
||||||
<View style={styles.versionBadge}>
|
<View style={styles.versionBadge}>
|
||||||
@@ -318,16 +267,11 @@ export function ProfileScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<View style={styles.bottomSpacer} />
|
<View style={styles.bottomSpacer} />
|
||||||
</AnimatedScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
<FeedbackModal
|
|
||||||
visible={showFeedbackModal}
|
|
||||||
onClose={() => setShowFeedbackModal(false)}
|
|
||||||
/>
|
|
||||||
<Paywall
|
<Paywall
|
||||||
visible={settings.showPaywall}
|
visible={showPaywall}
|
||||||
onClose={() => settings.setShowPaywall(false)}
|
onClose={() => setShowPaywall(false)}
|
||||||
/>
|
/>
|
||||||
</GradientBackground>
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
@@ -337,33 +281,7 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
// Fixed Nav Bar
|
|
||||||
navBar: {
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
paddingTop: STATUS_BAR_HEIGHT - 10,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
zIndex: 100,
|
|
||||||
},
|
|
||||||
navBarBackground: {
|
|
||||||
...StyleSheet.absoluteFillObject,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: 'rgba(155, 89, 182, 0.15)', // Purple tint for profile
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
navBarTitle: {
|
|
||||||
...typography.headline,
|
|
||||||
color: colors.text,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
contentContainer: {
|
contentContainer: {
|
||||||
// Remove top padding here as the header adds it
|
|
||||||
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
||||||
},
|
},
|
||||||
contentContainerWide: {
|
contentContainerWide: {
|
||||||
@@ -372,7 +290,7 @@ const styles = StyleSheet.create({
|
|||||||
maxWidth: MAX_CONTENT_WIDTH,
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingTop: STATUS_BAR_HEIGHT + spacing.md,
|
paddingTop: Platform.OS === 'ios' ? 60 : 40,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingBottom: spacing.md,
|
paddingBottom: spacing.md,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -409,7 +327,65 @@ const styles = StyleSheet.create({
|
|||||||
marginHorizontal: spacing.sm,
|
marginHorizontal: spacing.sm,
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
},
|
},
|
||||||
preferencesDivider: {
|
// Profile Preview
|
||||||
|
profilePreview: {
|
||||||
|
marginHorizontal: spacing.lg,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
profilePreviewContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
avatarGradient: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
padding: 2,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
avatarInner: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
...typography.title2,
|
||||||
|
fontSize: 24,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
profileInfo: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: spacing.md,
|
||||||
|
},
|
||||||
|
userName: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
userEmail: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
profileChevron: {
|
||||||
|
padding: spacing.xs,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: colors.glass.border,
|
backgroundColor: colors.glass.border,
|
||||||
marginVertical: spacing.md,
|
marginVertical: spacing.md,
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export interface MenuItemProps {
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
/** Optional element to show on the right */
|
/** Optional element to show on the right */
|
||||||
rightElement?: React.ReactNode;
|
rightElement?: React.ReactNode;
|
||||||
|
/** Text to display on the right side (e.g., partner name) */
|
||||||
|
rightText?: string;
|
||||||
|
/** Badge label to display (e.g., "Premium") */
|
||||||
|
badge?: string;
|
||||||
/** Whether to show chevron arrow */
|
/** Whether to show chevron arrow */
|
||||||
showChevron?: boolean;
|
showChevron?: boolean;
|
||||||
/** Visual variant */
|
/** Visual variant */
|
||||||
@@ -34,6 +38,8 @@ export function MenuItem({
|
|||||||
description,
|
description,
|
||||||
onPress,
|
onPress,
|
||||||
rightElement,
|
rightElement,
|
||||||
|
rightText,
|
||||||
|
badge,
|
||||||
showChevron = true,
|
showChevron = true,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -65,17 +71,29 @@ export function MenuItem({
|
|||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
)}
|
)}
|
||||||
<View style={styles.textContainer}>
|
<View style={styles.textContainer}>
|
||||||
|
<View style={styles.labelRow}>
|
||||||
<Text style={[styles.label, { color: labelColor }]}>{label}</Text>
|
<Text style={[styles.label, { color: labelColor }]}>{label}</Text>
|
||||||
|
{badge && (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>{badge}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
{description && (
|
{description && (
|
||||||
<Text style={styles.description}>{description}</Text>
|
<Text style={styles.description}>{description}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={styles.rightSection}>
|
||||||
|
{rightText && (
|
||||||
|
<Text style={styles.rightText} numberOfLines={1}>{rightText}</Text>
|
||||||
|
)}
|
||||||
{rightElement || (showChevron && (
|
{rightElement || (showChevron && (
|
||||||
<View style={styles.chevron}>
|
<View style={styles.chevron}>
|
||||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -113,15 +131,43 @@ const styles = StyleSheet.create({
|
|||||||
textContainer: {
|
textContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
labelRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
label: {
|
label: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
|
badge: {
|
||||||
|
backgroundColor: colors.premium.goldLight,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
...typography.caption2,
|
||||||
|
color: colors.premium.gold,
|
||||||
|
fontWeight: '600',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
description: {
|
description: {
|
||||||
...typography.caption1,
|
...typography.caption1,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
|
rightSection: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
rightText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
maxWidth: 120,
|
||||||
|
},
|
||||||
chevron: {
|
chevron: {
|
||||||
padding: spacing.xs,
|
padding: spacing.xs,
|
||||||
},
|
},
|
||||||
|
|||||||
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user