diff --git a/updater.py b/updater.py index 050e783..8acc784 100644 --- a/updater.py +++ b/updater.py @@ -494,9 +494,211 @@ def _remove_readonly(func: Callable[[str], Any], path: str, exc: Any) -> None: func(path) +def _smart_selective_update(extract_path: str, target_dir: str) -> tuple[int, int]: + """ + Performs selective file replacement by comparing modification times. + + Issue #113: Smart update that only replaces changed files + + Args: + extract_path: Path to extracted new version files + target_dir: Target application directory to update + + Returns: + Tuple of (files_updated, files_skipped) + + Notes: + - Skips plugins/ directory entirely to preserve user plugins + - Only copies files that are newer in extract_path than target_dir + - Creates missing directories as needed + - Uses atomic file operations for safety + """ + import stat + + files_updated = 0 + files_skipped = 0 + + # Directories to skip (preserve user data) + skip_dirs = {'plugins', '__pycache__', '.git', 'node_modules'} + + def should_skip_dir(dirname): + return dirname in skip_dirs or dirname.startswith('.') + + def should_copy_file(src_path, dst_path): + """Return True if file should be copied (newer or doesn't exist)""" + if not os.path.exists(dst_path): + return True + src_mtime = os.path.getmtime(src_path) + dst_mtime = os.path.getmtime(dst_path) + # Copy if source is newer by at least 1 second (avoid rounding issues) + return src_mtime > dst_mtime + 1 + + # Walk through extracted files + for root, dirs, files in os.walk(extract_path): + # Filter out directories to skip + dirs[:] = [d for d in dirs if not should_skip_dir(d)] + + for filename in files: + src_path = os.path.join(root, filename) + + # Calculate relative path and destination + rel_path = os.path.relpath(src_path, extract_path) + dst_path = os.path.join(target_dir, rel_path) + + # Create destination directory if needed + dst_dir = os.path.dirname(dst_path) + os.makedirs(dst_dir, exist_ok=True) + + # Check if file needs updating + if should_copy_file(src_path, dst_path): + try: + # Atomic copy with metadata preservation + shutil.copy2(src_path, dst_path + '.tmp') + os.rename(dst_path + '.tmp', dst_path) # Atomic rename + + # Preserve permissions + src_mode = os.stat(src_path).st_mode & stat.S_IWRITE + if src_mode: + try: + os.chmod(dst_path, os.stat(src_path).st_mode) + except Exception as e: + print(f"Warning: Could not set permissions on {dst_path}: {e}") + + files_updated += 1 + except Exception as e: + print(f"Failed to copy {rel_path}: {e}") + else: + files_skipped += 1 + + return files_updated, files_skipped + + +def _backup_plugins(platform_name: str, app_name: str, source_dir: str, dest_dir: Optional[str] = None) -> bool: + """ + Backs up plugins from source directory to a temporary location. + + Issue #113: Preserve plugins during updates (FALLBACK METHOD - rarely used now) + + Returns True if backup was successful, False otherwise. + """ + plugins_src = os.path.join(source_dir, "plugins") + + if not os.path.exists(plugins_src): + print("No plugins directory to backup.") + return False + + # Create a temporary backup location (outside app dir so it won't be wiped) + import tempfile + backup_temp = tempfile.mkdtemp(prefix=f"{app_name}_plugins_backup_") + plugins_dest = os.path.join(backup_temp, "plugins") + + try: + shutil.copytree(plugins_src, plugins_dest) + plugin_count = sum(1 for f in os.listdir(plugins_dest)) + print(f"Backed up {plugin_count} plugin(s) to: {plugins_dest}") + + # Store backup path in a marker file for restoration + marker_path = os.path.join(backup_temp, ".plugins_backup_marker") + with open(marker_path, "w") as f: + f.write(f"plugins_dir={plugins_dest}\n") + f.write(f"target_dir={source_dir}\n") + + return True + except Exception as e: + print(f"Failed to backup plugins: {e}") + # Cleanup failed backup attempt + try: + shutil.rmtree(backup_temp, onexc=_remove_readonly) + except Exception: + pass + return False + + +def _restore_plugins(platform_name: str, app_name: str, source_dir: str) -> bool: + """ + Restores plugins from temporary backup to the application directory. + + Issue #113: Preserve plugins during updates + + Returns True if restoration was successful, False otherwise. + """ + import tempfile + + # Find all plugin backup temp directories + temp_dir = tempfile.gettempdir() + backups_found = [] + + try: + for entry in os.listdir(temp_dir): + if entry.startswith(f"{app_name}_plugins_backup_"): + backup_path = os.path.join(temp_dir, entry) + marker_path = os.path.join(backup_path, ".plugins_backup_marker") + if os.path.exists(marker_path): + backups_found.append((backup_path, entry)) + except Exception as e: + print(f"Error searching for plugin backups: {e}") + + if not backups_found: + print("No plugin backups found to restore.") + return False + + # Restore from all found backups (in case multiple exist) + plugins_restored = 0 + + for backup_path, entry_name in sorted(backups_found): + try: + marker_path = os.path.join(backup_path, ".plugins_backup_marker") + + # Parse marker file to get original paths + backup_info = {} + with open(marker_path, "r") as f: + for line in f: + if "=" in line: + key, value = line.strip().split("=", 1) + backup_info[key] = value + + plugins_src = backup_info.get("plugins_dir", os.path.join(backup_path, "plugins")) + + # Create plugins directory if it doesn't exist + target_plugins_dir = os.path.join(source_dir, "plugins") + os.makedirs(target_plugins_dir, exist_ok=True) + + # Copy each plugin to target + if os.path.exists(plugins_src): + for item in os.listdir(plugins_src): + src_item = os.path.join(plugins_src, item) + dst_item = os.path.join(target_plugins_dir, item) + + if os.path.isdir(src_item): + if os.path.exists(dst_item): + shutil.rmtree(dst_item, onexc=_remove_readonly) + shutil.copytree(src_item, dst_item) + else: + shutil.copy2(src_item, dst_item) + plugins_restored += 1 + + # Cleanup backup after successful restore + try: + shutil.rmtree(backup_path, onexc=_remove_readonly) + print(f"Cleaned up backup directory: {entry_name}") + except Exception as e: + print(f"Warning: Could not cleanup backup: {e}") + + except Exception as e: + print(f"Failed to restore plugins from {entry_name}: {e}") + + if plugins_restored > 0: + print(f"Restored {plugins_restored} plugin(s) to: {os.path.join(source_dir, 'plugins')}") + return True + + return False + + def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, finish_update: bool) -> None: """ Completes a pending application update if '--finish-update' is present in the command-line arguments. + + Issue #113: Added smart selective update (only replaces changed files). """ if not finish_update: @@ -541,6 +743,12 @@ def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, fi primary_update_folder = update_folders[0] target_updater_folder = os.path.join(primary_update_folder, f"{app_name}-darwin") if platform_name == 'darwin' else primary_update_folder + + # 2a. Perform SMART SELECTIVE UPDATE (Issue #113) + target_base = get_app_dir() + print(f"Performing selective update from: {primary_update_folder}") + files_updated, files_skipped = _smart_selective_update(primary_update_folder, target_base) + print(f"Selective update complete: {files_updated} files updated, {files_skipped} files skipped") # 3. Wait for updater process to exit print(f"Waiting for {app_name}_updater to exit...") @@ -611,6 +819,8 @@ def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, fi else: print(f"Failed to delete update folder '{folder}' after 3 attempts: {e}") + # Note: _restore_plugins() no longer needed - selective update preserves plugins automatically + if "--finish-update" in sys.argv: sys.argv.remove("--finish-update")