commit c5016dd829a6063e0cfd022c48f1a08bd2f07351 Author: Jeremy Anderson Date: Sun Jul 26 21:24:02 2026 -0400 A disc-image editor for ISO 9660 and UDF, built with PySide6 and pycdlib. Runs as a gui or cli. diff --git a/BLOG.md b/BLOG.md new file mode 100755 index 0000000..f054f35 --- /dev/null +++ b/BLOG.md @@ -0,0 +1,98 @@ +# ISO Scalpel 1.1.0 + +I just released ISO Scalpel 1.1.0 — a disc-image editor I wrote in Python. It edits ISO 9660 and UDF images through a graphical interface or a command-line tool. No web browser, no Electron, no runtime dependencies beyond Python and Qt. + +## Why I Built It + +I wanted a graphical ISO editor that supported UDF. The existing GUI editors are stuck in the mid-2000s — GTK+ 2 interfaces, no UDF, no EFI boot, and codebases that haven't seen a commit in years. The command-line tools (`xorriso`, `genisoimage`) work, but nobody can remember the flags. + +## Architecture + +ISO Scalpel is split into two layers. `iso_handler.py` is the engine — it wraps pycdlib and never imports PySide6. Every operation (create, open, save, add, remove, rename, extract, boot, metadata) goes through this one module. `diff.py` is the same: a filesystem diff engine with no Qt dependency. The GUI layer (`main_window.py`, the panes, the dialogs) is a thin shell that calls into the handler and redraws. + +This matters because the build environment I develop in has no display server. I can run the full test suite — every feature, including UDF and boot — without a GUI. The same engine powers the CLI. + +The GUI uses a split-navigation layout: two panes, each with breadcrumb navigation, back/forward/up history, a live filter box, and tabs. Either pane can host the filesystem or the ISO image, and they can be swapped with one keystroke. I did not copy the layout of any existing ISO editor. + +## What's New in 1.1.0 + +Two features: a command-line interface and a filesystem diff mode. + +The CLI (`cli.py`) exposes every operation the GUI does. `new`, `list`, `info`, `add`, `extract`, `rm`, `boot`, and `diff` subcommands, each with options for the naming convention and extensions. You can build an image, add files to it, configure boot, and save it from a single command. The CLI is the same engine as the GUI — no duplicated logic. + +The diff mode compares two ISO images at the filesystem level. It walks both directory trees, matches entries by path, and reports which were added, removed, modified, or unchanged. It does not compare file contents — only names, sizes, and dates. The output looks like a unified diff: + +``` +--- old.iso ++++ new.iso + +2 added, 1 removed, 1 modified, 14 unchanged + + + /newfile.txt (2048 bytes) + - /oldfile.txt (1024 bytes) + M /readme.txt (12 -> 48 bytes) + +(14 entries unchanged) +``` + +In the GUI, the same comparison is available under Tools → Compare Images (`Ctrl+D`). It opens a dialog where you pick two images and get a color-coded tree: green for added, red for removed, amber for modified, grey for unchanged. + +## The Engine + +pycdlib treats ISO 9660, Rock Ridge, Joliet, and UDF as four parallel directory trees. When you add a file, it gets written into all four — with the same data, but four directory entries pointing at it. The handler keeps an in-memory tree that tracks each directory's path in every convention, so a single `add_file` call resolves the path across all four and hands them to pycdlib together. + +ISO 9660 level 1 names are mangled to upper-case 8.3 with a `;1` version suffix. `My Vacation Photos.jpeg` becomes `MY_VACAT.JPE;1`. The handler checks for collisions and appends a numeric suffix (`MY_VACAT_2.JPE;1`) until it finds a free slot. The original name is preserved in the Rock Ridge, Joliet, and UDF trees. + +El Torito boot is supported for both BIOS (platform ID 0) and EFI (platform ID 0xEF), with the boot-info-table patch that ISOLINUX images expect. The boot catalog is generated for every enabled naming convention. + +## Build It + +```sh +tar xzf iso-scalpel-1.1.0.tar.gz +cd iso_scalpel_py +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +python main.py # GUI +python cli.py --help # CLI +``` + +Config lives at `~/.config/iso-scalpel/settings.json` and is created on first run. + +## Quality Assurance Pass + +The 1.1.0 release went through a senior-team QA pass — a QA analyst, a +Linux engineer, an architect, an admin, and a DevOps project manager +each signed off on production readiness. The pass applied PEP 8, +POSIX, SEI CERT, and MISRA-aligned discipline uniformly: + +- **Dispatch tables over if/elif chains.** Flag parsing, name-type + resolution, distro package-manager selection, and diff-status + formatting are all flat lookup tables now, so adding a switch or a + distro is a one-line append. +- **Comprehensions and `next()` over explicit loops** in the pure + filter/map spots (`list_dir`, `available_name_types`, the + modified-time probe in `from_pycdlib`). +- **Narrow exception handling.** Every `except Exception` was either + narrowed to a specific tuple (`PyCdlibException`, `OSError`, + `ValueError`, `KeyError`) or marked as a deliberate top-level error + boundary with an auditable `# noqa: BLE001 -- ` comment. + This surfaced two latent bugs the blind excepts had been hiding: + `_read_boot_info` probed a non-existent pycdlib attribute, and + `_image_size` treated `logical_block_size` as a property when it is + a method. Both are fixed. +- **Step-down / guard clauses** at every choice fork, with the unhappy + path returning early so the main logic sits at the top indentation + level. +- **Locked-in standards.** A `[tool.ruff]` block in `pyproject.toml` + selects the E/W/F/I/B/C4/SIM/UP/S/BLE/RUF/EXE rule families and + per-file ignores for the legitimate exceptions (tests use `assert`; + entry points insert on `sys.path` before imports). `ruff check .` + is part of the definition of done. + +## What's Next + +The roadmap for 1.2 is straightforward: add file-content diffing as an option (byte-for-byte and SHA-256), add a verify-image command that checks the filesystem against the recorded metadata, and improve the CLI output formatting. I'm also considering an `iso-scalpel` console-script entry point so `pip install` gives you a command on your PATH. + +The repository is at git.dcos.net. It's GPL-2.0, and contributions are welcome. diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..4a8b0e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,344 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + + Everyone is permitted to copy and distribute verbatim copies of this + license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your freedom +to share and change it. By contrast, the GNU General Public License is +intended to guarantee your freedom to share and change free software--to +make sure the software is free for all its users. This General Public +License applies to most of the Free Software Foundation's software and +to any other program whose authors commit to using it. (Some other +Free Software Foundation software is covered by the GNU Library General +Public License instead.) You can apply it to your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish); that you receive source code or can get it if +you want it; that you can change the software and use pieces of it in +new free programs; and that you are informed that you can do these +things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, +and (2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains a +notice placed by the copyright holder saying it may be distributed under +the terms of this General Public License. The "Program", below, refers +to any such program or work, and a "work based on the Program" means +either the Program or any derivative work under copyright law: that is +to say, a work containing the Program or a portion of it, either +verbatim or with modifications and/or translated into another language. +(Hereinafter, translation is included without limitation in the term +"modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of running +the Program is not restricted, and the output from the Program is +covered only if its contents constitute a work based on the Program +(independent of having been made by running the Program). Whether that +is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of a +storage or distribution medium does not bring the other work under the +scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software + interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to control +compilation and installation of the executable. However, as a special +exception, the source code distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies the +executable. + +If distribution of executable or object code is made by offering access +to copy from a designated place, then offering equivalent access to +copy the source code from the same place counts as distribution of the +source code, even though third parties are not compelled to copy the +source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt otherwise +to copy, modify, sublicense or distribute the Program is void, and will +automatically terminate your rights under this License. However, +parties who have received copies, or rights, from you under this +License will not have their licenses terminated so long as such parties +remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying the +Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed through +that system in reliance on consistent application of that system; it is +up to the author/donor to decide if he or she is willing to distribute +software through any other system and a licensee cannot impose that +choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new +versions of the General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Program does not specify a +version number of this License, you may choose any version ever +published by the Free Software Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the +author to ask for permission. For software which is copyrighted by the +Free Software Foundation, write to the Free Software Foundation; we +sometimes make exceptions for this. Our decision will be guided by the +two goals of preserving the free status of all derivatives of our free +software and of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these +terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + ISO Scalpel — a PySide6/pycdlib disc-image editor. + Copyright (C) 2025 Jeremy Anderson + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the +appropriate parts of the General Public License. Of course, the +commands you use may be called something other than `show w' and `show +c'; they could even be mouse-clicks or menu items--whatever suits your +program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + signature of Ty Coon, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications +with the library. If this is what you want to do, use the GNU Library +General Public License instead of this License. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100755 index 0000000..27c8bda --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,25 @@ +# MANIFEST.in — controls which files are included in the source tarball. +# Copyright (C) 2025 Jeremy Anderson +# Licensed under GPL v2 — see LICENSE. + +include README.md +include QUICKSTART.md +include BLOG.md +include LICENSE +include MANIFEST.in +include requirements.txt +include pytest.ini + +recursive-include iso_scalpel *.py +recursive-include resources * +recursive-include tests *.py *.md + +# Exclude dev/build artifacts +global-exclude __pycache__ +global-exclude *.py[cod] +global-exclude .DS_Store +global-exclude *.swp +prune .venv +prune .git +prune build +prune dist diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100755 index 0000000..da5bcfa --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,212 @@ +# Quick Start + +Install and run ISO Scalpel in under five minutes. + +> **Prerequisite:** Python 3.10+. Check with `python3 --version`. + +## Install + +```bash +tar xzf iso-scalpel-1.1.0.tar.gz +cd iso-scalpel-1.1.0 + +python3 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate + +pip install -r requirements.txt +``` + +> **On Arch / Fedora / Debian 12+ (PEP 668 distros):** do **not** run +> `pip install` against the system Python -- it will be refused with +> `externally-managed-environment`. Always use a project venv as shown +> above, install the distro packages (`sudo pacman -S python-pyside6 +> python-pycdlib`), or `pipx install iso-scalpel` once published. If +> you launch `python main.py` without dependencies installed, ISO +> Scalpel will detect your distro and offer the right install command. + +On minimal Linux, also install the Qt runtime libraries: + +```bash +sudo apt install libegl1 libgl1 libglib2.0-0 libfontconfig1 \ + libdbus-1-3 libxkbcommon0 libxcb-cursor0 +``` + +## GUI + +```bash +python main.py # empty window +python main.py my_image.iso # open an image +python main.py --check-deps # report any missing dependencies and exit +python main.py --no-install-deps # never prompt to install missing deps +``` + +If a required Python package (`PySide6`, `pycdlib`) is missing when you +launch the GUI, ISO Scalpel will print a clear, copy-pasteable message +listing exactly what's missing and the right install command for your +distro (e.g. `pacman -S python-pycdlib` on Arch, `apt-get install +python3-pycdlib` on Debian/Ubuntu, `dnf install python3-pycdlib` on +Fedora). When run from an interactive terminal it offers install +strategies in order of lowest privilege first: + +1. **Project venv** (recommended on PEP 668 distros like Arch): + `python -m venv .venv && .venv/bin/pip install ...` -- no sudo, no + system mutation. Afterwards run `./.venv/bin/python main.py`. +2. **Distro package manager** with `sudo` (e.g. `sudo pacman -S + python-pycdlib`): shown with the exact command first, runs only after + you type `y`. +3. **`pip install --user --break-system-packages`**: last-resort override + for PEP 668, only with explicit consent. + +> **Note on pipx:** `pipx install pycdlib` installs pycdlib into an +> isolated venv that exports only its CLI tools (`pycdlib-explorer` +> etc.) to your PATH. The Python module is **not** importable from your +> system interpreter, so ISO Scalpel will still report it as missing. +> For libraries, use a project venv or the distro package instead. For +> the ISO Scalpel application itself, you can `pipx install iso-scalpel` +> once it's published (see `pyproject.toml`). + +Pass `--no-install-deps` to skip the prompts and just exit. + +The window has two panes. By default the left pane is the filesystem and +the right pane is the ISO image. Each pane has breadcrumb navigation, +back/forward/up arrows, a filter box, and a tab bar. Press +`Ctrl+Shift+X` to swap the panes. + +### Create an image + +1. **Image → New…** (`Ctrl+N`). +2. Set the volume label. +3. Tick the extensions: Joliet, Rock Ridge, UDF. +4. Pick the ISO 9660 interchange level. +5. Click **OK**. + +### Add files + +Drag files from the filesystem pane onto a directory in the ISO pane. +Or select them and press `Insert`. Files are written into every enabled +naming convention; ISO 9660 names are mangled to valid 8.3 with +collision avoidance. + +### Extract + +Select entries in the ISO pane and press `Ctrl+E`. + +### Make it bootable + +**Tools → Boot Image…** (`Ctrl+B`). Pick a boot image, the platform ID +(x86 / EFI), and the media type. The boot file is added to the image +and the boot catalog is generated. + +### Compare two images + +**Tools → Compare Images…** (`Ctrl+D`). Pick image A and image B, click +**Compare**. The tree shows added (`+`), removed (`-`), modified (`M`), +and unchanged (`=`) entries. File contents are not compared — only the +filesystem structure. + +## CLI + +```bash +python cli.py --help +``` + +### Create an image + +```bash +python cli.py new disc.iso -l MYDISC \ + --joliet 3 --rock-ridge 1.09 --udf 2.60 \ + --add readme.txt +``` + +### List contents + +```bash +python cli.py list disc.iso +python cli.py list disc.iso / --view udf +python cli.py list disc.iso -r # recursive +``` + +### Show metadata + +```bash +python cli.py info disc.iso +``` + +### Add to an image + +```bash +python cli.py add disc.iso ./file.txt / +python cli.py add disc.iso ./folder /sub +``` + +### Extract + +```bash +python cli.py extract disc.iso /readme.txt ./out.txt +python cli.py extract disc.iso /sub ./out_dir +``` + +### Remove an entry + +```bash +python cli.py rm disc.iso /old.txt +``` + +### Configure boot + +```bash +python cli.py boot disc.iso --set boot.img --platform 0 +python cli.py boot disc.iso --set efi.img --platform 0xEF +python cli.py boot disc.iso --clear +``` + +### Diff two images + +```bash +python cli.py diff old.iso new.iso +python cli.py diff old.iso new.iso --all +``` + +## Scripting + +The handler and diff engine are importable directly: + +```python +from iso_scalpel.iso_handler import IsoHandler, NewIsoOptions +from iso_scalpel.diff import diff_images, format_diff_text + +iso = IsoHandler() +iso.new(NewIsoOptions(volume_label="BACKUP", udf="2.60", joliet=3)) +iso.add_file("readme.txt", "/", iso.default_name_type()) +iso.save("backup.iso") +iso.close() + +# compare two images +a = IsoHandler(); a.open("old.iso") +b = IsoHandler(); b.open("new.iso") +print(format_diff_text(diff_images(a, b))) +``` + +## Lint and tests + +The project ships with a locked-in `ruff` configuration (PEP 8, SEI CERT, +MISRA-aligned immutability, POSIX shebang discipline). Run both before +pushing: + +```bash +pip install -e '.[dev]' # pytest + ruff +ruff check . # must report "All checks passed!" +python -m pytest # hermetic deps tests always run; GUI tests + # self-skip when PySide6 / pycdlib are absent +``` + +See [README.md · Coding standards](README.md#coding-standards) for the +full rule set and the rationale behind each. + +## Where to go next + +- [README.md](README.md) — full feature list and API reference +- [BLOG.md](BLOG.md) — what's new in this release +- `iso_scalpel/iso_handler.py` — the core engine (read the docstrings) +- `iso_scalpel/diff.py` — the diff engine +- `~/.config/iso-scalpel/settings.json` — application settings diff --git a/README.md b/README.md new file mode 100755 index 0000000..c1c0263 --- /dev/null +++ b/README.md @@ -0,0 +1,246 @@ +# ISO Scalpel + +A disc-image editor for ISO 9660 and UDF, built with PySide6 (Qt 6) and +[pycdlib](https://github.com/clalancette/pycdlib). Runs as a graphical +application or from the command line. + +**Author:** Jeremy Anderson · · +**License:** GPL-2.0 + +--- + +## Features + +- ISO 9660 interchange levels 1, 2, 3 +- Rock Ridge 1.09 / 1.12 +- Joliet 1 / 2 / 3 +- UDF 2.50 / 2.60 +- El Torito boot images (BIOS and EFI) +- Add, extract, rename, delete files and folders +- Volume metadata editing +- Filesystem diff between two images +- Command-line interface +- Split-navigation GUI with tabs, breadcrumbs, and filtering + +## Requirements + +- Python 3.10+ +- PySide6 >= 6.6 +- pycdlib >= 1.13 + +On a minimal Linux install you also need the Qt runtime libraries: + +```bash +# Debian / Ubuntu +sudo apt install libegl1 libgl1 libglib2.0-0 libfontconfig1 \ + libdbus-1-3 libxkbcommon0 libxcb-cursor0 +``` + +## Install + +```bash +tar xzf iso-scalpel-1.1.0.tar.gz +cd iso_scalpel_py +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +## Running the GUI + +```bash +python main.py # empty window +python main.py my_image.iso # open an image +``` + +The interface is a split-nav file manager: two panes side by side, each +with breadcrumb navigation, back/forward/up history, a live filter box, +and tabs. Either pane can host the filesystem or the ISO image; press +`Ctrl+Shift+X` to swap them. + +## Running the CLI + +```bash +python cli.py --help +``` + +Commands: + +| Command | Description | +|---------|-------------| +| `new` | Create a new ISO image | +| `list` | List the contents of an image | +| `info` | Show volume metadata | +| `add` | Add a file or directory to an image | +| `extract` | Extract a file or directory | +| `rm` | Remove an entry | +| `boot` | Show or configure El Torito boot | +| `diff` | Compare the filesystems of two images | +| `gui` | Launch the graphical interface | + +Examples: + +```bash +# create a UDF image with a file in it +python cli.py new disc.iso -l MYDISC --joliet 3 --rock-ridge 1.09 --udf 2.60 \ + --add readme.txt + +# list contents +python cli.py list disc.iso +python cli.py list disc.iso / --view udf + +# show metadata +python cli.py info disc.iso + +# add a directory tree +python cli.py add disc.iso ./myfolder /sub + +# extract a file +python cli.py extract disc.iso /readme.txt ./out.txt + +# remove an entry +python cli.py rm disc.iso /old.txt + +# compare two images (filesystem diff) +python cli.py diff old.iso new.iso +python cli.py diff old.iso new.iso --all +``` + +The `diff` command follows the POSIX `diff(1)` exit-code convention: +`0` when the images are identical, `1` when they differ, `2` on error. +This makes it scriptable in a shell `if` / `&&` pipeline. + +## The diff view + +`diff` compares two images at the filesystem level — which entries were +added, removed, or modified — without comparing file contents. The GUI +exposes the same comparison under **Tools → Compare Images…** +(`Ctrl+D`), showing a unified tree with status indicators: + +- `+` added (only in B) +- `-` removed (only in A) +- `M` modified (in both, different size or date) +- `=` unchanged + +Output example: + +``` +--- old.iso ++++ new.iso + +2 added, 1 removed, 1 modified, 14 unchanged + + + /newfile.txt (2048 bytes) + - /oldfile.txt (1024 bytes) + M /readme.txt (12 -> 48 bytes) + +(14 entries unchanged) +``` + +## Keyboard shortcuts + +| Shortcut | Action | +|----------|--------| +| `Ctrl+N` | New image | +| `Ctrl+O` | Open image | +| `Ctrl+S` | Save | +| `Ctrl+Shift+S` | Save As… | +| `Ctrl+W` | Close image / close tab | +| `Ctrl+Q` | Quit | +| `Alt+←` / `Alt+→` | Back / forward | +| `Alt+↑` | Up to parent | +| `Ctrl+L` | Go to location | +| `Ctrl+Shift+X` | Swap panes | +| `Ctrl+T` | New tab | +| `Insert` | Add to image | +| `Ctrl+E` | Extract | +| `Ctrl+Shift+N` | New folder | +| `F2` | Rename | +| `Delete` | Delete | +| `Ctrl+F` | Filter | +| `Ctrl+A` | Select all | +| `Alt+Enter` | Properties | +| `Ctrl+B` | Boot image | +| `Ctrl+D` | Compare images | +| `F5` | Refresh | +| `Ctrl+,` | Preferences | + +## Project layout + +``` +iso_scalpel_py/ +├── main.py # GUI entry point +├── cli.py # CLI entry point +├── requirements.txt +├── README.md +├── QUICKSTART.md +├── BLOG.md +├── LICENSE +└── iso_scalpel/ + ├── __init__.py # version metadata + ├── app.py # QApplication + stylesheet + ├── main_window.py # split-nav window, menus, toolbar + ├── iso_handler.py # pycdlib wrapper (no Qt dependency) + ├── iso_record.py # record abstraction + ├── iso_model.py # Qt tree model + ├── diff.py # filesystem diff engine (no Qt dependency) + ├── config.py # settings persistence + ├── dialogs/ # New, Properties, Boot, Extract, Diff, Settings, About + └── widgets/ # filesystem + ISO panes +``` + +`iso_handler.py` and `diff.py` are GUI-agnostic. They never import +PySide6, so the entire engine can be driven from a script or test +harness with no display attached. + +## Configuration + +Settings are stored as JSON: + +- Linux: `~/.config/iso-scalpel/settings.json` +- macOS: `~/Library/Application Support/iso-scalpel/settings.json` +- Windows: `%APPDATA%\iso-scalpel\settings.json` + +## Coding standards + +The codebase is held to a fixed set of rules enforced by +[`ruff`](https://docs.astral.sh/ruff/) (configured in `pyproject.toml`): + +- **PEP 8** style and **PEP 585 / 604** annotations (`list[str]`, + `str | None`). +- **SEI CERT** error handling: no blind `except Exception` and no silent + `try`/`except`/`pass`. pycdlib calls catch a narrow tuple + (`PyCdlibException`, `OSError`, `ValueError`, `KeyError`); top-level CLI + and GUI error boundaries opt out per-occurrence with an auditable + `# noqa: BLE001 -- ` comment. +- **MISRA-aligned** immutability: no mutable default arguments, no function + calls in defaults (`parent=QModelIndex()` uses a module-level singleton). +- **POSIX** discipline: entry-point scripts (`main.py`, `cli.py`) carry a + shebang on line 1 and the executable bit; `cli.py diff` exits `0`/`1`/`2` + per `diff(1)`. +- **Dispatch tables over if/elif chains** for status, name-type, and + flag parsing (`_FLAG_TABLE`, `_PATH_KWARG`, `_NAME_TYPE_PROBES`, + `_PKG_MANAGERS`, `VolumeProperties.extensions`). +- **Comprehensions and `next()` over explicit loops** where the body is a + pure filter/map (`list_dir`, `available_name_types`, `from_pycdlib` + modified-time probe, `_human_size`). +- **Step-down / guard clauses** at choice forks: early `return` on the + unhappy path, main logic at the top indentation level. + +Run the checks with: + +```bash +pip install -e '.[dev]' # installs pytest + ruff +ruff check . # lint (must be clean) +python -m pytest # test suite +``` + +## License + +Copyright © 2025 Jeremy Anderson . + +GPL-2.0. See [LICENSE](LICENSE) for the full text. This program comes +with ABSOLUTELY NO WARRANTY. + +ISO Scalpel is an independent project. No source code from any other ISO +editing tool was used. diff --git a/cli.py b/cli.py new file mode 100755 index 0000000..e04e398 --- /dev/null +++ b/cli.py @@ -0,0 +1,467 @@ +#!/usr/bin/env python3 +"""ISO Scalpel — command-line interface. + +Provides scriptable access to the ISO engine without the GUI. Every +feature of the graphical application is reachable from here, plus the +filesystem diff mode. + +Usage: + python cli.py [options] + +Commands: + new Create a new ISO image + list List the contents of an ISO image + info Show volume metadata for an ISO image + add Add a file or directory to an image + extract Extract a file or directory from an image + rm Remove an entry from an image + boot Configure or show El Torito boot information + diff Compare the filesystems of two images + gui Launch the graphical interface (default if no command) +""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +import argparse +import os +import sys + +# Make the package importable when run directly. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from iso_scalpel import __app_name__, __version__ +from iso_scalpel.diff import DiffEntry, DiffStatus, diff_images, format_diff_text +from iso_scalpel.iso_handler import BootInfo, IsoHandler, NameType, NewIsoOptions +from iso_scalpel.iso_record import NAME_TYPE_LABELS, _human_size + + +# -------------------------------------------------------------------------- +# Argument parsing +# -------------------------------------------------------------------------- +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="iso-scalpel", + description=f"{__app_name__} {__version__} — disc image editor (CLI)", + ) + p.add_argument("--version", action="version", version=f"{__app_name__} {__version__}") + sub = p.add_subparsers(dest="command", metavar="") + + # --- new ----------------------------------------------------------- + sp_new = sub.add_parser("new", help="Create a new ISO image") + sp_new.add_argument("output", help="Output .iso filename") + sp_new.add_argument("-l", "--label", default="CDROM", help="Volume label") + sp_new.add_argument("--level", type=int, choices=[1, 2, 3], default=1, + help="ISO9660 interchange level (default: 1)") + sp_new.add_argument("--joliet", type=int, choices=[1, 2, 3], default=None, + help="Enable Joliet at the given level") + sp_new.add_argument("--rock-ridge", choices=["1.09", "1.12"], default=None, + help="Enable Rock Ridge") + sp_new.add_argument("--udf", choices=["2.50", "2.60"], default=None, + help="Enable UDF") + sp_new.add_argument("--publisher", default="") + sp_new.add_argument("--preparer", default="") + sp_new.add_argument("--application", default="ISO Scalpel") + sp_new.add_argument("--add", action="append", default=[], + help="Add a file or directory to the new image (repeatable). " + "Format: LOCAL_PATH[:ISO_PATH]") + sp_new.set_defaults(func=cmd_new) + + # --- list ---------------------------------------------------------- + sp_list = sub.add_parser("list", help="List the contents of an ISO image") + sp_list.add_argument("file", help="ISO image file") + sp_list.add_argument("path", nargs="?", default="/", help="Directory to list (default: /)") + sp_list.add_argument("-v", "--view", choices=["iso9660", "rr", "joliet", "udf"], + default=None, help="Naming convention to display") + sp_list.add_argument("-r", "--recursive", action="store_true", + help="Recursively list all entries") + sp_list.set_defaults(func=cmd_list) + + # --- info ---------------------------------------------------------- + sp_info = sub.add_parser("info", help="Show volume metadata") + sp_info.add_argument("file", help="ISO image file") + sp_info.set_defaults(func=cmd_info) + + # --- add ----------------------------------------------------------- + sp_add = sub.add_parser("add", help="Add a file or directory to an image") + sp_add.add_argument("file", help="ISO image file to modify") + sp_add.add_argument("local", help="Local file or directory to add") + sp_add.add_argument("iso_path", nargs="?", default="/", + help="Destination directory in the image (default: /)") + sp_add.add_argument("-v", "--view", choices=["iso9660", "rr", "joliet", "udf"], + default=None, help="Naming convention for the destination") + sp_add.set_defaults(func=cmd_add) + + # --- extract ------------------------------------------------------- + sp_ex = sub.add_parser("extract", help="Extract a file or directory") + sp_ex.add_argument("file", help="ISO image file") + sp_ex.add_argument("iso_path", help="Path inside the image to extract") + sp_ex.add_argument("local", help="Local destination path") + sp_ex.add_argument("-v", "--view", choices=["iso9660", "rr", "joliet", "udf"], + default=None) + sp_ex.set_defaults(func=cmd_extract) + + # --- rm ------------------------------------------------------------ + sp_rm = sub.add_parser("rm", help="Remove an entry from an image") + sp_rm.add_argument("file", help="ISO image file to modify") + sp_rm.add_argument("iso_path", help="Path inside the image to remove") + sp_rm.add_argument("-v", "--view", choices=["iso9660", "rr", "joliet", "udf"], + default=None) + sp_rm.set_defaults(func=cmd_rm) + + # --- boot ---------------------------------------------------------- + sp_boot = sub.add_parser("boot", help="Show or configure El Torito boot") + sp_boot.add_argument("file", help="ISO image file") + sp_boot.add_argument("--set", metavar="BOOT_FILE", + help="Set a host file as the boot image") + sp_boot.add_argument("--platform", type=lambda x: int(x, 0), default=0, + help="Platform ID (0=x86, 1=PPC, 2=Mac, 0xEF=EFI)") + sp_boot.add_argument("--media", default="noemul", + choices=["noemul", "floppy", "hdemul"], + help="Media type") + sp_boot.add_argument("--no-bootable", action="store_true", + help="Mark as non-bootable") + sp_boot.add_argument("--info-table", action="store_true", + help="Patch boot-info-table (for ISOLINUX)") + sp_boot.add_argument("--clear", action="store_true", + help="Remove the El Torito boot record") + sp_boot.set_defaults(func=cmd_boot) + + # --- diff ---------------------------------------------------------- + sp_diff = sub.add_parser("diff", help="Compare the filesystems of two images") + sp_diff.add_argument("file_a", help="First ISO image (the 'from')") + sp_diff.add_argument("file_b", help="Second ISO image (the 'to')") + sp_diff.add_argument("-v", "--view", choices=["iso9660", "rr", "joliet", "udf"], + default=None, help="Naming convention to compare in") + sp_diff.add_argument("--all", action="store_true", + help="Show unchanged entries too") + sp_diff.set_defaults(func=cmd_diff) + + # --- gui ----------------------------------------------------------- + sp_gui = sub.add_parser("gui", help="Launch the graphical interface") + sp_gui.add_argument("file", nargs="?", help="Optional ISO file to open") + sp_gui.set_defaults(func=cmd_gui) + + return p + + +# -------------------------------------------------------------------------- +# Name-type parsing helper +# -------------------------------------------------------------------------- +_VIEW_MAP = { + "iso9660": NameType.ISO9660, + "rr": NameType.ROCK_RIDGE, + "joliet": NameType.JOLIET, + "udf": NameType.UDF, +} + + +def _resolve_view(handler: IsoHandler, view: str | None) -> NameType: + if view is None: + return handler.default_name_type() + nt = _VIEW_MAP.get(view) + if nt is None: + raise SystemExit(f"Unknown view: {view}") + return nt + + +# -------------------------------------------------------------------------- +# Command implementations +# -------------------------------------------------------------------------- +def cmd_new(args: argparse.Namespace) -> int: + opts = NewIsoOptions( + volume_label=args.label, + interchange_level=args.level, + joliet=args.joliet, + rock_ridge=args.rock_ridge, + udf=args.udf, + publisher=args.publisher, + preparer=args.preparer, + application=args.application, + ) + handler = IsoHandler() + try: + handler.new(opts) + nt = handler.default_name_type() + for spec in args.add: + if ":" in spec and os.path.exists(spec.split(":", 1)[0]): + local, iso_path = spec.split(":", 1) + else: + local, iso_path = spec, "/" + if os.path.isdir(local): + name = os.path.basename(local) + new_dir = handler.add_directory(iso_path, nt, name) + _import_tree(handler, local, + iso_path.rstrip("/") + "/" + new_dir, nt) + else: + handler.add_file(local, iso_path, nt, + nice_name=os.path.basename(local)) + handler.save(args.output) + except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary + print(f"error: {exc}", file=sys.stderr) + return 1 + finally: + handler.close() + print(f"Created {args.output} ({_human_size(os.path.getsize(args.output))})") + return 0 + + +def _import_tree(handler: IsoHandler, local_dir: str, iso_dir: str, + nt: NameType) -> None: + for entry in sorted(os.listdir(local_dir)): + full = os.path.join(local_dir, entry) + if os.path.isdir(full): + new_dir = handler.add_directory(iso_dir, nt, entry) + _import_tree(handler, full, iso_dir.rstrip("/") + "/" + new_dir, nt) + else: + handler.add_file(full, iso_dir, nt, nice_name=entry) + + +def cmd_list(args: argparse.Namespace) -> int: + handler = IsoHandler() + try: + handler.open(args.file) + except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary + print(f"error: {exc}", file=sys.stderr) + return 1 + nt = _resolve_view(handler, args.view) + if args.recursive: + entries = [] + _collect_recursive(handler, args.path, nt, entries) + else: + entries = handler.list_dir(args.path, nt) + print(f"{args.file} ({NAME_TYPE_LABELS[nt]} view)") + print(f"{'Name':<40} {'Size':>12} {'Type':<6} {'Date'}") + for rec in entries: + name = rec.name + ("/" if rec.is_dir else "") + size = "" if rec.is_dir else _human_size(rec.size) + typ = "dir" if rec.is_dir else "file" + print(f"{name:<40} {size:>12} {typ:<6} {rec.date_label}") + handler.close() + return 0 + + +def _collect_recursive(handler: IsoHandler, path: str, nt: NameType, + out: list) -> None: + for rec in handler.list_dir(path, nt): + out.append(rec) + if rec.is_dir: + _collect_recursive(handler, rec.path, nt, out) + + +def cmd_info(args: argparse.Namespace) -> int: + handler = IsoHandler() + try: + handler.open(args.file) + except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary + print(f"error: {exc}", file=sys.stderr) + return 1 + p = handler.get_properties() + print(f"File: {handler.filename}") + print(f"Volume label: {p.volume_label}") + print(f"Image size: {_human_size(p.total_size)} ({p.total_size} bytes)") + print(f"Block size: {p.block_size}") + print(f"ISO9660 level: {p.interchange_level}") + print(f"Extensions: {', '.join(p.extensions) or '(none)'}") + # Optional descriptor fields are printed only when present. + optional_fields = ( + ("System ID:", p.system_id), + ("Publisher:", p.publisher), + ("Preparer:", p.preparer), + ("Application:", p.application), + ("Volume set ID:", p.volume_set_id if p.volume_set_id and p.volume_set_id.strip() else ""), + ) + for label, value in optional_fields: + if value: + print(f"{label:<17}{value}") + boot = handler.get_boot_info() + print(f"El Torito boot: {'enabled' if boot.enabled else 'disabled'}") + handler.close() + return 0 + + +def cmd_add(args: argparse.Namespace) -> int: + handler = IsoHandler() + try: + handler.open(args.file) + nt = _resolve_view(handler, args.view) + if os.path.isdir(args.local): + name = os.path.basename(args.local) + new_dir = handler.add_directory(args.iso_path, nt, name) + _import_tree(handler, args.local, + args.iso_path.rstrip("/") + "/" + new_dir, nt) + else: + handler.add_file(args.local, args.iso_path, nt, + nice_name=os.path.basename(args.local)) + handler.save(args.file) + except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary + print(f"error: {exc}", file=sys.stderr) + return 1 + finally: + handler.close() + print(f"Added {args.local} to {args.file}") + return 0 + + +def cmd_extract(args: argparse.Namespace) -> int: + handler = IsoHandler() + try: + handler.open(args.file) + nt = _resolve_view(handler, args.view) + # determine if it's a dir or file by looking it up + recs = {r.path: r for r in handler.list_dir( + args.iso_path.rpartition("/")[0] or "/", nt)} + rec = recs.get(args.iso_path) + if rec and rec.is_dir: + handler.extract_dir(args.iso_path, nt, args.local) + else: + handler.extract_file(args.iso_path, nt, args.local) + except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary + print(f"error: {exc}", file=sys.stderr) + return 1 + finally: + handler.close() + print(f"Extracted {args.iso_path} to {args.local}") + return 0 + + +def cmd_rm(args: argparse.Namespace) -> int: + handler = IsoHandler() + try: + handler.open(args.file) + nt = _resolve_view(handler, args.view) + # look up whether it's a dir + parent = args.iso_path.rpartition("/")[0] or "/" + recs = {r.path: r for r in handler.list_dir(parent, nt)} + rec = recs.get(args.iso_path) + is_dir = rec.is_dir if rec else False + handler.remove(args.iso_path, nt, is_dir) + handler.save(args.file) + except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary + print(f"error: {exc}", file=sys.stderr) + return 1 + finally: + handler.close() + print(f"Removed {args.iso_path} from {args.file}") + return 0 + + +def cmd_boot(args: argparse.Namespace) -> int: + handler = IsoHandler() + try: + handler.open(args.file) + if args.clear: + handler.clear_boot() + handler.save(args.file) + print("Boot record removed.") + return 0 + if args.set: + info = BootInfo( + bootable=not args.no_bootable, + platform_id=args.platform, + media_name=args.media, + boot_info_table=args.info_table, + load_segment=0x07C0, + ) + if args.platform == 0xEF: + info.efi = True + handler.set_boot(info, boot_file_local=args.set) + handler.save(args.file) + print(f"Boot image set to {args.set}") + return 0 + # just show info + info = handler.get_boot_info() + print(f"Boot enabled: {info.enabled}") + print(f"Bootable: {info.bootable}") + print(f"Boot image: {info.boot_image_path or '(none)'}") + print(f"Platform ID: 0x{info.platform_id:02X}") + print(f"Media: {info.media_name}") + except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary + print(f"error: {exc}", file=sys.stderr) + return 1 + finally: + handler.close() + return 0 + + +def cmd_diff(args: argparse.Namespace) -> int: + ha = IsoHandler() + hb = IsoHandler() + try: + ha.open(args.file_a) + hb.open(args.file_b) + nt = _resolve_view(ha, args.view) if args.view else None + result = diff_images(ha, hb, name_type=nt) + except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary + print(f"error: {exc}", file=sys.stderr) + return 1 + finally: + ha.close() + hb.close() + + if args.all: + # Show every entry, including unchanged. The size annotation is + # selected by status via a flat table so the print loop stays linear. + print(f"--- {result.a_file}") + print(f"+++ {result.b_file}") + print(f"{result.added_count} added, {result.removed_count} removed, " + f"{result.modified_count} modified, {result.same_count} unchanged") + print() + + def _size_note(e: DiffEntry) -> str: + if e.status == DiffStatus.MODIFIED: + return f"{e.a_size} -> {e.b_size} bytes" + if e.status == DiffStatus.ADDED: + return f"{e.b_size} bytes" + return f"{e.a_size} bytes" + + for e in result.entries: + mark = e.status_label + name = e.path + ("/" if e.is_dir else "") + print(f" {mark} {name} ({_size_note(e)})") + else: + print(format_diff_text(result)) + # POSIX diff(1) convention: 0 = identical, 1 = different, 2 = error. + return 0 if result.is_identical else 1 + + +def cmd_gui(args: argparse.Namespace) -> int: + from iso_scalpel.app import run + argv = ["iso-scalpel"] + if args.file: + argv.append(args.file) + return run(argv) + + +# -------------------------------------------------------------------------- +# Entry point +# -------------------------------------------------------------------------- +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + if not getattr(args, "command", None): + # No subcommand: launch the GUI. + from iso_scalpel.app import run + return run(["iso-scalpel"] + (argv or [])) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/iso_scalpel/__init__.py b/iso_scalpel/__init__.py new file mode 100644 index 0000000..3b09cad --- /dev/null +++ b/iso_scalpel/__init__.py @@ -0,0 +1,28 @@ +""" +iso_scalpel — a PySide6 / pycdlib disc-image editor. +Supports ISO9660, Rock Ridge, Joliet, UDF and El Torito boot images. +""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +__version__ = "1.1.0" +__app_name__ = "ISO Scalpel" +__author__ = "Jeremy Anderson " +__author_url__ = "https://dcos.net" +__tagline__ = "Precise disc image editing" diff --git a/iso_scalpel/__pycache__/__init__.cpython-314.pyc b/iso_scalpel/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..e194c41 Binary files /dev/null and b/iso_scalpel/__pycache__/__init__.cpython-314.pyc differ diff --git a/iso_scalpel/__pycache__/_deps.cpython-314.pyc b/iso_scalpel/__pycache__/_deps.cpython-314.pyc new file mode 100644 index 0000000..7a18fad Binary files /dev/null and b/iso_scalpel/__pycache__/_deps.cpython-314.pyc differ diff --git a/iso_scalpel/__pycache__/app.cpython-314.pyc b/iso_scalpel/__pycache__/app.cpython-314.pyc new file mode 100644 index 0000000..23bce6e Binary files /dev/null and b/iso_scalpel/__pycache__/app.cpython-314.pyc differ diff --git a/iso_scalpel/__pycache__/config.cpython-314.pyc b/iso_scalpel/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..c548c61 Binary files /dev/null and b/iso_scalpel/__pycache__/config.cpython-314.pyc differ diff --git a/iso_scalpel/__pycache__/diff.cpython-314.pyc b/iso_scalpel/__pycache__/diff.cpython-314.pyc new file mode 100644 index 0000000..de8df40 Binary files /dev/null and b/iso_scalpel/__pycache__/diff.cpython-314.pyc differ diff --git a/iso_scalpel/__pycache__/iso_handler.cpython-314.pyc b/iso_scalpel/__pycache__/iso_handler.cpython-314.pyc new file mode 100644 index 0000000..b3f1970 Binary files /dev/null and b/iso_scalpel/__pycache__/iso_handler.cpython-314.pyc differ diff --git a/iso_scalpel/__pycache__/iso_model.cpython-314.pyc b/iso_scalpel/__pycache__/iso_model.cpython-314.pyc new file mode 100644 index 0000000..397f852 Binary files /dev/null and b/iso_scalpel/__pycache__/iso_model.cpython-314.pyc differ diff --git a/iso_scalpel/__pycache__/iso_record.cpython-314.pyc b/iso_scalpel/__pycache__/iso_record.cpython-314.pyc new file mode 100644 index 0000000..8a222ec Binary files /dev/null and b/iso_scalpel/__pycache__/iso_record.cpython-314.pyc differ diff --git a/iso_scalpel/__pycache__/main_window.cpython-314.pyc b/iso_scalpel/__pycache__/main_window.cpython-314.pyc new file mode 100644 index 0000000..578ec8f Binary files /dev/null and b/iso_scalpel/__pycache__/main_window.cpython-314.pyc differ diff --git a/iso_scalpel/_deps.py b/iso_scalpel/_deps.py new file mode 100644 index 0000000..f21b3a2 --- /dev/null +++ b/iso_scalpel/_deps.py @@ -0,0 +1,908 @@ +"""Runtime dependency checking and (optionally) interactive installation. + +Production rule: never let the user see a raw ``ImportError`` traceback for +a missing third-party package. Instead, detect what is missing, explain it +in plain English, and -- only when the user explicitly agrees -- offer to +install it using the strategy that is appropriate for the host distro. + +Why distro awareness matters +---------------------------- +Modern Python distributions ship an "externally managed" environment +(PEP 668). On Arch Linux ``pip install --user`` is refused outright and +the user is steered towards ``pacman`` or ``pipx``. On Debian/Ubuntu the +``python3-xyz`` apt packages are the blessed route. Telling an Arch user +to ``sudo apt install`` is unhelpful, and telling them to ``pip install`` +without ``--break-system-packages`` fails. This module picks the right +tool for each distro and only escalates to ``sudo`` after explicit consent. + +This module is import-safe: it depends only on the Python standard +library, so it can run before any third-party dependency is available. +""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +import importlib +import os +import re +import shutil +import subprocess +import sys +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass, field +from pathlib import Path + + +# ========================================================================== +# Dependency registry +# ========================================================================== +@dataclass(frozen=True) +class Dependency: + """One declared third-party dependency. + + Attributes + ---------- + import_name: + The name used in ``import`` statements, e.g. ``"PySide6"`` or + ``"pycdlib"``. + pip_name: + The name used on PyPI, e.g. ``"PySide6"`` or ``"pycdlib"``. + min_version: + Optional minimum version string (e.g. ``"6.6"``). Comparison is + element-wise numeric on dot-separated parts, with the shorter + vector padded with zeros. ``"1.13"`` therefore matches + ``"1.13.0"`` and ``"1.13rc1"``. + purpose: + Short human-readable description of why this package is needed. + distro_packages: + Optional mapping ``{distro_id: distro_package_name}`` overriding + the PyPI name when the distro's native package manager is used. + For example, on Arch ``PySide6`` is ``python-pyside6`` and on + Debian it is ``python3-pyside6`` (the apt prefix is added by the + strategy builder, so just ``pyside6`` here would be enough). + """ + + import_name: str + pip_name: str + min_version: str | None = None + purpose: str = "" + distro_packages: dict = field(default_factory=dict) + + def matches(self, version: str) -> bool: + if not self.min_version: + return True + + def _vec(s: str) -> list[int]: + cleaned = "" + for ch in s: + if ch.isdigit() or ch == ".": + cleaned += ch + else: + break + return [int(p) if p.isdigit() else 0 for p in cleaned.split(".") if p] + + want = _vec(self.min_version) + have = _vec(version) + n = max(len(want), len(have)) + want += [0] * (n - len(want)) + have += [0] * (n - len(have)) + return have >= want + + +# Declared in requirements.txt -- the single source of truth lives here so +# we can describe them to the user without re-parsing the file. +REQUIREMENTS: Sequence[Dependency] = ( + Dependency( + import_name="PySide6", + pip_name="PySide6", + min_version="6.6", + purpose="Qt6 GUI toolkit (main window, dialogs, widgets).", + distro_packages={ + "arch": "python-pyside6", + "debian": "python3-pyside6", + "ubuntu": "python3-pyside6", + "fedora": "python3-pyside6", + "opensuse": "python3-pyside6", + }, + ), + Dependency( + import_name="pycdlib", + pip_name="pycdlib", + min_version="1.13", + purpose="Reads and writes ISO9660 / Joliet / Rock Ridge / UDF / El Torito images.", + distro_packages={ + "arch": "python-pycdlib", + "debian": "python3-pycdlib", + "ubuntu": "python3-pycdlib", + "fedora": "python3-pycdlib", + "opensuse": "python3-pycdlib", + }, + ), +) + + +# ========================================================================== +# Distro detection +# ========================================================================== +@dataclass(frozen=True) +class DistroInfo: + """A best-effort description of the host Linux distribution. + + Attributes + ---------- + id: + Lowercase canonical id: ``"arch"``, ``"debian"``, ``"ubuntu"``, + ``"fedora"``, ``"opensuse"``, ``"macos"``, ``"windows"`` or + ``"unknown"``. + id_like: + List of compatibility ids from ``ID_LIKE=`` (e.g. Ubuntu reports + ``["debian"]``, Linux Mint reports ``["ubuntu", "debian"]``). + version: + Version string (e.g. ``"22.04"``) or ``""`` if unknown. + name: + Human-readable pretty name (e.g. ``"Arch Linux"``). + """ + + id: str + id_like: tuple[str, ...] + version: str + name: str + + def matches(self, *ids: str) -> bool: + """Return True if this distro's id or any id_like matches.""" + return self.id in ids or any(x in ids for x in self.id_like) + + +def detect_distro() -> DistroInfo: + """Detect the host distribution using ``/etc/os-release`` (Linux) or + platform hints on macOS / Windows. + + Falls back to :data:`DistroInfo` ``id="unknown"`` when nothing + recognizable is found. Never raises. + """ + if sys.platform == "darwin": + try: + # sw_vers ships at /usr/bin/sw_vers on every macOS release. + v = subprocess.check_output( + ["/usr/bin/sw_vers", "-productVersion"], text=True).strip() + except (OSError, subprocess.SubprocessError): + v = "" + return DistroInfo(id="macos", id_like=(), version=v, name="macOS") + + if sys.platform == "win32": + return DistroInfo(id="windows", id_like=(), version="", name="Windows") + + # Linux: parse /etc/os-release (the standard since systemd 219). + os_release = _parse_os_release() + if not os_release: + # Older fallbacks: /etc/lsb-release, /etc/arch-release + os_release = _parse_legacy_release() + if not os_release: + return DistroInfo(id="unknown", id_like=(), version="", name="Unknown") + + raw_id = os_release.get("ID", "unknown").strip().lower() + raw_like = os_release.get("ID_LIKE", "").strip() + id_like = tuple(x for x in re.split(r"\s+", raw_like) if x) + return DistroInfo( + id=raw_id, + id_like=id_like, + version=os_release.get("VERSION_ID", "").strip(), + name=os_release.get("PRETTY_NAME", raw_id).strip() or raw_id, + ) + + +def _parse_os_release(path: str = "/etc/os-release") -> dict: + """Parse ``/etc/os-release`` into a dict. Returns ``{}`` on failure.""" + try: + with open(path, encoding="utf-8") as fh: + text = fh.read() + except OSError: + # Try the symlink fallback some distros use. + try: + with open("/usr/lib/os-release", encoding="utf-8") as fh: + text = fh.read() + except OSError: + return {} + + out: dict = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + # Strip surrounding quotes + v = v.strip().strip('"').strip("'") + out[k.strip()] = v + return out + + +def _parse_legacy_release() -> dict: + """Fallback distro detection for systems without ``/etc/os-release``.""" + # /etc/lsb-release (older Ubuntu / Mint) + try: + with open("/etc/lsb-release", encoding="utf-8") as fh: + data = _parse_kv(fh.read()) + if data.get("DISTRIB_ID"): + data["ID"] = data["DISTRIB_ID"].lower() + data["PRETTY_NAME"] = data.get("DISTRIB_DESCRIPTION", data["DISTRIB_ID"]) + data["VERSION_ID"] = data.get("DISTRIB_RELEASE", "") + return data + except OSError: + pass + + # /etc/arch-release (just a flag file) + if os.path.exists("/etc/arch-release"): + return {"ID": "arch", "PRETTY_NAME": "Arch Linux", "VERSION_ID": ""} + + # /etc/redhat-release (RHEL/CentOS/Fedora pre-os-release) + for fname, distro_id in ( + ("/etc/redhat-release", "fedora"), + ("/etc/centos-release", "centos"), + ("/etc/SuSE-release", "opensuse"), + ): + try: + with open(fname, encoding="utf-8") as fh: + line = fh.readline().strip() + return {"ID": distro_id, "PRETTY_NAME": line, "VERSION_ID": ""} + except OSError: + continue + + return {} + + +def _parse_kv(text: str) -> dict: + out: dict = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + out[k.strip()] = v.strip().strip('"').strip("'") + return out + + +# ========================================================================== +# Missing-dependency detection +# ========================================================================== +@dataclass +class MissingDependency: + """A dependency that could not be imported (or was the wrong version).""" + + dep: Dependency + reason: str # "not_installed" | "import_error" | "version_too_low" + found_version: str | None = None + import_error: str | None = None + + +def _get_version(module) -> str | None: + """Best-effort extraction of a module's version string. + + Tries the common attribute names in priority order. An attribute that + is callable (e.g. ``pkg_resources``-style ``version()``) is invoked; a + failure there falls through to the next candidate rather than aborting + the whole probe. + """ + for attr in ("__version__", "version", "VERSION"): + v = getattr(module, attr, None) + if isinstance(v, str) and v: + return v + if callable(v): + try: + return str(v()) + except (TypeError, ValueError, AttributeError): + continue + return None + + +def check_dependency(dep: Dependency) -> MissingDependency | None: + """Return ``None`` if ``dep`` is satisfied, else a :class:`MissingDependency`.""" + try: + module = importlib.import_module(dep.import_name) + except ImportError as exc: + msg = str(exc) + head = msg.split("'")[1] if "'" in msg else msg + if head == dep.import_name or head.startswith(dep.import_name + "."): + return MissingDependency(dep=dep, reason="not_installed", import_error=msg) + return MissingDependency(dep=dep, reason="import_error", import_error=msg) + except Exception as exc: # noqa: BLE001 -- top-level error boundary + # Any other import-time failure (SyntaxError in the dep, binary + # incompatibility, segfault wrapper, ...) is reported as an import + # error rather than crashing the dependency probe. + return MissingDependency(dep=dep, reason="import_error", import_error=str(exc)) + + if dep.min_version: + v = _get_version(module) + if v is None: + return None + if not dep.matches(v): + return MissingDependency( + dep=dep, reason="version_too_low", found_version=v + ) + return None + + +def check_dependencies(deps: Sequence[Dependency] = REQUIREMENTS) -> list[MissingDependency]: + """Return the list of unsatisfied dependencies (empty if all OK).""" + return [m for m in (check_dependency(d) for d in deps) if m is not None] + + +# ========================================================================== +# Install strategies +# ========================================================================== +@dataclass(frozen=True) +class InstallStrategy: + """One concrete install plan for a set of missing packages. + + Attributes + ---------- + description: + Human-readable label for this strategy (e.g. ``"project venv"``). + command: + The argv list to execute. Empty list means "no command available + for this strategy on this distro". + requires_sudo: + True if the command needs root privileges (and will be wrapped in + ``sudo -E``). + rationale: + Short explanation of *why* this strategy is being suggested, shown + to the user before they consent. + """ + + description: str + command: list[str] + requires_sudo: bool + rationale: str + + +def _distro_pkg_name(dep: Dependency, distro: DistroInfo) -> str | None: + """Return the distro-native package name for ``dep`` on ``distro``. + + Falls back through ``id_like`` entries. Returns ``None`` if no mapping + exists. + """ + candidates = (distro.id, *distro.id_like) + for cid in candidates: + if cid in dep.distro_packages: + return dep.distro_packages[cid] + return None + + +# Each row: (manager_id, install_prefix, needs_sudo, matched-distro-ids). +# Drives _distro_pkg_manager() as a flat table so adding a new distro family +# is a one-line append instead of another if-branch. +_PKG_MANAGERS: tuple[tuple[str, list[str], bool, tuple[str, ...]], ...] = ( + ("pacman", ["pacman", "-S", "--noconfirm"], True, ("arch",)), + ("apt", ["apt-get", "install", "-y"], True, + ("debian", "ubuntu", "linuxmint", "raspbian")), + ("dnf", ["dnf", "install", "-y"], True, + ("fedora", "centos", "rhel", "rocky", "alma")), + ("zypper", ["zypper", "--non-interactive", "install"], True, + ("opensuse", "suse", "sles")), +) + + +def _distro_pkg_manager(distro: DistroInfo) -> tuple[str, list[str], bool] | None: + """Return ``(manager_id, install_prefix, needs_sudo)`` for ``distro``. + + The install prefix is the argv fragment that precedes the package name(s). + ``needs_sudo`` is True for system-wide package managers. + """ + for pm_id, prefix, needs_sudo, ids in _PKG_MANAGERS: + if distro.matches(*ids): + return (pm_id, prefix, needs_sudo) + return None + + +def _pipx_available() -> bool: + return shutil.which("pipx") is not None + + +def _project_venv_python(project_root: Path | None = None) -> Path | None: + """Return the path to a project-local ``.venv/bin/python`` if it exists.""" + if project_root is None: + # Default: the directory containing the iso_scalpel package. + project_root = Path(__file__).resolve().parent.parent + candidate = project_root / ".venv" / "bin" / "python" + return candidate if candidate.exists() else None + + +def _check_in_venv( + venv_python: Path, + deps: Sequence[Dependency], + *, + runner: Callable[[Sequence[str]], int] | None = None, +) -> bool: + """Verify that ``deps`` are importable from ``venv_python``. + + Runs `` -c 'import dep1; import dep2'``. Returns True if + the subprocess exits 0 (i.e. all imports succeeded). Used after a + venv-targeted install to confirm the install actually landed -- the + *current* interpreter cannot see the venv's site-packages. + """ + if runner is None: + def runner(cmd: Sequence[str]) -> int: + return subprocess.call(list(cmd)) # noqa: S603 + import_statements = "; ".join(f"import {d.import_name}" for d in deps) + cmd = [str(venv_python), "-c", import_statements] + return runner(cmd) == 0 + + +def build_strategies( + missing: list[MissingDependency], + distro: DistroInfo, + *, + project_root: Path | None = None, +) -> list[InstallStrategy]: + """Build the ordered list of install strategies for ``missing`` on ``distro``. + + Order matters: lower-privilege, lower-blast-radius strategies come + first. The caller offers them one at a time and only escalates when + the user agrees. + + Strategy order (filtered to what's actually available on this host): + + 1. **Project venv** -- create ``./.venv`` and install there. No sudo, + no system mutation. Works on every distro. The user must then run + ``./.venv/bin/python main.py`` (or activate the venv) afterwards. + 2. **pipx** -- ``pipx install`` is *only* valid for installable apps, + so this strategy is offered only for the iso_scalpel package itself + (not for libraries like pycdlib). Excluded here for libraries. + 3. **Distro package manager** -- ``pacman -S python-pycdlib`` etc. + Requires sudo; shown with explicit consent. + 4. **pip --break-system-packages --user** -- last resort on PEP-668 + distros when nothing else is available. + """ + strategies: list[InstallStrategy] = [] + + # Build the pip-install spec for each missing dep, including a minimum + # version pin only when the dep is fully absent (not when upgrading). + # The spec is shell-quoted to prevent bash from interpreting ``>=`` as + # an output redirection (``pycdlib>=1.13`` would otherwise create a + # file named ``=1.13`` and silently drop the version pin). + def _pip_spec(m: MissingDependency) -> str: + if m.dep.min_version and m.reason == "not_installed": + return f"{m.dep.pip_name}>={m.dep.min_version}" + return m.dep.pip_name + + def _shell_quote(spec: str) -> str: + """Single-quote a string for safe inclusion in a bash -c command.""" + return "'" + spec.replace("'", "'\"'\"'") + "'" + + # 1. Project venv ------------------------------------------------------ + venv_python = _project_venv_python(project_root) + if venv_python is None: + project_root_resolved = project_root or Path(__file__).resolve().parent.parent + venv_dir = project_root_resolved / ".venv" + # Quote each pip spec so >= doesn't trigger shell redirection. + specs = " ".join(_shell_quote(_pip_spec(m)) for m in missing) + chain = ( + f"{sys.executable} -m venv {venv_dir} " + f"&& {venv_dir}/bin/python -m pip install --upgrade pip " + f"&& {venv_dir}/bin/python -m pip install {specs}" + ) + strategies.append(InstallStrategy( + description="project venv (recommended on Arch / PEP 668 distros)", + command=["bash", "-c", chain], + requires_sudo=False, + rationale=( + "Create an isolated ./.venv for ISO Scalpel and install the " + "missing packages there. After this completes, run: " + "./.venv/bin/python main.py" + ), + )) + else: + # venv already exists; just install into it. No shell quoting + # needed because this is an argv list passed directly to execv. + cmd = [str(venv_python), "-m", "pip", "install"] + [_pip_spec(m) for m in missing] + strategies.append(InstallStrategy( + description=f"existing project venv ({venv_python})", + command=cmd, + requires_sudo=False, + rationale=( + "A project venv was found at ./.venv. Install the missing " + f"packages there, then run: {venv_python} main.py" + ), + )) + + # 2. pipx (only valid for apps, not libraries) ------------------------- + # pycdlib and PySide6 are libraries -- they should NOT be installed via + # ``pipx install`` (pipx is for CLI tools). We deliberately do not + # offer pipx as an install strategy for missing library imports. + # What we DO is check whether the user already installed them via pipx + # and offer to import from that venv (see _find_pipx_venv_site_packages). + + # 3. Distro package manager -------------------------------------------- + pm = _distro_pkg_manager(distro) + if pm: + pm_id, pm_prefix, needs_sudo = pm + pkg_names: list[str] = [] + for m in missing: + n = _distro_pkg_name(m.dep, distro) + if n: + pkg_names.append(n) + if pkg_names: + cmd: list[str] = [] + if needs_sudo: + cmd += ["sudo", "-E"] + cmd += pm_prefix + pkg_names + strategies.append(InstallStrategy( + description=f"{pm_id} (system-wide, requires sudo)", + command=cmd, + requires_sudo=needs_sudo, + rationale=( + f"Install the {pm_id} packages provided by {distro.name}. " + "This is the most stable route on this distro but affects " + "the whole system." + ), + )) + + # 4. pip --break-system-packages --user (last resort) ------------------ + cmd = [sys.executable, "-m", "pip", "install", "--user", + "--break-system-packages"] + [_pip_spec(m) for m in missing] + strategies.append(InstallStrategy( + description="pip --user --break-system-packages (last resort)", + command=cmd, + requires_sudo=False, + rationale=( + "Force pip to install into your user site-packages, overriding " + "PEP 668. This may conflict with the distro's package manager " + "and is offered only as a fallback." + ), + )) + + return strategies + + +# ========================================================================== +# pipx venv detection +# ========================================================================== +def _pipx_home() -> Path: + """Return the pipx base directory (default ``~/.local/share/pipx``).""" + env = os.environ.get("PIPX_HOME") + if env: + return Path(env) + return Path.home() / ".local" / "share" / "pipx" + + +def find_pipx_venv(package: str) -> Path | None: + """Return the path to a pipx-managed venv for ``package``, or ``None``. + + Looks under ``$PIPX_HOME/venvs/``. This is useful when the + user has already run ``pipx install pycdlib`` and we want to surface + the fact that the package *is* installed -- just not in the current + interpreter's site-packages. + """ + base = _pipx_home() / "venvs" / package + return base if base.exists() and (base / "lib").is_dir() else None + + +def pipx_venv_site_packages(package: str, distro: DistroInfo) -> Path | None: + """Return the site-packages dir inside a pipx venv, if present.""" + venv = find_pipx_venv(package) + if venv is None: + return None + lib = venv / "lib" + if not lib.is_dir(): + return None + # Find the pythonX.Y directory under lib/ + for entry in sorted(lib.iterdir()): + if entry.name.startswith("python"): + sp = entry / "site-packages" + if sp.is_dir(): + return sp + return None + + +# ========================================================================== +# Reporting +# ========================================================================== +def format_missing_report( + missing: Iterable[MissingDependency], + distro: DistroInfo | None = None, +) -> str: + """Build a multi-line, human-readable explanation of what's missing. + + When ``distro`` is provided the report includes distro-specific + install hints (e.g. ``pacman -S python-pycdlib`` on Arch). + """ + missing = list(missing) + if not missing: + return "" + + lines: list[str] = [] + if distro is None: + distro = detect_distro() + lines.append( + f"ISO Scalpel cannot start because the following dependencies " + f"are missing or out of date (detected distro: {distro.name}):\n" + ) + for m in missing: + d = m.dep + if m.reason == "not_installed": + lines.append(f" - {d.pip_name} (>= {d.min_version or 'any'}) -- {d.purpose}") + # Distros with a native package get a native hint first. + native = _distro_pkg_name(d, distro) + pm = _distro_pkg_manager(distro) + if native and pm: + pm_cmd = " ".join(pm[1] + [native]) + if pm[2]: + pm_cmd = "sudo " + pm_cmd + lines.append(f" not installed. On {distro.id}: {pm_cmd}") + lines.append(f" or use a project venv: python -m venv .venv " + f"&& .venv/bin/pip install {d.pip_name}") + else: + lines.append(f" not installed. Install with: " + f"pip install {d.pip_name}") + elif m.reason == "version_too_low": + lines.append(f" - {d.pip_name} (>= {d.min_version}) -- {d.purpose}") + lines.append(f" found version {m.found_version}; upgrade with: " + f"pip install --upgrade {d.pip_name}") + else: # import_error + lines.append(f" - {d.pip_name} -- {d.purpose}") + lines.append(f" installed but failed to import: {m.import_error}") + # Surface pipx misinstalls. + pv = find_pipx_venv(d.pip_name) + if pv: + lines.append( + f" note: a pipx venv for {d.pip_name} exists at {pv}, " + "but pipx venvs are isolated and not importable from this " + "interpreter. Install the package into a project venv " + "(`python -m venv .venv`) or use the distro package instead." + ) + lines.append("") + lines.append("See requirements.txt for the canonical version pins.") + return "\n".join(lines) + + +# ========================================================================== +# Install execution +# ========================================================================== +def _run(cmd: Sequence[str]) -> int: + """Run ``cmd`` and stream its output to the parent terminal. + + ``cmd`` is an argv list built by :func:`build_strategies` from trusted + inputs (the project's own dependency declarations and the detected + distro's package manager); it is never assembled from user-typed text. + """ + # If the command contains shell glue (e.g. ``&&``) it is already + # wrapped in ``bash -c`` by build_strategies; just execute it. + proc = subprocess.Popen(list(cmd), stdout=sys.stdout, stderr=sys.stderr) # noqa: S603 + return proc.wait() + + +# ========================================================================== +# Interactive flow +# ========================================================================== +def _prompt(question: str, *, input_fn: Callable[[str], str] = input) -> str: + return input_fn(question) + + +def _is_interactive() -> bool: + return sys.stdin.isatty() + + +def offer_to_install( + missing: list[MissingDependency], + *, + distro: DistroInfo | None = None, + runner: Callable[[Sequence[str]], int] = _run, + input_fn: Callable[[str], str] = input, + interactive: bool | None = None, + project_root: Path | None = None, +) -> bool: + """Walk the user through the available install strategies for ``missing``. + + Returns ``True`` if all installs succeeded, ``False`` otherwise. + + The flow is: + + 1. List the missing packages. + 2. For each strategy returned by :func:`build_strategies` (lowest + privilege first), show the exact command and ask for consent. + Stop as soon as one strategy succeeds. + 3. After each successful install, re-verify imports. + """ + if interactive is None: + interactive = _is_interactive() + if not missing: + return True + if not interactive: + return False + if distro is None: + distro = detect_distro() + + print(f"\nDetected distro: {distro.name} (id={distro.id})") + print("The following packages are required:") + for m in missing: + print(f" - {m.dep.pip_name} ({m.reason}" + + (f", found {m.found_version}" if m.found_version else "") + ")") + + # Warn about pipx-misinstalled libraries up front. + for m in missing: + pv = find_pipx_venv(m.dep.pip_name) + if pv: + print( + f"\nNote: {m.dep.pip_name} is installed in a pipx venv at {pv}, " + "but pipx venvs are isolated -- their packages are not visible " + "to this interpreter. The strategies below will install it " + "where this Python can actually import it." + ) + + strategies = build_strategies(missing, distro, project_root=project_root) + for i, strat in enumerate(strategies, 1): + print(f"\n[{i}/{len(strategies)}] {strat.description}") + print("Proposed command:") + print(" " + " ".join(strat.command)) + if strat.requires_sudo: + print("This command requires sudo (system-wide change).") + print(f"Rationale: {strat.rationale}") + answer = _prompt("Run this command now? [y/N] ", input_fn=input_fn).strip().lower() + if answer not in ("y", "yes"): + print("Skipping. Trying next strategy...") + continue + + rc = runner(strat.command) + if rc != 0: + print(f"\nInstall failed with exit code {rc}.") + continue + + print("Install reported success. Re-checking imports...") + # Critical: invalidate importlib's caches and drop any negative + # cached entries from sys.modules. After our first failed + # importlib.import_module() the failure is sticky -- without this + # step the freshly-installed package is still reported missing + # even though it's now on disk. + importlib.invalidate_caches() + for m in missing: + sys.modules.pop(m.dep.import_name, None) + # Also drop parent packages whose sub-import may have been + # cached as failing (e.g. for foo.bar we drop both foo and + # foo.bar). + if "." in m.dep.import_name: + parent = m.dep.import_name.split(".")[0] + sys.modules.pop(parent, None) + still_missing = check_dependencies([m.dep for m in missing]) + + # Venv-targeted strategies are special: the install lands in + # ./.venv, which the *current* interpreter cannot see. Re-check + # using the venv's python instead, and offer to re-exec main.py + # from the venv so the user doesn't have to remember to do it. + if still_missing and "venv" in strat.description: + venv_python = _project_venv_python(project_root) + if venv_python and venv_python.exists(): + if _check_in_venv(venv_python, [m.dep for m in missing]): + print(f"\nPackages installed successfully into {venv_python.parent.parent}.") + print(f"The current interpreter ({sys.executable}) cannot see " + "them, but the venv python can.") + print("\nTo launch ISO Scalpel using the venv, run:") + print(f" {venv_python} main.py") + answer = _prompt( + "Restart ISO Scalpel using the venv now? [y/N] ", + input_fn=input_fn, + ).strip().lower() + if answer in ("y", "yes"): + # Replace the current process with the venv python. + # os.execv does not return on success. + os.execv( # noqa: S606 + str(venv_python), + [str(venv_python), *sys.argv]) + print("OK -- not restarting. Re-run with the venv python " + "when ready.") + return False + else: + print("Venv install reported success but the venv python " + "still cannot import the packages. This is " + "unexpected; please report this bug.") + + if not still_missing: + print("All dependencies satisfied.") + return True + print("Some packages are still missing after install:") + for m in still_missing: + print(f" - {m.dep.pip_name} ({m.reason})") + missing = still_missing # narrow the next strategy to what's left + + print("\nNo strategy succeeded. Please install the packages manually.") + print("Suggested options:") + print(" 1. Create a project venv:") + print(" python -m venv .venv && .venv/bin/pip install -r requirements.txt") + print(" .venv/bin/python main.py") + print(" 2. Install the distro packages (example for Arch):") + print(" sudo pacman -S python-pyside6 python-pycdlib") + print(" 3. Install ISO Scalpel itself as a pipx app (after publishing):") + print(" pipx install iso-scalpel") + return False + + +# ========================================================================== +# Top-level entry point +# ========================================================================== +class DependencyError(SystemExit): + """Raised when required dependencies cannot be satisfied.""" + + +def ensure_dependencies( + *, + deps: Sequence[Dependency] = REQUIREMENTS, + interactive: bool | None = None, + auto_install: bool = True, + runner: Callable[[Sequence[str]], int] = _run, + input_fn: Callable[[str], str] = input, + distro: DistroInfo | None = None, + project_root: Path | None = None, +) -> None: + """Verify that all ``deps`` are importable; offer to install if not. + + Raises :class:`DependencyError` (a subclass of :class:`SystemExit`) with + exit code ``1`` when dependencies remain unsatisfied. Returns silently + when everything is OK. + """ + missing = check_dependencies(deps) + if not missing: + return + + if distro is None: + distro = detect_distro() + + if not auto_install: + sys.stderr.write(format_missing_report(missing, distro) + "\n") + raise DependencyError(1) + + if interactive is None: + interactive = _is_interactive() + + if not interactive: + sys.stderr.write(format_missing_report(missing, distro) + "\n") + sys.stderr.write( + "\nRe-run from an interactive terminal to be offered an automatic " + "install. On a PEP 668 distro (Arch, Fedora 38+, Debian 12+) the " + "recommended path is a project venv:\n" + " python -m venv .venv && .venv/bin/pip install -r requirements.txt\n" + " .venv/bin/python main.py\n" + ) + raise DependencyError(1) + + ok = offer_to_install( + missing, distro=distro, runner=runner, input_fn=input_fn, + interactive=True, project_root=project_root, + ) + if not ok: + sys.stderr.write("\n" + format_missing_report(missing, distro) + "\n") + raise DependencyError(1) + + +__all__ = [ + "REQUIREMENTS", + "Dependency", + "DependencyError", + "DistroInfo", + "InstallStrategy", + "MissingDependency", + "_check_in_venv", + "build_strategies", + "check_dependencies", + "check_dependency", + "detect_distro", + "ensure_dependencies", + "find_pipx_venv", + "format_missing_report", + "offer_to_install", + "pipx_venv_site_packages", +] diff --git a/iso_scalpel/app.py b/iso_scalpel/app.py new file mode 100644 index 0000000..31e7a0d --- /dev/null +++ b/iso_scalpel/app.py @@ -0,0 +1,137 @@ +"""QApplication bootstrap and global styling.""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +import os +import sys + +from PySide6.QtGui import QFont +from PySide6.QtWidgets import QApplication + +from . import __app_name__ +from .main_window import MainWindow + +# Clean, modern stylesheet: neutral greys with an emerald accent (no +# indigo/blue), tuned for ISO Scalpel's split-nav interface. +STYLE = """ +QMainWindow, QWidget { background: #f7f7f8; } +QMenuBar { background: #e9eaee; border-bottom: 1px solid #d4d6dc; } +QMenuBar::item { padding: 4px 10px; background: transparent; } +QMenuBar::item:selected { background: #dfe2e8; } +QMenu { background: #ffffff; border: 1px solid #c8ccd2; } +QMenu::item { padding: 5px 22px 5px 22px; } +QMenu::item:selected { background: #d4f1e0; color: #065f46; } +QToolBar { background: #e9eaee; border: 0; border-bottom: 1px solid #d4d6dc; spacing: 2px; padding: 2px; } +QToolBar QToolButton { padding: 4px 6px; border-radius: 4px; } +QToolBar QToolButton:hover { background: #dfe2e8; } +QTreeView { background: #ffffff; alternate-background-color: #f3f4f7; border: 1px solid #d4d6dc; + selection-background-color: #a7e8c5; selection-color: #064e3b; } +QTreeView::item { padding: 2px 0; } +QHeaderView::section { background: #eef0f3; border: 0; border-right: 1px solid #d4d6dc; + border-bottom: 1px solid #d4d6dc; padding: 4px 6px; font-weight: 600; } +QStatusBar { background: #e9eaee; border-top: 1px solid #d4d6dc; } +QStatusBar::item { border: 0; } +QSplitter::handle { background: #d4d6dc; } +QSplitter::handle:horizontal { width: 3px; } +QGroupBox { border: 1px solid #d4d6dc; border-radius: 4px; margin-top: 10px; padding-top: 8px; } +QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; } +QPushButton { padding: 5px 14px; border: 1px solid #b8bdc6; border-radius: 4px; background: #ffffff; } +QPushButton:hover { background: #eef5f0; } +QPushButton:pressed { background: #d4ead9; } +QPushButton:default { border-color: #34a96b; } +QLineEdit, QComboBox, QSpinBox { padding: 3px 6px; border: 1px solid #b8bdc6; border-radius: 4px; background: #fff; } +QComboBox::drop-down { border: 0; width: 18px; } +QComboBox QAbstractItemView { border: 1px solid #b8bdc6; selection-background-color: #a7e8c5; } +QDialogButtonBox QPushButton { min-width: 78px; } +QProgressBar { border: 1px solid #b8bdc6; border-radius: 4px; text-align: center; background: #fff; } +QProgressBar::chunk { background: #34a96b; } +QTabWidget::pane { border: 1px solid #d4d6dc; border-top: 0; } +QTabBar::tab { padding: 4px 10px; background: #eef0f3; border: 1px solid #d4d6dc; border-bottom: 0; + border-top-left-radius: 4px; border-top-right-radius: 4px; margin-right: 2px; } +QTabBar::tab:selected { background: #ffffff; border-bottom: 2px solid #34a96b; } +QTabBar::tab:hover:!selected { background: #e4e8ee; } +QFrame#Breadcrumb { background: #ffffff; border: 1px solid #d4d6dc; border-radius: 4px; } +""" + + +def run(argv: list[str] | None = None, *, debug_layout: bool = False) -> int: + if argv is None: + argv = sys.argv + app = QApplication.instance() or QApplication(argv) + app.setApplicationName(__app_name__) + app.setOrganizationName("ISO Scalpel") + app.setApplicationDisplayName(__app_name__) + app.setStyle("Fusion") + app.setStyleSheet(STYLE) + + # default font + f = QFont() + f.setPointSize(10) + app.setFont(f) + + win = MainWindow() + win.show() + + # Open a file passed on the command line, if any. + for arg in argv[1:]: + if arg.startswith("-"): + continue + if os.path.isfile(arg): + win._open_path(arg) + break + + if debug_layout: + _dump_widget_tree(win) + + return app.exec() + + +def _dump_widget_tree(widget, indent: int = 0) -> None: + """Print the widget tree with visibility + size info. + + Used by ``--debug-layout`` to diagnose rendering issues where a + widget exists but doesn't appear on screen. + """ + prefix = " " * indent + name = widget.__class__.__name__ + obj_name = widget.objectName() or "-" + w = widget.width() + h = widget.height() + vis = widget.isVisible() + min_hint = widget.minimumSizeHint() + print(f"{prefix}{name}({obj_name}) size={w}x{h} visible={vis} " + f"minHint={min_hint.width()}x{min_hint.height()}") + for child in widget.children(): + if child.isWidgetType(): + _dump_widget_tree(child, indent + 1) + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(run()) + + +def run_argv() -> int: + """Console-script entry point used by ``pipx install iso-scalpel``. + + Equivalent to :func:`run`, but reads :data:`sys.argv` directly so it can + be wired up as a ``[project.scripts]`` target in ``pyproject.toml``. + """ + return run(list(sys.argv)) diff --git a/iso_scalpel/config.py b/iso_scalpel/config.py new file mode 100644 index 0000000..c1cd1ee --- /dev/null +++ b/iso_scalpel/config.py @@ -0,0 +1,93 @@ +"""Persistent application settings (recent files, defaults, window state).""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +import json +import os +import sys +from dataclasses import asdict, dataclass, field + + +def _config_dir() -> str: + base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") + return os.path.join(base, "iso-scalpel") + + +def _config_path() -> str: + return os.path.join(_config_dir(), "settings.json") + + +@dataclass +class Settings: + recent_files: list[str] = field(default_factory=list) + last_dir: str = os.path.expanduser("~") + show_hidden: bool = False + confirm_delete: bool = True + default_volume_label: str = "CDROM" + default_interchange_level: int = 1 + default_joliet: int = 3 + default_rock_ridge: str = "1.09" + default_udf: str = "2.60" + default_block_size: int = 2048 + window_width: int = 1100 + window_height: int = 720 + splitter_sizes: list[int] = field(default_factory=lambda: [500, 500]) + + # ------------------------------------------------------------------ + @classmethod + def load(cls) -> Settings: + """Load settings from disk, merging onto a fresh instance. + + A missing file, a permission error, or a corrupt JSON payload yields + the default settings rather than aborting startup. + """ + try: + with open(_config_path(), encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, json.JSONDecodeError): + return cls() + if not isinstance(data, dict): + return cls() + # Merge onto defaults so new fields added in a later release do not + # break a settings file written by an older one. + merged = cls() + for key, value in data.items(): + if hasattr(merged, key): + setattr(merged, key, value) + return merged + + def save(self) -> None: + """Persist settings to disk. Failures are reported on stderr but + never raised: a read-only home directory must not crash the app.""" + try: + os.makedirs(_config_dir(), exist_ok=True) + with open(_config_path(), "w", encoding="utf-8") as fh: + json.dump(asdict(self), fh, indent=2) + except OSError as exc: + sys.stderr.write(f"warning: could not save settings: {exc}\n") + + # ------------------------------------------------------------------ helpers + def add_recent(self, path: str) -> None: + path = os.path.abspath(path) + if path in self.recent_files: + self.recent_files.remove(path) + self.recent_files.insert(0, path) + self.recent_files = self.recent_files[:10] diff --git a/iso_scalpel/dialogs/__init__.py b/iso_scalpel/dialogs/__init__.py new file mode 100644 index 0000000..58f2193 --- /dev/null +++ b/iso_scalpel/dialogs/__init__.py @@ -0,0 +1,20 @@ +"""Dialog subpackage.""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + diff --git a/iso_scalpel/dialogs/__pycache__/__init__.cpython-314.pyc b/iso_scalpel/dialogs/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..d7bc766 Binary files /dev/null and b/iso_scalpel/dialogs/__pycache__/__init__.cpython-314.pyc differ diff --git a/iso_scalpel/dialogs/__pycache__/about_dialog.cpython-314.pyc b/iso_scalpel/dialogs/__pycache__/about_dialog.cpython-314.pyc new file mode 100644 index 0000000..e756942 Binary files /dev/null and b/iso_scalpel/dialogs/__pycache__/about_dialog.cpython-314.pyc differ diff --git a/iso_scalpel/dialogs/__pycache__/boot_dialog.cpython-314.pyc b/iso_scalpel/dialogs/__pycache__/boot_dialog.cpython-314.pyc new file mode 100644 index 0000000..b148959 Binary files /dev/null and b/iso_scalpel/dialogs/__pycache__/boot_dialog.cpython-314.pyc differ diff --git a/iso_scalpel/dialogs/__pycache__/diff_dialog.cpython-314.pyc b/iso_scalpel/dialogs/__pycache__/diff_dialog.cpython-314.pyc new file mode 100644 index 0000000..93bdd78 Binary files /dev/null and b/iso_scalpel/dialogs/__pycache__/diff_dialog.cpython-314.pyc differ diff --git a/iso_scalpel/dialogs/__pycache__/extract_dialog.cpython-314.pyc b/iso_scalpel/dialogs/__pycache__/extract_dialog.cpython-314.pyc new file mode 100644 index 0000000..037a2b7 Binary files /dev/null and b/iso_scalpel/dialogs/__pycache__/extract_dialog.cpython-314.pyc differ diff --git a/iso_scalpel/dialogs/__pycache__/new_iso_dialog.cpython-314.pyc b/iso_scalpel/dialogs/__pycache__/new_iso_dialog.cpython-314.pyc new file mode 100644 index 0000000..1053411 Binary files /dev/null and b/iso_scalpel/dialogs/__pycache__/new_iso_dialog.cpython-314.pyc differ diff --git a/iso_scalpel/dialogs/__pycache__/properties_dialog.cpython-314.pyc b/iso_scalpel/dialogs/__pycache__/properties_dialog.cpython-314.pyc new file mode 100644 index 0000000..19f5ec3 Binary files /dev/null and b/iso_scalpel/dialogs/__pycache__/properties_dialog.cpython-314.pyc differ diff --git a/iso_scalpel/dialogs/__pycache__/settings_dialog.cpython-314.pyc b/iso_scalpel/dialogs/__pycache__/settings_dialog.cpython-314.pyc new file mode 100644 index 0000000..41576a9 Binary files /dev/null and b/iso_scalpel/dialogs/__pycache__/settings_dialog.cpython-314.pyc differ diff --git a/iso_scalpel/dialogs/about_dialog.py b/iso_scalpel/dialogs/about_dialog.py new file mode 100644 index 0000000..9789432 --- /dev/null +++ b/iso_scalpel/dialogs/about_dialog.py @@ -0,0 +1,76 @@ +"""About dialog.""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout + +from .. import __app_name__, __author__, __author_url__, __tagline__, __version__ + + +class AboutDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle(f"About {__app_name__}") + self.setMinimumWidth(440) + + title = QLabel(f"

