diff --git a/CMakeLists.txt b/CMakeLists.txt index 862a1ac..594d7ee 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -114,13 +114,24 @@ endif() if(BUILD_MODE_QMLREBUILD) target_compile_definitions(reMarkable PRIVATE BUILD_MODE_QMLREBUILD=1) - # Enable Qt MOC for MessageBroker + # Enable Qt MOC for MessageBroker (HttpServer is pure Obj-C/Foundation, no MOC needed) set_target_properties(reMarkable PROPERTIES AUTOMOC ON) - # Add MessageBroker source (needs MOC processing) + # Add MessageBroker (needs MOC) and HttpServer (native macOS) target_sources(reMarkable PRIVATE ${PROJECT_ROOT_DIR}/src/utils/MessageBroker.mm + ${PROJECT_ROOT_DIR}/src/utils/HttpServer.mm ) + + find_package(Qt6 COMPONENTS Qml QUIET) + if(Qt6Qml_FOUND) + target_link_libraries(reMarkable PRIVATE Qt6::Qml) + else() + find_package(Qt5 COMPONENTS Qml QUIET) + if(Qt5Qml_FOUND) + target_link_libraries(reMarkable PRIVATE Qt5::Qml) + endif() + endif() message(STATUS "Build mode: qmlrebuild (resource hooking)") endif() diff --git a/docs/DocumentAccepted_MessageBroker_snippet.qml b/docs/DocumentAccepted_MessageBroker_snippet.qml new file mode 100644 index 0000000..1fc6a1d --- /dev/null +++ b/docs/DocumentAccepted_MessageBroker_snippet.qml @@ -0,0 +1,54 @@ +// Add this MessageBroker to a QML component that has access to PlatformHelpers +// This listens for documentAccepted signals from the HTTP server +// and calls PlatformHelpers.documentAccepted() + +import net.noham.MessageBroker + +MessageBroker { + id: documentAcceptedBroker + listeningFor: ["documentAccepted"] + + onSignalReceived: (signal, message) => { + console.log("[DocumentAccepted.MessageBroker] Received signal:", signal); + console.log("[DocumentAccepted.MessageBroker] Message data:", message); + + try { + // Parse JSON message from HTTP server + const data = JSON.parse(message); + console.log("[DocumentAccepted.MessageBroker] Parsed request:", JSON.stringify(data)); + + // Extract parameters with defaults + const url = data.url || ""; + const password = data.password || ""; + const directoryId = data.directoryId || ""; + const flag1 = data.flag1 !== undefined ? data.flag1 : false; + const flag2 = data.flag2 !== undefined ? data.flag2 : false; + + console.log("[DocumentAccepted.MessageBroker] Parameters:"); + console.log("[DocumentAccepted.MessageBroker] url:", url); + console.log("[DocumentAccepted.MessageBroker] password:", password ? "(set)" : "(empty)"); + console.log("[DocumentAccepted.MessageBroker] directoryId:", directoryId); + console.log("[DocumentAccepted.MessageBroker] flag1:", flag1); + console.log("[DocumentAccepted.MessageBroker] flag2:", flag2); + + // Validate required parameters + if (!url) { + console.error("[DocumentAccepted.MessageBroker] ERROR: Missing 'url' parameter"); + return; + } + if (!directoryId) { + console.error("[DocumentAccepted.MessageBroker] ERROR: Missing 'directoryId' parameter"); + return; + } + + // Call PlatformHelpers.documentAccepted + console.log("[DocumentAccepted.MessageBroker] Calling PlatformHelpers.documentAccepted..."); + PlatformHelpers.documentAccepted(url, password, directoryId, flag1, flag2); + console.log("[DocumentAccepted.MessageBroker] Document accepted successfully"); + + } catch (error) { + console.error("[DocumentAccepted.MessageBroker] ERROR parsing request:", error); + console.error("[DocumentAccepted.MessageBroker] Message was:", message); + } + } +} diff --git a/docs/ExportDialog_MessageBroker_snippet.qml b/docs/ExportDialog_MessageBroker_snippet.qml new file mode 100644 index 0000000..680dd51 --- /dev/null +++ b/docs/ExportDialog_MessageBroker_snippet.qml @@ -0,0 +1,67 @@ +// Add this MessageBroker to ExportDialog.qml after the PopupDialog definition +// This should be added near the top of the component, after property definitions + +import net.noham.MessageBroker + +// ... existing properties ... + +// MessageBroker for HTTP server export requests +MessageBroker { + id: exportBroker + listeningFor: ["exportFile"] + + onSignalReceived: (signal, message) => { + console.log("[ExportDialog.MessageBroker] Received signal:", signal); + console.log("[ExportDialog.MessageBroker] Message data:", message); + + try { + // Parse JSON message from HTTP server + const data = JSON.parse(message); + console.log("[ExportDialog.MessageBroker] Parsed export request:", JSON.stringify(data)); + + // Extract parameters + const target = data.target || ""; + const documentId = data.id || data.documentId || ""; + const format = data.format !== undefined ? data.format : PlatformHelpers.ExportPdf; + const password = data.password || ""; + const keepPassword = data.keepPassword !== undefined ? data.keepPassword : true; + const grayscale = data.grayscale !== undefined ? data.grayscale : false; + const pageSelection = data.pageSelection || []; + + console.log("[ExportDialog.MessageBroker] Export parameters:"); + console.log("[ExportDialog.MessageBroker] target:", target); + console.log("[ExportDialog.MessageBroker] documentId:", documentId); + console.log("[ExportDialog.MessageBroker] format:", format); + console.log("[ExportDialog.MessageBroker] keepPassword:", keepPassword); + console.log("[ExportDialog.MessageBroker] grayscale:", grayscale); + console.log("[ExportDialog.MessageBroker] pageSelection:", JSON.stringify(pageSelection)); + + // Validate required parameters + if (!target) { + console.error("[ExportDialog.MessageBroker] ERROR: Missing 'target' parameter"); + return; + } + if (!documentId) { + console.error("[ExportDialog.MessageBroker] ERROR: Missing 'id' or 'documentId' parameter"); + return; + } + + // Call PlatformHelpers.exportFile + console.log("[ExportDialog.MessageBroker] Calling PlatformHelpers.exportFile..."); + + if (pageSelection && pageSelection.length > 0) { + console.log("[ExportDialog.MessageBroker] Exporting with page selection"); + PlatformHelpers.exportFile(target, documentId, format, password, keepPassword, grayscale, pageSelection); + } else { + console.log("[ExportDialog.MessageBroker] Exporting full document"); + PlatformHelpers.exportFile(target, documentId, format, password, keepPassword, grayscale); + } + + console.log("[ExportDialog.MessageBroker] Export completed successfully"); + + } catch (error) { + console.error("[ExportDialog.MessageBroker] ERROR parsing export request:", error); + console.error("[ExportDialog.MessageBroker] Message was:", message); + } + } +} diff --git a/docs/HTTP_SERVER.md b/docs/HTTP_SERVER.md new file mode 100644 index 0000000..cdf9e21 --- /dev/null +++ b/docs/HTTP_SERVER.md @@ -0,0 +1,293 @@ +# HTTP Server for Export Requests + +The RMHook dylib includes an HTTP server that accepts export requests and forwards them to the reMarkable application via MessageBroker. + +## Server Details + +- **Host**: `localhost` +- **Port**: `8080` +- **Base URL**: `http://localhost:8080` + +## Endpoints + +### `POST /exportFile` + +Trigger a document export from the reMarkable application. + +**Request Body** (JSON): +```json +{ + "target": "file:///Users/username/Desktop/output.pdf", + "id": "document-uuid-here", + "format": 0, + "password": "", + "keepPassword": true, + "grayscale": false, + "pageSelection": [] +} +``` + +**Parameters**: +- `target` (string, required): File path or folder URL for the export. Use `file://` prefix for local paths. +- `id` or `documentId` (string, required): The UUID of the document to export. +- `format` (integer, optional): Export format. Default: `0` (PDF) + - `0`: PDF + - `1`: PNG + - `2`: SVG + - `3`: RmBundle + - `4`: RmHtml +- `password` (string, optional): Password for password-protected documents. Default: `""` +- `keepPassword` (boolean, optional): Whether to keep password protection on PDF exports. Default: `true` +- `grayscale` (boolean, optional): Export with grayscale pens. Default: `false` +- `pageSelection` (array, optional): Array of page indices to export. If empty or omitted, exports all pages. Example: `[0, 1, 2]` + +**Response**: +```json +{ + "status": "success", + "message": "Export request sent to application" +} +``` + +**Error Response**: +```json +{ + "error": "Error description" +} +``` + +### `POST /documentAccepted` + +Import/accept a document into the reMarkable application. + +**Request Body** (JSON): +```json +{ + "url": "file:///Users/username/Desktop/test.pdf", + "password": "", + "directoryId": "2166c19d-d2cc-456c-9f0e-49482031092a", + "flag1": false, + "flag2": false +} +``` + +**Parameters**: +- `url` (string, required): File URL to import. Use `file://` prefix for local paths. +- `password` (string, optional): Password for password-protected documents. Default: `""` +- `directoryId` (string, required): The UUID of the target directory/folder where the document should be imported. +- `flag1` (boolean, optional): Purpose unclear. Default: `false` +- `flag2` (boolean, optional): Purpose unclear. Default: `false` + +**Response**: +```json +{ + "status": "success", + "message": "Document accepted request sent to application" +} +``` + +**Error Response**: +```json +{ + "error": "Error description" +} +``` + +### `GET /health` + +Health check endpoint. + +**Response**: +```json +{ + "status": "ok", + "service": "RMHook HTTP Server" +} +``` + +## Example Requests + +### Export a document to PDF + +```bash +curl -X POST http://localhost:8080/exportFile \ + -H "Content-Type: application/json" \ + -d '{ + "target": "file:///Users/noham/Desktop/export.pdf", + "id": "12345678-1234-1234-1234-123456789abc", + "format": 0, + "grayscale": false, + "keepPassword": true + }' +``` + +### Export specific pages as PNG + +```bash +curl -X POST http://localhost:8080/exportFile \ + -H "Content-Type: application/json" \ + -d '{ + "target": "file:///Users/noham/Desktop/pages", + "id": "12345678-1234-1234-1234-123456789abc", + "format": 1, + "pageSelection": [0, 1, 2], + "grayscale": true + }' +``` + +### Export to RmBundle format + +```bash +curl -X POST http://localhost:8080/exportFile \ + -H "Content-Type: application/json" \ + -d '{ + "target": "file:///Users/noham/Desktop/MyDocument", + "id": "12345678-1234-1234-1234-123456789abc", + "format": 3 + }' +``` + +### Import/Accept a document + +```bash +curl -X POST http://localhost:8080/documentAccepted \ + -H "Content-Type: application/json" \ + -d '{ + "url": "file:///Users/noham/Desktop/test.pdf", + "password": "", + "directoryId": "2166c19d-d2cc-456c-9f0e-49482031092a", + "flag1": false, + "flag2": false + }' +``` + +### Python Example - Export + +```python +import requests +import json + +# Export configuration +export_data = { + "target": "file:///Users/noham/Desktop/output.pdf", + "id": "12345678-1234-1234-1234-123456789abc", + "format": 0, # PDF + "grayscale": False, + "keepPassword": True +} + +# Send request +response = requests.post( + "http://localhost:8080/exportFile", + json=export_data +) + +print(f"Status: {response.status_code}") +print(f"Response: {response.json()}") +``` + +### Python Example - Import Document + +```python +import requests + +# Import configuration +import_data = { + "url": "file:///Users/noham/Desktop/test.pdf", + "password": "", + "directoryId": "2166c19d-d2cc-456c-9f0e-49482031092a", + "flag1": False, + "flag2": False +} + +# Send request +response = requests.post( + "http://localhost:8080/documentAccepted", + json=import_data +) + +print(f"Status: {response.status_code}") +print(f"Response: {response.json()}") +``` + +### JavaScript Example - Export + +```javascript +// Export configuration +const exportData = { + target: "file:///Users/noham/Desktop/output.pdf", + id: "12345678-1234-1234-1234-123456789abc", + format: 0, // PDF + grayscale: false, + keepPassword: true +}; + +// Send request +fetch("http://localhost:8080/exportFile", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(exportData) +}) + .then(response => response.json()) + .then(data => console.log("Success:", data)) + .catch(error => console.error("Error:", error)); +``` + +### JavaScript Example - Import Document + +```javascript +// Import configuration +const importData = { + url: "file:///Users/noham/Desktop/test.pdf", + password: "", + directoryId: "2166c19d-d2cc-456c-9f0e-49482031092a", + flag1: false, + flag2: false +}; + +// Send request +fetch("http://localhost:8080/documentAccepted", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(importData) +}) + .then(response => response.json()) + .then(data => console.log("Success:", data)) + .catch(error => console.error("Error:", error)); +``` + +## Integration with QML + +### Export Dialog Integration + +Add the MessageBroker snippet from `docs/ExportDialog_MessageBroker_snippet.qml` to your ExportDialog.qml replacement file. This will enable the QML side to receive export requests from the HTTP server. + +The MessageBroker listens for "exportFile" signals and automatically calls `PlatformHelpers.exportFile()` with the provided parameters. + +### Document Import Integration + +Add the MessageBroker snippet from `docs/DocumentAccepted_MessageBroker_snippet.qml` to a QML component (such as GeneralSettings.qml) that has access to PlatformHelpers. This will enable the QML side to receive document import requests from the HTTP server. + +The MessageBroker listens for "documentAccepted" signals and automatically calls `PlatformHelpers.documentAccepted()` with the provided parameters. + +## Document ID and Directory ID Discovery + +To find document and directory IDs, you can: + +1. Check the reMarkable application logs when opening documents or folders +2. Use the reMarkable Cloud API +3. Access the local database at `~/Library/Application Support/remarkable/desktop-app/` +4. For the root directory ID, check the logs when navigating to "My Files" + +## Troubleshooting + +- Ensure the HTTP server started successfully by checking the logs: `2025-12-08 17:32:22.288 reMarkable[19574:1316287] [HttpServer] HTTP server started successfully on http://localhost:8080` +- Test the health endpoint: `curl http://localhost:8080/health` +- Check the Console.app for detailed logging from the MessageBroker and HttpServer +- Verify the document ID and directory ID are correct UUIDs +- Ensure the target/url path is accessible and uses the `file://` prefix +- For imports, verify the target directory exists and is accessible diff --git a/scripts/http_server.py b/scripts/http_server.py new file mode 100644 index 0000000..f8980a7 --- /dev/null +++ b/scripts/http_server.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +import requests +import json +import sys +import argparse + +BASE_URL = "http://localhost:8080" + +def export_document(document_id, target_path, format_type=0, grayscale=False, + keep_password=True, password="", page_selection=None): + """ + Export a document via HTTP API + + Args: + document_id: UUID of the document to export + target_path: Target path for the export (use file:// prefix) + format_type: Export format (0=PDF, 1=PNG, 2=SVG, 3=RmBundle, 4=RmHtml) + grayscale: Export with grayscale pens + keep_password: Keep password protection (for PDFs) + password: Password for protected documents + page_selection: List of page indices to export (None = all pages) + """ + print(f"\nExporting document {document_id}...") + print(f"Target: {target_path}") + print(f"Format: {format_type}") + + data = { + "target": target_path, + "id": document_id, + "format": format_type, + "grayscale": grayscale, + "keepPassword": keep_password, + "password": password + } + + if page_selection: + data["pageSelection"] = page_selection + print(f"Pages: {page_selection}") + + try: + response = requests.post( + f"{BASE_URL}/exportFile", + json=data, + timeout=10 + ) + print(f"Status: {response.status_code}") + print(f"Response: {json.dumps(response.json(), indent=2)}") + return response.status_code == 200 + except Exception as e: + print(f"Error: {e}") + return False + +def import_document(file_url, directory_id, password="", flag1=False, flag2=False): + """ + Import a document via HTTP API + + Args: + file_url: File URL to import (use file:// prefix) + directory_id: UUID of the target directory + password: Password for protected documents (optional) + flag1: Additional flag parameter + flag2: Additional flag parameter + """ + print(f"\nImporting document from {file_url}...") + print(f"Target directory: {directory_id}") + + data = { + "url": file_url, + "password": password, + "directoryId": directory_id, + "flag1": flag1, + "flag2": flag2 + } + + try: + response = requests.post( + f"{BASE_URL}/documentAccepted", + json=data, + timeout=10 + ) + print(f"Status: {response.status_code}") + print(f"Response: {json.dumps(response.json(), indent=2)}") + return response.status_code == 200 + except Exception as e: + print(f"Error: {e}") + return False + +def main(): + parser = argparse.ArgumentParser( + description='reMarkable HTTP Server API Client', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +Examples: + Export a document as PDF: + %(prog)s export 12345678-1234-1234-1234-123456789abc file:///Users/noham/Desktop/test.pdf + + Export as PNG with grayscale: + %(prog)s export --format 1 --grayscale + + Export specific pages: + %(prog)s export --pages 0 1 2 + + Import a document: + %(prog)s import file:///Users/noham/Desktop/test.pdf 2166c19d-d2cc-456c-9f0e-49482031092a + + Import with password: + %(prog)s import --password mypassword + ''' + ) + + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + # Export command + export_parser = subparsers.add_parser('export', help='Export a document') + export_parser.add_argument('document_id', help='UUID of the document to export') + export_parser.add_argument('target_path', help='Target path for export (use file:// prefix)') + export_parser.add_argument( + '--format', '-f', + type=int, + default=0, + choices=[0, 1, 2, 3, 4], + help='Export format: 0=PDF, 1=PNG, 2=SVG, 3=RmBundle, 4=RmHtml (default: 0)' + ) + export_parser.add_argument( + '--grayscale', '-g', + action='store_true', + help='Export with grayscale pens' + ) + export_parser.add_argument( + '--no-keep-password', + action='store_true', + help='Do not keep password protection (for PDFs)' + ) + export_parser.add_argument( + '--password', '-p', + default='', + help='Password for protected documents' + ) + export_parser.add_argument( + '--pages', + type=int, + nargs='+', + help='List of page indices to export (default: all pages)' + ) + + # Import command + import_parser = subparsers.add_parser('import', help='Import a document') + import_parser.add_argument('file_url', help='File URL to import (use file:// prefix)') + import_parser.add_argument('directory_id', help='UUID of the target directory') + import_parser.add_argument( + '--password', '-p', + default='', + help='Password for protected documents' + ) + import_parser.add_argument( + '--flag1', + action='store_true', + help='Additional flag parameter' + ) + import_parser.add_argument( + '--flag2', + action='store_true', + help='Additional flag parameter' + ) + + args = parser.parse_args() + + if args.command == 'export': + success = export_document( + document_id=args.document_id, + target_path=args.target_path, + format_type=args.format, + grayscale=args.grayscale, + keep_password=not args.no_keep_password, + password=args.password, + page_selection=args.pages + ) + if success: + print("\n✅ Export request sent successfully!") + else: + print("\n❌ Export request failed!") + sys.exit(1) + elif args.command == 'import': + success = import_document( + file_url=args.file_url, + directory_id=args.directory_id, + password=args.password, + flag1=args.flag1, + flag2=args.flag2 + ) + if success: + print("\n✅ Import request sent successfully!") + else: + print("\n❌ Import request failed!") + sys.exit(1) + else: + parser.print_help() + +if __name__ == "__main__": + main() diff --git a/scripts/test_http_server.py b/scripts/test_http_server.py new file mode 100644 index 0000000..4254480 --- /dev/null +++ b/scripts/test_http_server.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +Test script for RMHook HTTP Server +Demonstrates how to trigger exports and imports via HTTP API +""" + +import requests +import json +import sys + +BASE_URL = "http://localhost:8080" + +def test_health(): + """Test the health endpoint""" + print("Testing /health endpoint...") + try: + response = requests.get(f"{BASE_URL}/health", timeout=5) + print(f"Status: {response.status_code}") + print(f"Response: {json.dumps(response.json(), indent=2)}") + return response.status_code == 200 + except Exception as e: + print(f"Error: {e}") + return False + +def export_document(document_id, target_path, format_type=0, grayscale=False, + keep_password=True, password="", page_selection=None): + """ + Export a document via HTTP API + + Args: + document_id: UUID of the document to export + target_path: Target path for the export (use file:// prefix) + format_type: Export format (0=PDF, 1=PNG, 2=SVG, 3=RmBundle, 4=RmHtml) + grayscale: Export with grayscale pens + keep_password: Keep password protection (for PDFs) + password: Password for protected documents + page_selection: List of page indices to export (None = all pages) + """ + print(f"\nExporting document {document_id}...") + print(f"Target: {target_path}") + print(f"Format: {format_type}") + + data = { + "target": target_path, + "id": document_id, + "format": format_type, + "grayscale": grayscale, + "keepPassword": keep_password, + "password": password + } + + if page_selection: + data["pageSelection"] = page_selection + print(f"Pages: {page_selection}") + + try: + response = requests.post( + f"{BASE_URL}/exportFile", + json=data, + timeout=10 + ) + print(f"Status: {response.status_code}") + print(f"Response: {json.dumps(response.json(), indent=2)}") + return response.status_code == 200 + except Exception as e: + print(f"Error: {e}") + return False + +def import_document(file_url, directory_id, password=""): + """ + Import a document via HTTP API + + Args: + file_url: File URL to import (use file:// prefix) + directory_id: UUID of the target directory + password: Password for protected documents (optional) + """ + print(f"\nImporting document from {file_url}...") + print(f"Target directory: {directory_id}") + + data = { + "url": file_url, + "password": password, + "directoryId": directory_id, + "flag1": False, + "flag2": False + } + + try: + response = requests.post( + f"{BASE_URL}/documentAccepted", + json=data, + timeout=10 + ) + print(f"Status: {response.status_code}") + print(f"Response: {json.dumps(response.json(), indent=2)}") + return response.status_code == 200 + except Exception as e: + print(f"Error: {e}") + return False + +def main(): + print("=" * 60) + print("RMHook HTTP Server Test Script") + print("=" * 60) + + # Test health endpoint + if not test_health(): + print("\n❌ Health check failed. Is the server running?") + print("Make sure reMarkable app is running with the dylib injected.") + sys.exit(1) + + print("\n✅ Server is running!") + + # Command line interface + if len(sys.argv) < 2: + print("\n" + "=" * 60) + print("Usage Examples") + print("=" * 60) + print("\n1. Export a document:") + print(' python3 test_http_server.py export [format] [grayscale]') + print("\n Example:") + print(' python3 test_http_server.py export "abc-123" "file:///Users/noham/Desktop/test.pdf" 0 false') + + print("\n2. Import a document:") + print(' python3 test_http_server.py import ') + print("\n Example:") + print(' python3 test_http_server.py import "file:///Users/noham/Desktop/test.pdf" "2166c19d-d2cc-456c-9f0e-49482031092a"') + + sys.exit(0) + + command = sys.argv[1].lower() + + if command == "export" and len(sys.argv) >= 4: + doc_id = sys.argv[2] + target = sys.argv[3] + format_type = int(sys.argv[4]) if len(sys.argv) > 4 else 0 + grayscale = sys.argv[5].lower() == "true" if len(sys.argv) > 5 else False + + success = export_document(doc_id, target, format_type, grayscale) + if success: + print("\n✅ Export request sent successfully!") + else: + print("\n❌ Export request failed!") + sys.exit(1) + + elif command == "import" and len(sys.argv) >= 4: + file_url = sys.argv[2] + directory_id = sys.argv[3] + password = sys.argv[4] if len(sys.argv) > 4 else "" + + success = import_document(file_url, directory_id, password) + if success: + print("\n✅ Import request sent successfully!") + else: + print("\n❌ Import request failed!") + sys.exit(1) + + else: + print(f"\n❌ Invalid command or missing arguments: {' '.join(sys.argv[1:])}") + print("Run without arguments to see usage examples.") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/src/reMarkable/reMarkable.m b/src/reMarkable/reMarkable.m index b730f7d..86ecab1 100644 --- a/src/reMarkable/reMarkable.m +++ b/src/reMarkable/reMarkable.m @@ -9,6 +9,7 @@ #endif #ifdef BUILD_MODE_QMLREBUILD #import "MessageBroker.h" +#import "HttpServer.h" #endif #import #import @@ -314,6 +315,13 @@ static inline bool shouldPatchURL(const QString &host) { NSLogger(@"[reMarkable] Native callback received signal '%s' with value '%s'", signal, value); }); + // Start HTTP server for export requests + if (httpserver::start(8080)) { + NSLogger(@"[reMarkable] HTTP server started on http://localhost:8080"); + } else { + NSLogger(@"[reMarkable] Failed to start HTTP server"); + } + [MemoryUtils hookSymbol:@"QtCore" symbolName:@"__Z21qRegisterResourceDataiPKhS0_S0_" hookFunction:(void *)hooked_qRegisterResourceData diff --git a/src/utils/HttpServer.h b/src/utils/HttpServer.h new file mode 100644 index 0000000..0800079 --- /dev/null +++ b/src/utils/HttpServer.h @@ -0,0 +1,23 @@ +// HTTP Server for RMHook - native macOS implementation +#pragma once + +#import + +#ifdef __cplusplus +extern "C" { +#endif + +namespace httpserver { + // Start HTTP server on specified port + bool start(uint16_t port = 8080); + + // Stop HTTP server + void stop(); + + // Check if server is running + bool isRunning(); +} + +#ifdef __cplusplus +} +#endif diff --git a/src/utils/HttpServer.mm b/src/utils/HttpServer.mm new file mode 100644 index 0000000..8e8e7de --- /dev/null +++ b/src/utils/HttpServer.mm @@ -0,0 +1,305 @@ +// HTTP Server for RMHook - native macOS implementation using CFSocket + +#import +#import +#include +#include +#include +#include +#include "HttpServer.h" +#include "MessageBroker.h" +#include "Logger.h" + +static CFSocketRef g_serverSocket = NULL; +static uint16_t g_serverPort = 0; +static bool g_isRunning = false; + +// Forward declarations +static void handleClientConnection(int clientSocket); +static void sendResponse(int clientSocket, int statusCode, NSString *body, NSString *contentType); +static void handleExportFileRequest(int clientSocket, NSDictionary *jsonData); +static void handleDocumentAcceptedRequest(int clientSocket, NSDictionary *jsonData); + +// Socket callback +static void socketCallback(CFSocketRef socket, CFSocketCallBackType type, + CFDataRef address, const void *data, void *info) +{ + if (type == kCFSocketAcceptCallBack) { + CFSocketNativeHandle clientSocket = *(CFSocketNativeHandle *)data; + NSLogger(@"[HttpServer] New connection accepted, socket: %d", clientSocket); + + // Handle client in background + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + handleClientConnection(clientSocket); + }); + } +} + +static void handleClientConnection(int clientSocket) +{ + @autoreleasepool { + // Read request + char buffer[4096]; + ssize_t bytesRead = recv(clientSocket, buffer, sizeof(buffer) - 1, 0); + + if (bytesRead <= 0) { + close(clientSocket); + return; + } + + buffer[bytesRead] = '\0'; + NSString *request = [NSString stringWithUTF8String:buffer]; + + NSLogger(@"[HttpServer] Received request (%ld bytes)", (long)bytesRead); + + // Parse request line + NSArray *lines = [request componentsSeparatedByString:@"\r\n"]; + if (lines.count == 0) { + sendResponse(clientSocket, 400, @"{\"error\": \"Invalid request\"}", @"application/json"); + close(clientSocket); + return; + } + + NSArray *requestLine = [lines[0] componentsSeparatedByString:@" "]; + if (requestLine.count < 3) { + sendResponse(clientSocket, 400, @"{\"error\": \"Invalid request line\"}", @"application/json"); + close(clientSocket); + return; + } + + NSString *method = requestLine[0]; + NSString *path = requestLine[1]; + + NSLogger(@"[HttpServer] %@ %@", method, path); + + // Find body (after \r\n\r\n) + NSRange bodyRange = [request rangeOfString:@"\r\n\r\n"]; + NSString *body = nil; + if (bodyRange.location != NSNotFound) { + body = [request substringFromIndex:bodyRange.location + 4]; + } + + // Route requests + if ([path isEqualToString:@"/exportFile"] && [method isEqualToString:@"POST"]) { + if (!body || body.length == 0) { + sendResponse(clientSocket, 400, @"{\"error\": \"Missing request body\"}", @"application/json"); + close(clientSocket); + return; + } + + NSData *jsonData = [body dataUsingEncoding:NSUTF8StringEncoding]; + NSError *error = nil; + id json = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error]; + + if (error || ![json isKindOfClass:[NSDictionary class]]) { + NSString *errorMsg = [NSString stringWithFormat:@"{\"error\": \"Invalid JSON: %@\"}", + error ? error.localizedDescription : @"Not an object"]; + sendResponse(clientSocket, 400, errorMsg, @"application/json"); + close(clientSocket); + return; + } + + handleExportFileRequest(clientSocket, (NSDictionary *)json); + + } else if ([path isEqualToString:@"/documentAccepted"] && [method isEqualToString:@"POST"]) { + if (!body || body.length == 0) { + sendResponse(clientSocket, 400, @"{\"error\": \"Missing request body\"}", @"application/json"); + close(clientSocket); + return; + } + + NSData *jsonData = [body dataUsingEncoding:NSUTF8StringEncoding]; + NSError *error = nil; + id json = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error]; + + if (error || ![json isKindOfClass:[NSDictionary class]]) { + NSString *errorMsg = [NSString stringWithFormat:@"{\"error\": \"Invalid JSON: %@\"}", + error ? error.localizedDescription : @"Not an object"]; + sendResponse(clientSocket, 400, errorMsg, @"application/json"); + close(clientSocket); + return; + } + + handleDocumentAcceptedRequest(clientSocket, (NSDictionary *)json); + + } else if ([path isEqualToString:@"/health"] || [path isEqualToString:@"/"]) { + sendResponse(clientSocket, 200, @"{\"status\": \"ok\", \"service\": \"RMHook HTTP Server\"}", @"application/json"); + + } else { + sendResponse(clientSocket, 404, @"{\"error\": \"Endpoint not found\"}", @"application/json"); + } + + close(clientSocket); + } +} + +static void handleExportFileRequest(int clientSocket, NSDictionary *jsonData) +{ + NSLogger(@"[HttpServer] Processing /exportFile request"); + + // Convert to JSON string for MessageBroker + NSError *error = nil; + NSData *jsonDataEncoded = [NSJSONSerialization dataWithJSONObject:jsonData + options:0 + error:&error]; + + if (error) { + NSString *errorMsg = [NSString stringWithFormat:@"{\"error\": \"Failed to encode JSON: %@\"}", + error.localizedDescription]; + sendResponse(clientSocket, 500, errorMsg, @"application/json"); + return; + } + + NSString *jsonStr = [[NSString alloc] initWithData:jsonDataEncoded encoding:NSUTF8StringEncoding]; + NSLogger(@"[HttpServer] Broadcasting exportFile signal with data: %@", jsonStr); + + // Broadcast to MessageBroker + messagebroker::broadcast("exportFile", [jsonStr UTF8String]); + + // Send success response + sendResponse(clientSocket, 200, + @"{\"status\": \"success\", \"message\": \"Export request sent to application\"}", + @"application/json"); +} + +static void handleDocumentAcceptedRequest(int clientSocket, NSDictionary *jsonData) +{ + NSLogger(@"[HttpServer] Processing /documentAccepted request"); + + // Convert to JSON string for MessageBroker + NSError *error = nil; + NSData *jsonDataEncoded = [NSJSONSerialization dataWithJSONObject:jsonData + options:0 + error:&error]; + + if (error) { + NSString *errorMsg = [NSString stringWithFormat:@"{\"error\": \"Failed to encode JSON: %@\"}", + error.localizedDescription]; + sendResponse(clientSocket, 500, errorMsg, @"application/json"); + return; + } + + NSString *jsonStr = [[NSString alloc] initWithData:jsonDataEncoded encoding:NSUTF8StringEncoding]; + NSLogger(@"[HttpServer] Broadcasting documentAccepted signal with data: %@", jsonStr); + + // Broadcast to MessageBroker + messagebroker::broadcast("documentAccepted", [jsonStr UTF8String]); + + // Send success response + sendResponse(clientSocket, 200, + @"{\"status\": \"success\", \"message\": \"Document accepted request sent to application\"}", + @"application/json"); +} + +static void sendResponse(int clientSocket, int statusCode, NSString *body, NSString *contentType) +{ + NSString *statusText; + switch (statusCode) { + case 200: statusText = @"OK"; break; + case 400: statusText = @"Bad Request"; break; + case 404: statusText = @"Not Found"; break; + case 500: statusText = @"Internal Server Error"; break; + default: statusText = @"Unknown"; break; + } + + NSData *bodyData = [body dataUsingEncoding:NSUTF8StringEncoding]; + + NSString *response = [NSString stringWithFormat: + @"HTTP/1.1 %d %@\r\n" + @"Content-Type: %@; charset=utf-8\r\n" + @"Content-Length: %lu\r\n" + @"Access-Control-Allow-Origin: *\r\n" + @"Connection: close\r\n" + @"\r\n" + @"%@", + statusCode, statusText, contentType, (unsigned long)bodyData.length, body + ]; + + NSData *responseData = [response dataUsingEncoding:NSUTF8StringEncoding]; + send(clientSocket, responseData.bytes, responseData.length, 0); +} + +namespace httpserver { + +bool start(uint16_t port) +{ + if (g_isRunning) { + NSLogger(@"[HttpServer] Server already running on port %d", g_serverPort); + return true; + } + + // Create socket + CFSocketContext context = {0, NULL, NULL, NULL, NULL}; + g_serverSocket = CFSocketCreate(kCFAllocatorDefault, + PF_INET, + SOCK_STREAM, + IPPROTO_TCP, + kCFSocketAcceptCallBack, + socketCallback, + &context); + + if (!g_serverSocket) { + NSLogger(@"[HttpServer] Failed to create socket"); + return false; + } + + // Set socket options + int yes = 1; + setsockopt(CFSocketGetNative(g_serverSocket), SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + + // Bind to address + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_len = sizeof(addr); + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // localhost only + + CFDataRef addressData = CFDataCreate(kCFAllocatorDefault, + (const UInt8 *)&addr, + sizeof(addr)); + + CFSocketError error = CFSocketSetAddress(g_serverSocket, addressData); + CFRelease(addressData); + + if (error != kCFSocketSuccess) { + NSLogger(@"[HttpServer] Failed to bind to port %d (error: %ld)", port, (long)error); + CFRelease(g_serverSocket); + g_serverSocket = NULL; + return false; + } + + // Add to run loop + CFRunLoopSourceRef source = CFSocketCreateRunLoopSource(kCFAllocatorDefault, g_serverSocket, 0); + CFRunLoopAddSource(CFRunLoopGetMain(), source, kCFRunLoopCommonModes); + CFRelease(source); + + g_serverPort = port; + g_isRunning = true; + + NSLogger(@"[HttpServer] HTTP server started successfully on http://localhost:%d", port); + return true; +} + +void stop() +{ + if (!g_isRunning) { + return; + } + + if (g_serverSocket) { + CFSocketInvalidate(g_serverSocket); + CFRelease(g_serverSocket); + g_serverSocket = NULL; + } + + g_isRunning = false; + NSLogger(@"[HttpServer] HTTP server stopped"); +} + +bool isRunning() +{ + return g_isRunning; +} + +} // namespace httpserver diff --git a/src/utils/ResourceUtils.m b/src/utils/ResourceUtils.m index ca51e04..8ff32a8 100644 --- a/src/utils/ResourceUtils.m +++ b/src/utils/ResourceUtils.m @@ -348,6 +348,8 @@ void ReMarkableDumpResourceFile(struct ResourceRoot *root, int node, const char static const char *kFilesToReplace[] = { "/qml/client/dialogs/ExportDialog.qml", "/qml/client/settings/GeneralSettings.qml", + "/qml/client/dialogs/ExportUtils.js", + "/qml/client/desktop/FileImportDialog.qml", NULL // Sentinel to mark end of list }; diff --git a/src/utils/mb.m b/src/utils/mb.m new file mode 100644 index 0000000..41adc88 --- /dev/null +++ b/src/utils/mb.m @@ -0,0 +1,17 @@ +// Example usage of MessageBroker from C++/Objective-C + +#include +#include +#include +#include +#include "MessageBroker.h" + +// Example: Register MessageBroker QML type (called from dylib init) +void initMessageBroker() { + messagebroker::registerQmlType(); +} + +// Example: Send a signal from C++ to QML +void sendSignal() { + messagebroker::broadcast("demoSignal", "Hello from C!"); +} \ No newline at end of file diff --git a/src/utils/mb.qml b/src/utils/mb.qml new file mode 100644 index 0000000..8d2c122 --- /dev/null +++ b/src/utils/mb.qml @@ -0,0 +1,16 @@ +import net.noham.MessageBroker + +MessageBroker { + id: demoBroker + listeningFor: ["demoSignal"] + + onSignalReceived: (signal, message) => { + console.log("Got message", signal, message); + } +} + +MouseArea { + onClicked: () => { + demoBroker.sendSignal("mySignalName", "Hello from QML!"); + } +} \ No newline at end of file