sorcgcs-server 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sorcgcs_server-0.1.0/.gitignore +71 -0
- sorcgcs_server-0.1.0/LICENSE +21 -0
- sorcgcs_server-0.1.0/PKG-INFO +83 -0
- sorcgcs_server-0.1.0/README.md +63 -0
- sorcgcs_server-0.1.0/pyproject.toml +31 -0
- sorcgcs_server-0.1.0/src/sorcgcs_server/__init__.py +3 -0
- sorcgcs_server-0.1.0/src/sorcgcs_server/cli.py +44 -0
- sorcgcs_server-0.1.0/src/sorcgcs_server/server.py +583 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
.pnp
|
|
4
|
+
.pnp.js
|
|
5
|
+
|
|
6
|
+
# Testing
|
|
7
|
+
coverage/
|
|
8
|
+
|
|
9
|
+
# Next.js
|
|
10
|
+
.next/
|
|
11
|
+
out/
|
|
12
|
+
|
|
13
|
+
# Production
|
|
14
|
+
build/
|
|
15
|
+
dist/
|
|
16
|
+
|
|
17
|
+
# Misc
|
|
18
|
+
.DS_Store
|
|
19
|
+
*.pem
|
|
20
|
+
|
|
21
|
+
# Debug
|
|
22
|
+
npm-debug.log*
|
|
23
|
+
yarn-debug.log*
|
|
24
|
+
yarn-error.log*
|
|
25
|
+
|
|
26
|
+
# Local env files
|
|
27
|
+
.env*.local
|
|
28
|
+
.env
|
|
29
|
+
|
|
30
|
+
# Vercel
|
|
31
|
+
.vercel
|
|
32
|
+
|
|
33
|
+
# TypeScript
|
|
34
|
+
*.tsbuildinfo
|
|
35
|
+
next-env.d.ts
|
|
36
|
+
|
|
37
|
+
# IDE
|
|
38
|
+
.idea/
|
|
39
|
+
.vscode/
|
|
40
|
+
*.swp
|
|
41
|
+
*.swo
|
|
42
|
+
|
|
43
|
+
# Examples folder - not needed in deployment
|
|
44
|
+
Examples/
|
|
45
|
+
|
|
46
|
+
# Memory bank - project documentation only
|
|
47
|
+
# memory-bank/
|
|
48
|
+
|
|
49
|
+
# Debug viewport log
|
|
50
|
+
debug-viewport.log
|
|
51
|
+
|
|
52
|
+
# Debug logs
|
|
53
|
+
_debug/
|
|
54
|
+
_debug_history/
|
|
55
|
+
|
|
56
|
+
# Electron build output
|
|
57
|
+
release/
|
|
58
|
+
build-output/
|
|
59
|
+
installer-output*/
|
|
60
|
+
|
|
61
|
+
# Update token (sensitive)
|
|
62
|
+
update-token.txt
|
|
63
|
+
|
|
64
|
+
# CorridorKey test environment (cloned repo + model weights)
|
|
65
|
+
scripts/corridor-key/CorridorKey/
|
|
66
|
+
|
|
67
|
+
# Rigging debug logs
|
|
68
|
+
logs/
|
|
69
|
+
|
|
70
|
+
# Animation debug logs
|
|
71
|
+
docs/anim-debug/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SorcGCS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sorcgcs-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local development server for SorcGCS — game preview, file management, and AI agent support
|
|
5
|
+
Project-URL: Homepage, https://sorceress.app
|
|
6
|
+
Project-URL: Repository, https://github.com/SorcGCS/sorcgcs-server
|
|
7
|
+
Author: SorcGCS
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: game-preview,gamedev,local-server,sorcgcs
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Games/Entertainment
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# sorcgcs-server
|
|
22
|
+
|
|
23
|
+
Local development server for [SorcGCS (Sorceress)](https://sorceress.app) — game preview, file management, and AI code agent support.
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Using uv (fastest)
|
|
29
|
+
uvx sorcgcs-server
|
|
30
|
+
|
|
31
|
+
# Using pipx
|
|
32
|
+
pipx run sorcgcs-server
|
|
33
|
+
|
|
34
|
+
# Or install and run
|
|
35
|
+
pip install sorcgcs-server
|
|
36
|
+
sorcgcs-server
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Options
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
sorcgcs-server --port 8080 # Custom port (default: 8080)
|
|
43
|
+
sorcgcs-server --dir ./games # Serve a specific directory
|
|
44
|
+
sorcgcs-server --version # Show version
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## What It Does
|
|
48
|
+
|
|
49
|
+
This is a lightweight local HTTP server (Python stdlib only, no dependencies) that provides:
|
|
50
|
+
|
|
51
|
+
- **Game Preview** — Serve HTML5 games with live reload support
|
|
52
|
+
- **File API** — Read, write, list, search, and manage project files
|
|
53
|
+
- **FFmpeg Integration** — Audio/video format conversion (if ffmpeg is installed)
|
|
54
|
+
- **AI Agent Support** — Endpoints for the SorcGCS AI code agent
|
|
55
|
+
- **Checkpoint System** — Create and restore file snapshots
|
|
56
|
+
|
|
57
|
+
## API Endpoints
|
|
58
|
+
|
|
59
|
+
| Method | Path | Description |
|
|
60
|
+
|--------|------|-------------|
|
|
61
|
+
| GET | `/api/status` | Server status and config |
|
|
62
|
+
| GET | `/api/games` | List available games |
|
|
63
|
+
| GET | `/preview/*` | Serve game preview files |
|
|
64
|
+
| GET | `/games/*` | Serve game files |
|
|
65
|
+
| POST | `/api/read-file` | Read a file |
|
|
66
|
+
| POST | `/api/write-file` | Write a file |
|
|
67
|
+
| POST | `/api/list-dir` | List directory contents |
|
|
68
|
+
| POST | `/api/search-files` | Search files by glob pattern |
|
|
69
|
+
| POST | `/api/run-command` | Execute a shell command |
|
|
70
|
+
| POST | `/api/create-dir` | Create a directory |
|
|
71
|
+
| POST | `/api/delete-file` | Delete a file |
|
|
72
|
+
| POST | `/api/rename-file` | Rename/move a file |
|
|
73
|
+
| POST | `/api/ffmpeg-process` | Process media with FFmpeg |
|
|
74
|
+
|
|
75
|
+
## Requirements
|
|
76
|
+
|
|
77
|
+
- Python 3.8+
|
|
78
|
+
- No additional dependencies (uses Python standard library only)
|
|
79
|
+
- FFmpeg (optional, for media processing)
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# sorcgcs-server
|
|
2
|
+
|
|
3
|
+
Local development server for [SorcGCS (Sorceress)](https://sorceress.app) — game preview, file management, and AI code agent support.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Using uv (fastest)
|
|
9
|
+
uvx sorcgcs-server
|
|
10
|
+
|
|
11
|
+
# Using pipx
|
|
12
|
+
pipx run sorcgcs-server
|
|
13
|
+
|
|
14
|
+
# Or install and run
|
|
15
|
+
pip install sorcgcs-server
|
|
16
|
+
sorcgcs-server
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Options
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
sorcgcs-server --port 8080 # Custom port (default: 8080)
|
|
23
|
+
sorcgcs-server --dir ./games # Serve a specific directory
|
|
24
|
+
sorcgcs-server --version # Show version
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## What It Does
|
|
28
|
+
|
|
29
|
+
This is a lightweight local HTTP server (Python stdlib only, no dependencies) that provides:
|
|
30
|
+
|
|
31
|
+
- **Game Preview** — Serve HTML5 games with live reload support
|
|
32
|
+
- **File API** — Read, write, list, search, and manage project files
|
|
33
|
+
- **FFmpeg Integration** — Audio/video format conversion (if ffmpeg is installed)
|
|
34
|
+
- **AI Agent Support** — Endpoints for the SorcGCS AI code agent
|
|
35
|
+
- **Checkpoint System** — Create and restore file snapshots
|
|
36
|
+
|
|
37
|
+
## API Endpoints
|
|
38
|
+
|
|
39
|
+
| Method | Path | Description |
|
|
40
|
+
|--------|------|-------------|
|
|
41
|
+
| GET | `/api/status` | Server status and config |
|
|
42
|
+
| GET | `/api/games` | List available games |
|
|
43
|
+
| GET | `/preview/*` | Serve game preview files |
|
|
44
|
+
| GET | `/games/*` | Serve game files |
|
|
45
|
+
| POST | `/api/read-file` | Read a file |
|
|
46
|
+
| POST | `/api/write-file` | Write a file |
|
|
47
|
+
| POST | `/api/list-dir` | List directory contents |
|
|
48
|
+
| POST | `/api/search-files` | Search files by glob pattern |
|
|
49
|
+
| POST | `/api/run-command` | Execute a shell command |
|
|
50
|
+
| POST | `/api/create-dir` | Create a directory |
|
|
51
|
+
| POST | `/api/delete-file` | Delete a file |
|
|
52
|
+
| POST | `/api/rename-file` | Rename/move a file |
|
|
53
|
+
| POST | `/api/ffmpeg-process` | Process media with FFmpeg |
|
|
54
|
+
|
|
55
|
+
## Requirements
|
|
56
|
+
|
|
57
|
+
- Python 3.8+
|
|
58
|
+
- No additional dependencies (uses Python standard library only)
|
|
59
|
+
- FFmpeg (optional, for media processing)
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sorcgcs-server"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local development server for SorcGCS — game preview, file management, and AI agent support"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "SorcGCS" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["gamedev", "local-server", "game-preview", "sorcgcs"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: Games/Entertainment",
|
|
23
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://sorceress.app"
|
|
28
|
+
Repository = "https://github.com/SorcGCS/sorcgcs-server"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
sorcgcs-server = "sorcgcs_server.cli:main"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""CLI entry point for sorcgcs-server."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
prog="sorcgcs-server",
|
|
12
|
+
description="SorcGCS Local Server — game preview, file management, and AI agent support",
|
|
13
|
+
)
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
"--port", type=int, default=8080, help="Port to listen on (default: 8080)"
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--host",
|
|
19
|
+
type=str,
|
|
20
|
+
default="0.0.0.0",
|
|
21
|
+
help="Host to bind to (default: 0.0.0.0)",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--dir",
|
|
25
|
+
type=str,
|
|
26
|
+
default=None,
|
|
27
|
+
help="Games/project directory to serve (default: current directory)",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--version", action="version", version=f"sorcgcs-server {__version__}"
|
|
31
|
+
)
|
|
32
|
+
args = parser.parse_args()
|
|
33
|
+
|
|
34
|
+
from .server import run_server
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
run_server(port=args.port, host=args.host, games_dir=args.dir)
|
|
38
|
+
except KeyboardInterrupt:
|
|
39
|
+
print("\n Server stopped.")
|
|
40
|
+
sys.exit(0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
main()
|
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SorcGCS Local Server
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- Static file serving for game preview (with HTML error injection)
|
|
6
|
+
- REST API for file read/write/list/search/delete/rename
|
|
7
|
+
- Game directory browsing and management
|
|
8
|
+
- Script proxy for cross-origin JS loading
|
|
9
|
+
- FFmpeg media processing (if ffmpeg is in PATH)
|
|
10
|
+
- Checkpoint create/restore/list
|
|
11
|
+
- Command execution for AI agent integration
|
|
12
|
+
|
|
13
|
+
Source: https://github.com/SorcGCS/sorcgcs-server
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import http.server
|
|
17
|
+
import socketserver
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
import glob
|
|
22
|
+
import mimetypes
|
|
23
|
+
import re
|
|
24
|
+
import base64
|
|
25
|
+
import threading
|
|
26
|
+
import uuid
|
|
27
|
+
import time
|
|
28
|
+
from urllib.parse import quote, unquote, parse_qs, urlparse
|
|
29
|
+
import urllib.request
|
|
30
|
+
|
|
31
|
+
CFG = {"games": os.getcwd(), "preview": None}
|
|
32
|
+
FFMPEG_JOBS = {}
|
|
33
|
+
|
|
34
|
+
ERROR_CAPTURE_SCRIPT = r'''<script>(function(){var cs=function(s){return s?s.replace(/https?:\/\/[^/]+\/api\/script-proxy\?url=[^:]+%2F([^:%]+\.js)(:\d+)?(:\d+)?/g,"$1$2$3").replace(/https?:\/\/[^/]+\/api\/script-proxy\?url=[^\s]+%2F([^%\s]+)/g,"$1"):s};var cf=function(s){if(!s)return"";if(s.indexOf("/api/script-proxy")>-1){var m=s.match(/%2F([^%]+\.js)/);if(m)return m[1]}return s.split("/").pop().split("?")[0]};var _p=function(d){try{window.parent.postMessage(d,"*")}catch(e){}};var lastE=null;window.addEventListener("error",function(e){lastE=e;var msg=e.error&&e.error.stack?cs(e.error.stack):e.error&&e.error.message?e.error.message:e.message||"Script error";var src=e.filename?cf(e.filename):"";_p({type:"error",level:"error",message:msg,source:src,line:e.lineno||0})},true);window.onerror=function(m,s,l,c,e){if(lastE&&lastE.message===m){lastE=null;return false}var msg=e&&e.stack?cs(e.stack):m;_p({type:"error",level:"error",message:msg,source:cf(s),line:l||0});return false};window.onunhandledrejection=function(e){var msg="Promise: "+(e.reason&&e.reason.stack?cs(e.reason.stack):e.reason&&e.reason.message?e.reason.message:String(e.reason||"Unknown"));_p({type:"error",level:"error",message:msg})};["error","warn","log"].forEach(function(lvl){var o=console[lvl];console[lvl]=function(){var a=[];for(var i=0;i<arguments.length;i++){var x=arguments[i];a.push(x instanceof Error?cs(x.stack||x.message):x&&x.stack?cs(x.stack):typeof x==="object"?JSON.stringify(x):String(x))}_p({type:"console",level:lvl,message:a.join(" ")});o.apply(console,arguments)}})})();</script>'''
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
38
|
+
def __init__(self, *args, **kwargs):
|
|
39
|
+
super().__init__(*args, directory=CFG["games"], **kwargs)
|
|
40
|
+
|
|
41
|
+
def end_headers(self):
|
|
42
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
43
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
44
|
+
self.send_header("Access-Control-Allow-Headers", "*")
|
|
45
|
+
super().end_headers()
|
|
46
|
+
|
|
47
|
+
def do_OPTIONS(self):
|
|
48
|
+
self.send_response(200)
|
|
49
|
+
self.end_headers()
|
|
50
|
+
|
|
51
|
+
def jres(self, d, c=200):
|
|
52
|
+
self.send_response(c)
|
|
53
|
+
self.send_header("Content-Type", "application/json")
|
|
54
|
+
self.end_headers()
|
|
55
|
+
self.wfile.write(json.dumps(d).encode())
|
|
56
|
+
|
|
57
|
+
def rbody(self):
|
|
58
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
59
|
+
return json.loads(self.rfile.read(length)) if length > 0 else {}
|
|
60
|
+
|
|
61
|
+
def rpath(self, p, pp):
|
|
62
|
+
return p if os.path.isabs(p) else os.path.normpath(os.path.join(pp or CFG["games"], p))
|
|
63
|
+
|
|
64
|
+
# ── FFmpeg processing ────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def handle_ffmpeg_process(self):
|
|
67
|
+
import tempfile
|
|
68
|
+
import shutil
|
|
69
|
+
|
|
70
|
+
ffmpeg_path = shutil.which("ffmpeg")
|
|
71
|
+
if not ffmpeg_path:
|
|
72
|
+
self.jres({"error": "FFmpeg not found in PATH"}, 500)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
content_type = self.headers.get("Content-Type", "")
|
|
76
|
+
if "multipart/form-data" not in content_type:
|
|
77
|
+
self.jres({"error": "Expected multipart/form-data"}, 400)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
boundary = None
|
|
81
|
+
for part in content_type.split(";"):
|
|
82
|
+
part = part.strip()
|
|
83
|
+
if part.startswith("boundary="):
|
|
84
|
+
boundary = part[9:].strip('"')
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
if not boundary:
|
|
88
|
+
self.jres({"error": "No boundary found"}, 400)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
92
|
+
body = self.rfile.read(length)
|
|
93
|
+
|
|
94
|
+
parts = body.split(("--" + boundary).encode())
|
|
95
|
+
file_data = None
|
|
96
|
+
file_name = None
|
|
97
|
+
options = {}
|
|
98
|
+
CRLF = bytes([13, 10])
|
|
99
|
+
CRLF2 = bytes([13, 10, 13, 10])
|
|
100
|
+
|
|
101
|
+
for part in parts:
|
|
102
|
+
if b"Content-Disposition" not in part:
|
|
103
|
+
continue
|
|
104
|
+
header_end = part.find(CRLF2)
|
|
105
|
+
if header_end == -1:
|
|
106
|
+
continue
|
|
107
|
+
header_section = part[:header_end].decode("utf-8", errors="replace")
|
|
108
|
+
content = part[header_end + 4:]
|
|
109
|
+
if content.endswith(b"--" + CRLF):
|
|
110
|
+
content = content[:-6]
|
|
111
|
+
elif content.endswith(CRLF):
|
|
112
|
+
content = content[:-2]
|
|
113
|
+
if 'name="file"' in header_section:
|
|
114
|
+
file_data = content
|
|
115
|
+
crlf_str = chr(13) + chr(10)
|
|
116
|
+
for line in header_section.split(crlf_str):
|
|
117
|
+
if "filename=" in line:
|
|
118
|
+
start = line.find('filename="') + 10
|
|
119
|
+
end = line.find('"', start)
|
|
120
|
+
file_name = line[start:end]
|
|
121
|
+
elif 'name="options"' in header_section:
|
|
122
|
+
try:
|
|
123
|
+
options = json.loads(content.decode("utf-8"))
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
if not file_data or not file_name:
|
|
128
|
+
self.jres({"error": "No file uploaded"}, 400)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=os.path.splitext(file_name)[1]) as tmp_in:
|
|
132
|
+
tmp_in.write(file_data)
|
|
133
|
+
input_path = tmp_in.name
|
|
134
|
+
|
|
135
|
+
job_id = str(uuid.uuid4())
|
|
136
|
+
FFMPEG_JOBS[job_id] = {"status": "processing", "file_name": file_name}
|
|
137
|
+
print(f" [FFmpeg] Job {job_id[:8]} queued for {file_name}")
|
|
138
|
+
|
|
139
|
+
def run_ffmpeg():
|
|
140
|
+
try:
|
|
141
|
+
opt_type = options.get("type", "")
|
|
142
|
+
if opt_type == "format-convert":
|
|
143
|
+
out_ext = "." + options.get("outputFormat", "mp4")
|
|
144
|
+
elif opt_type in ("video-resize", "webm-to-mp4"):
|
|
145
|
+
out_ext = ".mp4"
|
|
146
|
+
elif opt_type == "audio-compress":
|
|
147
|
+
out_ext = ".mp3"
|
|
148
|
+
else:
|
|
149
|
+
out_ext = os.path.splitext(file_name)[1]
|
|
150
|
+
|
|
151
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=out_ext) as tmp_out:
|
|
152
|
+
output_path = tmp_out.name
|
|
153
|
+
|
|
154
|
+
cmd = [ffmpeg_path, "-y", "-i", input_path]
|
|
155
|
+
if opt_type == "audio-compress":
|
|
156
|
+
cmd += ["-b:a", options.get("bitrate", "128k"), "-ac", str(options.get("channels", 2)), output_path]
|
|
157
|
+
elif opt_type == "video-resize":
|
|
158
|
+
cmd += ["-vf", f"scale=-2:{options.get('height', 720)}", "-c:v", "libx264", "-crf", str(options.get("crf", 23)), "-c:a", "aac", output_path]
|
|
159
|
+
elif opt_type == "webm-to-mp4":
|
|
160
|
+
crf = str(options.get("crf", 18))
|
|
161
|
+
fps = str(options.get("fps", 30))
|
|
162
|
+
cmd += ["-r", fps, "-c:v", "libx264", "-crf", crf, "-pix_fmt", "yuv420p", "-an", output_path]
|
|
163
|
+
elif opt_type == "volume-adjust":
|
|
164
|
+
cmd += ["-af", f"volume={options.get('volume', 1.0)}", output_path]
|
|
165
|
+
elif opt_type == "format-convert":
|
|
166
|
+
fmt = options.get("outputFormat", "mp4")
|
|
167
|
+
FORMAT_ARGS = {
|
|
168
|
+
"mp4": ["-c:v", "libx264", "-crf", str(options.get("crf", 23)), "-preset", "medium", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"],
|
|
169
|
+
"webm": ["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-c:a", "libopus", "-b:a", "128k"],
|
|
170
|
+
"mov": ["-c:v", "libx264", "-crf", str(options.get("crf", 23)), "-c:a", "aac", "-b:a", "192k"],
|
|
171
|
+
"mkv": ["-c:v", "libx264", "-crf", str(options.get("crf", 23)), "-c:a", "aac", "-b:a", "192k"],
|
|
172
|
+
"avi": ["-c:v", "libx264", "-crf", str(options.get("crf", 23)), "-c:a", "aac", "-b:a", "192k"],
|
|
173
|
+
"gif": ["-vf", "fps=15,scale=480:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", "-loop", "0"],
|
|
174
|
+
"mp3": ["-vn", "-c:a", "libmp3lame", "-q:a", "2"],
|
|
175
|
+
"wav": ["-vn", "-c:a", "pcm_s16le"],
|
|
176
|
+
"ogg": ["-vn", "-c:a", "libvorbis", "-q:a", "4"],
|
|
177
|
+
"flac": ["-vn", "-c:a", "flac"],
|
|
178
|
+
"aac": ["-vn", "-c:a", "aac", "-b:a", "192k"],
|
|
179
|
+
"m4a": ["-vn", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"],
|
|
180
|
+
}
|
|
181
|
+
cmd += FORMAT_ARGS.get(fmt, []) + [output_path]
|
|
182
|
+
else:
|
|
183
|
+
FFMPEG_JOBS[job_id] = {"status": "error", "error": f"Unknown type: {opt_type}"}
|
|
184
|
+
os.unlink(input_path)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
print(f" [FFmpeg] Job {job_id[:8]} running: {' '.join(cmd)}")
|
|
188
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
189
|
+
|
|
190
|
+
if result.returncode != 0:
|
|
191
|
+
print(f" [FFmpeg] Job {job_id[:8]} error: {result.stderr[:200]}")
|
|
192
|
+
FFMPEG_JOBS[job_id] = {"status": "error", "error": result.stderr[:200]}
|
|
193
|
+
os.unlink(input_path)
|
|
194
|
+
if os.path.exists(output_path):
|
|
195
|
+
os.unlink(output_path)
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
output_size = os.path.getsize(output_path)
|
|
199
|
+
ext = os.path.splitext(file_name)[1].lower()
|
|
200
|
+
ctype_map = {
|
|
201
|
+
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
|
|
202
|
+
".flac": "audio/flac", ".m4a": "audio/mp4", ".aac": "audio/aac",
|
|
203
|
+
".mp4": "video/mp4", ".m4v": "video/mp4", ".webm": "video/webm",
|
|
204
|
+
".mov": "video/quicktime", ".mkv": "video/x-matroska",
|
|
205
|
+
".avi": "video/x-msvideo", ".gif": "image/gif",
|
|
206
|
+
}
|
|
207
|
+
if opt_type == "format-convert":
|
|
208
|
+
ctype = ctype_map.get("." + options.get("outputFormat", "mp4"), "application/octet-stream")
|
|
209
|
+
elif opt_type in ("video-resize", "webm-to-mp4"):
|
|
210
|
+
ctype = "video/mp4"
|
|
211
|
+
elif opt_type == "audio-compress":
|
|
212
|
+
ctype = "audio/mpeg"
|
|
213
|
+
else:
|
|
214
|
+
ctype = ctype_map.get(ext, "application/octet-stream")
|
|
215
|
+
|
|
216
|
+
FFMPEG_JOBS[job_id] = {
|
|
217
|
+
"status": "done", "output_path": output_path,
|
|
218
|
+
"output_size": output_size, "content_type": ctype,
|
|
219
|
+
"file_name": file_name,
|
|
220
|
+
}
|
|
221
|
+
print(f" [FFmpeg] Job {job_id[:8]} done! {output_size} bytes")
|
|
222
|
+
os.unlink(input_path)
|
|
223
|
+
|
|
224
|
+
def cleanup():
|
|
225
|
+
time.sleep(600)
|
|
226
|
+
job = FFMPEG_JOBS.pop(job_id, None)
|
|
227
|
+
if job and job.get("output_path") and os.path.exists(job["output_path"]):
|
|
228
|
+
os.unlink(job["output_path"])
|
|
229
|
+
|
|
230
|
+
threading.Thread(target=cleanup, daemon=True).start()
|
|
231
|
+
|
|
232
|
+
except Exception as e:
|
|
233
|
+
print(f" [FFmpeg] Job {job_id[:8]} exception: {e}")
|
|
234
|
+
FFMPEG_JOBS[job_id] = {"status": "error", "error": str(e)}
|
|
235
|
+
if os.path.exists(input_path):
|
|
236
|
+
os.unlink(input_path)
|
|
237
|
+
|
|
238
|
+
threading.Thread(target=run_ffmpeg, daemon=True).start()
|
|
239
|
+
self.jres({"jobId": job_id, "status": "processing"})
|
|
240
|
+
|
|
241
|
+
def handle_ffmpeg_status(self, job_id):
|
|
242
|
+
job = FFMPEG_JOBS.get(job_id)
|
|
243
|
+
if not job:
|
|
244
|
+
self.jres({"error": "Job not found"}, 404)
|
|
245
|
+
return
|
|
246
|
+
self.jres({
|
|
247
|
+
"jobId": job_id, "status": job["status"],
|
|
248
|
+
"error": job.get("error"), "outputSize": job.get("output_size"),
|
|
249
|
+
"fileName": job.get("file_name"),
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
def handle_ffmpeg_download(self, job_id):
|
|
253
|
+
job = FFMPEG_JOBS.get(job_id)
|
|
254
|
+
if not job:
|
|
255
|
+
self.jres({"error": "Job not found"}, 404)
|
|
256
|
+
return
|
|
257
|
+
if job["status"] != "done":
|
|
258
|
+
self.jres({"error": "Not ready", "status": job["status"]}, 400)
|
|
259
|
+
return
|
|
260
|
+
output_path = job.get("output_path", "")
|
|
261
|
+
if not output_path or not os.path.exists(output_path):
|
|
262
|
+
self.jres({"error": "Output file missing"}, 404)
|
|
263
|
+
return
|
|
264
|
+
try:
|
|
265
|
+
with open(output_path, "rb") as f:
|
|
266
|
+
data = f.read()
|
|
267
|
+
self.send_response(200)
|
|
268
|
+
self.send_header("Content-Type", job.get("content_type", "application/octet-stream"))
|
|
269
|
+
self.send_header("Content-Length", str(len(data)))
|
|
270
|
+
self.end_headers()
|
|
271
|
+
self.wfile.write(data)
|
|
272
|
+
print(f" [FFmpeg] Job {job_id[:8]} downloaded ({len(data)} bytes)")
|
|
273
|
+
FFMPEG_JOBS.pop(job_id, None)
|
|
274
|
+
os.unlink(output_path)
|
|
275
|
+
except Exception as e:
|
|
276
|
+
print(f" [FFmpeg] Download error: {e}")
|
|
277
|
+
|
|
278
|
+
# ── POST endpoints ───────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
def do_POST(self):
|
|
281
|
+
try:
|
|
282
|
+
if self.path == "/api/ffmpeg-process":
|
|
283
|
+
self.handle_ffmpeg_process()
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
b = self.rbody()
|
|
287
|
+
pp = b.get("projectPath", CFG["games"])
|
|
288
|
+
|
|
289
|
+
if self.path == "/api/read-file":
|
|
290
|
+
fp = self.rpath(b.get("path", ""), pp)
|
|
291
|
+
if os.path.isfile(fp):
|
|
292
|
+
if b.get("binary"):
|
|
293
|
+
self.jres({"content": base64.b64encode(open(fp, "rb").read()).decode("ascii"), "binary": True})
|
|
294
|
+
else:
|
|
295
|
+
self.jres({"content": open(fp, "r", encoding="utf-8", errors="replace").read()})
|
|
296
|
+
else:
|
|
297
|
+
self.jres({"error": "File not found"}, 404)
|
|
298
|
+
|
|
299
|
+
elif self.path == "/api/write-file":
|
|
300
|
+
fp = self.rpath(b.get("path", ""), pp)
|
|
301
|
+
pd = os.path.dirname(fp)
|
|
302
|
+
if pd:
|
|
303
|
+
os.makedirs(pd, exist_ok=True)
|
|
304
|
+
open(fp, "w", encoding="utf-8").write(b.get("content", ""))
|
|
305
|
+
self.jres({"success": True})
|
|
306
|
+
|
|
307
|
+
elif self.path == "/api/list-dir":
|
|
308
|
+
dp = self.rpath(b.get("path", "."), pp)
|
|
309
|
+
if os.path.isdir(dp):
|
|
310
|
+
entries = []
|
|
311
|
+
for n in os.listdir(dp):
|
|
312
|
+
full_path = os.path.join(dp, n)
|
|
313
|
+
entries.append({
|
|
314
|
+
"name": n,
|
|
315
|
+
"path": os.path.relpath(full_path, pp).replace("\\", "/"),
|
|
316
|
+
"isDirectory": os.path.isdir(full_path),
|
|
317
|
+
})
|
|
318
|
+
self.jres({"entries": entries})
|
|
319
|
+
else:
|
|
320
|
+
self.jres({"error": "Not found"}, 404)
|
|
321
|
+
|
|
322
|
+
elif self.path == "/api/search-files":
|
|
323
|
+
pattern = b.get("pattern", "**/*")
|
|
324
|
+
files = [
|
|
325
|
+
os.path.relpath(f, pp).replace("\\", "/")
|
|
326
|
+
for f in glob.glob(os.path.join(pp, pattern), recursive=True)
|
|
327
|
+
if os.path.isfile(f)
|
|
328
|
+
][:500]
|
|
329
|
+
self.jres({"files": files})
|
|
330
|
+
|
|
331
|
+
elif self.path == "/api/run-command":
|
|
332
|
+
r = subprocess.run(
|
|
333
|
+
b.get("command", ""), shell=True,
|
|
334
|
+
cwd=b.get("cwd", pp),
|
|
335
|
+
capture_output=True, text=True, timeout=60,
|
|
336
|
+
)
|
|
337
|
+
self.jres({"exitCode": r.returncode, "stdout": r.stdout, "stderr": r.stderr})
|
|
338
|
+
|
|
339
|
+
elif self.path == "/api/create-dir":
|
|
340
|
+
os.makedirs(self.rpath(b.get("path", ""), pp), exist_ok=True)
|
|
341
|
+
self.jres({"success": True})
|
|
342
|
+
|
|
343
|
+
elif self.path == "/api/delete-file":
|
|
344
|
+
fp = self.rpath(b.get("path", ""), pp)
|
|
345
|
+
if os.path.isfile(fp):
|
|
346
|
+
os.remove(fp)
|
|
347
|
+
self.jres({"success": True})
|
|
348
|
+
else:
|
|
349
|
+
self.jres({"error": "Not found"}, 404)
|
|
350
|
+
|
|
351
|
+
elif self.path == "/api/rename-file":
|
|
352
|
+
op = self.rpath(b.get("oldPath", ""), pp)
|
|
353
|
+
np_path = self.rpath(b.get("newPath", ""), pp)
|
|
354
|
+
if os.path.exists(op):
|
|
355
|
+
os.rename(op, np_path)
|
|
356
|
+
self.jres({"success": True, "newPath": np_path})
|
|
357
|
+
else:
|
|
358
|
+
self.jres({"error": "Not found"}, 404)
|
|
359
|
+
|
|
360
|
+
elif self.path == "/api/write-file-binary":
|
|
361
|
+
fp = self.rpath(b.get("path", ""), pp)
|
|
362
|
+
pd = os.path.dirname(fp)
|
|
363
|
+
if pd:
|
|
364
|
+
os.makedirs(pd, exist_ok=True)
|
|
365
|
+
with open(fp, "wb") as f:
|
|
366
|
+
f.write(base64.b64decode(b.get("content", "")))
|
|
367
|
+
self.jres({"success": True})
|
|
368
|
+
|
|
369
|
+
elif self.path == "/api/set-preview-path":
|
|
370
|
+
CFG["preview"] = b.get("path", CFG["games"])
|
|
371
|
+
self.jres({"success": True, "previewPath": CFG["preview"]})
|
|
372
|
+
|
|
373
|
+
elif self.path == "/api/set-games-dir":
|
|
374
|
+
CFG["games"] = b.get("path", CFG["games"])
|
|
375
|
+
self.jres({"success": True, "gamesDir": CFG["games"]})
|
|
376
|
+
|
|
377
|
+
elif self.path == "/api/checkpoint/create":
|
|
378
|
+
cid = b.get("checkpointId", "")
|
|
379
|
+
cp = b.get("checkpoint", {})
|
|
380
|
+
cpdir = os.path.join(pp, ".sorceress", "checkpoints", cid)
|
|
381
|
+
os.makedirs(cpdir, exist_ok=True)
|
|
382
|
+
with open(os.path.join(cpdir, "checkpoint.json"), "w", encoding="utf-8") as f:
|
|
383
|
+
json.dump(cp, f)
|
|
384
|
+
self.jres({"success": True, "checkpointId": cid})
|
|
385
|
+
|
|
386
|
+
elif self.path == "/api/checkpoint/restore-file":
|
|
387
|
+
f_data = b.get("file", {})
|
|
388
|
+
fp = self.rpath(f_data.get("path", ""), pp)
|
|
389
|
+
if f_data.get("action") == "created":
|
|
390
|
+
if os.path.isfile(fp):
|
|
391
|
+
os.remove(fp)
|
|
392
|
+
elif f_data.get("action") in ["modified", "deleted"]:
|
|
393
|
+
if f_data.get("previousContent") is not None:
|
|
394
|
+
pd = os.path.dirname(fp)
|
|
395
|
+
if pd:
|
|
396
|
+
os.makedirs(pd, exist_ok=True)
|
|
397
|
+
with open(fp, "w", encoding="utf-8") as wf:
|
|
398
|
+
wf.write(f_data.get("previousContent", ""))
|
|
399
|
+
self.jres({"success": True})
|
|
400
|
+
|
|
401
|
+
elif self.path == "/api/checkpoint/list":
|
|
402
|
+
cpbase = os.path.join(pp, ".sorceress", "checkpoints")
|
|
403
|
+
cps = []
|
|
404
|
+
if os.path.exists(cpbase):
|
|
405
|
+
for d in os.listdir(cpbase):
|
|
406
|
+
cpf = os.path.join(cpbase, d, "checkpoint.json")
|
|
407
|
+
if os.path.exists(cpf):
|
|
408
|
+
with open(cpf, "r", encoding="utf-8") as rf:
|
|
409
|
+
cps.append(json.load(rf))
|
|
410
|
+
cps.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
|
|
411
|
+
self.jres({"checkpoints": cps[:50]})
|
|
412
|
+
|
|
413
|
+
else:
|
|
414
|
+
self.jres({"error": "Unknown endpoint"}, 404)
|
|
415
|
+
|
|
416
|
+
except Exception as e:
|
|
417
|
+
self.jres({"error": str(e)}, 500)
|
|
418
|
+
|
|
419
|
+
# ── File serving ─────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
def serve_file(self, fp):
|
|
422
|
+
if os.path.isdir(fp):
|
|
423
|
+
fp = os.path.join(fp, "index.html")
|
|
424
|
+
if not os.path.isfile(fp):
|
|
425
|
+
self.send_error(404)
|
|
426
|
+
return
|
|
427
|
+
ct = mimetypes.guess_type(fp)[0] or "application/octet-stream"
|
|
428
|
+
self.send_response(200)
|
|
429
|
+
self.send_header("Content-Type", ct)
|
|
430
|
+
self.end_headers()
|
|
431
|
+
with open(fp, "rb") as f:
|
|
432
|
+
content = f.read()
|
|
433
|
+
if ct == "text/html":
|
|
434
|
+
html = content.decode("utf-8", errors="replace")
|
|
435
|
+
if "<head>" in html:
|
|
436
|
+
html = html.replace("<head>", "<head>" + ERROR_CAPTURE_SCRIPT)
|
|
437
|
+
elif "<html" in html:
|
|
438
|
+
idx = html.index("<html")
|
|
439
|
+
end_idx = idx + html[idx:].index(">") + 1
|
|
440
|
+
html = html[:end_idx] + ERROR_CAPTURE_SCRIPT + html[end_idx:]
|
|
441
|
+
else:
|
|
442
|
+
html = ERROR_CAPTURE_SCRIPT + html
|
|
443
|
+
content = html.encode("utf-8")
|
|
444
|
+
self.wfile.write(content)
|
|
445
|
+
|
|
446
|
+
# ── GET endpoints ────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
def do_GET(self):
|
|
449
|
+
import shutil as _shutil
|
|
450
|
+
|
|
451
|
+
if self.path == "/api/status":
|
|
452
|
+
ffmpeg_path = _shutil.which("ffmpeg")
|
|
453
|
+
self.jres({
|
|
454
|
+
"connected": True,
|
|
455
|
+
"gamesDir": CFG["games"],
|
|
456
|
+
"previewPath": CFG["preview"],
|
|
457
|
+
"port": CFG.get("port", 8080),
|
|
458
|
+
"agent": True,
|
|
459
|
+
"ffmpeg": ffmpeg_path is not None,
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
elif self.path.startswith("/api/ffmpeg-status/"):
|
|
463
|
+
job_id = self.path.split("/api/ffmpeg-status/")[1].split("?")[0]
|
|
464
|
+
self.handle_ffmpeg_status(job_id)
|
|
465
|
+
|
|
466
|
+
elif self.path.startswith("/api/ffmpeg-download/"):
|
|
467
|
+
job_id = self.path.split("/api/ffmpeg-download/")[1].split("?")[0]
|
|
468
|
+
self.handle_ffmpeg_download(job_id)
|
|
469
|
+
|
|
470
|
+
elif self.path == "/api/browse-folder":
|
|
471
|
+
try:
|
|
472
|
+
import tkinter as tk
|
|
473
|
+
from tkinter import filedialog
|
|
474
|
+
root = tk.Tk()
|
|
475
|
+
root.withdraw()
|
|
476
|
+
root.attributes("-topmost", True)
|
|
477
|
+
folder = filedialog.askdirectory(title="Select Project Folder", initialdir=CFG["games"])
|
|
478
|
+
root.destroy()
|
|
479
|
+
if folder:
|
|
480
|
+
self.jres({"success": True, "path": folder.replace("/", os.sep)})
|
|
481
|
+
else:
|
|
482
|
+
self.jres({"success": False, "cancelled": True})
|
|
483
|
+
except Exception as e:
|
|
484
|
+
self.jres({"success": False, "error": str(e)})
|
|
485
|
+
|
|
486
|
+
elif self.path.startswith("/api/script-proxy"):
|
|
487
|
+
try:
|
|
488
|
+
qs = parse_qs(urlparse(self.path).query)
|
|
489
|
+
url = unquote(qs.get("url", [""])[0])
|
|
490
|
+
if not url or not url.startswith(("http://", "https://")):
|
|
491
|
+
self.send_error(400, "Invalid URL")
|
|
492
|
+
return
|
|
493
|
+
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
|
494
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
495
|
+
script_content = resp.read()
|
|
496
|
+
self.send_response(200)
|
|
497
|
+
self.send_header("Content-Type", "application/javascript")
|
|
498
|
+
self.send_header("Cache-Control", "public, max-age=300")
|
|
499
|
+
self.end_headers()
|
|
500
|
+
self.wfile.write(script_content)
|
|
501
|
+
except Exception as e:
|
|
502
|
+
self.send_error(502, "Failed to fetch script: " + str(e))
|
|
503
|
+
|
|
504
|
+
elif self.path == "/api/games":
|
|
505
|
+
games = []
|
|
506
|
+
if os.path.exists(CFG["games"]):
|
|
507
|
+
for n in os.listdir(CFG["games"]):
|
|
508
|
+
game_path = os.path.join(CFG["games"], n)
|
|
509
|
+
if os.path.isdir(game_path) and os.path.exists(os.path.join(game_path, "index.html")):
|
|
510
|
+
games.append({"name": n, "path": n})
|
|
511
|
+
self.jres({"games": games})
|
|
512
|
+
|
|
513
|
+
elif self.path.startswith("/api/checkpoint/get"):
|
|
514
|
+
qs = parse_qs(urlparse(self.path).query)
|
|
515
|
+
cid = qs.get("id", [""])[0]
|
|
516
|
+
cppath = qs.get("projectPath", [CFG["games"]])[0]
|
|
517
|
+
cpf = os.path.join(cppath, ".sorceress", "checkpoints", cid, "checkpoint.json")
|
|
518
|
+
if os.path.exists(cpf):
|
|
519
|
+
with open(cpf, "r", encoding="utf-8") as rf:
|
|
520
|
+
self.jres(json.load(rf))
|
|
521
|
+
else:
|
|
522
|
+
self.jres({"error": "Not found"}, 404)
|
|
523
|
+
|
|
524
|
+
elif self.path.startswith("/preview"):
|
|
525
|
+
rp = self.path[8:].lstrip("/").split("?")[0] if len(self.path) > 8 else ""
|
|
526
|
+
rp = rp.replace("/", os.sep)
|
|
527
|
+
fp = os.path.normpath(os.path.join(CFG["preview"], rp)) if rp else CFG["preview"]
|
|
528
|
+
self.serve_file(fp)
|
|
529
|
+
|
|
530
|
+
elif self.path == "/games" or self.path == "/games/":
|
|
531
|
+
self.send_response(200)
|
|
532
|
+
self.send_header("Content-Type", "text/html")
|
|
533
|
+
self.end_headers()
|
|
534
|
+
html = '<html><body style="background:#111;color:#fff;font-family:sans-serif;"><h1>Games</h1><ul>'
|
|
535
|
+
if os.path.exists(CFG["games"]):
|
|
536
|
+
for name in sorted(os.listdir(CFG["games"])):
|
|
537
|
+
game_path = os.path.join(CFG["games"], name)
|
|
538
|
+
if os.path.isdir(game_path) and os.path.exists(os.path.join(game_path, "index.html")):
|
|
539
|
+
html += f'<li><a href="/games/{name}/" style="color:#a855f7">{name}</a></li>'
|
|
540
|
+
html += "</ul></body></html>"
|
|
541
|
+
self.wfile.write(html.encode())
|
|
542
|
+
|
|
543
|
+
elif self.path.startswith("/games/"):
|
|
544
|
+
path = self.path[7:].split("?")[0]
|
|
545
|
+
path = path.replace("/", os.sep)
|
|
546
|
+
file_path = os.path.normpath(os.path.join(CFG["games"], path))
|
|
547
|
+
if os.path.isdir(file_path):
|
|
548
|
+
index_path = os.path.join(file_path, "index.html")
|
|
549
|
+
if os.path.exists(index_path):
|
|
550
|
+
file_path = index_path
|
|
551
|
+
self.serve_file(file_path)
|
|
552
|
+
|
|
553
|
+
else:
|
|
554
|
+
super().do_GET()
|
|
555
|
+
|
|
556
|
+
def log_message(self, format, *args):
|
|
557
|
+
pass # Quiet by default; startup banner is enough
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
class ThreadedServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
|
|
561
|
+
allow_reuse_address = True
|
|
562
|
+
daemon_threads = True
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def run_server(port=8080, host="0.0.0.0", games_dir=None):
|
|
566
|
+
if games_dir:
|
|
567
|
+
CFG["games"] = os.path.abspath(games_dir)
|
|
568
|
+
CFG["preview"] = CFG["games"]
|
|
569
|
+
CFG["port"] = port
|
|
570
|
+
|
|
571
|
+
print("")
|
|
572
|
+
print(" SORCGCS LOCAL SERVER")
|
|
573
|
+
print(" ====================")
|
|
574
|
+
print(f" Games: {CFG['games']}")
|
|
575
|
+
print(f" URL: http://localhost:{port}")
|
|
576
|
+
print("")
|
|
577
|
+
print(" Tip: Games folder can be changed from Settings or the Games page.")
|
|
578
|
+
print("")
|
|
579
|
+
print(" Press Ctrl+C to stop")
|
|
580
|
+
print("")
|
|
581
|
+
|
|
582
|
+
with ThreadedServer((host, port), Handler) as httpd:
|
|
583
|
+
httpd.serve_forever()
|