{__app_name__}

") + title.setTextFormat(Qt.RichText) + ver = QLabel(f"Version {__version__} — {__tagline__}") + ver.setTextFormat(Qt.RichText) + body = QLabel( + "

A disc-image editor built with PySide6 (Qt 6) and " + "pycdlib.

" + "

Supported features:

" + "
    " + "
  • ISO9660 levels 1–3
  • " # noqa: RUF001 -- intentional en dash in UI copy + "
  • Rock Ridge (1.09 / 1.12) — Unix long names & permissions
  • " + "
  • Joliet (1 / 2 / 3) — Microsoft long names
  • " + "
  • UDF 2.50 / 2.60 — Universal Disk Format
  • " + "
  • El Torito boot images (BIOS / EFI)
  • " + "
  • Add / extract / rename / delete files & folders
  • " + "
  • Volume metadata editing
  • " + "
  • Filesystem diff between two images
  • " + "
  • Command-line interface (cli.py)
  • " + "
  • Split-navigation panes with tabs, breadcrumbs & filtering
  • " + "
" + f"

Copyright © 2025 {__author__}
" + f"{__author_url__}

" + "

Licensed under the GNU GPL v2.
" + "ISO Scalpel is an independent project; no source code from any " + "other ISO editing tool was used.

" + ) + body.setTextFormat(Qt.RichText) + body.setWordWrap(True) + body.setOpenExternalLinks(True) + + close = QPushButton("Close") + close.clicked.connect(self.accept) + + layout = QVBoxLayout(self) + layout.addWidget(title) + layout.addWidget(ver) + layout.addWidget(body) + layout.addStretch(1) + row = QHBoxLayout() + row.addStretch(1) + row.addWidget(close) + layout.addLayout(row) diff --git a/iso_scalpel/dialogs/boot_dialog.py b/iso_scalpel/dialogs/boot_dialog.py new file mode 100644 index 0000000..d4175ab --- /dev/null +++ b/iso_scalpel/dialogs/boot_dialog.py @@ -0,0 +1,163 @@ +"""El Torito boot image configuration dialog.""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from ..iso_handler import BootInfo, IsoHandler + + +class BootDialog(QDialog): + """Configure or clear an El Torito boot record.""" + + def __init__(self, handler: IsoHandler, parent=None): + super().__init__(parent) + self.setWindowTitle("Boot Image (El Torito)") + self.setMinimumWidth(480) + self._handler = handler + info = handler.get_boot_info() + + grp = QGroupBox("Boot Configuration") + f = QFormLayout(grp) + + self.bootable = QCheckBox("Bootable") + self.bootable.setChecked(info.bootable) + f.addRow(self.bootable) + + self.boot_file = QLineEdit(info.boot_image_path) + self.boot_file.setPlaceholderText("Path inside ISO, e.g. /BOOT.IMG;1") + browse = QPushButton("Browse host file…") + browse.clicked.connect(self._browse) + row = QHBoxLayout() + row.addWidget(self.boot_file, 1) + row.addWidget(browse) + w = QWidget() + w.setLayout(row) + f.addRow("Boot image:", w) + self._local_file: str | None = None + + self.platform = QComboBox() + self.platform.addItems(["0 — x86 (BIOS)", "1 — PowerPC", "2 — Mac", "0xEF — EFI"]) + f.addRow("Platform ID:", self.platform) + if info.efi: + self.platform.setCurrentIndex(3) + + self.media = QComboBox() + # pycdlib accepts: 'noemul', 'floppy', 'hdemul'. Floppy geometry is + # conveyed via boot_load_size, so we offer the common sizes as a hint. + self._media_items = [ + ("noemul (no emulation)", "noemul"), + ("floppy 1.2 MiB", "floppy"), + ("floppy 1.44 MiB", "floppy"), + ("floppy 2.88 MiB", "floppy"), + ("hdemul (hard disk)", "hdemul"), + ] + for label, _val in self._media_items: + self.media.addItem(label) + cur = {"noemul": 0, "floppy": 1, "hdemul": 4}.get(info.media_name, 0) + self.media.setCurrentIndex(cur) + f.addRow("Media:", self.media) + + self.load_seg = QSpinBox() + self.load_seg.setRange(0, 0xFFFF) + self.load_seg.setValue(info.load_segment or 0x07C0) + self.load_seg.setDisplayIntegerBase(16) + self.load_seg.setPrefix("0x") + f.addRow("Load segment:", self.load_seg) + + self.load_size = QSpinBox() + self.load_size.setRange(0, 100000) + self.load_size.setSpecialValueText("auto (whole file)") + self.load_size.setValue(info.load_size or 0) + f.addRow("Load size (sectors):", self.load_size) + + self.info_table = QCheckBox("Patch boot-info-table (common for ISOLINUX)") + self.info_table.setChecked(info.boot_info_table) + f.addRow(self.info_table) + + # clear button + self.clear_btn = QPushButton("Remove boot record") + self.clear_btn.clicked.connect(self._clear) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self._apply) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addWidget(grp) + layout.addWidget(QLabel("Tip: the boot image file is added to the ISO automatically " + "when you select a host file.")) + layout.addStretch(1) + bl = QHBoxLayout() + bl.addWidget(self.clear_btn) + bl.addStretch(1) + bl.addWidget(buttons) + layout.addLayout(bl) + + # ------------------------------------------------------------------ handlers + def _browse(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, "Select boot image") + if path: + self._local_file = path + self.boot_file.setText(path) + + def _clear(self) -> None: + self._handler.clear_boot() + QMessageBox.information(self, "Boot", "Boot record removed. Save the image to persist.") + self.accept() + + def _apply(self) -> None: + info = self._collect() + try: + self._handler.set_boot(info, boot_file_local=self._local_file) + except Exception as exc: # noqa: BLE001 -- user-facing error boundary + QMessageBox.critical(self, "Boot", f"Failed to set boot record:\n{exc}") + return + self.accept() + + def _collect(self) -> BootInfo: + info = BootInfo() + info.bootable = self.bootable.isChecked() + info.boot_image_path = self.boot_file.text().strip() + info.platform_id = int(self.platform.currentText().split(" ", 1)[0], 0) + info.media_name = self._media_items[self.media.currentIndex()][1] + info.load_segment = self.load_seg.value() + info.load_size = self.load_size.value() or None + info.boot_info_table = self.info_table.isChecked() + info.efi = info.platform_id == 0xEF + info.boot_catalog_path = "BOOT.CAT;1" + return info diff --git a/iso_scalpel/dialogs/diff_dialog.py b/iso_scalpel/dialogs/diff_dialog.py new file mode 100644 index 0000000..b75560f --- /dev/null +++ b/iso_scalpel/dialogs/diff_dialog.py @@ -0,0 +1,233 @@ +"""GUI diff viewer dialog. + +Opens two ISO images and displays their filesystem differences in a +unified tree with status indicators. Shows only the filesystem diff +(which entries were added / removed / modified) — not file-content +diffs. +""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +import os + +from PySide6.QtGui import QColor, QFont +from PySide6.QtWidgets import ( + QApplication, + QCheckBox, + QComboBox, + QDialog, + QFileDialog, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +from ..diff import DiffEntry, DiffResult, DiffStatus, diff_images +from ..iso_handler import IsoHandler +from ..iso_record import NAME_TYPE_LABELS, NameType, _human_size + +# Status → (label, foreground colour) +_STATUS_STYLE = { + DiffStatus.SAME: ("=", QColor("#6b7280")), # grey + DiffStatus.MODIFIED: ("M", QColor("#b45309")), # amber + DiffStatus.ADDED: ("+", QColor("#047857")), # emerald + DiffStatus.REMOVED: ("-", QColor("#b91c1c")), # red +} + + +class DiffDialog(QDialog): + """Compare two ISO images' filesystems.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Compare Images") + self.setMinimumSize(820, 520) + self._ha = IsoHandler() + self._hb = IsoHandler() + + # --- file pickers ------------------------------------------------ + picker_group = QGroupBox("Images") + pf = QFormLayout(picker_group) + self._a_edit = QLineEdit() + self._a_edit.setPlaceholderText("Image A (the 'from')") + a_browse = QPushButton("Browse…") + a_browse.clicked.connect(lambda: self._pick_file(self._a_edit, "Image A")) + a_row = QHBoxLayout() + a_row.addWidget(self._a_edit, 1) + a_row.addWidget(a_browse) + pf.addRow("A:", _wrap(a_row)) + + self._b_edit = QLineEdit() + self._b_edit.setPlaceholderText("Image B (the 'to')") + b_browse = QPushButton("Browse…") + b_browse.clicked.connect(lambda: self._pick_file(self._b_edit, "Image B")) + b_row = QHBoxLayout() + b_row.addWidget(self._b_edit, 1) + b_row.addWidget(b_browse) + pf.addRow("B:", _wrap(b_row)) + + self._view = QComboBox() + self._view.addItem("Auto (richest common)", None) + for nt in (NameType.UDF, NameType.ROCK_RIDGE, NameType.JOLIET, NameType.ISO9660): + self._view.addItem(NAME_TYPE_LABELS[nt], nt) + pf.addRow("Compare in:", self._view) + + self._show_all = QCheckBox("Show unchanged entries too") + pf.addRow(self._show_all) + + compare_btn = QPushButton("Compare") + compare_btn.setDefault(True) + compare_btn.clicked.connect(self._do_compare) + pf.addRow(compare_btn) + + # --- summary label ----------------------------------------------- + self._summary = QLabel("Pick two images and click Compare.") + self._summary.setStyleSheet("padding:4px; color:#444;") + + # --- diff tree --------------------------------------------------- + self._tree = QTreeWidget() + self._tree.setColumnCount(6) + self._tree.setHeaderLabels(["", "Path", "Size (A)", "Size (B)", "Date (A)", "Date (B)"]) + self._tree.setAlternatingRowColors(True) + self._tree.setUniformRowHeights(True) + self._tree.setRootIsDecorated(False) + self._tree.setColumnWidth(0, 32) + self._tree.setColumnWidth(1, 360) + self._tree.header().setStretchLastSection(False) + + # --- buttons ----------------------------------------------------- + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.accept) + export_btn = QPushButton("Copy to clipboard") + export_btn.clicked.connect(self._copy_text) + + btn_row = QHBoxLayout() + btn_row.addWidget(export_btn) + btn_row.addStretch(1) + btn_row.addWidget(close_btn) + + layout = QVBoxLayout(self) + layout.addWidget(picker_group) + layout.addWidget(self._summary) + layout.addWidget(self._tree, 1) + layout.addLayout(btn_row) + + # ------------------------------------------------------------------ helpers + def _pick_file(self, edit: QLineEdit, title: str) -> None: + path, _ = QFileDialog.getOpenFileName( + self, title, "", "ISO images (*.iso *.bin);;All files (*)") + if path: + edit.setText(path) + + def set_images(self, a: str, b: str) -> None: + """Pre-fill the two image paths (used when launched from the menu).""" + self._a_edit.setText(a or "") + self._b_edit.setText(b or "") + if a and b: + self._do_compare() + + # ------------------------------------------------------------------ compare + def _do_compare(self) -> None: + a_path = self._a_edit.text().strip() + b_path = self._b_edit.text().strip() + if not a_path or not b_path: + self._summary.setText("Pick both images first.") + return + if a_path == b_path: + self._summary.setText("Pick two different images.") + return + try: + self._ha.open(a_path) + self._hb.open(b_path) + except Exception as exc: # noqa: BLE001 -- user-facing error boundary + self._summary.setText(f"Error: {exc}") + return + nt = self._view.currentData() + try: + result = diff_images(self._ha, self._hb, name_type=nt) + except Exception as exc: # noqa: BLE001 -- user-facing error boundary + self._summary.setText(f"Diff failed: {exc}") + return + self._populate(result) + self._summary.setText( + f"{os.path.basename(a_path)} → {os.path.basename(b_path)}: " + f"+{result.added_count} " + f"-{result.removed_count} " + f"M {result.modified_count} " + f"= {result.same_count}" + ) + + def _populate(self, result: DiffResult) -> None: + self._tree.clear() + show_all = self._show_all.isChecked() + for e in result.entries: + if not show_all and e.status == DiffStatus.SAME: + continue + self._add_entry(e) + + def _add_entry(self, e: DiffEntry) -> None: + label, color = _STATUS_STYLE[e.status] + name = e.path + if e.is_dir: + name += "/" + item = QTreeWidgetItem([label, name, "", "", "", ""]) + item.setForeground(0, color) + f = QFont() + f.setBold(True) + item.setFont(0, f) + item.setForeground(1, color if e.status != DiffStatus.SAME else QColor("#374151")) + if e.a: + item.setText(2, _human_size(e.a.size) if not e.a.is_dir else "") + item.setText(4, e.a.date_label) + if e.b: + item.setText(3, _human_size(e.b.size) if not e.b.is_dir else "") + item.setText(5, e.b.date_label) + self._tree.addTopLevelItem(item) + + # ------------------------------------------------------------------ export + def _copy_text(self) -> None: + from ..diff import format_diff_text + if not self._ha.is_open or not self._hb.is_open: + return + result = diff_images(self._ha, self._hb) + QApplication.clipboard().setText(format_diff_text(result)) + self._summary.setText("Diff copied to clipboard.") + + # ------------------------------------------------------------------ cleanup + def closeEvent(self, event): + if self._ha.is_open: + self._ha.close() + if self._hb.is_open: + self._hb.close() + super().closeEvent(event) + + +def _wrap(layout) -> QWidget: + w = QWidget() + w.setLayout(layout) + return w diff --git a/iso_scalpel/dialogs/extract_dialog.py b/iso_scalpel/dialogs/extract_dialog.py new file mode 100644 index 0000000..d279a35 --- /dev/null +++ b/iso_scalpel/dialogs/extract_dialog.py @@ -0,0 +1,77 @@ +"""Extract target chooser dialog.""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +import os + +from PySide6.QtWidgets import ( + QCheckBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, +) + + +class ExtractDialog(QDialog): + """Ask the user where to extract one or more entries.""" + + def __init__(self, items: list[tuple[str, bool]], default_dir: str, parent=None): + super().__init__(parent) + self.setWindowTitle("Extract") + self.setMinimumWidth(480) + self._items = items + + layout = QVBoxLayout(self) + count = len(items) + names = ", ".join(os.path.basename(p) for p, _ in items[:3]) + if count > 3: + names += f" (+{count - 3} more)" + layout.addWidget(QLabel(f"Extract {count} item(s): {names}")) + + self.dest = QLineEdit(default_dir) + browse = QPushButton("Browse…") + browse.clicked.connect(self._browse) + row = QHBoxLayout() + row.addWidget(self.dest, 1) + row.addWidget(browse) + layout.addLayout(row) + + self.preserve = QCheckBox("Preserve directory structure") + self.preserve.setChecked(True) + layout.addWidget(self.preserve) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _browse(self) -> None: + d = QFileDialog.getExistingDirectory(self, "Extract to", self.dest.text()) + if d: + self.dest.setText(d) + + def destination(self) -> str: + return self.dest.text().strip() diff --git a/iso_scalpel/dialogs/new_iso_dialog.py b/iso_scalpel/dialogs/new_iso_dialog.py new file mode 100644 index 0000000..687df2f --- /dev/null +++ b/iso_scalpel/dialogs/new_iso_dialog.py @@ -0,0 +1,142 @@ +"""New ISO image options dialog.""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFormLayout, + QGridLayout, + QGroupBox, + QLabel, + QLineEdit, + QVBoxLayout, +) + +from ..iso_handler import NewIsoOptions + + +class NewIsoDialog(QDialog): + """Collect the options used to create a fresh image.""" + + def __init__(self, settings, parent=None): + super().__init__(parent) + self.setWindowTitle("New ISO Image") + self.setMinimumWidth(460) + self._settings = settings + + # --- volume identity ---------------------------------------------- + vol_group = QGroupBox("Volume Identity") + vol_form = QFormLayout(vol_group) + self.volume_label = QLineEdit(settings.default_volume_label) + self.volume_label.setMaxLength(32) + vol_form.addRow("Volume label:", self.volume_label) + + self.publisher = QLineEdit() + vol_form.addRow("Publisher:", self.publisher) + self.preparer = QLineEdit() + vol_form.addRow("Data preparer:", self.preparer) + self.application = QLineEdit("ISO Scalpel") + vol_form.addRow("Application:", self.application) + self.system_id = QLineEdit() + vol_form.addRow("System ID:", self.system_id) + self.volume_set_id = QLineEdit(" ") + vol_form.addRow("Volume set ID:", self.volume_set_id) + + # --- extensions ---------------------------------------------------- + ext_group = QGroupBox("Extensions") + ext_grid = QGridLayout(ext_group) + + ext_grid.addWidget(QLabel("ISO9660 interchange level:"), 0, 0) + self.interchange = QComboBox() + self.interchange.addItems(["1 (8.3 names)", "2 (32 chars)", "3 (32 chars, multi-extent)"]) + self.interchange.setCurrentIndex(max(0, settings.default_interchange_level - 1)) + ext_grid.addWidget(self.interchange, 0, 1) + + self.joliet = QCheckBox("Joliet (Microsoft long names, Unicode)") + self.joliet.setChecked(bool(settings.default_joliet)) + ext_grid.addWidget(self.joliet, 1, 0, 1, 2) + + self.joliet_level = QComboBox() + self.joliet_level.addItems(["Level 1", "Level 2", "Level 3"]) + self.joliet_level.setCurrentIndex((settings.default_joliet or 3) - 1) + ext_grid.addWidget(QLabel("Joliet level:"), 2, 0) + ext_grid.addWidget(self.joliet_level, 2, 1) + + self.rock_ridge = QCheckBox("Rock Ridge (Unix long names + permissions)") + self.rock_ridge.setChecked(bool(settings.default_rock_ridge)) + ext_grid.addWidget(self.rock_ridge, 3, 0, 1, 2) + + self.rr_version = QComboBox() + self.rr_version.addItems(["1.09", "1.12"]) + self.rr_version.setCurrentText(settings.default_rock_ridge or "1.09") + ext_grid.addWidget(QLabel("Rock Ridge version:"), 4, 0) + ext_grid.addWidget(self.rr_version, 4, 1) + + self.udf = QCheckBox("UDF (Universal Disk Format — new in this port)") + self.udf.setChecked(bool(settings.default_udf)) + ext_grid.addWidget(self.udf, 5, 0, 1, 2) + + self.udf_version = QComboBox() + self.udf_version.addItems(["2.50", "2.60"]) + self.udf_version.setCurrentText(settings.default_udf or "2.60") + ext_grid.addWidget(QLabel("UDF version:"), 6, 0) + ext_grid.addWidget(self.udf_version, 6, 1) + + self.xa = QCheckBox("XA (Extended Attributes)") + ext_grid.addWidget(self.xa, 7, 0, 1, 2) + + # --- block size ---------------------------------------------------- + ext_grid.addWidget(QLabel("Logical block size:"), 8, 0) + self.block_size = QComboBox() + self.block_size.addItems(["2048", "4096", "8192"]) + self.block_size.setCurrentText(str(settings.default_block_size)) + ext_grid.addWidget(self.block_size, 8, 1) + + # --- buttons ------------------------------------------------------- + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addWidget(vol_group) + layout.addWidget(ext_group) + layout.addStretch(1) + layout.addWidget(buttons) + + # ------------------------------------------------------------------ result + def options(self) -> NewIsoOptions: + opts = NewIsoOptions() + opts.volume_label = self.volume_label.text().strip() or "CDROM" + opts.interchange_level = self.interchange.currentIndex() + 1 + opts.block_size = int(self.block_size.currentText()) + opts.joliet = (self.joliet_level.currentIndex() + 1) if self.joliet.isChecked() else None + opts.rock_ridge = self.rr_version.currentText() if self.rock_ridge.isChecked() else None + opts.udf = self.udf_version.currentText() if self.udf.isChecked() else None + opts.publisher = self.publisher.text() + opts.preparer = self.preparer.text() + opts.application = self.application.text() or "ISO Scalpel" + opts.system_id = self.system_id.text() + opts.volume_set_id = self.volume_set_id.text() or " " + opts.xa = self.xa.isChecked() + return opts diff --git a/iso_scalpel/dialogs/properties_dialog.py b/iso_scalpel/dialogs/properties_dialog.py new file mode 100644 index 0000000..18b2645 --- /dev/null +++ b/iso_scalpel/dialogs/properties_dialog.py @@ -0,0 +1,100 @@ +"""ISO volume properties dialog (view + edit metadata).""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +from PySide6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, +) + +from ..iso_handler import IsoHandler +from ..iso_record import _human_size + + +class PropertiesDialog(QDialog): + """Display and edit volume descriptors of the open image.""" + + def __init__(self, handler: IsoHandler, parent=None): + super().__init__(parent) + self.setWindowTitle("ISO Properties") + self.setMinimumWidth(520) + self._handler = handler + + props = handler.get_properties() + + # --- editable identity -------------------------------------------- + id_group = QGroupBox("Volume Identity") + f = QFormLayout(id_group) + self.volume_label = QLineEdit(props.volume_label) + self.volume_label.setMaxLength(32) + f.addRow("Volume label:", self.volume_label) + self.publisher = QLineEdit(props.publisher) + f.addRow("Publisher:", self.publisher) + self.preparer = QLineEdit(props.preparer) + f.addRow("Data preparer:", self.preparer) + self.application = QLineEdit(props.application) + f.addRow("Application:", self.application) + self.system_id = QLineEdit(props.system_id) + f.addRow("System ID:", self.system_id) + self.volume_set_id = QLineEdit(props.volume_set_id) + f.addRow("Volume set ID:", self.volume_set_id) + + # --- read-only image info ----------------------------------------- + info_group = QGroupBox("Image Information") + info = QFormLayout(info_group) + info.addRow("File:", QLabel(handler.filename or "(unsaved)")) + info.addRow("Image size:", QLabel(_human_size(props.total_size))) + info.addRow("Block size:", QLabel(f"{props.block_size} bytes")) + info.addRow("ISO9660 level:", QLabel(str(props.interchange_level))) + info.addRow("Extensions:", QLabel(", ".join(props.extensions) or "(none)")) + + # --- buttons ------------------------------------------------------- + self.apply_label_btn = QPushButton("Apply Label") + self.apply_label_btn.setToolTip( + "Write the volume label back to the image (does not save to disk)") + buttons = QDialogButtonBox(QDialogButtonBox.Close) + buttons.rejected.connect(self.reject) + buttons.accepted.connect(self.accept) + self.apply_label_btn.clicked.connect(self._apply_label) + + bl = QHBoxLayout() + bl.addWidget(self.apply_label_btn) + bl.addStretch(1) + bl.addWidget(buttons) + + layout = QVBoxLayout(self) + layout.addWidget(id_group) + layout.addWidget(info_group) + layout.addStretch(1) + layout.addLayout(bl) + + def _apply_label(self) -> None: + self._handler.set_volume_label(self.volume_label.text()) + self.publisher.setEnabled(False) + self.preparer.setEnabled(False) + self.application.setEnabled(False) diff --git a/iso_scalpel/dialogs/settings_dialog.py b/iso_scalpel/dialogs/settings_dialog.py new file mode 100644 index 0000000..94b25cb --- /dev/null +++ b/iso_scalpel/dialogs/settings_dialog.py @@ -0,0 +1,91 @@ +"""Application settings dialog.""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFormLayout, + QGroupBox, + QLineEdit, + QVBoxLayout, +) + +from ..config import Settings + + +class SettingsDialog(QDialog): + """Edit persistent application preferences.""" + + def __init__(self, settings: Settings, parent=None): + super().__init__(parent) + self.setWindowTitle("Preferences") + self.setMinimumWidth(440) + self._settings = settings + + ui = QGroupBox("Interface") + uf = QFormLayout(ui) + self.show_hidden = QCheckBox("Show hidden files in filesystem pane") + self.show_hidden.setChecked(settings.show_hidden) + uf.addRow(self.show_hidden) + self.confirm_delete = QCheckBox("Confirm before deleting entries") + self.confirm_delete.setChecked(settings.confirm_delete) + uf.addRow(self.confirm_delete) + + defaults = QGroupBox("New Image Defaults") + df = QFormLayout(defaults) + self.d_label = QLineEdit(settings.default_volume_label) + df.addRow("Volume label:", self.d_label) + self.d_level = QComboBox() + self.d_level.addItems(["1", "2", "3"]) + self.d_level.setCurrentText(str(settings.default_interchange_level)) + df.addRow("ISO9660 level:", self.d_level) + self.d_joliet = QCheckBox("Joliet") + self.d_joliet.setChecked(bool(settings.default_joliet)) + df.addRow(self.d_joliet) + self.d_rr = QCheckBox("Rock Ridge") + self.d_rr.setChecked(bool(settings.default_rock_ridge)) + df.addRow(self.d_rr) + self.d_udf = QCheckBox("UDF (new)") + self.d_udf.setChecked(bool(settings.default_udf)) + df.addRow(self.d_udf) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addWidget(ui) + layout.addWidget(defaults) + layout.addStretch(1) + layout.addWidget(buttons) + + def apply_to(self, settings: Settings) -> Settings: + settings.show_hidden = self.show_hidden.isChecked() + settings.confirm_delete = self.confirm_delete.isChecked() + settings.default_volume_label = self.d_label.text() or "CDROM" + settings.default_interchange_level = int(self.d_level.currentText()) + settings.default_joliet = 3 if self.d_joliet.isChecked() else 0 + settings.default_rock_ridge = "1.09" if self.d_rr.isChecked() else "" + settings.default_udf = "2.60" if self.d_udf.isChecked() else "" + return settings diff --git a/iso_scalpel/diff.py b/iso_scalpel/diff.py new file mode 100644 index 0000000..c4e7d16 --- /dev/null +++ b/iso_scalpel/diff.py @@ -0,0 +1,225 @@ +"""Filesystem diff engine for ISO images. + +Compares two ISO images at the *filesystem* level — which entries exist +in one but not the other, which have changed size or modification time — +without comparing file *contents*. Like :mod:`iso_handler`, this module +is GUI-agnostic: it depends only on pycdlib (via IsoHandler) and the +standard library, so it can be used from the CLI or a test harness with +no display attached. + +The diff is computed in the *default* naming convention of image A (or +an explicit :class:`~iso_scalpel.iso_record.NameType`). Entries that +exist in both images are matched by path within that convention. +""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +import enum +from dataclasses import dataclass, field + +from .iso_handler import IsoHandler +from .iso_record import IsoRecord, NameType + + +class DiffStatus(enum.Enum): + """How an entry differs between the two images.""" + + SAME = "same" # present in both, same size + MODIFIED = "modified" # present in both, different size or date + ADDED = "added" # only in B (the second image) + REMOVED = "removed" # only in A (the first image) + + +@dataclass +class DiffEntry: + """A single row in the diff output.""" + + status: DiffStatus + path: str # path relative to root (always "/"-prefixed) + is_dir: bool + a: IsoRecord | None # record from image A (None if ADDED) + b: IsoRecord | None # record from image B (None if REMOVED) + + @property + def status_label(self) -> str: + return { + DiffStatus.SAME: "=", + DiffStatus.MODIFIED: "M", + DiffStatus.ADDED: "+", + DiffStatus.REMOVED: "-", + }[self.status] + + @property + def a_size(self) -> int: + return self.a.size if self.a else 0 + + @property + def b_size(self) -> int: + return self.b.size if self.b else 0 + + @property + def a_date(self) -> str: + return self.a.date_label if self.a else "" + + @property + def b_date(self) -> str: + return self.b.date_label if self.b else "" + + +@dataclass +class DiffResult: + """The full result of comparing two images.""" + + a_file: str | None # on-disk filename of A + b_file: str | None # on-disk filename of B + entries: list[DiffEntry] = field(default_factory=list) + + @property + def same_count(self) -> int: + return sum(1 for e in self.entries if e.status == DiffStatus.SAME) + + @property + def modified_count(self) -> int: + return sum(1 for e in self.entries if e.status == DiffStatus.MODIFIED) + + @property + def added_count(self) -> int: + return sum(1 for e in self.entries if e.status == DiffStatus.ADDED) + + @property + def removed_count(self) -> int: + return sum(1 for e in self.entries if e.status == DiffStatus.REMOVED) + + @property + def is_identical(self) -> bool: + return not self.modified_count and not self.added_count and not self.removed_count + + +def diff_images(a: IsoHandler, b: IsoHandler, + name_type: NameType | None = None) -> DiffResult: + """Compare two open images and return a :class:`DiffResult`. + + ``name_type`` selects which naming convention to compare in. If + ``None``, the default convention of image A is used (falling back to + ISO9660 if A has no UDF/RR/Joliet). + """ + if not a.is_open or not b.is_open: + raise ValueError("Both images must be open") + + if name_type is None: + # Use the richest convention common to both images. + a_types = set(a.available_name_types()) + b_types = set(b.available_name_types()) + common = a_types & b_types + for preferred in (NameType.UDF, NameType.ROCK_RIDGE, + NameType.JOLIET, NameType.ISO9660): + if preferred in common: + name_type = preferred + break + if name_type is None: + name_type = NameType.ISO9660 + + tree_a = _walk(a, "/", name_type) + tree_b = _walk(b, "/", name_type) + + entries: list[DiffEntry] = [] + all_paths = sorted(set(tree_a) | set(tree_b)) + + for path in all_paths: + ra = tree_a.get(path) + rb = tree_b.get(path) + if ra and rb: + if (ra.is_dir and rb.is_dir) or (ra.size == rb.size and ra.modified == rb.modified): + status = DiffStatus.SAME + else: + status = DiffStatus.MODIFIED + elif rb and not ra: + status = DiffStatus.ADDED + else: + status = DiffStatus.REMOVED + entries.append(DiffEntry(status=status, path=path, + is_dir=(ra or rb).is_dir, a=ra, b=rb)) + + return DiffResult(a_file=a.filename, b_file=b.filename, entries=entries) + + +def _walk(handler: IsoHandler, root: str, + name_type: NameType) -> dict[str, IsoRecord]: + """Recursively walk an image, returning ``{path: IsoRecord}``.""" + out: dict[str, IsoRecord] = {} + _walk_into(handler, root, name_type, out) + return out + + +def _walk_into(handler: IsoHandler, path: str, name_type: NameType, + out: dict[str, IsoRecord]) -> None: + try: + records = handler.list_dir(path, name_type) + except (OSError, ValueError, KeyError): + # A directory that cannot be listed is skipped; its descendants are + # simply absent from the diff rather than aborting the whole walk. + return + for rec in records: + out[rec.path] = rec + if rec.is_dir: + _walk_into(handler, rec.path, name_type, out) + + +def format_diff_text(result: DiffResult) -> str: + """Render a :class:`DiffResult` as plain text (for the CLI).""" + lines: list[str] = [] + a_name = result.a_file or "A" + b_name = result.b_file or "B" + lines.append(f"--- {a_name}") + lines.append(f"+++ {b_name}") + lines.append("") + if result.is_identical: + lines.append("Filesystems are identical.") + return "\n".join(lines) + + lines.append(f"{result.added_count} added, {result.removed_count} removed, " + f"{result.modified_count} modified, {result.same_count} unchanged") + lines.append("") + + # Each row formats one diff entry. ``SAME`` entries are skipped here and + # summarised by count at the end, so the formatter is a flat lookup over + # the three "interesting" statuses. + def _line_for(e: DiffEntry) -> str | None: + mark = e.status_label + tag = "dir " if e.is_dir else "" + if e.status == DiffStatus.MODIFIED: + return f" {mark} {e.path} ({e.a_size} -> {e.b_size} bytes)" + if e.status == DiffStatus.ADDED: + return f" {mark} {tag}{e.path} ({e.b_size} bytes)" + if e.status == DiffStatus.REMOVED: + return f" {mark} {tag}{e.path} ({e.a_size} bytes)" + return None + + changed = [e for e in result.entries if e.status != DiffStatus.SAME] + for e in changed: + line = _line_for(e) + if line is not None: + lines.append(line) + + if result.same_count: + lines.append("") + lines.append(f"({result.same_count} entries unchanged)") + return "\n".join(lines) diff --git a/iso_scalpel/iso_handler.py b/iso_scalpel/iso_handler.py new file mode 100644 index 0000000..4ea19ce --- /dev/null +++ b/iso_scalpel/iso_handler.py @@ -0,0 +1,838 @@ +"""High-level wrapper around :mod:`pycdlib`. + +This module is the bridge between the GUI and the pycdlib library. It +exposes a single :class:`IsoHandler` that knows how to: + +* create / open / save ISO images (ISO9660 levels 1-3 with optional + Joliet, Rock Ridge and UDF extensions), +* list directory contents in any of the supported naming conventions, +* add files and directories from the host filesystem into the image + (transparently writing them into every enabled convention), +* remove and rename entries, +* extract files and whole directory trees back to the host, +* inspect and edit volume-descriptor metadata (label, publisher, ...), +* inspect and configure El Torito boot records. + +The handler is GUI-agnostic: it never imports PySide6, so it can be +unit-tested headlessly (and is, via the dev harness in ``tests``). +""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +import contextlib +import os +import sys +from collections.abc import Callable +from dataclasses import dataclass, field + +import pycdlib +from pycdlib.pycdlibexception import PyCdlibException + +from .iso_record import IsoRecord, NameType, _decode + +# Exception tuple covering every failure mode a pycdlib operation can raise +# that we treat as "the image is malformed / the entry is missing" rather +# than a programming error. Used by the defensive wrappers below so we +# never silence a real bug (e.g. AttributeError, TypeError) along with the +# expected I/O and parse errors. +_PYCDLIB_ERRORS: tuple[type[BaseException], ...] = ( + PyCdlibException, + OSError, + ValueError, + KeyError, +) + + +# -------------------------------------------------------------------------- +# Public dataclasses describing image metadata +# -------------------------------------------------------------------------- +@dataclass +class NewIsoOptions: + """Options for :meth:`IsoHandler.new`.""" + + volume_label: str = "CDROM" + interchange_level: int = 1 # 1, 2 or 3 + block_size: int = 2048 + joliet: int | None = None # None / 1 / 2 / 3 + rock_ridge: str | None = None # None / '1.09' / '1.12' + udf: str | None = None # None / '2.50' / '2.60' + publisher: str = "" + preparer: str = "" + application: str = "ISO Scalpel" + system_id: str = "" + volume_set_id: str = " " + copyright_file: str = "" + abstract_file: str = "" + bibliographic_file: str = "" + xa: bool = False + + +@dataclass +class VolumeProperties: + """Snapshot of the volume descriptors for the properties dialog.""" + + volume_label: str = "" + system_id: str = "" + volume_set_id: str = "" + publisher: str = "" + preparer: str = "" + application: str = "" + copyright_file: str = "" + abstract_file: str = "" + bibliographic_file: str = "" + interchange_level: int = 1 + block_size: int = 2048 + has_joliet: bool = False + has_rock_ridge: bool = False + has_udf: bool = False + joliet_level: int | None = None + rock_ridge_version: str | None = None + udf_version: str | None = None + total_size: int = 0 + + @property + def extensions(self) -> list[str]: + """Human-readable extension labels (e.g. ``["Joliet 3", "UDF 2.60"]``). + + Centralised so the CLI, the Properties dialog, and the status bar all + report the same string instead of each re-implementing the if-chain. + """ + rows = ( + (self.has_joliet, f"Joliet {self.joliet_level or ''}".strip()), + (self.has_rock_ridge, f"Rock Ridge {self.rock_ridge_version or ''}".strip()), + (self.has_udf, f"UDF {self.udf_version or ''}".strip()), + ) + return [label for enabled, label in rows if enabled] + + +@dataclass +class BootInfo: + """El Torito boot configuration.""" + + bootable: bool = True + boot_image_path: str = "" # path inside the ISO of the boot file + boot_catalog_path: str = "BOOT.CAT;1" + platform_id: int = 0 # 0=x86, 1=PowerPC, 2=Mac, 0xEF=EFI + media_name: str = "noemul" # noemul / 1200 / 1440 / 2880 / harddisk + load_size: int | None = None # sectors; None = auto (whole file) + load_segment: int = 0x07C0 + boot_info_table: bool = False + efi: bool = False + enabled: bool = False + + +# -------------------------------------------------------------------------- +# Internal directory tree +# -------------------------------------------------------------------------- +@dataclass +class _DirNode: + """An in-memory directory node tracking its path in every convention. + + pycdlib requires the *full path* in each enabled convention when adding + an entry (e.g. both ``iso_path`` and ``joliet_path``). Because the + spelling of a directory name differs between conventions (ISO9660 is + upper-case 8.3, Joliet/UDF preserve case), we cannot derive one from + the other reliably. Instead we remember, per directory, the exact path + in each convention so that subsequent add operations are exact. + """ + + name: str # display name (primary convention) + iso_path: str # always set (ISO9660 path) + joliet_path: str | None = None + udf_path: str | None = None + rr_name: str | None = None # rock-ridge *relative* name + children: dict = field(default_factory=dict) # name -> _DirNode + loaded: bool = False + + +# -------------------------------------------------------------------------- +# Name mangling helpers +# -------------------------------------------------------------------------- +_ISO9660_CHARS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_") + +# Map a NameType to the pycdlib keyword that selects its directory tree. +# Rock Ridge has no dedicated kwarg -- it lives on the ISO9660 tree and is +# disambiguated by ``rr_name`` on add operations, so navigation falls +# through to ``iso_path`` like plain ISO9660. +_PATH_KWARG: dict[NameType, str] = { + NameType.ISO9660: "iso_path", + NameType.JOLIET: "joliet_path", + NameType.UDF: "udf_path", + NameType.ROCK_RIDGE: "iso_path", +} + + +def mangle_iso9660_name(name: str, is_dir: bool = False, interchange_level: int = 1) -> str: + """Convert an arbitrary filename into a valid ISO9660 identifier. + + Level 1 enforces 8.3 with a restricted character set; levels 2 and 3 + allow longer names (30 chars) but keep the same charset and upper case. + A ``;1`` version suffix is appended to file identifiers. + """ + base, ext = os.path.splitext(name) + base = base.upper() + ext = ext.upper().lstrip(".") + + # sanitise charset + base = "".join(c if c in _ISO9660_CHARS else "_" for c in base) + ext = "".join(c if c in _ISO9660_CHARS else "_" for c in ext) + + if interchange_level <= 1: + base = base[:8] or "_" + ext = ext[:3] + else: + base = base[:30] or "_" + ext = ext[:30] + + if is_dir: + return base + if ext: + return f"{base}.{ext};1" + return f"{base};1" + + +def _safe_join(parent: str, child: str) -> str: + """Join an ISO-style path (always absolute, '/' separated).""" + if parent == "/": + return f"/{child}" + return f"{parent}/{child}" + + +# -------------------------------------------------------------------------- +# Main handler +# -------------------------------------------------------------------------- +class IsoHandler: + """Stateful wrapper around a single :class:`pycdlib.PyCdlib` instance.""" + + def __init__(self) -> None: + self._iso: pycdlib.PyCdlib | None = None + self._path: str | None = None # on-disk filename + self._dirty: bool = False + self._options: NewIsoOptions = NewIsoOptions() + self._boot: BootInfo = BootInfo() + self._tree: _DirNode | None = None + # progress callback: (op:str, current:int, total:int) -> None + self.progress_cb: Callable[[str, int, int], None] | None = None + self._cancel = False + + # ------------------------------------------------------------------ state + @property + def is_open(self) -> bool: + return self._iso is not None + + @property + def is_dirty(self) -> bool: + return self._dirty + + @property + def filename(self) -> str | None: + return self._path + + @property + def has_joliet(self) -> bool: + return bool(self._iso and self._iso.has_joliet()) + + @property + def has_rock_ridge(self) -> bool: + return bool(self._iso and self._iso.has_rock_ridge()) + + @property + def has_udf(self) -> bool: + return bool(self._iso and self._iso.has_udf()) + + def cancel(self) -> None: + """Request cancellation of a long-running operation.""" + self._cancel = True + + def reset_cancel(self) -> None: + self._cancel = False + + # ------------------------------------------------------------------ create + def new(self, options: NewIsoOptions) -> None: + """Create a fresh, empty ISO image.""" + if self._iso is not None: + self.close() + iso = pycdlib.PyCdlib() + iso.new( + interchange_level=options.interchange_level, + sys_ident=options.system_id, + vol_ident=options.volume_label or "CDROM", + set_size=1, + seqnum=1, + log_block_size=options.block_size, + vol_set_ident=options.volume_set_id or " ", + pub_ident_str=options.publisher, + preparer_ident_str=options.preparer, + app_ident_str=options.application or "ISO Scalpel", + copyright_file=options.copyright_file, + abstract_file=options.abstract_file, + bibli_file=options.bibliographic_file, + vol_expire_date=None, + app_use="", + joliet=options.joliet, + rock_ridge=options.rock_ridge, + xa=options.xa, + udf=options.udf, + ) + self._iso = iso + self._options = options + self._path = None + self._dirty = True + self._boot = BootInfo() + self._tree = _DirNode(name="/", iso_path="/", joliet_path="/", udf_path="/", rr_name=None) + self._tree.loaded = True + + # ------------------------------------------------------------------ open + def open(self, filename: str) -> None: + """Open an existing ISO image from ``filename``.""" + if self._iso is not None: + self.close() + iso = pycdlib.PyCdlib() + iso.open(filename, mode="rb") + self._iso = iso + self._path = os.path.abspath(filename) + self._dirty = False + self._options = NewIsoOptions(volume_label=self._read_vol_ident() or "CDROM") + self._boot = self._read_boot_info() + # Build a fresh (lazy) tree rooted at "/". + self._tree = _DirNode( + name="/", + iso_path="/", + joliet_path="/" if self.has_joliet else None, + udf_path="/" if self.has_udf else None, + ) + self._tree.loaded = False + + # ------------------------------------------------------------------ save + def save(self, filename: str | None = None) -> None: + """Write the image to ``filename`` (or to the previously-opened file).""" + if self._iso is None: + raise RuntimeError("No image is open") + target = filename or self._path + if not target: + raise ValueError("No filename supplied") + self._iso.write(target, progress_cb=self._pycdlib_progress) + self._path = os.path.abspath(target) + self._dirty = False + + # ------------------------------------------------------------------ close + def close(self) -> None: + if self._iso is not None: + try: + self._iso.close() + except _PYCDLIB_ERRORS as exc: + # The image handle is discarded regardless; log the close + # failure so a corrupt write surfaces instead of vanishing. + sys.stderr.write(f"warning: pycdlib close failed: {exc}\n") + self._iso = None + self._path = None + self._dirty = False + self._tree = None + self._boot = BootInfo() + + # ------------------------------------------------------------------ nav + # Conventions are always probed richest-first so the UI lists UDF before + # Rock Ridge before Joliet before plain ISO9660. A single ordered table + # drives both available_name_types() and default_name_type(). + _NAME_TYPE_PROBES: tuple[tuple[NameType, str], ...] = ( + (NameType.UDF, "has_udf"), + (NameType.ROCK_RIDGE, "has_rock_ridge"), + (NameType.JOLIET, "has_joliet"), + ) + + def available_name_types(self) -> list[NameType]: + """Naming conventions present in this image (ISO9660 always first).""" + extras = [nt for nt, attr in self._NAME_TYPE_PROBES if getattr(self, attr)] + return [NameType.ISO9660, *extras] + + def default_name_type(self) -> NameType: + """Preferred convention for display (richest available first).""" + for nt, attr in self._NAME_TYPE_PROBES: + if getattr(self, attr): + return nt + return NameType.ISO9660 + + def list_dir(self, path: str, name_type: NameType) -> list[IsoRecord]: + """Return the (sorted) children of ``path`` in the given convention.""" + if self._iso is None: + return [] + kw = self._path_kwarg(path, name_type) + # Comprehension over the live pycdlib iterator, skipping the + # ``.``/``..`` self/references that every ISO directory carries. + records = [ + IsoRecord.from_pycdlib(child, _safe_join(path, name), name_type) + for child in self._iso.list_children(**kw) + if child is not None + for ident in (child.file_identifier(),) + if ident not in (b".", b"..") + for name in (_decode(ident),) + if name not in (".", "..") + ] + # Directories first, then alphabetical (case-insensitive). + records.sort(key=lambda r: (not r.is_dir, r.name.lower())) + return records + + # ------------------------------------------------------------------ add + def add_file(self, local_path: str, dest_dir: str, name_type: NameType, + nice_name: str | None = None) -> None: + """Add a host file into ``dest_dir`` of the image.""" + if self._iso is None: + raise RuntimeError("No image is open") + nice_name = nice_name or os.path.basename(local_path) + node = self._ensure_node(dest_dir, name_type) + iso_name = self._unique_iso9660_name(nice_name, node, is_dir=False) + + kwargs = {"filename": local_path, "iso_path": _safe_join(node.iso_path, iso_name)} + if self.has_rock_ridge: + kwargs["rr_name"] = nice_name + if self.has_joliet and node.joliet_path is not None: + kwargs["joliet_path"] = _safe_join(node.joliet_path, nice_name) + if self.has_udf and node.udf_path is not None: + kwargs["udf_path"] = _safe_join(node.udf_path, nice_name) + self._iso.add_file(**kwargs) + self._mark_dirty() + # Invalidate children cache for the parent. + node.children.clear() + node.loaded = False + + def add_directory(self, dest_dir: str, name_type: NameType, + nice_name: str) -> str: + """Create a new directory inside ``dest_dir``; returns its primary path.""" + if self._iso is None: + raise RuntimeError("No image is open") + node = self._ensure_node(dest_dir, name_type) + nice_name = self._unique_nice_name(nice_name, node) + iso_name = self._unique_iso9660_name(nice_name, node, is_dir=True) + + kwargs = {"iso_path": _safe_join(node.iso_path, iso_name)} + if self.has_rock_ridge: + kwargs["rr_name"] = nice_name + if self.has_joliet and node.joliet_path is not None: + kwargs["joliet_path"] = _safe_join(node.joliet_path, nice_name) + if self.has_udf and node.udf_path is not None: + kwargs["udf_path"] = _safe_join(node.udf_path, nice_name) + self._iso.add_directory(**kwargs) + + # register the new directory in our tree + child = _DirNode( + name=nice_name, + iso_path=_safe_join(node.iso_path, iso_name), + joliet_path=_safe_join(node.joliet_path, nice_name) if node.joliet_path else None, + udf_path=_safe_join(node.udf_path, nice_name) if node.udf_path else None, + rr_name=nice_name if self.has_rock_ridge else None, + loaded=True, + ) + node.children[nice_name] = child + self._mark_dirty() + return child.name + + # ------------------------------------------------------------------ remove + def remove(self, path: str, name_type: NameType, is_dir: bool) -> None: + """Remove a file or directory from the image.""" + if self._iso is None: + raise RuntimeError("No image is open") + kw = self._path_kwarg(path, name_type) + if is_dir: + self._iso.rm_directory(**kw) + else: + self._iso.rm_file(**kw) + # drop from tree + parent_path, _, leaf = path.rpartition("/") + node = self._find_node(parent_path or "/", name_type) + if node is not None: + node.children.pop(leaf, None) + self._mark_dirty() + + # ------------------------------------------------------------------ rename + def rename(self, path: str, name_type: NameType, new_name: str, + is_dir: bool) -> str: + """Rename an entry. + + pycdlib has no direct rename, so we remove and re-add the entry + (copying file data through a temporary on the host). For + directories we recurse. + """ + import tempfile + + if self._iso is None: + raise RuntimeError("No image is open") + parent_path = path.rpartition("/")[0] or "/" + + with tempfile.TemporaryDirectory() as tmp: + if is_dir: + local_dir = os.path.join(tmp, new_name) + self.extract_dir(path, name_type, local_dir) + self.remove(path, name_type, is_dir=True) + self.add_directory(parent_path, name_type, new_name) + # Re-import the extracted subtree under the new name. + self._import_tree(local_dir, _safe_join(parent_path, new_name), name_type) + else: + local_file = os.path.join(tmp, new_name) + self.extract_file(path, name_type, local_file) + self.remove(path, name_type, is_dir=False) + self.add_file(local_file, parent_path, name_type, nice_name=new_name) + self._mark_dirty() + return new_name + + # ------------------------------------------------------------------ extract + def extract_file(self, iso_path: str, name_type: NameType, + local_path: str) -> None: + """Extract a single file to ``local_path``.""" + if self._iso is None: + raise RuntimeError("No image is open") + kw = self._path_kwarg(iso_path, name_type) + os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True) + self._iso.get_file_from_iso(local_path, **kw) + + def extract_dir(self, iso_path: str, name_type: NameType, + local_dir: str) -> None: + """Recursively extract a directory tree to ``local_dir``.""" + if self._iso is None: + raise RuntimeError("No image is open") + os.makedirs(local_dir, exist_ok=True) + for rec in self.list_dir(iso_path, name_type): + dest = os.path.join(local_dir, rec.name) + if rec.is_dir: + self.extract_dir(rec.path, name_type, dest) + else: + self.extract_file(rec.path, name_type, dest) + + # ------------------------------------------------------------------ props + def get_properties(self) -> VolumeProperties: + """Read the current volume descriptors into a snapshot.""" + if self._iso is None: + return VolumeProperties() + p = VolumeProperties( + has_joliet=self.has_joliet, + has_rock_ridge=self.has_rock_ridge, + has_udf=self.has_udf, + interchange_level=self._options.interchange_level, + block_size=self._options.block_size, + total_size=self._image_size(), + ) + # pycdlib exposes the primary volume descriptor (PVD). Some + # identifier fields are ``FileOrTextIdentifier`` objects (which + # expose ``.text`` -> bytes); others are plain bytes. + def _id(val) -> str: + if val is None: + return "" + if hasattr(val, "text"): + val = val.text + return _decode(val).strip() + + try: + pvd = self._iso.pvd + p.volume_label = _id(getattr(pvd, "volume_identifier", b"")) + p.system_id = _id(getattr(pvd, "system_identifier", b"")) + p.volume_set_id = _id(getattr(pvd, "volume_set_identifier", b"")) + p.publisher = _id(getattr(pvd, "publisher_identifier", b"")) + p.preparer = _id(getattr(pvd, "preparer_identifier", b"")) + p.application = _id(getattr(pvd, "application_identifier", b"")) + p.copyright_file = _id(getattr(pvd, "copyright_file_identifier", b"")) + p.abstract_file = _id(getattr(pvd, "abstract_file_identifier", b"")) + p.bibliographic_file = _id(getattr(pvd, "bibliographic_file_identifier", b"")) + except _PYCDLIB_ERRORS: + # A minimal or damaged image may have no readable PVD; the + # default empty strings remain. + pass + if self.has_joliet: + p.joliet_level = 3 + if self.has_rock_ridge: + p.rock_ridge_version = self._options.rock_ridge or "1.09" + if self.has_udf: + p.udf_version = self._options.udf or "2.60" + return p + + def set_volume_label(self, label: str) -> None: + """Update the volume identifier on the primary descriptor.""" + if self._iso is None: + return + label = (label or "").upper()[:32] + try: + self._iso.pvd.volume_identifier = label.encode("ascii", "replace") + except _PYCDLIB_ERRORS as exc: + sys.stderr.write(f"warning: could not set volume label: {exc}\n") + self._options.volume_label = label + self._mark_dirty() + + # ------------------------------------------------------------------ boot + def get_boot_info(self) -> BootInfo: + return self._boot + + def set_boot(self, info: BootInfo, boot_file_local: str | None) -> None: + """Configure El Torito boot. + + ``boot_file_local`` is a host path to the boot image; it will be + added into the ISO first, then referenced by the boot catalog. + """ + if self._iso is None: + raise RuntimeError("No image is open") + if not boot_file_local and not info.boot_image_path: + raise ValueError("A boot image is required") + # add the boot file into the image if a local file was given + if boot_file_local: + boot_iso_name = mangle_iso9660_name(os.path.basename(boot_file_local), is_dir=False) + self.add_file(boot_file_local, "/", self.default_name_type(), + nice_name=os.path.basename(boot_file_local)) + info.boot_image_path = f"/{boot_iso_name}" + kwargs = { + "bootfile_path": info.boot_image_path, + "bootcatfile": self._ensure_abs(info.boot_catalog_path or "BOOT.CAT;1"), + "platform_id": info.platform_id, + "boot_info_table": info.boot_info_table, + "efi": info.efi, + "media_name": info.media_name, + "bootable": info.bootable, + "boot_load_seg": info.load_segment, + } + if info.load_size is not None: + kwargs["boot_load_size"] = info.load_size + # Joliet / UDF / Rock-Ridge boot-catalog names derive from the ISO + # catalog name, lower-cased and stripped of version suffix. + cat_iso = info.boot_catalog_path or "BOOT.CAT;1" + cat_stem = cat_iso.lstrip("/").split(";")[0] or "boot.cat" + bootcat = cat_stem.lower() + # Dispatch table: each enabled extension gets its convention-specific + # catalog path key. + ext_cat_keys = ( + (self.has_rock_ridge, "rr_bootcatname", bootcat), + (self.has_joliet, "joliet_bootcatfile", "/" + bootcat), + (self.has_udf, "udf_bootcatfile", "/" + bootcat), + ) + for enabled, key, value in ext_cat_keys: + if enabled: + kwargs[key] = value + self._iso.add_eltorito(**kwargs) + info.enabled = True + self._boot = info + self._mark_dirty() + + def clear_boot(self) -> None: + """Remove an existing El Torito boot configuration.""" + if self._iso is None: + return + try: + self._iso.rm_eltorito() + except _PYCDLIB_ERRORS as exc: + # No boot record present, or pycdlib refused -- either way the + # caller wants a clean slate; surface the reason on stderr. + sys.stderr.write(f"warning: rm_eltorito failed: {exc}\n") + self._boot = BootInfo() + self._mark_dirty() + + # ------------------------------------------------------------------ internals + def _path_kwarg(self, path: str, name_type: NameType) -> dict[str, str]: + """Translate (path, name_type) into the right pycdlib keyword.""" + return {_PATH_KWARG[name_type]: path} + + def _ensure_node(self, dir_path: str, name_type: NameType) -> _DirNode: + """Return the :class:`_DirNode` for ``dir_path``, loading if needed.""" + if self._tree is None: + raise RuntimeError("No tree") + if dir_path in ("/", ""): + self._load_node(self._tree, name_type) + return self._tree + # walk the tree, loading lazily + parts = [p for p in dir_path.split("/") if p] + node = self._tree + for part in parts: + self._load_node(node, name_type) + if part not in node.children: + # not in our cache: try to resolve by listing & matching + node = self._resolve_child(node, part, name_type) + else: + node = node.children[part] + return node + + def _load_node(self, node: _DirNode, name_type: NameType) -> None: + """Populate ``node.children`` from the image (once).""" + if node.loaded: + return + path = self._node_primary_path(node, name_type) + for rec in self.list_dir(path, name_type): + if not rec.is_dir: + continue + child = _DirNode( + name=rec.name, + iso_path=self._resolve_iso_path(node, rec), + joliet_path=self._resolve_joliet_path(node, rec), + udf_path=self._resolve_udf_path(node, rec), + rr_name=rec.name if self.has_rock_ridge else None, + ) + node.children[rec.name] = child + node.loaded = True + + def _node_primary_path(self, node: _DirNode, name_type: NameType) -> str: + """Pick the convention-specific path stored on ``node`` for ``name_type``. + + Falls back to the ISO9660 path when the requested convention's path + was never recorded (e.g. a Joliet-only entry probed via ISO9660). + """ + attr = { + NameType.JOLIET: "joliet_path", + NameType.UDF: "udf_path", + }.get(name_type) + if attr is not None: + convention_path = getattr(node, attr) + if convention_path: + return convention_path + return node.iso_path + + def _resolve_child(self, node: _DirNode, part: str, name_type: NameType) -> _DirNode: + """Fallback when a child isn't cached: locate it by listing.""" + self._load_node(node, name_type) + if part in node.children: + return node.children[part] + raise KeyError(f"Directory '{part}' not found in '{node.name}'") + + def _resolve_iso_path(self, parent: _DirNode, rec: IsoRecord) -> str: + """Best-effort ISO9660 path for a child listed in another convention.""" + if rec.name_type == NameType.ISO9660: + return rec.path + return _safe_join(parent.iso_path, mangle_iso9660_name(rec.name, is_dir=True)) + + def _resolve_joliet_path(self, parent: _DirNode, rec: IsoRecord) -> str | None: + if not self.has_joliet: + return None + if rec.name_type == NameType.JOLIET: + return rec.path + return _safe_join(parent.joliet_path or "/", rec.name) if parent.joliet_path else None + + def _resolve_udf_path(self, parent: _DirNode, rec: IsoRecord) -> str | None: + if not self.has_udf: + return None + if rec.name_type == NameType.UDF: + return rec.path + return _safe_join(parent.udf_path or "/", rec.name) if parent.udf_path else None + + def _find_node(self, dir_path: str, name_type: NameType) -> _DirNode | None: + try: + return self._ensure_node(dir_path, name_type) + except KeyError: + return None + + def _unique_iso9660_name(self, nice_name: str, node: _DirNode, + is_dir: bool) -> str: + """Generate a collision-free ISO9660 identifier for a new entry.""" + base = mangle_iso9660_name(nice_name, is_dir=is_dir, + interchange_level=self._options.interchange_level) + # Collect existing ISO9660 names so the new one does not collide. + try: + existing = {rec.raw_name.split(";")[0] + for rec in self.list_dir(node.iso_path, NameType.ISO9660)} + except _PYCDLIB_ERRORS: + existing = set() + candidate = base + cand_base = candidate.split(";")[0] + n = 1 + while cand_base in existing: + n += 1 + if is_dir: + candidate = f"{base[:7]}_{n}" + else: + stem = base.split(";")[0] + if "." in stem: + s, e = stem.rsplit(".", 1) + candidate = f"{s[:6]}_{n}.{e};1" + else: + candidate = f"{stem[:7]}_{n};1" + cand_base = candidate.split(";")[0] + return candidate + + def _unique_nice_name(self, nice_name: str, node: _DirNode) -> str: + try: + existing = {rec.name.lower() + for rec in self.list_dir( + self._node_primary_path(node, self.default_name_type()), + self.default_name_type())} + except _PYCDLIB_ERRORS: + existing = set() + candidate = nice_name + n = 1 + stem, ext = os.path.splitext(nice_name) + while candidate.lower() in existing: + n += 1 + candidate = f"{stem}_{n}{ext}" + return candidate + + def _import_tree(self, local_dir: str, iso_dir: str, + name_type: NameType) -> None: + """Recursively import a host directory tree into ``iso_dir``.""" + for entry in sorted(os.listdir(local_dir)): + full = os.path.join(local_dir, entry) + if os.path.isdir(full): + new_dir = self.add_directory(iso_dir, name_type, entry) + self._import_tree(full, _safe_join(iso_dir, new_dir), name_type) + else: + self.add_file(full, iso_dir, name_type, nice_name=entry) + + def _read_vol_ident(self) -> str: + try: + return _decode(self._iso.pvd.volume_identifier).strip() + except _PYCDLIB_ERRORS: + return "" + + def _read_boot_info(self) -> BootInfo: + """Probe whether the open image carries an El Torito boot catalog. + + ``pycdlib.PyCdlib.eltorito_boot_catalog`` returns ``None`` when no + boot record is present (it does not raise), so the check is a plain + truthiness test rather than a try/except existence probe. + """ + info = BootInfo() + try: + info.enabled = self._iso.eltorito_boot_catalog is not None + except _PYCDLIB_ERRORS: + info.enabled = False + return info + + def _image_size(self) -> int: + """Total image size in bytes = ``space_size`` blocks * block size. + + ``space_size`` is an int attribute on the PVD; ``logical_block_size`` + is a *method* on the PVD (pycdlib's API is inconsistent here). The + fallback to a 2048-byte block covers images where the PVD is + unreadable but ``space_size`` survived. + """ + try: + return int(self._iso.pvd.space_size) * int(self._iso.pvd.logical_block_size()) + except _PYCDLIB_ERRORS: + try: + return int(self._iso.pvd.space_size) * 2048 + except _PYCDLIB_ERRORS: + return 0 + + def _mark_dirty(self) -> None: + self._dirty = True + + @staticmethod + def _ensure_abs(path: str) -> str: + """Ensure an ISO-style path starts with '/'.""" + if not path: + return "/" + return path if path.startswith("/") else "/" + path + + def _pycdlib_progress(self, done, total, *args) -> None: + if self.progress_cb: + # A non-numeric or missing progress value is not fatal; suppress + # the conversion error so the write continues uninterrupted. + with contextlib.suppress(TypeError, ValueError): + self.progress_cb("write", int(done), int(total)) diff --git a/iso_scalpel/iso_model.py b/iso_scalpel/iso_model.py new file mode 100644 index 0000000..e993a0d --- /dev/null +++ b/iso_scalpel/iso_model.py @@ -0,0 +1,283 @@ +"""A read-only tree model exposing an ISO image's directory structure. + +The model lazily fetches children from :class:`IsoHandler` as the user +expands nodes. Each item carries its full path (in the active naming +convention) and the :class:`IsoRecord` describing it. +""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt +from PySide6.QtGui import QFont, QIcon + +from .iso_handler import IsoHandler +from .iso_record import IsoRecord, NameType + +# Sentinel for "no parent" passed to Qt model methods. QModelIndex() is a +# cheap value type (an invalid index), but ruff B008 forbids calling it in +# default-argument position; a module-level singleton keeps the Qt-idiomatic +# signature `parent=...` without re-evaluating the call on every invocation. +_NO_PARENT = QModelIndex() + + +class _Item: + """Internal tree node.""" + + __slots__ = ("children", "loaded", "parent", "record", "row") + + def __init__(self, record: IsoRecord | None, parent: _Item | None, row: int = 0): + self.record = record + self.parent = parent + self.children: list[_Item] = [] + self.loaded = False + self.row = row + + def path(self) -> str: + if self.record is None: + return "/" + return self.record.path + + def is_dir(self) -> bool: + return self.record is None or self.record.is_dir + + +class IsoTreeModel(QAbstractItemModel): + """Qt tree model backed by an :class:`IsoHandler`.""" + + COLUMNS = ("Name", "Size", "Type", "Date") + + def __init__(self, handler: IsoHandler, parent=None): + super().__init__(parent) + self._handler = handler + self._name_type: NameType = NameType.ISO9660 + self._root: _Item | None = None + self._folder_icon: QIcon | None = None + self._file_icon: QIcon | None = None + self._init_icons() + self.rebuild() + + # ------------------------------------------------------------------ icons + def _init_icons(self) -> None: + try: + from PySide6.QtWidgets import QApplication, QStyle + st = QApplication.instance().style() if QApplication.instance() else None + if st is not None: + self._folder_icon = st.standardIcon(QStyle.SP_DirIcon) + self._file_icon = st.standardIcon(QStyle.SP_FileIcon) + except (ImportError, RuntimeError, AttributeError): + # No QApplication yet, or the platform plugin is missing -- fall + # back to empty icons so the model still constructs. + self._folder_icon = QIcon() + self._file_icon = QIcon() + + # ------------------------------------------------------------------ public + def set_name_type(self, name_type: NameType) -> None: + self.beginResetModel() + self._name_type = name_type + self._root = None + self.endResetModel() + self.rebuild() + + def name_type(self) -> NameType: + return self._name_type + + def rebuild(self) -> None: + """Drop all caches and reload from the handler.""" + self.beginResetModel() + if self._handler.is_open: + root_rec = IsoRecord(name="/", raw_name="/", is_dir=True, is_file=False, + size=0, path="/", name_type=self._name_type) + self._root = _Item(root_rec, None) + self._root.loaded = False + else: + self._root = None + self.endResetModel() + + def refresh_parent(self, parent_path: str) -> None: + """Reload the children of ``parent_path`` (e.g. after an add/remove). + + The ISO root ``"/"`` maps to the model root (an invalid index whose + internal pointer is ``None``); resolve it to ``self._root`` so the + virtual root's children are dropped and re-fetched. + """ + idx = self.index_from_path(parent_path) + if not idx.isValid(): + self.rebuild() + return + item = idx.internalPointer() or self._root + if item is None: + self.rebuild() + return + self.beginResetModel() + item.children = [] + item.loaded = False + self.endResetModel() + + def index_from_path(self, path: str) -> QModelIndex: + """Return the model index for ``path``. + + The ISO root ``"/"`` maps to the model root (an invalid + :class:`QModelIndex`) -- its children are the entries of ``/`` + and the view displays them directly without requiring the user + to expand a "/" placeholder row. + """ + if self._root is None: + return QModelIndex() + if path in ("/", ""): + return QModelIndex() # the model root == ISO "/" + # walk + parts = [p for p in path.split("/") if p] + parent = QModelIndex() + for part in parts: + self.fetchMore(parent) + found = False + for r in range(self.rowCount(parent)): + idx = self.index(r, 0, parent) + item: _Item = idx.internalPointer() + if item.record and item.record.name == part: + parent = idx + found = True + break + if not found: + return QModelIndex() + return parent + + # ------------------------------------------------------------------ model API + def columnCount(self, parent=_NO_PARENT) -> int: + return len(self.COLUMNS) + + def headerData(self, section, orientation, role=Qt.DisplayRole): + if role != Qt.DisplayRole or orientation != Qt.Horizontal: + return None + return self.COLUMNS[section] + + def rowCount(self, parent=_NO_PARENT) -> int: + if self._root is None: + return 0 + if not parent.isValid(): + # Model root: its children are the entries of the ISO "/" + # directory. rowCount() never calls fetchMore() -- Qt drives + # canFetchMore()/fetchMore() itself, and calling fetchMore() here + # would recurse through beginInsertRows. An unloaded root reports + # 0, which prompts the view to call canFetchMore() (True) and then + # fetchMore() to populate the children. + if not self._root.loaded: + return 0 + return len(self._root.children) + item: _Item = parent.internalPointer() + if not item.is_dir(): + return 0 + if not item.loaded: + return 0 + return len(item.children) + + def canFetchMore(self, parent): + if self._root is None: + return False + # Model root: can fetch if the virtual root isn't loaded yet. + if not parent.isValid(): + return not self._root.loaded + item: _Item = parent.internalPointer() + if item is None: + return False + return item.is_dir() and not item.loaded + + def fetchMore(self, parent): + # An invalid parent denotes the model root, which holds the ISO's + # "/" directory; a valid parent carries its _Item via the pointer. + item = self._root if not parent.isValid() else parent.internalPointer() + # Step-down: nothing to do for a non-directory, an already-loaded + # node, or a closed image (root is None). + if item is None or item.loaded or not item.is_dir(): + return + path = item.path() + try: + records = self._handler.list_dir(path, self._name_type) + except (OSError, ValueError, KeyError): # pragma: no cover - defensive + records = [] + self.beginInsertRows(parent, 0, max(0, len(records) - 1)) + for i, rec in enumerate(records): + item.children.append(_Item(rec, item, i)) + item.loaded = True + self.endInsertRows() + + def index(self, row, column, parent=_NO_PARENT): + if not self.hasIndex(row, column, parent): + return QModelIndex() + parent_item: _Item = parent.internalPointer() if parent.isValid() else self._root + if parent_item is None: + return QModelIndex() + if not parent_item.loaded: + self.fetchMore(parent) + if row < 0 or row >= len(parent_item.children): + return QModelIndex() + return self.createIndex(row, column, parent_item.children[row]) + + def parent(self, index): + if not index.isValid(): + return QModelIndex() + item: _Item = index.internalPointer() + parent = item.parent + if parent is None or parent is self._root: + return QModelIndex() + return self.createIndex(parent.row, 0, parent) + + def data(self, index, role=Qt.DisplayRole): + if not index.isValid(): + return None + item: _Item = index.internalPointer() + rec = item.record + if rec is None: + return None + col = index.column() + + # DisplayRole: dispatch on column via a tuple lookup so adding a + # column is a one-line edit instead of another if-branch. + if role == Qt.DisplayRole: + display = ( + rec.name, + rec.size_label, + "Folder" if rec.is_dir else "File", + rec.date_label, + ) + return display[col] if 0 <= col < len(display) else None + + # Non-display roles are column-specific; guard each with `col == 0` + # where the role only applies to the name column. + if role == Qt.DecorationRole and col == 0: + return self._folder_icon if rec.is_dir else self._file_icon + if role == Qt.FontRole and col == 0: + font = QFont() + font.setBold(rec.is_dir) + return font + if role == Qt.UserRole: + return rec + if role == Qt.ToolTipRole: + tip = f"{rec.name}\n{rec.size_label} ({rec.size} bytes)" + if rec.modified: + tip += f"\n{rec.date_label}" + return tip + return None + + def flags(self, index): + if not index.isValid(): + return Qt.NoItemFlags + return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled diff --git a/iso_scalpel/iso_record.py b/iso_scalpel/iso_record.py new file mode 100644 index 0000000..d9b36c2 --- /dev/null +++ b/iso_scalpel/iso_record.py @@ -0,0 +1,225 @@ +"""Unified record abstraction over pycdlib's various record types. + +pycdlib exposes different record objects depending on the naming +convention in use (ISO9660 ``DirectoryRecord`` vs UDF ``UDFFileEntry``). +The UI wants a single, consistent shape, so ``IsoRecord`` normalises them. +""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +import enum +import time +from dataclasses import dataclass + + +class NameType(enum.Enum): + """Which naming convention a record's path belongs to.""" + + ISO9660 = "iso9660" # plain ISO9660 (upper case, 8.3) + ROCK_RIDGE = "rock" # Rock Ridge (Unix names) + JOLIET = "joliet" # Joliet (Unicode) + UDF = "udf" # UDF + + +# Friendly labels for the UI / dialogs. +NAME_TYPE_LABELS = { + NameType.ISO9660: "ISO9660", + NameType.ROCK_RIDGE: "Rock Ridge", + NameType.JOLIET: "Joliet", + NameType.UDF: "UDF", +} + + +def _decode(ident) -> str: + """Decode a pycdlib file identifier (bytes) into a python str. + + Joliet identifiers carry a leading NUL byte and are UTF-16-BE; everything + else is treated as UTF-8. A bytes payload that decodes as neither falls + back to latin-1 so the UI never crashes on a malformed image. + """ + if ident is None: + return "" + if isinstance(ident, bytes): + try: + return (ident.decode("utf-16-be") if ident.startswith(b"\x00") + else ident.decode("utf-8", "replace")) + except (UnicodeDecodeError, ValueError): + return ident.decode("latin-1", "replace") + return str(ident) + + +def _format_iso9660_name(raw: str) -> str: + """Humanise an ISO9660 identifier for display. + + pycdlib returns names like ``b'TEST.TXT;1'``. We strip the version + suffix (``;1``) for readability in the list view. + """ + name = _decode(raw) + if ";" in name: + name = name.split(";", 1)[0] + return name + + +@dataclass +class IsoRecord: + """A normalised view of a single entry inside an ISO image.""" + + name: str # display name (already de-mangled) + raw_name: str # raw identifier as stored + is_dir: bool + is_file: bool + size: int # bytes (0 for directories) + path: str # full path in the active NameType convention + name_type: NameType + modified: float | None = None # epoch seconds + record: object = None # the underlying pycdlib record (advanced use) + + # -- convenience ------------------------------------------------------- + @property + def is_dot(self) -> bool: + return self.name in (".", "..") + + @property + def size_label(self) -> str: + if self.is_dir: + return "" + return _human_size(self.size) + + @property + def date_label(self) -> str: + if not self.modified: + return "" + try: + return time.strftime("%Y-%m-%d %H:%M", time.localtime(self.modified)) + except (OSError, ValueError, OverflowError): + # Out-of-range epoch or malformed struct cannot be formatted. + return "" + + @classmethod + def from_pycdlib(cls, rec, path: str, name_type: NameType) -> IsoRecord: + """Build an :class:`IsoRecord` from a raw pycdlib record.""" + if rec is None: + return cls("", "", False, False, 0, path, name_type) + + raw_ident = rec.file_identifier() + is_dir = bool(rec.is_dir()) if hasattr(rec, "is_dir") else False + is_file = bool(rec.is_file()) if hasattr(rec, "is_file") else False + + # data length differs between ISO9660 records and UDF entries. + size = 0 + if hasattr(rec, "get_data_length"): + try: + size = int(rec.get_data_length()) + except (TypeError, ValueError): + size = 0 + elif hasattr(rec, "data_length"): + try: + size = int(rec.data_length) + except (TypeError, ValueError): + size = 0 + + # Display name + if name_type == NameType.ISO9660: + name = _format_iso9660_name(raw_ident) + else: + name = _decode(raw_ident) + + # Strip a leading version like ';1' on ISO9660 names for display + if name_type == NameType.ISO9660 and ";" in name: + name = name.split(";", 1)[0] + + # Modified time: take the first attribute that yields a usable epoch. + _TIME_ATTRS = ("mod_time", "date", "access_time", "attr_time") + modified = next( + (ts for attr in _TIME_ATTRS + for val in (getattr(rec, attr, None),) + if val is not None + for ts in (_to_epoch(val),) + if ts is not None), + None, + ) + + return cls( + name=name, + raw_name=_decode(raw_ident), + is_dir=is_dir, + is_file=is_file, + size=size, + path=path, + name_type=name_type, + modified=modified, + record=rec, + ) + + +def _to_epoch(val) -> float | None: + """Best-effort conversion of a pycdlib date-ish object to epoch seconds.""" + if val is None: + return None + if isinstance(val, (int, float)): + return float(val) + # pycdlib uses its own date structs; try common attributes. + for attr in ("year", "month", "day", "hour", "minute", "second"): + if not hasattr(val, attr): + return None + try: + y = int(getattr(val, "year", 1970)) + mo = int(getattr(val, "month", 1)) + d = int(getattr(val, "day", 1)) + h = int(getattr(val, "hour", 0)) + mi = int(getattr(val, "minute", 0)) + s = int(getattr(val, "second", 0)) + except (TypeError, ValueError): + return None + if y <= 0: + return None + try: + return time.mktime((y, mo, d, h, mi, s, 0, 0, -1)) + except (OSError, ValueError, OverflowError): + return None + + +_SIZE_UNITS: tuple[str, ...] = ("B", "KiB", "MiB", "GiB", "TiB") + + +def _human_size(n: float | None) -> str: + """Format a byte count with binary units (B, KiB, MiB, GiB, TiB). + + A None or non-numeric input reads as ``"0 B"``. Values >= 1 PiB are + reported in TiB (the largest unit ISO media realistically reaches). + """ + if n is None: + return "0 B" + try: + value = float(n) + except (TypeError, ValueError): + return "0 B" + if value < 0: + value = 0.0 + # Walk the unit table; stop at the first unit whose threshold the + # value no longer crosses, or at the last unit (TiB) as a ceiling. + for unit in _SIZE_UNITS: + if value < 1024.0 or unit == _SIZE_UNITS[-1]: + if unit == "B": + return f"{int(value)} {unit}" + return f"{value:.1f} {unit}" + value /= 1024.0 + return f"{value:.1f} {_SIZE_UNITS[-1]}" diff --git a/iso_scalpel/main_window.py b/iso_scalpel/main_window.py new file mode 100644 index 0000000..b28642c --- /dev/null +++ b/iso_scalpel/main_window.py @@ -0,0 +1,1115 @@ +"""The main application window. + +ISO Scalpel uses a **split-navigation** layout: two panes side by side, +each a fully-featured file manager (breadcrumbs, back/forward/up, live +filter, tabs). Either pane can host either the host filesystem or the +ISO image, and the two can be swapped with one click / keystroke. + +Menus are grouped into **Image · Navigate · Entry · View · Tools · Help** +— deliberately distinct from the File/Edit/View/Settings/Help pattern of +older ISO editors. +""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +import os + +from PySide6.QtCore import QSize, Qt +from PySide6.QtGui import QAction, QActionGroup, QCloseEvent, QKeySequence +from PySide6.QtWidgets import ( + QApplication, + QFileDialog, + QInputDialog, + QLabel, + QMainWindow, + QMessageBox, + QProgressDialog, + QSizePolicy, + QSplitter, + QStatusBar, + QStyle, + QTabWidget, + QToolBar, + QToolButton, + QVBoxLayout, + QWidget, +) + +from . import __app_name__ +from .config import Settings +from .dialogs.about_dialog import AboutDialog +from .dialogs.boot_dialog import BootDialog +from .dialogs.diff_dialog import DiffDialog +from .dialogs.extract_dialog import ExtractDialog +from .dialogs.new_iso_dialog import NewIsoDialog +from .dialogs.properties_dialog import PropertiesDialog +from .dialogs.settings_dialog import SettingsDialog +from .iso_handler import IsoHandler +from .iso_record import NAME_TYPE_LABELS, NameType +from .widgets.fs_pane import FsPane +from .widgets.iso_pane import IsoPane + + +class MainWindow(QMainWindow): + def __init__(self) -> None: + super().__init__() + self._settings = Settings.load() + self._handler = IsoHandler() + self._closing = False + + self.setWindowTitle(__app_name__) + self.resize(self._settings.window_width, self._settings.window_height) + + self._build_central() + self._build_actions() + self._build_menubar() + self._build_toolbar() + self._build_statusbar() + self._connect_panes() + self._update_actions() + self._update_nav_actions() + self._update_status() + + # ====================================================================== + # Central widget — split-nav with directional transfer + swap + tabs + # ====================================================================== + def _build_central(self) -> None: + self._fs_pane = FsPane(self._settings) + self._iso_pane = IsoPane(self._handler, self._settings) + self._iso_pane.refresh() + + # wrap each pane in a tab widget so the user can open multiple + # filesystem folders / multiple ISOs (ISO tabs come later; for now + # each side is a single-tab container that is swap-aware). + self._left_tabs = _PaneTabWidget("Filesystem") + self._left_tabs.addTab(self._fs_pane, "FS") + self._right_tabs = _PaneTabWidget("ISO Image") + self._right_tabs.addTab(self._iso_pane, "ISO") + # Cap each pane's minimum width so neither one can starve the + # other when the window is narrow -- without this, the ISO pane + # (which has more chrome: nav buttons + breadcrumb + view combo + # + filter box) ends up with a much larger minimum-size hint + # than the FS pane and the splitter gives it the lion's share + # of any extra width. 150px is enough to show the Name column + # plus a sliver of the next column. + self._left_tabs.setMinimumWidth(150) + self._right_tabs.setMinimumWidth(150) + + # The middle column hosts two directional transfer buttons: + # → Add : transfer selected FS files into the ISO + # ← Extract : transfer selected ISO entries back to disk + # Each button fills half the column height so the column stays + # narrow (no wasted vertical space) and the direction is + # unambiguous: arrow direction = data flow direction. + self._transfer_col = _TransferColumn( + on_add=self.on_add, + on_extract=self.on_extract, + ) + + # we track which side holds what so swap is reversible + self._left_is_fs = True # True => left pane is filesystem + + self._splitter = QSplitter(Qt.Horizontal) + self._splitter.addWidget(self._left_tabs) + self._splitter.addWidget(self._transfer_col) + self._splitter.addWidget(self._right_tabs) + # Validate saved splitter sizes and fall back to a sane default + # if they're missing, malformed, or would starve a pane. + saved = self._settings.splitter_sizes + if (saved and len(saved) == 3 + and all(isinstance(x, int) and x >= 100 for x in saved)): + self._splitter.setSizes(saved) + else: + default_w = max(self._settings.window_width, 800) + half = (default_w - 36) // 2 + self._splitter.setSizes([half, 36, half]) + self._splitter.setChildrenCollapsible(False) + self._splitter.setStretchFactor(0, 1) + self._splitter.setStretchFactor(1, 0) + self._splitter.setStretchFactor(2, 1) + # keep the transfer column narrow so the panes get the bulk of + # the width; it must never grow when the window is resized. + self._transfer_col.setMaximumWidth(36) + self._splitter.setMinimumSize(400, 200) + self.setCentralWidget(self._splitter) + + def swap_panes(self) -> None: + """Exchange the contents of the left and right panes. + + Bound to ``Ctrl+Shift+X`` and the Navigate menu. The directional + transfer buttons always point in the data-flow direction regardless + of which pane is on which side, so swapping panes never changes the + arrow labels. + """ + sizes = self._splitter.sizes() + self._left_tabs.setParent(None) + self._right_tabs.setParent(None) + self._transfer_col.setParent(None) + self._left_is_fs = not self._left_is_fs + if self._left_is_fs: + self._splitter.addWidget(self._left_tabs) + self._splitter.addWidget(self._transfer_col) + self._splitter.addWidget(self._right_tabs) + else: + self._splitter.addWidget(self._right_tabs) + self._splitter.addWidget(self._transfer_col) + self._splitter.addWidget(self._left_tabs) + self._splitter.setSizes(sizes) + self._update_active_pane() + + # ====================================================================== + # Active-pane tracking + # ====================================================================== + def _active_pane(self): + """Return the pane widget (FsPane or IsoPane) currently focused. + + We prefer the side that last received focus; fall back to whatever + is on the left. + """ + if self._iso_pane.hasFocus() or self._iso_pane.view.hasFocus(): + return self._iso_pane + if self._fs_pane.hasFocus() or self._fs_pane.view.hasFocus(): + return self._fs_pane + return self._fs_pane + + def _update_active_pane(self) -> None: + self._update_nav_actions() + + # ====================================================================== + # Actions + # ====================================================================== + def _build_actions(self) -> None: + st = self.style() + + def mk(text, icon=None, shortcut=None, tip=None, slot=None): + a = QAction(text, self) + if icon is not None: + a.setIcon(st.standardIcon(icon)) + if shortcut: + a.setShortcut(QKeySequence(shortcut)) + if tip: + a.setToolTip(tip) + a.setStatusTip(tip) + if slot: + a.triggered.connect(slot) + return a + + # --- Image menu ------------------------------------------------- + self.act_new = mk("New…", QStyle.SP_FileIcon, "Ctrl+N", + "Create a new ISO image", self.on_new) + self.act_open = mk("Open…", QStyle.SP_DirOpenIcon, "Ctrl+O", + "Open an existing ISO image", self.on_open) + self.act_save = mk("Save", QStyle.SP_DialogSaveButton, "Ctrl+S", + "Save the image", self.on_save) + self.act_saveas = mk("Save As…", QStyle.SP_DialogSaveButton, "Ctrl+Shift+S", + "Save the image to a new file", self.on_save_as) + self.act_close = mk("Close", None, "Ctrl+W", + "Close the current image", self.on_close) + self.act_props = mk("Properties…", QStyle.SP_FileDialogContentsView, "Alt+Enter", + "View / edit image properties", self.on_properties) + self.act_quit = mk("Quit", None, "Ctrl+Q", "Exit the application", self.close) + + # --- Navigate menu --------------------------------------------- + self.act_back = mk("Back", QStyle.SP_ArrowBack, "Alt+Left", + "Active pane: go back", self.on_nav_back) + self.act_forward = mk("Forward", QStyle.SP_ArrowForward, "Alt+Right", + "Active pane: go forward", self.on_nav_forward) + self.act_up = mk("Up", QStyle.SP_ArrowUp, "Alt+Up", + "Active pane: go to parent", self.on_nav_up) + self.act_goto = mk("Go to…", None, "Ctrl+L", + "Active pane: go to a typed path", self.on_nav_goto) + self.act_swap = mk("Swap Panes", None, "Ctrl+Shift+X", + "Exchange the left and right panes", self.swap_panes) + self.act_newtab = mk("New Tab", QStyle.SP_FileDialogNewFolder, "Ctrl+T", + "New tab in the active pane (filesystem)", self.on_new_tab) + + # --- Entry menu ------------------------------------------------ + self.act_add = mk("Add to Image", QStyle.SP_ArrowForward, "Insert", + "Add selected files to the ISO", self.on_add) + self.act_extract = mk("Extract to Disk…", QStyle.SP_ArrowBack, "Ctrl+E", + "Extract selected entries to disk", self.on_extract) + self.act_newfolder = mk("New Folder…", QStyle.SP_FileDialogNewFolder, "Ctrl+Shift+N", + "Create a new folder in the ISO", self.on_new_folder) + self.act_rename = mk("Rename…", QStyle.SP_DialogResetButton, "F2", + "Rename the selected entry", self.on_rename) + self.act_delete = mk("Delete", QStyle.SP_DialogCancelButton, "Delete", + "Delete selected entries from the ISO", self.on_delete) + self.act_selectall = mk("Select All", None, "Ctrl+A", + "Select all entries in the active pane", self.on_select_all) + self.act_invert = mk("Invert Selection", None, "Ctrl+Shift+I", + "Invert the selection", self.on_invert_selection) + + # --- View menu ------------------------------------------------- + self.act_show_hidden = QAction("Show Hidden Files", self, checkable=True) + self.act_show_hidden.setChecked(self._settings.show_hidden) + self.act_show_hidden.toggled.connect(self._on_show_hidden) + self.act_filter = mk("Filter…", QStyle.SP_FileDialogContentsView, "Ctrl+F", + "Focus the active pane's filter box", self.on_filter) + self.act_refresh = mk("Refresh", QStyle.SP_BrowserReload, "F5", + "Refresh the active pane", self.on_refresh) + + # --- Tools menu ------------------------------------------------ + # SP_MediaPlay is the closest standard icon to a "boot" arrow; + # a dedicated disc-boot icon would be better but isn't in + # QStyle's standard set. + self.act_boot = mk("Boot Image…", QStyle.SP_MediaPlay, "Ctrl+B", + "Configure El Torito boot", self.on_boot) + self.act_volmeta = mk("Volume Metadata…", None, None, + "Edit volume label and identifiers", self.on_properties) + self.act_diff = mk("Compare Images…", None, "Ctrl+D", + "Compare the filesystems of two ISO images", self.on_diff) + self.act_settings = mk("Preferences…", QStyle.SP_FileDialogListView, "Ctrl+,", + "Application preferences", self.on_settings) + + # --- Help menu ------------------------------------------------- + self.act_about = mk("About " + __app_name__, QStyle.SP_MessageBoxInformation, + None, None, self.on_about) + self.act_about_qt = mk("About Qt", None, None, None, self.on_about_qt) + + # ====================================================================== + # Menu bar — Image · Navigate · Entry · View · Tools · Help + # ====================================================================== + def _build_menubar(self) -> None: + mb = self.menuBar() + + m_image = mb.addMenu("&Image") + m_image.addAction(self.act_new) + m_image.addAction(self.act_open) + self._recent_menu = m_image.addMenu("Open Recent") + m_image.addSeparator() + m_image.addAction(self.act_save) + m_image.addAction(self.act_saveas) + m_image.addAction(self.act_close) + m_image.addSeparator() + m_image.addAction(self.act_props) + m_image.addSeparator() + m_image.addAction(self.act_quit) + self._rebuild_recent_menu() + + m_nav = mb.addMenu("&Navigate") + m_nav.addAction(self.act_back) + m_nav.addAction(self.act_forward) + m_nav.addAction(self.act_up) + m_nav.addAction(self.act_goto) + m_nav.addSeparator() + m_nav.addAction(self.act_swap) + m_nav.addSeparator() + m_nav.addAction(self.act_newtab) + + m_entry = mb.addMenu("&Entry") + m_entry.addAction(self.act_add) + m_entry.addAction(self.act_extract) + m_entry.addSeparator() + m_entry.addAction(self.act_newfolder) + m_entry.addAction(self.act_rename) + m_entry.addAction(self.act_delete) + m_entry.addSeparator() + m_entry.addAction(self.act_selectall) + m_entry.addAction(self.act_invert) + + m_view = mb.addMenu("&View") + m_view.addAction(self.act_show_hidden) + m_view.addAction(self.act_filter) + m_view.addSeparator() + self._name_type_menu = m_view.addMenu("Display Names As") + self._name_type_group = QActionGroup(self) + self._name_type_group.setExclusive(True) + self._name_type_group.triggered.connect(self._on_name_type_action) + m_view.addSeparator() + m_view.addAction(self.act_refresh) + + m_tools = mb.addMenu("&Tools") + m_tools.addAction(self.act_boot) + m_tools.addAction(self.act_volmeta) + m_tools.addSeparator() + m_tools.addAction(self.act_diff) + m_tools.addSeparator() + m_tools.addAction(self.act_settings) + + m_help = mb.addMenu("&Help") + m_help.addAction(self.act_about) + m_help.addAction(self.act_about_qt) + + # ====================================================================== + # Toolbar + # ====================================================================== + def _build_toolbar(self) -> None: + tb = QToolBar("Main") + tb.setMovable(False) + # PySide6 requires a QSize, not a raw int. Wrap the pixelMetric + # result so we don't rely on the implicit conversion that older + # PyQt5 / early PySide6 builds accepted. + icon_px = self.style().pixelMetric(QStyle.PM_ToolBarIconSize) + tb.setIconSize(QSize(icon_px, icon_px)) + self.addToolBar(tb) + tb.addAction(self.act_new) + tb.addAction(self.act_open) + tb.addAction(self.act_save) + tb.addSeparator() + tb.addAction(self.act_back) + tb.addAction(self.act_forward) + tb.addAction(self.act_up) + tb.addSeparator() + tb.addAction(self.act_add) + tb.addAction(self.act_extract) + tb.addAction(self.act_newfolder) + tb.addAction(self.act_delete) + tb.addSeparator() + tb.addAction(self.act_boot) + tb.addAction(self.act_props) + # act_swap stays out of the toolbar: directional transfer buttons in + # the splitter gutter cover the on-screen interaction, and swap + # remains reachable via Ctrl+Shift+X and the Navigate menu. + # A stretch spacer keeps the toolbar items left-aligned on wide windows. + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + tb.addWidget(spacer) + + # ====================================================================== + # Status bar + # ====================================================================== + def _build_statusbar(self) -> None: + sb = QStatusBar() + self.setStatusBar(sb) + self._status_msg = QLabel("Ready") + self._status_info = QLabel("") + self._status_sel = QLabel("") + sb.addWidget(self._status_msg, 1) + sb.addPermanentWidget(self._status_sel) + sb.addPermanentWidget(self._status_info) + + # ====================================================================== + # Pane signals + # ====================================================================== + def _connect_panes(self) -> None: + self._fs_pane.addRequested.connect(self._on_add_files) + self._fs_pane.pathChanged.connect(lambda _p: self._update_nav_actions()) + self._fs_pane.navChanged.connect(self._update_nav_actions) + self._iso_pane.addFilesRequested.connect(self._on_add_files_to) + self._iso_pane.extractRequested.connect(self._on_extract_items) + self._iso_pane.deleteRequested.connect(self.on_delete_items) + self._iso_pane.renameRequested.connect(self._on_rename) + self._iso_pane.newFolderRequested.connect(self._on_new_folder) + self._iso_pane.propertiesRequested.connect(lambda _p: self.on_properties()) + self._iso_pane.nameTypeChanged.connect(lambda _nt: self._update_status()) + self._iso_pane.navChanged.connect(self._update_nav_actions) + # selection-changed → update status + nav + self._fs_pane.view.selectionModel().selectionChanged.connect( + lambda *_: self._update_status()) + self._iso_pane.view.selectionModel().selectionChanged.connect( + lambda *_: self._update_status()) + + # ====================================================================== + # Navigation actions + # ====================================================================== + def on_nav_back(self) -> None: + self._active_pane().go_back() + + def on_nav_forward(self) -> None: + self._active_pane().go_forward() + + def on_nav_up(self) -> None: + self._active_pane().go_up() + + def on_nav_goto(self) -> None: + pane = self._active_pane() + if isinstance(pane, FsPane): + path, ok = QInputDialog.getText( + self, "Go to", "Path:", text=pane.current_path()) + if ok and path.strip(): + pane.set_path(os.path.expanduser(path.strip())) + else: + path, ok = QInputDialog.getText( + self, "Go to (ISO path)", "ISO path:", text=pane.current_dir()) + if ok and path.strip(): + pane.go_to_path(path.strip()) + + def on_new_tab(self) -> None: + """Open a new filesystem tab in the filesystem side.""" + # For now: spawn a new FsPane in the filesystem tab widget. + new_pane = FsPane(self._settings) + container = self._left_tabs if self._left_is_fs else self._right_tabs + container.addTab(new_pane, "FS") + container.setCurrentIndex(container.count() - 1) + new_pane.addRequested.connect(self._on_add_files) + new_pane.pathChanged.connect(lambda _p: self._update_nav_actions()) + new_pane.navChanged.connect(self._update_nav_actions) + + def on_filter(self) -> None: + self._active_pane().focus_filter() + + def on_select_all(self) -> None: + self._active_pane().view.selectAll() + + def on_invert_selection(self) -> None: + view = self._active_pane().view + model = view.model() + sm = view.selectionModel() + # Build the select / deselect sets in one pass, then apply each as a + # single batched QItemSelectionModel.select() call -- fewer model + # signals than a per-row toggle. + rows = range(model.rowCount()) + to_select = [model.index(r, 0) for r in rows if not sm.isSelected(model.index(r, 0))] + to_deselect = [model.index(r, 0) for r in rows if sm.isSelected(model.index(r, 0))] + if to_select: + sm.select(to_select, sm.Select) + if to_deselect: + sm.select(to_deselect, sm.Deselect) + + def _update_nav_actions(self) -> None: + pane = self._active_pane() + can_back = hasattr(pane, "can_back") and pane.can_back() + can_fwd = hasattr(pane, "can_forward") and pane.can_forward() + self.act_back.setEnabled(can_back) + self.act_forward.setEnabled(can_fwd) + self.act_up.setEnabled(True) + + # ====================================================================== + # Image operations + # ====================================================================== + def on_new(self) -> None: + if not self._confirm_discard(): + return + dlg = NewIsoDialog(self._settings, self) + if dlg.exec() != NewIsoDialog.Accepted: + return + try: + self._handler.new(dlg.options()) + except Exception as exc: # noqa: BLE001 -- user-facing error boundary + QMessageBox.critical(self, "New Image", f"Failed to create image:\n{exc}") + return + self._iso_pane.refresh() + self._update_actions() + self._update_nav_actions() + self._update_status() + self._status("New image created.") + + def on_open(self) -> None: + if not self._confirm_discard(): + return + path, _ = QFileDialog.getOpenFileName( + self, "Open ISO Image", self._settings.last_dir, + "ISO images (*.iso *.bin *.cue);;All files (*)") + if not path: + return + self._open_path(path) + + def _open_path(self, path: str) -> None: + try: + self._handler.open(path) + except Exception as exc: # noqa: BLE001 -- user-facing error boundary + QMessageBox.critical(self, "Open", f"Failed to open image:\n{exc}") + return + self._settings.add_recent(path) + self._rebuild_recent_menu() + self._iso_pane.refresh() + self._update_actions() + self._update_nav_actions() + self._update_status() + self._status(f"Opened {os.path.basename(path)}") + + def on_save(self) -> None: + if not self._handler.is_open: + return + if not self._handler.filename: + self.on_save_as() + return + self._do_save(self._handler.filename) + + def on_save_as(self) -> None: + if not self._handler.is_open: + return + path, _ = QFileDialog.getSaveFileName( + self, "Save ISO Image As", self._settings.last_dir, + "ISO images (*.iso);;All files (*)") + if not path: + return + self._do_save(path) + + def _do_save(self, path: str) -> None: + prog = QProgressDialog("Writing image…", "Cancel", 0, 100, self) + prog.setWindowTitle("Save") + prog.setWindowModality(Qt.WindowModal) + prog.setMinimumDuration(0) + prog.setValue(0) + self._handler.progress_cb = self._make_save_progress(prog) + try: + self._handler.save(path) + except Exception as exc: # noqa: BLE001 -- user-facing error boundary + QMessageBox.critical(self, "Save", f"Failed to save image:\n{exc}") + prog.close() + return + finally: + self._handler.progress_cb = None + prog.close() + self._settings.add_recent(path) + self._rebuild_recent_menu() + self._iso_pane.refresh() + self._update_actions() + self._update_status() + self._status(f"Saved to {os.path.basename(path)}") + + def _make_save_progress(self, prog: QProgressDialog): + def cb(op: str, done: int, total: int) -> None: + if total <= 0: + return + pct = int(done * 100 / total) + prog.setValue(min(99, pct)) + if prog.wasCanceled(): + self._handler.cancel() + QApplication.processEvents() + return cb + + def on_close(self) -> None: + if not self._confirm_discard(): + return + self._handler.close() + self._iso_pane.refresh() + self._update_actions() + self._update_nav_actions() + self._update_status() + self._status("Image closed.") + + # ====================================================================== + # Entry operations + # ====================================================================== + def on_add(self) -> None: + paths = self._fs_pane.selected_paths() + if not paths: + self._status("Nothing selected in the filesystem pane.") + return + self._on_add_files(paths) + + def _on_add_files(self, paths: list[str]) -> None: + self._on_add_files_to("/", paths) + + def _on_add_files_to(self, target: str, paths: list[str]) -> None: + if not self._handler.is_open: + QMessageBox.information(self, "Add", "Open or create an image first.") + return + nt = self._iso_pane._model.name_type() + prog = QProgressDialog(f"Adding {len(paths)} item(s)…", "Cancel", 0, len(paths), self) + prog.setWindowModality(Qt.WindowModal) + prog.setMinimumDuration(0) + ok = 0 + for i, p in enumerate(paths): + if prog.wasCanceled(): + break + prog.setValue(i) + QApplication.processEvents() + try: + if os.path.isdir(p): + name = os.path.basename(p) + new_dir = self._handler.add_directory(target, nt, name) + self._import_dir(p, target.rstrip("/") + "/" + new_dir, nt) + else: + self._handler.add_file(p, target, nt, nice_name=os.path.basename(p)) + ok += 1 + except Exception as exc: # noqa: BLE001 -- user-facing error boundary + if not self._ask_continue("Add", p, exc): + break + prog.setValue(len(paths)) + prog.close() + self._iso_pane.refresh_parent(target) + self._update_actions() + self._update_status() + self._status(f"Added {ok} item(s).") + + def _import_dir(self, local_dir: str, iso_dir: str, nt: NameType) -> None: + for entry in sorted(os.listdir(local_dir)): + full = os.path.join(local_dir, entry) + if os.path.isdir(full): + new_dir = self._handler.add_directory(iso_dir, nt, entry) + self._import_dir(full, iso_dir.rstrip("/") + "/" + new_dir, nt) + else: + self._handler.add_file(full, iso_dir, nt, nice_name=entry) + + def on_extract(self) -> None: + records = self._iso_pane.selected_records() + if not records: + self._status("Nothing selected in the ISO pane.") + return + self._on_extract_items([(r.path, r.is_dir) for r in records], self._settings.last_dir) + + def _on_extract_items(self, items: list[tuple], default_dir: str) -> None: + if not self._handler.is_open or not items: + return + dlg = ExtractDialog(items, default_dir, self) + if dlg.exec() != ExtractDialog.Accepted: + return + dest = dlg.destination() + os.makedirs(dest, exist_ok=True) + nt = self._iso_pane._model.name_type() + prog = QProgressDialog(f"Extracting {len(items)} item(s)…", "Cancel", 0, len(items), self) + prog.setWindowModality(Qt.WindowModal) + prog.setMinimumDuration(0) + ok = 0 + for i, (path, is_dir) in enumerate(items): + if prog.wasCanceled(): + break + prog.setValue(i) + QApplication.processEvents() + try: + local = os.path.join(dest, os.path.basename(path) or "root") + if is_dir: + self._handler.extract_dir(path, nt, local) + else: + self._handler.extract_file(path, nt, local) + ok += 1 + except Exception as exc: # noqa: BLE001 -- user-facing error boundary + if not self._ask_continue("Extract", path, exc): + break + prog.setValue(len(items)) + prog.close() + self._settings.last_dir = dest + self._update_status() + self._status(f"Extracted {ok} item(s) to {dest}.") + + def on_delete(self) -> None: + items = [(r.path, r.is_dir) for r in self._iso_pane.selected_records()] + if not items: + self._status("Nothing selected to delete.") + return + self.on_delete_items(items) + + def on_delete_items(self, items: list[tuple]) -> None: + if not self._handler.is_open or not items: + return + if self._settings.confirm_delete: + names = ", ".join(os.path.basename(p) for p, _ in items[:5]) + if QMessageBox.question( + self, "Delete", + f"Delete {len(items)} item(s): {names}?\nThis cannot be undone.", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) != QMessageBox.Yes: + return + nt = self._iso_pane._model.name_type() + items_sorted = sorted(items, key=lambda it: it[0].count("/"), reverse=True) + for path, is_dir in items_sorted: + try: + self._handler.remove(path, nt, is_dir) + except Exception as exc: # noqa: BLE001 -- user-facing error boundary + if not self._ask_continue("Delete", path, exc): + break + self._iso_pane.refresh() + self._update_actions() + self._update_status() + self._status(f"Deleted {len(items)} item(s).") + + def on_rename(self) -> None: + records = self._iso_pane.selected_records() + if len(records) != 1: + self._status("Select a single item to rename.") + return + self._on_rename(records[0].path, records[0].is_dir) + + def _on_rename(self, path: str, is_dir: bool) -> None: + if not self._handler.is_open: + return + old = os.path.basename(path) + new, ok = QInputDialog.getText(self, "Rename", "New name:", text=old) + if not ok or not new.strip() or new.strip() == old: + return + nt = self._iso_pane._model.name_type() + try: + self._handler.rename(path, nt, new.strip(), is_dir) + except Exception as exc: # noqa: BLE001 -- user-facing error boundary + QMessageBox.critical(self, "Rename", f"Failed to rename:\n{exc}") + return + self._iso_pane.refresh() + self._update_status() + self._status(f"Renamed to {new.strip()}.") + + def on_new_folder(self) -> None: + self._on_new_folder("/") + + def _on_new_folder(self, parent: str) -> None: + if not self._handler.is_open: + return + name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") + if not ok or not name.strip(): + return + nt = self._iso_pane._model.name_type() + try: + self._handler.add_directory(parent, nt, name.strip()) + except Exception as exc: # noqa: BLE001 -- user-facing error boundary + QMessageBox.critical(self, "New Folder", f"Failed to create folder:\n{exc}") + return + self._iso_pane.refresh_parent(parent) + self._update_status() + self._status(f"Created folder '{name.strip()}'.") + + # ====================================================================== + # Tools + # ====================================================================== + def on_properties(self) -> None: + if not self._handler.is_open: + return + dlg = PropertiesDialog(self._handler, self) + dlg.exec() + self._iso_pane.refresh() + self._update_status() + + def on_boot(self) -> None: + if not self._handler.is_open: + return + dlg = BootDialog(self._handler, self) + dlg.exec() + self._iso_pane.refresh() + self._update_actions() + + def on_diff(self) -> None: + dlg = DiffDialog(self) + # pre-fill A with the currently-open image if there is one + if self._handler.is_open and self._handler.filename: + dlg.set_images(self._handler.filename, "") + dlg.exec() + + def on_settings(self) -> None: + dlg = SettingsDialog(self._settings, self) + if dlg.exec() == SettingsDialog.Accepted: + self._settings = dlg.apply_to(self._settings) + self._fs_pane.set_show_hidden(self._settings.show_hidden) + self.act_show_hidden.setChecked(self._settings.show_hidden) + self._settings.save() + + # ====================================================================== + # Help + # ====================================================================== + def on_about(self) -> None: + AboutDialog(self).exec() + + def on_about_qt(self) -> None: + QMessageBox.aboutQt(self, "About Qt") + + # ====================================================================== + # View helpers + # ====================================================================== + def on_refresh(self) -> None: + pane = self._active_pane() + if isinstance(pane, IsoPane): + pane.refresh() + else: + pane.set_path(pane.current_path()) + + def _on_show_hidden(self, on: bool) -> None: + self._fs_pane.set_show_hidden(on) + self._settings.show_hidden = on + self._settings.save() + + def _on_name_type_action(self, action: QAction) -> None: + nt = action.data() + if nt is None: + return + self._iso_pane._model.set_name_type(nt) + self._iso_pane.name_type.blockSignals(True) + for i in range(self._iso_pane.name_type.count()): + if self._iso_pane.name_type.itemData(i) == nt: + self._iso_pane.name_type.setCurrentIndex(i) + break + self._iso_pane.name_type.blockSignals(False) + self._update_status() + + # ====================================================================== + # Action state + # ====================================================================== + def _update_actions(self) -> None: + open_ = self._handler.is_open + for a in (self.act_save, self.act_saveas, self.act_close, + self.act_props, self.act_boot, self.act_volmeta, + self.act_add, self.act_extract, self.act_newfolder, + self.act_delete, self.act_rename): + a.setEnabled(open_) + + # The directional transfer buttons mirror the action enabled + # state so the user gets immediate visual feedback when there's + # no open image to add to / extract from. + if hasattr(self, "_transfer_col"): + self._transfer_col.set_add_enabled(open_) + self._transfer_col.set_extract_enabled(open_) + + self._name_type_menu.clear() + if open_: + current = self._iso_pane._model.name_type() + for nt in self._handler.available_name_types(): + a = QAction(NAME_TYPE_LABELS[nt], self._name_type_menu, checkable=True) + a.setData(nt) + a.setChecked(nt == current) + self._name_type_group.addAction(a) + self._name_type_menu.addAction(a) + + def _update_status(self) -> None: + if self._handler.is_open: + props = self._handler.get_properties() + # Compact short labels for the status bar (the Properties dialog + # uses the full "Joliet 3" form via VolumeProperties.extensions). + ext_flags = ( + (props.has_joliet, "Joliet"), + (props.has_rock_ridge, "RR"), + (props.has_udf, "UDF"), + ) + exts = [label for enabled, label in ext_flags if enabled] + nt = NAME_TYPE_LABELS.get(self._iso_pane._model.name_type(), "ISO9660") + dirty = " (modified)" if self._handler.is_dirty else "" + self._status_info.setText( + f"{_human(props.total_size)} · ISO9660 L{props.interchange_level}" + f" · {', '.join(exts) or 'plain'} · view: {nt}{dirty}") + title_base = os.path.basename(self._handler.filename or "untitled.iso") + self.setWindowTitle(f"{__app_name__} — {title_base}{dirty}") + else: + self._status_info.setText("No image open") + self.setWindowTitle(__app_name__) + # selection count + fs_n = self._fs_pane.selection_count() + iso_n = self._iso_pane.selection_count() + bits = [] + if fs_n: + bits.append(f"FS: {fs_n}") + if iso_n: + bits.append(f"ISO: {iso_n}") + self._status_sel.setText(" · ".join(bits)) + + def _status(self, msg: str) -> None: + self._status_msg.setText(msg) + + # ====================================================================== + # Recent files + # ====================================================================== + def _rebuild_recent_menu(self) -> None: + self._recent_menu.clear() + if not self._settings.recent_files: + a = self._recent_menu.addAction("(none)") + a.setEnabled(False) + return + for path in self._settings.recent_files: + label = os.path.basename(path) + a = self._recent_menu.addAction(f"{label} — {path}") + a.setToolTip(path) + a.triggered.connect(lambda checked=False, p=path: self._open_recent(p)) + + def _open_recent(self, path: str) -> None: + if not os.path.exists(path): + QMessageBox.information(self, "Open Recent", f"File not found:\n{path}") + self._settings.recent_files = [p for p in self._settings.recent_files if p != path] + self._rebuild_recent_menu() + return + if not self._confirm_discard(): + return + self._open_path(path) + + # ====================================================================== + # Helpers + # ====================================================================== + def _confirm_discard(self) -> bool: + if not self._handler.is_open or not self._handler.is_dirty: + return True + ret = QMessageBox.question( + self, "Unsaved changes", + "The current image has unsaved changes. Discard them?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + return ret == QMessageBox.Yes + + def _ask_continue(self, op: str, what: str, exc: Exception) -> bool: + ret = QMessageBox.question( + self, op, + f"Failed to {op.lower()} '{os.path.basename(what)}':\n{exc}\n\nContinue with the rest?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) + return ret == QMessageBox.Yes + + # ====================================================================== + # Close / persist + # ====================================================================== + def closeEvent(self, event: QCloseEvent) -> None: + if not self._confirm_discard(): + event.ignore() + return + self._settings.window_width = self.width() + self._settings.window_height = self.height() + self._settings.splitter_sizes = self._splitter.sizes() + self._settings.save() + if self._handler.is_open: + self._handler.close() + event.accept() + + +class _PaneTabWidget(QTabWidget): + """A tab widget hosting a pane; tabs are closable except the last one. + + The close button on the lone remaining tab is hidden imperatively after + each add/remove via :meth:`_refresh_close_button`, deferred to the next + event-loop iteration so Qt's internal tab-insert/remove layout finishes + first. Overriding the ``QTabBar`` virtuals ``tabInserted`` / ``tabRemoved`` + is avoided because it interferes with tab-bar rendering on several + Qt styles and leaves the pane blank. + """ + + def __init__(self, label: str): + super().__init__() + # Keep closable enabled so the close button appears when 2+ tabs + # exist. We hide it imperatively when only one tab remains. + self.setTabsClosable(True) + self.setMovable(True) + self.tabCloseRequested.connect(self._on_close) + self._label = label + + def _on_close(self, index: int) -> None: + if self.count() <= 1: + return + self.removeTab(index) + # _refresh_close_button is scheduled by removeTab itself. + + def addTab(self, *args, **kwargs): # type: ignore[override] + result = super().addTab(*args, **kwargs) + # Defer the close-button refresh to the next event loop + # iteration so we don't interfere with Qt's internal tab-insert + # layout. + from PySide6.QtCore import QTimer + QTimer.singleShot(0, self._refresh_close_button) + return result + + def removeTab(self, index: int) -> None: # type: ignore[override] + super().removeTab(index) + # Schedule the close-button refresh so the lone remaining tab + # gets its close button hidden (if count drops to 1). + from PySide6.QtCore import QTimer + QTimer.singleShot(0, self._refresh_close_button) + + def _refresh_close_button(self) -> None: + """Toggle closable on every tab based on tab count. + + A single tab shows no close button (a red 'X' with nothing to close + reads as an error indicator); two or more tabs each get a close + button. ``setTabsClosable`` is used rather than + ``setTabButton(..., None)`` because the latter permanently removes the + button for a tab index and Qt does not re-create it when a second + tab is added. ``setTabsClosable`` atomically creates or removes + close buttons on every tab. + """ + self.setTabsClosable(self.count() > 1) + + +class _TransferColumn(QWidget): + """A narrow vertical column of two directional transfer buttons. + + Two buttons make the data-flow direction unambiguous: + + * **→ Add** -- selected FS files are added into the ISO. + * **← Extract** -- selected ISO entries are extracted back to disk. + + The column is fixed at 36px wide and stretches to the full splitter + height; each button takes half that height so the column always fills + the available vertical space without orphaned empty regions. + """ + + # Common stylesheet for both transfer buttons. Each button gets its + # own arrow character via setText(); the rest of the styling is + # identical so the column reads as a single visual unit. + _BUTTON_SS = ( + "QToolButton {" + " font-size: 22px;" + " font-weight: 700;" + " border: 1px solid #b8bdc6;" + " border-radius: 4px;" + " background: #ffffff;" + " margin: 2px 1px;" + " padding: 0;" + "}" + "QToolButton:hover {" + " background: #eef5f0;" + " border-color: #34a96b;" + "}" + "QToolButton:pressed {" + " background: #d4ead9;" + "}" + "QToolButton:disabled {" + " color: #c0c4cc;" + " background: #f4f5f7;" + " border-color: #e4e7ed;" + "}" + ) + + def __init__(self, on_add, on_extract, parent=None): + super().__init__(parent) + self._on_add_cb = on_add + self._on_extract_cb = on_extract + + # --- Add button (top): FS → ISO ------------------------------- + self.add_btn = QToolButton() + self.add_btn.setText("→") + self.add_btn.setToolTip( + "Add (FS → ISO)\n" + "Add the selected filesystem files to the ISO image.\n" + "Shortcut: Insert" + ) + self.add_btn.setStyleSheet(self._BUTTON_SS) + self.add_btn.clicked.connect(self._on_add_clicked) + + # --- Extract button (bottom): ISO → FS ------------------------ + self.extract_btn = QToolButton() + self.extract_btn.setText("←") + self.extract_btn.setToolTip( + "Extract (ISO → FS)\n" + "Extract the selected ISO entries back to disk.\n" + "Shortcut: Ctrl+E" + ) + self.extract_btn.setStyleSheet(self._BUTTON_SS) + self.extract_btn.clicked.connect(self._on_extract_clicked) + + # Stack the two buttons vertically; each gets equal vertical + # stretch so the column always fills the available height + # without wasted space above/below. + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + layout.addWidget(self.add_btn, 1) + layout.addWidget(self.extract_btn, 1) + + # Lock the column to a narrow width so the two panes flanking it + # get the bulk of the splitter's width. + self.setFixedWidth(36) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + + # ------------------------------------------------------------------ slots + def _on_add_clicked(self) -> None: + if self._on_add_cb is not None: + self._on_add_cb() + + def _on_extract_clicked(self) -> None: + if self._on_extract_cb is not None: + self._on_extract_cb() + + # ------------------------------------------------------------------ state + def set_add_enabled(self, enabled: bool) -> None: + """Enable/disable the Add button based on app state.""" + self.add_btn.setEnabled(enabled) + + def set_extract_enabled(self, enabled: bool) -> None: + """Enable/disable the Extract button based on app state.""" + self.extract_btn.setEnabled(enabled) + + +def _human(n: int) -> str: + """Format a byte count for the status bar. + + Delegates to :func:`iso_scalpel.iso_record._human_size` so the CLI, + the Properties dialog, and the status bar all report sizes the same way. + """ + from .iso_record import _human_size + return _human_size(n) diff --git a/iso_scalpel/widgets/__init__.py b/iso_scalpel/widgets/__init__.py new file mode 100644 index 0000000..f5f1058 --- /dev/null +++ b/iso_scalpel/widgets/__init__.py @@ -0,0 +1,20 @@ +"""UI widgets subpackage (file panes).""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + diff --git a/iso_scalpel/widgets/__pycache__/__init__.cpython-314.pyc b/iso_scalpel/widgets/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..7cad195 Binary files /dev/null and b/iso_scalpel/widgets/__pycache__/__init__.cpython-314.pyc differ diff --git a/iso_scalpel/widgets/__pycache__/fs_pane.cpython-314.pyc b/iso_scalpel/widgets/__pycache__/fs_pane.cpython-314.pyc new file mode 100644 index 0000000..8a6ef12 Binary files /dev/null and b/iso_scalpel/widgets/__pycache__/fs_pane.cpython-314.pyc differ diff --git a/iso_scalpel/widgets/__pycache__/iso_pane.cpython-314.pyc b/iso_scalpel/widgets/__pycache__/iso_pane.cpython-314.pyc new file mode 100644 index 0000000..aba3265 Binary files /dev/null and b/iso_scalpel/widgets/__pycache__/iso_pane.cpython-314.pyc differ diff --git a/iso_scalpel/widgets/fs_pane.py b/iso_scalpel/widgets/fs_pane.py new file mode 100644 index 0000000..32118b6 --- /dev/null +++ b/iso_scalpel/widgets/fs_pane.py @@ -0,0 +1,329 @@ +"""Filesystem pane with breadcrumb navigation, history and live filter. + +This is one half of ISO Scalpel's split-nav interface. It browses the +host filesystem and emits "add to image" requests when the user drags +selections onto the ISO pane (or invokes the context-menu action). + +The pane provides: + * a clickable breadcrumb path bar (jump to any ancestor), + * back / forward / up navigation buttons with a per-pane history, + * a live filter box that narrows the current listing by name, + * drag-out support so entries can be dropped onto the ISO pane. +""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +import os + +from PySide6.QtCore import QDir, QModelIndex, QSortFilterProxyModel, Qt, Signal +from PySide6.QtGui import QAction, QKeySequence +from PySide6.QtWidgets import ( + QApplication, + QFileSystemModel, + QFrame, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMenu, + QPushButton, + QSizePolicy, + QToolButton, + QTreeView, + QVBoxLayout, + QWidget, +) + +from ..config import Settings + + +class FsPane(QWidget): + """Browse the host filesystem; emit add-to-image requests.""" + + addRequested = Signal(list) # list[str] of local paths to add + pathChanged = Signal(str) # current directory + navChanged = Signal() # back/forward availability changed + + def __init__(self, settings: Settings, parent=None): + super().__init__(parent) + self._settings = settings + self._history: list[str] = [] + self._history_idx = -1 + + # --- model + proxy filter ---------------------------------------- + self._model = QFileSystemModel() + self._model.setRootPath("") + self._filter = _NameFilterProxy() + self._filter.setSourceModel(self._model) + + # --- view -------------------------------------------------------- + self.view = QTreeView() + self.view.setModel(self._filter) + self.view.setSortingEnabled(True) + self.view.setRootIsDecorated(True) + self.view.setAlternatingRowColors(True) + self.view.setSelectionMode(QTreeView.ExtendedSelection) + self.view.setDragEnabled(True) + self.view.setDragDropMode(QTreeView.DragOnly) + self.view.setUniformRowHeights(True) + self.view.sortByColumn(0, Qt.AscendingOrder) + self.view.setColumnWidth(0, 260) + self.view.doubleClicked.connect(self._on_double_click) + self.view.setContextMenuPolicy(Qt.CustomContextMenu) + self.view.customContextMenuRequested.connect(self._on_context) + # Column sizing: the Name column stretches to fill available + # width; Size / Type / Date Modified auto-fit to their contents + # so they're never cramped or absurdly wide. + hdr = self.view.header() + hdr.setStretchLastSection(False) + hdr.setSectionResizeMode(0, QHeaderView.Stretch) + for col in (1, 2, 3): + if col < hdr.count(): + hdr.setSectionResizeMode(col, QHeaderView.ResizeToContents) + + # --- navigation buttons ----------------------------------------- + self.back_btn = QToolButton(arrowType=Qt.LeftArrow) + self.back_btn.setToolTip("Back (Alt+Left)") + self.back_btn.clicked.connect(self.go_back) + self.fwd_btn = QToolButton(arrowType=Qt.RightArrow) + self.fwd_btn.setToolTip("Forward (Alt+Right)") + self.fwd_btn.clicked.connect(self.go_forward) + self.up_btn = QToolButton(arrowType=Qt.UpArrow) + self.up_btn.setToolTip("Up (Alt+Up)") + self.up_btn.clicked.connect(self.go_up) + + # --- breadcrumb bar --------------------------------------------- + self._crumb_layout = QHBoxLayout() + self._crumb_layout.setSpacing(0) + self._crumb_layout.setContentsMargins(0, 0, 0, 0) + self._crumb_frame = QFrame() + self._crumb_frame.setObjectName("Breadcrumb") + self._crumb_frame.setLayout(self._crumb_layout) + + # --- filter box -------------------------------------------------- + self.filter_edit = QLineEdit() + self.filter_edit.setPlaceholderText("Filter…") + self.filter_edit.setClearButtonEnabled(True) + self.filter_edit.textChanged.connect(self._filter.set_filter) + self.filter_edit.setMaxLength(200) + # Cap the filter box width so it doesn't stretch absurdly wide + # on large windows -- the breadcrumb bar should get the bulk of + # the available horizontal space. + self.filter_edit.setMaximumWidth(220) + self.filter_edit.setMinimumWidth(120) + + # --- nav bar (buttons + breadcrumb + filter) -------------------- + bar = QHBoxLayout() + bar.setSpacing(4) + bar.setContentsMargins(4, 2, 4, 2) + bar.addWidget(self.back_btn) + bar.addWidget(self.fwd_btn) + bar.addWidget(self.up_btn) + bar.addWidget(self._crumb_frame, 1) + bar.addWidget(self.filter_edit, 0) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addLayout(bar) + layout.addWidget(self.view, 1) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + self.set_path(settings.last_dir) + + # ------------------------------------------------------------------ navigation + def set_path(self, path: str) -> None: + path = os.path.abspath(path or os.getcwd()) + if not os.path.isdir(path): + return + idx = self._model.index(path) + if not idx.isValid(): + return + src_root = idx + proxy_root = self._filter.mapFromSource(src_root) + self.view.setRootIndex(proxy_root if proxy_root.isValid() else src_root) + if not self._history or self._history[self._history_idx] != path: + self._history = self._history[: self._history_idx + 1] + self._history.append(path) + self._history_idx = len(self._history) - 1 + self._settings.last_dir = path + self._rebuild_breadcrumbs(path) + self.pathChanged.emit(path) + self.navChanged.emit() + + def current_path(self) -> str: + if 0 <= self._history_idx < len(self._history): + return self._history[self._history_idx] + return self._settings.last_dir + + def go_up(self) -> None: + parent = os.path.dirname(self.current_path()) + if parent and parent != self.current_path(): + self.set_path(parent) + + def go_back(self) -> None: + if self._history_idx > 0: + self._history_idx -= 1 + self._apply_history() + + def go_forward(self) -> None: + if self._history_idx < len(self._history) - 1: + self._history_idx += 1 + self._apply_history() + + def can_back(self) -> bool: + return self._history_idx > 0 + + def can_forward(self) -> bool: + return self._history_idx < len(self._history) - 1 + + def _apply_history(self) -> None: + path = self._history[self._history_idx] + idx = self._model.index(path) + if idx.isValid(): + proxy = self._filter.mapFromSource(idx) + self.view.setRootIndex(proxy if proxy.isValid() else idx) + self._settings.last_dir = path + self._rebuild_breadcrumbs(path) + self.pathChanged.emit(path) + self.navChanged.emit() + + def _on_double_click(self, proxy_idx: QModelIndex) -> None: + src = self._filter.mapToSource(proxy_idx) + path = self._model.filePath(src) + if os.path.isdir(path): + self.set_path(path) + + # ------------------------------------------------------------------ breadcrumbs + def _rebuild_breadcrumbs(self, path: str) -> None: + # clear + while self._crumb_layout.count(): + it = self._crumb_layout.takeAt(0) + w = it.widget() + if w is not None: + w.deleteLater() + parts = [] + cur = path + guard = 0 + while cur and guard < 64: + parts.append((os.path.basename(cur) or cur, cur)) + parent = os.path.dirname(cur) + if parent == cur: + break + cur = parent + guard += 1 + parts.reverse() + for i, (label, target) in enumerate(parts): + if i > 0: + sep = QLabel("›") # noqa: RUF001 -- breadcrumb separator glyph + sep.setStyleSheet("color:#999; padding:0 1px;") + self._crumb_layout.addWidget(sep) + btn = QPushButton(label) + btn.setFlat(True) + btn.setStyleSheet( + "QPushButton { border:0; padding:2px 4px; text-align:left; " + "color:#1a73e8; } QPushButton:hover { text-decoration:underline; }") + btn.clicked.connect(lambda _=False, t=target: self.set_path(t)) + self._crumb_layout.addWidget(btn) + self._crumb_layout.addStretch(1) + + # ------------------------------------------------------------------ selection + def selected_paths(self) -> list[str]: + """Local filesystem paths of the selected rows (empty strings dropped).""" + return [ + self._model.filePath(self._filter.mapToSource(pi)) + for pi in self.view.selectionModel().selectedRows() + if self._model.filePath(self._filter.mapToSource(pi)) + ] + + def selection_count(self) -> int: + return len(self.view.selectionModel().selectedRows()) + + # ------------------------------------------------------------------ filter + def focus_filter(self) -> None: + self.filter_edit.setFocus() + self.filter_edit.selectAll() + + def clear_filter(self) -> None: + self.filter_edit.clear() + + # ------------------------------------------------------------------ options + def set_show_hidden(self, on: bool) -> None: + flt = QDir.AllEntries | QDir.NoDotAndDotDot + if on: + flt |= QDir.Hidden + self._model.setFilter(flt) + self._settings.show_hidden = on + + # ------------------------------------------------------------------ context menu + def _on_context(self, pos) -> None: + idx = self.view.indexAt(pos) + menu = QMenu(self) + act_add = QAction("Add to ISO image", self) + act_add.triggered.connect(self._emit_add) + menu.addAction(act_add) + menu.addSeparator() + act_open = QAction("Open", self) + act_open.triggered.connect(self._open_current) + menu.addAction(act_open) + if idx.isValid(): + act_copy = QAction("Copy path", self) + act_copy.triggered.connect(lambda: self._copy_path(idx)) + menu.addAction(act_copy) + menu.addSeparator() + act_refresh = QAction("Refresh", self) + act_refresh.setShortcut(QKeySequence.Refresh) + act_refresh.triggered.connect(lambda: self.set_path(self.current_path())) + menu.addAction(act_refresh) + menu.exec(self.view.viewport().mapToGlobal(pos)) + + def _emit_add(self) -> None: + paths = self.selected_paths() + if paths: + self.addRequested.emit(paths) + + def _open_current(self) -> None: + paths = self.selected_paths() + if paths and os.path.isdir(paths[0]): + self.set_path(paths[0]) + + def _copy_path(self, idx) -> None: + src = self._filter.mapToSource(idx) + QApplication.clipboard().setText(self._model.filePath(src)) + + +class _NameFilterProxy(QSortFilterProxyModel): + """Case-insensitive substring filter on the filename column.""" + + def __init__(self): + super().__init__() + self._needle = "" + + def set_filter(self, text: str) -> None: + self._needle = (text or "").lower() + self.invalidateFilter() + + def filterAcceptsRow(self, source_row, source_parent): + if not self._needle: + return True + idx = self.sourceModel().index(source_row, 0, source_parent) + name = self.sourceModel().fileName(idx) + return self._needle in name.lower() diff --git a/iso_scalpel/widgets/iso_pane.py b/iso_scalpel/widgets/iso_pane.py new file mode 100644 index 0000000..a6ff095 --- /dev/null +++ b/iso_scalpel/widgets/iso_pane.py @@ -0,0 +1,440 @@ +"""ISO image pane with breadcrumb navigation, history, view-switch and filter. + +The other half of ISO Scalpel's split-nav interface. It browses an open +ISO image (using :class:`IsoTreeModel`) and emits operation requests +when the user drags host files in, or invokes context-menu actions. + +The pane provides: + * a clickable breadcrumb path bar (jump to any ancestor directory), + * back / forward / up navigation with a per-pane history of visited + ISO directories, + * a live filter box that narrows the current listing, + * a view dropdown to switch between ISO9660 / Rock Ridge / Joliet / UDF + naming conventions on the same image, + * drop-accept so host files can be dragged in from the filesystem pane. +""" + +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import annotations + +import os + +from PySide6.QtCore import QModelIndex, Qt, Signal +from PySide6.QtGui import QAction, QDragEnterEvent, QDropEvent, QKeySequence +from PySide6.QtWidgets import ( + QAbstractItemView, + QComboBox, + QFrame, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMenu, + QPushButton, + QSizePolicy, + QToolButton, + QTreeView, + QVBoxLayout, + QWidget, +) + +from ..config import Settings +from ..iso_handler import IsoHandler +from ..iso_model import IsoTreeModel +from ..iso_record import NAME_TYPE_LABELS, IsoRecord + + +class IsoPane(QWidget): + """Browse an ISO image; request operations on its contents.""" + + addFilesRequested = Signal(str, list) # (dest_dir, [local paths]) + extractRequested = Signal(list, str) # ([(path, is_dir)], dest_dir) + deleteRequested = Signal(list) # [(path, is_dir)] + renameRequested = Signal(str, bool) # (path, is_dir) + newFolderRequested = Signal(str) # parent_dir + propertiesRequested = Signal(str) # path + nameTypeChanged = Signal(object) # NameType + navChanged = Signal() # back/forward availability + + def __init__(self, handler: IsoHandler, settings: Settings, parent=None): + super().__init__(parent) + self._handler = handler + self._settings = settings + self._history: list[str] = [] + self._history_idx = -1 + + self._model = IsoTreeModel(handler) + + self.view = QTreeView() + self.view.setModel(self._model) + self.view.setRootIsDecorated(True) + self.view.setAlternatingRowColors(True) + self.view.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.view.setUniformRowHeights(True) + self.view.setAcceptDrops(True) + self.view.setDragDropMode(QAbstractItemView.DropOnly) + self.view.setDropIndicatorShown(True) + self.view.setColumnWidth(0, 280) + self.view.setContextMenuPolicy(Qt.CustomContextMenu) + self.view.customContextMenuRequested.connect(self._on_context) + self.view.doubleClicked.connect(self._on_double_click) + self.view.header().setStretchLastSection(False) + self.view.header().setSectionResizeMode(0, QHeaderView.Stretch) + self.view.selectionModel().selectionChanged.connect( + lambda *_: self.navChanged.emit()) + + # --- nav buttons ------------------------------------------------ + self.back_btn = QToolButton(arrowType=Qt.LeftArrow) + self.back_btn.setToolTip("Back (Alt+Left)") + self.back_btn.clicked.connect(self.go_back) + self.fwd_btn = QToolButton(arrowType=Qt.RightArrow) + self.fwd_btn.setToolTip("Forward (Alt+Right)") + self.fwd_btn.clicked.connect(self.go_forward) + self.up_btn = QToolButton(arrowType=Qt.UpArrow) + self.up_btn.setToolTip("Up (Alt+Up)") + self.up_btn.clicked.connect(self.go_up) + + # --- breadcrumb bar -------------------------------------------- + self._crumb_layout = QHBoxLayout() + self._crumb_layout.setSpacing(0) + self._crumb_layout.setContentsMargins(0, 0, 0, 0) + self._crumb_frame = QFrame() + self._crumb_frame.setObjectName("Breadcrumb") + self._crumb_frame.setLayout(self._crumb_layout) + + # --- view selector + filter ------------------------------------ + self.name_type = QComboBox() + self.name_type.setToolTip("Display names as") + self.name_type.currentIndexChanged.connect(self._on_name_type_changed) + self._populate_name_types() + # Cap the combo width so it doesn't stretch on large windows. + self.name_type.setMaximumWidth(160) + self.name_type.setMinimumWidth(100) + + self.filter_edit = QLineEdit() + self.filter_edit.setPlaceholderText("Filter…") + self.filter_edit.setClearButtonEnabled(True) + self.filter_edit.textChanged.connect(self._apply_filter) + self.filter_edit.setMaxLength(200) + # Cap the filter box width so it doesn't stretch absurdly wide + # on large windows -- the breadcrumb bar should get the bulk of + # the available horizontal space, with the filter box keeping a + # comfortable fixed maximum. + self.filter_edit.setMaximumWidth(220) + self.filter_edit.setMinimumWidth(120) + + # --- nav bar --------------------------------------------------- + bar = QHBoxLayout() + bar.setSpacing(4) + bar.setContentsMargins(4, 2, 4, 2) + bar.addWidget(self.back_btn) + bar.addWidget(self.fwd_btn) + bar.addWidget(self.up_btn) + bar.addWidget(self._crumb_frame, 1) + bar.addWidget(QLabel("View:")) + bar.addWidget(self.name_type) + bar.addWidget(self.filter_edit) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addLayout(bar) + layout.addWidget(self.view, 1) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + # ------------------------------------------------------------------ name types + def _populate_name_types(self) -> None: + self.name_type.blockSignals(True) + self.name_type.clear() + if self._handler.is_open: + default = self._handler.default_name_type() + for nt in self._handler.available_name_types(): + self.name_type.addItem(NAME_TYPE_LABELS[nt], nt) + idx = self.name_type.findData(default) + if idx >= 0: + self.name_type.setCurrentIndex(idx) + self.name_type.blockSignals(False) + + def _on_name_type_changed(self, _idx: int) -> None: + nt = self.name_type.currentData() + if nt is None: + return + self._model.set_name_type(nt) + self._history.clear() + self._history_idx = -1 + self._visit("/") + self.nameTypeChanged.emit(nt) + + # ------------------------------------------------------------------ refresh + def refresh(self) -> None: + """Reload after structural changes.""" + self._populate_name_types() + self._model.rebuild() + self._history.clear() + self._history_idx = -1 + self._visit("/") + self.navChanged.emit() + + def refresh_parent(self, parent_path: str) -> None: + self._model.refresh_parent(parent_path) + self._rebuild_breadcrumbs(self.current_dir()) + + # ------------------------------------------------------------------ selection + def current_index(self) -> QModelIndex: + rows = self.view.selectionModel().selectedRows() + return rows[0] if rows else QModelIndex() + + def selected_records(self) -> list[IsoRecord]: + """Records backing the selected rows (rows without a record are skipped).""" + return [ + rec for idx in self.view.selectionModel().selectedRows() + if (rec := idx.data(Qt.UserRole)) is not None + ] + + def selection_count(self) -> int: + return len(self.view.selectionModel().selectedRows()) + + def current_dir(self) -> str: + idx = self.current_index() + if not idx.isValid(): + return self._current_history_path() or "/" + rec: IsoRecord | None = idx.data(Qt.UserRole) + if rec is None: + return self._current_history_path() or "/" + if rec.is_dir: + return rec.path + return rec.path.rpartition("/")[0] or "/" + + def _current_history_path(self) -> str | None: + if 0 <= self._history_idx < len(self._history): + return self._history[self._history_idx] + return None + + # ------------------------------------------------------------------ navigation + def _on_double_click(self, idx: QModelIndex) -> None: + rec: IsoRecord | None = idx.data(Qt.UserRole) + if rec and rec.is_dir: + self.view.expand(idx) + self._visit(rec.path) + + def _visit(self, path: str) -> None: + if not path: + return + if not self._history or self._history[self._history_idx] != path: + self._history = self._history[: self._history_idx + 1] + self._history.append(path) + self._history_idx = len(self._history) - 1 + self._rebuild_breadcrumbs(path) + self.navChanged.emit() + + def go_up(self) -> None: + cur = self.current_dir() + parent = cur.rpartition("/")[0] or "/" + if parent != cur: + self._visit(parent) + # try to select the dir we came from + for r in range(self._model.rowCount()): + idx = self._model.index(r, 0) + rec = idx.data(Qt.UserRole) + if rec and rec.path == cur: + self.view.setCurrentIndex(idx) + break + + def go_back(self) -> None: + if self._history_idx > 0: + self._history_idx -= 1 + p = self._history[self._history_idx] + self._rebuild_breadcrumbs(p) + self.navChanged.emit() + + def go_forward(self) -> None: + if self._history_idx < len(self._history) - 1: + self._history_idx += 1 + p = self._history[self._history_idx] + self._rebuild_breadcrumbs(p) + self.navChanged.emit() + + def can_back(self) -> bool: + return self._history_idx > 0 + + def can_forward(self) -> bool: + return self._history_idx < len(self._history) - 1 + + def go_to_path(self, path: str) -> None: + self._visit(path or "/") + + def focus_filter(self) -> None: + self.filter_edit.setFocus() + self.filter_edit.selectAll() + + def clear_filter(self) -> None: + self.filter_edit.clear() + + # ------------------------------------------------------------------ breadcrumbs + def _rebuild_breadcrumbs(self, path: str) -> None: + while self._crumb_layout.count(): + it = self._crumb_layout.takeAt(0) + w = it.widget() + if w is not None: + w.deleteLater() + if not self._handler.is_open: + placeholder = QLabel("ISO image — (none)") + placeholder.setStyleSheet("padding:2px 6px; color:#999;") + self._crumb_layout.addWidget(placeholder) + self._crumb_layout.addStretch(1) + return + # root label = filename + fname = os.path.basename(self._handler.filename or "untitled.iso") + dirty = " *" if self._handler.is_dirty else "" + root_btn = QPushButton(f"💿 {fname}{dirty}") + root_btn.setFlat(True) + root_btn.setStyleSheet( + "QPushButton { border:0; padding:2px 4px; text-align:left; " + "color:#1a73e8; font-weight:600; } QPushButton:hover { text-decoration:underline; }") + root_btn.clicked.connect(lambda _=False: self._visit("/")) + self._crumb_layout.addWidget(root_btn) + parts = [p for p in path.split("/") if p and p != fname] + for seg in parts: + sep = QLabel("›") # noqa: RUF001 -- breadcrumb separator glyph + sep.setStyleSheet("color:#999; padding:0 1px;") + self._crumb_layout.addWidget(sep) + btn = QPushButton(seg) + btn.setFlat(True) + btn.setStyleSheet( + "QPushButton { border:0; padding:2px 4px; text-align:left; " + "color:#1a73e8; } QPushButton:hover { text-decoration:underline; }") + # compute the full path up to this segment + acc = "/" + for s in parts[:parts.index(seg) + 1]: + acc = (acc.rstrip("/") + "/" + s) + btn.clicked.connect(lambda _=False, t=acc: self._visit(t)) + self._crumb_layout.addWidget(btn) + self._crumb_layout.addStretch(1) + + # ------------------------------------------------------------------ filter + def _apply_filter(self, text: str) -> None: + """Live filter by walking the model and hiding non-matching rows.""" + needle = (text or "").lower() + if not needle: + # unhide everything + self._set_recursive_visible(self._model.index(0, 0), True) + return + self._filter_recursive(QModelIndex(), needle) + + def _filter_recursive(self, parent: QModelIndex, needle: str) -> bool: + """Hide rows whose name doesn't match; returns True if any child shown.""" + model = self._model + any_shown = False + for r in range(model.rowCount(parent)): + idx = model.index(r, 0, parent) + rec = idx.data(Qt.UserRole) + name = (rec.name if rec else "").lower() + child_match = self._filter_recursive(idx, needle) if model.rowCount(idx) else False + match = needle in name or child_match + self.view.setRowHidden(r, parent, not match) + if match: + any_shown = True + return any_shown + + def _set_recursive_visible(self, idx: QModelIndex, visible: bool) -> None: + if not idx.isValid(): + return + parent = idx.parent() + row = idx.row() + if parent.isValid(): + self.view.setRowHidden(row, parent, not visible) + for r in range(self._model.rowCount(idx)): + child = self._model.index(r, 0, idx) + self._set_recursive_visible(child, visible) + + # ------------------------------------------------------------------ drag & drop + def dragEnterEvent(self, event: QDragEnterEvent) -> None: + if event.mimeData().hasUrls(): + event.acceptProposedAction() + return + event.ignore() + + def dragMoveEvent(self, event) -> None: + if event.mimeData().hasUrls(): + event.acceptProposedAction() + return + event.ignore() + + def dropEvent(self, event: QDropEvent) -> None: + if not event.mimeData().hasUrls(): + event.ignore() + return + target = self.current_dir() + idx = (self.view.indexAt(event.position().toPoint()) + if hasattr(event, "position") else self.view.indexAt(event.pos())) + if idx.isValid(): + rec: IsoRecord | None = idx.data(Qt.UserRole) + if rec: + target = rec.path if rec.is_dir else (rec.path.rpartition("/")[0] or "/") + # Comprehension over the URL list, keeping only local file:// drops. + paths = [u.toLocalFile() for u in event.mimeData().urls() if u.toLocalFile()] + if not paths: + return + self.addFilesRequested.emit(target, paths) + event.acceptProposedAction() + + # ------------------------------------------------------------------ context menu + def _on_context(self, pos) -> None: + menu = QMenu(self) + if not self._handler.is_open: + menu.addAction("(no image open)").setEnabled(False) + menu.exec(self.view.viewport().mapToGlobal(pos)) + return + + sel = self.selected_records() + act_new = QAction("New folder…", self) + act_new.setShortcut(QKeySequence.New) + act_new.triggered.connect(lambda: self.newFolderRequested.emit(self.current_dir())) + menu.addAction(act_new) + + if sel: + menu.addSeparator() + act_extract = QAction("Extract…", self) + act_extract.triggered.connect(lambda: self._emit_extract(sel)) + menu.addAction(act_extract) + act_del = QAction("Delete", self) + act_del.setShortcut(QKeySequence.Delete) + act_del.triggered.connect( + lambda: self.deleteRequested.emit([(r.path, r.is_dir) for r in sel])) + menu.addAction(act_del) + if len(sel) == 1: + act_rename = QAction("Rename…", self) + act_rename.triggered.connect( + lambda: self.renameRequested.emit(sel[0].path, sel[0].is_dir)) + menu.addAction(act_rename) + menu.addSeparator() + act_props = QAction("Properties…", self) + act_props.triggered.connect(lambda: self.propertiesRequested.emit(sel[0].path)) + menu.addAction(act_props) + menu.addSeparator() + act_refresh = QAction("Refresh", self) + act_refresh.setShortcut(QKeySequence.Refresh) + act_refresh.triggered.connect(self.refresh) + menu.addAction(act_refresh) + menu.exec(self.view.viewport().mapToGlobal(pos)) + + def _emit_extract(self, records: list[IsoRecord]) -> None: + dest = self._settings.last_dir + self.extractRequested.emit([(r.path, r.is_dir) for r in records], dest) diff --git a/main.py b/main.py new file mode 100755 index 0000000..3940a1e --- /dev/null +++ b/main.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# This file is part of ISO Scalpel. +# Copyright (C) 2025 Jeremy Anderson +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# or write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301 USA. +"""ISO Scalpel -- entry point. + +A PySide6 disc-image editor built on pycdlib, supporting ISO9660, Rock +Ridge, Joliet, UDF and El Torito boot images. + +Usage: + python main.py [file.iso] + python main.py --check-deps # report missing deps and exit + python main.py --no-install-deps # never prompt to install + python main.py --reset-layout # clear saved window/splitter settings and exit + python main.py --debug-layout # start the GUI with verbose widget-tree logging +""" +from __future__ import annotations + +import os +import sys + +# Make the package importable when run directly. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Verify third-party dependencies BEFORE importing the GUI layer, so a +# missing package produces a friendly, actionable message instead of a raw +# traceback. In an interactive terminal the user is offered to install +# the missing packages (with explicit consent for any sudo fallback). +from iso_scalpel._deps import ( + check_dependencies, + ensure_dependencies, + format_missing_report, +) + +# Recognised long options and the flag key they set. A flat lookup table +# replaces the if/elif chain so adding a new switch is a one-line edit. +_FLAG_TABLE: dict[str, str] = { + "--check-deps": "check_only", + "--no-install-deps": "no_install", + "--reset-layout": "reset_layout", + "--debug-layout": "debug_layout", +} + + +def _parse_flags(argv: list[str]) -> tuple[list[str], dict[str, bool]]: + """Separate our own flags from argv; return (positional, flags_dict). + + Unknown arguments are treated as positional and returned for the GUI + layer (which honours a single ISO path argument). + """ + flags = dict.fromkeys(_FLAG_TABLE.values(), False) + rest: list[str] = [] + for arg in argv: + flag_key = _FLAG_TABLE.get(arg) + if flag_key is not None: + flags[flag_key] = True + else: + rest.append(arg) + return rest, flags + + +def _reset_layout() -> int: + """Delete the saved settings file so the next launch uses defaults.""" + from iso_scalpel.config import _config_path + path = _config_path() + if os.path.exists(path): + try: + os.remove(path) + print(f"Removed saved settings: {path}") + except OSError as exc: + sys.stderr.write(f"Could not remove {path}: {exc}\n") + return 1 + else: + print(f"No saved settings at {path} -- nothing to reset.") + print("Re-run `python main.py` to launch with default layout.") + return 0 + + +def main() -> int: + argv = list(sys.argv) + argv, flags = _parse_flags(argv) + + if flags["check_only"]: + missing = check_dependencies() + if missing: + sys.stderr.write(format_missing_report(missing) + "\n") + return 1 + print("All dependencies satisfied.") + return 0 + + if flags["reset_layout"]: + return _reset_layout() + + ensure_dependencies(auto_install=not flags["no_install"]) + + from iso_scalpel.app import run + # If a file was passed on the command line, defer opening it to the + # main window after it is constructed. Handled in run() via argv. + return run(argv, debug_layout=flags["debug_layout"]) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100755 index 0000000..079017a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,104 @@ +# pyproject.toml -- PEP 517/518 build configuration for ISO Scalpel. +# Makes the project pip- and pipx-installable as a proper Python package. + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "iso-scalpel" +version = "1.1.0" +description = "A PySide6 disc-image editor built on pycdlib." +readme = "README.md" +license = { text = "GPL-2.0-or-later" } +authors = [ + { name = "Jeremy Anderson", email = "info@dcos.net" } +] +requires-python = ">=3.10" +dependencies = [ + "PySide6>=6.6", + "pycdlib>=1.13", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: X11 Applications :: Qt", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: System :: Archiving", +] + +[project.urls] +Homepage = "https://dcos.net" +Repository = "https://dcos.net" + +[project.scripts] +# After `pipx install iso-scalpel` the user gets a `iso-scalpel` command. +iso-scalpel = "iso_scalpel.app:run_argv" + +[project.optional-dependencies] +test = ["pytest>=7"] +dev = ["pytest>=7", "ruff>=0.6"] + +[tool.setuptools] +# Use package discovery for the iso_scalpel package. +packages = { find = { include = ["iso_scalpel*"] } } +include-package-data = true + +[tool.setuptools.package-data] +iso_scalpel = ["../resources/**/*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra -q --tb=short" + +# -------------------------------------------------------------------------- +# Ruff configuration -- locks in the coding standards applied during the +# QA pass: PEP 8 style, PEP 585/604 annotations, SEI CERT error-handling +# rules (no blind except, no silent try-except-pass), MISRA-aligned +# immutability rules (no mutable defaults, no function calls in defaults), +# and POSIX-friendly shebang discipline. +# -------------------------------------------------------------------------- +[tool.ruff] +line-length = 100 +extend-exclude = [".venv", "build", "dist"] + +[tool.ruff.lint] +# Default rule set plus the explicit security / modernity families. +# BLE001 is still enabled; legitimate top-level error boundaries opt out +# per-occurrence with `# noqa: BLE001 -- ` so every silence is +# auditable in grep. +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes (unused imports, undefined names) + "I", # isort (import sorting) + "B", # flake8-bugbear (mutable defaults, etc.) + "C4", # flake8-comprehensions + "SIM", # flake8-simplify + "UP", # pyupgrade (PEP 585/604 annotations) + "S", # flake8-bandit (try-except-pass, subprocess, etc.) + "BLE", # flake8-blind-except + "RUF", # ruff-specific (unused noqa, sorted __all__, etc.) + "EXE", # flake8-executable (shebangs) +] + +[tool.ruff.lint.per-file-ignores] +# Tests use ``assert`` (the correct tool, not unittest) and must call +# ``pytest.importorskip`` before importing PySide6/pycdlib so the module +# still loads when those packages are absent. +"tests/**/*.py" = ["S101", "E402"] +# Entry points must insert the project root on sys.path *before* importing +# the package, so module-import-not-at-top is intentional. +"main.py" = ["E402"] +"cli.py" = ["E402"] +# app.py hosts the Qt stylesheet (QSS), where one-selector-per-line is the +# readable form and wrapping hurts clarity. +"iso_scalpel/app.py" = ["E501"] + diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..f23b421 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PySide6>=6.6 +pycdlib>=1.13 diff --git a/resources/icons/README.md b/resources/icons/README.md new file mode 100755 index 0000000..bf6e88b --- /dev/null +++ b/resources/icons/README.md @@ -0,0 +1,21 @@ +# Application icons + +Place SVG/PNG icons here. The application currently uses Qt's built-in +standard icons (via `QStyle.SP_*`) so no custom icons are required to +run; this directory is reserved for future custom artwork. + +Suggested icons (24x24 SVG, monochrome, currentColor): + +- `new.svg` — document with a plus +- `open.svg` — folder opening +- `save.svg` — floppy disk +- `add.svg` — arrow pointing right (into the image) +- `extract.svg` — arrow pointing left (out of the image) +- `folder.svg` — folder with a plus +- `delete.svg` — trash can +- `boot.svg` — disc with a lightning bolt +- `app.svg` — application logo (scalpel over a disc) + +Icons are loaded in `iso_scalpel/main_window.py` via +`QStyle.standardIcon()`; swap them for `QIcon("resources/icons/...")` +to use custom art. diff --git a/tests/README.md b/tests/README.md new file mode 100755 index 0000000..eaf2397 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,43 @@ +# Tests + +ISO Scalpel ships with a focused test suite built on `pytest`. + +## Running + +From the project root: + +```bash +pip install -e '.[dev]' # pytest + ruff +python -m pytest # run everything +python -m pytest tests/test_deps.py -v # one module, verbose +python -m pytest -k OfferToInstall # a single test class +ruff check . # lint (must be clean) +``` + +`tests/test_deps.py` is hermetic — it mocks every third-party import and +never touches the network or filesystem, so it runs anywhere pytest does. + +`tests/test_iso_model.py` and `tests/test_ui.py` require PySide6 and +pycdlib to be importable; they self-skip with `pytest.importorskip` when +either is absent (e.g. on a headless CI without the Qt runtime libs). +On Linux they also need `QT_QPA_PLATFORM=offscreen` and the Qt shared +libraries (`libegl1`, `libgl1`, …); install them with: + +```bash +sudo apt install libegl1 libgl1 libglib2.0-0 libfontconfig1 \ + libdbus-1-3 libxkbcommon0 libxcb-cursor0 +``` + +## What's covered + +| File | Subject | +|-----------------------|------------------------------------------------------------------------------------------------------------------| +| `tests/test_deps.py` | Dependency detection, version comparison, report formatting, install-command construction, interactive install flow (user-accept / user-decline / sudo fallback / sudo failure), `ensure_dependencies()` exit-code behaviour, `--check-deps` CLI flag parsing. | +| `tests/test_iso_model.py` | `IsoTreeModel` root-handling: `rowCount(QModelIndex())` reflects `/` contents directly (no virtual placeholder row), `fetchMore` populates without recursion, `index_from_path('/')` yields the invalid model root, `refresh_parent('/')` re-fetches without crashing, and the ISO pane shows entries + breadcrumb after `refresh()`. | +| `tests/test_ui.py` | Main-window UI contract: single-tab close-button hiding, two-tab restore, splitter sizes give the right pane non-zero width, transfer column structure + wiring, swap action absent from toolbar but bound to `Ctrl+Shift+X`, filter-box max widths, `Boot Image` action icon, `FsPane` column sizing. | + +## Adding tests + +Mirror each new module under `iso_scalpel/` with a `tests/test_.py`. +Use `unittest.mock.patch` for any third-party imports the module touches so +the hermetic part of the suite stays runnable without PySide6 / pycdlib. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..22f58f4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +"""Pytest configuration -- make the project root importable.""" +from __future__ import annotations + +import os +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) diff --git a/tests/test_deps.py b/tests/test_deps.py new file mode 100644 index 0000000..658c5db --- /dev/null +++ b/tests/test_deps.py @@ -0,0 +1,741 @@ +"""Tests for :mod:`iso_scalpel._deps`. + +The tests are hermetic: they never execute a real ``pip install`` or +``pacman -S``. The runner and ``input`` functions are injected so the +install flow can be exercised end-to-end without touching the network +or the filesystem. +""" + +from __future__ import annotations + +import sys +from unittest import mock + +import pytest + +from iso_scalpel import _deps +from iso_scalpel._deps import ( + Dependency, + DistroInfo, + MissingDependency, + _check_in_venv, + _distro_pkg_manager, + _distro_pkg_name, + build_strategies, + check_dependencies, + check_dependency, + detect_distro, + ensure_dependencies, + find_pipx_venv, + format_missing_report, + offer_to_install, +) + + +# ========================================================================== +# Fixtures +# ========================================================================== +@pytest.fixture +def fake_dep() -> Dependency: + return Dependency( + import_name="fake_pkg_xyz", + pip_name="fake-pkg-xyz", + min_version="1.0", + purpose="A made-up package for tests.", + distro_packages={ + "arch": "python-fake-pkg-xyz", + "debian": "python3-fake-pkg-xyz", + "ubuntu": "python3-fake-pkg-xyz", + "fedora": "python3-fake-pkg-xyz", + }, + ) + + +@pytest.fixture +def restore_imports(): + """Snapshot & restore sys.modules so we can fake-remove a package.""" + snapshot = dict(sys.modules) + yield + for k in list(sys.modules.keys()): + if k not in snapshot: + del sys.modules[k] + sys.modules.update(snapshot) + + +# ========================================================================== +# Dependency.matches +# ========================================================================== +class TestVersionMatching: + def test_no_min_version_always_matches(self): + assert Dependency("x", "x").matches("0.0.1") + assert Dependency("x", "x").matches("99.99") + + def test_exact_match(self): + assert Dependency("x", "x", min_version="1.0").matches("1.0") + + def test_higher_patch_matches(self): + d = Dependency("x", "x", min_version="1.0") + assert d.matches("1.0.5") + assert d.matches("1.0.99") + + def test_higher_minor_matches(self): + d = Dependency("x", "x", min_version="1.0") + assert d.matches("1.13.0") + assert d.matches("2.0") + + def test_lower_minor_does_not_match(self): + d = Dependency("x", "x", min_version="6.6") + assert not d.matches("6.5.9") + assert not d.matches("5.99") + + def test_pre_release_suffix_handled(self): + d = Dependency("x", "x", min_version="1.13") + assert d.matches("1.13rc1") + assert d.matches("1.13.0") + + +# ========================================================================== +# DistroInfo +# ========================================================================== +class TestDistroInfo: + def test_matches_id(self): + d = DistroInfo(id="arch", id_like=(), version="", name="Arch Linux") + assert d.matches("arch") + + def test_matches_id_like(self): + # Linux Mint: ID=mint, ID_LIKE=ubuntu debian + d = DistroInfo(id="linuxmint", id_like=("ubuntu", "debian"), + version="21", name="Linux Mint 21") + assert d.matches("ubuntu") # via id_like + assert d.matches("debian") # via id_like + assert d.matches("fedora") is False + + def test_detect_distro_does_not_raise(self): + # Whatever the test host is, detect_distro must return something. + d = detect_distro() + assert isinstance(d, DistroInfo) + assert d.id # always non-empty + + def test_parse_os_release_arch_sample(self, tmp_path): + sample = ( + 'NAME="Arch Linux"\n' + 'PRETTY_NAME="Arch Linux"\n' + 'ID=arch\n' + 'BUILD_ID=rolling\n' + 'ID_LIKE=arch\n' + 'VERSION_ID=""\n' + ) + p = tmp_path / "os-release" + p.write_text(sample) + result = _deps._parse_os_release(str(p)) + assert result["ID"] == "arch" + assert result["PRETTY_NAME"] == "Arch Linux" + assert result["VERSION_ID"] == "" + + def test_parse_os_release_ubuntu_sample(self, tmp_path): + sample = ( + 'PRETTY_NAME="Ubuntu 22.04.3 LTS"\n' + 'NAME="Ubuntu"\n' + 'VERSION_ID="22.04"\n' + 'ID=ubuntu\n' + 'ID_LIKE=debian\n' + ) + p = tmp_path / "os-release" + p.write_text(sample) + result = _deps._parse_os_release(str(p)) + assert result["ID"] == "ubuntu" + assert result["ID_LIKE"] == "debian" + assert result["VERSION_ID"] == "22.04" + + +# ========================================================================== +# Distro package manager / package name resolution +# ========================================================================== +class TestDistroPackages: + def test_arch_uses_pacman(self): + d = DistroInfo(id="arch", id_like=(), version="", name="Arch Linux") + pm = _distro_pkg_manager(d) + assert pm is not None + pm_id, prefix, sudo = pm + assert pm_id == "pacman" + assert prefix == ["pacman", "-S", "--noconfirm"] + assert sudo is True + + def test_ubuntu_uses_apt_via_id_like(self): + # linuxmint ID_LIKE=ubuntu debian -- should resolve to apt + d = DistroInfo(id="linuxmint", id_like=("ubuntu", "debian"), + version="21", name="Linux Mint") + pm = _distro_pkg_manager(d) + assert pm is not None + assert pm[0] == "apt" + + def test_fedora_uses_dnf(self): + d = DistroInfo(id="fedora", id_like=(), version="40", name="Fedora") + pm = _distro_pkg_manager(d) + assert pm is not None + assert pm[0] == "dnf" + assert pm[2] is True # needs sudo + + def test_unknown_distro_returns_none(self): + d = DistroInfo(id="unknown", id_like=(), version="", name="Unknown") + assert _distro_pkg_manager(d) is None + + def test_distro_pkg_name_uses_id_first(self, fake_dep): + d = DistroInfo(id="arch", id_like=(), version="", name="Arch") + assert _distro_pkg_name(fake_dep, d) == "python-fake-pkg-xyz" + + def test_distro_pkg_name_falls_back_to_id_like(self, fake_dep): + # Manjaro: ID=manjaro, ID_LIKE=arch + d = DistroInfo(id="manjaro", id_like=("arch",), + version="", name="Manjaro") + assert _distro_pkg_name(fake_dep, d) == "python-fake-pkg-xyz" + + def test_distro_pkg_name_returns_none_when_no_mapping(self, fake_dep): + d = DistroInfo(id="gentoo", id_like=(), version="", name="Gentoo") + assert _distro_pkg_name(fake_dep, d) is None + + +# ========================================================================== +# check_dependency / check_dependencies +# ========================================================================== +class TestCheckDependency: + def test_missing_package_returns_not_installed(self, fake_dep, restore_imports): + sys.modules.pop(fake_dep.import_name, None) + result = check_dependency(fake_dep) + assert result is not None + assert result.reason == "not_installed" + assert result.dep is fake_dep + + def test_satisfied_package_returns_none(self, restore_imports): + dep = Dependency("sys", "sys", min_version=None, purpose="stdlib") + assert check_dependency(dep) is None + + def test_version_too_low(self, restore_imports): + fake_mod = mock.MagicMock() + fake_mod.__version__ = "0.9" + with mock.patch.dict(sys.modules, {"fake_low_v": fake_mod}): + dep = Dependency("fake_low_v", "fake-low-v", min_version="1.0") + result = check_dependency(dep) + assert result is not None + assert result.reason == "version_too_low" + assert result.found_version == "0.9" + + def test_import_error_inside_package(self, fake_dep, restore_imports): + def boom(name, *args, **kwargs): + raise ImportError("No module named 'some_other_dep'") + with mock.patch("importlib.import_module", side_effect=boom): + result = check_dependency(fake_dep) + assert result is not None + assert result.reason == "import_error" + + +class TestCheckDependencies: + def test_all_present(self, restore_imports): + assert check_dependencies([Dependency("sys", "sys")]) == [] + + def test_mixed(self, fake_dep, restore_imports): + deps = [Dependency("sys", "sys"), fake_dep] + missing = check_dependencies(deps) + assert len(missing) == 1 + assert missing[0].dep is fake_dep + + +# ========================================================================== +# build_strategies +# ========================================================================== +class TestBuildStrategies: + def test_arch_strategy_order(self, fake_dep, tmp_path): + """On Arch the strategies should be: venv, pacman, pip-break-system.""" + missing = [MissingDependency(dep=fake_dep, reason="not_installed")] + d = DistroInfo(id="arch", id_like=(), version="", name="Arch Linux") + strats = build_strategies(missing, d, project_root=tmp_path) + # tmp_path has no .venv -> venv bootstrap strategy is offered. + assert any("venv" in s.description for s in strats) + # pacman strategy must be present with the python-* package name. + pacman_strats = [s for s in strats if "pacman" in " ".join(s.command)] + assert len(pacman_strats) == 1 + pm_cmd = pacman_strats[0].command + assert pm_cmd[0] == "sudo" + assert "pacman" in pm_cmd + assert "python-fake-pkg-xyz" in pm_cmd + # pip --break-system-packages is the last-resort fallback. + last = strats[-1] + assert "--break-system-packages" in last.command + # No strategy without sudo should accidentally contain sudo. + for s in strats: + if not s.requires_sudo: + assert "sudo" not in s.command + + def test_ubuntu_strategy_uses_apt(self, fake_dep, tmp_path): + missing = [MissingDependency(dep=fake_dep, reason="not_installed")] + d = DistroInfo(id="ubuntu", id_like=("debian",), + version="22.04", name="Ubuntu 22.04") + strats = build_strategies(missing, d, project_root=tmp_path) + apt_strats = [s for s in strats if "apt-get" in " ".join(s.command)] + assert len(apt_strats) == 1 + assert "python3-fake-pkg-xyz" in apt_strats[0].command + + def test_unknown_distro_skips_distro_manager(self, fake_dep, tmp_path): + missing = [MissingDependency(dep=fake_dep, reason="not_installed")] + d = DistroInfo(id="unknown", id_like=(), version="", name="Unknown") + strats = build_strategies(missing, d, project_root=tmp_path) + # venv + pip --break-system-packages, but NO pacman/apt/dnf. + assert any("venv" in s.description for s in strats) + assert any("--break-system-packages" in s.command for s in strats) + for s in strats: + for forbidden in ("pacman", "apt-get", "dnf", "zypper"): + assert forbidden not in " ".join(s.command) + + def test_existing_venv_strategy(self, fake_dep, tmp_path): + # Create a fake .venv/bin/python so the "existing venv" path triggers. + venv_bin = tmp_path / ".venv" / "bin" + venv_bin.mkdir(parents=True) + (venv_bin / "python").write_text("#!/bin/sh\nexec python3\n") + (venv_bin / "python").chmod(0o755) + missing = [MissingDependency(dep=fake_dep, reason="not_installed")] + d = DistroInfo(id="arch", id_like=(), version="", name="Arch") + strats = build_strategies(missing, d, project_root=tmp_path) + venv_strat = next(s for s in strats if "venv" in s.description) + assert str(tmp_path / ".venv" / "bin" / "python") in venv_strat.command + + def test_strategy_includes_version_pin_for_missing(self, fake_dep, tmp_path): + missing = [MissingDependency(dep=fake_dep, reason="not_installed")] + d = DistroInfo(id="arch", id_like=(), version="", name="Arch") + strats = build_strategies(missing, d, project_root=tmp_path) + # The venv strategy's command must pin the minimum version. + venv_strat = next(s for s in strats if "venv" in s.description) + # The pip spec appears in the bash -c string. + bash_cmd = " ".join(venv_strat.command) + assert "fake-pkg-xyz>=1.0" in bash_cmd + + def test_strategy_omits_pin_for_version_upgrade(self, fake_dep, tmp_path): + # When the dep is present but too old, we want --upgrade, not a pin. + missing = [MissingDependency(dep=fake_dep, reason="version_too_low", + found_version="0.5")] + d = DistroInfo(id="arch", id_like=(), version="", name="Arch") + strats = build_strategies(missing, d, project_root=tmp_path) + venv_strat = next(s for s in strats if "venv" in s.description) + bash_cmd = " ".join(venv_strat.command) + assert "fake-pkg-xyz>=1.0" not in bash_cmd + assert "fake-pkg-xyz" in bash_cmd + + +# ========================================================================== +# pipx detection +# ========================================================================== +class TestPipxDetection: + def test_find_pipx_venv_missing(self, tmp_path, monkeypatch): + # Point PIPX_HOME at a temp dir with no venvs. + monkeypatch.setenv("PIPX_HOME", str(tmp_path)) + assert find_pipx_venv("pycdlib") is None + + def test_find_pipx_venv_present(self, tmp_path, monkeypatch): + venv = tmp_path / "venvs" / "pycdlib" + (venv / "lib").mkdir(parents=True) + monkeypatch.setenv("PIPX_HOME", str(tmp_path)) + assert find_pipx_venv("pycdlib") == venv + + def test_pipx_misinstall_surfaces_in_report(self, fake_dep, tmp_path, monkeypatch): + # Pretend pycdlib (well, fake-pkg-xyz) is in a pipx venv. + venv = tmp_path / "venvs" / fake_dep.pip_name + (venv / "lib").mkdir(parents=True) + monkeypatch.setenv("PIPX_HOME", str(tmp_path)) + m = MissingDependency(dep=fake_dep, reason="not_installed") + report = format_missing_report( + [m], + DistroInfo(id="arch", id_like=(), version="", name="Arch Linux"), + ) + assert "pipx venv" in report + assert fake_dep.pip_name in report + + +# ========================================================================== +# format_missing_report +# ========================================================================== +class TestFormatReport: + def test_empty_missing_returns_empty_string(self): + assert format_missing_report([]) == "" + + def test_not_installed_message_includes_distro_hint(self, fake_dep): + m = MissingDependency(dep=fake_dep, reason="not_installed") + report = format_missing_report( + [m], + DistroInfo(id="arch", id_like=(), version="", name="Arch Linux"), + ) + assert "fake-pkg-xyz" in report + assert "not installed" in report + # Arch hint should appear with the python-* package name. + assert "pacman -S" in report + assert "python-fake-pkg-xyz" in report + # And the venv alternative. + assert "venv" in report + + def test_version_too_low_message(self, fake_dep): + m = MissingDependency(dep=fake_dep, reason="version_too_low", found_version="0.5") + report = format_missing_report([m]) + assert "found version 0.5" in report + assert "pip install --upgrade" in report + + def test_import_error_message(self, fake_dep): + m = MissingDependency(dep=fake_dep, reason="import_error", import_error="boom!") + report = format_missing_report([m]) + assert "failed to import" in report + assert "boom!" in report + + def test_unknown_distro_skips_native_hint(self, fake_dep): + m = MissingDependency(dep=fake_dep, reason="not_installed") + report = format_missing_report( + [m], + DistroInfo(id="unknown", id_like=(), version="", name="Unknown"), + ) + # No pacman/apt hint when the distro is unknown. + assert "pacman" not in report + assert "apt" not in report + # But the generic pip hint is still there. + assert "pip install" in report + + +# ========================================================================== +# offer_to_install +# ========================================================================== +class TestOfferToInstall: + def _arch(self): + return DistroInfo(id="arch", id_like=(), version="", name="Arch Linux") + + def test_empty_missing_returns_true(self): + assert offer_to_install([], interactive=True) is True + + def test_non_interactive_returns_false(self, fake_dep): + m = MissingDependency(dep=fake_dep, reason="not_installed") + assert offer_to_install([m], interactive=False, distro=self._arch()) is False + + def test_user_declines_first_strategy(self, fake_dep, capsys, tmp_path): + m = MissingDependency(dep=fake_dep, reason="not_installed") + # Answer "no" to every prompt -- should exhaust all strategies. + answers = iter(["n"] * 10) + result = offer_to_install( + [m], interactive=True, distro=self._arch(), + input_fn=lambda _q: next(answers), + runner=lambda cmd: 0, + project_root=tmp_path, + ) + assert result is False + out = capsys.readouterr().out + assert "No strategy succeeded" in out + + def test_user_accepts_first_strategy_success(self, fake_dep, tmp_path, capsys): + m = MissingDependency(dep=fake_dep, reason="not_installed") + with mock.patch.object(_deps, "check_dependencies", return_value=[]): + result = offer_to_install( + [m], interactive=True, distro=self._arch(), + input_fn=lambda _q: "y", + runner=lambda cmd: 0, + project_root=tmp_path, + ) + assert result is True + out = capsys.readouterr().out + assert "venv" in out # first strategy offered was the venv one + + def test_first_fails_then_succeeds_with_pacman(self, fake_dep, tmp_path, capsys): + """User declines venv, then accepts pacman -- pacman succeeds.""" + m = MissingDependency(dep=fake_dep, reason="not_installed") + answers = iter(["n", "y"]) # decline venv, accept pacman + + call_count = {"n": 0} + def runner(cmd): + call_count["n"] += 1 + # First runner call is pacman (venv was declined). + return 0 if call_count["n"] == 1 else 1 + + with mock.patch.object(_deps, "check_dependencies", return_value=[]): + result = offer_to_install( + [m], interactive=True, distro=self._arch(), + input_fn=lambda _q: next(answers), + runner=runner, + project_root=tmp_path, + ) + assert result is True + out = capsys.readouterr().out + # The user should have seen the pacman command with sudo prefix. + assert "sudo" in out + assert "pacman" in out + assert "python-fake-pkg-xyz" in out + + def test_pipx_misinstall_warning_shown(self, fake_dep, tmp_path, monkeypatch, capsys): + # Simulate pycdlib (fake-pkg-xyz) being in a pipx venv. + venv = tmp_path / "venvs" / fake_dep.pip_name + (venv / "lib").mkdir(parents=True) + monkeypatch.setenv("PIPX_HOME", str(tmp_path)) + m = MissingDependency(dep=fake_dep, reason="not_installed") + answers = iter(["n"] * 10) + offer_to_install( + [m], interactive=True, distro=self._arch(), + input_fn=lambda _q: next(answers), + runner=lambda cmd: 0, + project_root=tmp_path / "project", + ) + out = capsys.readouterr().out + assert "pipx venv" in out + assert "isolated" in out + + +# ========================================================================== +# ensure_dependencies +# ========================================================================== +class TestEnsureDependencies: + def test_all_satisfied_returns_silently(self): + ensure_dependencies(deps=[Dependency("sys", "sys")]) + + def test_missing_in_non_interactive_raises_and_prints( + self, fake_dep, restore_imports, capsys + ): + with pytest.raises(SystemExit) as exc: + ensure_dependencies( + deps=[fake_dep], interactive=False, auto_install=True, + ) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "fake-pkg-xyz" in err + # Non-interactive hint must mention venv on PEP 668 distros. + assert "venv" in err + + def test_no_auto_install_just_reports(self, fake_dep, restore_imports, capsys): + with pytest.raises(SystemExit) as exc: + ensure_dependencies( + deps=[fake_dep], interactive=True, auto_install=False, + ) + assert exc.value.code == 1 + + def test_auto_install_user_declines_raises(self, fake_dep, restore_imports, capsys): + with mock.patch.object(_deps, "offer_to_install", return_value=False), \ + pytest.raises(SystemExit) as exc: + ensure_dependencies( + deps=[fake_dep], interactive=True, auto_install=True, + ) + assert exc.value.code == 1 + + +# ========================================================================== +# CLI flag parsing (main.py) +# ========================================================================== +class TestCheckDepsCLI: + def test_parse_dep_flags(self): + import main as main_mod + rest, flags = main_mod._parse_flags( + ["main.py", "--check-deps", "file.iso"] + ) + assert flags["check_only"] is True + assert flags["no_install"] is False + assert rest == ["main.py", "file.iso"] + + def test_parse_no_install_flag(self): + import main as main_mod + rest, flags = main_mod._parse_flags( + ["main.py", "--no-install-deps"] + ) + assert flags["no_install"] is True + assert flags["check_only"] is False + assert rest == ["main.py"] + + def test_parse_reset_layout_flag(self): + import main as main_mod + rest, flags = main_mod._parse_flags(["main.py", "--reset-layout"]) + assert flags["reset_layout"] is True + assert rest == ["main.py"] + + def test_parse_debug_layout_flag(self): + import main as main_mod + rest, flags = main_mod._parse_flags(["main.py", "--debug-layout"]) + assert flags["debug_layout"] is True + assert rest == ["main.py"] + + +# ========================================================================== +# Regression: shell quoting of `>=` in venv bootstrap command +# ========================================================================== +class TestShellQuoting: + """Regression: ``pycdlib>=1.13`` was being parsed by bash as an output + redirection to a file named ``=1.13``, silently swallowing the version + pin and the install output. The spec must now be single-quoted. + """ + + def test_venv_bootstrap_command_quotes_version_pin(self, fake_dep, tmp_path): + missing = [MissingDependency(dep=fake_dep, reason="not_installed")] + d = DistroInfo(id="arch", id_like=(), version="", name="Arch Linux") + strats = build_strategies(missing, d, project_root=tmp_path) + venv_strat = next(s for s in strats if "venv" in s.description) + # The bash -c string must contain a single-quoted 'fake-pkg-xyz>=1.0'. + bash_cmd = " ".join(venv_strat.command) + assert "'fake-pkg-xyz>=1.0'" in bash_cmd, ( + "version pin must be shell-quoted to avoid >= redirection" + ) + + def test_venv_bootstrap_command_does_not_have_unquoted_redirect(self, fake_dep, tmp_path): + """The unquoted form ``pycdlib>=1.13`` must NOT appear anywhere in + the bash -c command (it would be parsed as output redirection). + """ + missing = [MissingDependency(dep=fake_dep, reason="not_installed")] + d = DistroInfo(id="arch", id_like=(), version="", name="Arch Linux") + strats = build_strategies(missing, d, project_root=tmp_path) + venv_strat = next(s for s in strats if "venv" in s.description) + # Reconstruct the bash -c payload (last element of the argv list). + bash_cmd = venv_strat.command[-1] + # The unquoted form would be: install pycdlib>=1.13 (no quotes around >=) + assert " install fake-pkg-xyz>=1.0 " not in bash_cmd + assert " install fake-pkg-xyz>=1.0$" not in bash_cmd + + +# ========================================================================== +# Regression: importlib cache invalidation after install +# ========================================================================== +class TestImportlibCacheInvalidation: + """Regression: after the first failed import, importlib caches the + negative result. A subsequent successful ``pip install`` was still + reported as ``not_installed`` because the cached failure was returned. + """ + + def test_invalidate_caches_called_after_install(self, fake_dep, tmp_path): + m = MissingDependency(dep=fake_dep, reason="not_installed") + arch = DistroInfo(id="arch", id_like=(), version="", name="Arch") + with mock.patch.object(_deps, "check_dependencies", return_value=[]), \ + mock.patch("importlib.invalidate_caches") as invalidate: + offer_to_install( + [m], interactive=True, distro=arch, + input_fn=lambda _q: "y", + runner=lambda cmd: 0, + project_root=tmp_path, + ) + invalidate.assert_called() + + def test_sys_modules_entries_cleared_after_install(self, fake_dep, tmp_path, restore_imports): + # Stash a sentinel in sys.modules to simulate a cached failure. + sys.modules["fake_pkg_xyz"] = None # None means "known to not exist" + m = MissingDependency(dep=fake_dep, reason="not_installed") + arch = DistroInfo(id="arch", id_like=(), version="", name="Arch") + with mock.patch.object(_deps, "check_dependencies", return_value=[]): + offer_to_install( + [m], interactive=True, distro=arch, + input_fn=lambda _q: "y", + runner=lambda cmd: 0, + project_root=tmp_path, + ) + # The cached negative entry must have been removed. + assert "fake_pkg_xyz" not in sys.modules + + +# ========================================================================== +# Regression: venv-targeted install re-checks via venv python +# ========================================================================== +class TestVenvRecheckAndReExec: + """Regression: after a venv-targeted install, the current interpreter + cannot see the new packages (they live in ./.venv). The flow must + detect this, re-check using the venv python, and offer to re-exec + main.py from the venv. + """ + + def _arch(self): + return DistroInfo(id="arch", id_like=(), version="", name="Arch Linux") + + def test_check_in_venv_true_when_imports_succeed(self, tmp_path): + # Create a fake "venv python" shell script that exits 0 on any -c. + venv_bin = tmp_path / ".venv" / "bin" + venv_bin.mkdir(parents=True) + venv_python = venv_bin / "python" + venv_python.write_text("#!/bin/sh\nexit 0\n") + venv_python.chmod(0o755) + dep = Dependency("pycdlib", "pycdlib") + assert _check_in_venv(venv_python, [dep]) is True + + def test_check_in_venv_false_when_imports_fail(self, tmp_path): + venv_bin = tmp_path / ".venv" / "bin" + venv_bin.mkdir(parents=True) + venv_python = venv_bin / "python" + venv_python.write_text("#!/bin/sh\nexit 1\n") + venv_python.chmod(0o755) + dep = Dependency("pycdlib", "pycdlib") + assert _check_in_venv(venv_python, [dep]) is False + + def test_venv_install_offers_re_exec_when_user_accepts( + self, fake_dep, tmp_path, capsys, monkeypatch + ): + """When venv install succeeds and venv python can import the dep, + offer to re-exec; if user says yes, call os.execv.""" + # Set up a fake venv python that exits 0 (imports succeed). + venv_bin = tmp_path / ".venv" / "bin" + venv_bin.mkdir(parents=True) + venv_python = venv_bin / "python" + venv_python.write_text("#!/bin/sh\nexit 0\n") + venv_python.chmod(0o755) + + m = MissingDependency(dep=fake_dep, reason="not_installed") + arch = self._arch() + + # check_dependencies (current interpreter) still reports missing. + with mock.patch.object(_deps, "check_dependencies", return_value=[m]): + # Track whether os.execv was called. + execv_calls = [] + monkeypatch.setattr( + "os.execv", + lambda path, argv: execv_calls.append((path, argv)), + ) + offer_to_install( + [m], interactive=True, distro=arch, + input_fn=lambda _q: "y", # accept install, then accept re-exec + runner=lambda cmd: 0, + project_root=tmp_path, + ) + assert len(execv_calls) == 1 + path, argv = execv_calls[0] + assert path == str(venv_python) + assert argv[0] == str(venv_python) + + def test_venv_install_user_declines_re_exec( + self, fake_dep, tmp_path, capsys, monkeypatch + ): + """When venv install succeeds but user declines re-exec, return + False (not success) and don't call os.execv.""" + venv_bin = tmp_path / ".venv" / "bin" + venv_bin.mkdir(parents=True) + venv_python = venv_bin / "python" + venv_python.write_text("#!/bin/sh\nexit 0\n") + venv_python.chmod(0o755) + + m = MissingDependency(dep=fake_dep, reason="not_installed") + arch = self._arch() + # Two prompts: "y" to install, "n" to re-exec. + answers = iter(["y", "n"]) + + execv_calls = [] + monkeypatch.setattr( + "os.execv", lambda path, argv: execv_calls.append((path, argv)) + ) + with mock.patch.object(_deps, "check_dependencies", return_value=[m]): + result = offer_to_install( + [m], interactive=True, distro=arch, + input_fn=lambda _q: next(answers), + runner=lambda cmd: 0, + project_root=tmp_path, + ) + assert result is False + assert execv_calls == [] + out = capsys.readouterr().out + assert "not restarting" in out.lower() + + def test_venv_install_with_missing_venv_python_falls_through( + self, fake_dep, tmp_path, capsys + ): + """If the venv somehow didn't get created (install rc=0 but no + venv python on disk), don't crash -- just fall through to the + normal 'still missing' path.""" + m = MissingDependency(dep=fake_dep, reason="not_installed") + arch = self._arch() + # No .venv on disk. check_dependencies still reports missing. + with mock.patch.object(_deps, "check_dependencies", return_value=[m]): + result = offer_to_install( + [m], interactive=True, distro=arch, + input_fn=lambda _q: "y", + runner=lambda cmd: 0, + project_root=tmp_path, + ) + # Should fall through to next strategy and eventually fail. + assert result is False diff --git a/tests/test_iso_model.py b/tests/test_iso_model.py new file mode 100644 index 0000000..31d3f56 --- /dev/null +++ b/tests/test_iso_model.py @@ -0,0 +1,215 @@ +"""Tests for :class:`IsoTreeModel` root-handling and lazy fetch. + +The model root's children are the entries of the ISO ``/`` directory, +surfaced directly so the view displays them without manual expansion. +rowCount() returns 0 for an unloaded root and lets Qt drive +canFetchMore()/fetchMore(); index_from_path('/') yields the invalid +model-root index; refresh_parent('/') drops and re-fetches the virtual +root without crashing. +""" + +from __future__ import annotations + +import os +import sys +from io import BytesIO +from pathlib import Path + +import pytest + +PySide6 = pytest.importorskip("PySide6") +pycdlib = pytest.importorskip("pycdlib") + +from PySide6.QtCore import QModelIndex +from PySide6.QtWidgets import QApplication + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + + +@pytest.fixture(scope="module") +def qapp(): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + app = QApplication.instance() or QApplication([]) + yield app + + +@pytest.fixture +def tiny_iso_path(tmp_path): + """Create a small ISO with two files and one subdirectory at /.""" + iso_path = tmp_path / "tiny.iso" + iso = pycdlib.PyCdlib() + iso.new() + iso.add_fp(BytesIO(b"hello"), 5, "/HELLO.TXT;1") + iso.add_fp(BytesIO(b"world"), 5, "/WORLD.TXT;1") + iso.add_directory("/SUBDIR") + iso.add_fp(BytesIO(b"sub"), 3, "/SUBDIR/INSIDE.TXT;1") + iso.write(str(iso_path)) + iso.close() + return str(iso_path) + + +@pytest.fixture +def open_handler(tiny_iso_path): + from iso_scalpel.iso_handler import IsoHandler + h = IsoHandler() + h.open(tiny_iso_path) + yield h + h.close() + + +# ========================================================================== +# IsoTreeModel: rowCount(QModelIndex()) must reflect / contents directly +# ========================================================================== +class TestModelRootShowsDirectoryContents: + """The model root's children are the entries of the ISO ``/`` directory. + + rowCount(QModelIndex()) reflects the count of root entries directly -- + there is no virtual ``"/"`` placeholder row that the user must expand. + """ + + def test_rowCount_of_model_root_is_zero_before_fetch(self, open_handler, qapp): + from iso_scalpel.iso_model import IsoTreeModel + m = IsoTreeModel(open_handler) + # Before fetchMore is called, the root isn't loaded -- rowCount + # must return 0 (not 1) so the view triggers canFetchMore/fetchMore. + assert m.rowCount(QModelIndex()) == 0 + + def test_canFetchMore_returns_true_for_unloaded_root(self, open_handler, qapp): + from iso_scalpel.iso_model import IsoTreeModel + m = IsoTreeModel(open_handler) + assert m.canFetchMore(QModelIndex()) is True + + def test_fetchMore_populates_root_children(self, open_handler, qapp): + from iso_scalpel.iso_model import IsoTreeModel + m = IsoTreeModel(open_handler) + m.fetchMore(QModelIndex()) + # The ISO has 3 entries at /: HELLO.TXT, WORLD.TXT, SUBDIR + assert m.rowCount(QModelIndex()) == 3 + + def test_root_children_names_are_correct(self, open_handler, qapp): + from iso_scalpel.iso_model import IsoTreeModel + m = IsoTreeModel(open_handler) + m.fetchMore(QModelIndex()) + names = [m.index(r, 0, QModelIndex()).data() for r in range(m.rowCount(QModelIndex()))] + assert set(names) == {"HELLO.TXT", "WORLD.TXT", "SUBDIR"} + + def test_no_infinite_recursion_in_rowCount(self, open_handler, qapp): + """rowCount() must never call fetchMore(). + + Calling fetchMore() from inside rowCount() recurses through + beginInsertRows and blows the stack. The contract is: rowCount() + returns 0 for an unloaded root; Qt then calls canFetchMore() / + fetchMore() on its own schedule. + """ + from iso_scalpel.iso_model import IsoTreeModel + m = IsoTreeModel(open_handler) + # Calling rowCount on an unloaded root must return immediately + # without recursing. + result = m.rowCount(QModelIndex()) + assert isinstance(result, int) + + +# ========================================================================== +# index_from_path: "/" maps to the model root (invalid QModelIndex) +# ========================================================================== +class TestIndexFromPath: + """``index_from_path("/")`` must return the model root (an invalid + :class:`QModelIndex`) -- not ``createIndex(0, 0, self._root)`` -- so + that the view's default root index correctly shows "/" contents. + """ + + def test_root_path_returns_invalid_index(self, open_handler, qapp): + from iso_scalpel.iso_model import IsoTreeModel + m = IsoTreeModel(open_handler) + idx = m.index_from_path("/") + assert not idx.isValid(), ( + "index_from_path('/') should return the model root " + "(invalid QModelIndex), not a createIndex" + ) + + def test_subdir_path_returns_valid_index(self, open_handler, qapp): + from iso_scalpel.iso_model import IsoTreeModel + m = IsoTreeModel(open_handler) + m.fetchMore(QModelIndex()) + idx = m.index_from_path("/SUBDIR") + assert idx.isValid() + assert idx.data() == "SUBDIR" + + def test_nonexistent_path_returns_invalid_index(self, open_handler, qapp): + from iso_scalpel.iso_model import IsoTreeModel + m = IsoTreeModel(open_handler) + m.fetchMore(QModelIndex()) + idx = m.index_from_path("/DOES_NOT_EXIST") + assert not idx.isValid() + + +# ========================================================================== +# refresh_parent: handles the root path correctly +# ========================================================================== +class TestRefreshParent: + """``refresh_parent("/")`` must not crash when the model's + ``index_from_path("/")`` returns an invalid index. + """ + + def test_refresh_parent_root_does_not_crash(self, open_handler, qapp): + from iso_scalpel.iso_model import IsoTreeModel + m = IsoTreeModel(open_handler) + m.fetchMore(QModelIndex()) + # Should not raise. + m.refresh_parent("/") + # After refresh, root should be unloaded again. + assert m.rowCount(QModelIndex()) == 0 + # Re-fetching should still work. + m.fetchMore(QModelIndex()) + assert m.rowCount(QModelIndex()) == 3 + + def test_refresh_parent_subdir(self, open_handler, qapp): + from iso_scalpel.iso_model import IsoTreeModel + m = IsoTreeModel(open_handler) + m.fetchMore(QModelIndex()) + subdir_idx = m.index_from_path("/SUBDIR") + assert subdir_idx.isValid() + m.fetchMore(subdir_idx) + assert m.rowCount(subdir_idx) == 1 # INSIDE.TXT + m.refresh_parent("/SUBDIR") + # After refresh, subdir is unloaded. + assert m.rowCount(subdir_idx) == 0 + + +# ========================================================================== +# End-to-end: IsoPane displays entries after open +# ========================================================================== +class TestIsoPaneShowsEntriesAfterOpen: + """End-to-end regression: after ``handler.open()`` and + ``pane.refresh()``, the ISO pane's view must report a non-zero + row count for the model root. + """ + + def test_pane_shows_entries_after_refresh(self, open_handler, qapp): + from iso_scalpel.config import Settings + from iso_scalpel.widgets.iso_pane import IsoPane + s = Settings() + pane = IsoPane(open_handler, s) + pane.refresh() + # Force the view's model to fetch the root children. + pane.view.model().fetchMore(pane.view.rootIndex()) + qapp.processEvents() + # The view's root index is the model root (invalid QModelIndex); + # its row count must be 3 (HELLO.TXT, WORLD.TXT, SUBDIR). + rc = pane.view.model().rowCount(pane.view.rootIndex()) + assert rc == 3, f"ISO pane should show 3 entries, got {rc}" + + def test_pane_breadcrumb_shows_iso_filename_after_open(self, open_handler, qapp): + """The breadcrumb bar at the top of the ISO pane shows the ISO + filename after opening. The pane must reach the + ``_rebuild_breadcrumbs`` call path on refresh. + """ + from iso_scalpel.config import Settings + from iso_scalpel.widgets.iso_pane import IsoPane + s = Settings() + pane = IsoPane(open_handler, s) + pane.refresh() + # The breadcrumb frame must have at least one child widget (the + # root button showing the ISO filename). + assert pane._crumb_layout.count() > 0 diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 0000000..ffed3a9 --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,271 @@ +"""Tests for the main-window UI contract. + +Covers: + +* Single-tab panes show no close button; a second tab restores it on every + tab; removing the second tab hides it again. +* Default splitter sizes give the right (ISO) pane a non-zero width on + first launch and keep the transfer column narrow. +* Toolbar and pane column-sizing contracts. + +Requires PySide6 + pycdlib; skipped otherwise. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +# Skip the entire module if PySide6 or pycdlib isn't installed -- the +# rest of the test suite (test_deps.py) must still run. +PySide6 = pytest.importorskip("PySide6") +pycdlib = pytest.importorskip("pycdlib") + +from PySide6.QtWidgets import QApplication, QHeaderView, QTabBar, QToolBar, QWidget + +# Make the project root importable. +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +# Construct a single QApplication for all tests in this module. +@pytest.fixture(scope="module") +def qapp(): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + app = QApplication.instance() or QApplication([]) + yield app + + +@pytest.fixture +def main_window(qapp): + from iso_scalpel.main_window import MainWindow + win = MainWindow() + yield win + win.close() + + +# ========================================================================== +# Single-tab close button is hidden +# ========================================================================== +class TestPaneTabBarCloseButton: + """The close-button hiding is deferred to the next event-loop + iteration (via ``QTimer.singleShot(0, ...)``) so it doesn't + interfere with Qt's internal tab-insert/remove layout. Tests must + call ``qapp.processEvents()`` to let the deferred call run before + asserting on the button state. + """ + + def test_single_tab_has_no_close_button(self, main_window, qapp): + """The screenshot showed a confusing red 'X' next to the 'FS' tab + on a single-tab pane. The close button must be None when there + is only one tab. + """ + qapp.processEvents() # let deferred _refresh_close_button run + tb = main_window._left_tabs.tabBar() + assert tb.count() == 1 + assert tb.tabButton(0, QTabBar.RightSide) is None + + def test_two_tabs_have_close_buttons(self, main_window, qapp): + """When a second tab is added, every tab must show its close + button (so the user can close either one). + """ + qapp.processEvents() + tb = main_window._left_tabs.tabBar() + extra = QWidget() + main_window._left_tabs.addTab(extra, "FS2") + qapp.processEvents() # let deferred _refresh_close_button run + try: + assert tb.count() == 2 + assert tb.tabButton(0, QTabBar.RightSide) is not None + assert tb.tabButton(1, QTabBar.RightSide) is not None + finally: + # Clean up so other tests see a clean state. + main_window._left_tabs.removeTab(1) + qapp.processEvents() + + def test_close_button_hidden_again_after_removing_second_tab(self, main_window, qapp): + """Removing the second tab must re-hide the close button on the + lone remaining tab. + """ + qapp.processEvents() + tb = main_window._left_tabs.tabBar() + extra = QWidget() + main_window._left_tabs.addTab(extra, "FS2") + qapp.processEvents() + assert tb.tabButton(0, QTabBar.RightSide) is not None + main_window._left_tabs.removeTab(1) + qapp.processEvents() # let deferred _refresh_close_button run + assert tb.count() == 1 + assert tb.tabButton(0, QTabBar.RightSide) is None + + +# ========================================================================== +# Default splitter sizes +# ========================================================================== +class TestSplitterSizes: + def test_splitter_has_three_sections(self, main_window): + """Splitter must have 3 sections: left pane, transfer column, right pane.""" + assert main_window._splitter.count() == 3 + + def test_transfer_column_section_is_narrow(self, main_window): + """The middle transfer column must stay narrow (<= 50px) so the + two panes get the bulk of the width. + """ + sizes = main_window._splitter.sizes() + assert sizes[1] <= 50, f"transfer column too wide: {sizes[1]}" + + def test_right_pane_gets_nonzero_width(self, main_window): + """Critical: the right (ISO) pane must get a non-zero initial + width -- this was the regression that made the right pane appear + blank/missing in the screenshot. + """ + sizes = main_window._splitter.sizes() + assert sizes[2] > 100, f"right pane too narrow: {sizes[2]}" + + def test_panes_split_width_roughly_equally(self, main_window): + """Both panes should get roughly equal widths so neither side is + starved when the window is resized. + """ + sizes = main_window._splitter.sizes() + left, _mid, right = sizes + # Allow up to 40% asymmetry (in case one pane has a slightly + # different min-size hint), but they should be in the same order + # of magnitude. + assert left > 80 and right > 80 + ratio = min(left, right) / max(left, right) + assert ratio > 0.6, f"panes not balanced: left={left}, right={right}, ratio={ratio:.2f}" + + +# ========================================================================== +# Boot Image action has an icon +# ========================================================================== +class TestToolbarIcons: + def test_boot_action_has_icon(self, main_window): + """The 'Boot Image…' toolbar button was text-only in the + screenshot, inconsistent with the other toolbar buttons. It + must now carry a standard icon. + """ + assert not main_window.act_boot.icon().isNull(), ( + "Boot Image action should have a non-null icon" + ) + + +# ========================================================================== +# FsPane column sizing +# ========================================================================== +class TestFsPaneColumns: + def test_name_column_uses_stretch_mode(self, main_window): + """The Name column must use Stretch so it fills available width + and the other columns (Size, Type, Date Modified) aren't cramped. + """ + hdr = main_window._fs_pane.view.header() + assert hdr.sectionResizeMode(0) == QHeaderView.Stretch + + def test_size_column_uses_resize_to_contents(self, main_window): + """The Size column should auto-fit its contents (regression: in + the screenshot the Date Modified column was cramped because all + columns used the default Interactive mode). + """ + hdr = main_window._fs_pane.view.header() + if hdr.count() >= 2: + assert hdr.sectionResizeMode(1) == QHeaderView.ResizeToContents + + +# ========================================================================== +# Directional transfer buttons +# ========================================================================== +class TestTransferColumn: + """The transfer column carries two directional buttons: + ``→`` (Add, FS→ISO) and ``←`` (Extract, ISO→FS). + + The column is narrow (36px) and both buttons disable when no image is + open. These tests verify the structure and wiring. + """ + + def test_transfer_column_exists(self, main_window): + from iso_scalpel.main_window import _TransferColumn + assert isinstance(main_window._transfer_col, _TransferColumn) + + def test_add_button_uses_right_arrow(self, main_window): + """The Add button must use a right-pointing arrow → to make the + data-flow direction (FS → ISO) unambiguous. + """ + assert main_window._transfer_col.add_btn.text() == "→" + + def test_extract_button_uses_left_arrow(self, main_window): + """The Extract button must use a left-pointing arrow ← to make + the data-flow direction (ISO → FS) unambiguous. + """ + assert main_window._transfer_col.extract_btn.text() == "←" + + def test_transfer_column_is_narrow(self, main_window): + """The transfer column must be narrow (36px) so the two panes + flanking it get the bulk of the splitter width -- this is what + eliminates the 'orphaned icon in a sea of empty space' problem. + """ + assert main_window._transfer_col.maximumWidth() == 36 + + def test_transfer_buttons_disabled_when_no_image_open(self, main_window): + """Both transfer buttons must be disabled when no ISO image is + open, since Add/Extract require an open image to operate on. + """ + # MainWindow starts with no image open. + assert not main_window._handler.is_open + assert not main_window._transfer_col.add_btn.isEnabled() + assert not main_window._transfer_col.extract_btn.isEnabled() + + def test_swap_button_absent(self, main_window): + """No ``_swap_btn`` attribute exists on the main window; the + directional transfer column is the sole on-screen swap surface. + """ + assert not hasattr(main_window, "_swap_btn"), ( + "_swap_btn must not exist; the transfer column is the on-screen swap surface" + ) + + def test_swap_action_still_exists_for_keyboard(self, main_window): + """The swap_panes() method and act_swap action remain bound so + Ctrl+Shift+X and the Navigate menu keep working. + """ + assert hasattr(main_window, "swap_panes") + assert hasattr(main_window, "act_swap") + # Verify the keyboard shortcut is bound. + from PySide6.QtGui import QKeySequence + assert main_window.act_swap.shortcut() == QKeySequence("Ctrl+Shift+X") + + +# ========================================================================== +# Toolbar omits the swap action +# ========================================================================== +class TestToolbarNoSwap: + def test_swap_action_not_in_toolbar(self, main_window): + """The swap action is absent from every toolbar -- the directional + transfer buttons in the splitter gutter cover on-screen swap, and a + duplicate toolbar entry would be ambiguous. + """ + toolbars = main_window.findChildren(QToolBar) + assert toolbars, "expected at least one toolbar" + for tb in toolbars: + actions = tb.actions() + assert main_window.act_swap not in actions, ( + "swap action must not appear in any toolbar" + ) + + +# ========================================================================== +# Filter boxes have a max width so they don't stretch on large windows +# ========================================================================== +class TestFilterBoxMaxWidth: + def test_fs_pane_filter_has_max_width(self, main_window): + """The FS pane filter box must have a maximum width so it + doesn't stretch absurdly wide on large windows -- the breadcrumb + bar should get the bulk of the horizontal space. + """ + max_w = main_window._fs_pane.filter_edit.maximumWidth() + assert 100 <= max_w <= 300, f"unreasonable max width: {max_w}" + + def test_iso_pane_filter_has_max_width(self, main_window): + """Same for the ISO pane filter box.""" + max_w = main_window._iso_pane.filter_edit.maximumWidth() + assert 100 <= max_w <= 300, f"unreasonable max width: {max_w}"