diff --git a/.github/workflows/package_windows.yml b/.github/workflows/package_windows.yml new file mode 100644 index 000000000..108ea32d2 --- /dev/null +++ b/.github/workflows/package_windows.yml @@ -0,0 +1,59 @@ +name: windows-build + +on: + push: + branches: [ main ] + tags: [ "v*" ] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-windows: + runs-on: windows-latest + + defaults: + run: + shell: powershell + + steps: + - uses: actions/checkout@v4 + - name: Set up Conda + uses: conda-incubator/setup-miniconda@v3 + with: + python-version: ${{ matrix.python-version }} + auto-update-conda: true + channels: conda-forge + activate-environment: "datashuttle-test" + + - name: Install rclone + run: | + conda activate datashuttle-test + conda install -c conda-forge rclone + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[dev] + pip install pyinstaller + + # Install Inno Setup silently + - name: Install Inno Setup + run: | + choco install innosetup --yes + + - name: Verify Inno Setup installation + run: | + Get-Command "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" + + - name: Run Windows packaging script + run: | + python package/package_windows.py + + - name: Upload installer artifact + uses: actions/upload-artifact@v4 + with: + name: windows-installer + path: package\Output\datashuttle_0.0.0.exe diff --git a/.gitignore b/.gitignore index d661ff545..47b494a45 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,13 @@ +# Packaging +package/dist +package/build +package/_vendored +package/Output +package/inno_complie_script.iss + # python-dotenv environment file for test account credentials. .env - # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -35,7 +41,6 @@ MANIFEST # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest -*.spec # Installer logs pip-log.txt diff --git a/datashuttle/tui/css/tui_menu.tcss b/datashuttle/tui/css/tui_menu.tcss index ce1d9e110..072499a95 100644 --- a/datashuttle/tui/css/tui_menu.tcss +++ b/datashuttle/tui/css/tui_menu.tcss @@ -4,6 +4,7 @@ #mainwindow_contents_container { align: center top; padding: 1 0 0 0; + overflow: hidden auto; } #mainwindow_contents_container > Button { width: 50%; diff --git a/datashuttle/tui_launcher.py b/datashuttle/tui_launcher.py index f98f4b0be..d54d83358 100644 --- a/datashuttle/tui_launcher.py +++ b/datashuttle/tui_launcher.py @@ -42,4 +42,7 @@ def main() -> None: if __name__ == "__main__": - main() + try: + main() + except: + input("hello") diff --git a/datashuttle/utils/rclone.py b/datashuttle/utils/rclone.py index f6392db48..e1efbafb5 100644 --- a/datashuttle/utils/rclone.py +++ b/datashuttle/utils/rclone.py @@ -27,12 +27,32 @@ import platform import shlex import subprocess +import sys import tempfile from datashuttle.configs import canonical_configs from datashuttle.utils import rclone_encryption, utils +def get_command(command: str) -> str: + """ """ + from pathlib import Path + + if getattr(sys, "frozen", False): + # PyInstaller: binary extracted to _MEIPASS + if sys.platform == "win32": + format_command = ( + f"{str(Path(sys._MEIPASS) / 'rclone.exe')} {command}" + ) + else: + format_command = f"{sys._MEIPASS}/rclone {command}" + else: + # Normal Python execution: use PATH or fixed path + format_command = f"rclone {command}" # or provide full path if needed + + return format_command + + def call_rclone(command: str, pipe_std: bool = False) -> CompletedProcess: """Call rclone with the specified command. @@ -49,13 +69,17 @@ def call_rclone(command: str, pipe_std: bool = False) -> CompletedProcess: subprocess.CompletedProcess with `stdout` and `stderr` attributes. """ - command = "rclone " + command + format_command = get_command(command) + if pipe_std: output = subprocess.run( - command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True + format_command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=True, ) else: - output = subprocess.run(command, shell=True) + output = subprocess.run(format_command, shell=True) if output.returncode != 0: prompt_rclone_download_if_does_not_exist() @@ -101,18 +125,18 @@ def call_rclone_through_script_for_central_connection( """ system = platform.system() - command = "rclone " + command - if system == "Windows": suffix = ".bat" else: suffix = ".sh" command = "#!/bin/bash\n" + command + format_command = get_command(command) + with tempfile.NamedTemporaryFile( mode="w", suffix=suffix, delete=False ) as tmp_script: - tmp_script.write(command) + tmp_script.write(format_command) tmp_script_path = tmp_script.name try: @@ -594,7 +618,7 @@ def check_rclone_with_default_call() -> bool: """ try: output = subprocess.run( - "rclone -h", + get_command("-h"), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, diff --git a/package/NeuroBlueprint_icon.ico b/package/NeuroBlueprint_icon.ico new file mode 100644 index 000000000..3f5fc6635 Binary files /dev/null and b/package/NeuroBlueprint_icon.ico differ diff --git a/package/README.md b/package/README.md new file mode 100644 index 000000000..a566913c0 --- /dev/null +++ b/package/README.md @@ -0,0 +1,64 @@ +# Packaging + +Packaging datashuttle is slightly more complex than packaging a simple Python application, +because we need to vendor the terminal to run datashuttle in, because it can render strangely +depending on the platform (e.g. macOS terminal, older Windows terminals). + +Wezterm, a cross-platform terminal that renders datashuttle well. It is vendored as part of +this distribution. + +Packaging is a two-step process. First, we package datashuttle into an executable. +This executable contains all datashuttle dependencies and can be run from any terminal. + +Next, a simple script ('terminal launcher') that runs the datashuttle executable in the vendored +Wezterm terminal is packaged using pyinstaller. This is the main entry point for the distribution. + +Finally, this executable is distributed via an installer (inno setup on Windows, TODO on macos (`.dmg`) +This installs the executable in the proper place on the system, adds shortcuts etc. + +So the call chain is: +1) The main executable is 'terminal launcher' +2) This opens the vendored Wezterm, and uses it to start the second, datashuttle executable. + +## The `package` directory + +The path handling is a little fiddly here, and some scrips are shared between platform. +Therefore, all materials are currently stored in this folder and not refactored into their own folder. + +The key files are: + +- **datashuttle.spec**: The pyinstaller command file that packages datashuttle, through `datashuttle_launcher.py` script. + This outputs a datashuttle executable that contains all datashuttle dependencies, and can be run through a terminal. +- **datashuttle_launcher.py**: A wrapper script that `datashuttle.spec` packages, that launches datashuttle. +- **license.txt**: The datashuttle license +- **package_.py**: The entry point script that coordinates the packaging of datashuttle on macOS / Windows. +- **terminal_launcher_.py**: The entry point script for the final datashuttle distribution. + It opens Wezterm and runs the datashuttle executable in it. +- **terminal_launcher_macos.spec**: The Pyinstaller command file that packages the `terminal_launcher` script into an executable. +- **wezterm_config.lua**: The Wezterm config. Use this to change settings that affect how the vendored Wezterm appears. +- **inno_compile_script.iss**: The Inno script used to package the Windows distribution into an installer. The output + of this script is shipped. +- **packaging_utils.py**: General utils used in the packaging scripts. + +## Windows + +When packaging on Windows, a number of intermediate folders will be created. Under normal circumstances +they will be deleted, but knowing their contents can be useful for debugging: + +- **build**: Generated by pyinstaller, it contains intermediate files generating during the +packaging of `datashuttle` and `terminal_launcher`. Generally you do not need to inspect these files, +they are pyinstaller stuff. +- **dist**: Initially generated by pyinstaller, this is the complete distribution. During packaging, +this file is used for the Pyinstaller dist of `datashuttle` and `terminal_launcher`, then we +copy a few auxillary files (e.g. license, Wezterm config)). Because we are building two Pyinstaller +packages into one directory, some care had to be taken to avoid configs. The folders included are: +1) `datashuttle`: The datashuttle distribution generated by Pyinstaller +2) `terminal_launcher.exe` and `_internal` is the `terminal_launcher` distribution generated by Pyinstaller. +3) `_vendored` is our vendor folder, that contains the vendored Wezterm temrinal. +4) datashuttle license and icon +- **Output**: This is the output of Inno Setup, containing the installer. This is the final version to be shipped. + + + + +## macOS diff --git a/package/TODO.txt b/package/TODO.txt new file mode 100644 index 000000000..c84c90b14 --- /dev/null +++ b/package/TODO.txt @@ -0,0 +1 @@ +DONT FORGET TO INCLUDE THE LICENSE diff --git a/package/datashuttle.spec b/package/datashuttle.spec new file mode 100644 index 000000000..a9d74e8eb --- /dev/null +++ b/package/datashuttle.spec @@ -0,0 +1,79 @@ +# main.spec +# -*- mode: python ; coding: utf-8 -*- +import os +import sys +import platform +from glob import glob +from pathlib import Path +import shutil + +# Include .tcss files +tcss_files = [ + (f, os.path.join("datashuttle", "tui", "css")) + for f in glob("../datashuttle/tui/css/*.tcss") +] + +# Get current conda environment prefix +env_prefix = sys.prefix + +# Detect OS and set rclone path + +rclone_src = shutil.which("rclone") + +if rclone_src is None: + raise FileNotFoundError( + "rclone not found in PATH. Ensure it is installed before running PyInstaller." + ) + +binaries = [(rclone_src, ".")] + +a = Analysis( + ['datashuttle_launcher.py'], # terminal_launcher + pathex=[os.path.abspath('..')], + binaries=binaries, + datas=tcss_files, + hiddenimports=[ + 'datashuttle.tui_launcher', + 'datashuttle.tui.app', + 'textual.widgets._tab_pane', + 'textual.widgets._input', + 'textual.widgets._tree_control', + 'rich._unicode_data.unicode17-0-0' + ], + hookspath=['hooks'], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='datashuttle', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='datashuttle' +) diff --git a/package/datashuttle_launcher.py b/package/datashuttle_launcher.py new file mode 100644 index 000000000..b3dd46e22 --- /dev/null +++ b/package/datashuttle_launcher.py @@ -0,0 +1,24 @@ +import sys +from pathlib import Path + +# Add project root to sys.path +project_root = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(project_root)) + +# Import main from tui_launcher +from datashuttle.tui_launcher import main as datashuttle_main + + +def run(): + # Simulate: datashuttle launch + sys.argv = ["datashuttle", "launch"] + datashuttle_main() + + +if __name__ == "__main__": + try: + run() + except Exception as e: + print(f"\nError: {e}") + finally: + input("\nPress Enter to exit...") diff --git a/package/license.txt b/package/license.txt new file mode 100644 index 000000000..3b18e512d --- /dev/null +++ b/package/license.txt @@ -0,0 +1 @@ +hello world diff --git a/package/make_inno_setup_script.py b/package/make_inno_setup_script.py new file mode 100644 index 000000000..4028210e0 --- /dev/null +++ b/package/make_inno_setup_script.py @@ -0,0 +1,73 @@ +def make_inno_setup_script(version, base_path): + """{} are required in text so easier to use this method""" + file_body = ( + r""" + ; Script generated by the Inno Setup Script Wizard. + ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + + #define MyAppName "Datashuttle" + #define MyAppVersion """ + + '"' + + version + + """" + #define MyAppPublisher "Neuroinformatics Unit, Sainsbury Wellcome Centre, London, UK." + #define MyAppURL "www.datashuttle.neuroinformatics.dev" + #define MyAppExeName "terminal_launcher.exe" + + [Setup] + ; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. + ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) + AppId={{D063D197-9D21-426B-BF8C-D5612C74DF15} + AppName={#MyAppName} + AppVersion={#MyAppVersion} + ;AppVerName={#MyAppName} {#MyAppVersion} + AppPublisher={#MyAppPublisher} + AppPublisherURL={#MyAppURL} + AppSupportURL={#MyAppURL} + AppUpdatesURL={#MyAppURL} + DefaultDirName={autopf}\DataShuttle + DisableProgramGroupPage=yes + LicenseFile=""" + + base_path + + """\dist\license.txt + ; Uncomment the following line to run in non administrative install mode (install for current user only.) + ;PrivilegesRequired=lowest + PrivilegesRequiredOverridesAllowed=dialog + OutputBaseFilename=datashuttle_""" + + version + + """ + SetupIconFile=""" + + base_path + + r"""\dist\NeuroBlueprint_icon.ico + UninstallDisplayIcon={app}\NeuroBlueprint_icon.ico + Compression=lzma + SolidCompression=yes + WizardStyle=modern + + [Languages] + Name: "english"; MessagesFile: "compiler:Default.isl" + + [Tasks] + Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + + [Files] + Source: """ + + '"' + + base_path + + r"""\dist\terminal_launcher.exe"; DestDir: "{app}"; Flags: ignoreversion + Source: """ + + '"' + + base_path + + """\dist\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + ; NOTE: Don't use "Flags: ignoreversion" on any shared system files + + [Icons] + Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" + Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + + [Run] + Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + """ + ) + + return file_body diff --git a/package/package_macos.py b/package/package_macos.py new file mode 100644 index 000000000..2919cf762 --- /dev/null +++ b/package/package_macos.py @@ -0,0 +1,52 @@ +import shutil +import subprocess +from pathlib import Path + +import packaging_utils + +WEZTERM_VERSION = packaging_utils.get_wezterm_version() +WEZTERM_FOLDERNAME = f"WezTerm-macos-{WEZTERM_VERSION}" +WEZTERM_URL = f"https://github.com/wezterm/wezterm/releases/download/{WEZTERM_VERSION}/{WEZTERM_FOLDERNAME}.zip" + +# Paths +project_root = Path(__file__).parent +vendored_dir = project_root / "_vendored" + +if not (vendored_dir / WEZTERM_FOLDERNAME).exists(): + packaging_utils.download_wezterm(vendored_dir, WEZTERM_FOLDERNAME) + +if (build_path := project_root / "build").exists(): + shutil.rmtree(build_path) + +if (dist_path := project_root / "dist").exists(): + shutil.rmtree(dist_path) + +# Step 2: Run PyInstaller builds +subprocess.run(f"pyinstaller {project_root / 'datashuttle.spec'}", shell=True) +subprocess.run( + f"pyinstaller {project_root / 'terminal_launcher_macos.spec'}", shell=True +) + +app_macos_path = ( + project_root / "dist" / "Datashuttle.app" / "Contents" / "Resources" +) + +shutil.copytree( + vendored_dir / f"{WEZTERM_FOLDERNAME}", + app_macos_path / "_vendored" / f"{WEZTERM_FOLDERNAME}", +) + +shutil.copytree( + project_root / "dist" / "datashuttle" / "_internal", + app_macos_path.parent / "Resources" / "_internal", +) + +shutil.copy( + project_root / "dist" / "datashuttle" / "datashuttle", + app_macos_path.parent / "Resources", +) + +shutil.copy( + project_root / "wezterm_config.lua", + app_macos_path.parent / "Resources" / "_vendored" / WEZTERM_FOLDERNAME, +) diff --git a/package/package_windows.py b/package/package_windows.py new file mode 100644 index 000000000..81c124b1a --- /dev/null +++ b/package/package_windows.py @@ -0,0 +1,117 @@ +import os +import shutil +import subprocess +from pathlib import Path + +import packaging_utils +from make_inno_setup_script import make_inno_setup_script + +WEZTERM_VERSION = packaging_utils.get_wezterm_version() +WEZTERM_FOLDERNAME = f"WezTerm-windows-{WEZTERM_VERSION}" +WEZTERM_URL = f"https://github.com/wezterm/wezterm/releases/download/{WEZTERM_VERSION}/{WEZTERM_FOLDERNAME}.zip" + +project_root = Path(__file__).parent +vendored_dir = project_root / "_vendored" + +# Before we start, remove leftover folders from a previous builds +if (build_path := project_root / "build").exists(): + shutil.rmtree(build_path) + +if (dist_path := project_root / "dist").exists(): + shutil.rmtree(dist_path) + +# First, download Wezterm to be vendored +if not (vendored_dir / WEZTERM_FOLDERNAME).exists(): + packaging_utils.download_wezterm(vendored_dir, WEZTERM_FOLDERNAME) + +# Run pyinstaller that will create the datashuttle executable. This is +# the executable that we will later call through the vendored Wezterm terminal. +subprocess.run( + [ + "pyinstaller", + str(project_root / "datashuttle.spec"), + "--distpath", + str(project_root / "dist"), + "--workpath", + str(project_root / "build"), + "--noconfirm", + "--clean", + ], + check=True, +) + +# Now run pyinstaller to create the terminal-launcher executable. It is this +# executable that when run, will open the vendored Wezterm and in it run the +# datashuttle executable created above. + +subprocess.run( + [ + "pyinstaller", + str(project_root / "terminal_launcher_windows.spec"), + "--distpath", + str(project_root / "dist"), + "--workpath", + str(project_root / "build"), + "--noconfirm", + "--clean", + ], + check=True, +) + +# Now we create the distribution folder, that contains the datashuttle executable, +# terminal launcher executable, vendored Wezterm and all auxillary files +dist_dir = project_root / "dist" +launcher_subdir = dist_dir / "terminal_launcher" + +# Copy contents of dist/terminal_launcher/ (the output of pyinstaller packaging of +# terminal launcher) into dist/, one folder level up. +for item in launcher_subdir.iterdir(): + dest = dist_dir / item.name + if item.is_dir(): + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(item, dest) + else: + shutil.copy2(item, dest) + +# Remove the now-empty terminal_launcher folder +shutil.rmtree(launcher_subdir) + +# Copy WezTerm into dist/_vendored +terminal_launcher_dist_dir = dist_dir / "terminal_launcher" +vendored_output_path = dist_dir / "_vendored" / WEZTERM_FOLDERNAME + +shutil.copytree( + vendored_dir / WEZTERM_FOLDERNAME, vendored_output_path, dirs_exist_ok=True +) + +# Copy the datashuttle license +shutil.copy(project_root / "license.txt", dist_dir) + +# Copy the datashuttle icon +shutil.copy(project_root / "NeuroBlueprint_icon.ico", dist_dir) + +# Copy the Wezterm configuration file +shutil.copy( + project_root / "wezterm_config.lua", + project_root / "dist" / "_vendored" / WEZTERM_FOLDERNAME, +) +breakpoint() +# Finally, we will parcel the distribution folder into an installer. +# The output of this step is shipped, and when run will install the +# distribution in the correct place on the system, create shortcuts etc. +# Inno setup runs through a script, we generate it dynamically, removing +# and old versions before we start. +inno_path = project_root / "inno_complie_script.iss" + +if os.path.isfile(inno_path): + os.remove(inno_path) +f = open(inno_path, "a") + +text = make_inno_setup_script("0.0.0", str(project_root)) + +f.write(text.strip()) +f.close() + +# Run inno set up on the generated script. +subprocess.call(rf"C:\Program Files (x86)\Inno Setup 6\iscc {inno_path}") diff --git a/package/packaging_utils.py b/package/packaging_utils.py new file mode 100644 index 000000000..c53f18959 --- /dev/null +++ b/package/packaging_utils.py @@ -0,0 +1,76 @@ +import platform +import subprocess +import zipfile +from pathlib import Path +from sys import platform + +import requests + + +def get_wezterm_version(): + return "20240203-110809-5046fc22" + + +def download_wezterm(vendored_dir: Path, wezterm_foldername: str) -> None: + """Download Wezterm for the current platform (macOS or Windows). + + This downloads a specific version of Wezterm, to be vendored in the Datashuttle distribution. + """ + vendored_dir: str = ( + vendored_dir.as_posix() + ) # unfortunately we need to do some Path / str juggling here + + # First set up Wezterm URL and paths depending on platform + wezterm_url = f"https://github.com/wezterm/wezterm/releases/download/{get_wezterm_version()}/{wezterm_foldername}" + wezterm_zip_path = f"{vendored_dir}/{wezterm_foldername}" + + if platform.system() == "Windows": + wezterm_url += ".zip" + wezterm_zip_path += ".zip" + + wezterm_extracted_dir = f"{vendored_dir}/{wezterm_foldername}" + + # Download and unzip Wezterm (if it has not been downloaded already) + if Path(wezterm_extracted_dir).exists(): + print( + f"Not downloading Wezterm as it already exists at {wezterm_extracted_dir}." + ) + + else: + print(f"Downloading Wezterm from: {wezterm_url}") + + Path(vendored_dir).mkdir(parents=True, exist_ok=True) + response = requests.get(wezterm_url) + response.raise_for_status() + + with open(wezterm_zip_path, "wb") as f: + f.write(response.content) + + print(f"Unzipping Wezterm at: {wezterm_zip_path}") + + if platform == "darwin": + unzip_macos(wezterm_zip_path, vendored_dir) + else: + unzip_windows(wezterm_zip_path, vendored_dir) + + Path(wezterm_zip_path).unlink() + + +def unzip_macos(wezterm_zip_path: str, vendored_dir: str): + """Unzip a folder on macOS.""" + subprocess.run( + [ + "unzip", + "-q", + wezterm_zip_path, + "-d", + vendored_dir, + ], + check=True, + ) + + +def unzip_windows(wezterm_zip_path: str, vendored_dir: str): + """Unzip a folder on Windows.""" + with zipfile.ZipFile(wezterm_zip_path, "r") as zip_ref: + zip_ref.extractall(vendored_dir) diff --git a/package/terminal_launcher_macos.py b/package/terminal_launcher_macos.py new file mode 100644 index 000000000..766e39d88 --- /dev/null +++ b/package/terminal_launcher_macos.py @@ -0,0 +1,47 @@ +"""This script launches the terminal from inside the frozen, packaged +version of datashuttle (or from this script, if testing). + +It must know all paths relative parts to the wezterm executable. +This differs subtly between operating system, the Windows version +of this script is `terminal_launcher_windows.py`. + +This script is the entry point for starting datashuttle +in wezterm, packaged by `terminal_launcher_macos.spec`. +""" + +import os +import subprocess +import sys +from pathlib import Path + +import packaging_utils + + +def main(): + WEZTERM_VERSION = packaging_utils.get_wezterm_version() + + # Get the path relative to the running executable / script. + if getattr(sys, "frozen", False): + # Running as a bundled executable (the final, packaged version) + base_path = Path(sys.executable).parent.parent / "Resources" + else: + # Running as a script (for testing) + base_path = Path(__file__).resolve().parent.parent + + # Get all relative paths to the Wezterm executable + wezterm_path = base_path / f"_vendored/WezTerm-macos-{WEZTERM_VERSION}" + wezterm_exe_path = wezterm_path / "Wezterm.app/Contents/MacOS/wezterm-gui" + wezterm_config_path = wezterm_path / "wezterm_config.lua" + datashutle_executable = base_path / "datashuttle" + + # Start the wezterm terminal, and within it start datashuttle + env = os.environ.copy() + env["WEZTERM_CONFIG_FILE"] = str(wezterm_config_path.as_posix()) + + cmd = f"""{wezterm_exe_path} start -- sh -c 'echo "Starting datashuttle..."; "{datashutle_executable}"'""" + + subprocess.Popen(cmd, shell=True, env=env) + + +if __name__ == "__main__": + main() diff --git a/package/terminal_launcher_macos.spec b/package/terminal_launcher_macos.spec new file mode 100644 index 000000000..084ef66a6 --- /dev/null +++ b/package/terminal_launcher_macos.spec @@ -0,0 +1,63 @@ +# -*- mode: python ; coding: utf-8 -*- + +a = Analysis( + ['terminal_launcher_macos.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='terminal_launcher', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='terminal_launcher', +) + +app = BUNDLE( + exe, + a.binaries, + a.datas, + name='datashuttle.app', # <- the app bundle name + icon=None, # <- optional .icns file here + bundle_identifier="com.yourdomain.terminal_launcher", # optional + info_plist={ + 'NSHighResolutionCapable': 'True', + 'CFBundleDisplayName': 'Datashuttle', + 'CFBundleName': 'Datashuttle', + 'CFBundleIdentifier': 'com.yourdomain.datashuttle', + 'CFBundleVersion': '0.1.0', + 'CFBundleShortVersionString': '0.1.0', + 'NSPrincipalClass': 'NSApplication', + 'LSMinimumSystemVersion': '10.13.0', + } +) diff --git a/package/terminal_launcher_windows.py b/package/terminal_launcher_windows.py new file mode 100644 index 000000000..578b5d9e2 --- /dev/null +++ b/package/terminal_launcher_windows.py @@ -0,0 +1,56 @@ +"""This script launches the terminal from inside the frozen, packaged +version of datashuttle (or from this script, if testing). + +It must know all paths relative parts to the wezterm executable. +This differs subtly between operating system, the macOS version +of this script is `terminal_launcher_macos.py`. + +This script is the entry point for starting datashuttle +in wezterm, packaged by `terminal_launcher_windows.spec`. +""" + +import os +import subprocess +import sys +from pathlib import Path + +import packaging_utils + + +def main(): + WEZTERM_VERSION = packaging_utils.get_wezterm_version() + + # Get the path relative to the running executable / script. + if getattr(sys, "frozen", False): + # Running as a bundled executable (the final, packaged version) + base_path = Path(sys.executable).parent + else: + # Running as a script (for testing) + base_path = Path(__file__).resolve().parent + + # Get all relative paths to the Wezterm executable + wezterm_path = ( + Path(__file__).parent.parent + / "_vendored" + / f"WezTerm-windows-{WEZTERM_VERSION}" + ) + wezterm_exe_path = wezterm_path / "wezterm-gui.exe" + wezterm_config_path = wezterm_path / "wezterm_config.lua" + datashutle_executable = base_path / "datashuttle" / "datashuttle.exe" + + # Print all vars for debugging: + print("Local Variables: \n") + for var in locals(): + print(f"{eval(var)}: {var}\n") + + # Start the Wezterm terminal, and within it start datashuttle + env = os.environ.copy() + env["WEZTERM_CONFIG_FILE"] = str(wezterm_config_path.as_posix()) + + cmd = f'"{wezterm_exe_path}" start -- "{datashutle_executable}"' + + subprocess.Popen(cmd, shell=True, env=env) + + +if __name__ == "__main__": + main() diff --git a/package/terminal_launcher_windows.spec b/package/terminal_launcher_windows.spec new file mode 100644 index 000000000..3277dc52c --- /dev/null +++ b/package/terminal_launcher_windows.spec @@ -0,0 +1,43 @@ +# -*- mode: python ; coding: utf-8 -*- + +a = Analysis( + ['terminal_launcher_windows.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + exclude_binaries=True, + name='terminal_launcher', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, # Set `True` for debugging + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='terminal_launcher', +) diff --git a/package/wezterm_config.lua b/package/wezterm_config.lua new file mode 100644 index 000000000..f51cf6541 --- /dev/null +++ b/package/wezterm_config.lua @@ -0,0 +1,21 @@ +local wezterm = require 'wezterm' + +local config = {} + +wezterm.on("format-tab-title", function(tab, tabs, panes, config, hover, max_width) + return { + { Text = " datashuttle " }, + } +end) + +-- ✅ Start maximized (not true fullscreen) +wezterm.on("gui-startup", function(cmd) + local tab, pane, window = wezterm.mux.spawn_window(cmd or {}) + window:gui_window():maximize() +end) + +config.initial_rows = 32 +config.initial_cols = 132 +config.font_size = 11 + +return config diff --git a/pyproject.toml b/pyproject.toml index b432c530c..4a258c79e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ dev = [ "ruff", "setuptools_scm", "textual-dev", + "pyinstaller", "types-requests", "types-PyYAML", "types-appdirs",