Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion docs/daemon-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,8 @@ Development `--static-dir` resources continue to be read from disk on each reque
| GET | `/api/config/desktop-multi-instance` | read global desktop multi-instance preference |
| PUT | `/api/config/desktop-multi-instance` | save global desktop multi-instance preference |
| PUT | `/api/config/ui-preferences` | write UI preferences |
| GET | `/api/themes` | downloadable catalogue plus installed local AI themes |
| GET | `/api/themes` | built-in and dynamic workshop catalogues plus installed local AI themes |
| GET | `/api/themes/workshop/<key>/thumbnail` | authenticated proxy for a public workshop PNG thumbnail |
| POST | `/api/themes/first-run` | legacy compatibility: durably claim the one-time National Day startup attempt |
| POST | `/api/themes/import/preview` | validate a raw theme ZIP and return a read-only preview |
| POST | `/api/themes/import?sha256=<digest>` | import the same previewed ZIP after confirmation |
Expand Down Expand Up @@ -3826,6 +3827,25 @@ endpoint. Their IDs match `^ai-[a-z0-9]+(?:-[a-z0-9]+)*$` and are at most 64
characters. Definitions accept light or dark mode and all 28 HEX tokens. The
definition and image routes work offline. Built-in IDs cannot be overwritten.

Workshop discovery reads every public `/api/themes?page=N` page from the update
host at its effective port plus 10000 (82 → 10082, 1234 → 11234), retaining the
HTTP/HTTPS scheme and clearing the update path. `source:"workshop"` entries use
dynamic `ai-*` IDs, names, versions, swatches and a content-addressed `key`.
Each ID appears once, at its highest public semantic version, merged with local
`installed`, `installed_version` and `update_available` state. `package` includes
the exact bytes, SHA-256 and a daemon-constructed workshop download URL.
The thumbnail proxy accepts only a 64-character hex key and constructs the
resource URL from the current configured host; remote arbitrary URLs are ignored.

`refresh=1` updates both catalogues. Workshop failure returns `workshop_error`
without reusing old remote offers; installed themes remain as local entries.
Workshop installs recheck the public list and the confirmed package metadata,
then reuse the local import validation and atomic transaction. A withdrawn theme
cannot be newly installed; an existing installation remains usable offline.
Settings refreshes discovery on open, window focus and update-source changes.
Deleting an installed workshop theme leaves its public offer available to install
again. Pending/rejected submissions are never advertised by the public API.

Schema version 1 definitions may also include an `appearance` object:

```json
Expand Down
10 changes: 10 additions & 0 deletions docs/themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,16 @@ Use a new version if any approved artwork or palette changes after publication.

## Independent publication

The Appearance theme grid also reads approved themes dynamically from the
independent workshop. Its address uses the update server's effective port plus
10000 (for example, 82 → 10082). Opening Settings, returning from the workshop,
or changing the update source refreshes the list. All public pages are read;
the newest semantic version of each theme ID appears as one card with its local
installation state. Select a card to download, verify, install and apply it;
there is no manual ZIP import step. Workshop updates use the same confirmation
and progress flow as built-in downloads. Installed themes remain usable offline
or after withdrawal. A workshop connection failure shows a retry action.

The current aupdate root is `J:/jenkins_green/aupdate`, publicly served at
`http://2017studio.imwork.net:82/aupdate/`. Copy the versioned ZIP and thumbnail
under `themes/eva-01/1.0.2/`, verify the public downloads against their SHA-256
Expand Down
109 changes: 109 additions & 0 deletions scripts/verify_theme_workshop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Exercise the real workshop through an isolated Windows ACECode daemon.

