diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..667e5e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +dist/ +build/ +*.egg-info/ +*.egg + +# Virtual environments +venv/ +env/ +.venv/ + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db +desktop.ini + +# Logs +*.log + +# App Config +config.json + +# PyInstaller +*.spec diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..74afe1c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,86 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [GUI_v1.1] — 2026-05-08 + +### Added +- **Drag-and-Drop:** Added `tkinterdnd2` support allowing users to drag backup folders directly into the application. +- **Settings Persistence:** Created `config.json` integration to save and automatically load user preferences and selected directories across sessions. +- **Log Exporting:** Added an "Export Log" button to save the current terminal output to a text file for auditing. +- **Output Folder Quick Access:** Added an "Open Output" button that becomes clickable after a successful decryption to instantly view the extracted files. + +--- + +## [GUI_v1.0] — 2026-05-08 + +### Added +- **Full GUI application** (`kobackupdec_gui.py`) with modern dark theme +- **Password verification** — validates password against backup before decryption starts +- **Selective folder decryption** — scan backup and choose specific folders (pictures, video, audios, etc.) +- **Pause / Resume** button to temporarily halt decryption +- **Stop** button to cancel decryption mid-process +- **Real-time log output** with color-coded levels (INFO=green, WARNING=yellow, ERROR=red, DEBUG=gray) +- **Responsive layout** — resizes from 600×500 to fullscreen with auto-reflowing folder checkboxes +- **Progress status** — shows current phase and folder being processed +- **Browse dialogs** for backup and destination folders +- **Show/hide password** toggle +- **Select All / Deselect All** for folder selection +- **Auto-scan** — folder list auto-populates when backup path is set + +### Unchanged +- Core decryption engine (`kobackupdec.py`) — no modifications to original code +- CLI interface — fully preserved and backward-compatible + +--- + +## [20200705] — 2020-07-05 + +### Fixed +- `decrypt_large_package` now correctly reads input in chunks + +## [20200611] — 2020-06-11 + +### Added +- `--expandtar` option to control automatic TAR expansion +- `--writable` option to skip setting read-only permissions on decrypted files +- Large TAR files are managed in chunks but not expanded + +## [20200607] — 2020-06-07 + +### Fixed +- Merged empty `CheckMsg` handling +- Updated `folder_to_media_type` mapping (by @realSnoopy) + +## [20200406] — 2020-04-06 + +### Fixed +- Merged file and folder permissions fix (by @lp4n6) + +## [20200405] — 2020-04-05 + +### Added +- Python minor version check with informative note (thanks @lp4n6) + +## [2020test] — 2020 + +### Changed +- Complete rewrite to handle v9 and v10 KoBackup structures + +## [20200107] — 2020-01-07 + +### Fixed +- Merged pull by @lp4n6, fixed current version handling + +## [20191113] — 2019-11-13 + +### Fixed +- Double folder creation error + +## [20190729] — 2019-07-29 + +### Added +- First public release +- Huawei KoBackup / HiSuite backup decryption +- Automatic output folder restructuring to mimic Android filesystem +- Support for APK, DB, TAR, and media file decryption diff --git a/README.md b/README.md index 2388cac..884c794 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,179 @@ -# kobackupdec -Huawei backup decryptor +

+

🔐 KoBackup Decryptor

+

+ Decrypt Huawei HiSuite & KoBackup encrypted backups
+ CLI + Modern GUI • Selective Folder Decryption • Drag & Drop • Password Verification +

+

+ Python 3.7+ + MIT License + Version +

+

+

⬇️ Download the Standalone Windows Executable (.exe) ⬇️

+

+

-_This script is introduced by the blog post at https://blog.digital-forensics.it/2019/07/huawei-backup-decryptor.html._ +--- -The `kobackupdec` is a Python3 script aimed to decrypt Huawei *HiSuite* or *KoBackup* (the Android app) backups. When decrypting and uncompressing the archives, it will re-organize the output folders structure trying to _mimic_ the typical Android one. The script will work both on Windows and Linux hosts, provided the PyCryptoDome dependency. Starting from **20100107** the script was rewritten to handle v9 and v10 kobackup backups structures. +

+ KoBackup Decryptor GUI +
+ Modern dark-themed GUI with selective folder decryption, pause/stop controls, and real-time log output +

