|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Keep only the current Particle release; never touch models or workspace data.""" |
| 3 | +from pathlib import Path |
| 4 | +import argparse,json,plistlib,re,shutil,subprocess |
| 5 | + |
| 6 | +BUNDLE_ID='app.particleone.desktop' |
| 7 | +ARCHIVE=re.compile(r'^(?:[0-9a-f]{64}--)?Particle(?:One)?-([0-9]+(?:\.[0-9]+)*)(?:-([0-9]+))?-arm64\.zip$') |
| 8 | + |
| 9 | +def number(value): |
| 10 | + if not isinstance(value,str) or not re.fullmatch(r'[0-9]+(?:\.[0-9]+)*',value):raise ValueError('Invalid release number') |
| 11 | + parts=list(map(int,value.split('.'))) |
| 12 | + while len(parts)>1 and parts[-1]==0:parts.pop() |
| 13 | + return tuple(parts) |
| 14 | + |
| 15 | +def older(version,build,current_version,current_build): |
| 16 | + a,b=number(version),number(current_version) |
| 17 | + return a<b or (a==b and build is not None and number(build)<number(current_build)) |
| 18 | + |
| 19 | +def prune_archives(folders,version,build): |
| 20 | + number(version);number(build);removed=[] |
| 21 | + for folder in folders: |
| 22 | + if not folder.is_dir() or folder.is_symlink():continue |
| 23 | + for path in folder.iterdir(): |
| 24 | + match=ARCHIVE.fullmatch(path.name) |
| 25 | + if path.is_symlink() or not path.is_file() or not match:continue |
| 26 | + if older(match[1],match[2],version,build):path.unlink();removed.append(str(path)) |
| 27 | + return removed |
| 28 | + |
| 29 | +def archive_folders(root,home): |
| 30 | + return [root/'dist',root/'dist/update-staging',home/'Library/Application Support/ParticleOneUpdates/Releases',home/'Library/Caches/Homebrew/downloads'] |
| 31 | + |
| 32 | +def cleanup(root,version,build,home=None): |
| 33 | + home=home or Path.home();app=home/'Applications/Particle.app' |
| 34 | + current=plistlib.loads((app/'Contents/Info.plist').read_bytes()) |
| 35 | + if current.get('CFBundleIdentifier')!=BUNDLE_ID or current.get('CFBundleShortVersionString')!=version or current.get('CFBundleVersion')!=build:raise RuntimeError('Install the current release before pruning application copies.') |
| 36 | + subprocess.run(['/usr/bin/codesign','--verify','--deep','--strict',str(app)],check=True) |
| 37 | + processes=subprocess.check_output(['/bin/ps','-axww','-o','comm='],text=True).splitlines() |
| 38 | + removed=prune_archives(archive_folders(root,home),version,build);active=[] |
| 39 | + folders=[root/'dist',root/'dist/update-staging',home/'Applications',Path('/Applications'),home/'.Trash'] |
| 40 | + cache=home/'Library/Caches/Sparkle_generate_appcast' |
| 41 | + candidates=[] |
| 42 | + for folder in folders: |
| 43 | + if folder.is_symlink():continue |
| 44 | + try:candidates.extend(folder.glob('*Particle*.app')) |
| 45 | + except OSError:continue |
| 46 | + if cache.is_dir() and not cache.is_symlink():candidates.extend(cache.glob('*/*Particle*.app')) |
| 47 | + for path in candidates: |
| 48 | + if path.is_symlink() or not path.is_dir() or path==app:continue |
| 49 | + try:info=plistlib.loads((path/'Contents/Info.plist').read_bytes()) |
| 50 | + except (OSError,ValueError):continue |
| 51 | + if info.get('CFBundleIdentifier')!=BUNDLE_ID:continue |
| 52 | + try:is_old=older(info.get('CFBundleShortVersionString'),info.get('CFBundleVersion'),version,build) |
| 53 | + except ValueError:continue |
| 54 | + if not is_old:continue |
| 55 | + if any(str(path)+'/Contents/' in line for line in processes):active.append(str(path));continue |
| 56 | + shutil.rmtree(path);removed.append(str(path)) |
| 57 | + return {'current_version':version,'current_build':build,'removed':removed,'active_older_copies':active} |
| 58 | + |
| 59 | +def main(): |
| 60 | + parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--root',type=Path,default=Path(__file__).resolve().parents[2]);args=parser.parse_args() |
| 61 | + receipt=json.loads((args.root/'dist/particleone-release.json').read_text()) |
| 62 | + report=cleanup(args.root,receipt['version'],receipt['build']);print(json.dumps(report,indent=2)) |
| 63 | + output=args.root/('docs/particleone/validation-'+receipt['version']);output.mkdir(parents=True,exist_ok=True) |
| 64 | + (output/'older-release-cleanup.json').write_text(json.dumps(report,indent=2)+'\n') |
| 65 | + if report['active_older_copies']:raise SystemExit('Close the listed older Particle copies normally, then rerun cleanup.') |
| 66 | +if __name__=='__main__':main() |
0 commit comments