-
-
Notifications
You must be signed in to change notification settings - Fork 56
Expand file tree
/
Copy pathbuild-plugin.sh
More file actions
executable file
·1646 lines (1506 loc) · 72.9 KB
/
Copy pathbuild-plugin.sh
File metadata and controls
executable file
·1646 lines (1506 loc) · 72.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
# Unifideck Plugin Build Script - new-architecture branch
#
# This script is responsible for preparing and packaging the Unifideck plugin
# for Decky Loader. It handles both "production" and "development" builds,
# pre-build requirements (like downloading external binaries and compiling
# locale files), and packages the final structure into a .zip file.
#
# It supports two build strategies:
# 1. Docker/Podman with the Decky CLI (preferred, matches upstream CI).
# 2. Local bash/pnpm fallback (useful for direct Steam Deck builds without containers).
#
# Reflects the 5-layer package restructure (v0.7+)
# Exit immediately if a command exits with a non-zero status.
set -e
# Establish absolute paths to ensure script works regardless of where it's called from.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLI_LOCATION="$SCRIPT_DIR/cli"
OUTPUT_DIR="$SCRIPT_DIR/out"
# ── Colors ──────────────────────────────────────────────────
# Standard ANSI color codes for readable terminal output.
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; NC='\033[0m'
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[✓]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# ── Argument parsing ─────────────────────────────────────────
usage() {
cat <<'EOF'
Usage: ./build-plugin.sh [dev|prod] [install|quick-install] [push]
The mode is optional and defaults to dev, so it can be left off entirely.
If you do name one, it must come first.
dev (default) development build; stays entirely on this machine
prod production build (uses package.json version)
install after build, full reinstall to ~/homebrew/plugins/Unifideck
(rm -rf + unzip + chown - ~30s)
quick-install skip build entirely, rsync source → install in seconds.
Use after editing Python / config / bundled binaries.
Includes defaults/ so config never drifts. For frontend
edits, run `pnpm run build` first to refresh dist/.
push dev only: publish the built zip to GitHub as a new
"Dev-*" prerelease and retire the previous one. WITHOUT
this argument nothing is ever uploaded to GitHub.
-h, --help show this message
Examples:
./build-plugin.sh # local dev zip, no install, no upload
./build-plugin.sh quick-install # fastest deploy to the live plugin
./build-plugin.sh install push # rebuild, install, publish for testers
./build-plugin.sh push # dev build, publish it for testers
./build-plugin.sh prod # release zip in out/
EOF
}
# `push` is deliberately opt-in. Publishing creates a remote tag + prerelease
# AND deletes the previous dev release, so an ordinary local iteration build
# must never touch the repo of its own accord (see publish_dev_release).
case "${1:-}" in
-h|--help|help)
usage
exit 0
;;
esac
# The mode is OPTIONAL, not merely defaulted: only consume $1 when it actually
# names one. Eating $1 unconditionally made every mode-less invocation die on
# "Unknown mode: push" - a dev build is the overwhelmingly common case and
# should not have to be spelled out.
ENV_MODE="dev"
case "${1:-}" in
dev|prod)
ENV_MODE="$1"
shift
;;
esac
INSTALL_AFTER=""
PUSH_DEV_RELEASE=0
# Every argument is validated. Silently ignoring an unrecognised one (as this
# used to for $2) costs a full build cycle to notice - a typo'd "instal" simply
# never installed.
for arg in "$@"; do
case "$arg" in
-h|--help|help)
usage
exit 0
;;
install|quick-install)
if [ -n "$INSTALL_AFTER" ] && [ "$INSTALL_AFTER" != "$arg" ]; then
log_error "Cannot combine 'install' and 'quick-install'."
echo ""
usage
exit 1
fi
INSTALL_AFTER="$arg"
;;
push)
PUSH_DEV_RELEASE=1
;;
dev|prod)
# Only reachable when the mode is misplaced ("push dev"), since a
# leading mode was already consumed above.
log_error "'$arg' is a build mode: put it first, or omit it"
log_error " entirely for a dev build."
exit 1
;;
*)
log_error "Unknown argument: $arg"
echo ""
usage
exit 1
;;
esac
done
if [ "$PUSH_DEV_RELEASE" = "1" ] && [ "$ENV_MODE" = "prod" ]; then
log_error "'push' is a dev-only argument. Production releases go through"
log_error " the draft-release flow, not the dev prerelease tag."
exit 1
fi
if [ "$PUSH_DEV_RELEASE" = "1" ] && [ "$INSTALL_AFTER" = "quick-install" ]; then
log_error "'push' cannot be combined with 'quick-install': quick-install"
log_error " rsyncs the source tree and never builds a zip to publish."
exit 1
fi
# Parse the base version from package.json (the JS/UI project).
# We use grep/sed here instead of `jq` so we don't require the user to have `jq` installed.
PACKAGE_VERSION=$(grep '"version"' "$SCRIPT_DIR/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
if [[ "$ENV_MODE" == "prod" ]]; then
# Production builds use exact version numbers.
VERSION_TAG="v$PACKAGE_VERSION"
ZIP_NAME="unifideck.prod.$VERSION_TAG.zip"
PLUGIN_VERSION="$PACKAGE_VERSION"
# Empty on purpose (see _write_dev_build_json): a prod zip still
# ships a dev_build.json, just with a blank build_id, so installing
# it actively clears any dev_build.json left behind by a previous
# dev install - Decky's own plugin installer overlays the new zip's
# files onto the existing plugin directory rather than wiping it
# first, so a file the new zip doesn't contain is never removed.
GIT_BRANCH=""
GIT_SHA=""
DEV_BUILD_ID=""
log_info "Building in PRODUCTION mode ($VERSION_TAG)"
elif [[ "$ENV_MODE" == "dev" ]]; then
mkdir -p "$OUTPUT_DIR"
# ── Dev build identifier ──────────────────────────────────
# A dev build that is pushed (the `push` argument) publishes its own
# GitHub prerelease, tagged "Dev-<date>-<time>-<sha>" (see
# publish_dev_release). That tag is deliberately free of any "X.Y"
# substring, so the updater's version parser can't turn it into a semver -
# meaning nothing in the GitHub API's version field can tell two dev builds
# apart. We bake a self-describing identifier (branch + short commit SHA)
# into the asset FILENAME itself, and stamp the same value into
# dev_build.json inside the zip (see _write_dev_build_json), so the
# pre-install dropdown (reads the filename) and the post-install "Current"
# line (reads dev_build.json) show the same build id. Local-only builds
# carry the same identifier - it is what tells two unpushed zips in out/
# apart too.
#
# Branch name (not $PACKAGE_VERSION) leads the identifier: working
# branches are named after the target version (e.g. "0.7.1") while
# package.json/plugin.json intentionally stay frozen at the last
# official release until the real version-bump commit.
GIT_BRANCH=""
GIT_SHA=""
DEV_BUILD_ID=""
if command -v git >/dev/null 2>&1 \
&& git -C "$SCRIPT_DIR" rev-parse --git-dir >/dev/null 2>&1; then
GIT_BRANCH=$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
GIT_SHA=$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || echo "")
BRANCH_LABEL="$GIT_BRANCH"
# Detached HEAD makes "HEAD" a useless leading component - fall
# back to package.json's version for that segment only.
if [ -z "$BRANCH_LABEL" ] || [ "$BRANCH_LABEL" = "HEAD" ]; then
BRANCH_LABEL="$PACKAGE_VERSION"
fi
# Branch names may contain '/' (e.g. "feature/x"), which would
# break the zip filename / GitHub asset name.
BRANCH_LABEL=$(echo "$BRANCH_LABEL" | tr '/' '-')
if [ -n "$GIT_SHA" ]; then
DEV_BUILD_ID="${BRANCH_LABEL}.g${GIT_SHA}"
fi
fi
if [ -z "$DEV_BUILD_ID" ]; then
# Fallback: no git available (e.g. a source tarball with no
# .git). A build must never fail just because we couldn't
# compute a cosmetic identifier - reuse the old local counter.
log_warn "git unavailable - falling back to local dev counter for build id"
LATEST_DEV=$(ls -1 "$OUTPUT_DIR"/unifideck.dev.v*.zip 2>/dev/null | \
sed 's/.*unifideck\.dev\.v\([0-9]*\)\.zip/\1/' | sort -n | tail -1)
DEV_VER=$([ -z "$LATEST_DEV" ] && echo 1 || echo $((LATEST_DEV + 1)))
DEV_BUILD_ID="v$DEV_VER"
fi
VERSION_TAG="$DEV_BUILD_ID"
ZIP_NAME="unifideck.dev.$DEV_BUILD_ID.zip"
PLUGIN_VERSION="$PACKAGE_VERSION-dev.$DEV_BUILD_ID"
log_info "Building in DEVELOPMENT mode ($VERSION_TAG)"
else
# Unreachable: the parser above only ever assigns "dev" or "prod". Kept as
# a backstop so a future edit to that case can't silently build nothing.
log_error "Unknown mode: $ENV_MODE. Use 'dev' or 'prod'."
exit 1
fi
# The final absolute path where the .zip file will be saved.
OUTPUT_FILE="$OUTPUT_DIR/$ZIP_NAME"
echo "========================================="
echo "Unifideck Plugin Build Script (v0.7+)"
echo "========================================="
echo "Mode: $ENV_MODE"
echo "Target: $OUTPUT_FILE"
echo ""
# ── Binary versions (sourced from package.json remote_binary) ─
# To add a new binary:
# 1. Add it to "remote_binary" in package.json.
# 2. Add the URL below.
# 3. Add a check step inside the `prebuild_binaries` function.
# These must stay in sync with package.json "remote_binary" entries:
# tests/unit/_tooling/test_binary_manifest_sync.py fails the build if they drift.
#
# NOTE: legendary >=0.20.40 and gogdl >=1.2.2 ship a Python ZIPAPP for Linux,
# not a PyInstaller ELF. They need a `python3` on PATH (shebang) and a writable
# HOME - they extract native modules to ~/.cache/{legendary,heroic_gogdl}/
# on first run. That applies to this build host too, since _download_bin
# validates by executing the file it just downloaded.
LEGENDARY_URL="https://github.com/Heroic-Games-Launcher/legendary/releases/download/0.20.43/legendary_linux_x86_64"
GOGDL_URL="https://github.com/Heroic-Games-Launcher/heroic-gogdl/releases/download/v1.3.0/gogdl_linux_x86_64"
NILE_URL="https://github.com/imLinguin/nile/releases/download/v1.1.2/nile_linux_x86_64"
COMET_URL="https://github.com/imLinguin/comet/releases/download/v0.3.2/comet-x86_64-unknown-linux-gnu"
WINETRICKS_URL="https://raw.githubusercontent.com/Winetricks/winetricks/20260125/src/winetricks"
# ── Pre-build: download/verify bundled binaries ───────────────
# Decky Loader expects all dependencies to be included in the zip file.
# This function pulls down the large third-party store clients.
prebuild_binaries() {
log_info "Running pre-build binary checks..."
# Helper function: Downloads to a `.new` file, marks executable,
# and runs a quick validation command (usually `--version`) before
# swapping it in place. This prevents corrupt downloads from breaking the plugin.
#
# Skips the download entirely when the binary we already have was fetched
# from this exact URL. Every URL in the manifest is version-pinned (e.g.
# .../download/0.20.43/legendary_linux_x86_64), so "same URL" *is* "same
# version" - a bump changes the URL and re-downloads on its own.
#
# This matters more than it looks: the five binaries are ~28 MB combined and
# were re-fetched on EVERY build. GitHub's release-asset throughput to a
# Deck is not dependable (measured at ~100 KB/s, turning prebuild alone into
# 274 s, while the same assets can arrive in seconds on a good day). That
# single step was dominating total build time and made it look like the
# whole script had regressed.
_download_bin() {
local name="$1" url="$2" dest="$3" validate_cmd="$4"
local stamp="$dest.url"
log_info "Checking $name..."
# Cache hit: binary present, executable, and stamped with this URL.
if [ -x "$dest" ] && [ "$(cat "$stamp" 2>/dev/null)" = "$url" ]; then
log_success "$name up to date (cached)"
return 0
fi
if curl -fsSL "$url" -o "$dest.new"; then
chmod +x "$dest.new"
if eval "$validate_cmd" > /dev/null 2>&1; then
mv "$dest.new" "$dest"
printf '%s\n' "$url" > "$stamp"
log_success "$name downloaded/verified"
else
rm -f "$dest.new"
log_warn "$name: downloaded binary failed validation, keeping existing"
fi
else
log_warn "$name: download failed, keeping existing"
fi
}
# Legendary: Epic Games Store CLI.
_download_bin "legendary" "$LEGENDARY_URL" "$SCRIPT_DIR/bin/legendary" \
'"$SCRIPT_DIR/bin/legendary.new" --version'
# Gogdl: GOG download manager (developed by Heroic).
_download_bin "gogdl" "$GOGDL_URL" "$SCRIPT_DIR/bin/gogdl" \
'"$SCRIPT_DIR/bin/gogdl.new" --version --auth-config-path /dev/null'
# Nile: Amazon Games CLI.
_download_bin "nile" "$NILE_URL" "$SCRIPT_DIR/bin/nile" \
'"$SCRIPT_DIR/bin/nile.new" --version'
# Comet: GOG Galaxy online services wrapper.
_download_bin "comet" "$COMET_URL" "$SCRIPT_DIR/bin/comet" \
'"$SCRIPT_DIR/bin/comet.new" --version'
# Winetricks is a shell script, so it doesn't have a reliable --version flag.
# Validated by checking for the "WINETRICKS_VERSION" string instead, which is
# why it cannot reuse _download_bin - but it gets the same URL stamp so a
# rebuild skips it too.
log_info "Checking winetricks..."
if [ -x "$SCRIPT_DIR/bin/winetricks" ] \
&& [ "$(cat "$SCRIPT_DIR/bin/winetricks.url" 2>/dev/null)" \
= "$WINETRICKS_URL" ]; then
log_success "winetricks up to date (cached)"
elif curl -fsSL "$WINETRICKS_URL" -o "$SCRIPT_DIR/bin/winetricks.new"; then
chmod +x "$SCRIPT_DIR/bin/winetricks.new"
if grep -q "WINETRICKS_VERSION" "$SCRIPT_DIR/bin/winetricks.new"; then
mv "$SCRIPT_DIR/bin/winetricks.new" "$SCRIPT_DIR/bin/winetricks"
printf '%s\n' "$WINETRICKS_URL" > "$SCRIPT_DIR/bin/winetricks.url"
log_success "winetricks downloaded/verified"
else
rm -f "$SCRIPT_DIR/bin/winetricks.new"
log_warn "winetricks: downloaded file invalid, keeping existing"
fi
else
log_warn "winetricks: download failed, keeping existing"
fi
echo ""
}
# ── Pre-build: requirements check ────────────────────────────
# Decky's backend expects a requirements.txt file to install Python dependencies.
# We keep requirements.in as the source of truth, so this step ensures
# it is correctly mirrored to requirements.txt for the build system.
check_requirements() {
if [ ! -f "$SCRIPT_DIR/requirements.txt" ] && [ -f "$SCRIPT_DIR/requirements.in" ]; then
log_info "requirements.txt missing - copying from requirements.in..."
cp "$SCRIPT_DIR/requirements.in" "$SCRIPT_DIR/requirements.txt"
log_success "Created requirements.txt"
elif [ ! -f "$SCRIPT_DIR/requirements.txt" ]; then
log_warn "requirements.txt missing and requirements.in not found!"
fi
}
# ── Pre-build: vendor Python deps into py_modules/ ──────────
# Decky Loader is *supposed* to pip-install requirements.txt at
# plugin load time, but in practice this is unreliable across
# Loader versions. We vendor the wheels into py_modules/ ourselves
# so the install zip is self-contained and doesn't depend on the
# Loader's pip behaviour.
#
# We download manylinux wheels for Python 3.11 (Decky Loader's bundled
# Python, which runs the plugin backend), regardless of the host's
# Python. ``--only-binary :all:`` refuses sdists so we never accidentally
# compile against the host's libpython.
#
# NOTE: the Steam shortcut launcher (bin/unifideck-launcher) does NOT run
# under Decky's Python - it runs under the *system* /usr/bin/python3, whose
# minor version varies by distro (SteamOS/Bazzite/CachyOS are 3.13 today, but
# CachyOS is rolling and Arch will bump to 3.14). Any ABI-specific C extension
# that a launcher code path imports must therefore be vendored for Decky's
# Python AND every system Python we want to support. Today that's cffi's
# ``_cffi_backend`` (pulled in by cryptography via the cloud-save / token
# paths): see vendor_launcher_cffi(), which loops LAUNCHER_PYTHON_VERSIONS.
# Without a matching .so the launcher now degrades cloud-save gracefully
# (see launcher/dispatcher.py) rather than aborting every game launch.
#
# Idempotent: if a package is already in py_modules/ we leave it
# alone (--upgrade-strategy only-if-needed). Disk-cheap and fast
# (~2s when fully cached, ~10s on first run).
DECK_PYTHON_VERSION="3.11"
# The launcher runs under the HOST system Python, whose minor version differs
# across distros (SteamOS/Bazzite/CachyOS are 3.13 today, but CachyOS is rolling
# and Arch will bump to 3.14; an older Fedora rebase could still be on 3.12).
# We vendor _cffi_backend for EVERY version in this list so whichever
# /usr/bin/python3 the host ships finds a matching ABI .so. Versions with no
# published cffi wheel are skipped gracefully. See vendor_launcher_cffi().
# Keep this range in sync with ACCEPTED_VERSIONS in
# py_modules/unifideck/launcher/proton/infrastructure/selector.py.
LAUNCHER_PYTHON_VERSIONS=(3.10 3.11 3.12 3.13 3.14)
DECK_PLATFORM_TAG="manylinux2014_x86_64"
vendor_deps() {
[ -f "$SCRIPT_DIR/requirements.txt" ] || {
log_warn "requirements.txt not found - skipping vendor step"
return 0
}
log_info "Vendoring Python deps into py_modules/ (Python $DECK_PYTHON_VERSION, $DECK_PLATFORM_TAG)..."
# Use a quiet cache dir so repeated builds don't re-download.
local cache_dir="$SCRIPT_DIR/.cache/pip-vendor"
mkdir -p "$cache_dir"
# --target installs into py_modules/ instead of site-packages.
# --platform + --python-version + --only-binary force the
# SteamOS-compatible wheel set regardless of host interpreter.
# --upgrade-strategy only-if-needed avoids churning unchanged deps.
if python3 -m pip install \
--quiet \
--target "$SCRIPT_DIR/py_modules" \
--platform "$DECK_PLATFORM_TAG" \
--python-version "$DECK_PYTHON_VERSION" \
--only-binary ":all:" \
--upgrade \
--upgrade-strategy only-if-needed \
--cache-dir "$cache_dir" \
-r "$SCRIPT_DIR/requirements.txt" 2>&1 | tail -20; then
log_success "Python deps vendored"
else
log_warn "vendor_deps failed - the zip may be missing required Python deps"
log_warn "(check that you have pip and a network connection)"
fi
prune_stale_dist_info
# Sanity-check that the four runtime deps actually landed.
local missing_deps=()
for dep in aiohttp websockets cryptography jsonschema; do
if ! ls "$SCRIPT_DIR/py_modules/$dep" >/dev/null 2>&1 \
&& ! ls "$SCRIPT_DIR/py_modules/${dep}-"*.dist-info >/dev/null 2>&1; then
missing_deps+=("$dep")
fi
done
if [ "${#missing_deps[@]}" -gt 0 ]; then
log_warn "Missing vendored deps after pip install: ${missing_deps[*]}"
log_warn "Plugin features depending on these will be disabled at runtime."
fi
echo ""
}
# Drop *.dist-info directories left behind by earlier vendoring runs.
#
# ``pip install --target --upgrade`` overwrites a package's .py files but
# NEVER removes the previous version's metadata directory. Over many builds
# py_modules/ accumulates one dist-info per version ever vendored - we had
# SIX for aiohttp (3.13.3 through 3.14.3), four each for websockets/yarl/idna,
# and two for cffi. Two things break because of that:
#
# * pip-audit (quality.yml) walks this directory and reports CVEs against
# versions that are not the ones actually importable, so the CVE floors
# documented in requirements.txt are gated against phantom data.
# * vendor_launcher_cffi() below derived the cffi version from a glob whose
# alphabetically-first match was the STALE dist-info - so it vendored
# _cffi_backend 2.0.0 next to the cffi 2.1.0 package it had just
# installed, and every FFI() construction raised "Version mismatch".
#
# pip writes a package's dist-info at install time, so the most recently
# modified one is the live copy (verified: newest mtime == the version in the
# package's own __version__, for aiohttp/cffi/cryptography alike). Keep that
# one, delete its predecessors.
prune_stale_dist_info() {
local pruned=0 pkg newest d
# Group by distribution name: strip the trailing -<version>.dist-info.
while IFS= read -r pkg; do
# -t sorts by mtime, newest first; everything after the first is stale.
newest=1
while IFS= read -r d; do
if [ "$newest" = "1" ]; then
newest=0
continue
fi
rm -rf "$d"
pruned=$((pruned + 1))
done < <(ls -dt "$SCRIPT_DIR/py_modules/${pkg}-"*.dist-info 2>/dev/null)
done < <(
ls -d "$SCRIPT_DIR"/py_modules/*.dist-info 2>/dev/null \
| sed -E 's#.*/([^/]+)-[^-]+\.dist-info$#\1#' | sort -u
)
if [ "$pruned" -gt 0 ]; then
log_success "Pruned $pruned stale dist-info director$([ "$pruned" = 1 ] && echo y || echo ies)"
fi
}
# Vendor cffi's ABI-specific _cffi_backend for the LAUNCHER's Python too.
#
# vendor_deps() above targets Decky's Python ($DECK_PYTHON_VERSION) and so
# only produces _cffi_backend.cpython-311-*.so. But bin/unifideck-launcher
# runs under the system /usr/bin/python3, whose minor version varies by host
# (see LAUNCHER_PYTHON_VERSIONS). The cloud-save / token-refresh paths import
# cryptography → cffi → _cffi_backend at load time; under a system Python with
# no matching .so the import raises and (historically) the launcher aborted:
# killing ALL game launches. cryptography's own binding is abi3
# (version-agnostic) and the rest of cffi is pure-python, so the ONLY
# ABI-specific piece we need for the system interpreter is _cffi_backend.
#
# To stay portable across distros (SteamOS/Bazzite/CachyOS, and future Python
# bumps) we vendor _cffi_backend for EVERY version in LAUNCHER_PYTHON_VERSIONS.
# CPython's import machinery auto-selects the .so whose ABI tag matches the
# running interpreter, so no runtime code change is needed. Versions with no
# published cffi wheel (e.g. a not-yet-released CPython) are skipped with a
# warning. Pure-python files are identical across versions so they're left
# untouched.
vendor_launcher_cffi() {
local cffi_ver
# Read the version from the PACKAGE, not from a dist-info glob. cffi's own
# api.py compares its __version__ against the compiled backend's and raises
# on any difference, so the package is the only source of truth that
# matters. (The old glob took the alphabetically-first dist-info, which
# after an upgrade was the stale one - it pinned the backend to the version
# we had just replaced. See prune_stale_dist_info above.)
cffi_ver=$(sed -nE 's/^__version__ *= *"([^"]+)".*/\1/p' \
"$SCRIPT_DIR/py_modules/cffi/__init__.py" 2>/dev/null | head -1)
if [ -z "$cffi_ver" ]; then
log_info "cffi not vendored (no cloud-save crypto path) - skipping launcher cffi"
return 0
fi
# The .so files carry no readable version, so record which cffi they were
# built for. Build-local (never shipped): a fresh clone simply re-vendors.
# Without this the "already present" check below was version-blind and
# happily kept a mismatched backend forever.
local stamp="$SCRIPT_DIR/.cache/cffi-backend-version"
local stamped=""
[ -f "$stamp" ] && stamped=$(cat "$stamp" 2>/dev/null)
if [ "$stamped" != "$cffi_ver" ]; then
if [ -n "$stamped" ] || ls "$SCRIPT_DIR"/py_modules/_cffi_backend.*.so \
>/dev/null 2>&1; then
log_warn "cffi backend stamp '${stamped:-none}' != package $cffi_ver - re-vendoring backends"
fi
rm -f "$SCRIPT_DIR"/py_modules/_cffi_backend.*.so
fi
local vendored=() skipped=()
local ver abitag tmp
for ver in "${LAUNCHER_PYTHON_VERSIONS[@]}"; do
abitag="cpython-3${ver#3.}"
# Already present AND known to match cffi_ver (stamp verified above).
if ls "$SCRIPT_DIR"/py_modules/_cffi_backend.${abitag}-*.so \
>/dev/null 2>&1; then
vendored+=("$ver")
continue
fi
tmp=$(mktemp -d)
if python3 -m pip install \
--quiet \
--target "$tmp" \
--platform "$DECK_PLATFORM_TAG" \
--python-version "$ver" \
--only-binary ":all:" \
--no-deps \
--cache-dir "$SCRIPT_DIR/.cache/pip-vendor" \
"cffi==$cffi_ver" 2>&1 | tail -5 \
&& cp -f "$tmp"/_cffi_backend.${abitag}-*.so \
"$SCRIPT_DIR/py_modules/" 2>/dev/null; then
vendored+=("$ver")
else
# No wheel for this Python (e.g. unreleased) - non-fatal.
skipped+=("$ver")
fi
rm -rf "$tmp"
done
if [ "${#vendored[@]}" -gt 0 ]; then
mkdir -p "$(dirname "$stamp")"
printf '%s\n' "$cffi_ver" > "$stamp"
log_success "Vendored launcher cffi backends (cffi==$cffi_ver) for Python: ${vendored[*]}"
fi
if [ "${#skipped[@]}" -gt 0 ]; then
log_warn "No cffi wheel for Python: ${skipped[*]} - cloud-save degrades gracefully on those hosts"
fi
echo ""
}
# ── Pre-build: generate src/i18n/locales.generated.ts ────────
# The frontend uses i18next for localization, but ES modules cannot natively
# do dynamic imports from JSON without breaking the bundler configuration.
# This python script reads our supported languages config and generates
# a `.ts` file with static imports that Rollup can consume.
gen_locales() {
log_info "Generating src/i18n/locales.generated.ts..."
if cd "$SCRIPT_DIR/scripts" && python3 gen_locale_imports.py \
--config "$SCRIPT_DIR/defaults/config.json" \
--output "$SCRIPT_DIR/src/i18n/locales.generated.ts" 2>&1; then
cd "$SCRIPT_DIR"
log_success "locales.generated.ts ready"
else
cd "$SCRIPT_DIR"
log_warn "Locale generation failed - build may fail if file is missing"
fi
}
# ── Pre-build: read version from plugin.json ─────────────────
# Grab the plugin version defined in Decky's plugin manifest for our logs.
sync_version() {
PLUGIN_VERSION=$(grep '"version"' "$SCRIPT_DIR/plugin.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
log_info "Plugin version (plugin.json): $PLUGIN_VERSION"
echo ""
}
# ── Decky CLI detection ───────────────────────────────────────
# The Decky CLI handles building the UI and packaging everything securely.
# These functions download the appropriate CLI binary for the host OS.
# Asset names are ``decky-<os>-<arch>`` — a BARE binary, not an archive,
# and the arch is spelled ``x86_64``/``aarch64`` (uname's spelling), not
# ``x64``/``arm64``. Getting either wrong 404s.
#
# This was wrong in both respects and nobody noticed, because a working
# ``cli/decky`` was committed to the repo and ``check_decky_cli`` returns
# early when the file is already there. Deleting that binary (PR #270) is
# what surfaced it: the download 404s, the build silently falls back to
# the local path, and on a clean checkout there is no CLI at all.
get_decky_cli_url() {
local os arch base="https://github.com/SteamDeckHomebrew/cli/releases/latest/download"
case "$(uname -s)" in Linux*) os="linux";; Darwin*) os="macOS";; CYGWIN*|MINGW*|MSYS*) os="windows";; *) os="linux";; esac
case "$(uname -m)" in x86_64|amd64) arch="x86_64";; arm64|aarch64) arch="aarch64";; *) arch="x86_64";; esac
if [ "$os" = "windows" ]; then echo "${base}/decky-${os}-${arch}.exe"
else echo "${base}/decky-${os}-${arch}"; fi
}
check_decky_cli() {
local cli="$CLI_LOCATION/decky"
# If the CLI is already present and works, proceed.
if test -f "$cli" && "$cli" --version > /dev/null 2>&1; then return 0; fi
# If it's present but broken (e.g. built for the wrong architecture after moving files), clear it.
if test -f "$cli"; then
log_warn "Decky CLI incompatible with this platform - re-downloading..."
rm -f "$cli"
fi
log_info "Downloading Decky CLI for $(uname -s)/$(uname -m)..."
local url; url=$(get_decky_cli_url)
mkdir -p "$CLI_LOCATION"
# ``-f`` so a 404 is a curl failure instead of an HTML error page
# written to disk and then chmod'd executable.
if curl -fsSL "$url" -o "$cli"; then
chmod +x "$cli" 2>/dev/null || true
if "$cli" --version > /dev/null 2>&1; then log_success "Decky CLI ready"; return 0; fi
log_warn "Downloaded Decky CLI does not run on this platform"
rm -f "$cli"
fi
log_warn "Could not download Decky CLI ($url) - will use local build"
return 1
}
# Determines whether Docker or Podman is available for the Decky CLI to use.
# Podman is the default on SteamOS.
check_container_engine() {
if command -v docker &>/dev/null && docker info &>/dev/null 2>&1; then echo "docker"; return 0; fi
if command -v podman &>/dev/null && podman info &>/dev/null 2>&1; then echo "podman"; return 0; fi
return 1
}
# ── Staging directory contents ───────────────────────────────
# Mirrors the exact runtime layout expected by Decky Loader on the Steam Deck.
# It avoids zipping unnecessary dev files (.git, tests, etc.)
# Directories included (relative to repo root):
# py_modules/ - vendored deps + unifideck 5-layer package
# bin/ - native binaries + shell wrappers (no .py scripts allowed here)
# defaults/ - config.json schema + backend defaults
# src/ - TypeScript source (built into dist/ by Decky CLI)
# assets/ - plugin artwork/icons
# Files included:
# main.py, plugin.json, package.json, pnpm-lock.yaml,
# tsconfig.json, rollup.config.mjs, requirements.txt,
# LICENSE, README.md
# ── Dev build-identity stamp ──────────────────────────────────
# Writes dev_build.json to $1, read at runtime by
# UpdaterService.get_current_build_id() so the Settings UI can show
# which specific dev build is installed. Shipped in EVERY build, dev
# and prod alike - DEV_BUILD_ID/GIT_BRANCH/GIT_SHA are simply empty
# strings for a prod build, so get_current_build_id() correctly reads
# no build id back. This is deliberate, not redundant: Decky's own
# plugin installer overlays a newly-installed zip's files onto the
# existing plugin directory instead of wiping it first, so a file
# absent from the new zip is never removed - without a prod build
# also shipping (and thereby overwriting) this file, a stale dev
# install's dev_build.json would linger and the Settings UI would
# claim a dev build is "installed" long after a prod version replaced
# it.
_write_dev_build_json() {
local target="$1"
cat > "$target" <<EOF
{
"build_id": "$DEV_BUILD_ID",
"branch": "$GIT_BRANCH",
"commit": "$GIT_SHA",
"built_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
}
# Stamps dev_build.json into an already-built plugin ZIP. This runs
# AFTER packaging rather than being staged alongside main.py/plugin.json
# beforehand: the Decky CLI's containerized `plugin build` step does its
# own internal repackaging of the staging directory and silently drops
# any file it doesn't recognize (confirmed - pnpm-lock.yaml, tsconfig.json,
# rollup.config.mjs, and requirements.txt also don't survive into its
# output, alongside a first attempt at staging dev_build.json the same
# way). Appending the file directly to the finished ZIP sidesteps that
# entirely, and works identically for both build paths.
_inject_dev_build_json() {
local zip_path="$1"
local tmp; tmp=$(mktemp -d)
mkdir -p "$tmp/Unifideck"
_write_dev_build_json "$tmp/Unifideck/dev_build.json"
(cd "$tmp" && zip -q "$zip_path" "Unifideck/dev_build.json")
rm -rf "$tmp"
}
_stage_plugin_files() {
local dest="$1"
mkdir -p "$dest"
for dir in py_modules bin defaults src; do
[ -d "$SCRIPT_DIR/$dir" ] && cp -r "$SCRIPT_DIR/$dir" "$dest/"
done
cp -r "$SCRIPT_DIR/assets" "$dest/" 2>/dev/null || true
for f in main.py plugin.json package.json pnpm-lock.yaml tsconfig.json \
rollup.config.mjs requirements.txt LICENSE README.md; do
[ -f "$SCRIPT_DIR/$f" ] && cp "$SCRIPT_DIR/$f" "$dest/"
done
# Build-time leftovers that must never ship: the per-binary download stamps
# (see prebuild_binaries) and any half-finished download, plus the lint
# caches, which the copy above otherwise drags in from py_modules/. Both
# caches are created by simply running the CI gate block before a build,
# so a release build is exactly when they are most likely to be present.
rm -f "$dest"/bin/*.url "$dest"/bin/*.new
rm -rf "$dest/py_modules/.mypy_cache" "$dest/py_modules/.import_linter_cache"
}
# ── Skip the Decky CLI's redundant binary re-download ─────────
# The CLI downloads every ``remote_binary`` entry from package.json into the
# container on EVERY build - the same ~28 MB of store CLIs that
# ``prebuild_binaries`` already fetched, verified and cached in bin/, and that
# ``_stage_plugin_files`` already copied into the staging dir. It then
# overwrites our copies with byte-identical ones.
#
# That download WAS the single largest cost of a full build. Measured A/B on
# this Deck, same tree, warm caches, minutes apart:
#
# with the CLI download 320 s total (312 s in the container)
# without it 47 s total ( 40 s in the container)
#
# 28 MB in 272 s is ~103 KB/s. The same asset fetched from the HOST with curl,
# the same afternoon, came down at 8.7 MB/s - roughly 85x faster. So this is not
# "GitHub is slow to a Deck", as it long appeared to be: it is the container's
# network path. Which is why it never looked like a variable-network problem -
# it was reliably, boringly slow on every single build.
#
# The CLI also has no cache, so it paid that cost whether or not a version had
# been bumped. A bump merely made it visible, because that is the one build
# where prebuild_binaries re-downloads too and you pay twice.
#
# So: strip ``remote_binary`` from the STAGED package.json - a throwaway copy in
# a temp dir - and let the CLI zip the binaries we staged. The repo's own
# package.json is never touched, so the manifest the Decky Store builds from,
# and the CI check that keeps it in sync with this script, are unaffected.
#
# The CLI's download step is also what verified those binaries' checksums, so we
# do it here instead, against the same ``sha256hash`` values from the same
# manifest. If ANY binary is missing or mismatched we leave ``remote_binary``
# alone and let the CLI fetch it the slow way - correctness first, speed second.
_stage_skip_cli_binary_download() {
local staged="$1"
local pkg="$staged/package.json"
[ -f "$pkg" ] || return 0
local verified
if ! verified=$(STAGED_BIN_DIR="$staged/bin" python3 - "$pkg" <<'PY'
import hashlib, json, os, sys
pkg_path = sys.argv[1]
bin_dir = os.environ["STAGED_BIN_DIR"]
with open(pkg_path) as fh:
pkg = json.load(fh)
entries = pkg.get("remote_binary") or []
if not entries:
raise SystemExit(1) # nothing to strip; let the CLI do whatever it does
for entry in entries:
path = os.path.join(bin_dir, entry["name"])
if not os.path.isfile(path) or not os.access(path, os.X_OK):
print("missing or non-executable: " + entry["name"], file=sys.stderr)
raise SystemExit(1)
digest = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
digest.update(chunk)
expected = (entry.get("sha256hash") or "").lower()
if not expected:
print("no sha256hash in manifest: " + entry["name"], file=sys.stderr)
raise SystemExit(1)
if digest.hexdigest() != expected:
print("checksum mismatch: " + entry["name"], file=sys.stderr)
raise SystemExit(1)
pkg.pop("remote_binary")
with open(pkg_path, "w") as fh:
json.dump(pkg, fh, indent=2)
fh.write("\n")
print(len(entries))
PY
); then
log_warn "Staged binaries failed verification - letting the Decky CLI"
log_warn " re-download them (slower, but guaranteed correct)."
return 0
fi
log_success "Verified $verified bundled binaries (sha256); skipping the CLI re-download"
}
# ── Build with Decky CLI (Docker/Podman) ─────────────────────
# This is the primary build path. It stages files, runs the Decky CLI inside
# a container, compiles the frontend using Rollup, and generates a clean ZIP.
build_with_cli() {
local engine="$1"
log_info "Building with Decky CLI using $engine..."
rm -f "$OUTPUT_FILE"
# Clean dist. Because container builds might leave root-owned files behind,
# we have to run a containerized `rm -rf` to delete them without `sudo`.
if [ -d "$SCRIPT_DIR/dist" ]; then
log_info "Cleaning dist/..."
rm -rf "$SCRIPT_DIR/dist" 2>/dev/null || \
"$engine" run --rm -v "$SCRIPT_DIR":/v -w /v alpine rm -rf dist
fi
# Stage files into a clean temporary directory. A trap removes it on any
# exit path, because a CLI failure trips `set -e` and would otherwise leak
# a full source copy into /tmp. The path is baked into the trap now since
# function-locals are not visible to the EXIT trap.
local staging; staging=$(mktemp -d)
trap "rm -rf '$staging'" EXIT
local staging_plugin="$staging/unifideck-staging"
_stage_plugin_files "$staging_plugin"
_stage_skip_cli_binary_download "$staging_plugin"
chmod -R a+rX "$staging_plugin" 2>/dev/null || true
# Fire up the Decky CLI builder. By default the CLI stages its own temp
# copy under /tmp/decky. A previous root-context build can leave that
# directory root-owned, and a deck build then cannot write into it, which
# fails with "Temporary build directory already exists / Permission
# denied". Point it at a fresh directory inside our own deck-owned staging
# tree so a stray root-owned /tmp/decky can never block a sudo-less build.
mkdir -p "$OUTPUT_DIR"
"$CLI_LOCATION/decky" plugin build "$staging_plugin" \
--output-path "$OUTPUT_DIR" \
--tmp-output-path "$staging/decky-tmp" \
--engine "$engine" \
--follow-symlinks \
--build-as-root
# Clean up staging dir (and disarm the trap set above)
rm -rf "$staging"
trap - EXIT
# The CLI hardcodes the output name to "Unifideck.zip". We rename it to our versioned format.
local expected="$OUTPUT_DIR/Unifideck.zip"
if [ -f "$expected" ] && [ "$expected" != "$OUTPUT_FILE" ]; then
mv "$expected" "$OUTPUT_FILE"
log_success "Renamed Unifideck.zip → $ZIP_NAME"
elif [ -f "$OUTPUT_FILE" ]; then
log_success "Build output at $ZIP_NAME"
else
log_warn "Expected CLI output not found: Unifideck.zip"
fi
# Stamped for both dev and prod builds - see _write_dev_build_json.
if [ -f "$OUTPUT_FILE" ]; then
_inject_dev_build_json "$OUTPUT_FILE"
fi
log_success "CLI build complete: $OUTPUT_FILE"
}
# ── Local build (Steam Deck / no container fallback) ─────────
# This acts as a fallback for users building directly on their Steam Deck
# without podman installed. It runs `pnpm` natively and manually zips the output.
build_local() {
log_info "Building locally (no container engine)..."
cd "$SCRIPT_DIR"
log_info "Compiling TypeScript frontend..."
if ! pnpm run build; then log_error "Frontend compilation failed"; exit 1; fi
log_success "Frontend compiled"
mkdir -p "$OUTPUT_DIR"
local build_dir; build_dir=$(mktemp -d)
trap "rm -rf '$build_dir'" EXIT
local plugin_dir="$build_dir/Unifideck"
_stage_plugin_files "$plugin_dir"
# The CLI builds into `dist/` natively. For the local fallback,
# we manually copy the newly built frontend into our staging area.
[ -d "$SCRIPT_DIR/dist" ] && cp -r "$SCRIPT_DIR/dist" "$plugin_dir/"
# ── Critical file verification ───────────────────────────
# Since we are zipping manually, we verify that every critical architectural
# component is present before packaging. If one of these is missing,
# it indicates a structural flaw (like a missing import or broken script)
# and we abort the build to prevent shipping a broken plugin.
#
# NOTE: If you add a new service or store connector, ADD IT TO THIS LIST!
log_info "Verifying critical files..."
local CRITICAL_FILES=(
# Plugin root files
"main.py"
"plugin.json"
"dist/index.js"
# Layer 1 - core/types (pure data island, no dependencies)
"py_modules/unifideck/core/types/__init__.py"
"py_modules/unifideck/core/types/domain.py"
"py_modules/unifideck/core/types/events.py"
"py_modules/unifideck/core/types/results.py"
# Layer 2 - core infrastructure (utils, binary resolvers, file I/O)
"py_modules/unifideck/core/__init__.py"
"py_modules/unifideck/core/cache_manager.py"
"py_modules/unifideck/core/sync_service.py"
"py_modules/unifideck/core/manifest.py"
"py_modules/unifideck/core/paths.py"
"py_modules/unifideck/core/exe_finder.py"
"py_modules/unifideck/core/metrics_collector.py"
"py_modules/unifideck/core/io/__init__.py"
"py_modules/unifideck/core/io/async_file_ops.py"
"py_modules/unifideck/core/io/safe_file_op.py"
"py_modules/unifideck/core/binaries/__init__.py"
"py_modules/unifideck/core/binaries/binary_resolver.py"
"py_modules/unifideck/core/binaries/binary_signatures.py"
"py_modules/unifideck/core/binaries/cli_timeouts.py"
# EventBus - Message queue and event routing
"py_modules/unifideck/event_bus/__init__.py"
"py_modules/unifideck/event_bus/event_bus.py"
"py_modules/unifideck/event_bus/priority_dispatcher.py"
"py_modules/unifideck/event_bus/event_replay.py"
"py_modules/unifideck/event_bus/event_bus_extensions.py"
"py_modules/unifideck/event_bus/bus_pipeline.py"
# Config - Validation and startup schema
"py_modules/unifideck/config/__init__.py"
"py_modules/unifideck/config/config_manager.py"
"py_modules/unifideck/config/schema.json"
"py_modules/unifideck/config/validator.py"
"py_modules/unifideck/config/startup.py"
# Bootstrap - Dependency injection and lifecycle
"py_modules/unifideck/bootstrap/boot.py"
"py_modules/unifideck/bootstrap/teardown.py"
"py_modules/unifideck/bootstrap/pipeline_factory.py"
"py_modules/unifideck/bootstrap/cache_registry.py"
# Layer 6 - RPC mixins (Frontend communication API)
"py_modules/unifideck/rpc/__init__.py"
"py_modules/unifideck/rpc/mixins/store.py"
"py_modules/unifideck/rpc/mixins/sync.py"
"py_modules/unifideck/rpc/mixins/download.py"
"py_modules/unifideck/rpc/mixins/launch.py"
"py_modules/unifideck/rpc/mixins/playtime.py"
"py_modules/unifideck/rpc/mixins/security.py"
"py_modules/unifideck/rpc/mixins/observability.py"
"py_modules/unifideck/rpc/mixins/action.py"
"py_modules/unifideck/rpc/mixins/cloud_failure.py"
"py_modules/unifideck/rpc/mixins/config_validation.py"
"py_modules/unifideck/rpc/mixins/storage.py"
"py_modules/unifideck/rpc/mixins/ui.py"
"py_modules/unifideck/rpc/mixins/updater.py"
# Layer 4 - Store connectors (3rd party API implementations)
"py_modules/unifideck/stores/__init__.py"
"py_modules/unifideck/stores/epic/__init__.py"
"py_modules/unifideck/stores/epic/store.py"
"py_modules/unifideck/stores/gog/__init__.py"
"py_modules/unifideck/stores/gog/store.py"
"py_modules/unifideck/stores/amazon/__init__.py"
"py_modules/unifideck/stores/amazon/amazon_store.py"
"py_modules/unifideck/stores/ubisoft/__init__.py"
"py_modules/unifideck/stores/ubisoft/store.py"
"py_modules/unifideck/stores/battlenet/__init__.py"
"py_modules/unifideck/stores/battlenet/store.py"
"py_modules/unifideck/launcher/proton/handlers/battlenet.py"
"py_modules/unifideck/launcher/wrapper_stores.py"
"py_modules/unifideck/stores/microsoft/__init__.py"
"py_modules/unifideck/stores/microsoft/microsoft_store.py"
# Layer 5 - Services (Cross-cutting infrastructure like downloads/art)
"py_modules/unifideck/services/__init__.py"
"py_modules/unifideck/services/download/service.py"
"py_modules/unifideck/services/playtime/service.py"
"py_modules/unifideck/services/cloud_save/service.py"
"py_modules/unifideck/services/shortcut/service.py"
"py_modules/unifideck/services/artwork/service.py"
"py_modules/unifideck/services/launcher/service.py"
"py_modules/unifideck/services/security/service.py"
"py_modules/unifideck/services/bootstrap/service_defs.py"
"py_modules/unifideck/services/bootstrap/container.py"
"py_modules/unifideck/services/metadata_service.py"
"py_modules/unifideck/services/account_service.py"
"py_modules/unifideck/services/proton_service.py"
"py_modules/unifideck/services/updater/__init__.py"
"py_modules/unifideck/services/updater/service.py"