#!/usr/bin/env python3 """DPL-only theme download settings migration; never edits XB core or other sites.""" import argparse,datetime,hashlib,html,json,os,re,shutil,subprocess,sys,tempfile,urllib.request from pathlib import Path BASE='https://downloads.gemcook.net/downloads/software/' PAGE='/theme/Stellar/resource-downloads.html' FILES={ 'windows':('clash-verge-rev','clash-verge-rev-windows-x64.exe'), 'macos_arm64':('clash-verge-rev','clash-verge-rev-macos-arm64.dmg'), 'macos_x64':('clash-verge-rev','clash-verge-rev-macos-x64.dmg'), 'android':('flclash','flclash-android-arm64.apk'), } CHOICES=[('windows','Windows ARM64','clash-verge-rev','clash-verge-rev-windows-arm64.exe'),('macos','macOS Apple 芯片','clash-verge-rev','clash-verge-rev-macos-arm64.dmg'),('macos','macOS Intel 芯片','clash-verge-rev','clash-verge-rev-macos-x64.dmg'),('android','Android ARM64(多数手机)','flclash','flclash-android-arm64.apk'),('android','Android ARMv7(旧款手机)','flclash','flclash-android-armv7.apk')]+[('linux',f'Linux {arch.upper()} · {ext.upper()}','clash-verge-rev',f'clash-verge-rev-linux-{arch}.{ext}') for arch in ['x64','arm64'] for ext in ['deb','rpm']] def url(software,name):return BASE+software+'/'+name def digest(b):return hashlib.sha256(b).hexdigest() def prepare(root): env=root/'storage/theme/Stellar/env.js';s=env.read_text() pat=r'([\"\']?client_downloads[\"\']?\s*:\s*\{)(.*?)(\})' match=re.search(pat,s,re.S);assert match,'Missing client_downloads' block=match[2];before=block settings={k:url(*v) for k,v in FILES.items()};settings.update(macos=PAGE+'#macos',linux=PAGE+'#linux') for key,value in settings.items(): block,n=re.subn(r'([\"\']?'+key+r'[\"\']?\s*:\s*)([\"\'])([^\"\']*)\2',lambda m:m[1]+m[2]+value+m[2],block) assert n==1,'Missing or duplicated download field: '+key # Verify unrelated download options and every other setting are byte-identical. assert all(re.search(r'[\"\']?'+k+r'[\"\']?\s*:\s*[\"\']([^\"\']*)',before)[1]==re.search(r'[\"\']?'+k+r'[\"\']?\s*:\s*[\"\']([^\"\']*)',block)[1] for k in ['ios','router']) replacement=s[:match.start(2)]+block+s[match.end(2):] page='''
按设备架构选择安装包。下载链接固定,软件更新后无需更换地址。
''' for group,label in [('windows','Windows'),('macos','macOS'),('android','Android'),('linux','Linux')]: page+=f'Ubuntu / Debian / Deepin 选择 DEB,Fedora / RHEL 系选择 RPM。运行 uname -m:x86_64 选择 X64,aarch64 选择 ARM64。Clash Verge Rev 需要图形桌面。
armv7l 选 armhf 或 armhfp',';当前 Clash Verge Rev 安装包仅提供 x64 与 ARM64'),('2.5.7、x86_64','x64'),('Clash.Verge_2.5.7_amd64.deb','clash-verge-rev-linux-x64.deb'),('Clash.Verge-2.5.7-1.x86_64.rpm','clash-verge-rev-linux-x64.rpm')]
}
for suffix,pairs in edits.items():
p=root/'resources/views/tutorials'/f'{suffix}.blade.php';old=p.read_text();new=old
for a,b in pairs:
assert a in new or b in new,'Tutorial content changed; review required: '+suffix
new=new.replace(a,b)
changes[p]=new.encode()
return {p:b for p,b in changes.items() if not p.exists() or p.read_bytes()!=b}
def verify_resources():
base=Path('/www/wwwroot/resource-software/app/storage/app/private/software/current');pairs=set(FILES.values())|{(s,n) for _,_,s,n in CHOICES}
for sw,name in sorted(pairs):
cat=json.loads((base/sw/'catalog.json').read_text());asset=next(a for a in cat['assets'] if a['filename']==name)
p=base/sw/name;assert p.is_file() and p.stat().st_size==asset['size']
h=hashlib.sha256()
with p.open('rb') as f:
for chunk in iter(lambda:f.read(1024*1024),b''):h.update(chunk)
assert h.hexdigest()==asset['sha256']
req=urllib.request.Request(url(sw,name),headers={'Range':'bytes=0-31','User-Agent':'DPL-Resource-Acceptance/1.0'})
with urllib.request.urlopen(req,timeout=30) as response:
assert response.status==206 and response.geturl()==url(sw,name)
with p.open('rb') as source:assert response.read()==source.read(32)
print('verified',sw,name,cat['version'])
def apply(root,backup):
changes=prepare(root)
if not changes:print('No changes required');return
snapshots={p:p.read_bytes() if p.exists() else None for p in changes}
if root==Path('/www/wwwroot/dp-link.net'):verify_resources()
for p,b in snapshots.items():
assert (p.read_bytes() if p.exists() else None)==b,'Concurrent file change: '+str(p)
backup.mkdir(parents=True,exist_ok=False);os.chmod(backup,0o700);manifest=[]
for i,(p,b) in enumerate(changes.items()):
old=snapshots[p];entry={'path':str(p),'before':digest(old) if old is not None else None,'after':digest(b),'backup':str(i)+'.bak' if old is not None else None}
if old is not None:(backup/entry['backup']).write_bytes(old)
manifest.append(entry)
(backup/'manifest.json').write_text(json.dumps(manifest,indent=2))
try:
for p,b in changes.items():
assert (p.read_bytes() if p.exists() else None)==snapshots[p],'Concurrent file change'
stat=p.stat() if p.exists() else p.parent.stat();fd,tmp=tempfile.mkstemp(dir=p.parent,prefix='.resource-download-')
with os.fdopen(fd,'wb') as f:f.write(b)
os.chmod(tmp,stat.st_mode&0o777 if p.exists() else 0o644)
if os.geteuid()==0:os.chown(tmp,stat.st_uid,stat.st_gid)
os.replace(tmp,p)
assert not prepare(root)
except Exception:
rollback(backup);raise
print('Applied',len(changes),'files; backup',backup)
def rollback(backup):
rows=json.loads((backup/'manifest.json').read_text())
for row in rows:
p=Path(row['path']);assert p.exists() and digest(p.read_bytes())==row['after'],'File changed since deployment; manual merge required'
for row in rows:
p=Path(row['path'])
if row['backup']:p.write_bytes((backup/row['backup']).read_bytes())
else:p.unlink()
print('Rollback completed')
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('mode',choices=['plan','apply','rollback']);parser.add_argument('--root',default='/www/wwwroot/dp-link.net');parser.add_argument('--backup');args=parser.parse_args();root=Path(args.root).resolve()
assert root in [Path('/www/wwwroot/dp-link.net'),Path('/Users/hellcat/Documents/GemCodes/xb')],'DPL roots only'
if args.mode=='rollback':rollback(Path(args.backup))
elif args.mode=='plan':print('\n'.join(str(p) for p in prepare(root)))
else:apply(root,Path(args.backup) if args.backup else Path('/root/codex-backups/dpl/resource-downloads-'+datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')))