├── broadcast ├── version ├── run.sh ├── email_template.html ├── main.py └── emails.py ├── macos ├── terminal-notifier.app │ └── Contents │ │ ├── PkgInfo │ │ ├── CodeResources │ │ ├── MacOS │ │ └── terminal-notifier │ │ ├── Resources │ │ ├── Terminal.icns │ │ └── en.lproj │ │ │ ├── MainMenu.nib │ │ │ ├── InfoPlist.strings │ │ │ └── Credits.rtf │ │ ├── Info.plist │ │ └── _CodeSignature │ │ └── CodeResources └── broadcast.scpt.template ├── .gitignore ├── constants.py ├── assets ├── icon.png └── icon.icns ├── run.sh ├── README.md ├── linux ├── notify-action.sh └── notify-send.sh ├── main.py └── utils.py /broadcast/version: -------------------------------------------------------------------------------- 1 | 0.1.1 -------------------------------------------------------------------------------- /macos/terminal-notifier.app/Contents/PkgInfo: -------------------------------------------------------------------------------- 1 | APPL???? -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .venv 3 | __pycache__ 4 | .pytest_cache 5 | logs -------------------------------------------------------------------------------- /constants.py: -------------------------------------------------------------------------------- 1 | BROADCAST_ENABLED = True 2 | TRASH_RETENTION_DAYS = 7 3 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leriomaggio/inbox/main/assets/icon.png -------------------------------------------------------------------------------- /assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leriomaggio/inbox/main/assets/icon.icns -------------------------------------------------------------------------------- /macos/terminal-notifier.app/Contents/CodeResources: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leriomaggio/inbox/main/macos/terminal-notifier.app/Contents/CodeResources -------------------------------------------------------------------------------- /macos/terminal-notifier.app/Contents/MacOS/terminal-notifier: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leriomaggio/inbox/main/macos/terminal-notifier.app/Contents/MacOS/terminal-notifier -------------------------------------------------------------------------------- /macos/terminal-notifier.app/Contents/Resources/Terminal.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leriomaggio/inbox/main/macos/terminal-notifier.app/Contents/Resources/Terminal.icns -------------------------------------------------------------------------------- /macos/terminal-notifier.app/Contents/Resources/en.lproj/MainMenu.nib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leriomaggio/inbox/main/macos/terminal-notifier.app/Contents/Resources/en.lproj/MainMenu.nib -------------------------------------------------------------------------------- /macos/terminal-notifier.app/Contents/Resources/en.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leriomaggio/inbox/main/macos/terminal-notifier.app/Contents/Resources/en.lproj/InfoPlist.strings -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [ ! -d ".venv" ]; then 5 | echo "Virtual environment not found. Creating one..." 6 | uv venv .venv 7 | echo "Virtual environment created successfully." 8 | else 9 | echo "Virtual environment already exists." 10 | fi 11 | 12 | uv pip install -U syftbox 13 | 14 | . .venv/bin/activate 15 | 16 | echo "Running 'inbox' with $(python3 --version) at '$(which python3)'" 17 | uv run python3 main.py 18 | 19 | deactivate 20 | -------------------------------------------------------------------------------- /broadcast/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [ ! -d ".venv" ]; then 5 | echo "Virtual environment not found. Creating one..." 6 | uv venv .venv 7 | echo "Virtual environment created successfully." 8 | else 9 | echo "Virtual environment already exists." 10 | fi 11 | 12 | uv pip install -U syftbox 13 | 14 | . .venv/bin/activate 15 | 16 | echo "Running 'broadcast' with $(python3 --version) at '$(which python3)'" 17 | uv run python3 main.py 18 | 19 | deactivate 20 | -------------------------------------------------------------------------------- /macos/terminal-notifier.app/Contents/Resources/en.lproj/Credits.rtf: -------------------------------------------------------------------------------- 1 | {\rtf0\ansi{\fonttbl\f0\fswiss Helvetica;} 2 | {\colortbl;\red255\green255\blue255;} 3 | \paperw9840\paperh8400 4 | \pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\ql\qnatural 5 | 6 | \f0\b\fs24 \cf0 Engineering: 7 | \b0 \ 8 | Some people\ 9 | \ 10 | 11 | \b Human Interface Design: 12 | \b0 \ 13 | Some other people\ 14 | \ 15 | 16 | \b Testing: 17 | \b0 \ 18 | Hopefully not nobody\ 19 | \ 20 | 21 | \b Documentation: 22 | \b0 \ 23 | Whoever\ 24 | \ 25 | 26 | \b With special thanks to: 27 | \b0 \ 28 | Mom\ 29 | } 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Inbox API 2 | 3 | The Inbox API is a component of the SyftBox ecosystem designed to send and receive API requests between datasites. This app is compatible with MacOS and Linux. 4 | 5 | ## Features 6 | 7 | * Receive and process API requests from others. 8 | * Send API requests to any datasite. 9 | * Broadcast API requests to all datasites. 10 | * Send email and desktop notifications for new API requests. 11 | 12 | ## Usage 13 | 14 | ### What is an API? 15 | 16 | An API is any folder containing a `run.sh` entry script. 17 | 18 | ### Sending API Requests 19 | 20 | You can share your API with datasites in two ways: 21 | 22 | 1. **Send to a Single Datasite** 23 | Copy your API folder into the target datasite's `inbox/` directory. 24 | 25 | 2. **Broadcast to All Datasites** 26 | Place your API folder in the `/SyftBox/apis/broadcast/` directory. 27 | The system will: 28 | * Validate your API. 29 | * Send it to all datasites with the Inbox API installed. 30 | 31 | After an API is sent, datasite owners will receive notifications (desktop and email). They can then review the code of your API request and choose to **approve** or **reject** it. Approved requests execute automatically if the recipient's SyftBox client is active. 32 | 33 | ### Managing Incoming API Requests 34 | 35 | Incoming API requests appear in your datasite’s `inbox/` folder. Here’s how to handle them: 36 | 37 | 1. **Folder Structure** 38 | The `inbox/` folder contains two symlinked subfolders: 39 | * `approved`: Links to `/SyftBox/apis/`, where approved APIs are start executing automatically. 40 | * `rejected`: Serves as a temporary bin. Rejected APIs remain here for 7 days before being deleted. 41 | 42 | 2. **Review Process** 43 | * Inspect the code of new API requests in the `inbox/` folder. 44 | * After reviewing, move the API folder to either `approved` or `rejected`. 45 | 46 | ### Uninstalling the Inbox API 47 | 48 | The Inbox API is an API in itself and can be uninstalled if needed. To uninstall, simply delete the `/SyftBox/apis/inbox/` directory along with its contents. 49 | -------------------------------------------------------------------------------- /macos/terminal-notifier.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 23E224 7 | CFBundleDevelopmentRegion 8 | en 9 | CFBundleExecutable 10 | terminal-notifier 11 | CFBundleIconFile 12 | Terminal 13 | CFBundleIdentifier 14 | dev.smart-app.terminal-notifier 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | terminal-notifier 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | 3.0.0 23 | CFBundleSignature 24 | ???? 25 | CFBundleSupportedPlatforms 26 | 27 | MacOSX 28 | 29 | CFBundleVersion 30 | 15 31 | DTCompiler 32 | com.apple.compilers.llvm.clang.1_0 33 | DTPlatformBuild 34 | 35 | DTPlatformName 36 | macosx 37 | DTPlatformVersion 38 | 14.4 39 | DTSDKBuild 40 | 23E208 41 | DTSDKName 42 | macosx14.4 43 | DTXcode 44 | 1530 45 | DTXcodeBuild 46 | 15E204a 47 | LSMinimumSystemVersion 48 | 13.0 49 | LSUIElement 50 | 51 | NSAppTransportSecurity 52 | 53 | NSAllowsArbitraryLoads 54 | 55 | 56 | NSHumanReadableCopyright 57 | Copyright © 2012-2017 Eloy Durán, Julien Blanchard. All rights reserved. 58 | NSMainNibFile 59 | MainMenu 60 | NSPrincipalClass 61 | NSApplication 62 | NSUserNotificationAlertStyle 63 | banner 64 | 65 | 66 | -------------------------------------------------------------------------------- /linux/notify-action.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | DBUS_MONITOR_PID=/tmp/notify-action-dbus-monitor.$$.pid 4 | DBUS_MONITOR=(dbus-monitor --session "type='signal',interface='org.freedesktop.Notifications'") 5 | 6 | NOTIFICATION_ID="$1" 7 | if [[ -z "$NOTIFICATION_ID" ]]; then 8 | exit 1 9 | fi 10 | shift 11 | 12 | ACTION_COMMANDS=("$@") 13 | if [[ -z "$ACTION_COMMANDS" ]]; then 14 | exit 1 15 | fi 16 | 17 | cleanup() { 18 | rm -f "$DBUS_MONITOR_PID" 19 | } 20 | 21 | create_pid_file() { 22 | rm -f "$DBUS_MONITOR_PID" 23 | umask 077 24 | touch "$DBUS_MONITOR_PID" 25 | } 26 | 27 | invoke_action() { 28 | local invoked_action_id="$1" 29 | local action="" cmd="" 30 | 31 | for index in "${!ACTION_COMMANDS[@]}"; do 32 | if [[ $((index % 2)) == 0 ]]; then 33 | action="${ACTION_COMMANDS[$index]}" 34 | else 35 | cmd="${ACTION_COMMANDS[$index]}" 36 | if [[ "$action" == "$invoked_action_id" ]]; then 37 | bash -c "${cmd}" & 38 | return 39 | fi 40 | fi 41 | done 42 | } 43 | 44 | monitor() { 45 | create_pid_file 46 | 47 | ( "${DBUS_MONITOR[@]}" & echo $! >&3 ) 3>"$DBUS_MONITOR_PID" | while read -r line; do 48 | # Handle notification closed signal 49 | if [[ "$line" =~ "member=NotificationClosed" ]]; then 50 | read -r line # notification ID 51 | closed_notification_id=$(echo "$line" | sed 's/^.*uint32 \([0-9]\+\).*$/\1/') 52 | 53 | if [[ "$closed_notification_id" == "$NOTIFICATION_ID" ]]; then 54 | invoke_action close 55 | break 56 | fi 57 | 58 | # Handle action invoked signal 59 | elif [[ "$line" =~ "member=ActionInvoked" ]]; then 60 | read -r line # notification ID 61 | invoked_id=$(echo "$line" | sed 's/^.*uint32 \([0-9]\+\).*$/\1/') 62 | read -r line # action ID 63 | action_id=$(echo "$line" | sed 's/^.*string "\(.*\)".*$/\1/') 64 | 65 | if [[ "$invoked_id" == "$NOTIFICATION_ID" ]]; then 66 | invoke_action "$action_id" 67 | break 68 | fi 69 | fi 70 | done 71 | kill $(<"$DBUS_MONITOR_PID") 72 | cleanup 73 | } 74 | 75 | trap 'cleanup; exit' INT TERM 76 | 77 | monitor -------------------------------------------------------------------------------- /broadcast/email_template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | API Request Notification 8 | 46 | 47 | 48 | 49 |
50 |
51 | SyftBox Icon 52 |
53 |
54 |

