#!/usr/bin/env python3 """ JGR Cursor Installer v2.2.1 Dark-themed cursor installer for Windows with smart auto-detection. Supports: .cur .ani .ico .zip .rar .7z .tar .gz .bz2 .xz """ import sys import os import re import shutil import ctypes import math import struct import zipfile import tarfile import tempfile import webbrowser import json import subprocess from pathlib import Path # ── Version & Auto-Update Config ───────────────────────────────────────────── APP_VERSION = '2.2.1' APP_NAME = 'JGR Cursor Installer' # ╔═══════════════════════════════════════════════════════════════════════════╗ # ║ AUTO-UPDATE CONFIG — Change these when you set up hosting ║ # ╠═══════════════════════════════════════════════════════════════════════════╣ # ║ Option A: GitHub Releases ║ # ║ UPDATE_CHECK_URL = 'https://api.github.com/repos/YOU/REPO/releases/latest' # ║ The JSON must have: "tag_name" (version), "body" (changelog), ║ # ║ and "assets" array with a .exe "browser_download_url". ║ # ║ ║ # ║ Option B: Custom JSON endpoint ║ # ║ UPDATE_CHECK_URL = 'https://yoursite.com/jgr-update.json' ║ # ║ Return JSON: {"version":"2.2.0","download_url":"...","changelog":""} ║ # ║ ║ # ║ Set to '' to disable update checking entirely. ║ # ╚═══════════════════════════════════════════════════════════════════════════╝ UPDATE_CHECK_URL = 'https://api.github.com/repos/infamousjuu-debug/jgr-cursor-installer/releases/latest' UPDATE_CHECK_SECS = 3600 # How often to re-check (seconds); default 1 hour # ╔═══════════════════════════════════════════════════════════════════════════╗ # ║ AI CURSOR CREATOR CONFIG — Uses Google Gemini to generate cursors ║ # ╠═══════════════════════════════════════════════════════════════════════════╣ # ║ Paste your Gemini API key below BEFORE building the .exe. ║ # ║ Get a free key at: https://aistudio.google.com/apikey ║ # ║ When a key is set here, the API key field is hidden from end users — ║ # ║ they just type a theme and click Generate. ║ # ║ ║ # ║ IMPORTANT: Do NOT push this file to a PUBLIC repo with a key in it! ║ # ║ Only set the key locally before building your .exe. ║ # ╚═══════════════════════════════════════════════════════════════════════════╝ GEMINI_API_KEY = '' # ← Paste your key here before building the .exe GEMINI_MODEL = 'gemini-3-pro-image-preview' from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QGraphicsDropShadowEffect, QSizePolicy, QScrollArea, QFileDialog, QDialog, QComboBox, QGraphicsOpacityEffect, QLineEdit, QTextEdit, QProgressBar ) from PyQt5.QtCore import ( Qt, QPoint, pyqtSignal, QThread, QTimer, QRect, QPropertyAnimation, QEasingCurve ) from PyQt5.QtGui import QColor, QFont, QPainter, QPen, QBrush try: import py7zr; HAS_7Z = True except ImportError: HAS_7Z = False try: import rarfile; HAS_RAR = True except ImportError: HAS_RAR = False try: from PIL import Image; HAS_PIL = True except ImportError: HAS_PIL = False # ── Windows API ─────────────────────────────────────────────────────────────── SPI_SETCURSORS = 0x0057 SPIF_UPDATEINIFILE = 0x01 SPIF_SENDCHANGE = 0x02 CURSOR_EXTS = {'.cur', '.ani', '.ico'} ARCHIVE_EXTS = {'.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.tgz', '.tbz2'} SCHEME_EXTS = {'.inf', '.ini', '.txt', '.theme', '.crs'} # ── Cursor roles with PLAIN-ENGLISH descriptions ───────────────────────────── CURSOR_ROLES = [ ('Arrow', 'Normal pointer on desktop'), ('Hand', 'Hover over clickable links'), ('IBeam', 'Text typing cursor'), ('Wait', 'Loading / busy spinner'), ('AppStarting', 'Loading in background'), ('Cross', 'Precise pixel selection'), ('SizeAll', 'Drag to move in any direction'), ('SizeNWSE', 'Stretch diagonally \\ '), ('SizeNESW', 'Stretch diagonally / '), ('SizeWE', 'Stretch left and right'), ('SizeNS', 'Stretch up and down'), ('No', 'Action not allowed'), ('Help', 'Click for help info'), ('UpArrow', 'Alternate selection pointer'), ('NWPen', 'Handwriting / pen input'), ('Pin', 'Pick a location on map'), ('Person', 'Select a person / contact'), ] # The canonical roles Windows registry uses REGISTRY_ROLES = [ 'Arrow', 'Hand', 'IBeam', 'Wait', 'AppStarting', 'Cross', 'SizeAll', 'SizeNWSE', 'SizeNESW', 'SizeWE', 'SizeNS', 'No', 'Help', 'UpArrow', 'NWPen', 'Pin', 'Person', ] # All known registry value names under Control Panel\Cursors (for cleanup) _KNOWN_CURSOR_KEYS = { '', 'Arrow', 'Hand', 'IBeam', 'Wait', 'AppStarting', 'Cross', 'SizeAll', 'SizeNWSE', 'SizeNESW', 'SizeWE', 'SizeNS', 'No', 'Help', 'UpArrow', 'Pin', 'Person', 'NWPen', 'Scheme Source', 'ContactVisualization', 'CursorBaseSize', 'GestureVisualization', } # ── TRUE SYSTEM DEFAULTS ───────────────────────────────────────────────────── _SYSTEM_DEFAULTS = { '': '', 'Arrow': '', 'Hand': '', 'IBeam': '', 'Wait': '', 'AppStarting': '', 'Cross': '', 'SizeAll': '', 'SizeNWSE': '', 'SizeNESW': '', 'SizeWE': '', 'SizeNS': '', 'No': '', 'Help': '', 'UpArrow': '', 'Pin': '', 'Person': '', } CURSOR_SITES = [ ('cursor.cc', 'https://www.cursor.cc/', 'Design your own cursors online'), ('cursors-4u.com', 'https://cursors-4u.com/', 'Huge free cursor library'), ('RW Designer', 'https://www.rw-designer.com/cursor-library', 'Community-made cursor packs'), ('DeviantArt', 'https://www.deviantart.com/customization/skins/windows/cursors/', 'Artist-made cursor themes'), ('Pling / KDE Store', 'https://www.pling.com/browse?cat=107', 'Open-source cursor collections'), ('cursor.in', 'https://cursor.in/', 'Animated cursor packs'), ] INSTALL_DIR = Path(os.environ.get('LOCALAPPDATA', os.path.expanduser('~'))) / 'JGR' / 'Cursors' REG_PATH = r'Control Panel\Cursors' # ── Dark theme combo style ─────────────────────────────────────────────────── COMBO_STYLE = ( 'QComboBox{color:#ffffff;background:rgba(255,255,255,12);' 'border:1px solid rgba(255,255,255,40);border-radius:5px;' 'padding:1px 5px;font-size:8pt;font-weight:bold;}' 'QComboBox:hover{border-color:rgba(255,255,255,90);background:rgba(255,255,255,22);}' 'QComboBox[unassigned="true"]{color:rgba(255,180,60,210);' 'border-color:rgba(255,180,60,60);background:rgba(255,150,0,12);}' 'QComboBox::drop-down{border:none;width:16px;}' 'QComboBox::down-arrow{border-left:4px solid transparent;' 'border-right:4px solid transparent;border-top:5px solid rgba(255,255,255,140);' 'margin-right:4px;}' 'QComboBox QAbstractItemView{background:rgb(18,18,22);color:#e0e0e0;' 'selection-background-color:rgba(255,255,255,18);selection-color:#ffffff;' 'border:1px solid rgba(255,255,255,40);outline:none;}' ) # ============================================================================= # Registry helpers # ============================================================================= def _read_current_cursors(): """Read current cursor registry values.""" try: import winreg result = {} with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_READ) as reg: try: result[''] = winreg.QueryValueEx(reg, '')[0] except Exception: result[''] = '' for key, _ in CURSOR_ROLES: try: result[key] = winreg.QueryValueEx(reg, key)[0] except Exception: result[key] = '' return result except Exception: return dict(_SYSTEM_DEFAULTS) # ============================================================================= # INF / SCHEME FILE PARSER (highest-priority detection) # ============================================================================= _INF_KEY_MAP = {} _inf_aliases = { 'Arrow': ['arrow', 'pointer', 'normal', 'normal select', 'default', 'idc_arrow', '0', 'normalselect', 'standard', 'main', 'regular', 'select', 'cursor', 'left_ptr', 'top_left_arrow', 'base_arrow', 'mainarrow', 'basecursor', 'arrowcursor'], 'Help': ['help', 'help select', 'idc_help', 'question', 'helpselect', 'whatsthis', 'question_arrow', 'dnd_ask', 'helpcur', 'helpcursor', 'questionmark'], 'AppStarting': ['appstarting', 'app starting', 'working in background', 'working', 'background', 'idc_appstarting', 'busy_l', 'workinginbackground', 'backgroundbusy', 'busyarrow', 'progress', 'left_ptr_watch', 'half_busy', 'halfbusy', 'bgworking', 'startingapp', 'arrowbusy', 'waitarrow', 'progressarrow'], 'Wait': ['wait', 'busy', 'idc_wait', 'hourglass', 'loading', 'spinning', 'busy_r', 'spin', 'spinner', 'clock', 'timer', 'watch', 'fullbusy', 'processing', 'waitcur', 'waitcursor', 'busycursor', 'thinking'], 'Cross': ['cross', 'crosshair', 'precision', 'precision select', 'idc_cross', 'precisionselect', 'target', 'tcross', 'crosshaircur', 'exactselect', 'pinpoint'], 'IBeam': ['ibeam', 'text', 'text select', 'i-beam', 'idc_ibeam', 'beam', 'textselect', 'caret', 'edit', 'type', 'xterm', 'textcur', 'textcursor', 'typecursor', 'editcursor', 'typetext', 'inserttext'], 'SizeNWSE': ['sizenwse', 'nwse', 'diagonal resize 1', 'idc_sizenwse', 'dgn1', 'nw-se', 'diag1', 'diagonalresize1', 'nwseresize', 'sizenw', 'sizese', 'bd_double_arrow', 'top_left_corner', 'bottom_right_corner', 'size_fdiag', 'diagonal1', 'nwse_resize', 'diag_left', 'diagleft'], 'SizeNESW': ['sizenesw', 'nesw', 'diagonal resize 2', 'idc_sizenesw', 'dgn2', 'ne-sw', 'diag2', 'diagonalresize2', 'neswresize', 'sizene', 'sizesw', 'fd_double_arrow', 'top_right_corner', 'bottom_left_corner', 'size_bdiag', 'diagonal2', 'nesw_resize', 'diag_right', 'diagright'], 'SizeWE': ['sizewe', 'we', 'horizontal resize', 'idc_sizewe', 'horz', 'ew', 'w-e', 'hresize', 'horizontalresize', 'leftright', 'col', 'sb_h_double_arrow', 'left_side', 'right_side', 'split_h', 'h_double_arrow', 'col_resize', 'ewresize', 'ew_resize', 'horzresize', 'sizeh', 'resizehoriz', 'resizehorizontal'], 'SizeNS': ['sizens', 'ns', 'vertical resize', 'idc_sizens', 'vert', 'n-s', 'vresize', 'verticalresize', 'updown', 'row', 'sb_v_double_arrow', 'top_side', 'bottom_side', 'split_v', 'v_double_arrow', 'row_resize', 'nsresize', 'ns_resize', 'vertresize', 'sizev', 'resizevert', 'resizevertical'], 'SizeAll': ['sizeall', 'move', 'idc_sizeall', 'all', 'allmove', 'fourway', '4way', 'fleur', 'size_all', 'grabbing', 'closedhand', 'dnd_move', 'movecursor', 'dragmove', 'allscroll', 'moveall'], 'No': ['no', 'unavailable', 'idc_no', 'forbidden', 'not allowed', 'blocked', 'stop', 'denied', 'unavail', 'block', 'deny', 'notallowed', 'cant', 'circle', 'dnd_no_drop', 'no_drop', 'crossed_circle', 'not_allowed', 'restricted', 'banned', 'prohibited', 'nodrop', 'cannotdrop', 'stopcur', 'disable', 'disabled'], 'Hand': ['hand', 'link', 'link select', 'idc_hand', 'finger', 'pointing', 'linkselect', 'grab', 'point', 'pointing_hand', 'hand2', 'openhand', 'handpointer', 'clickhand', 'weblink', 'urlhand'], 'UpArrow': ['uparrow', 'up arrow', 'alternate', 'alternate select', 'idc_uparrow', 'alternateselect', 'up', 'alt', 'up_arrow', 'center_ptr', 'altselect', 'alt_select', 'altuparrow'], 'NWPen': ['nwpen', 'handwriting', 'idc_nwpen', 'pen', 'write', 'pencil', 'scribble', 'stylus', 'freeform', 'inkpen', 'writecur'], 'Pin': ['pin', 'location', 'location select', 'idc_pin', 'marker', 'mappin', 'locator', 'geopin'], 'Person': ['person', 'person select', 'idc_person', 'contact', 'people', 'user', 'personselect', 'selectperson'], } for _role, _aliases in _inf_aliases.items(): for _a in _aliases: _INF_KEY_MAP[_a.lower()] = _role _INF_KEY_MAP[_role.lower()] = _role def _resolve_cursor_path(val, cursor_dir): """Resolve a cursor filename/path to an actual file on disk.""" val = val.strip().strip('"').strip("'") if not val: return '' p = Path(val) if p.is_absolute() and p.exists(): return str(p) check = Path(cursor_dir) / val if check.exists(): return str(check) check = Path(cursor_dir) / p.name if check.exists(): return str(check) target = p.name.lower() try: for f in Path(cursor_dir).iterdir(): if f.name.lower() == target: return str(f) except Exception: pass return '' def _parse_inf_file(inf_path, cursor_dir): """ Parse cursor scheme files for role -> filename mappings. Supports: .crs, .inf, .ini, .theme formats. """ mapping = {} try: raw = Path(inf_path).read_bytes() for enc in ('utf-16', 'utf-8-sig', 'utf-8', 'latin-1'): try: text = raw.decode(enc) break except (UnicodeDecodeError, UnicodeError): continue else: return mapping lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n') current_section = '' for line in lines: line = line.strip() if not line or line.startswith(';') or line.startswith('#'): continue sec_match = re.match(r'^\[(.+)\]$', line) if sec_match: current_section = sec_match.group(1).strip() continue if '=' not in line: continue key, _, val = line.partition('=') key = key.strip().strip('"').strip("'").lower() val = val.strip().strip('"').strip("'") # .crs format: [Arrow] section with Path=filename if key == 'path' and current_section: role = _INF_KEY_MAP.get(current_section.lower(), '') if role and val: fp = _resolve_cursor_path(val, cursor_dir) if fp: mapping[fp] = role continue # .inf format: HKCU registry lines if val.startswith('HKCU,') or val.startswith('HKLM,'): parts = [p.strip().strip('"') for p in val.split(',')] if len(parts) >= 5 and 'cursors' in parts[1].lower(): role_candidate = parts[2].strip() cursor_file = parts[-1].strip().strip('"') role = _INF_KEY_MAP.get(role_candidate.lower(), '') if role and cursor_file: fp = _resolve_cursor_path(cursor_file, cursor_dir) if fp: mapping[fp] = role continue # .theme format: [Control Panel\Cursors] if current_section.lower().replace(' ', '') in ( 'controlpanel\\cursors', 'controlpanel\\\\cursors', r'control panel\cursors'): role = _INF_KEY_MAP.get(key, '') if role and val: fname = Path(val).name if val else '' if fname: fp = _resolve_cursor_path(fname, cursor_dir) if fp: mapping[fp] = role continue # .ini format: direct key=value role = _INF_KEY_MAP.get(key, '') if role and val: fp = _resolve_cursor_path(val, cursor_dir) if fp: mapping[fp] = role # Fallback scan if not mapping: for line in lines: for ext in ('.cur', '.ani', '.ico'): if ext in line.lower(): m = re.match(r'^\s*([^=]+?)\s*=\s*(.+?' + re.escape(ext) + r')\s*$', line, re.IGNORECASE) if m: key = m.group(1).strip().strip('"').lower() val = m.group(2).strip().strip('"') role = _INF_KEY_MAP.get(key, '') if role: fp = _resolve_cursor_path(val, cursor_dir) if fp: mapping[fp] = role except Exception: pass return mapping # ============================================================================= # BINARY CURSOR PARSING # ============================================================================= def _parse_cur_header(data): """Return ((hx,hy),(w,h)) from raw .cur bytes, or (None,None).""" try: _, typ, count = struct.unpack_from('= 24: fc = struct.unpack_from(' 0: frames = fc elif cid == b'LIST': list_type = buf[j+8:j+12] if j + 12 <= end else b'' if list_type == b'fram': _walk(buf, j + 12, j + 8 + sz) elif cid == b'icon': hs, ims = _parse_cur_header(buf[j+8 : j+8+sz]) if hs and hotspot is None: hotspot = hs if ims and img_size is None: img_size = ims j += 8 + sz + (sz % 2) _walk(data, 12, len(data)) except Exception: pass return frames, hotspot, img_size def _extract_first_frame_from_ani(data): """Extract the first .cur/.ico frame from a RIFF .ani file as PIL Image. Handles both top-level icon chunks AND icons nested inside LIST/fram.""" try: import io if len(data) < 12 or data[:4] != b'RIFF': return None def _find_first_icon(buf, start, end): """Walk chunk list and return first valid icon Image.""" j = start while j < end - 8: cid = buf[j:j+4] csz = struct.unpack_from(' 6: icon_data = buf[j+8 : j+8+csz] if len(icon_data) >= 6: _, typ, _ = struct.unpack_from(' 30: vis.append((y, x)) total = len(vis) if total < 8: return '' rows_l = [r for r, c in vis] cols_l = [c for r, c in vis] r0, r1 = min(rows_l), max(rows_l) c0, c1 = min(cols_l), max(cols_l) ch = r1 - r0 + 1 cw = c1 - c0 + 1 if ch == 0 or cw == 0: return '' aspect = cw / ch cm_r = sum(rows_l) / total cm_c = sum(cols_l) / total ncm_r = (cm_r - r0) / ch ncm_c = (cm_c - c0) / cw fill = total / (ch * cw) if (ch * cw) > 0 else 0 rw_dict = {} cw_dict = {} for r, c in vis: rw_dict[r] = rw_dict.get(r, 0) + 1 cw_dict[c] = cw_dict.get(c, 0) + 1 rw = [rw_dict.get(r, 0) for r in range(r0, r1 + 1)] cw_arr = [cw_dict.get(c, 0) for c in range(c0, c1 + 1)] max_rw = max(rw) if rw else 1 max_cww = max(cw_arr) if cw_arr else 1 # ── Helper: tip strength (how sharply a profile narrows) ────────── def tip_strength(widths, from_start): n = len(widths) if n < 5: return 0.0 w = list(reversed(widths)) if not from_start else widths tip_n = max(1, n // 8) mid_s = n // 3 mid_e = 2 * n // 3 tip_avg = sum(w[:tip_n]) / tip_n mid_avg = sum(w[mid_s : mid_e]) / max(1, mid_e - mid_s) if mid_avg == 0: return 0.0 ratio = tip_avg / mid_avg return max(0.0, min(1.0, 1.0 - ratio / 0.45)) n_str = tip_strength(rw, from_start=True) s_str = tip_strength(rw, from_start=False) w_str = tip_strength(cw_arr, from_start=True) e_str = tip_strength(cw_arr, from_start=False) T = 0.45 n_tip = n_str > T s_tip = s_str > T w_tip = w_str > T e_tip = e_str > T # ── Helper: symmetry ───────────────────────────────────────────── vis_set = set(vis) mid_r = (r0 + r1) / 2.0 mid_c = (c0 + c1) / 2.0 sym_lr_m = sum(1 for r, c in vis if (r, int(2*mid_c - c + 0.5)) in vis_set) sym_ud_m = sum(1 for r, c in vis if (int(2*mid_r - r + 0.5), c) in vis_set) sym_lr = sym_lr_m / total if total > 0 else 0 sym_ud = sym_ud_m / total if total > 0 else 0 # ── IBeam: narrow vertical bar, possibly with serifs ───────────── if cw <= max(W * 0.65, 20): norm_rw = [w / max_rw for w in rw] if max_rw > 0 else [] if len(norm_rw) >= 10: top_serifs = max(norm_rw[:max(1, ch//6)]) bot_serifs = max(norm_rw[ch - max(1, ch//6):]) mid_s_idx = ch // 4 mid_e_idx = 3 * ch // 4 mid_widths = norm_rw[mid_s_idx:mid_e_idx] if mid_widths: mid_avg_n = sum(mid_widths) / len(mid_widths) mid_max_n = max(mid_widths) # IBeam: wide at top & bottom, narrow middle, OR uniformly narrow if mid_max_n < 0.60 and aspect < 0.55: return 'IBeam' if top_serifs > mid_avg_n * 1.5 and bot_serifs > mid_avg_n * 1.5: if mid_avg_n < 0.50: return 'IBeam' # Very narrow aspect and small width if aspect < 0.35 and cw <= max(W * 0.32, 11): return 'IBeam' # ── Double-arrow shapes (check before single-arrow) ────────────── # SizeWE: wide aspect, tips on left+right if aspect > 2.0 and (w_tip or e_tip): return 'SizeWE' if w_tip and e_tip and not n_tip and not s_tip: return 'SizeWE' # SizeNS: tall aspect, tips on top+bottom if aspect < 0.55 and n_tip and s_tip: return 'SizeNS' if n_tip and s_tip and not w_tip and not e_tip: return 'SizeNS' # ── Cross-band analysis (Cross vs SizeAll) ─────────────────────── band_r = max(H * 0.14, 2) band_c = max(W * 0.14, 2) h_band = sum(1 for r, c in vis if abs(r - cm_r) < band_r) v_band = sum(1 for r, c in vis if abs(c - cm_c) < band_c) h_ratio = h_band / total v_ratio = v_band / total if h_ratio > 0.42 and v_ratio > 0.42: corner_pix = sum(1 for r, c in vis if abs(r - cm_r) > band_r and abs(c - cm_c) > band_c) corner_ratio = corner_pix / total # SizeAll: has arrowheads extending into corners if corner_ratio > 0.06: # Verify it has tips in all 4 cardinal directions if n_tip and s_tip and w_tip and e_tip: return 'SizeAll' return 'SizeAll' # Cross: clean crosshair, almost nothing in corners if corner_ratio < 0.08: return 'Cross' # ── No / Forbidden: circle with diagonal slash ─────────────────── if cw >= 10 and ch >= 10: rad = min(cw, ch) / 2.0 dists = [math.sqrt((r-cm_r)**2+(c-cm_c)**2) for r, c in vis] ring = sum(1 for d in dists if 0.28 * rad <= d <= 0.98 * rad) inner = sum(1 for d in dists if d < 0.28 * rad) # Check for NESW diagonal slash (top-right to bottom-left) slash_nesw = sum(1 for r, c in vis if abs((r - cm_r) + (c - cm_c)) < rad * 0.35) # Check for NWSE diagonal slash (top-left to bottom-right) slash_nwse = sum(1 for r, c in vis if abs((r - cm_r) - (c - cm_c)) < rad * 0.35) slash = max(slash_nesw, slash_nwse) ring_r = ring / total inner_r = inner / total slash_r = slash / total if ring_r > 0.30 and inner_r < 0.25 and slash_r > 0.06: return 'No' # Also detect filled circle with slash (some themes use solid No cursor) if 0.75 < aspect < 1.35 and sym_lr > 0.4 and sym_ud > 0.4: if fill > 0.25 and slash_r > 0.15: return 'No' # ── Arrow: tip at top, widens downward ─────────────────────────── if n_tip and not s_tip: third = max(1, ch // 3) top_avg = sum(rw[:third]) / third bot_avg = sum(rw[ch - third :]) / third widens = bot_avg > top_avg * 1.20 if widens and ncm_r > 0.40: # Check for Hand shape: narrow tip then sudden wide section norm = [w / max_rw for w in rw] first_wide = next((i for i, w in enumerate(norm) if w > 0.30), ch) if first_wide > ch * 0.25 and norm[0] < 0.20: if first_wide < ch and norm[first_wide] > 0.50: return 'Hand' return 'Arrow' # ── Hand: narrow tip/finger at top, wider palm below ───────────── if ch > 10 and n_tip: norm = [w / max_rw for w in rw] first_wide = next((i for i, w in enumerate(norm) if w > 0.30), ch) if first_wide > ch * 0.18 and norm[0] < 0.25: if first_wide < ch * 0.65 and max(norm[first_wide:]) > 0.50: return 'Hand' # ── Wait / spinner: roughly circular, symmetric ────────────────── if cw >= 10 and ch >= 10 and 0.60 < aspect < 1.65: rad = min(cw, ch) / 2.0 dists = [math.sqrt((r-cm_r)**2+(c-cm_c)**2) for r, c in vis] ring = sum(1 for d in dists if 0.25 * rad <= d <= 0.98 * rad) inner = sum(1 for d in dists if d < 0.25 * rad) ring_r = ring / total inner_r = inner / total # Hollow ring (hourglass outline, spinner) if ring_r > 0.38 and inner_r < 0.20 and sym_lr > 0.45: return 'Wait' # Filled circular shape (solid hourglass) if fill > 0.30 and sym_lr > 0.55 and 0.70 < aspect < 1.35: mid_idx = ch // 2 mid_w = rw[mid_idx] if mid_idx < len(rw) else max_rw edge_avg = (rw[0] + rw[-1]) / 2.0 if len(rw) > 1 else max_rw if mid_w < edge_avg * 0.75: return 'Wait' # ── Diagonal double-arrows ─────────────────────────────────────── dscale = max(cw, ch) dband = dscale * 0.22 d_nwse = sum(1 for r, c in vis if abs((r-cm_r) - (c-cm_c)) < dband) d_nesw = sum(1 for r, c in vis if abs((r-cm_r) + (c-cm_c)) < dband) d_nwse_r = d_nwse / total d_nesw_r = d_nesw / total if d_nwse_r > 0.50 and d_nwse_r > d_nesw_r + 0.10: return 'SizeNWSE' if d_nesw_r > 0.50 and d_nesw_r > d_nwse_r + 0.10: return 'SizeNESW' # ── Fallback Arrow: any top-tip shape ──────────────────────────── if n_tip and not s_tip and ncm_r > 0.35: return 'Arrow' # ── Fallback wide → SizeWE ─────────────────────────────────────── if aspect > 1.8: return 'SizeWE' # ── Fallback tall → SizeNS ─────────────────────────────────────── if aspect < 0.55 and sym_lr > 0.4: return 'SizeNS' return '' except Exception: return '' # ============================================================================= # FILENAME-BASED DETECTION (massive pattern database) # ============================================================================= _TOKEN_MAP = { # Arrow / Normal 'arrow': 'Arrow', 'normal': 'Arrow', 'default': 'Arrow', 'standard': 'Arrow', 'regular': 'Arrow', 'main': 'Arrow', # Hand / Link (NOT 'pointer' — too ambiguous, many packs use it for Arrow) 'hand': 'Hand', 'link': 'Hand', 'finger': 'Hand', 'grab': 'Hand', 'pointing': 'Hand', # IBeam / Text 'ibeam': 'IBeam', 'beam': 'IBeam', 'caret': 'IBeam', 'text': 'IBeam', 'xterm': 'IBeam', # Wait / Busy 'wait': 'Wait', 'busy': 'Wait', 'loading': 'Wait', 'hourglass': 'Wait', 'spinner': 'Wait', 'clock': 'Wait', 'watch': 'Wait', 'thinking': 'Wait', 'processing': 'Wait', # Cross 'cross': 'Cross', 'crosshair': 'Cross', 'precision': 'Cross', 'pinpoint': 'Cross', 'target': 'Cross', # No / Unavailable 'unavailable': 'No', 'unavail': 'No', 'forbidden': 'No', 'blocked': 'No', 'no': 'No', 'stop': 'No', 'denied': 'No', 'prohibited': 'No', 'banned': 'No', 'restricted': 'No', 'disabled': 'No', # SizeAll / Move 'move': 'SizeAll', 'sizeall': 'SizeAll', 'fleur': 'SizeAll', 'grabbing': 'SizeAll', # SizeNWSE 'nwse': 'SizeNWSE', 'sizenwse': 'SizeNWSE', # SizeNESW 'nesw': 'SizeNESW', 'sizenesw': 'SizeNESW', # SizeWE 'sizewe': 'SizeWE', 'horizontal': 'SizeWE', # SizeNS 'sizens': 'SizeNS', 'vertical': 'SizeNS', # Help 'help': 'Help', 'question': 'Help', # AppStarting 'working': 'AppStarting', 'progress': 'AppStarting', # UpArrow 'alternate': 'UpArrow', 'up': 'UpArrow', 'alt': 'UpArrow', # NWPen / Handwriting 'handwriting': 'NWPen', 'nwpen': 'NWPen', 'pen': 'NWPen', 'pencil': 'NWPen', 'stylus': 'NWPen', # Pin / Location 'location': 'Pin', 'pin': 'Pin', 'marker': 'Pin', # Person 'person': 'Person', 'contact': 'Person', 'people': 'Person', } _COMPOUND_MAP = { # Arrow 'normalselect': 'Arrow', 'leftptr': 'Arrow', 'basearrow': 'Arrow', 'arrowcursor': 'Arrow', 'mainarrow': 'Arrow', 'topleftarrow': 'Arrow', # NWPen / Handwriting — BEFORE Hand so it matches first 'handwriting': 'NWPen', 'inkpen': 'NWPen', 'freeform': 'NWPen', # Hand 'linkselect': 'Hand', 'pointinghand': 'Hand', 'handpointer': 'Hand', 'openhand': 'Hand', 'clickhand': 'Hand', 'weblink': 'Hand', # IBeam 'textselect': 'IBeam', 'textcursor': 'IBeam', 'editcursor': 'IBeam', 'typecursor': 'IBeam', 'inserttext': 'IBeam', 'typetext': 'IBeam', # Wait 'hourglass': 'Wait', 'fullbusy': 'Wait', 'busycursor': 'Wait', 'waitcursor': 'Wait', # Cross 'precisionselect': 'Cross', 'exactselect': 'Cross', 'crosshaircur': 'Cross', # No 'notallowed': 'No', 'unavailable': 'No', 'unavail': 'No', 'forbidden': 'No', 'crossedcircle': 'No', 'nodrop': 'No', 'cannotdrop': 'No', 'restricted': 'No', 'prohibited': 'No', # SizeNWSE 'sizenw': 'SizeNWSE', 'sizese': 'SizeNWSE', 'diag1': 'SizeNWSE', 'dgn1': 'SizeNWSE', 'diagonalresize1': 'SizeNWSE', 'diagonal1': 'SizeNWSE', 'sizefdiag': 'SizeNWSE', 'nwseresize': 'SizeNWSE', 'diagleft': 'SizeNWSE', 'bddoublearrow': 'SizeNWSE', 'topleftcorner': 'SizeNWSE', 'bottomrightcorner': 'SizeNWSE', # SizeNESW 'sizene': 'SizeNESW', 'sizesw': 'SizeNESW', 'diag2': 'SizeNESW', 'dgn2': 'SizeNESW', 'diagonalresize2': 'SizeNESW', 'diagonal2': 'SizeNESW', 'sizebdiag': 'SizeNESW', 'neswresize': 'SizeNESW', 'diagright': 'SizeNESW', 'fddoublearrow': 'SizeNESW', 'toprightcorner': 'SizeNESW', 'bottomleftcorner': 'SizeNESW', # SizeWE 'horizontalresize': 'SizeWE', 'hresize': 'SizeWE', 'leftright': 'SizeWE', 'colresize': 'SizeWE', 'ewresize': 'SizeWE', 'sbhdoublearrow': 'SizeWE', 'hdoublearrow': 'SizeWE', 'horzresize': 'SizeWE', 'resizehoriz': 'SizeWE', 'resizehorizontal': 'SizeWE', 'splith': 'SizeWE', # SizeNS 'verticalresize': 'SizeNS', 'vresize': 'SizeNS', 'updown': 'SizeNS', 'rowresize': 'SizeNS', 'nsresize': 'SizeNS', 'sbvdoublearrow': 'SizeNS', 'vdoublearrow': 'SizeNS', 'vertresize': 'SizeNS', 'resizevert': 'SizeNS', 'resizevertical': 'SizeNS', 'splitv': 'SizeNS', # SizeAll 'allmove': 'SizeAll', 'fourway': 'SizeAll', '4way': 'SizeAll', 'movecursor': 'SizeAll', 'dragmove': 'SizeAll', 'allscroll': 'SizeAll', 'closedhand': 'SizeAll', 'moveall': 'SizeAll', 'dndmove': 'SizeAll', # AppStarting 'appstarting': 'AppStarting', 'appstart': 'AppStarting', 'workinginbackground': 'AppStarting', 'backgroundbusy': 'AppStarting', 'busyarrow': 'AppStarting', 'halfbusy': 'AppStarting', 'leftptrwatch': 'AppStarting', 'arrowbusy': 'AppStarting', 'waitarrow': 'AppStarting', 'progressarrow': 'AppStarting', 'bgworking': 'AppStarting', # Help 'helpselect': 'Help', 'whatsthis': 'Help', 'questionarrow': 'Help', 'questionmark': 'Help', 'helpcursor': 'Help', 'dndask': 'Help', # UpArrow 'uparrow': 'UpArrow', 'alternateselect': 'UpArrow', 'centerptr': 'UpArrow', 'altselect': 'UpArrow', 'altuparrow': 'UpArrow', # Pin 'mappin': 'Pin', 'locationselect': 'Pin', 'geopin': 'Pin', # Person 'personselect': 'Person', 'selectperson': 'Person', } _TOKEN_PAIR_MAP = { ('text', 'select'): 'IBeam', ('text', 'cursor'): 'IBeam', ('text', 'edit'): 'IBeam', ('normal', 'select'): 'Arrow', ('link', 'select'): 'Hand', ('help', 'select'): 'Help', ('precision', 'select'): 'Cross', ('alternate', 'select'): 'UpArrow', ('not', 'allowed'): 'No', ('size', 'all'): 'SizeAll', ('move', 'all'): 'SizeAll', ('no', 'drop'): 'No', ('left', 'right'): 'SizeWE', ('up', 'down'): 'SizeNS', ('up', 'arrow'): 'UpArrow', ('app', 'starting'): 'AppStarting', ('location', 'select'): 'Pin', ('person', 'select'): 'Person', ('hand', 'writing'): 'NWPen', } _NUMBERED_ORDER = [ 'Arrow', 'Help', 'AppStarting', 'Wait', 'Cross', 'IBeam', 'SizeNWSE', 'SizeNESW', 'SizeWE', 'SizeNS', 'SizeAll', 'No', 'Hand', 'UpArrow', 'NWPen', 'Pin', 'Person', ] def _filename_hint(filepath): """ Filename-based cursor type detection. Strategy: 1. Strip common prefixes (aero_, win_, cursor_, etc.) 2. Split into tokens (camelCase, underscore, dash, space, numbers) 3. Check token PAIRS first (highest confidence) 4. Check compound substring patterns (long, safe patterns) 5. Check individual tokens last (exact whole-word matches) """ stem = Path(filepath).stem.lower() # Strip common prefixes that don't convey cursor type info for prefix in ('aero_', 'win_', 'cursor_', 'cur_', 'theme_', 'set_'): if stem.startswith(prefix): rest = stem[len(prefix):] if rest: stem = rest # Split into tokens with camelCase and number boundary splitting tokens = [t for t in re.split(r'[-_\s.]+', stem) if t] expanded = [] for t in tokens: parts = re.sub(r'([a-z])([A-Z])', r'\1_\2', t).split('_') for p in parts: sub = re.split(r'(\d+)', p) expanded.extend(s.lower() for s in sub if s and not s.isdigit()) tokens = expanded tokens = [t for t in tokens if len(t) >= 2] token_set = set(tokens) # 1. Token pair matches (highest confidence) for (t1, t2), role in _TOKEN_PAIR_MAP.items(): if t1 in token_set and t2 in token_set: return role # 2. Compound substring matches clean = re.sub(r'[-_\s.]+', '', stem) for hint in sorted(_COMPOUND_MAP, key=len, reverse=True): if hint in clean: return _COMPOUND_MAP[hint] # 3. Exact whole-token matches for t in tokens: if t in _TOKEN_MAP: return _TOKEN_MAP[t] return '' def _detect_numbered_scheme(filepaths): """ If files are CLEARLY numbered as cursor indices (01.cur..17.cur), map by standard Windows order. STRICT rules to avoid false positives on database IDs (e.g. nat884.cur): - Only triggers on purely numeric filenames (like "01.cur", "13.cur") OR short prefix + low number (like "cursor01.cur") - Starting number must be 0 or 1 (cursor index, not database ID) - Numbers must be tightly sequential (no big gaps) """ numbered = [] for fp in filepaths: stem = Path(fp).stem # Only match PURELY numeric filenames like "01", "1", "13" m = re.match(r'^(\d{1,3})$', stem) if not m: # Or very short prefix + number: "cur01", "c1", but NOT "nat884" m = re.match(r'^[a-zA-Z]{0,3}(\d{1,2})$', stem) if m: numbered.append((int(m.group(1)), fp)) # Need most files to be numbered, and at least 3 if len(numbered) < len(filepaths) * 0.7 or len(numbered) < 3: return {} numbered.sort(key=lambda x: x[0]) first_num = numbered[0][0] last_num = numbered[-1][0] # Starting number must be low (0 or 1) — high numbers are database IDs if first_num > 1: return {} # Numbers must be tightly packed (no more than a few gaps) span = last_num - first_num + 1 if span > len(numbered) * 1.5: return {} result = {} for num, fp in numbered: idx = num - first_num if 0 <= idx < len(_NUMBERED_ORDER): result[fp] = _NUMBERED_ORDER[idx] return result def _detect_consecutive_id_scheme(filepaths): """ Detect cursor packs from sites like cursors-4u.com where files have database IDs as names (e.g. nat884.ani through nat899.ani). These are converted CursorFX packs where: - All files share the same alphabetic prefix - Numbers are perfectly consecutive - Total count is 12-17 (standard cursor set sizes) Returns dict { filepath: role } or empty dict. """ if len(filepaths) < 12 or len(filepaths) > 17: return {} parsed = [] for fp in filepaths: stem = Path(fp).stem m = re.match(r'^([a-zA-Z]+)(\d+)$', stem) if not m: return {} # ALL files must match prefix+number pattern parsed.append((m.group(1).lower(), int(m.group(2)), fp)) # All must share the SAME prefix prefixes = set(p for p, n, f in parsed) if len(prefixes) != 1: return {} # Sort by number parsed.sort(key=lambda x: x[1]) nums = [n for _, n, _ in parsed] # Must be perfectly consecutive (no gaps) for i in range(1, len(nums)): if nums[i] != nums[i-1] + 1: return {} # Starting number must be > 1 (these are database IDs, not indices) # If starting from 0 or 1, _detect_numbered_scheme handles it if nums[0] <= 1: return {} # Map by standard Windows cursor order result = {} for i, (prefix, num, fp) in enumerate(parsed): if i < len(_NUMBERED_ORDER): result[fp] = _NUMBERED_ORDER[i] return result # ============================================================================= # COMBINED SMART DETECTION # ============================================================================= _inf_mappings_cache = {} def smart_detect_cursor_type(filepath, with_source=False): """ Cursor type detection -- filename is PRIMARY, image is fallback. Priority: INF cache > filename > image > hotspot > animation. """ def _ret(role, src=''): return (role, src) if with_source else role if filepath in _inf_mappings_cache: return _ret(_inf_mappings_cache[filepath], 'scheme file') ext = Path(filepath).suffix.lower() is_ani = ext == '.ani' frames = 1 hotspot = None img_size = None try: with open(filepath, 'rb') as fh: raw = fh.read() if is_ani: frames, hotspot, img_size = _parse_ani_header(raw) elif ext in ('.cur', '.ico'): hotspot, img_size = _parse_cur_header(raw) except Exception: pass fn = _filename_hint(filepath) img = _image_shape_hint(filepath) hs_zone = '' if hotspot and img_size: hx, hy = hotspot w, h = img_size if w > 0 and h > 0: nhx, nhy = hx / w, hy / h if nhx < 0.07 and nhy < 0.07: hs_zone = 'arrow' elif 0.07 <= nhx <= 0.50 and nhy < 0.22: hs_zone = 'hand' elif 0.25 < nhx < 0.75 and 0.25 < nhy < 0.75: hs_zone = 'center' elif nhy < 0.22 and 0.22 <= nhx <= 0.78: hs_zone = 'top_ctr' elif nhx < 0.22 and 0.25 < nhy < 0.75: hs_zone = 'left_ctr' elif nhx > 0.78 and 0.25 < nhy < 0.75: hs_zone = 'right_ctr' # ── Decision: filename FIRST, then image, then hotspot ─────────────────── if fn: if fn == 'Wait' and img == 'Arrow': return _ret('AppStarting', 'filename + image refine') if fn == 'Arrow' and hs_zone == 'hand': return _ret('Hand', 'hotspot override') return _ret(fn, 'filename') if img: if img == 'Arrow' and hs_zone == 'hand': return _ret('Hand', 'image + hotspot') if img in ('Arrow', 'Hand') and hs_zone == 'center': if is_ani and frames > 2: return _ret('Wait', 'animated + centered') # Image + hotspot cross-validation: prefer image analysis # but adjust when hotspot strongly contradicts if img == 'SizeAll' and hs_zone == 'arrow': return _ret('Arrow', 'hotspot override on SizeAll') if img == 'No' and hs_zone == 'arrow': return _ret('Arrow', 'hotspot override on No') return _ret(img, 'image analysis') # ── Hotspot-only fallback ──────────────────────────────────────────── if hs_zone == 'arrow': if is_ani and frames > 2: return _ret('Arrow', 'hotspot + animated') return _ret('Arrow', 'hotspot') if hs_zone == 'hand': return _ret('Hand', 'hotspot') if hs_zone == 'center': if is_ani and frames > 2: return _ret('Wait', 'animated + centered') return _ret('Cross', 'hotspot center') if hs_zone == 'top_ctr': return _ret('SizeNS', 'hotspot') if hs_zone in ('left_ctr', 'right_ctr'): return _ret('SizeWE', 'hotspot') # ── Animation-based fallback (weakest signal) ──────────────────────── if is_ani and frames > 4: return _ret('Wait', 'animated fallback') return _ret('', '') def smart_detect_batch(filepaths, inf_mapping=None): """Detect cursor types for a batch. Returns dict { filepath: (role, source) }""" global _inf_mappings_cache result = {} if inf_mapping: for fp, role in inf_mapping.items(): if role in REGISTRY_ROLES: result[fp] = (role, 'scheme file') _inf_mappings_cache[fp] = role remaining = [fp for fp in filepaths if fp not in result] # Try standard numbered scheme (01.cur, 02.cur, ...) numbered = _detect_numbered_scheme(remaining) if numbered and len(numbered) >= len(remaining) * 0.6: for fp, role in numbered.items(): if fp not in result: result[fp] = (role, 'numbered order') remaining = [fp for fp in remaining if fp not in result] # Try consecutive-ID scheme (nat884.ani, nat885.cur, ... from cursors-4u.com) if remaining: consec = _detect_consecutive_id_scheme(remaining) if consec and len(consec) >= len(remaining) * 0.6: for fp, role in consec.items(): if fp not in result: result[fp] = (role, 'consecutive ID order') remaining = [fp for fp in remaining if fp not in result] for fp in remaining: ct, src = smart_detect_cursor_type(fp, with_source=True) if ct: result[fp] = (ct, src) role_files = {} for fp, (role, src) in result.items(): role_files.setdefault(role, []).append(fp) animated_roles = {'Wait', 'AppStarting'} for role, fps in role_files.items(): if len(fps) > 1: if role in animated_roles: ani_files = [f for f in fps if Path(f).suffix.lower() == '.ani'] best = ani_files[0] if ani_files else fps[0] else: cur_files = [f for f in fps if Path(f).suffix.lower() == '.cur'] best = cur_files[0] if cur_files else fps[0] for f in fps: if f != best: result.pop(f, None) return result # ============================================================================= # Archive extraction # ============================================================================= def extract_cursors_from_archive(archive_path, extract_dir): ext = Path(archive_path).suffix.lower() name = Path(archive_path).name extracted = [] scheme_files = [] err = '' inf_mapping = {} all_exts = CURSOR_EXTS | SCHEME_EXTS scheme_names = {'install.inf', 'scheme.ini', 'cursor.inf'} try: if ext == '.zip': with zipfile.ZipFile(archive_path, 'r') as zf: for member in zf.namelist(): mext = Path(member).suffix.lower() mname = Path(member).name.lower() if mext in all_exts or mname in scheme_names: zf.extract(member, extract_dir) src = Path(extract_dir) / member dst = Path(extract_dir) / src.name if src != dst and src.exists(): if dst.exists(): dst = Path(extract_dir) / (src.stem + '_' + src.parent.name + src.suffix) shutil.move(str(src), str(dst)) if mext in CURSOR_EXTS: extracted.append(str(dst)) else: scheme_files.append(str(dst)) elif ext in {'.tar','.gz','.bz2','.xz','.tgz','.tbz2'} or \ name.endswith(('.tar.gz','.tar.bz2','.tar.xz')): with tarfile.open(archive_path, 'r:*') as tf: for member in tf.getmembers(): if not member.isfile(): continue mext = Path(member.name).suffix.lower() mname = Path(member.name).name.lower() if mext in all_exts or mname in scheme_names: member.name = Path(member.name).name tf.extract(member, extract_dir) fp = str(Path(extract_dir) / member.name) if mext in CURSOR_EXTS: extracted.append(fp) else: scheme_files.append(fp) elif ext == '.7z': if not HAS_7Z: err = 'py7zr not installed' else: with py7zr.SevenZipFile(archive_path, mode='r') as zf: targets = [f for f in zf.getnames() if Path(f).suffix.lower() in all_exts or Path(f).name.lower() in scheme_names] if targets: zf.extract(path=extract_dir, targets=targets) for t in targets: src = Path(extract_dir) / t dst = Path(extract_dir) / src.name if src != dst and src.exists(): shutil.move(str(src), str(dst)) text = Path(t).suffix.lower() if text in CURSOR_EXTS: extracted.append(str(dst)) else: scheme_files.append(str(dst)) elif ext == '.rar': if not HAS_RAR: err = 'rarfile not installed' else: try: with rarfile.RarFile(archive_path) as rf: for info in rf.infolist(): if info.is_dir(): continue mext = Path(info.filename).suffix.lower() mname = Path(info.filename).name.lower() if mext in all_exts or mname in scheme_names: rf.extract(info, extract_dir) src = Path(extract_dir) / info.filename dst = Path(extract_dir) / src.name if src != dst and src.exists(): shutil.move(str(src), str(dst)) if mext in CURSOR_EXTS: extracted.append(str(dst)) else: scheme_files.append(str(dst)) except Exception as re_err: err = 'RAR error: ' + str(re_err) else: err = 'Unsupported format: ' + ext except Exception as exc: err = str(exc) for sf in scheme_files: try: m = _parse_inf_file(sf, extract_dir) inf_mapping.update(m) except Exception: pass return extracted, inf_mapping, err # ============================================================================= # Threads # ============================================================================= class InstallerThread(QThread): progress = pyqtSignal(int, int, str) finished = pyqtSignal(bool, str) def __init__(self, cursor_map): super().__init__() self.cursor_map = cursor_map def run(self): try: import winreg INSTALL_DIR.mkdir(parents=True, exist_ok=True) installed = {} items = list(self.cursor_map.items()) for i, (key, src) in enumerate(items): self.progress.emit(i, len(items), 'Copying ' + Path(src).name) dest = INSTALL_DIR / Path(src).name if dest.exists() and str(dest) != src: dest = INSTALL_DIR / (Path(src).stem + '_' + key + Path(src).suffix) shutil.copy2(src, dest) installed[key] = str(dest) self.progress.emit(len(items), len(items), 'Updating registry...') with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_SET_VALUE) as reg: winreg.SetValueEx(reg, '', 0, winreg.REG_SZ, 'JGR') for key, dest in installed.items(): winreg.SetValueEx(reg, key, 0, winreg.REG_EXPAND_SZ, dest) ctypes.windll.user32.SystemParametersInfoW( SPI_SETCURSORS, 0, None, SPIF_UPDATEINIFILE | SPIF_SENDCHANGE) self.finished.emit(True, 'Installed ' + str(len(installed)) + ' cursor(s)!') except Exception as exc: self.finished.emit(False, str(exc)) class RevertThread(QThread): finished = pyqtSignal(bool, str) def __init__(self, restore_map): super().__init__() self.restore_map = restore_map def run(self): try: import winreg with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_ALL_ACCESS) as reg: winreg.SetValueEx(reg, '', 0, winreg.REG_SZ, '') all_roles = {k for k, _ in CURSOR_ROLES} for key in all_roles: try: winreg.SetValueEx(reg, key, 0, winreg.REG_EXPAND_SZ, '') except Exception: pass try: idx = 0 to_delete = [] while True: try: vname, vdata, vtype = winreg.EnumValue(reg, idx) if vname not in _KNOWN_CURSOR_KEYS and vname not in all_roles: to_delete.append(vname) idx += 1 except OSError: break for vname in to_delete: try: winreg.DeleteValue(reg, vname) except Exception: pass except Exception: pass ctypes.windll.user32.SystemParametersInfoW( SPI_SETCURSORS, 0, None, SPIF_UPDATEINIFILE | SPIF_SENDCHANGE) self.finished.emit(True, 'Default cursors restored!') except Exception as exc: self.finished.emit(False, str(exc)) class AutoAssignThread(QThread): result = pyqtSignal(str, str, str) done = pyqtSignal(int) def __init__(self, file_items, inf_mapping=None): super().__init__() self.file_items = file_items self.inf_mapping = inf_mapping or {} def run(self): filepaths = list(self.file_items.keys()) batch_result = smart_detect_batch(filepaths, self.inf_mapping) count = 0 for fp in filepaths: entry = batch_result.get(fp) if entry: ct, src = entry if ct: count += 1 self.result.emit(fp, ct, src) else: self.result.emit(fp, '', '') self.done.emit(count) # ============================================================================= # AUTO-UPDATE SYSTEM # ============================================================================= def _compare_versions(a, b): """Compare version strings like '2.1.0' > '2.0.0'. Returns 1, 0, or -1.""" def _parts(v): return [int(x) for x in re.sub(r'[^0-9.]', '', v).split('.') if x] pa, pb = _parts(a), _parts(b) for x, y in zip(pa, pb): if x > y: return 1 if x < y: return -1 return len(pa) - len(pb) class UpdateChecker(QThread): """Background thread that checks for a newer version of the app.""" update_available = pyqtSignal(str, str, str) # new_version, download_url, changelog def run(self): if not UPDATE_CHECK_URL: return try: import urllib.request, ssl ctx = ssl.create_default_context() req = urllib.request.Request(UPDATE_CHECK_URL, headers={ 'User-Agent': f'{APP_NAME}/{APP_VERSION}', 'Accept': 'application/json', }) with urllib.request.urlopen(req, timeout=10, context=ctx) as resp: data = json.loads(resp.read().decode()) # Support both GitHub Releases API and custom JSON formats if 'tag_name' in data: # GitHub Releases format new_ver = data['tag_name'].lstrip('vV') changelog = data.get('body', '') dl_url = '' for asset in data.get('assets', []): name = asset.get('name', '').lower() if name.endswith('.exe'): dl_url = asset['browser_download_url'] break if not dl_url: dl_url = data.get('html_url', '') else: # Custom JSON: {"version":"X.Y.Z","download_url":"...","changelog":"..."} new_ver = data.get('version', '0.0.0') dl_url = data.get('download_url', '') changelog = data.get('changelog', '') if _compare_versions(new_ver, APP_VERSION) > 0: self.update_available.emit(new_ver, dl_url, changelog) except Exception: pass # Silently fail — update check should never break the app class UpdateDownloader(QThread): """Downloads the new .exe in the background.""" progress = pyqtSignal(int) # percent 0-100 finished = pyqtSignal(str) # path to downloaded file ('' on failure) error = pyqtSignal(str) # error message def __init__(self, download_url): super().__init__() self.download_url = download_url def run(self): try: import urllib.request, ssl ctx = ssl.create_default_context() req = urllib.request.Request(self.download_url, headers={ 'User-Agent': f'{APP_NAME}/{APP_VERSION}'}) tmp_path = os.path.join(tempfile.gettempdir(), f'jgr_update_{os.getpid()}.exe') with urllib.request.urlopen(req, timeout=120, context=ctx) as resp: total = int(resp.headers.get('Content-Length', 0)) downloaded = 0 with open(tmp_path, 'wb') as f: while True: chunk = resp.read(65536) if not chunk: break f.write(chunk) downloaded += len(chunk) if total > 0: self.progress.emit(int(downloaded / total * 100)) self.finished.emit(tmp_path) except Exception as exc: self.error.emit(str(exc)) self.finished.emit('') def _apply_update_and_restart(new_exe_path): """Launch a batch script that waits for us to exit, swaps the exe, and relaunches.""" current_exe = sys.executable # If running as a script (not frozen exe), can't self-update if not getattr(sys, 'frozen', False): webbrowser.open(os.path.dirname(new_exe_path)) return bat_path = os.path.join(tempfile.gettempdir(), f'jgr_update_{os.getpid()}.bat') bat_content = f'''@echo off :: Wait for the app to fully close timeout /t 2 /nobreak >nul :: Try to replace the exe (retry a few times in case file is still locked) set RETRIES=10 :retry copy /y "{new_exe_path}" "{current_exe}" >nul 2>&1 if errorlevel 1 ( set /a RETRIES-=1 if %RETRIES% gtr 0 ( timeout /t 1 /nobreak >nul goto retry ) echo Update failed - file may be locked. pause goto cleanup ) :: Relaunch the updated app start "" "{current_exe}" :cleanup :: Clean up temp files del /q "{new_exe_path}" >nul 2>&1 del /q "%~f0" >nul 2>&1 ''' with open(bat_path, 'w') as f: f.write(bat_content) # Launch the batch script hidden (no visible cmd window) subprocess.Popen( ['cmd', '/c', bat_path], creationflags=0x08000000, # CREATE_NO_WINDOW close_fds=True ) class UpdateBanner(QWidget): """A sleek notification banner that auto-downloads and installs updates.""" def __init__(self, parent, new_version, download_url, changelog=''): super().__init__(parent) self.download_url = download_url self.new_version = new_version self._downloaded = '' self.setFixedHeight(0) self._target_h = 54 self.setStyleSheet('background:transparent;') lay = QHBoxLayout(self) lay.setContentsMargins(14, 6, 14, 6) lay.setSpacing(10) # "NEW" badge self._badge = QLabel('NEW') self._badge.setFont(QFont('Segoe UI', 7, QFont.Bold)) self._badge.setFixedSize(36, 18) self._badge.setAlignment(Qt.AlignCenter) self._badge.setStyleSheet( 'color:#000;background:#00e070;border-radius:4px;letter-spacing:1px;') # Message self._msg = QLabel(f'Version {new_version} is available!') self._msg.setFont(QFont('Segoe UI', 9)) self._msg.setStyleSheet('color:rgba(255,255,255,180);background:transparent;') # Progress bar (hidden until downloading) self._prog = QProgressBar() self._prog.setFixedSize(80, 10) self._prog.setRange(0, 100); self._prog.setValue(0) self._prog.setTextVisible(False) self._prog.setStyleSheet( 'QProgressBar{background:rgba(255,255,255,12);border:none;border-radius:4px;}' 'QProgressBar::chunk{background:#00e070;border-radius:4px;}') self._prog.hide() # Update button self._upd_btn = QPushButton('Update') self._upd_btn.setFont(QFont('Segoe UI', 8, QFont.Bold)) self._upd_btn.setFixedSize(70, 26) self._upd_btn.setStyleSheet( 'QPushButton{color:#000;background:#fff;border:none;border-radius:6px;}' 'QPushButton:hover{background:#e0e0e0;}' 'QPushButton:disabled{color:rgba(80,80,80,120);background:rgba(255,255,255,20);}') self._upd_btn.clicked.connect(self._on_update) # Dismiss dismiss = QPushButton('x') dismiss.setFont(QFont('Segoe UI', 9)) dismiss.setFixedSize(22, 22) dismiss.setStyleSheet( 'QPushButton{color:rgba(255,255,255,60);background:transparent;border:none;}' 'QPushButton:hover{color:rgba(255,255,255,180);}') dismiss.clicked.connect(self._dismiss) lay.addWidget(self._badge) lay.addWidget(self._msg) lay.addStretch() lay.addWidget(self._prog) lay.addWidget(self._upd_btn) lay.addWidget(dismiss) # Slide-in animation self._anim = QPropertyAnimation(self, b'maximumHeight') self._anim.setDuration(350) self._anim.setEasingCurve(QEasingCurve.OutCubic) QTimer.singleShot(200, self._slide_in) def _slide_in(self): self._anim.setStartValue(0) self._anim.setEndValue(self._target_h) self._anim.start() def _dismiss(self): self._anim.setStartValue(self.height()) self._anim.setEndValue(0) self._anim.finished.connect(lambda: self.deleteLater()) self._anim.start() def _on_update(self): if not self.download_url: return if self._downloaded: # Already downloaded — apply and restart self._msg.setText('Restarting...') self._upd_btn.setEnabled(False) QApplication.processEvents() _apply_update_and_restart(self._downloaded) QApplication.quit() return # Start downloading self._upd_btn.setEnabled(False) self._upd_btn.setText('...') self._msg.setText(f'Downloading v{self.new_version}...') self._prog.show() self._prog.setValue(0) self._dl_thread = UpdateDownloader(self.download_url) self._dl_thread.progress.connect(self._on_dl_progress) self._dl_thread.error.connect(self._on_dl_error) self._dl_thread.finished.connect(self._on_dl_finished) self._dl_thread.start() def _on_dl_progress(self, pct): self._prog.setValue(pct) def _on_dl_error(self, err_msg): self._msg.setText('Download failed — click to retry') self._msg.setStyleSheet('color:rgba(255,100,100,200);background:transparent;') self._upd_btn.setEnabled(True) self._upd_btn.setText('Retry') self._prog.hide() def _on_dl_finished(self, path): if not path: return # error handler already ran self._downloaded = path self._prog.setValue(100) self._prog.hide() self._badge.setText('OK') self._badge.setStyleSheet( 'color:#000;background:#00e070;border-radius:4px;letter-spacing:1px;') self._msg.setText(f'v{self.new_version} ready — click to install & restart') self._msg.setStyleSheet('color:rgba(80,230,130,220);background:transparent;') self._upd_btn.setEnabled(True) self._upd_btn.setText('Install') self._upd_btn.setStyleSheet( 'QPushButton{color:#000;background:#00e070;border:none;border-radius:6px;}' 'QPushButton:hover{background:#00cc60;}') def paintEvent(self, event): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) p.setPen(QPen(QColor(255, 255, 255, 15))) p.setBrush(QBrush(QColor(30, 200, 100, 18))) p.drawRoundedRect(4, 2, self.width() - 8, self.height() - 4, 10, 10) p.end() # ============================================================================= # AI CURSOR CREATOR — Generate full cursor packs from a text description # ============================================================================= # Hotspot positions for each role (x, y on a 32x32 grid) _ROLE_HOTSPOTS = { 'Arrow': (0, 0), 'Help': (0, 0), 'AppStarting': (0, 0), 'Wait': (16, 16), 'Cross': (16, 16), 'IBeam': (16, 16), 'SizeNWSE': (16, 16), 'SizeNESW': (16, 16), 'SizeWE': (16, 16), 'SizeNS': (16, 16), 'SizeAll': (16, 16), 'No': (16, 16), 'Hand': (8, 0), 'UpArrow': (16, 0), 'NWPen': (0, 30), 'Pin': (8, 30), 'Person': (0, 0), } # How to describe each cursor for the AI prompt _ROLE_PROMPTS = { 'Arrow': 'a standard mouse pointer arrow, pointing upper-left', 'Help': 'a mouse pointer arrow with a small question mark next to it', 'AppStarting': 'a mouse pointer arrow with a small spinning loading icon', 'Wait': 'a spinning loading/busy indicator (hourglass or spinner circle)', 'Cross': 'a precise crosshair/plus-sign target reticle', 'IBeam': 'a text cursor I-beam (thin vertical line with serifs top and bottom)', 'SizeNWSE': 'a double-headed diagonal arrow going from top-left to bottom-right', 'SizeNESW': 'a double-headed diagonal arrow going from top-right to bottom-left', 'SizeWE': 'a double-headed horizontal arrow pointing left and right', 'SizeNS': 'a double-headed vertical arrow pointing up and down', 'SizeAll': 'a four-way arrow cross pointing in all 4 directions (move cursor)', 'No': 'a circle with a diagonal line through it (forbidden/not-allowed symbol)', 'Hand': 'a pointing hand cursor with index finger pointing up (link select)', 'UpArrow': 'an arrow pointing straight up', 'NWPen': 'a pen or pencil cursor tilted for handwriting', 'Pin': 'a map pin/location marker icon', 'Person': 'a small person/user silhouette icon', } # Roles to generate (the main 13 most packs use, plus extras) _GEN_ROLES_MAIN = ['Arrow', 'Help', 'AppStarting', 'Wait', 'Cross', 'IBeam', 'SizeNWSE', 'SizeNESW', 'SizeWE', 'SizeNS', 'SizeAll', 'No', 'Hand'] _GEN_ROLES_EXTRA = ['UpArrow', 'NWPen', 'Pin', 'Person'] def _build_cursor_prompt(theme_desc, role): """Build a Gemini prompt for generating a single cursor image.""" shape_desc = _ROLE_PROMPTS.get(role, 'a mouse cursor icon') return ( f"Generate a single 32x32 pixel cursor icon: {shape_desc}. " f"Style/theme: {theme_desc}. " f"Requirements: transparent background (PNG with alpha), " f"clean pixel art at exactly 32x32 pixels, " f"the icon should be clearly visible and recognizable as a cursor, " f"use sharp edges suitable for a small icon. " f"Output ONLY the image, no text." ) def _write_cur_file(img, hotspot, out_path): """Write a PIL Image as a Windows .cur file.""" import io img = img.convert('RGBA').resize((32, 32), Image.LANCZOS if hasattr(Image, 'LANCZOS') else Image.ANTIALIAS) W, H = img.size hx, hy = hotspot # Build DIB (device-independent bitmap) for the cursor pix = img.load() xor_data = bytearray() for y in range(H - 1, -1, -1): # bottom-up for x in range(W): r, g, b, a = pix[x, y] xor_data.extend([b, g, r, a]) # AND mask (1-bit, bottom-up, padded to 4-byte boundary) and_row_bytes = ((W + 31) // 32) * 4 and_data = bytearray() for y in range(H - 1, -1, -1): row = bytearray(and_row_bytes) for x in range(W): a = pix[x, y][3] if a < 128: row[x // 8] |= (0x80 >> (x % 8)) and_data.extend(row) # BITMAPINFOHEADER (40 bytes) bih = struct.pack('