Files
SeaLoader/sealoader_gui.py
HRiggs 5e68076bbb
All checks were successful
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Successful in 2m32s
Steam Based ID Finiding, Version Control, Storage
2025-09-16 23:31:51 -04:00

710 lines
27 KiB
Python

import configparser
import os
import shutil
import sys
import threading
import time
import webbrowser
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Tuple
import tkinter as tk
from tkinter import messagebox, ttk, filedialog
from steam_required_ids import extract_required_item_ids, resolve_workshop_names, expand_required_ids_recursive
from sealoader_version import __version__
# Minimal dark theme colors
BG = "#16181c"
PANEL_BG = "#1f232a"
FG = "#e6e6e6"
MUTED = "#a0a6ad"
ACCENT = "#4f8cff"
DEFAULT_INI_PATH = str(Path.home() / "AppData" / "LocalLow" / "Triassic Games" / "Sea Power" / "usersettings.ini")
@dataclass
class InstalledMod:
mod_id: str
enabled: bool
def read_installed_mods(ini_path: str) -> Dict[str, InstalledMod]:
parser = configparser.ConfigParser()
parser.optionxform = str
if not os.path.exists(ini_path):
raise FileNotFoundError(f"usersettings.ini not found at: {ini_path}")
parser.read(ini_path, encoding="utf-8")
if "[LoadOrder]" in "\n".join(open(ini_path, encoding="utf-8").readlines()):
section = "LoadOrder"
else:
section = "LoadOrder"
if section not in parser:
return {}
mods: Dict[str, InstalledMod] = {}
for key, value in parser[section].items():
if not key.lower().startswith("mod"):
continue
# Format: ModXDirectory=<id or name>,<True|False>
parts = value.split(",")
if not parts:
continue
left_token = parts[0].strip()
# Prefer numeric Workshop ID if present at start, else keep full token
num_prefix = ""
for ch in left_token:
if ch.isdigit():
num_prefix += ch
else:
break
mod_id = num_prefix if num_prefix else left_token
enabled = False
if len(parts) > 1:
enabled = parts[1].strip().lower() == "true"
mods[mod_id] = InstalledMod(mod_id=mod_id, enabled=enabled)
return mods
def _get_enabled_mod_ids_from_ini(ini_path: str) -> set[str]:
try:
mods = read_installed_mods(ini_path)
except FileNotFoundError:
return set()
return {mid for mid, m in mods.items() if m.enabled}
def _get_all_mod_ids_from_ini(ini_path: str) -> set[str]:
try:
mods = read_installed_mods(ini_path)
except FileNotFoundError:
return set()
return set(mods.keys())
def _find_steamapps_dir_from_game_path(game_path: str) -> Path | None:
p = Path(game_path).resolve()
for parent in [p] + list(p.parents):
if parent.name.lower() == "common" and parent.parent.name.lower() == "steamapps":
return parent.parent
if parent.name.lower() == "steamapps":
return parent
return None
def _detect_app_id(steamapps_dir: Path, installdir_name: str) -> str | None:
target = installdir_name.lower()
for acf in steamapps_dir.glob("appmanifest_*.acf"):
try:
text = acf.read_text(encoding="utf-8", errors="ignore")
except Exception:
continue
if '"installdir"' in text:
for line in text.splitlines():
if '"installdir"' in line:
parts = [seg for seg in line.split('"') if seg.strip()]
if parts:
val = parts[-1].strip().lower()
if val == target:
return acf.stem.replace("appmanifest_", "")
return None
def read_installed_mods_from_workshop(game_path: str, ini_path: str) -> Dict[str, InstalledMod]:
if not game_path:
return {}
steamapps = _find_steamapps_dir_from_game_path(game_path)
if not steamapps or not steamapps.exists():
return {}
installdir = Path(game_path).name
app_id = _detect_app_id(steamapps, installdir)
if not app_id:
return {}
workshop_dir = steamapps / "workshop" / "content" / app_id
if not workshop_dir.exists():
return {}
enabled_ids = _get_enabled_mod_ids_from_ini(ini_path)
mods: Dict[str, InstalledMod] = {}
try:
for child in workshop_dir.iterdir():
if child.is_dir() and child.name.isdigit():
mid = child.name
mods[mid] = InstalledMod(mod_id=mid, enabled=(mid in enabled_ids))
except Exception:
pass
return mods
def enable_mods_in_ini(ini_path: str, ids_to_enable: List[str]) -> Tuple[int, int]:
parser = configparser.ConfigParser()
parser.optionxform = str
parser.read(ini_path, encoding="utf-8")
section = "LoadOrder"
if section not in parser:
raise ValueError("[LoadOrder] section not found in usersettings.ini")
# Backup before changes
backup_path = ini_path + ".bak"
shutil.copyfile(ini_path, backup_path)
enabled_count = 0
missing_count = 0
# Determine next index for new Mod entries
next_idx = 1
for key, _ in parser[section].items():
m = re.match(r"Mod(\d+)Directory", key, re.IGNORECASE)
if m:
try:
next_idx = max(next_idx, int(m.group(1)) + 1)
except Exception:
pass
# Maintain order and preserve original left token (may include name after ID)
items = list(parser[section].items())
# Maintain order
for target_id in ids_to_enable:
found_key = None
for key, value in items:
if not key.lower().startswith("mod"):
continue
parts = value.split(",")
if not parts:
continue
left_token = parts[0].strip()
# Extract numeric prefix for matching
num_prefix = ""
for ch in left_token:
if ch.isdigit():
num_prefix += ch
else:
break
mod_id = num_prefix if num_prefix else left_token
if mod_id == target_id:
found_key = key
current_enabled = False
if len(parts) > 1:
current_enabled = parts[1].strip().lower() == "true"
if not current_enabled:
# Preserve original left token, only toggle enabled flag
new_value = f"{left_token},True"
parser[section][key] = new_value
enabled_count += 1
break
if not found_key:
# Append new entry at bottom for this workshop ID, disabled status will be set to True
new_key = f"Mod{next_idx}Directory"
parser[section][new_key] = f"{target_id},True"
next_idx += 1
enabled_count += 1
with open(ini_path, "w", encoding="utf-8") as f:
parser.write(f)
return enabled_count, missing_count
class SeaLoaderApp(tk.Tk):
def __init__(self, ini_path: str):
super().__init__()
self.title("SeaLoader")
self.configure(bg=BG)
self.geometry("960x560")
self.minsize(800, 480)
self.ini_path = ini_path
self.installed_mods: Dict[str, InstalledMod] = {}
self.required_ids: List[str] = []
self.installed_names_map: Dict[str, str] = {}
self._icon_img = None
self._did_flash_fetch = False
self._did_flash_enable = False
self._style()
self._layout()
self._load_installed_mods()
# App icon from PNG (optional)
try:
icon_path = self._resource_path("SeaLoader.png")
if icon_path.exists():
self._icon_img = tk.PhotoImage(file=str(icon_path))
self.iconphoto(True, self._icon_img)
except Exception:
pass
# Help shortcut
try:
self.bind("<F1>", lambda e: self._on_help())
except Exception:
pass
# Guide: when URL is filled, flash Fetch
try:
self.url_var.trace_add('write', self._on_url_changed)
except Exception:
pass
# Load saved game path (if any) and auto-load mods
try:
saved = self._load_saved_game_path()
if saved:
self.game_path_var.set(saved)
self._load_installed_mods()
except Exception:
pass
# Check for updates asynchronously
threading.Thread(target=self._check_for_updates_async, daemon=True).start()
def _style(self) -> None:
style = ttk.Style(self)
try:
style.theme_use("clam")
except Exception:
pass
style.configure("TFrame", background=BG)
style.configure("Panel.TFrame", background=PANEL_BG)
style.configure("TLabel", background=PANEL_BG, foreground=FG)
style.configure("Header.TLabel", background=BG, foreground=FG, font=("Segoe UI", 12, "bold"))
style.configure("TButton", background=PANEL_BG, foreground=FG, borderwidth=0)
style.map("TButton", background=[("active", ACCENT)])
style.configure("TEntry", fieldbackground=PANEL_BG, foreground=FG, insertcolor=FG)
style.configure("TScrollbar", background=PANEL_BG, troughcolor=BG)
style.configure("Treeview", background=PANEL_BG, fieldbackground=PANEL_BG, foreground=FG, borderwidth=0)
style.configure("Treeview.Heading", background=PANEL_BG, foreground=MUTED)
# Flash styles
style.configure("Flash.TEntry", fieldbackground="#2b3a5a", foreground=FG, insertcolor=FG)
style.configure("Flash.TButton", background=ACCENT, foreground="#0d1117")
def _layout(self) -> None:
top = ttk.Frame(self, style="TFrame")
top.pack(fill=tk.X, padx=12, pady=(12, 6))
url_label = ttk.Label(top, text="Workshop URL:", style="TLabel")
url_label.pack(side=tk.LEFT)
self.url_var = tk.StringVar()
self.url_entry = ttk.Entry(top, textvariable=self.url_var, width=70)
self.url_entry.pack(side=tk.LEFT, padx=8, fill=tk.X, expand=True)
self.fetch_btn = ttk.Button(top, text="Fetch Required Mods", command=self._on_fetch)
self.fetch_btn.pack(side=tk.LEFT)
# Game path row
path_row = ttk.Frame(self, style="TFrame")
path_row.pack(fill=tk.X, padx=12, pady=(0, 6))
ttk.Label(path_row, text="Game Path:", style="TLabel").pack(side=tk.LEFT)
self.game_path_var = tk.StringVar()
self.game_path_entry = ttk.Entry(path_row, textvariable=self.game_path_var, width=70)
self.game_path_entry.pack(side=tk.LEFT, padx=8, fill=tk.X, expand=True)
ttk.Button(path_row, text="Browse", command=self._browse_game_path).pack(side=tk.LEFT)
main = ttk.Frame(self, style="TFrame")
main.pack(fill=tk.BOTH, expand=True, padx=12, pady=6)
left_panel = ttk.Frame(main, style="Panel.TFrame")
left_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
right_panel = ttk.Frame(main, style="Panel.TFrame")
right_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(8, 0))
left_header = ttk.Label(left_panel, text="Installed Mods", style="Header.TLabel")
left_header.pack(anchor="w", padx=8, pady=8)
self.installed_tree = self._create_tree(left_panel, ["Mod ID", "Name", "Status"], [0.30, 0.50, 0.20])
self.installed_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 8))
right_header = ttk.Label(right_panel, text="Scenario Required Mods", style="Header.TLabel")
right_header.pack(anchor="w", padx=8, pady=8)
self.required_tree = self._create_tree(right_panel, ["Mod ID", "Name", "Status"], [0.25, 0.60, 0.15])
self.required_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 8))
bottom = ttk.Frame(self, style="TFrame")
bottom.pack(fill=tk.X, padx=12, pady=(0, 12))
self.enable_btn = ttk.Button(bottom, text="Enable Matching Mods", command=self._on_enable)
self.enable_btn.pack(side=tk.LEFT)
self.disable_all_btn = ttk.Button(bottom, text="Disable All Mods", command=self._on_disable_all)
self.disable_all_btn.pack(side=tk.LEFT, padx=(8, 0))
self.help_btn = ttk.Button(bottom, text="Help", command=self._on_help)
self.subscribe_btn = ttk.Button(bottom, text="Subscribe Missing Mods", command=self._on_subscribe_missing)
self.subscribe_btn.pack(side=tk.RIGHT, padx=(0, 8))
self.help_btn.pack(side=tk.RIGHT)
# Footer attribution
footer = ttk.Frame(self, style="TFrame")
footer.pack(fill=tk.X, padx=12, pady=(0, 10))
self._footer_icon = self._load_png_icon("hrsys.png", max_px=18)
if self._footer_icon is not None:
icon_lbl = ttk.Label(footer, image=self._footer_icon, style="TLabel")
icon_lbl.pack(side=tk.LEFT)
icon_lbl.bind("<Button-1>", lambda e: self._open_link("https://hudsonriggs.systems"))
link_lbl = ttk.Label(footer, text="Created by HudsonRiggs.Systems", style="TLabel", foreground=MUTED)
link_lbl.pack(side=tk.LEFT, padx=(6, 0))
link_lbl.bind("<Button-1>", lambda e: self._open_link("https://hudsonriggs.systems"))
# Update label (hidden by default)
self.update_var = tk.StringVar(value="")
self.update_lbl = ttk.Label(footer, textvariable=self.update_var, style="TLabel", foreground=ACCENT)
self.update_lbl.pack(side=tk.RIGHT)
def _create_tree(self, parent, columns: List[str], ratios: List[float]):
tree = ttk.Treeview(parent, columns=columns, show="headings", height=12)
for col in columns:
tree.heading(col, text=col)
tree.column(col, width=50, anchor=tk.W, stretch=True)
vsb = ttk.Scrollbar(parent, orient="vertical", command=tree.yview)
tree.configure(yscrollcommand=vsb.set)
tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
vsb.pack(side=tk.LEFT, fill=tk.Y)
self._bind_auto_columns(parent, tree, ratios)
return tree
def _bind_auto_columns(self, container, tree: ttk.Treeview, ratios: List[float]) -> None:
ratios = [r / sum(ratios) for r in ratios]
def on_resize(event):
# Reserve some space for vertical scrollbar and paddings
total = max(event.width - 18, 100)
for col, ratio in zip(tree["columns"], ratios):
tree.column(col, width=int(total * ratio))
container.bind("<Configure>", on_resize, add="+")
def _load_installed_mods(self) -> None:
try:
game_path = self.game_path_var.get().strip() if hasattr(self, "game_path_var") else ""
if game_path:
self.installed_mods = read_installed_mods_from_workshop(game_path, self.ini_path)
else:
self.installed_mods = {}
except FileNotFoundError as e:
messagebox.showerror("SeaLoader", str(e))
self.installed_mods = {}
self._refresh_installed_list()
self._resolve_installed_names_async()
def _browse_game_path(self) -> None:
path = filedialog.askdirectory(title="Select Sea Power install folder (steamapps/common/Sea Power)")
if path:
self.game_path_var.set(path)
# Auto-load mods when path selected
self._load_installed_mods()
# Flash the URL box to guide next step
self._flash_widget(self.url_entry, entry=True)
# Persist selection
try:
self._save_game_path(path)
except Exception:
pass
def _refresh_installed_list(self) -> None:
for row in self.installed_tree.get_children():
self.installed_tree.delete(row)
ini_ids = _get_all_mod_ids_from_ini(self.ini_path)
for mod in self.installed_mods.values():
if mod.enabled:
status = "Enabled"
else:
status = "Disabled" if mod.mod_id in ini_ids else "Disabled (Not in INI)"
display_name = self.installed_names_map.get(mod.mod_id, "")
self.installed_tree.insert("", tk.END, values=(mod.mod_id, display_name, status))
def _resolve_installed_names_async(self) -> None:
ids = [mid for mid in self.installed_mods.keys() if mid.isdigit()]
ids_to_lookup = [mid for mid in ids if mid not in self.installed_names_map]
if not ids_to_lookup:
return
def task():
try:
names = resolve_workshop_names(ids_to_lookup)
except Exception:
names = {}
def update():
if names:
self.installed_names_map.update(names)
self._refresh_installed_list()
self.after(0, update)
threading.Thread(target=task, daemon=True).start()
def _on_fetch(self) -> None:
url = self.url_var.get().strip()
if not url:
messagebox.showwarning("SeaLoader", "Please paste a Workshop URL.")
return
self.fetch_btn.configure(state=tk.DISABLED)
self.enable_btn.configure(state=tk.DISABLED)
def task():
try:
base_ids = extract_required_item_ids(url)
ids = expand_required_ids_recursive(base_ids)
# Reuse installed cache to reduce API calls
names_map = {i: self.installed_names_map[i] for i in ids if i in self.installed_names_map}
ids_to_lookup = [i for i in ids if i not in names_map]
if ids_to_lookup:
fetched = resolve_workshop_names(ids_to_lookup)
names_map.update(fetched)
except Exception as exc:
self.after(0, lambda: messagebox.showerror("SeaLoader", f"Failed to fetch IDs: {exc}"))
self.after(0, lambda: self.fetch_btn.configure(state=tk.NORMAL))
self.after(0, lambda: self.enable_btn.configure(state=tk.NORMAL))
return
def update():
self.required_ids = ids
self._refresh_required_list(names_map)
self.fetch_btn.configure(state=tk.NORMAL)
self.enable_btn.configure(state=tk.NORMAL)
# Flash Enable button to guide next step
self._maybe_flash_enable()
self.after(0, update)
threading.Thread(target=task, daemon=True).start()
def _refresh_required_list(self, names_map: Dict[str, str] | None = None) -> None:
for row in self.required_tree.get_children():
self.required_tree.delete(row)
installed_ids = set(self.installed_mods.keys())
names_map = names_map or {}
for mod_id in self.required_ids:
status = "Installed" if mod_id in installed_ids else "Missing"
display_name = names_map.get(mod_id, "")
self.required_tree.insert("", tk.END, values=(mod_id, display_name, status))
def _on_enable(self) -> None:
if not self.required_ids:
messagebox.showinfo("SeaLoader", "No required mods loaded. Fetch from URL first.")
return
try:
enabled_count, missing_count = enable_mods_in_ini(self.ini_path, self.required_ids)
except Exception as exc:
messagebox.showerror("SeaLoader", f"Failed to update INI: {exc}")
return
# Reload to reflect changes
self._load_installed_mods()
self._refresh_required_list()
msg_lines = [f"Enabled {enabled_count} mod(s)."]
if missing_count:
msg_lines.append(f"{missing_count} required mod(s) not found in usersettings.ini.")
else:
msg_lines.append("All required mods were found.")
messagebox.showinfo("SeaLoader", "\n".join(msg_lines))
def _on_help(self) -> None:
ini_hint = (
f"Default INI: {self.ini_path}\n\n"
)
text = (
"Usage:\n\n"
"1) Paste a Steam Workshop URL at the top and click 'Fetch Required Mods'.\n"
" - Right list shows required Mod IDs, names and whether they are installed.\n"
"2) Installed mods load automatically from usersettings.ini (left list).\n"
"3) Click 'Subscribe Missing Mods' to subscribe to any missing Workshop items in Steam.\n"
" - Steam desktop must be running and signed in; downloads happen in the background.\n"
"4) Click 'Enable Matching Mods' to turn on any installed required mods.\n"
" - A .bak backup of usersettings.ini is created before changes.\n\n"
"Tips:\n"
"- Run with a custom INI path: python sealoader_gui.py \"<path-to-usersettings.ini>\"\n"
"- Press F1 to open this help.\n\n"
+ ini_hint
)
messagebox.showinfo("SeaLoader Help", text)
def _resource_path(self, name: str) -> Path:
try:
base = getattr(sys, "_MEIPASS", None)
if base:
return Path(base) / name
except Exception:
pass
return Path(__file__).resolve().parent / name
def _open_link(self, url: str) -> None:
try:
webbrowser.open(url)
except Exception:
pass
def _load_png_icon(self, filename: str, max_px: int = 18):
try:
png_path = self._resource_path(filename)
if not png_path.exists():
return None
img = tk.PhotoImage(file=str(png_path))
# Downscale if needed using integer subsample
sx = max(img.width() // max_px, 1)
sy = max(img.height() // max_px, 1)
scale = max(sx, sy)
if scale > 1:
img = img.subsample(scale, scale)
return img
except Exception:
return None
def _on_disable_all(self) -> None:
confirm = messagebox.askyesno("SeaLoader", "Disable all mods in usersettings.ini?")
if not confirm:
return
try:
self._disable_all_mods()
except Exception as exc:
messagebox.showerror("SeaLoader", f"Failed to disable all mods: {exc}")
return
self._load_installed_mods()
messagebox.showinfo("SeaLoader", "All mods disabled.")
def _disable_all_mods(self) -> None:
parser = configparser.ConfigParser()
parser.optionxform = str
parser.read(self.ini_path, encoding="utf-8")
section = "LoadOrder"
if section not in parser:
raise ValueError("[LoadOrder] section not found in usersettings.ini")
shutil.copyfile(self.ini_path, self.ini_path + ".bak")
for key, value in list(parser[section].items()):
if not key.lower().startswith("mod"):
continue
parts = value.split(",")
if not parts:
continue
left_token = parts[0].strip()
parser[section][key] = f"{left_token},False"
with open(self.ini_path, "w", encoding="utf-8") as f:
parser.write(f)
def _on_subscribe_missing(self) -> None:
if not self.required_ids:
messagebox.showinfo("SeaLoader", "Fetch a Workshop URL first to determine missing mods.")
return
installed_ids = set(self.installed_mods.keys())
missing = [i for i in self.required_ids if i not in installed_ids]
if not missing:
messagebox.showinfo("SeaLoader", "No missing mods to subscribe.")
return
self.subscribe_btn.configure(state=tk.DISABLED)
# Process one-by-one with remaining counter in the dialog
self._subscribe_queue = list(missing)
self._subscribe_next()
def _subscribe_next(self) -> None:
queue: List[str] = getattr(self, "_subscribe_queue", [])
if not queue:
self.subscribe_btn.configure(state=tk.NORMAL)
messagebox.showinfo("SeaLoader", "Finished opening subscriptions in Steam.")
return
mod_id = queue[0]
remaining = len(queue) - 1
try:
self._open_link(f"steam://subscribe/{mod_id}")
time.sleep(0.2)
self._open_link(f"steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id={mod_id}")
except Exception:
pass
# Show per-item info and proceed when dismissed
messagebox.showinfo("SeaLoader", f"Opened subscribe for {mod_id}.\n{remaining} remaining.")
# Advance queue and continue
queue.pop(0)
self._subscribe_queue = queue
self.after(0, self._subscribe_next)
# Guided flashing helpers
def _flash_widget(self, widget: ttk.Widget, entry: bool = False, cycles: int = 6, interval_ms: int = 300) -> None:
try:
normal_style = widget.cget('style') or ("TEntry" if entry else "TButton")
flash_style = "Flash.TEntry" if entry else "Flash.TButton"
state = {"on": True, "count": cycles * 2}
def tick():
if state["count"] <= 0:
widget.configure(style=normal_style)
return
widget.configure(style=flash_style if state["on"] else normal_style)
state["on"] = not state["on"]
state["count"] -= 1
self.after(interval_ms, tick)
tick()
except Exception:
try:
widget.focus_set()
except Exception:
pass
def _on_url_changed(self, *_args):
if not self._did_flash_fetch and self.url_var.get().strip():
self._did_flash_fetch = True
self._flash_widget(self.fetch_btn, entry=False)
def _maybe_flash_enable(self) -> None:
if self.required_ids and not self._did_flash_enable:
self._did_flash_enable = True
self._flash_widget(self.enable_btn, entry=False)
# Simple storage for game path next to INI
def _storage_file_path(self) -> Path:
try:
ini_dir = Path(self.ini_path).resolve().parent
except Exception:
ini_dir = Path.cwd()
return ini_dir / "sealoader_game_path.txt"
def _save_game_path(self, path: str) -> None:
p = self._storage_file_path()
p.write_text(path.strip(), encoding="utf-8")
def _load_saved_game_path(self) -> str:
p = self._storage_file_path()
if p.exists():
return p.read_text(encoding="utf-8").strip()
return ""
# Update checker
def _check_for_updates_async(self) -> None:
try:
import requests
# Configure your Gitea server/repo here
owner_repo = os.getenv("SEALOADER_REPO", "HudsonRiggs/SeaLoader")
server = os.getenv("SEALOADER_GITEA", "https://git.hudsonriggs.systems")
url = f"{server}/api/v1/repos/{owner_repo}/releases/latest"
resp = requests.get(url, timeout=10)
if resp.status_code != 200:
return
data = resp.json()
latest = data.get("tag_name") or data.get("name") or ""
if latest and latest != __version__:
def show():
self.update_var.set(f"Update available: {latest} (current {__version__})")
# Flash the label
self._flash_widget(self.update_lbl, entry=False, cycles=8, interval_ms=400)
# Click opens latest release
self.update_lbl.bind("<Button-1>", lambda e: self._open_link(data.get("html_url") or f"{server}/{owner_repo}/releases"))
self.after(0, show)
except Exception:
pass
def main() -> None:
ini_path = DEFAULT_INI_PATH
if len(sys.argv) > 1:
ini_path = sys.argv[1]
app = SeaLoaderApp(ini_path)
app.mainloop()
if __name__ == "__main__":
main()