Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35dfedd5c9 | |||
| b4c74f098b | |||
| 61a2458f83 | |||
| d5a4628281 | |||
| 5456b6c5fd | |||
| 6bdf31cb26 | |||
| 8737720af6 | |||
| 0c7b2d3bdb | |||
| a2ef1bf2d5 |
1199
src/IRCPanel.py
1199
src/IRCPanel.py
File diff suppressed because it is too large
Load Diff
213
src/LocalServer.py
Normal file
213
src/LocalServer.py
Normal file
@@ -0,0 +1,213 @@
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
from typing import Callable, Iterable, List, Optional
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
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: Optional[LocalOnlyIRCServer] = None
|
||||
self._thread: Optional[threading.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)
|
||||
|
||||
@@ -19,6 +19,7 @@ class NotesDialog(wx.Frame):
|
||||
style=wx.DEFAULT_FRAME_STYLE)
|
||||
|
||||
self.parent = parent
|
||||
self.theme = getattr(parent, "theme", None)
|
||||
self.notes_data = notes_data or defaultdict(dict)
|
||||
self.current_note_key = None
|
||||
self.updating_title = False
|
||||
@@ -27,7 +28,7 @@ class NotesDialog(wx.Frame):
|
||||
self.last_save_time = time.time()
|
||||
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
|
||||
if parent:
|
||||
@@ -60,6 +61,11 @@ class NotesDialog(wx.Frame):
|
||||
def close_parent(self, pId):
|
||||
if self.GetParent().GetId() == pId:
|
||||
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):
|
||||
# Create menu bar
|
||||
@@ -72,6 +78,7 @@ class NotesDialog(wx.Frame):
|
||||
|
||||
# Left panel - notes list
|
||||
left_panel = wx.Panel(splitter)
|
||||
left_panel.SetBackgroundColour(self.get_theme_colour("sidebar_bg", left_panel.GetBackgroundColour()))
|
||||
left_sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
|
||||
notes_label = wx.StaticText(left_panel, label="Your Notes:")
|
||||
@@ -101,6 +108,7 @@ class NotesDialog(wx.Frame):
|
||||
|
||||
# Right panel - editor
|
||||
right_panel = wx.Panel(splitter)
|
||||
right_panel.SetBackgroundColour(self.get_theme_colour("content_bg", right_panel.GetBackgroundColour()))
|
||||
right_sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
|
||||
title_label = wx.StaticText(right_panel, label="Note Title:")
|
||||
@@ -226,6 +234,7 @@ class NotesDialog(wx.Frame):
|
||||
|
||||
def setup_editor_toolbar(self, parent):
|
||||
self.toolbar = wx.Panel(parent)
|
||||
self.toolbar.SetBackgroundColour(self.get_theme_colour("control_bg", self.toolbar.GetBackgroundColour()))
|
||||
toolbar_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
|
||||
# Text formatting buttons
|
||||
|
||||
@@ -229,24 +229,41 @@ class ScanWizardResultsPage(adv.WizardPageSimple):
|
||||
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")
|
||||
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):
|
||||
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"}")
|
||||
"""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):
|
||||
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()
|
||||
"""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()
|
||||
|
||||
477
src/main.py
477
src/main.py
@@ -17,6 +17,7 @@ from IRCPanel import IRCPanel
|
||||
from AboutDialog import AboutDialog
|
||||
from NotesDialog import NotesDialog
|
||||
from ScanWizard import ScanWizardDialog
|
||||
from LocalServer import LocalServerManager
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
@@ -39,12 +40,214 @@ class UIUpdate:
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
class IRCFrame(wx.Frame):
|
||||
def __init__(self):
|
||||
super().__init__(None, title="wxIRC", size=(1200, 700))
|
||||
|
||||
# Apply white theme
|
||||
self.apply_white_theme()
|
||||
# Determine platform theme once
|
||||
self.theme = self.build_theme()
|
||||
self.apply_theme()
|
||||
|
||||
# Show privacy notice first
|
||||
self.show_privacy_notice()
|
||||
@@ -70,6 +273,12 @@ class IRCFrame(wx.Frame):
|
||||
self.timestamps = True
|
||||
|
||||
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
|
||||
self.user_colors = {}
|
||||
@@ -121,18 +330,69 @@ class IRCFrame(wx.Frame):
|
||||
self.Bind(wx.EVT_MENU, self.on_find_previous, id=1002)
|
||||
self.Bind(wx.EVT_MENU, self.on_quick_escape, id=1003)
|
||||
|
||||
def apply_white_theme(self):
|
||||
"""Apply white theme to the application"""
|
||||
def build_theme(self):
|
||||
"""Build a small theme descriptor that respects the host platform."""
|
||||
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()
|
||||
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:
|
||||
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):
|
||||
"""Show privacy notice dialog at startup"""
|
||||
@@ -199,12 +459,12 @@ class IRCFrame(wx.Frame):
|
||||
def setup_ui(self):
|
||||
"""Setup UI components with white theme"""
|
||||
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)
|
||||
|
||||
# Left sidebar - light gray for contrast
|
||||
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)
|
||||
|
||||
conn_box = wx.StaticBox(left_panel, label="Connection")
|
||||
@@ -270,7 +530,7 @@ class IRCFrame(wx.Frame):
|
||||
|
||||
# Center - Notebook
|
||||
self.notebook = wx.Notebook(panel)
|
||||
self.notebook.SetBackgroundColour(wx.Colour(255, 255, 255))
|
||||
self.notebook.SetBackgroundColour(self.theme["content_bg"])
|
||||
|
||||
# Server panel
|
||||
server_panel = IRCPanel(self.notebook, self)
|
||||
@@ -280,7 +540,7 @@ class IRCFrame(wx.Frame):
|
||||
|
||||
# Right sidebar - Users - light gray for contrast
|
||||
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)
|
||||
|
||||
users_box = wx.StaticBox(right_panel, label="Users")
|
||||
@@ -383,10 +643,27 @@ class IRCFrame(wx.Frame):
|
||||
self.Bind(wx.EVT_MENU, self.on_menu_help, id=207)
|
||||
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(edit_menu, "&Edit")
|
||||
menubar.Append(channel_menu, "&Channel")
|
||||
menubar.Append(tools_menu, "&Tools")
|
||||
menubar.Append(server_menu, "&IRC Server")
|
||||
|
||||
self.SetMenuBar(menubar)
|
||||
except Exception as e:
|
||||
@@ -782,38 +1059,76 @@ Available commands:
|
||||
user_color = self.get_user_color(self.nick)
|
||||
timestamp = self.get_timestamp()
|
||||
self.channels[target].add_formatted_message(timestamp, self.nick, args, user_color, is_action=True)
|
||||
elif cmd == "nick" and self.is_connected():
|
||||
self.connection.nick(args)
|
||||
elif cmd == "join" and self.is_connected():
|
||||
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)
|
||||
elif cmd == "nick":
|
||||
if self.is_connected():
|
||||
self.connection.nick(args)
|
||||
else:
|
||||
self.connection.topic(target)
|
||||
elif cmd == "away" and 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)
|
||||
self.safe_ui_update(self.log_server, f"Not connected. Cannot change nickname.", wx.Colour(255, 0, 0))
|
||||
elif cmd == "join":
|
||||
if self.is_connected():
|
||||
if not args:
|
||||
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:
|
||||
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:
|
||||
@@ -851,6 +1166,16 @@ Available commands:
|
||||
self.channels["SERVER"].add_system_message(f"{self.get_timestamp()}{message}", color, bold)
|
||||
except Exception as 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):
|
||||
"""Log a message to a channel with username coloring"""
|
||||
@@ -920,6 +1245,65 @@ Available commands:
|
||||
return False
|
||||
|
||||
# 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):
|
||||
try:
|
||||
dlg = wx.TextEntryDialog(self, "Enter channel name:", "Join Channel")
|
||||
@@ -1316,6 +1700,9 @@ COMMANDS (type /help in chat for full list):
|
||||
# Stop UI timer first
|
||||
if self.ui_timer and self.ui_timer.IsRunning():
|
||||
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)
|
||||
# User can save to file if they want persistence
|
||||
|
||||
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