pixelweaver 0.1.0__py3-none-any.whl
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.
- pixelweaver/__init__.py +1 -0
- pixelweaver/__main__.py +4 -0
- pixelweaver/autosave.py +114 -0
- pixelweaver/bridge.py +127 -0
- pixelweaver/cli.py +199 -0
- pixelweaver/config.py +24 -0
- pixelweaver/connections.py +54 -0
- pixelweaver/main.py +271 -0
- pixelweaver/mcp_bridge.py +189 -0
- pixelweaver/mcp_drawing_tools.py +178 -0
- pixelweaver/mcp_export_tools.py +291 -0
- pixelweaver/mcp_frame_tools.py +423 -0
- pixelweaver/mcp_history_tools.py +106 -0
- pixelweaver/mcp_layer_tools.py +64 -0
- pixelweaver/mcp_lock.py +37 -0
- pixelweaver/mcp_project_tools.py +302 -0
- pixelweaver/mcp_read_tools.py +163 -0
- pixelweaver/mcp_registry.py +247 -0
- pixelweaver/mcp_resources.py +312 -0
- pixelweaver/mcp_server.py +234 -0
- pixelweaver/protocol.py +219 -0
- pixelweaver/state.py +267 -0
- pixelweaver/storage.py +293 -0
- pixelweaver/websocket.py +282 -0
- pixelweaver-0.1.0.dist-info/METADATA +14 -0
- pixelweaver-0.1.0.dist-info/RECORD +28 -0
- pixelweaver-0.1.0.dist-info/WHEEL +4 -0
- pixelweaver-0.1.0.dist-info/entry_points.txt +3 -0
pixelweaver/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""PixelWeaver collaboration server."""
|
pixelweaver/__main__.py
ADDED
pixelweaver/autosave.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Auto-save: periodic flush of in-memory state to disk.
|
|
2
|
+
|
|
3
|
+
The auto-saver runs as an asyncio background task. State is saved when:
|
|
4
|
+
- A debounce timer expires after the last state mutation, OR
|
|
5
|
+
- A fixed interval elapses since the last save
|
|
6
|
+
|
|
7
|
+
Whichever fires first triggers a save, preventing both data loss and
|
|
8
|
+
excessive I/O.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from pixelweaver.state import ServerState
|
|
18
|
+
from pixelweaver.storage import save_project
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AutoSaver:
|
|
24
|
+
"""Periodically flushes server state to disk."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
state: ServerState,
|
|
29
|
+
save_dir: str,
|
|
30
|
+
debounce_ms: int = 500,
|
|
31
|
+
interval_s: int = 30,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.state = state
|
|
34
|
+
self.save_dir = save_dir
|
|
35
|
+
self.debounce_ms = debounce_ms
|
|
36
|
+
self.interval_s = interval_s
|
|
37
|
+
self._dirty = False
|
|
38
|
+
self._last_save: float = 0
|
|
39
|
+
self._task: asyncio.Task[None] | None = None
|
|
40
|
+
self._debounce_task: asyncio.Task[None] | None = None
|
|
41
|
+
|
|
42
|
+
def mark_dirty(self) -> None:
|
|
43
|
+
"""Called after any state change to schedule a debounced save."""
|
|
44
|
+
self._dirty = True
|
|
45
|
+
# Reset the debounce timer
|
|
46
|
+
if self._debounce_task is not None and not self._debounce_task.done():
|
|
47
|
+
self._debounce_task.cancel()
|
|
48
|
+
self._debounce_task = asyncio.ensure_future(self._debounce_save())
|
|
49
|
+
|
|
50
|
+
async def _debounce_save(self) -> None:
|
|
51
|
+
"""Wait for the debounce period, then save if still dirty."""
|
|
52
|
+
try:
|
|
53
|
+
await asyncio.sleep(self.debounce_ms / 1000.0)
|
|
54
|
+
if self._dirty:
|
|
55
|
+
await self.save_now()
|
|
56
|
+
except asyncio.CancelledError:
|
|
57
|
+
pass # Debounce was reset by a newer mutation
|
|
58
|
+
|
|
59
|
+
async def start(self) -> None:
|
|
60
|
+
"""Start the auto-save background task (periodic interval saves)."""
|
|
61
|
+
self._task = asyncio.ensure_future(self._periodic_loop())
|
|
62
|
+
logger.info(
|
|
63
|
+
"Auto-saver started (debounce=%dms, interval=%ds, dir=%s)",
|
|
64
|
+
self.debounce_ms,
|
|
65
|
+
self.interval_s,
|
|
66
|
+
self.save_dir,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def stop(self) -> None:
|
|
70
|
+
"""Stop the background task gracefully, saving any pending changes."""
|
|
71
|
+
if self._task is not None:
|
|
72
|
+
self._task.cancel()
|
|
73
|
+
try:
|
|
74
|
+
await self._task
|
|
75
|
+
except asyncio.CancelledError:
|
|
76
|
+
pass
|
|
77
|
+
if self._debounce_task is not None:
|
|
78
|
+
self._debounce_task.cancel()
|
|
79
|
+
# Final save if dirty
|
|
80
|
+
if self._dirty:
|
|
81
|
+
await self.save_now()
|
|
82
|
+
|
|
83
|
+
async def _periodic_loop(self) -> None:
|
|
84
|
+
"""Run periodic saves on a fixed interval."""
|
|
85
|
+
try:
|
|
86
|
+
while True:
|
|
87
|
+
await asyncio.sleep(self.interval_s)
|
|
88
|
+
if self._dirty:
|
|
89
|
+
await self.save_now()
|
|
90
|
+
except asyncio.CancelledError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
async def save_now(self) -> None:
|
|
94
|
+
"""Flush all project state to disk immediately."""
|
|
95
|
+
if not self.state.projects:
|
|
96
|
+
self._dirty = False
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
# Track per-loop failures so a single failed project doesn't lose
|
|
100
|
+
# the dirty marker for the rest. _dirty is a single flag here, so
|
|
101
|
+
# if anything failed we leave it set and the next debounce/interval
|
|
102
|
+
# tick will retry the whole batch.
|
|
103
|
+
any_failed = False
|
|
104
|
+
for project in self.state.projects.values():
|
|
105
|
+
try:
|
|
106
|
+
await asyncio.to_thread(save_project, project, self.save_dir)
|
|
107
|
+
except Exception:
|
|
108
|
+
any_failed = True
|
|
109
|
+
logger.error("Failed to save project %s", project.name, exc_info=True)
|
|
110
|
+
|
|
111
|
+
if not any_failed:
|
|
112
|
+
self._dirty = False
|
|
113
|
+
self._last_save = time.monotonic()
|
|
114
|
+
logger.debug("Auto-saved %d project(s) to %s", len(self.state.projects), self.save_dir)
|
pixelweaver/bridge.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Desktop file I/O bridge for pywebview.
|
|
2
|
+
|
|
3
|
+
Provides native file dialogs and filesystem access callable from JavaScript
|
|
4
|
+
via pywebview's js_api mechanism. All binary data is transferred as
|
|
5
|
+
base64-encoded strings since the JS bridge serializes return values as JSON.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DesktopBridge:
|
|
18
|
+
"""File I/O bridge exposed to the frontend via pywebview js_api.
|
|
19
|
+
|
|
20
|
+
Methods on this class become callable from JavaScript as:
|
|
21
|
+
window.pywebview.api.method_name(args)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def save_file(
|
|
25
|
+
self,
|
|
26
|
+
data_b64: str,
|
|
27
|
+
default_name: str,
|
|
28
|
+
filter_name: str,
|
|
29
|
+
extensions: list[str],
|
|
30
|
+
) -> str | None:
|
|
31
|
+
"""Show a native Save dialog and write the file.
|
|
32
|
+
|
|
33
|
+
Returns the saved file path, or None if the user cancelled.
|
|
34
|
+
"""
|
|
35
|
+
import webview
|
|
36
|
+
|
|
37
|
+
window = webview.windows[0]
|
|
38
|
+
file_types = (f"{filter_name} ({' '.join('*.' + e for e in extensions)})",)
|
|
39
|
+
result = window.create_file_dialog(
|
|
40
|
+
webview.SAVE_DIALOG,
|
|
41
|
+
save_filename=default_name,
|
|
42
|
+
file_types=file_types,
|
|
43
|
+
)
|
|
44
|
+
if not result:
|
|
45
|
+
return None
|
|
46
|
+
path = result if isinstance(result, str) else result[0]
|
|
47
|
+
try:
|
|
48
|
+
data = base64.b64decode(data_b64)
|
|
49
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
Path(path).write_bytes(data)
|
|
51
|
+
return path
|
|
52
|
+
except Exception:
|
|
53
|
+
logger.exception("Failed to write file %s", path)
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def open_file(
|
|
57
|
+
self,
|
|
58
|
+
filter_name: str,
|
|
59
|
+
extensions: list[str],
|
|
60
|
+
) -> dict | None:
|
|
61
|
+
"""Show a native Open dialog and read the file.
|
|
62
|
+
|
|
63
|
+
Returns {"path": str, "data": base64_string} or None if cancelled.
|
|
64
|
+
"""
|
|
65
|
+
import webview
|
|
66
|
+
|
|
67
|
+
window = webview.windows[0]
|
|
68
|
+
file_types = (f"{filter_name} ({' '.join('*.' + e for e in extensions)})",)
|
|
69
|
+
result = window.create_file_dialog(
|
|
70
|
+
webview.OPEN_DIALOG,
|
|
71
|
+
file_types=file_types,
|
|
72
|
+
)
|
|
73
|
+
if not result:
|
|
74
|
+
return None
|
|
75
|
+
path = result[0] if isinstance(result, (tuple, list)) else result
|
|
76
|
+
try:
|
|
77
|
+
data = Path(path).read_bytes()
|
|
78
|
+
return {
|
|
79
|
+
"path": str(path),
|
|
80
|
+
"data": base64.b64encode(data).decode("ascii"),
|
|
81
|
+
}
|
|
82
|
+
except Exception:
|
|
83
|
+
logger.exception("Failed to read file %s", path)
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def write_file(self, path: str, data_b64: str) -> bool:
|
|
87
|
+
"""Write binary data to a specific path (no dialog)."""
|
|
88
|
+
try:
|
|
89
|
+
data = base64.b64decode(data_b64)
|
|
90
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
Path(path).write_bytes(data)
|
|
92
|
+
return True
|
|
93
|
+
except Exception:
|
|
94
|
+
logger.exception("Failed to write file %s", path)
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
def pick_directory(self) -> str | None:
|
|
98
|
+
"""Show a native directory picker. Returns path or None if cancelled."""
|
|
99
|
+
import webview
|
|
100
|
+
|
|
101
|
+
window = webview.windows[0]
|
|
102
|
+
result = window.create_file_dialog(webview.FOLDER_DIALOG)
|
|
103
|
+
if not result:
|
|
104
|
+
return None
|
|
105
|
+
return result[0] if isinstance(result, (tuple, list)) else str(result)
|
|
106
|
+
|
|
107
|
+
def write_files_to_directory(
|
|
108
|
+
self,
|
|
109
|
+
dir_path: str,
|
|
110
|
+
files: list[dict],
|
|
111
|
+
) -> bool:
|
|
112
|
+
"""Write multiple files to a directory.
|
|
113
|
+
|
|
114
|
+
Each entry in files should be {"name": str, "data": base64_string}.
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
base = Path(dir_path)
|
|
118
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
for entry in files:
|
|
120
|
+
file_path = base / entry["name"]
|
|
121
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
data = base64.b64decode(entry["data"])
|
|
123
|
+
file_path.write_bytes(data)
|
|
124
|
+
return True
|
|
125
|
+
except Exception:
|
|
126
|
+
logger.exception("Failed to write files to %s", dir_path)
|
|
127
|
+
return False
|
pixelweaver/cli.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""PixelWeaver CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import strictcli
|
|
10
|
+
from strictcli import App
|
|
11
|
+
|
|
12
|
+
_DEFAULT_PID = Path(".pixelweaver.pid")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_version() -> str:
|
|
16
|
+
from importlib.metadata import version
|
|
17
|
+
return version("pixelweaver")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _pkg_version(package_name: str, module_name: str | None = None) -> str:
|
|
21
|
+
"""Get package version, trying module attr first then importlib.metadata."""
|
|
22
|
+
if module_name:
|
|
23
|
+
try:
|
|
24
|
+
mod = __import__(module_name)
|
|
25
|
+
ver = getattr(mod, "__version__", None)
|
|
26
|
+
if ver:
|
|
27
|
+
return ver
|
|
28
|
+
except ImportError:
|
|
29
|
+
pass
|
|
30
|
+
try:
|
|
31
|
+
from importlib.metadata import version
|
|
32
|
+
return version(package_name)
|
|
33
|
+
except Exception:
|
|
34
|
+
return "NOT FOUND"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
app = App(
|
|
38
|
+
name="pixelweaver",
|
|
39
|
+
help="PixelWeaver collaboration server",
|
|
40
|
+
version=_get_version(),
|
|
41
|
+
config=True,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Reusable tag for --data-dir flag (shared by multiple commands)
|
|
45
|
+
_data_dir_tag = strictcli.Tag(name="data", flags=[
|
|
46
|
+
strictcli.Flag(
|
|
47
|
+
name="data-dir",
|
|
48
|
+
type=str,
|
|
49
|
+
help="Projects directory",
|
|
50
|
+
default="./projects",
|
|
51
|
+
),
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command("serve", help="Start the server", tags=[_data_dir_tag])
|
|
56
|
+
@strictcli.flag("host", type=str, help="Bind address", default="127.0.0.1")
|
|
57
|
+
@strictcli.flag("port", type=int, help="Port", default=7779)
|
|
58
|
+
@strictcli.flag("reload", type=bool, help="Auto-reload on file changes")
|
|
59
|
+
def cmd_serve(*, host: str, port: int, data_dir: str, reload: bool, **_kw: object) -> None:
|
|
60
|
+
from pixelweaver.config import set_data_dir
|
|
61
|
+
from wesktop import serve
|
|
62
|
+
set_data_dir(data_dir)
|
|
63
|
+
serve("pixelweaver.main:app", foreground=True, host=host, port=port, pid_path=_DEFAULT_PID, reload=reload, name="PIXELWEAVER")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command(
|
|
67
|
+
"new",
|
|
68
|
+
help="Create a new project on disk",
|
|
69
|
+
args=[strictcli.Arg(name="name", help="Project name")],
|
|
70
|
+
tags=[_data_dir_tag],
|
|
71
|
+
)
|
|
72
|
+
@strictcli.flag("width", type=int, help="Canvas width in pixels")
|
|
73
|
+
@strictcli.flag("height", type=int, help="Canvas height in pixels")
|
|
74
|
+
def cmd_new(name: str, *, width: int, height: int, data_dir: str, **_kw: object) -> None:
|
|
75
|
+
from pixelweaver.state import ServerState
|
|
76
|
+
from pixelweaver.storage import save_project
|
|
77
|
+
state = ServerState()
|
|
78
|
+
project = state.create_project(name, width, height)
|
|
79
|
+
save_project(project, data_dir)
|
|
80
|
+
print(f"Created project '{name}' ({width}x{height}) in {data_dir}/")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command("list", help="List projects on disk", tags=[_data_dir_tag])
|
|
84
|
+
def cmd_list(*, data_dir: str, **_kw: object) -> None:
|
|
85
|
+
from pixelweaver.storage import list_projects
|
|
86
|
+
projects = list_projects(data_dir)
|
|
87
|
+
if not projects:
|
|
88
|
+
print("No projects found.")
|
|
89
|
+
else:
|
|
90
|
+
for name in projects:
|
|
91
|
+
print(f" {name}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command("mcp", help="Start the MCP tool server (stdio)", tags=[_data_dir_tag])
|
|
95
|
+
def cmd_mcp(*, data_dir: str, **_kw: object) -> None:
|
|
96
|
+
from pixelweaver.config import set_data_dir
|
|
97
|
+
from pixelweaver.mcp_server import run_mcp_server
|
|
98
|
+
set_data_dir(data_dir)
|
|
99
|
+
run_mcp_server()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command("dev", help="Development mode with Vite HMR", tags=[_data_dir_tag])
|
|
103
|
+
@strictcli.flag("host", type=str, help="Bind address", default="127.0.0.1")
|
|
104
|
+
@strictcli.flag("port", type=int, help="Port", default=7779)
|
|
105
|
+
@strictcli.flag("vite-port", type=int, help="Vite dev server port", default=5173)
|
|
106
|
+
def cmd_dev(*, host: str, port: int, data_dir: str, vite_port: int, **_kw: object) -> None:
|
|
107
|
+
from pixelweaver.config import set_data_dir
|
|
108
|
+
from wesktop import dev
|
|
109
|
+
set_data_dir(data_dir)
|
|
110
|
+
dev(
|
|
111
|
+
"pixelweaver.main:app",
|
|
112
|
+
vite_command="npm run dev",
|
|
113
|
+
vite_port=vite_port,
|
|
114
|
+
host=host,
|
|
115
|
+
port=port,
|
|
116
|
+
pid_path=_DEFAULT_PID,
|
|
117
|
+
name="PIXELWEAVER",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command("open", help="Launch desktop window", tags=[_data_dir_tag])
|
|
122
|
+
@strictcli.flag("host", type=str, help="Bind address", default="127.0.0.1")
|
|
123
|
+
@strictcli.flag("port", type=int, help="Port", default=7779)
|
|
124
|
+
@strictcli.flag("browser", type=bool, help="Open in browser instead of native window")
|
|
125
|
+
def cmd_open(*, host: str, port: int, data_dir: str, browser: bool, **_kw: object) -> None:
|
|
126
|
+
from pixelweaver.config import set_data_dir
|
|
127
|
+
set_data_dir(data_dir)
|
|
128
|
+
|
|
129
|
+
if browser:
|
|
130
|
+
import threading
|
|
131
|
+
import webbrowser
|
|
132
|
+
from wesktop import serve
|
|
133
|
+
url = serve(
|
|
134
|
+
"pixelweaver.main:app",
|
|
135
|
+
foreground=False,
|
|
136
|
+
host=host,
|
|
137
|
+
port=port,
|
|
138
|
+
pid_path=_DEFAULT_PID,
|
|
139
|
+
name="PIXELWEAVER",
|
|
140
|
+
)
|
|
141
|
+
webbrowser.open(url)
|
|
142
|
+
try:
|
|
143
|
+
threading.Event().wait()
|
|
144
|
+
except KeyboardInterrupt:
|
|
145
|
+
pass
|
|
146
|
+
else:
|
|
147
|
+
from pixelweaver.bridge import DesktopBridge
|
|
148
|
+
from wesktop import run
|
|
149
|
+
run(
|
|
150
|
+
"pixelweaver.main:app",
|
|
151
|
+
title="PixelWeaver",
|
|
152
|
+
width=1280,
|
|
153
|
+
height=800,
|
|
154
|
+
host=host,
|
|
155
|
+
port=port,
|
|
156
|
+
pid_path=_DEFAULT_PID,
|
|
157
|
+
icon=str(Path("assets/icon.png").resolve()),
|
|
158
|
+
js_api=DesktopBridge(),
|
|
159
|
+
name="PIXELWEAVER",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command("diagnose", help="Check runtime environment and dependencies")
|
|
164
|
+
def cmd_diagnose(**_kw: object) -> None:
|
|
165
|
+
rows: list[tuple[str, str]] = []
|
|
166
|
+
rows.append(("Python", f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"))
|
|
167
|
+
rows.append(("pixelweaver", _get_version()))
|
|
168
|
+
|
|
169
|
+
for label, pkg, mod in [
|
|
170
|
+
("wesktop", "wesktop", "wesktop"),
|
|
171
|
+
("pywebview", "pywebview", "webview"),
|
|
172
|
+
("pydantic", "pydantic", "pydantic"),
|
|
173
|
+
("pillow", "pillow", "PIL"),
|
|
174
|
+
("mcp", "mcp", "mcp"),
|
|
175
|
+
]:
|
|
176
|
+
ver = _pkg_version(pkg, mod)
|
|
177
|
+
suffix = " (ok)" if ver != "NOT FOUND" else ""
|
|
178
|
+
rows.append((label, f"{ver}{suffix}"))
|
|
179
|
+
|
|
180
|
+
rows.append(("platform", f"{platform.system()} {platform.machine()}"))
|
|
181
|
+
rows.append(("config", app.config_file_path))
|
|
182
|
+
|
|
183
|
+
label_width = max(len(label) for label, _ in rows)
|
|
184
|
+
for label, value in rows:
|
|
185
|
+
print(f" {label:<{label_width}} {value}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.command("stop", help="Stop the running server")
|
|
189
|
+
def cmd_stop(**_kw: object) -> None:
|
|
190
|
+
from wesktop import stop
|
|
191
|
+
if not _DEFAULT_PID.exists():
|
|
192
|
+
print(f"No PID file found at {_DEFAULT_PID}")
|
|
193
|
+
return
|
|
194
|
+
stop(_DEFAULT_PID)
|
|
195
|
+
print("Server stopped.")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def main() -> None:
|
|
199
|
+
app.run()
|
pixelweaver/config.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Process-wide configuration for PixelWeaver server.
|
|
2
|
+
|
|
3
|
+
Single source of truth for settings that are set once at startup (e.g. from
|
|
4
|
+
CLI arguments) and then read throughout the application. Both the HTTP
|
|
5
|
+
server (main.py) and the MCP server (mcp_server.py) share this module so
|
|
6
|
+
they never drift out of sync.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# Default matches the CLI's DEFAULT_DATA_DIR; overridden via set_data_dir()
|
|
12
|
+
# before the server app starts.
|
|
13
|
+
_data_dir: str = "./projects"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_data_dir() -> str:
|
|
17
|
+
"""Return the currently configured projects data directory."""
|
|
18
|
+
return _data_dir
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def set_data_dir(data_dir: str) -> None:
|
|
22
|
+
"""Set the projects data directory (called from CLI before app starts)."""
|
|
23
|
+
global _data_dir
|
|
24
|
+
_data_dir = data_dir
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""WebSocket connection manager for PixelWeaver.
|
|
2
|
+
|
|
3
|
+
Tracks active connections and provides helpers for sending messages
|
|
4
|
+
to individual clients or broadcasting to all/most clients.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from wesktop import WebSocket
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConnectionManager:
|
|
18
|
+
"""Manages active WebSocket connections."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self.active_connections: list[WebSocket] = []
|
|
22
|
+
|
|
23
|
+
async def connect(self, websocket: WebSocket) -> None:
|
|
24
|
+
"""Accept and register a new WebSocket connection."""
|
|
25
|
+
await websocket.accept()
|
|
26
|
+
self.active_connections.append(websocket)
|
|
27
|
+
logger.info("Client connected (%d total)", len(self.active_connections))
|
|
28
|
+
|
|
29
|
+
def disconnect(self, websocket: WebSocket) -> None:
|
|
30
|
+
"""Unregister a WebSocket connection."""
|
|
31
|
+
try:
|
|
32
|
+
self.active_connections.remove(websocket)
|
|
33
|
+
except ValueError:
|
|
34
|
+
pass # Already removed
|
|
35
|
+
logger.info("Client disconnected (%d remaining)", len(self.active_connections))
|
|
36
|
+
|
|
37
|
+
async def broadcast(
|
|
38
|
+
self,
|
|
39
|
+
message: dict[str, Any],
|
|
40
|
+
exclude: WebSocket | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Send a JSON message to all connected clients, optionally excluding one."""
|
|
43
|
+
# Snapshot the list so disconnects triggered by send failures (via
|
|
44
|
+
# exception handlers mutating active_connections) don't corrupt iteration.
|
|
45
|
+
for connection in list(self.active_connections):
|
|
46
|
+
if connection is not exclude:
|
|
47
|
+
try:
|
|
48
|
+
await connection.send_json(message)
|
|
49
|
+
except Exception:
|
|
50
|
+
logger.warning("Failed to send broadcast to a client", exc_info=True)
|
|
51
|
+
|
|
52
|
+
async def send(self, websocket: WebSocket, message: dict[str, Any]) -> None:
|
|
53
|
+
"""Send a JSON message to a single client."""
|
|
54
|
+
await websocket.send_json(message)
|