diff --git a/gui.py b/gui.py index fd8bc2d..110ac53 100644 --- a/gui.py +++ b/gui.py @@ -3,4 +3,7 @@ from ltchiptool.gui import cli if __name__ == "__main__": + from ltchiptool.util.ltim import LTIM + + LTIM.get().is_gui_entrypoint = True cli() diff --git a/ltchiptool/__main__.py b/ltchiptool/__main__.py index 3b2e21f..b492dfc 100644 --- a/ltchiptool/__main__.py +++ b/ltchiptool/__main__.py @@ -9,11 +9,10 @@ from ltchiptool.util.cli import get_multi_command_class from ltchiptool.util.logging import VERBOSE, LoggingHandler, log_setup_click_bars +from ltchiptool.util.ltim import LTIM from ltchiptool.util.lvm import LVM from ltchiptool.util.streams import LoggingStreamHook -from .version import get_version - COMMANDS = { # compile commands "elf2bin": "ltchiptool/commands/compile/elf2bin.py", @@ -86,10 +85,10 @@ type=click.Path(exists=True, dir_okay=True), ) @click.version_option( - get_version(), + LTIM.get_version_full(), "-V", "--version", - message="ltchiptool v%(version)s", + message="ltchiptool %(version)s", ) @click.pass_context def cli_entrypoint( diff --git a/ltchiptool/gui/__init__.py b/ltchiptool/gui/__init__.py index 475a291..21d7b8d 100644 --- a/ltchiptool/gui/__init__.py +++ b/ltchiptool/gui/__init__.py @@ -6,6 +6,12 @@ def cli(): - from .__main__ import cli + import sys - cli() + from .__main__ import cli, install_cli + + if len(sys.argv) > 1 and sys.argv[1] == "install": + sys.argv.pop(1) + install_cli() + else: + cli() diff --git a/ltchiptool/gui/__main__.py b/ltchiptool/gui/__main__.py index 2de1c7b..ef1786c 100644 --- a/ltchiptool/gui/__main__.py +++ b/ltchiptool/gui/__main__.py @@ -2,14 +2,15 @@ import sys from logging import INFO, NOTSET, error, exception +from pathlib import Path import click -from ltchiptool import get_version from ltchiptool.util.logging import LoggingHandler +from ltchiptool.util.ltim import LTIM -def gui_entrypoint(*args, **kwargs): +def gui_entrypoint(install: bool, *args, **kwargs): if sys.version_info < (3, 10, 0): error("ltchiptool GUI requires Python 3.10 or newer") exit(1) @@ -23,13 +24,20 @@ def gui_entrypoint(*args, **kwargs): app = wx.App() try: - from .main import MainFrame - if LoggingHandler.get().level == NOTSET: LoggingHandler.get().level = INFO - frm = MainFrame(None, title=f"ltchiptool v{get_version()}") - frm.init_params = kwargs - frm.Show() + + if not install: + from .main import MainFrame + + frm = MainFrame(None, title=f"ltchiptool {LTIM.get_version_full()}") + frm.init_params = kwargs + frm.Show() + else: + from .install import InstallFrame + + frm = InstallFrame(install_kwargs=kwargs, parent=None) + frm.Show() app.MainLoop() except Exception as e: LoggingHandler.get().exception_hook = None @@ -47,7 +55,42 @@ def gui_entrypoint(*args, **kwargs): @click.argument("FILE", type=str, required=False) def cli(*args, **kwargs): try: - gui_entrypoint(*args, **kwargs) + gui_entrypoint(install=False, *args, **kwargs) + except Exception as e: + exception(None, exc_info=e) + exit(1) + + +@click.command(help="Start the installer") +@click.argument( + "out_path", + type=click.Path( + file_okay=False, + dir_okay=True, + writable=True, + resolve_path=True, + path_type=Path, + ), +) +@click.option( + "--shortcut", + type=click.Choice(["private", "public"]), + help="Create a desktop shortcut", +) +@click.option( + "--fta", + type=str, + multiple=True, + help="File extensions to associate with ltchiptool", +) +@click.option( + "--add-path", + is_flag=True, + help="Add to system PATH", +) +def install_cli(*args, **kwargs): + try: + gui_entrypoint(install=True, *args, **kwargs) except Exception as e: exception(None, exc_info=e) exit(1) diff --git a/ltchiptool/gui/install.py b/ltchiptool/gui/install.py new file mode 100644 index 0000000..e171f7c --- /dev/null +++ b/ltchiptool/gui/install.py @@ -0,0 +1,87 @@ +# Copyright (c) Kuba Szczodrzyński 2023-12-14. + +from logging import INFO + +import wx +import wx.xrc + +from ltchiptool.util.logging import LoggingHandler +from ltchiptool.util.ltim import LTIM + +from .base.window import BaseWindow +from .panels.log import LogPanel +from .utils import load_xrc_file +from .work.base import BaseThread +from .work.install import InstallThread + + +# noinspection PyPep8Naming +class InstallFrame(wx.Frame, BaseWindow): + failed: bool = False + finished: bool = False + + def __init__(self, install_kwargs: dict, *args, **kw): + super().__init__(*args, **kw) + + xrc = LTIM.get().get_gui_resource("ltchiptool.xrc") + icon = LTIM.get().get_gui_resource("ltchiptool.ico") + self.Xrc = load_xrc_file(xrc) + + LoggingHandler.get().level = INFO + LoggingHandler.get().exception_hook = self.ShowExceptionMessage + + self.Log = LogPanel(parent=self, frame=self) + # noinspection PyTypeChecker + self.Log.OnDonateClose(None) + + self.install_kwargs = install_kwargs + + self.Bind(wx.EVT_SHOW, self.OnShow) + self.Bind(wx.EVT_CLOSE, self.OnClose) + + self.SetTitle("Installing ltchiptool") + self.SetIcon(wx.Icon(str(icon), wx.BITMAP_TYPE_ICO)) + self.SetSize((1000, 400)) + self.SetMinSize((600, 200)) + self.Center() + + def ShowExceptionMessage(self, e, msg): + self.failed = True + self.finished = True + wx.MessageBox( + message="Installation failed!\n\nRefer to the log window for details.", + caption="Error", + style=wx.ICON_ERROR, + ) + + def OnShow(self, *_): + self.InitWindow(self) + thread = InstallThread(**self.install_kwargs) + thread.daemon = True + self.StartWork(thread) + + def OnWorkStopped(self, t: BaseThread): + if self.failed: + return + wx.MessageBox( + message="Installation finished\n\nClick OK to close the installer", + caption="Success", + style=wx.ICON_INFORMATION, + ) + self.Close() + + def OnClose(self, *_): + if self.finished: + self.Destroy() + return + if ( + wx.MessageBox( + message="Do you want to cancel the installation process?", + caption="Installation in progress", + style=wx.ICON_QUESTION | wx.YES_NO, + ) + != wx.YES + ): + return + self.StopWork(InstallThread) + self.Destroy() diff --git a/ltchiptool/gui/ltchiptool.wxui b/ltchiptool/gui/ltchiptool.wxui index 0b67f71..0954b73 100644 --- a/ltchiptool/gui/ltchiptool.wxui +++ b/ltchiptool/gui/ltchiptool.wxui @@ -556,19 +556,37 @@ border_size="0" flags="wxEXPAND" /> + + + + + row="6" /> + row="7"> + row="8"> + row="9"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ltchiptool/gui/ltchiptool.xrc b/ltchiptool/gui/ltchiptool.xrc index d59120e..60f1fa4 100644 --- a/ltchiptool/gui/ltchiptool.xrc +++ b/ltchiptool/gui/ltchiptool.xrc @@ -671,13 +671,38 @@ 1,3 wxALL|wxEXPAND 5 + + wxVERTICAL + + wxALL + 0 + + + -1 + + + + wxALL + 0 + + + -1 + + + + + + 6,0 + 1,3 + wxALL|wxEXPAND + 5 20,-1d - 6,0 + 7,0 1,3 wxALL|wxEXPAND 5 @@ -702,7 +727,7 @@ - 7,0 + 8,0 1,3 wxALL|wxEXPAND 5 @@ -727,7 +752,7 @@ - 8,0 + 9,0 1,3 wxALL|wxEXPAND 5 @@ -817,4 +842,239 @@ + + + + wxVERTICAL + + wxALL + 5 + + + -1 + + + + wxALL|wxEXPAND + 5 + + + 20,-1d + + + + wxTOP|wxRIGHT|wxLEFT|wxALIGN_CENTER_HORIZONTAL + 5 + + + -1 + + + + wxALIGN_CENTER_HORIZONTAL + 5 + + wxHORIZONTAL + + wxALL + 5 + + + + + + wxTOP|wxBOTTOM|wxRIGHT + 5 + + + + + + + + wxALL|wxEXPAND + 5 + + + 20,-1d + + + + wxALL + 5 + + + -1 + + + + wxBOTTOM|wxEXPAND + 5 + + wxHORIZONTAL + + wxRIGHT|wxLEFT + 5 + + + + + wxRIGHT|wxEXPAND + 5 + + + + + + + + wxALL|wxEXPAND + 5 + + + 20,-1d + + + + wxEXPAND + 5 + + wxHORIZONTAL + + + 5 + + + wxVERTICAL + + wxALL + 5 + + + -1 + + + + wxBOTTOM|wxRIGHT|wxLEFT + 5 + + + + + + + wxALL + 5 + + + + + + wxALL + 5 + + + + + + + + wxTOP|wxBOTTOM|wxRIGHT|wxEXPAND + 5 + + + + + + + 5 + + + wxVERTICAL + + wxALL + 5 + + + -1 + + + + wxBOTTOM|wxRIGHT|wxLEFT + 5 + + + + + + wxALL + 5 + + + + + + wxALL + 5 + + + + + + + + wxTOP|wxBOTTOM|wxRIGHT|wxEXPAND + 5 + + + + + + + 5 + + + wxVERTICAL + + wxALL + 5 + + + -1 + + + + wxBOTTOM|wxRIGHT|wxLEFT + 5 + + + + + + + + + + wxALL|wxEXPAND + 5 + + + 20,-1d + + + + wxBOTTOM|wxRIGHT|wxLEFT|wxEXPAND + 5 + + + - + + + + diff --git a/ltchiptool/gui/main.py b/ltchiptool/gui/main.py index 806dc6f..35dba2c 100644 --- a/ltchiptool/gui/main.py +++ b/ltchiptool/gui/main.py @@ -1,9 +1,9 @@ # Copyright (c) Kuba Szczodrzyński 2023-1-2. -import sys +import os from logging import debug, exception, info, warning from os import rename, unlink -from os.path import dirname, isfile, join +from os.path import isfile, join import wx import wx.xrc @@ -13,6 +13,7 @@ from ltchiptool.util.fileio import readjson, writejson from ltchiptool.util.logging import LoggingHandler, verbose from ltchiptool.util.lpm import LPM +from ltchiptool.util.ltim import LTIM from ltchiptool.util.lvm import LVM from .base.frame import BaseFrame @@ -37,14 +38,9 @@ def __init__(self, *args, **kw): super().__init__(*args, **kw) LoggingHandler.get().exception_hook = self.ShowExceptionMessage - is_bundled = getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS") - if is_bundled: - xrc = join(sys._MEIPASS, "ltchiptool.xrc") - icon = join(sys._MEIPASS, "ltchiptool.ico") - else: - xrc = join(dirname(__file__), "ltchiptool.xrc") - icon = join(dirname(__file__), "ltchiptool.ico") - + ltim = LTIM.get() + xrc = ltim.get_gui_resource("ltchiptool.xrc") + icon = ltim.get_gui_resource("ltchiptool.ico") self.Xrc = load_xrc_file(xrc) try: @@ -84,11 +80,13 @@ def __init__(self, *args, **kw): # list all built-in panels from .panels.about import AboutPanel from .panels.flash import FlashPanel + from .panels.install import InstallPanel from .panels.plugins import PluginsPanel windows = [ ("flash", FlashPanel), - ("plugins", (not is_bundled) and PluginsPanel), + ("plugins", (not ltim.is_bundled) and PluginsPanel), + ("install", ltim.is_gui_entrypoint and os.name == "nt" and InstallPanel), ("about", AboutPanel), ] @@ -154,7 +152,7 @@ def __init__(self, *args, **kw): self.SetSize((750, 850)) self.SetMinSize((700, 800)) - self.SetIcon(wx.Icon(icon, wx.BITMAP_TYPE_ICO)) + self.SetIcon(wx.Icon(str(icon), wx.BITMAP_TYPE_ICO)) self.CreateStatusBar() @property diff --git a/ltchiptool/gui/panels/about.py b/ltchiptool/gui/panels/about.py index 5effe02..e59e69c 100644 --- a/ltchiptool/gui/panels/about.py +++ b/ltchiptool/gui/panels/about.py @@ -5,7 +5,7 @@ import wx.xrc -from ltchiptool import get_version +from ltchiptool.util.ltim import LTIM from ltchiptool.util.lvm import LVM, LVMPlatform from .base import BasePanel @@ -26,9 +26,8 @@ def __init__(self, parent: wx.Window, frame): else: lt_path_title = "LibreTiny package path" - tool_version = "v" + get_version() - if "site-packages" not in __file__ and not hasattr(sys, "_MEIPASS"): - tool_version += " (dev)" + python = ".".join(str(i) for i in sys.version_info[:3]) + python += f" ({sys.executable})" if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): logo = join(sys._MEIPASS, "ltchiptool-192x192.png") @@ -39,7 +38,9 @@ def __init__(self, parent: wx.Window, frame): build_date = None self.FindStaticText("text_lt_version").SetLabel(lt_version or "-") - self.FindStaticText("text_tool_version").SetLabel(tool_version or "-") + self.FindStaticText("text_tool_version").SetLabel( + LTIM.get_version_full() or "-" + ) if build_date: self.FindStaticText("text_build_date").SetLabel(build_date) else: @@ -49,6 +50,7 @@ def __init__(self, parent: wx.Window, frame): path = self.BindHyperlinkCtrl("text_path") path.SetLabel(lt_path) path.SetURL(lt_path) + self.FindStaticText("text_python").SetLabel(python or "-") bitmap = self.FindStaticBitmap("bmp_logo") size = bitmap.GetSize().y diff --git a/ltchiptool/gui/panels/install.py b/ltchiptool/gui/panels/install.py new file mode 100644 index 0000000..c28086f --- /dev/null +++ b/ltchiptool/gui/panels/install.py @@ -0,0 +1,152 @@ +# Copyright (c) Kuba Szczodrzyński 2023-12-15. + +import os +from os.path import expandvars +from pathlib import Path + +import wx.xrc + +from ltchiptool.gui.utils import on_event +from ltchiptool.gui.work.install import InstallThread + +from .base import BasePanel + + +class InstallPanel(BasePanel): + def __init__(self, parent: wx.Window, frame): + super().__init__(parent, frame) + self.LoadXRC("InstallPanel") + self.AddToNotebook("Install") + + self.BindButton("button_full", self.OnFullClick).SetAuthNeeded(True) + self.BindButton("button_portable", self.OnPortableClick) + self.BindButton("button_browse", self.OnBrowseClick) + + self.OutPath = self.BindTextCtrl("input_out_path") + self.ShortcutNone = self.BindRadioButton("radio_shortcut_none") + self.ShortcutPrivate = self.BindRadioButton("radio_shortcut_private") + self.ShortcutPublic = self.BindRadioButton("radio_shortcut_public") + + self.FtaUf2 = self.BindCheckBox("checkbox_fta_uf2") + self.FtaRbl = self.BindCheckBox("checkbox_fta_rbl") + self.FtaBin = self.BindCheckBox("checkbox_fta_bin") + + self.AddPath = self.BindCheckBox("checkbox_add_path") + + self.Start = self.BindCommandButton("button_start", self.OnStartClick) + self.Start.SetNote("") + + # noinspection PyTypeChecker + self.OnFullClick(None) + + def OnUpdate(self, target: wx.Window = None): + super().OnUpdate(target) + + auth_needed = [ + self.shortcut == "public", + bool(self.fta), + self.add_path, + ] + + try: + path = Path(self.out_path) + if not path.is_absolute(): + path = None + else: + path = path.resolve() + if path.is_file(): + path = None + except Exception: + path = None + + if path and not any(auth_needed): + test_path = path + while not test_path.is_dir(): + test_path = test_path.parent + test_file = test_path / "test_file.txt" + can_write = os.access(test_path, os.W_OK) + if can_write: + try: + test_file.write_text("") + test_file.unlink(missing_ok=True) + except (OSError, PermissionError, IOError): + can_write = False + auth_needed.append(not can_write) + + self.Start.SetAuthNeeded(any(auth_needed)) + self.Start.Enable(bool(path)) + self.Start.SetNote("" if path else "Invalid target directory") + + @on_event + def OnFullClick(self) -> None: + path = expandvars("%PROGRAMFILES%\\kuba2k2\\ltchiptool") + self.OutPath.SetValue(path) + self.ShortcutPublic.SetValue(True) + self.FtaUf2.SetValue(True) + self.FtaRbl.SetValue(True) + self.FtaBin.SetValue(False) + self.AddPath.SetValue(False) + self.DoUpdate(self.OutPath) + + @on_event + def OnPortableClick(self) -> None: + path = expandvars("%APPDATA%\\ltchiptool\\portable") + self.OutPath.SetValue(path) + self.ShortcutNone.SetValue(True) + self.FtaUf2.SetValue(False) + self.FtaRbl.SetValue(False) + self.FtaBin.SetValue(False) + self.AddPath.SetValue(False) + self.DoUpdate(self.OutPath) + + @on_event + def OnBrowseClick(self) -> None: + with wx.DirDialog( + parent=self, + message="Choose directory", + defaultPath=self.out_path, + style=wx.DD_NEW_DIR_BUTTON, + ) as dialog: + dialog: wx.DirDialog + if dialog.ShowModal() == wx.ID_CANCEL: + return + self.OutPath.SetValue(dialog.GetPath()) + + @property + def out_path(self) -> str: + return self.OutPath.GetValue() + + @property + def shortcut(self) -> str | None: + if self.ShortcutPublic.GetValue(): + return "public" + if self.ShortcutPrivate.GetValue(): + return "private" + return None + + @property + def fta(self) -> list[str]: + result = [] + if self.FtaUf2.GetValue(): + result.append("uf2") + if self.FtaRbl.GetValue(): + result.append("rbl") + if self.FtaBin.GetValue(): + result.append("bin") + return result + + @property + def add_path(self) -> bool: + return self.AddPath.GetValue() + + @on_event + def OnStartClick(self) -> None: + self.StartWork( + InstallThread( + relaunch="uac" if self.Start.GetAuthNeeded() else "normal", + out_path=Path(self.out_path), + shortcut=self.shortcut, + fta=self.fta, + add_path=self.add_path, + ) + ) diff --git a/ltchiptool/gui/panels/log.py b/ltchiptool/gui/panels/log.py index 364b7ff..88484cf 100644 --- a/ltchiptool/gui/panels/log.py +++ b/ltchiptool/gui/panels/log.py @@ -57,14 +57,13 @@ def render_progress(self) -> None: self.time_left.Show() self.bar.Show() - pct = self.format_pct() - pos = sizeof(self.pos) - length = sizeof(self.length) - - if self.length == 0: + if self.length in [0, None]: self.progress.SetLabel(self.label or "") self.bar.Pulse() else: + pct = self.format_pct() + pos = sizeof(self.pos) + length = sizeof(self.length) if self.label: self.progress.SetLabel(f"{self.label} - {pct} ({pos} / {length})") else: @@ -182,7 +181,13 @@ def SetSettings( handler.full_traceback = full_traceback LoggingStreamHook.set_registered(Serial, registered=dump_serial) + if donate_closed: + # noinspection PyTypeChecker + self.OnDonateClose(None) + menu_bar: wx.MenuBar = self.TopLevelParent.MenuBar + if not menu_bar: + return menu: wx.Menu = menu_bar.GetMenu(menu_bar.FindMenu("Logging")) if not menu: warning(f"Couldn't find Logging menu") @@ -200,10 +205,6 @@ def SetSettings( case _ if item.GetItemLabel() == level_name: item.Check() - if donate_closed: - # noinspection PyTypeChecker - self.OnDonateClose(None) - @on_event def OnIdle(self): while True: diff --git a/ltchiptool/gui/utils.py b/ltchiptool/gui/utils.py index a7284a6..2b3fd8d 100644 --- a/ltchiptool/gui/utils.py +++ b/ltchiptool/gui/utils.py @@ -1,6 +1,6 @@ # Copyright (c) Kuba Szczodrzyński 2023-1-3. -from os.path import join +from pathlib import Path from typing import Callable import wx @@ -40,12 +40,11 @@ def int_or_zero(value: str) -> int: return 0 -def load_xrc_file(*path: str) -> wx.xrc.XmlResource: - xrc = join(*path) +def load_xrc_file(*path: str | Path) -> wx.xrc.XmlResource: + xrc = Path(*path) try: - with open(xrc, "r") as f: - xrc_str = f.read() - xrc_str = xrc_str.replace("", '') + xrc_str = xrc.read_text() + xrc_str = xrc_str.replace("", '') res = wx.xrc.XmlResource() res.LoadFromBuffer(xrc_str.encode()) return res diff --git a/ltchiptool/gui/work/install.py b/ltchiptool/gui/work/install.py new file mode 100644 index 0000000..e98f427 --- /dev/null +++ b/ltchiptool/gui/work/install.py @@ -0,0 +1,74 @@ +# Copyright (c) Kuba Szczodrzyński 2023-12-14. + +import shlex +import sys +from logging import info +from pathlib import Path + +from ltchiptool.util.ltim import LTIM + +from .base import BaseThread + + +class InstallThread(BaseThread): + def __init__( + self, + out_path: Path, + shortcut: str | None, + fta: list[str], + add_path: bool, + relaunch: str = None, + ): + super().__init__() + self.out_path = out_path + self.shortcut = shortcut + self.fta = fta + self.add_path = add_path + self.relaunch = relaunch + + def run_impl(self): + if not self.relaunch: + LTIM.get().install( + out_path=self.out_path, + shortcut=self.shortcut, + fta=self.fta, + add_path=self.add_path, + ) + return + + from win32comext.shell.shell import ShellExecuteEx + from win32comext.shell.shellcon import ( + SEE_MASK_NO_CONSOLE, + SEE_MASK_NOCLOSEPROCESS, + ) + from win32con import SW_SHOWNORMAL + from win32event import INFINITE, WaitForSingleObject + from win32process import GetExitCodeProcess + + prog = sys.executable.replace("python.exe", "pythonw.exe") + args = [] + if not LTIM.get().is_bundled: + args += list(sys.argv) + args += ["install", str(self.out_path)] + + if self.shortcut: + args += ["--shortcut", self.shortcut] + for ext in self.fta: + args += ["--fta", ext] + if self.add_path: + args += ["--add-path"] + args = shlex.join(args).replace("'", '"') + + info(f"Launching: {prog} {args}") + + proc_info = ShellExecuteEx( + nShow=SW_SHOWNORMAL, + fMask=SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NO_CONSOLE, + lpVerb=self.relaunch == "uac" and "runas" or "", + lpFile=prog, + lpParameters=args, + ) + handle = proc_info["hProcess"] + WaitForSingleObject(handle, INFINITE) + return_code = GetExitCodeProcess(handle) + info(f"Process returned {return_code}") diff --git a/ltchiptool/util/cli.py b/ltchiptool/util/cli.py index 96f4e1a..fff0762 100644 --- a/ltchiptool/util/cli.py +++ b/ltchiptool/util/cli.py @@ -3,9 +3,10 @@ import shlex from logging import WARNING, error, info, warning from os.path import basename, dirname, join +from pathlib import Path from subprocess import PIPE, Popen from threading import Thread -from typing import IO, Callable, Dict, Iterable, List, Optional +from typing import IO, Callable, Dict, Iterable, List, Optional, Union import click from click import Command, Context, MultiCommand @@ -67,7 +68,7 @@ def find_serial_port() -> Optional[str]: return ports[0][0] -def run_subprocess(*args, cwd: str = None) -> int: +def run_subprocess(*args, cwd: Union[str, Path] = None) -> int: def stream(io: IO[bytes], func: Callable[[str], None]): for line in iter(io.readline, b""): func(line.decode("utf-8").rstrip()) diff --git a/ltchiptool/util/lpm.py b/ltchiptool/util/lpm.py index a3e2945..1612767 100644 --- a/ltchiptool/util/lpm.py +++ b/ltchiptool/util/lpm.py @@ -21,6 +21,8 @@ class LPM: + """ltchiptool plugin manager""" + INSTANCE: "LPM" = None plugins: List[PluginBase] disabled: Set[str] diff --git a/ltchiptool/util/ltim.py b/ltchiptool/util/ltim.py new file mode 100644 index 0000000..99885c0 --- /dev/null +++ b/ltchiptool/util/ltim.py @@ -0,0 +1,339 @@ +# Copyright (c) Kuba Szczodrzyński 2023-12-13. + +import platform +import shutil +import sys +from functools import lru_cache +from io import BytesIO +from logging import DEBUG +from os.path import expandvars +from pathlib import Path +from subprocess import PIPE, Popen +from typing import List, Optional, Tuple +from zipfile import ZipFile + +import requests +from semantic_version import SimpleSpec, Version + +from ltchiptool.util.cli import run_subprocess +from ltchiptool.util.logging import LoggingHandler +from ltchiptool.util.streams import ClickProgressCallback +from ltchiptool.version import get_version + +PYTHON_RELEASES = "https://www.python.org/api/v2/downloads/release/?pre_release=false&is_published=true&version=3" +PYTHON_RELEASE_FILE_FMT = ( + "https://www.python.org/api/v2/downloads/release_file/?release=%s&os=1" +) +PYTHON_GET_PIP = "https://bootstrap.pypa.io/get-pip.py" + +PYTHON_WIN = "python.exe" +PYTHONW_WIN = "pythonw.exe" +ICON_FILE = "ltchiptool.ico" + + +# utilities to manage ltchiptool installation in different modes, +# fetch version information, find bundled resources, etc. + + +class LTIM: + """ltchiptool installation manager""" + + INSTANCE: "LTIM" = None + callback: ClickProgressCallback = None + is_gui_entrypoint: bool = False + + @staticmethod + def get() -> "LTIM": + if LTIM.INSTANCE: + return LTIM.INSTANCE + LTIM.INSTANCE = LTIM() + return LTIM.INSTANCE + + @property + def is_bundled(self) -> bool: + return getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS") + + def get_resource(self, name: str) -> Path: + if self.is_bundled: + return Path(sys._MEIPASS) / name + return Path(__file__).parents[2] / name + + def get_gui_resource(self, name: str) -> Path: + if self.is_bundled: + return Path(sys._MEIPASS) / name + return Path(__file__).parents[1] / "gui" / name + + @staticmethod + @lru_cache + def get_version() -> Optional[str]: + return get_version() + + @staticmethod + def get_version_full() -> Optional[str]: + tool_version = LTIM.get_version() + if not tool_version: + return None + tool_version = "v" + tool_version + if "site-packages" not in __file__ and not hasattr(sys, "_MEIPASS"): + tool_version += " (dev)" + return tool_version + + def install( + self, + out_path: Path, + shortcut: Optional[str], + fta: List[str], + add_path: bool, + ) -> None: + self.callback = ClickProgressCallback() + + out_path = out_path.expanduser().resolve() + out_path.mkdir(parents=True, exist_ok=True) + + python_path, pythonw_path = self._install_python_windows(out_path) + + self.callback.on_message("Downloading get-pip.py") + get_pip_path = out_path / "get-pip.py" + with requests.get(PYTHON_GET_PIP) as r: + get_pip_path.write_bytes(r.content) + + opts = ["--prefer-binary", "--no-warn-script-location"] + + self.callback.on_message("Installing pip") + return_code = run_subprocess( + python_path, + get_pip_path, + *opts, + cwd=out_path, + ) + if return_code != 0: + raise RuntimeError(f"{get_pip_path.name} returned {return_code}") + + self.callback.on_message("Checking pip installation") + return_code = run_subprocess( + python_path, + "-m", + "pip", + "--version", + cwd=out_path, + ) + if return_code != 0: + raise RuntimeError(f"pip --version returned {return_code}") + + self.callback.on_message("Installing ltchiptool with GUI extras") + return_code = run_subprocess( + python_path, + "-m", + "pip", + "install", + "ltchiptool[gui]", + *opts, + "--upgrade", + cwd=out_path, + ) + if return_code != 0: + raise RuntimeError(f"pip install returned {return_code}") + + if shortcut: + self._install_shortcut_windows(out_path, public=shortcut == "public") + if fta: + self._install_fta_windows(out_path, *fta) + if add_path: + self._install_path_windows(out_path) + + self.callback.finish() + + def _install_python_windows(self, out_path: Path) -> Tuple[Path, Path]: + version_spec = SimpleSpec("~3.11") + + self.callback.on_message("Checking the latest Python version") + with requests.get(PYTHON_RELEASES) as r: + releases = r.json() + releases_map = [ + (Version.coerce(release["name"].partition(" ")[2]), release) + for release in releases + ] + latest_version, latest_release = max( + ( + (version, release) + for (version, release) in releases_map + if version in version_spec + ), + key=lambda tpl: tpl[0], + ) + latest_release_id = next( + part + for part in latest_release["resource_uri"].split("/") + if part.isnumeric() + ) + + self.callback.on_message(f"Will install Python {latest_version}") + with requests.get(PYTHON_RELEASE_FILE_FMT % latest_release_id) as r: + release_files = r.json() + for release_file in release_files: + release_url = release_file["url"] + if ( + "embed-" in release_url + and platform.machine().lower() in release_url + ): + break + else: + raise RuntimeError("Couldn't find embeddable package URL") + + self.callback.on_message(f"Downloading '{release_url}'") + with requests.get(release_url, stream=True) as r: + try: + self.callback.on_total(int(r.headers["Content-Length"])) + except ValueError: + self.callback.on_total(-1) + io = BytesIO() + for chunk in r.iter_content(chunk_size=128 * 1024): + self.callback.on_update(len(chunk)) + io.write(chunk) + self.callback.on_total(None) + + self.callback.on_message(f"Extracting to '{out_path}'") + with ZipFile(io) as z: + self.callback.on_total(len(z.filelist)) + for member in z.filelist: + z.extract(member, out_path) + self.callback.on_total(None) + + self.callback.on_message("Checking installed executable") + python_path = out_path / PYTHON_WIN + pythonw_path = out_path / PYTHONW_WIN + p = Popen( + args=[python_path, "--version"], + stdout=PIPE, + ) + version_name, _ = p.communicate() + if p.returncode != 0: + raise RuntimeError(f"{python_path.name} returned {p.returncode}") + version_tuple = version_name.decode().partition(" ")[2].split(".") + + self.callback.on_message("Enabling site-packages") + pth_path = out_path / ("python%s%s._pth" % tuple(version_tuple[:2])) + if not pth_path.is_file(): + raise RuntimeError(f"Extraction failed, {pth_path.name} is not a file") + pth = pth_path.read_text() + pth = pth.replace("#import site", "import site") + pth_path.write_text(pth) + + self.callback.on_message("Installing icon resource") + icon_path = out_path / ICON_FILE + icon_res = self.get_gui_resource(ICON_FILE) + shutil.copy(icon_res, icon_path) + + return python_path, pythonw_path + + def _install_shortcut_windows(self, out_path: Path, public: bool) -> None: + import pylnk3 + from win32comext.shell.shell import SHGetFolderPath + from win32comext.shell.shellcon import ( + CSIDL_COMMON_DESKTOPDIRECTORY, + CSIDL_DESKTOP, + ) + + if public: + desktop_dir = SHGetFolderPath(0, CSIDL_COMMON_DESKTOPDIRECTORY, 0, 0) + else: + desktop_dir = SHGetFolderPath(0, CSIDL_DESKTOP, 0, 0) + + gui_path = Path(desktop_dir) / "ltchiptool GUI.lnk" + cli_path = Path(desktop_dir) / "ltchiptool CLI.lnk" + + self.callback.on_message("Creating desktop shortcuts") + pylnk3.for_file( + target_file=str(out_path / PYTHONW_WIN), + lnk_name=str(gui_path), + arguments="-m ltchiptool gui", + description="Launch ltchiptool GUI", + icon_file=str(out_path / ICON_FILE), + ) + pylnk3.for_file( + target_file=expandvars("%COMSPEC%"), + lnk_name=str(cli_path), + arguments="/K ltchiptool", + description="Launch ltchiptool CLI", + icon_file=str(out_path / ICON_FILE), + work_dir=str(out_path / "Scripts"), + ) + + def _install_fta_windows(self, out_path: Path, *fta: str) -> None: + from winreg import HKEY_LOCAL_MACHINE, REG_SZ, CreateKeyEx, OpenKey, SetValue + + from win32comext.shell.shell import SHChangeNotify + from win32comext.shell.shellcon import SHCNE_ASSOCCHANGED, SHCNF_IDLIST + + for ext in fta: + ext = ext.lower().strip(".") + self.callback.on_message(f"Associating {ext.upper()} file type") + with OpenKey(HKEY_LOCAL_MACHINE, "SOFTWARE\\Classes") as classes: + with CreateKeyEx(classes, f".{ext}") as ext_key: + SetValue(ext_key, "", REG_SZ, f"ltchiptool.{ext.upper()}") + with CreateKeyEx(classes, f"ltchiptool.{ext.upper()}") as cls_key: + SetValue(cls_key, "", REG_SZ, f"ltchiptool {ext.upper()} file") + with CreateKeyEx(cls_key, "DefaultIcon") as icon_key: + SetValue(icon_key, "", REG_SZ, str(out_path / ICON_FILE)) + with CreateKeyEx(cls_key, "shell") as shell_key: + SetValue(shell_key, "", REG_SZ, f"open") + with CreateKeyEx(shell_key, "open") as open_key: + with CreateKeyEx(open_key, "command") as command_key: + command = [ + str(out_path / PYTHONW_WIN), + "-m", + "ltchiptool", + "gui", + '"%1"', + ] + SetValue(command_key, "", REG_SZ, " ".join(command)) + self.callback.on_message("Notifying other programs") + SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None) + + def _install_path_windows(self, out_path: Path) -> None: + from winreg import ( + HKEY_LOCAL_MACHINE, + KEY_ALL_ACCESS, + REG_SZ, + OpenKey, + QueryValueEx, + SetValueEx, + ) + + from win32con import HWND_BROADCAST, SMTO_ABORTIFHUNG, WM_SETTINGCHANGE + from win32gui import SendMessageTimeout + + script_path = out_path / "Scripts" / "ltchiptool.exe" + bin_path = out_path / "bin" / "ltchiptool.exe" + bin_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(script_path, bin_path) + + self.callback.on_message("Updating PATH variable") + with OpenKey( + HKEY_LOCAL_MACHINE, + "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment", + 0, + KEY_ALL_ACCESS, + ) as key: + new_dir = str(bin_path.parent) + path, _ = QueryValueEx(key, "PATH") + path = path.split(";") + while new_dir in path: + path.remove(new_dir) + path.insert(0, new_dir) + SetValueEx(key, "PATH", None, REG_SZ, ";".join(path)) + + self.callback.on_message("Notifying other programs") + SendMessageTimeout( + HWND_BROADCAST, + WM_SETTINGCHANGE, + 0, + "Environment", + SMTO_ABORTIFHUNG, + 5000, + ) + + +if __name__ == "__main__": + LoggingHandler.get().level = DEBUG + LTIM.get().install(out_path=Path(expandvars("%PROGRAMFILES%\\kuba2k2\\ltchiptool"))) diff --git a/ltchiptool/util/lvm.py b/ltchiptool/util/lvm.py index 22511d0..bd372f6 100644 --- a/ltchiptool/util/lvm.py +++ b/ltchiptool/util/lvm.py @@ -13,6 +13,8 @@ class LVM: + """libretiny version manager""" + INSTANCE: "LVM" = None platforms: List["LVMPlatform"] = field(default_factory=lambda: []) pio = None diff --git a/ltchiptool/util/streams.py b/ltchiptool/util/streams.py index 26008bd..8cd654c 100644 --- a/ltchiptool/util/streams.py +++ b/ltchiptool/util/streams.py @@ -199,7 +199,8 @@ def __init__(self, length: int = 0, width: int = 64): def on_update(self, steps: int) -> None: self.bar.update(steps) - def on_total(self, total: int) -> None: + def on_total(self, total: Optional[int]) -> None: + self.bar.pos = 0 self.bar.length = total self.bar.render_progress() diff --git a/pyproject.toml b/pyproject.toml index 767f819..70500c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,8 @@ hexdump = "^3.3" bitstruct = "^8.1.1" zeroconf = {version = "^0", optional = true} requests = "^2.31.0" +pyuac = {version = "^0.0.3", optional = true, markers = "sys_platform == 'win32'"} +pylnk3 = {version = "^0.4.2", optional = true, markers = "sys_platform == 'win32'"} [tool.poetry.dependencies.pycryptodome] version = "^3.9.9" @@ -41,7 +43,7 @@ version = "^1.6.1" markers = "platform_machine in 'armv6l,armv7l,armv8l,armv8b,aarch64'" [tool.poetry.extras] -gui = ["wxPython", "pywin32", "zeroconf"] +gui = ["wxPython", "pywin32", "zeroconf", "pyuac", "pylnk3"] [tool.poetry.dev-dependencies] black = "^22.6.0"