From 999f62a917e63d59e912db038bee2cb5be1c383c Mon Sep 17 00:00:00 2001 From: Dylan Baker Date: Mon, 17 Aug 2026 17:43:10 +0000 Subject: [PATCH 1/8] entry: use match instead of if tree --- flatpaker/entry.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/flatpaker/entry.py b/flatpaker/entry.py index 99630af..38f0f9e 100644 --- a/flatpaker/entry.py +++ b/flatpaker/entry.py @@ -133,17 +133,18 @@ def main() -> None: args = typing.cast('BaseArguments', parser.parse_args()) success = True - if args.action == 'build': - bargs = typing.cast('BuildArguments', args) - success = build_flatpak(bargs) - if bargs.deltas: - static_deltas(bargs) - if args.action == 'build-runtimes': - brargs = typing.cast('BuildRuntimeArguments', args) - success = build_runtimes(brargs) - if brargs.deltas: - static_deltas(brargs) - if args.action == 'generate': - success = generate(typing.cast('GenerateArguments', args)) + match args.action: + case 'build': + bargs = typing.cast('BuildArguments', args) + success = build_flatpak(bargs) + if bargs.deltas: + static_deltas(bargs) + case 'build-runtimes': + brargs = typing.cast('BuildRuntimeArguments', args) + success = build_runtimes(brargs) + if brargs.deltas: + static_deltas(brargs) + case 'generate': + success = generate(typing.cast('GenerateArguments', args)) sys.exit(0 if success else 1) From dc4fca7495ebdeb4b0e397c2a262c2384088a5dc Mon Sep 17 00:00:00 2001 From: Dylan Baker Date: Mon, 17 Aug 2026 18:54:31 +0000 Subject: [PATCH 2/8] entry: remove `--install` and modify `--export` In order to add additional export modes, namely flat-manager, refactor the way exporting works. In the future the user will have the choice to either export to a local repo, install locally, or push to flat-manager. this means that `--export` now takes a manditory argument. As a side effect, it is no longer supported to install and export to a local repo at the same time. This is never a thing I used, and isn't something that I want to support. --- README.md | 10 ++++++-- flatpaker/actions/build_flatpak.py | 13 +++++----- flatpaker/actions/build_runtime.py | 17 ++++++------- flatpaker/config.py | 3 +++ flatpaker/entry.py | 38 +++++++++++++++++++++++------- 5 files changed, 57 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 2522dc5..7d3c878 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,10 @@ to provide even better security by using Wayland instead of X11 (or XWayland). 2. Download any mods or addons (optional) 3. Generate a toml description `flatpaker generate com.developer.game "Game Name" engine archive.zip` 4. Edit the generated description to fill in missing information -5. run `flatpaker build-runtimes --install` (which adds the runtimes and sdks) -6. run `flatpaker build --install *.toml` or `flatpaker build --export --gpg-sign *.toml` (for local install or for export to a shared repo) +5. run `flatpaker build-runtimes --export=install` (which adds the runtimes and sdks) +6. run `flatpaker build --export=install *.toml` or `flatpaker build + --export=repo --gpg-sign *.toml` (for local install or for export to a shared + repo) ### Toml Format @@ -181,6 +183,10 @@ That file must be written to `$XDG_CONFIG_HOME/flatpaker/config.toml` (if unset # The absolute path to a repo to write to. overwritten by the --repo option repo = "/path/to/a/repo/to/export" + + # The default export mode + # May be one of: "none", "install", "repo" + export = "none" ``` diff --git a/flatpaker/actions/build_flatpak.py b/flatpaker/actions/build_flatpak.py index 8f99e3c..3a9ad5c 100644 --- a/flatpaker/actions/build_flatpak.py +++ b/flatpaker/actions/build_flatpak.py @@ -47,12 +47,13 @@ def _build(args: BaseBuildArguments, description: Description) -> None: (workdir / f'{appid}.json').absolute().as_posix(), ] - if args.export: - build_command.extend(['--repo', args.repo]) - if args.gpg: - build_command.extend(['--gpg-sign', args.gpg]) - if args.install: - build_command.extend(['--install']) + match args.export: + case 'repo': + build_command.extend(['--repo', args.repo]) + if args.gpg: + build_command.extend(['--gpg-sign', args.gpg]) + case 'install': + build_command.extend(['--install']) subprocess.run(build_command, check=True) if args.cleanup: diff --git a/flatpaker/actions/build_runtime.py b/flatpaker/actions/build_runtime.py index 33acc58..708fb81 100644 --- a/flatpaker/actions/build_runtime.py +++ b/flatpaker/actions/build_runtime.py @@ -20,17 +20,18 @@ def _build_runtime(args: BaseBuildArguments, sdk: pathlib.Path, 'flatpak-builder', '--force-clean', '--user', '--install-deps-from=flathub', 'build', sdk.as_posix()] - if args.export: - build_command.extend(['--repo', args.repo]) - if args.gpg: - build_command.extend(['--gpg-sign', args.gpg]) - if args.install: - build_command.extend(['--install']) + match args.export: + case 'repo': + build_command.extend(['--repo', args.repo]) + if args.gpg: + build_command.extend(['--gpg-sign', args.gpg]) + case 'install': + build_command.extend(['--install']) subprocess.run(build_command, check=True) # Work around https://github.com/flatpak/flatpak-builder/issues/630 - if need_platform_workaround and args.install and 'Sdk' in sdk.name: + if need_platform_workaround and args.export == 'install' and 'Sdk' in sdk.name: if '8' in sdk.name: branch = '8' elif '7.py2' in sdk.name: @@ -40,7 +41,7 @@ def _build_runtime(args: BaseBuildArguments, sdk: pathlib.Path, else: raise RuntimeError('Unexpected Sdk') - repo = args.repo if args.export else pathlib.Path('.flatpak-builder/cache').absolute().as_posix() + repo = pathlib.Path('.flatpak-builder/cache').absolute().as_posix() platform_id = '.'.join(sdk.name.split('.', maxsplit=5)[:-1]) install_command = [ diff --git a/flatpaker/config.py b/flatpaker/config.py index e12e5b5..723a6e1 100644 --- a/flatpaker/config.py +++ b/flatpaker/config.py @@ -10,11 +10,14 @@ if typing.TYPE_CHECKING: + ExportMode = typing.Literal['none', 'repo', 'install'] + Common = typing.TypedDict( 'Common', { 'gpg-key': str, 'repo': str, + 'export': ExportMode, }, total=False, ) diff --git a/flatpaker/entry.py b/flatpaker/entry.py index 38f0f9e..e2c90ae 100644 --- a/flatpaker/entry.py +++ b/flatpaker/entry.py @@ -14,6 +14,7 @@ from flatpaker.actions.generate import generate if typing.TYPE_CHECKING: + from flatpaker.config import ExportMode from flatpaker.description import EngineName class BaseArguments(typing.Protocol): @@ -22,8 +23,7 @@ class BaseArguments(typing.Protocol): class BaseBuildArguments(BaseArguments, typing.Protocol): repo: str gpg: str | None - install: bool - export: bool + export: ExportMode cleanup: bool deltas: bool keep_going: bool @@ -45,7 +45,7 @@ class GenerateArguments(BaseArguments, typing.Protocol): def static_deltas(args: BaseBuildArguments) -> None: - if not (args.deltas or args.export): + if not (args.deltas or args.export != 'repo'): return command = ['flatpak', 'build-update-repo', args.repo, '--generate-static-deltas'] if args.gpg: @@ -54,7 +54,7 @@ def static_deltas(args: BaseBuildArguments) -> None: subprocess.run(command, check=True) -def main() -> None: +def _parse_args() -> BaseArguments: config = flatpaker.config.load_config() # An inheritable parser instance used to add arguments to both build and build-runtimes @@ -69,10 +69,21 @@ def main() -> None: default=config['common'].get('gpg-key'), action='store', help='A GPG key to sign the output to when writing to a repo') - pp.add_argument('--export', action='store_true', help='Export to the provided repo') - pp.add_argument('--install', action='store_true', help="Install for the user (useful for testing)") + pp.add_argument( + '--export', + action='store', + choices=['none', 'install', 'repo'], + default=config['common'].get('export', 'none'), + help='Export the repo using one of the following methods. ' + '"none": Do not export, only build; ' + '"export": write to an ostree repo; ' + '"install": install for the user(useful for testing)') pp.add_argument('--no-cleanup', action='store_false', dest='cleanup', help="don't delete the temporary directory") - pp.add_argument('--static-deltas', action='store_true', dest='deltas', help="generate static deltas when exporting") + pp.add_argument( + '--static-deltas', + action='store_true', + dest='deltas', + help="generate static deltas when exporting to a repo. Has not effect if `--export-mode` is not `repo`") pp.add_argument('--keep-going', action='store_true', help="Don't stop if building a runtime or app fails.") from . import __version__ @@ -130,7 +141,18 @@ def main() -> None: ) generate_parser.set_defaults(action='generate') - args = typing.cast('BaseArguments', parser.parse_args()) + base = typing.cast('BaseArguments', parser.parse_args()) + + if base.action in {'build', 'build-runtimes'}: + runargs = typing.cast('BaseBuildArguments', base) + if runargs.export == 'repo' and not runargs.repo: + parser.error('export is set to "repo", but no "repo" is defined') + + return base + + +def main() -> None: + args = _parse_args() success = True match args.action: From ba91395afc014ddc83b600b7e060ecd3cc34e94a Mon Sep 17 00:00:00 2001 From: Dylan Baker Date: Mon, 17 Aug 2026 19:46:02 +0000 Subject: [PATCH 3/8] build: convert descriptions to Path objects sooner --- flatpaker/actions/build_flatpak.py | 6 +++--- flatpaker/description.py | 6 +++--- flatpaker/entry.py | 5 +++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/flatpaker/actions/build_flatpak.py b/flatpaker/actions/build_flatpak.py index 3a9ad5c..6f53a78 100644 --- a/flatpaker/actions/build_flatpak.py +++ b/flatpaker/actions/build_flatpak.py @@ -30,7 +30,8 @@ def select_impl(name: EngineName) -> JsonWriterImpl: return mod.write_rules -def _build(args: BaseBuildArguments, description: Description) -> None: +def _build(args: BaseBuildArguments, path: pathlib.Path) -> None: + description = load_description(path) # TODO: This could be common appid = f"{description.common.reverse_url}.{util.sanitize_name(description.common.name)}" @@ -65,8 +66,7 @@ def build_flatpak(args: BuildArguments) -> bool: for d in args.descriptions: try: - description = load_description(d) - _build(args, description) + _build(args, d) except Exception: if not args.keep_going: raise diff --git a/flatpaker/description.py b/flatpaker/description.py index cf77b65..4b76cf5 100644 --- a/flatpaker/description.py +++ b/flatpaker/description.py @@ -88,13 +88,13 @@ class Description: sources: Sources -def load_description(name: str) -> Description: - relpath = pathlib.Path(name).parent.absolute() +def load_description(path: pathlib.Path) -> Description: + relpath = path.parent.absolute() # TODO: the cast to Any leaves us with the same # validation problem with had previous, but without the hints. # I wish python had something like serde - with open(name, 'rb') as f: + with path.open('rb') as f: d = typing.cast('typing.Any', tomlkit.load(f)) quirks = Quirks(**d.get('quirks', {})) diff --git a/flatpaker/entry.py b/flatpaker/entry.py index e2c90ae..6ce7df2 100644 --- a/flatpaker/entry.py +++ b/flatpaker/entry.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import pathlib import subprocess import sys import typing @@ -29,7 +30,7 @@ class BaseBuildArguments(BaseArguments, typing.Protocol): keep_going: bool class BuildArguments(BaseBuildArguments, typing.Protocol): - descriptions: list[str] + descriptions: list[pathlib.Path] class BuildRuntimeArguments(BaseBuildArguments, typing.Protocol): runtimes: list[EngineName] @@ -93,7 +94,7 @@ def _parse_args() -> BaseArguments: subparsers = parser.add_subparsers(required=True) build_parser = subparsers.add_parser( 'build', help='Build flatpaks from descriptions', parents=[pp]) - build_parser.add_argument('descriptions', nargs='+', help="A Toml description file") + build_parser.add_argument('descriptions', nargs='+', type=pathlib.Path, help="A Toml description file") build_parser.set_defaults(action='build') _all_runtimes = ['renpy8', 'renpy7', 'renpy7-py3', 'rpgmaker'] From e90c5f7a2ffac86df47e2f486f6db7a08638d95f Mon Sep 17 00:00:00 2001 From: Dylan Baker Date: Tue, 18 Aug 2026 16:48:04 +0000 Subject: [PATCH 4/8] entry: Move from namespace protocols to dataclasses for config This is going to be used in upcoming patches to make more transformations of the arguments into a form more useful further down. It already allows us to clean up some of the differences between how the generate cli is structured and how we want to use that data. --- flatpaker/actions/build_flatpak.py | 6 +- flatpaker/actions/build_runtime.py | 6 +- flatpaker/actions/generate.py | 8 +-- flatpaker/entry.py | 98 ++++++++++++++++++++++++++---- 4 files changed, 96 insertions(+), 22 deletions(-) diff --git a/flatpaker/actions/build_flatpak.py b/flatpaker/actions/build_flatpak.py index 6f53a78..b666632 100644 --- a/flatpaker/actions/build_flatpak.py +++ b/flatpaker/actions/build_flatpak.py @@ -14,7 +14,7 @@ if typing.TYPE_CHECKING: from flatpaker.description import Description, EngineName - from flatpaker.entry import BaseBuildArguments, BuildArguments + from flatpaker.entry import BuildFlatpakConfig JsonWriterImpl = typing.Callable[[Description, pathlib.Path, str, pathlib.Path, pathlib.Path], None] @@ -30,7 +30,7 @@ def select_impl(name: EngineName) -> JsonWriterImpl: return mod.write_rules -def _build(args: BaseBuildArguments, path: pathlib.Path) -> None: +def _build(args: BuildFlatpakConfig, path: pathlib.Path) -> None: description = load_description(path) # TODO: This could be common appid = f"{description.common.reverse_url}.{util.sanitize_name(description.common.name)}" @@ -61,7 +61,7 @@ def _build(args: BaseBuildArguments, path: pathlib.Path) -> None: shutil.rmtree('build', ignore_errors=True) -def build_flatpak(args: BuildArguments) -> bool: +def build_flatpak(args: BuildFlatpakConfig) -> bool: success = True for d in args.descriptions: diff --git a/flatpaker/actions/build_runtime.py b/flatpaker/actions/build_runtime.py index 708fb81..458ca89 100644 --- a/flatpaker/actions/build_runtime.py +++ b/flatpaker/actions/build_runtime.py @@ -11,10 +11,10 @@ from flatpaker import util if typing.TYPE_CHECKING: - from ..entry import BaseBuildArguments, BuildRuntimeArguments + from ..entry import BuildRuntimeConfig -def _build_runtime(args: BaseBuildArguments, sdk: pathlib.Path, +def _build_runtime(args: BuildRuntimeConfig, sdk: pathlib.Path, need_platform_workaround: bool) -> None: build_command: list[str] = [ 'flatpak-builder', '--force-clean', '--user', @@ -69,7 +69,7 @@ def _need_platform_workaround() -> bool: return tuple(int(v) for v in raw_ver.split('.')) < (1, 4, 5) -def build_runtimes(args: BuildRuntimeArguments) -> bool: +def build_runtimes(args: BuildRuntimeConfig) -> bool: command = [ 'flatpak', 'install', '--no-auto-pin', '--user', f'org.freedesktop.Platform//{util.RUNTIME_VERSION}', diff --git a/flatpaker/actions/generate.py b/flatpaker/actions/generate.py index 1aab883..d0384a1 100644 --- a/flatpaker/actions/generate.py +++ b/flatpaker/actions/generate.py @@ -15,10 +15,10 @@ if typing.TYPE_CHECKING: import tomlkit.items # noqa: TC004 - from flatpaker.entry import GenerateArguments + from flatpaker.entry import GenerateConfig -def generate(args: GenerateArguments) -> bool: +def generate(args: GenerateConfig) -> bool: name = f'{args.url}.{util.sanitize_name(args.appname)}' projectdir = pathlib.Path(name) sourcedir = projectdir / 'sources' @@ -49,7 +49,7 @@ def add(table: tomlkit.items.Table, key: str, entry: object, doc.add('appdata', appdata) archives: list[tomlkit.items.Table] = [] - for src in [args.archive] + args.archives: + for src in args.archives: archive = tomlkit.table() add(archive, 'path', os.path.join(sourcedir.name, os.path.basename(src))) add(archive, 'sha256', util.sha256(pathlib.Path(src))) @@ -93,7 +93,7 @@ def add(table: tomlkit.items.Table, key: str, entry: object, tomlkit.dump(doc, f) # move files after writing the toml, so we don't move things then fail - for srcs, subdir in [([args.archive] + args.archives + args.files, sourcedir), + for srcs, subdir in [(args.archives + args.files, sourcedir), (args.patches, patchdir)]: for src in srcs: srcp = pathlib.Path(src) diff --git a/flatpaker/entry.py b/flatpaker/entry.py index 6ce7df2..9bb023c 100644 --- a/flatpaker/entry.py +++ b/flatpaker/entry.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import dataclasses import pathlib import subprocess import sys @@ -45,7 +46,46 @@ class GenerateArguments(BaseArguments, typing.Protocol): files: list[str] -def static_deltas(args: BaseBuildArguments) -> None: +@dataclasses.dataclass(slots=False, eq=False) +class _BuildCommonConfig: + """Common configuration for "build" and "build-runtimes".""" + + repo: str + gpg: str | None + export: ExportMode + cleanup: bool + deltas: bool + keep_going: bool + + +@dataclasses.dataclass(slots=False, eq=False) +class BuildFlatpakConfig(_BuildCommonConfig): + """Configuration for "build".""" + + descriptions: list[pathlib.Path] + + +@dataclasses.dataclass(slots=False, eq=False) +class BuildRuntimeConfig(_BuildCommonConfig): + """Configuration for "build-runtimes".""" + + runtimes: list[EngineName] + + +@dataclasses.dataclass(slots=False, eq=False) +class GenerateConfig: + """Configuration for "generate".""" + + url: str + appname: str + engine: EngineName + archives: list[str] + patches: list[str] + files: list[str] + + + +def static_deltas(args: BuildRuntimeConfig | BuildFlatpakConfig) -> None: if not (args.deltas or args.export != 'repo'): return command = ['flatpak', 'build-update-repo', args.repo, '--generate-static-deltas'] @@ -152,22 +192,56 @@ def _parse_args() -> BaseArguments: return base -def main() -> None: +def _args_to_config() -> BuildFlatpakConfig | BuildRuntimeConfig | GenerateConfig: args = _parse_args() - success = True - match args.action: case 'build': bargs = typing.cast('BuildArguments', args) - success = build_flatpak(bargs) - if bargs.deltas: - static_deltas(bargs) + return BuildFlatpakConfig( + repo=bargs.repo, + cleanup=bargs.cleanup, + deltas=bargs.deltas, + export=bargs.export, + gpg=bargs.gpg, + keep_going=bargs.keep_going, + descriptions=bargs.descriptions, + ) case 'build-runtimes': - brargs = typing.cast('BuildRuntimeArguments', args) - success = build_runtimes(brargs) - if brargs.deltas: - static_deltas(brargs) + rargs = typing.cast('BuildRuntimeArguments', args) + return BuildRuntimeConfig( + repo=rargs.repo, + cleanup=rargs.cleanup, + deltas=rargs.deltas, + export=rargs.export, + gpg=rargs.gpg, + keep_going=rargs.keep_going, + runtimes=rargs.runtimes, + ) case 'generate': - success = generate(typing.cast('GenerateArguments', args)) + gargs = typing.cast('GenerateArguments', args) + return GenerateConfig( + appname=gargs.appname, + archives=[gargs.archive] + gargs.archives, + engine=gargs.engine, + files=gargs.files, + patches=gargs.patches, + url=gargs.url, + ) + +def main() -> None: + config = _args_to_config() + success = True + + match config: + case BuildFlatpakConfig(): + success = build_flatpak(config) + if config.deltas: + static_deltas(config) + case BuildRuntimeConfig(): + success = build_runtimes(config) + if config.deltas: + static_deltas(config) + case GenerateConfig(): + success = generate(config) sys.exit(0 if success else 1) From 0a719eeae206c0c40760c02ab00c5dc00c5927de Mon Sep 17 00:00:00 2001 From: Dylan Baker Date: Tue, 18 Aug 2026 20:52:56 +0000 Subject: [PATCH 5/8] runtime: use a single runtime base instead of multiple --- flatpaker/actions/build_runtime.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/flatpaker/actions/build_runtime.py b/flatpaker/actions/build_runtime.py index 458ca89..1e32832 100644 --- a/flatpaker/actions/build_runtime.py +++ b/flatpaker/actions/build_runtime.py @@ -14,6 +14,9 @@ from ..entry import BuildRuntimeConfig +_RUNTIME_ID_BASE = 'com.github.dcbaker.flatpaker' + + def _build_runtime(args: BuildRuntimeConfig, sdk: pathlib.Path, need_platform_workaround: bool) -> None: build_command: list[str] = [ @@ -30,6 +33,7 @@ def _build_runtime(args: BuildRuntimeConfig, sdk: pathlib.Path, subprocess.run(build_command, check=True) + platform_id = sdk.name.removeprefix(_RUNTIME_ID_BASE).removeprefix('.').split('.', maxsplit=1)[0] # Work around https://github.com/flatpak/flatpak-builder/issues/630 if need_platform_workaround and args.export == 'install' and 'Sdk' in sdk.name: if '8' in sdk.name: @@ -42,7 +46,6 @@ def _build_runtime(args: BuildRuntimeConfig, sdk: pathlib.Path, raise RuntimeError('Unexpected Sdk') repo = pathlib.Path('.flatpak-builder/cache').absolute().as_posix() - platform_id = '.'.join(sdk.name.split('.', maxsplit=5)[:-1]) install_command = [ 'flatpak', 'install', '--user', '-y', '--noninteractive', @@ -77,16 +80,15 @@ def build_runtimes(args: BuildRuntimeConfig) -> bool: ] subprocess.run(command, check=True) - basename = 'com.github.dcbaker.flatpaker' runtimes: list[str] = [] if 'rpgmaker' in args.runtimes: - runtimes.append(f'{basename}.RPGM.Platform.yml') + runtimes.append(f'{_RUNTIME_ID_BASE}.RPGM.Platform.yml') if 'renpy8' in args.runtimes: - runtimes.append(f'{basename}.RenPy.8.Sdk.yml') + runtimes.append(f'{_RUNTIME_ID_BASE}.RenPy.8.Sdk.yml') if 'renpy7' in args.runtimes: - runtimes.append(f'{basename}.RenPy.7.py2.Sdk.yml') + runtimes.append(f'{_RUNTIME_ID_BASE}.RenPy.7.py2.Sdk.yml') if 'renpy7-py3' in args.runtimes: - runtimes.append(f'{basename}.RenPy.7.py3.Sdk.yml') + runtimes.append(f'{_RUNTIME_ID_BASE}.RenPy.7.py3.Sdk.yml') success = True From 36f6d9e452fe6622dc5494fc2e0018fe6008a170 Mon Sep 17 00:00:00 2001 From: Dylan Baker Date: Tue, 18 Aug 2026 20:36:37 +0000 Subject: [PATCH 6/8] use `flatpak-builder --install-deps-from` So we don't have to do this manually. --- flatpaker/actions/build_flatpak.py | 3 ++- flatpaker/actions/build_runtime.py | 7 ------- flatpaker/impl/rpgmaker.py | 2 +- flatpaker/util.py | 2 -- 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/flatpaker/actions/build_flatpak.py b/flatpaker/actions/build_flatpak.py index b666632..37d1a7d 100644 --- a/flatpaker/actions/build_flatpak.py +++ b/flatpaker/actions/build_flatpak.py @@ -44,7 +44,8 @@ def _build(args: BuildFlatpakConfig, path: pathlib.Path) -> None: write_build_rules(description, workdir, appid, desktop_file, appdata_file) build_command: list[str] = [ - 'flatpak-builder', '--force-clean', '--user', 'build', + 'flatpak-builder', '--install-deps-from=flathub', + '--force-clean', '--user', 'build', (workdir / f'{appid}.json').absolute().as_posix(), ] diff --git a/flatpaker/actions/build_runtime.py b/flatpaker/actions/build_runtime.py index 1e32832..3493a13 100644 --- a/flatpaker/actions/build_runtime.py +++ b/flatpaker/actions/build_runtime.py @@ -73,13 +73,6 @@ def _need_platform_workaround() -> bool: def build_runtimes(args: BuildRuntimeConfig) -> bool: - command = [ - 'flatpak', 'install', '--no-auto-pin', '--user', - f'org.freedesktop.Platform//{util.RUNTIME_VERSION}', - f'org.freedesktop.Sdk//{util.RUNTIME_VERSION}', - ] - subprocess.run(command, check=True) - runtimes: list[str] = [] if 'rpgmaker' in args.runtimes: runtimes.append(f'{_RUNTIME_ID_BASE}.RPGM.Platform.yml') diff --git a/flatpaker/impl/rpgmaker.py b/flatpaker/impl/rpgmaker.py index a09ddc7..0a1fbd5 100644 --- a/flatpaker/impl/rpgmaker.py +++ b/flatpaker/impl/rpgmaker.py @@ -65,7 +65,7 @@ def write_rules(description: Description, workdir: pathlib.Path, appid: str, des ] struct = { - 'sdk': f'org.freedesktop.Sdk//{util.RUNTIME_VERSION}', + 'sdk': 'org.freedesktop.Sdk//24.08', 'runtime': 'com.github.dcbaker.flatpaker.RPGM.Platform', 'runtime-version': 'master', 'id': appid, diff --git a/flatpaker/util.py b/flatpaker/util.py index 0f4be22..3ff90b2 100644 --- a/flatpaker/util.py +++ b/flatpaker/util.py @@ -15,8 +15,6 @@ if typing.TYPE_CHECKING: from .description import Description -RUNTIME_VERSION = "24.08" - def _subelem(elem: ET.Element, tag: str, text: str | None = None, **extra: str) -> ET.Element: new = ET.SubElement(elem, tag, extra) From 4ed71e40499492553ea1e0056e7f924bec565ada Mon Sep 17 00:00:00 2001 From: Dylan Baker Date: Tue, 18 Aug 2026 20:43:32 +0000 Subject: [PATCH 7/8] util: Update signature of `tmpdir` Using `Iterator` is deprecated now --- flatpaker/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flatpaker/util.py b/flatpaker/util.py index 3ff90b2..a855f60 100644 --- a/flatpaker/util.py +++ b/flatpaker/util.py @@ -145,7 +145,7 @@ def sanitize_name(name: str) -> str: @contextlib.contextmanager -def tmpdir(name: str, cleanup: bool = True) -> typing.Iterator[pathlib.Path]: +def tmpdir(name: str, cleanup: bool = True) -> typing.Generator[pathlib.Path]: tdir = pathlib.Path(tempfile.gettempdir()) / 'flatpaker' / name tdir.mkdir(parents=True, exist_ok=True) yield tdir From fc96d6b7f2dde63337eb7ec60de719811b521e6c Mon Sep 17 00:00:00 2001 From: Dylan Baker Date: Mon, 17 Aug 2026 20:52:53 +0000 Subject: [PATCH 8/8] add support for publishing to a flat-manager instance This adds a requirement for keyring, which is used to store the repo token secret. I don't plan to support any other way to store and retrieve the token, the logic here isn't actually that complicated, so an end user can just write their own uploader if they want that. Fixes: #41 --- .github/workflows/lint.yml | 2 +- README.md | 34 ++++++++-- flatpaker/actions/build_flatpak.py | 16 ++++- flatpaker/actions/build_runtime.py | 39 +++++++---- flatpaker/config.py | 55 ++++++++++++++- flatpaker/entry.py | 104 ++++++++++++++++++++++++++++- flatpaker/util.py | 28 ++++++++ pyproject.toml | 3 + 8 files changed, 259 insertions(+), 22 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index dceb2cf..356a50f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,7 +23,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install mypy ruff tomlkit + pip install mypy ruff tomlkit keyring - name: Lint with Ruff run: ruff check flatpaker - name: Lint with mypy diff --git a/README.md b/README.md index 7d3c878..e06e45c 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,10 @@ to provide even better security by using Wayland instead of X11 (or XWayland). 3. Generate a toml description `flatpaker generate com.developer.game "Game Name" engine archive.zip` 4. Edit the generated description to fill in missing information 5. run `flatpaker build-runtimes --export=install` (which adds the runtimes and sdks) -6. run `flatpaker build --export=install *.toml` or `flatpaker build - --export=repo --gpg-sign *.toml` (for local install or for export to a shared - repo) +6. Run one of: + - `flatpaker build --export=install *.toml` to install for your user + - `flatpaker build --export=repo --gpg-sign *.toml` to export to a local ostree repo + - `flatpaker build --export=flat-manager *.toml` to export to a flat-manager instance ### Toml Format @@ -185,17 +186,42 @@ That file must be written to `$XDG_CONFIG_HOME/flatpaker/config.toml` (if unset repo = "/path/to/a/repo/to/export" # The default export mode - # May be one of: "none", "install", "repo" + # May be one of: "none", "install", "repo", "flat-manager" export = "none" + +[flat-manager] + # The address that the flat-manager instance listens on + remote = "https://flat-manager.example.com:8080" + + # The default repo on that flat-manager instance to push to + repo = "stable" + + # Only one of the following may be set. This is the repo key that + # will be passed to the flat-manager client. + + # A pair of strings to pass to `keyring.get_password()` + # The token can be written into the keyring with the command line tool + # `keyring set `. + token-keyring = ["service name", "keyname"] + + # A file containing the key + # Both environment variables and `~` can be used here + # This is the only form that can be set on the command line + token-file = "/secret.d/flat-manager/1" + + # The keyfile written straight into the config file + token-str = "ABC123" ``` ## What is required? - python >= 3.10 +- python-keyring (if using flat-manager export with a secret stored in a keyring) - python-tomlkit - flatpak-builder - flatpak +- flat-manager-client (if using flat-manager export) ### Schema diff --git a/flatpaker/actions/build_flatpak.py b/flatpaker/actions/build_flatpak.py index 37d1a7d..ab353db 100644 --- a/flatpaker/actions/build_flatpak.py +++ b/flatpaker/actions/build_flatpak.py @@ -49,15 +49,29 @@ def _build(args: BuildFlatpakConfig, path: pathlib.Path) -> None: (workdir / f'{appid}.json').absolute().as_posix(), ] + repo = args.repo + match args.export: case 'repo': - build_command.extend(['--repo', args.repo]) + build_command.extend(['--repo', repo]) if args.gpg: build_command.extend(['--gpg-sign', args.gpg]) case 'install': build_command.extend(['--install']) + case 'flat-manager': + # Use a temporary repo for each runtime and app + # This simplifies uploading with flat-manager-client + repos = pathlib.Path.cwd() / '.flat-manager-repos' + repos.mkdir(exist_ok=True) + repo = repos.joinpath(appid).as_posix() + build_command.extend(['--repo', repo]) subprocess.run(build_command, check=True) + + if args.export == 'flat-manager': + assert args.flat_manager is not None + util.export_to_flat_manager(repo, args.flat_manager) + if args.cleanup: shutil.rmtree('build', ignore_errors=True) diff --git a/flatpaker/actions/build_runtime.py b/flatpaker/actions/build_runtime.py index 3493a13..8c48958 100644 --- a/flatpaker/actions/build_runtime.py +++ b/flatpaker/actions/build_runtime.py @@ -17,39 +17,54 @@ _RUNTIME_ID_BASE = 'com.github.dcbaker.flatpaker' +def _get_renpy_branch(sdk: str) -> str: + if '8' in sdk: + return '8' + elif '7.py2' in sdk: + return '7' + elif '7.py3' in sdk: + return '7-PY3' + raise RuntimeError("Unknown Ren'Py branch") + + def _build_runtime(args: BuildRuntimeConfig, sdk: pathlib.Path, need_platform_workaround: bool) -> None: build_command: list[str] = [ 'flatpak-builder', '--force-clean', '--user', '--install-deps-from=flathub', 'build', sdk.as_posix()] + repo = args.repo + match args.export: case 'repo': - build_command.extend(['--repo', args.repo]) + build_command.extend(['--repo', repo]) if args.gpg: build_command.extend(['--gpg-sign', args.gpg]) case 'install': build_command.extend(['--install']) + case 'flat-manager': + # Use a temporary repo for each runtime and app + # This simplifies uploading with flat-manager-client + repos = pathlib.Path.cwd() / '.flat-manager-repos' + repos.mkdir(exist_ok=True) + repo = repos.joinpath(sdk.name).as_posix() + build_command.extend(['--repo', repo]) subprocess.run(build_command, check=True) + if args.export == 'flat-manager': + assert args.flat_manager is not None + util.export_to_flat_manager(repo, args.flat_manager) + platform_id = sdk.name.removeprefix(_RUNTIME_ID_BASE).removeprefix('.').split('.', maxsplit=1)[0] # Work around https://github.com/flatpak/flatpak-builder/issues/630 - if need_platform_workaround and args.export == 'install' and 'Sdk' in sdk.name: - if '8' in sdk.name: - branch = '8' - elif '7.py2' in sdk.name: - branch = '7' - elif '7.py3' in sdk.name: - branch = '7-PY3' - else: - raise RuntimeError('Unexpected Sdk') - + if need_platform_workaround and args.export == 'install' and platform_id == 'RenPy': repo = pathlib.Path('.flatpak-builder/cache').absolute().as_posix() + branch = _get_renpy_branch(sdk.name) install_command = [ 'flatpak', 'install', '--user', '-y', '--noninteractive', - '--reinstall', repo, f'{platform_id}.Platform//{branch}', + '--reinstall', repo, f'{_RUNTIME_ID_BASE}.{platform_id}.Platform//{branch}', ] subprocess.run(install_command, check=True) diff --git a/flatpaker/config.py b/flatpaker/config.py index 723a6e1..7614f94 100644 --- a/flatpaker/config.py +++ b/flatpaker/config.py @@ -9,8 +9,19 @@ import tomlkit if typing.TYPE_CHECKING: + ExportMode = typing.Literal['none', 'repo', 'install', 'flat-manager'] - ExportMode = typing.Literal['none', 'repo', 'install'] + FlatManager = typing.TypedDict( + 'FlatManager', + { + 'remote': str, + 'repo': str, + 'token-file': str, + 'token-str': str, + 'token-keyring': tuple[str, str] + }, + total=False, + ) Common = typing.TypedDict( 'Common', @@ -22,8 +33,40 @@ total=False, ) - class Config(typing.TypedDict): - common: Common + Config = typing.TypedDict( + 'Config', + { + 'common': Common, + 'flat-manager': FlatManager, + }, + ) + + +def _load_flat_manager(raw: dict[str, object]) -> FlatManager: + token_file = raw.get('token-file', None) + token_str = raw.get('token-str', None) + token_key = raw.get('token-keyring', None) + + if len([k for k in [token_file, token_str, token_key] if k is not None]) > 1: + raise TypeError('Configuration file may only contain one of: ' + '"flat-manager.token-file", "flat-manager.token-str", or ' + '"flat-manager.token-key"') + + if token_file is not None and not isinstance(token_file, str): + raise TypeError('Configuration key "flat-manager.token-file" must be a string') + if token_str is not None and not isinstance(token_str, str): + raise TypeError('Configuration key "flat-manager.token-str" must be a string') + if token_key is not None: + if not isinstance(token_key, list): + raise TypeError('Configuration key "flat-manager.token-key" must be a a list') + if any(not isinstance(k, str) for k in token_key): + raise TypeError('Configuration key "flat-manager.token-key" elements must be strings') + if len(token_key) != 2: + raise TypeError('Configuration key "flat-manager.token-key" must be an array ' + 'with exactly two elements') + raw['token-keyring'] = tuple(token_key) + + return typing.cast('FlatManager', raw) def load_config() -> Config: @@ -39,4 +82,10 @@ def load_config() -> Config: if 'common' not in raw: raw['common'] = {} + + if fm := raw.get('flat-manager'): + raw['flat-manager'] = _load_flat_manager(fm) + else: + raw['flat-manager'] = {} + return typing.cast('Config', raw) diff --git a/flatpaker/entry.py b/flatpaker/entry.py index 9bb023c..043d898 100644 --- a/flatpaker/entry.py +++ b/flatpaker/entry.py @@ -5,6 +5,7 @@ import argparse import dataclasses +import os import pathlib import subprocess import sys @@ -29,6 +30,12 @@ class BaseBuildArguments(BaseArguments, typing.Protocol): cleanup: bool deltas: bool keep_going: bool + flat_manager_remote: str | None + flat_manager_repo: str | None + flat_manager_token: str | None + flat_manager_token_file: str | None + flat_manager_token_keyring_service: str | None + flat_manager_token_keyring_keyid: str | None class BuildArguments(BaseBuildArguments, typing.Protocol): descriptions: list[pathlib.Path] @@ -46,6 +53,14 @@ class GenerateArguments(BaseArguments, typing.Protocol): files: list[str] +@dataclasses.dataclass(slots=False, eq=False) +class FlatManagerConfig: + + remote: str + repo: str + token: str + + @dataclasses.dataclass(slots=False, eq=False) class _BuildCommonConfig: """Common configuration for "build" and "build-runtimes".""" @@ -56,6 +71,7 @@ class _BuildCommonConfig: cleanup: bool deltas: bool keep_going: bool + flat_manager: FlatManagerConfig | None @dataclasses.dataclass(slots=False, eq=False) @@ -113,12 +129,48 @@ def _parse_args() -> BaseArguments: pp.add_argument( '--export', action='store', - choices=['none', 'install', 'repo'], + choices=['none', 'install', 'repo', 'flat-manager'], default=config['common'].get('export', 'none'), help='Export the repo using one of the following methods. ' '"none": Do not export, only build; ' '"export": write to an ostree repo; ' '"install": install for the user(useful for testing)') + pp.add_argument( + '--flat-manager-remote', + action='store', + default=config['flat-manager'].get('remote'), + help='The flat-manager url', + ) + pp.add_argument( + '--flat-manager-repo', + action='store', + default=config['flat-manager'].get('repo'), + help='The repo of the flat-manager instance to manage', + ) + pp.add_argument( + '--flat-manager-token', + action='store', + default=config['flat-manager'].get('token-str'), + help='A path to a file containing a flat-manager repo token', + ) + pp.add_argument( + '--flat-manager-token-file', + action='store', + default=config['flat-manager'].get('token-file'), + help='Path to a file containing flat-manager repo token', + ) + pp.add_argument( + '--flat-manager-token-keyring-service', + action='store', + default=config['flat-manager'].get('token-keyring', (None, None))[0], + help='A service to pass to `keyring.get_password(service, keyid)', + ) + pp.add_argument( + '--flat-manager-token-keyring-keyid', + action='store', + default=config['flat-manager'].get('token-keyring', (None, None))[1], + help='A keyid to pass to `keyring.get_password(service, keyid)', + ) pp.add_argument('--no-cleanup', action='store_false', dest='cleanup', help="don't delete the temporary directory") pp.add_argument( '--static-deltas', @@ -188,10 +240,58 @@ def _parse_args() -> BaseArguments: runargs = typing.cast('BaseBuildArguments', base) if runargs.export == 'repo' and not runargs.repo: parser.error('export is set to "repo", but no "repo" is defined') + if runargs.export == 'flat-manager': + if not runargs.flat_manager_remote: + parser.error('export is set to "flat-manager", but "flat-manager-remote" is not defined') + if not runargs.flat_manager_repo: + parser.error('export is set to "flat-manager", but "flat-manager-repo" is not defined') + if type(runargs.flat_manager_token_keyring_keyid) != type(runargs.flat_manager_token_keyring_service): + parser.error('only one of: "flat-manager-token-keyring-service" and ' + '"flat-manager-token-keyring-keyid" is set. ' + 'Both must be set to use the keyring.') + # We can check either service or keyid here, since we know they're both None or they're both str + if not any([runargs.flat_manager_token, runargs.flat_manager_token_file, + runargs.flat_manager_token_keyring_service]): + parser.error('export is set to "flat-manager", but no flat-manager token is defined') return base +def _flat_manager_config(args: BaseBuildArguments) -> FlatManagerConfig | None: + if args.export != 'flat-manager': + return None + + repo = args.flat_manager_repo + assert repo is not None + remote = args.flat_manager_remote + assert remote is not None + + if args.flat_manager_token: + token = args.flat_manager_token + elif p := args.flat_manager_token_file: + with open(os.path.expanduser(os.path.expandvars(p)), 'r', encoding='utf-8') as f: + token = f.read().strip() + else: + # This is imported here becaue it's optional. + # Someday this can be `lazy import`ed + try: + import keyring + except ImportError as e: + raise RuntimeError('Requested the use of `keyring` for flat-manager runtime secret, ' + 'but the keyring module cannot be imported') from e + + service = args.flat_manager_token_keyring_service + assert service is not None + keyid = args.flat_manager_token_keyring_keyid + assert keyid is not None + if t := keyring.get_password(service, keyid): + token = t + else: + raise RuntimeError(f'There is not keyring secret available for: "{service}":"{keyid}"') + + return FlatManagerConfig(remote, repo, token) + + def _args_to_config() -> BuildFlatpakConfig | BuildRuntimeConfig | GenerateConfig: args = _parse_args() match args.action: @@ -205,6 +305,7 @@ def _args_to_config() -> BuildFlatpakConfig | BuildRuntimeConfig | GenerateConfi gpg=bargs.gpg, keep_going=bargs.keep_going, descriptions=bargs.descriptions, + flat_manager=_flat_manager_config(bargs) ) case 'build-runtimes': rargs = typing.cast('BuildRuntimeArguments', args) @@ -216,6 +317,7 @@ def _args_to_config() -> BuildFlatpakConfig | BuildRuntimeConfig | GenerateConfi gpg=rargs.gpg, keep_going=rargs.keep_going, runtimes=rargs.runtimes, + flat_manager=_flat_manager_config(rargs) ) case 'generate': gargs = typing.cast('GenerateArguments', args) diff --git a/flatpaker/util.py b/flatpaker/util.py index a855f60..692e33e 100644 --- a/flatpaker/util.py +++ b/flatpaker/util.py @@ -7,6 +7,7 @@ import hashlib import pathlib import shutil +import subprocess import tempfile import textwrap import typing @@ -14,6 +15,7 @@ if typing.TYPE_CHECKING: from .description import Description + from .entry import FlatManagerConfig def _subelem(elem: ET.Element, tag: str, text: str | None = None, **extra: str) -> ET.Element: @@ -180,3 +182,29 @@ def bd_metadata(desktop: pathlib.Path, appdata: pathlib.Path, game: list[str]) - 'install -Dm755 game.sh -t /app/bin', ], } + + +def export_to_flat_manager(repodir: str, config: FlatManagerConfig) -> None: + env = {'REPO_TOKEN': config.token} + exe = shutil.which('flat-manager-client') + if exe is None: + raise RuntimeError('Could not find flat-manager-client!') + + out = subprocess.run( + [exe, 'create', config.remote, config.repo], + check=True, + env=env, + stdout=subprocess.PIPE, + text=True, + timeout=5, + ) + build = out.stdout.strip() + + # Now that we have a build repo, we want to ensure it is purged even if we + # somewhere along the line + try: + cmd = [exe, 'push', '--commit', '--publish', build, repodir] + subprocess.run(cmd, check=True, env=env) + finally: + subprocess.run([exe, 'purge', build], env=env, check=True) + diff --git a/pyproject.toml b/pyproject.toml index 891ce7c..ed6f4df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ ] dependencies = ['tomlkit'] + [project.optional-dependencies] + flat-manager = ["keyring"] + [project.scripts] flatpaker = "flatpaker.entry:main"