Skip to content

Commit ed75bbf

Browse files
Add diagnostics, recovery, printing and toolbox maintenance
1 parent 9b1235c commit ed75bbf

19 files changed

Lines changed: 795 additions & 15 deletions

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ and confirm each installation before it runs.
3737
| **Boot and login themes** | Twelve GRUB themes, a plain GRUB restore option, and the Revan SDDM theme. |
3838
| **Video editing** | Native Resolve setup on Fedora/Arch, plus Davincibox on Debian/Ubuntu and derivatives, with dependency installation and a compatibility launcher. |
3939
| **Everyday maintenance** | A personalized Topgrade configuration and Debian Nala setup with an interactive `apt` alias. |
40+
| **Recovery and diagnostics** | System reports, configuration backup restoration, toolbox updates and action history. |
41+
| **Network printing** | Driverless IPP Everywhere setup for compatible printers on Fedora, Arch and Debian/Ubuntu. |
4042
| **Security tools** | AppArmor setup on supported systems and SELinux tools for Fedora. |
4143

4244
The wider Linutil application and system-tool catalog remains available alongside
@@ -126,6 +128,15 @@ automated tests cover installer orchestration and launcher generation.
126128

127129
[Resolve setup, requirements and troubleshooting →](docs/resolve.md)
128130

131+
## Maintenance tools
132+
133+
Open **Utilities → Toolbox Maintenance** for diagnostics, config backup restore,
134+
the toolbox updater and installation history. Driverless printing is under
135+
**Utilities → Printers**; Resolve maintenance is beside its installers in
136+
**Applications Setup → Design Tools**.
137+
138+
[Maintenance and recovery guide →](docs/maintenance.md)
139+
129140
## Platform support
130141

131142
The custom installers target conventional Linux installations:

‎SPEC.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,9 +294,15 @@ The TUI must provide:
294294
- A warning when Linutil itself is run as root unless explicitly bypassed.
295295
- A minimum-size check with an explicit bypass option.
296296

297-
Raw and local commands selected together execute in selection order within one
298-
shell program. A failure must be visible to the user and reflected in the final
299-
command status.
297+
Raw and local commands selected together execute in selection order in isolated
298+
subshells. A failed action stops the queue and is reflected in the final status.
299+
Action names and per-action statuses are stored under the user's private
300+
`$XDG_STATE_HOME/commander-toolbox/history` (default `~/.local/state`). Pending
301+
actions remain distinguishable from completed ones. A still-running marker after
302+
an interrupted session is not success. Set `COMMANDER_TOOLBOX_HISTORY=0` to opt out.
303+
Terminal output is saved only when explicitly requested with L; history does not
304+
record keystrokes, passwords or command bodies. Saved output can contain private
305+
information and is readable only by the user by default.
300306

301307
## 8. Configuration and automation
302308

‎core/tabs/applications-setup/tab_data.toml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ matches = false
9696
data = "file_exists"
9797
values = ["/run/ostree-booted"]
9898

