Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00c111da73 | |||
| 7301186102 | |||
|
|
f1ed8d36c4 | ||
|
|
69b10b0864 | ||
| faeac6c96f | |||
| 35dfedd5c9 | |||
| b4c74f098b | |||
| 61a2458f83 | |||
| d5a4628281 | |||
| 5456b6c5fd | |||
| 6bdf31cb26 | |||
| 8737720af6 | |||
| 0c7b2d3bdb | |||
| a2ef1bf2d5 |
@@ -10,8 +10,11 @@ pyinstaller `
|
|||||||
--hidden-import wx._xml `
|
--hidden-import wx._xml `
|
||||||
--add-data "FiraCode-Regular.ttf;." `
|
--add-data "FiraCode-Regular.ttf;." `
|
||||||
--add-data "FiraCode-SemiBold.ttf;." `
|
--add-data "FiraCode-SemiBold.ttf;." `
|
||||||
|
--add-data "src\sounds\*;sounds" `
|
||||||
--add-data "venv\Lib\site-packages\irc\codes.txt;irc" `
|
--add-data "venv\Lib\site-packages\irc\codes.txt;irc" `
|
||||||
--add-data "icon.ico;." `
|
--add-data "icon.ico;." `
|
||||||
|
--add-data "src\channel.ico;." `
|
||||||
|
--add-data "src\server.ico;." `
|
||||||
--icon "icon.ico" `
|
--icon "icon.ico" `
|
||||||
"src/main.py"
|
"src/main.py"
|
||||||
|
|
||||||
|
|||||||
@@ -31,31 +31,20 @@ class AboutDialog(wx.Dialog):
|
|||||||
info_text = wx.StaticText(self, label="wxIRC Client")
|
info_text = wx.StaticText(self, label="wxIRC Client")
|
||||||
info_font = wx.Font(14, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD)
|
info_font = wx.Font(14, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD)
|
||||||
info_text.SetFont(info_font)
|
info_text.SetFont(info_font)
|
||||||
|
sizer.Add(info_text, 0, wx.ALL | wx.ALIGN_CENTER, 5)
|
||||||
|
|
||||||
import base64, hashlib
|
version_text = wx.StaticText(self, label="Version : 0.2.3") # COMMIT_HUNDRED:COMMIT_TEN:COMMIT_ONE e.g 23 commits -> 0.2.3
|
||||||
|
|
||||||
self.encoded = base64.b64encode(
|
|
||||||
hashlib.sha256(f"{self.GetId()}-{self.GetHandle()}".encode()).digest()
|
|
||||||
).decode('utf-8')
|
|
||||||
|
|
||||||
|
|
||||||
version_text = wx.StaticText(self, label=f"wxIRC V:e1")
|
|
||||||
version_font = wx.Font(10, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)
|
version_font = wx.Font(10, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)
|
||||||
version_text.SetFont(version_font)
|
version_text.SetFont(version_font)
|
||||||
|
|
||||||
|
|
||||||
rand_hash = wx.StaticText(self, label=f"{self.encoded}")
|
|
||||||
rand_hash.SetFont(version_font)
|
|
||||||
|
|
||||||
contrubutors_text = wx.StaticText(self, label="This software may not be used for commercial purposes. \n And may not be distributed for commercial purposes.")
|
|
||||||
contrubutors_font = wx.Font(10, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)
|
|
||||||
contrubutors_text.SetFont(contrubutors_font)
|
|
||||||
|
|
||||||
# Add info to sizer
|
|
||||||
sizer.Add(info_text, 0, wx.ALL | wx.ALIGN_CENTER, 5)
|
|
||||||
sizer.Add(version_text, 0, wx.ALL | wx.ALIGN_CENTER, 5)
|
sizer.Add(version_text, 0, wx.ALL | wx.ALIGN_CENTER, 5)
|
||||||
sizer.Add(rand_hash, 0, wx.ALL | wx.ALIGN_CENTER, 5)
|
|
||||||
sizer.Add(contrubutors_text, 0, wx.ALL | wx.ALIGN_CENTER, 5)
|
contributors_text = wx.StaticText(self, label="""
|
||||||
|
Contributors:
|
||||||
|
* rattatwinko (rattatwinko.servecounterstrike.com)
|
||||||
|
""")
|
||||||
|
contributors_font = wx.Font(10, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)
|
||||||
|
contributors_text.SetFont(contributors_font)
|
||||||
|
sizer.Add(contributors_text, 0, wx.ALL | wx.ALIGN_CENTER, 5)
|
||||||
|
|
||||||
# OK button
|
# OK button
|
||||||
ok_btn = wx.Button(self, wx.ID_OK, "OK")
|
ok_btn = wx.Button(self, wx.ID_OK, "OK")
|
||||||
|
|||||||
161
src/CommandAutocomplete.py
Normal file
161
src/CommandAutocomplete.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import wx
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class CommandAutocomplete(wx.PopupTransientWindow):
|
||||||
|
"""Popup window for IRC command autocomplete, similar to Minecraft."""
|
||||||
|
def __init__(self, parent, commands, on_select_callback):
|
||||||
|
super().__init__(parent, wx.BORDER_SIMPLE)
|
||||||
|
self.on_select_callback = on_select_callback
|
||||||
|
self.commands = commands # List of (command, description) tuples
|
||||||
|
self.filtered_commands = commands.copy()
|
||||||
|
self.selected_index = 0
|
||||||
|
|
||||||
|
self._init_ui()
|
||||||
|
self.Bind(wx.EVT_WINDOW_DESTROY, self.on_destroy)
|
||||||
|
|
||||||
|
def _init_ui(self):
|
||||||
|
panel = wx.Panel(self)
|
||||||
|
main_sizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
|
||||||
|
# Command list
|
||||||
|
self.list_ctrl = wx.ListCtrl(panel, style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_NO_HEADER)
|
||||||
|
self.list_ctrl.InsertColumn(0, "Command", width=120)
|
||||||
|
self.list_ctrl.InsertColumn(1, "Description", width=280)
|
||||||
|
|
||||||
|
self._update_list()
|
||||||
|
|
||||||
|
# Bind events
|
||||||
|
self.list_ctrl.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.on_item_activated)
|
||||||
|
self.list_ctrl.Bind(wx.EVT_LIST_ITEM_SELECTED, self.on_item_selected)
|
||||||
|
self.Bind(wx.EVT_CHAR_HOOK, self.on_char_hook)
|
||||||
|
self.Bind(wx.EVT_KILL_FOCUS, self.on_kill_focus)
|
||||||
|
|
||||||
|
main_sizer.Add(self.list_ctrl, 1, wx.EXPAND | wx.ALL, 2)
|
||||||
|
panel.SetSizer(main_sizer)
|
||||||
|
main_sizer.Fit(panel)
|
||||||
|
self.SetClientSize(panel.GetBestSize())
|
||||||
|
|
||||||
|
# Select first item
|
||||||
|
if self.list_ctrl.GetItemCount() > 0:
|
||||||
|
self.list_ctrl.Select(0)
|
||||||
|
self.selected_index = 0
|
||||||
|
|
||||||
|
def _update_list(self):
|
||||||
|
"""Update the list with filtered commands."""
|
||||||
|
self.list_ctrl.DeleteAllItems()
|
||||||
|
for cmd, desc in self.filtered_commands:
|
||||||
|
idx = self.list_ctrl.InsertItem(self.list_ctrl.GetItemCount(), cmd)
|
||||||
|
self.list_ctrl.SetItem(idx, 1, desc)
|
||||||
|
|
||||||
|
# Resize to fit content (max 8 items visible)
|
||||||
|
item_height = 20
|
||||||
|
max_items = min(8, len(self.filtered_commands))
|
||||||
|
self.list_ctrl.SetSize((410, item_height * max_items + 4))
|
||||||
|
self.SetClientSize((410, item_height * max_items + 4))
|
||||||
|
|
||||||
|
def filter_commands(self, search_text):
|
||||||
|
"""Filter commands based on search text."""
|
||||||
|
search_lower = search_text.lower().strip()
|
||||||
|
if not search_lower:
|
||||||
|
self.filtered_commands = self.commands.copy()
|
||||||
|
else:
|
||||||
|
self.filtered_commands = [
|
||||||
|
(cmd, desc) for cmd, desc in self.commands
|
||||||
|
if cmd.lower().startswith(search_lower)
|
||||||
|
]
|
||||||
|
|
||||||
|
self.selected_index = 0
|
||||||
|
self._update_list()
|
||||||
|
if self.list_ctrl.GetItemCount() > 0:
|
||||||
|
self.list_ctrl.Select(0)
|
||||||
|
|
||||||
|
def on_item_activated(self, event):
|
||||||
|
"""Handle double-click or Enter on item."""
|
||||||
|
idx = event.GetIndex()
|
||||||
|
if 0 <= idx < len(self.filtered_commands):
|
||||||
|
cmd, _ = self.filtered_commands[idx]
|
||||||
|
if self.on_select_callback:
|
||||||
|
self.on_select_callback(cmd)
|
||||||
|
self.safe_dismiss()
|
||||||
|
|
||||||
|
def on_item_selected(self, event):
|
||||||
|
"""Handle item selection."""
|
||||||
|
self.selected_index = event.GetIndex()
|
||||||
|
|
||||||
|
def select_next(self):
|
||||||
|
"""Select next item."""
|
||||||
|
if self.list_ctrl.GetItemCount() > 0:
|
||||||
|
self.selected_index = (self.selected_index + 1) % self.list_ctrl.GetItemCount()
|
||||||
|
self.list_ctrl.Select(self.selected_index)
|
||||||
|
self.list_ctrl.EnsureVisible(self.selected_index)
|
||||||
|
|
||||||
|
def select_previous(self):
|
||||||
|
"""Select previous item."""
|
||||||
|
if self.list_ctrl.GetItemCount() > 0:
|
||||||
|
self.selected_index = (self.selected_index - 1) % self.list_ctrl.GetItemCount()
|
||||||
|
self.list_ctrl.Select(self.selected_index)
|
||||||
|
self.list_ctrl.EnsureVisible(self.selected_index)
|
||||||
|
|
||||||
|
def get_selected_command(self):
|
||||||
|
"""Get the currently selected command."""
|
||||||
|
if 0 <= self.selected_index < len(self.filtered_commands):
|
||||||
|
return self.filtered_commands[self.selected_index][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def on_char_hook(self, event):
|
||||||
|
"""Handle keyboard events."""
|
||||||
|
keycode = event.GetKeyCode()
|
||||||
|
if keycode == wx.WXK_ESCAPE:
|
||||||
|
self.safe_dismiss()
|
||||||
|
elif keycode == wx.WXK_UP:
|
||||||
|
self.select_previous()
|
||||||
|
elif keycode == wx.WXK_DOWN:
|
||||||
|
self.select_next()
|
||||||
|
elif keycode == wx.WXK_RETURN or keycode == wx.WXK_NUMPAD_ENTER:
|
||||||
|
cmd = self.get_selected_command()
|
||||||
|
if cmd and self.on_select_callback:
|
||||||
|
self.on_select_callback(cmd)
|
||||||
|
self.safe_dismiss()
|
||||||
|
else:
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
|
def on_kill_focus(self, event):
|
||||||
|
"""Handle focus loss."""
|
||||||
|
focused = wx.Window.FindFocus()
|
||||||
|
if focused and (focused == self.list_ctrl or self.IsDescendant(focused)):
|
||||||
|
event.Skip()
|
||||||
|
return
|
||||||
|
wx.CallLater(100, self.safe_dismiss)
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
|
def on_destroy(self, event):
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
|
def safe_dismiss(self):
|
||||||
|
"""Safely dismiss the popup."""
|
||||||
|
if not self.IsBeingDeleted() and self.IsShown():
|
||||||
|
try:
|
||||||
|
self.Dismiss()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error dismissing command autocomplete: {e}")
|
||||||
|
|
||||||
|
def show_at_input(self, input_ctrl):
|
||||||
|
"""Show popup near the input control."""
|
||||||
|
input_screen_pos = input_ctrl.ClientToScreen((0, 0))
|
||||||
|
popup_size = self.GetSize()
|
||||||
|
display_size = wx.GetDisplaySize()
|
||||||
|
|
||||||
|
# Show above the input, otherwise below
|
||||||
|
if input_screen_pos.y - popup_size.height > 0:
|
||||||
|
popup_y = input_screen_pos.y - popup_size.height - 2
|
||||||
|
else:
|
||||||
|
popup_y = input_screen_pos.y + input_ctrl.GetSize().height + 2
|
||||||
|
|
||||||
|
# Keep popup on screen horizontally
|
||||||
|
popup_x = max(10, min(input_screen_pos.x, display_size.x - popup_size.width - 10))
|
||||||
|
|
||||||
|
self.Position((popup_x, popup_y), (0, 0))
|
||||||
|
self.Popup()
|
||||||
858
src/IRCPanel.py
858
src/IRCPanel.py
@@ -1,368 +1,367 @@
|
|||||||
import wx
|
import wx
|
||||||
import threading
|
import wx.adv
|
||||||
|
import random
|
||||||
import logging
|
import logging
|
||||||
from SearchDialog import SearchDialog
|
from SearchDialog import SearchDialog
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
import KaomojiPicker as KaomojiPicker
|
||||||
|
import CommandAutocomplete as CommandAutocomplete
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class IRCPanel(wx.Panel):
|
class IRCPanel(wx.Panel):
|
||||||
|
# IRC commands with descriptions
|
||||||
|
IRC_COMMANDS = [
|
||||||
|
("help", "Show available commands"),
|
||||||
|
("join", "Join a channel - /join <channel>"),
|
||||||
|
("part", "Leave current or specified channel - /part [channel]"),
|
||||||
|
("msg", "Send private message - /msg <nick> <message>"),
|
||||||
|
("me", "Send action message - /me <action>"),
|
||||||
|
("nick", "Change nickname - /nick <newnick>"),
|
||||||
|
("whois", "Get user information - /whois <nick>"),
|
||||||
|
("topic", "Get or set channel topic - /topic [newtopic]"),
|
||||||
|
("kick", "Kick user from channel - /kick <user> [reason]"),
|
||||||
|
("away", "Set away status - /away [message]"),
|
||||||
|
("quit", "Disconnect from server - /quit [message]"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Default kaomoji groups
|
||||||
|
KAOMOJI_GROUPS = {
|
||||||
|
"Happy": [
|
||||||
|
":-)", ":)", ":-D", ":D", "^_^", "^o^", "(*^_^*)", "( ^_^)/",
|
||||||
|
"(:3)", "=)", "=]", "^.^", "UwU", "OwO", "^w^", "^u^", "x3",
|
||||||
|
":3", ":3c", "nya~", "n_n", "(>ω<)", ":33", "^3^"
|
||||||
|
],
|
||||||
|
"Sad": [
|
||||||
|
":-(", ":'(", "T_T", ";_;", ">_<", "(-_-)", "(_ _)"
|
||||||
|
],
|
||||||
|
"Angry": [
|
||||||
|
">:(", ">:-(", ">:-O", ">.<", "(-_-)#"
|
||||||
|
],
|
||||||
|
"Love": [
|
||||||
|
"<3", "(*^3^)", "(^^)v", "(^_^)", "*^_^*"
|
||||||
|
],
|
||||||
|
"Surprised": [
|
||||||
|
":-O", ":O", ":-0", "O_O", "o_O", "O_o"
|
||||||
|
],
|
||||||
|
"Sleepy": [
|
||||||
|
"-_-", "(-.-) zzz", "(~_~)", "zzz"
|
||||||
|
],
|
||||||
|
"Shrug": [
|
||||||
|
r"¯\_(._.)_/¯", "(¬_¬)", "(*_*)", "(>_>)", "(<_<)",
|
||||||
|
"(o_O)", "(O_o)", "('_')"
|
||||||
|
],
|
||||||
|
"Blush": [
|
||||||
|
"^///^", "(//▽//)", "(*^///^*)", ">///<", "^_^;",
|
||||||
|
"^///^;", "(*^▽^*)", "(*´▽`*)"
|
||||||
|
],
|
||||||
|
"Other": [
|
||||||
|
"owo", "rawr", ":33p", "xD", ";-)", ";)", ":-P", ":P", ":-|", ":|"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, parent, main_frame):
|
def __init__(self, parent, main_frame):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
self.main_frame = main_frame
|
self.main_frame = main_frame
|
||||||
self.messages = []
|
self.messages = []
|
||||||
|
|
||||||
sizer = wx.BoxSizer(wx.VERTICAL)
|
|
||||||
|
|
||||||
# Use a better font for chat with white theme
|
|
||||||
self.text_ctrl = wx.TextCtrl(self, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH2 | wx.TE_AUTO_URL)
|
|
||||||
|
|
||||||
# White theme colors
|
|
||||||
self.text_ctrl.SetBackgroundColour(wx.Colour(255, 255, 255)) # White background
|
|
||||||
self.text_ctrl.SetForegroundColour(wx.Colour(0, 0, 0)) # Black text
|
|
||||||
|
|
||||||
# Load appropriate font
|
|
||||||
self.font = self.load_system_font()
|
|
||||||
self.text_ctrl.SetFont(self.font)
|
|
||||||
|
|
||||||
sizer.Add(self.text_ctrl, 1, wx.EXPAND | wx.ALL, 0)
|
|
||||||
|
|
||||||
input_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
|
||||||
self.input_ctrl = wx.TextCtrl(self, style=wx.TE_PROCESS_ENTER)
|
|
||||||
self.input_ctrl.SetHint("Type message here …")
|
|
||||||
self.input_ctrl.Bind(wx.EVT_TEXT_ENTER, self.on_send)
|
|
||||||
self.input_ctrl.Bind(wx.EVT_KEY_DOWN, self.on_key_down)
|
|
||||||
|
|
||||||
send_btn = wx.Button(self, label="Send")
|
|
||||||
send_btn.SetToolTip("Send message (Enter)")
|
|
||||||
send_btn.Bind(wx.EVT_BUTTON, self.on_send)
|
|
||||||
|
|
||||||
input_sizer.Add(self.input_ctrl, 1, wx.EXPAND | wx.ALL, 2)
|
|
||||||
input_sizer.Add(send_btn, 0, wx.ALL, 2)
|
|
||||||
|
|
||||||
sizer.Add(input_sizer, 0, wx.EXPAND | wx.ALL, 0)
|
|
||||||
|
|
||||||
self.SetSizer(sizer)
|
|
||||||
self.target = None
|
self.target = None
|
||||||
self.history = []
|
self.history = []
|
||||||
self.history_pos = -1
|
self.history_pos = -1
|
||||||
|
self.current_popup = None
|
||||||
|
self.command_popup = None
|
||||||
|
|
||||||
# Search state
|
# Search state
|
||||||
self.search_text = ""
|
self.search_text = ""
|
||||||
self.search_positions = []
|
self.search_positions = []
|
||||||
self.current_search_index = -1
|
self.current_search_index = -1
|
||||||
|
|
||||||
# Bind Ctrl+F for search
|
# Theme setup
|
||||||
|
self.theme = getattr(self.main_frame, "theme", None)
|
||||||
|
self.default_text_colour = self.theme["text"] if self.theme else wx.Colour(0, 0, 0)
|
||||||
|
|
||||||
|
self._init_ui()
|
||||||
|
self._setup_accelerators()
|
||||||
|
|
||||||
|
def _init_ui(self):
|
||||||
|
"""Initialize the UI components."""
|
||||||
|
sizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
|
||||||
|
# Text display - use TE_RICH2 for styled text support (especially on Windows)
|
||||||
|
self.text_ctrl = wx.TextCtrl(
|
||||||
|
self,
|
||||||
|
style=wx.TE_READONLY | wx.TE_MULTILINE | wx.TE_AUTO_URL | wx.TE_RICH2
|
||||||
|
)
|
||||||
|
self.text_ctrl.SetFont(self._load_system_font())
|
||||||
|
self._apply_theme()
|
||||||
self.text_ctrl.Bind(wx.EVT_KEY_DOWN, self.on_text_key_down)
|
self.text_ctrl.Bind(wx.EVT_KEY_DOWN, self.on_text_key_down)
|
||||||
accel_tbl = wx.AcceleratorTable([(wx.ACCEL_CTRL, ord('F'), wx.ID_FIND)])
|
|
||||||
|
sizer.Add(self.text_ctrl, 1, wx.EXPAND | wx.ALL, 0)
|
||||||
|
|
||||||
|
# Input area
|
||||||
|
input_sizer = self._create_input_area()
|
||||||
|
sizer.Add(input_sizer, 0, wx.EXPAND | wx.ALL, 0)
|
||||||
|
|
||||||
|
self.SetSizer(sizer)
|
||||||
|
|
||||||
|
def _create_input_area(self):
|
||||||
|
"""Create the input controls."""
|
||||||
|
input_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
|
|
||||||
|
# Text input
|
||||||
|
self.input_ctrl = wx.TextCtrl(self, style=wx.TE_PROCESS_ENTER)
|
||||||
|
self.input_ctrl.SetHint("Type message here…")
|
||||||
|
self.input_ctrl.Bind(wx.EVT_TEXT_ENTER, self.on_send)
|
||||||
|
self.input_ctrl.Bind(wx.EVT_KEY_DOWN, self.on_key_down)
|
||||||
|
self.input_ctrl.Bind(wx.EVT_TEXT, self.on_input_text_change)
|
||||||
|
|
||||||
|
# Kaomoji button
|
||||||
|
random_kaomoji = self._get_random_kaomoji()
|
||||||
|
self.kaomoji_btn = wx.Button(self, label=random_kaomoji)
|
||||||
|
self.kaomoji_btn.SetToolTip(f"Kaomojis {random_kaomoji}")
|
||||||
|
self.kaomoji_btn.Bind(wx.EVT_BUTTON, self.on_pick_kaomoji)
|
||||||
|
|
||||||
|
# Send button
|
||||||
|
send_btn = wx.Button(self, label="Send")
|
||||||
|
send_btn.SetToolTip("Send message (Enter)")
|
||||||
|
send_btn.Bind(wx.EVT_BUTTON, self.on_send)
|
||||||
|
|
||||||
|
input_sizer.Add(self.input_ctrl, 1, wx.EXPAND | wx.ALL, 2)
|
||||||
|
input_sizer.Add(self.kaomoji_btn, 0, wx.ALL, 2)
|
||||||
|
input_sizer.Add(send_btn, 0, wx.ALL, 2)
|
||||||
|
|
||||||
|
return input_sizer
|
||||||
|
|
||||||
|
def _setup_accelerators(self):
|
||||||
|
"""Setup keyboard shortcuts."""
|
||||||
|
accel_tbl = wx.AcceleratorTable([
|
||||||
|
(wx.ACCEL_CTRL, ord('F'), wx.ID_FIND)
|
||||||
|
])
|
||||||
self.SetAcceleratorTable(accel_tbl)
|
self.SetAcceleratorTable(accel_tbl)
|
||||||
self.Bind(wx.EVT_MENU, self.on_search, id=wx.ID_FIND)
|
self.Bind(wx.EVT_MENU, self.on_search, id=wx.ID_FIND)
|
||||||
|
|
||||||
def load_system_font(self):
|
def _apply_theme(self):
|
||||||
"""Load appropriate system font with high DPI support"""
|
"""Apply theme colors if available."""
|
||||||
|
if self.theme:
|
||||||
|
self.text_ctrl.SetBackgroundColour(self.theme["content_bg"])
|
||||||
|
self.text_ctrl.SetForegroundColour(self.theme["text"])
|
||||||
|
self.SetBackgroundColour(self.theme["content_bg"])
|
||||||
|
|
||||||
|
def _load_system_font(self):
|
||||||
|
"""Load appropriate system font with DPI awareness."""
|
||||||
try:
|
try:
|
||||||
# Get system DPI scale factor
|
|
||||||
dc = wx.ClientDC(self)
|
dc = wx.ClientDC(self)
|
||||||
dpi_scale = dc.GetPPI().GetWidth() / 96.0 # 96 is standard DPI
|
dpi_scale = dc.GetPPI().GetWidth() / 96.0
|
||||||
|
|
||||||
# Calculate base font size based on DPI
|
# Calculate font size based on DPI
|
||||||
base_size = 10
|
base_size = 10
|
||||||
if dpi_scale > 1.5:
|
if dpi_scale > 1.5:
|
||||||
font_size = int(base_size * 1.5) # 150% scaling
|
font_size = int(base_size * 1.5)
|
||||||
elif dpi_scale > 1.25:
|
elif dpi_scale > 1.25:
|
||||||
font_size = int(base_size * 1.25) # 125% scaling
|
font_size = int(base_size * 1.25)
|
||||||
else:
|
else:
|
||||||
font_size = base_size
|
font_size = base_size
|
||||||
|
|
||||||
# Try system fonts in order of preference
|
# Try fonts in preference order
|
||||||
font_families = [
|
font_preferences = [
|
||||||
(wx.FONTFAMILY_TELETYPE, "Consolas"),
|
"Consolas", "Courier New", "Monaco",
|
||||||
(wx.FONTFAMILY_TELETYPE, "Courier New"),
|
"DejaVu Sans Mono", "Liberation Mono"
|
||||||
(wx.FONTFAMILY_TELETYPE, "Monaco"),
|
|
||||||
(wx.FONTFAMILY_TELETYPE, "DejaVu Sans Mono"),
|
|
||||||
(wx.FONTFAMILY_TELETYPE, "Liberation Mono"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for family, face_name in font_families:
|
for face_name in font_preferences:
|
||||||
font = wx.Font(font_size, family, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False, face_name)
|
font = wx.Font(
|
||||||
|
font_size, wx.FONTFAMILY_TELETYPE,
|
||||||
|
wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL,
|
||||||
|
False, face_name
|
||||||
|
)
|
||||||
if font.IsOk():
|
if font.IsOk():
|
||||||
logger.info(f"Using font: {face_name} at {font_size}pt")
|
logger.info(f"Using font: {face_name} at {font_size}pt")
|
||||||
return font
|
return font
|
||||||
|
|
||||||
# Fallback to default monospace
|
# Fallback
|
||||||
font = wx.Font(font_size, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)
|
logger.info("Using system monospace font")
|
||||||
logger.info("Using system monospace font as fallback")
|
return wx.Font(
|
||||||
return font
|
font_size, wx.FONTFAMILY_TELETYPE,
|
||||||
|
wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading system font: {e}")
|
logger.error(f"Error loading font: {e}")
|
||||||
# Ultimate fallback
|
return wx.Font(
|
||||||
return wx.Font(10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)
|
10, wx.FONTFAMILY_TELETYPE,
|
||||||
|
wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_random_kaomoji(self):
|
||||||
|
"""Get a random kaomoji for the button label."""
|
||||||
|
all_kaomojis = [k for group in self.KAOMOJI_GROUPS.values() for k in group]
|
||||||
|
return random.choice(all_kaomojis) if all_kaomojis else ":)"
|
||||||
|
|
||||||
def set_target(self, target):
|
def set_target(self, target):
|
||||||
|
"""Set the current chat target."""
|
||||||
self.target = target
|
self.target = target
|
||||||
|
|
||||||
def add_message(self, message, username=None, username_color=None, message_color=None, bold=False, italic=False, underline=False):
|
def add_message(self, message, username=None, username_color=None,
|
||||||
"""Thread-safe message addition with username coloring"""
|
message_color=None, bold=False, italic=False, underline=False):
|
||||||
|
"""Thread-safe message addition."""
|
||||||
try:
|
try:
|
||||||
# Use CallAfter for thread safety
|
|
||||||
if wx.IsMainThread():
|
if wx.IsMainThread():
|
||||||
self._add_message_safe(message, username, username_color, message_color, bold, italic, underline)
|
self._add_message_safe(message, username_color, message_color, bold, italic, underline)
|
||||||
else:
|
else:
|
||||||
wx.CallAfter(self._add_message_safe, message, username, username_color, message_color, bold, italic, underline)
|
wx.CallAfter(self._add_message_safe, message, username_color, message_color, bold, italic, underline)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in add_message: {e}")
|
logger.error(f"Error in add_message: {e}")
|
||||||
|
|
||||||
def _add_message_safe(self, message, username=None, username_color=None, message_color=None, bold=False, italic=False, underline=False):
|
def _add_message_safe(self, message, username_color=None, message_color=None,
|
||||||
"""Actually add message - must be called from main thread"""
|
bold=False, italic=False, underline=False):
|
||||||
|
"""Add message to display with formatting (must be called from main thread)."""
|
||||||
try:
|
try:
|
||||||
|
# Safety check: ensure text_ctrl still exists
|
||||||
|
if not self.text_ctrl or not self:
|
||||||
|
return
|
||||||
|
|
||||||
self.messages.append(message)
|
self.messages.append(message)
|
||||||
|
|
||||||
# Save current position for formatting
|
# Check if user is at bottom
|
||||||
start_pos = self.text_ctrl.GetLastPosition()
|
last_pos = self.text_ctrl.GetLastPosition()
|
||||||
|
current_pos = self.text_ctrl.GetInsertionPoint()
|
||||||
|
at_bottom = (last_pos - current_pos) <= 10
|
||||||
|
|
||||||
if username and username_color:
|
# Create text attribute for styling
|
||||||
# Add username with its color
|
text_attr = wx.TextAttr()
|
||||||
attr = wx.TextAttr()
|
|
||||||
attr.SetTextColour(username_color)
|
|
||||||
if bold:
|
|
||||||
attr.SetFontWeight(wx.FONTWEIGHT_BOLD)
|
|
||||||
if italic:
|
|
||||||
attr.SetFontStyle(wx.FONTSTYLE_ITALIC)
|
|
||||||
if underline:
|
|
||||||
attr.SetFontUnderlined(True)
|
|
||||||
attr.SetFont(self.font)
|
|
||||||
self.text_ctrl.SetDefaultStyle(attr)
|
|
||||||
self.text_ctrl.AppendText(username)
|
|
||||||
|
|
||||||
# Add the rest of the message with message color
|
|
||||||
attr = wx.TextAttr()
|
|
||||||
if message_color:
|
if message_color:
|
||||||
attr.SetTextColour(message_color)
|
text_attr.SetTextColour(message_color)
|
||||||
else:
|
else:
|
||||||
attr.SetTextColour(wx.Colour(0, 0, 0)) # Black text for white theme
|
text_attr.SetTextColour(self.default_text_colour)
|
||||||
|
|
||||||
attr.SetFont(self.font)
|
if bold:
|
||||||
self.text_ctrl.SetDefaultStyle(attr)
|
pass # i hate bold
|
||||||
|
if italic:
|
||||||
|
text_attr.SetFontStyle(wx.FONTSTYLE_ITALIC)
|
||||||
|
if underline:
|
||||||
|
text_attr.SetUnderlined(True)
|
||||||
|
|
||||||
# Append the message (without username if we already added it)
|
# Set style and append text
|
||||||
if username and username_color:
|
self.text_ctrl.SetDefaultStyle(text_attr)
|
||||||
# Find the message part after username
|
self.text_ctrl.AppendText(message + "\n")
|
||||||
message_text = message[message.find(username) + len(username):]
|
|
||||||
self.text_ctrl.AppendText(message_text + "\n")
|
|
||||||
else:
|
|
||||||
self.text_ctrl.AppendText(message + "\n")
|
|
||||||
|
|
||||||
# Auto-scroll to bottom
|
# Reset to default style
|
||||||
self.text_ctrl.ShowPosition(self.text_ctrl.GetLastPosition())
|
default_attr = wx.TextAttr()
|
||||||
|
default_attr.SetTextColour(self.default_text_colour)
|
||||||
|
self.text_ctrl.SetDefaultStyle(default_attr)
|
||||||
|
|
||||||
|
# Auto-scroll if at bottom
|
||||||
|
if at_bottom:
|
||||||
|
self.text_ctrl.SetInsertionPoint(self.text_ctrl.GetLastPosition())
|
||||||
|
self.text_ctrl.ShowPosition(self.text_ctrl.GetLastPosition())
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding message safely: {e}")
|
logger.error(f"Error adding message: {e}")
|
||||||
|
|
||||||
def add_formatted_message(self, timestamp, username, content, username_color=None, is_action=False):
|
def add_formatted_message(self, timestamp, username, content,
|
||||||
"""Add a formatted message with colored username"""
|
username_color=None, is_action=False):
|
||||||
|
"""Add a formatted IRC message with colored username."""
|
||||||
try:
|
try:
|
||||||
|
# Check if user is at bottom
|
||||||
|
last_pos = self.text_ctrl.GetLastPosition()
|
||||||
|
current_pos = self.text_ctrl.GetInsertionPoint()
|
||||||
|
at_bottom = (last_pos - current_pos) <= 10
|
||||||
|
|
||||||
|
# Append timestamp with default color
|
||||||
|
default_attr = wx.TextAttr()
|
||||||
|
default_attr.SetTextColour(self.default_text_colour)
|
||||||
|
self.text_ctrl.SetDefaultStyle(default_attr)
|
||||||
|
self.text_ctrl.AppendText(timestamp)
|
||||||
|
|
||||||
|
# Append action marker or opening bracket
|
||||||
if is_action:
|
if is_action:
|
||||||
message = f"{timestamp}* {username} {content}"
|
self.text_ctrl.AppendText("* ")
|
||||||
self.add_message(message, f"* {username}", username_color, wx.Colour(128, 0, 128), italic=True) # Dark purple for actions
|
|
||||||
else:
|
else:
|
||||||
message = f"{timestamp}<{username}> {content}"
|
self.text_ctrl.AppendText("<")
|
||||||
self.add_message(message, f"<{username}>", username_color, wx.Colour(0, 0, 0)) # Black text
|
|
||||||
|
# Append username with color
|
||||||
|
if username_color:
|
||||||
|
username_attr = wx.TextAttr()
|
||||||
|
username_attr.SetTextColour(username_color)
|
||||||
|
username_attr.SetFontWeight(wx.FONTWEIGHT_BOLD)
|
||||||
|
self.text_ctrl.SetDefaultStyle(username_attr)
|
||||||
|
else:
|
||||||
|
default_attr.SetFontWeight(wx.FONTWEIGHT_BOLD)
|
||||||
|
self.text_ctrl.SetDefaultStyle(default_attr)
|
||||||
|
|
||||||
|
self.text_ctrl.AppendText(username)
|
||||||
|
|
||||||
|
# Append closing bracket and content with default color
|
||||||
|
default_attr.SetFontWeight(wx.FONTWEIGHT_NORMAL)
|
||||||
|
self.text_ctrl.SetDefaultStyle(default_attr)
|
||||||
|
if not is_action:
|
||||||
|
self.text_ctrl.AppendText("> ")
|
||||||
|
else:
|
||||||
|
self.text_ctrl.AppendText(" ")
|
||||||
|
|
||||||
|
self.text_ctrl.AppendText(content)
|
||||||
|
self.text_ctrl.AppendText("\n")
|
||||||
|
|
||||||
|
# Auto-scroll if at bottom
|
||||||
|
if at_bottom:
|
||||||
|
self.text_ctrl.SetInsertionPoint(self.text_ctrl.GetLastPosition())
|
||||||
|
self.text_ctrl.ShowPosition(self.text_ctrl.GetLastPosition())
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding formatted message: {e}")
|
logger.error(f"Error adding formatted message: {e}")
|
||||||
|
|
||||||
def add_system_message(self, message, color=None, bold=False):
|
def add_system_message(self, message, color=None, bold=False):
|
||||||
"""Add system message without username coloring"""
|
"""Add a system message with optional color and bold formatting."""
|
||||||
try:
|
try:
|
||||||
if color is None:
|
self.add_message(message, message_color=color, bold=bold)
|
||||||
color = wx.Colour(0, 0, 128) # Dark blue for system messages
|
|
||||||
self.add_message(message, None, None, color, bold, False, False)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding system message: {e}")
|
logger.error(f"Error adding system message: {e}")
|
||||||
|
|
||||||
def on_text_key_down(self, event):
|
def on_pick_kaomoji(self, event):
|
||||||
"""Handle key events in the text control"""
|
"""Show the kaomoji picker popup."""
|
||||||
keycode = event.GetKeyCode()
|
|
||||||
if event.ControlDown() and keycode == ord('F'):
|
|
||||||
self.on_search(event)
|
|
||||||
else:
|
|
||||||
event.Skip()
|
|
||||||
|
|
||||||
def on_search(self, event):
|
|
||||||
"""Open search dialog"""
|
|
||||||
try:
|
try:
|
||||||
dlg = SearchDialog(self)
|
# Close existing popup if any
|
||||||
dlg.ShowModal()
|
if self.current_popup and not self.current_popup.IsBeingDeleted():
|
||||||
dlg.Destroy()
|
self.current_popup.Dismiss()
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in search: {e}")
|
|
||||||
|
|
||||||
def perform_search(self, search_text, case_sensitive=False, whole_word=False):
|
|
||||||
"""Perform text search in the chat history"""
|
|
||||||
try:
|
|
||||||
self.search_text = search_text
|
|
||||||
self.search_positions = []
|
|
||||||
self.current_search_index = -1
|
|
||||||
|
|
||||||
# Get all text
|
# Create new popup
|
||||||
full_text = self.text_ctrl.GetValue()
|
self.current_popup = KaomojiPicker.KaomojiPicker(
|
||||||
if not full_text or not search_text:
|
self,
|
||||||
return
|
self.KAOMOJI_GROUPS,
|
||||||
|
self.on_kaomoji_insert
|
||||||
# Prepare search parameters
|
)
|
||||||
search_flags = 0
|
self.current_popup.show_at_button(self.kaomoji_btn)
|
||||||
if not case_sensitive:
|
|
||||||
# For manual search, we'll handle case sensitivity ourselves
|
|
||||||
search_text_lower = search_text.lower()
|
|
||||||
full_text_lower = full_text.lower()
|
|
||||||
|
|
||||||
# Manual search implementation since wx.TextCtrl doesn't have FindText
|
|
||||||
pos = 0
|
|
||||||
while pos < len(full_text):
|
|
||||||
if case_sensitive:
|
|
||||||
# Case sensitive search
|
|
||||||
found_pos = full_text.find(search_text, pos)
|
|
||||||
else:
|
|
||||||
# Case insensitive search
|
|
||||||
found_pos = full_text_lower.find(search_text_lower, pos)
|
|
||||||
|
|
||||||
if found_pos == -1:
|
|
||||||
break
|
|
||||||
|
|
||||||
# For whole word search, verify boundaries
|
|
||||||
if whole_word:
|
|
||||||
# Check if it's a whole word
|
|
||||||
is_word_start = (found_pos == 0 or not full_text[found_pos-1].isalnum())
|
|
||||||
is_word_end = (found_pos + len(search_text) >= len(full_text) or
|
|
||||||
not full_text[found_pos + len(search_text)].isalnum())
|
|
||||||
|
|
||||||
if is_word_start and is_word_end:
|
|
||||||
self.search_positions.append(found_pos)
|
|
||||||
pos = found_pos + 1 # Move forward to avoid infinite loop
|
|
||||||
else:
|
|
||||||
self.search_positions.append(found_pos)
|
|
||||||
pos = found_pos + len(search_text)
|
|
||||||
|
|
||||||
if self.search_positions:
|
|
||||||
self.current_search_index = 0
|
|
||||||
self.highlight_search_result()
|
|
||||||
self.main_frame.SetStatusText(f"Found {len(self.search_positions)} occurrences of '{search_text}'")
|
|
||||||
else:
|
|
||||||
self.main_frame.SetStatusText(f"Text '{search_text}' not found")
|
|
||||||
wx.Bell()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error performing search: {e}")
|
logger.error(f"Error showing kaomoji picker: {e}")
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
def highlight_search_result(self):
|
def on_kaomoji_insert(self, kaomoji):
|
||||||
"""Highlight the current search result"""
|
"""Insert selected kaomoji into input."""
|
||||||
try:
|
try:
|
||||||
if not self.search_positions or self.current_search_index < 0:
|
current = self.input_ctrl.GetValue()
|
||||||
return
|
|
||||||
|
|
||||||
pos = self.search_positions[self.current_search_index]
|
|
||||||
|
|
||||||
# Select the found text
|
|
||||||
self.text_ctrl.SetSelection(pos, pos + len(self.search_text))
|
|
||||||
self.text_ctrl.ShowPosition(pos)
|
|
||||||
|
|
||||||
# Update status
|
|
||||||
self.main_frame.SetStatusText(
|
|
||||||
f"Found {self.current_search_index + 1} of {len(self.search_positions)}: '{self.search_text}'"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error highlighting search result: {e}")
|
|
||||||
|
|
||||||
def find_next(self):
|
|
||||||
"""Find next occurrence"""
|
|
||||||
if self.search_positions:
|
|
||||||
self.current_search_index = (self.current_search_index + 1) % len(self.search_positions)
|
|
||||||
self.highlight_search_result()
|
|
||||||
|
|
||||||
def find_previous(self):
|
|
||||||
"""Find previous occurrence"""
|
|
||||||
if self.search_positions:
|
|
||||||
self.current_search_index = (self.current_search_index - 1) % len(self.search_positions)
|
|
||||||
self.highlight_search_result()
|
|
||||||
|
|
||||||
def on_key_down(self, event):
|
|
||||||
try:
|
|
||||||
keycode = event.GetKeyCode()
|
|
||||||
if keycode == wx.WXK_UP:
|
|
||||||
if self.history and self.history_pos < len(self.history) - 1:
|
|
||||||
self.history_pos += 1
|
|
||||||
self.input_ctrl.SetValue(self.history[-(self.history_pos + 1)])
|
|
||||||
elif keycode == wx.WXK_DOWN:
|
|
||||||
if self.history_pos > 0:
|
|
||||||
self.history_pos -= 1
|
|
||||||
self.input_ctrl.SetValue(self.history[-(self.history_pos + 1)])
|
|
||||||
elif self.history_pos == 0:
|
|
||||||
self.history_pos = -1
|
|
||||||
self.input_ctrl.Clear()
|
|
||||||
elif keycode == wx.WXK_TAB:
|
|
||||||
# Tab completion for nicknames
|
|
||||||
self.handle_tab_completion()
|
|
||||||
return # Don't skip to prevent default tab behavior
|
|
||||||
elif keycode == wx.WXK_F3:
|
|
||||||
# F3 for find next
|
|
||||||
if self.search_positions:
|
|
||||||
self.find_next()
|
|
||||||
return
|
|
||||||
elif event.ShiftDown() and keycode == wx.WXK_F3:
|
|
||||||
# Shift+F3 for find previous
|
|
||||||
if self.search_positions:
|
|
||||||
self.find_previous()
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
event.Skip()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in key handler: {e}")
|
|
||||||
event.Skip()
|
|
||||||
|
|
||||||
def handle_tab_completion(self):
|
|
||||||
"""Handle tab completion for nicknames"""
|
|
||||||
try:
|
|
||||||
current_text = self.input_ctrl.GetValue()
|
|
||||||
if not current_text or not self.target or not self.target.startswith('#'):
|
|
||||||
return
|
|
||||||
|
|
||||||
users = self.main_frame.channel_users.get(self.target, [])
|
|
||||||
if not users:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Find word at cursor position
|
|
||||||
pos = self.input_ctrl.GetInsertionPoint()
|
pos = self.input_ctrl.GetInsertionPoint()
|
||||||
text_before = current_text[:pos]
|
|
||||||
words = text_before.split()
|
|
||||||
|
|
||||||
if not words:
|
# Add space before kaomoji if needed
|
||||||
return
|
needs_space = pos > 0 and current[pos - 1] not in (' ', '\t', '\n')
|
||||||
|
insert_text = f" {kaomoji}" if needs_space else kaomoji
|
||||||
|
|
||||||
current_word = words[-1]
|
# Insert at cursor
|
||||||
|
new_value = current[:pos] + insert_text + current[pos:]
|
||||||
|
self.input_ctrl.SetValue(new_value)
|
||||||
|
self.input_ctrl.SetInsertionPoint(pos + len(insert_text))
|
||||||
|
self.input_ctrl.SetFocus()
|
||||||
|
|
||||||
# Find matching nicks
|
|
||||||
matches = [user for user in users if user.lower().startswith(current_word.lower())]
|
|
||||||
|
|
||||||
if matches:
|
|
||||||
if len(matches) == 1:
|
|
||||||
# Single match - complete it
|
|
||||||
new_word = matches[0]
|
|
||||||
if ':' in text_before or text_before.strip().endswith(current_word):
|
|
||||||
# Replace the current word
|
|
||||||
new_text = text_before[:-len(current_word)] + new_word + current_text[pos:]
|
|
||||||
self.input_ctrl.SetValue(new_text)
|
|
||||||
self.input_ctrl.SetInsertionPoint(pos - len(current_word) + len(new_word))
|
|
||||||
else:
|
|
||||||
# Multiple matches - show in status
|
|
||||||
self.main_frame.SetStatusText(f"Tab completion: {', '.join(matches[:5])}{'...' if len(matches) > 5 else ''}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in tab completion: {e}")
|
logger.error(f"Error inserting kaomoji: {e}")
|
||||||
|
|
||||||
def on_send(self, event):
|
def on_send(self, event):
|
||||||
|
"""Send the current message."""
|
||||||
try:
|
try:
|
||||||
|
self._hide_command_popup() # Hide command popup when sending
|
||||||
message = self.input_ctrl.GetValue().strip()
|
message = self.input_ctrl.GetValue().strip()
|
||||||
if message and self.target:
|
if message and self.target:
|
||||||
self.history.append(message)
|
self.history.append(message)
|
||||||
@@ -371,3 +370,316 @@ class IRCPanel(wx.Panel):
|
|||||||
self.input_ctrl.Clear()
|
self.input_ctrl.Clear()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending message: {e}")
|
logger.error(f"Error sending message: {e}")
|
||||||
|
|
||||||
|
def on_input_text_change(self, event):
|
||||||
|
"""Handle text changes in input field to show command autocomplete."""
|
||||||
|
try:
|
||||||
|
current_text = self.input_ctrl.GetValue()
|
||||||
|
pos = self.input_ctrl.GetInsertionPoint()
|
||||||
|
|
||||||
|
# Check if we're typing a command (starts with /)
|
||||||
|
if current_text.startswith('/') and pos > 0:
|
||||||
|
# Extract the command part (everything after / up to cursor or space)
|
||||||
|
text_before_cursor = current_text[:pos]
|
||||||
|
if ' ' in text_before_cursor:
|
||||||
|
# Command already has arguments, don't show autocomplete
|
||||||
|
self._hide_command_popup()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get the command being typed
|
||||||
|
command_part = text_before_cursor[1:] # Remove the '/'
|
||||||
|
self._show_command_popup(command_part)
|
||||||
|
else:
|
||||||
|
self._hide_command_popup()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in text change handler: {e}")
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
|
def _show_command_popup(self, command_part=""):
|
||||||
|
"""Show or update command autocomplete popup."""
|
||||||
|
try:
|
||||||
|
if not self.command_popup or self.command_popup.IsBeingDeleted():
|
||||||
|
# Create new popup
|
||||||
|
self.command_popup = CommandAutocomplete.CommandAutocomplete( # fuckass python
|
||||||
|
self,
|
||||||
|
self.IRC_COMMANDS,
|
||||||
|
self.on_command_select
|
||||||
|
)
|
||||||
|
self.command_popup.show_at_input(self.input_ctrl)
|
||||||
|
|
||||||
|
# Filter commands
|
||||||
|
self.command_popup.filter_commands(command_part)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error showing command popup: {e}")
|
||||||
|
|
||||||
|
def _hide_command_popup(self):
|
||||||
|
"""Hide command autocomplete popup."""
|
||||||
|
try:
|
||||||
|
if self.command_popup and not self.command_popup.IsBeingDeleted():
|
||||||
|
self.command_popup.safe_dismiss()
|
||||||
|
self.command_popup = None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error hiding command popup: {e}")
|
||||||
|
|
||||||
|
def on_command_select(self, command):
|
||||||
|
"""Handle command selection from autocomplete."""
|
||||||
|
try:
|
||||||
|
current_text = self.input_ctrl.GetValue()
|
||||||
|
pos = self.input_ctrl.GetInsertionPoint()
|
||||||
|
|
||||||
|
# Find the command part (from / to cursor or space)
|
||||||
|
text_before_cursor = current_text[:pos]
|
||||||
|
if text_before_cursor.startswith('/'):
|
||||||
|
# Replace the command part with the selected command
|
||||||
|
if ' ' in text_before_cursor:
|
||||||
|
# Has arguments, keep them
|
||||||
|
space_pos = text_before_cursor.find(' ')
|
||||||
|
new_text = f"/{command}{current_text[space_pos:]}"
|
||||||
|
else:
|
||||||
|
# No arguments, just replace command
|
||||||
|
new_text = f"/{command} {current_text[pos:]}"
|
||||||
|
|
||||||
|
self.input_ctrl.SetValue(new_text)
|
||||||
|
# Position cursor after command and space
|
||||||
|
new_pos = len(f"/{command} ")
|
||||||
|
self.input_ctrl.SetInsertionPoint(new_pos)
|
||||||
|
self.input_ctrl.SetFocus()
|
||||||
|
|
||||||
|
self._hide_command_popup()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error selecting command: {e}")
|
||||||
|
|
||||||
|
def on_key_down(self, event):
|
||||||
|
"""Handle input field keyboard events."""
|
||||||
|
try:
|
||||||
|
keycode = event.GetKeyCode()
|
||||||
|
|
||||||
|
# Handle command popup navigation
|
||||||
|
if self.command_popup and not self.command_popup.IsBeingDeleted() and self.command_popup.IsShown():
|
||||||
|
if keycode == wx.WXK_UP:
|
||||||
|
self.command_popup.select_previous()
|
||||||
|
return # Don't skip, handled
|
||||||
|
elif keycode == wx.WXK_DOWN:
|
||||||
|
self.command_popup.select_next()
|
||||||
|
return # Don't skip, handled
|
||||||
|
elif keycode == wx.WXK_RETURN or keycode == wx.WXK_NUMPAD_ENTER:
|
||||||
|
# If popup is shown, select command instead of sending
|
||||||
|
cmd = self.command_popup.get_selected_command()
|
||||||
|
if cmd:
|
||||||
|
self.on_command_select(cmd)
|
||||||
|
return # Don't skip, handled
|
||||||
|
elif keycode == wx.WXK_ESCAPE:
|
||||||
|
self._hide_command_popup()
|
||||||
|
return # Don't skip, handled
|
||||||
|
|
||||||
|
if keycode == wx.WXK_UP:
|
||||||
|
self._navigate_history_up()
|
||||||
|
elif keycode == wx.WXK_DOWN:
|
||||||
|
self._navigate_history_down()
|
||||||
|
elif keycode == wx.WXK_TAB:
|
||||||
|
self.handle_tab_completion()
|
||||||
|
return # Don't skip
|
||||||
|
elif keycode == wx.WXK_F3:
|
||||||
|
if event.ShiftDown():
|
||||||
|
self.find_previous()
|
||||||
|
else:
|
||||||
|
self.find_next()
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in key handler: {e}")
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
|
def _navigate_history_up(self):
|
||||||
|
"""Navigate up in message history."""
|
||||||
|
if self.history and self.history_pos < len(self.history) - 1:
|
||||||
|
self.history_pos += 1
|
||||||
|
self.input_ctrl.SetValue(self.history[-(self.history_pos + 1)])
|
||||||
|
|
||||||
|
def _navigate_history_down(self):
|
||||||
|
"""Navigate down in message history."""
|
||||||
|
if self.history_pos > 0:
|
||||||
|
self.history_pos -= 1
|
||||||
|
self.input_ctrl.SetValue(self.history[-(self.history_pos + 1)])
|
||||||
|
elif self.history_pos == 0:
|
||||||
|
self.history_pos = -1
|
||||||
|
self.input_ctrl.Clear()
|
||||||
|
|
||||||
|
def handle_tab_completion(self):
|
||||||
|
"""Handle tab completion for commands and nicknames."""
|
||||||
|
try:
|
||||||
|
current_text = self.input_ctrl.GetValue()
|
||||||
|
if not current_text:
|
||||||
|
return
|
||||||
|
|
||||||
|
pos = self.input_ctrl.GetInsertionPoint()
|
||||||
|
text_before = current_text[:pos]
|
||||||
|
|
||||||
|
# Check if we're completing a command (starts with / and no space yet)
|
||||||
|
if text_before.startswith('/') and ' ' not in text_before:
|
||||||
|
command_part = text_before[1:] # Remove the '/'
|
||||||
|
matches = [cmd for cmd, _ in self.IRC_COMMANDS if cmd.lower().startswith(command_part.lower())]
|
||||||
|
|
||||||
|
if len(matches) == 1:
|
||||||
|
# Single match - complete it
|
||||||
|
new_text = f"/{matches[0]} {current_text[pos:]}"
|
||||||
|
self.input_ctrl.SetValue(new_text)
|
||||||
|
self.input_ctrl.SetInsertionPoint(len(f"/{matches[0]} "))
|
||||||
|
elif len(matches) > 1:
|
||||||
|
# Multiple matches - show autocomplete popup or status
|
||||||
|
if not self.command_popup or self.command_popup.IsBeingDeleted():
|
||||||
|
self._show_command_popup(command_part)
|
||||||
|
else:
|
||||||
|
# Update existing popup
|
||||||
|
self.command_popup.filter_commands(command_part)
|
||||||
|
display = ', '.join(matches[:5])
|
||||||
|
if len(matches) > 5:
|
||||||
|
display += '...'
|
||||||
|
self.main_frame.SetStatusText(f"Tab completion: {display}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Otherwise, try nickname completion (only in channels)
|
||||||
|
if not self.target or not self.target.startswith('#'):
|
||||||
|
return
|
||||||
|
|
||||||
|
users = self.main_frame.channel_users.get(self.target, [])
|
||||||
|
if not users:
|
||||||
|
return
|
||||||
|
|
||||||
|
words = text_before.split()
|
||||||
|
if not words:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_word = words[-1]
|
||||||
|
matches = [u for u in users if u.lower().startswith(current_word.lower())]
|
||||||
|
|
||||||
|
if len(matches) == 1:
|
||||||
|
# Single match - complete it
|
||||||
|
new_word = matches[0]
|
||||||
|
new_text = text_before[:-len(current_word)] + new_word + current_text[pos:]
|
||||||
|
self.input_ctrl.SetValue(new_text)
|
||||||
|
self.input_ctrl.SetInsertionPoint(pos - len(current_word) + len(new_word))
|
||||||
|
elif len(matches) > 1:
|
||||||
|
# Multiple matches - show options
|
||||||
|
display = ', '.join(matches[:5])
|
||||||
|
if len(matches) > 5:
|
||||||
|
display += '...'
|
||||||
|
self.main_frame.SetStatusText(f"Tab completion: {display}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in tab completion: {e}")
|
||||||
|
|
||||||
|
# Search functionality
|
||||||
|
def on_text_key_down(self, event):
|
||||||
|
"""Handle text control keyboard events."""
|
||||||
|
if event.ControlDown() and event.GetKeyCode() == ord('F'):
|
||||||
|
self.on_search(event)
|
||||||
|
else:
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
|
def on_search(self, event):
|
||||||
|
"""Open search dialog."""
|
||||||
|
try:
|
||||||
|
dlg = SearchDialog(self)
|
||||||
|
dlg.ShowModal()
|
||||||
|
dlg.Destroy()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error opening search: {e}")
|
||||||
|
|
||||||
|
def perform_search(self, search_text, case_sensitive=False, whole_word=False):
|
||||||
|
"""Perform text search in chat history."""
|
||||||
|
try:
|
||||||
|
self.search_text = search_text
|
||||||
|
self.search_positions = []
|
||||||
|
self.current_search_index = -1
|
||||||
|
|
||||||
|
full_text = self.text_ctrl.GetValue()
|
||||||
|
if not full_text or not search_text:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Prepare for case-insensitive search
|
||||||
|
search_lower = search_text.lower() if not case_sensitive else search_text
|
||||||
|
text_lower = full_text.lower() if not case_sensitive else full_text
|
||||||
|
|
||||||
|
# Find all occurrences
|
||||||
|
pos = 0
|
||||||
|
while pos < len(full_text):
|
||||||
|
found_pos = text_lower.find(search_lower, pos)
|
||||||
|
if found_pos == -1:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check whole word constraint
|
||||||
|
if whole_word:
|
||||||
|
is_start = found_pos == 0 or not full_text[found_pos - 1].isalnum()
|
||||||
|
is_end = (found_pos + len(search_text) >= len(full_text) or
|
||||||
|
not full_text[found_pos + len(search_text)].isalnum())
|
||||||
|
|
||||||
|
if is_start and is_end:
|
||||||
|
self.search_positions.append(found_pos)
|
||||||
|
pos = found_pos + 1
|
||||||
|
else:
|
||||||
|
self.search_positions.append(found_pos)
|
||||||
|
pos = found_pos + len(search_text)
|
||||||
|
|
||||||
|
# Show results
|
||||||
|
if self.search_positions:
|
||||||
|
self.current_search_index = 0
|
||||||
|
self.highlight_search_result()
|
||||||
|
self.main_frame.SetStatusText(
|
||||||
|
f"Found {len(self.search_positions)} occurrences of '{search_text}'"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.main_frame.SetStatusText(f"Text '{search_text}' not found")
|
||||||
|
wx.Bell()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error performing search: {e}")
|
||||||
|
|
||||||
|
def highlight_search_result(self):
|
||||||
|
"""Highlight current search result."""
|
||||||
|
try:
|
||||||
|
if not self.search_positions or self.current_search_index < 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
pos = self.search_positions[self.current_search_index]
|
||||||
|
self.text_ctrl.SetSelection(pos, pos + len(self.search_text))
|
||||||
|
self.text_ctrl.ShowPosition(pos)
|
||||||
|
|
||||||
|
self.main_frame.SetStatusText(
|
||||||
|
f"Found {self.current_search_index + 1} of "
|
||||||
|
f"{len(self.search_positions)}: '{self.search_text}'"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error highlighting search: {e}")
|
||||||
|
|
||||||
|
def find_next(self):
|
||||||
|
"""Find next search result."""
|
||||||
|
if self.search_positions:
|
||||||
|
self.current_search_index = (
|
||||||
|
(self.current_search_index + 1) % len(self.search_positions)
|
||||||
|
)
|
||||||
|
self.highlight_search_result()
|
||||||
|
|
||||||
|
def find_previous(self):
|
||||||
|
"""Find previous search result."""
|
||||||
|
if self.search_positions:
|
||||||
|
self.current_search_index = (
|
||||||
|
(self.current_search_index - 1) % len(self.search_positions)
|
||||||
|
)
|
||||||
|
self.highlight_search_result()
|
||||||
|
|
||||||
|
def insert_text_at_caret(self, text):
|
||||||
|
"""Insert text at current caret position."""
|
||||||
|
try:
|
||||||
|
current = self.input_ctrl.GetValue()
|
||||||
|
pos = self.input_ctrl.GetInsertionPoint()
|
||||||
|
new_value = current[:pos] + text + current[pos:]
|
||||||
|
self.input_ctrl.SetValue(new_value)
|
||||||
|
self.input_ctrl.SetInsertionPoint(pos + len(text))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error inserting text: {e}")
|
||||||
103
src/InterfaceSelectDialog.py
Normal file
103
src/InterfaceSelectDialog.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import wx
|
||||||
|
import socket
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class InterfaceSelectDialog(wx.Dialog):
|
||||||
|
"""Dialog that lets the user pick which local interface the server should bind to."""
|
||||||
|
|
||||||
|
def __init__(self, parent, current_host="127.0.0.1"):
|
||||||
|
super().__init__(parent, title="Select Network Interface", size=(420, 380))
|
||||||
|
try:
|
||||||
|
self.SetIcon(parent.GetIcon())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.selected_host = current_host
|
||||||
|
panel = wx.Panel(self)
|
||||||
|
panel.SetBackgroundColour(parent.theme["window_bg"])
|
||||||
|
main_sizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
|
||||||
|
info = wx.StaticText(
|
||||||
|
panel,
|
||||||
|
label="Choose the Network interface where your server should run:\n"
|
||||||
|
"You are EXPOSING a Server to your LOCAL Network, this may give away who you are!\n",
|
||||||
|
)
|
||||||
|
info.Wrap(380)
|
||||||
|
main_sizer.Add(info, 0, wx.ALL | wx.EXPAND, 10)
|
||||||
|
|
||||||
|
self.interface_list = wx.ListCtrl(panel, style=wx.LC_REPORT | wx.BORDER_SUNKEN)
|
||||||
|
self.interface_list.InsertColumn(0, "Interface", width=180)
|
||||||
|
self.interface_list.InsertColumn(1, "Address", width=180)
|
||||||
|
|
||||||
|
self.interfaces = self._gather_interfaces()
|
||||||
|
current_index = 0
|
||||||
|
for idx, entry in enumerate[tuple[str, str]](self.interfaces):
|
||||||
|
name, address = entry
|
||||||
|
self.interface_list.InsertItem(idx, name)
|
||||||
|
self.interface_list.SetItem(idx, 1, address)
|
||||||
|
if address == current_host:
|
||||||
|
current_index = idx
|
||||||
|
self.interface_list.Select(current_index)
|
||||||
|
self.interface_list.EnsureVisible(current_index)
|
||||||
|
if self.interfaces:
|
||||||
|
self.selected_host = self.interfaces[current_index][1]
|
||||||
|
self.interface_list.Bind(wx.EVT_LIST_ITEM_SELECTED, self.on_select)
|
||||||
|
self.interface_list.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.on_activate)
|
||||||
|
main_sizer.Add(self.interface_list, 1, wx.ALL | wx.EXPAND, 10)
|
||||||
|
|
||||||
|
button_bar = wx.StdDialogButtonSizer()
|
||||||
|
ok_btn = wx.Button(panel, wx.ID_OK)
|
||||||
|
cancel_btn = wx.Button(panel, wx.ID_CANCEL)
|
||||||
|
button_bar.AddButton(ok_btn)
|
||||||
|
button_bar.AddButton(cancel_btn)
|
||||||
|
button_bar.Realize()
|
||||||
|
main_sizer.Add(button_bar, 0, wx.ALL | wx.EXPAND, 10)
|
||||||
|
|
||||||
|
ok_btn.Bind(wx.EVT_BUTTON, self.on_ok)
|
||||||
|
|
||||||
|
panel.SetSizer(main_sizer)
|
||||||
|
|
||||||
|
def on_select(self, event):
|
||||||
|
index = event.GetIndex()
|
||||||
|
_, address = self.interfaces[index]
|
||||||
|
self.selected_host = address
|
||||||
|
|
||||||
|
def _gather_interfaces(self):
|
||||||
|
entries = [
|
||||||
|
("Loopback only", "127.0.0.1"),
|
||||||
|
("All interfaces", "0.0.0.0"),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
seen = {addr for _, addr in entries}
|
||||||
|
for name, addrs in psutil.net_if_addrs().items():
|
||||||
|
for addr in addrs:
|
||||||
|
if addr.family == socket.AF_INET and addr.address not in seen:
|
||||||
|
label = f"{name}"
|
||||||
|
entries.append((label, addr.address))
|
||||||
|
seen.add(addr.address)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unable to enumerate network interfaces: {e}")
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def get_selected_host(self):
|
||||||
|
return self.selected_host
|
||||||
|
|
||||||
|
def on_activate(self, event):
|
||||||
|
self.on_select(event)
|
||||||
|
self.EndModal(wx.ID_OK)
|
||||||
|
|
||||||
|
def on_ok(self, event):
|
||||||
|
index = self.interface_list.GetFirstSelected()
|
||||||
|
if index == -1 and self.interfaces:
|
||||||
|
wx.MessageBox("Select an interface before starting the server.", "No Interface Selected", wx.OK | wx.ICON_INFORMATION)
|
||||||
|
return
|
||||||
|
if index != -1:
|
||||||
|
_, address = self.interfaces[index]
|
||||||
|
self.selected_host = address
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
197
src/KaomojiPicker.py
Normal file
197
src/KaomojiPicker.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
import wx
|
||||||
|
import wx.adv
|
||||||
|
|
||||||
|
# Set up logging
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class KaomojiPicker(wx.PopupTransientWindow):
|
||||||
|
def __init__(self, parent, kaomoji_groups, on_select_callback):
|
||||||
|
super().__init__(parent, wx.BORDER_SIMPLE)
|
||||||
|
self.on_select_callback = on_select_callback
|
||||||
|
self.kaomoji_groups = kaomoji_groups
|
||||||
|
|
||||||
|
self._init_ui()
|
||||||
|
self.Bind(wx.EVT_WINDOW_DESTROY, self.on_destroy)
|
||||||
|
|
||||||
|
def _init_ui(self):
|
||||||
|
panel = wx.Panel(self)
|
||||||
|
main_sizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
|
||||||
|
# Scrolled content area
|
||||||
|
self.scroll = wx.ScrolledWindow(panel, size=(380, 420), style=wx.VSCROLL)
|
||||||
|
self.scroll.SetScrollRate(0, 15)
|
||||||
|
self.scroll_sizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
|
||||||
|
# Storage for filtering
|
||||||
|
self.all_buttons = []
|
||||||
|
self.group_headers = {}
|
||||||
|
self.group_containers = {}
|
||||||
|
|
||||||
|
# Build kaomoji groups
|
||||||
|
self._build_kaomoji_groups()
|
||||||
|
|
||||||
|
self.scroll.SetSizer(self.scroll_sizer)
|
||||||
|
main_sizer.Add(self.scroll, 1, wx.EXPAND)
|
||||||
|
|
||||||
|
panel.SetSizer(main_sizer)
|
||||||
|
main_sizer.Fit(panel)
|
||||||
|
self.SetClientSize(panel.GetBestSize())
|
||||||
|
|
||||||
|
# Bind events
|
||||||
|
self._bind_events(panel)
|
||||||
|
|
||||||
|
def _build_kaomoji_groups(self):
|
||||||
|
dc = wx.ClientDC(self.scroll)
|
||||||
|
dc.SetFont(self.scroll.GetFont())
|
||||||
|
|
||||||
|
self.scroll_sizer.AddSpacer(8)
|
||||||
|
|
||||||
|
for group_name, kaomojis in self.kaomoji_groups.items():
|
||||||
|
if not kaomojis:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Group header
|
||||||
|
header = self._create_group_header(group_name)
|
||||||
|
self.group_headers[group_name] = header
|
||||||
|
self.scroll_sizer.Add(header, 0, wx.LEFT | wx.TOP | wx.BOTTOM, 12)
|
||||||
|
|
||||||
|
# Wrap sizer for buttons
|
||||||
|
wrap_sizer = wx.WrapSizer(wx.HORIZONTAL)
|
||||||
|
|
||||||
|
for kaomoji in kaomojis:
|
||||||
|
btn = self._create_kaomoji_button(kaomoji, dc, group_name, wrap_sizer)
|
||||||
|
self.all_buttons.append(btn)
|
||||||
|
wrap_sizer.Add(btn, 0, wx.ALL, 3)
|
||||||
|
|
||||||
|
self.group_containers[group_name] = wrap_sizer
|
||||||
|
self.scroll_sizer.Add(wrap_sizer, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 12)
|
||||||
|
|
||||||
|
def _create_group_header(self, group_name):
|
||||||
|
header = wx.StaticText(self.scroll, label=group_name)
|
||||||
|
font = header.GetFont()
|
||||||
|
font.SetWeight(wx.FONTWEIGHT_BOLD)
|
||||||
|
font.SetPointSize(9)
|
||||||
|
header.SetFont(font)
|
||||||
|
return header
|
||||||
|
|
||||||
|
def _create_kaomoji_button(self, kaomoji, dc, group_name, wrap_sizer):
|
||||||
|
text_width, text_height = dc.GetTextExtent(kaomoji)
|
||||||
|
btn_width = min(max(text_width + 20, 55), 140)
|
||||||
|
btn_height = text_height + 14
|
||||||
|
|
||||||
|
btn = wx.Button(self.scroll, label=kaomoji, size=(btn_width, btn_height))
|
||||||
|
btn.SetToolTip(f"{kaomoji} - {group_name}")
|
||||||
|
|
||||||
|
btn._kaomoji_text = kaomoji.lower()
|
||||||
|
btn._kaomoji_group = group_name.lower()
|
||||||
|
btn._group_name = group_name
|
||||||
|
|
||||||
|
# Bind click event
|
||||||
|
btn.Bind(wx.EVT_BUTTON, lambda evt, k=kaomoji: self.on_kaomoji_selected(k))
|
||||||
|
|
||||||
|
return btn
|
||||||
|
|
||||||
|
def _bind_events(self, panel):
|
||||||
|
panel.Bind(wx.EVT_CHAR_HOOK, self.on_char_hook)
|
||||||
|
self.Bind(wx.EVT_KILL_FOCUS, self.on_kill_focus)
|
||||||
|
|
||||||
|
def on_kaomoji_selected(self, kaomoji):
|
||||||
|
try:
|
||||||
|
if self.on_select_callback:
|
||||||
|
self.on_select_callback(kaomoji)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in kaomoji selection callback: {e}")
|
||||||
|
finally:
|
||||||
|
# Safely dismiss the popup
|
||||||
|
wx.CallAfter(self.safe_dismiss)
|
||||||
|
|
||||||
|
def on_search(self, event):
|
||||||
|
try:
|
||||||
|
search_text = self.search_ctrl.GetValue().lower().strip()
|
||||||
|
|
||||||
|
if not search_text:
|
||||||
|
self._show_all()
|
||||||
|
return
|
||||||
|
|
||||||
|
visible_groups = set()
|
||||||
|
|
||||||
|
for btn in self.all_buttons:
|
||||||
|
# Match kaomoji text or group name
|
||||||
|
is_match = (search_text in btn._kaomoji_text or
|
||||||
|
search_text in btn._kaomoji_group)
|
||||||
|
btn.Show(is_match)
|
||||||
|
|
||||||
|
if is_match:
|
||||||
|
visible_groups.add(btn._group_name)
|
||||||
|
|
||||||
|
# Show/hide group headers
|
||||||
|
for group_name, header in self.group_headers.items():
|
||||||
|
header.Show(group_name in visible_groups)
|
||||||
|
|
||||||
|
self.scroll.Layout()
|
||||||
|
self.scroll.FitInside()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in kaomoji search: {e}")
|
||||||
|
|
||||||
|
def on_clear_search(self, event):
|
||||||
|
self.search_ctrl.SetValue("")
|
||||||
|
self._show_all()
|
||||||
|
|
||||||
|
def _show_all(self):
|
||||||
|
for btn in self.all_buttons:
|
||||||
|
btn.Show()
|
||||||
|
for header in self.group_headers.values():
|
||||||
|
header.Show()
|
||||||
|
self.scroll.Layout()
|
||||||
|
self.scroll.FitInside()
|
||||||
|
|
||||||
|
def on_char_hook(self, event):
|
||||||
|
if event.GetKeyCode() == wx.WXK_ESCAPE:
|
||||||
|
self.safe_dismiss()
|
||||||
|
else:
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
|
def on_kill_focus(self, event):
|
||||||
|
focused = wx.Window.FindFocus()
|
||||||
|
if focused and (focused == self.search_ctrl or
|
||||||
|
focused.GetParent() == self.scroll or
|
||||||
|
self.scroll.IsDescendant(focused)):
|
||||||
|
event.Skip()
|
||||||
|
return
|
||||||
|
|
||||||
|
wx.CallLater(100, self.safe_dismiss)
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
|
def on_destroy(self, event):
|
||||||
|
self.all_buttons.clear()
|
||||||
|
self.group_headers.clear()
|
||||||
|
self.group_containers.clear()
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
|
def safe_dismiss(self):
|
||||||
|
if not self.IsBeingDeleted() and self.IsShown():
|
||||||
|
try:
|
||||||
|
self.Dismiss()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error dismissing popup: {e}")
|
||||||
|
|
||||||
|
def show_at_button(self, button):
|
||||||
|
btn_screen_pos = button.ClientToScreen((0, 0))
|
||||||
|
popup_size = self.GetSize()
|
||||||
|
display_size = wx.GetDisplaySize()
|
||||||
|
|
||||||
|
# Try to show above the button, otherwise below
|
||||||
|
if btn_screen_pos.y - popup_size.height > 0:
|
||||||
|
popup_y = btn_screen_pos.y - popup_size.height
|
||||||
|
else:
|
||||||
|
popup_y = btn_screen_pos.y + button.GetSize().height
|
||||||
|
|
||||||
|
# Keep popup on screen horizontally
|
||||||
|
popup_x = max(10, min(btn_screen_pos.x, display_size.x - popup_size.width - 10))
|
||||||
|
|
||||||
|
self.Position((popup_x, popup_y), (0, 0))
|
||||||
|
self.Popup()
|
||||||
102
src/LocalServer.py
Normal file
102
src/LocalServer.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
from typing import Callable, Iterable, List, Optional
|
||||||
|
from irc import server as irc_server
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class LocalOnlyIRCServer(irc_server.IRCServer):
|
||||||
|
"""IRC server that only accepts connections from local/LAN addresses."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
server_address,
|
||||||
|
request_handler_class,
|
||||||
|
allowed_networks: Iterable[ipaddress.IPv4Network],
|
||||||
|
blocked_callback: Optional[Callable[[str], None]] = None,
|
||||||
|
):
|
||||||
|
self.allowed_networks = list(allowed_networks)
|
||||||
|
self.blocked_callback = blocked_callback
|
||||||
|
super().__init__(server_address, request_handler_class)
|
||||||
|
|
||||||
|
def verify_request(self, request, client_address):
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(client_address[0])
|
||||||
|
for network in self.allowed_networks:
|
||||||
|
if ip in network:
|
||||||
|
return super().verify_request(request, client_address)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Rejected malformed IP address: %s", client_address[0])
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.blocked_callback:
|
||||||
|
self.blocked_callback(client_address[0])
|
||||||
|
logger.warning("Rejected non-LAN connection from %s", client_address[0])
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import signal
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Run a local-only IRC server."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--host", type=str, default="0.0.0.0",
|
||||||
|
help="Bind host (default: 0.0.0.0)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port", type=int, default=6667,
|
||||||
|
help="Bind port (default: 6667)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--channels", type=str, default="#lobby",
|
||||||
|
help="Comma-separated list of channels (default: #lobby)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose", action="store_true",
|
||||||
|
help="Enable verbose logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Set up logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize the server manager
|
||||||
|
manager = LocalServerManager( # pyright: ignore[reportUndefinedVariable]
|
||||||
|
listen_host=args.host,
|
||||||
|
listen_port=args.port
|
||||||
|
)
|
||||||
|
manager.set_channels([ch.strip() for ch in args.channels.split(",") if ch.strip()])
|
||||||
|
|
||||||
|
# Handle Ctrl+C gracefully
|
||||||
|
def signal_handler(sig, frame):
|
||||||
|
print("\nStopping server...")
|
||||||
|
manager.stop()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|
||||||
|
try:
|
||||||
|
manager.start()
|
||||||
|
print(f"IRC server running on {args.host}:{args.port}")
|
||||||
|
# Keep the main thread alive while server runs
|
||||||
|
while manager.is_running():
|
||||||
|
import time
|
||||||
|
time.sleep(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
manager.stop()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
main()
|
||||||
179
src/LocalServerManager.py
Normal file
179
src/LocalServerManager.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
from typing import Callable, Iterable, List, Optional
|
||||||
|
from LocalServer import LocalOnlyIRCServer
|
||||||
|
from irc import server as irc_server
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def _default_log_callback(message: str, color=None, bold: bool = False):
|
||||||
|
"""Fallback logger when UI callback is not available."""
|
||||||
|
logger.info(message)
|
||||||
|
|
||||||
|
class LocalServerManager:
|
||||||
|
"""Manages the background IRC server lifecycle."""
|
||||||
|
|
||||||
|
DEFAULT_CHANNELS = ["#lobby"]
|
||||||
|
DEFAULT_ALLOWED_NETWORKS = [
|
||||||
|
ipaddress.ip_network("127.0.0.0/8"), # Loopback
|
||||||
|
ipaddress.ip_network("10.0.0.0/8"), # RFC1918
|
||||||
|
ipaddress.ip_network("172.16.0.0/12"), # RFC1918
|
||||||
|
ipaddress.ip_network("192.168.0.0/16"), # RFC1918
|
||||||
|
ipaddress.ip_network("169.254.0.0/16"), # Link-local
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
log_callback: Callable[[str, Optional[object], bool], None] = _default_log_callback,
|
||||||
|
listen_host: str = "0.0.0.0",
|
||||||
|
listen_port: int = 6667,
|
||||||
|
):
|
||||||
|
self.log_callback = log_callback or _default_log_callback
|
||||||
|
self.listen_host = listen_host
|
||||||
|
self.listen_port = listen_port
|
||||||
|
self.allowed_networks = list(self.DEFAULT_ALLOWED_NETWORKS)
|
||||||
|
self._channels = list(self.DEFAULT_CHANNELS)
|
||||||
|
|
||||||
|
self._server = None
|
||||||
|
self._thread = None
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._running = threading.Event()
|
||||||
|
self._ready = threading.Event()
|
||||||
|
self._error: Optional[Exception] = None
|
||||||
|
|
||||||
|
# Public API ---------------------------------------------------------
|
||||||
|
def start(self, timeout: float = 5.0):
|
||||||
|
"""Start the background IRC server."""
|
||||||
|
with self._lock:
|
||||||
|
if self._running.is_set():
|
||||||
|
raise RuntimeError("Local IRC server is already running.")
|
||||||
|
|
||||||
|
self._running.set()
|
||||||
|
self._ready.clear()
|
||||||
|
self._error = None
|
||||||
|
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._serve_forever,
|
||||||
|
name="Local-IRC-Server",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
if not self._ready.wait(timeout):
|
||||||
|
self._running.clear()
|
||||||
|
raise TimeoutError("Local IRC server failed to start in time.")
|
||||||
|
|
||||||
|
if self._error:
|
||||||
|
raise self._error
|
||||||
|
|
||||||
|
def stop(self, timeout: float = 5.0):
|
||||||
|
"""Stop the IRC server if it is running."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._running.is_set():
|
||||||
|
return
|
||||||
|
|
||||||
|
server = self._server
|
||||||
|
thread = self._thread
|
||||||
|
|
||||||
|
if server:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
if thread:
|
||||||
|
thread.join(timeout=timeout)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._server = None
|
||||||
|
self._thread = None
|
||||||
|
self._running.clear()
|
||||||
|
self._ready.clear()
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._running.is_set()
|
||||||
|
|
||||||
|
def set_listen_host(self, host: str):
|
||||||
|
"""Update the bind address. Local server must be stopped."""
|
||||||
|
with self._lock:
|
||||||
|
if self._running.is_set():
|
||||||
|
raise RuntimeError("Stop the server before changing the interface.")
|
||||||
|
self.listen_host = host
|
||||||
|
self._log(f"Local server interface set to {host}.")
|
||||||
|
|
||||||
|
def get_channels(self) -> List[str]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._channels)
|
||||||
|
|
||||||
|
def set_channels(self, channels: Iterable[str]):
|
||||||
|
cleaned = self._sanitize_channels(channels)
|
||||||
|
with self._lock:
|
||||||
|
self._channels = cleaned or list(self.DEFAULT_CHANNELS)
|
||||||
|
|
||||||
|
if self.is_running():
|
||||||
|
self._log(
|
||||||
|
"Channel list updated. Restart local server to apply changes.",
|
||||||
|
bold=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Internal helpers ---------------------------------------------------
|
||||||
|
def _serve_forever(self):
|
||||||
|
try:
|
||||||
|
server = LocalOnlyIRCServer(
|
||||||
|
(self.listen_host, self.listen_port),
|
||||||
|
irc_server.IRCClient,
|
||||||
|
self.allowed_networks,
|
||||||
|
blocked_callback=lambda ip: self._log(
|
||||||
|
f"Blocked connection attempt from {ip}", bold=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
server.servername = socket.gethostname() or "wxirc-local"
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._server = server
|
||||||
|
seed_channels = self._channels or list(self.DEFAULT_CHANNELS)
|
||||||
|
for channel in seed_channels:
|
||||||
|
server.channels.setdefault(channel, irc_server.IRCChannel(channel))
|
||||||
|
|
||||||
|
self._log(
|
||||||
|
f"Local IRC server listening on {self.listen_host}:{self.listen_port}",
|
||||||
|
bold=True,
|
||||||
|
)
|
||||||
|
self._ready.set()
|
||||||
|
server.serve_forever()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Local IRC server failed: %s", exc)
|
||||||
|
self._error = exc
|
||||||
|
self._ready.set()
|
||||||
|
self._log(f"Local server error: {exc}", bold=True)
|
||||||
|
finally:
|
||||||
|
self._running.clear()
|
||||||
|
self._log("Local IRC server stopped.")
|
||||||
|
|
||||||
|
def _sanitize_channels(self, channels: Iterable[str]) -> List[str]:
|
||||||
|
unique = []
|
||||||
|
seen = set()
|
||||||
|
for channel in channels:
|
||||||
|
if not channel:
|
||||||
|
continue
|
||||||
|
name = channel.strip()
|
||||||
|
if not name.startswith("#"):
|
||||||
|
name = f"#{name}"
|
||||||
|
if not self._is_valid_channel(name):
|
||||||
|
continue
|
||||||
|
if name.lower() not in seen:
|
||||||
|
unique.append(name)
|
||||||
|
seen.add(name.lower())
|
||||||
|
return unique
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_valid_channel(name: str) -> bool:
|
||||||
|
if len(name) < 2:
|
||||||
|
return False
|
||||||
|
allowed = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_#"
|
||||||
|
return all(ch in allowed for ch in name)
|
||||||
|
|
||||||
|
def _log(self, message: str, color=None, bold: bool = False):
|
||||||
|
try:
|
||||||
|
self.log_callback(message, color, bold)
|
||||||
|
except Exception:
|
||||||
|
logger.info(message)
|
||||||
104
src/ManageChannelsDialog.py
Normal file
104
src/ManageChannelsDialog.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import wx
|
||||||
|
import wx.adv
|
||||||
|
|
||||||
|
class ManageChannelsDialog(wx.Dialog):
|
||||||
|
"""Simple dialog for curating the local server channel allowlist."""
|
||||||
|
|
||||||
|
def __init__(self, parent, channels):
|
||||||
|
super().__init__(parent, title="Manage Local Channels", size=(360, 420))
|
||||||
|
try:
|
||||||
|
self.SetIcon(parent.GetIcon())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
panel = wx.Panel(self)
|
||||||
|
panel.SetBackgroundColour(parent.theme["window_bg"])
|
||||||
|
|
||||||
|
main_sizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
info = wx.StaticText(
|
||||||
|
panel,
|
||||||
|
label="Channels are shared with anyone on your LAN who joins the built-in server.",
|
||||||
|
)
|
||||||
|
info.Wrap(320)
|
||||||
|
main_sizer.Add(info, 0, wx.ALL | wx.EXPAND, 8)
|
||||||
|
|
||||||
|
self.list_box = wx.ListBox(panel)
|
||||||
|
for channel in channels:
|
||||||
|
self.list_box.Append(channel)
|
||||||
|
main_sizer.Add(self.list_box, 1, wx.ALL | wx.EXPAND, 8)
|
||||||
|
|
||||||
|
input_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
|
self.channel_input = wx.TextCtrl(panel, style=wx.TE_PROCESS_ENTER)
|
||||||
|
self.channel_input.SetHint("#channel-name")
|
||||||
|
add_btn = wx.Button(panel, label="Add")
|
||||||
|
input_sizer.Add(self.channel_input, 1, wx.RIGHT, 4)
|
||||||
|
input_sizer.Add(add_btn, 0)
|
||||||
|
main_sizer.Add(input_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 8)
|
||||||
|
|
||||||
|
remove_btn = wx.Button(panel, label="Remove Selected")
|
||||||
|
reset_btn = wx.Button(panel, label="Reset to #lobby")
|
||||||
|
btn_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
|
btn_sizer.Add(remove_btn, 1, wx.RIGHT, 4)
|
||||||
|
btn_sizer.Add(reset_btn, 1)
|
||||||
|
main_sizer.Add(btn_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 8)
|
||||||
|
|
||||||
|
button_bar = wx.StdDialogButtonSizer()
|
||||||
|
ok_btn = wx.Button(panel, wx.ID_OK)
|
||||||
|
cancel_btn = wx.Button(panel, wx.ID_CANCEL)
|
||||||
|
button_bar.AddButton(ok_btn)
|
||||||
|
button_bar.AddButton(cancel_btn)
|
||||||
|
button_bar.Realize()
|
||||||
|
main_sizer.Add(button_bar, 0, wx.ALL | wx.EXPAND, 8)
|
||||||
|
|
||||||
|
panel.SetSizer(main_sizer)
|
||||||
|
|
||||||
|
# Bindings
|
||||||
|
add_btn.Bind(wx.EVT_BUTTON, self.on_add)
|
||||||
|
remove_btn.Bind(wx.EVT_BUTTON, self.on_remove)
|
||||||
|
reset_btn.Bind(wx.EVT_BUTTON, self.on_reset)
|
||||||
|
self.channel_input.Bind(wx.EVT_TEXT_ENTER, self.on_add)
|
||||||
|
ok_btn.Bind(wx.EVT_BUTTON, self.on_ok)
|
||||||
|
|
||||||
|
def get_channels(self):
|
||||||
|
return [self.list_box.GetString(i) for i in range(self.list_box.GetCount())]
|
||||||
|
|
||||||
|
def on_add(self, event):
|
||||||
|
value = self.channel_input.GetValue().strip()
|
||||||
|
if not value:
|
||||||
|
return
|
||||||
|
if not value.startswith('#'):
|
||||||
|
value = f"#{value}"
|
||||||
|
if not self._is_valid_channel(value):
|
||||||
|
wx.MessageBox(
|
||||||
|
"Channel names may contain letters, numbers, -, and _ only.",
|
||||||
|
"Invalid Channel",
|
||||||
|
wx.OK | wx.ICON_WARNING,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if self.list_box.FindString(value) != wx.NOT_FOUND:
|
||||||
|
wx.MessageBox("Channel already exists.", "Duplicate Channel", wx.OK | wx.ICON_INFORMATION)
|
||||||
|
return
|
||||||
|
self.list_box.Append(value)
|
||||||
|
self.channel_input.Clear()
|
||||||
|
|
||||||
|
def on_remove(self, event):
|
||||||
|
selection = self.list_box.GetSelection()
|
||||||
|
if selection != wx.NOT_FOUND:
|
||||||
|
self.list_box.Delete(selection)
|
||||||
|
|
||||||
|
def on_reset(self, event):
|
||||||
|
self.list_box.Clear()
|
||||||
|
self.list_box.Append("#lobby")
|
||||||
|
|
||||||
|
def on_ok(self, event):
|
||||||
|
if self.list_box.GetCount() == 0:
|
||||||
|
wx.MessageBox("Add at least one channel before saving.", "No Channels", wx.OK | wx.ICON_INFORMATION)
|
||||||
|
return
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_valid_channel(name):
|
||||||
|
if len(name) < 2:
|
||||||
|
return False
|
||||||
|
allowed = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_#"
|
||||||
|
return all(ch in allowed for ch in name)
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ class NotesDialog(wx.Frame):
|
|||||||
style=wx.DEFAULT_FRAME_STYLE)
|
style=wx.DEFAULT_FRAME_STYLE)
|
||||||
|
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
|
self.theme = getattr(parent, "theme", None)
|
||||||
self.notes_data = notes_data or defaultdict(dict)
|
self.notes_data = notes_data or defaultdict(dict)
|
||||||
self.current_note_key = None
|
self.current_note_key = None
|
||||||
self.updating_title = False
|
self.updating_title = False
|
||||||
@@ -27,7 +28,7 @@ class NotesDialog(wx.Frame):
|
|||||||
self.last_save_time = time.time()
|
self.last_save_time = time.time()
|
||||||
self.auto_save_interval = 2 # seconds - reduced for immediate saving
|
self.auto_save_interval = 2 # seconds - reduced for immediate saving
|
||||||
|
|
||||||
self.SetBackgroundColour(wx.Colour(245, 245, 245))
|
self.SetBackgroundColour(self.get_theme_colour("window_bg", wx.SystemSettings().GetColour(wx.SYS_COLOUR_WINDOW)))
|
||||||
|
|
||||||
# Set icon if parent has one
|
# Set icon if parent has one
|
||||||
if parent:
|
if parent:
|
||||||
@@ -60,6 +61,11 @@ class NotesDialog(wx.Frame):
|
|||||||
def close_parent(self, pId):
|
def close_parent(self, pId):
|
||||||
if self.GetParent().GetId() == pId:
|
if self.GetParent().GetId() == pId:
|
||||||
self.GetParent().Close()
|
self.GetParent().Close()
|
||||||
|
|
||||||
|
def get_theme_colour(self, key, fallback):
|
||||||
|
if self.theme and key in self.theme:
|
||||||
|
return self.theme[key]
|
||||||
|
return fallback
|
||||||
|
|
||||||
def create_controls(self):
|
def create_controls(self):
|
||||||
# Create menu bar
|
# Create menu bar
|
||||||
@@ -72,6 +78,7 @@ class NotesDialog(wx.Frame):
|
|||||||
|
|
||||||
# Left panel - notes list
|
# Left panel - notes list
|
||||||
left_panel = wx.Panel(splitter)
|
left_panel = wx.Panel(splitter)
|
||||||
|
left_panel.SetBackgroundColour(self.get_theme_colour("sidebar_bg", left_panel.GetBackgroundColour()))
|
||||||
left_sizer = wx.BoxSizer(wx.VERTICAL)
|
left_sizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
|
||||||
notes_label = wx.StaticText(left_panel, label="Your Notes:")
|
notes_label = wx.StaticText(left_panel, label="Your Notes:")
|
||||||
@@ -101,6 +108,7 @@ class NotesDialog(wx.Frame):
|
|||||||
|
|
||||||
# Right panel - editor
|
# Right panel - editor
|
||||||
right_panel = wx.Panel(splitter)
|
right_panel = wx.Panel(splitter)
|
||||||
|
right_panel.SetBackgroundColour(self.get_theme_colour("content_bg", right_panel.GetBackgroundColour()))
|
||||||
right_sizer = wx.BoxSizer(wx.VERTICAL)
|
right_sizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
|
||||||
title_label = wx.StaticText(right_panel, label="Note Title:")
|
title_label = wx.StaticText(right_panel, label="Note Title:")
|
||||||
@@ -226,6 +234,7 @@ class NotesDialog(wx.Frame):
|
|||||||
|
|
||||||
def setup_editor_toolbar(self, parent):
|
def setup_editor_toolbar(self, parent):
|
||||||
self.toolbar = wx.Panel(parent)
|
self.toolbar = wx.Panel(parent)
|
||||||
|
self.toolbar.SetBackgroundColour(self.get_theme_colour("control_bg", self.toolbar.GetBackgroundColour()))
|
||||||
toolbar_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
toolbar_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
|
|
||||||
# Text formatting buttons
|
# Text formatting buttons
|
||||||
|
|||||||
120
src/ScanHandler.py
Normal file
120
src/ScanHandler.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import psutil
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ScanHandler:
|
||||||
|
"""Fast local network IRC scanner with minimal network overhead."""
|
||||||
|
|
||||||
|
def __init__(self, timeout=0.35, max_workers=64):
|
||||||
|
self.timeout = timeout
|
||||||
|
self.max_workers = max_workers
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._thread = None
|
||||||
|
self.results = []
|
||||||
|
self.total_hosts = 0
|
||||||
|
|
||||||
|
def detect_networks(self):
|
||||||
|
"""Return private IPv4 networks discovered on local interfaces."""
|
||||||
|
networks = []
|
||||||
|
try:
|
||||||
|
for iface, addrs in psutil.net_if_addrs().items():
|
||||||
|
for addr in addrs:
|
||||||
|
if addr.family != socket.AF_INET or not addr.netmask:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
interface = ipaddress.IPv4Interface(f"{addr.address}/{addr.netmask}")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not interface.network.is_private:
|
||||||
|
continue
|
||||||
|
network = self._cap_network(interface)
|
||||||
|
label = f"{iface} : {interface.ip} through {network.with_prefixlen} range"
|
||||||
|
networks.append({"label": label, "cidr": str(network)})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to enumerate interfaces: %s", exc)
|
||||||
|
|
||||||
|
if not networks:
|
||||||
|
default_net = "192.168.1.0/24"
|
||||||
|
label = f"Default guess : {default_net}"
|
||||||
|
networks.append({"label": label, "cidr": default_net})
|
||||||
|
return networks
|
||||||
|
|
||||||
|
def start_scan(self, network_cidr, ports, progress_cb=None, result_cb=None, done_cb=None):
|
||||||
|
"""Launch the threaded scan."""
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(network_cidr, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
hosts = [str(host) for host in network.hosts()]
|
||||||
|
if not hosts:
|
||||||
|
hosts = [str(network.network_address)]
|
||||||
|
self.total_hosts = len(hosts)
|
||||||
|
self.results.clear()
|
||||||
|
self._stop_event.clear()
|
||||||
|
|
||||||
|
def _worker():
|
||||||
|
logger.info("Starting IRC scan across %s hosts (%s)", len(hosts), network_cidr)
|
||||||
|
scanned = 0
|
||||||
|
try:
|
||||||
|
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||||
|
futures = {executor.submit(self._probe_host, host, ports): host for host in hosts}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
scanned += 1
|
||||||
|
server_info = future.result()
|
||||||
|
if server_info:
|
||||||
|
self.results.append(server_info)
|
||||||
|
if result_cb:
|
||||||
|
result_cb(server_info)
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb(scanned, self.total_hosts)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Scan failure: %s", exc)
|
||||||
|
finally:
|
||||||
|
if done_cb:
|
||||||
|
done_cb(self.results)
|
||||||
|
logger.info("IRC scan finished (%s discovered)", len(self.results))
|
||||||
|
|
||||||
|
self._thread = threading.Thread(target=_worker, name="IRC-Scan", daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop_scan(self):
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
def _probe_host(self, host, ports):
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
return None
|
||||||
|
for port in ports:
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=self.timeout) as sock:
|
||||||
|
sock.settimeout(0.2)
|
||||||
|
banner = ""
|
||||||
|
try:
|
||||||
|
chunk = sock.recv(256)
|
||||||
|
if chunk:
|
||||||
|
banner = chunk.decode(errors="ignore").strip()
|
||||||
|
except socket.timeout:
|
||||||
|
banner = "IRC server (silent banner)"
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return {"address": host, "port": port, "banner": banner or "IRC server detected"}
|
||||||
|
except (socket.timeout, ConnectionRefusedError, OSError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cap_network(interface):
|
||||||
|
"""Cap huge networks to /24 to keep scans lightweight."""
|
||||||
|
if interface.network.prefixlen >= 24:
|
||||||
|
return interface.network
|
||||||
|
return ipaddress.ip_network(f"{interface.ip}/24", strict=False)
|
||||||
@@ -1,282 +1,19 @@
|
|||||||
import ipaddress
|
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import ScanHandler
|
||||||
import threading
|
import ScanWizardIntroPage
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
import ScanWizardResultsPage
|
||||||
|
|
||||||
import psutil
|
|
||||||
import wx
|
import wx
|
||||||
import wx.adv as adv
|
import wx.adv as adv
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ScanHandler:
|
|
||||||
"""Fast local network IRC scanner with minimal network overhead."""
|
|
||||||
|
|
||||||
def __init__(self, timeout=0.35, max_workers=64):
|
|
||||||
self.timeout = timeout
|
|
||||||
self.max_workers = max_workers
|
|
||||||
self._stop_event = threading.Event()
|
|
||||||
self._thread = None
|
|
||||||
self.results = []
|
|
||||||
self.total_hosts = 0
|
|
||||||
|
|
||||||
def detect_networks(self):
|
|
||||||
"""Return private IPv4 networks discovered on local interfaces."""
|
|
||||||
networks = []
|
|
||||||
try:
|
|
||||||
for iface, addrs in psutil.net_if_addrs().items():
|
|
||||||
for addr in addrs:
|
|
||||||
if addr.family != socket.AF_INET or not addr.netmask:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
interface = ipaddress.IPv4Interface(f"{addr.address}/{addr.netmask}")
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if not interface.network.is_private:
|
|
||||||
continue
|
|
||||||
network = self._cap_network(interface)
|
|
||||||
label = f"{iface} : {interface.ip} through {network.with_prefixlen} range"
|
|
||||||
networks.append({"label": label, "cidr": str(network)})
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Failed to enumerate interfaces: %s", exc)
|
|
||||||
|
|
||||||
if not networks:
|
|
||||||
default_net = "192.168.1.0/24"
|
|
||||||
label = f"Default guess : {default_net}"
|
|
||||||
networks.append({"label": label, "cidr": default_net})
|
|
||||||
return networks
|
|
||||||
|
|
||||||
def start_scan(self, network_cidr, ports, progress_cb=None, result_cb=None, done_cb=None):
|
|
||||||
"""Launch the threaded scan."""
|
|
||||||
if self._thread and self._thread.is_alive():
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
network = ipaddress.ip_network(network_cidr, strict=False)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
hosts = [str(host) for host in network.hosts()]
|
|
||||||
if not hosts:
|
|
||||||
hosts = [str(network.network_address)]
|
|
||||||
self.total_hosts = len(hosts)
|
|
||||||
self.results.clear()
|
|
||||||
self._stop_event.clear()
|
|
||||||
|
|
||||||
def _worker():
|
|
||||||
logger.info("Starting IRC scan across %s hosts (%s)", len(hosts), network_cidr)
|
|
||||||
scanned = 0
|
|
||||||
try:
|
|
||||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
|
||||||
futures = {executor.submit(self._probe_host, host, ports): host for host in hosts}
|
|
||||||
for future in as_completed(futures):
|
|
||||||
if self._stop_event.is_set():
|
|
||||||
break
|
|
||||||
scanned += 1
|
|
||||||
server_info = future.result()
|
|
||||||
if server_info:
|
|
||||||
self.results.append(server_info)
|
|
||||||
if result_cb:
|
|
||||||
result_cb(server_info)
|
|
||||||
if progress_cb:
|
|
||||||
progress_cb(scanned, self.total_hosts)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Scan failure: %s", exc)
|
|
||||||
finally:
|
|
||||||
if done_cb:
|
|
||||||
done_cb(self.results)
|
|
||||||
logger.info("IRC scan finished (%s discovered)", len(self.results))
|
|
||||||
|
|
||||||
self._thread = threading.Thread(target=_worker, name="IRC-Scan", daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def stop_scan(self):
|
|
||||||
self._stop_event.set()
|
|
||||||
|
|
||||||
def _probe_host(self, host, ports):
|
|
||||||
if self._stop_event.is_set():
|
|
||||||
return None
|
|
||||||
for port in ports:
|
|
||||||
try:
|
|
||||||
with socket.create_connection((host, port), timeout=self.timeout) as sock:
|
|
||||||
sock.settimeout(0.2)
|
|
||||||
banner = ""
|
|
||||||
try:
|
|
||||||
chunk = sock.recv(256)
|
|
||||||
if chunk:
|
|
||||||
banner = chunk.decode(errors="ignore").strip()
|
|
||||||
except socket.timeout:
|
|
||||||
banner = "IRC server (silent banner)"
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return {"address": host, "port": port, "banner": banner or "IRC server detected"}
|
|
||||||
except (socket.timeout, ConnectionRefusedError, OSError):
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _cap_network(interface):
|
|
||||||
"""Cap huge networks to /24 to keep scans lightweight."""
|
|
||||||
if interface.network.prefixlen >= 24:
|
|
||||||
return interface.network
|
|
||||||
return ipaddress.ip_network(f"{interface.ip}/24", strict=False)
|
|
||||||
|
|
||||||
|
|
||||||
class ScanWizardIntroPage(adv.WizardPageSimple):
|
|
||||||
def __init__(self, parent, scan_handler):
|
|
||||||
super().__init__(parent)
|
|
||||||
self.scan_handler = scan_handler
|
|
||||||
self.networks = self.scan_handler.detect_networks()
|
|
||||||
self._build_ui()
|
|
||||||
|
|
||||||
def _build_ui(self):
|
|
||||||
sizer = wx.BoxSizer(wx.VERTICAL)
|
|
||||||
|
|
||||||
intro = wx.StaticText(self, label="Scan the local network for open IRC servers.\n\n Security Warning: This scan may reveal information about your device, and may make you vulnerable to attacks.")
|
|
||||||
intro.Wrap(420)
|
|
||||||
sizer.Add(intro, 0, wx.ALL, 5)
|
|
||||||
|
|
||||||
sizer.Add(wx.StaticText(self, label="Network"), 0, wx.TOP | wx.LEFT, 8)
|
|
||||||
labels = [net["label"] for net in self.networks]
|
|
||||||
self.network_choice = wx.Choice(self, choices=labels)
|
|
||||||
if labels:
|
|
||||||
self.network_choice.SetSelection(0)
|
|
||||||
sizer.Add(self.network_choice, 0, wx.EXPAND | wx.ALL, 5)
|
|
||||||
|
|
||||||
sizer.Add(wx.StaticText(self, label="Ports (comma separated)"), 0, wx.TOP | wx.LEFT, 8)
|
|
||||||
self.port_ctrl = wx.TextCtrl(self, value="6667,6697")
|
|
||||||
sizer.Add(self.port_ctrl, 0, wx.EXPAND | wx.ALL, 5)
|
|
||||||
|
|
||||||
self.SetSizer(sizer)
|
|
||||||
|
|
||||||
def get_scan_params(self):
|
|
||||||
selection = self.network_choice.GetSelection()
|
|
||||||
if selection == wx.NOT_FOUND:
|
|
||||||
wx.MessageBox("Select a network to scan.", "Missing selection", wx.OK | wx.ICON_WARNING)
|
|
||||||
return None
|
|
||||||
cidr = self.networks[selection]["cidr"]
|
|
||||||
raw_ports = self.port_ctrl.GetValue().split(",")
|
|
||||||
ports = []
|
|
||||||
for raw in raw_ports:
|
|
||||||
raw = raw.strip()
|
|
||||||
if not raw:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
port = int(raw)
|
|
||||||
if 1 <= port <= 65535:
|
|
||||||
ports.append(port)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if not ports:
|
|
||||||
wx.MessageBox("Enter at least one valid TCP port.", "Invalid ports", wx.OK | wx.ICON_WARNING)
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
network = ipaddress.ip_network(cidr, strict=False)
|
|
||||||
except ValueError:
|
|
||||||
wx.MessageBox("Invalid network selection.", "Network error", wx.OK | wx.ICON_ERROR)
|
|
||||||
return None
|
|
||||||
host_count = max(network.num_addresses - (2 if network.version == 4 and network.prefixlen <= 30 else 0), 1)
|
|
||||||
return {"cidr": str(network), "ports": ports, "host_count": host_count}
|
|
||||||
|
|
||||||
|
|
||||||
class ScanWizardResultsPage(adv.WizardPageSimple):
|
|
||||||
def __init__(self, parent, scan_handler, main_frame):
|
|
||||||
super().__init__(parent)
|
|
||||||
self.scan_handler = scan_handler
|
|
||||||
self.main_frame = main_frame
|
|
||||||
self.discovered = []
|
|
||||||
self._build_ui()
|
|
||||||
|
|
||||||
def _build_ui(self):
|
|
||||||
sizer = wx.BoxSizer(wx.VERTICAL)
|
|
||||||
|
|
||||||
self.summary = wx.StaticText(self, label="Waiting to start…")
|
|
||||||
sizer.Add(self.summary, 0, wx.ALL, 5)
|
|
||||||
|
|
||||||
self.gauge = wx.Gauge(self, range=100, style=wx.GA_SMOOTH)
|
|
||||||
sizer.Add(self.gauge, 0, wx.EXPAND | wx.ALL, 5)
|
|
||||||
|
|
||||||
self.results_list = wx.ListCtrl(self, style=wx.LC_REPORT | wx.BORDER_SUNKEN)
|
|
||||||
self.results_list.InsertColumn(0, "Address", width=140)
|
|
||||||
self.results_list.InsertColumn(1, "Port", width=60)
|
|
||||||
self.results_list.InsertColumn(2, "Details", width=260)
|
|
||||||
self.results_list.Bind(wx.EVT_LIST_ITEM_SELECTED, self._toggle_buttons)
|
|
||||||
self.results_list.Bind(wx.EVT_LIST_ITEM_DESELECTED, self._toggle_buttons)
|
|
||||||
sizer.Add(self.results_list, 1, wx.EXPAND | wx.ALL, 5)
|
|
||||||
|
|
||||||
btn_row = wx.BoxSizer(wx.HORIZONTAL)
|
|
||||||
self.quick_connect_btn = wx.Button(self, label="Quick Connect")
|
|
||||||
self.quick_connect_btn.Disable()
|
|
||||||
self.quick_connect_btn.Bind(wx.EVT_BUTTON, self.on_quick_connect)
|
|
||||||
btn_row.Add(self.quick_connect_btn, 0, wx.RIGHT, 5)
|
|
||||||
|
|
||||||
self.rescan_btn = wx.Button(self, label="Rescan")
|
|
||||||
self.rescan_btn.Bind(wx.EVT_BUTTON, self.on_rescan)
|
|
||||||
btn_row.Add(self.rescan_btn, 0)
|
|
||||||
sizer.Add(btn_row, 0, wx.ALL | wx.ALIGN_RIGHT, 5)
|
|
||||||
|
|
||||||
self.SetSizer(sizer)
|
|
||||||
|
|
||||||
def prepare_for_scan(self, params, start_callback):
|
|
||||||
self.results_list.DeleteAllItems()
|
|
||||||
self.discovered = []
|
|
||||||
self.summary.SetLabel(f"Scanning {params['cidr']} on ports {', '.join(map(str, params['ports']))}…")
|
|
||||||
self.gauge.SetRange(max(params["host_count"], 1))
|
|
||||||
self.gauge.SetValue(0)
|
|
||||||
self.quick_connect_btn.Disable()
|
|
||||||
start_callback(params)
|
|
||||||
|
|
||||||
def on_scan_progress(self, scanned, total):
|
|
||||||
total = max(total, 1)
|
|
||||||
self.gauge.SetRange(total)
|
|
||||||
self.gauge.SetValue(min(scanned, total))
|
|
||||||
self.summary.SetLabel(f"Scanning… {scanned}/{total} hosts checked")
|
|
||||||
|
|
||||||
def on_scan_result(self, server_info):
|
|
||||||
idx = self.results_list.InsertItem(self.results_list.GetItemCount(), server_info["address"])
|
|
||||||
self.results_list.SetItem(idx, 1, str(server_info["port"]))
|
|
||||||
self.results_list.SetItem(idx, 2, server_info.get("banner", "IRC server detected"))
|
|
||||||
self.discovered.append(server_info)
|
|
||||||
self.summary.SetLabel(f"Found {len(self.discovered)} {"server" if self.discovered == 1 else "servers"}")
|
|
||||||
|
|
||||||
def on_scan_complete(self, results):
|
|
||||||
if results:
|
|
||||||
self.summary.SetLabel(f"Scan complete : {len(results)} {"server" if len(results) == 1 else "servers"} ready.")
|
|
||||||
else:
|
|
||||||
self.summary.SetLabel("Scan complete : no IRC servers discovered.")
|
|
||||||
self._toggle_buttons()
|
|
||||||
|
|
||||||
def on_quick_connect(self, event):
|
|
||||||
row = self.results_list.GetFirstSelected()
|
|
||||||
if row == -1:
|
|
||||||
return
|
|
||||||
server = self.results_list.GetItemText(row, 0)
|
|
||||||
port = int(self.results_list.GetItemText(row, 1))
|
|
||||||
if self.main_frame.quick_connect(server, port):
|
|
||||||
self.GetParent().EndModal(wx.ID_OK)
|
|
||||||
|
|
||||||
def on_rescan(self, event):
|
|
||||||
wizard = self.GetParent()
|
|
||||||
wizard.ShowPage(wizard.intro_page)
|
|
||||||
|
|
||||||
def _toggle_buttons(self, event=None):
|
|
||||||
has_selection = self.results_list.GetFirstSelected() != -1
|
|
||||||
if has_selection:
|
|
||||||
self.quick_connect_btn.Enable()
|
|
||||||
else:
|
|
||||||
self.quick_connect_btn.Disable()
|
|
||||||
|
|
||||||
|
|
||||||
class ScanWizardDialog(adv.Wizard):
|
class ScanWizardDialog(adv.Wizard):
|
||||||
"""Wizard that drives the ScanHandler workflow."""
|
|
||||||
|
|
||||||
def __init__(self, parent):
|
def __init__(self, parent):
|
||||||
super().__init__(parent, title="wxScan")
|
super().__init__(parent, title="wxScan")
|
||||||
self.scan_handler = ScanHandler()
|
self.scan_handler = ScanHandler.ScanHandler()
|
||||||
self.intro_page = ScanWizardIntroPage(self, self.scan_handler)
|
self.intro_page = ScanWizardIntroPage.ScanWizardIntroPage(self, self.scan_handler)
|
||||||
self.results_page = ScanWizardResultsPage(self, self.scan_handler, parent)
|
self.results_page = ScanWizardResultsPage.ScanWizardResultsPage(self, self.scan_handler, parent)
|
||||||
self._chain_pages()
|
self._chain_pages()
|
||||||
self.Bind(adv.EVT_WIZARD_PAGE_CHANGING, self.on_page_changing)
|
self.Bind(adv.EVT_WIZARD_PAGE_CHANGING, self.on_page_changing)
|
||||||
self.Bind(adv.EVT_WIZARD_CANCEL, self.on_cancel)
|
self.Bind(adv.EVT_WIZARD_CANCEL, self.on_cancel)
|
||||||
|
|||||||
59
src/ScanWizardIntroPage.py
Normal file
59
src/ScanWizardIntroPage.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import wx
|
||||||
|
import wx.adv as adv
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
class ScanWizardIntroPage(adv.WizardPageSimple):
|
||||||
|
def __init__(self, parent, scan_handler):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.scan_handler = scan_handler
|
||||||
|
self.networks = self.scan_handler.detect_networks()
|
||||||
|
self._build_ui()
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
sizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
|
||||||
|
intro = wx.StaticText(self, label="Scan the local network for open IRC servers.\n\n Security Warning: This scan may reveal information about your device, and may make you vulnerable to attacks.")
|
||||||
|
intro.Wrap(420)
|
||||||
|
sizer.Add(intro, 0, wx.ALL, 5)
|
||||||
|
|
||||||
|
sizer.Add(wx.StaticText(self, label="Network"), 0, wx.TOP | wx.LEFT, 8)
|
||||||
|
labels = [net["label"] for net in self.networks]
|
||||||
|
self.network_choice = wx.Choice(self, choices=labels)
|
||||||
|
if labels:
|
||||||
|
self.network_choice.SetSelection(0)
|
||||||
|
sizer.Add(self.network_choice, 0, wx.EXPAND | wx.ALL, 5)
|
||||||
|
|
||||||
|
sizer.Add(wx.StaticText(self, label="Ports (comma separated)"), 0, wx.TOP | wx.LEFT, 8)
|
||||||
|
self.port_ctrl = wx.TextCtrl(self, value="6667,6697")
|
||||||
|
sizer.Add(self.port_ctrl, 0, wx.EXPAND | wx.ALL, 5)
|
||||||
|
|
||||||
|
self.SetSizer(sizer)
|
||||||
|
|
||||||
|
def get_scan_params(self):
|
||||||
|
selection = self.network_choice.GetSelection()
|
||||||
|
if selection == wx.NOT_FOUND:
|
||||||
|
wx.MessageBox("Select a network to scan.", "Missing selection", wx.OK | wx.ICON_WARNING)
|
||||||
|
return None
|
||||||
|
cidr = self.networks[selection]["cidr"]
|
||||||
|
raw_ports = self.port_ctrl.GetValue().split(",")
|
||||||
|
ports = []
|
||||||
|
for raw in raw_ports:
|
||||||
|
raw = raw.strip()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
port = int(raw)
|
||||||
|
if 1 <= port <= 65535:
|
||||||
|
ports.append(port)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not ports:
|
||||||
|
wx.MessageBox("Enter at least one valid TCP port.", "Invalid ports", wx.OK | wx.ICON_WARNING)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(cidr, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
wx.MessageBox("Invalid network selection.", "Network error", wx.OK | wx.ICON_ERROR)
|
||||||
|
return None
|
||||||
|
host_count = max(network.num_addresses - (2 if network.version == 4 and network.prefixlen <= 30 else 0), 1)
|
||||||
|
return {"cidr": str(network), "ports": ports, "host_count": host_count}
|
||||||
109
src/ScanWizardResultsPage.py
Normal file
109
src/ScanWizardResultsPage.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import wx
|
||||||
|
import wx.adv as adv
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ScanWizardResultsPage(adv.WizardPageSimple):
|
||||||
|
def __init__(self, parent, scan_handler, main_frame):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.scan_handler = scan_handler
|
||||||
|
self.main_frame = main_frame
|
||||||
|
self.discovered = []
|
||||||
|
self._build_ui()
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
sizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
|
||||||
|
self.summary = wx.StaticText(self, label="Waiting to start…")
|
||||||
|
sizer.Add(self.summary, 0, wx.ALL, 5)
|
||||||
|
|
||||||
|
self.gauge = wx.Gauge(self, range=100, style=wx.GA_SMOOTH)
|
||||||
|
sizer.Add(self.gauge, 0, wx.EXPAND | wx.ALL, 5)
|
||||||
|
|
||||||
|
self.results_list = wx.ListCtrl(self, style=wx.LC_REPORT | wx.BORDER_SUNKEN)
|
||||||
|
self.results_list.InsertColumn(0, "Address", width=140)
|
||||||
|
self.results_list.InsertColumn(1, "Port", width=60)
|
||||||
|
self.results_list.InsertColumn(2, "Details", width=260)
|
||||||
|
self.results_list.Bind(wx.EVT_LIST_ITEM_SELECTED, self._toggle_buttons)
|
||||||
|
self.results_list.Bind(wx.EVT_LIST_ITEM_DESELECTED, self._toggle_buttons)
|
||||||
|
sizer.Add(self.results_list, 1, wx.EXPAND | wx.ALL, 5)
|
||||||
|
|
||||||
|
btn_row = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
|
self.quick_connect_btn = wx.Button(self, label="Quick Connect")
|
||||||
|
self.quick_connect_btn.Disable()
|
||||||
|
self.quick_connect_btn.Bind(wx.EVT_BUTTON, self.on_quick_connect)
|
||||||
|
btn_row.Add(self.quick_connect_btn, 0, wx.RIGHT, 5)
|
||||||
|
|
||||||
|
self.rescan_btn = wx.Button(self, label="Rescan")
|
||||||
|
self.rescan_btn.Bind(wx.EVT_BUTTON, self.on_rescan)
|
||||||
|
btn_row.Add(self.rescan_btn, 0)
|
||||||
|
sizer.Add(btn_row, 0, wx.ALL | wx.ALIGN_RIGHT, 5)
|
||||||
|
|
||||||
|
self.SetSizer(sizer)
|
||||||
|
|
||||||
|
def prepare_for_scan(self, params, start_callback):
|
||||||
|
self.results_list.DeleteAllItems()
|
||||||
|
self.discovered = []
|
||||||
|
self.summary.SetLabel(f"Scanning {params['cidr']} on ports {', '.join(map(str, params['ports']))}…")
|
||||||
|
self.gauge.SetRange(max(params["host_count"], 1))
|
||||||
|
self.gauge.SetValue(0)
|
||||||
|
self.quick_connect_btn.Disable()
|
||||||
|
start_callback(params)
|
||||||
|
|
||||||
|
def on_scan_progress(self, scanned, total):
|
||||||
|
try:
|
||||||
|
total = max(total, 1)
|
||||||
|
self.gauge.SetRange(total)
|
||||||
|
self.gauge.SetValue(min(scanned, total))
|
||||||
|
self.summary.SetLabel(f"Scanning… {scanned}/{total} hosts checked")
|
||||||
|
except RuntimeError:
|
||||||
|
# C++ SHIT
|
||||||
|
logger.debug("Scan progress update after controls destroyed; ignoring")
|
||||||
|
|
||||||
|
def on_scan_result(self, server_info):
|
||||||
|
"""Handle a single discovered server row."""
|
||||||
|
try:
|
||||||
|
idx = self.results_list.InsertItem(self.results_list.GetItemCount(), server_info["address"])
|
||||||
|
self.results_list.SetItem(idx, 1, str(server_info["port"]))
|
||||||
|
self.results_list.SetItem(idx, 2, server_info.get("banner", "IRC server detected"))
|
||||||
|
self.discovered.append(server_info)
|
||||||
|
self.summary.SetLabel(
|
||||||
|
f"Found {len(self.discovered)} {'server' if len(self.discovered) == 1 else 'servers'}"
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
logger.debug("Scan result update after controls destroyed; ignoring")
|
||||||
|
|
||||||
|
def on_scan_complete(self, results):
|
||||||
|
"""Final scan completion callback."""
|
||||||
|
try:
|
||||||
|
if results:
|
||||||
|
self.summary.SetLabel(
|
||||||
|
f"Scan complete : {len(results)} "
|
||||||
|
f"{'server' if len(results) == 1 else 'servers'} ready."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.summary.SetLabel("Scan complete : no IRC servers discovered.")
|
||||||
|
self._toggle_buttons()
|
||||||
|
except RuntimeError:
|
||||||
|
logger.debug("Scan completion update after controls destroyed; ignoring")
|
||||||
|
|
||||||
|
def on_quick_connect(self, event):
|
||||||
|
row = self.results_list.GetFirstSelected()
|
||||||
|
if row == -1:
|
||||||
|
return
|
||||||
|
server = self.results_list.GetItemText(row, 0)
|
||||||
|
port = int(self.results_list.GetItemText(row, 1))
|
||||||
|
if self.main_frame.quick_connect(server, port):
|
||||||
|
self.GetParent().EndModal(wx.ID_OK)
|
||||||
|
|
||||||
|
def on_rescan(self, event):
|
||||||
|
wizard = self.GetParent()
|
||||||
|
wizard.ShowPage(wizard.intro_page)
|
||||||
|
|
||||||
|
def _toggle_buttons(self, event=None):
|
||||||
|
has_selection = self.results_list.GetFirstSelected() != -1
|
||||||
|
if has_selection:
|
||||||
|
self.quick_connect_btn.Enable()
|
||||||
|
else:
|
||||||
|
self.quick_connect_btn.Disable()
|
||||||
99
src/Win32API.py
Normal file
99
src/Win32API.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
import time
|
||||||
|
|
||||||
|
SW_HIDE = 0
|
||||||
|
SW_SHOW = 5
|
||||||
|
|
||||||
|
GWL_EXSTYLE = -20
|
||||||
|
WS_EX_TOOLWINDOW = 0x00000080
|
||||||
|
WS_EX_APPWINDOW = 0x00040000
|
||||||
|
WS_EX_LAYERED = 0x80000
|
||||||
|
LWA_ALPHA = 0x2
|
||||||
|
|
||||||
|
|
||||||
|
class Win32API:
|
||||||
|
def __init__(self, hwnd):
|
||||||
|
self.hwnd = hwnd
|
||||||
|
self.hidden = False
|
||||||
|
self._load_api()
|
||||||
|
|
||||||
|
def _load_api(self):
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
|
||||||
|
self.ShowWindow = user32.ShowWindow
|
||||||
|
self.GetWindowLong = user32.GetWindowLongW
|
||||||
|
self.SetWindowLong = user32.SetWindowLongW
|
||||||
|
self._RegisterHotKey = user32.RegisterHotKey
|
||||||
|
self._SetLayeredWindowAttributes = user32.SetLayeredWindowAttributes
|
||||||
|
|
||||||
|
self.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
|
||||||
|
self.GetWindowLong.argtypes = [wintypes.HWND, ctypes.c_int]
|
||||||
|
self.SetWindowLong.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_long]
|
||||||
|
self._RegisterHotKey.argtypes = [
|
||||||
|
wintypes.HWND,
|
||||||
|
wintypes.INT,
|
||||||
|
wintypes.UINT,
|
||||||
|
wintypes.UINT,
|
||||||
|
]
|
||||||
|
self._SetLayeredWindowAttributes.argtypes = [
|
||||||
|
wintypes.HWND,
|
||||||
|
wintypes.COLORREF,
|
||||||
|
wintypes.BYTE,
|
||||||
|
wintypes.DWORD,
|
||||||
|
]
|
||||||
|
|
||||||
|
def remove_from_taskbar(self):
|
||||||
|
style = self.GetWindowLong(self.hwnd, GWL_EXSTYLE)
|
||||||
|
new_style = (style & ~WS_EX_APPWINDOW) | WS_EX_TOOLWINDOW
|
||||||
|
self.SetWindowLong(self.hwnd, GWL_EXSTYLE, new_style)
|
||||||
|
|
||||||
|
def hide(self):
|
||||||
|
self.ShowWindow(self.hwnd, SW_HIDE)
|
||||||
|
self.hidden = True
|
||||||
|
|
||||||
|
def show(self):
|
||||||
|
self.ShowWindow(self.hwnd, SW_SHOW)
|
||||||
|
self.hidden = False
|
||||||
|
|
||||||
|
def toggle(self, fade=True, duration=500, steps=20):
|
||||||
|
"""Toggle window visibility with optional fade effect"""
|
||||||
|
if self.hidden:
|
||||||
|
if fade:
|
||||||
|
self.fade_in(duration, steps)
|
||||||
|
else:
|
||||||
|
self.show()
|
||||||
|
else:
|
||||||
|
if fade:
|
||||||
|
self.fade_out(duration, steps)
|
||||||
|
else:
|
||||||
|
self.hide()
|
||||||
|
|
||||||
|
|
||||||
|
def register_hotkey(self, hotkey_id, key, modifiers):
|
||||||
|
vk = ord(key.upper())
|
||||||
|
return self._RegisterHotKey(None, hotkey_id, modifiers, vk)
|
||||||
|
|
||||||
|
def _set_layered(self):
|
||||||
|
style = self.GetWindowLong(self.hwnd, GWL_EXSTYLE)
|
||||||
|
self.SetWindowLong(self.hwnd, GWL_EXSTYLE, style | WS_EX_LAYERED)
|
||||||
|
|
||||||
|
def fade_in(self, duration=50, steps=50):
|
||||||
|
self.show()
|
||||||
|
self._set_layered()
|
||||||
|
for idx in range(steps + 1):
|
||||||
|
alpha = int(255 * (idx / steps))
|
||||||
|
self._SetLayeredWindowAttributes(self.hwnd, 0, alpha, LWA_ALPHA)
|
||||||
|
ctypes.windll.kernel32.Sleep(int(duration / steps))
|
||||||
|
self.hidden = False
|
||||||
|
|
||||||
|
def fade_out(self, duration=50, steps=50):
|
||||||
|
self._set_layered()
|
||||||
|
for idx in range(steps + 1):
|
||||||
|
alpha = int(255 * (1 - idx /steps))
|
||||||
|
self._SetLayeredWindowAttributes(self.hwnd, 0, alpha, LWA_ALPHA)
|
||||||
|
ctypes.windll.kernel32.Sleep(int(duration / steps))
|
||||||
|
self.hidden = True
|
||||||
|
self.hide()
|
||||||
|
|
||||||
|
|
||||||
97
src/Win32SoundHandler.py
Normal file
97
src/Win32SoundHandler.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import ctypes
|
||||||
|
import logging
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
# Set up logging
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def get_resource_path(relative_path):
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
try:
|
||||||
|
base_path = sys._MEIPASS
|
||||||
|
except Exception:
|
||||||
|
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__)))
|
||||||
|
base_path = project_root
|
||||||
|
full_path = os.path.normpath(os.path.join(base_path, relative_path))
|
||||||
|
return full_path
|
||||||
|
|
||||||
|
class Win32SoundHandler:
|
||||||
|
def __init__(self, hwnd):
|
||||||
|
self.hwnd = hwnd
|
||||||
|
self.SND_ALIAS = 0x00010000
|
||||||
|
self.SND_FILENAME = 0x00020000
|
||||||
|
self.SND_ASYNC = 0x00000001
|
||||||
|
self._setup_playsoundapi()
|
||||||
|
|
||||||
|
def _setup_playsoundapi(self):
|
||||||
|
winmm = ctypes.windll.winmm
|
||||||
|
self.PlaySoundW = winmm.PlaySoundW
|
||||||
|
self.PlaySoundW.argtypes = (
|
||||||
|
wintypes.LPCWSTR,
|
||||||
|
wintypes.HMODULE,
|
||||||
|
wintypes.DWORD
|
||||||
|
)
|
||||||
|
self.PlaySoundW.restype = wintypes.BOOL
|
||||||
|
|
||||||
|
def play_info_sound(self):
|
||||||
|
self.PlaySoundW("SystemAsterisk", None, self.SND_ALIAS | self.SND_ASYNC)
|
||||||
|
|
||||||
|
def play_error_sound(self):
|
||||||
|
self.PlaySoundW("SystemHand", None, self.SND_ALIAS | self.SND_ASYNC)
|
||||||
|
|
||||||
|
def play_warn_sound(self):
|
||||||
|
self.PlaySoundW("SystemExclamation", None, self.SND_ALIAS | self.SND_ASYNC)
|
||||||
|
|
||||||
|
def play_question_sound(self):
|
||||||
|
self.PlaySoundW("SystemQuestion", None, self.SND_ALIAS | self.SND_ASYNC)
|
||||||
|
|
||||||
|
def play_default_sound(self):
|
||||||
|
self.PlaySoundW("SystemDefault", None, self.SND_ALIAS | self.SND_ASYNC)
|
||||||
|
|
||||||
|
def play_notification_sound(self):
|
||||||
|
self.PlaySoundW("SystemNotification", None, self.SND_ALIAS | self.SND_ASYNC)
|
||||||
|
|
||||||
|
def play_mail_sound(self):
|
||||||
|
self.PlaySoundW("MailBeep", None, self.SND_ALIAS | self.SND_ASYNC)
|
||||||
|
|
||||||
|
def play_exit_sound(self):
|
||||||
|
self.PlaySoundW("SystemExit", None, self.SND_ALIAS | self.SND_ASYNC)
|
||||||
|
|
||||||
|
def play_connect_server_or_channel(self):
|
||||||
|
try:
|
||||||
|
sound_path = get_resource_path("sounds/space-pdj.wav")
|
||||||
|
import os
|
||||||
|
if os.path.exists(sound_path):
|
||||||
|
self.PlaySoundW(sound_path, None, self.SND_FILENAME | self.SND_ASYNC)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Sound file not found: {sound_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error playing popout click sound: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def play_popout_click(self):
|
||||||
|
try:
|
||||||
|
sound_path = get_resource_path("sounds/startup.wav")
|
||||||
|
import os
|
||||||
|
if os.path.exists(sound_path):
|
||||||
|
self.PlaySoundW(sound_path, None, self.SND_FILENAME | self.SND_ASYNC)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Sound file not found: {sound_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error playing popout click sound: {e}")
|
||||||
|
|
||||||
|
def play_msg_recv(self):
|
||||||
|
try:
|
||||||
|
sound_path = get_resource_path("sounds/balloon.wav")
|
||||||
|
import os
|
||||||
|
if os.path.exists(sound_path):
|
||||||
|
self.PlaySoundW(sound_path, None, self.SND_FILENAME | self.SND_ASYNC)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Sound file not found: {sound_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error playing message received sound: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
BIN
src/channel.ico
Normal file
BIN
src/channel.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
547
src/main.py
547
src/main.py
@@ -1,4 +1,6 @@
|
|||||||
import wx
|
import wx
|
||||||
|
import wx.aui
|
||||||
|
import wx.lib.agw.aui as aui
|
||||||
import irc.client
|
import irc.client
|
||||||
import threading
|
import threading
|
||||||
import re
|
import re
|
||||||
@@ -15,8 +17,12 @@ import sys
|
|||||||
from PrivacyNoticeDialog import PrivacyNoticeDialog
|
from PrivacyNoticeDialog import PrivacyNoticeDialog
|
||||||
from IRCPanel import IRCPanel
|
from IRCPanel import IRCPanel
|
||||||
from AboutDialog import AboutDialog
|
from AboutDialog import AboutDialog
|
||||||
|
from InterfaceSelectDialog import InterfaceSelectDialog
|
||||||
|
from ManageChannelsDialog import ManageChannelsDialog
|
||||||
from NotesDialog import NotesDialog
|
from NotesDialog import NotesDialog
|
||||||
|
if os.name == "nt": from Win32API import Win32API ; from Win32SoundHandler import Win32SoundHandler
|
||||||
from ScanWizard import ScanWizardDialog
|
from ScanWizard import ScanWizardDialog
|
||||||
|
from LocalServerManager import LocalServerManager
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
@@ -43,12 +49,13 @@ class IRCFrame(wx.Frame):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(None, title="wxIRC", size=(1200, 700))
|
super().__init__(None, title="wxIRC", size=(1200, 700))
|
||||||
|
|
||||||
# Apply white theme
|
# Determine platform theme once
|
||||||
self.apply_white_theme()
|
self.theme = self.build_theme()
|
||||||
|
self.apply_theme()
|
||||||
|
|
||||||
# Show privacy notice first
|
# Show privacy notice first
|
||||||
self.show_privacy_notice()
|
self.show_privacy_notice()
|
||||||
|
|
||||||
self.reactor = None
|
self.reactor = None
|
||||||
self.connection = None
|
self.connection = None
|
||||||
self.reactor_thread = None
|
self.reactor_thread = None
|
||||||
@@ -68,8 +75,14 @@ class IRCFrame(wx.Frame):
|
|||||||
self.auto_join_channels = []
|
self.auto_join_channels = []
|
||||||
self.away = False
|
self.away = False
|
||||||
self.timestamps = True
|
self.timestamps = True
|
||||||
|
|
||||||
self.notes_data = defaultdict(dict)
|
self.notes_data = defaultdict(dict)
|
||||||
|
self.server_menu_items = {}
|
||||||
|
self.local_bind_host = "127.0.0.1"
|
||||||
|
self.local_server_manager = LocalServerManager(
|
||||||
|
log_callback=self.log_local_server,
|
||||||
|
listen_host=self.local_bind_host,
|
||||||
|
)
|
||||||
|
|
||||||
# User color mapping - darker colors for white theme
|
# User color mapping - darker colors for white theme
|
||||||
self.user_colors = {}
|
self.user_colors = {}
|
||||||
@@ -91,6 +104,18 @@ class IRCFrame(wx.Frame):
|
|||||||
self.motd_lines = []
|
self.motd_lines = []
|
||||||
self.collecting_motd = False
|
self.collecting_motd = False
|
||||||
|
|
||||||
|
self.hwnd = self.GetHandle()
|
||||||
|
self.ctrl = Win32API(self.hwnd)
|
||||||
|
|
||||||
|
# Initialize sound handler for Windows
|
||||||
|
if os.name == "nt":
|
||||||
|
self.sound_handler = Win32SoundHandler(self.hwnd)
|
||||||
|
else:
|
||||||
|
self.sound_handler = None
|
||||||
|
|
||||||
|
# Sound toggle state (enabled by default)
|
||||||
|
self.sounds_enabled = True
|
||||||
|
|
||||||
self.setup_irc_handlers()
|
self.setup_irc_handlers()
|
||||||
self.create_menubar()
|
self.create_menubar()
|
||||||
self.setup_ui()
|
self.setup_ui()
|
||||||
@@ -120,24 +145,85 @@ class IRCFrame(wx.Frame):
|
|||||||
self.Bind(wx.EVT_MENU, self.on_find_next, id=1001)
|
self.Bind(wx.EVT_MENU, self.on_find_next, id=1001)
|
||||||
self.Bind(wx.EVT_MENU, self.on_find_previous, id=1002)
|
self.Bind(wx.EVT_MENU, self.on_find_previous, id=1002)
|
||||||
self.Bind(wx.EVT_MENU, self.on_quick_escape, id=1003)
|
self.Bind(wx.EVT_MENU, self.on_quick_escape, id=1003)
|
||||||
|
|
||||||
|
HOTKEY_ID = 1
|
||||||
|
MOD_CONTROL = 0x0002
|
||||||
|
MOD_ALT = 0x0001
|
||||||
|
|
||||||
|
# Register directly on the wx.Frame
|
||||||
|
self.RegisterHotKey(HOTKEY_ID, MOD_CONTROL | MOD_ALT, ord('H'))
|
||||||
|
self.Bind(wx.EVT_HOTKEY, self.on_hotkey, id=HOTKEY_ID)
|
||||||
|
|
||||||
|
def on_hotkey(self, event):
|
||||||
|
self.ctrl.toggle()
|
||||||
|
|
||||||
def apply_white_theme(self):
|
def build_theme(self):
|
||||||
"""Apply white theme to the application"""
|
"""Build a small theme descriptor that respects the host platform."""
|
||||||
try:
|
try:
|
||||||
# Set system colors for white theme
|
|
||||||
self.SetBackgroundColour(wx.Colour(255, 255, 255))
|
|
||||||
self.SetForegroundColour(wx.Colour(0, 0, 0))
|
|
||||||
|
|
||||||
# Set system settings for light theme
|
|
||||||
sys_settings = wx.SystemSettings()
|
sys_settings = wx.SystemSettings()
|
||||||
|
system_window = sys_settings.GetColour(wx.SYS_COLOUR_WINDOW)
|
||||||
|
system_text = sys_settings.GetColour(wx.SYS_COLOUR_WINDOWTEXT)
|
||||||
|
system_face = sys_settings.GetColour(wx.SYS_COLOUR_BTNFACE)
|
||||||
|
|
||||||
|
is_windows = wx.Platform == "__WXMSW__"
|
||||||
|
if is_windows:
|
||||||
|
window_bg = wx.Colour(255, 255, 255)
|
||||||
|
control_bg = wx.Colour(240, 240, 240)
|
||||||
|
text = wx.Colour(0, 0, 0)
|
||||||
|
else:
|
||||||
|
window_bg = system_window
|
||||||
|
control_bg = system_face
|
||||||
|
text = system_text
|
||||||
|
|
||||||
|
sidebar_delta = 15 if self._is_light_colour(window_bg) else -20
|
||||||
|
sidebar_bg = self._adjust_colour(control_bg if control_bg.IsOk() else window_bg, sidebar_delta)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"window_bg": window_bg,
|
||||||
|
"content_bg": window_bg,
|
||||||
|
"text": text,
|
||||||
|
"sidebar_bg": sidebar_bg,
|
||||||
|
"control_bg": control_bg if control_bg.IsOk() else window_bg,
|
||||||
|
"force_light": is_windows,
|
||||||
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error applying white theme: {e}")
|
logger.error(f"Error building theme: {e}")
|
||||||
|
# Fallback to a simple light theme
|
||||||
|
return {
|
||||||
|
"window_bg": wx.Colour(255, 255, 255),
|
||||||
|
"content_bg": wx.Colour(255, 255, 255),
|
||||||
|
"text": wx.Colour(0, 0, 0),
|
||||||
|
"sidebar_bg": wx.Colour(240, 240, 240),
|
||||||
|
"control_bg": wx.Colour(240, 240, 240),
|
||||||
|
"force_light": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _is_light_colour(self, colour):
|
||||||
|
"""Simple luminance check to know if a colour is light."""
|
||||||
|
luminance = 0.299 * colour.Red() + 0.587 * colour.Green() + 0.114 * colour.Blue()
|
||||||
|
return luminance >= 128
|
||||||
|
|
||||||
|
def _adjust_colour(self, colour, delta):
|
||||||
|
"""Lighten or darken a colour by delta."""
|
||||||
|
def clamp(value):
|
||||||
|
return max(0, min(255, value))
|
||||||
|
return wx.Colour(
|
||||||
|
clamp(colour.Red() + delta),
|
||||||
|
clamp(colour.Green() + delta),
|
||||||
|
clamp(colour.Blue() + delta),
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply_theme(self):
|
||||||
|
"""Apply the detected theme."""
|
||||||
|
try:
|
||||||
|
self.SetBackgroundColour(self.theme["window_bg"])
|
||||||
|
self.SetForegroundColour(self.theme["text"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error applying theme: {e}")
|
||||||
|
|
||||||
def show_privacy_notice(self):
|
def show_privacy_notice(self):
|
||||||
"""Show privacy notice dialog at startup"""
|
"""Show privacy notice dialog at startup"""
|
||||||
dlg = PrivacyNoticeDialog(self)
|
dlg = PrivacyNoticeDialog(self)
|
||||||
dlg.ShowModal()
|
|
||||||
dlg.Destroy()
|
dlg.Destroy()
|
||||||
|
|
||||||
def get_user_color(self, username):
|
def get_user_color(self, username):
|
||||||
@@ -199,12 +285,12 @@ class IRCFrame(wx.Frame):
|
|||||||
def setup_ui(self):
|
def setup_ui(self):
|
||||||
"""Setup UI components with white theme"""
|
"""Setup UI components with white theme"""
|
||||||
panel = wx.Panel(self)
|
panel = wx.Panel(self)
|
||||||
panel.SetBackgroundColour(wx.Colour(255, 255, 255)) # White background
|
panel.SetBackgroundColour(self.theme["window_bg"])
|
||||||
main_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
main_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
|
|
||||||
# Left sidebar - light gray for contrast
|
# Left sidebar - light gray for contrast
|
||||||
left_panel = wx.Panel(panel)
|
left_panel = wx.Panel(panel)
|
||||||
left_panel.SetBackgroundColour(wx.Colour(240, 240, 240)) # Light gray
|
left_panel.SetBackgroundColour(self.theme["sidebar_bg"])
|
||||||
left_sizer = wx.BoxSizer(wx.VERTICAL)
|
left_sizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
|
||||||
conn_box = wx.StaticBox(left_panel, label="Connection")
|
conn_box = wx.StaticBox(left_panel, label="Connection")
|
||||||
@@ -268,19 +354,35 @@ class IRCFrame(wx.Frame):
|
|||||||
|
|
||||||
left_panel.SetSizer(left_sizer)
|
left_panel.SetSizer(left_sizer)
|
||||||
|
|
||||||
# Center - Notebook
|
self.notebook = wx.aui.AuiNotebook(panel, style=
|
||||||
self.notebook = wx.Notebook(panel)
|
wx.aui.AUI_NB_DEFAULT_STYLE |
|
||||||
self.notebook.SetBackgroundColour(wx.Colour(255, 255, 255))
|
wx.aui.AUI_NB_CLOSE_ON_ACTIVE_TAB |
|
||||||
|
wx.aui.AUI_NB_MIDDLE_CLICK_CLOSE
|
||||||
|
)
|
||||||
|
self.notebook.SetBackgroundColour(self.theme["content_bg"])
|
||||||
|
|
||||||
|
# Setup tab icons
|
||||||
|
self.setup_tab_icons()
|
||||||
|
|
||||||
|
# Bind close event
|
||||||
|
self.notebook.Bind(wx.aui.EVT_AUINOTEBOOK_PAGE_CLOSE, self.on_notebook_page_close)
|
||||||
|
|
||||||
# Server panel
|
|
||||||
server_panel = IRCPanel(self.notebook, self)
|
server_panel = IRCPanel(self.notebook, self)
|
||||||
server_panel.set_target("SERVER")
|
server_panel.set_target("SERVER")
|
||||||
|
|
||||||
|
idx = self.notebook.GetPageCount()
|
||||||
self.notebook.AddPage(server_panel, "Server")
|
self.notebook.AddPage(server_panel, "Server")
|
||||||
|
|
||||||
|
# THIS is the missing line
|
||||||
|
if self.icon_server != -1:
|
||||||
|
self.notebook.SetPageImage(idx, self.icon_server)
|
||||||
|
|
||||||
self.channels["SERVER"] = server_panel
|
self.channels["SERVER"] = server_panel
|
||||||
|
|
||||||
|
|
||||||
# Right sidebar - Users - light gray for contrast
|
# Right sidebar - Users - light gray for contrast
|
||||||
right_panel = wx.Panel(panel)
|
right_panel = wx.Panel(panel)
|
||||||
right_panel.SetBackgroundColour(wx.Colour(240, 240, 240)) # Light gray
|
right_panel.SetBackgroundColour(self.theme["sidebar_bg"])
|
||||||
right_sizer = wx.BoxSizer(wx.VERTICAL)
|
right_sizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
|
||||||
users_box = wx.StaticBox(right_panel, label="Users")
|
users_box = wx.StaticBox(right_panel, label="Users")
|
||||||
@@ -335,8 +437,12 @@ class IRCFrame(wx.Frame):
|
|||||||
file_menu = wx.Menu()
|
file_menu = wx.Menu()
|
||||||
file_menu.Append(300, "&About", "About wxIRC Client")
|
file_menu.Append(300, "&About", "About wxIRC Client")
|
||||||
file_menu.AppendSeparator()
|
file_menu.AppendSeparator()
|
||||||
|
self.sound_toggle_item = file_menu.AppendCheckItem(301, "Toggle &Sounds")
|
||||||
|
self.sound_toggle_item.Check(True) # Sounds enabled by default
|
||||||
|
file_menu.AppendSeparator()
|
||||||
file_menu.Append(wx.ID_EXIT, "E&xit\tCtrl+Q")
|
file_menu.Append(wx.ID_EXIT, "E&xit\tCtrl+Q")
|
||||||
self.Bind(wx.EVT_MENU, self.on_about, id=300)
|
self.Bind(wx.EVT_MENU, self.on_about, id=300)
|
||||||
|
self.Bind(wx.EVT_MENU, self.on_toggle_sounds, id=301)
|
||||||
self.Bind(wx.EVT_MENU, self.on_close, id=wx.ID_EXIT)
|
self.Bind(wx.EVT_MENU, self.on_close, id=wx.ID_EXIT)
|
||||||
|
|
||||||
# Edit menu with search
|
# Edit menu with search
|
||||||
@@ -383,15 +489,37 @@ class IRCFrame(wx.Frame):
|
|||||||
self.Bind(wx.EVT_MENU, self.on_menu_help, id=207)
|
self.Bind(wx.EVT_MENU, self.on_menu_help, id=207)
|
||||||
self.Bind(wx.EVT_MENU, self.on_scan_local_network, id=210)
|
self.Bind(wx.EVT_MENU, self.on_scan_local_network, id=210)
|
||||||
|
|
||||||
|
# IRC Server menu
|
||||||
|
server_menu = wx.Menu()
|
||||||
|
start_item = server_menu.Append(401, "Start Local Server")
|
||||||
|
stop_item = server_menu.Append(402, "Stop Local Server")
|
||||||
|
stop_item.Enable(False)
|
||||||
|
server_menu.AppendSeparator()
|
||||||
|
manage_item = server_menu.Append(403, "Manage Channels...")
|
||||||
|
self.server_menu_items = {
|
||||||
|
"start": start_item,
|
||||||
|
"stop": stop_item,
|
||||||
|
"manage": manage_item,
|
||||||
|
}
|
||||||
|
self.Bind(wx.EVT_MENU, self.on_start_local_server, id=401)
|
||||||
|
self.Bind(wx.EVT_MENU, self.on_stop_local_server, id=402)
|
||||||
|
self.Bind(wx.EVT_MENU, self.on_manage_local_channels, id=403)
|
||||||
|
|
||||||
menubar.Append(file_menu, "&File")
|
menubar.Append(file_menu, "&File")
|
||||||
menubar.Append(edit_menu, "&Edit")
|
menubar.Append(edit_menu, "&Edit")
|
||||||
menubar.Append(channel_menu, "&Channel")
|
menubar.Append(channel_menu, "&Channel")
|
||||||
menubar.Append(tools_menu, "&Tools")
|
menubar.Append(tools_menu, "&Tools")
|
||||||
|
menubar.Append(server_menu, "&IRC Server")
|
||||||
|
|
||||||
self.SetMenuBar(menubar)
|
self.SetMenuBar(menubar)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating menu: {e}")
|
logger.error(f"Error creating menu: {e}")
|
||||||
|
|
||||||
|
def on_toggle_sounds(self, event):
|
||||||
|
"""Toggle sound effects on/off"""
|
||||||
|
self.sounds_enabled = self.sound_toggle_item.IsChecked()
|
||||||
|
logger.info(f"Sounds {'enabled' if self.sounds_enabled else 'disabled'}")
|
||||||
|
|
||||||
def on_about(self, event):
|
def on_about(self, event):
|
||||||
"""Show About dialog"""
|
"""Show About dialog"""
|
||||||
try:
|
try:
|
||||||
@@ -400,6 +528,11 @@ class IRCFrame(wx.Frame):
|
|||||||
dlg.Destroy()
|
dlg.Destroy()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error showing about dialog: {e}")
|
logger.error(f"Error showing about dialog: {e}")
|
||||||
|
if self.sound_handler and self.sounds_enabled:
|
||||||
|
try:
|
||||||
|
self.sound_handler.play_error_sound()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
def on_notes(self, event):
|
def on_notes(self, event):
|
||||||
"""Open notes editor dialog"""
|
"""Open notes editor dialog"""
|
||||||
@@ -420,6 +553,11 @@ class IRCFrame(wx.Frame):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error opening notes dialog: {e}")
|
logger.error(f"Error opening notes dialog: {e}")
|
||||||
self.log_server(f"Error opening notes: {e}", wx.Colour(255, 0, 0))
|
self.log_server(f"Error opening notes: {e}", wx.Colour(255, 0, 0))
|
||||||
|
if self.sound_handler and self.sounds_enabled:
|
||||||
|
try:
|
||||||
|
self.sound_handler.play_error_sound()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
def on_notes_closed(self, event=None):
|
def on_notes_closed(self, event=None):
|
||||||
"""Handle notes frame closing"""
|
"""Handle notes frame closing"""
|
||||||
@@ -585,6 +723,13 @@ class IRCFrame(wx.Frame):
|
|||||||
self.connect_btn.Enable(True)
|
self.connect_btn.Enable(True)
|
||||||
self.SetStatusText("Connection failed")
|
self.SetStatusText("Connection failed")
|
||||||
logger.error(f"Connection failed: {error_msg}")
|
logger.error(f"Connection failed: {error_msg}")
|
||||||
|
|
||||||
|
# Play error sound
|
||||||
|
if self.sound_handler and self.sounds_enabled:
|
||||||
|
try:
|
||||||
|
self.sound_handler.play_error_sound()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error playing connection failure sound: {e}")
|
||||||
|
|
||||||
def disconnect(self):
|
def disconnect(self):
|
||||||
"""Safely disconnect from IRC server"""
|
"""Safely disconnect from IRC server"""
|
||||||
@@ -615,6 +760,13 @@ class IRCFrame(wx.Frame):
|
|||||||
|
|
||||||
def on_disconnect_cleanup(self):
|
def on_disconnect_cleanup(self):
|
||||||
"""Clean up after disconnect"""
|
"""Clean up after disconnect"""
|
||||||
|
# Play disconnect notification sound
|
||||||
|
if self.sound_handler and self.sounds_enabled:
|
||||||
|
try:
|
||||||
|
self.sound_handler.play_warn_sound()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error playing disconnect sound: {e}")
|
||||||
|
|
||||||
with self.connection_lock:
|
with self.connection_lock:
|
||||||
self.connection = None
|
self.connection = None
|
||||||
self.is_connecting = False
|
self.is_connecting = False
|
||||||
@@ -782,38 +934,76 @@ Available commands:
|
|||||||
user_color = self.get_user_color(self.nick)
|
user_color = self.get_user_color(self.nick)
|
||||||
timestamp = self.get_timestamp()
|
timestamp = self.get_timestamp()
|
||||||
self.channels[target].add_formatted_message(timestamp, self.nick, args, user_color, is_action=True)
|
self.channels[target].add_formatted_message(timestamp, self.nick, args, user_color, is_action=True)
|
||||||
elif cmd == "nick" and self.is_connected():
|
elif cmd == "nick":
|
||||||
self.connection.nick(args)
|
if self.is_connected():
|
||||||
elif cmd == "join" and self.is_connected():
|
self.connection.nick(args)
|
||||||
if args and not args.startswith('#'):
|
|
||||||
args = '#' + args
|
|
||||||
self.connection.join(args)
|
|
||||||
elif cmd == "part" and self.is_connected():
|
|
||||||
channel = args if args else target
|
|
||||||
self.connection.part(channel)
|
|
||||||
elif cmd == "quit" and self.is_connected():
|
|
||||||
reason = args if args else "Goodbye"
|
|
||||||
self.connection.quit(reason)
|
|
||||||
elif cmd == "msg" and self.is_connected():
|
|
||||||
nick_msg = args.split(' ', 1)
|
|
||||||
if len(nick_msg) == 2:
|
|
||||||
self.connection.privmsg(nick_msg[0], nick_msg[1])
|
|
||||||
elif cmd == "whois" and self.is_connected():
|
|
||||||
self.connection.whois([args])
|
|
||||||
elif cmd == "kick" and self.is_connected():
|
|
||||||
kick_args = args.split(' ', 1)
|
|
||||||
user = kick_args[0]
|
|
||||||
reason = kick_args[1] if len(kick_args) > 1 else "Kicked"
|
|
||||||
self.connection.kick(target, user, reason)
|
|
||||||
elif cmd == "topic" and self.is_connected():
|
|
||||||
if args:
|
|
||||||
self.connection.topic(target, args)
|
|
||||||
else:
|
else:
|
||||||
self.connection.topic(target)
|
self.safe_ui_update(self.log_server, f"Not connected. Cannot change nickname.", wx.Colour(255, 0, 0))
|
||||||
elif cmd == "away" and self.is_connected():
|
elif cmd == "join":
|
||||||
self.connection.send_raw(f"AWAY :{args}" if args else "AWAY")
|
if self.is_connected():
|
||||||
self.away = bool(args)
|
if not args:
|
||||||
self.safe_ui_update(self.away_item.Check, self.away)
|
self.safe_ui_update(self.log_server, f"Usage: /join <channel>", wx.Colour(255, 0, 0))
|
||||||
|
else:
|
||||||
|
if not args.startswith('#'):
|
||||||
|
args = '#' + args
|
||||||
|
self.connection.join(args)
|
||||||
|
else:
|
||||||
|
self.safe_ui_update(self.log_server, f"Not connected. Cannot join channel.", wx.Colour(255, 0, 0))
|
||||||
|
elif cmd == "part":
|
||||||
|
if self.is_connected():
|
||||||
|
channel = args if args else target
|
||||||
|
self.connection.part(channel)
|
||||||
|
else:
|
||||||
|
self.safe_ui_update(self.log_server, f"Not connected. Cannot part channel.", wx.Colour(255, 0, 0))
|
||||||
|
elif cmd == "quit":
|
||||||
|
if self.is_connected():
|
||||||
|
reason = args if args else "Goodbye"
|
||||||
|
self.connection.quit(reason)
|
||||||
|
else:
|
||||||
|
self.safe_ui_update(self.log_server, f"Not connected.", wx.Colour(255, 0, 0))
|
||||||
|
elif cmd == "msg":
|
||||||
|
if self.is_connected():
|
||||||
|
nick_msg = args.split(' ', 1)
|
||||||
|
if len(nick_msg) == 2:
|
||||||
|
self.connection.privmsg(nick_msg[0], nick_msg[1])
|
||||||
|
else:
|
||||||
|
self.safe_ui_update(self.log_server, f"Usage: /msg <nick> <message>", wx.Colour(255, 0, 0))
|
||||||
|
else:
|
||||||
|
self.safe_ui_update(self.log_server, f"Not connected. Cannot send private message.", wx.Colour(255, 0, 0))
|
||||||
|
elif cmd == "whois":
|
||||||
|
if self.is_connected():
|
||||||
|
if args:
|
||||||
|
self.connection.whois([args])
|
||||||
|
else:
|
||||||
|
self.safe_ui_update(self.log_server, f"Usage: /whois <nick>", wx.Colour(255, 0, 0))
|
||||||
|
else:
|
||||||
|
self.safe_ui_update(self.log_server, f"Not connected. Cannot perform WHOIS.", wx.Colour(255, 0, 0))
|
||||||
|
elif cmd == "kick":
|
||||||
|
if self.is_connected():
|
||||||
|
if args:
|
||||||
|
kick_args = args.split(' ', 1)
|
||||||
|
user = kick_args[0]
|
||||||
|
reason = kick_args[1] if len(kick_args) > 1 else "Kicked"
|
||||||
|
self.connection.kick(target, user, reason)
|
||||||
|
else:
|
||||||
|
self.safe_ui_update(self.log_server, f"Usage: /kick <user> [reason]", wx.Colour(255, 0, 0))
|
||||||
|
else:
|
||||||
|
self.safe_ui_update(self.log_server, f"Not connected. Cannot kick user.", wx.Colour(255, 0, 0))
|
||||||
|
elif cmd == "topic":
|
||||||
|
if self.is_connected():
|
||||||
|
if args:
|
||||||
|
self.connection.topic(target, args)
|
||||||
|
else:
|
||||||
|
self.connection.topic(target)
|
||||||
|
else:
|
||||||
|
self.safe_ui_update(self.log_server, f"Not connected. Cannot get/set topic.", wx.Colour(255, 0, 0))
|
||||||
|
elif cmd == "away":
|
||||||
|
if self.is_connected():
|
||||||
|
self.connection.send_raw(f"AWAY :{args}" if args else "AWAY")
|
||||||
|
self.away = bool(args)
|
||||||
|
self.safe_ui_update(self.away_item.Check, self.away)
|
||||||
|
else:
|
||||||
|
self.safe_ui_update(self.log_server, f"Not connected. Cannot set away status.", wx.Colour(255, 0, 0))
|
||||||
else:
|
else:
|
||||||
self.safe_ui_update(self.log_server, f"Unknown command: {cmd}. Use /help for available commands.", wx.Colour(255, 0, 0))
|
self.safe_ui_update(self.log_server, f"Unknown command: {cmd}. Use /help for available commands.", wx.Colour(255, 0, 0))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -831,11 +1021,18 @@ Available commands:
|
|||||||
try:
|
try:
|
||||||
if channel in self.channels and channel != "SERVER":
|
if channel in self.channels and channel != "SERVER":
|
||||||
def _close_channel():
|
def _close_channel():
|
||||||
|
# Find and delete the page
|
||||||
for i in range(self.notebook.GetPageCount()):
|
for i in range(self.notebook.GetPageCount()):
|
||||||
if self.notebook.GetPageText(i) == channel:
|
if self.notebook.GetPageText(i) == channel:
|
||||||
self.notebook.DeletePage(i)
|
self.notebook.DeletePage(i)
|
||||||
break
|
break
|
||||||
del self.channels[channel]
|
|
||||||
|
# Clean up channel data
|
||||||
|
if channel in self.channels:
|
||||||
|
del self.channels[channel]
|
||||||
|
|
||||||
|
if channel in self.channel_users:
|
||||||
|
del self.channel_users[channel]
|
||||||
|
|
||||||
idx = self.channel_list.FindString(channel)
|
idx = self.channel_list.FindString(channel)
|
||||||
if idx != wx.NOT_FOUND:
|
if idx != wx.NOT_FOUND:
|
||||||
@@ -844,19 +1041,32 @@ Available commands:
|
|||||||
self.safe_ui_update(_close_channel)
|
self.safe_ui_update(_close_channel)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error closing channel: {e}")
|
logger.error(f"Error closing channel: {e}")
|
||||||
|
|
||||||
def log_server(self, message, color=None, bold=False, italic=False, underline=False):
|
def log_server(self, message, color=None, bold=False, italic=False, underline=False):
|
||||||
try:
|
try:
|
||||||
if "SERVER" in self.channels:
|
if "SERVER" in self.channels:
|
||||||
self.channels["SERVER"].add_system_message(f"{self.get_timestamp()}{message}", color, bold)
|
self.channels["SERVER"].add_system_message(f"{self.get_timestamp()}{message}", color, bold)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error logging server message: {e}")
|
logger.error(f"Error logging server message: {e}")
|
||||||
|
|
||||||
|
def log_local_server(self, message, color=None, bold=False):
|
||||||
|
"""Bridge LocalServerManager log output into the Server tab."""
|
||||||
|
display_color = color or wx.Colour(34, 139, 34) # Forest green
|
||||||
|
self.safe_ui_update(
|
||||||
|
self.log_server,
|
||||||
|
f"[Local Server] {message}",
|
||||||
|
display_color,
|
||||||
|
bold,
|
||||||
|
)
|
||||||
|
|
||||||
def log_channel_message(self, channel, username, message, is_action=False, is_system=False):
|
def log_channel_message(self, channel, username, message, is_action=False, is_system=False):
|
||||||
"""Log a message to a channel with username coloring"""
|
"""Log a message to a channel with username coloring"""
|
||||||
try:
|
try:
|
||||||
|
# Don't create new channels if they don't exist and we're trying to log to them
|
||||||
if channel not in self.channels:
|
if channel not in self.channels:
|
||||||
self.safe_ui_update(self.add_channel, channel)
|
# Only create channel if it's being opened by the user, not just receiving messages
|
||||||
|
return
|
||||||
|
|
||||||
if channel in self.channels:
|
if channel in self.channels:
|
||||||
timestamp = self.get_timestamp()
|
timestamp = self.get_timestamp()
|
||||||
|
|
||||||
@@ -879,12 +1089,140 @@ Available commands:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error logging channel message: {e}")
|
logger.error(f"Error logging channel message: {e}")
|
||||||
|
|
||||||
|
def setup_tab_icons(self):
|
||||||
|
try:
|
||||||
|
self.tab_image_list = wx.ImageList(32, 32)
|
||||||
|
|
||||||
|
# Icon file names to search for
|
||||||
|
icon_files = {
|
||||||
|
'server': 'server.ico',
|
||||||
|
'channel': 'channel.ico',
|
||||||
|
'query': 'channel.ico' # Reuse channel.ico for queries if no query.ico exists
|
||||||
|
}
|
||||||
|
|
||||||
|
# Search paths for icons
|
||||||
|
base_paths = [
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
get_resource_path(""),
|
||||||
|
os.path.join(os.getcwd(), "src"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Try to load each icon
|
||||||
|
loaded_icons = {}
|
||||||
|
for icon_type, filename in icon_files.items():
|
||||||
|
icon_path = None
|
||||||
|
for base_path in base_paths:
|
||||||
|
test_path = os.path.join(base_path, filename)
|
||||||
|
if os.path.exists(test_path):
|
||||||
|
icon_path = test_path
|
||||||
|
break
|
||||||
|
|
||||||
|
if icon_path:
|
||||||
|
try:
|
||||||
|
img = wx.Image(icon_path, wx.BITMAP_TYPE_ICO)
|
||||||
|
img = img.Scale(32, 32, wx.IMAGE_QUALITY_HIGH)
|
||||||
|
bmp = wx.Bitmap(img)
|
||||||
|
loaded_icons[icon_type] = self.tab_image_list.Add(bmp)
|
||||||
|
logger.info(f"Loaded {icon_type} icon from {icon_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load {icon_type} icon: {e}")
|
||||||
|
loaded_icons[icon_type] = -1
|
||||||
|
else:
|
||||||
|
logger.info(f"{filename} not found for {icon_type}")
|
||||||
|
loaded_icons[icon_type] = -1
|
||||||
|
|
||||||
|
# Assign icon indices
|
||||||
|
self.icon_server = loaded_icons.get('server', -1)
|
||||||
|
self.icon_channel = loaded_icons.get('channel', -1)
|
||||||
|
self.icon_query = loaded_icons.get('query', -1)
|
||||||
|
|
||||||
|
# Use fallback icons if any failed to load
|
||||||
|
if self.icon_server == -1 or self.icon_channel == -1 or self.icon_query == -1:
|
||||||
|
logger.info("Using fallback icons for missing icon files")
|
||||||
|
self._setup_fallback_icons()
|
||||||
|
|
||||||
|
self.notebook.SetImageList(self.tab_image_list)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error setting up tab icons: {e}")
|
||||||
|
self.icon_server = -1
|
||||||
|
self.icon_channel = -1
|
||||||
|
self.icon_query = -1
|
||||||
|
|
||||||
|
def _setup_fallback_icons(self):
|
||||||
|
"""Setup fallback icons using wx.ArtProvider."""
|
||||||
|
try:
|
||||||
|
self.icon_server = self.tab_image_list.Add(
|
||||||
|
wx.ArtProvider.GetBitmap(wx.ART_INFORMATION, wx.ART_OTHER, (48, 48))
|
||||||
|
)
|
||||||
|
self.icon_channel = self.tab_image_list.Add(
|
||||||
|
wx.ArtProvider.GetBitmap(wx.ART_NORMAL_FILE, wx.ART_OTHER, (48, 48))
|
||||||
|
)
|
||||||
|
self.icon_query = self.tab_image_list.Add(
|
||||||
|
wx.ArtProvider.GetBitmap(wx.ART_HELP, wx.ART_OTHER, (48, 48))
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error setting up fallback icons: {e}")
|
||||||
|
self.icon_server = -1
|
||||||
|
self.icon_channel = -1
|
||||||
|
self.icon_query = -1
|
||||||
|
|
||||||
|
def on_notebook_page_close(self, event):
|
||||||
|
"""Handle tab close button clicks"""
|
||||||
|
try:
|
||||||
|
page_idx = event.GetSelection()
|
||||||
|
channel = self.notebook.GetPageText(page_idx)
|
||||||
|
|
||||||
|
if channel == "Server":
|
||||||
|
event.Veto()
|
||||||
|
wx.MessageBox("Can't close Server!", "Error", wx.OK | wx.ICON_ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
if channel in self.channels:
|
||||||
|
del self.channels[channel]
|
||||||
|
|
||||||
|
if channel in self.channel_users:
|
||||||
|
del self.channel_users[channel]
|
||||||
|
|
||||||
|
# Remove from channel list
|
||||||
|
idx = self.channel_list.FindString(channel)
|
||||||
|
if idx != wx.NOT_FOUND:
|
||||||
|
self.channel_list.Delete(idx)
|
||||||
|
|
||||||
|
if channel.startswith('#') and self.is_connected():
|
||||||
|
try:
|
||||||
|
self.connection.part(channel)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Allow the close to proceed
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error closing tab: {e}")
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
def add_channel(self, channel):
|
def add_channel(self, channel):
|
||||||
|
"""Add a new channel/query tab with appropriate icon"""
|
||||||
try:
|
try:
|
||||||
if channel not in self.channels:
|
if channel not in self.channels:
|
||||||
panel = IRCPanel(self.notebook, self)
|
panel = IRCPanel(self.notebook, self)
|
||||||
panel.set_target(channel)
|
panel.set_target(channel)
|
||||||
self.notebook.AddPage(panel, channel)
|
|
||||||
|
# Determine icon based on channel type
|
||||||
|
if channel == "SERVER":
|
||||||
|
icon_idx = getattr(self, 'icon_server', -1)
|
||||||
|
elif channel.startswith('#'):
|
||||||
|
icon_idx = getattr(self, 'icon_channel', -1)
|
||||||
|
else:
|
||||||
|
icon_idx = getattr(self, 'icon_query', -1)
|
||||||
|
|
||||||
|
# Add page with icon (if icon_idx is -1, no icon will be shown)
|
||||||
|
if icon_idx >= 0:
|
||||||
|
self.notebook.AddPage(panel, channel, select=True, imageId=icon_idx)
|
||||||
|
else:
|
||||||
|
self.notebook.AddPage(panel, channel, select=True)
|
||||||
|
|
||||||
self.channels[channel] = panel
|
self.channels[channel] = panel
|
||||||
|
|
||||||
if channel.startswith('#'):
|
if channel.startswith('#'):
|
||||||
@@ -901,6 +1239,11 @@ Available commands:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error opening scan wizard: {e}")
|
logger.error(f"Error opening scan wizard: {e}")
|
||||||
self.log_server(f"Scan wizard failed to open: {e}", wx.Colour(255, 0, 0))
|
self.log_server(f"Scan wizard failed to open: {e}", wx.Colour(255, 0, 0))
|
||||||
|
if self.sound_handler and self.sounds_enabled:
|
||||||
|
try:
|
||||||
|
self.sound_handler.play_error_sound()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
def quick_connect(self, server, port):
|
def quick_connect(self, server, port):
|
||||||
"""Populate connection fields and initiate a connection if idle."""
|
"""Populate connection fields and initiate a connection if idle."""
|
||||||
@@ -920,6 +1263,65 @@ Available commands:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Menu handlers
|
# Menu handlers
|
||||||
|
def update_server_menu_state(self):
|
||||||
|
try:
|
||||||
|
if not self.server_menu_items:
|
||||||
|
return
|
||||||
|
running = self.local_server_manager.is_running()
|
||||||
|
self.server_menu_items["start"].Enable(not running)
|
||||||
|
self.server_menu_items["stop"].Enable(running)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating server menu state: {e}")
|
||||||
|
|
||||||
|
def on_start_local_server(self, event):
|
||||||
|
try:
|
||||||
|
dlg = InterfaceSelectDialog(self, self.local_bind_host)
|
||||||
|
result = dlg.ShowModal()
|
||||||
|
if result != wx.ID_OK:
|
||||||
|
dlg.Destroy()
|
||||||
|
return
|
||||||
|
selected_host = dlg.get_selected_host()
|
||||||
|
dlg.Destroy()
|
||||||
|
|
||||||
|
if selected_host != self.local_bind_host:
|
||||||
|
self.local_server_manager.set_listen_host(selected_host)
|
||||||
|
self.local_bind_host = selected_host
|
||||||
|
|
||||||
|
self.local_server_manager.start()
|
||||||
|
self.update_server_menu_state()
|
||||||
|
self.SetStatusText(f"Local IRC server running on {self.local_bind_host}:6667 (LAN only)")
|
||||||
|
self.log_local_server(f"Server is online and listening on {self.local_bind_host}:6667.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start local server: {e}")
|
||||||
|
self.log_local_server(f"Failed to start: {e}", wx.Colour(255, 0, 0), bold=True)
|
||||||
|
wx.MessageBox(f"Could not start local IRC server:\n{e}", "Server Error", wx.OK | wx.ICON_ERROR)
|
||||||
|
self.update_server_menu_state()
|
||||||
|
|
||||||
|
def on_stop_local_server(self, event):
|
||||||
|
try:
|
||||||
|
self.local_server_manager.stop()
|
||||||
|
self.update_server_menu_state()
|
||||||
|
self.SetStatusText("Local IRC server stopped")
|
||||||
|
self.log_local_server("Server stopped.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to stop local server: {e}")
|
||||||
|
self.log_local_server(f"Failed to stop: {e}", wx.Colour(255, 0, 0), bold=True)
|
||||||
|
|
||||||
|
def on_manage_local_channels(self, event):
|
||||||
|
try:
|
||||||
|
dlg = ManageChannelsDialog(self, self.local_server_manager.get_channels())
|
||||||
|
if dlg.ShowModal() == wx.ID_OK:
|
||||||
|
channels = dlg.get_channels()
|
||||||
|
self.local_server_manager.set_channels(channels)
|
||||||
|
self.log_local_server(
|
||||||
|
f"Manage Channels updated: {', '.join(channels)}",
|
||||||
|
wx.Colour(0, 100, 0),
|
||||||
|
)
|
||||||
|
dlg.Destroy()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error managing local channels: {e}")
|
||||||
|
wx.MessageBox(f"Unable to open Manage Channels: {e}", "Error", wx.OK | wx.ICON_ERROR)
|
||||||
|
|
||||||
def on_menu_join(self, event):
|
def on_menu_join(self, event):
|
||||||
try:
|
try:
|
||||||
dlg = wx.TextEntryDialog(self, "Enter channel name:", "Join Channel")
|
dlg = wx.TextEntryDialog(self, "Enter channel name:", "Join Channel")
|
||||||
@@ -1050,6 +1452,13 @@ COMMANDS (type /help in chat for full list):
|
|||||||
self.log_server("Connected to server!", wx.Colour(0, 128, 0), bold=True) # Dark green
|
self.log_server("Connected to server!", wx.Colour(0, 128, 0), bold=True) # Dark green
|
||||||
self.log_server(f"Welcome message: {' '.join(event.arguments)}", wx.Colour(0, 100, 0))
|
self.log_server(f"Welcome message: {' '.join(event.arguments)}", wx.Colour(0, 100, 0))
|
||||||
|
|
||||||
|
# Play welcome/connection sound
|
||||||
|
if self.sound_handler and self.sounds_enabled:
|
||||||
|
try:
|
||||||
|
self.sound_handler.play_connect_server_or_channel()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error playing welcome sound: {e}")
|
||||||
|
|
||||||
# Auto-join channels
|
# Auto-join channels
|
||||||
for channel in self.auto_join_channels:
|
for channel in self.auto_join_channels:
|
||||||
if not channel.startswith('#'):
|
if not channel.startswith('#'):
|
||||||
@@ -1068,6 +1477,13 @@ COMMANDS (type /help in chat for full list):
|
|||||||
if nick == self.nick:
|
if nick == self.nick:
|
||||||
self.safe_ui_update(self.add_channel, channel)
|
self.safe_ui_update(self.add_channel, channel)
|
||||||
self.log_server(f"Joined channel {channel}", wx.Colour(0, 128, 0)) # Dark green
|
self.log_server(f"Joined channel {channel}", wx.Colour(0, 128, 0)) # Dark green
|
||||||
|
|
||||||
|
# Play sound when we join a channel
|
||||||
|
if self.sound_handler and self.sounds_enabled:
|
||||||
|
try:
|
||||||
|
self.sound_handler.play_connect_server_or_channel()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error playing channel join sound: {e}")
|
||||||
|
|
||||||
self.log_channel_message(channel, nick, f"→ {nick} joined", is_system=True)
|
self.log_channel_message(channel, nick, f"→ {nick} joined", is_system=True)
|
||||||
|
|
||||||
@@ -1125,6 +1541,13 @@ COMMANDS (type /help in chat for full list):
|
|||||||
self.log_channel_message(channel, nick, message, is_action=True)
|
self.log_channel_message(channel, nick, message, is_action=True)
|
||||||
else:
|
else:
|
||||||
self.log_channel_message(channel, nick, message)
|
self.log_channel_message(channel, nick, message)
|
||||||
|
|
||||||
|
# Play sound notification only for real user messages (not from self)
|
||||||
|
if self.sound_handler and self.sounds_enabled and nick.lower() != self.nick.lower():
|
||||||
|
try:
|
||||||
|
self.sound_handler.play_msg_recv()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error playing message sound: {e}")
|
||||||
|
|
||||||
# Highlight own nick in messages
|
# Highlight own nick in messages
|
||||||
if self.nick.lower() in message.lower():
|
if self.nick.lower() in message.lower():
|
||||||
@@ -1143,6 +1566,13 @@ COMMANDS (type /help in chat for full list):
|
|||||||
self.log_channel_message(nick, nick, message, is_action=True)
|
self.log_channel_message(nick, nick, message, is_action=True)
|
||||||
else:
|
else:
|
||||||
self.log_channel_message(nick, nick, message)
|
self.log_channel_message(nick, nick, message)
|
||||||
|
|
||||||
|
# Play mail sound for private messages
|
||||||
|
if self.sound_handler and self.sounds_enabled and nick.lower() != self.nick.lower():
|
||||||
|
try:
|
||||||
|
self.sound_handler.play_mail_sound()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error playing private message sound: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in privmsg handler: {e}")
|
logger.error(f"Error in privmsg handler: {e}")
|
||||||
|
|
||||||
@@ -1316,6 +1746,9 @@ COMMANDS (type /help in chat for full list):
|
|||||||
# Stop UI timer first
|
# Stop UI timer first
|
||||||
if self.ui_timer and self.ui_timer.IsRunning():
|
if self.ui_timer and self.ui_timer.IsRunning():
|
||||||
self.ui_timer.Stop()
|
self.ui_timer.Stop()
|
||||||
|
|
||||||
|
if self.local_server_manager and self.local_server_manager.is_running():
|
||||||
|
self.local_server_manager.stop()
|
||||||
|
|
||||||
# Notes data will be lost when app closes (RAM only)
|
# Notes data will be lost when app closes (RAM only)
|
||||||
# User can save to file if they want persistence
|
# User can save to file if they want persistence
|
||||||
@@ -1341,14 +1774,12 @@ if os.name == 'nt':
|
|||||||
ctypes.windll.user32.SetProcessDPIAware()
|
ctypes.windll.user32.SetProcessDPIAware()
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
enable_high_dpi()
|
enable_high_dpi()
|
||||||
|
|
||||||
app = wx.App()
|
app = wx.App()
|
||||||
frame = IRCFrame()
|
frame = IRCFrame()
|
||||||
frame.SetIcon(wx.Icon(get_resource_path("icon.ico"), wx.BITMAP_TYPE_ICO))
|
frame.SetIcon(wx.Icon(get_resource_path("icon.ico"), wx.BITMAP_TYPE_ICO))
|
||||||
|
|||||||
BIN
src/server.ico
Normal file
BIN
src/server.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
BIN
src/sounds/balloon.wav
Normal file
BIN
src/sounds/balloon.wav
Normal file
Binary file not shown.
BIN
src/sounds/space-pdj.wav
Normal file
BIN
src/sounds/space-pdj.wav
Normal file
Binary file not shown.
BIN
src/sounds/startup.wav
Normal file
BIN
src/sounds/startup.wav
Normal file
Binary file not shown.
336
write.ps1
Normal file
336
write.ps1
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
param(
|
||||||
|
[string]$usbLetter,
|
||||||
|
[string]$srcPath = ".\src",
|
||||||
|
[string]$distPath = ".\dist\main.exe"
|
||||||
|
)
|
||||||
|
|
||||||
|
function Write-Header {
|
||||||
|
param([string]$title)
|
||||||
|
$width = 80
|
||||||
|
$padding = [math]::Floor(($width - $title.Length - 2) / 2)
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ("=" * $width) -ForegroundColor Cyan
|
||||||
|
Write-Host ("=" + (" " * $padding) + $title + (" " * $padding) + "=") -ForegroundColor Cyan
|
||||||
|
Write-Host ("=" * $width) -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Step {
|
||||||
|
param([string]$message)
|
||||||
|
Write-Host "[*] " -NoNewline -ForegroundColor Green
|
||||||
|
Write-Host $message -ForegroundColor White
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-ErrorMsg {
|
||||||
|
param([string]$message)
|
||||||
|
Write-Host "[X] " -NoNewline -ForegroundColor Red
|
||||||
|
Write-Host $message -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Success {
|
||||||
|
param([string]$message)
|
||||||
|
Write-Host "[+] " -NoNewline -ForegroundColor Green
|
||||||
|
Write-Host $message -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Warning {
|
||||||
|
param([string]$message)
|
||||||
|
Write-Host "[!] " -NoNewline -ForegroundColor Yellow
|
||||||
|
Write-Host $message -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-ProgressBar {
|
||||||
|
param(
|
||||||
|
[int]$current,
|
||||||
|
[int]$total,
|
||||||
|
[string]$activity
|
||||||
|
)
|
||||||
|
$percent = [math]::Min(100, [math]::Floor(($current / $total) * 100))
|
||||||
|
$barWidth = 50
|
||||||
|
$completed = [math]::Floor(($percent / 100) * $barWidth)
|
||||||
|
$remaining = $barWidth - $completed
|
||||||
|
|
||||||
|
$completedBar = "#" * $completed
|
||||||
|
$remainingBar = "-" * $remaining
|
||||||
|
$bar = "[" + $completedBar + $remainingBar + "]"
|
||||||
|
|
||||||
|
Write-Host ("`r$activity $bar $percent% ($current/$total)") -NoNewline -ForegroundColor Cyan
|
||||||
|
|
||||||
|
if ($current -eq $total) {
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-Prerequisites {
|
||||||
|
$errors = @()
|
||||||
|
|
||||||
|
# Check if running as admin (for autorun.inf)
|
||||||
|
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
if (-not $isAdmin) {
|
||||||
|
Write-Warning "Not running as administrator. Autorun.inf may not work properly."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate USB letter format
|
||||||
|
if ($usbLetter -notmatch '^[A-Z]:?$') {
|
||||||
|
$errors += "Invalid USB letter format. Use format like 'E:' or 'E'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Normalize USB letter
|
||||||
|
$script:usbLetter = $usbLetter.TrimEnd(':') + ':'
|
||||||
|
|
||||||
|
# Check if paths exist
|
||||||
|
if (-not (Test-Path $srcPath)) {
|
||||||
|
$errors += "Source path not found: $srcPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $distPath)) {
|
||||||
|
$errors += "Distribution executable not found: $distPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check USB drive
|
||||||
|
$usbRoot = "$($script:usbLetter)\"
|
||||||
|
if (-not (Test-Path $usbRoot)) {
|
||||||
|
$errors += "USB drive not found: $usbRoot"
|
||||||
|
} else {
|
||||||
|
# Check if USB has enough space
|
||||||
|
try {
|
||||||
|
$drive = Get-PSDrive -Name $script:usbLetter.TrimEnd(':') -ErrorAction Stop
|
||||||
|
$freeSpaceMB = [math]::Round($drive.Free / 1MB, 2)
|
||||||
|
|
||||||
|
# Estimate required space
|
||||||
|
$srcSize = (Get-ChildItem -Path $srcPath -Recurse -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.FullName -notmatch "__pycache__" } |
|
||||||
|
Measure-Object -Property Length -Sum).Sum
|
||||||
|
$distSize = (Get-Item $distPath).Length
|
||||||
|
$requiredMB = [math]::Round(($srcSize + $distSize) / 1MB * 1.1, 2)
|
||||||
|
|
||||||
|
if ($freeSpaceMB -lt $requiredMB) {
|
||||||
|
$errors += "Insufficient space on USB. Required: ~$requiredMB MB, Available: $freeSpaceMB MB"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Warning "Could not check USB free space: $_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors
|
||||||
|
}
|
||||||
|
|
||||||
|
Clear-Host
|
||||||
|
Write-Header "wxIRC USB Writer"
|
||||||
|
|
||||||
|
# Show usage if no USB letter provided
|
||||||
|
if (-not $usbLetter) {
|
||||||
|
Write-Host "Usage: .\write.ps1 -usbLetter E:" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Parameters:" -ForegroundColor Cyan
|
||||||
|
Write-Host " -usbLetter : Target USB drive letter (required)" -ForegroundColor White
|
||||||
|
Write-Host " -srcPath : Source directory (default: .\src)" -ForegroundColor White
|
||||||
|
Write-Host " -distPath : Executable path (default: .\dist\main.exe)" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Validating prerequisites..."
|
||||||
|
$validationErrors = Test-Prerequisites
|
||||||
|
|
||||||
|
if ($validationErrors.Count -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
foreach ($err in $validationErrors) {
|
||||||
|
Write-ErrorMsg $err
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Success "All prerequisites validated"
|
||||||
|
|
||||||
|
# Convert to absolute paths
|
||||||
|
try {
|
||||||
|
$srcPath = (Resolve-Path $srcPath -ErrorAction Stop).Path
|
||||||
|
$distPath = (Resolve-Path $distPath -ErrorAction Stop).Path
|
||||||
|
} catch {
|
||||||
|
Write-ErrorMsg "Failed to resolve paths: $_"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$usbRoot = "$usbLetter\"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Configuration:" -ForegroundColor Cyan
|
||||||
|
Write-Host " Source : $srcPath" -ForegroundColor White
|
||||||
|
Write-Host " Executable : $distPath" -ForegroundColor White
|
||||||
|
Write-Host " USB Target : $usbRoot" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Confirm before proceeding
|
||||||
|
Write-Host "This will overwrite existing files on the USB drive." -ForegroundColor Yellow
|
||||||
|
$response = Read-Host "Continue? (Y/N)"
|
||||||
|
if ($response -ne 'Y' -and $response -ne 'y') {
|
||||||
|
Write-Host "Operation cancelled." -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
$startTime = Get-Date
|
||||||
|
|
||||||
|
Write-Step "Preparing source directory..."
|
||||||
|
$srcDest = Join-Path $usbRoot "src"
|
||||||
|
|
||||||
|
if (Test-Path $srcDest) {
|
||||||
|
try {
|
||||||
|
Remove-Item -Recurse -Force $srcDest -ErrorAction Stop
|
||||||
|
} catch {
|
||||||
|
Write-ErrorMsg "Failed to remove existing src directory: $_"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Path $srcDest -ErrorAction Stop | Out-Null
|
||||||
|
} catch {
|
||||||
|
Write-ErrorMsg "Failed to create src directory: $_"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Scanning source files..."
|
||||||
|
$allFiles = @(Get-ChildItem -Path $srcPath -Recurse -ErrorAction SilentlyContinue | Where-Object {
|
||||||
|
$_.FullName -notmatch "__pycache__"
|
||||||
|
})
|
||||||
|
|
||||||
|
$totalFiles = $allFiles.Count
|
||||||
|
$currentFile = 0
|
||||||
|
|
||||||
|
Write-Step "Copying $totalFiles files..."
|
||||||
|
|
||||||
|
foreach ($item in $allFiles) {
|
||||||
|
$currentFile++
|
||||||
|
|
||||||
|
try {
|
||||||
|
$relative = $item.FullName.Substring($srcPath.Length)
|
||||||
|
$target = Join-Path $srcDest $relative
|
||||||
|
|
||||||
|
if ($item.PSIsContainer) {
|
||||||
|
if (-not (Test-Path $target)) {
|
||||||
|
New-Item -ItemType Directory -Path $target -ErrorAction Stop | Out-Null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$targetDir = Split-Path -Parent $target
|
||||||
|
if (-not (Test-Path $targetDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $targetDir -ErrorAction Stop | Out-Null
|
||||||
|
}
|
||||||
|
Copy-Item $item.FullName -Destination $target -Force -ErrorAction Stop
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-ProgressBar -current $currentFile -total $totalFiles -activity "Copying files"
|
||||||
|
} catch {
|
||||||
|
Write-Host ""
|
||||||
|
Write-ErrorMsg "Failed to copy $($item.FullName): $_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Success "Source files copied successfully"
|
||||||
|
|
||||||
|
Write-Step "Copying executable..."
|
||||||
|
try {
|
||||||
|
$exeDest = Join-Path $usbRoot "wxIRC.exe"
|
||||||
|
$exeSize = (Get-Item $distPath).Length
|
||||||
|
$exeSizeMB = [math]::Round($exeSize / 1MB, 2)
|
||||||
|
|
||||||
|
Write-Host " Size: $exeSizeMB MB" -ForegroundColor Gray
|
||||||
|
|
||||||
|
# Copy with progress simulation for large files
|
||||||
|
if ($exeSize -gt 5MB) {
|
||||||
|
$buffer = 1MB
|
||||||
|
$read = 0
|
||||||
|
$totalChunks = [math]::Ceiling($exeSize / $buffer)
|
||||||
|
$currentChunk = 0
|
||||||
|
|
||||||
|
$sourceStream = [System.IO.File]::OpenRead($distPath)
|
||||||
|
$destStream = [System.IO.File]::Create($exeDest)
|
||||||
|
$bufferArray = New-Object byte[] $buffer
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (($read = $sourceStream.Read($bufferArray, 0, $buffer)) -gt 0) {
|
||||||
|
$destStream.Write($bufferArray, 0, $read)
|
||||||
|
$currentChunk++
|
||||||
|
Write-ProgressBar -current $currentChunk -total $totalChunks -activity "Copying executable"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$sourceStream.Close()
|
||||||
|
$destStream.Close()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
# For smaller files, just copy directly
|
||||||
|
Copy-Item $distPath -Destination $exeDest -Force -ErrorAction Stop
|
||||||
|
Write-ProgressBar -current 1 -total 1 -activity "Copying executable"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Success "Executable copied as wxIRC.exe"
|
||||||
|
} catch {
|
||||||
|
Write-ErrorMsg "Failed to copy executable: $_"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Creating autorun.inf..."
|
||||||
|
try {
|
||||||
|
$autorun = Join-Path $usbRoot "autorun.inf"
|
||||||
|
@(
|
||||||
|
"[AutoRun]"
|
||||||
|
"open=wxIRC.exe"
|
||||||
|
"label=wxIRC"
|
||||||
|
"icon=src\icon.ico"
|
||||||
|
) | Set-Content -Path $autorun -Encoding ASCII -ErrorAction Stop
|
||||||
|
|
||||||
|
# Try to set hidden attribute (may fail without admin)
|
||||||
|
try {
|
||||||
|
$autorunItem = Get-Item $autorun -ErrorAction Stop
|
||||||
|
$autorunItem.Attributes = $autorunItem.Attributes -bor [System.IO.FileAttributes]::Hidden
|
||||||
|
} catch {
|
||||||
|
# Silently continue if can't set hidden attribute
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Success "Autorun.inf created"
|
||||||
|
} catch {
|
||||||
|
Write-ErrorMsg "Failed to create autorun.inf: $_"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Generating write info..."
|
||||||
|
$endTime = Get-Date
|
||||||
|
$duration = ($endTime - $startTime).TotalSeconds
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sizeMB = (Get-ChildItem -Recurse -Path $usbRoot -ErrorAction SilentlyContinue |
|
||||||
|
Measure-Object Length -Sum).Sum / 1MB
|
||||||
|
$speed = if ($duration -gt 0) { [math]::Round(($sizeMB / $duration), 2) } else { 0 }
|
||||||
|
|
||||||
|
$infoFile = Join-Path $usbRoot "writeinfo.txt"
|
||||||
|
@(
|
||||||
|
"wxIRC USB Write Report"
|
||||||
|
"=" * 50
|
||||||
|
"Completed : $endTime"
|
||||||
|
"Total Size : $([math]::Round($sizeMB, 2)) MB"
|
||||||
|
"Duration : $([math]::Round($duration, 2)) seconds"
|
||||||
|
"Write Speed : $speed MB/s"
|
||||||
|
"Files Copied : $totalFiles"
|
||||||
|
""
|
||||||
|
"USB Drive : $usbRoot"
|
||||||
|
) | Set-Content -Path $infoFile -ErrorAction Stop
|
||||||
|
|
||||||
|
Write-Success "Write info saved to writeinfo.txt"
|
||||||
|
} catch {
|
||||||
|
Write-ErrorMsg "Failed to create writeinfo.txt: $_"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ("=" * 80) -ForegroundColor Green
|
||||||
|
Write-Host " USB WRITE COMPLETED SUCCESSFULLY!" -ForegroundColor Green
|
||||||
|
Write-Host ("=" * 80) -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Summary:" -ForegroundColor Cyan
|
||||||
|
Write-Host " Files Copied : $totalFiles" -ForegroundColor White
|
||||||
|
Write-Host " Total Size : $([math]::Round($sizeMB, 2)) MB" -ForegroundColor White
|
||||||
|
Write-Host " Duration : $([math]::Round($duration, 2)) seconds" -ForegroundColor White
|
||||||
|
Write-Host " Write Speed : $speed MB/s" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "The USB drive is ready to use!" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
Reference in New Issue
Block a user