Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions docs/Implementation-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -836,16 +836,23 @@ Development:

### Installation Methods

#### From PyPI
**Note:** PWA Forge is not yet published to PyPI. Install from source:

#### From Source (Recommended)
```bash
pip install pwa-forge
git clone https://github.com/bigr/pwa_forge.git
cd pwa_forge
pip install -e .
```

#### From Source
#### Via pip with git
```bash
git clone https://github.com/yourusername/pwa-forge.git
cd pwa-forge
pip install -e .
pip install git+https://github.com/bigr/pwa_forge.git
```

#### From PyPI (Future)
```bash
pip install pwa-forge # Not yet available
```

#### System Package (Future)
Expand Down Expand Up @@ -2661,7 +2668,7 @@ Examples:
pwa-forge list --verbose
pwa-forge audit chatgpt --fix

For more help: https://github.com/yourusername/pwa-forge
For more help: https://github.com/bigr/pwa_forge
```

### Add Command Help
Expand Down
2 changes: 1 addition & 1 deletion docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -786,7 +786,7 @@ If your issue isn't covered here:
```

4. **Search existing issues:**
- GitHub Issues: https://github.com/yourusername/pwa-forge/issues
- GitHub Issues: https://github.com/bigr/pwa_forge/issues

5. **Create a bug report:**
Include:
Expand Down
14 changes: 8 additions & 6 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,16 @@ Complete guide to using PWA Forge for managing Progressive Web Apps on Linux.

### Installation

```bash
# Install from PyPI (when published)
pip install pwa-forge
**Note:** PWA Forge is not yet published to PyPI. Please install from source:

# Or install from source
git clone https://github.com/yourusername/pwa-forge.git
cd pwa-forge
```bash
# Install from source
git clone https://github.com/bigr/pwa_forge.git
cd pwa_forge
pip install -e .

