Compare commits
2
Commits
aee78d5392
...
19576de166
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19576de166 | ||
|
|
ce22de754a |
@@ -1,2 +1,2 @@
|
|||||||
uv add pyinstaller pillow
|
uv sync
|
||||||
uv run pyinstaller --noconfirm --onefile --windowed --name "VideoSync" --icon "icon.png" --add-data "icon.png;." --add-data "copy.svg;." --add-data "check.svg;." main.py
|
uv run pyinstaller --noconfirm --onefile --windowed --name "VideoSync" --icon "icon.png" --collect-data "certifi" --add-data "icon.png;." --add-data "copy.svg;." --add-data "check.svg;." main.py
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from PyQt6.QtCore import Qt, pyqtSignal
|
|||||||
class ChatBubble(QWidget):
|
class ChatBubble(QWidget):
|
||||||
def __init__(self, author, text, time_str, is_self, on_link_clicked):
|
def __init__(self, author, text, time_str, is_self, on_link_clicked):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
layout = QHBoxLayout(self)
|
layout = QHBoxLayout(self)
|
||||||
layout.setContentsMargins(5, 2, 5, 2)
|
layout.setContentsMargins(5, 2, 5, 2)
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ class ChatBubble(QWidget):
|
|||||||
|
|
||||||
# Author
|
# Author
|
||||||
author_lbl = QLabel("You" if is_self else author)
|
author_lbl = QLabel("You" if is_self else author)
|
||||||
|
author_lbl.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
author_lbl.setStyleSheet(f"color: {author_color}; font-weight: bold; font-size: 11px; background: transparent;")
|
author_lbl.setStyleSheet(f"color: {author_color}; font-weight: bold; font-size: 11px; background: transparent;")
|
||||||
bubble_layout.addWidget(author_lbl)
|
bubble_layout.addWidget(author_lbl)
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ class ChatBubble(QWidget):
|
|||||||
# width of 0 lets the label wrap within whatever space the bubble
|
# width of 0 lets the label wrap within whatever space the bubble
|
||||||
# actually receives rather than expanding the bubble outward.
|
# actually receives rather than expanding the bubble outward.
|
||||||
self.text_lbl = QLabel(text)
|
self.text_lbl = QLabel(text)
|
||||||
|
self.text_lbl.setTextFormat(Qt.TextFormat.RichText)
|
||||||
self.text_lbl.setWordWrap(True)
|
self.text_lbl.setWordWrap(True)
|
||||||
self.text_lbl.setMinimumWidth(0)
|
self.text_lbl.setMinimumWidth(0)
|
||||||
self.text_lbl.setSizePolicy(
|
self.text_lbl.setSizePolicy(
|
||||||
@@ -71,6 +74,14 @@ class ChatBubble(QWidget):
|
|||||||
layout.addWidget(self.bubble)
|
layout.addWidget(self.bubble)
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
|
|
||||||
|
def resizeEvent(self, event):
|
||||||
|
super().resizeEvent(event)
|
||||||
|
# Recalculate after the scrollbar or splitter changes the available width.
|
||||||
|
# A fixed row height prevents the scroll layout squeezing wrapped text.
|
||||||
|
height = self.layout().heightForWidth(event.size().width())
|
||||||
|
if height >= 0:
|
||||||
|
self.setFixedHeight(height)
|
||||||
|
|
||||||
|
|
||||||
class SystemMessageWidget(QWidget):
|
class SystemMessageWidget(QWidget):
|
||||||
def __init__(self, text):
|
def __init__(self, text):
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class VlcSyncApp(QMainWindow):
|
|||||||
self.sync_client.connected.connect(lambda: self.room_widget.update_connection_status(True))
|
self.sync_client.connected.connect(lambda: self.room_widget.update_connection_status(True))
|
||||||
self.sync_client.disconnected.connect(self.on_ws_disconnected)
|
self.sync_client.disconnected.connect(self.on_ws_disconnected)
|
||||||
self.sync_client.disconnected.connect(lambda: self.room_widget.update_connection_status(False))
|
self.sync_client.disconnected.connect(lambda: self.room_widget.update_connection_status(False))
|
||||||
|
self.sync_client.connection_error.connect(self.on_connection_error)
|
||||||
self.sync_client.room_joined.connect(self.on_room_joined)
|
self.sync_client.room_joined.connect(self.on_room_joined)
|
||||||
self.sync_client.room_rejoined.connect(self.on_room_rejoined)
|
self.sync_client.room_rejoined.connect(self.on_room_rejoined)
|
||||||
self.sync_client.room_error.connect(self.on_room_error)
|
self.sync_client.room_error.connect(self.on_room_error)
|
||||||
@@ -135,6 +136,12 @@ class VlcSyncApp(QMainWindow):
|
|||||||
self.lobby_widget.reset_ui()
|
self.lobby_widget.reset_ui()
|
||||||
self.pending_connect_action = None
|
self.pending_connect_action = None
|
||||||
|
|
||||||
|
def on_connection_error(self, msg: str):
|
||||||
|
QMessageBox.critical(self, "Secure Connection Error", msg)
|
||||||
|
self.pending_connect_action = None
|
||||||
|
self.lobby_widget.reset_ui()
|
||||||
|
self.sync_client.stop()
|
||||||
|
|
||||||
def on_room_error(self, msg: str):
|
def on_room_error(self, msg: str):
|
||||||
QMessageBox.critical(self, "Room Error", msg)
|
QMessageBox.critical(self, "Room Error", msg)
|
||||||
self.lobby_widget.reset_ui()
|
self.lobby_widget.reset_ui()
|
||||||
@@ -493,4 +500,3 @@ if __name__ == "__main__":
|
|||||||
window = VlcSyncApp()
|
window = VlcSyncApp()
|
||||||
window.show()
|
window.show()
|
||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ description = "Add your description here"
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"certifi>=2026.6.17",
|
||||||
"pillow>=12.1.1",
|
"pillow>=12.1.1",
|
||||||
"pyinstaller>=6.19.0",
|
"pyinstaller>=6.19.0",
|
||||||
"pyqt6>=6.10.2",
|
"pyqt6>=6.10.2",
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ class RoomWidget(QWidget):
|
|||||||
self.popout_window = None
|
self.popout_window = None
|
||||||
|
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
|
self._unread_messages = 0
|
||||||
|
QApplication.instance().focusChanged.connect(self._on_chat_focus_changed)
|
||||||
|
|
||||||
def _setup_ui(self):
|
def _setup_ui(self):
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
@@ -174,7 +176,7 @@ class RoomWidget(QWidget):
|
|||||||
self.chat_container.setMaximumWidth(500)
|
self.chat_container.setMaximumWidth(500)
|
||||||
chat_layout = QVBoxLayout(self.chat_container)
|
chat_layout = QVBoxLayout(self.chat_container)
|
||||||
|
|
||||||
chat_header = QLabel("Live Chat")
|
chat_header = self.chat_header = QLabel("Live Chat")
|
||||||
chat_header.setObjectName("chatHeader")
|
chat_header.setObjectName("chatHeader")
|
||||||
|
|
||||||
self.popout_btn = QPushButton("↗")
|
self.popout_btn = QPushButton("↗")
|
||||||
@@ -323,6 +325,7 @@ class RoomWidget(QWidget):
|
|||||||
self.room_file_badge.setText(f"📄 {file_name}")
|
self.room_file_badge.setText(f"📄 {file_name}")
|
||||||
|
|
||||||
# Clear chat
|
# Clear chat
|
||||||
|
self._clear_chat_alert()
|
||||||
for i in reversed(range(self.chat_content_layout.count())):
|
for i in reversed(range(self.chat_content_layout.count())):
|
||||||
self.chat_content_layout.itemAt(i).widget().setParent(None)
|
self.chat_content_layout.itemAt(i).widget().setParent(None)
|
||||||
|
|
||||||
@@ -335,6 +338,7 @@ class RoomWidget(QWidget):
|
|||||||
self.vlc_player.set_volume(self.volume_slider.value())
|
self.vlc_player.set_volume(self.volume_slider.value())
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
|
self._clear_chat_alert()
|
||||||
self.vlc_player.stop()
|
self.vlc_player.stop()
|
||||||
self.current_users = []
|
self.current_users = []
|
||||||
self._is_first_user_update = True
|
self._is_first_user_update = True
|
||||||
@@ -723,13 +727,20 @@ class RoomWidget(QWidget):
|
|||||||
time_str = dt.strftime("%I:%M %p")
|
time_str = dt.strftime("%I:%M %p")
|
||||||
|
|
||||||
safe_text = html.escape(text)
|
safe_text = html.escape(text)
|
||||||
safe_author = html.escape(author)
|
|
||||||
|
|
||||||
# Regex to find timestamps like 1:23 or 01:23:45
|
# Regex to find timestamps like 1:23 or 01:23:45
|
||||||
pattern = r'\b(\d{1,2}:\d{2}(?::\d{2})?)\b'
|
pattern = r'\b(\d{1,2}:\d{2}(?::\d{2})?)\b'
|
||||||
linked_text = re.sub(pattern, r'<a href="seek:\1" style="color: #66b3ff; text-decoration: none;">\1</a>', safe_text)
|
linked_text = re.sub(pattern, r'<a href="seek:\1" style="color: #66b3ff; text-decoration: none;">\1</a>', safe_text)
|
||||||
|
linked_text = linked_text.replace('\n', '<br>')
|
||||||
|
|
||||||
is_self = author == self.username
|
is_self = author == self.username
|
||||||
|
if not is_self and not self._chat_has_focus():
|
||||||
|
self._unread_messages += 1
|
||||||
|
self.chat_header.setText(f"Live Chat • {self._unread_messages} new")
|
||||||
|
self.chat_toggle_btn.setText("💬•")
|
||||||
|
self.chat_toggle_btn.setToolTip(f"{self._unread_messages} new chat messages")
|
||||||
|
self.chat_toggle_btn.setStyleSheet("color: #66b3ff; border: 1px solid #66b3ff; border-radius: 6px;")
|
||||||
|
QApplication.alert(self.chat_container.window(), 3000)
|
||||||
|
|
||||||
# Auto-extract tags added by users directly via /tag command
|
# Auto-extract tags added by users directly via /tag command
|
||||||
# We look for the pattern generated by /tag: "MM:SS tag text"
|
# We look for the pattern generated by /tag: "MM:SS tag text"
|
||||||
@@ -742,6 +753,24 @@ class RoomWidget(QWidget):
|
|||||||
bubble = ChatBubble(author, linked_text, time_str, is_self, self._handle_link_from_bubble)
|
bubble = ChatBubble(author, linked_text, time_str, is_self, self._handle_link_from_bubble)
|
||||||
self.chat_content_layout.addWidget(bubble)
|
self.chat_content_layout.addWidget(bubble)
|
||||||
self._scroll_to_bottom()
|
self._scroll_to_bottom()
|
||||||
|
|
||||||
|
def _chat_has_focus(self):
|
||||||
|
focus = QApplication.focusWidget()
|
||||||
|
return (self.chat_container.isVisible()
|
||||||
|
and self.chat_container.window().isActiveWindow()
|
||||||
|
and focus is not None
|
||||||
|
and (focus is self.chat_container or self.chat_container.isAncestorOf(focus)))
|
||||||
|
|
||||||
|
def _on_chat_focus_changed(self, old, new):
|
||||||
|
if self._chat_has_focus():
|
||||||
|
self._clear_chat_alert()
|
||||||
|
|
||||||
|
def _clear_chat_alert(self):
|
||||||
|
self._unread_messages = 0
|
||||||
|
self.chat_header.setText("Live Chat")
|
||||||
|
self.chat_toggle_btn.setText("💬")
|
||||||
|
self.chat_toggle_btn.setToolTip("Toggle Chat")
|
||||||
|
self.chat_toggle_btn.setStyleSheet("")
|
||||||
|
|
||||||
def _handle_link_from_bubble(self, url_str):
|
def _handle_link_from_bubble(self, url_str):
|
||||||
if url_str.startswith("seek:"):
|
if url_str.startswith("seek:"):
|
||||||
@@ -782,6 +811,7 @@ class RoomWidget(QWidget):
|
|||||||
self._scroll_to_bottom()
|
self._scroll_to_bottom()
|
||||||
|
|
||||||
def clear_chat(self):
|
def clear_chat(self):
|
||||||
|
self._clear_chat_alert()
|
||||||
for i in reversed(range(self.chat_content_layout.count())):
|
for i in reversed(range(self.chat_content_layout.count())):
|
||||||
item = self.chat_content_layout.itemAt(i)
|
item = self.chat_content_layout.itemAt(i)
|
||||||
if item.widget():
|
if item.widget():
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import ssl
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
import certifi
|
||||||
import websockets
|
import websockets
|
||||||
from PyQt6.QtCore import QThread, pyqtSignal
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
|
|
||||||
|
|
||||||
class SyncClientThread(QThread):
|
class SyncClientThread(QThread):
|
||||||
# Signals from WebSocket -> PyQt UI
|
# Signals from WebSocket -> PyQt UI
|
||||||
connected = pyqtSignal()
|
connected = pyqtSignal()
|
||||||
disconnected = pyqtSignal()
|
disconnected = pyqtSignal()
|
||||||
|
connection_error = pyqtSignal(str)
|
||||||
room_joined = pyqtSignal(dict)
|
room_joined = pyqtSignal(dict)
|
||||||
room_rejoined = pyqtSignal(dict)
|
room_rejoined = pyqtSignal(dict)
|
||||||
room_error = pyqtSignal(str)
|
room_error = pyqtSignal(str)
|
||||||
file_check_needed = pyqtSignal(dict) # msg
|
file_check_needed = pyqtSignal(dict) # msg
|
||||||
users_updated = pyqtSignal(list)
|
users_updated = pyqtSignal(list)
|
||||||
chat_message = pyqtSignal(str, str, int) # author, text, timestamp
|
chat_message = pyqtSignal(str, str, int) # author, text, timestamp
|
||||||
system_message = pyqtSignal(str)
|
system_message = pyqtSignal(str)
|
||||||
sync_event = pyqtSignal(dict)
|
sync_event = pyqtSignal(dict)
|
||||||
latency_updated = pyqtSignal(int) # latency in ms
|
latency_updated = pyqtSignal(int) # latency in ms
|
||||||
|
|
||||||
def __init__(self, url="ws://localhost:3000/ws"):
|
def __init__(self, url="ws://localhost:3000/ws"):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -26,13 +31,31 @@ class SyncClientThread(QThread):
|
|||||||
self.running = False
|
self.running = False
|
||||||
self._ping_sent_at = 0
|
self._ping_sent_at = 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_ssl_context():
|
||||||
|
"""Build a verified TLS context from the CA bundle shipped with the app."""
|
||||||
|
return ssl.create_default_context(cafile=certifi.where())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _certificate_error(exc):
|
||||||
|
"""Return a nested certificate verification error, if present."""
|
||||||
|
while exc is not None:
|
||||||
|
if isinstance(exc, ssl.SSLCertVerificationError):
|
||||||
|
return exc
|
||||||
|
exc = exc.__cause__ or exc.__context__
|
||||||
|
return None
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Runs strictly within the newly created QThread"""
|
"""Runs strictly within the newly created QThread"""
|
||||||
self.running = True
|
self.running = True
|
||||||
self.loop = asyncio.new_event_loop()
|
self.loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(self.loop)
|
asyncio.set_event_loop(self.loop)
|
||||||
self.loop.run_until_complete(self._connect_and_listen())
|
try:
|
||||||
self.loop.close()
|
self.loop.run_until_complete(self._connect_and_listen())
|
||||||
|
finally:
|
||||||
|
self.ws = None
|
||||||
|
self.running = False
|
||||||
|
self.loop.close()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self.running = False
|
self.running = False
|
||||||
@@ -61,12 +84,17 @@ class SyncClientThread(QThread):
|
|||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
async def _connect_and_listen(self):
|
async def _connect_and_listen(self):
|
||||||
|
ssl_context = (
|
||||||
|
self._create_ssl_context() if self.url.startswith("wss://") else None
|
||||||
|
)
|
||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
async with websockets.connect(self.url) as ws:
|
connect_options = {"ssl": ssl_context} if ssl_context else {}
|
||||||
|
async with websockets.connect(self.url, **connect_options) as ws:
|
||||||
self.ws = ws
|
self.ws = ws
|
||||||
self.connected.emit()
|
self.connected.emit()
|
||||||
|
|
||||||
ping_task = asyncio.create_task(self._ping_loop(ws))
|
ping_task = asyncio.create_task(self._ping_loop(ws))
|
||||||
try:
|
try:
|
||||||
async for message in ws:
|
async for message in ws:
|
||||||
@@ -77,38 +105,60 @@ class SyncClientThread(QThread):
|
|||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
ping_task.cancel()
|
ping_task.cancel()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
certificate_error = self._certificate_error(e)
|
||||||
|
if certificate_error:
|
||||||
|
verification_reason = getattr(
|
||||||
|
certificate_error, "verify_message", str(certificate_error)
|
||||||
|
)
|
||||||
|
message = (
|
||||||
|
"Secure connection failed because the server certificate could not be "
|
||||||
|
f"verified ({verification_reason}). "
|
||||||
|
"Check that your computer's date and time are correct, then update or "
|
||||||
|
"restart VideoSync."
|
||||||
|
)
|
||||||
|
print(f"WebSocket TLS Error: {certificate_error}")
|
||||||
|
self.connection_error.emit(message)
|
||||||
|
self.running = False
|
||||||
|
break
|
||||||
print(f"WebSocket Error: {e}")
|
print(f"WebSocket Error: {e}")
|
||||||
|
|
||||||
self.disconnected.emit()
|
finally:
|
||||||
|
self.ws = None
|
||||||
|
self.disconnected.emit()
|
||||||
|
|
||||||
if self.running:
|
if self.running:
|
||||||
# Reconnect backoff
|
# Reconnect backoff
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
def _handle_message(self, msg: dict):
|
def _handle_message(self, msg: dict):
|
||||||
t = msg.get("type")
|
t = msg.get("type")
|
||||||
|
|
||||||
if t == "room_created":
|
if t == "room_created":
|
||||||
self.room_joined.emit(msg)
|
self.room_joined.emit(msg)
|
||||||
|
|
||||||
elif t == "room_joined":
|
elif t == "room_joined":
|
||||||
self.room_joined.emit(msg)
|
self.room_joined.emit(msg)
|
||||||
|
|
||||||
elif t == "room_rejoined":
|
elif t == "room_rejoined":
|
||||||
self.room_rejoined.emit(msg)
|
self.room_rejoined.emit(msg)
|
||||||
|
|
||||||
elif t == "error":
|
elif t == "error":
|
||||||
self.room_error.emit(msg.get("message", "Unknown error"))
|
self.room_error.emit(msg.get("message", "Unknown error"))
|
||||||
|
|
||||||
elif t == "room_file_check":
|
elif t == "room_file_check":
|
||||||
self.file_check_needed.emit(msg)
|
self.file_check_needed.emit(msg)
|
||||||
|
|
||||||
elif t in ["user_joined", "user_left"]:
|
elif t in ["user_joined", "user_left"]:
|
||||||
self.users_updated.emit(msg.get("users", []))
|
self.users_updated.emit(msg.get("users", []))
|
||||||
|
|
||||||
elif t == "chat":
|
elif t == "chat":
|
||||||
self.chat_message.emit(msg.get("username", "Unknown"), msg.get("message", ""), msg.get("timestamp", 0))
|
self.chat_message.emit(
|
||||||
|
msg.get("username", "Unknown"),
|
||||||
|
msg.get("message", ""),
|
||||||
|
msg.get("timestamp", 0),
|
||||||
|
)
|
||||||
|
|
||||||
elif t == "sync":
|
elif t == "sync":
|
||||||
self.sync_event.emit(msg)
|
self.sync_event.emit(msg)
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PyQt6.QtCore import QObject, pyqtSignal
|
||||||
|
from PyQt6.QtGui import QTextDocument
|
||||||
|
from PyQt6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
import room_widget
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerSignals(QObject):
|
||||||
|
time_changed = pyqtSignal(int)
|
||||||
|
state_changed = pyqtSignal(bool, int)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def room(qtbot, monkeypatch):
|
||||||
|
player = Mock(signals=PlayerSignals())
|
||||||
|
monkeypatch.setattr(room_widget, 'VLCSyncPlayer', lambda frame: player)
|
||||||
|
widget = room_widget.RoomWidget()
|
||||||
|
widget.username = 'Me'
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
widget.resize(1050, 500)
|
||||||
|
widget.show()
|
||||||
|
return widget
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('text', ["It's someone's & my <message>",
|
||||||
|
"It's at 1:23 & <b>literal</b>",
|
||||||
|
'line one\nline two', 'literal ' entity'])
|
||||||
|
def test_message_rendering(room, text):
|
||||||
|
room.add_chat_message("<b>O'Brien</b>", text, 0)
|
||||||
|
bubble = room.chat_content_layout.itemAt(0).widget()
|
||||||
|
doc = QTextDocument()
|
||||||
|
doc.setHtml(bubble.text_lbl.text())
|
||||||
|
assert doc.toPlainText() == text
|
||||||
|
if '1:23' in text:
|
||||||
|
assert 'href="seek:1:23"' in bubble.text_lbl.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bubbles_do_not_shrink_or_clip_when_overflowing(room, qtbot):
|
||||||
|
text = 'A wrapped message with several words. ' * 8
|
||||||
|
room.add_chat_message('Other', text, 0)
|
||||||
|
qtbot.wait(100)
|
||||||
|
first = room.chat_content_layout.itemAt(0).widget()
|
||||||
|
initial_height = first.height()
|
||||||
|
for i in range(35):
|
||||||
|
room.add_chat_message('Me' if i % 2 else 'Other', text, 0)
|
||||||
|
qtbot.wait(200)
|
||||||
|
assert room.chat_messages.verticalScrollBar().maximum() > 0
|
||||||
|
assert first.height() >= initial_height
|
||||||
|
for width in (220, 450, 280):
|
||||||
|
room.splitter.setSizes([room.width() - width, width])
|
||||||
|
qtbot.wait(100)
|
||||||
|
for i in range(room.chat_content_layout.count()):
|
||||||
|
bubble = room.chat_content_layout.itemAt(i).widget()
|
||||||
|
label = bubble.text_lbl
|
||||||
|
assert label.height() >= label.heightForWidth(label.width())
|
||||||
|
assert bubble.bubble.rect().contains(label.geometry())
|
||||||
|
|
||||||
|
|
||||||
|
def test_unread_alert_and_focus_clear(room, qtbot, monkeypatch):
|
||||||
|
alert = Mock()
|
||||||
|
monkeypatch.setattr(QApplication, 'alert', alert)
|
||||||
|
room.activateWindow()
|
||||||
|
room.setFocus()
|
||||||
|
qtbot.wait(50)
|
||||||
|
room.add_chat_message('Other', 'hello', 0)
|
||||||
|
assert room._unread_messages == 1
|
||||||
|
alert.assert_called_once()
|
||||||
|
room.add_chat_message('Me', 'reply', 0)
|
||||||
|
assert room._unread_messages == 1
|
||||||
|
room.chat_input.setFocus()
|
||||||
|
qtbot.wait(50)
|
||||||
|
assert room._unread_messages == 0
|
||||||
|
room.add_chat_message('Other', 'focused', 0)
|
||||||
|
assert room._unread_messages == 0
|
||||||
|
room.chat_container.hide()
|
||||||
|
room.add_chat_message('Other', 'hidden', 0)
|
||||||
|
assert room._unread_messages == 1
|
||||||
|
room.clear_chat()
|
||||||
|
assert room._unread_messages == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_popout_focus_and_wrapping(room, qtbot, monkeypatch):
|
||||||
|
monkeypatch.setattr(QApplication, 'alert', Mock())
|
||||||
|
room.popout_chat()
|
||||||
|
popout = room.popout_window
|
||||||
|
qtbot.addWidget(popout)
|
||||||
|
popout.activateWindow()
|
||||||
|
room.chat_input.setFocus()
|
||||||
|
qtbot.wait(100)
|
||||||
|
room.add_chat_message('Other', 'Wrapped popout text ' * 40, 0)
|
||||||
|
qtbot.wait(100)
|
||||||
|
assert room._unread_messages == 0
|
||||||
|
bubble = room.chat_content_layout.itemAt(0).widget()
|
||||||
|
assert bubble.text_lbl.height() >= bubble.text_lbl.heightForWidth(bubble.text_lbl.width())
|
||||||
|
room.activateWindow()
|
||||||
|
room.setFocus()
|
||||||
|
qtbot.wait(100)
|
||||||
|
room.add_chat_message('Other', 'Background popout', 0)
|
||||||
|
assert room._unread_messages == 1
|
||||||
|
popout.close()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import ssl
|
||||||
|
|
||||||
|
from sync_client import SyncClientThread
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssl_context_uses_bundled_ca_certificates():
|
||||||
|
context = SyncClientThread._create_ssl_context()
|
||||||
|
|
||||||
|
assert context.verify_mode == ssl.CERT_REQUIRED
|
||||||
|
assert context.check_hostname is True
|
||||||
|
assert context.cert_store_stats()["x509_ca"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_certificate_error_finds_nested_verification_error():
|
||||||
|
certificate_error = ssl.SSLCertVerificationError(1, "certificate has expired")
|
||||||
|
|
||||||
|
try:
|
||||||
|
raise RuntimeError("connection failed") from certificate_error
|
||||||
|
except RuntimeError as error:
|
||||||
|
assert SyncClientThread._certificate_error(error) is certificate_error
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_certificate_error_is_ignored():
|
||||||
|
assert SyncClientThread._certificate_error(ConnectionError("offline")) is None
|
||||||
Generated
+11
@@ -11,6 +11,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.6.17"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "macholib"
|
name = "macholib"
|
||||||
version = "1.16.4"
|
version = "1.16.4"
|
||||||
@@ -163,6 +172,7 @@ name = "python-app"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "pyinstaller" },
|
{ name = "pyinstaller" },
|
||||||
{ name = "pyqt6" },
|
{ name = "pyqt6" },
|
||||||
@@ -172,6 +182,7 @@ dependencies = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "certifi", specifier = ">=2026.6.17" },
|
||||||
{ name = "pillow", specifier = ">=12.1.1" },
|
{ name = "pillow", specifier = ">=12.1.1" },
|
||||||
{ name = "pyinstaller", specifier = ">=6.19.0" },
|
{ name = "pyinstaller", specifier = ">=6.19.0" },
|
||||||
{ name = "pyqt6", specifier = ">=6.10.2" },
|
{ name = "pyqt6", specifier = ">=6.10.2" },
|
||||||
|
|||||||
Reference in New Issue
Block a user