diff --git a/.gitignore b/.gitignore index 9623af8..3b7da59 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,6 @@ Scripts/settings.json Configuration.tex .DS_Store ProperTree.app + +# personal build notes for this fork, not part of the tool +ROADMAP.local.md diff --git a/README.md b/README.md index 8e091fe..c6cda1f 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,163 @@ -# What is it? +# ProperTreeCLI -ProperTree is a cross-platform GUI plist editor written using Python *(compatible with both 2.x and 3.x)* and Tkinter. +Edit plists the way you edit config files on a server: from the +terminal, over ssh, inside tmux, on a machine with no display at all. -## Features + plist tests/sample.plist -- [x] Cross-platform - should work anywhere python and tkinter do -- [x] Document-based to support multiple windows -- [x] Node drag and drop to reorder -- [x] Copy and paste -- [x] Find/Replace - allows searching keys or values -- [x] Ordered - or unordered - dictionary support -- [x] Full undo-redo stack -- [x] Backported support for binary property lists and unicode in python 2 -- [x] Expanded integer casting to allow for hex integers (eg. `0xFFFF`) in xml `` tags -- [x] Context-aware right-click menu that includes template info to OpenCore or Clover config.plist files -- [x] OC (Clean) Snapshot to walk the contents of ACPI, Drivers, Kexts, and Tools for OpenCore config.plist files -- [x] Value converter that supports Base64, Hex, Ascii, and Decimal +opens a plist as a tree you can walk with the arrow keys, fold like +a file explorer, edit inline, and save with ctrl+s. Styled after +W0lfSword - frost blue on grey, boxed banners, nothing on screen that +does not earn its place. If that look is good enough for a kernel +exploit toolkit, it is good enough for an EFI folder. -*** +ProperTreeCLI is a fork of corpnewt's ProperTree, a tkinter app. The +gui still works; the point of this repo is the command line twin. It +sits on Scripts/plist.py for the xml and binary io and adds what a +terminal tool should have: scriptable one-shots, exit codes, and no +window. -## Getting ProperTree +tests/sample.plist is a small config to try it on (nested dicts, +arrays, data, a date). No file yet? plist new my.plist starts one +from scratch. -### Downloading The Repo As A ZIP File +## Try it in a minute -On any system you can choose the green `Code` button, followed by the `Download ZIP` button (or click [here](https://github.com/corpnewt/ProperTree/archive/refs/heads/master.zip)) to download the entire repo as a zip file (note, this does not allow you to update via `git pull` - any updates would require you to download the repo again in the same fashion). + git clone https://github.com/kaffeindecaf/ProperTreeCLI + cd ProperTreeCLI + ./install.sh # symlinks `plist` into ~/.local/bin + plist tests/sample.plist # sample editor (q quits, ? shows keys) + plist --version -### Cloning The Repo Via Git +No pip, no venv, no tkinter. Stdlib only. Run plist from any +directory afterwards. -#### On *nix systems: +Piped output is plain text: no terminal means no colors and no editor, +just the tree, so it greps and scripts. -``` -git clone https://github.com/corpnewt/ProperTree -python ./ProperTree/ProperTree.py -- or - -python3 ./ProperTree/ProperTree.py -``` +## The editor -\* On macOS, you can simply double-click the `ProperTree.command` after cloning to launch. + plist tests/sample.plist -#### On Windows: +j/k or the arrows move, home/end top/bottom, ctrl+d / ctrl+u half a +page, { } jump between siblings, left/right fold containers, enter +edits a value (booleans toggle), i adds an entry (pick the type, then +name it - on an empty plist it fills the root), D duplicates, d +deletes, r renames a key, t changes a value's type, c/x/p +copy/cut/paste, u undoes (ctrl+r redoes, 200 steps), ctrl+s saves, q +quits (it asks when the file is dirty). ? shows every keybind. -``` -git clone https://github.com/corpnewt/ProperTree -./ProperTree/ProperTree.bat -``` +/ finds. n and N cycle the matches, esc clears. tab inside the find +prompt picks what to search - keys, values, or both - and the choice +sticks for the session. R replaces the query inside string values +(when the search is scoped to keys it refuses; replacing keys is a +rename, do it by hand with r). -*** +^t (ctrl+t) opens the value converter on the selected entry: read the +text as ascii, base64, decimal, hex, or binary and render it as +another. pick from and to, tweak the text if you like, and enter on +the result writes it back as the entry's own kind - pasting foreign +base64 into a data field and entering stores the decoded bytes in one +trip. -## FAQ +T inserts from the OpenCore/Clover preset library: pick a section, +then a path (Kernel/Add, ACPI/Patch...), then a preset such as a blank +entry or a ready kext pack. Missing containers are created along the +way; it asks before clobbering anything. -* **What does OC Snapshot do?** +Hold j/k or the arrows and movement accelerates smoothly: one row per +repeat at first, then gradually more, no sudden jumps. - The OC Snapshot function will prompt you to select an OC folder, then walk the contents of the ACPI, Kexts, Tools, and Drivers directories within that folder - comparing all entries to the current document's `ACPI -> Add`, `Kernel -> Add`, `Misc -> Tools`, and `UEFI -> Drivers` respectively. It will add or remove entries as needed, and also ensures kext load order by comparing each kext's `CFBundleIdentifier` to all other kexts' `OSBundleLibraries` within their Info.plist - making sure that any kext that is relied on by others is loaded before them. It will also warn if it detects duplicate `CFBundleIdentifiers` (with support for `MinKernel`, `MaxKernel`, and `MatchKernel` overlap checks), and offer to disable all after the first found. It checks for disabled parent kexts with enabled child kexts as well. The schema used is (by default) determined by comparing the MD5 hash of the `OpenCore.efi` file to a known list of Acidanthera debug/release versions. If the MD5 hash does not match any known version, it will fall back to the newest schema in the script's `snapshot.plist`. This behavior can be customized in the Settings per the `OC Snapshot Target Version` menu. +Writes are atomic and verified by re-reading; the file keeps its +format and key order. -* **What is the difference between OC Snapshot and OC Clean Snapshot?** +## One-shots - Both snapshot variants accomplish the same tasks - they just leverage different starting points. An OC **Clean** Snapshot will first clear out `ACPI -> Add`, `Kernel -> Add`, `Misc -> Tools`, and `UEFI -> Drivers`, then add everything from within the respective ACPI, Kexts, Tools, and Drivers directory anew. A regular OC Snapshot starts with the information within the current document for those four locations, and only pulls changes - adding and removing entries as needed. - -* **When should I use an OC Clean Snapshot vs an OC Snapshot?** + plist get config.plist Misc.Boot.Timeout + plist set config.plist Misc.Boot.Timeout 5 -i + plist set config.plist Kernel.Quirks.EnableWriteUnprotector false -b + plist del config.plist Wifi + plist diff config.plist config-clean.plist what changed between two + plist convert config.plist -t binary + plist new new-config.plist start from an empty plist + plist settings show the config values + plist settings set theme red every text color red - Typically, an OC **Clean** Snapshot should only be used the first time you snapshot to ensure any sample entries in the config.plist are removed and added anew. Every subsequent snapshot should be a regular OC Snapshot to ensure any customizations you've made are preserved. +diff walks both trees and prints added/removed/changed keypaths; +it exits 0 when the files match and 1 when they differ, so it works +in a build script or a pre-push check. -* **Can't click anything on Sonoma (14.x) and Newer** +set creates missing keys along the way and keeps the file's format +and key order. get prints strings raw, booleans lowercase, data as +hex; add --json for machine output. Keypaths are dotted, array +elements are indexes: Drivers.0.Path. Values that start with a dash +need -- first: - This appears to manifest when using python 3.11.x and older due to some isssue with tk and macOS. Updating to at least python 3.12.0 (found [here](https://www.python.org/downloads/macos/)) appears to fix it. If you are unable to update your python version, you can also move the window around before trying to click the elements in the treeview. + plist set config.plist Boot-args -- -v keepsyms=1 -* **ProperTree opens a black window on macOS Monterey (12.x) and Newer** +Run plist help for the whole list. - It appears the default tk implementation that ships with macOS Monterey (and the version installed with the Command Line Tools) doesn't display correctly. A workaround is to download and install the latest build of python from python.org (found [here](https://www.python.org/downloads/macos/)) which has a compatible tk bundled, then use the `buildapp-select.command` located in ProperTree's `Scripts` directory to build an application bundle targeting the installed python's path. You can then leverage the `ProperTree.app` bundle it creates. - -* **ProperTree cannot open or save plist files on macOS Monterey (12.x)** +## Config - This appears to be an issue with the built-in tk, and the earlier "universal" installers from python.org. With at least python 3.10.2, this issue has been resolved in the universal builds. You can get the latest python 3 installer [here](https://www.python.org/downloads/macos/). After installing, use the `buildapp-select.command` located in ProperTree's `Scripts` directory to build an application bundle targeting the installed python's path. You can then leverage the `ProperTree.app` bundle it creates. +The editor reads ~/.config/propertreecli/config (created on first +run, XDG_CONFIG_HOME aware): -* **How can I have ProperTree open when I double-click a .plist file?** +- expand_mode = all, auto, or none: whether containers open expanded + or folded +- format = xml or binary: what plist new writes +- find_scope = keys, values, or both: what / searches by default +- theme = frost or red: the palette. red turns every text color red - On macOS you can run `buildapp-select.command` located in ProperTree's `Scripts` directory to build an application bundle which can be associated with .plist files. - - On Windows, you can run `AssociatePlistFiles.bat` located in ProperTree's `Scripts` directory to associate .plist files with `ProperTree.bat`, and also to add an `Open with ProperTree` option to the contextual menu when right-clicking .plist files. This approach is location-dependent, and moving your copy of ProperTree will require you re-run `AssociatePlistFiles.bat`. +plist settings shows and changes these without hand-editing the file. -* **When I try to run ProperTree, I get `[ModuleNotFoundError: No module name 'tkinter']`** +## What is reused from ProperTree - That is because the graphical interface library that ProperTree depends on isn't present or cannot be detected, you need to install `tkinter` from your package manager. +The terminal side does not rewrite what already works: - To install it on Ubuntu (and Ubuntu-based distros), you can run `sudo apt-get install python3-tk -y` +- Scripts/plist.py reads and writes xml and binary plists, data, uids + and 0x integers, the same code path the gui uses +- the value converter, find/replace (with the gui's find scope) and the + OpenCore/Clover insert-from-template presets are ports of the gui's + behaviour, not new implementations -* **ProperTree doesn't run because it doesn't have permissions, what gives?** +Neither propertreecli.py nor plist_tui.py imports the gui: the only +shared code is Scripts/plist.py. - This shouldn't happen and it is recommended that you download only from the official ProperTree repository, but if you are confident about your source, then running `chmod +x ProperTree.command` should sort it out +## Adding this to an existing ProperTree checkout -* **I use an international keyboard layout on macOS and some keys crash ProperTree with `NSRangeException', reason: '-[__NSCFConstantString characterAtIndex:]: Range or index out of bounds`** +Everything here is additive, so the two can live in one checkout. No +file that ships with ProperTree is modified: - This is a bug in the Cocoa implementation of Tcl/Tk on macOS (discussed [here](https://bugs.python.org/issue22566)). The latest python 2 installer from [python.org](https://www.python.org/downloads/release/python-2718/) ships with, and uses Tcl/Tk 8.6.8 which has this issue fixed. Given that the shebang in `ProperTree.command` leverages `#!/usr/bin/env python` - the first python 2 binary found should be used. `buildapp-select.command` from ProperTree's `Scripts` directory can be used to hardcode a specific python install's path into the .app bundle's executable shebang. - -* **ProperTree crashes on Big Sur (macOS 11)** +| added | what it is | +| --- | --- | +| propertreecli.py | the `plist` command, the one-shots, the plist io | +| plist_tui.py | the curses editor | +| install.sh | symlinks `plist` into ~/.local/bin | +| tests/ | pty smoke tests, plus the sample plist | - __As of macOS 11.2 (20D5029f), the system's `tk` installation appears to be fixed, and works correctly. As such, it should not require an external python version to function.__ +Copy those in next to ProperTree.py and Scripts/, and `python3 +propertreecli.py --version` works with no install step. `./install.sh` +puts `plist` on PATH. python 3 only, no dependencies, no build, same +BSD 3-Clause licence as upstream. - This is due to the default python installs on macOS leveraging an older `tk` version - which lacks support for macOS 11. To solve this, you can download and install the latest python 3 version from https://www.python.org/downloads/mac-osx/ (note: Currently the "universal" 3.9.1 installer causes theme issues and should not be used) then leverage the `buildapp-select.command` from ProperTree's `Scripts` directory to build a .app bundle that will leverage that python version. - - If you already have python 3 installed via `brew` or another package manager - it is likely still linking to the system `tk` version, which will still have issues unless linked against a newer version. +## Tests -* **`buildapp-select.command` Usage** + tests/run.sh # compile check + pty smoke tests, stdlib only - An example of the output of `buildapp-select.command` is shown below. It will walk the output of `which python` and `which python3`, then attempt to load the `tk` interface while keeping track of which work and which fail. The example below is from macOS 11.2 (20D4029f) with the system versions of python 2 and 3, as well as python 3.9.1 installed from python.org. If there's an existing `ProperTree.app` in the directory above the `Scripts` folder, the shebang of that app will be located and served up as the `C. Current` option. At the following menu, I would select option `3` or `C` to use the non-system python install. +drives the real editor over a pty and asserts on the saved file: filling +a brand new plist of both root types (the empty tree has no rows to +select, so `i` has to fall back to the root), plus sibling insert order, +fold, copy/paste, undo, rename and clean quit on a normal tree. no +framework, no deps, exit code says pass or fail. -``` - - Currently Available Python Versions - +## Running the original gui -1. /usr/bin/python 2.7.16 - tk 8.5 (8.6+ recommended) -2. /usr/bin/python3 3.8.2 - tk 8.5 (8.6+ recommended) -3. /Library/Frameworks/Python.framework/Versions/3.9/bin/python3 3.9.1 - tk 8.6 -4. /usr/bin/env python -5. /usr/bin/env python3 + python3 ProperTree.py [file.plist] -C. Current (/Library/Frameworks/Python.framework/Versions/3.9/bin/python3) -Q. Quit +needs python 3 with tkinter (apt install python3-tk on debian/ubuntu). +The terminal side does not: it needs neither tkinter nor the gui. -Please select the python version to use: -``` +## Credit + +ProperTree by CorpNewt, BSD 3-Clause. Forked and repurposed by +kaffeindecaf. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..74969c2 --- /dev/null +++ b/install.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# plist install - symlink into ~/.local/bin (or the dir given) +# usage: ./install.sh [dir] link the plist command into dir +# ./install.sh --uninstall [dir] remove the links +set -euo pipefail +repo="$(cd "$(dirname "$0")" && pwd)" +dir="${2:-$HOME/.local/bin}" + +if [ "${1:-}" = "--uninstall" ]; then + rm -f "$dir/plist" "$dir/propertreecli" + echo "removed $dir/plist" + exit 0 +fi + +mkdir -p "$dir" +ln -sfn "$repo/propertreecli.py" "$dir/plist" +# old name kept as an alias so nothing that learned it breaks +ln -sfn "$repo/propertreecli.py" "$dir/propertreecli" +echo "linked $dir/plist -> $repo/propertreecli.py" + +case ":$PATH:" in + *":$dir:"*) ;; + *) echo "add $dir to your PATH to run it from anywhere:" + echo " export PATH=\"$dir:\$PATH\"" + ;; +esac diff --git a/plist_tui.py b/plist_tui.py new file mode 100644 index 0000000..b05b2a6 --- /dev/null +++ b/plist_tui.py @@ -0,0 +1,1745 @@ +#!/usr/bin/env python3 +# plist_tui - the curses editor behind `plist ` +# new code in the ProperTree tree, BSD-3 like the rest (see LICENSE). +# imports helpers from propertreecli lazily, so the module only loads +# when an editor actually starts. +# +# layout, top to bottom: +# header: file (frost bold) format dirty marker hints (dim) +# ─ dim rule +# tree: key / type / value rows, ▸▾ for collapsed containers +# status: mode + transient messages +# footer: condensed keybind legend (dim) +# +# look follows W0lfSword: frost accents on grey, boxed menus, dim hints, +# status glyphs. selection is a frost bar with dark text. + +import base64 +import copy +import curses +import datetime +import locale +import os +import sys +import time +import traceback + +# ── color mapping ───────────────────────────────────────────── +# W0lfSword indexes on 256-color terminals; degrade for 8/16-color. +# frost/brand are 256-color tones, the semantic colors stay ansi base. +_EXACT = {"frost": 117, "brand": 153, "dim": 240, "grn": 2, "amb": 3, "red": 1} +_16 = {"frost": 14, "brand": 14, "dim": 8, "grn": 10, "amb": 11, "red": 9} +_8 = {"frost": 6, "brand": 6, "dim": 7, "grn": 2, "amb": 3, "red": 1} +# theme = red turns every text color red: accents at 196, dim at 124 so +# hints stay muted, and the depth shades walk down through darker reds. +_RED_EXACT = {"frost": 196, "brand": 196, "dim": 124, "grn": 196, "amb": 196, "red": 196} +_RED_16 = {"frost": 9, "brand": 9, "dim": 1, "grn": 9, "amb": 9, "red": 9} +_RED_8 = {"frost": 1, "brand": 1, "dim": 1, "grn": 1, "amb": 1, "red": 1} +# pairs: 1 frost(accents/keys) 2 dim 3 grn 4 amb 5 red 6 brand 7 sel bar +# depth pairs 10..15: d1..d6 for nested levels. d0 is not a real pair - +# top-level keys draw as plain frost (PAIR d0 -> 1) so the root of a file +# looks exactly like it always did. +PAIR = {} +# nested-key ramp: a straight desaturation of frost (135,215,255) toward +# gray, walking green and blue down together while red stays fixed - no +# hue detours. 110 is steel, 103 is gray-blue (red=green), 102 is gray, +# then flat. no init_color on purpose: palette redefinition is +# terminal-dependent and entries 16-21 silently render as black on +# terminals that ignore it, which looks like missing text. +_DEPTH_STOCK = [110, 103, 102, 102, 102, 102] # d1..d6, clamped at d3 +# red theme: same idea, darker reds per level (clamped at 88) +_DEPTH_RED = [160, 124, 88, 88, 88, 88] + +def _load_theme(): + # which palette theme the config asks for; never throws - a missing or + # broken config just means frost + try: + from propertreecli import ensure_config, load_config + ensure_config() + return load_config().get("theme", "frost") + except Exception: + return "frost" + +def _init_colors(): + curses.start_color() + try: + curses.use_default_colors() + except curses.error: + pass + red = _load_theme() == "red" + if curses.COLORS >= 256: + m = _RED_EXACT if red else _EXACT + elif curses.COLORS >= 16: + m = _RED_16 if red else _16 + else: + m = _RED_8 if red else _8 + depth = _DEPTH_RED if red else _DEPTH_STOCK + for i, key in enumerate(("frost", "dim", "grn", "amb", "red", "brand"), 1): + try: + curses.init_pair(i, m[key], -1) + except curses.error: + curses.init_pair(i, m[key], 0) + # selection bar: dark text on a frost background + try: + curses.init_pair(7, 0, m["frost"]) + except curses.error: + curses.init_pair(7, 0, 14) + PAIR["frost"] = 1 + PAIR["dim"] = 2 + PAIR["grn"] = 3 + PAIR["amb"] = 4 + PAIR["red"] = 5 + PAIR["brand"] = 6 + PAIR["d0"] = 1 # top level = plain frost, same as the original keys + if curses.COLORS >= 256: + # stock-index depth shades, pairs 10..15 (d1..d6) + for i, idx in enumerate(depth): + try: + curses.init_pair(10 + i, idx, -1) + except curses.error: + curses.init_pair(10 + i, idx, 0) + PAIR["d{}".format(i + 1)] = 10 + i + else: + # 8/16-color terminals: no depth tint, every key plain frost + for i in range(1, len(depth) + 1): + PAIR["d{}".format(i)] = 1 + +def P(name, bold=False): + a = curses.color_pair(PAIR[name]) + return a | curses.A_BOLD if bold else a + +# ── display helpers ─────────────────────────────────────────── +def _esc(s): + return s.replace("\\", "\\\\").replace("\n", "\\n").replace("\t", "\\t") + +def _type_name(v): + from propertreecli import type_name + return type_name(v) + +def canonical(v): + # text form used for editing prefill and type conversion + if isinstance(v, bool): + return "True" if v else "False" + if isinstance(v, int): + return str(v) + if isinstance(v, float): + return repr(v) + if isinstance(v, str): + return v + if isinstance(v, bytes): + return " ".join("{:02X}".format(b) for b in v) + if _is_uid(v): + return str(getattr(v, "data", v)) + if isinstance(v, datetime.datetime): + return v.strftime("%Y-%m-%d %H:%M:%S") + d = getattr(v, "data", None) + if isinstance(d, bytes): + return " ".join("{:02X}".format(b) for b in d) + return str(v) + +def _is_uid(v): + from propertreecli import _is_uid as iu + return iu(v) + +def value_color_name(v): + if isinstance(v, bool): + return "grn" if v else "dim" + return None + +_KINDS = { + "string": "s", "integer": "i", "real": "f", "boolean": "b", + "data": "x", "date": "d", "uid": "u", +} +_NEW_DEFAULTS = { + "string": "", "integer": 0, "real": 0.0, "boolean": True, + "data": b"", "date": None, "uid": None, +} + +def _parse(text, kind): + from propertreecli import parse_value + return parse_value(text, kind) + +# ── converter (ctrl+t) ──────────────────────────────────────── +# the tk gui's Convert Window (Ctrl+T) is a scratchpad that +# renders text as bytes in one encoding and back out in another. +# same five views here: ascii, base64, decimal, hex, binary. +_CONV_NAMES = ("Ascii", "Base64", "Decimal", "Hex", "Binary") + +def _convert_text(text, frm, to): + # raise ValueError with a readable reason on bad input + if not text: + raise ValueError("nothing to convert") + f, t = frm.lower(), to.lower() + if f == "hex": + v = text[2:] if text.lower().startswith("0x") else text + v = v.replace(" ", "").replace("<", "").replace(">", "") + if not v or any(c.lower() not in "0123456789abcdef" for c in v): + raise ValueError("hex text may only hold 0-9 a-f (0x, <>, spaces ok)") + if len(v) % 2: + v = "0" + v + data = bytes.fromhex(v) + elif f in ("decimal", "binary"): + base = 10 if f == "decimal" else 2 + try: + n = int("".join(text.split()), base) + except ValueError: + raise ValueError("not a valid {} number: '{}'".format(frm, text.strip())) + h = "{:x}".format(n) + if len(h) % 2: + h = "0" + h + data = bytes.fromhex(h) + elif f == "base64": + s = "".join(text.split()) + s2 = s.rstrip("=") + if len(s2) % 4 == 1: + raise ValueError("not valid base64 text") + s = s2 + "=" * ((4 - len(s2) % 4) % 4) + try: + data = base64.b64decode(s, validate=True) + except Exception: + raise ValueError("not valid base64 text") + else: # ascii + data = text.encode("utf-8") + if t == "base64": + return base64.b64encode(data).decode("ascii") + if t == "hex": + out = data.hex().upper() + return " ".join(out[i:i + 8] for i in range(0, len(out), 8)) + if t == "decimal": + return str(int(data.hex() or "0", 16)) + if t == "binary": + return "{:b}".format(int(data.hex() or "0", 16)) + try: + return data.decode("utf-8") + except UnicodeDecodeError: + raise ValueError("bytes are not valid utf-8 text (try hex or base64)") + +def _convert_prefill(kind): + # (from_index, to_index) preselects for a node kind + if kind == "data": + return 3, 1 # hex -> base64 + if kind == "integer": + return 2, 3 # decimal -> hex + return 0, 3 # string: ascii -> hex + +def _convert_apply(kind, text, frm): + # write the source text back into a value of the node's own kind, + # interpreting the text as the from-encoding (what the user picked + # in the converter). raises ValueError when that encoding cannot + # round-trip into the plist type (view-only conversions). + f = frm.lower() + if kind == "data": + if f == "hex": + return bytes.fromhex("".join(text.split())) + if f == "base64": + return base64.b64decode("".join(text.split())) + if f == "ascii": + return text.encode("utf-8") + raise ValueError("decimal/binary cannot rebuild data bytes - view only") + if kind == "integer": + base = {"decimal": 10, "hex": 16, "binary": 2}.get(f) + if base is None: + raise ValueError("ascii/base64 cannot rebuild an integer - view only") + clean = "".join(text.split()) + if f == "hex": + clean = clean[2:] if clean.lower().startswith("0x") else clean + try: + return int(clean, base) + except ValueError: + raise ValueError("not a valid {} number: '{}'".format(frm, text.strip())) + # string + if f == "ascii": + return text + if f == "hex": + try: + return bytes.fromhex("".join(text.split())).decode("utf-8") + except UnicodeDecodeError: + raise ValueError("those bytes are not utf-8 text - view only") + if f == "base64": + try: + return base64.b64decode("".join(text.split())).decode("utf-8") + except Exception: + raise ValueError("not text in that base64 - view only") + raise ValueError("decimal/binary cannot rebuild a string - view only") + +# ── insert from template (T) ────────────────────────────────── +# the tk gui right-clicks a node and offers presets from +# Scripts/menu.plist: each preset knows its destination path +# (Root/ACPI/Add, Root/Kernel/Add, ...), the container types +# along it (d = dict, a = array), and the value to insert. +# the terminal editor has no pointer, so T offers the whole +# list and the preset's own path decides where it lands. +_MENU_CACHE_PATH = None +_MENU_CACHE_DATA = None + +def _template_root(): + # repo layout: this file sits next to Scripts/menu.plist + return os.path.join(os.path.dirname(os.path.realpath(__file__)), + "Scripts", "menu.plist") + +def _template_data(): + # lazy one-time load of menu.plist; returns {section: {path: [items]}} + global _MENU_CACHE_PATH, _MENU_CACHE_DATA + p = _template_root() + if _MENU_CACHE_PATH == p and _MENU_CACHE_DATA is not None: + return _MENU_CACHE_DATA + if not os.path.exists(p): + return None + try: + from propertreecli import load_plist + data = load_plist(p) + _MENU_CACHE_PATH = p + _MENU_CACHE_DATA = data + return data + except Exception: + return None + +# ── the editor ──────────────────────────────────────────────── +class Editor: + def __init__(self, stdscr, root, path): + from propertreecli import file_format, ensure_config, load_config + ensure_config() + cfg = load_config() + self.s = stdscr + self.root = root + self.path = path + self.fmt = file_format(path) + self.expand_mode = cfg.get("expand_mode", "auto") + self.find_scope = cfg.get("find_scope", "both") # keys | values | both + self.rows = [] # visible rows, rebuilt per frame + self.expanded = {} # path tuple -> bool + self.sel = 0 + self.top = 0 + self.undo = [] + self.redo = [] + self.dirty = False + self.msg = "" + self.msg_color = "dim" + self.find = None # active query + self.matches = [] # row indexes matching self.find + self.match_idx = -1 + self.clip_file = os.path.join( + os.environ.get("XDG_RUNTIME_DIR") or "/tmp", "plist-clipboard.plist") + self.log = "/tmp/plist_tui.log" + self._row_of_path = {} + self._node_count = 0 + # hold-to-accelerate state for j/k and the arrow keys + self._hold_dir = 0 + self._ramp = 0.0 + self._frac = 0.0 + self._mv_last = 0.0 + self._cycle_i = 0 # last pick in a cycling prompt (find scope) + + # ── frames ──────────────────────────────────────────────── + def H(self): + return self.s.getmaxyx()[0] + + def W(self): + return self.s.getmaxyx()[1] + + def _count(self, node): + # total descendants of a container (cached per frame) + if isinstance(node, dict): + return 1 + sum(self._count(v) for v in node.values()) + if isinstance(node, list): + return 1 + sum(self._count(v) for v in node) + return 1 + + def _is_expanded(self, path, node): + if path not in self.expanded: + # decided by the config: all / auto (huge files fold deep + # levels) / none (start collapsed, unfold by hand) + mode = self.expand_mode + if mode == "none": + want = False + elif mode == "all": + want = True + else: + want = not (self._node_count > 1200 and len(path) > 1) + self.expanded[path] = want + return self.expanded[path] + + def _build_rows(self): + rows = [] + + def walk(node, path, depth): + if isinstance(node, dict): + for k, v in node.items(): + p = path + [k] + if isinstance(v, (dict, list)): + n = self._count(v) + rows.append({"path": p, "key": k, "depth": depth, + "node": v, "type": _type_name(v), + "count": n, "leaf": False}) + if self._is_expanded(tuple(p), v): + walk(v, p, depth + 1) + else: + rows.append({"path": p, "key": k, "depth": depth, + "node": v, "type": _type_name(v), + "leaf": True}) + elif isinstance(node, list): + for i, v in enumerate(node): + p = path + [i] + if isinstance(v, (dict, list)): + n = self._count(v) + rows.append({"path": p, "key": i, "depth": depth, + "node": v, "type": _type_name(v), + "count": n, "leaf": False}) + if self._is_expanded(tuple(p), v): + walk(v, p, depth + 1) + else: + rows.append({"path": p, "key": i, "depth": depth, + "node": v, "type": _type_name(v), + "leaf": True}) + + self._node_count = self._count(self.root) + self.root_is_dict = isinstance(self.root, dict) + self.root_is_list = isinstance(self.root, list) + walk(self.root, [], 0) + self.rows = rows + if self.sel >= len(rows): + self.sel = max(len(rows) - 1, 0) + + def _recompute_matches(self): + self.matches = [] + if not self.find: + return + q = self.find.lower() + for i, r in enumerate(self.rows): + key_hit = isinstance(r["key"], str) and q in r["key"].lower() + # only scalar rows carry a value; containers match on their key + val_hit = r["leaf"] and q in canonical(r["node"]).lower() + if self.find_scope == "keys": + hit = key_hit + elif self.find_scope == "values": + hit = val_hit + else: + hit = key_hit or val_hit + if hit: + self.matches.append(i) + + # ── undo ────────────────────────────────────────────────── + def _push_undo(self): + self.undo.append(copy.deepcopy(self.root)) + if len(self.undo) > 200: + self.undo.pop(0) + self.redo = [] + self.dirty = True + + def _undo(self): + if not self.undo: + self._flash("nothing to undo") + return + self.redo.append(copy.deepcopy(self.root)) + self.root = self.undo.pop() + self.dirty = True + self._after_mutate() + self._flash("undone", "grn") + + def _redo(self): + if not self.redo: + self._flash("nothing to redo") + return + self.undo.append(copy.deepcopy(self.root)) + self.root = self.redo.pop() + self.dirty = True + self._after_mutate() + self._flash("redone", "grn") + + def _after_mutate(self): + self._build_rows() + self._recompute_matches() + self._clamp() + + def _clamp(self): + if self.sel >= len(self.rows): + self.sel = max(len(self.rows) - 1, 0) + + def _goto(self, idx): + # jump the selection to a row index; the next draw scrolls it in + self.sel = idx + self._clamp() + + def _flash(self, text, color="dim"): + self.msg = text + self.msg_color = color + + def _parent_of(self, row): + p = row["path"] + parent = self.root + for seg in p[:-1]: + if isinstance(parent, dict): + parent = parent[seg] + else: + parent = parent[int(seg)] + return parent, p[-1] + + def _sel_row(self): + # the selected row, or None when the tree is empty: the root + # container is never drawn as a row, so a fresh `plist new` (or + # any empty root) has no rows at all for the row ops to point at + if not self.rows: + kind = ("dict" if getattr(self, "root_is_dict", False) + else "array" if getattr(self, "root_is_list", False) else "plist") + self._flash("empty {} - press i to add the first entry".format(kind)) + return None + return self.rows[self.sel] + + def _target_of_row(self, row): + # (container, anchor, into_container) for an add or a paste: a + # container row takes children, a leaf row takes siblings, and no + # row at all means the root itself is the container + if row is None: + return self.root, None, True + if isinstance(row["node"], (dict, list)): + return self._node_at(row["path"]), None, True + return self._node_at(row["path"][:-1]), row["path"][-1], False + + def _last(self): + # index of the last row, 0 when the tree is empty (never -1) + return max(len(self.rows) - 1, 0) + + # ── save / quit ─────────────────────────────────────────── + def _save(self): + from propertreecli import file_format, write_plist + try: + write_plist(self.root, self.path, file_format(self.path)) + except Exception as e: + self._flash("save failed: {}".format(e), "red") + return False + self.dirty = False + self._flash("saved \u2713", "grn") + return True + + def _confirm_dirty(self): + if not self.dirty: + return "y" + m = self._menu("save changes?", ["yes", "no", "cancel"]) + return "y" if m == 0 else ("n" if m == 1 else "c") + + # ── row ops ─────────────────────────────────────────────── + def _row_index_of_path(self, path): + for i, r in enumerate(self.rows): + if tuple(r["path"]) == tuple(path): + return i + return None + + def _rebuild_rows_keep(self, path): + # rebuild after an expand/collapse, keeping the selection on the + # container that was toggled (its children appear or vanish below) + self._build_rows() + i = self._row_index_of_path(path) + self.sel = i if i is not None else self.sel + self._clamp() + self._recompute_matches() + + def _toggle_expand(self, row=None): + row = row or self._sel_row() + if row is None: + return + p = tuple(row["path"]) + self.expanded[p] = not self.expanded.get(p, True) + self._rebuild_rows_keep(row["path"]) + + def _row_is_container(self, row): + return not row["leaf"] + + def _parent_is_dict(self, row): + if len(row["path"]) == 1: + return self.root_is_dict + parent, _ = self._parent_of(row) + return isinstance(parent, dict) + + def _add_entry(self): + # no rows means an empty tree, so the root is the container to + # add into - this is the only way to fill a fresh plist + row = self.rows[self.sel] if self.rows else None + if row is None and not isinstance(self.root, (dict, list)): + self._flash("the root is a {} value - nothing to add to".format( + _type_name(self.root)), "red") + return + parent, anchor, _ = self._target_of_row(row) + types = list(_KINDS.keys()) + ["dict", "array"] + pick = self._menu("add entry - type", types) + if pick is None: + return + tname = types[pick] + if tname in ("dict", "array"): + value = {} if tname == "dict" else [] + else: + if tname == "uid" and not self._binary(): + self._flash("uid only survives in binary plists", "red") + return + if tname == "date": + value = datetime.datetime.now().replace(microsecond=0) + elif tname == "uid": + value = _parse("0", "u") + else: + value = _NEW_DEFAULTS[tname] + if isinstance(parent, dict): + key = self._prompt("key name:", "") + if key is None: + return + if key in parent: + self._flash("key already exists: {}".format(key), "red") + return + if anchor is None: + self._push_undo() + parent[key] = value + else: + self._push_undo() + self._dict_insert_after(parent, anchor, key, value) + else: + self._push_undo() + if anchor is None: + parent.append(value) + else: + parent.insert(int(anchor) + 1, value) + self._after_mutate() + self._flash("added {}".format(_type_name(value)), "grn") + + def _dict_insert_after(self, d, anchor, key, value): + # dicts keep insertion order; rebuild with the new key after anchor + items = list(d.items()) + out = {} + for k, v in items: + out[k] = v + if k == anchor: + out[key] = value + self._replace_parent_dict(d, out) + + def _replace_parent_dict(self, old, new): + # swap `old` for `new` wherever it sits under self.root + if old is self.root: + self.root = new + return + stack = [(self.root, None, None)] + while stack: + cur, parent, key = stack.pop() + if cur is old: + if isinstance(parent, dict): + # rebuild the parent, swapping the one entry in place + items = list(parent.items()) + out = {} + for k, v in items: + out[k] = new if v is old else v + self._replace_parent_dict(parent, out) + else: + parent[key] = new + return + if isinstance(cur, dict): + for k, v in cur.items(): + stack.append((v, cur, k)) + elif isinstance(cur, list): + for i, v in enumerate(cur): + stack.append((v, cur, i)) + + def _delete_row(self): + row = self._sel_row() + if row is None: + return + parent, last = self._parent_of(row) + label = self._row_label(row) + if isinstance(parent, dict): + if not self._confirm("delete {}?".format(label)): + return + self._push_undo() + if parent is self.root: + items = list(parent.items()) + out = {k: v for k, v in items if k != last} + self.root = out + else: + self._rebuild_dict_without(parent, last) + elif isinstance(parent, list): + if not self._confirm("delete {}?".format(label)): + return + self._push_undo() + del parent[int(last)] + else: + self._flash("cannot delete the root value", "red") + return + self._after_mutate() + self._flash("deleted {}".format(label), "grn") + + def _rebuild_dict_without(self, d, drop): + items = list(d.items()) + out = {k: v for k, v in items if k != drop} + self._replace_parent_dict(d, out) + + def _rename_row(self): + row = self._sel_row() + if row is None: + return + if not isinstance(row["key"], str): + self._flash("array elements have no key to rename", "red") + return + if not self._parent_is_dict(row): + return + parent, last = self._parent_of(row) + new = self._prompt("rename {} to:".format(last), last) + if new is None or new == last: + return + if new in parent: + self._flash("key already exists: {}".format(new), "red") + return + self._push_undo() + items = list(parent.items()) + out = {} + for k, v in items: + if k == last: + out[new] = v + else: + out[k] = v + self._replace_parent_dict(parent, out) + self._after_mutate() + self._flash("renamed to {}".format(new), "grn") + + def _change_type(self): + row = self._sel_row() + if row is None: + return + if not row["leaf"]: + self._flash("change type works on values, not containers", "red") + return + cur = _type_name(row["node"]) + options = [t for t in _KINDS if t != cur] + pick = self._menu("change type (from {})".format(cur), options) + if pick is None: + return + target = options[pick] + kind = _KINDS[target] + if target == "uid" and not self._binary(): + self._flash("uid only survives in binary plists", "red") + return + text = canonical(row["node"]) + if target == "string": + new = text + else: + try: + new = _parse(text, kind) + except ValueError as e: + self._flash("cannot convert: {}".format(e), "red") + return + if new == row["node"]: + self._flash("already a {}".format(target)) + return + self._push_undo() + parent, last = self._parent_of(row) + if isinstance(parent, dict): + parent[last] = new + else: + parent[int(last)] = new + self._after_mutate() + self._flash("changed to {}".format(target), "grn") + + # ── converter (^t) ──────────────────────────────────────── + def _convert_value(self): + # port of the tk gui's Convert Window, bound to the selected + # value: pick from/to encodings, tweak the text, convert. + # enter on the result writes the source back into the value + # (interpreted as its own kind), so pasting foreign base64 + # or hex into a data field works in one trip. + row = self._sel_row() + if row is None: + return + if not row["leaf"]: + self._flash("converter works on values, not containers", "red") + return + v = row["node"] + kind = _type_name(v) + if kind not in ("string", "data", "integer"): + self._flash("converter works on string, data, and integer values", "red") + return + label = self._row_label(row) + fi, ti = _convert_prefill(kind) + f = self._menu("convert {} - from ({})".format(label, _CONV_NAMES[fi]), + list(_CONV_NAMES), sel=fi) + if f is None: + return + t = self._menu("convert {} - to ({})".format(label, _CONV_NAMES[ti]), + list(_CONV_NAMES), sel=ti) + if t is None: + return + fname, tname = _CONV_NAMES[f], _CONV_NAMES[t] + text = self._prompt("convert ({} -> {}):".format(fname, tname), + canonical(v)) + if text is None: + return + if not text.strip() and kind != "string": + self._flash("nothing to convert", "red") + return + try: + result = _convert_text(text, fname, tname) + except ValueError as e: + self._flash(str(e), "red") + return + # result box: show the conversion, enter writes back + w = self.W() + chunk = max(w - 8, 12) + lines = ["{} ({} -> {})".format(label, fname, tname)] + rl = result if len(result) <= chunk else result[:chunk - 1] + "\u2026" + lines.append(rl) + lines.append("") + lines.append("enter = write source back as {}".format(kind)) + if not self._choice_box("convert", lines): + self._flash("converted (not written)") + return + try: + new = _convert_apply(kind, text, fname) + except ValueError as e: + self._flash(str(e), "red") + return + if new == v: + self._flash("unchanged") + return + self._push_undo() + parent, last = self._parent_of(row) + if isinstance(parent, dict): + parent[last] = new + else: + parent[int(last)] = new + self._after_mutate() + self._flash("converted \u2713", "grn") + + # ── insert from template (T) ────────────────────────────── + def _insert_template(self): + data = _template_data() + if not data: + self._flash("template data missing - Scripts/menu.plist not found", "red") + return + if not isinstance(self.root, dict): + self._flash("template presets need a dictionary root", "red") + return + sections = sorted(s for s in data if isinstance(data[s], dict) and data[s]) + if not sections: + self._flash("no template sections found", "red") + return + pick = self._menu("insert from template - section", sections) + if pick is None: + return + section = sections[pick] + paths = sorted(p for p in data[section] + if isinstance(data[section][p], list) and data[section][p]) + labels = [p.replace("Root/", "", 1) if p.startswith("Root/") else p + for p in paths] + pick = self._menu("insert from template - {}".format(section), labels) + if pick is None: + return + path = paths[pick] + # presets are the entries with a name + types + value; separator + # and title rows are menu furniture the terminal does not need + presets = [e for e in data[section][path] + if isinstance(e, dict) and "name" in e + and "types" in e and "value" in e] + if not presets: + self._flash("no insertable presets under {}".format(labels[pick]), "red") + return + names = [e["name"] for e in presets] + pick = self._menu("insert - {}".format(labels[pick]), names) + if pick is None: + return + self._template_apply(path, presets[pick]) + + def _template_apply(self, path, preset): + # walk the preset's own path from the root, creating missing + # containers (d = dict, a = array), then drop the value in: + # arrays append, dicts merge keys. mirrors the tk gui's + # merge_menu_preset, minus the drag-and-drop bookkeeping. + types = str(preset.get("types", "")).split("/") + value = preset.get("value") + segs = [s for s in str(path).split("/") if s] + if segs and segs[0].lower() == "root": + segs, types = segs[1:], types[1:] + if not segs or len(segs) != len(types) or not isinstance(value, dict): + self._flash("malformed preset ({})".format(path), "red") + return + # walk until the first segment that is missing or the wrong + # type - from there down everything gets (re)built fresh + cur = self.root + question = None + replace_kind = None + complete = True + for seg, typ in zip(segs, types): + want_list = str(typ).lower() == "a" + if not isinstance(cur, dict): + # an array sits mid-path; presets only descend dicts + self._flash("preset path crosses an array ({})".format(path), "red") + return + if seg not in cur: + complete = False + break + ex = cur[seg] + good = isinstance(ex, list) if want_list else isinstance(ex, dict) + if not good: + question = "{} is a {} - replace it with {}?".format( + seg, _type_name(ex), "array" if want_list else "dict") + replace_kind = "array" if want_list else "dict" + complete = False + break + cur = ex + if question is not None and not self._confirm(question): + self._flash("template not applied") + return + if complete and isinstance(cur, dict): + for k in value: + if k in cur: + if not self._confirm("key {} already exists - replace it?".format(k)): + self._flash("template not applied") + return + # apply (single undo step covers the whole insert) + self._push_undo() + cur = self.root + for seg, typ in zip(segs, types): + want_list = str(typ).lower() == "a" + if isinstance(cur, dict): + if seg not in cur or not ( + isinstance(cur[seg], list) if want_list + else isinstance(cur[seg], dict)): + cur[seg] = [] if want_list else {} + cur = cur[seg] + if isinstance(cur, list): + cur.append(copy.deepcopy(value)) + sel_path = segs + [len(cur) - 1] + else: + for k, v in value.items(): + cur[k] = copy.deepcopy(v) + sel_path = segs + [list(value)[-1]] + # expand the container path so the insert is visible, then jump to it + for i in range(1, len(segs) + 1): + self.expanded[tuple(segs[:i])] = True + self._after_mutate() + idx = self._row_index_of_path(sel_path) + if idx is not None: + self.sel = idx + self._flash("inserted {}".format(preset.get("name", path)), "grn") + + def _duplicate_row(self): + # copy the whole entry and insert it right after itself; dict keys + # get a "copy" suffix that auto-increments on collision + row = self._sel_row() + if row is None: + return + parent, last = self._parent_of(row) + payload = copy.deepcopy(row["node"]) + self._push_undo() + if isinstance(parent, dict): + base = "{} copy".format(last) if isinstance(last, str) else "copy" + key, n = base, 2 + while key in parent: + key = "{} {}".format(base, n) + n += 1 + self._dict_insert_after(parent, last, key, payload) + newpath = row["path"][:-1] + [key] + elif isinstance(parent, list): + i = int(last) + parent.insert(i + 1, payload) + newpath = row["path"][:-1] + [i + 1] + else: + return + self._rebuild_rows_keep(newpath) + self._flash("duplicated", "grn") + + def _sibling_jump(self, direction): + # { / }: jump to the previous / next sibling of the current row + row = self._sel_row() + if row is None: + return + parent_path = tuple(row["path"][:-1]) + sib = [i for i, r in enumerate(self.rows) + if tuple(r["path"][:-1]) == parent_path] + if len(sib) < 2: + self._flash("no siblings here") + return + pos = sib.index(self.sel) + npos = pos + direction + if not 0 <= npos < len(sib): + self._flash("at the {} sibling".format("first" if direction < 0 else "last")) + return + self.sel = sib[npos] + + def _move_row(self, delta): + row = self._sel_row() + if row is None: + return + parent, last = self._parent_of(row) + if isinstance(parent, dict): + keys = list(parent.keys()) + i = keys.index(last) + j = i + delta + if not 0 <= j < len(keys): + self._flash("already at the edge", "dim") + return + self._push_undo() + other = keys[j] + items = list(parent.items()) + out = {} + for k, v in items: + if k == last: + out[other] = parent[other] + elif k == other: + out[last] = parent[last] + else: + out[k] = v + self._replace_parent_dict(parent, out) + elif isinstance(parent, list): + i = int(last) + j = i + delta + if not 0 <= j < len(parent): + self._flash("already at the edge", "dim") + return + self._push_undo() + parent[i], parent[j] = parent[j], parent[i] + else: + return + self._after_mutate() + if delta < 0 and self.sel > 0: + self.sel -= 1 + elif delta > 0 and self.sel < len(self.rows) - 1: + self.sel += 1 + self._flash("moved {}".format("up" if delta < 0 else "down"), "grn") + + # ── value editing ───────────────────────────────────────── + def _edit_value(self): + row = self._sel_row() + if row is None: + return + if not row["leaf"]: + self._toggle_expand(row) + return + v = row["node"] + if isinstance(v, bool): + self._push_undo() + parent, last = self._parent_of(row) + if isinstance(parent, dict): + parent[last] = not v + else: + parent[int(last)] = not v + self._after_mutate() + self._flash("toggled to {}".format("true" if not v else "false"), "grn") + return + text = self._prompt("value ({}):".format(_type_name(v)), canonical(v)) + if text is None: + return + try: + new = _parse(text, _KINDS[_type_name(v)]) + except ValueError as e: + self._flash(str(e), "red") + return + if new == v: + self._flash("unchanged") + return + self._push_undo() + parent, last = self._parent_of(row) + if isinstance(parent, dict): + parent[last] = new + else: + parent[int(last)] = new + self._after_mutate() + self._flash("edited \u2713", "grn") + + # ── clipboard ───────────────────────────────────────────── + def _copy_row(self, cut=False): + row = self._sel_row() + if row is None: + return + payload = copy.deepcopy(row["node"]) + from propertreecli import _plist_mod + plist = _plist_mod() + try: + with open(self.clip_file, "wb") as f: + plist.dump({"v": payload}, f, fmt=plist.FMT_XML, sort_keys=False) + except Exception as e: + self._flash("clipboard write failed: {}".format(e), "red") + return + if cut: + if not row["leaf"] and not self._confirm( + "cut {} (its whole subtree)?".format(self._row_label(row))): + return + self._delete_row_no_confirm() + self._flash("cut {}".format(self._row_label(row)), "grn") + else: + self._flash("copied {}".format(self._row_label(row)), "grn") + + def _delete_row_no_confirm(self): + row = self._sel_row() + if row is None: + return + parent, last = self._parent_of(row) + self._push_undo() + if isinstance(parent, dict): + if parent is self.root: + self.root = {k: v for k, v in parent.items() if k != last} + else: + self._rebuild_dict_without(parent, last) + else: + del parent[int(last)] + self._after_mutate() + + def _paste(self): + if not os.path.exists(self.clip_file): + self._flash("clipboard is empty", "red") + return + from propertreecli import _plist_mod + try: + with open(self.clip_file, "rb") as f: + data = _plist_mod().load(f) + payload = data["v"] + except Exception as e: + self._flash("clipboard read failed: {}".format(e), "red") + return + row = self.rows[self.sel] if self.rows else None + if row is None and not isinstance(self.root, (dict, list)): + self._flash("the root is a {} value - nothing to paste into".format( + _type_name(self.root)), "red") + return + parent, anchor, _ = self._target_of_row(row) + if isinstance(parent, dict): + default = (str(row["key"]) + if row is not None and isinstance(row["key"], str) else "") + key = self._prompt("paste as key:", default) + if key is None: + return + if key in parent: + self._flash("key already exists: {}".format(key), "red") + return + self._push_undo() + if anchor is None: + parent[key] = payload + else: + self._dict_insert_after(parent, anchor, key, payload) + else: + self._push_undo() + if anchor is None: + parent.append(payload) + else: + parent.insert(int(anchor) + 1, payload) + self._after_mutate() + self._flash("pasted \u2713", "grn") + + def _node_at(self, path): + cur = self.root + for seg in path: + cur = cur[seg] if isinstance(cur, dict) else cur[int(seg)] + return cur + + def _row_label(self, row): + if isinstance(row["key"], str): + return row["key"] + return "#{} ({})".format(row["key"], _type_name(row["node"])) + + def _binary(self): + return self.fmt == "binary" + + # ── find ────────────────────────────────────────────────── + _SCOPES = ("keys", "values", "both") + + def _find(self): + # tab inside the prompt cycles keys -> values -> both; the last + # used scope sticks for n/N and R until the next / search + start = self._SCOPES.index(self.find_scope) + q = self._prompt("find:", self.find or "", + cycle=self._SCOPES, cycle_i=start) + if q is None: + return + self.find_scope = self._SCOPES[self._cycle_i % len(self._SCOPES)] + if q == "": + self.find = None + self.matches = [] + self._flash("find cleared") + return + self.find = q + self._recompute_matches() + if not self.matches: + self._flash("no {} match for '{}'".format(self.find_scope, q), "red") + return + # jump to the first match at or after the selection + pos = next((i for i in self.matches if i >= self.sel), self.matches[0]) + self.match_idx = self.matches.index(pos) + self._goto(pos) + self._flash("match {}/{} ({})".format(self.match_idx + 1, len(self.matches), + self.find_scope), "grn") + + def _find_step(self, d): + if not self.matches: + self._flash("no active find (press /)", "red") + return + self.match_idx = (self.match_idx + d) % len(self.matches) + self._goto(self.matches[self.match_idx]) + self._flash("match {}/{} ({})".format(self.match_idx + 1, len(self.matches), + self.find_scope), "grn") + + def _replace_all(self): + if not self.find: + self._flash("no active find (press / first)", "red") + return + if self.find_scope == "keys": + self._flash("replace works on values - press / then tab to scope=values", "red") + return + q = self.find + repl = self._prompt("replace '{}' with:".format(q), "") + if repl is None: + return + count = 0 + + def fix(v): + nonlocal count + if isinstance(v, dict): + return {k: fix(x) for k, x in v.items()} + if isinstance(v, list): + return [fix(x) for x in v] + if isinstance(v, str) and q in v: + count += 1 + return v.replace(q, repl) + return v + + self._push_undo() + self.root = fix(self.root) + self._after_mutate() + if count: + self._flash("replaced {} in string values".format(count), "grn") + else: + self._flash("no string values matched (keys are left alone)", "red") + self._undo() + + # ── popups ──────────────────────────────────────────────── + def _box(self, title, lines, width=None, color="frost"): + h, w = self.H(), self.W() + width = width or min(max(len(title) + 4, max(len(l) for l in lines) + 4), w - 4) + height = len(lines) + 4 + top = max((h - height) // 2, 0) + left = max((w - width) // 2, 0) + self.s.move(top, 0) # force a sync point + border = P("brand" if color == "brand" else "frost", bold=True) + self.s.addstr(top, left, "\u2554" + "\u2550" * (width - 2) + "\u2557", border) + self.s.addstr(top + 1, left, "\u2551" + " " * (width - 2) + "\u2551", border) + t = title[: width - 4] + pad = width - 2 - len(t) + self.s.addstr(top + 1, left + 1, " " * (pad // 2) + t + " " * (pad - pad // 2), P("frost", bold=True)) + for i, l in enumerate(lines): + self.s.addstr(top + 2 + i, left, "\u2551", border) + self.s.addstr(top + 2 + i, left + 1, " " * (width - 2), P("dim")) + self.s.addstr(top + 2 + i, left + 1, l[: width - 2]) + self.s.addstr(top + 2 + i, left + width - 1, "\u2551", border) + self.s.addstr(top + height - 1, left, + "\u255a" + "\u2550" * (width - 2) + "\u255d", border) + + def _menu(self, title, items, sel=0): + # centered pick list; returns index or None. j/k or arrows move, + # enter picks, esc cancels. + h, w = self.H(), self.W() + sel = sel % len(items) + top = 0 + while True: + self._draw() + shown = items + rows_h = min(len(shown), h - 6) + offset = min(max(sel - rows_h + 1, 0), max(len(shown) - rows_h, 0)) + vis = shown[offset:offset + rows_h] + width = min(max(len(title) + 6, max(len(i) for i in vis) + 8), w - 4) + top = max((h - len(vis) - 4) // 2, 0) + left = max((w - width) // 2, 0) + self._box(title, [" " * (width - 4)] * len(vis), width) + for i, item in enumerate(vis): + y = top + 3 + i + x = left + 2 + if offset + i == sel: + self.s.addstr(y, x, "{:>2}.".format(offset + i + 1), P("frost", bold=True) | curses.A_REVERSE) + self.s.addstr(y, x + 3, item, curses.A_REVERSE | P("frost", bold=True)) + else: + self.s.addstr(y, x, "{:>2}.".format(offset + i + 1), P("frost", bold=True)) + self.s.addstr(y, x + 3, item) + self.s.refresh() + ch = self.s.getch() + if ch in (ord("j"), curses.KEY_DOWN): + sel = min(sel + 1, len(items) - 1) + elif ch in (ord("k"), curses.KEY_UP): + sel = max(sel - 1, 0) + elif ch in (curses.KEY_NPAGE,): + sel = min(sel + rows_h, len(items) - 1) + elif ch in (curses.KEY_PPAGE,): + sel = max(sel - rows_h, 0) + elif ch in (10, 13, curses.KEY_ENTER, ord(" ")): + return sel + elif ch in (27, ord("q")): + return None + return None + + def _confirm(self, question): + m = self._menu(question, ["yes", "no"], sel=1) + return m == 0 + + def _help(self): + lines = [ + "move arrows, j/k, g/G, home/end, pgup/pgdn", + "page ctrl+d / ctrl+u (half screen)", + "siblings { } jump to the prev/next sibling", + "fold left/right or space, enter on a container", + "edit enter on a value (booleans toggle)", + "add i (into a container, else as sibling)", + " an empty plist has no rows: i fills the root", + "duplicate D (copies under a new key / array slot)", + "delete d (asks first, even for leaves)", + "rename r (dict keys)", + "type t (scalar conversions)", + "convert ^t (text: ascii/base64/decimal/hex/binary)", + "template T (insert OC/Clover presets from menu.plist)", + "move entry < > (reorder inside its parent)", + "copy/cut/paste c x p (shared /tmp clipboard)", + "undo/redo u / ctrl+r (200 steps)", + "find / then n / N to cycle, esc clears", + " tab inside the prompt: keys / values / both", + "replace R (replace-all in string values, values scope)", + "save ctrl+s or F2", + "quit q (save prompt when dirty)", + "", + "writes are atomic and verified; the file keeps its format", + "and key order. esc cancels prompts. ? shows this again.", + ] + self._static_box("plist - keybinds", lines) + + def _static_box(self, title, lines): + self._draw() + h, w = self.H(), self.W() + width = min(max(len(title) + 6, max(len(l) for l in lines) + 6), w - 4) + vis = lines[: max(h - 6, 1)] + top = max((h - len(vis) - 4) // 2, 0) + left = max((w - width) // 2, 0) + self._box(title, [" " * (width - 4)] * len(vis), width) + for i, l in enumerate(vis): + y = top + 3 + i + self.s.addstr(y, left + 2, l[: width - 4]) + self.s.refresh() + while True: + ch = self.s.getch() + if ch != -1: + return + + def _choice_box(self, title, lines): + # like _static_box, but enter = yes and esc = no so callers + # can branch on the answer + self._draw() + h, w = self.H(), self.W() + width = min(max(len(title) + 6, max(len(l) for l in lines) + 6), w - 4) + vis = lines[: max(h - 6, 1)] + top = max((h - len(vis) - 4) // 2, 0) + left = max((w - width) // 2, 0) + self._box(title, [" " * (width - 4)] * len(vis), width) + for i, l in enumerate(vis): + y = top + 3 + i + self.s.addstr(y, left + 2, l[: width - 4]) + self.s.refresh() + while True: + ch = self.s.getch() + if ch in (10, 13, curses.KEY_ENTER): + return True + if ch in (27, ord("q"), ord("n")): + return False + if ch != -1: + return False + + # ── prompt line ─────────────────────────────────────────── + def _prompt(self, label, prefill="", cycle=None, cycle_i=0): + # blocking line editor on the status row; returns text or None. + # cycle = optional list of modes shown in the label; tab steps + # through them and the pick lands in self._cycle_i. + buf = list(prefill) + pos = len(buf) + self._cycle_i = cycle_i % len(cycle) if cycle else 0 + while True: + h, w = self.H(), self.W() + lab = label + if cycle: + lab = "{} <{}>".format(label, cycle[self._cycle_i]) + self._draw() + y = h - 3 + self.s.move(y, 0) + self.s.clrtoeol() + self.s.addstr(y, 0, " " + lab + " ", P("frost", bold=True)) + x0 = len(lab) + 2 + show = "".join(buf) + # window the buffer so the caret stays visible + avail = max(w - x0 - 1, 1) + if len(show) <= avail: + win = 0 + elif pos >= avail: + win = min(pos - avail + 1, len(show) - avail) + else: + win = 0 + view = show[win:win + avail] + self.s.addstr(y, x0, view, curses.A_UNDERLINE) + try: + self.s.move(y, x0 + (pos - win)) + except curses.error: + pass + self.s.refresh() + ch = self.s.getch() + if ch in (9,) and cycle: # tab: cycle the mode + self._cycle_i = (self._cycle_i + 1) % len(cycle) + continue + if ch in (27,): + return None + if ch in (10, 13, curses.KEY_ENTER): + return "".join(buf) + if ch in (curses.KEY_BACKSPACE, 127, 8): + if pos > 0: + del buf[pos - 1] + pos -= 1 + elif ch in (21,): # ctrl+u: clear line + buf = [] + pos = 0 + elif ch == curses.KEY_LEFT: + pos = max(pos - 1, 0) + elif ch == curses.KEY_RIGHT: + pos = min(pos + 1, len(buf)) + elif ch in (curses.KEY_HOME,): + pos = 0 + elif ch in (curses.KEY_END,): + pos = len(buf) + elif 32 <= ch < 127: + buf.insert(pos, chr(ch)) + pos += 1 + + # ── drawing ─────────────────────────────────────────────── + def _draw(self, status=None): + self.s.erase() + h, w = self.H(), self.W() + if h < 10 or w < 30: + self.s.addstr(0, 0, "terminal too small for the editor") + self.s.refresh() + return + # header + dirty = "*" if self.dirty else "" + name = self.path + if len(name) + 20 > w: + name = "..." + name[-(w - 23):] + self.s.addstr(0, 0, " " + name, P("frost", bold=True)) + x = len(name) + 2 + fmt = "binary" if self.path.endswith("plist") and self._binary() else "xml" + self.s.addstr(0, x, "[{}]".format(fmt), P("dim")) + if self.dirty: + self.s.addstr(0, x + len(fmt) + 2, dirty, P("amb", bold=True)) + right = "? help q quit ^s save"[: max(w - 30, 1)] + self.s.addstr(0, max(w - len(right) - 1, x + 10), right, P("dim")) + try: + self.s.addstr(1, 0, " " + "\u2500" * (w - 1), P("dim")) + except curses.error: + pass + + # tree + body_top, body_bot = 2, h - 4 + list_h = body_bot - body_top + if self.sel < self.top: + self.top = self.sel + if self.sel >= self.top + list_h: + self.top = self.sel - list_h + 1 + self._keyw = 0 + for r in self.rows[self.top:self.top + list_h]: + kt = str(r["key"]) + self._keyw = max(self._keyw, min(len(kt), 26)) + self._keyw = min(max(self._keyw, 8), 26) + for i, r in enumerate(self.rows[self.top:self.top + list_h]): + y = body_top + i + self._draw_row(y, r, self.top + i == self.sel, self.top + i) + if not self.rows: + self._draw_empty(body_top) + # footer / status + self._draw_status(status) + + def _draw_empty(self, y): + # an empty root draws no rows: say what the file is and how to + # fill it rather than leaving the body blank + if self.root_is_dict: + what = "dictionary" + elif self.root_is_list: + what = "array" + else: + what = "{} value".format(_type_name(self.root)) + self.s.addstr(y, 2, "(empty {} root)".format(what), P("dim")) + hint = "press " + self.s.addstr(y + 1, 2, hint, P("dim")) + self.s.addstr(y + 1, 2 + len(hint), "i", P("frost", bold=True)) + hint2 = " to add the first entry, or " + self.s.addstr(y + 1, 2 + len(hint) + 1, hint2, P("dim")) + self.s.addstr(y + 1, 2 + len(hint) + 1 + len(hint2), "T", P("frost", bold=True)) + self.s.addstr(y + 1, 2 + len(hint) + 2 + len(hint2), + " for an OpenCore preset", P("dim")) + + def _row_value_text(self, r): + if not r["leaf"]: + if r["count"] == 0: + return "(empty)" + collapsed = not self.expanded.get(tuple(r["path"]), True) + if collapsed: + return "{} entries".format(r["count"]) + return "" + v = r["node"] + t = canonical(v) + return _esc(t) + + def _draw_row(self, y, r, selected, abs_idx): + w = self.W() + indent = " " * (2 + r["depth"] * 2) + if not r["leaf"]: + arrow = "\u25be" if self.expanded.get(tuple(r["path"]), True) else "\u25b8" + else: + arrow = " " + x = 0 + key = str(r["key"]) + if isinstance(r["key"], int): + keypair = P("dim") + else: + # blue by depth: top-level keys keep plain frost, nested keys + # stay the same hue, slightly desaturated (d1..d6, clamped) + keypair = P("d{}".format(min(r["depth"], 6)), bold=True) + # pad key to the column width + key_txt = key[: self._keyw] + if len(key) > self._keyw: + key_txt = key[: self._keyw - 1] + "\u2026" + segments = [] + if not r["leaf"]: + segments.append((indent + arrow + " ", P("dim"))) + else: + segments.append((indent + " ", P("dim"))) + segments.append((key_txt.ljust(self._keyw) + " ", keypair)) + segments.append((_type_name(r["node"]).ljust(9), P("dim"))) + vc = value_color_name(r["node"]) + vt = self._row_value_text(r) + seg_col = P("grn") if vc == "grn" else (P("dim") if vc == "dim" else 0) + match = bool(self.find) and abs_idx in self.matches + if selected: + # frost selection bar, dark text + for txt, attr in segments: + try: + self.s.addstr(y, x, txt, curses.color_pair(7) | curses.A_BOLD) + except curses.error: + pass + x += len(txt) + try: + self.s.addstr(y, x, vt, curses.color_pair(7) | curses.A_BOLD) + self.s.addstr(y, x + len(vt), " " * (w - x - len(vt)), curses.color_pair(7)) + except curses.error: + pass + else: + for txt, attr in segments: + try: + self.s.addstr(y, x, txt, attr) + except curses.error: + pass + x += len(txt) + try: + if match: + self.s.addstr(y, x, vt, P("amb")) + else: + self.s.addstr(y, x, vt, seg_col) + except curses.error: + pass + + def _draw_status(self, status=None): + h, w = self.H(), self.W() + y = h - 3 + self.s.move(y, 0) + self.s.clrtoeol() + mode = status or "normal" + self.s.addstr(y, 0, " " + mode, P("frost", bold=True)) + if self.find: + m = "find: {} ({})".format(self.find, len(self.matches)) + self.s.addstr(y, len(mode) + 2, m, P("amb")) + if self.msg: + xx = len(mode) + 2 + (len("find: ") + len(self.find or "") + 4 if self.find else 0) + room = max(w - xx - 3, 1) + self.s.addstr(y, min(xx + 2, w - 1), self.msg[:room], P(self.msg_color)) + self.s.move(h - 2, 0) + self.s.clrtoeol() + self.s.addstr(h - 2, 0, " " + "\u2500" * (w - 1), P("dim")) + legend = ("\u2191\u2193 move \u2190\u2192 fold enter edit i add d del " + "c/x/p copy cut paste u undo / find ? help").strip() + # never occupy the bottom-right cell: ncurses errors on it + self.s.addstr(h - 1, 0, (" " + legend)[: w - 1], P("dim")) + + # ── main loop ───────────────────────────────────────────── + def run(self): + self._build_rows() + self._flash("arrow keys to move - ? for help", "dim") + while True: + self._draw() + self.s.refresh() + ch = self.s.getch() + try: + if self._handle(ch): + return + except Exception as e: + with open(self.log, "a") as f: + f.write(traceback.format_exc()) + self._flash("error: {} (see {})".format(e, self.log), "red") + + def _move_sel(self, d): + # d is -1 (up) or 1 (down). holding a direction accelerates + # smoothly: a fractional accumulator adds roughly 1 row per + # repeat plus a slowly growing ramp, so the step size drifts + # 1 -> 2 -> 3 -> 4 over a few seconds of holding instead of + # jumping between fixed tiers. no teleporting half-pages. + now = time.monotonic() + if d == self._hold_dir and (now - self._mv_last) < 0.4: + self._ramp = min(self._ramp + 0.03, 2.2) + else: + self._hold_dir = d + self._ramp = 0.03 + self._frac = 0.0 + self._mv_last = now + self._frac += 1.0 + self._ramp + step = int(self._frac) + self._frac -= step + if d < 0: + self.sel = max(self.sel - step, 0) + else: + self.sel = min(self.sel + step, self._last()) + + def _handle(self, ch): + if ch == -1: + return None + if ch not in (ord("j"), ord("k"), curses.KEY_DOWN, curses.KEY_UP): + # any other key breaks the hold-acceleration ramp + self._hold_dir = 0 + self._ramp = 0.0 + self._frac = 0.0 + r = self.sel + if ch in (ord("j"), curses.KEY_DOWN): + self._move_sel(1) + elif ch in (ord("k"), curses.KEY_UP): + self._move_sel(-1) + elif ch in (ord("g"),): + self.sel = 0 + elif ch in (ord("G"),): + self.sel = self._last() + elif ch in (curses.KEY_NPAGE,): + self.sel = min(self.sel + self.H() - 6, self._last()) + elif ch in (curses.KEY_PPAGE,): + self.sel = max(self.sel - (self.H() - 6), 0) + elif ch in (curses.KEY_LEFT, ord("h")): + if not self.rows: + return None + row = self.rows[self.sel] + p = tuple(row["path"]) + if not row["leaf"] and self.expanded.get(p, True): + self._toggle_expand(row) + elif len(row["path"]) > 1: + # folded already: jump to the parent row + target = tuple(row["path"][:-1]) + i = self._row_index_of_path(target) + if i is not None: + self.sel = i + elif ch in (curses.KEY_RIGHT, ord("l"), ord(" ")): + if not self.rows: + return None + row = self.rows[self.sel] + p = tuple(row["path"]) + if not row["leaf"] and not self.expanded.get(p, True): + self._toggle_expand(row) + elif ch in (ord("{"),): + self._sibling_jump(-1) + elif ch in (ord("}"),): + self._sibling_jump(1) + elif ch in (4, 21): # ctrl+d / ctrl+u: half a page + step = max((self.H() - 6) // 2, 1) + if ch == 4: + self.sel = min(self.sel + step, self._last()) + else: + self.sel = max(self.sel - step, 0) + elif ch in (curses.KEY_HOME,): + self.sel = 0 + elif ch in (curses.KEY_END,): + self.sel = self._last() + elif ch in (10, 13, curses.KEY_ENTER): + self._edit_value() + elif ch in (ord("i"), curses.KEY_IC): + self._add_entry() + elif ch in (ord("D"),): + self._duplicate_row() + elif ch == ord("d"): + self._delete_row() + elif ch == ord("r"): + self._rename_row() + elif ch == ord("t"): + self._change_type() + elif ch in (20,): # ctrl+t: the value converter (tk gui parity) + self._convert_value() + elif ch == ord("T"): + self._insert_template() + elif ch in (ord("c"),): + self._copy_row(cut=False) + elif ch in (ord("x"),): + self._copy_row(cut=True) + elif ch in (ord("p"),): + self._paste() + elif ch in (ord("u"), 26): # u or ctrl+z + self._undo() + elif ch in (18, 25): # ctrl+r or ctrl+y + self._redo() + elif ch == ord("R"): + self._replace_all() + elif ch == ord("/"): + self._find() + elif ch in (ord("n"),): + self._find_step(1) + elif ch in (ord("N"),): + self._find_step(-1) + elif ch == ord("<"): + self._move_row(-1) + elif ch == ord(">"): + self._move_row(1) + elif ch == 27: # esc: clear find / close overlays + if self.find: + self.find = None + self.matches = [] + self._flash("find cleared") + elif ch in (ord("?"),): + self._help() + elif ch in (ord("q"), 3): # q or ctrl+c + c = self._confirm_dirty() + if c == "y": + if not self._save(): + return None + return True + if c == "n": + return True + elif ch in (19, curses.KEY_F2): # ctrl+s or F2 + self._save() + elif ch == curses.KEY_RESIZE: + pass + return None + +def run_editor(root, path): + # main entry: wraps everything in curses and returns an exit code + def app(stdscr): + try: + locale.setlocale(locale.LC_ALL, "") + except locale.Error: + pass + try: + curses.curs_set(0) + except curses.error: + pass + stdscr.keypad(True) + # stay in cbreak (keypad escape parsing needs it) but drop IXON so + # ctrl+s arrives as a key instead of pausing output, and ISIG so + # ctrl+c arrives as a key (quit-with-confirm) instead of a signal + try: + import termios as _termios + _a = _termios.tcgetattr(sys.stdin.fileno()) + _a[0] &= ~_termios.IXON + _a[3] &= ~_termios.ISIG + _termios.tcsetattr(sys.stdin.fileno(), _termios.TCSANOW, _a) + except Exception: + pass + _init_colors() + ed = Editor(stdscr, root, path) + ed.run() + + try: + curses.wrapper(app) + return 0 + except KeyboardInterrupt: + return 130 + +if __name__ == "__main__": + print("plist_tui is a module - run it through propertreecli.py") diff --git a/propertreecli.py b/propertreecli.py new file mode 100755 index 0000000..3398481 --- /dev/null +++ b/propertreecli.py @@ -0,0 +1,1087 @@ +#!/usr/bin/env python3 +# propertreecli - a plist editor for the terminal +# new code in corpnewt's ProperTree tree, BSD-3 like the rest (see +# LICENSE). the terminal look is styled after W0lfSword, a toolkit of +# mine. +# +# VERSION is the single source of truth. the command is `plist`; the +# file keeps the repo name so it can never shadow Scripts/plist.py on +# import. +VERSION = "0.5.0" + +import argparse +import datetime +import json +import os +import plistlib +import shutil +import sys +import traceback + +# repo root, resolved through the ~/.local/bin symlink so the Scripts +# import works no matter which directory the command runs from +_HERE = os.path.dirname(os.path.realpath(__file__)) +if _HERE not in sys.path: + sys.path.insert(0, _HERE) + +# ── color palette - arctic wolf ─────────────────────────────── +# one accent (C_FROST), one muted tone (C_DIM), three semantic colors +# (grn ok, amb warn, red error). C_BRAND is for the tree art only. +# bold (B) marks important words. everything resets with NC. +C_BRAND = "\033[38;5;153m" +C_FROST = "\033[38;5;117m" +C_DIM = "\033[38;5;240m" +C_GRN = "\033[0;32m" +C_AMB = "\033[1;33m" +C_RED = "\033[0;31m" +B = "\033[1m" +NC = "\033[0m" + +COLOR = True # flipped off by --no-color, NO_COLOR, or a non-tty stdout + +def color_on(): + return COLOR + +def reset_colors(): + global C_BRAND, C_FROST, C_DIM, C_GRN, C_AMB, C_RED, B, NC + C_BRAND = C_FROST = C_DIM = C_GRN = C_AMB = C_RED = B = NC = "" + +# ── drawing helpers ─────────────────────────────────────────── +def term_cols(): + return shutil.get_terminal_size((80, 24)).columns + +def _vis_len(s): + # visible length of an ansi-free string + return len(s) + +def _trunc(s, n): + if _vis_len(s) <= n: + return s + if n <= 1: + return s[:n] + return s[: n - 1] + "\u2026" + +def rule(n=None): + # dim horizontal rule + n = n or min(term_cols() - 2, 76) + print(" " + C_DIM + "\u2500" * n + NC) + +def banner(title, sub=None, color=None): + # even boxed banner, W0lfSword style: computed padding, so the title + # centers regardless of length. only drawn on a tty. + color = color or C_FROST + w = term_cols() - 6 + w = min(max(w, 40), 72) + w -= w % 2 + inner = w - 2 + lines = [title] + ([sub] if sub else []) + print(" " + color + B + "\u2554" + "\u2550" * inner + "\u2557" + NC) + for t in lines: + t = _trunc(t, inner) + pad = inner - _vis_len(t) + l = pad // 2 + r = pad - l + print(" " + color + B + "\u2551" + NC + " " * l + t + " " * r + color + B + "\u2551" + NC) + print(" " + color + B + "\u255a" + "\u2550" * inner + "\u255d" + NC) + +# ── the tree logo ───────────────────────────────────────────── +# built from column math instead of hand-tuned so the branches always +# line up. a plist is a tree: root tag branching into its children. +def tree_art_lines(): + pad = " " * 8 + w = 21 + b = "+" + "-" * (w - 2) + "+" + # inner text centered: "" in a w-2 wide cell + tag = "" + cell = w - 2 + lp = (cell - len(tag)) // 2 + rp = cell - lp - len(tag) + mid = w // 2 # 10: center column inside the box + half = (w - 3) // 2 # 9: dash runs on the branch line + lines = [ + pad + b, + pad + "|" + " " * lp + tag + " " * rp + "|", + pad + "+" + "-" * half + "+" + "-" * half + "+", + pad + " " * mid + "|", + pad + "+" + "-" * half + "+" + "-" * half + "+", + pad + "|" + " " * half + "|" + " " * half + "|", + ] + # labels centered under the three branch points (cols 0, mid, w-1), + # in absolute columns so the left one never clips + labels = [(0, ""), (mid, ""), (w - 1, "")] + row_w = len(pad) + w + 8 + row = [" "] * row_w + for col, text in labels: + start = len(pad) + col - len(text) // 2 + for i, ch in enumerate(text): + if 0 <= start + i < row_w: + row[start + i] = ch + lines.append("".join(row).rstrip()) + return lines + +def show_logo(): + if not COLOR: + return + art = tree_art_lines() + print(C_BRAND + "\n".join(art) + NC) + print("") + +# ── status glyphs (W0lfSword convention) ────────────────────── +def ok(text): print(" " + C_GRN + "\u2713" + NC + " " + text) +def err(text): print(" " + C_RED + "\u2717" + NC + " " + text, file=sys.stderr) +def warn(text): print(" " + C_AMB + "\u26a0" + NC + " " + text, file=sys.stderr) +def info(text): print(" " + C_FROST + "\u2139" + NC + " " + text) +def hint(text): print(" " + C_DIM + " \u2192 " + text + NC) +def hint_err(text): print(" " + C_DIM + " \u2192 " + text + NC, file=sys.stderr) + +# ── plist io ────────────────────────────────────────────────── +def _plist_mod(): + # lazy import: --version/--help must work even if Scripts/plist.py + # misbehaves on some python build + from Scripts import plist as _plist + return _plist + +def is_binary(path): + with open(path, "rb") as f: + return f.read(8) == b"bplist00" + +def file_format(path): + return "binary" if is_binary(path) else "xml" + +def load_plist(path): + return _plist_mod().load(open(path, "rb")) + +def write_plist(root, path, fmt="xml"): + # atomic write + verify: dump to a temp file, parse it back, then + # swap it in. a broken write never leaves a half-written plist behind. + from Scripts import plist as _plist + fmt = _plist.FMT_BINARY if fmt == "binary" else _plist.FMT_XML + tmp = path + ".tmp" + try: + with open(tmp, "wb") as f: + _plist.dump(root, f, fmt=fmt, sort_keys=False) + with open(tmp, "rb") as f: + _plist.load(f) # throws if the dump is garbage + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + +# ── value formatting ────────────────────────────────────────── +def type_name(v): + if isinstance(v, bool): return "boolean" + if isinstance(v, int): return "integer" + if isinstance(v, float): return "real" + if isinstance(v, str): return "string" + if isinstance(v, bytes): return "data" + if isinstance(v, plistlib.UID) or type(v).__name__ == "UID": + return "uid" + if isinstance(v, datetime.datetime): + return "date" + if isinstance(v, dict): return "dict" + if isinstance(v, list): return "array" + return type(v).__name__.lower() + +def value_text(v): + # plain text for a leaf value; dict/array return "" (children drawn + # below). strings get control chars unescaped so rows stay single-line. + if isinstance(v, bool): + return "True" if v else "False" + if isinstance(v, int): + return str(v) + if isinstance(v, float): + return repr(v) + if isinstance(v, str): + return v.replace("\\", "\\\\").replace("\n", "\\n").replace("\t", "\\t") + if isinstance(v, bytes): + s = " ".join("{:02X}".format(x) for x in v[:32]) + return s + (" \u2026" if len(v) > 32 else "") + if isinstance(v, plistlib.UID) or type(v).__name__ == "UID": + return str(getattr(v, "data", v)) + if isinstance(v, datetime.datetime): + return v.strftime("%Y-%m-%d %H:%M:%S") + # data objects from old parsers (plistlib.Data and friends) + d = getattr(v, "data", None) + if d is not None and isinstance(d, bytes): + return " ".join("{:02X}".format(x) for x in d[:32]) + return str(v) + +def value_color(v): + # booleans read as status: enabled green, disabled grey. everything + # else keeps the default foreground - frost is for keys. + if isinstance(v, bool): + return C_GRN if v else C_DIM + return "" + +# ── tree rendering ──────────────────────────────────────────── +def _max_key_len(node): + best = 0 + if isinstance(node, dict): + for k, v in node.items(): + best = max(best, len(k)) + if isinstance(v, (dict, list)): + best = max(best, _max_key_len(v)) + elif isinstance(node, list): + for v in node: + if isinstance(v, (dict, list)): + best = max(best, _max_key_len(v)) + return best + +def _render_node(node, prefix, keyw, cols, last=True, key=None): + # one row per entry: guides (dim), key (bold frost), type (dim), + # value (plain or status-colored). containers recurse. visible width + # is tracked separately from the ansi codes so values don't truncate + # early when colors are on. + leaf = not isinstance(node, (dict, list)) + if key is None and isinstance(node, dict): + key = "" + guide = prefix + ("\u2514\u2500 " if last else "\u251c\u2500 ") + out = [C_DIM + guide + NC] + vis = len(guide) + if key is not None: + ktxt = _trunc(str(key), keyw) + # array indexes are structural, not keys: dim them + if isinstance(key, int): + out.append(C_DIM + ktxt + NC) + else: + out.append(C_FROST + B + ktxt + NC) + out.append(" " * (keyw - len(ktxt)) + " ") + vis += keyw + 1 + out.append(C_DIM + type_name(node).ljust(9) + " " + NC) + vis += 10 + if leaf: + vt = value_text(node) + vc = value_color(node) + out.append(vc + _trunc(vt, max(cols - vis, 0)) + (NC if vc else "")) + elif len(node) == 0: + out.append(C_DIM + "(empty)" + NC) + print("".join(out)) + if not leaf: + child_prefix = prefix + (" " if last else "\u2502 ") + items = list(node.items()) if isinstance(node, dict) else list(enumerate(node)) + for i, (ck, cv) in enumerate(items): + _render_node(cv, child_prefix, keyw, cols, last=i == len(items) - 1, key=ck) + +def show_tree(root, cols=None): + cols = cols or term_cols() - 2 + keyw = min(max(_max_key_len(root), 8), 32) + if isinstance(root, (dict, list)): + # the root container has no row of its own; its entries do + items = list(root.items()) if isinstance(root, dict) else list(enumerate(root)) + for i, (k, v) in enumerate(items): + _render_node(v, " ", keyw, cols, last=i == len(items) - 1, key=k) + else: + _render_node(root, " ", keyw, cols, last=True, key="root") + +def _describe_root(root): + if isinstance(root, dict): + n = len(root) + return "{} key{}".format(n, "" if n == 1 else "s") + if isinstance(root, list): + n = len(root) + return "{} item{}".format(n, "" if n == 1 else "s") + return type_name(root) + +def _open_error(path, e): + # friendly failure text for the common cases: missing file, directory, + # or something that is not a plist + if isinstance(e, FileNotFoundError): + err("cannot open {}: no such file".format(path)) + hint_err("create it with: plist new {}".format(path)) + elif isinstance(e, IsADirectoryError): + err("{} is a directory, not a plist".format(path)) + else: + err("cannot open {}: {}".format(path, e)) + hint_err("is it a plist? xml and binary both work") + +def open_and_show(path, show_banner=True): + root = _load_or_err(path) + if root is None: + return 1 + fmt = file_format(path) + if show_banner and COLOR: + show_logo() + banner(path, "{} plist - {}".format(fmt, _describe_root(root))) + else: + print(path) + show_tree(root) + return 0 + +# ── config ──────────────────────────────────────────────────── +# plain key=value file, # comments, template written on first run. +# the schema doubles as the docs, same trick as W0lfSword's config_schema(). +CONFIG_DEFAULTS = {"expand_mode": "auto", "format": "xml", "find_scope": "both", + "theme": "frost"} +# valid values per key, so `plist settings set` can validate before writing +CONFIG_VALID = { + "expand_mode": ("all", "auto", "none"), + "format": ("xml", "binary"), + "find_scope": ("keys", "values", "both"), + "theme": ("frost", "red"), +} +CONFIG_TEMPLATE = """# plist config - plain key=value, # comments. the editor reads this +# on open. edit by hand, or manage it with: plist settings +# plist settings show current values +# plist settings set K V change one (validated) +# plist settings reset back to defaults + +# expand_mode - how containers look when the editor opens a file: +# all every container expanded +# auto expand everything unless the file is huge (>1200 nodes) +# none everything collapsed; unfold with right arrow or space +expand_mode = auto + +# format - what `plist new` writes by default: xml | binary +format = xml + +# find_scope - what / searches by default: keys | values | both +# (tab inside the find prompt cycles the scope for that search) +find_scope = both + +# theme - frost is the usual pale blue; red turns every text color red +theme = frost +""" + +def config_dir(): + base = os.environ.get("XDG_CONFIG_HOME") or os.path.join( + os.path.expanduser("~"), ".config") + return os.path.join(base, "propertreecli") + +def config_path(): + return os.path.join(config_dir(), "config") + +def ensure_config(): + p = config_path() + if os.path.exists(p): + return p + try: + os.makedirs(config_dir(), exist_ok=True) + with open(p, "w") as f: + f.write(CONFIG_TEMPLATE) + except OSError: + pass + return p + +def load_config(): + cfg = dict(CONFIG_DEFAULTS) + try: + with open(config_path()) as f: + for line in f: + line = line.split("#", 1)[0].strip() + if not line or "=" not in line: + continue + k, v = [x.strip() for x in line.split("=", 1)] + cfg[k] = v + except OSError: + pass + if cfg.get("expand_mode") not in CONFIG_VALID["expand_mode"]: + cfg["expand_mode"] = "auto" + if cfg.get("format") not in CONFIG_VALID["format"]: + cfg["format"] = "xml" + if cfg.get("find_scope") not in CONFIG_VALID["find_scope"]: + cfg["find_scope"] = "both" + if cfg.get("theme") not in CONFIG_VALID["theme"]: + cfg["theme"] = "frost" + return cfg + +# ── keypaths ────────────────────────────────────────────────── +class PathError(Exception): + pass + +def split_path(s): + # dot separated; a backslash escapes a literal dot in a key name + parts, buf = [], [] + i = 0 + while i < len(s): + c = s[i] + if c == "\\" and i + 1 < len(s): + buf.append(s[i + 1]) + i += 2 + continue + if c == ".": + parts.append("".join(buf)) + buf = [] + i += 1 + continue + buf.append(c) + i += 1 + parts.append("".join(buf)) + for p in parts: + if p == "": + raise PathError("empty segment in keypath '{}'".format(s)) + return parts + +def _step_into(cur, seg, create=False): + # descend one level during path resolution + if isinstance(cur, dict): + if seg in cur: + return cur[seg] + if create: + cur[seg] = {} + return cur[seg] + raise PathError("no key '{}'".format(seg)) + if isinstance(cur, list): + try: + i = int(seg) + except ValueError: + raise PathError("'{}' is not an array index".format(seg)) + if i < len(cur): + return cur[i] + if create and i == len(cur): + cur.append({}) + return cur[-1] + raise PathError("array index {} out of range (len {})".format(i, len(cur))) + raise PathError("cannot descend into a {} value".format(type_name(cur))) + +def _resolve_path(root, segs, create=False): + # walk everything but the last segment; returns (container, last) + cur = root + for seg in segs[:-1]: + cur = _step_into(cur, seg, create=create) + return cur, segs[-1] + +def _is_uid(v): + return isinstance(v, plistlib.UID) or type(v).__name__ == "UID" + +def _has_uid(v): + if isinstance(v, dict): + return any(_has_uid(x) for x in v.values()) + if isinstance(v, list): + return any(_has_uid(x) for x in v) + return _is_uid(v) + +# ── one-shot value io ───────────────────────────────────────── +_BOOL_WORDS = { + "true": True, "false": False, "yes": True, "no": False, + "on": True, "off": False, "1": True, "0": False, +} + +def parse_value(raw, kind): + # kind: s string (default), i integer (0x ok), f real, b boolean, + # x data from hex, d date (iso), u uid (binary plists only) + if kind == "s": + return raw + if kind == "i": + try: + return int(raw, 0) + except ValueError: + raise ValueError("not an integer: '{}'".format(raw)) + if kind == "f": + try: + return float(raw) + except ValueError: + raise ValueError("not a number: '{}'".format(raw)) + if kind == "b": + w = raw.lower() + if w not in _BOOL_WORDS: + raise ValueError("not a boolean: '{}' (try true/false, yes/no, on/off, 1/0)".format(raw)) + return _BOOL_WORDS[w] + if kind == "x": + try: + return bytes.fromhex("".join(raw.split())) + except ValueError: + raise ValueError("not hex data: '{}'".format(raw)) + if kind == "d": + try: + return datetime.datetime.fromisoformat(raw.replace(" ", "T", 1)) + except ValueError: + raise ValueError("not an iso date: '{}' (try 2024-03-01 or 2024-03-01 12:30:00)".format(raw)) + if kind == "u": + if not hasattr(plistlib, "UID"): + raise ValueError("uid needs python 3.8+") + try: + return plistlib.UID(int(raw, 0)) + except ValueError: + raise ValueError("not an integer uid: '{}'".format(raw)) + raise ValueError("unknown type flag '{}'".format(kind)) + +def _data_bytes(v): + # bytes, or the data payload of legacy plistlib.Data style objects + if isinstance(v, bytes): + return v + d = getattr(v, "data", None) + return d if isinstance(d, bytes) else None + +def to_jsonable(v): + if isinstance(v, dict): + return {k: to_jsonable(x) for k, x in v.items()} + if isinstance(v, list): + return [to_jsonable(x) for x in v] + if isinstance(v, bool) or isinstance(v, (int, float)) or isinstance(v, str): + return v + d = _data_bytes(v) + if d is not None: + return " ".join("{:02X}".format(b) for b in d) + if _is_uid(v): + return getattr(v, "data", v) + if isinstance(v, datetime.datetime): + return v.strftime("%Y-%m-%d %H:%M:%S") + return str(v) + +def print_value(v, json_mode=False): + # script output: strings raw, bools lowercase, data as continuous + # hex (pipes into xxd -r -p), dates iso. containers tree out, or + # json with --json. + if json_mode: + print(json.dumps(to_jsonable(v))) + return + if isinstance(v, (dict, list)): + show_tree(v) + return + d = _data_bytes(v) + if isinstance(v, bool): + print("true" if v else "false") + elif d is not None: + print("".join("{:02X}".format(b) for b in d)) + elif _is_uid(v): + print(getattr(v, "data", v)) + elif isinstance(v, datetime.datetime): + print(v.strftime("%Y-%m-%d %H:%M:%S")) + else: + print(v) + +def _load_or_err(path): + try: + return load_plist(path) + except Exception as e: + _open_error(path, e) + if os.environ.get("PROPERTREECLI_DEBUG"): + traceback.print_exc() + return None + +def _write_or_err(root, path): + try: + write_plist(root, path, file_format(path)) + except Exception as e: + err("cannot write {}: {}".format(path, e)) + if os.environ.get("PROPERTREECLI_DEBUG"): + traceback.print_exc() + return 1 + ok("wrote {}".format(path)) + return 0 + +def cmd_get(argv, json_mode=False): + p = argparse.ArgumentParser(prog="plist get", + description="print the value at a keypath") + p.add_argument("file") + p.add_argument("keypath") + a = p.parse_args(argv) + root = _load_or_err(a.file) + if root is None: + return 1 + try: + segs = split_path(a.keypath) + if len(segs) == 1 and not isinstance(root, (dict, list)): + raise PathError("root is a {} value, not a container".format(type_name(root))) + parent, last = _resolve_path(root, segs) + v = _step_into(parent, last) + except PathError as e: + err("{}: {}".format(a.keypath, e)) + return 2 + print_value(v, json_mode) + return 0 + +def cmd_set(argv): + p = argparse.ArgumentParser(prog="plist set", + description="set the value at a keypath; missing dictionaries along " + "the path are created, arrays accept index == length to " + "append. the file keeps its format and key order.") + p.add_argument("file") + p.add_argument("keypath") + p.add_argument("value", nargs="?", help="value to store; omit with -b to flip an existing boolean") + g = p.add_mutually_exclusive_group() + g.add_argument("-i", action="store_const", const="i", dest="kind", help="integer (0x hex ok)") + g.add_argument("-f", action="store_const", const="f", dest="kind", help="real number") + g.add_argument("-b", action="store_const", const="b", dest="kind", help="boolean (true/false/yes/no/on/off/1/0)") + g.add_argument("-x", action="store_const", const="x", dest="kind", help="data from a hex string") + g.add_argument("-d", action="store_const", const="d", dest="kind", help="date (iso)") + g.add_argument("-u", action="store_const", const="u", dest="kind", help="uid (binary plists only)") + a = p.parse_args(argv) + kind = a.kind or "s" + root = _load_or_err(a.file) + if root is None: + return 1 + if a.value is None: + if kind == "b": + # flag flip: needs an existing boolean + try: + segs = split_path(a.keypath) + parent, last = _resolve_path(root, segs) + cur = _step_into(parent, last) + except PathError as e: + err("{}: {}".format(a.keypath, e)) + return 2 + if not isinstance(cur, bool): + err("{} is a {} - flip needs an existing boolean".format(a.keypath, type_name(cur))) + return 1 + parsed = not cur + else: + p.error("missing value") + else: + try: + parsed = parse_value(a.value, kind) + except ValueError as e: + err(str(e)) + return 1 + if kind == "u" and file_format(a.file) != "binary": + err("uid values only survive in binary plists - {} is xml".format(a.file)) + return 1 + try: + segs = split_path(a.keypath) + container, last = _resolve_path(root, segs, create=True) + if isinstance(container, dict): + existing = container.get(last) + if isinstance(existing, (dict, list)): + err("{} is a {} - refusing to replace a container (del it first)".format( + a.keypath, type_name(existing))) + return 1 + container[last] = parsed + elif isinstance(container, list): + try: + i = int(last) + except ValueError: + err("'{}' is not an array index".format(last)) + return 2 + if i < len(container): + existing = container[i] + if isinstance(existing, (dict, list)): + err("{} is a {} - refusing to replace a container (del it first)".format( + a.keypath, type_name(existing))) + return 1 + container[i] = parsed + elif i == len(container): + container.append(parsed) + else: + err("array index {} out of range (len {})".format(i, len(container))) + return 2 + else: + err("root is a {} value, not a container".format(type_name(root))) + return 1 + except PathError as e: + err("{}: {}".format(a.keypath, e)) + return 2 + if _has_uid(root) and file_format(a.file) != "binary": + err("file contains uid values; only binary plists can store them") + return 1 + code = _write_or_err(root, a.file) + if code == 0: + ok("{} = {}".format(a.keypath, a.value if a.value is not None else "flipped")) + return code + +def cmd_del(argv): + p = argparse.ArgumentParser(prog="plist del", + description="remove the key or array element at a keypath") + p.add_argument("file") + p.add_argument("keypath") + a = p.parse_args(argv) + root = _load_or_err(a.file) + if root is None: + return 1 + try: + segs = split_path(a.keypath) + container, last = _resolve_path(root, segs) + if isinstance(container, dict): + if last not in container: + raise PathError("no key '{}'".format(last)) + del container[last] + elif isinstance(container, list): + try: + i = int(last) + except ValueError: + raise PathError("'{}' is not an array index".format(last)) + if not 0 <= i < len(container): + raise PathError("array index {} out of range (len {})".format(i, len(container))) + del container[i] + else: + raise PathError("cannot descend into a {} value".format(type_name(container))) + except PathError as e: + err("{}: {}".format(a.keypath, e)) + return 2 + code = _write_or_err(root, a.file) + if code == 0: + ok("removed {}".format(a.keypath)) + return code + +def _fmt_diff_value(v): + # short value text for diff lines: containers as a count summary + if isinstance(v, dict): + return "".format(len(v)) + if isinstance(v, list): + return "".format(len(v)) + return value_text(v) + +def _diff_walk(a, b, path, out): + # out is a list of (kind, path, a_text, b_text) where kind is + # "+" added, "-" removed, "~" changed. arrays compare by index, + # dicts by key; recursion stops at the first difference. + if isinstance(a, dict) and isinstance(b, dict): + for k in a: + if k not in b: + out.append(("-", path + [k], _fmt_diff_value(a[k]), None)) + for k in b: + if k not in a: + out.append(("+", path + [k], None, _fmt_diff_value(b[k]))) + for k in a: + if k in b: + _diff_walk(a[k], b[k], path + [k], out) + elif isinstance(a, list) and isinstance(b, list): + for i in range(max(len(a), len(b))): + if i >= len(a): + out.append(("+", path + [i], None, _fmt_diff_value(b[i]))) + elif i >= len(b): + out.append(("-", path + [i], _fmt_diff_value(a[i]), None)) + else: + _diff_walk(a[i], b[i], path + [i], out) + elif a != b: + out.append(("~", path, _fmt_diff_value(a), _fmt_diff_value(b))) + +def _fmt_diff_path(path): + # dotted keypath for display; array indexes read as plain numbers + return ".".join(str(s) for s in path) + +def cmd_diff(argv): + # plist diff : walk both trees, print what differs. + # exit codes: 0 identical, 1 differences, 2 file/usage error. + p = argparse.ArgumentParser(prog="plist diff", + description="compare two plists, print added/removed/changed keypaths") + p.add_argument("files", nargs=2, metavar="file") + a = p.parse_args(argv) + f1, f2 = a.files + r1 = _load_or_err(f1) + if r1 is None: + return 2 + r2 = _load_or_err(f2) + if r2 is None: + return 2 + out = [] + _diff_walk(r1, r2, [], out) + # sort by keypath so the output is stable and readable + out.sort(key=lambda t: _fmt_diff_path(t[1])) + for kind, path, va, vb in out: + kp = _fmt_diff_path(path) + if kind == "+": + print(" " + C_GRN + "+ " + NC + kp + " = " + vb) + elif kind == "-": + print(" " + C_RED + "- " + NC + kp + " = " + va) + else: + print(" " + C_AMB + "~ " + NC + kp + ": " + va + " -> " + vb) + if out: + n_add = sum(1 for t in out if t[0] == "+") + n_del = sum(1 for t in out if t[0] == "-") + n_chg = len(out) - n_add - n_del + info("{} added, {} removed, {} changed".format(n_add, n_del, n_chg)) + return 1 + ok("identical") + return 0 + +def cmd_new(argv): + p = argparse.ArgumentParser(prog="plist new", + description="create a new empty plist and open it in the editor") + p.add_argument("path", help="file to create (refuses to overwrite)") + p.add_argument("--binary", action="store_true", + help="write a binary plist (default: xml, or the config format)") + p.add_argument("--root", choices=("dict", "array"), default="dict", + help="root container type (default dict)") + a = p.parse_args(argv) + if os.path.exists(a.path): + err("{} already exists - refusing to overwrite".format(a.path)) + return 1 + root = {} if a.root == "dict" else [] + fmt = "binary" if a.binary else load_config().get("format", "xml") + try: + write_plist(root, a.path, fmt) + except Exception as e: + err("cannot create {}: {}".format(a.path, e)) + if os.environ.get("PROPERTREECLI_DEBUG"): + traceback.print_exc() + return 1 + ok("created {} ({} plist, {} root)".format(a.path, fmt, a.root)) + if _tty(): + return _start_editor(a.path) + return 0 + +def cmd_convert(argv): + p = argparse.ArgumentParser(prog="plist convert", + description="rewrite a plist in the other format, in place unless -o") + p.add_argument("file") + p.add_argument("-t", "--to", required=True, choices=("xml", "binary"), + help="target format") + p.add_argument("-o", "--out", help="output path (default: overwrite the file)") + a = p.parse_args(argv) + root = _load_or_err(a.file) + if root is None: + return 1 + if a.to == "xml" and _has_uid(root): + err("{} holds uid values - xml plists cannot represent them".format(a.file)) + return 1 + out = a.out or a.file + try: + write_plist(root, out, a.to) + except Exception as e: + err("cannot write {}: {}".format(out, e)) + if os.environ.get("PROPERTREECLI_DEBUG"): + traceback.print_exc() + return 1 + ok("wrote {} as a {} plist".format(out, a.to)) + return 0 + +def _write_config_file(p, text): + # atomic-ish config write: temp file + rename, same discipline as plists + tmp = p + ".tmp" + with open(tmp, "w") as f: + f.write(text) + os.replace(tmp, p) + +def cmd_settings(argv): + # plist settings | settings set key value | settings reset + p = argparse.ArgumentParser(prog="plist settings", + description="show or change the plist config file") + p.add_argument("action", nargs="?", default="show", choices=("show", "set", "reset")) + p.add_argument("rest", nargs="*") + a = p.parse_args(argv) + path = ensure_config() + if a.action == "show": + cfg = load_config() + ok("config: {}".format(path)) + for k in CONFIG_DEFAULTS: + info("{} = {}".format(k, cfg.get(k, CONFIG_DEFAULTS[k]))) + return 0 + if a.action == "reset": + try: + os.remove(path) + except OSError: + pass + ensure_config() + ok("config reset to defaults: {}".format(path)) + return 0 + # set key value + if len(a.rest) != 2: + err("settings set takes a key and a value, e.g.: plist settings set format binary") + return 2 + key, value = a.rest + if key not in CONFIG_VALID: + err("unknown setting '{}' - known: {}".format(key, ", ".join(sorted(CONFIG_VALID)))) + return 2 + if value not in CONFIG_VALID[key]: + err("'{}' is not valid for {} - use one of: {}".format( + value, key, ", ".join(CONFIG_VALID[key]))) + return 2 + # update the file in place, preserving comments and other keys + lines = [] + try: + with open(path) as f: + lines = f.readlines() + except OSError: + pass + found = False + for i, ln in enumerate(lines): + body = ln.split("#", 1)[0].strip() + if body.startswith(key + "=") or body.startswith(key + " =")\ + or body == key: + lines[i] = "{} = {}\n".format(key, value) + found = True + break + if not found: + lines.append("{} = {}\n".format(key, value)) + _write_config_file(path, "".join(lines)) + ok("{} = {}".format(key, value)) + return 0 + +# ── cli ─────────────────────────────────────────────────────── +JSON_OUT = False + +COMMANDS = ("get", "set", "del", "diff", "convert", "edit", "new", "settings", "help") + +SHORT_USAGE = ( + "usage: plist [--json] [--no-color] [args] | \n" + " commands: get, set, del, diff, convert, edit, new, settings (plist help for details)" +) + +HELP = """\ +plist v{} - a plist editor for the terminal (W0lfSword-flavored) + +usage: + plist [file] open a plist in the interactive + editor (falls back to a tree print + when stdout is piped) + plist show plists as a tree + plist edit force the interactive editor + plist get [--json] print the value at a keypath + plist set [-i|-f|-b|-x|-d|-u] + set the value at a keypath + plist del remove a key or array element + plist diff show added/removed/changed keys + (exit 0 identical, 1 differs) + plist convert --to xml|binary [-o out] + rewrite in the other format + plist new [--binary] [--root dict|array] + create an empty plist (xml by + default) and open the editor + plist settings show the config values + plist settings set change one (validated) + plist settings reset back to defaults + +options: + --json machine-readable output (get only) + --no-color plain output (implied when stdout is not a tty) + --version print the version + -h, --help this help + +the editor: + arrow keys or j/k move, left/right (or space) fold containers, home/end + top/bottom, {{ }} jump to the previous/next sibling, ctrl+d / ctrl+u + half a page, enter edits a value (booleans toggle), i adds an entry, + d deletes, D duplicates, r renames a key, t changes a value's type, + c/x/p copy/cut/paste, u undoes (ctrl+r redoes, 200 steps), / finds + with n/N cycling, R replaces the find text in string values, ctrl+s + saves, q quits (it asks when dirty), ? shows every keybind. the file + keeps its format and key order, and writes are atomic + verified by + re-reading. + +config: ~/.config/propertreecli/config (created on first editor run). +expand_mode = all | auto | none decides how containers open; format += xml | binary is what plist new writes; find_scope = keys | values | +both is what / searches by default (tab inside the find prompt cycles +the scope for that search); theme = frost | red picks the palette +(red turns every text color red). plist settings shows and changes +these. + +one-shots: + keypaths are dot separated: Misc.Boot.Timeout or Drivers.0.Path; a + backslash escapes a literal dot in a key name (com\\\\.apple\\\\.x). get and + del walk existing keys; set creates missing dictionaries along the way + and appends to arrays at index == length. values default to strings: + -i integer (0x hex ok) -f real -b boolean (true/false/yes/no/on/off/ + 1/0; omit the value to flip an existing boolean) -x data from hex + -d date (iso) -u uid (binary plists only). values that start with a + dash need a -- separator first: plist set f.plist Boot-args -- -v x + +get prints strings raw, booleans lowercase, data as continuous hex (pipe +it into xxd -r -p), dates iso; containers tree out. exit codes: +0 ok, 1 file or value error, 2 bad keypath or usage +""".format(VERSION) + +def _apply_color_flags(): + global COLOR + if not COLOR or os.environ.get("NO_COLOR"): + COLOR = False + elif not sys.stdout.isatty(): + COLOR = False + if not COLOR: + reset_colors() + return + # theme = red from the config file turns every color red (frost 196, + # dim 124 so hints stay muted) - same idea as the editor palette + try: + if load_config().get("theme") == "red": + global C_BRAND, C_FROST, C_DIM, C_GRN, C_AMB, C_RED + C_BRAND = C_FROST = C_GRN = C_AMB = C_RED = "\033[38;5;196m" + C_DIM = "\033[38;5;124m" + except Exception: + pass + +def _tty(): + return sys.stdout.isatty() and sys.stdin.isatty() + +def _start_editor(path): + # the curses editor (plist_tui); only works on a real terminal + if not _tty(): + err("the editor needs a terminal - run it in one, or use get/set/del") + return 1 + root = _load_or_err(path) + if root is None: + return 1 + try: + from plist_tui import run_editor + except Exception as e: + err("editor failed to load: {}".format(e)) + if os.environ.get("PROPERTREECLI_DEBUG"): + traceback.print_exc() + return 1 + return run_editor(root, path) + +def main(argv=None): + global COLOR, JSON_OUT + argv = list(sys.argv[1:]) if argv is None else list(argv) + # global flags can sit anywhere (W0lfSword pre-scans the same way) + rest, help_wanted = [], False + for a in argv: + if a == "--json": + JSON_OUT = True + elif a == "--no-color": + COLOR = False + elif a == "--version": + print("plist v" + VERSION) + return 0 + elif a in ("-h", "--help"): + help_wanted = True + else: + rest.append(a) + _apply_color_flags() + + if not rest: + if help_wanted: + print(HELP) + return 0 + if os.path.exists("config.plist"): + return _start_editor("config.plist") if _tty() else open_and_show("config.plist") + err("nothing to open: no config.plist in this directory") + hint_err("try: plist (open a plist)") + hint_err(" plist new my.plist (start from scratch)") + hint_err(" plist help (everything)") + return 1 + + cmd = rest[0] + if cmd in COMMANDS: + sub = rest[1:] + if help_wanted: + sub.insert(0, "-h") + if cmd == "help": + print(HELP) + return 0 + if cmd == "edit": + if len(sub) != 1: + err("edit takes exactly one plist file") + return 2 + return _start_editor(sub[0]) + if cmd == "new": + return cmd_new(sub) + if cmd == "settings": + return cmd_settings(sub) + if cmd == "get": + return cmd_get(sub, json_mode=JSON_OUT) + if cmd == "set": + return cmd_set(sub) + if cmd == "del": + return cmd_del(sub) + if cmd == "diff": + return cmd_diff(sub) + return cmd_convert(sub) + if help_wanted or cmd.startswith("-"): + print(HELP) + return 0 if help_wanted else 2 + + # not a command: plist files. one file on a terminal opens the + # editor; anything else prints the tree (plain when piped) + if len(rest) == 1 and _tty() and not help_wanted: + return _start_editor(rest[0]) + code = 0 + for i, f in enumerate(rest): + if i: + print("") + code |= open_and_show(f) + return code + +if __name__ == "__main__": + try: + sys.exit(main()) + except BrokenPipeError: + # stdout closed early (propertreecli file.plist | head) - not an error + try: + sys.stdout.close() + except Exception: + pass + sys.exit(0) diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..bc76ec7 --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# tui tests - pty driven, stdlib only, no test framework +# usage: tests/run.sh run everything +set -euo pipefail +cd "$(dirname "$(readlink -f "$0")")/.." + +python3 -m py_compile plist_tui.py propertreecli.py Scripts/plist.py +echo "compile ok" + +rm -rf /tmp/plist_tui_tests +python3 tests/tui_empty_test.py +python3 tests/tui_regress_test.py +echo "all tui tests passed" diff --git a/tests/sample.plist b/tests/sample.plist new file mode 100644 index 0000000..dd8696c --- /dev/null +++ b/tests/sample.plist @@ -0,0 +1,104 @@ + + + + + Comment + press enter and type something! + Boot-args + -v keepsyms=1 debug=0x100 + csr-active-config + + ZwAAAA== + + Misc + + Boot + + Timeout + 5 + HibernateMode + Auto + PollAppleHotKeys + + LauncherOption + Full + + BlessOverride + + \EFI\OC\Bootstrap\Bootstrap.efi + + + NVRAM + + Add + + 7C436110-AB2A-4BBB-A880-FE41995C9F82 + + boot-args + keepsyms=1 + prev-lang:kbd + en:0 + + + + ACPI + + Add + + + Comment + EC fix + Enabled + + Path + SSDT-EC.aml + + + Comment + USB map + Enabled + + Path + SSDT-USB.aml + + + + Drivers + + + Path + OpenRuntime.efi + Enabled + + + + Path + ResetNvram.efi + Enabled + + + + Kernel + + Add + + Quirks + + AppleCpuPmCfgLock + + DisableIoMapper + + PanicNoKextDump + + + + Tags + + hackintosh + opencore + plist + + Built + 2026-09-03T12:00:00Z + + diff --git a/tests/tui_empty_test.py b/tests/tui_empty_test.py new file mode 100644 index 0000000..30eda86 --- /dev/null +++ b/tests/tui_empty_test.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +# regression smoke test: filling a brand new (empty root) plist in the tui +import os, fcntl, pty, plistlib, select, struct, subprocess, sys, termios, time + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +WD = "/tmp/plist_tui_tests" # scratch files for the run +XDG = os.path.join(WD, "config") # first-run config stays out of $HOME +LOG = "/tmp/plist_tui.log" # the editor appends tracebacks here +os.makedirs(XDG, exist_ok=True) +os.makedirs(WD, exist_ok=True) + +DOWN, UP, RIGHT, LEFT = b"\x1bOB", b"\x1bOA", b"\x1bOC", b"\x1bOD" +CTRL_S = b"\x13" +fails = [] + +def check(name, cond, extra=""): + print(("PASS " if cond else "FAIL ") + name + (" " + extra if extra and not cond else "")) + if not cond: + fails.append(name) + +class Tui: + def __init__(self, path): + self.master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 100, 0, 0)) + env = dict(os.environ, TERM="xterm-256color", LC_ALL="C.UTF-8", + LANG="C.UTF-8", XDG_CONFIG_HOME=XDG) + self.proc = subprocess.Popen([sys.executable, os.path.join(REPO, "propertreecli.py"), + "edit", path], + stdin=slave, stdout=slave, stderr=slave, + env=env, close_fds=True) + os.close(slave) + self.buf = b"" + + def pump(self, t=0.4): + end = time.time() + t + while time.time() < end: + r, _, _ = select.select([self.master], [], [], 0.1) + if r: + try: + c = os.read(self.master, 65536) + except OSError: + break + if not c: + break + self.buf += c + if len(self.buf) > 400000: + self.buf = self.buf[-200000:] + + def wait_for(self, text, timeout=6): + end = time.time() + timeout + while time.time() < end: + if text in self.buf.decode("utf-8", "replace"): + return True + self.pump(0.2) + return False + + def send(self, d, t=0.35): + os.write(self.master, d if isinstance(d, bytes) else d.encode()) + time.sleep(t) + self.pump() + + def quit(self): + self.send(b"q") + time.sleep(0.8) + self.pump(0.5) + try: + rc = self.proc.wait(timeout=4) + except subprocess.TimeoutExpired: + self.proc.kill() + return None + os.close(self.master) + return rc + +def new_plist(path, *extra): + return subprocess.run([sys.executable, os.path.join(REPO, "propertreecli.py"), + "new", path] + list(extra), + capture_output=True, env=dict(os.environ, XDG_CONFIG_HOME=XDG)).returncode + +def load(path): + with open(path, "rb") as f: + return plistlib.load(f) + +open(LOG, "w").close() # the editor appends tracebacks here + +# ── empty dict root ──────────────────────────────────────────────────── +F = os.path.join(WD, "fresh.plist") +os.path.exists(F) and os.unlink(F) +check("plist new creates the file", new_plist(F) == 0 and os.path.exists(F)) +t = Tui(F) +check("empty root is announced", t.wait_for("empty dictionary root")) +# every one of these used to raise IndexError on an empty tree +for k in (LEFT, RIGHT, UP, DOWN, b"\r", b"c", b"d", b"r", b"t", b"D", b"x", + b"g", b"G", b">{", b"{"): + t.send(k) +check("no crash from row keys", not os.path.getsize(LOG), open(LOG).read()[-400:]) +check("still alive after row keys", t.proc.poll() is None) + +# i -> string -> key name +t.send(b"i"); t.send(b"\r"); t.send(b"Alpha\r") +check("first entry added to the root", t.wait_for("Alpha")) +t.send(b"i"); t.send(b"\r"); t.send(b"Beta\r") +# last row, then a dict child: menu item 9 is "dict" +t.send(b"G"); t.send(b"i"); t.send(b"j" * 7); t.send(b"\r"); t.send(b"Nested\r") +t.send(b"G"); t.send(b"i"); t.send(b"\r"); t.send(b"Child\r") +t.send(CTRL_S, t=0.6) +d = load(F) +check("dict root filled", d == {"Alpha": "", "Beta": "", "Nested": {"Child": ""}}, repr(d)) +# delete the child through the confirm menu +t.send(b"G"); t.send(b"d"); t.send(b"k\r") # confirm menu defaults to no +t.send(CTRL_S, t=0.6) +d = load(F) +check("delete works on the filled tree", d == {"Alpha": "", "Beta": "", "Nested": {}}, repr(d)) +rc = t.quit() +check("clean quit (dict)", rc == 0, "rc=%s" % rc) +check("no tracebacks (dict run)", not os.path.getsize(LOG), open(LOG).read()[-400:]) + +# ── empty array root ─────────────────────────────────────────────────── +A = os.path.join(WD, "fresh_array.plist") +os.path.exists(A) and os.unlink(A) +check("plist new --root array", new_plist(A, "--root", "array") == 0) +t = Tui(A) +check("empty array root is announced", t.wait_for("empty array root")) +t.send(b"i"); t.send(b"\r"); t.send(CTRL_S, t=0.6) +d = load(A) +check("array root filled", d == [""], repr(d)) +t.send(b"i"); t.send(b"j" * 8); t.send(b"\r") # -> array, appended +t.send(b"i"); t.send(b"j" * 3); t.send(b"\r") # -> boolean, appended +t.send(CTRL_S, t=0.6) +d = load(A) +check("array keeps taking mixed types", d == ["", True, []], repr(d)) +t.send(b">") +t.send(b"G"); t.send(b"i"); t.send(b"\r") # on the empty array row: adds into it +t.send(CTRL_S, t=0.6) +d = load(A) +check("move row + add into the last container", d == [True, "", [""]], repr(d)) +rc = t.quit() +check("clean quit (array)", rc == 0, "rc=%s" % rc) +check("no tracebacks (array run)", not os.path.getsize(LOG), open(LOG).read()[-400:]) + +print("FAILS:", len(fails)) +sys.exit(1 if fails else 0) diff --git a/tests/tui_regress_test.py b/tests/tui_regress_test.py new file mode 100644 index 0000000..3a09891 --- /dev/null +++ b/tests/tui_regress_test.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# regression check: the row ops on a non-empty tree still behave (the +# empty-tree guards must not have changed the normal paths) +import os, fcntl, pty, plistlib, select, struct, subprocess, sys, termios, time + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +WD = "/tmp/plist_tui_tests" # scratch files for the run +XDG = os.path.join(WD, "config") # first-run config stays out of $HOME +LOG = "/tmp/plist_tui.log" # the editor appends tracebacks here +F = os.path.join(WD, "normal.plist") +os.makedirs(XDG, exist_ok=True); os.makedirs(WD, exist_ok=True) +with open(F, "wb") as f: + plistlib.dump({"Alpha": "one", "Flag": True, "Nest": {"Deep": "x"}}, + f, fmt=plistlib.FMT_XML, sort_keys=False) + +DOWN, UP = b"\x1bOB", b"\x1bOA" +CTRL_S, CTRL_Z = b"\x13", b"\x1a" +fails = [] +def check(n, c, extra=""): + print(("PASS " if c else "FAIL ") + n + (" " + extra if extra and not c else "")) + if not c: fails.append(n) + +master, slave = pty.openpty() +fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 100, 0, 0)) +env = dict(os.environ, TERM="xterm-256color", LC_ALL="C.UTF-8", XDG_CONFIG_HOME=XDG) +proc = subprocess.Popen([sys.executable, os.path.join(REPO, "propertreecli.py"), "edit", F], + stdin=slave, stdout=slave, stderr=slave, env=env, close_fds=True) +os.close(slave) +buf = b"" +def pump(t=0.4): + global buf + end = time.time() + t + while time.time() < end: + r, _, _ = select.select([master], [], [], 0.1) + if r: + try: c = os.read(master, 65536) + except OSError: break + if not c: break + buf += c +def wait_for(text, timeout=6): + end = time.time() + timeout + while time.time() < end: + if text in buf.decode("utf-8", "replace"): return True + pump(0.2) + return False +def send(d, t=0.35): + os.write(master, d if isinstance(d, bytes) else d.encode()); time.sleep(t); pump() +def load(): + with open(F, "rb") as f: return plistlib.load(f) + +open(LOG, "w").close() +check("boots", wait_for("Alpha") and wait_for("Nest")) +# rows: 0 Alpha, 1 Flag, 2 Nest, 3 Deep (Nest auto-expands in a small file) +def down(n): + for _ in range(n): + send(b"j") +# add a sibling after Alpha (root dict, leaf row) +send(b"i"); send(b"\r"); send(b"Mid\r") +send(b"g"); down(3) # Nest +send(b"i"); send(b"\r"); send(b"Extra\r") +send(b"g"); down(4) # Deep (Extra appends after it in Nest) +send(b"c", t=0.5) +send(b"g") # back to Alpha +send(b"p"); send(b"\x15Pasted\r", t=0.6) # ctrl+u clears the prefilled key +send(CTRL_S, t=0.6) +d = load() +check("sibling insert keeps dict order", + list(d) == ["Alpha", "Pasted", "Mid", "Flag", "Nest"], repr(list(d))) +check("child added into the container", d["Nest"].get("Extra") == "", repr(d)) +check("paste copies the value", d["Pasted"] == "x", repr(d)) +send(b"g"); down(3) # Flag +send(b"\r", t=0.5) +send(CTRL_S, t=0.5) +check("enter toggles a boolean", load()["Flag"] is False, repr(load())) +send(CTRL_Z, t=0.5); send(CTRL_S, t=0.5) +check("undo restores it", load()["Flag"] is True, repr(load())) +send(b"g"); send(b"r"); send(b"\x15Renamed\r", t=0.5) +send(CTRL_S, t=0.5) +check("rename", load().get("Renamed") == "one", repr(load())) +send(b"q", t=0.8); pump(0.5) +try: + rc = proc.wait(timeout=4) +except subprocess.TimeoutExpired: + proc.kill(); rc = None +check("clean quit", rc == 0, "rc=%s" % rc) +check("no tracebacks", not os.path.getsize(LOG), open(LOG).read()[-400:]) +os.close(master) +print("FAILS:", len(fails)) +sys.exit(1 if fails else 0)