99+
[[data.entries]]
100+
name = "DaVinci Resolve Maintenance"
101+
description = "Check native/container dependencies, inspect the Resolve container, repair backed-up launchers or remove the dedicated container after confirmation. Includes native uninstall guidance."
102+
script = "../commander/tools-resolve.sh"
103+
task_list = "FM RP"
104+
multi_select = false
105+
99106
[[data.entries]]
100107
name = "Darktable"
101108
description = "Darktable is a free and open-source photography application and raw developer."
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/bin/sh -e
2+
# shellcheck source=core/tabs/commander/common.sh
3+
. ./common.sh
4+
# shellcheck source=core/tabs/common-service-script.sh
5+
. ../common-service-script.sh
6+
commander_init
7+
checkInitManager 'systemctl'
8+
[ -d /run/systemd/system ] || { printf '%s\n' 'This setup requires a running systemd installation.' >&2; exit 1; }
9+
confirm 'Install CUPS and local network discovery, then enable their services? This supports IPP Everywhere printers; older models may still need vendor drivers.'
10+
case "$PACKAGER" in
11+
pacman) install_packages cups avahi ;;
12+
apt-get | nala) install_packages cups cups-client cups-ipp-utils avahi-daemon avahi-utils ;;
13+
dnf) install_packages cups cups-client cups-ipptool avahi avahi-tools ;;
14+
*) printf '%s\n' 'Unsupported package manager.' >&2; exit 1 ;;
15+
esac
16+
startAndEnableService cups
17+
startAndEnableService avahi-daemon
18+
printf '%s\n' 'Discovered IPP printers (printer must be powered on and reachable on your network):'
19+
if command_exists ippfind; then
20+
ippfind --timeout 5 --print || printf '%s\n' 'No printer discovered. You can enter its IPP URI manually.'
21+
fi
22+
printf '%s\n' 'Use an ipp:// or ipps:// URI from discovery or your printer settings.'
23+
printf '%s' 'Printer URI (Enter finishes without adding a queue): '
24+
read -r uri
25+
[ -n "$uri" ] || exit 0
26+
case "$uri" in ipp://?* | ipps://?*) ;; *) printf '%s\n' 'An IPP or IPPS URI is required.' >&2; exit 1 ;; esac
27+
printf '%s' 'New queue name (letters, digits, underscore or hyphen): '
28+
read -r queue
29+
case "$queue" in '' | -* | *[!A-Za-z0-9_-]*) printf '%s\n' 'Invalid queue name.' >&2; exit 1 ;; esac
30+
if lpstat -p "$queue" >/dev/null 2>&1; then
31+
printf '%s\n' 'That queue already exists. Choose a new name or manage it in CUPS; existing settings were preserved.' >&2
32+
exit 1
33+
fi
34+
confirm "Create printer queue $queue for $uri using IPP Everywhere? The queue will not be shared."
35+
"$ESCALATION_TOOL" lpadmin -p "$queue" -E -v "$uri" -m everywhere -o printer-is-shared=false
36+
lpstat -p "$queue"
37+
printf '%s\n' 'Printer added. Manage options or print a test page from your desktop printer settings or http://localhost:631.'
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
"""Interactive maintenance tools. Never require root for read-only operations."""
2+
import datetime
3+
import importlib.util
4+
import os
5+
from pathlib import Path
6+
import re
7+
import shutil
8+
import subprocess
9+
import sys
10+
import tempfile
11+
12+
from configure import check_destination, deploy
13+
14+
15+
def run(args, *, check=True, capture=False, cwd=None, timeout=None):
16+
return subprocess.run(args, check=check, text=True, cwd=cwd, timeout=timeout,
17+
stdout=subprocess.PIPE if capture else None,
18+
stderr=subprocess.STDOUT if capture else None)
19+
20+
21+
def confirm(message, token='APPLY'):
22+
print(message)
23+
if input(f'Type {token} to continue: ').strip() != token:
24+
raise ValueError('Cancelled; no further changes made.')
25+
26+
27+
def state_dir():
28+
return Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'commander-toolbox'
29+
30+
31+
def safe_text(text):
32+
# Prevent log output from injecting terminal control sequences when viewed.
33+
text = re.sub(r'\x1b\][^\x07]*(?:\x07|\x1b\\)', '', text)
34+
text = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', text)
35+
return ''.join(c for c in text if c in '\n\t' or (ord(c) >= 32 and not 127 <= ord(c) <= 159))
36+
37+
38+
def diagnostics():
39+
reports = ['Commander Toolbox diagnostics (read-only)', Path('/etc/os-release').read_text()]
40+
commands = [
41+
['uname', '-srmo'], ['systemd-detect-virt'], ['df', '-h', '/', str(Path.home())],
42+
['lspci', '-nnk'], ['nvidia-smi', '--query-gpu=name,driver_version,memory.total', '--format=csv'],
43+
['clinfo', '-l'], ['vulkaninfo', '--summary'], ['mokutil', '--sb-state'],
44+
]
45+
for args in commands:
46+
reports.append('\n$ ' + ' '.join(args))
47+
if not shutil.which(args[0]):
48+
reports.append('Not installed; skipped.')
49+
continue
50+
try:
51+
result = run(args, check=False, capture=True, timeout=15)
52+
reports.append(safe_text(result.stdout))
53+
reports.append(f'Exit status: {result.returncode}')
54+
except subprocess.TimeoutExpired:
55+
reports.append('Timed out after 15 seconds.')
56+
reports.append('\nGraphics device access:')
57+
for device in sorted(Path('/dev/dri').glob('renderD*')) + [Path('/dev/kfd')]:
58+
if device.exists():
59+
reports.append(f'{device}: read={os.access(device, os.R_OK)} write={os.access(device, os.W_OK)}')
60+
reports.append('A Virtio/VMware/VirtualBox adapter does not establish Resolve GPU support.\n'
61+
'Missing diagnostic tools or successful checks do not prove application compatibility.')
62+
report = '\n'.join(reports)
63+
print(report)
64+
if input('Save this report to your private toolbox state directory? [y/N]: ').lower() == 'y':
65+
directory = state_dir() / 'diagnostics'
66+
check_destination(directory)
67+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
68+
path = directory / (datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f') + '.txt')
69+
with path.open('x') as handle:
70+
path.chmod(0o600)
71+
handle.write(report)
72+
print(f'Saved: {path}')
73+
74+
75+
BACKUP = re.compile(r'^(.+)\.commander-backup-[0-9][0-9-]*$')
76+
77+
78+
def discover_backups(home=None):
79+
home = Path(home or Path.home())
80+
roots = [Path(os.environ.get('XDG_CONFIG_HOME', home / '.config')),
81+
home / '.local/bin', Path(os.environ.get('XDG_DATA_HOME', home / '.local/share'))]
82+
found = [p for p in home.iterdir() if BACKUP.fullmatch(p.name) and not p.is_symlink()]
83+
for root in roots:
84+
if not root.is_dir() or root.is_symlink():
85+
continue
86+
for directory, dirs, files in os.walk(root, followlinks=False):
87+
base = Path(directory)
88+
for name in dirs + files:
89+
path = base / name
90+
if BACKUP.fullmatch(name) and not path.is_symlink():
91+
found.append(path)
92+
dirs[:] = [d for d in dirs if not BACKUP.fullmatch(d) and d not in
93+
('.git', 'node_modules', 'sources', 'Trash', 'containers', 'Steam')
94+
and not (base / d).is_symlink()]
95+
return sorted(set(found), key=lambda p: p.stat().st_mtime, reverse=True)
96+
97+
98+
def restore_backup(source):
99+
source = Path(source)
100+
match = BACKUP.fullmatch(source.name)
101+
if not match:
102+
raise ValueError('Not a Commander backup.')
103+
check_destination(source)
104+
destination = source.with_name(match[1])
105+
check_destination(destination)
106+
# Never follow links inside a backup into another configuration owner.
107+
if source.is_dir():
108+
for directory, dirs, files in os.walk(source, followlinks=False):
109+
if any((Path(directory) / name).is_symlink() for name in dirs + files):
110+
raise ValueError('Backup contains symlinks; restore it through its configuration owner.')
111+
deploy(source, destination)
112+
return destination
113+
114+
115+
def backups():
116+
paths = discover_backups()
117+
if not paths:
118+
print('No Commander user-configuration backups found. System/boot backups are not restored here.')
119+
return
120+
for index, path in enumerate(paths, 1):
121+
print(f'{index}. {path}')
122+
selection = input('Backup number to restore (Enter cancels): ').strip()
123+
if not selection:
124+
return
125+
if not selection.isdigit() or not 1 <= int(selection) <= len(paths):
126+
raise ValueError('Invalid backup number.')
127+
source = paths[int(selection) - 1]
128+
target = source.with_name(BACKUP.fullmatch(source.name)[1])
129+
confirm(f'Restore {source}\nto {target}\nThe current target will receive its own backup.')
130+
print(f'Restored: {restore_backup(source)}')
131+
132+
133+
def history():
134+
directory = state_dir() / 'history'
135+
check_destination(directory)
136+
if not directory.exists():
137+
print('No action history yet. Only actions run with the updated toolbox are recorded.')
138+
return
139+
runs = sorted((p for p in directory.iterdir() if p.is_dir() and not p.is_symlink()), reverse=True)
140+
for index, path in enumerate(runs, 1):
141+
try:
142+
timestamp = datetime.datetime.fromtimestamp(int(path.name.split('-')[0]) / 1_000_000_000).isoformat(' ', timespec='seconds')
143+
except (ValueError, OverflowError, OSError):
144+
timestamp = path.name
145+
print(f'\n{index}. {timestamp}')
146+
for name in sorted(path.glob('*.name')):
147+
status = name.with_suffix('.status')
148+
result = status.read_text().strip() if status.exists() else 'unknown'
149+
result = {'0': 'success', 'pending': 'not started', 'running': 'running or interrupted'}.get(result, f'exit {result}')
150+
print(f' {safe_text(name.read_text().strip())}: {result}')
151+
print(' Saved log available' if (path / 'output.log').is_file() else ' No output log saved')
152+
choice = input('\nRun number to read its saved log (Enter closes): ').strip()
153+
if not choice:
154+
return
155+
if not choice.isdigit() or not 1 <= int(choice) <= len(runs):
156+
raise ValueError('Invalid run number.')
157+
log = runs[int(choice) - 1] / 'output.log'
158+
check_destination(log)
159+
if not log.is_file():
160+
print('No saved log. Use L after an action finishes to save its terminal output.')
161+
return
162+
print(safe_text(log.read_text(errors='replace')))
163+
164+
165+
def git(repo, *args):
166+
return run(['git', '-C', str(repo), *args], capture=True).stdout.strip()
167+
168+
169+
def update_checkout(repo):
170+
repo = Path(repo).expanduser().resolve()
171+
if Path(git(repo, 'rev-parse', '--show-toplevel')) != repo:
172+
raise ValueError('Choose the repository root.')
173+
if git(repo, 'status', '--porcelain', '--untracked-files=all'):
174+
raise ValueError('Local changes found. Commit or stash them yourself before updating; nothing was discarded.')
175+
branch = git(repo, 'symbolic-ref', '--quiet', '--short', 'HEAD')
176+
remote = git(repo, 'remote', 'get-url', 'origin')
177+
allowed = ('https://github.com/Commanderx-code/commander-toolbox.git',
178+
'https://github.com/Commanderx-code/commander-toolbox',
179+
'git@github.com:Commanderx-code/commander-toolbox.git')
180+
if remote not in allowed:
181+
raise ValueError('origin is not Commanderx-code/commander-toolbox. Update custom forks manually.')
182+
print(f'Checkout: {repo}\nBranch: {branch}\nCurrent revision: {git(repo, "rev-parse", "--short", "HEAD")}\nRemote: {remote}')
183+
confirm('Fetch origin/main, fast-forward this checkout and build the release executable?')
184+
run(['git', '-C', str(repo), 'fetch', 'origin', 'main'])
185+
if run(['git', '-C', str(repo), 'merge-base', '--is-ancestor', 'HEAD', 'origin/main'], check=False).returncode:
186+
raise ValueError('Local commits are ahead of or diverged from origin/main. Nothing was reset or merged.')
187+
run(['git', '-C', str(repo), 'merge', '--ff-only', 'origin/main'])
188+
try:
189+
run(['cargo', 'build', '--locked', '--release', '--package', 'linutil_tui', '--target-dir', str(repo / 'target')], cwd=repo)
190+
except subprocess.CalledProcessError:
191+
raise ValueError('Checkout updated, but the build failed. Your installed executable was not replaced. Fix the build and retry.') from None
192+
directory = state_dir()
193+
check_destination(directory / 'checkout')
194+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
195+
(directory / 'checkout').write_text(str(repo))
196+
return repo / 'target/release/commander-toolbox'
197+
198+
199+
def updater():
200+
saved = state_dir() / 'checkout'
201+
default = saved.read_text().strip() if saved.is_file() and not saved.is_symlink() else os.environ.get('COMMANDER_TOOLBOX_SOURCE', '')
202+
entered = input(f'Checkout path [{default}]: ').strip() or default
203+
if not entered:
204+
raise ValueError('A Git checkout is required; clone Commander Toolbox first.')
205+
binary = update_checkout(entered)
206+
print(f'Built: {binary}\nClose this running toolbox before launching the new version.')
207+
target = Path.home() / '.local/bin/commander-toolbox'
208+
if target.is_file():
209+
with target.open('rb') as handle:
210+
is_script = handle.read(2) == b'#!'
211+
if is_script:
212+
print('Your script launcher is preserved. If it points to this checkout, the next run uses the update.')
213+
return
214+
if input(f'Install executable at {target}? Type INSTALL, or Enter to keep the build only: ') == 'INSTALL':
215+
check_destination(target)
216+
# Copy to a sibling staging file, then rename: never truncate a running binary.
217+
target.parent.mkdir(parents=True, exist_ok=True)
218+
with tempfile.TemporaryDirectory(dir=target.parent) as tmp:
219+
staging = Path(tmp) / 'commander-toolbox'
220+
shutil.copy2(binary, staging)
221+
deploy(staging, target)
222+
print('User executable installed. Restart the toolbox.')
223+
224+
225+
def resolve_maintenance():
226+
print('1. Native dependency check\n2. Container status and dependency check\n3. Repair native launcher\n'
227+
'4. Repair container launcher\n5. Remove Resolve container\n6. Native removal instructions')
228+
choice = input('Choose [1-6], Enter cancels: ').strip()
229+
if not choice:
230+
return
231+
if choice == '6':
232+
print('Back up/export Resolve projects first. For a native vendor install, use its uninstaller at\n'
233+
'/opt/resolve/installer (run its help to inspect uninstall options). For an AUR-managed install,\n'
234+
'use your package manager. This tool does not delete native Resolve files or project data.')
235+
return
236+
if choice not in ('1', '2', '3', '4', '5'):
237+
raise ValueError('Invalid choice.')
238+
container = choice in ('2', '4', '5')
239+
os.environ['DBX_CONTAINER_MANAGER'] = 'podman'
240+
if container:
241+
run(['podman', 'container', 'exists', 'commander-resolve'])
242+
run(['podman', 'ps', '-a', '--filter', 'name=^commander-resolve$', '--format', '{{.Names}} {{.Status}} {{.Image}}'])
243+
prefix = ['distrobox', 'enter', '--name', 'commander-resolve', '--']
244+
else:
245+
if not Path('/opt/resolve/bin/resolve').is_file():
246+
raise ValueError('Native Resolve was not found.')
247+
prefix = []
248+
if choice == '5':
249+
confirm('Remove commander-resolve and its container-local data? Export/back up projects first.\n'
250+
'Shared home files and host GPU drivers remain.', 'REMOVE commander-resolve')
251+
run(['distrobox', 'rm', '--force', 'commander-resolve'])
252+
print('Container removed. Its launcher can be restored/replaced through Config Backup Restore or launcher repair.')
253+
return
254+
spec = importlib.util.spec_from_file_location('resolve_launcher', Path(__file__).with_name('resolve-launcher.py'))
255+
module = importlib.util.module_from_spec(spec)
256+
spec.loader.exec_module(module)
257+
with tempfile.TemporaryDirectory(prefix='.resolve-check-', dir=Path.home()) as tmp:
258+
script = Path(tmp) / 'runtime.sh'
259+
script.write_text(module.NATIVE)
260+
run(prefix + ['sh', str(script), '--check'])
261+
if choice in ('3', '4'):
262+
confirm('Recreate the Commander Resolve launcher and desktop entry with backups?')
263+
module.install('container' if container else 'native')
264+
print('Dependency check passed. This does not prove GPU acceleration or codec support.')
265+
266+
267+
if __name__ == '__main__':
268+
actions = {'diagnostics': diagnostics, 'backups': backups, 'history': history,
269+
'update': updater, 'resolve': resolve_maintenance}
270+
try:
271+
actions[sys.argv[1]]()
272+
except (ValueError, OSError, subprocess.CalledProcessError, EOFError, KeyboardInterrupt) as error:
273+
print(f'Stopped: {error}', file=sys.stderr)
274+
sys.exit(1)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/bin/sh -e
2+
exec python3 -B ./toolbox-tools.py backups
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/bin/sh -e
2+
exec python3 -B ./toolbox-tools.py diagnostics
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/bin/sh -e
2+
exec python3 -B ./toolbox-tools.py history
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/bin/sh -e
2+
exec python3 -B ./toolbox-tools.py resolve
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/bin/sh -e
2+
exec python3 -B ./toolbox-tools.py update

0 commit comments

Comments
 (0)