# Or install directly via pip with git
pip install git+https://github.com/bigr/pwa_forge.git
```

### Create Your First PWA
Expand Down
Binary file added docs/testing/report1.pdf
Binary file not shown.
4 changes: 4 additions & 0 deletions src/pwa_forge/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ def cli(ctx: click.Context, verbose: int, quiet: bool, no_color: bool) -> None:
pwa-forge list
pwa-forge remove chatgpt
"""
# Validate mutually exclusive options
if quiet and verbose > 0:
raise click.UsageError("--quiet and --verbose are mutually exclusive")

# Load configuration
config = load_config()

Expand Down
30 changes: 26 additions & 4 deletions src/pwa_forge/commands/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pwa_forge.registry import Registry
from pwa_forge.templates import render_template
from pwa_forge.validation import (
ValidationStatus,
extract_name_from_url,
generate_id,
generate_wm_class,
Expand Down Expand Up @@ -65,10 +66,10 @@ def add_app(
logger.info(f"Adding PWA for URL: {url}")

# Validate URL
is_valid, message = validate_url(url, verify=False)
is_valid, status, message = validate_url(url, verify=False)
if not is_valid:
raise AddCommandError(f"Invalid URL: {message}")
if "Warning" in message:
if status == ValidationStatus.WARNING:
logger.warning(message)

# Determine app name
Expand Down Expand Up @@ -109,8 +110,14 @@ def add_app(
# Handle icon
icon_path = _handle_icon(icon, app_id, config.icons_dir, dry_run) if icon else None

# Get browser executable
browser_exec = _get_browser_executable(browser, config)
# Get browser executable (skip in dry-run to allow testing without browsers installed)
if not dry_run:
browser_exec = _get_browser_executable(browser, config)
else:
# In dry-run, use placeholder path that won't be written
browser_exec_str = f"/usr/bin/{browser}"
logger.info(f"[DRY-RUN] Would use browser: {browser_exec_str}")
browser_exec = Path(browser_exec_str)

# Parse chrome flags
parsed_flags = _parse_chrome_flags(chrome_flags) if chrome_flags else {}
Expand Down Expand Up @@ -286,6 +293,14 @@ def _get_browser_executable(browser: str, config: Config) -> Path:
"edge": ["/usr/bin/microsoft-edge-stable", "/usr/bin/microsoft-edge"],
}

# Map browser names to common executable names for shutil.which()
browser_executables = {
"chrome": ["google-chrome-stable", "google-chrome"],
"chromium": ["chromium-browser", "chromium"],
"firefox": ["firefox"],
"edge": ["microsoft-edge-stable", "microsoft-edge"],
}

# Try configured path first
if hasattr(config.browsers, browser):
browser_path = getattr(config.browsers, browser)
Expand All @@ -300,6 +315,13 @@ def _get_browser_executable(browser: str, config: Config) -> Path:
logger.debug(f"Found browser at: {path}")
return path

# Fallback: search in PATH using shutil.which()
for executable_name in browser_executables.get(browser, []):
which_path = shutil.which(executable_name)
if which_path:
logger.debug(f"Found browser in PATH: {which_path}")
return Path(which_path)

raise AddCommandError(f"Browser '{browser}' not found. Please install it or specify the path in config.")


Expand Down
66 changes: 51 additions & 15 deletions src/pwa_forge/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

from __future__ import annotations

import fcntl
import json
import logging
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import datetime
Expand All @@ -13,6 +13,12 @@

logger = logging.getLogger(__name__)

# Platform-specific imports for file locking
if sys.platform == "win32":
import msvcrt
else:
import fcntl


class RegistryError(Exception):
"""Base exception for registry operations."""
Expand Down Expand Up @@ -47,17 +53,31 @@ def __init__(self, registry_path: Path) -> None:
def _lock(self) -> Iterator[None]:
"""Acquire exclusive lock on registry file.

Uses platform-specific locking mechanisms:
- Unix/Linux/macOS: fcntl.flock
- Windows: msvcrt.locking

Yields:
None - context is locked for the duration of the block.
"""
self._lock_path.parent.mkdir(parents=True, exist_ok=True)
with open(self._lock_path, "w") as lock_file:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
if sys.platform == "win32":
# Windows: lock the entire file
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
else:
# Unix-like systems: use fcntl
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
logger.debug(f"Acquired lock on {self._lock_path}")
yield
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
if sys.platform == "win32":
# Windows: unlock the file
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
else:
# Unix-like systems: unlock
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
logger.debug(f"Released lock on {self._lock_path}")

def _read(self) -> dict[str, Any]:
Expand Down Expand Up @@ -138,18 +158,34 @@ def add_app(self, app_data: dict[str, Any]) -> None:

app_id = app_data["id"]

# Check if app already exists
try:
self.get_app(app_id)
raise AppExistsError(f"PWA '{app_id}' already exists in registry")
except AppNotFoundError:
pass # This is expected

# Add app to registry
data = self._read()
data["apps"].append(app_data)
self._write(data)
logger.info(f"Added app to registry: {app_id}")
# Atomic read-check-write within a single lock to prevent race conditions
with self._lock():
# Read current registry
if not self.registry_path.exists():
data: dict[str, Any] = {"version": 1, "apps": [], "handlers": []}
else:
try:
content = self.registry_path.read_text()
data = json.loads(content)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in registry file: {e}")
raise RegistryError(f"Corrupted registry file: {e}") from e

# Check if app already exists
apps: list[dict[str, Any]] = data.get("apps", [])
for app in apps:
if app.get("id") == app_id:
raise AppExistsError(f"PWA '{app_id}' already exists in registry")

# Add app to registry
apps.append(app_data)
data["apps"] = apps

# Write atomically
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
content = json.dumps(data, indent=2)
self.registry_path.write_text(content)
logger.info(f"Added app to registry: {app_id}")

def update_app(self, app_id: str, updates: dict[str, Any]) -> None:
"""Update an existing PWA entry.
Expand Down
29 changes: 20 additions & 9 deletions src/pwa_forge/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@
logger = logging.getLogger(__name__)


def validate_url(url: str, verify: bool = False, timeout: int = 5) -> tuple[bool, str]:
class ValidationStatus:
"""URL validation result status codes."""

OK = "OK"
WARNING = "WARNING"
ERROR = "ERROR"


def validate_url(url: str, verify: bool = False, timeout: int = 5) -> tuple[bool, str, str]:
"""Validate URL format and optionally check accessibility.

Args:
Expand All @@ -20,30 +28,33 @@ def validate_url(url: str, verify: bool = False, timeout: int = 5) -> tuple[bool
timeout: Connection timeout in seconds for verification.

Returns:
Tuple of (is_valid, message) where message is "OK" or error description.
Tuple of (is_valid, status, message) where:
- is_valid: True if URL is valid (may have warnings)
- status: One of ValidationStatus.OK, ValidationStatus.WARNING, ValidationStatus.ERROR
- message: Description of the validation result
"""
# Parse URL
try:
parsed = urlparse(url)
except Exception as e:
logger.debug(f"URL parsing failed for '{url}': {e}")
return False, f"Invalid URL format: {e}"
return False, ValidationStatus.ERROR, f"Invalid URL format: {e}"

# Check scheme
if parsed.scheme not in ("http", "https"):
logger.debug(f"Invalid URL scheme: {parsed.scheme}")
return False, "URL must use http:// or https://"
return False, ValidationStatus.ERROR, "URL must use http:// or https://"

# Check host
if not parsed.netloc:
logger.debug("URL missing hostname")
return False, "URL must include a hostname"
return False, ValidationStatus.ERROR, "URL must include a hostname"

# Warn about localhost
hostname = parsed.netloc.split(":")[0] if ":" in parsed.netloc else parsed.netloc
if hostname in ("localhost", "127.0.0.1", "::1"):
logger.warning(f"Localhost URL detected: {url}")
return True, "Warning: localhost URLs won't work from system launcher"
return True, ValidationStatus.WARNING, "localhost URLs won't work from system launcher"

# Optional connectivity check
if verify:
Expand All @@ -52,13 +63,13 @@ def validate_url(url: str, verify: bool = False, timeout: int = 5) -> tuple[bool
response = requests.head(url, timeout=timeout, allow_redirects=True)
if response.status_code >= 400:
logger.warning(f"URL returned HTTP {response.status_code}: {url}")
return False, f"URL returned HTTP {response.status_code}"
return False, ValidationStatus.ERROR, f"URL returned HTTP {response.status_code}"
except requests.RequestException as e:
logger.warning(f"URL not accessible: {url} - {e}")
return False, f"URL not accessible: {e}"
return False, ValidationStatus.ERROR, f"URL not accessible: {e}"

logger.debug(f"URL validation passed: {url}")
return True, "OK"
return True, ValidationStatus.OK, "OK"


def generate_id(name: str) -> str:
Expand Down
3 changes: 3 additions & 0 deletions tests/e2e/test_e2e_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ def mock_exists(self: Path) -> bool:

monkeypatch.setattr(Path, "exists", mock_exists)

# Mock shutil.which to return None (browser not in PATH)
monkeypatch.setattr("shutil.which", lambda _: None)

# Add PWA should fail
from pwa_forge.commands.add import AddCommandError

Expand Down
Loading
Loading