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
+
+
+
+ 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"