diff --git a/README.md b/README.md
index 1fcf8d1..acc3fa8 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
-![Python](https://img.shields.io/badge/Python-%3E=%203.10-4b8bbe?style=for-the-badge&logo=python&logoColor=white)
+![Python](https://img.shields.io/badge/Python-%3E=%203.11-4b8bbe?style=for-the-badge&logo=python&logoColor=white)
![Rust](https://img.shields.io/badge/Rust-%3E=%201.60-221f1e?style=for-the-badge&logo=rust&logoColor=white)
The chatting application to end all chatting applications.
@@ -19,7 +19,7 @@ Here are two of the standard clients for you to choose from:
- Full Desktop App ([based on tauri](https://tauri.app/))
- Download the latest release for your system from [here](https://github.com/iiPythonx/nightwatch/releases/latest).
- Alternatively, run it manually:
- - Follow the instructions from [Tauri prerequisites](https://tauri.app/v1/guides/getting-started/prerequisites) (including installing [Rust](https://rust-lang.org)).
+ - Follow the instructions from [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
- Install the Tauri CLI with `cd nightwatch/desktop && bun i`.
- Launch via `bun run tauri dev`.
diff --git a/nightwatch/auth/__init__.py b/nightwatch/auth/__init__.py
index 2e99123..c3d0a91 100644
--- a/nightwatch/auth/__init__.py
+++ b/nightwatch/auth/__init__.py
@@ -46,10 +46,26 @@ def __init__(self) -> None:
# Handle errors
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
- error = exc.errors()[0] # Just focus on one
+ error, message = exc.errors()[0], str(exc) # Just focus on one
+ match error["type"]:
+ case "too_short" | "string_too_short":
+ message = f"{error['loc'][1].capitalize()} should be at least {error['ctx']['min_length']} characters."
+
+ case "too_long" | "string_too_long":
+ message = f"{error['loc'][1].capitalize()} should be {error['ctx']['max_length']} characters at most."
+
+ case "missing":
+ message = f"{error['loc'][1].capitalize()} needs to be specified."
+
+ case "string_type":
+ message = f"{error['loc'][1].capitalize()} must be a valid string."
+
+ case "url_type" | "url_scheme" | "url_parsing":
+ message = f"{error['loc'][1].capitalize()} must be a valid URL."
+
return JSONResponse(
status_code = 422,
- content = {"code": 422, "data": error["msg"].lower().replace("value", error["loc"][1])}
+ content = {"code": 422, "data": message}
)
# Routing
diff --git a/nightwatch/client/__init__.py b/nightwatch/client/__init__.py
index b0d6c74..a93b4cb 100644
--- a/nightwatch/client/__init__.py
+++ b/nightwatch/client/__init__.py
@@ -28,7 +28,7 @@ def connect_loop(host: str, port: int, username: str) -> None:
ws = ORJSONWebSocket(ws)
# Handle identification payload
- ws.send({"type": "identify", "data": {"name": username, "color": config["user.color"]}})
+ ws.send({"type": "identify", "data": {"name": username, "color": config["client.user_color"]}})
response = ws.recv()
if response["type"] == "error":
exit(f"\nCould not connect to {destination}. Additional details:\n{response['data']['text']}")
diff --git a/nightwatch/desktop/src/css/main.css b/nightwatch/desktop/src/css/main.css
index 5dcba08..7b68932 100644
--- a/nightwatch/desktop/src/css/main.css
+++ b/nightwatch/desktop/src/css/main.css
@@ -58,6 +58,11 @@ ul.nav {
}
/* Custom element colors based on Soda */
+#authServer {
+ cursor: pointer;
+ color: var(--sd-cherry-blossom-d25);
+}
+
a.text-primary-emphasis {
color: var(--sd-sky-d20) !important;
}
diff --git a/nightwatch/desktop/src/frames/auth/login.html b/nightwatch/desktop/src/frames/auth/login.html
index 8209245..2685195 100644
--- a/nightwatch/desktop/src/frames/auth/login.html
+++ b/nightwatch/desktop/src/frames/auth/login.html
@@ -1,4 +1,4 @@
-
diff --git a/nightwatch/desktop/src/js/frames/auth.js b/nightwatch/desktop/src/js/frames/auth.js
index 75bbfed..1ab5496 100644
--- a/nightwatch/desktop/src/js/frames/auth.js
+++ b/nightwatch/desktop/src/js/frames/auth.js
@@ -1,11 +1,22 @@
-document.querySelector("form").addEventListener("submit", async (e) => {
- e.preventDefault();
+(() => {
+ const form = document.querySelector("form"), button = document.querySelector("button[type = submit]");
+ form.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ button.innerHTML = `
`;
- // Handle authentication
- const response = await window.__NWAUTHMETHOD__(
- document.getElementById("usernameInput").value,
- document.getElementById("passwordInput").value
- )
- if (response.code !== 200) return notifier.alert(response.data);
- load_frame("welcome");
-});
\ No newline at end of file
+ // Handle authentication
+ const response = await nightwatch[form.getAttribute("data-nightwatch-action")](
+ document.getElementById("usernameInput").value,
+ document.getElementById("passwordInput").value
+ )
+ if (response.code !== 200) {
+ button.innerText = "Continue";
+ return notifier.alert(response.data);
+ }
+
+ await nightwatch.authenticate();
+ if (nightwatch._error) return load_frame(`errors/${nightwatch._error.type}`);
+ load_frame("welcome");
+ });
+ document.getElementById("authServer").innerText = nightwatch.auth_server;
+})();
diff --git a/nightwatch/desktop/src/js/nightwatch.js b/nightwatch/desktop/src/js/nightwatch.js
index 1dcf8b2..a128fcd 100644
--- a/nightwatch/desktop/src/js/nightwatch.js
+++ b/nightwatch/desktop/src/js/nightwatch.js
@@ -9,9 +9,20 @@ class Nightwatch {
this.auth_server = "auth.iipython.dev";
}
+ // Handle servers
+ async add_server(server) {
+ try {
+ const info = await (await fetch(`https://${server.replace("/gateway", "/info")}`)).json();
+ return {
+ type: "success",
+ data: info
+ }
+ } catch (error) { return { type: "fail" }; }
+ }
+
// Handle local authentication
async authenticate() {
- this.token = localStorage.getItem("token");
+ this.token = this.token || localStorage.getItem("token");
if (!this.token) return;
// Fetch user data
diff --git a/nightwatch/server/__init__.py b/nightwatch/server/__init__.py
index 0b74897..539355f 100644
--- a/nightwatch/server/__init__.py
+++ b/nightwatch/server/__init__.py
@@ -1,13 +1,17 @@
# Copyright (c) 2024 iiPython
# Modules
-from http import HTTPStatus
-
+import anyio
import orjson
from pydantic import ValidationError
-from websockets import WebSocketCommonProtocol, Headers
-from websockets.exceptions import ConnectionClosedError
+from starlette.requests import Request
+from starlette.routing import Route, WebSocketRoute
+from starlette.responses import JSONResponse, PlainTextResponse
+from starlette.websockets import WebSocket
+from starlette.applications import Starlette
+
+from .utils import broadcast
from .utils.commands import registry
from .utils.constant import Constant
from .utils.websocket import NightwatchClient
@@ -19,31 +23,42 @@ class NightwatchStateManager():
def __init__(self) -> None:
self.clients = {}
- def add_client(self, client: WebSocketCommonProtocol) -> None:
+ def add_client(self, client: WebSocket) -> None:
self.clients[client] = None
- def remove_client(self, client: WebSocketCommonProtocol) -> None:
+ def remove_client(self, client: WebSocket) -> None:
if client in self.clients:
del self.clients[client]
state = NightwatchStateManager()
# Handle API
-async def process_api(path: str, request_headers: Headers) -> tuple[HTTPStatus, list, bytes]:
- if path == "/info":
- return HTTPStatus.OK, [], orjson.dumps({
- "name": Constant.SERVER_NAME,
- "version": Constant.SERVER_VERSION,
- "icon": Constant.SERVER_ICON
- })
+async def route_home(request: Request) -> PlainTextResponse:
+ return PlainTextResponse("Nightwatch is running.")
- return HTTPStatus.NOT_FOUND, [], b"Not Found\n"
+async def route_info(request: Request) -> JSONResponse:
+ return JSONResponse({
+ "name": Constant.SERVER_NAME,
+ "version": Constant.SERVER_VERSION,
+ "icon": Constant.SERVER_ICON
+ })
# Socket entrypoint
-async def connection(websocket: WebSocketCommonProtocol) -> None:
+async def route_gateway(websocket: WebSocket) -> None:
+ await websocket.accept()
+
+ client = NightwatchClient(state, websocket)
+ async with anyio.create_task_group() as task_group:
+ async def run_chatroom_ws_receiver() -> None:
+ await chatroom_ws_receiver(websocket, client)
+ task_group.cancel_scope.cancel()
+
+ task_group.start_soon(run_chatroom_ws_receiver)
+ await chatroom_ws_sender(websocket)
+
+async def chatroom_ws_receiver(websocket: WebSocket, client: NightwatchClient) -> None:
try:
- client = NightwatchClient(state, websocket)
- async for message in websocket:
+ async for message in websocket.iter_text():
message = orjson.loads(message)
if message.get("type") not in registry.commands:
await client.send("error", text = "Specified command type does not exist or is missing.")
@@ -67,7 +82,20 @@ async def connection(websocket: WebSocketCommonProtocol) -> None:
except orjson.JSONDecodeError:
log.warn("ws", "Failed to decode JSON from client.")
- except ConnectionClosedError:
- log.info("ws", "Client disconnected.")
-
state.remove_client(websocket)
+ log.info("ws", "Client disconnected.")
+
+async def chatroom_ws_sender(websocket: WebSocket) -> None:
+ async with broadcast.subscribe("general") as sub:
+ async for event in sub: # type: ignore
+ await websocket.send_text(event.message)
+
+app = Starlette(
+ routes = [
+ Route("/", route_home),
+ Route("/info", route_info),
+ WebSocketRoute("/gateway", route_gateway)
+ ],
+ on_startup = [broadcast.connect],
+ on_shutdown = [broadcast.disconnect]
+)
diff --git a/nightwatch/server/__main__.py b/nightwatch/server/__main__.py
index 748744d..a4fa83c 100644
--- a/nightwatch/server/__main__.py
+++ b/nightwatch/server/__main__.py
@@ -2,21 +2,19 @@
# Modules
import os
-import asyncio
-from websockets.server import serve
+import uvicorn
-from . import connection, process_api
+from . import app
from nightwatch import __version__
from nightwatch.logging import log
# Entrypoint
-async def main() -> None:
+def main() -> None:
host, port = os.getenv("HOST", "localhost"), int(os.getenv("PORT", 8000))
log.info("ws", f"Nightwatch v{__version__} running on ws://{host}:{port}/")
- async with serve(connection, host, port, process_request = process_api):
- await asyncio.Future()
+ uvicorn.run(app, host = host, port = port, log_level = "info")
if __name__ == "__main__":
- asyncio.run(main())
+ main()
diff --git a/nightwatch/server/utils/__init__.py b/nightwatch/server/utils/__init__.py
new file mode 100644
index 0000000..1df7d52
--- /dev/null
+++ b/nightwatch/server/utils/__init__.py
@@ -0,0 +1,7 @@
+# Copyright (c) 2024 iiPython
+
+# Modules
+from broadcaster import Broadcast
+
+# Setup broadcast connection
+broadcast = Broadcast("memory://")
diff --git a/nightwatch/server/utils/commands.py b/nightwatch/server/utils/commands.py
index a0aabf7..03a2366 100644
--- a/nightwatch/server/utils/commands.py
+++ b/nightwatch/server/utils/commands.py
@@ -4,9 +4,8 @@
from typing import Callable
import orjson
-import websockets
-from . import models
+from . import models, broadcast
from .constant import Constant
from .websocket import NightwatchClient
@@ -42,7 +41,7 @@ async def command_identify(state, client: NightwatchClient, data: models.Identif
client.identified = True
await client.send("server", name = Constant.SERVER_NAME, online = len(state.clients))
- websockets.broadcast(state.clients, orjson.dumps({
+ await broadcast.publish("general", orjson.dumps({
"type": "message",
"data": {"text": f"{data.name} joined the chatroom.", "user": Constant.SERVER_USER}
}).decode())
@@ -52,7 +51,7 @@ async def command_message(state, client: NightwatchClient, data: models.MessageM
if not client.identified:
return await client.send("error", text = "You must identify before sending a message.")
- websockets.broadcast(state.clients, orjson.dumps({
+ await broadcast.publish("general", orjson.dumps({
"type": "message",
"data": {"text": data.text, "user": client.user_data}
}).decode())
diff --git a/nightwatch/server/utils/websocket.py b/nightwatch/server/utils/websocket.py
index aa7f845..da7e19d 100644
--- a/nightwatch/server/utils/websocket.py
+++ b/nightwatch/server/utils/websocket.py
@@ -4,12 +4,12 @@
from typing import Any
import orjson
-from websockets import WebSocketCommonProtocol
+from starlette.websockets import WebSocket
class NightwatchClient():
- """This class acts as a wrapper on top of WebSocketCommonProtocol that implements
+ """This class acts as a wrapper on top of WebSocket that implements
data serialization through orjson."""
- def __init__(self, state, client: WebSocketCommonProtocol) -> None:
+ def __init__(self, state, client: WebSocket) -> None:
self.client = client
self.identified, self.callback = False, None
@@ -22,7 +22,7 @@ async def send(self, message_type: str, **message_data) -> None:
payload["callback"] = self.callback
self.callback = None
- await self.client.send(orjson.dumps(payload).decode())
+ await self.client.send_text(orjson.dumps(payload).decode())
def set_callback(self, callback: str) -> None:
self.callback = callback
diff --git a/pyproject.toml b/pyproject.toml
index 53f87d1..11034f0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,9 +35,12 @@ dependencies = [
[project.optional-dependencies]
serve = [
+ "anyio",
+ "uvloop",
"fastapi",
"pymongo",
"pydantic",
+ "broadcaster",
"pydantic-extra-types",
"starlette",
"argon2-cffi",