-## _EOL_ +--- -On 1.1.2021 the script will get its _end of life_ status. It was needed two years ago to overcome issues for some Huawei devices' forensics acquisitions. Now commercial forensics solutions include the very same capabilities, and much more: there are no more reasons to maintain it. We've got messages from guys using this script to manage theirs backups: we do not recommend it, and we did not write it for this reason. Anyhow we're happy some of you did find it useful, and we thank you for the feedback. We shared it to the community, trying to give back something: if someone has any interest in maintaining it, please let us know so we can include a link to the project. +## 📖 Overview -## Usage +**KoBackup Decryptor** (`kobackupdec`) is a Python 3 tool for decrypting Huawei *HiSuite* and *KoBackup* (Android app) encrypted backups. It supports both **v9** and **v10** backup structures. -The script *assumes* that backups are encrypted with a user-provided password. Actually it does not support the HiSuite _self_ generated password, when the user does not provide its own. +When decrypting, it automatically: +- Reorganizes the output folder structure to mimic a typical Android filesystem +- Extracts and expands TAR archives (optional) +- Handles large files in chunks for memory efficiency +### ✨ What's New — GUI Edition + +This fork adds a **full-featured graphical interface** built with tkinter, bringing the power of `kobackupdec` to users who prefer a visual workflow. + +| Feature | CLI | GUI | +|---|:---:|:---:| +| Decrypt full backups | ✅ | ✅ | +| Password verification before decrypt | — | ✅ | +| **Selective folder decryption** | — | ✅ | +| **Drag and Drop support** | — | ✅ | +| **Settings persistence** | — | ✅ | +| Pause / Resume / Stop controls | — | ✅ | +| Export decryption logs | — | ✅ | +| Open Output folder button | — | ✅ | +| Real-time color-coded log output | — | ✅ | +| Progress tracking with status updates | — | ✅ | +| Responsive dark-themed interface | — | ✅ | + +--- + +## 🚀 Installation + +### Prerequisites + +- **Python 3.7** or later +- **pip** (Python package manager) + +### Steps + +```bash +# 1. Clone the repository +git clone https://github.com/YOUR_USERNAME/kobackupdec.git +cd kobackupdec + +# 2. Install dependencies +pip install -r requirements.txt +``` + +### 📦 Building a Standalone Executable (.exe) + +You can compile the GUI into a portable `.exe` file that requires zero setup (no Python installation needed): + +1. Install PyInstaller: `pip install pyinstaller Pillow` +2. Run the build script: `python build.py` +3. The standalone application will be generated at `dist/KoBackupDecryptor.exe`. + +### Dependencies + +| Package | Purpose | +|---|---| +| `pycryptodome` | AES / PBKDF2 / HMAC cryptographic operations | +| `tkinterdnd2` | Drag and drop functionality for the GUI | +| `tkinter` | GUI framework (bundled with Python on most platforms) | + +> **Note:** On some Linux distributions, tkinter may need to be installed separately: +> ```bash +> # Ubuntu / Debian +> sudo apt-get install python3-tk +> +> # Fedora +> sudo dnf install python3-tkinter +> ``` + +--- + +## 🖥️ Usage + +### GUI Mode (Recommended) + +Launch the graphical interface: + +```bash +python kobackupdec_gui.py ``` -usage: kobackupdec.py [-h] [-v] password backup_path dest_path -Huawei KoBackup decryptor version 20200611 +#### GUI Workflow + +1. **Enter Password** — Type your backup password (toggle visibility with 👁) +2. **Select Backup Folder** — Drag and drop your Huawei backup directory right into the application, or click **Browse** +3. **Select Destination** — Choose where to save decrypted files (pick a parent, name the output folder) +4. **Configure Options** — Toggle TAR expansion, writable permissions, and log verbosity +5. **Select Folders** — After setting the backup path, check/uncheck individual folders (pictures, video, audios, etc.) to decrypt only what you need +6. **Start Decryption** — Click **🔓 Start Decryption** +7. **Open Output** — When finished, click **📂 Open Output** to view your files immediately + +#### GUI Controls + +| Button | Function | +|---|---| +| 🔓 **Start Decryption** | Verifies password first, then begins decryption | +| ⏸ **Pause / ▶ Resume** | Temporarily halt and resume the process | +| ⏹ **Stop** | Cancel the decryption (partially decrypted files are kept) | +| 📂 **Open Output** | Opens destination folder in Windows Explorer (enabled after success) | +| **Export Log** | Save decryption logs to a text file for auditing | +| **Select All / Deselect All** | Quickly toggle all folder checkboxes | +| **🔍 Scan** | Re-scan backup directory for available folders | +| **Clear Log** | Clear the log output panel | + +#### GUI Features + +- **🔑 Password Verification** — Validates the password against `info.xml` before starting decryption. Wrong passwords are caught instantly. +- **📂 Selective Folder Decryption** — Only decrypt what you need (e.g., just pictures and contacts, skip video and apps). +- **💾 Settings Persistence** — The app remembers your selected folders and checkboxes across launches via `config.json`. +- **🖱️ Drag and Drop** — Seamlessly drop backup folders into the app instead of browsing manually. +- **📊 Real-Time Progress & Logs** — Status bar shows current phase. Export logs anytime. +- **🎨 Dark Theme** — Modern, responsive dark interface with color-coded log levels (green=info, yellow=warning, red=error). +- **📐 Responsive Layout** — Resizes gracefully from 600×500 to fullscreen. Folder checkboxes reflow automatically. + +--- + +### CLI Mode + +For scripting and automation, the original command-line interface is fully preserved: + +``` +usage: kobackupdec.py [-h] [-e] [-w] [-v] password backup_path dest_path + +Huawei KoBackup decryptor version 20200705 positional arguments: - password user password for the backup - backup_path backup folder - dest_path decrypted backup folder + password user password for the backup + backup_path backup folder + dest_path decrypted backup folder optional arguments: -h, --help show this help message and exit -e, --expandtar expand tar files - -w, --writable do not set RO pemission on decrypted data + -w, --writable do not set RO permission on decrypted data -v, --verbose verbose level, -v to -vvv ``` -- `password`, is the user provided password. -- `backup_path`, is the folder containing the Huawei backup, relative or absolute paths can be used. -- `dest_path`, is the folder to be created in the specified path, absolute or relative. It will complain if the provided folder already exists. -- `[-v]` (from `-v` to `-vvv`) verbosity level, written on *stderr*. It's suggested to use *-vvv* with a redirect to get a log of the process. +#### CLI Example -### Example +```bash +python kobackupdec.py -vvv 123456 "Z:\HUAWEI P30 Pro_2019-06-28 22.56.31" Z:\HiSuiteBackup +``` + +
+📋 Click to see example output ``` -Z:\> py -3 kobackupdec.py -vvv 123456 "Z:\HUAWEI P30 Pro_2019-06-28 22.56.31" Z:\HiSuiteBackup INFO:root:getting files and folder from Z:\HUAWEI P30 Pro_2019-06-28 22.56.31 INFO:root:parsing XML files... INFO:root:parsing xml audio.xml @@ -51,8 +186,6 @@ DEBUG:root:ignoring entry BackupFilePhoneInfo DEBUG:root:ignoring entry BackupFileVersionInfo INFO:root:parsing xml picture.xml DEBUG:root:parsing xml file picture.xml -INFO:root:parsing xml soundrecorder.xml -DEBUG:root:parsing xml file soundrecorder.xml INFO:root:parsing xml video.xml DEBUG:root:parsing xml file video.xml DEBUG:root:crypto_init: using version 3. @@ -60,45 +193,165 @@ DEBUG:root:SHA256(BKEY)[16] = b'8d969eef6ecad3c29a3a629280e686cf' ... ``` -The **output** folder structure will be similar to the following one: *data/data* applications will be exploded in their proper paths, and the APKs will be *restored* too (not icons, actually). Note that the **db** folder will contain the *special* databases as created by the Huawei backups. +
+ +--- + +## 📁 Output Structure + +The decrypted output folder mimics a standard Android filesystem: ``` -HiSuiteBackup -|-- data -| |-- app -| | |-- de.sec.mobile.apk-1 -| | | [...] -| | `-- org.telegram.messenger.apk-1 -| `-- data -| |-- de.sec.mobile -| | [...] -| `-- org.telegram.messenger -|-- db -| |-- HWlanucher.db -| |-- Memo.db -| |-- alarm.db -| |-- calendar.db -| |-- calllog.db -| |-- camera.db -| |-- clock.db -| |-- contact.db -| |-- harassment.db -| |-- phoneManager.db -| |-- setting.db -| |-- sms.db -| |-- soundrecorder.db -| |-- systemUI.db -| |-- weather.db -| `-- wifiConfig.db -`-- storage - |-- DCIM - |-- Download - |-- Huawei - |-- MagazineUnlock - |-- Notifications - |-- Pictures - |-- WhatsApp - |-- mp3 - |-- parallel_intl - `-- s8-wallpapers-9011.PNG +DecryptedBackup/ +├── data/ +│ ├── app/ # APK files +│ │ ├── com.example.app.apk-1/ +│ │ └── org.telegram.messenger.apk-1/ +│ └── data/ # App data (TAR contents) +│ ├── com.example.app/ +│ └── org.telegram.messenger/ +├── db/ # System databases +│ ├── calendar.db +│ ├── calllog.db +│ ├── contact.db +│ ├── sms.db +│ └── ... +├── storage/ # Media files +│ ├── DCIM/ +│ ├── Download/ +│ ├── Pictures/ +│ ├── WhatsApp/ +│ └── ... +└── unknown/ # Unrecognized files (copied as-is) ``` + +--- + +## 📋 Requirements + +| Requirement | Minimum Version | +|---|---| +| Python | 3.7 | +| pycryptodome | Any recent | +| Operating System | Windows, Linux, macOS | + +--- + +## ⚙️ Building Executables + +You can compile the scripts into standalone executables using **cx_Freeze**: + +```bash +# Build executable +python setup.py build + +# Build Windows MSI installer +python setup.py bdist_msi +``` + +--- + +## 🗂️ Project Structure + +``` +kobackupdec/ +├── kobackupdec.py # Core decryption engine (CLI) +├── kobackupdec_gui.py # GUI application (tkinter) +├── requirements.txt # Python dependencies +├── setup.py # cx_Freeze build config +├── LICENSE # MIT License +├── README.md # This file +├── CHANGELOG.md # Version history +└── .github/ + └── ISSUE_TEMPLATE/ # GitHub issue templates +``` + +--- + +## 📝 Changelog + +See [CHANGELOG.md](CHANGELOG.md) for the full version history. + +### Highlights + +- **GUI Edition** — Full graphical interface with selective decryption, pause/stop, and password verification +- **20200705** — Fixed `decrypt_large_package` to read input chunks +- **20200611** — Added `expandtar` and `writable` options +- **20200607** — Merged empty CheckMsg handling +- **2020test** — Rewritten for v9 and v10 backups +- **20190729** — First public release + +--- + +## ❓ FAQ + +
+What backup formats are supported? + +Both **v9** and **v10** Huawei KoBackup / HiSuite backup structures. The tool looks for `info.xml` either at the root or inside `backupFiles1/`. +
+ +
+Does it support HiSuite auto-generated passwords? + +No. The tool only supports backups encrypted with a **user-provided password**. HiSuite's self-generated password is not supported. +
+ +
+I get "No module named 'Crypto'" error + +Install `pycryptodome`: +```bash +pip install pycryptodome +``` +If you have both `pycrypto` and `pycryptodome`, uninstall the old one first: +```bash +pip uninstall pycrypto +pip install pycryptodome +``` +
+ +
+I get "Wrong password" — is my password incorrect? + +The tool validates your password against the backup's `checkMsg` field. If the password is wrong, decryption will not proceed. Double-check the password you used when creating the backup in HiSuite/KoBackup. +
+ +
+Can I decrypt only specific folders (e.g., just photos)? + +**Yes!** In GUI mode, after selecting the backup folder, click **🔍 Scan** to list all available folders. Then uncheck everything you don't need and only the selected folders will be decrypted. +
+ +
+Does the GUI modify the original backup files? + +No. The original backup is only read, never modified. Decrypted files are written to the destination folder you specify. +
+ +--- + +## 🤝 Contributing + +Contributions are welcome! Please: + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/my-feature`) +3. Commit your changes (`git commit -m "Add my feature"`) +4. Push to the branch (`git push origin feature/my-feature`) +5. Open a Pull Request + +--- + +## 📄 License + +This project is licensed under the **MIT License** — see the [LICENSE](LICENSE) file for details. + +**Original Author:** Francesco "dfirfpi" Picasso, Reality Net System Solutions +**GUI Extension:** Community contribution + +--- + +## ⚠️ Disclaimer + +This tool is intended for **legitimate use only** — decrypting your own backups or backups you are authorized to access. The authors are not responsible for any misuse. diff --git a/app.ico b/app.ico new file mode 100644 index 0000000..47f4133 Binary files /dev/null and b/app.ico differ diff --git a/build.py b/build.py new file mode 100644 index 0000000..29865c9 --- /dev/null +++ b/build.py @@ -0,0 +1,23 @@ +import PyInstaller.__main__ +import tkinterdnd2 +import os +import shutil + +def build(): + tkdnd_path = os.path.join(os.path.dirname(tkinterdnd2.__file__), 'tkdnd') + + # Ensure dist and build dirs are clean + if os.path.exists('dist'): shutil.rmtree('dist') + if os.path.exists('build'): shutil.rmtree('build') + + PyInstaller.__main__.run([ + 'kobackupdec_gui.py', + '--name=KoBackupDecryptor', + '--onefile', + '--windowed', + '--icon=app.ico', + f'--add-data={tkdnd_path};tkinterdnd2/tkdnd' + ]) + +if __name__ == '__main__': + build() diff --git a/create_icon.py b/create_icon.py new file mode 100644 index 0000000..42d387f --- /dev/null +++ b/create_icon.py @@ -0,0 +1,19 @@ +import sys +from PIL import Image + +def create_ico(input_path, output_path): + img = Image.open(input_path) + # Crop to square if necessary + width, height = img.size + if width != height: + min_dim = min(width, height) + left = (width - min_dim) / 2 + top = (height - min_dim) / 2 + right = (width + min_dim) / 2 + bottom = (height + min_dim) / 2 + img = img.crop((left, top, right, bottom)) + + img.save(output_path, format="ICO", sizes=[(256, 256), (128, 128), (64, 64), (32, 32), (16, 16)]) + +if __name__ == "__main__": + create_ico(sys.argv[1], sys.argv[2]) diff --git a/kobackupdec.py b/kobackupdec.py old mode 100755 new mode 100644 diff --git a/kobackupdec_gui.py b/kobackupdec_gui.py new file mode 100644 index 0000000..a26df63 --- /dev/null +++ b/kobackupdec_gui.py @@ -0,0 +1,1081 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +""" +Huawei KoBackup Decryptor — GUI Application +A modern, dark-themed graphical interface for kobackupdec.py + +Requires: tkinter (bundled with Python), pycryptodome +""" + +import logging +import os +import pathlib +import sys +import threading +import tkinter as tk +from tkinter import filedialog, messagebox, simpledialog, ttk +import queue +import time +import json +import subprocess +from tkinterdnd2 import TkinterDnD, DND_FILES + +# --------------------------------------------------------------------------- +# Logging handler that forwards records into a thread-safe queue +# --------------------------------------------------------------------------- + +class QueueHandler(logging.Handler): + """Sends log records to a queue for consumption by the GUI thread.""" + + def __init__(self, log_queue: queue.Queue): + super().__init__() + self.log_queue = log_queue + + def emit(self, record): + self.log_queue.put(self.format(record)) + + +# --------------------------------------------------------------------------- +# Main GUI Application +# --------------------------------------------------------------------------- + +class KoBackupDecGUI(TkinterDnD.Tk): + """Tkinter-based GUI for the Huawei KoBackup decryptor.""" + + # -- Colour palette (dark mode) ---------------------------------------- + BG = "#0f1117" + BG_CARD = "#181b24" + BG_INPUT = "#1e2230" + BG_HOVER = "#252a3a" + FG = "#e4e6ed" + FG_DIM = "#8b8fa3" + ACCENT = "#6c63ff" + ACCENT_HOVER = "#8a82ff" + ACCENT_DARK = "#4e47b8" + SUCCESS = "#34d399" + WARNING = "#fbbf24" + ERROR = "#f87171" + BORDER = "#2a2e3d" + LOG_BG = "#12141c" + + FONT_FAMILY = "Segoe UI" + FONT_TITLE = ("Segoe UI", 22, "bold") + FONT_SUB = ("Segoe UI", 11) + FONT_LABEL = ("Segoe UI", 10, "bold") + FONT_INPUT = ("Segoe UI", 10) + FONT_BTN = ("Segoe UI", 11, "bold") + FONT_LOG = ("Consolas", 9) + FONT_STATUS = ("Segoe UI", 10) + + def __init__(self): + super().__init__() + + self.title("KoBackup Decryptor") + self.configure(bg=self.BG) + self.minsize(600, 500) + self.geometry("900x820") + + # Center on screen + self.update_idletasks() + w, h = 900, 820 + x = (self.winfo_screenwidth() - w) // 2 + y = (self.winfo_screenheight() - h) // 2 + self.geometry(f"{w}x{h}+{x}+{y}") + + # Application icon — set title-bar colour on Windows + try: + self.iconbitmap(default="") + except Exception: + pass + + # State + self._running = False + self._log_queue: queue.Queue = queue.Queue() + self._stop_event = threading.Event() + self._pause_event = threading.Event() + self._pause_event.set() # not paused initially + self._worker_thread = None + self._folder_vars = {} # {name: BooleanVar} for folder selection + + # ---------- configure ttk styles ----------- + self._setup_styles() + + # ---------- build widgets ------------------ + # Scrollable main container so content never gets clipped + self._build_main_container() + + self._build_header() + self._build_form() + self._build_options() + self._build_folder_selector() + self._build_action() + self._build_log() + self._build_status_bar() + + # Bind resize to reflow folder checkboxes + self.bind("", self._on_resize) + + # Load saved settings + self._load_config() + + # Save config on exit + self.protocol("WM_DELETE_WINDOW", self._on_closing) + + # Start polling the log queue + self._poll_log_queue() + + # ----------------------------------------------------------------- + # Style helpers + # ----------------------------------------------------------------- + + def _setup_styles(self): + style = ttk.Style(self) + style.theme_use("clam") + + style.configure("Card.TFrame", background=self.BG_CARD) + style.configure("Dark.TFrame", background=self.BG) + + style.configure("TCheckbutton", + background=self.BG_CARD, + foreground=self.FG, + font=self.FONT_INPUT, + focuscolor=self.BG_CARD) + style.map("TCheckbutton", + background=[("active", self.BG_CARD)], + foreground=[("active", self.ACCENT)]) + + style.configure("Horizontal.TProgressbar", + troughcolor=self.BG_INPUT, + background=self.ACCENT, + thickness=6, + borderwidth=0) + + def _make_rounded_frame(self, parent, expandable=False, **kw): + """Simulated rounded card using a Frame + padding. + Set expandable=True for sections that should grow (like the log). + """ + outer = tk.Frame(parent, bg=self.BG, padx=0, pady=6) + inner = tk.Frame(outer, bg=self.BG_CARD, highlightbackground=self.BORDER, + highlightthickness=1, padx=20, pady=16, **kw) + if expandable: + inner.pack(fill="both", expand=True) + else: + inner.pack(fill="x") + return outer, inner + + def _build_main_container(self): + """Create a vertically-scrollable container for all widgets.""" + # Status bar is packed at bottom of self, everything else in _main + self._main = tk.Frame(self, bg=self.BG) + self._main.pack(fill="both", expand=True) + + # ----------------------------------------------------------------- + # Build UI sections + # ----------------------------------------------------------------- + + def _build_header(self): + hdr = tk.Frame(self._main, bg=self.BG, pady=10) + hdr.pack(fill="x", padx=30) + + # Gradient-like effect: coloured accent bar + accent_bar = tk.Frame(hdr, bg=self.ACCENT, height=4) + accent_bar.pack(fill="x", pady=(0, 14)) + + title = tk.Label(hdr, text="🔐 KoBackup Decryptor", + font=self.FONT_TITLE, bg=self.BG, fg=self.FG) + title.pack(anchor="w") + + sub = tk.Label(hdr, text="Huawei HiSuite / KoBackup encrypted backup decryption tool • v20200705", + font=self.FONT_SUB, bg=self.BG, fg=self.FG_DIM) + sub.pack(anchor="w", pady=(2, 0)) + + # -- Form fields --------------------------------------------------- + + def _build_form(self): + outer, card = self._make_rounded_frame(self._main) + outer.pack(fill="x", padx=30) + + # Section title + sec = tk.Label(card, text="CONFIGURATION", font=("Segoe UI", 9, "bold"), + bg=self.BG_CARD, fg=self.ACCENT) + sec.pack(anchor="w", pady=(0, 10)) + + # Password + self._add_field_label(card, "Backup Password") + pw_frame = tk.Frame(card, bg=self.BG_CARD) + pw_frame.pack(fill="x", pady=(0, 12)) + + self.password_var = tk.StringVar() + self.show_pw = False + self.pw_entry = tk.Entry(pw_frame, textvariable=self.password_var, + show="•", font=self.FONT_INPUT, + bg=self.BG_INPUT, fg=self.FG, + insertbackground=self.ACCENT, + relief="flat", bd=0, + highlightbackground=self.BORDER, + highlightthickness=1, + highlightcolor=self.ACCENT) + self.pw_entry.pack(side="left", fill="x", expand=True, ipady=7, padx=(0, 8)) + + self.toggle_pw_btn = tk.Button(pw_frame, text="👁", font=("Segoe UI", 11), + bg=self.BG_INPUT, fg=self.FG_DIM, + activebackground=self.BG_HOVER, + activeforeground=self.FG, + relief="flat", bd=0, cursor="hand2", + command=self._toggle_password) + self.toggle_pw_btn.pack(side="right", ipadx=6, ipady=4) + + # Backup path + self._add_field_label(card, "Backup Folder") + self._build_path_row(card, "backup") + + # Destination path + self._add_field_label(card, "Destination Folder") + self._build_path_row(card, "dest") + + def _add_field_label(self, parent, text): + lbl = tk.Label(parent, text=text, font=self.FONT_LABEL, + bg=self.BG_CARD, fg=self.FG_DIM) + lbl.pack(anchor="w", pady=(0, 3)) + + def _build_path_row(self, parent, tag): + row = tk.Frame(parent, bg=self.BG_CARD) + row.pack(fill="x", pady=(0, 12)) + + var = tk.StringVar() + setattr(self, f"{tag}_var", var) + + entry = tk.Entry(row, textvariable=var, font=self.FONT_INPUT, + bg=self.BG_INPUT, fg=self.FG, + insertbackground=self.ACCENT, + relief="flat", bd=0, + highlightbackground=self.BORDER, + highlightthickness=1, + highlightcolor=self.ACCENT) + entry.pack(side="left", fill="x", expand=True, ipady=7, padx=(0, 8)) + + # Enable Drag and Drop + entry.drop_target_register(DND_FILES) + entry.dnd_bind('<>', lambda e, v=var, t=tag: self._on_drop(e, v, t)) + + btn = tk.Button(row, text="Browse", font=("Segoe UI", 9, "bold"), + bg=self.ACCENT, fg="#ffffff", + activebackground=self.ACCENT_HOVER, + activeforeground="#ffffff", + relief="flat", bd=0, cursor="hand2", + padx=14, pady=4, + command=lambda t=tag: self._browse(t)) + btn.pack(side="right", ipady=2) + + # Hover effects + btn.bind("", lambda e, b=btn: b.configure(bg=self.ACCENT_HOVER)) + btn.bind("", lambda e, b=btn: b.configure(bg=self.ACCENT)) + + # -- Options section ----------------------------------------------- + + def _build_options(self): + outer, card = self._make_rounded_frame(self._main) + outer.pack(fill="x", padx=30) + + sec = tk.Label(card, text="OPTIONS", font=("Segoe UI", 9, "bold"), + bg=self.BG_CARD, fg=self.ACCENT) + sec.pack(anchor="w", pady=(0, 10)) + + opts_row = tk.Frame(card, bg=self.BG_CARD) + opts_row.pack(fill="x") + + self.expandtar_var = tk.BooleanVar(value=False) + self.writable_var = tk.BooleanVar(value=False) + + cb1 = ttk.Checkbutton(opts_row, text=" Expand TAR archives", + variable=self.expandtar_var) + cb1.pack(side="left", padx=(0, 30)) + + cb2 = ttk.Checkbutton(opts_row, text=" Keep files writable (skip read-only)", + variable=self.writable_var) + cb2.pack(side="left") + + # Verbose level + verb_row = tk.Frame(card, bg=self.BG_CARD) + verb_row.pack(fill="x", pady=(12, 0)) + + vlbl = tk.Label(verb_row, text="Log Verbosity", font=self.FONT_LABEL, + bg=self.BG_CARD, fg=self.FG_DIM) + vlbl.pack(side="left", padx=(0, 12)) + + self.verbose_var = tk.StringVar(value="INFO") + for level in ("ERROR", "WARNING", "INFO", "DEBUG"): + rb = tk.Radiobutton(verb_row, text=level, variable=self.verbose_var, + value=level, + font=("Segoe UI", 9), + bg=self.BG_CARD, fg=self.FG, + selectcolor=self.BG_INPUT, + activebackground=self.BG_CARD, + activeforeground=self.ACCENT, + highlightthickness=0, + cursor="hand2") + rb.pack(side="left", padx=(0, 10)) + + # -- Folder selection panel ---------------------------------------- + + def _build_folder_selector(self): + outer, card = self._make_rounded_frame(self._main) + outer.pack(fill="x", padx=30) + self._folder_card = card + self._folder_outer = outer + + # Header row with title + buttons + hdr = tk.Frame(card, bg=self.BG_CARD) + hdr.pack(fill="x", pady=(0, 8)) + + sec = tk.Label(hdr, text="FOLDER SELECTION", + font=("Segoe UI", 9, "bold"), + bg=self.BG_CARD, fg=self.ACCENT) + sec.pack(side="left") + + desel_btn = tk.Button(hdr, text="Deselect All", + font=("Segoe UI", 8), + bg=self.BG_INPUT, fg=self.FG_DIM, + activebackground=self.BG_HOVER, + activeforeground=self.FG, + relief="flat", bd=0, cursor="hand2", + padx=8, pady=2, + command=lambda: self._set_all_folders(False)) + desel_btn.pack(side="right", padx=(4, 0)) + + sel_btn = tk.Button(hdr, text="Select All", + font=("Segoe UI", 8), + bg=self.BG_INPUT, fg=self.FG_DIM, + activebackground=self.BG_HOVER, + activeforeground=self.FG, + relief="flat", bd=0, cursor="hand2", + padx=8, pady=2, + command=lambda: self._set_all_folders(True)) + sel_btn.pack(side="right", padx=(4, 0)) + + scan_btn = tk.Button(hdr, text="🔍 Scan", + font=("Segoe UI", 8, "bold"), + bg=self.ACCENT, fg="#ffffff", + activebackground=self.ACCENT_HOVER, + activeforeground="#ffffff", + relief="flat", bd=0, cursor="hand2", + padx=8, pady=2, + command=self._scan_backup_folders) + scan_btn.pack(side="right", padx=(4, 0)) + + # Hint label + self._folder_hint = tk.Label( + card, + text="Set a backup folder above, then click Scan to detect available folders.", + font=("Segoe UI", 9), + bg=self.BG_CARD, fg=self.FG_DIM, anchor="w") + self._folder_hint.pack(fill="x") + + # Scrollable checkbox area + self._folder_canvas = tk.Canvas(card, bg=self.BG_CARD, + highlightthickness=0, height=0) + self._folder_inner = tk.Frame(self._folder_canvas, bg=self.BG_CARD) + self._folder_canvas.create_window((0, 0), window=self._folder_inner, + anchor="nw") + # Don't pack canvas yet — shown after scan + + def _scan_backup_folders(self): + """Scan the backup path and populate folder checkboxes.""" + bkp = self.backup_var.get().strip() + if not bkp or not pathlib.Path(bkp).is_dir(): + messagebox.showwarning("No Backup Folder", + "Please select a valid backup folder first.") + return + + bkp_path = pathlib.Path(bkp) + + # Find the files_folder + files_folder = None + if bkp_path.joinpath('info.xml').exists(): + files_folder = bkp_path + else: + bf1 = bkp_path.joinpath('backupFiles1') + if bf1.is_dir(): + info_xml = next(bf1.glob('**/info.xml'), None) + if info_xml: + files_folder = info_xml.parent + + # Collect folder names + folder_names = [] + + # Root files (apk, db, tar in the files_folder) + if files_folder: + has_root = any( + f.is_file() and f.suffix.lower() != '.xml' + for f in files_folder.glob('*')) + if has_root: + folder_names.append(('__root__', '📄 Root files (APK, DB, TAR)')) + + for entry in sorted(files_folder.glob('*')): + if entry.is_dir(): + folder_names.append( + (entry.name, f'📁 {entry.name}')) + + # Media folder + if bkp_path.joinpath('media').is_dir(): + folder_names.append(('__media__', '🎬 Media')) + + # Clear old checkboxes + for w in self._folder_inner.winfo_children(): + w.destroy() + self._folder_vars.clear() + + if not folder_names: + self._folder_hint.configure( + text="No decryptable folders found in this backup.") + self._folder_canvas.pack_forget() + return + + self._folder_hint.pack_forget() + + # Build checkbox grid (3 columns) + cols = 3 + for i, (key, label) in enumerate(folder_names): + var = tk.BooleanVar(value=True) + self._folder_vars[key] = var + cb = tk.Checkbutton( + self._folder_inner, text=label, variable=var, + font=("Segoe UI", 9), + bg=self.BG_CARD, fg=self.FG, + selectcolor=self.BG_INPUT, + activebackground=self.BG_CARD, + activeforeground=self.ACCENT, + highlightthickness=0, cursor="hand2", + anchor="w") + cb.grid(row=i // cols, column=i % cols, + sticky="w", padx=(0, 18), pady=2) + + self._folder_canvas.pack(fill="x", pady=(4, 0)) + self._folder_inner.update_idletasks() + self._folder_canvas.configure( + height=self._folder_inner.winfo_reqheight()) + + def _set_all_folders(self, state: bool): + for var in self._folder_vars.values(): + var.set(state) + + def _get_selected_folders(self): + """Return set of selected folder keys.""" + return {k for k, v in self._folder_vars.items() if v.get()} + + # -- Action button & progress bar ----------------------------------- + + def _build_action(self): + action_frame = tk.Frame(self._main, bg=self.BG, pady=6) + action_frame.pack(fill="x", padx=30) + + # Progress bar + self.progress = ttk.Progressbar(action_frame, mode="indeterminate", + style="Horizontal.TProgressbar") + self.progress.pack(fill="x", pady=(0, 10)) + + btn_row = tk.Frame(action_frame, bg=self.BG) + btn_row.pack(fill="x") + + self.decrypt_btn = tk.Button( + btn_row, + text="🔓 Start Decryption", + font=self.FONT_BTN, + bg=self.ACCENT, + fg="#ffffff", + activebackground=self.ACCENT_HOVER, + activeforeground="#ffffff", + relief="flat", bd=0, + cursor="hand2", + padx=28, pady=10, + command=self._start_decrypt + ) + self.decrypt_btn.pack(side="left") + self.decrypt_btn.bind("", + lambda e: self.decrypt_btn.configure(bg=self.ACCENT_HOVER)) + self.decrypt_btn.bind("", + lambda e: self.decrypt_btn.configure(bg=self.ACCENT)) + + # Pause button + self.pause_btn = tk.Button( + btn_row, + text="⏸ Pause", + font=("Segoe UI", 10, "bold"), + bg=self.WARNING, + fg="#1a1a2e", + activebackground="#f59e0b", + activeforeground="#1a1a2e", + relief="flat", bd=0, + cursor="hand2", + padx=18, pady=10, + state="disabled", + command=self._toggle_pause + ) + self.pause_btn.pack(side="left", padx=(10, 0)) + + # Stop button + self.stop_btn = tk.Button( + btn_row, + text="⏹ Stop", + font=("Segoe UI", 10, "bold"), + bg=self.ERROR, + fg="#ffffff", + activebackground="#ef4444", + activeforeground="#ffffff", + relief="flat", bd=0, + cursor="hand2", + padx=18, pady=10, + state="disabled", + command=self._stop_decrypt + ) + self.stop_btn.pack(side="left", padx=(10, 0)) + + self.open_output_btn = tk.Button( + btn_row, + text="📂 Open Output", + font=("Segoe UI", 9), + bg=self.SUCCESS, + fg="#1a1a2e", + activebackground="#10b981", + activeforeground="#1a1a2e", + relief="flat", bd=0, + cursor="hand2", + padx=14, pady=8, + state="disabled", + command=self._open_output_folder + ) + self.open_output_btn.pack(side="right", padx=(10, 0)) + + self.export_log_btn = tk.Button( + btn_row, + text="Export Log", + font=("Segoe UI", 9), + bg=self.BG_INPUT, + fg=self.FG_DIM, + activebackground=self.BG_HOVER, + activeforeground=self.FG, + relief="flat", bd=0, + cursor="hand2", + padx=14, pady=8, + command=self._export_log + ) + self.export_log_btn.pack(side="right", padx=(10, 0)) + + self.clear_log_btn = tk.Button( + btn_row, + text="Clear Log", + font=("Segoe UI", 9), + bg=self.BG_INPUT, + fg=self.FG_DIM, + activebackground=self.BG_HOVER, + activeforeground=self.FG, + relief="flat", bd=0, + cursor="hand2", + padx=14, pady=8, + command=self._clear_log + ) + self.clear_log_btn.pack(side="right") + + # -- Log output area ----------------------------------------------- + + def _build_log(self): + outer, card = self._make_rounded_frame(self._main, expandable=True) + outer.pack(fill="both", expand=True, padx=30) + + sec = tk.Label(card, text="LOG OUTPUT", font=("Segoe UI", 9, "bold"), + bg=self.BG_CARD, fg=self.ACCENT) + sec.pack(anchor="w", pady=(0, 6)) + + log_frame = tk.Frame(card, bg=self.LOG_BG, highlightbackground=self.BORDER, + highlightthickness=1) + log_frame.pack(fill="both", expand=True) + + self.log_text = tk.Text(log_frame, font=self.FONT_LOG, + bg=self.LOG_BG, fg="#a0aec0", + insertbackground=self.ACCENT, + relief="flat", bd=8, + wrap="word", + state="disabled", + highlightthickness=0) + scroll = tk.Scrollbar(log_frame, command=self.log_text.yview, + bg=self.BG_CARD, troughcolor=self.LOG_BG, + activebackground=self.ACCENT, + highlightthickness=0, bd=0) + self.log_text.configure(yscrollcommand=scroll.set) + scroll.pack(side="right", fill="y") + self.log_text.pack(fill="both", expand=True) + + # Tag colours for log levels + self.log_text.tag_configure("ERROR", foreground=self.ERROR) + self.log_text.tag_configure("WARNING", foreground=self.WARNING) + self.log_text.tag_configure("INFO", foreground=self.SUCCESS) + self.log_text.tag_configure("DEBUG", foreground=self.FG_DIM) + self.log_text.tag_configure("CRITICAL", foreground=self.ERROR, + font=("Consolas", 9, "bold")) + + # -- Status bar ---------------------------------------------------- + + def _build_status_bar(self): + bar = tk.Frame(self, bg=self.BORDER, height=30) + bar.pack(fill="x", side="bottom") + + self.status_var = tk.StringVar(value="Ready") + self.status_lbl = tk.Label(bar, textvariable=self.status_var, + font=self.FONT_STATUS, + bg=self.BORDER, fg=self.FG_DIM, + padx=14, pady=4) + self.status_lbl.pack(side="left") + + ver = tk.Label(bar, text="kobackupdec v20200705", + font=("Segoe UI", 8), bg=self.BORDER, fg=self.FG_DIM, + padx=14) + ver.pack(side="right") + + # ----------------------------------------------------------------- + # Responsive helpers + # ----------------------------------------------------------------- + + _last_resize_width = 0 + + def _on_resize(self, event): + """Reflow folder checkboxes when the window width changes.""" + if event.widget is not self: + return + # Only reflow when width changes meaningfully (>30px) + if abs(event.width - self._last_resize_width) > 30: + self._last_resize_width = event.width + self._reflow_folder_checkboxes() + + def _reflow_folder_checkboxes(self): + """Re-grid folder checkboxes to fit the current width.""" + children = self._folder_inner.winfo_children() + if not children: + return + # Estimate available width (window - padding) + avail = self.winfo_width() - 120 + # Measure widest checkbox + max_cb_w = max(c.winfo_reqwidth() for c in children) or 200 + cols = max(1, avail // (max_cb_w + 18)) + for i, cb in enumerate(children): + cb.grid_configure(row=i // cols, column=i % cols) + self._folder_inner.update_idletasks() + self._folder_canvas.configure( + height=self._folder_inner.winfo_reqheight()) + + # ----------------------------------------------------------------- + # Actions & helpers + # ----------------------------------------------------------------- + + def _toggle_password(self): + self.show_pw = not self.show_pw + self.pw_entry.configure(show="" if self.show_pw else "•") + self.toggle_pw_btn.configure(text="🔒" if self.show_pw else "👁") + + def _browse(self, tag): + if tag == "dest": + # For destination: pick a parent dir, then ask for a new subfolder name + parent = filedialog.askdirectory(title="Select parent folder for output") + if parent: + subfolder = simpledialog.askstring( + "Destination Folder Name", + "Enter a name for the new output folder:", + parent=self + ) + if subfolder and subfolder.strip(): + full = os.path.join(parent, subfolder.strip()) + self.dest_var.set(full) + else: + path = filedialog.askdirectory(title="Select folder") + if path: + getattr(self, f"{tag}_var").set(path) + if tag == "backup": + self._scan_backup_folders() + + def _clear_log(self): + self.log_text.configure(state="normal") + self.log_text.delete("1.0", "end") + self.log_text.configure(state="disabled") + + def _append_log(self, msg: str): + self.log_text.configure(state="normal") + tag = None + for lvl in ("CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"): + if msg.startswith(lvl): + tag = lvl + break + if tag: + self.log_text.insert("end", msg + "\n", tag) + else: + self.log_text.insert("end", msg + "\n") + self.log_text.see("end") + self.log_text.configure(state="disabled") + + def _poll_log_queue(self): + """Drain queued log messages into the text widget.""" + while True: + try: + msg = self._log_queue.get_nowait() + self._append_log(msg) + except queue.Empty: + break + self.after(100, self._poll_log_queue) + + # -- Validation --- + + def _validate(self) -> bool: + pw = self.password_var.get().strip() + bkp = self.backup_var.get().strip() + dest = self.dest_var.get().strip() + + if not pw: + messagebox.showwarning("Missing Password", + "Please enter the backup password.") + return False + if not bkp or not pathlib.Path(bkp).is_dir(): + messagebox.showwarning("Invalid Backup Folder", + "The backup folder does not exist.\n" + "Please select a valid directory.") + return False + if not dest: + messagebox.showwarning("Missing Destination", + "Please specify a destination folder.") + return False + if pathlib.Path(dest).is_dir(): + proceed = messagebox.askyesno( + "Destination Exists", + "The destination folder already exists.\n" + "Decrypted files will be written into it and may overwrite " + "existing content.\n\nContinue anyway?" + ) + if not proceed: + return False + return True + + # -- Stop / Pause controls --- + + def _toggle_pause(self): + if self._pause_event.is_set(): + self._pause_event.clear() + self.pause_btn.configure(text="▶ Resume", bg=self.SUCCESS) + self.status_var.set("⏸ Paused") + self.progress.stop() + else: + self._pause_event.set() + self.pause_btn.configure(text="⏸ Pause", bg=self.WARNING) + self.status_var.set("▶ Resumed — decrypting…") + self.progress.start(12) + + def _stop_decrypt(self): + if self._running: + self._stop_event.set() + self._pause_event.set() # unblock if paused + self.status_var.set("⏹ Stopping…") + + def _check_stop_pause(self): + """Returns True if thread should abort. Blocks while paused.""" + if self._stop_event.is_set(): + return True + while not self._pause_event.is_set(): + if self._stop_event.is_set(): + return True + time.sleep(0.1) + return False + + def _set_controls_running(self, running): + """Toggle button states for running / idle.""" + if running: + self.decrypt_btn.configure(state="disabled", bg=self.ACCENT_DARK, + text="⏳ Decrypting…") + self.pause_btn.configure(state="normal") + self.stop_btn.configure(state="normal") + else: + self.decrypt_btn.configure(state="normal", bg=self.ACCENT, + text="🔓 Start Decryption") + self.pause_btn.configure(state="disabled", + text="⏸ Pause", bg=self.WARNING) + self.stop_btn.configure(state="disabled") + + # -- Decryption thread --- + + def _start_decrypt(self): + if self._running: + return + if not self._validate(): + return + + self._running = True + self._stop_event.clear() + self._pause_event.set() + self._set_controls_running(True) + self.progress.start(12) + self.status_var.set("🔑 Verifying password…") + + self._worker_thread = threading.Thread( + target=self._run_decrypt, daemon=True) + self._worker_thread.start() + + def _setup_logging(self): + root_logger = logging.getLogger() + root_logger.handlers.clear() + level_map = { + "ERROR": logging.ERROR, + "WARNING": logging.WARNING, + "INFO": logging.INFO, + "DEBUG": logging.DEBUG, + } + root_logger.setLevel( + level_map.get(self.verbose_var.get(), logging.INFO)) + qh = QueueHandler(self._log_queue) + qh.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + root_logger.addHandler(qh) + + def _find_files_folder(self, bkp_path): + """Locate the folder containing info.xml.""" + if bkp_path.joinpath('info.xml').exists(): + return bkp_path + bf1 = bkp_path.joinpath('backupFiles1') + if bf1.is_dir(): + info_xml = next(bf1.glob('**/info.xml'), None) + if info_xml: + return info_xml.parent + raise FileNotFoundError('Unable to find info.xml in backupFiles1!') + raise FileNotFoundError( + 'No backupFiles1 folder nor info.xml file found!') + + def _run_decrypt(self): + """Background thread: verify password then decrypt with stop/pause.""" + import kobackupdec + self._setup_logging() + + password = self.password_var.get().strip().encode('utf-8') + bkp_path = pathlib.Path(self.backup_var.get().strip()) + dest_path = pathlib.Path(self.dest_var.get().strip()) + expand = self.expandtar_var.get() + writable = self.writable_var.get() + + success = False + stopped = False + try: + # ── Phase 1: locate backup & verify password ────────── + files_folder = self._find_files_folder(bkp_path) + + decrypt_info = kobackupdec.parse_info_xml( + files_folder.joinpath('info.xml'), password) + if not decrypt_info: + raise ValueError('Failed to parse info.xml') + if not decrypt_info.decryptor.good: + raise ValueError( + 'Wrong password! Decryptor verification failed.') + + logging.info('Password verified successfully!') + self.after(0, lambda: self.status_var.set( + '✅ Password OK — decrypting…')) + + if self._check_stop_pause(): + raise InterruptedError('Stopped') + + # ── Phase 2: parse XML keys ─────────────────────────── + dest_path.mkdir(0o755, parents=True, exist_ok=True) + + for entry in files_folder.glob('*.xml'): + if self._check_stop_pause(): + raise InterruptedError('Stopped') + if entry.name != 'info.xml' \ + and not entry.name.startswith('._'): + kobackupdec.parse_generic_xml(entry, decrypt_info) + + logging.debug(decrypt_info.dump()) + + # Determine which folders the user selected + selected = self._get_selected_folders() + # If no scan was done (no checkboxes), decrypt everything + decrypt_all = len(self._folder_vars) == 0 + + # ── Phase 3: decrypt root files ─────────────────────── + if decrypt_all or '__root__' in selected: + if self._check_stop_pause(): + raise InterruptedError('Stopped') + self.after(0, lambda: self.status_var.set( + '📂 Decrypting root files…')) + kobackupdec.decrypt_files_in_root( + decrypt_info, files_folder, dest_path, expand) + else: + logging.info('Skipping root files (not selected)') + + # ── Phase 4: decrypt sub-folders one by one ─────────── + all_folders = [e for e in files_folder.glob('*') if e.is_dir()] + folders = [e for e in all_folders + if decrypt_all or e.name in selected] + skipped = len(all_folders) - len(folders) + if skipped: + logging.info('Skipping %d unselected folder(s)', skipped) + + for idx, entry in enumerate(folders, 1): + if self._check_stop_pause(): + raise InterruptedError('Stopped') + self.after(0, lambda n=entry.name, i=idx, t=len(folders): + self.status_var.set( + f'📂 Folder {i}/{t}: {n}')) + kobackupdec.decrypt_files_in_folder( + decrypt_info, entry, dest_path, expand) + + # ── Phase 5: media ──────────────────────────────────── + if bkp_path.joinpath('media').is_dir() and \ + (decrypt_all or '__media__' in selected): + if self._check_stop_pause(): + raise InterruptedError('Stopped') + self.after(0, lambda: self.status_var.set( + '🎬 Decrypting media…')) + kobackupdec.decrypt_media( + password, bkp_path.joinpath('media'), + dest_path, expand) + elif bkp_path.joinpath('media').is_dir(): + logging.info('Skipping media folder (not selected)') + + # ── Phase 6: permissions ────────────────────────────── + if self._check_stop_pause(): + raise InterruptedError('Stopped') + if writable: + logging.info('Not setting read-only on decrypted files') + else: + self.after(0, lambda: self.status_var.set( + '🔒 Setting permissions…')) + for fentry in dest_path.glob('**/*'): + if self._stop_event.is_set(): + raise InterruptedError('Stopped') + if os.path.isfile(fentry): + os.chmod(fentry, 0o444) + elif os.path.isdir(fentry): + os.chmod(fentry, 0o555) + + success = True + + except InterruptedError: + stopped = True + logging.warning('Decryption stopped by user.') + except Exception as exc: + logging.critical('Decryption failed: %s', exc) + self._log_queue.put(f'CRITICAL: {exc}') + + self.after(0, lambda: self._decrypt_done(success, stopped)) + + def _decrypt_done(self, success: bool, stopped: bool = False): + self._running = False + self.progress.stop() + self._set_controls_running(False) + self.open_output_btn.configure(state="disabled") + + if stopped: + self.status_var.set('⏹ Decryption stopped by user') + self._append_log('') + self._append_log('WARNING: ⏹ Decryption was stopped by the user.') + elif success: + self.open_output_btn.configure(state="normal") + self.status_var.set('✅ Decryption completed successfully!') + self._append_log('') + self._append_log('=' * 50) + self._append_log('INFO: ✅ Decryption completed successfully!') + self._append_log('=' * 50) + messagebox.showinfo('Success', + 'Backup has been decrypted successfully!\n\n' + f'Output: {self.dest_var.get()}') + else: + self.status_var.set('❌ Decryption failed — see log for details') + messagebox.showerror('Error', + 'Decryption failed.\n' + 'Check the log output for details.') + + # ----------------------------------------------------------------- + # New Utilities + # ----------------------------------------------------------------- + + def _on_drop(self, event, var, tag): + path = event.data + if path.startswith('{') and path.endswith('}'): + path = path[1:-1] + var.set(path) + if tag == "backup": + self._scan_backup_folders() + + def _export_log(self): + log_content = self.log_text.get("1.0", "end-1c") + if not log_content.strip(): + messagebox.showinfo("Export Log", "The log is empty.") + return + filepath = filedialog.asksaveasfilename( + defaultextension=".txt", + filetypes=[("Text files", "*.txt"), ("All files", "*.*")], + title="Save Log Output" + ) + if filepath: + try: + with open(filepath, "w", encoding="utf-8") as f: + f.write(log_content) + messagebox.showinfo("Success", "Log exported successfully.") + except Exception as e: + messagebox.showerror("Error", f"Failed to save log:\n{e}") + + def _open_output_folder(self): + path = self.dest_var.get().strip() + if os.path.isdir(path): + if sys.platform == "win32": + os.startfile(path) + elif sys.platform == "darwin": + subprocess.Popen(["open", path]) + else: + subprocess.Popen(["xdg-open", path]) + + def _get_config_path(self): + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") + + def _load_config(self): + config_path = self._get_config_path() + if os.path.exists(config_path): + try: + with open(config_path, "r", encoding="utf-8") as f: + data = json.load(f) + if "backup_path" in data: + self.backup_var.set(data["backup_path"]) + self._scan_backup_folders() + if "dest_path" in data: + self.dest_var.set(data["dest_path"]) + if "expandtar" in data: + self.expandtar_var.set(data["expandtar"]) + if "writable" in data: + self.writable_var.set(data["writable"]) + if "verbose" in data: + self.verbose_var.set(data["verbose"]) + except Exception as e: + logging.error(f"Failed to load config: {e}") + + def _save_config(self): + config_path = self._get_config_path() + data = { + "backup_path": self.backup_var.get(), + "dest_path": self.dest_var.get(), + "expandtar": self.expandtar_var.get(), + "writable": self.writable_var.get(), + "verbose": self.verbose_var.get() + } + try: + with open(config_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4) + except Exception as e: + logging.error(f"Failed to save config: {e}") + + def _on_closing(self): + self._save_config() + self.destroy() + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + # Ensure the script's own directory is on the path so we can import kobackupdec + script_dir = os.path.dirname(os.path.abspath(__file__)) + if script_dir not in sys.path: + sys.path.insert(0, script_dir) + + app = KoBackupDecGUI() + app.mainloop() diff --git a/screenshots/gui_main.png b/screenshots/gui_main.png new file mode 100644 index 0000000..68cc434 Binary files /dev/null and b/screenshots/gui_main.png differ