initial: packwiz → simple-mod-sync manifest converter

Reads packwiz pack (pack.toml + index.toml + per-file .pw.toml) and
emits a simple-mod-sync sync_version=3 manifest. Drops server-only
mods, skips CurseForge metadata-mode entries with a warning, maps
content type from the parent directory of each metafile.

Optional --bundle-non-mods zips config/, options.txt etc into one
archive served as a 'packed' entry — covers the gap where simple-mod-sync
only ships zip-extractable content for non-mods.

15 tests, includes integration against upstream packwiz-example-pack.
This commit is contained in:
2026-06-02 11:44:20 +02:00
commit 8651b076d3
15 changed files with 708 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.pytest_cache/
.venv/
*.egg-info/
build/
dist/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Timemachine
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+81
View File
@@ -0,0 +1,81 @@
# packwiz-to-sms
Convert a [packwiz](https://github.com/packwiz/packwiz) pack to a [simple-mod-sync](https://github.com/oxydien/simple-mod-sync) manifest (`sync_version: 3`).
## Why
Use **packwiz** to author the modpack (Modrinth/CurseForge integration, git-friendly TOML, optional/side-aware mods, `.mrpack` export for free), and **simple-mod-sync** for delivery to clients that can't or won't use Prism/MMC pre-launch hooks (vanilla launcher, TLauncher, cracked players).
One source of truth, two distribution channels — `.mrpack` export and simple-mod-sync manifest are both produced from the same packwiz repo.
## Install
Requires Python 3.11+ (uses `tomllib`). Zero runtime deps.
```bash
git clone https://git.timemachine.center/Timemachine/packwiz-to-sms.git
cd packwiz-to-sms
python3 packwiz_to_sms.py --help
```
## Usage
```bash
# Minimal — emit manifest to stdout
python3 packwiz_to_sms.py /path/to/packwiz/pack
# Write to file
python3 packwiz_to_sms.py /path/to/packwiz/pack -o manifest.json
# Bundle non-mod files (config/, options.txt, servers.dat) into a zip
# and add a 'packed' entry pointing at where you'll host it
python3 packwiz_to_sms.py /path/to/packwiz/pack \
-o manifest.json \
--bundle-non-mods overrides.zip \
--bundle-url https://packs.example.com/overrides.zip
```
## What gets emitted
| Packwiz path | Becomes simple-mod-sync `type` |
|---|---|
| `mods/*.pw.toml` (side=client or both) | `mod` |
| `mods/*.pw.toml` (side=server) | dropped |
| `resourcepacks/*.pw.toml` | `resourcepack` |
| `shaderpacks/*.pw.toml` | `shader` |
| `**/datapacks/*.pw.toml` | `datapack` |
| Any non-metafile (e.g. `options.txt`, `config/*`) | bundled into zip via `--bundle-non-mods`, emitted as one `packed` entry pointing at `directory: "."` |
CurseForge mods that use `mode = "metadata:curseforge"` (no direct URL) are skipped with a warning. Either switch to Modrinth equivalents or run `packwiz cf reexport` first to inline resolved URLs.
## What's not handled
- **Optional mods** — simple-mod-sync has no per-client toggle UI. All non-server mods are emitted unconditionally. Ship two manifests (with/without optional) if you need this.
- **Mod removal** — simple-mod-sync's `modify`/`remove` is not auto-populated. Convert by hand if you're dropping mods.
- **Rename / regex transforms** — packwiz has no equivalent concept, so we don't generate `modify.rename` entries.
## How it works
1. Reads `pack.toml` + `index.toml`.
2. For each entry marked `metafile = true`: reads the `.pw.toml`, pulls `download.url`, picks the simple-mod-sync `type` from the parent directory.
3. Drops `side = "server"`.
4. Drops entries where `download.url` is missing (CF metadata mode).
5. Optionally zips non-metafile files into a single archive for `packed` distribution.
Output schema matches `sync_version: 3` exactly (see [simple-mod-sync DOCS.md](https://github.com/oxydien/simple-mod-sync/blob/main/DOCS.md)).
## Tests
```bash
python3 -m pytest tests/
```
Covers conversion logic + CLI + bundle pipeline + a network integration test that converts the official [`packwiz-example-pack`](https://github.com/packwiz/packwiz-example-pack) (auto-skipped if offline).
## Upstream contribution
This tool fits the model used by other [simple-mod-sync translators](https://github.com/oxydien/simple-mod-sync/tree/main/translators). It can be PR'd upstream as `translators/packwiz.py`.
## License
MIT.
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env python3
"""Convert a packwiz pack into a simple-mod-sync manifest.
Reads a packwiz pack rooted at PACK_DIR and emits a simple-mod-sync
sync_version=3 manifest to stdout (or --output).
Mods, resourcepacks, shaders and datapacks are emitted with their direct
download URL pulled from each per-file packwiz metafile (.pw.toml).
Server-only mods are dropped. Client-only and both-side mods are kept.
CurseForge mods that lack a direct download URL (mode=metadata:curseforge)
are skipped with a warning; the operator can either swap to Modrinth or
pre-resolve URLs with `packwiz cf reexport`.
Arbitrary files (config/, options.txt, servers.dat, ...) are not emitted by
default since simple-mod-sync's `packed`/`config` types expect a zip.
Pass --bundle-non-mods PATH to write a zip of all non-metafile files; a
single `packed` entry pointing at <base-url>/<bundle-name> will be appended
to the manifest. Operator is responsible for hosting that zip.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import tomllib
import zipfile
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
# --- packwiz model --------------------------------------------------------
@dataclass(frozen=True)
class PackwizMetafile:
"""One mods/foo.pw.toml file (mod or resourcepack/shader/datapack)."""
path: Path # e.g. "mods/sodium.pw.toml"
name: str
filename: str
side: str # "client", "server", "both"
url: str | None # None when packwiz can't resolve (CF metadata mode)
hash_value: str | None
hash_format: str | None
def _load_pack_metafile(pack_root: Path, rel_path: str) -> PackwizMetafile:
abs_path = pack_root / rel_path
data = tomllib.loads(abs_path.read_text(encoding="utf-8"))
download = data.get("download", {})
mode = download.get("mode", "url")
url = download.get("url") if mode in ("url", "") else None
return PackwizMetafile(
path=Path(rel_path),
name=data.get("name", abs_path.stem),
filename=data.get("filename", ""),
side=data.get("side", "both"),
url=url,
hash_value=download.get("hash"),
hash_format=download.get("hash-format"),
)
def _load_index(pack_root: Path) -> dict[str, Any]:
return tomllib.loads((pack_root / "index.toml").read_text(encoding="utf-8"))
def _load_pack(pack_root: Path) -> dict[str, Any]:
return tomllib.loads((pack_root / "pack.toml").read_text(encoding="utf-8"))
# --- simple-mod-sync model ------------------------------------------------
_DIR_TO_TYPE = {
"mods": "mod",
"resourcepacks": "resourcepack",
"shaderpacks": "shader",
}
def _content_type_for(rel_path: Path) -> str | None:
"""Map mods/foo.pw.toml -> "mod", resourcepacks/foo.pw.toml -> "resourcepack" etc.
`datapack` is intentionally not auto-mapped: packwiz puts datapacks under
a world directory which varies. Operator should set type manually or
place datapacks in a `datapacks/` top-level dir (uncommon).
"""
if rel_path.parent.name == "datapacks":
return "datapack"
return _DIR_TO_TYPE.get(rel_path.parts[0]) if rel_path.parts else None
def _make_entry(meta: PackwizMetafile, content_type: str) -> dict[str, Any]:
version = meta.filename or "unknown"
# packwiz filenames carry the version (e.g. sodium-0.6.5+1.21.1.jar).
# If we can extract a cleaner version, do it; otherwise keep the filename.
if meta.filename:
m = re.search(r"[-_]([\w.+]+?)\.(?:jar|zip)$", meta.filename)
if m:
version = m.group(1)
return {
"url": meta.url,
"name": meta.name,
"version": version,
"type": content_type,
}
# --- conversion driver ----------------------------------------------------
@dataclass
class ConvertResult:
manifest: dict[str, Any]
skipped_server_only: list[str]
skipped_no_url: list[str]
skipped_unknown_type: list[str]
non_mod_files: list[Path]
def convert(
pack_root: Path,
*,
include_client_only: bool = True,
include_both: bool = True,
) -> ConvertResult:
pack = _load_pack(pack_root)
index = _load_index(pack_root)
sync: list[dict[str, Any]] = []
skipped_server_only: list[str] = []
skipped_no_url: list[str] = []
skipped_unknown_type: list[str] = []
non_mod_files: list[Path] = []
for f in index.get("files", []):
rel_path = Path(f["file"])
if not f.get("metafile"):
non_mod_files.append(rel_path)
continue
meta = _load_pack_metafile(pack_root, f["file"])
if meta.side == "server":
skipped_server_only.append(meta.name)
continue
if meta.side == "client" and not include_client_only:
continue
if meta.side == "both" and not include_both:
continue
content_type = _content_type_for(meta.path)
if content_type is None:
skipped_unknown_type.append(str(meta.path))
continue
if meta.url is None:
skipped_no_url.append(meta.name)
continue
sync.append(_make_entry(meta, content_type))
manifest = {
"sync_version": 3,
"_generator": {
"tool": "packwiz-to-sms",
"pack_name": pack.get("name", ""),
"pack_version": pack.get("version", ""),
},
"sync": sync,
}
return ConvertResult(
manifest=manifest,
skipped_server_only=skipped_server_only,
skipped_no_url=skipped_no_url,
skipped_unknown_type=skipped_unknown_type,
non_mod_files=non_mod_files,
)
def bundle_non_mods(
pack_root: Path,
non_mod_files: list[Path],
out_zip: Path,
) -> None:
"""Zip non-mod files (config/, options.txt, etc.) into one archive
suitable for shipping as a simple-mod-sync `packed` entry with
`directory: "."`."""
out_zip.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_DEFLATED) as zf:
for rel in non_mod_files:
abs_path = pack_root / rel
if not abs_path.is_file():
continue
zf.write(abs_path, arcname=str(rel))
def add_bundle_entry(
manifest: dict[str, Any],
bundle_url: str,
bundle_version: str,
) -> None:
manifest["sync"].append(
{
"url": bundle_url,
"name": "Pack overrides",
"version": bundle_version,
"type": "packed",
"directory": ".",
}
)
# --- CLI -------------------------------------------------------------------
def _build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="packwiz-to-sms",
description="Convert a packwiz pack to a simple-mod-sync manifest.",
)
p.add_argument("pack_root", type=Path, help="Path to packwiz pack root (contains pack.toml)")
p.add_argument(
"-o", "--output", type=Path, default=None,
help="Write manifest to this file (default stdout).",
)
p.add_argument(
"--bundle-non-mods", type=Path, default=None, metavar="ZIP_PATH",
help="Zip all non-mod files into this zip and add a 'packed' entry pointing at --bundle-url.",
)
p.add_argument(
"--bundle-url", type=str, default=None,
help="URL where the non-mod bundle will be hosted. Required with --bundle-non-mods.",
)
p.add_argument(
"--include-server-only", action="store_true",
help="Include mods marked side=server (off by default; only useful for mod-server distribution).",
)
p.add_argument("--quiet", action="store_true", help="Suppress skip warnings on stderr.")
return p
def main(argv: list[str] | None = None) -> int:
args = _build_arg_parser().parse_args(argv)
if not (args.pack_root / "pack.toml").is_file():
print(f"error: {args.pack_root} does not contain pack.toml", file=sys.stderr)
return 2
result = convert(args.pack_root)
if args.bundle_non_mods:
if not args.bundle_url:
print("error: --bundle-non-mods requires --bundle-url", file=sys.stderr)
return 2
bundle_non_mods(args.pack_root, result.non_mod_files, args.bundle_non_mods)
pack = _load_pack(args.pack_root)
add_bundle_entry(result.manifest, args.bundle_url, pack.get("version", "1"))
if not args.quiet:
for n in result.skipped_server_only:
print(f"skip (server-only): {n}", file=sys.stderr)
for n in result.skipped_no_url:
print(f"skip (no resolvable url, likely CurseForge metadata mode): {n}", file=sys.stderr)
for n in result.skipped_unknown_type:
print(f"skip (unknown content type for path): {n}", file=sys.stderr)
if result.non_mod_files and not args.bundle_non_mods:
print(
f"note: {len(result.non_mod_files)} non-mod file(s) in pack "
f"(config/, options.txt, ...) — pass --bundle-non-mods to ship them",
file=sys.stderr,
)
payload = json.dumps(result.manifest, indent=2) + "\n"
if args.output:
args.output.write_text(payload, encoding="utf-8")
else:
sys.stdout.write(payload)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,2 @@
voice_chat_volume=0.8
microphone_amplification=1.0
+39
View File
@@ -0,0 +1,39 @@
hash-format = "sha256"
[[files]]
file = "mods/sodium.pw.toml"
hash = "0000000000000000000000000000000000000000000000000000000000000000"
metafile = true
[[files]]
file = "mods/fabric-api.pw.toml"
hash = "0000000000000000000000000000000000000000000000000000000000000000"
metafile = true
[[files]]
file = "mods/server-side-tool.pw.toml"
hash = "0000000000000000000000000000000000000000000000000000000000000000"
metafile = true
[[files]]
file = "mods/cf-only-mod.pw.toml"
hash = "0000000000000000000000000000000000000000000000000000000000000000"
metafile = true
[[files]]
file = "resourcepacks/branding.pw.toml"
hash = "0000000000000000000000000000000000000000000000000000000000000000"
metafile = true
[[files]]
file = "shaderpacks/iris.pw.toml"
hash = "0000000000000000000000000000000000000000000000000000000000000000"
metafile = true
[[files]]
file = "config/voicechat-client.properties"
hash = "1111111111111111111111111111111111111111111111111111111111111111"
[[files]]
file = "options.txt"
hash = "2222222222222222222222222222222222222222222222222222222222222222"
@@ -0,0 +1,13 @@
name = "CF Only Mod"
filename = "cfmod-1.0.jar"
side = "both"
[download]
hash-format = "murmur2"
hash = "1234567"
mode = "metadata:curseforge"
[update]
[update.curseforge]
project-id = 12345
file-id = 67890
@@ -0,0 +1,8 @@
name = "Fabric API"
filename = "fabric-api-0.110.5+1.21.1.jar"
side = "both"
[download]
url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/4OZL6q6h/fabric-api-0.110.5%2B1.21.1.jar"
hash-format = "sha1"
hash = "1234560000000000000000000000000000000000"
@@ -0,0 +1,8 @@
name = "Server Side Tool"
filename = "spark-1.10.55-fabric.jar"
side = "server"
[download]
url = "https://example.com/spark.jar"
hash-format = "sha1"
hash = "9999990000000000000000000000000000000000"
+13
View File
@@ -0,0 +1,13 @@
name = "Sodium"
filename = "sodium-fabric-0.6.5+mc1.21.1.jar"
side = "client"
[download]
url = "https://cdn.modrinth.com/data/AANobbMI/versions/EoNKHoLH/sodium-fabric-0.6.5%2Bmc1.21.1.jar"
hash-format = "sha1"
hash = "abcdef0000000000000000000000000000000000"
[update]
[update.modrinth]
mod-id = "AANobbMI"
version = "EoNKHoLH"
+2
View File
@@ -0,0 +1,2 @@
fov:90
renderDistance:12
+13
View File
@@ -0,0 +1,13 @@
name = "Test Pack"
author = "tester"
version = "0.1.0"
pack-format = "packwiz:1.1.0"
[index]
file = "index.toml"
hash-format = "sha256"
hash = "0000000000000000000000000000000000000000000000000000000000000000"
[versions]
minecraft = "1.21.1"
fabric = "0.16.5"
@@ -0,0 +1,8 @@
name = "Server Branding"
filename = "automc-branding-0.3.zip"
side = "client"
[download]
url = "https://example.com/automc-branding-0.3.zip"
hash-format = "sha256"
hash = "aaaa000000000000000000000000000000000000000000000000000000000000"
@@ -0,0 +1,8 @@
name = "Complementary Shaders"
filename = "complementary-r5.5.zip"
side = "client"
[download]
url = "https://example.com/complementary-r5.5.zip"
hash-format = "sha256"
hash = "bbbb000000000000000000000000000000000000000000000000000000000000"
+194
View File
@@ -0,0 +1,194 @@
"""Unit tests for packwiz_to_sms.convert + CLI."""
from __future__ import annotations
import json
import subprocess
import sys
import zipfile
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
FIXTURE = Path(__file__).resolve().parent / "fixture-pack"
SCRIPT = ROOT / "packwiz_to_sms.py"
sys.path.insert(0, str(ROOT))
import packwiz_to_sms as p2s # noqa: E402
def _by_name(sync: list[dict]) -> dict[str, dict]:
return {e["name"]: e for e in sync}
def test_convert_emits_client_and_both_only() -> None:
r = p2s.convert(FIXTURE)
names = {e["name"] for e in r.manifest["sync"]}
# client + both kept
assert "Sodium" in names
assert "Fabric API" in names
# server-only dropped
assert "Server Side Tool" not in names
# CurseForge metadata-mode dropped (no URL)
assert "CF Only Mod" not in names
# resourcepack + shader kept
assert "Server Branding" in names
assert "Complementary Shaders" in names
def test_convert_skip_reasons() -> None:
r = p2s.convert(FIXTURE)
assert "Server Side Tool" in r.skipped_server_only
assert "CF Only Mod" in r.skipped_no_url
assert r.skipped_unknown_type == []
def test_convert_content_types() -> None:
r = p2s.convert(FIXTURE)
entries = _by_name(r.manifest["sync"])
assert entries["Sodium"]["type"] == "mod"
assert entries["Fabric API"]["type"] == "mod"
assert entries["Server Branding"]["type"] == "resourcepack"
assert entries["Complementary Shaders"]["type"] == "shader"
def test_convert_url_preserved_verbatim() -> None:
r = p2s.convert(FIXTURE)
sodium = _by_name(r.manifest["sync"])["Sodium"]
assert sodium["url"] == (
"https://cdn.modrinth.com/data/AANobbMI/versions/EoNKHoLH/"
"sodium-fabric-0.6.5%2Bmc1.21.1.jar"
)
def test_convert_version_extracted_from_filename() -> None:
r = p2s.convert(FIXTURE)
entries = _by_name(r.manifest["sync"])
assert entries["Sodium"]["version"] == "0.6.5+mc1.21.1"
assert entries["Fabric API"]["version"] == "0.110.5+1.21.1"
# Zip artifacts (resourcepacks, shaders) — same extractor handles them.
assert entries["Server Branding"]["version"] == "0.3"
assert entries["Complementary Shaders"]["version"] == "r5.5"
def test_convert_lists_non_mod_files() -> None:
r = p2s.convert(FIXTURE)
files = {str(p) for p in r.non_mod_files}
assert "config/voicechat-client.properties" in files
assert "options.txt" in files
def test_convert_top_level_shape() -> None:
r = p2s.convert(FIXTURE)
assert r.manifest["sync_version"] == 3
assert isinstance(r.manifest["sync"], list)
assert r.manifest["_generator"]["tool"] == "packwiz-to-sms"
assert r.manifest["_generator"]["pack_name"] == "Test Pack"
assert r.manifest["_generator"]["pack_version"] == "0.1.0"
def test_bundle_non_mods(tmp_path: Path) -> None:
r = p2s.convert(FIXTURE)
out = tmp_path / "overrides.zip"
p2s.bundle_non_mods(FIXTURE, r.non_mod_files, out)
with zipfile.ZipFile(out) as zf:
names = set(zf.namelist())
assert "config/voicechat-client.properties" in names
assert "options.txt" in names
def test_bundle_entry_appended() -> None:
r = p2s.convert(FIXTURE)
n_before = len(r.manifest["sync"])
p2s.add_bundle_entry(r.manifest, "https://example.com/overrides.zip", "0.1.0")
assert len(r.manifest["sync"]) == n_before + 1
bundle = r.manifest["sync"][-1]
assert bundle["type"] == "packed"
assert bundle["directory"] == "."
assert bundle["url"] == "https://example.com/overrides.zip"
def test_cli_writes_to_stdout(tmp_path: Path) -> None:
proc = subprocess.run(
[sys.executable, str(SCRIPT), str(FIXTURE), "--quiet"],
capture_output=True, text=True, check=True,
)
payload = json.loads(proc.stdout)
assert payload["sync_version"] == 3
assert any(e["name"] == "Sodium" for e in payload["sync"])
def test_cli_writes_to_output_flag(tmp_path: Path) -> None:
out = tmp_path / "manifest.json"
subprocess.run(
[sys.executable, str(SCRIPT), str(FIXTURE), "--quiet", "-o", str(out)],
check=True,
)
payload = json.loads(out.read_text())
assert payload["sync_version"] == 3
def test_cli_bundle_requires_url(tmp_path: Path) -> None:
proc = subprocess.run(
[sys.executable, str(SCRIPT), str(FIXTURE),
"--bundle-non-mods", str(tmp_path / "x.zip"), "--quiet"],
capture_output=True, text=True,
)
assert proc.returncode == 2
assert "--bundle-url" in proc.stderr
def test_cli_full_bundle_pipeline(tmp_path: Path) -> None:
bundle = tmp_path / "overrides.zip"
out = tmp_path / "manifest.json"
subprocess.run(
[sys.executable, str(SCRIPT), str(FIXTURE), "--quiet",
"-o", str(out),
"--bundle-non-mods", str(bundle),
"--bundle-url", "https://example.com/overrides.zip"],
check=True,
)
payload = json.loads(out.read_text())
bundle_entry = next(
(e for e in payload["sync"] if e.get("type") == "packed"), None
)
assert bundle_entry is not None
assert bundle_entry["url"] == "https://example.com/overrides.zip"
assert bundle.is_file()
def test_cli_missing_pack_toml(tmp_path: Path) -> None:
proc = subprocess.run(
[sys.executable, str(SCRIPT), str(tmp_path), "--quiet"],
capture_output=True, text=True,
)
assert proc.returncode == 2
assert "pack.toml" in proc.stderr
def test_convert_against_real_packwiz_example_pack(tmp_path: Path) -> None:
"""Integration: clone the upstream packwiz-example-pack and convert it.
Skipped if no network / git unavailable. Verifies the tool works on
a real pack, not just our hand-rolled fixture.
"""
pack_root = tmp_path / "example-pack"
try:
subprocess.run(
["git", "clone", "--depth", "1", "--branch", "v1",
"https://github.com/packwiz/packwiz-example-pack.git",
str(pack_root)],
check=True, capture_output=True, timeout=30,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
pytest.skip("network/git unavailable")
r = p2s.convert(pack_root)
assert r.manifest["sync_version"] == 3
# Example pack has at least two mods we recognize
names = {e["name"] for e in r.manifest["sync"]}
assert any("Borderless" in n for n in names)
# All entries have a URL
assert all(e.get("url") for e in r.manifest["sync"])