mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-30 04:23:01 +00:00
Compare commits
9 Commits
a8609aea18
...
36da426c29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36da426c29 | ||
|
|
6a60a57421 | ||
|
|
aafb238ac9 | ||
|
|
b8564c952c | ||
|
|
e64caccae6 | ||
|
|
441b3c56e9 | ||
|
|
5d213b5747 | ||
|
|
8d1579a51b | ||
|
|
636d0e181c |
500
docs/STARLARK_APPS_GUIDE.md
Normal file
500
docs/STARLARK_APPS_GUIDE.md
Normal file
@@ -0,0 +1,500 @@
|
||||
# Starlark Apps Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The Starlark Apps plugin for LEDMatrix enables you to run **Tidbyt/Tronbyte community apps** on your LED matrix display without modification. This integration allows you to access hundreds of pre-built widgets and apps from the vibrant Tidbyt community ecosystem.
|
||||
|
||||
## Important: Third-Party Content
|
||||
|
||||
**⚠️ Apps are NOT managed by the LEDMatrix project**
|
||||
|
||||
- Starlark apps are developed and maintained by the **Tidbyt/Tronbyte community**
|
||||
- LEDMatrix provides the runtime environment but does **not** create, maintain, or support these apps
|
||||
- All apps originate from the [Tronbyte Apps Repository](https://github.com/tronbyt/apps)
|
||||
- App quality, functionality, and security are the responsibility of individual app authors
|
||||
- LEDMatrix is not affiliated with Tidbyt Inc. or the Tronbyte project
|
||||
|
||||
## What is Starlark?
|
||||
|
||||
[Starlark](https://github.com/bazelbuild/starlark) is a Python-like language originally developed by Google for the Bazel build system. Tidbyt adopted Starlark for building LED display apps because it's:
|
||||
|
||||
- **Sandboxed**: Apps run in a safe, restricted environment
|
||||
- **Simple**: Python-like syntax that's easy to learn
|
||||
- **Deterministic**: Apps produce consistent output
|
||||
- **Fast**: Compiled and optimized for performance
|
||||
|
||||
## How It Works
|
||||
|
||||
### Architecture
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ LEDMatrix System │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ Starlark Apps Plugin (manager.py) │ │
|
||||
│ │ • Manages app lifecycle (install/uninstall) │ │
|
||||
│ │ • Handles app configuration │ │
|
||||
│ │ • Schedules app rendering │ │
|
||||
│ └─────────────────┬──────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────▼──────────────────────────────────┐ │
|
||||
│ │ Pixlet Renderer (pixlet_renderer.py) │ │
|
||||
│ │ • Executes .star files using Pixlet CLI │ │
|
||||
│ │ • Extracts configuration schemas │ │
|
||||
│ │ • Outputs WebP animations │ │
|
||||
│ └─────────────────┬──────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────▼──────────────────────────────────┐ │
|
||||
│ │ Frame Extractor (frame_extractor.py) │ │
|
||||
│ │ • Decodes WebP animations into frames │ │
|
||||
│ │ • Scales/centers output for display size │ │
|
||||
│ │ • Manages frame timing │ │
|
||||
│ └─────────────────┬──────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────▼──────────────────────────────────┐ │
|
||||
│ │ LED Matrix Display │ │
|
||||
│ │ • Renders final output to physical display │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
▲
|
||||
│
|
||||
Downloads apps from
|
||||
│
|
||||
┌───────────────────┴─────────────────────────────────────┐
|
||||
│ Tronbyte Apps Repository (GitHub) │
|
||||
│ • 974+ community-built apps │
|
||||
│ • Weather, sports, stocks, games, clocks, etc. │
|
||||
│ • https://github.com/tronbyt/apps │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Rendering Pipeline
|
||||
|
||||
1. **User installs app** from the Tronbyte repository via web UI
|
||||
2. **Plugin downloads** the `.star` file (and any assets like images/fonts)
|
||||
3. **Schema extraction** parses configuration options from the `.star` source
|
||||
4. **User configures** the app through the web UI (timezone, location, API keys, etc.)
|
||||
5. **Pixlet renders** the app with user config → produces WebP animation
|
||||
6. **Frame extraction** decodes WebP → individual PIL Image frames
|
||||
7. **Display scaling** adapts 64x32 Tidbyt output to your matrix size
|
||||
8. **Rotation** cycles through your installed apps based on schedule
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Install Pixlet
|
||||
|
||||
Pixlet is the rendering engine that executes Starlark apps. The plugin will attempt to use:
|
||||
|
||||
1. **Bundled binary** (recommended): Downloaded to `bin/pixlet/pixlet-{platform}-{arch}`
|
||||
2. **System installation**: If `pixlet` is available in your PATH
|
||||
|
||||
#### Auto-Install via Web UI
|
||||
|
||||
Navigate to: **Plugins → Starlark Apps → Status → Install Pixlet**
|
||||
|
||||
This runs the bundled installation script which downloads the appropriate binary for your platform.
|
||||
|
||||
#### Manual Installation
|
||||
|
||||
```bash
|
||||
cd /path/to/LEDMatrix
|
||||
bash scripts/download_pixlet.sh
|
||||
```
|
||||
|
||||
Verify installation:
|
||||
```bash
|
||||
./bin/pixlet/pixlet-linux-amd64 version
|
||||
# Pixlet 0.50.2 (or later)
|
||||
```
|
||||
|
||||
### 2. Enable the Starlark Apps Plugin
|
||||
|
||||
1. Open the web UI
|
||||
2. Navigate to **Plugins**
|
||||
3. Find **Starlark Apps** in the installed plugins list
|
||||
4. Enable the plugin
|
||||
5. Configure settings:
|
||||
- **Magnify**: Auto-calculated based on your display size (or set manually)
|
||||
- **Render Interval**: How often apps re-render (default: 300s)
|
||||
- **Display Duration**: How long each app shows (default: 15s)
|
||||
- **Cache Output**: Enable to reduce re-rendering (recommended)
|
||||
|
||||
### 3. Browse and Install Apps
|
||||
|
||||
1. Navigate to **Plugins → Starlark Apps → App Store**
|
||||
2. Browse available apps (974+ options)
|
||||
3. Filter by category: Weather, Sports, Finance, Games, Clocks, etc.
|
||||
4. Click **Install** on desired apps
|
||||
5. Configure each app:
|
||||
- Set location/timezone
|
||||
- Enter API keys if required
|
||||
- Customize display preferences
|
||||
|
||||
### 4. Configure Apps
|
||||
|
||||
Each app may have different configuration options:
|
||||
|
||||
#### Common Configuration Types
|
||||
|
||||
- **Location** (lat/lng/timezone): For weather, clocks, transit
|
||||
- **API Keys**: For services like weather, stocks, sports scores
|
||||
- **Display Preferences**: Colors, units, layouts
|
||||
- **Dropdown Options**: Team selections, language, themes
|
||||
- **Toggles**: Enable/disable features
|
||||
|
||||
Configuration is stored in `starlark-apps/{app-id}/config.json` and persists across app updates.
|
||||
|
||||
## App Sources and Categories
|
||||
|
||||
All apps are sourced from the [Tronbyte Apps Repository](https://github.com/tronbyt/apps). Popular categories include:
|
||||
|
||||
### 🌤️ Weather
|
||||
- Analog Clock (with weather)
|
||||
- Current Weather
|
||||
- Weather Forecast
|
||||
- Air Quality Index
|
||||
|
||||
### 🏈 Sports
|
||||
- NFL Scores
|
||||
- NBA Scores
|
||||
- MLB Scores
|
||||
- NHL Scores
|
||||
- Soccer/Football Scores
|
||||
- Formula 1 Results
|
||||
|
||||
### 💰 Finance
|
||||
- Stock Tickers
|
||||
- Cryptocurrency Prices
|
||||
- Market Indices
|
||||
|
||||
### 🎮 Games & Fun
|
||||
- Conway's Game of Life
|
||||
- Pong
|
||||
- Nyan Cat
|
||||
- Retro Animations
|
||||
|
||||
### 🕐 Clocks
|
||||
- Analog Clock
|
||||
- Fuzzy Clock
|
||||
- Binary Clock
|
||||
- Word Clock
|
||||
|
||||
### 📰 Information
|
||||
- News Headlines
|
||||
- RSS Feeds
|
||||
- GitHub Activity
|
||||
- Reddit Feed
|
||||
|
||||
### 🚌 Transit & Travel
|
||||
- Transit Arrivals
|
||||
- Flight Tracker
|
||||
- Train Schedules
|
||||
|
||||
## Display Size Compatibility
|
||||
|
||||
Tronbyte/Tidbyt apps are designed for **64×32 displays**. LEDMatrix automatically adapts content for different display sizes:
|
||||
|
||||
### Magnification
|
||||
|
||||
The plugin calculates optimal magnification based on your display:
|
||||
|
||||
```text
|
||||
magnify = floor(min(display_width / 64, display_height / 32))
|
||||
```
|
||||
|
||||
Examples:
|
||||
- **64×32**: magnify = 1 (native, pixel-perfect)
|
||||
- **128×64**: magnify = 2 (2x scaling, crisp)
|
||||
- **192×64**: magnify = 2 (2x + horizontal centering)
|
||||
- **256×64**: magnify = 2 (2x + centering)
|
||||
|
||||
### Scaling Modes
|
||||
|
||||
**Config → Starlark Apps → Scale Method:**
|
||||
- `nearest` (default): Sharp pixels, retro look
|
||||
- `bilinear`: Smooth scaling, slight blur
|
||||
- `bicubic`: Higher quality smooth scaling
|
||||
- `lanczos`: Best quality, most processing
|
||||
|
||||
**Center vs Scale:**
|
||||
- `scale_output=true`: Stretch to fill display (may distort aspect ratio)
|
||||
- `center_small_output=true`: Center output without stretching (preserves aspect ratio)
|
||||
|
||||
## Configuration Schema Extraction
|
||||
|
||||
LEDMatrix automatically extracts configuration schemas from Starlark apps by parsing the `get_schema()` function in the `.star` source code.
|
||||
|
||||
### Supported Field Types
|
||||
|
||||
| Starlark Type | Web UI Rendering |
|
||||
|--------------|------------------|
|
||||
| `schema.Location` | Lat/Lng/Timezone picker |
|
||||
| `schema.Text` | Text input field |
|
||||
| `schema.Toggle` | Checkbox/switch |
|
||||
| `schema.Dropdown` | Select dropdown |
|
||||
| `schema.Color` | Color picker |
|
||||
| `schema.DateTime` | Date/time picker |
|
||||
| `schema.OAuth2` | Warning message (not supported) |
|
||||
| `schema.PhotoSelect` | Warning message (not supported) |
|
||||
| `schema.LocationBased` | Text fallback with note |
|
||||
| `schema.Typeahead` | Text fallback with note |
|
||||
|
||||
### Schema Coverage
|
||||
|
||||
- **90-95%** of apps: Full schema support
|
||||
- **5%**: Partial extraction (complex/dynamic schemas)
|
||||
- **<1%**: No schema (apps without configuration)
|
||||
|
||||
Apps without extracted schemas can still run with default settings.
|
||||
|
||||
## File Structure
|
||||
|
||||
```text
|
||||
LEDMatrix/
|
||||
├── plugin-repos/starlark-apps/ # Plugin source code
|
||||
│ ├── manager.py # Main plugin logic
|
||||
│ ├── pixlet_renderer.py # Pixlet CLI wrapper
|
||||
│ ├── frame_extractor.py # WebP decoder
|
||||
│ ├── tronbyte_repository.py # GitHub API client
|
||||
│ └── requirements.txt # Python dependencies
|
||||
│
|
||||
├── starlark-apps/ # Installed apps (user data)
|
||||
│ ├── manifest.json # App registry
|
||||
│ │
|
||||
│ └── analogclock/ # Example app
|
||||
│ ├── analogclock.star # Starlark source
|
||||
│ ├── config.json # User configuration
|
||||
│ ├── schema.json # Extracted schema
|
||||
│ ├── cached_render.webp # Rendered output cache
|
||||
│ └── images/ # App assets (if any)
|
||||
│ ├── hour_hand.png
|
||||
│ └── minute_hand.png
|
||||
│
|
||||
├── bin/pixlet/ # Pixlet binaries
|
||||
│ ├── pixlet-linux-amd64
|
||||
│ ├── pixlet-linux-arm64
|
||||
│ └── pixlet-darwin-arm64
|
||||
│
|
||||
└── scripts/
|
||||
└── download_pixlet.sh # Pixlet installer
|
||||
```
|
||||
|
||||
## API Keys and External Services
|
||||
|
||||
Many apps require API keys for external services:
|
||||
|
||||
### Common API Services
|
||||
|
||||
- **Weather**: OpenWeatherMap, Weather.gov, Dark Sky
|
||||
- **Sports**: ESPN, The Sports DB, SportsData.io
|
||||
- **Finance**: Alpha Vantage, CoinGecko, Yahoo Finance
|
||||
- **Transit**: TransitLand, NextBus, local transit APIs
|
||||
- **News**: NewsAPI, Reddit, RSS feeds
|
||||
|
||||
### Security Note
|
||||
|
||||
- API keys are stored in `config.json` files on disk
|
||||
- The LEDMatrix web interface does NOT encrypt API keys
|
||||
- Ensure your Raspberry Pi is on a trusted network
|
||||
- Use read-only or limited-scope API keys when possible
|
||||
- **Never commit `starlark-apps/*/config.json` to version control**
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pixlet Not Found
|
||||
|
||||
**Symptom**: "Pixlet binary not found" error
|
||||
|
||||
**Solutions**:
|
||||
1. Run auto-installer: **Plugins → Starlark Apps → Install Pixlet**
|
||||
2. Manual install: `bash scripts/download_pixlet.sh`
|
||||
3. Check permissions: `chmod +x bin/pixlet/pixlet-*`
|
||||
4. Verify architecture: `uname -m` matches binary name
|
||||
|
||||
### App Fails to Render
|
||||
|
||||
**Symptom**: "Rendering failed" error in logs
|
||||
|
||||
**Solutions**:
|
||||
1. Check logs: `journalctl -u ledmatrix | grep -i pixlet`
|
||||
2. Verify config: Ensure all required fields are filled
|
||||
3. Test manually: `./bin/pixlet/pixlet-linux-amd64 render starlark-apps/{app-id}/{app-id}.star`
|
||||
4. Missing assets: Some apps need images/fonts that may fail to download
|
||||
5. API issues: Check API keys and rate limits
|
||||
|
||||
### Schema Not Extracted
|
||||
|
||||
**Symptom**: App installs but shows no configuration options
|
||||
|
||||
**Solutions**:
|
||||
1. App may not have a `get_schema()` function (normal for some apps)
|
||||
2. Schema extraction failed: Check logs for parse errors
|
||||
3. Manual config: Edit `starlark-apps/{app-id}/config.json` directly
|
||||
4. Report issue: File bug with app details at LEDMatrix GitHub
|
||||
|
||||
### Apps Show Distorted/Wrong Size
|
||||
|
||||
**Symptom**: Content appears stretched, squished, or cropped
|
||||
|
||||
**Solutions**:
|
||||
1. Check magnify setting: **Plugins → Starlark Apps → Config**
|
||||
2. Try `center_small_output=true` to preserve aspect ratio
|
||||
3. Adjust `magnify` manually (1-8) for your display size
|
||||
4. Some apps assume 64×32 - may not scale perfectly to all sizes
|
||||
|
||||
### App Shows Outdated Data
|
||||
|
||||
**Symptom**: Weather, sports scores, etc. don't update
|
||||
|
||||
**Solutions**:
|
||||
1. Check render interval: **App Config → Render Interval** (300s default)
|
||||
2. Force re-render: **Plugins → Starlark Apps → {App} → Render Now**
|
||||
3. Clear cache: Restart LEDMatrix service
|
||||
4. API rate limits: Some services throttle requests
|
||||
5. Check app logs for API errors
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Render Intervals
|
||||
|
||||
- Apps re-render on a schedule (default: 300s = 5 minutes)
|
||||
- Lower intervals = more CPU/API usage
|
||||
- Recommended minimums:
|
||||
- Static content (clocks): 30-60s
|
||||
- Weather: 300s (5min)
|
||||
- Sports scores: 60-120s
|
||||
- Stock tickers: 60s
|
||||
|
||||
### Caching
|
||||
|
||||
Enable caching to reduce CPU load:
|
||||
- `cache_rendered_output=true` (recommended)
|
||||
- `cache_ttl=300` (5 minutes)
|
||||
|
||||
Cached WebP files are stored in `starlark-apps/{app-id}/cached_render.webp`
|
||||
|
||||
### Display Rotation
|
||||
|
||||
Balance number of enabled apps with display duration:
|
||||
- 5 apps × 15s = 75s full cycle
|
||||
- 20 apps × 15s = 300s (5 min) cycle
|
||||
|
||||
Long cycles may cause apps to render before being displayed.
|
||||
|
||||
## Limitations
|
||||
|
||||
### Unsupported Features
|
||||
|
||||
- **OAuth2 Authentication**: Apps requiring OAuth login won't work
|
||||
- **PhotoSelect**: Image upload from mobile device not supported
|
||||
- **Push Notifications**: Apps can't receive real-time events
|
||||
- **Background Jobs**: No persistent background tasks
|
||||
|
||||
### API Rate Limits
|
||||
|
||||
Many apps use free API tiers with rate limits:
|
||||
- Rendering too frequently may exceed limits
|
||||
- Use appropriate `render_interval` settings
|
||||
- Consider paid API tiers for heavy usage
|
||||
|
||||
### Display Size Constraints
|
||||
|
||||
Apps designed for 64×32 may not utilize larger displays fully:
|
||||
- Content may appear small on 128×64+ displays
|
||||
- Magnification helps but doesn't add detail
|
||||
- Some apps hard-code 64×32 dimensions
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Manual App Installation
|
||||
|
||||
Upload custom `.star` files:
|
||||
1. Navigate to **Starlark Apps → Upload**
|
||||
2. Select `.star` file from disk
|
||||
3. Configure app ID and metadata
|
||||
4. Set render/display timing
|
||||
|
||||
### Custom App Development
|
||||
|
||||
While LEDMatrix runs Tronbyte apps, you can also create your own:
|
||||
|
||||
1. **Learn Starlark**: [Tidbyt Developer Docs](https://tidbyt.dev/)
|
||||
2. **Write `.star` file**: Use Pixlet APIs for rendering
|
||||
3. **Test locally**: `pixlet render myapp.star`
|
||||
4. **Upload**: Use LEDMatrix web UI to install
|
||||
5. **Share**: Contribute to [Tronbyte Apps](https://github.com/tronbyt/apps) repo
|
||||
|
||||
### Configuration Reference
|
||||
|
||||
**Plugin Config** (`config/config.json` → `plugins.starlark-apps`):
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"magnify": 0, // 0 = auto, 1-8 = manual
|
||||
"render_timeout": 30, // Max seconds for Pixlet render
|
||||
"cache_rendered_output": true, // Cache WebP files
|
||||
"cache_ttl": 300, // Cache duration (seconds)
|
||||
"scale_output": true, // Scale to display size
|
||||
"scale_method": "nearest", // nearest|bilinear|bicubic|lanczos
|
||||
"center_small_output": false, // Center instead of scale
|
||||
"default_frame_delay": 50, // Frame timing (ms)
|
||||
"max_frames": null, // Limit frames (null = unlimited)
|
||||
"auto_refresh_apps": true // Auto re-render on interval
|
||||
}
|
||||
```
|
||||
|
||||
**App Config** (`starlark-apps/{app-id}/config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"location": "{\"lat\":\"40.7128\",\"lng\":\"-74.0060\",\"timezone\":\"America/New_York\"}",
|
||||
"units": "imperial",
|
||||
"api_key": "your-api-key-here",
|
||||
"render_interval": 300, // App-specific override
|
||||
"display_duration": 15 // App-specific override
|
||||
}
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
### Official Documentation
|
||||
|
||||
- **Tidbyt Developer Docs**: https://tidbyt.dev/
|
||||
- **Starlark Language**: https://github.com/bazelbuild/starlark
|
||||
- **Pixlet Repository**: https://github.com/tidbyt/pixlet
|
||||
- **Tronbyte Apps**: https://github.com/tronbyt/apps
|
||||
|
||||
### LEDMatrix Documentation
|
||||
|
||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md)
|
||||
- [REST API Reference](REST_API_REFERENCE.md)
|
||||
- [Troubleshooting Guide](TROUBLESHOOTING.md)
|
||||
|
||||
### Community
|
||||
|
||||
- **Tidbyt Community**: https://discuss.tidbyt.com/
|
||||
- **Tronbyte Apps Issues**: https://github.com/tronbyt/apps/issues
|
||||
- **LEDMatrix Issues**: https://github.com/ChuckBuilds/LEDMatrix/issues
|
||||
|
||||
## License and Legal
|
||||
|
||||
- **LEDMatrix**: MIT License (see project root)
|
||||
- **Starlark Apps Plugin**: MIT License (part of LEDMatrix)
|
||||
- **Pixlet**: Apache 2.0 License (Tidbyt Inc.)
|
||||
- **Tronbyte Apps**: Various licenses (see individual app headers)
|
||||
- **Starlark Language**: Apache 2.0 License (Google/Bazel)
|
||||
|
||||
**Disclaimer**: LEDMatrix is an independent project and is not affiliated with, endorsed by, or sponsored by Tidbyt Inc. The Starlark Apps plugin enables interoperability with Tidbyt's open-source ecosystem but does not imply any official relationship.
|
||||
|
||||
## Support
|
||||
|
||||
For issues with:
|
||||
- **LEDMatrix integration**: File issues at [LEDMatrix GitHub](https://github.com/ChuckBuilds/LEDMatrix/issues)
|
||||
- **Specific apps**: File issues at [Tronbyte Apps](https://github.com/tronbyt/apps/issues)
|
||||
- **Pixlet rendering**: File issues at [Pixlet Repository](https://github.com/tidbyt/pixlet/issues)
|
||||
|
||||
---
|
||||
|
||||
**Ready to get started?** Install the Starlark Apps plugin and explore 974+ community apps! 🎨
|
||||
@@ -63,7 +63,7 @@ class StarlarkApp:
|
||||
try:
|
||||
with open(self.config_file, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning(f"Could not load config for {self.app_id}: {e}")
|
||||
return {}
|
||||
|
||||
@@ -73,7 +73,7 @@ class StarlarkApp:
|
||||
try:
|
||||
with open(self.schema_file, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning(f"Could not load schema for {self.app_id}: {e}")
|
||||
return None
|
||||
|
||||
@@ -118,6 +118,11 @@ class StarlarkApp:
|
||||
if isinstance(value, str) and value.strip().startswith('{'):
|
||||
try:
|
||||
loc = json.loads(value)
|
||||
except json.JSONDecodeError as e:
|
||||
return f"Invalid JSON for key {key}: {e}"
|
||||
|
||||
# Validate lat/lng if present
|
||||
try:
|
||||
if 'lat' in loc:
|
||||
lat = float(loc['lat'])
|
||||
if not -90 <= lat <= 90:
|
||||
@@ -126,9 +131,8 @@ class StarlarkApp:
|
||||
lng = float(loc['lng'])
|
||||
if not -180 <= lng <= 180:
|
||||
return f"Longitude {lng} out of range [-180, 180] for key {key}"
|
||||
except (json.JSONDecodeError, ValueError, KeyError) as e:
|
||||
# Not a location field, that's fine
|
||||
pass
|
||||
except ValueError as e:
|
||||
return f"Invalid numeric value for {key}: {e}"
|
||||
|
||||
return None
|
||||
|
||||
@@ -551,38 +555,59 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
def _save_manifest(self, manifest: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Save apps manifest to file with file locking to prevent race conditions.
|
||||
Uses exclusive lock during write to prevent concurrent modifications.
|
||||
Acquires exclusive lock on manifest file before writing to prevent concurrent modifications.
|
||||
"""
|
||||
temp_file = None
|
||||
lock_fd = None
|
||||
try:
|
||||
# Use atomic write pattern: write to temp file, then rename
|
||||
temp_file = self.manifest_file.with_suffix('.tmp')
|
||||
# Create parent directory if needed
|
||||
self.manifest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(temp_file, 'w') as f:
|
||||
# Acquire exclusive lock during write
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
# Open manifest file for locking (create if doesn't exist, don't truncate)
|
||||
# Use os.open with O_CREAT | O_RDWR to create if missing, but don't truncate
|
||||
lock_fd = os.open(str(self.manifest_file), os.O_CREAT | os.O_RDWR, 0o644)
|
||||
|
||||
# Acquire exclusive lock on manifest file BEFORE creating temp file
|
||||
# This serializes all writers and prevents concurrent races
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
|
||||
try:
|
||||
# Now that we hold the lock, create and write temp file
|
||||
temp_file = self.manifest_file.with_suffix('.tmp')
|
||||
|
||||
with open(temp_file, 'w') as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno()) # Ensure data is written to disk
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
# Atomic rename (overwrites destination)
|
||||
temp_file.replace(self.manifest_file)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving manifest: {e}")
|
||||
# Atomic rename (overwrites destination) while still holding lock
|
||||
temp_file.replace(self.manifest_file)
|
||||
return True
|
||||
finally:
|
||||
# Release lock
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
os.close(lock_fd)
|
||||
|
||||
except (OSError, IOError, json.JSONDecodeError, ValueError) as e:
|
||||
self.logger.exception("Error saving manifest while writing manifest file", exc_info=True)
|
||||
# Clean up temp file if it exists
|
||||
if temp_file.exists():
|
||||
if temp_file is not None and temp_file.exists():
|
||||
try:
|
||||
temp_file.unlink()
|
||||
except:
|
||||
pass
|
||||
except Exception as cleanup_exc:
|
||||
self.logger.warning(f"Failed to clean up temp file {temp_file}: {cleanup_exc}")
|
||||
# Clean up lock fd if still open
|
||||
if lock_fd is not None:
|
||||
try:
|
||||
os.close(lock_fd)
|
||||
except Exception as cleanup_exc:
|
||||
self.logger.warning(f"Failed to close lock file descriptor: {cleanup_exc}")
|
||||
return False
|
||||
|
||||
def _update_manifest_safe(self, updater_fn) -> bool:
|
||||
"""
|
||||
Safely update manifest with file locking to prevent race conditions.
|
||||
Holds exclusive lock for entire read-modify-write cycle.
|
||||
|
||||
Args:
|
||||
updater_fn: Function that takes manifest dict and modifies it in-place
|
||||
@@ -590,22 +615,60 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
lock_fd = None
|
||||
temp_file = None
|
||||
try:
|
||||
# Read current manifest with shared lock
|
||||
with open(self.manifest_file, 'r') as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||||
# Create parent directory if needed
|
||||
self.manifest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Open manifest file for locking (create if doesn't exist, don't truncate)
|
||||
lock_fd = os.open(str(self.manifest_file), os.O_CREAT | os.O_RDWR, 0o644)
|
||||
|
||||
# Acquire exclusive lock for entire read-modify-write cycle
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
|
||||
try:
|
||||
# Read current manifest while holding exclusive lock
|
||||
if self.manifest_file.exists() and self.manifest_file.stat().st_size > 0:
|
||||
with open(self.manifest_file, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
else:
|
||||
# Empty or non-existent file, start with default structure
|
||||
manifest = {"apps": {}}
|
||||
|
||||
# Apply updates while still holding lock
|
||||
updater_fn(manifest)
|
||||
|
||||
# Write back to temp file, then atomic replace (still holding lock)
|
||||
temp_file = self.manifest_file.with_suffix('.tmp')
|
||||
with open(temp_file, 'w') as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
|
||||
# Atomic rename while still holding lock
|
||||
temp_file.replace(self.manifest_file)
|
||||
return True
|
||||
|
||||
finally:
|
||||
# Release lock
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
os.close(lock_fd)
|
||||
|
||||
except (OSError, IOError, json.JSONDecodeError, ValueError) as e:
|
||||
self.logger.exception("Error updating manifest during read-modify-write cycle", exc_info=True)
|
||||
# Clean up temp file if it exists
|
||||
if temp_file is not None and temp_file.exists():
|
||||
try:
|
||||
manifest = json.load(f)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
# Apply updates
|
||||
updater_fn(manifest)
|
||||
|
||||
# Write back with exclusive lock (handled by _save_manifest)
|
||||
return self._save_manifest(manifest)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating manifest: {e}")
|
||||
temp_file.unlink()
|
||||
except Exception as cleanup_exc:
|
||||
self.logger.warning(f"Failed to clean up temp file {temp_file}: {cleanup_exc}")
|
||||
# Clean up lock fd if still open
|
||||
if lock_fd is not None:
|
||||
try:
|
||||
os.close(lock_fd)
|
||||
except Exception as cleanup_exc:
|
||||
self.logger.warning(f"Failed to close lock file descriptor: {cleanup_exc}")
|
||||
return False
|
||||
|
||||
def update(self) -> None:
|
||||
@@ -796,7 +859,7 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying frame: {e}")
|
||||
|
||||
def install_app(self, app_id: str, star_file_path: str, metadata: Optional[Dict[str, Any]] = None) -> bool:
|
||||
def install_app(self, app_id: str, star_file_path: str, metadata: Optional[Dict[str, Any]] = None, assets_dir: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Install a new Starlark app.
|
||||
|
||||
@@ -804,6 +867,7 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
app_id: Unique identifier for the app
|
||||
star_file_path: Path to .star file to install
|
||||
metadata: Optional metadata (name, description, etc.)
|
||||
assets_dir: Optional directory containing assets (images/, sources/, etc.)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
@@ -827,6 +891,21 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
self._verify_path_safety(star_dest, self.apps_dir)
|
||||
shutil.copy2(star_file_path, star_dest)
|
||||
|
||||
# Copy asset directories if provided (images/, sources/, etc.)
|
||||
if assets_dir and Path(assets_dir).exists():
|
||||
assets_path = Path(assets_dir)
|
||||
for item in assets_path.iterdir():
|
||||
if item.is_dir():
|
||||
# Copy entire directory (e.g., images/, sources/)
|
||||
dest_dir = app_dir / item.name
|
||||
# Verify dest_dir path safety
|
||||
self._verify_path_safety(dest_dir, self.apps_dir)
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
shutil.copytree(item, dest_dir)
|
||||
self.logger.debug(f"Copied assets directory: {item.name}")
|
||||
self.logger.info(f"Installed assets for {app_id}")
|
||||
|
||||
# Create app manifest entry
|
||||
app_manifest = {
|
||||
"name": metadata.get("name", app_id) if metadata else app_id,
|
||||
@@ -863,7 +942,9 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
def update_fn(manifest):
|
||||
manifest["apps"][safe_app_id] = app_manifest
|
||||
|
||||
self._update_manifest_safe(update_fn)
|
||||
if not self._update_manifest_safe(update_fn):
|
||||
self.logger.error(f"Failed to update manifest for {app_id}")
|
||||
return False
|
||||
|
||||
# Create app instance (use safe_app_id for internal key, original for display)
|
||||
app = StarlarkApp(safe_app_id, app_dir, app_manifest)
|
||||
@@ -897,19 +978,24 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
if self.current_app and self.current_app.app_id == app_id:
|
||||
self.current_app = None
|
||||
|
||||
# Remove from apps dict
|
||||
app = self.apps.pop(app_id)
|
||||
# Get app reference before removing from dict
|
||||
app = self.apps.get(app_id)
|
||||
|
||||
# Remove directory
|
||||
if app.app_dir.exists():
|
||||
shutil.rmtree(app.app_dir)
|
||||
|
||||
# Update manifest
|
||||
# Update manifest FIRST (before modifying filesystem)
|
||||
def update_fn(manifest):
|
||||
if app_id in manifest["apps"]:
|
||||
del manifest["apps"][app_id]
|
||||
|
||||
self._update_manifest_safe(update_fn)
|
||||
if not self._update_manifest_safe(update_fn):
|
||||
self.logger.error(f"Failed to update manifest when uninstalling {app_id}")
|
||||
return False
|
||||
|
||||
# Remove from apps dict
|
||||
self.apps.pop(app_id)
|
||||
|
||||
# Remove directory (after manifest update succeeds)
|
||||
if app and app.app_dir.exists():
|
||||
shutil.rmtree(app.app_dir)
|
||||
|
||||
self.logger.info(f"Uninstalled Starlark app: {app_id}")
|
||||
return True
|
||||
|
||||
@@ -41,9 +41,9 @@ class PixletRenderer:
|
||||
self.pixlet_binary = self._find_pixlet_binary(pixlet_path)
|
||||
|
||||
if self.pixlet_binary:
|
||||
logger.info(f"Pixlet renderer initialized with binary: {self.pixlet_binary}")
|
||||
logger.info(f"[Starlark Pixlet] Pixlet renderer initialized with binary: {self.pixlet_binary}")
|
||||
else:
|
||||
logger.warning("Pixlet binary not found - rendering will fail")
|
||||
logger.warning("[Starlark Pixlet] Pixlet binary not found - rendering will fail")
|
||||
|
||||
def _find_pixlet_binary(self, explicit_path: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
@@ -239,16 +239,15 @@ class PixletRenderer:
|
||||
return False, f"Star file not found: {star_file}"
|
||||
|
||||
try:
|
||||
# Build command
|
||||
# Build command - config params must be POSITIONAL between star_file and flags
|
||||
# Format: pixlet render <file.star> [key=value]... [flags]
|
||||
cmd = [
|
||||
self.pixlet_binary,
|
||||
"render",
|
||||
star_file,
|
||||
"-o", output_path,
|
||||
"-m", str(magnify)
|
||||
star_file
|
||||
]
|
||||
|
||||
# Add configuration parameters
|
||||
# Add configuration parameters as positional arguments (BEFORE flags)
|
||||
if config:
|
||||
for key, value in config.items():
|
||||
# Validate key format (alphanumeric + underscore only)
|
||||
@@ -259,18 +258,35 @@ class PixletRenderer:
|
||||
# Convert value to string for CLI
|
||||
if isinstance(value, bool):
|
||||
value_str = "true" if value else "false"
|
||||
elif isinstance(value, str) and (value.startswith('{') or value.startswith('[')):
|
||||
# JSON string - keep as-is, will be properly quoted by subprocess
|
||||
value_str = value
|
||||
else:
|
||||
value_str = str(value)
|
||||
|
||||
# Validate value doesn't contain shell metacharacters
|
||||
# Allow alphanumeric, spaces, and common safe chars: .-_:/@#,
|
||||
if not re.match(r'^[a-zA-Z0-9 .\-_:/@#,{}"\[\]]*$', value_str):
|
||||
logger.warning(f"Skipping config value with unsafe characters for key {key}: {value_str}")
|
||||
# Validate value doesn't contain dangerous shell metacharacters
|
||||
# Block: backticks, $(), pipes, redirects, semicolons, ampersands, null bytes
|
||||
# Allow: most printable chars including spaces, quotes, brackets, braces
|
||||
if re.search(r'[`$|<>&;\x00]|\$\(', value_str):
|
||||
logger.warning(f"Skipping config value with unsafe shell characters for key {key}: {value_str}")
|
||||
continue
|
||||
|
||||
cmd.extend(["-c", f"{key}={value_str}"])
|
||||
# Add as positional argument (not -c flag)
|
||||
cmd.append(f"{key}={value_str}")
|
||||
|
||||
logger.debug(f"Executing Pixlet: {' '.join(cmd)}")
|
||||
# Add flags AFTER positional config arguments
|
||||
cmd.extend([
|
||||
"-o", output_path,
|
||||
"-m", str(magnify)
|
||||
])
|
||||
|
||||
# Build sanitized command for logging (redact sensitive values)
|
||||
sanitized_cmd = [self.pixlet_binary, "render", star_file]
|
||||
if config:
|
||||
config_keys = list(config.keys())
|
||||
sanitized_cmd.append(f"[{len(config_keys)} config entries: {', '.join(config_keys)}]")
|
||||
sanitized_cmd.extend(["-o", output_path, "-m", str(magnify)])
|
||||
logger.debug(f"Executing Pixlet: {' '.join(sanitized_cmd)}")
|
||||
|
||||
# Execute rendering
|
||||
safe_cwd = self._get_safe_working_directory(star_file)
|
||||
@@ -363,6 +379,7 @@ class PixletRenderer:
|
||||
# Extract get_schema() function body
|
||||
schema_body = self._extract_get_schema_body(content)
|
||||
if not schema_body:
|
||||
logger.debug(f"No get_schema() function found in {file_path}")
|
||||
return None
|
||||
|
||||
# Extract version
|
||||
@@ -387,6 +404,7 @@ class PixletRenderer:
|
||||
|
||||
if bracket_count != 0:
|
||||
# Unmatched brackets
|
||||
logger.warning(f"Unmatched brackets in schema fields for {file_path}")
|
||||
return {"version": version, "schema": []}
|
||||
|
||||
fields_text = schema_body[fields_start_match.end():i-1]
|
||||
@@ -460,7 +478,7 @@ class PixletRenderer:
|
||||
|
||||
def _extract_get_schema_body(self, content: str) -> Optional[str]:
|
||||
"""
|
||||
Extract get_schema() function body.
|
||||
Extract get_schema() function body using indentation-aware parsing.
|
||||
|
||||
Args:
|
||||
content: .star file content
|
||||
@@ -468,12 +486,45 @@ class PixletRenderer:
|
||||
Returns:
|
||||
Function body text, or None if not found
|
||||
"""
|
||||
# Find def get_schema():
|
||||
pattern = r'def\s+get_schema\s*\(\s*\)\s*:(.*?)(?=\ndef\s|\Z)'
|
||||
match = re.search(pattern, content, re.DOTALL)
|
||||
# Find def get_schema(): line
|
||||
pattern = r'^(\s*)def\s+get_schema\s*\(\s*\)\s*:'
|
||||
match = re.search(pattern, content, re.MULTILINE)
|
||||
|
||||
if match:
|
||||
return match.group(1)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
# Get the indentation level of the function definition
|
||||
func_indent = len(match.group(1))
|
||||
func_start = match.end()
|
||||
|
||||
# Split content into lines starting after the function definition
|
||||
lines_after = content[func_start:].split('\n')
|
||||
body_lines = []
|
||||
|
||||
for line in lines_after:
|
||||
# Skip empty lines
|
||||
if not line.strip():
|
||||
body_lines.append(line)
|
||||
continue
|
||||
|
||||
# Calculate indentation of current line
|
||||
stripped = line.lstrip()
|
||||
line_indent = len(line) - len(stripped)
|
||||
|
||||
# If line has same or less indentation than function def, check if it's a top-level def
|
||||
if line_indent <= func_indent:
|
||||
# This is a line at the same or outer level - check if it's a function
|
||||
if re.match(r'def\s+\w+', stripped):
|
||||
# Found next top-level function, stop here
|
||||
break
|
||||
# Otherwise it might be a comment or other top-level code, stop anyway
|
||||
break
|
||||
|
||||
# Line is indented more than function def, so it's part of the body
|
||||
body_lines.append(line)
|
||||
|
||||
if body_lines:
|
||||
return '\n'.join(body_lines)
|
||||
return None
|
||||
|
||||
def _parse_schema_field(self, field_type: str, params_text: str, var_table: Dict) -> Optional[Dict[str, Any]]:
|
||||
@@ -536,15 +587,24 @@ class PixletRenderer:
|
||||
field_dict['icon'] = icon_match.group(1)
|
||||
|
||||
# default (can be string, bool, or variable reference)
|
||||
default_match = re.search(r'default\s*=\s*([^,\)]+)', params_text)
|
||||
# First try to match quoted strings (which may contain commas)
|
||||
default_match = re.search(r'default\s*=\s*"([^"]*)"', params_text)
|
||||
if not default_match:
|
||||
# Try single quotes
|
||||
default_match = re.search(r"default\s*=\s*'([^']*)'", params_text)
|
||||
if not default_match:
|
||||
# Fall back to unquoted value (stop at comma or closing paren)
|
||||
default_match = re.search(r'default\s*=\s*([^,\)]+)', params_text)
|
||||
|
||||
if default_match:
|
||||
default_value = default_match.group(1).strip()
|
||||
# Handle boolean
|
||||
if default_value in ('True', 'False'):
|
||||
field_dict['default'] = default_value.lower()
|
||||
# Handle string literal
|
||||
elif default_value.startswith('"') and default_value.endswith('"'):
|
||||
field_dict['default'] = default_value.strip('"')
|
||||
# Handle string literal from first two patterns (already extracted without quotes)
|
||||
elif re.search(r'default\s*=\s*["\']', params_text):
|
||||
# This was a quoted string, use the captured content directly
|
||||
field_dict['default'] = default_value
|
||||
# Handle variable reference (can't resolve, use as-is)
|
||||
else:
|
||||
# Try to extract just the value if it's like options[0].value
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
Pillow>=10.0.0
|
||||
PyYAML>=6.0
|
||||
requests>=2.31.0
|
||||
Pillow>=10.4.0
|
||||
PyYAML>=6.0.2
|
||||
requests>=2.32.0
|
||||
|
||||
@@ -9,6 +9,7 @@ import logging
|
||||
import time
|
||||
import requests
|
||||
import yaml
|
||||
import threading
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from pathlib import Path
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
@@ -18,6 +19,7 @@ logger = logging.getLogger(__name__)
|
||||
# Module-level cache for bulk app listing (survives across requests)
|
||||
_apps_cache = {'data': None, 'timestamp': 0, 'categories': [], 'authors': []}
|
||||
_CACHE_TTL = 7200 # 2 hours
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
class TronbyteRepository:
|
||||
@@ -94,16 +96,17 @@ class TronbyteRepository:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
return None
|
||||
|
||||
def _fetch_raw_file(self, file_path: str, branch: Optional[str] = None) -> Optional[str]:
|
||||
def _fetch_raw_file(self, file_path: str, branch: Optional[str] = None, binary: bool = False):
|
||||
"""
|
||||
Fetch raw file content from repository.
|
||||
|
||||
Args:
|
||||
file_path: Path to file in repository
|
||||
branch: Branch name (default: DEFAULT_BRANCH)
|
||||
binary: If True, return bytes; if False, return text
|
||||
|
||||
Returns:
|
||||
File content as string, or None on error
|
||||
File content as string/bytes, or None on error
|
||||
"""
|
||||
branch = branch or self.DEFAULT_BRANCH
|
||||
url = f"{self.raw_url}/{self.REPO_OWNER}/{self.REPO_NAME}/{branch}/{file_path}"
|
||||
@@ -111,7 +114,7 @@ class TronbyteRepository:
|
||||
try:
|
||||
response = self.session.get(url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
return response.text
|
||||
return response.content if binary else response.text
|
||||
else:
|
||||
logger.warning(f"Failed to fetch raw file: {file_path} ({response.status_code})")
|
||||
return None
|
||||
@@ -252,14 +255,17 @@ class TronbyteRepository:
|
||||
global _apps_cache
|
||||
|
||||
now = time.time()
|
||||
if _apps_cache['data'] is not None and (now - _apps_cache['timestamp']) < _CACHE_TTL:
|
||||
return {
|
||||
'apps': _apps_cache['data'],
|
||||
'categories': _apps_cache['categories'],
|
||||
'authors': _apps_cache['authors'],
|
||||
'count': len(_apps_cache['data']),
|
||||
'cached': True
|
||||
}
|
||||
|
||||
# Check cache with lock (read-only check)
|
||||
with _cache_lock:
|
||||
if _apps_cache['data'] is not None and (now - _apps_cache['timestamp']) < _CACHE_TTL:
|
||||
return {
|
||||
'apps': _apps_cache['data'],
|
||||
'categories': _apps_cache['categories'],
|
||||
'authors': _apps_cache['authors'],
|
||||
'count': len(_apps_cache['data']),
|
||||
'cached': True
|
||||
}
|
||||
|
||||
# Fetch directory listing (1 GitHub API call)
|
||||
success, app_dirs, error = self.list_apps()
|
||||
@@ -282,8 +288,8 @@ class TronbyteRepository:
|
||||
metadata['id'] = app_id
|
||||
metadata['repository_path'] = app_info.get('path', '')
|
||||
return metadata
|
||||
except (yaml.YAMLError, TypeError):
|
||||
pass
|
||||
except (yaml.YAMLError, TypeError) as e:
|
||||
logger.warning(f"Failed to parse manifest for {app_id}: {e}")
|
||||
# Fallback: minimal entry
|
||||
return {
|
||||
'id': app_id,
|
||||
@@ -294,7 +300,7 @@ class TronbyteRepository:
|
||||
|
||||
# Parallel manifest fetches via raw.githubusercontent.com (high rate limit)
|
||||
apps_with_metadata = []
|
||||
with ThreadPoolExecutor(max_workers=20) as executor:
|
||||
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||
futures = {executor.submit(fetch_one, info): info for info in app_dirs}
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
@@ -318,11 +324,12 @@ class TronbyteRepository:
|
||||
categories = sorted({a.get('category', '') for a in apps_with_metadata if a.get('category')})
|
||||
authors = sorted({a.get('author', '') for a in apps_with_metadata if a.get('author')})
|
||||
|
||||
# Update cache
|
||||
_apps_cache['data'] = apps_with_metadata
|
||||
_apps_cache['timestamp'] = now
|
||||
_apps_cache['categories'] = categories
|
||||
_apps_cache['authors'] = authors
|
||||
# Update cache with lock
|
||||
with _cache_lock:
|
||||
_apps_cache['data'] = apps_with_metadata
|
||||
_apps_cache['timestamp'] = now
|
||||
_apps_cache['categories'] = categories
|
||||
_apps_cache['authors'] = authors
|
||||
|
||||
logger.info(f"Cached {len(apps_with_metadata)} apps ({len(categories)} categories, {len(authors)} authors)")
|
||||
|
||||
@@ -347,8 +354,36 @@ class TronbyteRepository:
|
||||
Returns:
|
||||
Tuple of (success, error_message)
|
||||
"""
|
||||
# Use provided filename or fall back to app_id.star
|
||||
# Validate inputs for path traversal
|
||||
if '..' in app_id or '/' in app_id or '\\' in app_id:
|
||||
return False, f"Invalid app_id: contains path traversal characters"
|
||||
|
||||
star_filename = filename or f"{app_id}.star"
|
||||
if '..' in star_filename or '/' in star_filename or '\\' in star_filename:
|
||||
return False, f"Invalid filename: contains path traversal characters"
|
||||
|
||||
# Validate output_path to prevent path traversal
|
||||
import tempfile
|
||||
try:
|
||||
resolved_output = output_path.resolve()
|
||||
temp_dir = Path(tempfile.gettempdir()).resolve()
|
||||
|
||||
# Check if output_path is within the system temp directory
|
||||
# Use try/except for compatibility with Python < 3.9 (is_relative_to)
|
||||
try:
|
||||
is_safe = resolved_output.is_relative_to(temp_dir)
|
||||
except AttributeError:
|
||||
# Fallback for Python < 3.9: compare string paths
|
||||
is_safe = str(resolved_output).startswith(str(temp_dir) + '/')
|
||||
|
||||
if not is_safe:
|
||||
logger.warning(f"Path traversal attempt in download_star_file: app_id={app_id}, output_path={output_path}")
|
||||
return False, f"Invalid output_path for {app_id}: must be within temp directory"
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating output_path for {app_id}: {e}")
|
||||
return False, f"Invalid output_path for {app_id}"
|
||||
|
||||
# Use provided filename or fall back to app_id.star
|
||||
star_path = f"{self.APPS_PATH}/{app_id}/{star_filename}"
|
||||
|
||||
content = self._fetch_raw_file(star_path)
|
||||
@@ -389,6 +424,97 @@ class TronbyteRepository:
|
||||
files = [item['name'] for item in data if item.get('type') == 'file']
|
||||
return True, files, None
|
||||
|
||||
def download_app_assets(self, app_id: str, output_dir: Path) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Download all asset files (images, sources, etc.) for an app.
|
||||
|
||||
Args:
|
||||
app_id: App identifier
|
||||
output_dir: Directory to save assets to
|
||||
|
||||
Returns:
|
||||
Tuple of (success, error_message)
|
||||
"""
|
||||
# Validate app_id for path traversal
|
||||
if '..' in app_id or '/' in app_id or '\\' in app_id:
|
||||
return False, f"Invalid app_id: contains path traversal characters"
|
||||
|
||||
try:
|
||||
# Get directory listing for the app
|
||||
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}/{app_id}"
|
||||
data = self._make_request(url)
|
||||
if not data:
|
||||
return False, f"Failed to fetch app directory listing"
|
||||
|
||||
if not isinstance(data, list):
|
||||
return False, f"Invalid directory listing format"
|
||||
|
||||
# Find directories that contain assets (images, sources, etc.)
|
||||
asset_dirs = []
|
||||
for item in data:
|
||||
if item.get('type') == 'dir':
|
||||
dir_name = item.get('name')
|
||||
# Common asset directory names in Tronbyte apps
|
||||
if dir_name in ('images', 'sources', 'fonts', 'assets'):
|
||||
asset_dirs.append((dir_name, item.get('url')))
|
||||
|
||||
if not asset_dirs:
|
||||
# No asset directories, this is fine
|
||||
return True, None
|
||||
|
||||
# Download each asset directory
|
||||
for dir_name, dir_url in asset_dirs:
|
||||
# Validate directory name for path traversal
|
||||
if '..' in dir_name or '/' in dir_name or '\\' in dir_name:
|
||||
logger.warning(f"Skipping potentially unsafe directory: {dir_name}")
|
||||
continue
|
||||
|
||||
# Get files in this directory
|
||||
dir_data = self._make_request(dir_url)
|
||||
if not dir_data or not isinstance(dir_data, list):
|
||||
logger.warning(f"Could not list files in {app_id}/{dir_name}")
|
||||
continue
|
||||
|
||||
# Create local directory
|
||||
local_dir = output_dir / dir_name
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download each file
|
||||
for file_item in dir_data:
|
||||
if file_item.get('type') == 'file':
|
||||
file_name = file_item.get('name')
|
||||
|
||||
# Ensure file_name is a non-empty string before validation
|
||||
if not file_name or not isinstance(file_name, str):
|
||||
logger.warning(f"Skipping file with invalid name in {dir_name}: {file_item}")
|
||||
continue
|
||||
|
||||
# Validate filename for path traversal
|
||||
if '..' in file_name or '/' in file_name or '\\' in file_name:
|
||||
logger.warning(f"Skipping potentially unsafe file: {file_name}")
|
||||
continue
|
||||
|
||||
file_path = f"{self.APPS_PATH}/{app_id}/{dir_name}/{file_name}"
|
||||
content = self._fetch_raw_file(file_path, binary=True)
|
||||
if content:
|
||||
# Write binary content to file
|
||||
output_path = local_dir / file_name
|
||||
try:
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(content)
|
||||
logger.debug(f"Downloaded asset: {dir_name}/{file_name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save {dir_name}/{file_name}: {e}")
|
||||
else:
|
||||
logger.warning(f"Failed to download {dir_name}/{file_name}")
|
||||
|
||||
logger.info(f"Downloaded assets for {app_id} ({len(asset_dirs)} directories)")
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error downloading assets for {app_id}: {e}")
|
||||
return False, f"Error downloading assets: {e}"
|
||||
|
||||
def search_apps(self, query: str, apps_with_metadata: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search apps by name, summary, or description.
|
||||
|
||||
@@ -10,6 +10,7 @@ import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Dict, Any, Type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -2183,10 +2184,10 @@ def toggle_plugin():
|
||||
if starlark_plugin and starlark_app_id in starlark_plugin.apps:
|
||||
app = starlark_plugin.apps[starlark_app_id]
|
||||
app.manifest['enabled'] = enabled
|
||||
with open(starlark_plugin.manifest_file, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
manifest['apps'][starlark_app_id]['enabled'] = enabled
|
||||
starlark_plugin._save_manifest(manifest)
|
||||
# Use safe manifest update to prevent race conditions
|
||||
def update_fn(manifest):
|
||||
manifest['apps'][starlark_app_id]['enabled'] = enabled
|
||||
starlark_plugin._update_manifest_safe(update_fn)
|
||||
else:
|
||||
# Standalone: update manifest directly
|
||||
manifest = _read_starlark_manifest()
|
||||
@@ -6979,7 +6980,7 @@ def clear_old_errors():
|
||||
|
||||
# ─── Starlark Apps API ──────────────────────────────────────────────────────
|
||||
|
||||
def _get_tronbyte_repository_class():
|
||||
def _get_tronbyte_repository_class() -> Type[Any]:
|
||||
"""Import TronbyteRepository from plugin-repos directory."""
|
||||
import importlib.util
|
||||
import importlib
|
||||
@@ -6993,14 +6994,20 @@ def _get_tronbyte_repository_class():
|
||||
importlib.reload(sys.modules["tronbyte_repository"])
|
||||
return sys.modules["tronbyte_repository"].TronbyteRepository
|
||||
|
||||
spec = importlib.util.spec_from_file_location("tronbyte_repository", module_path)
|
||||
spec = importlib.util.spec_from_file_location("tronbyte_repository", str(module_path))
|
||||
if spec is None:
|
||||
raise ImportError(f"Failed to create module spec for tronbyte_repository at {module_path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
if module is None:
|
||||
raise ImportError(f"Failed to create module from spec for tronbyte_repository")
|
||||
|
||||
sys.modules["tronbyte_repository"] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module.TronbyteRepository
|
||||
|
||||
|
||||
def _get_pixlet_renderer_class():
|
||||
def _get_pixlet_renderer_class() -> Type[Any]:
|
||||
"""Import PixletRenderer from plugin-repos directory."""
|
||||
import importlib.util
|
||||
import importlib
|
||||
@@ -7014,14 +7021,20 @@ def _get_pixlet_renderer_class():
|
||||
importlib.reload(sys.modules["pixlet_renderer"])
|
||||
return sys.modules["pixlet_renderer"].PixletRenderer
|
||||
|
||||
spec = importlib.util.spec_from_file_location("pixlet_renderer", module_path)
|
||||
spec = importlib.util.spec_from_file_location("pixlet_renderer", str(module_path))
|
||||
if spec is None:
|
||||
raise ImportError(f"Failed to create module spec for pixlet_renderer at {module_path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
if module is None:
|
||||
raise ImportError(f"Failed to create module from spec for pixlet_renderer")
|
||||
|
||||
sys.modules["pixlet_renderer"] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module.PixletRenderer
|
||||
|
||||
|
||||
def _validate_and_sanitize_app_id(app_id, fallback_source=None):
|
||||
def _validate_and_sanitize_app_id(app_id: Optional[str], fallback_source: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Validate and sanitize app_id to a safe slug."""
|
||||
if not app_id and fallback_source:
|
||||
app_id = fallback_source
|
||||
@@ -7038,7 +7051,7 @@ def _validate_and_sanitize_app_id(app_id, fallback_source=None):
|
||||
return sanitized, None
|
||||
|
||||
|
||||
def _validate_timing_value(value, field_name, min_val=1, max_val=86400):
|
||||
def _validate_timing_value(value: Any, field_name: str, min_val: int = 1, max_val: int = 86400) -> Tuple[Optional[int], Optional[str]]:
|
||||
"""Validate and coerce timing values."""
|
||||
if value is None:
|
||||
return None, None
|
||||
@@ -7053,7 +7066,7 @@ def _validate_timing_value(value, field_name, min_val=1, max_val=86400):
|
||||
return int_value, None
|
||||
|
||||
|
||||
def _get_starlark_plugin():
|
||||
def _get_starlark_plugin() -> Optional[Any]:
|
||||
"""Get the starlark-apps plugin instance, or None."""
|
||||
if not api_v3.plugin_manager:
|
||||
return None
|
||||
@@ -7065,7 +7078,7 @@ _STARLARK_APPS_DIR = PROJECT_ROOT / 'starlark-apps'
|
||||
_STARLARK_MANIFEST_FILE = _STARLARK_APPS_DIR / 'manifest.json'
|
||||
|
||||
|
||||
def _read_starlark_manifest() -> dict:
|
||||
def _read_starlark_manifest() -> Dict[str, Any]:
|
||||
"""Read the starlark-apps manifest.json directly from disk."""
|
||||
try:
|
||||
if _STARLARK_MANIFEST_FILE.exists():
|
||||
@@ -7076,19 +7089,34 @@ def _read_starlark_manifest() -> dict:
|
||||
return {'apps': {}}
|
||||
|
||||
|
||||
def _write_starlark_manifest(manifest: dict) -> bool:
|
||||
"""Write the starlark-apps manifest.json to disk."""
|
||||
def _write_starlark_manifest(manifest: Dict[str, Any]) -> bool:
|
||||
"""Write the starlark-apps manifest.json to disk with atomic write."""
|
||||
temp_file = None
|
||||
try:
|
||||
_STARLARK_APPS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(_STARLARK_MANIFEST_FILE, 'w') as f:
|
||||
|
||||
# Atomic write pattern: write to temp file, then rename
|
||||
temp_file = _STARLARK_MANIFEST_FILE.with_suffix('.tmp')
|
||||
with open(temp_file, 'w') as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno()) # Ensure data is written to disk
|
||||
|
||||
# Atomic rename (overwrites destination)
|
||||
temp_file.replace(_STARLARK_MANIFEST_FILE)
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.error(f"Error writing starlark manifest: {e}")
|
||||
# Clean up temp file if it exists
|
||||
if temp_file and temp_file.exists():
|
||||
try:
|
||||
temp_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _install_star_file(app_id: str, star_file_path: str, metadata: dict) -> bool:
|
||||
def _install_star_file(app_id: str, star_file_path: str, metadata: Dict[str, Any], assets_dir: Optional[str] = None) -> bool:
|
||||
"""Install a .star file and update the manifest (standalone, no plugin needed)."""
|
||||
import shutil
|
||||
import json
|
||||
@@ -7097,7 +7125,21 @@ def _install_star_file(app_id: str, star_file_path: str, metadata: dict) -> bool
|
||||
dest = app_dir / f"{app_id}.star"
|
||||
shutil.copy2(star_file_path, str(dest))
|
||||
|
||||
# Copy asset directories if provided (images/, sources/, etc.)
|
||||
if assets_dir and Path(assets_dir).exists():
|
||||
assets_path = Path(assets_dir)
|
||||
for item in assets_path.iterdir():
|
||||
if item.is_dir():
|
||||
# Copy entire directory (e.g., images/, sources/)
|
||||
dest_dir = app_dir / item.name
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
shutil.copytree(item, dest_dir)
|
||||
logger.debug(f"Copied assets directory: {item.name}")
|
||||
logger.info(f"Installed assets for {app_id}")
|
||||
|
||||
# Try to extract schema using PixletRenderer
|
||||
schema = None
|
||||
try:
|
||||
PixletRenderer = _get_pixlet_renderer_class()
|
||||
pixlet = PixletRenderer()
|
||||
@@ -7111,6 +7153,19 @@ def _install_star_file(app_id: str, star_file_path: str, metadata: dict) -> bool
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract schema for {app_id}: {e}")
|
||||
|
||||
# Create default config — pre-populate with schema defaults
|
||||
default_config = {}
|
||||
if schema:
|
||||
fields = schema.get('fields') or schema.get('schema') or []
|
||||
for field in fields:
|
||||
if isinstance(field, dict) and 'id' in field and 'default' in field:
|
||||
default_config[field['id']] = field['default']
|
||||
|
||||
# Create config.json file
|
||||
config_path = app_dir / "config.json"
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(default_config, f, indent=2)
|
||||
|
||||
manifest = _read_starlark_manifest()
|
||||
manifest.setdefault('apps', {})[app_id] = {
|
||||
'name': metadata.get('name', app_id),
|
||||
@@ -7302,6 +7357,14 @@ def upload_starlark_app():
|
||||
if not file.filename or not file.filename.endswith('.star'):
|
||||
return jsonify({'status': 'error', 'message': 'File must have .star extension'}), 400
|
||||
|
||||
# Check file size (limit to 5MB for .star files)
|
||||
file.seek(0, 2) # Seek to end
|
||||
file_size = file.tell()
|
||||
file.seek(0) # Reset to beginning
|
||||
MAX_STAR_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
if file_size > MAX_STAR_SIZE:
|
||||
return jsonify({'status': 'error', 'message': f'File too large (max 5MB, got {file_size/1024/1024:.1f}MB)'}), 400
|
||||
|
||||
app_name = request.form.get('name')
|
||||
app_id_input = request.form.get('app_id')
|
||||
filename_base = file.filename.replace('.star', '') if file.filename else None
|
||||
@@ -7362,7 +7425,15 @@ def uninstall_starlark_app(app_id):
|
||||
else:
|
||||
# Standalone: remove app dir and manifest entry
|
||||
import shutil
|
||||
app_dir = _STARLARK_APPS_DIR / app_id
|
||||
app_dir = (_STARLARK_APPS_DIR / app_id).resolve()
|
||||
|
||||
# Path traversal check - ensure app_dir is within _STARLARK_APPS_DIR
|
||||
try:
|
||||
app_dir.relative_to(_STARLARK_APPS_DIR.resolve())
|
||||
except ValueError:
|
||||
logger.warning(f"Path traversal attempt in uninstall: {app_id}")
|
||||
return jsonify({'status': 'error', 'message': 'Invalid app_id'}), 400
|
||||
|
||||
if app_dir.exists():
|
||||
shutil.rmtree(app_dir)
|
||||
manifest = _read_starlark_manifest()
|
||||
@@ -7390,12 +7461,32 @@ def get_starlark_app_config(app_id):
|
||||
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||
return jsonify({'status': 'success', 'config': app.config, 'schema': app.schema})
|
||||
|
||||
# Standalone: read from manifest
|
||||
manifest = _read_starlark_manifest()
|
||||
app_data = manifest.get('apps', {}).get(app_id)
|
||||
if not app_data:
|
||||
# Standalone: read from config.json file
|
||||
app_dir = _STARLARK_APPS_DIR / app_id
|
||||
config_file = app_dir / "config.json"
|
||||
|
||||
if not app_dir.exists():
|
||||
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||
return jsonify({'status': 'success', 'config': app_data.get('config', {}), 'schema': None})
|
||||
|
||||
config = {}
|
||||
if config_file.exists():
|
||||
try:
|
||||
with open(config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load config for {app_id}: {e}")
|
||||
|
||||
# Load schema from schema.json
|
||||
schema = None
|
||||
schema_file = app_dir / "schema.json"
|
||||
if schema_file.exists():
|
||||
try:
|
||||
with open(schema_file, 'r') as f:
|
||||
schema = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load schema for {app_id}: {e}")
|
||||
|
||||
return jsonify({'status': 'success', 'config': config, 'schema': schema})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting config for {app_id}: {e}")
|
||||
@@ -7464,22 +7555,51 @@ def update_starlark_app_config(app_id):
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Failed to save configuration'}), 500
|
||||
|
||||
# Standalone: update manifest directly
|
||||
# Standalone: update both config.json and manifest
|
||||
manifest = _read_starlark_manifest()
|
||||
app_data = manifest.get('apps', {}).get(app_id)
|
||||
if not app_data:
|
||||
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||
|
||||
# Extract timing keys (they go in manifest, not config.json)
|
||||
render_interval = data.pop('render_interval', None)
|
||||
display_duration = data.pop('display_duration', None)
|
||||
|
||||
# Update manifest with timing values
|
||||
if render_interval is not None:
|
||||
app_data['render_interval'] = render_interval
|
||||
if display_duration is not None:
|
||||
app_data['display_duration'] = display_duration
|
||||
|
||||
# Load current config from config.json
|
||||
app_dir = _STARLARK_APPS_DIR / app_id
|
||||
config_file = app_dir / "config.json"
|
||||
current_config = {}
|
||||
if config_file.exists():
|
||||
try:
|
||||
with open(config_file, 'r') as f:
|
||||
current_config = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load config for {app_id}: {e}")
|
||||
|
||||
# Update config with new values (excluding timing keys)
|
||||
current_config.update(data)
|
||||
|
||||
# Write updated config to config.json
|
||||
try:
|
||||
with open(config_file, 'w') as f:
|
||||
json.dump(current_config, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save config.json for {app_id}: {e}")
|
||||
return jsonify({'status': 'error', 'message': f'Failed to save configuration: {e}'}), 500
|
||||
|
||||
# Also update manifest for backward compatibility
|
||||
app_data.setdefault('config', {}).update(data)
|
||||
if 'render_interval' in data:
|
||||
app_data['render_interval'] = data['render_interval']
|
||||
if 'display_duration' in data:
|
||||
app_data['display_duration'] = data['display_duration']
|
||||
|
||||
if _write_starlark_manifest(manifest):
|
||||
return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': app_data.get('config', {})})
|
||||
return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': current_config})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Failed to save configuration'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'Failed to save manifest'}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating config for {app_id}: {e}")
|
||||
@@ -7618,29 +7738,45 @@ def install_from_tronbyte_repository():
|
||||
if not success:
|
||||
return jsonify({'status': 'error', 'message': f'Failed to download app: {error}'}), 500
|
||||
|
||||
render_interval = data.get('render_interval', 300)
|
||||
ri, err = _validate_timing_value(render_interval, 'render_interval')
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
render_interval = ri or 300
|
||||
# Download assets (images, sources, etc.) to a temp directory
|
||||
import tempfile
|
||||
temp_assets_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
success_assets, error_assets = repo.download_app_assets(data['app_id'], Path(temp_assets_dir))
|
||||
# Asset download is non-critical - log warning but continue if it fails
|
||||
if not success_assets:
|
||||
logger.warning(f"Failed to download assets for {data['app_id']}: {error_assets}")
|
||||
|
||||
display_duration = data.get('display_duration', 15)
|
||||
dd, err = _validate_timing_value(display_duration, 'display_duration')
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
display_duration = dd or 15
|
||||
render_interval = data.get('render_interval', 300)
|
||||
ri, err = _validate_timing_value(render_interval, 'render_interval')
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
render_interval = ri or 300
|
||||
|
||||
install_metadata = {
|
||||
'name': metadata.get('name', app_id) if metadata else app_id,
|
||||
'render_interval': render_interval,
|
||||
'display_duration': display_duration
|
||||
}
|
||||
display_duration = data.get('display_duration', 15)
|
||||
dd, err = _validate_timing_value(display_duration, 'display_duration')
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
display_duration = dd or 15
|
||||
|
||||
starlark_plugin = _get_starlark_plugin()
|
||||
if starlark_plugin:
|
||||
success = starlark_plugin.install_app(data['app_id'], temp_path, install_metadata)
|
||||
else:
|
||||
success = _install_star_file(data['app_id'], temp_path, install_metadata)
|
||||
install_metadata = {
|
||||
'name': metadata.get('name', app_id) if metadata else app_id,
|
||||
'render_interval': render_interval,
|
||||
'display_duration': display_duration
|
||||
}
|
||||
|
||||
starlark_plugin = _get_starlark_plugin()
|
||||
if starlark_plugin:
|
||||
success = starlark_plugin.install_app(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir)
|
||||
else:
|
||||
success = _install_star_file(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir)
|
||||
finally:
|
||||
# Clean up temp assets directory
|
||||
import shutil
|
||||
try:
|
||||
shutil.rmtree(temp_assets_dir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if success:
|
||||
return jsonify({'status': 'success', 'message': f'App installed: {metadata.get("name", app_id) if metadata else app_id}', 'app_id': app_id})
|
||||
|
||||
@@ -470,6 +470,26 @@ def _load_starlark_config_partial(app_id):
|
||||
if not app_data:
|
||||
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
|
||||
|
||||
# Load schema from schema.json if it exists
|
||||
schema = None
|
||||
schema_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'schema.json'
|
||||
if schema_file.exists():
|
||||
try:
|
||||
with open(schema_file, 'r') as f:
|
||||
schema = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load schema for {app_id}: {e}")
|
||||
|
||||
# Load config from config.json if it exists
|
||||
config = {}
|
||||
config_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'config.json'
|
||||
if config_file.exists():
|
||||
try:
|
||||
with open(config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load config for {app_id}: {e}")
|
||||
|
||||
return render_template(
|
||||
'v3/partials/starlark_config.html',
|
||||
app_id=app_id,
|
||||
@@ -477,8 +497,8 @@ def _load_starlark_config_partial(app_id):
|
||||
app_enabled=app_data.get('enabled', True),
|
||||
render_interval=app_data.get('render_interval', 300),
|
||||
display_duration=app_data.get('display_duration', 15),
|
||||
config=app_data.get('config', {}),
|
||||
schema=None,
|
||||
config=config,
|
||||
schema=schema,
|
||||
has_frames=False,
|
||||
frame_count=0,
|
||||
last_render_time=None,
|
||||
|
||||
@@ -83,6 +83,9 @@
|
||||
[data-theme="dark"] .hover\:bg-gray-200:hover { background-color: #4b5563; }
|
||||
[data-theme="dark"] .hover\:text-gray-700:hover { color: #e5e7eb; }
|
||||
[data-theme="dark"] .hover\:border-gray-300:hover { border-color: #6b7280; }
|
||||
[data-theme="dark"] .bg-red-100 { background-color: #450a0a; }
|
||||
[data-theme="dark"] .text-red-700 { color: #fca5a5; }
|
||||
[data-theme="dark"] .hover\:bg-red-200:hover { background-color: #7f1d1d; }
|
||||
|
||||
/* Base styles */
|
||||
* {
|
||||
@@ -141,6 +144,7 @@ body {
|
||||
|
||||
.rounded-lg { border-radius: 0.5rem; }
|
||||
.rounded-md { border-radius: 0.375rem; }
|
||||
.rounded-full { border-radius: 9999px; }
|
||||
.rounded { border-radius: 0.25rem; }
|
||||
|
||||
.shadow { box-shadow: var(--shadow); }
|
||||
@@ -152,6 +156,7 @@ body {
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-2 { padding: 0.5rem; }
|
||||
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||
.py-1\.5 { padding-top: 0.375rem; padding-bottom: 0.375rem; }
|
||||
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
|
||||
.pb-4 { padding-bottom: 1rem; }
|
||||
@@ -199,6 +204,7 @@ body {
|
||||
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.gap-1\.5 { gap: 0.375rem; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-3 { gap: 0.75rem; }
|
||||
.gap-4 { gap: 1rem; }
|
||||
@@ -663,6 +669,31 @@ button.bg-white {
|
||||
color: var(--color-purple-text);
|
||||
}
|
||||
|
||||
/* Filter Pill Toggle States */
|
||||
.filter-pill,
|
||||
.category-filter-pill {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.filter-pill[data-active="true"],
|
||||
.category-filter-pill[data-active="true"] {
|
||||
background-color: var(--color-info-bg);
|
||||
border-color: var(--color-info);
|
||||
color: var(--color-info);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-pill[data-active="true"]:hover,
|
||||
.category-filter-pill[data-active="true"]:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.category-filter-pill[data-active="true"] {
|
||||
box-shadow: 0 0 0 1px var(--color-info);
|
||||
}
|
||||
|
||||
/* Section Headers with Subtle Gradients */
|
||||
.section-header {
|
||||
background: linear-gradient(135deg, rgb(255 255 255 / 90%) 0%, rgb(249 250 251 / 90%) 100%);
|
||||
|
||||
@@ -1,6 +1,43 @@
|
||||
// ─── LocalStorage Safety Wrappers ────────────────────────────────────────────
|
||||
// Handles environments where localStorage is unavailable or restricted (private browsing, etc.)
|
||||
const safeLocalStorage = {
|
||||
getItem(key) {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`safeLocalStorage.getItem failed for key "${key}":`, e.message);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(key, value);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`safeLocalStorage.setItem failed for key "${key}":`, e.message);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
removeItem(key) {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(key);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`localStorage.removeItem failed for key "${key}":`, e.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Define critical functions immediately so they're available before any HTML is rendered
|
||||
// Debug logging controlled by localStorage.setItem('pluginDebug', 'true')
|
||||
const _PLUGIN_DEBUG_EARLY = typeof localStorage !== 'undefined' && localStorage.getItem('pluginDebug') === 'true';
|
||||
// Debug logging controlled by safeLocalStorage.setItem('pluginDebug', 'true')
|
||||
const _PLUGIN_DEBUG_EARLY = safeLocalStorage.getItem('pluginDebug') === 'true';
|
||||
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS SCRIPT] Defining configurePlugin and togglePlugin at top level...');
|
||||
|
||||
// Expose on-demand functions early as stubs (will be replaced when IIFE runs)
|
||||
@@ -848,47 +885,47 @@ window.checkGitHubAuthStatus = function checkGitHubAuthStatus() {
|
||||
});
|
||||
};
|
||||
|
||||
// ── Plugin Store State (global scope for access by top-level functions) ──────
|
||||
var pluginStoreCache = null;
|
||||
var cacheTimestamp = null;
|
||||
var CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
var storeFilteredList = [];
|
||||
var storeFilterState = {
|
||||
sort: localStorage.getItem('storeSort') || 'a-z',
|
||||
filterCategory: '',
|
||||
filterInstalled: null,
|
||||
searchQuery: '',
|
||||
page: 1,
|
||||
perPage: parseInt(localStorage.getItem('storePerPage')) || 12,
|
||||
persist: function() {
|
||||
localStorage.setItem('storeSort', this.sort);
|
||||
localStorage.setItem('storePerPage', this.perPage);
|
||||
},
|
||||
reset: function() {
|
||||
this.sort = 'a-z';
|
||||
this.filterCategory = '';
|
||||
this.filterInstalled = null;
|
||||
this.searchQuery = '';
|
||||
this.page = 1;
|
||||
},
|
||||
activeCount: function() {
|
||||
var n = 0;
|
||||
if (this.searchQuery) n++;
|
||||
if (this.filterInstalled !== null) n++;
|
||||
if (this.filterCategory) n++;
|
||||
if (this.sort !== 'a-z') n++;
|
||||
return n;
|
||||
}
|
||||
};
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
if (_PLUGIN_DEBUG_EARLY) console.log('Plugin manager script starting...');
|
||||
|
||||
|
||||
// Local variables for this instance
|
||||
let installedPlugins = [];
|
||||
window.currentPluginConfig = null;
|
||||
let pluginStoreCache = null; // Cache for plugin store to speed up subsequent loads
|
||||
let cacheTimestamp = null;
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
let storeFilteredList = [];
|
||||
|
||||
// ── Plugin Store Filter State ───────────────────────────────────────────
|
||||
const storeFilterState = {
|
||||
sort: safeLocalStorage.getItem('storeSort') || 'a-z',
|
||||
filterCategory: '',
|
||||
filterInstalled: null, // null=all, true=installed, false=not-installed
|
||||
searchQuery: '',
|
||||
page: 1,
|
||||
perPage: parseInt(safeLocalStorage.getItem('storePerPage')) || 12,
|
||||
persist() {
|
||||
safeLocalStorage.setItem('storeSort', this.sort);
|
||||
safeLocalStorage.setItem('storePerPage', this.perPage);
|
||||
},
|
||||
reset() {
|
||||
this.sort = 'a-z';
|
||||
this.filterCategory = '';
|
||||
this.filterInstalled = null;
|
||||
this.searchQuery = '';
|
||||
this.page = 1;
|
||||
},
|
||||
activeCount() {
|
||||
let n = 0;
|
||||
if (this.searchQuery) n++;
|
||||
if (this.filterInstalled !== null) n++;
|
||||
if (this.filterCategory) n++;
|
||||
if (this.sort !== 'a-z') n++;
|
||||
return n;
|
||||
}
|
||||
};
|
||||
let onDemandStatusInterval = null;
|
||||
let currentOnDemandPluginId = null;
|
||||
let hasLoadedOnDemandStatus = false;
|
||||
@@ -1243,8 +1280,8 @@ const pluginLoadCache = {
|
||||
}
|
||||
};
|
||||
|
||||
// Debug flag - set via localStorage.setItem('pluginDebug', 'true')
|
||||
const PLUGIN_DEBUG = typeof localStorage !== 'undefined' && localStorage.getItem('pluginDebug') === 'true';
|
||||
// Debug flag - set via safeLocalStorage.setItem('pluginDebug', 'true')
|
||||
const PLUGIN_DEBUG = typeof localStorage !== 'undefined' && safeLocalStorage.getItem('pluginDebug') === 'true';
|
||||
function pluginLog(...args) {
|
||||
if (PLUGIN_DEBUG) console.log(...args);
|
||||
}
|
||||
@@ -7533,16 +7570,16 @@ setTimeout(function() {
|
||||
|
||||
// ── Filter State ────────────────────────────────────────────────────────
|
||||
const starlarkFilterState = {
|
||||
sort: localStorage.getItem('starlarkSort') || 'a-z',
|
||||
sort: safeLocalStorage.getItem('starlarkSort') || 'a-z',
|
||||
filterInstalled: null, // null=all, true=installed, false=not-installed
|
||||
filterAuthor: '',
|
||||
filterCategory: '',
|
||||
searchQuery: '',
|
||||
page: 1,
|
||||
perPage: parseInt(localStorage.getItem('starlarkPerPage')) || 24,
|
||||
perPage: parseInt(safeLocalStorage.getItem('starlarkPerPage')) || 24,
|
||||
persist() {
|
||||
localStorage.setItem('starlarkSort', this.sort);
|
||||
localStorage.setItem('starlarkPerPage', this.perPage);
|
||||
safeLocalStorage.setItem('starlarkSort', this.sort);
|
||||
safeLocalStorage.setItem('starlarkPerPage', this.perPage);
|
||||
},
|
||||
reset() {
|
||||
this.sort = 'a-z';
|
||||
@@ -7861,7 +7898,7 @@ setTimeout(function() {
|
||||
grid.innerHTML = apps.map(app => {
|
||||
const installed = isStarlarkInstalled(app.id);
|
||||
return `
|
||||
<div class="plugin-card">
|
||||
<div class="plugin-card" data-app-id="${escapeHtml(app.id)}">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center flex-wrap gap-1.5 mb-2">
|
||||
@@ -7877,15 +7914,37 @@ setTimeout(function() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-auto pt-3 border-t border-gray-200">
|
||||
<button onclick="window.installStarlarkApp('${escapeHtml(app.id)}')" class="btn ${installed ? 'bg-gray-500 hover:bg-gray-600' : 'bg-green-600 hover:bg-green-700'} text-white px-4 py-2 rounded-md text-sm font-semibold flex-1 flex justify-center items-center">
|
||||
<button data-action="install" class="btn ${installed ? 'bg-gray-500 hover:bg-gray-600' : 'bg-green-600 hover:bg-green-700'} text-white px-4 py-2 rounded-md text-sm font-semibold flex-1 flex justify-center items-center">
|
||||
<i class="fas ${installed ? 'fa-redo' : 'fa-download'} mr-2"></i>${installed ? 'Reinstall' : 'Install'}
|
||||
</button>
|
||||
<button onclick="window.open('https://github.com/tronbyt/apps/tree/main/apps/${encodeURIComponent(app.id)}', '_blank')" class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-semibold flex justify-center items-center">
|
||||
<button data-action="view" class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-semibold flex justify-center items-center">
|
||||
<i class="fas fa-external-link-alt mr-1"></i>View
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Add delegated event listener only once (prevent duplicate handlers)
|
||||
if (!grid.dataset.starlarkHandlerAttached) {
|
||||
grid.addEventListener('click', function handleStarlarkGridClick(e) {
|
||||
const button = e.target.closest('button[data-action]');
|
||||
if (!button) return;
|
||||
|
||||
const card = button.closest('.plugin-card');
|
||||
if (!card) return;
|
||||
|
||||
const appId = card.dataset.appId;
|
||||
if (!appId) return;
|
||||
|
||||
const action = button.dataset.action;
|
||||
if (action === 'install') {
|
||||
window.installStarlarkApp(appId);
|
||||
} else if (action === 'view') {
|
||||
window.open('https://github.com/tronbyt/apps/tree/main/apps/' + encodeURIComponent(appId), '_blank');
|
||||
}
|
||||
});
|
||||
grid.dataset.starlarkHandlerAttached = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filter UI Updates ───────────────────────────────────────────────────
|
||||
|
||||
@@ -1375,7 +1375,7 @@
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<!-- Custom v3 styles -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}?v=20260216b">
|
||||
</head>
|
||||
<body x-data="app()" class="bg-gray-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
@@ -5013,7 +5013,7 @@
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
|
||||
|
||||
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
|
||||
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20250116a" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260216b" defer></script>
|
||||
|
||||
<!-- Custom feeds table helper functions -->
|
||||
<script>
|
||||
|
||||
@@ -155,13 +155,13 @@
|
||||
class="w-10 h-10 rounded border border-gray-300 cursor-pointer p-0.5"
|
||||
value="{{ current_val or '#FFFFFF' }}"
|
||||
data-starlark-color-picker="{{ field_id }}"
|
||||
oninput="document.querySelector('[data-starlark-config={{ field_id }}]').value = this.value">
|
||||
oninput="this.closest('.space-y-6').querySelector('[data-starlark-config={{ field_id }}]').value = this.value">
|
||||
<input type="text"
|
||||
class="form-control flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm font-mono"
|
||||
value="{{ current_val or '#FFFFFF' }}"
|
||||
placeholder="#RRGGBB"
|
||||
data-starlark-config="{{ field_id }}"
|
||||
oninput="var cp = document.querySelector('[data-starlark-color-picker={{ field_id }}]'); if(this.value.match(/^#[0-9a-fA-F]{6}$/)) cp.value = this.value;">
|
||||
oninput="var cp = this.closest('.space-y-6').querySelector('[data-starlark-color-picker={{ field_id }}]'); if(cp && this.value.match(/^#[0-9a-fA-F]{6}$/)) cp.value = this.value;">
|
||||
</div>
|
||||
{% if field_desc %}
|
||||
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
|
||||
@@ -375,8 +375,15 @@ function toggleStarlarkApp(appId, enabled) {
|
||||
function saveStarlarkConfig(appId) {
|
||||
var config = {};
|
||||
|
||||
// Get container to scope queries (prevents conflicts if multiple modals open)
|
||||
var container = document.getElementById('plugin-config-starlark:' + appId);
|
||||
if (!container) {
|
||||
console.error('Container not found for appId:', appId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect standard inputs (text, number, select, datetime, color text companion)
|
||||
document.querySelectorAll('[data-starlark-config]').forEach(function(input) {
|
||||
container.querySelectorAll('[data-starlark-config]').forEach(function(input) {
|
||||
var key = input.getAttribute('data-starlark-config');
|
||||
var type = input.getAttribute('data-starlark-type');
|
||||
|
||||
@@ -390,7 +397,7 @@ function saveStarlarkConfig(appId) {
|
||||
});
|
||||
|
||||
// Collect location mini-form groups
|
||||
document.querySelectorAll('[data-starlark-location-group]').forEach(function(group) {
|
||||
container.querySelectorAll('[data-starlark-location-group]').forEach(function(group) {
|
||||
var fieldId = group.getAttribute('data-starlark-location-group');
|
||||
var loc = {};
|
||||
group.querySelectorAll('[data-starlark-location-field="' + fieldId + '"]').forEach(function(sub) {
|
||||
|
||||
Reference in New Issue
Block a user