diff --git a/updater.py b/updater.py index 6910f1d..56568fb 100644 --- a/updater.py +++ b/updater.py @@ -494,11 +494,90 @@ 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 + Issue #113: Preserve plugins during updates (FALLBACK METHOD - rarely used now) Returns True if backup was successful, False otherwise. """ @@ -619,7 +698,7 @@ def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, fi """ Completes a pending application update if '--finish-update' is present in the command-line arguments. - Issue #113: Added plugin preservation during updates. + Issue #113: Added smart selective update (only replaces changed files). """ if not finish_update: @@ -669,6 +748,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...") @@ -739,9 +824,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}") - # 6. Restore plugins after update (Issue #113) - _restore_plugins(platform_name, app_name, target_base) - + # Note: _restore_plugins() no longer needed - selective update preserves plugins automatically + if "--finish-update" in sys.argv: sys.argv.remove("--finish-update")