Hello {{recipient_name}},

55 |

You've received a new API request called {{api_request_name}} in your datasite from 56 | {{sender_email}}. 57 |

58 |

Action Item: 59 |

67 |

68 |

If you have any questions, please join our #support channel.

70 |
71 | 74 |
75 | 76 | 77 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from constants import BROADCAST_ENABLED 3 | from pathlib import Path 4 | from syftbox.lib import Client, SyftPermission 5 | from utils import create_symlink 6 | from utils import start_garbage_collector 7 | from utils import start_notification_service 8 | 9 | client = Client.load() 10 | 11 | my_inbox_path = client.my_datasite / "inbox" 12 | my_apps_path = client.workspace.apps 13 | api_data_path = client.api_data("inbox") 14 | trash_path = api_data_path / ".trash" 15 | 16 | approved_symlink_path = my_inbox_path / "approved" 17 | rejected_symplink_path = my_inbox_path / "rejected" 18 | 19 | # Create the necessary directories 20 | client.makedirs(my_inbox_path, trash_path) 21 | 22 | # Make the inbox path globally writeable 23 | permission = SyftPermission.mine_with_public_write(email=client.email) 24 | permission.ensure(path=my_inbox_path) 25 | 26 | # Create a symlink called "approved" in inbox, pointing to the apps folder 27 | create_symlink(my_apps_path, approved_symlink_path, overwrite=True) 28 | 29 | # Create a symlink called "rejected" in inbox, pointing to the trash folder 30 | create_symlink(trash_path, rejected_symplink_path, overwrite=True) 31 | 32 | start_notification_service(my_inbox_path, api_data_path) 33 | start_garbage_collector(trash_path, rejected_symplink_path) 34 | 35 | if BROADCAST_ENABLED: 36 | try: 37 | broadcast_app_src = Path(__file__).parent / "broadcast" 38 | broadcast_app_dst = my_apps_path / "broadcast" 39 | 40 | # Ensure source directory exists 41 | if not broadcast_app_src.exists(): 42 | raise FileNotFoundError( 43 | f"Broadcast source directory not found: {broadcast_app_src}" 44 | ) 45 | 46 | # Compare versions only if both files exist 47 | needs_update = True 48 | if broadcast_app_dst.exists(): 49 | try: 50 | src_version = (broadcast_app_src / "version").read_text().strip() 51 | dst_version = (broadcast_app_dst / "version").read_text().strip() 52 | needs_update = src_version != dst_version 53 | except (FileNotFoundError, IOError) as e: 54 | print(f"Warning: Broadcast app version check failed - {e}") 55 | 56 | if needs_update: 57 | print("Updating broadcast app") 58 | shutil.copytree( 59 | broadcast_app_src, 60 | broadcast_app_dst, 61 | dirs_exist_ok=True, 62 | ignore=shutil.ignore_patterns( 63 | "__pycache__", 64 | ".pytest_cache", 65 | ".venv", 66 | "*.pyc", 67 | ".git", 68 | ), 69 | ) 70 | print("Broadcast app updated successfully") 71 | 72 | except Exception as e: 73 | print(f"Error updating broadcast app: {e}") 74 | -------------------------------------------------------------------------------- /macos/broadcast.scpt.template: -------------------------------------------------------------------------------- 1 | on run 2 | set folderPath to choose folder with prompt "Select a folder containing an API request to broadcast" default location (path to me) 3 | processFolder(folderPath) 4 | end run 5 | 6 | on open theFiles 7 | if (count theFiles) is not equal to 1 then 8 | display dialog "Please drop only one API request folder at a time." buttons {"OK"} default button "OK" with title "API Request Broadcaster" with icon stop 9 | return 10 | end if 11 | 12 | set folderPath to item 1 of theFiles 13 | processFolder(folderPath) 14 | end open 15 | 16 | on processFolder(folderPath) 17 | -- Set variables 18 | set datasitesPath to quoted form of "{{DATASITES_PATH}}" 19 | 20 | -- Convert the folder path to POSIX format for shell compatibility 21 | set folderPOSIXPath to POSIX path of folderPath 22 | 23 | -- Check if the item is a folder and contains the 'run.sh' file using shell commands 24 | set folderCheck to do shell script "if [ -d " & quoted form of folderPOSIXPath & " ] && [ -f " & quoted form of folderPOSIXPath & "/run.sh ]; then echo 'valid'; else echo 'invalid'; fi" 25 | 26 | if folderCheck is not "valid" then 27 | display dialog "Invalid API request. Please ensure the selected item is a folder containing a 'run.sh' file." buttons {"OK"} default button "OK" with title "API Request Broadcaster" with icon stop 28 | return 29 | end if 30 | 31 | -- Get number of datasites with inbox installed 32 | set datasitesCount to do shell script "count=0; for d in " & datasitesPath & "/*/; do [ -d \"$d/inbox\" ] && ((count++)); done; echo $count" 33 | set datasiteLabel to "datasite" 34 | if datasitesCount is greater than 1 then set datasiteLabel to "datasites" 35 | 36 | -- Show confirmation dialog 37 | set userResponse to display dialog "You are about to broadcast the API request '" & (do shell script "basename " & quoted form of folderPOSIXPath) & "' to " & datasitesCount & " " & datasiteLabel & ". This action will notify multiple users across various locations. Are you sure you want to proceed?" buttons {"Cancel", "Schedule Broadcast"} default button "Schedule Broadcast" with title "Confirmation Needed" with icon caution 38 | 39 | if button returned of userResponse is "Schedule Broadcast" then 40 | set folderPOSIXPathWithoutTrailingSlash to do shell script "echo " & quoted form of folderPOSIXPath & " | sed 's|/$||'" 41 | 42 | -- Copy the folder to each inbox sub-directory within datasitesPath (only if inbox directory exists) 43 | do shell script "for d in " & datasitesPath & "/*/inbox; do [ -d \"$d\" ] && cp -R " & quoted form of folderPOSIXPathWithoutTrailingSlash & " \"$d\"; done" 44 | 45 | display dialog "The API request has been successfully scheduled to broadcast to " & datasitesCount & " " & datasiteLabel &". You can monitor the progress in the SyftBox logs." buttons {"OK"} default button "OK" with title "API Request Broadcasted" with icon note 46 | end if 47 | end processFolder 48 | -------------------------------------------------------------------------------- /broadcast/main.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import logging 3 | from pathlib import Path 4 | from syftbox.lib import Client 5 | from emails import EmailService 6 | 7 | 8 | # Set up SyftBox client 9 | client = Client.load() 10 | current_dir = Path(__file__).parent 11 | api_name = client.api_request_name 12 | 13 | # Configure logging 14 | logging.basicConfig(level=logging.INFO) 15 | logger = logging.getLogger(f"{api_name}:{Path(__file__).name}") 16 | 17 | # Constants 18 | IGNORED_PATTERNS = [ 19 | "__pycache__", 20 | ".pytest_cache", 21 | ".venv", 22 | "*.pyc", 23 | ".git", 24 | ] 25 | 26 | 27 | def is_valid_api_request(path: Path) -> bool: 28 | return path.is_dir() and (path / "run.sh").exists() 29 | 30 | 31 | def get_ignored_path_patterns(api_request_path: Path) -> list[str]: 32 | patterns = IGNORED_PATTERNS.copy() 33 | gitignore_path = api_request_path / ".gitignore" 34 | try: 35 | if gitignore_path.exists(): 36 | patterns.extend( 37 | line.strip() 38 | for line in gitignore_path.read_text().splitlines() 39 | if line.strip() and not line.startswith("#") 40 | ) 41 | except Exception as e: 42 | logger.warning(f"Failed to read .gitignore: {str(e)}") 43 | return patterns 44 | 45 | 46 | def broadcast_api_requests() -> None: 47 | try: 48 | valid_api_requests = [ 49 | d for d in current_dir.iterdir() if is_valid_api_request(d) 50 | ] 51 | 52 | if not valid_api_requests: 53 | logger.info( 54 | f"No new API requests to broadcast. Drop your API request folder to {current_dir.absolute()}/." 55 | ) 56 | return 57 | 58 | datasites_with_inbox = [ 59 | d 60 | for d in client.datasites.iterdir() 61 | if (d / "inbox").exists() and d != client.my_datasite 62 | ] 63 | 64 | if len(datasites_with_inbox) == 0: 65 | logger.info("No datasites with an inbox found.") 66 | return 67 | 68 | logger.info( 69 | f"Found {len(valid_api_requests)} new API requests to broadcast to {len(datasites_with_inbox)} datasites." 70 | ) 71 | 72 | for api_request_path in valid_api_requests: 73 | successful_broadcasts = 0 74 | for datasite in datasites_with_inbox: 75 | try: 76 | target_path = datasite / "inbox" / api_request_path.name 77 | shutil.copytree( 78 | api_request_path, 79 | target_path, 80 | dirs_exist_ok=True, 81 | ignore=shutil.ignore_patterns( 82 | *get_ignored_path_patterns(api_request_path) 83 | ), 84 | ) 85 | successful_broadcasts += 1 86 | logger.info( 87 | f"Broadcasted '{api_request_path.name}' to '{datasite.name}'" 88 | ) 89 | EmailService.add_to_email_queue( 90 | datasite.name, api_request_path.name, client.my_datasite.name 91 | ) 92 | except Exception as e: 93 | logger.error(f"Failed to broadcast to '{datasite.name}': {str(e)}") 94 | 95 | if successful_broadcasts > 0: 96 | logger.info( 97 | f"'{api_request_path.name}' broadcasted to {successful_broadcasts} datasites. Cleaning up..." 98 | ) 99 | shutil.rmtree(api_request_path) 100 | else: 101 | logger.error( 102 | f"Failed to broadcast '{api_request_path.name}' to any datasite" 103 | ) 104 | 105 | except Exception as e: 106 | logger.error(f"Broadcasting failed: {str(e)}") 107 | 108 | 109 | def main(): 110 | broadcast_api_requests() 111 | EmailService.process_email_queue() 112 | 113 | 114 | if __name__ == "__main__": 115 | main() 116 | -------------------------------------------------------------------------------- /broadcast/emails.py: -------------------------------------------------------------------------------- 1 | import json 2 | import httpx 3 | from typing import Any 4 | import logging 5 | from pathlib import Path 6 | from dataclasses import dataclass 7 | from syftbox.lib import Client 8 | 9 | 10 | # Set up SyftBox client 11 | client = Client.load() 12 | api_name = client.api_request_name 13 | 14 | # Configure logging 15 | logging.basicConfig(level=logging.INFO) 16 | logger = logging.getLogger(f"{api_name}:{Path(__file__).name}") 17 | 18 | api_data_dir = client.api_data() 19 | email_queue_json = api_data_dir / "email_queue.json" 20 | 21 | MAX_EMAIL_RETRIES = 3 22 | EMAIL_API_URL = "https://syftbox.openmined.org/emails/" 23 | EMAIL_SUBJECT = "SyftBox: New API request received ({api_request_name})" 24 | EMAIL_TEMPLATE = Path(__file__).parent / "email_template.html" 25 | SYFTBOX_LOGO_URL = "https://syftbox.openmined.org/icon.png" 26 | 27 | 28 | @dataclass 29 | class EmailQueueEntry: 30 | recipient: str 31 | api_request_name: str 32 | sender: str 33 | retries: int = 0 34 | 35 | 36 | class EmailService: 37 | @classmethod 38 | def read_email_queue(cls) -> list[dict[str, Any]]: 39 | if email_queue_json.exists(): 40 | with open(email_queue_json, "r") as f: 41 | return json.load(f) 42 | return [] 43 | 44 | @classmethod 45 | def write_email_queue(cls, queue: list[dict[str, Any]]) -> None: 46 | with open(email_queue_json, "w") as f: 47 | json.dump(queue, f) 48 | 49 | @classmethod 50 | def send_email(cls, sender: str, recipient: str, api_request_name: str) -> None: 51 | html = ( 52 | EMAIL_TEMPLATE.read_text() 53 | .replace("{{logo_url}}", SYFTBOX_LOGO_URL) 54 | .replace("{{sender_email}}", sender) 55 | .replace("{{recipient_name}}", recipient.split("@")[0].capitalize()) 56 | .replace("{{recipient_email}}", recipient) 57 | .replace("{{api_request_name}}", api_request_name) 58 | ) 59 | json_data = { 60 | "to": recipient, 61 | "subject": EMAIL_SUBJECT.format(api_request_name=api_request_name), 62 | "html": html, 63 | } 64 | logger.info(f"Sending email to '{recipient}' for '{api_request_name}'") 65 | try: 66 | with httpx.Client() as client: 67 | response = client.post(EMAIL_API_URL, json=json_data) 68 | response.raise_for_status() 69 | logger.info(f"✅ Email sent successfully to {recipient}") 70 | except Exception as e: 71 | logger.error(f"Failed to send email: {str(e)}") 72 | raise 73 | 74 | @classmethod 75 | def add_to_email_queue( 76 | cls, recipient: str, api_request_name: str, sender: str 77 | ) -> None: 78 | email_queue = cls.read_email_queue() 79 | entry = EmailQueueEntry(recipient, api_request_name, sender).__dict__ 80 | email_queue.append(entry) 81 | cls.write_email_queue(email_queue) 82 | logger.info(f"Added email to queue for {recipient}") 83 | 84 | @classmethod 85 | def process_email_queue(cls) -> None: 86 | email_queue = cls.read_email_queue() 87 | if not email_queue: 88 | logger.info("No emails to send. Email queue is empty.") 89 | return 90 | 91 | while email_queue: 92 | entry = email_queue[0] 93 | try: 94 | cls.send_email( 95 | entry["sender"], entry["recipient"], entry["api_request_name"] 96 | ) 97 | email_queue.pop(0) 98 | cls.write_email_queue(email_queue) 99 | logger.info(f"Email sent successfully to {entry['recipient']}") 100 | except Exception as e: 101 | entry["retries"] += 1 102 | logger.warning( 103 | f"Failed to send email to {entry['recipient']}: {e}. " 104 | f"Attempts remaining: {MAX_EMAIL_RETRIES - entry['retries']}" 105 | ) 106 | if entry["retries"] >= MAX_EMAIL_RETRIES: 107 | logger.error(f"Max retries reached for {entry['recipient']}") 108 | email_queue.pop(0) 109 | cls.write_email_queue(email_queue) 110 | -------------------------------------------------------------------------------- /macos/terminal-notifier.app/Contents/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | files 6 | 7 | Resources/Terminal.icns 8 | 9 | Oq9GtJM1DqcGF1JCHiEgb0hoN6I= 10 | 11 | Resources/en.lproj/Credits.rtf 12 | 13 | hash 14 | 15 | YKJIFIsxneJuNkJNJQIcJIjiPOg= 16 | 17 | optional 18 | 19 | 20 | Resources/en.lproj/InfoPlist.strings 21 | 22 | hash 23 | 24 | MiLKDDnrUKr4EmuvhS5VQwxHGK8= 25 | 26 | optional 27 | 28 | 29 | Resources/en.lproj/MainMenu.nib 30 | 31 | hash 32 | 33 | m1LTai+i2RVwxpP6nqcfTO9Tbcw= 34 | 35 | optional 36 | 37 | 38 | 39 | files2 40 | 41 | Resources/Terminal.icns 42 | 43 | hash 44 | 45 | Oq9GtJM1DqcGF1JCHiEgb0hoN6I= 46 | 47 | hash2 48 | 49 | zPwPRX277SsWSp9wjhoAAPrY+Jaw1TMrN24rdI8/9SU= 50 | 51 | 52 | Resources/en.lproj/Credits.rtf 53 | 54 | hash 55 | 56 | YKJIFIsxneJuNkJNJQIcJIjiPOg= 57 | 58 | hash2 59 | 60 | tDhv4c72XNkebI7MBl0RcIkIP5G3ytvww+Xq4g6LlkA= 61 | 62 | optional 63 | 64 | 65 | Resources/en.lproj/InfoPlist.strings 66 | 67 | hash 68 | 69 | MiLKDDnrUKr4EmuvhS5VQwxHGK8= 70 | 71 | hash2 72 | 73 | Oc8u4Ht7Mz58F50L9NeYpbcq9qTlhPUeZCcDu/pPyCg= 74 | 75 | optional 76 | 77 | 78 | Resources/en.lproj/MainMenu.nib 79 | 80 | hash 81 | 82 | m1LTai+i2RVwxpP6nqcfTO9Tbcw= 83 | 84 | hash2 85 | 86 | 36jTAKtjtYevIFm/xZ0j16kZxqs9PF0wMlgLY4oVgUY= 87 | 88 | optional 89 | 90 | 91 | 92 | rules 93 | 94 | ^Resources/ 95 | 96 | ^Resources/.*\.lproj/ 97 | 98 | optional 99 | 100 | weight 101 | 1000 102 | 103 | ^Resources/.*\.lproj/locversion.plist$ 104 | 105 | omit 106 | 107 | weight 108 | 1100 109 | 110 | ^Resources/Base\.lproj/ 111 | 112 | weight 113 | 1010 114 | 115 | ^version.plist$ 116 | 117 | 118 | rules2 119 | 120 | .*\.dSYM($|/) 121 | 122 | weight 123 | 11 124 | 125 | ^(.*/)?\.DS_Store$ 126 | 127 | omit 128 | 129 | weight 130 | 2000 131 | 132 | ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ 133 | 134 | nested 135 | 136 | weight 137 | 10 138 | 139 | ^.* 140 | 141 | ^Info\.plist$ 142 | 143 | omit 144 | 145 | weight 146 | 20 147 | 148 | ^PkgInfo$ 149 | 150 | omit 151 | 152 | weight 153 | 20 154 | 155 | ^Resources/ 156 | 157 | weight 158 | 20 159 | 160 | ^Resources/.*\.lproj/ 161 | 162 | optional 163 | 164 | weight 165 | 1000 166 | 167 | ^Resources/.*\.lproj/locversion.plist$ 168 | 169 | omit 170 | 171 | weight 172 | 1100 173 | 174 | ^Resources/Base\.lproj/ 175 | 176 | weight 177 | 1010 178 | 179 | ^[^/]+$ 180 | 181 | nested 182 | 183 | weight 184 | 10 185 | 186 | ^embedded\.provisionprofile$ 187 | 188 | weight 189 | 20 190 | 191 | ^version\.plist$ 192 | 193 | weight 194 | 20 195 | 196 | 197 | 198 | 199 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | from constants import TRASH_RETENTION_DAYS 2 | from datetime import datetime 3 | from datetime import timedelta 4 | import hashlib 5 | import json 6 | import os 7 | from pathlib import Path 8 | import platform 9 | import shutil 10 | 11 | 12 | def human_friendly_join( 13 | items: list[str], sep: str = ", ", last_sep: str = " and " 14 | ) -> str: 15 | """Joins a list of strings into a single string with specified separators. 16 | 17 | This function concatenates the elements of a list into a single string. 18 | Elements are separated by `sep`, except for the last two elements which 19 | are separated by `last_sep`. 20 | 21 | Parameters 22 | ---------- 23 | items : list[str] 24 | The list of strings to join. 25 | sep : str, optional 26 | The separator between all elements except the last two, by default ", " 27 | last_sep : str, optional 28 | The separator between the last two elements, by default " and " 29 | 30 | Returns 31 | ------- 32 | str 33 | The concatenated string. 34 | 35 | Examples 36 | -------- 37 | >>> human_friendly_join(["apple", "banana", "cherry"]) 38 | 'apple, banana and cherry' 39 | >>> human_friendly_join(["red", "yellow", "green]), sep="; ", last_sep=" or ") 40 | 'red; yellow or green' 41 | >>> human_friendly_join(["one"]) 42 | 'one' 43 | >>> human_friendly_join([]) 44 | '' 45 | """ 46 | if not items: 47 | return "" 48 | elif len(items) == 1: 49 | return items[0] 50 | else: 51 | return sep.join(items[:-1]) + last_sep + items[-1] 52 | 53 | 54 | def create_symlink(target_path: Path, symlink_path: Path, overwrite=False) -> None: 55 | if overwrite: 56 | if symlink_path.is_symlink() or symlink_path.is_file(): 57 | os.unlink(symlink_path) 58 | else: 59 | shutil.rmtree(symlink_path, ignore_errors=True) 60 | symlink_path.symlink_to(target_path) 61 | 62 | 63 | def is_valid_api_request(path: Path) -> bool: 64 | return path.is_dir() and (path / "run.sh").exists() 65 | 66 | 67 | def create_api_request_notifications_macos( 68 | title: str, message: str, inbox_path: Path, icon_path: Path 69 | ) -> None: 70 | return os.system( 71 | "./macos/terminal-notifier.app/Contents/MacOS/terminal-notifier" 72 | f" -title '{title}'" 73 | f" -message '{message}'" 74 | f" -contentImage '{icon_path.absolute()}'" 75 | f" -open file://{inbox_path.absolute()}" 76 | " -ignoreDnd" 77 | ) 78 | 79 | 80 | def create_api_request_notifications_linux( 81 | title: str, message: str, inbox_path: Path, icon_path: Path 82 | ) -> None: 83 | return os.system( 84 | "./linux/notify-send.sh" 85 | f" '{title}' '{message}'" 86 | f" --icon '{icon_path.absolute()}'" 87 | f" --action 'Show:xdg-open {inbox_path.absolute()}'" 88 | f" --default-action 'xdg-open {inbox_path.absolute()}'" 89 | " --hint=string:sound-name:message-new-email" 90 | ) 91 | 92 | 93 | def create_api_request_notifications(*api_requests: Path, inbox_path: Path) -> None: 94 | if len(api_requests) == 0: 95 | return 96 | 97 | for api_request in api_requests: 98 | title = "New API Request" 99 | message = ( 100 | f'A new API request has been received: "{api_request}".' 101 | ' Please review the code and move it to the "approved" or "rejected" folder.' 102 | ) 103 | icon_path = Path(__file__).parent / "assets" / "icon.png" 104 | if platform.system() == "Darwin": 105 | create_api_request_notifications_macos( 106 | title, message, inbox_path, icon_path 107 | ) 108 | elif platform.system() == "Linux": 109 | create_api_request_notifications_linux( 110 | title, message, inbox_path, icon_path 111 | ) 112 | 113 | 114 | def get_pending_api_requests(inbox_path: Path) -> list: 115 | ignore = [".DS_Store", "rejected", "_.syftperm", "approved"] 116 | pending_api_requests = [ 117 | d.name 118 | for d in inbox_path.iterdir() 119 | if d.name not in ignore and is_valid_api_request(d) 120 | ] 121 | return pending_api_requests 122 | 123 | 124 | def load_inbox_state(api_data_path: Path) -> dict: 125 | state_json_path = api_data_path / "state.json" 126 | if state_json_path.exists(): 127 | with open(state_json_path, "r") as f: 128 | return json.load(f) 129 | return {"pending_api_requests": []} 130 | 131 | 132 | def save_inbox_state(inbox_path: Path, api_data_path: Path) -> None: 133 | state_json_path = api_data_path / "state.json" 134 | print(f"Writing to {state_json_path}") 135 | 136 | state = {"pending_api_requests": get_pending_api_requests(inbox_path)} 137 | 138 | state_json_path.parent.mkdir(parents=True, exist_ok=True) 139 | with open(state_json_path, "w") as f: 140 | json.dump(state, f) 141 | 142 | 143 | def start_notification_service(inbox_path: Path, api_data_path: Path) -> None: 144 | print(f"Watching {inbox_path} for new API requests...") 145 | 146 | previous_pending_api_requests = load_inbox_state(api_data_path)[ 147 | "pending_api_requests" 148 | ] 149 | current_pending_api_requests = get_pending_api_requests(inbox_path) 150 | if previous_pending_api_requests != current_pending_api_requests: 151 | new_api_requests = sorted( 152 | set(current_pending_api_requests) - set(previous_pending_api_requests) 153 | ) 154 | if len(new_api_requests) > 0: 155 | print(f"New API requests received: {human_friendly_join(new_api_requests)}") 156 | save_inbox_state(inbox_path, api_data_path) 157 | create_api_request_notifications(*new_api_requests, inbox_path=inbox_path) 158 | 159 | 160 | def start_garbage_collector(trash_path: Path, trash_symlink_path: Path) -> None: 161 | print(f"Watching {trash_symlink_path} for rejected API requests...") 162 | if not trash_path.exists(): 163 | return 164 | seven_days_ago = datetime.now() - timedelta(days=TRASH_RETENTION_DAYS) 165 | apps_to_delete = sorted( 166 | [ 167 | d 168 | for d in trash_path.iterdir() 169 | if is_valid_api_request(d) 170 | and d.stat().st_ctime < seven_days_ago.timestamp() 171 | ], 172 | key=lambda d: d.name, 173 | ) 174 | 175 | if len(apps_to_delete) > 0: 176 | name_of_apps_to_delete = human_friendly_join([d.name for d in apps_to_delete]) 177 | print( 178 | f"Found {len(apps_to_delete)} rejected API requests older than {TRASH_RETENTION_DAYS} days:" 179 | f" {name_of_apps_to_delete}. Deleting..." 180 | ) 181 | 182 | for item in apps_to_delete: 183 | if os.path.islink(item): 184 | os.unlink(item) 185 | elif item.is_dir(): 186 | shutil.rmtree(str(item.absolute())) 187 | else: 188 | os.remove(item) 189 | 190 | 191 | def compile_broadcast_app(output_path: Path, datasites_path: Path) -> None: 192 | script_dir = Path(__file__).parent / "macos" 193 | script_template_path = script_dir / "broadcast.scpt.template" 194 | script_path = script_dir / "broadcast.scpt" 195 | 196 | with open(script_template_path, "r") as f: 197 | apple_script = f.read() 198 | 199 | apple_script = apple_script.replace( 200 | "{{DATASITES_PATH}}", str(datasites_path.absolute()) 201 | ) 202 | 203 | apple_script_md5 = hashlib.md5(apple_script.encode()).hexdigest() 204 | version_hash_path = output_path / "version_hash" 205 | if version_hash_path.exists() and version_hash_path.read_text() == apple_script_md5: 206 | # No need to recompile 207 | return 208 | 209 | with open(script_path, "w") as f: 210 | f.write(apple_script) 211 | 212 | compile_command = ( 213 | f"osacompile -o {output_path} {script_path} > /dev/null 2>&1" 214 | f' && echo "Broadcast app compiled to {output_path}"' 215 | f' || echo "Failed to compile broadcast app to {output_path}" >&2' 216 | ) 217 | os.system(compile_command) 218 | version_hash_path.write_text(apple_script_md5) 219 | icon_path = Path(__file__).parent / "assets" / "icon.icns" 220 | shutil.copy(icon_path, output_path / "Contents/Resources/droplet.icns") 221 | (output_path / "run.sh").touch() # to suppress an error message in SyftBox 222 | os.remove(script_path) 223 | -------------------------------------------------------------------------------- /linux/notify-send.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # notify-send.sh - drop-in replacement for notify-send with more features 4 | # Copyright (C) 2015-2021 notify-send.sh authors (see AUTHORS file) 5 | 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | # Desktop Notifications Specification 20 | # https://developer.gnome.org/notification-spec/ 21 | 22 | VERSION=1.2 23 | NOTIFY_ARGS=(--session 24 | --dest org.freedesktop.Notifications 25 | --object-path /org/freedesktop/Notifications) 26 | EXPIRE_TIME=-1 27 | APP_NAME="${0##*/}" 28 | REPLACE_ID=0 29 | URGENCY=1 30 | HINTS=() 31 | SUMMARY_SET=n 32 | 33 | help() { 34 | cat < [BODY] - create a notification 37 | 38 | Help Options: 39 | -?|--help Show help options 40 | 41 | Application Options: 42 | -u, --urgency=LEVEL Specifies the urgency level (low, normal, critical). 43 | -t, --expire-time=TIME Specifies the timeout in milliseconds at which to expire the notification. 44 | -f, --force-expire Forcefully closes the notification when the notification has expired. 45 | -a, --app-name=APP_NAME Specifies the app name for the icon. 46 | -i, --icon=ICON[,ICON...] Specifies an icon filename or stock icon to display. 47 | -c, --category=TYPE[,TYPE...] Specifies the notification category. 48 | -h, --hint=TYPE:NAME:VALUE Specifies basic extra data to pass. Valid types are int, double, string and byte. 49 | -o, --action=LABEL:COMMAND Specifies an action. Can be passed multiple times. LABEL is usually a button's label. COMMAND is a shell command executed when action is invoked. 50 | -d, --default-action=COMMAND Specifies the default action which is usually invoked by clicking the notification. 51 | -l, --close-action=COMMAND Specifies the action invoked when notification is closed. 52 | -p, --print-id Print the notification ID to the standard output. 53 | -r, --replace=ID Replace existing notification. 54 | -R, --replace-file=FILE Store and load notification replace ID to/from this file. 55 | -s, --close=ID Close notification. 56 | -v, --version Version of the package. 57 | 58 | EOF 59 | } 60 | 61 | convert_type() { 62 | case "$1" in 63 | int) echo int32 ;; 64 | double|string|byte) echo "$1" ;; 65 | *) echo error; return 1 ;; 66 | esac 67 | } 68 | 69 | make_action_key() { 70 | echo "$(tr -dc _A-Z-a-z-0-9 <<< \"$1\")${RANDOM}" 71 | } 72 | 73 | make_action() { 74 | local action_key="$1" 75 | printf -v text "%q" "$2" 76 | echo "\"$action_key\", \"$text\"" 77 | } 78 | 79 | make_hint() { 80 | type=$(convert_type "$1") 81 | [[ ! $? = 0 ]] && return 1 82 | name="$2" 83 | [[ "$type" = string ]] && command="\"$3\"" || command="$3" 84 | echo "\"$name\": <$type $command>" 85 | } 86 | 87 | concat_actions() { 88 | local result="$1" 89 | shift 90 | for s in "$@"; do 91 | result="$result, $s" 92 | done 93 | echo "[$result]" 94 | } 95 | 96 | concat_hints() { 97 | local result="$1" 98 | shift 99 | for s in "$@"; do 100 | result="$result, $s" 101 | done 102 | echo "{$result}" 103 | } 104 | 105 | parse_notification_id(){ 106 | sed 's/(uint32 \([0-9]\+\),)/\1/g' 107 | } 108 | 109 | notify() { 110 | local actions="$(concat_actions "${ACTIONS[@]}")" 111 | local hints="$(concat_hints "${HINTS[@]}")" 112 | 113 | NOTIFICATION_ID=$(gdbus call "${NOTIFY_ARGS[@]}" \ 114 | --method org.freedesktop.Notifications.Notify \ 115 | -- \ 116 | "$APP_NAME" "$REPLACE_ID" "$ICON" "$SUMMARY" "$BODY" \ 117 | "${actions}" "${hints}" "int32 $EXPIRE_TIME" \ 118 | | parse_notification_id) 119 | 120 | if [[ -n "$STORE_ID" ]] ; then 121 | echo "$NOTIFICATION_ID" > "$STORE_ID" 122 | fi 123 | if [[ -n "$PRINT_ID" ]] ; then 124 | echo "$NOTIFICATION_ID" 125 | fi 126 | 127 | if [[ -n "$FORCE_EXPIRE" ]] ; then 128 | SLEEP_TIME="$( LC_NUMERIC=C printf %f "${EXPIRE_TIME}e-3" )" 129 | ( sleep "$SLEEP_TIME" ; notify_close "$NOTIFICATION_ID" ) & 130 | fi 131 | 132 | maybe_run_action_handler 133 | } 134 | 135 | notify_close () { 136 | gdbus call "${NOTIFY_ARGS[@]}" --method org.freedesktop.Notifications.CloseNotification "$1" >/dev/null 137 | } 138 | 139 | process_urgency() { 140 | case "$1" in 141 | low) URGENCY=0 ;; 142 | normal) URGENCY=1 ;; 143 | critical) URGENCY=2 ;; 144 | *) echo "Unknown urgency $URGENCY specified. Known urgency levels: low, normal, critical." 145 | exit 1 146 | ;; 147 | esac 148 | } 149 | 150 | process_category() { 151 | IFS=, read -a categories <<< "$1" 152 | for category in "${categories[@]}"; do 153 | hint="$(make_hint string category "$category")" 154 | HINTS=("${HINTS[@]}" "$hint") 155 | done 156 | } 157 | 158 | process_hint() { 159 | IFS=: read type name command <<< "$1" 160 | if [[ -z "$name" ]] || [[ -z "$command" ]] ; then 161 | echo "Invalid hint syntax specified. Use TYPE:NAME:VALUE." 162 | exit 1 163 | fi 164 | hint="$(make_hint "$type" "$name" "$command")" 165 | if [[ ! $? = 0 ]] ; then 166 | echo "Invalid hint type \"$type\". Valid types are int, double, string and byte." 167 | exit 1 168 | fi 169 | HINTS=("${HINTS[@]}" "$hint") 170 | } 171 | 172 | maybe_run_action_handler() { 173 | if [[ -n "$NOTIFICATION_ID" ]] && [[ -n "$ACTION_COMMANDS" ]]; then 174 | local notify_action="$(dirname ${BASH_SOURCE[0]})/notify-action.sh" 175 | if [[ -x "$notify_action" ]] ; then 176 | "$notify_action" "$NOTIFICATION_ID" "${ACTION_COMMANDS[@]}" & 177 | exit 0 178 | else 179 | echo "executable file not found: $notify_action" 180 | exit 1 181 | fi 182 | fi 183 | } 184 | 185 | process_action() { 186 | IFS=: read name command <<<"$1" 187 | if [[ -z "$name" ]] || [[ -z "$command" ]]; then 188 | echo "Invalid action syntax specified. Use NAME:COMMAND." 189 | exit 1 190 | fi 191 | 192 | local action_key="$(make_action_key "$name")" 193 | ACTION_COMMANDS=("${ACTION_COMMANDS[@]}" "$action_key" "$command") 194 | 195 | local action="$(make_action "$action_key" "$name")" 196 | ACTIONS=("${ACTIONS[@]}" "$action") 197 | } 198 | 199 | process_special_action() { 200 | action_key="$1" 201 | command="$2" 202 | 203 | if [[ -z "$action_key" ]] || [[ -z "$command" ]]; then 204 | echo "Command must not be empty" 205 | exit 1 206 | fi 207 | 208 | ACTION_COMMANDS=("${ACTION_COMMANDS[@]}" "$action_key" "$command") 209 | 210 | if [[ "$action_key" != close ]]; then 211 | local action="$(make_action "$action_key" "$name")" 212 | ACTIONS=("${ACTIONS[@]}" "$action") 213 | fi 214 | } 215 | 216 | process_posargs() { 217 | if [[ "$1" = -* ]] && ! [[ "$positional" = yes ]] ; then 218 | echo "Unknown option $1" 219 | exit 1 220 | else 221 | if [[ "$SUMMARY_SET" = n ]]; then 222 | SUMMARY="$1" 223 | SUMMARY_SET=y 224 | else 225 | BODY="$1" 226 | fi 227 | fi 228 | } 229 | 230 | while (( $# > 0 )) ; do 231 | case "$1" in 232 | -\?|--help) 233 | help 234 | exit 0 235 | ;; 236 | -v|--version) 237 | echo "${0##*/} $VERSION" 238 | exit 0 239 | ;; 240 | -u|--urgency|--urgency=*) 241 | [[ "$1" = --urgency=* ]] && urgency="${1#*=}" || { shift; urgency="$1"; } 242 | process_urgency "$urgency" 243 | ;; 244 | -t|--expire-time|--expire-time=*) 245 | [[ "$1" = --expire-time=* ]] && EXPIRE_TIME="${1#*=}" || { shift; EXPIRE_TIME="$1"; } 246 | if ! [[ "$EXPIRE_TIME" =~ ^-?[0-9]+$ ]]; then 247 | echo "Invalid expire time: ${EXPIRE_TIME}" 248 | exit 1; 249 | fi 250 | ;; 251 | -f|--force-expire) 252 | FORCE_EXPIRE=yes 253 | ;; 254 | -a|--app-name|--app-name=*) 255 | [[ "$1" = --app-name=* ]] && APP_NAME="${1#*=}" || { shift; APP_NAME="$1"; } 256 | ;; 257 | -i|--icon|--icon=*) 258 | [[ "$1" = --icon=* ]] && ICON="${1#*=}" || { shift; ICON="$1"; } 259 | ;; 260 | -c|--category|--category=*) 261 | [[ "$1" = --category=* ]] && category="${1#*=}" || { shift; category="$1"; } 262 | process_category "$category" 263 | ;; 264 | -h|--hint|--hint=*) 265 | [[ "$1" = --hint=* ]] && hint="${1#*=}" || { shift; hint="$1"; } 266 | process_hint "$hint" 267 | ;; 268 | -o | --action | --action=*) 269 | [[ "$1" == --action=* ]] && action="${1#*=}" || { shift; action="$1"; } 270 | process_action "$action" 271 | ;; 272 | -d | --default-action | --default-action=*) 273 | [[ "$1" == --default-action=* ]] && default_action="${1#*=}" || { shift; default_action="$1"; } 274 | process_special_action default "$default_action" 275 | ;; 276 | -l | --close-action | --close-action=*) 277 | [[ "$1" == --close-action=* ]] && close_action="${1#*=}" || { shift; close_action="$1"; } 278 | process_special_action close "$close_action" 279 | ;; 280 | -p|--print-id) 281 | PRINT_ID=yes 282 | ;; 283 | -r|--replace|--replace=*) 284 | [[ "$1" = --replace=* ]] && REPLACE_ID="${1#*=}" || { shift; REPLACE_ID="$1"; } 285 | ;; 286 | -R|--replace-file|--replace-file=*) 287 | [[ "$1" = --replace-file=* ]] && filename="${1#*=}" || { shift; filename="$1"; } 288 | if [[ -s "$filename" ]]; then 289 | REPLACE_ID="$(< "$filename")" 290 | fi 291 | STORE_ID="$filename" 292 | ;; 293 | -s|--close|--close=*) 294 | [[ "$1" = --close=* ]] && close_id="${1#*=}" || { shift; close_id="$1"; } 295 | # always check that --close provides a numeric value 296 | if [[ -z "$close_id" || ! "$close_id" =~ ^[0-9]+$ ]]; then 297 | echo "Invalid close id: '$close_id'" 298 | exit 1 299 | fi 300 | notify_close "$close_id" 301 | exit $? 302 | ;; 303 | --) 304 | positional=yes 305 | ;; 306 | *) 307 | process_posargs "$1" 308 | ;; 309 | esac 310 | shift 311 | done 312 | 313 | # always force --replace and --replace-file to provide a numeric value; 0 means no id provided 314 | if [[ -z "$REPLACE_ID" || ! "$REPLACE_ID" =~ ^[0-9]+$ ]]; then 315 | REPLACE_ID=0 316 | fi 317 | 318 | # urgency is always set 319 | HINTS=("$(make_hint byte urgency "$URGENCY")" "${HINTS[@]}") 320 | 321 | if [[ "$SUMMARY_SET" = n ]] ; then 322 | help 323 | exit 1 324 | else 325 | notify 326 | fi 327 | --------------------------------------------------------------------------------