Files
Shackle/Shackle/MainForm.cs
2026-02-10 10:41:51 +01:00

728 lines
26 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using Newtonsoft.Json;
namespace UnshackleGUI
{
public partial class MainForm : Form
{
// =========================================================
// 1. FILE PATHS & SETTINGS
// =========================================================
private string _configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config.json");
private string _paramsPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "params.json");
private string _profilesDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Profiles");
// Data Storage
private AppSettings _appSettings = new AppSettings();
private List<UnshackleParameter> _parameterDefinitions = new List<UnshackleParameter>();
private Dictionary<string, object> _propertyValues = new Dictionary<string, object>();
// State Variables
private Process? _currentProcess;
private bool _isLoaded = false; // Flag to prevent saving while the app is starting up
private string _lastLoadedProfile = ""; // Tracks the original name
// =========================================================
// 2. CONSTRUCTOR & INITIALIZATION
// =========================================================
public MainForm()
{
InitializeComponent();
ApplyDarkTheme();
// Ensure the Profiles folder exists
if (!Directory.Exists(_profilesDir))
{
Directory.CreateDirectory(_profilesDir);
}
// Step 1: Set up all button clicks and text changes
SetupEventHandlers();
// Step 2: Load data from disk (Config, Params, Profiles)
LoadData();
}
private void SetupEventHandlers()
{
// --- Property Grid (The Settings List) ---
// Whenever a value changes in the grid, update the command text and save the profile immediately.
pgProfile.PropertyValueChanged += (s, e) =>
{
UpdateCommandPreview();
SaveCurrentProfile();
};
// --- Service Dropdown (Netflix, Amazon, etc.) ---
comboService.SelectedIndexChanged += (s, e) =>
{
// Don't run this logic if the app is still starting up
if (!_isLoaded) return;
if (comboService.SelectedItem != null)
{
// Update the "Service" value in our data dictionary
_propertyValues["Service"] = comboService.SelectedItem.ToString();
// Update the command preview
UpdateCommandPreview();
// Save this change to the JSON file immediately
SaveCurrentProfile();
}
};
// --- Text Box Changes ---
txtURL.TextChanged += (s, e) => UpdateCommandPreview();
txtBinName.TextChanged += (s, e) =>
{
UpdateCommandPreview();
SaveGlobalConfig();
};
txtRootPath.TextChanged += (s, e) => SaveGlobalConfig();
// --- Button Clicks ---
btnRun.Click += btnRun_Click;
btnStop.Click += btnStop_Click;
btnBrowse.Click += btnBrowse_Click;
btnOpenCookies.Click += btnOpenCookies_Click;
btnEditServiceConfig.Click += btnEditServiceConfig_Click;
btnClearLog.Click += btnClearLog_Click;
btnEditYaml.Click += btnEditYaml_Click;
btnAddProfile.Click += btnAddProfile_Click;
btnRemoveProfile.Click += btnRemoveProfile_Click;
// This line connects the click to the function below
btnSaveProfile.Click += btnSaveProfile_Click;
}
// =========================================================
// 3. DATA LOADING LOGIC
// =========================================================
private void LoadData()
{
_isLoaded = false; // Stop events from firing while we load
// A. Load Parameters (The definitions for flags like -q, -v, etc.)
if (File.Exists(_paramsPath))
{
var json = File.ReadAllText(_paramsPath);
_parameterDefinitions = JsonConvert.DeserializeObject<List<UnshackleParameter>>(json) ?? new List<UnshackleParameter>();
}
else
{
MessageBox.Show("Error: params.json not found! The grid will be empty.");
}
// B. Load Global Config (Root Path, Binary Name)
if (File.Exists(_configPath))
{
var json = File.ReadAllText(_configPath);
_appSettings = JsonConvert.DeserializeObject<AppSettings>(json) ?? new AppSettings();
txtRootPath.Text = _appSettings.RootPath;
txtBinName.Text = _appSettings.BinaryName;
}
// C. Populate the Services Dropdown (Read folders from disk)
RefreshFolders();
// D. Load Profiles and select the default one
RefreshProfileList();
_isLoaded = true; // Loading done, enable events
// Force an initial update of the command text
UpdateCommandPreview();
}
private void RefreshProfileList()
{
// 1. Temporarily stop listening to the selection event to prevent glitches
comboProfiles.SelectedIndexChanged -= ComboProfiles_SelectedIndexChanged;
// 2. Find all .json files in the Profiles folder
var files = Directory.GetFiles(_profilesDir, "*.json");
var profileNames = files.Select(Path.GetFileNameWithoutExtension).ToList();
// 3. If no profiles exist, create a "Default" one
if (profileNames.Count == 0)
{
CreateDefaultProfile();
profileNames.Add("Default");
}
// 4. Update the ComboBox
comboProfiles.DataSource = null;
comboProfiles.DataSource = profileNames;
// 5. Select "Default" or the first available profile
string targetProfile = profileNames.Contains("Default") ? "Default" : profileNames[0];
comboProfiles.SelectedItem = targetProfile;
// 6. Manually trigger the load for this profile
LoadProfile(targetProfile);
// 7. Start listening to events again
comboProfiles.SelectedIndexChanged += ComboProfiles_SelectedIndexChanged;
}
private void CreateDefaultProfile()
{
_propertyValues = new Dictionary<string, object>();
// Fill with default values from params.json
foreach (var p in _parameterDefinitions)
{
_propertyValues[p.Name] = p.Default ?? "";
}
// Set default service
if (comboService.Items.Count > 0)
{
_propertyValues["Service"] = comboService.Items[0].ToString();
}
SaveProfileFile("Default");
}
// =========================================================
// 4. PROFILE SWITCHING & LOADING
// =========================================================
private void ComboProfiles_SelectedIndexChanged(object? sender, EventArgs e)
{
if (comboProfiles.SelectedItem == null) return;
string selectedProfile = comboProfiles.SelectedItem.ToString();
LoadProfile(selectedProfile);
}
private void LoadProfile(string profileName)
{
_lastLoadedProfile = profileName;
string path = Path.Combine(_profilesDir, $"{profileName}.json");
if (!File.Exists(path)) return;
try
{
var json = File.ReadAllText(path);
var loadedValues = JsonConvert.DeserializeObject<Dictionary<string, object>>(json);
if (loadedValues != null)
{
_propertyValues = loadedValues;
// --- FIX 1: Add missing parameters ---
// If we added new options to params.json, old profiles won't have them.
// This adds them with default values.
foreach (var p in _parameterDefinitions)
{
if (!_propertyValues.ContainsKey(p.Name))
{
_propertyValues[p.Name] = p.Default ?? "";
}
}
// --- FIX 2: Handle the Service Dropdown ---
// Check if this profile has a saved Service (e.g., "AMZN")
if (_propertyValues.TryGetValue("Service", out var serviceObj))
{
string serviceName = serviceObj.ToString();
// Only try to switch if the service actually exists in the list
if (comboService.Items.Contains(serviceName))
{
// Temporarily disable _isLoaded to change dropdown without triggering a save loop
bool wasLoaded = _isLoaded;
_isLoaded = false;
comboService.SelectedItem = serviceName;
_isLoaded = wasLoaded;
}
}
else if (comboService.SelectedItem != null)
{
// If profile has NO service saved, save the current one to it
_propertyValues["Service"] = comboService.SelectedItem.ToString();
}
// --- FIX 3: Bind to PropertyGrid and Refresh ---
pgProfile.SelectedObject = new DynamicObject(_propertyValues, _parameterDefinitions);
pgProfile.Refresh(); // Crucial to prevent grey box
UpdateCommandPreview();
}
}
catch (Exception ex)
{
MessageBox.Show($"Error loading profile: {ex.Message}");
}
}
// =========================================================
// 5. SAVING LOGIC
// =========================================================
private void SaveCurrentProfile()
{
// Don't save if we are loading or if nothing is selected
if (!_isLoaded || comboProfiles.SelectedItem == null) return;
string name = comboProfiles.SelectedItem.ToString();
SaveProfileFile(name);
}
private void SaveProfileFile(string name)
{
try
{
// Ensure the currently selected service is saved into the dictionary
if (comboService.SelectedItem != null)
{
_propertyValues["Service"] = comboService.SelectedItem.ToString();
}
string path = Path.Combine(_profilesDir, $"{name}.json");
File.WriteAllText(path, JsonConvert.SerializeObject(_propertyValues, Formatting.Indented));
}
catch (Exception ex)
{
AppendLog($"[Error Saving Profile]: {ex.Message}");
}
}
private void SaveGlobalConfig()
{
if (!_isLoaded) return;
_appSettings.RootPath = txtRootPath.Text;
_appSettings.BinaryName = txtBinName.Text;
try
{
File.WriteAllText(_configPath, JsonConvert.SerializeObject(_appSettings, Formatting.Indented));
}
catch
{
// Ignore errors while typing (file might be busy)
}
}
// =========================================================
// 6. COMMAND GENERATION
// =========================================================
private void UpdateCommandPreview()
{
if (!_isLoaded) return;
StringBuilder sb = new StringBuilder();
// 1. Start with binary and command
// e.g., "uv run unshackle dl "
sb.Append($"{txtBinName.Text} run unshackle dl ");
// 2. Loop through all parameters in the grid
foreach (var p in _parameterDefinitions)
{
// If value doesn't exist in our data, skip it
if (!_propertyValues.TryGetValue(p.Name, out object val)) continue;
// Case A: Boolean Flags (e.g., --no-mux)
if (p.Type == "Bool" && val is bool isTrue && isTrue)
{
sb.Append($"{p.Flag} ");
}
// Case B: Text / Selection / Number
else if (p.Type != "Bool" && val != null)
{
string sVal = val.ToString();
// Validation:
// 1. Not empty
// 2. Not "any" (Default)
// 3. Not "0" (Default for numbers)
if (!string.IsNullOrWhiteSpace(sVal) && sVal != "any" && sVal != "0")
{
// Quote the value if it has spaces
if (sVal.Contains(" "))
{
sVal = $"\"{sVal}\"";
}
sb.Append($"{p.Flag} {sVal} ");
}
}
}
// 3. Append Service and URL
// e.g., "AMZN https://..."
sb.Append($"{comboService.Text} {txtURL.Text}");
// 4. Update UI
txtCommandPreview.Text = sb.ToString();
}
// =========================================================
// 7. CLI EXECUTION (The Run Button)
// =========================================================
private async void btnRun_Click(object? sender, EventArgs e)
{
string finalCmd = txtCommandPreview.Text;
if (string.IsNullOrWhiteSpace(finalCmd)) return;
// Disable buttons while running
ToggleUI(true);
txtLog.AppendText($"> Executing: {finalCmd}{Environment.NewLine}");
try
{
// Run in background thread to keep UI responsive
await Task.Run(() => RunCli(finalCmd));
}
catch (Exception ex)
{
AppendLog($"[ERROR]: {ex.Message}");
}
finally
{
// Re-enable buttons
ToggleUI(false);
}
}
private void RunCli(string cmd)
{
// We use CMD.EXE instead of PowerShell to avoid Antivirus false positives
ProcessStartInfo psi = new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c chcp 65001 && {cmd}", // Force UTF-8
WorkingDirectory = txtRootPath.Text,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8
};
// Set Environment variable for Python
psi.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8";
using (_currentProcess = Process.Start(psi))
{
if (_currentProcess == null) return;
// Handle Output
_currentProcess.OutputDataReceived += (s, e) =>
{
// Filter out the "Active code page: 65001" noise
if (!string.IsNullOrEmpty(e.Data) && !e.Data.Contains("Active code page"))
{
AppendLog(e.Data);
}
};
// Handle Errors
_currentProcess.ErrorDataReceived += (s, e) => AppendLog(e.Data);
_currentProcess.BeginOutputReadLine();
_currentProcess.BeginErrorReadLine();
_currentProcess.WaitForExit();
}
}
// =========================================================
// 8. PROFILE MANAGEMENT (Add/Remove/Save Buttons)
// =========================================================
private void btnAddProfile_Click(object? sender, EventArgs e)
{
string name = Microsoft.VisualBasic.Interaction.InputBox("Enter profile name:", "New Profile", "");
if (string.IsNullOrWhiteSpace(name)) return;
// Sanitize filename (remove illegal characters like / \ : *)
foreach (char c in Path.GetInvalidFileNameChars())
{
name = name.Replace(c, '_');
}
// Check if exists
if (File.Exists(Path.Combine(_profilesDir, $"{name}.json")))
{
MessageBox.Show("Profile already exists.");
return;
}
// Save current settings as the new profile
SaveProfileFile(name);
// Reload list and select new profile
RefreshProfileList();
comboProfiles.SelectedItem = name;
}
private void btnRemoveProfile_Click(object? sender, EventArgs e)
{
if (comboProfiles.SelectedItem == null) return;
string selected = comboProfiles.SelectedItem.ToString();
if (selected == "Default")
{
MessageBox.Show("Default profile cannot be deleted.");
return;
}
string path = Path.Combine(_profilesDir, $"{selected}.json");
if (File.Exists(path))
{
File.Delete(path);
}
RefreshProfileList();
}
private void btnSaveProfile_Click(object? sender, EventArgs e)
{
string newName = comboProfiles.Text.Trim();
// 1. Validation
if (string.IsNullOrWhiteSpace(newName))
{
MessageBox.Show("Please enter a profile name.");
return;
}
foreach (char c in Path.GetInvalidFileNameChars()) newName = newName.Replace(c, '_');
// 2. CHECK: Did the name change?
if (newName != _lastLoadedProfile && !string.IsNullOrEmpty(_lastLoadedProfile))
{
// Special Case: Cannot rename Default
if (_lastLoadedProfile == "Default")
{
// Just create new, don't ask to rename Default
SaveProfileFile(newName);
RefreshProfileList();
comboProfiles.SelectedItem = newName;
MessageBox.Show($"Created new profile '{newName}' from Default.", "New Profile Created");
return;
}
// Ask the user what to do
DialogResult choice = MessageBox.Show(
$"You changed the name from '{_lastLoadedProfile}' to '{newName}'.\n\n" +
"Click YES to RENAME (Delete old).\n" +
"Click NO to CREATE COPY (Keep old).",
"Rename or Copy?",
MessageBoxButtons.YesNoCancel,
MessageBoxIcon.Question);
if (choice == DialogResult.Cancel) return;
if (choice == DialogResult.Yes) // RENAME
{
// Save New
SaveProfileFile(newName);
// Delete Old
string oldPath = Path.Combine(_profilesDir, $"{_lastLoadedProfile}.json");
if (File.Exists(oldPath)) File.Delete(oldPath);
RefreshProfileList();
comboProfiles.SelectedItem = newName; // This updates _lastLoadedProfile automatically
MessageBox.Show($"Renamed to '{newName}'.");
}
else // CREATE COPY
{
SaveProfileFile(newName);
RefreshProfileList();
comboProfiles.SelectedItem = newName;
MessageBox.Show($"Created copy '{newName}'.");
}
}
else
{
// 3. Name didn't change (Normal Save)
SaveProfileFile(newName);
MessageBox.Show($"Profile '{newName}' saved!", "Saved", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
// =========================================================
// 9. HELPER METHODS & THEME
// =========================================================
private void AppendLog(string? text)
{
if (string.IsNullOrEmpty(text)) return;
// Ensure we update the UI on the main thread
this.BeginInvoke(new Action(() =>
{
txtLog.AppendText(text + Environment.NewLine);
}));
}
private void ToggleUI(bool isRunning)
{
this.BeginInvoke(new Action(() =>
{
btnRun.Enabled = !isRunning;
btnStop.Visible = isRunning;
}));
}
private void btnStop_Click(object? sender, EventArgs e)
{
if (_currentProcess != null && !_currentProcess.HasExited)
{
_currentProcess.Kill(true);
}
}
private void btnClearLog_Click(object? sender, EventArgs e)
{
txtLog.Clear();
}
private void btnBrowse_Click(object? sender, EventArgs e)
{
using (var fbd = new FolderBrowserDialog())
{
if (fbd.ShowDialog() == DialogResult.OK)
{
txtRootPath.Text = fbd.SelectedPath;
SaveGlobalConfig();
RefreshFolders();
}
}
}
private void btnOpenCookies_Click(object? sender, EventArgs e)
{
string path = Path.Combine(txtRootPath.Text, "unshackle", "cookies", comboService.Text);
Directory.CreateDirectory(path);
Process.Start("explorer.exe", path);
}
private void btnEditYaml_Click(object? sender, EventArgs e)
{
string yamlPath = Path.Combine(txtRootPath.Text, "unshackle/unshackle.yaml");
if (File.Exists(yamlPath))
{
Process.Start(new ProcessStartInfo(yamlPath) { UseShellExecute = true });
}
}
private void btnEditServiceConfig_Click(object? sender, EventArgs e)
{
string yamlPath = Path.Combine(txtRootPath.Text, "unshackle", "services", comboService.Text, "config.yaml");
if (File.Exists(yamlPath))
{
Process.Start(new ProcessStartInfo(yamlPath) { UseShellExecute = true });
}
else
{
MessageBox.Show($"No config.yaml found for {comboService.Text}");
}
}
private void RefreshFolders()
{
comboService.Items.Clear();
string path = Path.Combine(txtRootPath.Text, "unshackle", "services");
if (Directory.Exists(path))
{
var dirs = Directory.GetDirectories(path).Select(Path.GetFileName).ToArray();
comboService.Items.AddRange(dirs.Cast<object>().ToArray());
if (comboService.Items.Count > 0)
{
comboService.SelectedIndex = 0;
}
}
}
private void ApplyDarkTheme()
{
this.BackColor = Color.FromArgb(30, 30, 30);
this.ForeColor = Color.White;
foreach (Control c in this.Controls)
{
UpdateControlTheme(c);
}
}
private void UpdateControlTheme(Control c)
{
c.BackColor = Color.FromArgb(45, 45, 48);
c.ForeColor = Color.White;
if (c is TextBox tb)
{
tb.BorderStyle = BorderStyle.FixedSingle;
}
if (c is Button btn)
{
btn.FlatStyle = FlatStyle.Flat;
btn.FlatAppearance.BorderColor = Color.Gray;
}
if (c is PropertyGrid pg)
{
pg.BackColor = Color.FromArgb(37, 37, 38);
pg.ViewBackColor = Color.FromArgb(37, 37, 38);
pg.ViewForeColor = Color.White;
pg.LineColor = Color.FromArgb(45, 45, 48);
}
// Recursive call for panels and group boxes
foreach (Control child in c.Controls)
{
UpdateControlTheme(child);
}
}
}
// =========================================================
// 10. DATA MODELS
// =========================================================
public class AppSettings
{
public string RootPath { get; set; } = @"C:\DEVINE\unshackle";
public string BinaryName { get; set; } = "uv";
}
public class UnshackleParameter
{
public string Flag { get; set; } = "";
public string Name { get; set; } = "";
public string Type { get; set; } = "Text";
public List<string>? Options { get; set; }
public object? Default { get; set; }
public string Category { get; set; } = "Misc";
}
}