No production configuration or workshop data is changed. --serve keeps the
isolated daemon alive for UI verification; stop it using the printed PID.
"""
import argparse
import json
import os
from pathlib import Path
import secrets
import socket
import subprocess
import tempfile
import time
import urllib.request


def main():
parser = argparse.ArgumentParser()
parser.add_argument('--binary', type=Path, required=True)
parser.add_argument('--update-url', default='http://2017studio.imwork.net:82/aupdate/')
parser.add_argument('--serve', action='store_true')
args = parser.parse_args()
if os.name != 'nt':
raise SystemExit('This isolated USERPROFILE harness is Windows-only.')
root = Path(tempfile.mkdtemp(prefix='ace_workshop_verify_'))
data = root / '.acecode'
data.mkdir()
(data / 'config.json').write_text(json.dumps({'upgrade': {'base_url': args.update_url, 'auto_check': False}}), encoding='utf-8')
workspace = root / 'workspace'
workspace.mkdir()
with socket.socket() as probe:
probe.bind(('127.0.0.1', 0))
port = probe.getsockname()[1]
token = secrets.token_hex(24)
base = f'http://127.0.0.1:{port}'
env = dict(os.environ, USERPROFILE=str(root))
static = Path(__file__).resolve().parents[1] / 'web' / 'dist'
log = (root / 'daemon.log').open('wb')
process = subprocess.Popen([str(args.binary.resolve()), 'daemon', '--foreground', f'--port={port}',
f'--token={token}', f'--cwd={workspace}', f'--run-dir={root / "run"}', f'--static-dir={static}'],
env=env, stdout=log, stderr=subprocess.STDOUT, creationflags=subprocess.CREATE_NO_WINDOW)
checks = []

def request(path, method='GET', payload=None, binary=False):
body = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(base + path, body, {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}, method=method)
with urllib.request.urlopen(req, timeout=45) as response:
content = response.read()
return content if binary else json.loads(content)

def check(condition, name):
if not condition:
raise AssertionError(name)
checks.append(name)

keep = False
try:
for _ in range(90):
if process.poll() is not None:
raise RuntimeError(f'Daemon exited; see {root / "daemon.log"}')
try:
request('/api/config/upgrade')
break
except (OSError, ValueError):
time.sleep(1)
else:
raise RuntimeError('Daemon did not become ready')
catalog = request('/api/themes?refresh=1')
offers = [entry for entry in catalog['themes'] if entry.get('source') == 'workshop']
check(bool(offers), 'Public workshop offers appear in the client catalog')
check(len({entry['id'] for entry in catalog['themes']}) == len(catalog['themes']), 'No duplicate theme IDs')
theme = offers[0]
check(not theme['installed'], 'Fresh profile has not installed the workshop theme')
png = request(f'/api/themes/workshop/{theme["key"]}/thumbnail', binary=True)
check(png.startswith(b'\x89PNG\r\n\x1a\n'), 'Authenticated thumbnail proxy returns PNG')
request(f'/api/themes/{theme["id"]}/install', 'POST', {'confirm_download': True, 'version': theme['version'],
'bytes': theme['package']['bytes'], 'sha256': theme['package']['sha256']})
for _ in range(180):
job = request('/api/themes/job')
if job['state'] not in ('downloading', 'installing'):
break
time.sleep(0.25)
check(job['state'] == 'completed', f'Verified installation completes: {job.get("error", "ok")}')
definition = request(f'/api/themes/{theme["id"]}')
check(definition['id'] == theme['id'] and definition['version'] == theme['version'], 'Installed identity and version match')
check(request(f'/api/themes/{theme["id"]}/images/background', binary=True).startswith(b'\x89PNG'), 'Installed background is readable')
refreshed = request('/api/themes?refresh=1')['themes']
matching = [entry for entry in refreshed if entry['id'] == theme['id']]
check(len(matching) == 1 and matching[0]['installed'], 'Refresh merges installed state into one workshop card')
request(f'/api/themes/{theme["id"]}', 'DELETE')
restored = next(entry for entry in request('/api/themes?refresh=1')['themes'] if entry['id'] == theme['id'])
check(not restored['installed'], 'Deleting the isolated install leaves the public offer available')
report = {'checks': checks, 'theme': {'id': theme['id'], 'name': theme['name'], 'version': theme['version']},
'root': str(root), 'base_url': base, 'pid': process.pid}
(root / 'result.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
(root / 'browser-auth.txt').write_text(token, encoding='utf-8')
print(json.dumps(report, ensure_ascii=False), flush=True)
keep = args.serve
finally:
if not keep:
process.terminate()
process.wait(timeout=15)
log.close()


if __name__ == '__main__':
main()
Loading
Loading