Merge pull request 'feat(app-launcher): workout card launcher + URL args' (#54) from ash/workout-card-launcher into main
All checks were successful
CI / check (push) Successful in 1m40s
CI / build-and-cache (push) Successful in 3h10m42s

Reviewed-on: #54
This commit was merged in pull request #54.
This commit is contained in:
2026-04-13 17:13:41 -07:00

View File

@@ -5,6 +5,7 @@ import logging
import os import os
import subprocess import subprocess
import sys import sys
from datetime import date
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse from urllib.parse import urlparse
import psutil import psutil
@@ -22,6 +23,9 @@ ALLOWED_APPS = {
'kodi': 'kodi' 'kodi': 'kodi'
} }
# Workout card base URL
WORKOUT_CARD_BASE_URL = 'https://ogle.fyi/ash/workout'
def is_app_running(app_name): def is_app_running(app_name):
"""Check if an application is already running, returns (is_running, pid)""" """Check if an application is already running, returns (is_running, pid)"""
command = ALLOWED_APPS.get(app_name) command = ALLOWED_APPS.get(app_name)
@@ -88,7 +92,10 @@ class AppLauncherHandler(BaseHTTPRequestHandler):
response = { response = {
'status': 'running', 'status': 'running',
'available_apps': list(ALLOWED_APPS.keys()), 'available_apps': list(ALLOWED_APPS.keys()),
'usage': 'POST /launch/<app_name> to launch an application' 'endpoints': {
'POST /launch/<app_name>': 'Launch an application (optional JSON body: {"args": ["url"]})',
'POST /workout': 'Open today\'s workout card in Firefox'
}
} }
self.wfile.write(json.dumps(response, indent=2).encode()) self.wfile.write(json.dumps(response, indent=2).encode())
else: else:
@@ -101,8 +108,21 @@ class AppLauncherHandler(BaseHTTPRequestHandler):
if len(path_parts) == 2 and path_parts[0] == 'launch': if len(path_parts) == 2 and path_parts[0] == 'launch':
app_name = path_parts[1] app_name = path_parts[1]
self.launch_app(app_name) self.launch_app(app_name)
elif len(path_parts) == 1 and path_parts[0] == 'workout':
self.open_workout_card()
else: else:
self.send_error(404, "Invalid endpoint. Use /launch/<app_name>") self.send_error(404, "Invalid endpoint. Use /launch/<app_name> or /workout")
def read_post_body(self):
"""Read and parse JSON body from POST request, return dict or empty dict."""
content_length = int(self.headers.get('Content-Length', 0))
if content_length > 0:
try:
body = self.rfile.read(content_length)
return json.loads(body.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
logger.warning(f"Failed to parse POST body as JSON: {e}")
return {}
def launch_app(self, app_name): def launch_app(self, app_name):
if app_name not in ALLOWED_APPS: if app_name not in ALLOWED_APPS:
@@ -111,30 +131,44 @@ class AppLauncherHandler(BaseHTTPRequestHandler):
command = ALLOWED_APPS[app_name] command = ALLOWED_APPS[app_name]
# Read optional args from POST body
body = self.read_post_body()
extra_args = body.get('args', [])
# Validate args are strings
if not isinstance(extra_args, list) or not all(isinstance(a, str) for a in extra_args):
self.send_error(400, "'args' must be a list of strings")
return
full_command = [command] + extra_args
# Check if app is already running # Check if app is already running
is_running, existing_pid = is_app_running(app_name) is_running, existing_pid = is_app_running(app_name)
if is_running: if is_running:
logger.info(f"Application {app_name} is already running (PID: {existing_pid}), skipping launch") # If extra args provided, still launch a new instance (e.g., open a URL)
self.send_response(200) if extra_args:
self.send_header('Content-type', 'application/json') logger.info(f"Application {app_name} already running (PID: {existing_pid}), but extra args provided — launching new instance")
self.end_headers() else:
response = { logger.info(f"Application {app_name} is already running (PID: {existing_pid}), skipping launch")
'status': 'success', self.send_response(200)
'message': f'{app_name} is already running', self.send_header('Content-type', 'application/json')
'pid': existing_pid, self.end_headers()
'already_running': True response = {
} 'status': 'success',
self.wfile.write(json.dumps(response).encode()) 'message': f'{app_name} is already running',
return 'pid': existing_pid,
'already_running': True
}
self.wfile.write(json.dumps(response).encode())
return
try: try:
# Launch the application in the background # Launch the application in the background
# Ensure we have the proper environment for GUI apps # Ensure we have the proper environment for GUI apps
env = os.environ.copy() env = os.environ.copy()
logger.info(f"Launching application: {command}") logger.info(f"Launching application: {' '.join(full_command)}")
process = subprocess.Popen( process = subprocess.Popen(
[command], full_command,
env=env, env=env,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
@@ -159,12 +193,50 @@ class AppLauncherHandler(BaseHTTPRequestHandler):
logger.error(f"Error launching {command}: {e}") logger.error(f"Error launching {command}: {e}")
self.send_error(500, f"Failed to launch {app_name}: {str(e)}") self.send_error(500, f"Failed to launch {app_name}: {str(e)}")
def open_workout_card(self):
"""Open today's workout card in Firefox."""
today = date.today().strftime('%Y-%m-%d')
url = f"{WORKOUT_CARD_BASE_URL}/{today}.html"
logger.info(f"Opening workout card for {today}: {url}")
# Always launch Firefox with the URL, even if already running
command = ALLOWED_APPS['firefox']
env = os.environ.copy()
try:
process = subprocess.Popen(
[command, url],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()
response = {
'status': 'success',
'message': f'Opened workout card in Firefox',
'url': url,
'pid': process.pid
}
self.wfile.write(json.dumps(response).encode())
except FileNotFoundError:
logger.error(f"Firefox not found: {command}")
self.send_error(500, "Firefox not found on system")
except Exception as e:
logger.error(f"Error launching Firefox with workout card: {e}")
self.send_error(500, f"Failed to open workout card: {str(e)}")
def main(): def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8081 port = int(sys.argv[1]) if len(sys.argv) > 1 else 8081
server = HTTPServer(('0.0.0.0', port), AppLauncherHandler) server = HTTPServer(('0.0.0.0', port), AppLauncherHandler)
logger.info(f"App launcher server starting on port {port}") logger.info(f"App launcher server starting on port {port}")
logger.info(f"Available applications: {list(ALLOWED_APPS.keys())}") logger.info(f"Available applications: {list(ALLOWED_APPS.keys())}")
logger.info(f"Workout card URL: {WORKOUT_CARD_BASE_URL}/<date>.html")
try: try:
server.serve_forever() server.serve_forever()