Merge branch 'codex/epic-linux-download-launch' into codex/epic-download-latency

Integrates the Linux download/launch and polish work (#2909, #2915) so this
branch can target main after #2916.

Conflict resolution keeps both sides: latency timing marks are reapplied on
the Linux-shaped runtime blocks, install-options timing coexists with the
Linux Winetricks entry point, and the repacks modal keeps latency's removal
of the install-options prefetch while using Linux's installationAvailable.

Semantic fixes: an accepted Epic preparation that fails during Linux's
startup cleanup (which parks the record as paused) still reaches the
runtime error handler and advances the queue, and the Linux tests prepare
and persist the workspace before writing installed.json, matching the lazy
preparation introduced here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
eightlindao 2026-10-15 13:23:51 -03:00
commit cb37fda496
173 changed files with 14944 additions and 1543 deletions

View file

@ -6,3 +6,4 @@ migration.stub
hydra-python-rpc/
hydra-native/
/legendary/
native/**/target/

View file

@ -16,7 +16,35 @@ concurrency:
cancel-in-progress: true
jobs:
epic-prerequisite-supervisor:
strategy:
matrix:
os: [windows-2022, ubuntu-latest]
fail-fast: false
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.21.0
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- name: Install Linux supervisor toolchain
if: runner.os == 'Linux'
run: |
rustup target add x86_64-pc-windows-gnu
sudo apt-get update
sudo apt-get install -y gcc-mingw-w64-x86-64 wine64 msitools wixl xvfb
- name: Check and test supervisor source
run: |
cargo fmt --manifest-path native/epic-prerequisite-runner/Cargo.toml -- --check
cargo test --locked --manifest-path native/epic-prerequisite-runner/Cargo.toml
node --test scripts/epic-prerequisite-runner.test.mjs
- name: Test PE supervisor through Wine
if: runner.os == 'Linux'
run: xvfb-run -a node scripts/test-epic-prerequisite-wine.cjs
build:
needs: epic-prerequisite-supervisor
strategy:
matrix:
os: [windows-2022, ubuntu-latest]
@ -76,7 +104,9 @@ jobs:
- name: Install Linux native build prerequisites
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y build-essential cmake ninja-build pkg-config curl zip unzip tar autoconf automake libtool
run: |
rustup target add x86_64-pc-windows-gnu
sudo apt-get update && sudo apt-get install -y build-essential cmake ninja-build pkg-config curl zip unzip tar autoconf automake libtool gcc-mingw-w64-x86-64
- name: Install dependencies
run: yarn --frozen-lockfile
@ -154,6 +184,7 @@ jobs:
dist/*.AppImage
build-production:
needs: epic-prerequisite-supervisor
if: github.event_name == 'push'
strategy:
@ -215,7 +246,9 @@ jobs:
- name: Install Linux native build prerequisites
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y build-essential cmake ninja-build pkg-config curl zip unzip tar autoconf automake libtool
run: |
rustup target add x86_64-pc-windows-gnu
sudo apt-get update && sudo apt-get install -y build-essential cmake ninja-build pkg-config curl zip unzip tar autoconf automake libtool gcc-mingw-w64-x86-64
- name: Install dependencies
run: yarn --frozen-lockfile

2
.gitignore vendored
View file

@ -15,6 +15,8 @@ ludusavi/**
/hydra-native/
/legendary/
native/hydra-native/target/
native/epic-prerequisite-runner/target/
binaries/epic-prerequisite-runner/
# Sentry Config File
.env.sentry-build-plugin

View file

@ -8,3 +8,9 @@ pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
.cache/
legendary/
hydra-native/
native/**/target/
binaries/epic-prerequisite-runner/
native/epic-prerequisite-runner/tests/*.wxs

View file

@ -1,328 +0,0 @@
# SDD de sincronização de conquistas Epic no Hydra
Status: proposta de implementação. Data: 14/10/2026.
Base: launcher `codex/epic-download-launch-cloud-saves`, commit `f8e5a1ad1a067621abafca8fb887bff03731f39b`; API `fix/steam-sync-run-delete-indexes`, commit `d297f03a063283206d698713e73aff1ff83a5411`.
O sync Epic deve trazer os desbloqueios do usuário junto com jogos e playtime. A proposta é acrescentar uma etapa de coleta no main process e publicar as conquistas no mesmo snapshot Epic, seguindo a arquitetura da Steam. O backend já fornece as definições Epic, mas precisa aceitar e armazenar os desbloqueios com origem Epic. Portanto, a entrega requer mudanças no launcher e na API; o SDD fica no repositório do launcher.
O transporte recomendado é o GraphQL da loja Epic, autenticado com a sessão Legendary existente. A compatibilidade da consulta com essa sessão, inclusive em perfil privado, precisa passar por uma validação autenticada antes de habilitar a funcionalidade. A análise abaixo confirma código e contratos existentes; não representa um teste de importação contra uma conta real.
## Objetivo e escopo
Na primeira vinculação, no startup e no sync manual, importar biblioteca, playtime e conquistas Epic em uma execução. Exibir os desbloqueios na biblioteca, na página de conquistas e no perfil, preservando loja, conta vinculada e data original.
A primeira entrega cobre conquistas Epic Games Store acessíveis no perfil da loja. Jogos sem esse suporte ou com apenas conquistas EOS próprias continuam importando jogos e tempo. Importação histórica não cria notificações individuais, screenshots, souvenirs ou eventos de desbloqueio local.
Consulta ao abrir a página e após fechar o jogo pode usar o mesmo adapter em uma segunda etapa. Essa extensão não deve consultar conquistas a cada relatório de playtime de 60 segundos. Overlay, detecção instantânea durante o jogo e escrita de conquistas na Epic ficam fora deste escopo.
## Como os syncs funcionam hoje
### Epic
`EpicIntegrationCore` autentica com Legendary, confirma a vinculação no Hydra e persiste a sessão privada. `EpicSessionProvider` serializa o uso do bundle, renova o token e protege a rotação contra respostas de uma conexão antiga.
O fluxo completo em `EpicSyncOrchestrator` é:
```text
capturar conta e conexão → criar ou retomar run
→ Legendary list Windows e Mac → normalizar biblioteca
→ consultar playtime Epic → montar chunks
→ staging Hydra → commit → merge da biblioteca local → finalizar
```
Cada jogo é identificado por `namespace:catalogItemId`. Os `artifactIds` são aliases de execução e de playtime; não são IDs de conquista. A sessão usada na consulta de tempo vem de `EpicSessionProvider.token()`. O serviço consulta `/library/api/public/playtime/account/{epicAccountId}/all` diretamente da máquina do usuário.
O snapshot aceita somente `namespace`, `catalogItemId`, `artifactIds`, `name` e `playTimeInSeconds`. Não existe coleta de conquistas, campo correspondente ou relação `EpicAchievementUnlock`.
| Referência no launcher | Comportamento relevante |
| -------------------------------------------------------- | ------------------------------------------------------- |
| `src/main/services/epic/integration-core.ts:583` | Login, confirmação do vínculo e persistência da sessão. |
| `src/main/services/epic/session-provider.ts:83` e `:128` | Legendary serializado e acesso ao token no main. |
| `src/main/services/epic/legendary-sync.ts:140` | Coleta das bibliotecas Windows e Mac. |
| `src/main/services/epic/library-normalizer.ts:65` | Identidade operacional e agrupamento de artifacts. |
| `src/main/services/epic/playtime-source.ts:59` | Consulta Epic com cache e contexto da conta. |
| `src/main/services/epic/sync-orchestrator.ts:269` | Sequência de coleta, publicação e merge. |
| `src/main/services/epic/sync-runtime.ts:125` e `:231` | Endpoints Hydra e validação do contexto atual. |
| `src/main/services/epic/sync-runtime.ts:367` e `:575` | Sync individual e consultas após encerramento. |
O contexto válido inclui ambiente Hydra, usuário, geração de autenticação, `connectionId` e `epicAccountId`. Logout, troca de conta ou desconexão invalidam a operação. Esses controles também devem proteger a nova coleta.
### Steam
A Steam já executa `biblioteca/playtime → conquistas → snapshot → commit → merge`. A coleta de desbloqueios usa páginas HTML/XML da Steam Community autenticadas e associa cada conquista à chave interna do schema `IPlayerService/GetGameAchievements`. Esse fluxo não usa `ISteamUserStats/GetPlayerAchievements`.
O pool de conquistas tem oito jogos. O scrape Community é condicionado a playtime positivo; o schema é consultado para todos. Essa condição não deve ser copiada para a Epic: playtime ausente ou zero não comprova ausência de conquistas.
`buildSteamSnapshot` publica apenas desbloqueios com timestamp. Campo omitido significa coleta indisponível; array vazio significa zero desbloqueios confirmado. A API mantém a contribuição Steam separada em `SteamAchievementUnlock` e resolve a leitura do usuário a partir das origens disponíveis.
Referências: `src/main/services/steam-integration/steam-sync-orchestrator.ts:687`, `:969`; `steam-community-achievements.ts:278`, `:389`; `steam-web-api.ts:107`; `steam-sync-snapshot.ts:13`, `:50`; API `src/profile-steam/steam-snapshot-publication.service.ts:858` e `prisma/schema.prisma:414`.
Há duas diferenças que impedem copiar o código integralmente. O chunker Steam repete um jogo para dividir listas de conquistas; o protocolo Epic proíbe repetir jogos e artifacts no snapshot. Além disso, o sync Steam escreve unlocks em memória antes do commit; para Epic, o cache visível deve refletir uma publicação confirmada.
### Leitura e desbloqueios locais
`GET /games/{shop}/{objectId}/achievements` fornece o catálogo; `GET /users/{userId}/games/achievements` fornece o estado do usuário. `AchievementMemoryStore` usa a chave `${shop}:${objectId}`, sem escopo de conta. Os timestamps locais são milissegundos; os snapshots usam datas ISO.
O watcher de arquivos só coleta jogos `shop: steam`. `mergeAchievements` é o pipeline de desbloqueios locais: normaliza nomes, usa o endpoint genérico `PUT /profile/games/achievements` e pode criar notificações e souvenirs. No backend, esse endpoint atribui origem Hydra e envolve sessão, assinatura e feed. Ele não deve receber importações Epic.
Mesmo sem arquivos Epic, a leitura atual chama `firstSyncWithRemoteIfNeeded`, que só exclui `custom`, e pode executar `mergeAchievements(game, [], false)`. Esse método envia todos os unlocks já presentes em memória. Portanto, impedir o upload genérico apenas na nova coleta não basta: a primeira reconciliação local deve desviar jogos Epic, mantendo somente leitura remota, e o writer genérico deve impedir que contribuições importadas sejam reenviadas como Hydra. Referências: `src/main/events/user/get-unlocked-achievements.ts:122`; `src/main/services/achievements/achievement-watcher-manager.ts:263`, `:293`; `merge-achievements.ts:363`.
Referências: `src/main/services/achievements/collect-game-achievement-files.ts:46`; `merge-achievements.ts:286`, `:342`, `:374`; `achievement-memory-store.ts:10`; `src/main/events/user/get-unlocked-achievements.ts:26`; API `src/profile-games/profile-game-achievements.service.ts:174`, `:371`.
## Consulta das conquistas na Epic
### Transporte e cobertura
A Epic distingue conquistas EOS de conquistas Epic Games Store. As conquistas da loja são associadas às EOS de um produto; serviços EOS usam identificadores do usuário por produto. Isso não fornece ao Hydra acesso genérico às conquistas privadas de todos os jogos. A cobertura inicial deve acompanhar o perfil da loja. Referências oficiais: [configuração das conquistas da loja](https://dev.epicgames.com/docs/epic-games-store/services/epic-achievements/achievements-setup) e [identidade por produto no EOS](https://dev.epicgames.com/docs/epic-online-services/eos-fundamentals/connect-interface/connect-guide/log-in-with-an-epic-games-account).
O adapter deve fazer `POST https://launcher.store.epicgames.com/graphql`, no main, com `Authorization: Bearer <access_token>` obtido por `EpicSessionProvider.token()`. A implementação pública [EpicApi do Playnite PluginCommon](https://raw.githubusercontent.com/Lacro59/playnite-plugincommon/master/CommonPluginsStores/Epic/EpicApi.cs) consulta desbloqueios nesse endpoint e usa token EG1 para autenticar perfis privados. Ela contém consulta por sandbox e um caminho por produto. O [Legendary 0.21.1](https://github.com/legendary-gl/legendary/blob/0.21.1/legendary/api/egs.py) solicita tokens EG1. Reutilizar o bundle Hydra é, portanto, a hipótese de integração a validar; não exige desenhar um segundo login.
Consulta mínima candidata, baseada nas [operações extraídas do frontend Epic](https://gist.github.com/woctezuma/8ca464a276b15d7dfad475fd6b6cbee9):
```graphql
query PlayerAchievement($epicAccountId: String!, $sandboxId: String!) {
PlayerAchievement {
playerAchievementGameRecordsBySandbox(
epicAccountId: $epicAccountId
sandboxId: $sandboxId
) {
records {
totalUnlocked
playerAchievements {
playerAchievement {
epicAccountId
sandboxId
achievementSetId
achievementName
unlocked
unlockDate
}
}
}
}
}
}
```
Esse GraphQL é um contrato observado do storefront, sujeito a mudanças, e não uma API pública documentada para agregadores. Antes de implementá-lo como caminho habilitado, confirmar formato atual, completude e acesso com a sessão Hydra. Se a operação por sandbox estiver indisponível, validar o caminho `PlayerProfile.playerProfile.productAchievements(productId)` e a resolução explícita de `productId`. Escolher um caminho comprovado; não tentar múltiplas consultas a cada jogo nem fazer fallback silencioso para acesso anônimo.
### Identidade do jogo e do desbloqueio
O namespace executável pode diferir do sandbox das definições. A API já cobre `exec:item → catalogue primary:item` em `src/services/achievement-manager.spec.ts:238`. Consultar sempre o sandbox associado às definições do catálogo Epic resolvido pelo backend.
As definições existentes têm:
```ts
{
name: "epic_<identificador canônico do Hydra>",
externalSetId: string, // achievementSetId da Epic
externalId: string, // achievementName do desbloqueio
// textos localizados, ícones e pontos definidos pelo backend
}
```
A API gera/preserva `name` por identidade do catálogo e par externo. O launcher deve transportar `externalSetId` e `externalId` exatamente como recebidos. Não recriar o hash, aplicar uppercase aos IDs externos ou associar por título, tradução, posição, ícone ou identidade Steam.
Referências na API: `src/lambdas/steam-achievements/epic.ts:123`; `src/services/epic-achievement-definitions.ts:72`; `src/services/achievement-manager.ts:273`, `:305`; `src/games/game-achievements.service.ts:80`.
Para disponibilizar o sandbox correto sem milhares de requests individuais, adicionar um endpoint de descritores em lotes, proposta:
```text
POST /profile/integrations/epic/achievement-targets
{ connectionId, objectIds: ["namespace:catalogItemId", ...] } // até 250
→ { targets: [{ objectId, status, achievementSandboxId?, productId? }] }
```
`status` deve distinguir `supported`, `unsupported` e `unavailable`. O servidor resolve cada objeto pela mesma associação executável → catálogo já utilizada pelo import, inclusive para jogos recém-coletados ainda não publicados. O descritor não comprova posse; a publicação mantém seus controles próprios. Associação ausente ou ambígua não usa aproximação por nome. Como o catálogo atual retorna um array de conquistas sem esse descritor, esta é uma adição de contrato, não uma capacidade já disponível.
Deduplicar consultas pelo sandbox, mantendo o vínculo com cada objeto operacional. O mesmo resultado só pode ser aplicado a objetos cuja associação foi confirmada pelo backend.
## Arquitetura proposta no launcher
```mermaid
sequenceDiagram
participant L as Launcher main
participant E as Epic
participant H as Hydra API
participant U as Interface
L->>E: Biblioteca Legendary e playtime
L->>H: Resolver descritores Epic em lotes
H-->>L: Sandbox das conquistas por jogo
L->>E: GraphQL do usuário autenticado
E-->>L: IDs externos e datas
L->>H: Chunks de jogos, playtime e conquistas
L->>H: Commit com connectionId
H->>H: Resolver definições e reconciliar origem Epic
H-->>L: Publicação confirmada e resumo
L->>H: Merge de biblioteca e leitura de conquistas
L-->>U: Contadores e listas atualizados
```
Criar `src/main/services/epic/achievement-source.ts` para transporte e coleta, `achievement-protocol.ts` para validar resposta GraphQL e `achievement-normalizer.ts` para normalizar os desbloqueios. Usar dependências injetáveis de fetch, relógio, sessão e espera, seguindo o padrão de `EpicPlaytimeSource`.
Acrescentar `deps.achievements(context, games, signal, progress)` entre a aplicação de playtime e `buildEpicSnapshotChunks` em `sync-orchestrator.ts:275`. `EpicSyncRuntime` fornece descritores e fonte. A nova etapa retorna jogos enriquecidos e um resumo, sem gravar unlocks visíveis antes da publicação.
Não envolver toda a coleta em `session.serialize()` e chamar `session.token()` de dentro desse lease: a renovação também usa a fila e pode bloquear a própria operação. Obter tokens pelo provider; compartilhar uma renovação em andamento para evitar que falhas simultâneas forcem vários refreshes.
### Resposta completa e erros
Publicar somente entradas `unlocked: true`, com par externo não vazio e `unlockDate` válida, normalizada para ISO UTC. Conferir conta e sandbox retornados quando presentes, estrutura de records e duplicidade exata do par. Não converter progresso ou XP em desbloqueio e não criar uma data com `Date.now()`.
Resposta vazia só produz `[]` quando o adapter comprova que a consulta completa representa zero desbloqueios. `null`, campo ausente, erro de privacidade ou GraphQL `errors` não são zero. Se houver unlock com data inválida, ID ausente, pares conflitantes ou resposta truncada, preservar a contribuição anterior daquele jogo e marcar coleta incompleta. Não publicar uma lista filtrada como snapshot autoritativo.
Não há paginação de conquistas comprovada no código analisado. A validação inicial deve confirmar se o retorno é completo e como consultar páginas quando aplicável. Se houver paginação, limitar páginas, itens e bytes, rejeitar cursores repetidos e produzir resultado autoritativo somente após concluir todas as páginas. Não inventar parâmetros `cursor` ou `page` para a operação candidata.
Reutilizar `fetchEpicWithRetry` para HTTP: um refresh após 401, até três tentativas de rede/5xx e até cinco em 429, respeitando `Retry-After`. Acrescentar classificação de erros dentro de HTTP 200: sessão expirada, permissão, limite, schema incompatível e resposta inválida. HTTP 403 isolado não força novo login; 401 persistente interrompe a coleta restante e sinaliza reconexão.
Falhas na facet de conquistas não impedem publicar biblioteca/playtime já coletados. Os jogos afetados levam `achievements: null`; os demais mantêm seus resultados válidos. Aborto solicitado, logout ou troca de contexto interrompem a operação inteira e seguem as regras existentes de staging e commit.
### Orçamento e tamanho
Proposta inicial: quatro consultas simultâneas, timeout de 15 segundos por request e orçamento total de 90 segundos para conquistas, limitado pelo tempo restante do sync de cinco minutos. Reservar tempo para staging, commit e merge. Ao esgotar o orçamento, interromper workers de conquistas com um controller próprio e publicar jogos/playtime; jogos não concluídos preservam a facet anterior. Separar esse timeout de um cancelamento do usuário.
Primeiro sync considera todos os sandboxes suportados, mesmo com playtime zero. Para bibliotecas que não cabem no orçamento, manter uma fila de pendências escopada à conexão e priorizá-la no próximo sync, evitando consultar sempre os primeiros jogos. Sync manual força consulta fresca; cache de transporte não transforma falha em sucesso.
Limites existentes: 10.000 jogos, 250 jogos/chunk, 100 chunks, 1 MiB/chunk e 16 artifacts/jogo. Propor limites novos compartilhados de 2.000 unlocks/jogo, 100.000 unlocks/snapshot e 255 caracteres por componente externo; confirmar sua suficiência com fixtures reais na primeira etapa. IDs maiores não devem ser truncados.
O chunker mede o JSON completo em UTF-8. Se uma facet exceder um limite, publicar esse jogo sem a facet e registrar resultado parcial. Se as facets fizerem o snapshot ultrapassar 100 chunks, omitir facets de forma determinística até que o snapshot de biblioteca válido caiba. Nunca repetir o objeto Epic para fragmentá-lo. Uma biblioteca que exceda os limites mesmo sem conquistas mantém o erro atual de snapshot.
### Retomada
Manter staging/commit existentes. Em run PENDING, recoletar e reenviar todos os índices; `stage()` substitui o payload completo do índice via upsert. Se `totalChunks` mudar, cancelar staging e criar novo run. Fazer commit somente depois de todos os índices da nova coleta terem sido enviados, evitando misturar versões.
Em SUCCEEDED, retomar somente o merge local, sem nova coleta Epic. Persistir o resumo de conquistas no journal ou recuperá-lo do run para reportar corretamente uma execução parcial após crash. Falha de merge local não desfaz uma publicação confirmada.
## Contrato e persistência necessários na API
### Snapshot
Estender os tipos do launcher e `src/validation/profile-epic-sync.validation.ts` com:
```ts
interface EpicSnapshotAchievement {
externalSetId: string;
externalId: string;
unlockTime: string; // ISO UTC validada
}
interface EpicSnapshotGame {
namespace: string;
catalogItemId: string;
artifactIds: string[];
name: string;
playTimeInSeconds: number;
achievements?: EpicSnapshotAchievement[] | null;
}
```
| Campo de conquistas | Semântica do snapshot completo |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Omitido ou `null` | Não houve coleta completa; preservar contribuição Epic anterior. |
| `[]` | Coleta completa com zero desbloqueios; remover somente a contribuição Epic deste jogo/conexão, respeitando atualizações posteriores ao snapshot. |
| Lista | Reconciliar a contribuição Epic com os desbloqueios confirmados, preservando outras origens. |
Os endpoints de start, chunks e commit continuam os mesmos. `epicSnapshotGameSchema` e `epicIndividualSyncSchema` atuais são strict; enviar o novo campo ao backend antigo faz o sync falhar.
Adicionar uma capability opcional, proposta `capabilities.achievementsSnapshotVersion: 1`, na resposta da integração. Sem capability, o launcher mantém o payload atual e não inicia coleta nem envia campos novos. Publicar backend antes do launcher. O campo deve ser opcional para clientes e servidores anteriores; não fazer fallback depois de um commit de resultado incerto.
Cliente habilitado também envia `achievementSnapshotVersion: 1` como campo opcional no start do run, para distinguir importação solicitada de execução de um cliente antigo. Persistir no `EpicSyncRun` um resumo de aplicação, proposta `achievementSummary: { status: "not-requested" | "complete" | "partial", gamesApplied, gamesPending, gamesUnsupported, unlocksApplied }`, exposto opcionalmente no `GET /profile/integrations/epic/sync/{runId}`. Contadores de aplicação são calculados pelo servidor; o resumo local da coleta complementa o resultado, sem declarar que pares recusados foram importados. Não é necessário mudar o HTTP 204 do commit: após confirmação, o launcher consulta o run, inclusive ao retomar SUCCEEDED. O journal não substitui esse resultado persistido.
### Proveniência e resolução de definições
Criar `EpicAchievementUnlock` relacionado a `EpicConnection` e `GameAchievement`, com unicidade por `(connectionId, gameAchievementId)`, `unlockTime`, timestamps e identificação do último run quando necessária à reconciliação. O `GameAchievement` permanece a linha unificada do jogo; a nova relação distingue a contribuição importada.
O backend resolve cada par externo pelo catálogo Epic associado ao objeto executável e usa o `name` canônico existente. Textos, pontos e IDs internos não vêm do launcher. As definições Epic podem estar em `GameCatalogue.storeMetadata.epicAchievementDefinitions`; não assumir que estão sempre em `GameCatalogueAchievement` nem reutilizar diretamente a agregação Steam por `(shop, objectId, name)`.
Resolver/carregar definições antes da transação de publicação, com tempo limitado, e aplicar dados já preparados dentro do commit. Se as definições estiverem indisponíveis ou um par não puder ser associado com segurança, preservar toda a contribuição anterior do jogo e registrar pendência para nova consulta. Isso evita apagar unlocks por atraso de catálogo e mantém a publicação de jogos/playtime independente de indisponibilidade externa. O resumo do commit distingue consulta bem-sucedida de aplicação efetiva.
Antes dessa preparação, validar os chunks e mover o run para RUNNING sob lock, congelando o upload conforme o protocolo atual. Capturar catálogo, sandbox e revisão ou fingerprint das definições usadas. Dentro da transação final, revalidar conexão e associação executável → catálogo → sandbox e garantir que a revisão preparada ainda corresponde à identidade aplicada. Se a associação mudar, preservar a facet anterior e reagendar sua coleta; não aplicar nomes preparados para outro catálogo. Requests Epic ou de catálogo não devem manter uma transação de banco aberta.
Reconciliação deve preservar contribuições atualizadas após a criação do run, seguindo a ideia de `src/profile-steam/steam-snapshot-delta.ts:145`. Um snapshot antigo não pode apagar um unlock importado por uma operação individual mais recente. Commit repetido de SUCCEEDED continua idempotente.
### Leituras e ciclo de vida
Adicionar Epic às consultas de desbloqueios, timestamps, `unlockedOn`, contadores, pontos, estatísticas, comparação e Swagger. Contribuição Epic ativa deve aparecer independentemente de assinatura Hydra. Se um unlock existir em mais de uma origem no mesmo jogo, contar uma vez e expor as origens; não somar XP bruto enviado pela Epic aos pontos Hydra.
Desconexão, troca de vínculo e remoção de import excluem somente as contribuições da conexão Epic e recalculam os agregados. Reset de conquistas locais preserva contribuições importadas. Remover linhas órfãs apenas quando não há outra origem ou dado local que justifique mantê-las. `claimUnassignedAchievements` hoje associa qualquer linha sem sessão; excluir importações externas desse claim para não transformá-las em conquistas Hydra.
| Arquivo na API | Alteração esperada |
| ------------------------------------------------------------------- | -------------------------------------------------------- |
| `src/validation/profile-epic-sync.validation.ts:43` | Schema dos unlocks, limites e campo opcional. |
| `src/profile-epic/epic-snapshot-publication.service.ts:40` e `:203` | Aplicação da facet, resumo, proveniência e concorrência. |
| `src/repository/epic-executable-catalogue.ts:4` | Reuso da associação exata ao catálogo. |
| `prisma/schema.prisma:586` e `:1765` | Relação Epic com GameAchievement e conexão. |
| `src/helpers/game-achievement-platform-helper.ts:1` | Origem Epic em `unlockedOn`. |
| `src/profile-games/handlers/get-game.ts:75` | Visibilidade e contadores do import. |
| `src/users/users.service.ts:137` e `user-stats-aggregation.ts:14` | Listas, comparações e estatísticas. |
| `src/profile-epic/epic-import-cleanup.ts:7` | Remoção da contribuição Epic e recálculo. |
| `src/profile-games/profile-game-achievements.service.ts:978` | Reset/delete preservando importações. |
| `src/services/achievement-session-service.ts:7` | Impedir atribuição indevida de sessão Hydra. |
## Cache e apresentação no launcher
Após commit confirmado e merge, invalidar o estado Epic afetado e buscar a resposta Hydra sob o contexto capturado. O cache da fonte deve ter ambiente, usuário/generation, conexão, conta Epic e sandbox na chave. O estado visível só recebe nomes canônicos da API, com datas convertidas para milissegundos.
O cache genérico atual não distingue contas nem proveniência. Para unlocks importados Epic, usar uma camada escopada ou acrescentar proveniência ao cache; em ambos os casos, invalidar os dados ao mudar o owner. Não guardar raw IDs Epic no array genérico de nomes canônicos.
O merge atual é uma união de unlocks e os totais usam `Math.max(local, remote)`. Isso mantém dados antigos mesmo após um `[]` autoritativo ou desconexão. A atualização Epic precisa substituir sua contribuição e permitir diminuir contagens, preservando apenas contribuições locais independentes. Reads assíncronos devem conferir owner antes de escrever cache ou emitir evento. Se a leitura remota falhar após o commit, manter atualização pendente e tentar no próximo acesso, sem apresentar unlocks não publicados.
Ampliar `SteamAchievement` com metadados externos opcionais ou extrair um tipo genérico compatível; adicionar `"epic"` a `UserAchievement.source`. `getUnlockedAchievements` hoje reconstrói itens sem conservar a origem remota; preservar esse campo na composição de resultados e nas comparações.
No agrupamento por `canonicalId`, a apresentação prioriza catálogo Steam. Os contadores vêm de `presentationGame`, enquanto a página lê conquistas de `operationalGame`. Definir uma identidade compartilhada `achievementGame`, inicialmente igual ao membro operacional, e usar a mesma identidade para contador, lista e eventos. Quando houver dois membros, oferecer escolha Steam/Epic na página de conquistas, default no membro operacional. Não somar conquistas das lojas nem converter IDs entre elas.
Referências: `src/shared/library-catalogue.ts:106`, `:155`; `src/renderer/src/context/game-details/game-details.context.tsx:124`, `:222`, `:478`; `src/main/services/achievements/merge-unlocked-achievements.ts:3`; `achievement-memory-store.ts:102`; `src/types/game.types.ts:26`. Aplicar a mesma seleção no Big Picture.
Adicionar phase `"achievements"` a `EpicSyncPhase` e resumo de facet em state/finished, com jogos processados, aplicados, não suportados e pendentes. `ok` continua indicando publicação/merge de biblioteca; o resumo identifica `complete`, `partial` ou `unavailable` para conquistas. Um problema de sessão na facet pode exigir reconnect mesmo com biblioteca publicada.
Reusar IPC existente, sem expor tokens ou bundle. Settings mostra progresso e um único resultado manual, por exemplo: “Jogos e tempo sincronizados; conquistas de 2 jogos serão tentadas novamente.” Não gerar um toast para cada conquista histórica. Atualizar traduções, renderer e Big Picture; a mensagem atual de `source-permission` cita especificamente playtime e não descreve a nova facet.
## Extensão posterior do sync individual
Ampliar `PUT /profile/integrations/epic/games/{objectId}` com a mesma facet externa e suporte a atualização somente de conquistas quando o playtime não puder ser consultado. Hoje playtime é obrigatório e `syncGame` retorna cedo se não há total do artifact (`sync-runtime.ts:410`); isso precisa mudar para a extensão funcionar.
Escolher reconciliação autoritativa por jogo para Epic, com a mesma semântica `null`/`[]`/lista do snapshot. A Steam individual é aditiva, portanto não copiar sua semântica sem adaptação. Adicionar ordenação/revisão de observação no servidor para uma resposta antiga não substituir estado mais recente.
Separar origem/facets no runtime: página e saída podem consultar conquistas; confirmação de relatório periódico executa somente playtime. Deduplicar por jogo e contexto, aguardar o full sync e usar os delays de saída existentes de 10 e 30 segundos, cancelando se o jogo reabrir. Essa etapa usa a mesma persistência Epic e não o endpoint genérico de unlocks Hydra.
## Plano de implementação e validação
1. **Validar o transporte Epic.** Fazer consulta de leitura com sessão Legendary do Hydra em perfil privado e público, um jogo com unlocks conhecidos, zero unlocks, jogo sem conquistas da loja e jogo com mais de um set. Confirmar IDs, datas, conta, sandbox, completude e eventual paginação. Capturar fixtures sanitizadas. Só habilitar a capability após esse resultado; se a sessão não tiver acesso, manter a facet indisponível até validar uma alternativa de autenticação.
2. **Entregar o contrato backend.** Descritores em lote, capability, schema, migration de proveniência, preparação das definições, reconciliação atômica, leituras, agregações e cleanup. Clients antigos seguem publicando jogos/playtime sem conquistas.
3. **Integrar o launcher.** Fonte/parser/normalizador, etapa do orchestrator, limites, orçamento, journal, resultado parcial e invalidação de cache após commit.
4. **Alinhar a interface.** Origem Epic, identidade de conquistas nos grupos, contadores/listas, traduções e Big Picture.
5. **Habilitar e acompanhar.** Medir cobertura de jogos suportados, duração, rate limits, falhas de associação e pendências aplicadas. Registrar apenas códigos e métricas necessários; não incluir tokens, cookies ou respostas privadas em logs. Manter a capability como controle de compatibilidade e desativação.
6. **Estender página/saída.** Adicionar a facet individual após o sync completo passar nos cenários anteriores.
| Cenário de aceitação | Resultado exigido |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| Primeira vinculação com unlocks conhecidos | Jogos, playtime e conquistas aparecem com data original e origem Epic. |
| Jogo com playtime zero ou ausente | A coleta de conquistas ocorre independentemente do tempo. |
| Dois sets com o mesmo achievementName | Ambos associados pelo par externo correto. |
| Executável em namespace diferente do catálogo | Consulta o sandbox confirmado das definições. |
| Perfil privado com sessão válida | Importa sem exigir publicação do perfil. |
| Sem suporte, null, erro GraphQL ou schema alterado | Preserva unlocks anteriores e publica biblioteca/playtime. |
| Zero unlocks confirmado | `[]` remove apenas a contribuição Epic autoritativa. |
| ID/data inválida, resposta incompleta ou par desconhecido | Não publica lista parcial como substituição completa. |
| 401, 403, 429, timeout e orçamento esgotado | Retry limitado, resultado parcial correto e pendências preservadas. |
| Snapshot grande | Respeita bytes/quantidade sem duplicar jogo nem perder jogos/playtime por facet excessiva. |
| Cancelamento antes/depois do commit | Staging cancelado ou publicação confirmada reconciliada conforme estado real. |
| Crash em staging, commit ou merge | Retomada sem misturar chunks e sem reaplicar commit concluído. |
| Logout/troca de conta durante request | Resposta antiga não escreve cache, banco local ou UI da nova conta. |
| Snapshot antigo versus sync individual novo | Preserva atualização mais recente. |
| Desconectar Epic com unlock local no mesmo jogo | Remove origem Epic e conserva dados Hydra/Steam válidos. |
| Epic e Steam no mesmo grupo canônico | Contadores e lista usam a mesma loja; IDs não são misturados. |
| Abrir página após importar conquistas Epic | Não faz PUT genérico de conquistas nem cria origem Hydra para o import. |
| Catálogo ou sandbox muda durante preparação do commit | Facet preservada e reagendada, sem aplicar definições da associação antiga. |
| Retomar run concluído com pendência de aplicação na API | GET run recupera o resumo parcial real; launcher não anuncia importação completa. |
| Repetir sync sem mudanças | Sem duplicatas, screenshots, souvenirs ou notificações históricas. |
| Sem assinatura Hydra | Conquistas Epic importadas continuam visíveis. |
| API anterior sem capability | Sync atual funciona e nenhum campo novo é enviado. |
Usar fixtures para parser e contratos; mocks para retries, cancelamento, journal e troca de contexto; testes de integração da API para publicação/proveniência/leituras. Validar manualmente listas e contadores em renderer e Big Picture. Na implementação, executar os testes focados, typecheck e verificação de bridge OTA quando houver mudança nos tipos IPC/preload.
## Decisões para iniciar o desenvolvimento
O trabalho pode seguir com coleta no main e publicação no snapshot, mantendo Legendary como dono da sessão e a API como dona dos nomes canônicos. Os pontos que condicionam a habilitação são: consulta autenticada comprovada, resolução do sandbox correto, contrato backend com proveniência Epic e cache capaz de remover contribuições antigas.
O requisito central é importar os desbloqueios durante o sync atual. Preparar launch/overlay não substitui essa importação, e chamar o pipeline genérico de conquistas locais não preserva a origem necessária.

View file

@ -1,486 +0,0 @@
# Epic Games — Download, lançamento e saves pelo Legendary (SDD)
Fonte: https://docmost.losbroxas.org/s/hydra/p/epic-games-download-lancamento-e-saves-pelo-legendary-sdd-KjrbshTP23
Status: implementação no launcher, na branch `codex/epic-download-launch-cloud-saves`, com validação em Windows. O desenho abaixo registra a análise inicial; os ajustes e as evidências da implementação ficam ao final.
Base auditada: launcher codex/expose-library-canonical-id, commit 1e060f800d93979d447e2bfe93c287c702907b80, em 14/10/2026. Binário distribuído: Legendary 0.21.1, confirmado por --version e --full-help. A análise inicial não executou downloads. Na implementação, o usuário autorizou o teste com TerraScape; o save existente foi copiado e seu digest conferido antes dos testes.
1. Objetivo e limites 
Permitir baixar, instalar, executar e sincronizar os saves dos jogos Epic possuídos pelo usuário, usando o Legendary existente. A fonte Epic aparece no mesmo seletor da Steam; havendo posse importada nas duas lojas, Steam primeiro e Epic abaixo. O download Epic aparece na tela de Downloads do Hydra, inclusive no Big Picture.
Esta entrega pertence ao launcher. A API continua publicando biblioteca/playtime e entregando os aliases privados já existentes. Não há nova tabela, migration, endpoint HTTP, alteração de identidade ou coleta na API. Saves Epic usam a nuvem Epic e não exigem assinatura Hydra Cloud.
Inclui seleção de pasta, fila, progresso, pausa/retomada, cancelamento, recuperação após reinício, associação da instalação e sync de saves antes/depois da sessão. Não inclui atualização automática, gerenciador de DLCs, gerenciamento de overlays/conquistas, distribuição pelo launcher oficial ou suporte novo a EA/Ubisoft/Rockstar. A execução de cada jogo continua sujeita aos requisitos reais de DRM, launchers e plataforma.
Recorte recomendado: validar primeiro Windows, onde a análise confirmou o binário e o fluxo atual. Linux reutiliza Wine/Proton já disponíveis no Hydra; macOS usa somente builds nativas Epic comprovadas. Esses sistemas não serão anunciados como suportados por esta integração antes dos cenários específicos passarem. CrossOver/execução de builds Windows no macOS não é uma capacidade que esta entrega acrescenta.
Fluxo resumido 
Escolher Epic → confirmar posse/alias → selecionar pasta → baixar na fila Hydra → concluir instalação/prerequisitos → associar executável Epic.
Jogar Epic → validar conta/instalação → comparar/restaurar saves → preparar autenticação → iniciar e acompanhar o jogo → registrar trechos de playtime na Epic → fechamento real → enviar/confirmar saves e reconciliar o total remoto.
2. O que existe hoje e onde integrar 
Trecho existente
Constatação e alteração necessária
src/renderer/src/pages/game-details/modals/repacks-modal.tsx:246
src/big-picture/src/components/modals/download-game/index.tsx:589
Mostram a opção Steam olhando o registro operacional atual. Passar a resolver os membros reais do grupo para oferecer cada loja possuída, mantendo Steam acima de Epic.
src/main/events/library/install-game-on-steam.ts:8
Abre steam://install e acompanha manifests Steam. Permanece como está; Epic precisa executar um downloader local.
src/shared/library-catalogue.ts
src/renderer/src/context/game-details/game-details.context.tsx:120
Já separa catalogueGame, operationalGame, downloadGame e members. Manter a apresentação Steam e direcionar cada operação ao membro real escolhido.
src/main/services/epic/index.ts:70
src/main/services/epic/session-provider.ts
src/main/services/epic/store.ts:305
Login, confirmação do vínculo, fila de sessão, persistência privada e invalidação por contexto já existem. Compartilhar essas dependências; não criar um segundo login.
src/main/services/epic/legendary-sync.ts
src/main/services/epic/legendary-auth.ts:78
Runner usa diretório temporário, stdout acumulado, JSON e timeout curto. Reutilizar os princípios de aborto/encerramento da árvore, mas criar runner de instalação com streaming e estado persistente.
src/main/services/download/download-manager.ts:104
src/main/services/download-orchestrator.ts:72
Um slot ativo e fila comum. O manager atualmente classifica tudo que não é torrent como HTTP. Acrescentar despacho explícito Epic antes dessa classificação.
src/main/services/download/download-manager.ts:649
src/main/services/download/download-manager.ts:800
Polling, notificação e finalização já existem. Epic precisa fornecer DownloadProgress e concluir por pós-condições de instalação; não usar a rotina de extração/repack.
src/main/helpers/launch-game.ts:642
src/main/services/process-watcher.ts:402
Há preflight, wrappers, tracking e detecção de fechamento. Inserir preparação Epic antes do spawn e usar o watcher para a saída real do jogo.
src/main/services/epic/session-attribution.ts:95
src/main/services/epic/sync-runtime.ts:309
Aliases vêm do GET próprio do jogo. A identificação da instalação Legendary só consulta LEGENDARY_CONFIG_PATH externo quando configurado. Acrescentar o workspace gerenciado pelo Hydra.
src/shared/cloud-save-emulator-provider.ts
src/main/services/cloud-save/automatic-sync-mode.ts
src/main/services/process-watcher.ts:72
V2 atende Steam/emuladores; Epic pode executar backup legado Hydra. Introduzir ciclo Epic próprio e impedir que o automático legado também rode naquela sessão.
src/main/events/epic/index.ts
src/preload/index.ts
src/renderer/src/declaration.d.ts
Reutilizar validação de main frame confiável. Acrescentar bridge tipada sem expor credenciais, argv de autenticação ou paths internos de sessão.
src/main/index.ts:396
src/main/services/epic/import-cleanup.ts
Shutdown e troca/desconexão já invalidam sync. Acrescentar pausa dos processos gerenciados e limpeza de credenciais, preservando instalações e saves.
3. Posse, identidade e seleção da versão 
availableShops confirma lojas do catálogo; não comprova posse. A opção Epic exige membro real shop: "epic", hasActiveEpicImport: true, escopo atual e conexão Epic confirmada. A Steam exige seu membro importado real. Uma página Steam originada apenas de importação Epic oferece Epic, não uma instalação Steam fictícia.
No main, extrair/reutilizar a consulta privada de EpicSyncRuntime.ownGame para obter epicImport.connectionId e artifactIds, validando o vínculo atual. Preparar o workspace com list --json --platform da plataforma alvo quando o cache estiver ausente; reaproveitar somente metadados verificados para o mesmo escopo. A coleta temporária atual não mantém esse cache de instalação. Consultar info para os aliases confirmados e confirmar exatamente metadata.namespace:metadata.id. Não escolher por título, pelo primeiro alias da lista ou pela identidade Steam de apresentação.
Selecionar o alias com asset instalável para a plataforma desejada. Mais de um candidato instalável exige escolha explícita; não adivinhar edição/build. Itens não instaláveis, preload e jogos gerenciados por outra plataforma não iniciam esta instalação. Mostrar o motivo no seletor. Revalidar posse e conexão ao iniciar, retomar e lançar.
A identidade operacional não muda:
{
"catalogueGame": {
"shop": "steam",
"objectId": "<Steam appId associado no catálogo>"
},
"epicGame": {
"shop": "epic",
"objectId": "6b0541b5d9aa476cbf407643ab3b1d7d:96571224db9a4dae8414e06050230e51",
"artifactId": "<app_name confirmado pelo Legendary>"
}
}
O exemplo usa a identidade EXECUTABLE conhecida de Callisto; o alias é deliberadamente um marcador, pois esta análise não consultou a conta para obter seu app_name. O ID de catálogo AUDIENCE não substitui a identidade da biblioteca.
4. Componentes propostos 
Componente novo em src/main/services/epic
Responsabilidade
legendary-managed-runtime.ts
Workspace persistente por escopo/jogo, execução JSON/streaming, lease de sessão, serialização das mutações e término dos processos.
installation.ts
Resolver aliases/plataforma, consultar info, ler instalação e vincular o executável comprovado ao Game real.
download.ts
Adaptar o processo install ao DownloadManager, interpretar progresso e confirmar instalação completa.
launch.ts
Obter parâmetros autenticados no main e integrá-los ao lançamento existente.
cloud-saves.ts
Resolver pasta, observar versão remota/local, restaurar com proteção, enviar saves e tratar conflito/retry.
Nomes são destinos propostos, não arquivos já existentes. Não introduzir framework genérico de provedores, segundo gerenciador de fila, Redis, scheduler ou fork do Legendary. Separar pequenos parsers puros apenas quando os testes de protocolo precisarem deles.
5. Persistência local e sessão 
Manter gamesSublevel, downloadsSublevel e layout existentes. Acrescentar somente campos opcionais locais. Ausência equivale a instalação não gerenciada; o comportamento antigo é preservado.
interface EpicLocalOwner {
environment: string;
userId: string;
epicAccountId: string;
}
// Campo opcional em Game; não vai para a API Hydra.
epicInstallation?: {
owner: EpicLocalOwner;
artifactId: string;
platform: "Windows" | "Mac";
savePath?: string | null; // ausente: tentar metadados
automaticSaveSync?: boolean; // ausente: habilitado se compatível
saveAnchor?: {
remoteManifest: string | null;
localDigest: string;
};
pendingSaveUpload?: boolean; // ausente: false
};
// Campo opcional em Download; obrigatório para Downloader.Epic.
epic?: {
owner: EpicLocalOwner;
connectionId: string; // vínculo capturado ao enfileirar
artifactId: string;
platform: "Windows" | "Mac";
diskSizeInBytes: number; // info.manifest.disk_size
};
Por que cada campo existe: owner impede executar/sincronizar na conta errada; artifactId seleciona o app Legendary; platform evita usar o build de outra plataforma; savePath registra a escolha/resolução da pasta; automaticSaveSync permite opt-out; saveAnchor detecta mudança de ambas as cópias; pendingSaveUpload permite repetir um envio que falhou. A conexão do download exige reconciliação antes de retomar. diskSizeInBytes distingue espaço instalado de tamanho comprimido baixado.
Reutilizar Game.executablePath, installedSizeInBytes, winePrefixPath e protonPath. Não duplicar paths de instalação no novo modelo: a fonte é installed.json; Download já tem downloadPath/folderName. Não persistir tokens, URLs assinadas ou argumentos de lançamento nesses registros.
Workspace não credencial: userData/epic-managed/<sha256(environment,userId,epicAccountId)>/<sha256(artifactId,platform)>. Mantém metadata, manifests, installed.json, config e dados de retomada. Cada jogo tem workspace próprio para não compartilhar arquivos mutáveis entre downloads e saves. Não tocar na configuração externa do Legendary/Heroic/Epic.
Lease de credenciais: usar a mesma EpicConnectionStore e a fila EpicSessionProvider.serialize para leitura, refresh e início autenticado. Injetar uma cópia privada de user.json/version.json no workspace apenas durante o comando. Salvar sessão realmente renovada com os checks atuais de usuário/vínculo; remover as cópias em sucesso, aborto e shutdown. Na inicialização, apagar somente credenciais temporárias remanescentes; preservar estado de instalação.
A fila de sessão não pode ficar retida por todo o download. O parser de 0.21.1 usa a linha Download size, emitida depois de prepare_download, como fronteira comprovada da preparação: ler e persistir a sessão renovada antes de liberar a fila global. O restante continua serializado por workspace. Até essa fronteira, cancelamento/erro também precisa persistir eventual rotação antes de liberar a fila. Não executar refresh concorrente do mesmo token. Renovação inesperada exige pausar/reconciliar sob a fila antes de continuar. Acrescentar comparação da sessão de origem no refreshSession para que um resultado antigo não sobrescreva uma sessão mais recente do mesmo vínculo. A biblioteca continua usando seu runner temporário.
Escritas curtas em Game usam updateGameRecord/libraryWriteQueue com revalidação de contexto; nunca segurar essa fila durante requests, execução do processo ou cópia de saves. A conexão capturada é obrigatória nas operações; reconexão da mesma conta pode reaproveitar a instalação após validação, mas não autoriza retomar automaticamente um download antigo de outro vínculo.
6. Download e instalação 
Acrescentar Downloader.Epic = 15 ao final do enum atual, sem renumerar valores. A fonte não entra nos hosters/repack URIs: tem um handler dedicado que recebe identidade e pasta, e resolve as opções no main.
legendary --api-timeout 15 info <artifactId> --json --platform Windows
legendary --api-timeout 15 -y install <artifactId> --platform Windows --base-path <pasta-escolhida> --game-folder <pasta-validada> --skip-dlcs --skip-sdl
legendary list-installed --json --show-dirs
Comandos são exemplos de argv separados, sem interpolação em shell. Usar Mac apenas para asset Mac confirmado. Instalar somente dados necessários do jogo base. Não passar --ignore-free-space. Reexecutar install com o mesmo workspace e destino faz a retomada; não depender de um comando pause/resume que o CLI não possui. Atenção ao comportamento confirmado: -y suprime o postinstall de prerequisitos no Legendary 0.21.1. Ler prereq_info do installed.json e tratar essa fase explicitamente no Hydra: caminho dentro da instalação validado por realpath, argumentos separados e resultado observado; reutilizar o preflight comum para dependências conhecidas. Se houver interação/elevação, apresentar a ação ao usuário e aguardar. Nunca marcar prerequisito como instalado sem resultado comprovado.
O novo handler prepara Download com shop:"epic", objectId real, uri vazio (não usado neste provedor), downloader Epic, queued/status padrão do orquestrador, bytes/progress zero e tamanho de transferência conhecido. Desabilitar extração automática, exclusão de arquivos de archive e seeding. Não chamar createGame/prepareGameEntry para promover a importação a Hydra; exigir o Game Epic existente e preservar source/playtime.
Integração exata no manager:
startDownload: despachar Epic antes de isHttpDownloader; capturar generation e conectar callbacks ao slot atual.
getDownloadStatus/watchDownloads: obter progresso do adapter Epic e publicar o evento on-download-progress existente.
pauseDownload/cancelDownload: parar e aguardar toda a árvore Legendary antes de liberar o slot. Preservar arquivos parciais na pausa; cancelar não equivale a desinstalar.
resumeDownload: conferir owner, conexão, alias/plataforma e destino; reaproveitar manifests/dados parciais.
handleRuntimeDownloadError/processNextQueuedDownload: usar o tratamento e a fila existentes; callbacks antigos não encerram um download novo.
initializeTorrentService: o caminho atual também retoma o download persistido. Encaminhar Epic pelo despacho novo e reconciliar instalação antes da retomada.
handleDownloadCompletion: ramo Epic confirma installed.json e executável, atualiza tamanho instalado e associação, envia notificação e avança a fila. Não chamar searchAndBindExecutable, extração ou cálculo de installerSizeInBytes de repack.
disk-space.ts: Epic calcula espaço com dados descomprimidos e arquivos reutilizáveis, não somente fileSize comprimido. O check interno do Legendary também permanece habilitado.
Mapear saída por linhas, tratando quebra de linha e carriage return: progresso em [0,1], velocidade em bytes/s, bytes baixados e tamanho de transferência. Converter o ETA do CLI, em segundos, para milissegundos no contrato `DownloadProgress` já usado pelo Hydra. Retomada inclui bytes já reaproveitados. Sem estimativa válida, ETA = -1; não inventar valores. O percentual pode chegar a 100% antes da finalização: manter “Finalizando instalação” até término e pós-condições válidas. Erro/aborto nunca vira complete só porque progress = 1.
A tela existente de Downloads usa o novo nome “Epic Games” em DOWNLOADER_NAME e os eventos atuais. Ocultar ações de extrair, copiar link, seletor de hoster e seeding para este provedor. Pausa/fila/cancelamento continuam disponíveis. O limite de velocidade atualmente implementado é para HTTP/torrent; não anunciar que ele limita Legendary sem suporte comprovado no adapter.
Para remover arquivos pelo controle existente deleteGameFolder, acrescentar ramo Epic: parar processo, bloquear se em execução, validar containment/realpath do destino e invalidar a entrada instalada somente após exclusão bem-sucedida. Remover um item da lista de downloads não apaga a instalação. Desconectar a conta nunca remove diretórios de jogos/saves.
7. Lançamento e fechamento real 
O Jogar usa o membro operacional, não catalogueGame. Se somente a Epic estiver instalada, executar Epic mesmo na página Steam. Se ambas estiverem instaladas, conservar a preferência atual e oferecer escolha explícita “Jogar pela Steam / Jogar pela Epic Games”; a escolha vale para a sessão e não muda a apresentação ou os dados persistidos de tempo.
Acrescentar resolução de instalação Epic comprovada em launchGameWithCloudSaveChecks. Validar installed.json, alias, plataforma e realpath do executável. Ao finalizar uma instalação, vincular o executável informado pelo manifesto Legendary ao Game Epic; não procurar qualquer .exe por heurística.
legendary --api-timeout 15 launch <artifactId> --json
O JSON de lançamento fica exclusivamente no main. Converter os campos estruturados de comando/argumentos/ambiente/diretório para um spawn seguro, reutilizando wrappers e preparação de prefixo existentes. Não transformar o retorno em texto de shell. Não logar argv/ambiente que contenham códigos de autenticação. O runner Legendary encerra ao produzir os parâmetros; isso não significa que o jogo fechou.
Reutilizar a infraestrutura de lançamento native/Wine/Proton e o process-watcher para acompanhar o executável real, seus descendentes quando necessário e a janela de lançamento. Linux precisa preparar o prefixo antes de resolver/restaurar saves. macOS só recebe o asset nativo e acompanhamento correspondente comprovado.
Capturar no início da sessão identidade Epic, owner, conexão, instalação, pasta de save, política de sync e versão remota. O fechamento é consumido uma vez; mudanças de configuração/contexto não reassociam a sessão passada. Uma reabertura espera eventual envio anterior antes da nova restauração.
Atualizar session-attribution para ler o workspace Hydra explicitamente. Hoje somente a variável externa LEGENDARY_CONFIG_PATH e manifests do launcher oficial são reconhecidos. A nova instalação deve manter a prevenção de tempo duplicado: parcela Hydra continua suprimida na sessão Epic confirmada, salvo opt-in existente. O sync de playtime pós-saída continua usando as tentativas atuais de 10/30 segundos, separadas do upload de saves.
Falha na autenticação/geração de parâmetros não cai silenciosamente em execução sem autenticação. Se a versão instalada impedir o lançamento online, informar atualização necessária e permitir reexecutar install no mesmo destino pela opção Epic existente; não criar rotina de atualização automática. Offline só é oferecido quando os metadados indicarem suporte; saves ficam pendentes. Biblioteca importada, por si só, não prova executável nem suporte offline.
8. Saves Epic: descoberta, conflito e recuperação 
Usar os metadados de cloud save do jogo e plataforma e a conta da sessão. Resolver as variáveis reais do Legendary; pasta absoluta, realpath e prefixo devem corresponder ao ambiente de lançamento. Se faltar suporte, mostrar “Este jogo não oferece saves na nuvem Epic”. Se a pasta não puder ser resolvida, pedir seleção de pasta; não tentar adivinhar pelo título. Manter CloudIncludeList/CloudExcludeList e os filtros de arquivos Epic. Não gravar a pasta staging como savePath definitivo: sync-saves modifica installed.json.save_path ao receber --save-path; restaurar esse metadado sob o lock do workspace ao terminar.
O CLI confirmado expõe sync-saves com --skip-upload, --skip-download, --force-upload, --force-download e --save-path. list-saves não tem --json; seu adapter deve validar o agrupamento exato por app_name e os manifests retornados. Resposta incompreensível é falha, nunca “nuvem vazia”. O runner usa saída estruturada interna própria; não exibe logs brutos.
legendary --api-timeout 15 list-saves <artifactId>
# Restauração em diretório temporário vazio; depois aplicar localmente.
legendary --api-timeout 15 -y sync-saves <artifactId> --skip-upload --force-download --save-path <staging-privado>
# Envio dos saves locais após conferir a versão remota.
legendary --api-timeout 15 -y sync-saves <artifactId> --skip-download --save-path <pasta-real>
Não executar force-download diretamente sobre a pasta real: o downloader Legendary pode limpar arquivos no destino. Preparar staging, confirmar resultado completo, revalidar contexto/versão e aplicar com cópia de segurança e substituição controlada. Se a aplicação falhar, restaurar a cópia anterior e impedir lançamento até resolver ou escolher explicitamente continuar local. Sem save remoto, preservar todos os saves locais.
Política fechada:
Sem anchor e com saves nas duas origens: mostrar escolha “Usar saves locais / Usar saves da Epic”. Sem inventar que a data mais recente é a correta.
Com anchor: comparar o manifest remoto e o digest dos arquivos locais relevantes. Se apenas a nuvem mudou, restaurar; se apenas local mudou, preservar; se ambas mudaram, pedir escolha.
Sem mudanças relevantes, não transferir novamente. Depois de uma operação confirmada, atualizar anchor com o remoteManifest observado e digest local resultante.
Antes de abrir: concluir inspeção/restauração ou resolver conflito. Falha de rede permite ação explícita “Jogar com saves locais”; marcar envio pendente. Não anunciar sync bem-sucedido.
Depois de fechar: aguardar processos do jogo encerrarem e os arquivos estabilizarem; comparar com o anchor e consultar novamente a nuvem. Criar snapshot local privado dos arquivos filtrados e enviar essa cópia estável, sem sobrescrever uma mudança remota detectada. O comando de envio recebe --save-path dessa cópia, e installed.json.save_path volta para a pasta real ao terminar.
Quando o digest mudou mas a comparação de datas do Legendary não acusa mudança, force-upload é permitido somente após conferir que o manifest remoto segue igual ao anchor, ou após escolha explícita de usar local. Nunca usar force-upload indiscriminadamente.
Falha de envio deixa pendingSaveUpload = true. Repetir na próxima abertura/ação manual para o mesmo owner, começando pela comparação remota. Não restaurar por cima desse local pendente.
Não marcar sucesso apenas por exit code 0: validar logs de erro/resultado e pós-condições remotas/locais. O CLI pode terminar sem transferir. Na versão auditada, upload_save também não valida o status de cada PUT; por isso, confirmar envio com leitura de volta em staging e comparação dos arquivos filtrados com o snapshot local enviado. Falha/incompletude mantém pendingSaveUpload. A simples presença de um manifest remoto não comprova conteúdo completo.
O protocolo CLI não expõe compare-and-swap remoto. A checagem antes/depois identifica conflitos observáveis, mas não promete atomicidade contra outro dispositivo que escreva exatamente durante o envio. O SDD não transforma o controle local em lock distribuído.
Usar o gate existente para serializar as operações do Game real e a rotina de lançamento; bloquear restauração enquanto o jogo ou outro membro do grupo estiver em execução. As operações automáticas Epic substituem os callbacks de backup automático Hydra legado apenas para sessões Epic gerenciadas. Não ampliar isCloudSaveV2Eligible para Epic nem reutilizar autorização de assinatura para a nuvem Epic.
Uploads/restores manuais Hydra permanecem recursos Hydra. Os handlers src/main/events/cloud-save/upload-save-game.ts e src/main/events/cloud-save/download-game-artifact.ts precisam consultar a atividade Epic para não ler/restaurar durante um sync Epic. Coordenar também o destino físico quando coincidir, sem compartilhar dados de nuvem, anchors ou IDs entre lojas. Dados Hydra preexistentes permanecem intactos.
No desktop, os controles ficam na categoria “Epic Games” do modal de opções do jogo, abaixo do Hydra Cloud ativo e antes dos saves legados arquivados. O painel próprio mostra leitura inicial com skeleton, status, sincronização automática, pasta, sincronização manual e escolha explícita das cópias em conflito. Falhas mantêm a categoria visível com erro e retry; a categoria não depende de assinatura Hydra Cloud. Usa a instalação Epic real, mesmo quando a apresentação é Steam, e bloqueia alterações durante operações ou enquanto o jogo está aberto. O hero não exibe os controles Epic. A opção de sync automático Epic é separada das opções Hydra; o Big Picture mantém seu painel existente. Restaurar os saves após instalar é opcional; a garantia obrigatória é concluir a restauração antes da primeira sessão.
9. HTTP e IPC 
HTTP Hydra: nenhum endpoint novo. Reutilizar GET /profile/integrations/epic e GET /profile/games/epic/:objectId, com os checks já existentes. Download, parâmetros de lançamento e nuvem são acessados localmente pelo Legendary; nada é encaminhado para o backend.
Bridge nova proposta:
getEpicGameInstallOptions(epicObjectId: string): Promise<{
options: Array<{
artifactId: string;
platform: "Windows" | "Mac";
downloadSizeInBytes: number | null;
diskSizeInBytes: number | null;
supportsCloudSaves: boolean;
}>;
error?: EpicInstallError;
}>;
startEpicGameDownload({
epicObjectId, connectionId, artifactId, platform, downloadPath,
addToQueueOnly
}): Promise<{ ok: boolean; error?: EpicInstallError }>;
getEpicCloudSaveState(epicObjectId: string): Promise<EpicCloudSaveState>;
setEpicCloudSaveSettings(epicObjectId: string, {
automaticSync: boolean, savePath?: string | null
}): Promise<{ ok: boolean; error?: EpicSaveError }>;
syncEpicCloudSaves(epicObjectId: string): Promise<EpicCloudSaveState>;
resolveEpicCloudSaveConflict({
epicObjectId, expectedRemoteManifest, choice: "local" | "remote"
}): Promise<EpicCloudSaveState>;
onEpicCloudSaveStateChanged(callback): () => void;
Tipar os DTOs finais em epic-integration.types.ts/novo arquivo local dedicado e na declaração da bridge. Erros de instalação mínimos: conexão/sessão inválida, jogo não possuído, alias/plataforma inválidos, instalação não suportada, falta de espaço, processo falhou e cancelamento. Saves acrescentam sem suporte, pasta necessária, conflito, rede e contexto alterado. Não enviar stdout bruto ou erros com credenciais.
Game Install Options não recebe comandos/URLs arbitrárias. Main revalida identidade, alias, conexão, paths e plataforma mesmo se o renderer tiver recebido opções antes. Conflito é um resultado calculado pelo main; choice não autoriza ignorar uma versão remota que mudou desde a exibição.
Pausa/retomada/cancelamento e progresso reutilizam os IPCs/events atuais de downloads. Jogar reutiliza openGame/launchGame com a identidade real; a preparação Epic acontece no main. Adicionar listeners removíveis e assertTrustedSender permitindo main frames desktop/Big Picture. Ajustar a lista de capacidades/checagem OTA existente; renderers antigos ignoram campos opcionais e renderers novos não mostram Epic quando a bridge não existir.
10. Interfaces e operações do grupo 
Em repacks-modal e DownloadGameModal do Big Picture, construir opções oficiais a partir de presentation.members. Steam e Epic mantêm seus critérios de importação, filtro textual e posicionamento acima das fontes comuns. Com ambas elegíveis, Epic aparece imediatamente abaixo da Steam. Reutilizar o layout visual da opção Steam; Epic abre seleção de pasta/build e chama o handler dedicado.
A pasta usa os diretórios de download existentes, com folderName confirmado no main. O clique não depende do downloadGame atual: a fonte escolhida determina o membro de destino. Depois de enfileirado, a projeção downloadGame já direciona progresso/pausa corretamente. Um download Epic não modifica o executável Steam nem cancela instalação Steam externa.
Desktop e Big Picture conservam metadados Steam e soma de tempo somente para leitura. Play/configuração e saves seguem o membro realmente lançado. Nunca transmitir aggregate playtime para as parcelas Hydra/Epic. Preferências do grupo continuam usando os handlers existentes.
As badges indicam importações ativas, não disponibilidade no catálogo: somente Steam importada mostra Steam, somente Epic importada mostra Epic, e ambas importadas mostram Steam com “+”. Existir em `availableShops` ou usar `catalogueGame.shop: "steam"` não cria uma importação nem muda essa badge. Os tooltips descrevem as bibliotecas importadas. Desconectar uma origem remove sua participação na badge, preservando a origem restante e os metadados preferidos.
Atualizar botões de Jogar/download, progresso de finalização, labels de provedor e controles de saves nos dois modos. Erros de download aparecem no item e com ação de tentar novamente/reconectar; somente conclusão comprovada gera toast. Traduzir as chaves novas nos 35 idiomas. Não acrescentar contas ou entradas Steam artificiais.
11. Concorrência, desconexão e reinício 
Enfileiramento registra o vínculo; início/retomada revalidam a conta. Dois cliques no mesmo jogo não criam duas execuções. O slot existente controla a concorrência da fila.
Contexto Hydra, ambiente, conta Epic ou conexão alterados invalidam callbacks e comandos ainda autenticados. Pausar downloads, aguardar parada e limpar credenciais antes de finalizar desconexão. Fila antiga não inicia na conta nova.
Preservar arquivos de instalação, manifests de retomada e saves. clearEpicImports continua removendo as contribuições/importações conforme sua regra; não usar isDeleted para apagar dados físicos.
Callbacks de uma operação invalidada não escrevem Game, anchors, progress ou tokens no contexto novo. Revalidar antes de cada publicação local e evento.
Reinício reconcilia installed.json e o registro de download. Instalação completa com finalização local perdida só refaz o vínculo; parcial retoma. Se o dono/vínculo não for válido, manter pausado.
Shutdown precisa aguardar parada real do downloader antes de fechar o processo; guardar pausa e pendências. O save pós-saída exige Hydra ainda aberto para detectar o fechamento. Se o Hydra sair durante o jogo, não fingir envio: a próxima abertura reconcilia local/remoto.
Reabertura cancela uma tarefa pós-saída ainda não iniciada; se um envio já começou, aguardar sua conclusão antes de restaurar. Não matar o jogo ao limpar o runner que apenas produziu parâmetros JSON.
Constantes ficam nomeadas em epic/constants.ts: reutilizar API timeout 15s, timeout de operações curtas apropriado e encerramento da árvore já existente. O timeout total de cinco minutos do sync não se aplica ao download. Valores iniciais propostos, todos em constantes nomeadas: EPIC_DOWNLOAD_STALL_TIMEOUT_MS = 300000 (sem progresso/atividade útil; não vale durante interação de prerequisitos), EPIC_SAVE_SYNC_TIMEOUT_MS = 300000 por operação, EPIC_PROGRESS_PERSIST_INTERVAL_MS = 1000 e EPIC_PROCESS_MAX_LINE_BYTES = 65536. Reutilizar 128 KiB como buffer circular de diagnóstico sanitizado e 1 MiB para arquivos de sessão; respostas JSON de metadados usam o limite já existente de 64 MiB do sync. A transferência não acumula o log inteiro. Um download controlado deve confirmar esses limites antes de habilitar produção. Não inventar que 100%/silêncio equivale a processo encerrado.
12. Validação enxuta e critérios de aceite 
Testes unitários pequenos para adapters/parsers novos; ampliar as suites existentes de download, grupo, contexto, sessão Epic e processo. Não há suite PostgreSQL: esta entrega não modifica a API.
Opções: Epic importada sozinha com página Steam; ambas importadas em ordem Steam/Epic; catálogo em duas lojas sem posse Steam; Epic-only; alias ausente/ambíguo e build não suportado; bridge antiga.
Download: progresso em linhas fragmentadas/CR, bytes de retomada, ETA desconhecido, 100% antes de terminar, exit 0 sem instalação válida, falha de rede/disco e recuperação da finalização.
Fila: Epic junto com HTTP/torrent, pausa, troca de slot, cancelamento e callback tardio; reinício; troca de usuário/ambiente/conexão; item perdido não deixa processo órfão.
Instalação/lançamento: manifest e realpath corretos, executável fora do destino rejeitado, escolha explícita Steam/Epic, argv com espaços, credenciais ausentes nos eventos/logs, fechamento real e proteção de playtime.
Saves: sem suporte, pasta não resolvida, nuvem vazia, primeira sincronização com duas cópias, local/remoto/ambos alterados, alteração dentro da mesma janela de datas, staging incompleto, rollback da aplicação, envio pendente e retry.
Concorrência: refresh rotacionado durante preparação, download não bloqueia sync por horas, disconnect aguarda stop, reabertura durante upload, Hydra automático/manual não concorre com a operação Epic.
Regressões: instalação Steam continua delegada ao app Steam; fila/extração de repacks preservadas; Steam/Epic/Hydra conservam identidades, tempos e preferências; desconexão não apaga saves.
Teste manual com conta/instalação de teste: instalar um jogo compatível na pasta escolhida, acompanhar Downloads, pausar e reiniciar o Hydra, concluir, restaurar save Epic, jogar, fechar, confirmar upload e restaurar em outra instalação controlada. Registrar comportamento sem rede. No Linux/macOS, repetir os casos específicos antes de habilitar a plataforma.
Critério central: um usuário com o jogo nas duas lojas vê Steam e Epic no seletor, baixa a versão Epic pelo Hydra, joga essa instalação autenticada e retoma seu progresso pela nuvem Epic, mantendo a apresentação Steam e sem mistura de playtime ou dados Hydra.
Na implementação, executar suites pertinentes (incluindo fixtures de prerequisitos e upload parcialmente aceito), yarn typecheck, lint/formatação dos arquivos alterados, yarn check:ota-bridge e integridade textual. Antes de PR/push, cumprir os checks obrigatórios do launcher e commitlint sobre o commit efetivo. Não prometer checks como executados nesta entrega documental.
13. Ordem de implementação 
Extrair acesso privado ao jogo/contexto Epic e fechar DTOs locais; implementação do runtime gerenciado e rotação segura de sessão.
Adapter de instalação/download e integração com manager/fila; pós-condições, retomada e vínculo de executável.
Opções oficiais por membros reais, controles de downloads e bridge desktop/Big Picture.
Lançamento autenticado com parâmetros estruturados, tracking e atribuição Epic do workspace gerenciado.
Descoberta de saves, anchor/conflito, staging/restore, upload pós-saída e exclusão mútua com Hydra.
Shutdown/disconnect, mensagens/traduções e validação integrada controlada.
Não acrescentar fases de backend nem expandir para atualização automática/DLCs. Os pontos que dependem de fixtures reais de processo, permissões de prerequisitos e plataforma devem ser validados no adapter antes de habilitar a funcionalidade, e não tratados como capacidades já comprovadas.
14. Referências externas verificadas 
Legendary 0.21.1: CLI e contratos dos comandos; resolução de pastas e sincronização. Heroic: adapter de instalação/lançamento e ciclo de saves antes/depois. São referências de comportamento; o desenho acima usa os pontos existentes do Hydra e não copia a arquitetura do Heroic.
## Links da versão auditada
- [src/renderer/src/pages/game-details/modals/repacks-modal.tsx:246](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/renderer/src/pages/game-details/modals/repacks-modal.tsx#L246)
- [src/big-picture/src/components/modals/download-game/index.tsx:589](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/big-picture/src/components/modals/download-game/index.tsx#L589)
- [src/main/events/library/install-game-on-steam.ts:8](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/events/library/install-game-on-steam.ts#L8)
- [src/shared/library-catalogue.ts](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/shared/library-catalogue.ts)
- [src/renderer/src/context/game-details/game-details.context.tsx:120](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/renderer/src/context/game-details/game-details.context.tsx#L120)
- [src/main/services/epic/index.ts:70](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/services/epic/index.ts#L70)
- [src/main/services/epic/session-provider.ts](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/services/epic/session-provider.ts)
- [src/main/services/epic/store.ts:305](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/services/epic/store.ts#L305)
- [src/main/services/epic/legendary-sync.ts](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/services/epic/legendary-sync.ts)
- [src/main/services/epic/legendary-auth.ts:78](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/services/epic/legendary-auth.ts#L78)
- [src/main/services/download/download-manager.ts:104](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/services/download/download-manager.ts#L104)
- [src/main/services/download-orchestrator.ts:72](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/services/download-orchestrator.ts#L72)
- [src/main/services/download/download-manager.ts:649](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/services/download/download-manager.ts#L649)
- [src/main/services/download/download-manager.ts:800](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/services/download/download-manager.ts#L800)
- [src/main/helpers/launch-game.ts:642](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/helpers/launch-game.ts#L642)
- [src/main/services/process-watcher.ts:402](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/services/process-watcher.ts#L402)
- [src/main/services/epic/session-attribution.ts:95](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/services/epic/session-attribution.ts#L95)
- [src/main/services/epic/sync-runtime.ts:309](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/services/epic/sync-runtime.ts#L309)
- [src/shared/cloud-save-emulator-provider.ts](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/shared/cloud-save-emulator-provider.ts)
- [src/main/services/cloud-save/automatic-sync-mode.ts](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/services/cloud-save/automatic-sync-mode.ts)
- [src/main/services/process-watcher.ts:72](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/services/process-watcher.ts#L72)
- [src/main/events/epic/index.ts](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/events/epic/index.ts)
- [src/preload/index.ts](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/preload/index.ts)
- [src/renderer/src/declaration.d.ts](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/renderer/src/declaration.d.ts)
- [src/main/index.ts:396](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/index.ts#L396)
- [src/main/services/epic/import-cleanup.ts](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/services/epic/import-cleanup.ts)
- [src/main/events/cloud-save/upload-save-game.ts](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/events/cloud-save/upload-save-game.ts)
- [src/main/events/cloud-save/download-game-artifact.ts](https://github.com/hydralauncher/hydra/blob/1e060f800d93979d447e2bfe93c287c702907b80/src/main/events/cloud-save/download-game-artifact.ts)
- [CLI e contratos dos comandos](https://github.com/legendary-gl/legendary/blob/0.21.1/legendary/cli.py)
- [resolução de pastas e sincronização](https://github.com/legendary-gl/legendary/blob/0.21.1/legendary/core.py)
- [adapter de instalação/lançamento](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher/blob/main/src/backend/storeManagers/legendary/games.ts)
- [ciclo de saves antes/depois](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher/blob/main/src/backend/launcher.ts)
## Registro da implementação — 14/10/2026
Os contratos locais finais estão em `src/types/epic-managed.types.ts`. Os campos opcionais `Download.epic.phase` e `Download.epic.error` distinguem preparação, transferência, finalização, prerequisitos e falhas recuperáveis. As opções de instalação também devolvem o vínculo capturado e se o build já está instalado. O estado de saves inclui fase, suporte offline comprovado e datas observadas das cópias; não cria datas de jogo.
No desktop, a página antecipa a consulta das opções da cópia Epic confirmada. A prévia fica em memória por cinco minutos, limitada a 32 jogos e isolada por ambiente, usuário, geração de autenticação, conta Epic, conexão e identidade/plataforma. Consultas simultâneas compartilham a mesma execução; erros não são guardados. O estado instalado é relido dos arquivos locais. Esse cache serve somente à apresentação: iniciar/retomar um download mantém a confirmação da posse e a consulta atualizada pelo Legendary.
O runtime Windows supervisiona Legendary e prerequisitos por Job Object, aguardando o término da árvore antes de liberar o slot ou limpar credenciais. Os arquivos internos usam caminhos Windows com suporte a nomes longos. A instalação TerraScape confirmou essa necessidade no workspace real. O percentual persistido e os bytes comprimidos são medidas distintas: a retomada preserva ambos, sem recalcular o percentual anterior apenas pelos bytes.
Lançamento revalida a sessão após o preflight e imediatamente antes do spawn, inclusive com sync automático desativado ou offline. A reserva da sessão cobre o intervalo entre spawn e detecção pelo watcher. Reabrir cancela a estabilização pós-saída antes do primeiro comando de nuvem; após seu início, aguarda a tarefa. Falhas de inspeção publicam envio pendente sem expor mensagens brutas; tarefas de um contexto invalidado não publicam no seguinte.
O gate dos arquivos também cobre todas as entradas Hydra V2, além dos backups legados. As contribuições e os registros de instalação continuam separados. Uma sessão sem alterações de digest ou manifest apenas confirma o anchor e limpa a pendência, sem reenviar o mesmo conteúdo.
O shutdown aguarda a parada confirmada dos serviços Epic. Se a parada falhar, mantém o aplicativo aberto com uma mensagem traduzida, permitindo tentar novamente. Não encerra o aplicativo deixando o downloader supervisionado sem confirmação.
Validação executada:
- 352 testes pertinentes de Epic, IPC, instalação, fila, biblioteca e controles de interface: todos passaram, sem testes ignorados.
- `yarn typecheck`, ESLint sem auto-fix nos arquivos alterados, formatação, OTA e integridade dos JSONs/traduções. As 32 chaves novas estão nos 35 idiomas. ESLint não encontrou erros; existem avisos nos arquivos compartilhados.
- A suite completa apresentou 22 falhas restantes que também foram reproduzidas no commit-base, com os mesmos diagnósticos. A reprodução executou 102 testes, sem skips, usando os mesmos binários nativos. Três falhas de fixtures introduzidas pela integração foram corrigidas e seus cenários passaram no conjunto final.
- TerraScape real: download de 1.596.061.835 bytes, instalação de 1.870.418.152 bytes, build 2.2.1.0. Pausa, encerramento do processo, reabertura da persistência isolada e retomada reaproveitaram os arquivos parciais. O status só ficou completo após saída do processo e validação do manifesto/executável.
- Save local e remoto conferidos byte por byte; backups preservados. Restauração pela implementação concluída, lançamento autenticado e detecção do executável real confirmados. O usuário confirmou que o jogo abriu com seu save e o fechou pelo menu.
- Fechamento real detectado pelo watcher, seguido de estabilização, envio de cópia privada e leitura de volta da nuvem. Local e nuvem terminaram com o mesmo digest e `pendingSaveUpload: false`. Os três arquivos originais permaneceram iguais ao backup inicial; o jogo criou dois arquivos adicionais de backup, também incluídos pelo filtro Epic.
- Preparação offline real pelo Legendary com timestamps de credenciais expirados na cópia de teste: os parâmetros foram validados sem renovar sessão nem consultar a API Hydra. Não houve um segundo spawn do jogo; essa prova cobre a preparação, não uma sessão completa offline.
A instalação real usou os serviços de produção com portas isoladas de persistência e fila. Não substitui um teste visual completo dos IPCs e da tela de Downloads no Electron, nem comprova reinício completo do aplicativo. Linux/macOS continuam indisponíveis nesta integração até validação própria. Prerequisitos com UAC foram cobertos por testes de supervisão; TerraScape não precisou executá-los. Sem commit ou push automático.
## Playtime das sessões iniciadas pelo Hydra
O serviço `src/main/services/epic/playtime-reporter.ts` registra somente sessões gerenciadas pelo Hydra via Legendary, após detecção do processo e verificação do caminho. Sessões identificadas apenas pelos manifestos do launcher oficial não enviam tempo: continuam consultando a contribuição Epic existente. Download, preparação dos parâmetros JSON e reserva do lançamento não iniciam o contador.
O alvo é capturado no lançamento: ambiente, usuário Hydra, geração de autenticação, conexão, conta Epic e `artifactId` efetivamente instalado. O serviço guarda uma cópia dessa identidade e usa o provedor privado de sessão existente. Nenhum token chega ao renderer, ao relatório de erro ou ao estado público.
Cada inspeção com um processo do jogo verificado na árvore gerenciada atualiza a observação. Um overlay externo não invalida essa prova de atividade: a completude da árvore decide se é possível confirmar o fechamento, enquanto o processo verificado decide se há tempo a registrar. Um overlay sozinho não registra tempo. Duração monotônica e timestamps UTC em segundos inteiros delimitam trechos consecutivos de no máximo 60 segundos. O fechamento envia apenas o restante até a última observação real. Inspeção sem processo verificado, suspensão ou uma lacuna de mais de 10 segundos entre observações interrompem a continuidade; uma nova observação inicia outro trecho, sem preencher o intervalo desconhecido. Uma correção regressiva do relógio não pode sobrepor intervalos já submetidos.
O envio ocorre no main, por `PUT /library/api/public/playtime/account/{accountId}/bulk`, com um item em `playtimeAddList`: `machineId` UUIDv4 anônimo gerado uma vez por processo do Hydra, `artifactId`, `startTime`, `endTime`, `startSegment: true` e `endSegment: true`. As flags seguem o payload validado; não são tratadas como garantia de idempotência. A fila em memória é serial por sessão e aceita no máximo dois trechos pendentes. Os requests têm timeout de 15 segundos e não bloqueiam o watcher.
Um HTTP 401 explícito permite uma renovação coordenada e uma segunda tentativa do mesmo payload. Timeout, resposta perdida, 5xx e outros erros não reenviam o trecho. O cursor avança ao submeter: falhar na reconciliação não repete um incremento confirmado. Os erros registram somente identidade do jogo e código controlado.
Após confirmação, o cache de leitura é limpo sem abortar uma coleta de snapshot em andamento. O fluxo individual existente consulta o total fresco, publica o máximo na API e faz o merge local. Não se soma o intervalo diretamente ao tempo Epic ou Hydra. A opção de também contar no Hydra continua independente do envio à Epic.
Troca de usuário, ambiente, conexão e desconexão abortam os requests antigos. Shutdown normal drena os trechos já observados com timeout antes de invalidar a sessão, sem simular fechamento do jogo e sem executar o fluxo de saves por esse motivo. Se o aplicativo estiver encerrando, a leitura de reconciliação pode ficar para o próximo sync de inicialização.
Não há fila persistente ou recuperação de sessões offline. Períodos desconectados, falhas, intervalos sem observação e trechos descartados por fila cheia podem perder tempo. Crash pode perder o último trecho ainda não enviado. Uma conexão posteriormente confirmada pode registrar somente observações futuras da mesma identidade capturada; não recupera o intervalo anterior nem troca de conta.
O teste preliminar com TerraScape comprovou o endpoint: total remoto de 266 para 327 segundos, HTTP 204 e incremento de 61 segundos para um intervalo real de aproximadamente 60,8 segundos. A implementação usa segundos inteiros para evitar esse arredondamento entre trechos. A validação final confere dois segmentos consecutivos usando o serviço de produção, com os saves fora do fluxo do teste.
A validação final do serviço registrou dois trechos contíguos de 60 e 12 segundos, ambos HTTP 204. O total remoto passou de 327 para 399 segundos: incremento exato de 72. O mesmo `machineId` foi usado nos dois requests, não houve erros e as cópias privadas de credenciais foram removidas. Não foram executadas operações de nuvem de saves; o diretório local foi copiado antes de abrir a instalação de teste. Evidência sanitizada: `.cache/epic-playtime-validation/feature-result.json`.
Validação integrada no Electron em 14/10/2026: uma instalação nova do TerraScape reproduziu o bloqueio causado por `EOSOverlayRenderer-Win64-Shipping.exe` fora da pasta do jogo. A árvore tinha processos do jogo com ancestralidade e caminhos verificados, mas `complete: false` impedia iniciar o reporter. Após separar prova de atividade da prova de fechamento, a sessão aberta pelo botão Jogar enviou 60 segundos durante a execução e 29 segundos ao fechar. O total lido diretamente da Epic passou de 399 para 488 segundos. Não houve incremento sintético nem reenvio das sessões anteriores. O teste de regressão cobre jogo com overlay externo, overlay sozinho e fechamento real; os 18 testes focados passaram, assim como typecheck do main, ESLint e formatação dos arquivos afetados.
As suites pertinentes cobrem os intervalos, opt-in Hydra, vínculo/conta capturados, retry exclusivo de 401, timeout sem replay, inspeção e suspensão, encerramento, reconciliação, leitura do total e regressões Epic/Hydra. Typecheck do main e web, ESLint dos arquivos afetados, formatação e `git diff --check` integram a conferência desta entrega.

View file

@ -0,0 +1,391 @@
# Epic Games no Linux com Legendary e UMU
SDD para implementação em 14 de outubro de 2026. Status: desenho fechado; implementação e aceite real no Linux pendentes.
Base: launcher `a4dfafb6621fc4e274158db59604dcdccc41a244`, branch `codex/epic-achievements-sync`. Esta especificação complementa `docs/epic-download-launch-cloud-saves-sdd.md` e substitui, para o alvo abaixo, a decisão anterior de manter a instalação gerenciada restrita ao Windows.
## Resultado esperado
Um usuário Linux escolhe Epic Games no seletor atual, escolhe a pasta, baixa pela fila do Hydra e clica em Jogar. A preparação do Proton, do prefixo e das dependências acontece automaticamente. A experiência usa os mesmos modais, controles de download, janela de preparação e opções de compatibilidade já existentes. Conta Epic, autenticação de lançamento, instalação, saves e acompanhamento da sessão continuam sob o controle do Hydra.
O alvo de execução desta entrega é Linux x86_64, incluindo Steam Deck. A distribuição Linux atual é construída pelo job Ubuntu x64; uma versão ARM64 do Legendary, isoladamente, não comprova execução dos jogos Windows nesse hardware. Conexão e importação existentes em outras arquiteturas continuam disponíveis. Tradução de arquitetura, lançamento Epic em macOS, gerenciamento novo de DLCs e atualização automática de jogos não fazem parte desta entrega. A compatibilidade de cada jogo com Proton, DRM e anticheat continua determinando se ele executa; o fluxo precisa distinguir uma falha do jogo de uma falha da integração.
## Decisões de implementação
| Assunto | Decisão |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Download | Manter Legendary 0.21.1 e seu workspace gerenciado atual. Linux baixa o build Epic `Windows`. |
| Execução | Legendary produz parâmetros com `launch --json --no-wine`; Hydra os executa pelo UMU. |
| UMU | Reutilizar o pacote, o updater e o monitor existentes, acrescentando uma API de processo supervisionado. |
| Proton | Versão válida escolhida no jogo, depois padrão global válido, depois `UMU-Proton` gerenciado pelo UMU. A preparação automática passa esse token explicitamente. |
| Instalação de Proton e runtime | UMU faz o provisionamento; Hydra não cria outro downloader de Proton e não exige Steam instalado. |
| Python | Reutilizar o `python-runner` privado já distribuído para o Legendary Linux. Python de sistema deixa de ser requisito deste fluxo. |
| Prefixo | Reutilizar seleção e raiz configuradas no Hydra; novos prefixos Epic têm identidade estável por conta e artefato. |
| Pré-requisitos | Executar no mesmo prefixo e Proton, com resultado Windows completo coletado por um pequeno supervisor PE incluído no pacote. |
| Interface | Nenhuma nova tela. Somente estados, mensagens e correções nos controles atuais. |
| Saves e sessão | Preparar prefixo antes do restore; acompanhar o processo Windows real no Linux antes de contar tempo ou enviar saves. |
| Backend | Nenhum endpoint, migration ou alteração de identidade de biblioteca necessário. |
O Proton é escolhido uma vez por operação e convertido em caminho absoluto. Preparação, pré-requisitos e lançamento não podem resolver versões diferentes no meio do mesmo fluxo. A opção Automático mantém a usabilidade atual; sua última alternativa nesta integração é explicitamente `UMU-Proton`, sem depender do default implícito de uma versão do UMU.
## Comportamento na interface
### Baixar
O seletor oferece Epic quando existe posse importada no escopo atual e o main informa suporte à instalação gerenciada. O modal atual mostra os tamanhos, permite escolher diretório e inicia ou enfileira o download. Não há seleção de Wine, Proton ou prefixo nesse modal.
O download usa a lista e o slot atuais. Quando os arquivos terminarem, a fase existente `prerequisites` também representa a preparação necessária da compatibilidade. O rótulo dessa fase passa a ser neutro, como “Preparando instalação”. Bytes em 100% com preparação pendente continuam sendo uma instalação ativa. Concluir exige validar os arquivos, preparar o ambiente, resolver os pré-requisitos e associar a instalação.
Pausa, cancelamento, retomada e erro continuam nas ações atuais. Uma falha ao baixar o Proton ou instalar uma dependência permite tentar novamente a finalização, reaproveitando os arquivos do jogo.
### Jogar
Jogar usa o membro Epic operacional já instalado, inclusive quando a página apresenta dados Steam. Abre a janela de preparação atual. Ela apresenta o estado existente de preparação da compatibilidade, depois os estados de saves e de início do jogo. Proton e prefixo continuam configuráveis nas seções atuais de compatibilidade, no desktop e no Big Picture.
Sem configuração manual, o primeiro uso baixa o runtime necessário automaticamente. Sem rede e sem runtime em cache, a janela existente explica que a primeira preparação precisa de conexão e permite tentar novamente. Se o runtime já existir e o jogo permitir offline, jogar offline usa o mesmo prefixo, preserva saves locais e mantém o envio remoto pendente.
Não acrescentar wizard, login Epic adicional, importação pelo Heroic, escolha obrigatória de versão, tela de instalação de Proton ou seletor de executável para instalações comprovadas pelo manifesto. Conflito real de save continua usando o modal de conflito existente. Erros de permissão, prefixo inválido ou dependência do host aparecem no fluxo atual, com a ação pertinente, sem exigir terminal como parte do caminho normal.
## Evidência na base atual
As referências de linha abaixo correspondem ao commit-base e orientam a implementação.
| Local | Comportamento atual | Alteração necessária |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| `src/main/services/epic/installation.ts:315,393,480` | Bloqueia qualquer host diferente de `win32` antes de consultar opções. | Política única de host, arquitetura e build; permitir Linux x64 com build Windows. |
| `src/renderer/src/hooks/use-epic-managed.ts:13` | Disponibilidade verifica apenas funções IPC. | Consumir suporte à instalação informado pelo main. |
| `src/main/services/epic/download.ts:497,540,573` | Recupera build existente, valida arquivos, executa pré-requisito e faz bind. | Preservar o fluxo; acrescentar preparação Linux e pré-requisito por prefixo. |
| `src/main/helpers/launch-game.ts:254,519,758` | Já resolve Proton do jogo/global, prepara prefixo e calcula contexto Linux. | Extrair resolução compartilhada para download, lançamento e saves Epic. |
| `src/main/helpers/launch-game.ts:649,783` | Epic faz `spawn` direto do executável e ignora o runner Linux. | Despachar pelo UMU preservando o payload autenticado. |
| `src/main/services/umu.ts:366` | Retorna `void`, fixa diretório, registra argv/ambiente e ativa `PROTON_LOG`. | API com handle, cwd, ambiente, cancelamento e diagnóstico seguro. |
| `src/main/services/epic/launch-protocol.ts:28` | Exige `launch_command` vazio. | Manter a garantia; wrapper não entra pelo JSON Legendary. |
| `src/main/services/epic/managed-process-tracker.ts:15` | Considera executável do host dentro da instalação; intermediários externos deixam inspeção incompleta. | Prova específica de processo Windows executado por Proton. |
| `src/main/services/native-addon.ts:156` | `processTree` descarta contexto Linux útil. | Projeção mínima com identidade e dados de atribuição, sem argv/ambiente completo. |
| `src/main/services/process-watcher.ts:402` | Match heurístico pode prevalecer sobre o tracker Epic. | Tracker gerenciado autoritativo para sessões Epic iniciadas pelo Hydra. |
| `src/main/services/epic/cloud-saves.ts:178,244,274` | Prefixo implícito pode cair no home Linux em consultas e sync manual. | Usar o mesmo resolvedor de prefixo em todas as operações. |
| `resources/legendary/linux-python.sh`, `src/shared/legendary-manifest.json` | Runtime Python privado já existe e é verificado no pacote. | Expor seus caminhos para UMU sem passar pelo wrapper Legendary. |
O bloqueio inicial foi reproduzido por injeção de host Linux: `getOptions()` retorna `{ options: [], error: "unsupported-platform" }` antes de capturar a conta. Isso explica os tamanhos vazios; não é uma falha de download do Legendary.
## Responsabilidades e contratos
```mermaid
flowchart TD
UI[Modal Epic e fila existentes] --> DL[EpicDownloadService]
DL --> LG[Legendary gerenciado]
DL --> CTX[Contexto Linux compartilhado]
PLAY[Jogar existente] --> CTX
CTX --> UMU[UMU e Proton]
PLAY --> AUTH[Parâmetros Epic pelo Legendary]
AUTH --> UMU
UMU --> TRACK[Tracker da sessão Linux]
TRACK --> SAVE[Saves e playtime existentes]
```
`installation.ts` continua responsável por identidade, posse, metadados, instalação e paths. `download.ts` conserva a fila e a finalização. `epic/launch.ts` conserva autenticação e identidade da sessão. `umu.ts` executa processos; não conhece contas Epic nem publica dados de biblioteca. O tracker decide atividade e encerramento; o serviço de saves conserva conflito, backup, restore e verificação remota atuais.
Extrair de `launch-game.ts` a resolução comum de Proton/prefixo para um serviço pequeno de compatibilidade Linux. Acrescentar `src/main/services/epic/linux-runtime.ts` para coordenar essa resolução com owner, artefato, preparação e recibos Epic. Evitar dependência circular entre helpers de lançamento e serviços Epic.
Contratos internos propostos:
```ts
interface PreparedEpicLinuxContext {
pythonRunnerPath: string;
umuBinaryPath: string;
umuVersion: string;
protonPath: string; // caminho absoluto efetivo
protonFingerprint: string;
winePrefixPath: string; // prefixo Wine canônico
prefixGeneration: string;
gameId: string; // umu ID comprovado ou umu-default
store: "egs";
}
interface UmuProcessHandle {
pid: number;
invocationId: string;
exited: Promise<{
code: number | null;
signal: NodeJS.Signals | null;
}>; // saída do wrapper, não prova isolada de fechamento do jogo
terminate(): Promise<void>; // termina e confirma a árvore desta invocação
}
```
A nova entrada supervisionada do UMU recebe executável, `args: string[]`, cwd, ambiente validado, contexto preparado, finalidade (`game`, `prerequisite` ou `prepare`), `AbortSignal` e callbacks de estado. Retorna o handle ao observar `spawn`, sem esperar três segundos para registrar a sessão. O comportamento público atual de `Umu.launchExecutable` pode permanecer como adaptador para os consumidores existentes.
O callback de pré-requisito passa a receber `{ game, target, installed, executable, args, signal }`, permitindo escolher prefixo e owner. O ramo Windows continua usando seu executor e UAC atuais. Os DTOs de iniciar download e jogar permanecem suficientes; caminhos privados, handles e parâmetros autenticados não atravessam IPC.
## Suporte de plataforma e compatibilidade OTA
Centralizar a decisão de suporte no main, usada em opções, enqueue, prepare, read/repair e launch. Nesta entrega: Windows nos alvos já suportados e Linux x64 com build `Windows`. Não criar `EpicInstallPlatform = "Linux"` e não liberar `Mac` como consequência da remoção do guard.
Acrescentar um campo opcional local a `EpicConnectionState`:
```ts
managedInstallation?: {
available: boolean;
reason?: EpicInstallError;
};
```
Esse campo usa o IPC/evento de conexão existente, mas descreve instalação separadamente de login/importação. A ausência de Proton baixado não torna a plataforma indisponível: ela exige preparação recuperável. Falta de arquivo obrigatório no pacote deve gerar erro de runtime, não erro de credencial.
Desktop e Big Picture compartilham a checagem. Em main antigo sem o campo, o renderer mantém o comportamento legado Windows e trata Linux como dependente de atualização do aplicativo. Não inferir suporte Linux apenas da presença dos IPCs. O main sempre revalida; a UI não é uma barreira de segurança. A entrega exige pacote nativo atualizado, pois renderer OTA sozinho não altera serviço, Python, supervisor ou addon.
## Runtime e escolha do Proton
### Python e UMU distribuídos
Manter Legendary 0.21.1 e o bundle Linux já descrito no manifesto: CPython 3.13.16, release 20261003, e glibc privada com hashes. Expor um resolvedor de caminhos que devolva o `python-runner` ao lado desse runtime. Invocar `python-runner <umu-run> ...` diretamente. O wrapper `legendary` altera o cache para seu workspace e não deve ser usado para iniciar UMU.
O loader privado recebe `--library-path` somente nessa execução, sem exportar `LD_LIBRARY_PATH` para o jogo. O zipapp UMU distribuído contém suas dependências Python. O pacote deve passar por smoke real de imports, HTTPS e execução; a compatibilidade estática desses arquivos não substitui esse teste.
Preservar o updater UMU e a seleção de binário gerenciado quando houver adaptador de protocolo validado para aquela versão. Inicialmente, o contrato é o UMU 1.4.4 distribuído. Uma versão gerenciada sem contrato conhecido usa o 1.4.4 incluído, sem bloquear o usuário por um update automático ainda não homologado. Fixar o caminho/versão do binário enquanto a operação existir; a limpeza de versões antigas não pode remover arquivos em uso.
### Resolução automática sem outro gerenciador
A prioridade é `game.protonPath` válido, `defaultProtonPath` válido e, por último, o token explícito `UMU-Proton`. Reutilizar validação e descoberta existentes. Um path removido segue a resolução válida seguinte, como no Hydra atual; uma operação já preparada não troca de runner silenciosamente.
UMU faz download e validação do Proton e do Steam Linux Runtime, conforme o [gerenciador de Proton 1.4.4](https://github.com/Open-Wine-Components/umu-launcher/blob/1.4.4/umu/umu_proton.py). Não consultar releases do GE-Proton em um segundo serviço. Um Proton customizado continua selecionável pelos controles existentes e precisa satisfazer os requisitos do UMU utilizado.
Para fixar o caminho efetivo da seleção automática, executar `createprefix` sem autenticação Epic, com `UMU_LOG=debug` capturado exclusivamente em pipe privado. O adaptador do UMU 1.4.4 extrai apenas a atribuição final `PROTONPATH=<caminho absoluto>` emitida após a resolução. Validar `realpath`, `proton`, `toolmanifest.vdf` e identidade da versão; descartar o restante da saída. Não persistir dump de ambiente, escolher a primeira pasta encontrada nem depender do nome de um symlink mutável.
Nota da implementação: o smoke do UMU 1.4.4 confirmou que `createprefix` pode preparar o prefixo e retornar `1` pela ausência de um executável. O bootstrap usa o supervisor distribuído em modo `--probe`, sem autenticação e com saída `0` comprovada, mantendo a resolução privada de `PROTONPATH` e evitando aceitar um código de falha ambíguo.
Esse diagnóstico não é uma API JSON pública: mantê-lo isolado, versionado e coberto por fixture do zipapp distribuído. Prefixo válido sem runner efetivo comprovado não conclui a preparação. Depois disso, passar `PROTONPATH` absoluto em todas as chamadas. Para o próximo lançamento, Automático pode resolver a versão estável então disponível; dentro da sessão e de sua finalização, a identidade é fixa.
Usar os locks de download/runtime do UMU e uma preparação compartilhada no Hydra para cliques concorrentes. Offline carrega o caminho absoluto e os fingerprints do último contexto preparado no recibo, revalida os arquivos locais, não chama o updater Hydra e passa `UMU_RUNTIME_UPDATE=0` com `PROTONPATH` absoluto. Não executar bootstrap com token de release nesse caminho. Sem recibo/cache válido, retornar erro recuperável antes de obter parâmetros autenticados.
No UMU 1.4.4, a flag de runtime não elimina toda sondagem de metadata remota. O requisito é executar com cache mesmo sem rede, sem exigir atualização bem-sucedida nem trocar arquivos/runner; a sondagem deve respeitar o timeout do upstream. Não prometer ausência de qualquer tentativa de rede. O teste offline precisa bloquear a rede de fato e comprovar a execução, inclusive o comportamento desse timeout.
### Identidade para protonfixes
Passar `STORE=egs`. Resolver `GAMEID` por correspondência exata de loja e `artifactId/app_name` em snapshot local do CSV oficial [Open-Wine-Components/umu-database](https://github.com/Open-Wine-Components/umu-database#readme). O gerador de snapshot filtra `egs`, normaliza somente as chaves conforme o upstream e registra commit, hash e licença do CSV importado. Correspondências conflitantes são rejeitadas no build. O arquivo não precisa conter títulos nem biblioteca do usuário.
Ausência de entrada usa `umu-default`. Não sintetizar um umu ID a partir de título, `namespace:item`, canonicalId ou Steam appId apenas visual. Esse mapa pequeno permite aplicar os fixes disponíveis sem uma consulta remota obrigatória ao jogar. Atualizá-lo no fluxo de release; o updater do UMU continua responsável pelo runtime.
## Prefixo e persistência
Respeitar um `game.winePrefixPath` explicitamente configurado e canônico. Para instalações novas sem escolha explícita, usar a raiz de prefixos já configurada no Hydra, com segmento `epic/<hash(environment,userId,epicAccountId,artifactId,platform)>`. Isso preserva a preferência global de local e separa contas, lojas e artefatos. O contexto Epic não usa o prefixo externo do Legendary/Heroic.
O resolvedor é a autoridade de download, pré-requisito, launch, consulta de saves, sync manual, conflito e pós-saída. Um prefixo automático é derivado dessa identidade; não precisa virar uma segunda preferência. Prefixos explícitos existentes são preservados, sem migração destrutiva.
Acrescentar a consulta de leitura `getGameLinuxCompatibility(shop, objectId)`, com bridge opcional e validação de sender/identidade, para os controles atuais do desktop e Big Picture. Ela retorna `{ winePrefixPath, protonName, protonSource }` efetivos a partir do resolvedor e do recibo local, sem download, refresh de conta ou criação de prefixo. `protonSource` identifica `game`, `global` ou `umu`; `protonName` pode ser nulo antes do primeiro bootstrap. Não devolver credenciais ou preferências duplicadas. Remover a concatenação local `root/objectId` para Epic e mostrar o path retornado nos controles atuais. Em bridge antiga, usar a indicação Automático existente; o renderer não inventa um path Epic. Proteger a chamada com checagem de existência e incluir no teste OTA.
Reutilizar as utilidades de canonicalização, validação de registry/perfil e geração durável de prefixo. Preparação sem registry e perfil válidos falha antes de restore ou spawn. Troca ou recriação do prefixo invalida recibos de pré-requisito e referências de save do ambiente anterior. Acrescentar a `EpicInstallation` os campos opcionais `savePathSource: "automatic" | "custom"` e `savePathPrefixGeneration`. A resolução automática e `commitAnchor()` mantêm a origem; uma escolha no seletor atual grava `custom`. Paths automáticos são recalculados na mudança de prefixo; os explicitamente escolhidos conservam a validação atual.
Na migração, um path legado igual ao path automático comprovado do prefixo corrente pode ser marcado como automático. Os demais são preservados sem movimentação ou sobrescrita. Se a origem for ambígua e o ambiente mudar, limpar o anchor e exigir escolha pelo controle existente de pasta de saves, em vez de adivinhar. O anchor anterior não autoriza sobrescrever uma cópia local em outro prefixo: usar a resolução de conflito atual ao detectar mudança de ambos os lados.
Serializar mutações por prefixo canônico. Prefixo customizado compartilhado e ocupado por outra sessão não pode receber instalação de dependências ou restore. Não executar a limpeza genérica que mata processos pelo prefixo antes de provar que pertencem a uma invocação encerrada do Hydra. Jogos já abertos nunca são tratados como processos órfãos apenas por usarem o prefixo padrão.
Guardar no workspace Epic um recibo não credencial, com escrita atômica, para a preparação Linux: versão do esquema, geração/caminho canônico do prefixo, caminho absoluto efetivo/versão/fingerprint Proton, versão/fingerprint UMU, artefato/build e fingerprint do pré-requisito concluído. O fingerprint inclui os arquivos de identificação da ferramenta, não apenas seu nome de pasta. Esse arquivo registra o ambiente preparado; não substitui `installed.json`, `Game.winePrefixPath` ou `Game.protonPath`. Ele fornece o contexto local para offline. Se não houver geração durável, não confiar no recibo entre execuções; preparar novamente.
## Download e finalização
Preservar o comando e a fila atuais:
```text
legendary -y install <artifactId> --platform Windows
--base-path <pasta> --game-folder <pasta-validada>
--skip-dlcs --skip-sdl
```
Executar como argv separado, com a sessão e o workspace atuais. Manter verificação de posse, conexão, espaço, contenção e identidade do alias. Não promover importação Epic a jogo Hydra nem passar pelo pipeline de extração de repacks.
No Linux, finalizar nesta ordem: confirmar manifesto/build e executável; resolver contexto; preparar runtime/prefixo; executar pré-requisitos necessários; confirmar contexto atual e ausência de processos pendentes; fazer bind e marcar completo. Preparar o runtime também para jogos sem pré-requisito, deixando o primeiro Jogar pronto para o caminho normal.
O Legendary retoma pelos manifests e arquivos já concluídos; não prometer retomada exata do último byte. Pausa preserva arquivos e workspace. Se a build já estiver instalada, o retry entra na preparação/finalização sem repetir transferência. Cancelar interrompe também bootstrap ou pré-requisito, aguarda a árvore desta operação e conserva a possibilidade de retry. Remover da lista continua diferente de excluir a instalação.
A preparação ocupa o slot enquanto fizer parte da finalização, como os pré-requisitos Windows. Ela não segura a fila global de refresh de credenciais Epic: essa fila mantém a liberação após a fronteira autenticada já existente. Revalidar geração de conta/conexão antes de cada gravação final.
### Caminhos Windows em filesystem Linux
Executáveis e pré-requisitos podem ter barras ou casing diferentes do arquivo real. Resolver primeiro o path exato; se ele não existir, procurar os segmentos sem distinguir caixa somente para referências provenientes do build Windows. Exigir um único resultado em cada segmento. Depois de resolver, aplicar `realpath`, tipo de arquivo e contenção novamente.
Aplicar a mesma canonicalização à comparação do JSON de lançamento com o executável instalado. Não transformar paths Linux arbitrários em lowercase, aceitar ambiguidades de nome ou seguir symlink para fora da instalação. Cobrir diretórios com espaços, Unicode, volumes montados e a raiz `/mnt/storage` do relato. O prefixo padrão fica no local Linux já configurado; não movê-lo para o disco do jogo automaticamente.
## Pré-requisitos no Linux
O `-y` do Legendary não entrega essa instalação no Linux, como mostra o [pós-instalação no CLI fixado](https://github.com/legendary-gl/legendary/blob/42f6bdeadde3a9526dc8eb713763476999ac217b/legendary/cli.py). O Hydra executa o pré-requisito informado pelo manifesto, no Proton e prefixo já preparados. Desabilitar overlays, GameMode, opções de lançamento do usuário e parâmetros autenticados nessa chamada. Preservar os argumentos do instalador, sem inventar flags silenciosas.
O booleano global `prereq_info.installed` não comprova que uma dependência existe no prefixo atual. Considerar o recibo por geração do prefixo, artefato/build, hash do instalador, argumentos e fingerprint Proton. Revalidar também antes de jogar, pois o usuário pode trocar ou recriar o prefixo depois do download.
### Resultado Windows completo
Acrescentar `native/epic-prerequisite-runner`, um supervisor PE x64 pequeno, compilado do fonte versionado e empacotado com o Hydra Linux. Implementar em Rust com `serde_json` e `windows-sys`, usando `CreateProcessW`, espera do handle e `GetExitCodeProcess`. Ele recebe um arquivo JSON privado com executável, argv, cwd, nonce e destino do resultado; não recebe código de shell. Usar build `x86_64-pc-windows-gnu` no CI Linux e toolchain MinGW. O build deve produzir e verificar o artefato antes do empacotamento.
Para `.exe`, executar o instalador comprovado. Para `.msi`, o supervisor resolve `msiexec.exe` pelo diretório Windows de sistema (`GetSystemDirectoryW`) e executa `/i <pacote> ...args`, sem busca por PATH/cwd. Converter os caminhos consumidos por Win32 usando os verbos `getcompatpath` e `getnativepath` do Proton, chamados pelo mesmo UMU/contexto, e validar o roundtrip. Não presumir a existência de `Z:` em prefixos customizados.
O supervisor escreve atomicamente o DWORD completo retornado por `GetExitCodeProcess`, associado ao nonce. O main aceita somente resultado válido de arquivo privado regular, para a tentativa atual, seguido de encerramento comprovado da árvore do instalador. A política de pré-requisitos acompanha também filhos MSI/system PE fora da pasta do jogo, por token, prefixo e ancestralidade comprovada, inclusive após reparentamento; não aplica a regra de PE dentro da instalação usada para contar atividade do jogo. O resultado do processo raiz, sozinho, não conclui essa etapa.
Sucesso admite `0`, `3010` e `1641` comprovados. Para os dois últimos, após supervisor e filhos encerrarem e sob exclusão do prefixo, executar `wineboot.exe -r` do diretório Windows de sistema pelo mesmo supervisor, UMU e Proton. Esperar a invocação e seus processos de reboot, revalidar registry/perfil e só então persistir a preparação concluída. Reboot interrompido ou inválido continua pendente para retry. Nunca solicitar reboot do host. No Linux, os valores POSIX `194` e `105`, isoladamente, não comprovam esses códigos Windows.
Ausência de recibo, timeout, processo ainda ativo, falha ou cancelamento mantém a preparação incompleta. Reutilizar `EPIC_PREREQUISITE_TIMEOUT_MS` e a política de interrupção existente; constantes adicionais ficam nomeadas. Após sucesso, persistir o recibo Linux e atualizar `prereq_info.installed` sob o lock do workspace. A próxima tentativa pode repetir a preparação sem baixar os arquivos novamente.
Esse supervisor é necessário para manter o resultado de instaladores do fluxo Windows; não cria um novo gerenciador de dependências ou uma tela de instalação. Instaladores que exibirem sua própria interação mantêm essa interação no Wine.
## Lançamento autenticado
Preparar contexto e pré-requisitos antes de pedir o código de autenticação do jogo. Depois executar o gate de saves existente com esse prefixo, obter `legendary launch <artifactId> --json --no-wine`, validar o payload e revalidar a sessão imediatamente antes do spawn. A preparação longa não consome a validade do código retornado pela Epic.
Manter `launch_command` vazio no parser. Adicionar `--wrapper` ao JSON atual quebraria esse contrato; o wrapper pertence ao runner do Hydra. Encaminhar `game_parameters`, `user_parameters`, `egl_parameters`, ambiente e cwd como campos separados. `%command%`, GameMode e MangoHud continuam usando a composição existente, aplicada uma única vez ao comando UMU.
Para jogos `requires_ot`, o [core Legendary fixado](https://github.com/legendary-gl/legendary/blob/42f6bdeadde3a9526dc8eb713763476999ac217b/legendary/core.py) pode produzir `-epicovt=<path POSIX>` em `egl_parameters`. Esse argumento exige tradução de path, não cópia literal para o processo Windows. Durante o preflight, preparar o diretório `tmp` real e privado do workspace, sem symlink, validar/remover o arquivo anterior do basename esperado e mapear esse diretório pelo contexto Proton com roundtrip comprovado. Isso precisa acontecer antes do Legendary, cujo arquivo OVT tem nome determinístico e é aberto para escrita.
Após gerar o payload, aceitar somente arquivo regular contido nesse diretório, da sessão/artefato atuais, com leitura limitada e sem symlink. Converter somente o valor de `-epicovt`, usando o mapeamento de diretório já comprovado e o basename validado. Não fazer nova chamada de bootstrap/conversão UMU depois de obter o código de autenticação. O conteúdo do token e os demais argumentos autenticados permanecem inalterados.
Manter o arquivo OVT privado durante a vida da sessão, pois o processo Windows pode lê-lo depois do spawn. O handle da sessão conserva esse recurso e o remove após fechamento/cancelamento comprovado; no startup, reconciliar arquivos remanescentes antes de limpar. Não apagar um OVT ainda usado por jogo ativo ao desconectar a conta. O conteúdo e seu path autenticado não são publicados ou registrados em logs. Fixture e teste real de jogo `requires_ot` fazem parte do aceite.
A precedência do ambiente é: herdado do Hydra, ambiente Legendary validado, opções de ambiente do usuário e campos reservados finais da execução. `WINEPREFIX`, `PROTONPATH`, `STORE`, `GAMEID`, verbo Proton, ID da invocação e política de logging não podem ser substituídos por argumentos ou pelo payload remoto. UMU gera seus próprios campos de compatibilidade Steam. Rejeitar override dos parâmetros de identidade/autenticação Epic para impedir que opções do usuário alterem o vínculo comprovado. Não remover configurações gráficas legítimas do usuário por uma limpeza genérica de todo `LD_*`.
Para Epic gerenciado, falha do UMU é uma falha recuperável no fluxo atual. Não cair em `.exe` direto ou Wine de sistema com outro prefixo/runner. Uma execução sem autenticação só usa a opção offline já existente e os metadados que comprovam seu suporte.
### Diagnóstico sem credenciais
O caminho autenticado não usa os logs atuais de comando completo de `umu.ts`. Fixar `PROTON_LOG=0`, impedir debug UMU e capturar stdout/stderr em pipes tanto em desenvolvimento quanto no pacote. O [Proton registra argv em seu log quando habilitado](https://github.com/ValveSoftware/Proton/blob/proton_10.0/proton#L1694-L1702), fora do logger Hydra.
Converter somente eventos reconhecidos em códigos/estados seguros para os logs e a janela de preparação. Não salvar saída bruta de UMU/Proton/jogo, comando, ambiente Legendary ou exceção `spawn` com `spawnargs`. Logs operacionais podem conter versão do runtime, finalidade, fase, PID e códigos controlados. Limitar buffers/linhas com constantes. O diagnóstico de `createprefix` descrito acima ocorre sem payload Epic e é descartado após extrair o runner.
## Detecção do jogo e encerramento
O wrapper, o Python e o Wine loader não são o jogo. Criar um ID aleatório por invocação, passado como variável própria do Hydra, sem credenciais. Capturar PID e identidade de início antes de entregar o handle ao gate. O addon nativo projeta apenas PID, parentPID, start time, nome, cwd, executável do host, prefixo, ID da invocação e caminho PE comprovado quando disponível.
A extração do caminho Windows pode consultar `/proc/<pid>/cmdline` e mapas localmente e resolver as unidades do prefixo. Ela devolve somente o caminho canônico do PE; não exporta argv ou ambiente completo. Atribuir o jogo exige identidade da invocação ou ancestralidade já comprovada, prefixo correto e PE dentro da instalação validada. Nome de processo, cwd ou prefixo isolados não são suficientes. Identidade de início impede reutilização de PID. Comparações de paths Linux preservam caixa.
`EpicManagedProcessTracker` passa a distinguir atividade comprovada, preparação e inspeção desconhecida. Para uma sessão Epic gerenciada, esse resultado substitui o match heurístico genérico do watcher. Preservar a implementação Windows em seu ramo próprio.
Um filho reparentado conserva atribuição por token/prova anterior. Um wrapper ou overlay fora da pasta não conta playtime e não deixa o fechamento eternamente incompleto por sua mera presença. Um PE da sessão ainda capaz de escrever saves mantém o estado de execução. Falha de acesso ou snapshot incompleto gera estado desconhecido; não é evidência de lista vazia.
O encerramento exige desaparecimento comprovado dos processos de jogo/escrita atribuídos, inspeção completa e estabilização de saves existente. Inspeção completa se refere aos candidatos da própria invocação, delimitados por UID, ancestralidade, token, prefixo ou identidade previamente capturada; permissões ausentes em processos root/de outros usuários e sessões não tornam toda a máquina desconhecida. Uma falha de inspeção de candidato próprio suspende contagem e envio; recuperação de inspeção retoma a decisão. O handle UMU, `PR_SET_CHILD_SUBREAPER` e `waitforexitandrun` ajudam na observação, mas não são prova isolada de saída de toda a sessão. Após um crash do Hydra, reconciliar apenas provas duráveis da sessão/prefixo e manter upload pendente se o fechamento não puder ser provado.
Iniciar `EPIC_LAUNCH_DETECTION_TIMEOUT_MS` após preparação e spawn do jogo, não durante downloads de runtime ou instalação de dependências. Timeout antes de detectar jogo termina a preparação com erro controlado e encerra somente processos pertencentes àquela tentativa. Não usar os três segundos do monitor atual como prova de sucesso ou falha de um jogo de execução curta.
## Saves e playtime
Todas as entradas de saves resolvem o mesmo prefixo efetivo: consulta de estado, automático, manual, conflito, offline e pós-saída. No Linux, ausência de prefixo/perfil válido deixa o path não resolvido; nunca usa `app.getPath("home")` como perfil Windows. Resolver registry e diretórios reais do perfil antes de criar um caminho alternativo com casing diferente. Manter os filtros compatíveis com o Legendary no host; não tornar todo filtro case-insensitive apenas por ser um build Windows.
Reutilizar restauração em staging, backup, decisão de conflito, anchor, upload e verificação de leitura de volta atuais. Prefixo pronto e contexto atual são obrigatórios antes de restaurar. O restore acontece antes do processo autenticado. O upload ocorre uma vez após o fechamento real e mantém `pendingSaveUpload` quando falhar. Reabrir espera o envio iniciado ou cancela a inspeção ainda cancelável, conforme o comportamento atual.
Usar os eventos de detecção real para o reporter Epic e preservar opt-in de playtime Hydra. Wrapper sozinho não conta tempo. A sincronização automática Hydra legada/V2 não disputa arquivos com a sessão Epic gerenciada. Desconectar ou trocar conta invalida callbacks, publicação e sync da sessão capturada; uma sessão já em execução pode continuar, com observação mínima para manter o prefixo ocupado, sem enviar dados à nova conta. Finalizar processos de preparação e pré-requisito continua fazendo parte da invalidação.
## Recuperação e limites operacionais
| Situação | Comportamento exigido |
| -------------------------------------------- | --------------------------------------------------------------------------------------- |
| Reinício com download parcial | Retomar pelo mesmo workspace e destino após revalidar posse/conexão. |
| Arquivos completos e preparação interrompida | Repetir apenas preparação, recibos e bind necessários. |
| Mudança de Proton ou prefixo | Refazer contexto e pré-requisito pertinente antes de jogar; preservar arquivos e saves. |
| Pausa durante bootstrap ou instalador | Abortar e aguardar processos da tentativa antes de liberar o slot. |
| Conta muda durante finalização | Callback antigo não marca completo nem associa instalação. |
| Falha de rede no primeiro Proton | Erro recuperável na interface atual; download do jogo permanece aproveitável. |
| Offline com cache pronto | Mesmo runner/prefixo e política offline Epic existente; sem refresh obrigatório. |
| Prefixo compartilhado ocupado | Impedir mutação/restore com mensagem atual de operação/jogo em execução. |
| Exclusão do jogo falha | Preservar registro instalado para retry; não apagar prefixo ou saves implicitamente. |
| Encerramento não comprovado | Manter sync pendente; não fazer upload por saída do CLI ou wrapper. |
Na retomada automática do startup, falha de rede/runtime ou de revalidação da posse/conexão deve persistir `paused` com `epic.error` seguro e `queued=false`, publicar a atualização e liberar o slot apenas se nenhum processo estiver ativo. Arquivos/recibos são preservados e os controles atuais permitem retry. Não deixar um registro `active` sem processo porque o caller apenas registrou a exceção. Se a parada da árvore ainda não tiver sido comprovada, manter a operação ocupada e expor a falha de limpeza.
### Erros no fluxo existente
Acrescentar a `EpicInstallError` os códigos `compatibility-runtime-unavailable`, `compatibility-runtime-download-failed`, `compatibility-prefix-invalid`, `compatibility-prefix-busy` e `compatibility-launch-failed`. Mapear esses códigos no namespace `game_details`, no mesmo alerta/retry de download ou de lançamento. Falha de download de Proton não vira pedido de reconectar à Epic; prefixo ocupado usa a mensagem de jogo/operação em execução; falha de preparação indica tentar novamente ou abrir a seção de compatibilidade existente. `prerequisites-required` permanece para instalador/recibo incompleto, com texto neutro sobre concluir a preparação. Atualizar os idiomas e o reconhecimento de erros de launch; nenhum campo novo é obrigatório em persistência antiga.
Não matar todos os processos Wine do usuário. Interrupção usa grupo/descendentes e token da invocação; no UMU, o subprocesso do runtime pode ter nova sessão de processo. `kill(-pid)` do wrapper sozinho não garante limpeza. Revalidar identidade do PID antes de sinalizar e confirmar desaparecimento dos descendentes antes de concluir o cancelamento. Não limpar credenciais enquanto um comando Legendary ainda puder usá-las.
O runtime/Proton é compartilhado e não deve ser apagado ao remover um jogo, limpar downloads ou desconectar Epic. A limpeza de credenciais continua separada dos arquivos de instalação, prefixo, recibos e saves.
## Alterações por componente
| Componente | Trabalho |
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `epic/installation.ts`, tipos Epic | Política Linux/build, resolução de caminhos e suporte gerenciado separado de conexão. |
| Serviço comum de compatibilidade Linux e `epic/linux-runtime.ts` | Contexto, prefixo, geração, runner efetivo, locks e recibos. |
| `umu.ts`, `umu-launch-monitor.ts`, `umu-updater.ts` | Python privado, API supervisionada, versões em uso, estados e logging seguro. |
| `native/epic-prerequisite-runner`, scripts de build, `electron-builder.yml` | Supervisor PE, build reproduzível e verificação no pacote Linux. |
| `epic/download.ts`, `epic/index.ts` | Preparação Linux na finalização, callback de pré-requisito e cancelamento/invalidação. |
| `launch-game.ts`, `epic/launch.ts`, `launch-protocol.ts` | Despacho UMU, cwd/argv/env, contexto estável e detecção desde o spawn. |
| Addon Rust, `native-addon.ts`, tracker Epic e watcher | Prova Linux de invocação/PE, identidade de PID e fechamento real. |
| `epic/cloud-saves.ts` e utilidades de perfil | Prefixo efetivo em todas as entradas; segurança de restore/upload e mudança de ambiente. |
| Hook, modais desktop/Big Picture e traduções | Disponibilidade correta, fase neutra, mensagens contextuais e controles existentes. |
| Eventos, preload e declarações tipadas | Campo de suporte opcional, consulta de contexto por jogo e guards de compatibilidade OTA. |
| Snapshot UMU e seu gerador | Mapa Epic por artefato com origem/licença verificadas. |
Os HTTP 400 de `/profile/games/artifacts?shop=epic` e `/download-sources/changes` são contratos separados da API Hydra. Eles não originam o bloqueio local e não são dependência do downloader gerenciado. Não expandir `GAME_SHOP` globalmente como parte desta entrega. Se aparecerem na validação, registrar e corrigir no escopo próprio, sem mascarar falha de download.
## Ordem de implementação
1. Fechar fixtures de protocolo do Legendary/UMU, contexto compartilhado, Python privado, resolução de runner/prefixo e política de suporte. Adicionar o supervisor e o mapa UMU com seus builds/contratos.
2. Integrar a preparação e os recibos ao download; validar retry, pausa, encerramento da árvore e finalização sem baixar a build novamente.
3. Integrar o payload Epic ao UMU, composição de ambiente, diagnóstico seguro e tracker Linux. Corrigir as entradas de saves para o prefixo efetivo.
4. Ajustar disponibilidade, mensagens e estados nas interfaces existentes; validar desktop e Big Picture com os mesmos serviços.
5. Gerar pacote Linux, executar a matriz real abaixo, corrigir regressões Windows e só então habilitar suporte no release.
Trabalho de runtime/pré-requisito e trabalho de tracker podem avançar em paralelo após os contratos. O guard só é liberado no produto quando o caminho integrado estiver pronto. Remover o guard, esconder o botão ou provar apenas um download não conclui esta implementação.
## Testes e critérios de aceite
### Testes automatizados obrigatórios
| Área | Prova necessária |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Plataforma | Linux x64/build Windows e Windows legado aceitos; Mac/arquitetura não homologada recusados com motivo correto. |
| Disponibilidade | Mesmo resultado em desktop/Big Picture; main antigo/bridge ausente não anuncia suporte Linux; consulta de contexto exibe o prefixo efetivo sem criar runtime. |
| Seleção Proton | Jogo, global e automático; path removido; caminho efetivo fixo após bootstrap e sem troca no meio da sessão. |
| Python/pacote | Imports e zipapp com runtime privado; ausência do Python de sistema; hashes e todos os arquivos no pacote. |
| Parser UMU | Fixture 1.4.4 com path absoluto, output fragmentado/ANSI, path inválido, nenhum path e versão sem adaptador. |
| Prefixo | Isolamento por conta/artefato, customizado preservado, geração recriada, prefixo ocupado e falha de preparação. |
| Pré-requisito | EXE/MSI, argv/cwd corretos, nonce, recibo ausente/falso, códigos Windows completos 0/3010/1641 e falhas 194/105 reais. |
| Retry | Pré-requisito/Proton falha com bytes completos; retomar só finalização sem transferência duplicada; startup offline/conta alterada não deixa active sem processo. |
| Cancelamento | Durante download, bootstrap e instalador; árvore com subprocesso em nova sessão; slot retido até parar. |
| Paths | Espaços, Unicode, casing de metadata, colisão case-insensitive, symlink de escape e volume montado. |
| Auth/env | Argumentos Epic preservados e OVT convertido para Wine com lifetime correto; cwd; campos reservados protegidos; nenhuma credencial em logs, status ou erro spawn. |
| Prova de processo | Wine loader, PE filho, reparentamento, overlay, PID reutilizado, prefixo alheio e inspeção parcial. |
| Fechamento | CLI JSON termina, wrapper termina e jogo continua; nenhum dos dois dispara upload. Fechamento real dispara uma vez. |
| Saves | Prefixo padrão em getState/sync/conflito; nenhum fallback HOME; restore antes do jogo; upload/readback após saída. |
| Contexto | Desconexão/troca de conta durante cada etapa; callbacks antigos não escrevem na nova sessão. |
| Regressões | Windows, fila de outros provedores, usuários Linux existentes, instalação pelas duas lojas e comportamento OTA. |
Executar as suites Epic de instalação, download, manager/orquestrador, IPC, launch protocol, tracker, cloud saves e playtime; as suites UMU, prefixo, matching Linux, composição de launch options e estados de launcher; e testes nativos das projeções Linux e do supervisor. Acrescentar cenários funcionais aos testes existentes em vez de testar apenas helpers que espelham o código.
Para a implementação, seguir `.github/workflows/lint.yml` do launcher: `yarn test`, `yarn typecheck`, `yarn lint`, `yarn format-check` e `npx commitlint --last --verbose`. Como `yarn lint` faz auto-fix, revisar seu diff. Executar também `yarn check:ota-bridge`, o build nativo alterado e `yarn build:linux` no ambiente Linux. O pacote produzido deve conter Legendary/Python, UMU, supervisor e mapa com integridade verificada. Se o escopo separado da API for alterado e enviado, aplicar o checklist próprio de `hydra-api/AGENTS.md`.
### Aceite real no Linux
Usar conta e jogos de teste possuídos. Antes de operar nuvem, copiar os saves e conferir seus digests. A evidência deve identificar pacote/commit, distro, arquitetura, runtime/Proton efetivos, prefixo e resultados, sem tokens.
1. Em perfil limpo, sem Steam, Python sistêmico ou Proton previamente instalado, baixar um jogo pelo modal atual em pasta com espaços/volume montado. Confirmar provisionamento automático e arquivos/versão corretos.
2. Pausar durante transferência, fechar o Hydra, reabrir, retomar e comprovar reaproveitamento de arquivos. Repetir interrupção durante preparação e durante pré-requisito; retry não repete o download completo.
3. Executar instalador EXE e MSI de teste com os códigos completos de sucesso, reboot e falha, incluindo filhos e cancelamento. Para 3010/1641, o fixture agenda rename/delete e uma ação RunOnce; comprovar o efeito nos arquivos após o reboot do prefixo, além do recibo e bloqueio de conclusão prematura. Exit zero do wineboot ou desaparecimento da chave de operações pendentes, isoladamente, não prova esses efeitos.
4. Jogar online pelo botão atual. Confirmar autenticação Epic, Proton/prefixo escolhidos, estados de preparação, processo real detectado e funcionamento do jogo. Repetir com padrão global e seleção customizada por jogo, GameMode/MangoHud e argumentos com espaços.
5. Preparar um save remoto conhecido, restaurar antes de jogar, alterar progresso, fechar pelo jogo e comprovar upload somente após a saída real, com leitura de volta e digest. Repetir conflito e falha de rede no envio.
6. Jogar offline quando permitido, com cache completo; depois reconectar e confirmar recuperação do envio. Sem cache, obter erro recuperável sem danificar os arquivos.
7. Repetir download/início/erro/retomada no Big Picture e por controle, sem fluxo adicional. Validar ao menos desktop Linux e Steam Deck/gamescope, incluindo a janela de preparação existente.
8. Abrir jogo com launcher/filho/overlay e confirmar que saída do wrapper não encerra a sessão. Testar inspeção temporariamente indisponível e outro jogo no mesmo prefixo customizado.
9. Trocar/desconectar a conta com operações em andamento; confirmar ausência de publicação no owner errado e preservação de jogos/saves. Validar cancelamento sem processos órfãos da tentativa.
10. Executar o fluxo Windows de referência e comparar seleção, download, fila, pré-requisitos, Jogar, saves e erros. Fazer busca por credenciais sintéticas nos logs Hydra, UMU, Proton e saída de desenvolvimento.
Escolher um jogo simples, como Celeste se estiver na conta de teste, e outro que exija pré-requisito/autenticação mais representativos. Os fixtures de instalador complementam a amostra real. Um jogo sem pré-requisito ou sem cloud save não comprova as outras etapas.
A funcionalidade só está entregue quando downloads e runs reais, recuperação, isolamento de prefixo, pré-requisitos e fechamento/saves passarem na matriz pertinente. Sem essas provas, manter o suporte em validação e os casos pendentes explícitos; não declarar suporte completo com base apenas em testes unitários.
### Evidência disponível ao escrever o SDD
Na base auditada, 29 testes existentes de instalação Epic, launch protocol, tracker, monitor UMU, preparação de prefixo e matching Linux passaram, sem skips, usando `node --import ./scripts/register-ts-node.mjs --test --test-isolation=none` com as seis suites correspondentes. Isso confirma o comportamento atual desses contratos; não executa Proton nem comprova o suporte Linux proposto. Nenhuma alteração funcional foi feita para produzir esta especificação.
## Fontes primárias
- [Legendary 0.21.1 e release](https://github.com/legendary-gl/legendary/releases/tag/0.21.1): versão mantida no manifesto Hydra.
- [CLI Legendary no commit do release](https://github.com/legendary-gl/legendary/blob/42f6bdeadde3a9526dc8eb713763476999ac217b/legendary/cli.py): instalação, pós-instalação e `launch --json`.
- [Core Legendary no mesmo commit](https://github.com/legendary-gl/legendary/blob/42f6bdeadde3a9526dc8eb713763476999ac217b/legendary/core.py): ambiente, parâmetros e resolução de saves Wine.
- [Downloader Legendary](https://github.com/legendary-gl/legendary/blob/42f6bdeadde3a9526dc8eb713763476999ac217b/legendary/downloader/mp/manager.py): estado de retomada.
- [UMU 1.4.4](https://github.com/Open-Wine-Components/umu-launcher/blob/1.4.4/README.md): uso de Proton fora da Steam, runtime e seleção de ferramenta.
- [Execução UMU](https://github.com/Open-Wine-Components/umu-launcher/blob/1.4.4/umu/umu_run.py): `createprefix`, ambiente efetivo, cwd, subreaper e subprocesso.
- [Gerenciamento de Proton pelo UMU](https://github.com/Open-Wine-Components/umu-launcher/blob/1.4.4/umu/umu_proton.py): tokens, download e ferramentas gerenciadas.
- [Verbos e paths UMU](https://github.com/Open-Wine-Components/umu-launcher/blob/1.4.4/umu/umu_consts.py): verbos Proton e diretórios de runtime.
- [Proton 10](https://github.com/ValveSoftware/Proton/blob/proton_10.0/proton): logging, execução e conversão de paths. A implementação deve validar esses contratos no runner efetivamente escolhido, inclusive versões customizadas.
- [Banco oficial UMU](https://github.com/Open-Wine-Components/umu-database): correspondência por loja/codename, cobertura e licença do snapshot.
- [Manual Wine msiexec](https://github.com/wine-mirror/wine/blob/master/programs/msiexec/msiexec.man.in): instalação de pacotes MSI.
- [CreateProcessW](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw) e [GetExitCodeProcess](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-getexitcodeprocess): criação explícita do executável e coleta do resultado Windows completo.
- [Linux wait](https://man7.org/linux/man-pages/man2/wait.2.html): limite de oito bits observado pelo status de saída POSIX.
Fontes locais decisivas: `src/shared/legendary-manifest.json`, `scripts/legendary-linux-runtime.cjs`, `resources/legendary/linux-python.sh`, `src/main/services/umu-release.ts`, os componentes relacionados na tabela de evidência e os workflows de lint/build. As decisões de orquestração, supervisor, recibos, interface e aceite deste SDD são o desenho proposto para integrar essas capacidades.

View file

@ -0,0 +1,133 @@
# Epic Linux runtime validation
The runtime smoke is independent of an Epic account. It executes the production
runtime, prefix, path mapping and supervised process modules, replacing only the
Electron application/database and Legendary account boundary with local ports.
It uses the pinned UMU zipapp, the private Python interpreter and a real Proton
installation. It does not replace UMU or Wine with test doubles.
From a Linux x64 checkout with the development runtimes and PE fixtures built:
```sh
node scripts/test-epic-linux-runtime.cjs \
--python-runner /absolute/path/to/private/python-runner \
--umu /absolute/path/to/umu-run \
--data-dir /absolute/path/to/disposable-smoke-directory
```
Optional `--helper` and `--fixture` paths override the built development PE files.
Use Node 22.15 or newer (validated with 22.21.0). The smoke uses Node's normal
module loader with TypeScript transformation limited to real files inside this
checkout's `src` directory. Its Electron/database/account ports are objects,
not generated code. TypeScript and `string-argv` come only from this checkout's
installed dependencies; both are portable JavaScript, including when a Linux
Node executable runs the source from a Windows/NTFS mount. There is no dependency
directory or source-code override in the CLI. Other product builds still need
their platform's own native dependencies. The data directory contains only
synthetic smoke state and can be retained to inspect the runtime receipt. The
first execution downloads UMU-Proton and its Steam runtime into UMU's normal
per-user cache; later executions reuse that installation.
The smoke checks automatic UMU-Proton selection, an explicit Proton/custom prefix,
prefix generations, Unicode/spaces in paths, mapping a future output through its
existing parent's verified roundtrip, cached offline preparation, a real PE
launch, arguments, environment and working directory, empty private output buffers,
and absence of synthetic credentials in persisted runtime files. It also runs a
prerequisite returning the full Windows DWORD 3010 and the supervised system
reboot path. Cached offline mode does not promise that upstream UMU makes zero
metadata network attempts when executing the game.
The installation path is canonical and fixed in `STEAM_COMPAT_INSTALL_PATH`
through bootstrap, both path verbs, prerequisites and launch. Without this
context UMU infers different mounts from POSIX and Windows arguments: a mounted
volume can map to a drive such as `S:` that disappears before the reverse
lookup. The smoke also roundtrips its source fixture outside the prefix using
that fixture's captured installation context; this covers a Windows NTFS mount
when the checkout is accessed from WSL.
The Windows fixture is intentionally given synthetic authentication-like
arguments. Its temporary record is deleted before inspecting persisted files;
no Epic credentials are requested or written by this smoke.
The fixture does not emit credentials to stdout/stderr; the supervised-process
unit test separately emits synthetic secrets and proves they are discarded.
The implementation was exercised on Ubuntu in WSL2 with private Python 3.13.16,
UMU 1.4.4 and UMU-Proton-10.0-4. Separate Linux process tests exercise a detached
child after wrapper exit, natural tree completion, deadline cleanup after wrapper
success, incomplete process inspection, and spoof rejection for protected
systemd user infrastructure. These tests run in the normal user session.
A separate isolated run on rebased commit
`5a3734cfeaa24f5924e7cc1995787649596e4d83` used the real Linux native addon and
the production Epic tracker while a benign PE waited for ten seconds under UMU.
The native projection proved the invocation marker, canonical prefix, kernel
start ticks and canonical PE path; the tracker included that running fixture.
After natural PE and full process-tree exit, the tracker returned
`{ complete: true, paths: [] }`. The run exited zero. Its local evidence is
retained in `.cache/tracker-smoke-final.log`; only the explicit process identity
and path projection was logged, without raw argv or environment dumps.
Two upstream behaviors discovered by the real smoke are explicit implementation
decisions: UMU 1.4.4 treats `UMU_ZENITY=0` as truthy and skips the Python Proton
download, so the managed runtime clears that variable. Its `createprefix` command
intentionally exits 1 after initialization; Hydra instead runs the shipped
supervisor's benign `--probe` PE command and requires successful Windows execution
plus a valid prefix. Successful wrapper exit alone never permits a receipt:
attributed descendants must finish naturally within the tool deadline.
This smoke does not establish real Epic authentication, a commercial game
download, cloud account synchronization, DRM compatibility, or graphical game
rendering. Those require the separate authenticated product acceptance flow.
## Git and validation environment
The feature starts at the published #2906 head
`a4dfafb6621fc4e274158db59604dcdccc41a244` on
`codex/epic-linux-download-launch`. Its PR initially targets
`codex/epic-achievements-sync`; the original checkout's uncommitted changes were
not copied. Before publication, the feature was rebased onto the dependency's
updated published head `6d3a53f0a424dd01f27717b92f14ed836a7954d2`, preserving its
preview cancellation, shared caches and external-launcher checks. The branch was
subsequently rebased onto `719b1c363e9a3a02f182d9d9816ea7b18a1f4dd4`, preserving
the dependency's interrupted-save recovery and replacement-sync warnings. That
commit is now the Linux branch's dependency boundary; the sync-fix PR retains its
separate original boundary. The Linux validation copy uses an independent ext4
checkout of the same feature commit and its own dependencies and Rust targets.
The validation environment is Ubuntu WSL2 x86_64 with Node 22.21.0, Yarn 1.22.22,
Rust/MinGW, WSLg and Vulkan. Native addon loading is verified with Electron on
Linux and Windows. Wine supervisor acceptance separately exercises EXE/MSI,
full DWORD results, descendants, cancellation and real reboot effects
(rename/delete/RunOnce); it does not replace the UMU smoke above.
This host also produced nondeterministic V8 GC aborts in unchanged baseline
tests. Local checks use Node's `--single-threaded-gc` and bounded test concurrency;
the repository workflow and dependency versions remain unchanged. The full
checklist is `yarn test`, `yarn typecheck`, `yarn lint`, `yarn format-check`,
`npx commitlint --last --verbose`, `yarn check:ota-bridge`, native builds and
`yarn build:linux`. For the last command, a local Electron distribution of the
same version can avoid the configured download mirror:
```sh
yarn build:linux --config.electronDist=node_modules/electron/dist --publish never
```
The authenticated product cases in the SDD remain pending acceptance:
real Epic ownership/authentication and `requires_ot`, a real game download and
pause/restart/resume, prerequisite failure/retry, offline play, actual cloud
account restore/upload/conflicts and real graphical close on desktop, Big Picture
and Steam Deck/gamescope. WSLg/Vulkan and synthetic PE acceptance do not establish
Steam Deck acceptance. No merge is performed automatically.
Once #2906 is merged, reapply only this branch's feature commits, including when
the dependency was squash merged:
```sh
git fetch origin main
git rebase --onto origin/main 719b1c363e9a3a02f182d9d9816ea7b18a1f4dd4 codex/epic-linux-download-launch
git push --force-with-lease origin codex/epic-linux-download-launch
gh pr edit <linux-pr-number> --base main
```
Repeat affected checks before publishing that rebase. Never merge the feature PR
as part of this procedure.

View file

@ -20,6 +20,8 @@ files:
- "!src/*"
- "!native/hydra-native/target"
- "!native/hydra-native/target/**"
- "!native/epic-prerequisite-runner/**"
- "!binaries/epic-prerequisite-runner/**"
- "!electron.vite.config.{js,ts,mjs,cjs}"
- "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
- "!{.env,.env.*,.npmrc,pnpm-lock.yaml}"
@ -88,6 +90,11 @@ dmg:
artifactName: ${name}-${version}.${ext}
linux:
extraResources:
- from: binaries/epic-prerequisite-runner/x64
to: epic
filter:
- epic-prerequisite-runner.exe
- .build.json
- from: legendary/linux/${arch}
to: legendary
filter:

123
native/epic-prerequisite-runner/Cargo.lock generated Normal file
View file

@ -0,0 +1,123 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "epic-prerequisite-runner"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"windows-sys",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View file

@ -0,0 +1,19 @@
[package]
name = "epic-prerequisite-runner"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
serde = { version = "=1.0.228", features = ["derive"] }
serde_json = "=1.0.151"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_System_Registry"] }
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true

View file

@ -0,0 +1,23 @@
# Epic prerequisite supervisor
This small Windows PE runs inside the game's Proton prefix. It does not receive Epic credentials. The launcher passes one Windows path to a private UTF-8 JSON request:
```json
{
"schemaVersion": 1,
"kind": "exe",
"executable": "C:\\Game\\setup.exe",
"args": [],
"cwd": "C:\\Game",
"nonce": "attempt-id",
"resultPath": "C:\\Private\\result.json"
}
```
The request and result must share a private directory. `kind: "msi"` resolves `msiexec.exe` through `GetSystemDirectoryW` and prepends `/i`; `kind: "reboot"` requires an empty executable and argument array and resolves `wineboot.exe -r` internally. No command shell or caller-selected reboot command is accepted.
The root starts suspended and joins a job before it can spawn children. The supervisor waits for the root and every member of that job, then atomically publishes `{schemaVersion:1,nonce,exitCode}`. `exitCode` is the complete Windows DWORD, including failures. The helper's own zero exit only means this result was published; the launcher decides whether the installer succeeded. Closing or killing the supervisor kills its job and does not publish a result. An isolated `--probe` invocation exits zero without reading or writing a request, for the initial Proton bootstrap.
On Linux, install the Rust `x86_64-pc-windows-gnu` target and `gcc-mingw-w64-x86-64`, then run `node scripts/build-epic-prerequisite-runner.cjs` from the project root. This builds locked dependencies, removes the PE timestamp, verifies PE32+ x64 and writes a source/binary SHA-256 manifest alongside the ignored development output. Packaging rebuilds stale output and verifies the copied resource again.
Run native Windows tests with `cargo test --locked --manifest-path native/epic-prerequisite-runner/Cargo.toml`. Linux CI additionally runs `xvfb-run -a node scripts/test-epic-prerequisite-wine.cjs` with Wine, wixl and MinGW installed. These disposable-prefix fixtures cover full exit codes, argument preservation, descendants, cancellation, a real MSI registry effect, and reboot rename/delete and RunOnce effects. Wine tests complement validation with the actual UMU/Proton selected by Hydra; they do not replace it.

View file

@ -0,0 +1,116 @@
use std::{path::PathBuf, time::Duration};
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("--exit") => std::process::exit(args[1].parse::<u32>().unwrap() as i32),
Some("--record") => {
let env = [
"HYDRA_EPIC_SMOKE_SENTINEL",
"WINEPREFIX",
"PROTONPATH",
"HYDRA_EPIC_INVOCATION_ID",
]
.into_iter()
.filter_map(|name| std::env::var(name).ok().map(|value| (name, value)))
.collect::<std::collections::BTreeMap<_, _>>();
let value = serde_json::json!({
"args": &args[2..],
"cwd": std::env::current_dir().unwrap(),
"env": env,
});
std::fs::write(&args[1], serde_json::to_vec(&value).unwrap()).unwrap();
}
Some("--spawn-child") => {
std::process::Command::new(std::env::current_exe().unwrap())
.args(["--child", &args[1], &args[2]])
.spawn()
.unwrap();
}
Some("--child") => {
std::fs::write(
PathBuf::from(&args[2]).with_extension("started"),
b"started",
)
.unwrap();
std::thread::sleep(Duration::from_millis(args[1].parse().unwrap()));
std::fs::write(PathBuf::from(&args[2]), b"child-complete").unwrap();
}
#[cfg(windows)]
Some("--schedule-reboot") => schedule_reboot(&args[1]),
_ => std::process::exit(2),
}
}
#[cfg(windows)]
fn schedule_reboot(directory: &str) {
use epic_prerequisite_runner::quote_argument;
use windows_sys::Win32::System::Registry::*;
let dir = PathBuf::from(directory);
let source = dir.join("rename-source.txt");
let destination = dir.join("rename-destination.txt");
let deleted = dir.join("delete-on-reboot.txt");
std::fs::write(&source, b"rename-applied").unwrap();
std::fs::write(&deleted, b"delete-applied").unwrap();
let wide = |s: &str| s.encode_utf16().chain(Some(0)).collect::<Vec<_>>();
let set = |root, key: &str, name: &str, kind, value: &[u16]| unsafe {
let mut opened = std::ptr::null_mut();
assert_eq!(
RegCreateKeyExW(
root,
wide(key).as_ptr(),
0,
std::ptr::null(),
0,
KEY_SET_VALUE,
std::ptr::null(),
&mut opened,
std::ptr::null_mut()
),
0
);
assert_eq!(
RegSetValueExW(
opened,
wide(name).as_ptr(),
0,
kind,
value.as_ptr() as *const u8,
(value.len() * 2) as u32
),
0
);
RegCloseKey(opened);
};
let mut pending = Vec::new();
for file in [&source, &destination, &deleted] {
pending.extend(wide(&format!("\\??\\{}", file.display())));
}
pending.extend([0, 0]);
set(
HKEY_LOCAL_MACHINE,
"System\\CurrentControlSet\\Control\\Session Manager",
"PendingFileRenameOperations",
REG_MULTI_SZ,
&pending,
);
let command = [
std::env::current_exe()
.unwrap()
.to_string_lossy()
.into_owned(),
"--record".into(),
dir.join("runonce.json").to_string_lossy().into_owned(),
"runonce-executed".into(),
]
.iter()
.map(|arg| quote_argument(arg))
.collect::<Vec<_>>()
.join(" ");
set(
HKEY_LOCAL_MACHINE,
"Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce",
"HydraEpicFixture",
REG_SZ,
&wide(&command),
);
}

View file

@ -0,0 +1,223 @@
use serde::{Deserialize, Serialize};
use std::io::{self, Read, Write};
use std::path::Path;
const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
const MAX_ARGUMENTS: usize = 256;
const MAX_NONCE_LENGTH: usize = 128;
pub const MAX_COMMAND_UNITS: usize = 32_767;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Config {
pub schema_version: u32,
pub kind: Kind,
pub executable: String,
pub args: Vec<String>,
pub cwd: String,
pub nonce: String,
pub result_path: String,
}
#[derive(Debug, Deserialize, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Exe,
Msi,
Reboot,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Outcome<'a> {
schema_version: u32,
nonce: &'a str,
exit_code: u32,
}
pub fn invalid() -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, "invalid prerequisite request")
}
impl Config {
pub fn read(file: &Path) -> io::Result<Self> {
let metadata = std::fs::symlink_metadata(file)?;
if !metadata.is_file()
|| metadata.file_type().is_symlink()
|| metadata.len() > MAX_CONFIG_BYTES
{
return Err(invalid());
}
let mut bytes = Vec::new();
std::fs::File::open(file)?
.take(MAX_CONFIG_BYTES + 1)
.read_to_end(&mut bytes)?;
if bytes.len() as u64 > MAX_CONFIG_BYTES {
return Err(invalid());
}
let config: Self = serde_json::from_slice(&bytes).map_err(|_| invalid())?;
config.validate()?;
let parent = file.parent().ok_or_else(invalid)?.canonicalize()?;
let result_parent = Path::new(&config.result_path)
.parent()
.ok_or_else(invalid)?
.canonicalize()?;
if parent != result_parent || Path::new(&config.result_path).exists() {
return Err(invalid());
}
Ok(config)
}
pub fn validate(&self) -> io::Result<()> {
if self.schema_version != 1
|| self.args.len() > MAX_ARGUMENTS
|| self.nonce.is_empty()
|| self.nonce.len() > MAX_NONCE_LENGTH
|| !self
.nonce
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|| [&self.cwd, &self.result_path]
.iter()
.any(|s| s.is_empty() || s.contains('\0'))
|| self.executable.contains('\0')
|| (matches!(self.kind, Kind::Reboot)
&& (!self.executable.is_empty() || !self.args.is_empty()))
|| (!matches!(self.kind, Kind::Reboot) && self.executable.is_empty())
|| self.args.iter().any(|s| s.contains('\0'))
{
return Err(invalid());
}
let command = std::iter::once(self.executable.as_str())
.chain(self.args.iter().map(String::as_str))
.map(quote_argument)
.collect::<Vec<_>>()
.join(" ");
if command.encode_utf16().count() >= MAX_COMMAND_UNITS {
return Err(invalid());
}
Ok(())
}
pub fn write_outcome(&self, exit_code: u32) -> io::Result<()> {
let destination = Path::new(&self.result_path);
let temporary = destination.with_extension(format!("{}.tmp", self.nonce));
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)?;
let bytes = serde_json::to_vec(&Outcome {
schema_version: 1,
nonce: &self.nonce,
exit_code,
})
.map_err(|_| invalid())?;
file.write_all(&bytes)?;
file.sync_all()?;
drop(file);
if destination.exists() {
return Err(invalid());
}
std::fs::rename(temporary, destination)
}
}
/// Windows CRT argv rules; no command shell is involved.
pub fn quote_argument(value: &str) -> String {
if !value.is_empty() && !value.chars().any(|c| c.is_whitespace() || c == '"') {
return value.to_owned();
}
let mut quoted = String::from("\"");
let mut slashes = 0;
for character in value.chars() {
if character == '\\' {
slashes += 1;
continue;
}
quoted.extend(std::iter::repeat_n(
'\\',
if character == '"' {
slashes * 2 + 1
} else {
slashes
},
));
slashes = 0;
quoted.push(character);
}
quoted.extend(std::iter::repeat_n('\\', slashes * 2));
quoted.push('"');
quoted
}
/// MSI parses its own command line and requires quotes around a property value.
pub fn quote_msi_argument(value: &str) -> String {
if let Some((name, argument)) = value.split_once('=') {
if !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
return format!("{}={}", name, quote_argument(argument));
}
}
quote_argument(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quoting_preserves_empty_spaces_quotes_backslashes_and_shell_literals() {
assert_eq!(quote_argument(""), "\"\"");
assert_eq!(quote_argument("two words"), "\"two words\"");
assert_eq!(quote_argument("a\"b"), "\"a\\\"b\"");
assert_eq!(quote_argument("C:\\path\\"), "C:\\path\\");
assert_eq!(quote_argument("C:\\two words\\"), "\"C:\\two words\\\\\"");
assert_eq!(quote_argument("%PATH% & $(echo)"), "\"%PATH% & $(echo)\"");
}
#[test]
fn msi_options_are_not_quoted_and_properties_quote_only_the_value() {
assert_eq!(quote_msi_argument("/i"), "/i");
assert_eq!(quote_msi_argument("/qn"), "/qn");
assert_eq!(
quote_msi_argument("INSTALLDIR=C:\\two words"),
"INSTALLDIR=\"C:\\two words\""
);
assert_eq!(
quote_msi_argument("REBOOT=ReallySuppress"),
"REBOOT=ReallySuppress"
);
assert_eq!(quote_msi_argument("EMPTY="), "EMPTY=\"\"");
}
#[test]
fn schema_rejects_shell_unknown_fields_and_unsafe_nonce() {
let valid = r#"{"schemaVersion":1,"kind":"exe","executable":"a.exe","args":[],"cwd":"a","nonce":"attempt-1","resultPath":"result.json"}"#;
let mut request: Config = serde_json::from_str(valid).unwrap();
request.validate().unwrap();
assert!(serde_json::from_str::<Config>(
&valid.replace("\"kind\":\"exe\"", "\"kind\":\"shell\"")
)
.is_err());
assert!(serde_json::from_str::<Config>(
&valid.replace("\"args\":[]", "\"args\":[],\"shell\":true")
)
.is_err());
request.nonce = "../other".into();
assert!(request.validate().is_err());
request.nonce = "a".repeat(MAX_NONCE_LENGTH);
request.validate().unwrap();
request.nonce.push('a');
assert!(request.validate().is_err());
}
#[test]
fn reboot_has_no_caller_selected_program_or_arguments() {
let mut request: Config = serde_json::from_str(r#"{"schemaVersion":1,"kind":"reboot","executable":"","args":[],"cwd":"a","nonce":"attempt","resultPath":"a/result.json"}"#).unwrap();
request.validate().unwrap();
request.args.push("-k".into());
assert!(request.validate().is_err());
request.args.clear();
request.executable = "other.exe".into();
assert!(request.validate().is_err());
}
#[test]
fn outcome_preserves_full_windows_dword() {
for code in [0, 3010, 1641, 194, 105, u32::MAX] {
let value = serde_json::to_value(Outcome {
schema_version: 1,
nonce: "attempt",
exit_code: code,
})
.unwrap();
assert_eq!(value["exitCode"].as_u64(), Some(u64::from(code)));
}
}
}

View file

@ -0,0 +1,33 @@
use epic_prerequisite_runner::Config;
use std::path::Path;
#[cfg(windows)]
mod windows;
fn main() {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() == 1 && args[0] == "--probe" {
return;
}
let result = if args.len() == 1 {
run(Path::new(&args[0]))
} else {
Err(epic_prerequisite_runner::invalid())
};
if result.is_err() {
eprintln!("epic-prerequisite-runner: prerequisite-failed");
std::process::exit(1);
}
}
#[cfg(windows)]
fn run(file: &Path) -> std::io::Result<()> {
let config = Config::read(file)?;
let code = windows::execute(&config)?;
config.write_outcome(code)
}
#[cfg(not(windows))]
fn run(_file: &Path) -> std::io::Result<()> {
let _ = std::mem::size_of::<Config>();
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"Windows PE required",
))
}

View file

@ -0,0 +1,150 @@
use epic_prerequisite_runner::{
invalid, quote_argument, quote_msi_argument, Config, Kind, MAX_COMMAND_UNITS,
};
use std::{io, mem, path::Path, ptr};
use windows_sys::Win32::{
Foundation::{CloseHandle, HANDLE, WAIT_OBJECT_0},
System::{
JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectBasicAccountingInformation,
JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject,
JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
SystemInformation::GetSystemDirectoryW,
Threading::{
CreateProcessW, GetExitCodeProcess, ResumeThread, Sleep, TerminateProcess,
WaitForSingleObject, CREATE_SUSPENDED, INFINITE, PROCESS_INFORMATION, STARTUPINFOW,
},
},
};
const JOB_POLL_INTERVAL_MS: u32 = 25;
struct Handle(HANDLE);
impl Drop for Handle {
fn drop(&mut self) {
unsafe {
CloseHandle(self.0);
}
}
}
fn wide(value: &str) -> Vec<u16> {
value.encode_utf16().chain(Some(0)).collect()
}
pub fn execute(config: &Config) -> io::Result<u32> {
if (!matches!(config.kind, Kind::Reboot)
&& (!Path::new(&config.executable).is_absolute()
|| !std::fs::metadata(&config.executable)?.is_file()))
|| !Path::new(&config.cwd).is_absolute()
|| !Path::new(&config.result_path).is_absolute()
|| !std::fs::metadata(&config.cwd)?.is_dir()
{
return Err(invalid());
}
let (executable, args) = match config.kind {
Kind::Exe => (config.executable.clone(), config.args.clone()),
Kind::Msi | Kind::Reboot => {
let mut directory = vec![0u16; MAX_COMMAND_UNITS + 1];
let length =
unsafe { GetSystemDirectoryW(directory.as_mut_ptr(), directory.len() as u32) };
if length == 0 || length as usize >= directory.len() {
return Err(io::Error::last_os_error());
}
let system =
String::from_utf16(&directory[..length as usize]).map_err(|_| invalid())?;
if matches!(config.kind, Kind::Reboot) {
(format!("{}\\wineboot.exe", system), vec!["-r".into()])
} else {
let mut args = vec!["/i".into(), config.executable.clone()];
args.extend(config.args.clone());
(format!("{}\\msiexec.exe", system), args)
}
}
};
let executable_wide = wide(&executable);
let cwd = wide(&config.cwd);
let mut words = vec![quote_argument(&executable)];
words.extend(args.iter().map(|argument| {
if matches!(config.kind, Kind::Msi) {
quote_msi_argument(argument)
} else {
quote_argument(argument)
}
}));
let command = words.join(" ");
let mut command = wide(&command);
if command.len() > MAX_COMMAND_UNITS {
return Err(invalid());
}
unsafe {
// Suspend and assign before descendants can run. Closing this job kills its tree.
let job = Handle(CreateJobObjectW(ptr::null(), ptr::null()));
if job.0.is_null() {
return Err(io::Error::last_os_error());
}
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = mem::zeroed();
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if SetInformationJobObject(
job.0,
JobObjectExtendedLimitInformation,
&limits as *const _ as *const _,
mem::size_of_val(&limits) as u32,
) == 0
{
return Err(io::Error::last_os_error());
}
let mut startup: STARTUPINFOW = mem::zeroed();
startup.cb = mem::size_of_val(&startup) as u32;
let mut process: PROCESS_INFORMATION = mem::zeroed();
if CreateProcessW(
executable_wide.as_ptr(),
command.as_mut_ptr(),
ptr::null(),
ptr::null(),
0,
CREATE_SUSPENDED,
ptr::null(),
cwd.as_ptr(),
&startup,
&mut process,
) == 0
{
return Err(io::Error::last_os_error());
}
let root = Handle(process.hProcess);
let thread = Handle(process.hThread);
if AssignProcessToJobObject(job.0, root.0) == 0 {
let error = io::Error::last_os_error();
TerminateProcess(root.0, 1);
WaitForSingleObject(root.0, INFINITE);
return Err(error);
}
if ResumeThread(thread.0) == u32::MAX {
return Err(io::Error::last_os_error());
}
if WaitForSingleObject(root.0, INFINITE) != WAIT_OBJECT_0 {
return Err(io::Error::last_os_error());
}
let mut code = 0;
if GetExitCodeProcess(root.0, &mut code) == 0 {
return Err(io::Error::last_os_error());
}
loop {
let mut accounting: JOBOBJECT_BASIC_ACCOUNTING_INFORMATION = mem::zeroed();
if QueryInformationJobObject(
job.0,
JobObjectBasicAccountingInformation,
&mut accounting as *mut _ as *mut _,
mem::size_of_val(&accounting) as u32,
ptr::null_mut(),
) == 0
{
return Err(io::Error::last_os_error());
}
if accounting.ActiveProcesses == 0 {
break;
}
Sleep(JOB_POLL_INTERVAL_MS);
}
Ok(code)
}
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product Id="62EE9AAB-4665-4682-B152-D804B5D1A16A" Name="Hydra Epic prerequisite fixture" Language="1033" Version="1.0.0" Manufacturer="Hydra" UpgradeCode="52FD07F2-A693-4708-A128-93E10B0122FB">
<Package InstallerVersion="200" Compressed="yes" InstallScope="perUser" />
<Directory Id="TARGETDIR" Name="SourceDir">
<Component Id="FixtureRegistry" Guid="75A93842-F1B9-4A2E-B7C3-EEEBB2546DC1" Win64="yes">
<RegistryValue Root="HKCU" Key="Software\Hydra\EpicPrerequisiteFixture" Name="Installed" Type="string" Value="yes" KeyPath="yes" />
</Component>
</Directory>
<Feature Id="FixtureFeature" Level="1"><ComponentRef Id="FixtureRegistry" /></Feature>
</Product>
</Wix>

View file

@ -0,0 +1,132 @@
#![cfg(windows)]
use std::{
fs,
path::PathBuf,
process::Command,
time::{Duration, SystemTime, UNIX_EPOCH},
};
fn fixture(args: &[String], kind: &str) -> (PathBuf, PathBuf, PathBuf) {
let dir = std::env::temp_dir().join(format!(
"hydra prerequisite {} {}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).unwrap();
let executable = env!("CARGO_BIN_EXE_epic-prerequisite-fixture");
let config = dir.join("config.json");
let result = dir.join("result.json");
fs::write(
&config,
serde_json::to_vec(&serde_json::json!({
"schemaVersion": 1, "kind": kind, "executable": executable,
"args": args, "cwd": dir, "nonce": "fixture-attempt", "resultPath": result,
}))
.unwrap(),
)
.unwrap();
(dir, config, result)
}
#[test]
fn probe_confirms_a_real_pe_can_start_without_mutating_a_request() {
assert!(Command::new(env!("CARGO_BIN_EXE_epic-prerequisite-runner"))
.arg("--probe")
.status()
.unwrap()
.success());
}
#[test]
fn actual_process_results_preserve_success_reboot_and_failure_dwords() {
for code in [0u32, 3010, 1641, 194, 105, u32::MAX] {
let (dir, config, result) = fixture(&["--exit".into(), code.to_string()], "exe");
let output = Command::new(env!("CARGO_BIN_EXE_epic-prerequisite-runner"))
.arg(&config)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let value: serde_json::Value = serde_json::from_slice(&fs::read(&result).unwrap()).unwrap();
assert_eq!(value["exitCode"].as_u64(), Some(u64::from(code)));
assert_eq!(value["nonce"], "fixture-attempt");
fs::remove_dir_all(dir).unwrap();
}
}
#[test]
fn actual_process_preserves_argv_unicode_cwd_and_waits_for_children() {
let (dir, config, result) = fixture(&[], "exe");
let record = dir.join("argv.json");
let args = [
"",
"two words",
"quote\"value",
"C:\\trailing\\",
"%PATH% & $(literal)",
"á日本語",
];
let mut value: serde_json::Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap();
let mut record_args = vec!["--record".to_owned(), record.to_string_lossy().into_owned()];
record_args.extend(args.iter().map(|s| (*s).to_owned()));
value["args"] = serde_json::json!(record_args);
fs::write(&config, serde_json::to_vec(&value).unwrap()).unwrap();
assert!(Command::new(env!("CARGO_BIN_EXE_epic-prerequisite-runner"))
.arg(&config)
.status()
.unwrap()
.success());
let value: serde_json::Value = serde_json::from_slice(&fs::read(&record).unwrap()).unwrap();
assert_eq!(value["args"], serde_json::json!(args));
assert_eq!(
PathBuf::from(value["cwd"].as_str().unwrap())
.canonicalize()
.unwrap(),
dir.canonicalize().unwrap()
);
fs::remove_file(result).unwrap();
let marker = dir.join("child-finished");
let mut value: serde_json::Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap();
value["args"] = serde_json::json!(["--spawn-child", "150", marker]);
fs::write(&config, serde_json::to_vec(&value).unwrap()).unwrap();
assert!(Command::new(env!("CARGO_BIN_EXE_epic-prerequisite-runner"))
.arg(&config)
.status()
.unwrap()
.success());
assert_eq!(fs::read(marker).unwrap(), b"child-complete");
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn killing_supervisor_terminates_the_installer_job_without_publishing_a_result() {
let (dir, config, result) = fixture(&[], "exe");
let marker = dir.join("child-finished");
let started = marker.with_extension("started");
let mut value: serde_json::Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap();
value["args"] = serde_json::json!(["--spawn-child", "1000", marker]);
fs::write(&config, serde_json::to_vec(&value).unwrap()).unwrap();
let mut supervisor = Command::new(env!("CARGO_BIN_EXE_epic-prerequisite-runner"))
.arg(&config)
.spawn()
.unwrap();
for _ in 0..100 {
if started.exists() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
assert!(started.exists());
supervisor.kill().unwrap();
supervisor.wait().unwrap();
std::thread::sleep(Duration::from_millis(1200));
assert!(!marker.exists());
assert!(!result.exists());
fs::remove_dir_all(dir).unwrap();
}

View file

@ -144,6 +144,24 @@ mod tests {
assert!(rule_is_applicable(&supported_stores, &windows));
}
#[test]
fn matches_epic_store_rules_without_matching_steam_rules() {
let mut epic = context("windows", false);
epic.shop = "epic".into();
let steam = context("windows", false);
assert!(rule_is_applicable(&[condition(None, Some("epic"))], &epic));
assert!(rule_is_applicable(&[condition(None, Some("EPIC"))], &epic));
assert!(!rule_is_applicable(
&[condition(None, Some("steam"))],
&epic
));
assert!(!rule_is_applicable(
&[condition(None, Some("epic"))],
&steam
));
}
#[test]
fn requires_every_field_inside_one_condition() {
let windows = context("windows", false);

View file

@ -148,11 +148,12 @@ pub fn build_context(input: &ResolveSaveRulesInput) -> Result<PathResolutionCont
),
};
let derived_steam_root = derived_steam_root(input.executable_path.as_deref());
let configured_steam_root = input
.steam_path
.as_deref()
.map(normalize_separators)
let derived_steam_root = (!input.shop.eq_ignore_ascii_case("epic"))
.then(|| derived_steam_root(input.executable_path.as_deref()))
.flatten();
let configured_steam_root = (!input.shop.eq_ignore_ascii_case("epic"))
.then(|| input.steam_path.as_deref().map(normalize_separators))
.flatten()
.filter(|root| derived_steam_root.as_ref() != Some(root));
let windows_compatibility = input.platform == "linux"
&& input

View file

@ -22,6 +22,7 @@ pub(crate) use capture::{
pub(crate) use context::build_context;
pub(crate) use resolve_path::{glob_base_path, resolve_path};
pub(crate) use restore_root::{resolve_restore_root, target_matches_rule};
pub(crate) use types::PathResolutionContext;
pub use types::{ResolveSaveRulesInput, ResolvedCloudSavePath, ResolvedCloudSaveRule};
#[napi]

View file

@ -149,4 +149,99 @@ mod tests {
assert!(result[1].resolved_paths.is_empty());
assert_eq!(result[1].unresolved_tokens, vec![FOREIGN_ENVIRONMENT_TOKEN]);
}
#[test]
fn epic_never_expands_a_configured_steam_root() {
let context = build_context(&ResolveSaveRulesInput {
shop: "EPIC".into(),
object_id: "namespace:playable-item".into(),
platform: "windows".into(),
home_dir: "C:/Users/Hydra".into(),
documents_dir: None,
app_data_dir: None,
executable_path: Some("C:/Games/EpicGame/Game.exe".into()),
wine_prefix_path: None,
steam_path: Some("C:/Program Files/Steam".into()),
rules: Vec::new(),
})
.unwrap();
assert!(context.configured_steam_root.is_none());
assert!(context.derived_steam_root.is_none());
let result = resolve_rules(vec![rule("<root>/userdata/save.sav", None)], &context);
assert!(result[0].resolved_paths.is_empty());
assert!(!result[0].unresolved_tokens.is_empty());
}
#[test]
fn epic_resolves_install_and_profile_rules_on_wine_and_mac() {
let epic_rule = |raw_path: &str, os: &str| {
let mut rule = rule(raw_path, Some(os));
rule.when[0].store = Some("epic".into());
rule
};
let wine = build_context(&ResolveSaveRulesInput {
shop: "epic".into(),
object_id: "namespace:sonic-frontiers".into(),
platform: "linux".into(),
home_dir: "/home/hydra".into(),
documents_dir: None,
app_data_dir: None,
executable_path: Some("/games/SonicFrontiers/Game.exe".into()),
wine_prefix_path: Some("/prefix".into()),
steam_path: Some("/steam".into()),
rules: Vec::new(),
})
.unwrap();
let wine_rules = resolve_rules(
vec![
epic_rule("<base>/Saved/slot.sav", "windows"),
epic_rule(
"<winAppData>/SEGA/SonicFrontiers/epic/<storeUserId>",
"windows",
),
epic_rule("<winAppData>/SEGA/SonicFrontiers/epic/<storeUserId>", "mac"),
],
&wine,
);
assert!(wine_rules[0]
.resolved_paths
.iter()
.any(|path| path.path == "/games/SonicFrontiers/Saved/slot.sav"));
assert!(wine_rules[1].resolved_paths.iter().any(|path| {
path.path.starts_with(
"/prefix/drive_c/users/steamuser/AppData/Roaming/SEGA/SonicFrontiers/epic/",
)
}));
assert!(wine_rules[2].resolved_paths.is_empty());
let mac = build_context(&ResolveSaveRulesInput {
shop: "epic".into(),
object_id: "namespace:sonic-frontiers".into(),
platform: "mac".into(),
home_dir: "/Users/Hydra".into(),
documents_dir: None,
app_data_dir: Some("/Users/Hydra/Library".into()),
executable_path: Some("/Applications/SonicFrontiers/Game".into()),
wine_prefix_path: None,
steam_path: Some("/steam".into()),
rules: Vec::new(),
})
.unwrap();
let mac_rules = resolve_rules(
vec![
epic_rule("<home>/Library/Application Support/SonicFrontiers", "mac"),
epic_rule(
"<home>/Library/Application Support/SonicFrontiers",
"windows",
),
],
&mac,
);
assert_eq!(
mac_rules[0].resolved_paths[0].path,
"/Users/Hydra/Library/Application Support/SonicFrontiers"
);
assert!(mac_rules[1].resolved_paths.is_empty());
}
}

View file

@ -271,12 +271,141 @@ pub async fn build_local_game_snapshot_pipeline(
#[cfg(test)]
mod tests {
use std::fs;
#[cfg(not(windows))]
use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(not(windows))]
use indexmap::IndexMap;
use tempfile::tempdir;
use super::*;
#[cfg(not(windows))]
use crate::cloud_save::manifest::types::{
ManifestFileRule, ManifestGameEntry, ManifestIndex, ManifestRuleCondition,
};
use crate::cloud_save::path_resolution::ResolvedCloudSavePath;
use crate::cloud_save::save_scanner::{ScannedCloudSaveFile, ScannedCloudSavePath};
#[cfg(not(windows))]
use crate::constants::MANIFEST_INDEX_VERSION;
// Windows uses the live KnownFolder path even when the input home is synthetic.
// Keep this fixture away from a real player's save directory on Windows.
#[cfg(not(windows))]
#[tokio::test]
async fn epic_catalogue_snapshot_detects_two_accounts_and_a_changed_save() {
let temp = tempdir().unwrap();
let home = temp.path().join("Users/Hydra");
let roaming = home.join("AppData/Roaming");
let local = home.join("AppData/Local");
let first_save = local.join("FactoryGame/Saved/SaveGames/EpicUserA/slot.sav");
let second_save = local.join("FactoryGame/Saved/SaveGames/EpicUserB/slot.sav");
fs::create_dir_all(first_save.parent().unwrap()).unwrap();
fs::create_dir_all(second_save.parent().unwrap()).unwrap();
fs::write(&first_save, b"first account").unwrap();
fs::write(&second_save, b"second account").unwrap();
let source_url = "https://example.invalid/epic-fixture.yaml";
let raw_path = "<winLocalAppData>/FactoryGame/Saved/SaveGames/<storeUserId>";
let mut games = IndexMap::new();
games.insert(
"Satisfactory".to_string(),
ManifestGameEntry {
manifest_key: "Satisfactory".into(),
files: vec![
ManifestFileRule {
raw_path: raw_path.into(),
tags: vec!["save".into()],
when: vec![],
},
ManifestFileRule {
raw_path: "<root>/userdata/steam-only.sav".into(),
tags: vec!["save".into()],
when: vec![ManifestRuleCondition {
os: None,
store: Some("steam".into()),
}],
},
],
},
);
let fetched_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let index = ManifestIndex {
version: MANIFEST_INDEX_VERSION,
fetched_at,
source_url: source_url.into(),
games,
};
fs::write(
temp.path().join("cloud-save-manifest-index.json"),
serde_json::to_vec(&index).unwrap(),
)
.unwrap();
let input = |hash_cache| BuildLocalGameSnapshotPipelineInput {
shop: "epic".into(),
object_id: "namespace:playable-item-id".into(),
title: Some("Satisfactory".into()),
remote_id: None,
user_data_path: temp.path().to_string_lossy().into_owned(),
source_url: Some(source_url.into()),
platform: "windows".into(),
home_dir: home.to_string_lossy().into_owned(),
documents_dir: None,
app_data_dir: Some(roaming.to_string_lossy().into_owned()),
executable_path: Some(
temp.path()
.join("Games/Satisfactory/FactoryGame.exe")
.to_string_lossy()
.into_owned(),
),
wine_prefix_path: None,
steam_path: Some(temp.path().join("Steam").to_string_lossy().into_owned()),
environment_id: "epic-fixture-environment".into(),
hash_cache,
extra_rules: None,
};
let initial = build_local_game_snapshot_pipeline(input(vec![]))
.await
.unwrap();
assert_eq!(initial.game_id.shop, "epic");
assert_eq!(initial.game_id.object_id, "namespace:playable-item-id");
assert_eq!(initial.manifest_key.as_deref(), Some("Satisfactory"));
assert_eq!(initial.file_count, 2);
assert_eq!(initial.variants.len(), 2);
assert!(initial
.variants
.iter()
.all(|variant| variant.kind == "opaque-folder"));
assert!(initial.files.iter().all(|file| file.raw_path == raw_path));
assert_ne!(initial.files[0].variant_id, initial.files[1].variant_id);
let previous_hashes = initial
.source_files
.iter()
.map(|file| (file.absolute_path.clone(), file.hash.clone()))
.collect::<std::collections::HashMap<_, _>>();
let previous_aggregate = initial.aggregate_hash;
fs::write(&second_save, b"second account changed after game exit").unwrap();
let changed = build_local_game_snapshot_pipeline(input(initial.hash_cache))
.await
.unwrap();
assert_eq!(changed.file_count, 2);
assert_ne!(changed.aggregate_hash, previous_aggregate);
let second_save = fs::canonicalize(second_save).unwrap();
let changed_file = changed
.source_files
.iter()
.find(|file| fs::canonicalize(&file.absolute_path).unwrap() == second_save)
.unwrap();
assert_ne!(
changed_file.hash,
previous_hashes[&changed_file.absolute_path]
);
}
fn scanned_rule(
raw_path: &str,

View file

@ -8,8 +8,9 @@ use crate::cloud_save::hashing::batch::hash_files;
use crate::cloud_save::identity::{is_safe_capture, normalize_rule_path, SnapshotVariant};
use crate::cloud_save::manifest::types::CloudSaveRule;
use crate::cloud_save::path_resolution::{
build_context, glob_base_path, path_is_foreign_environment, resolve_path, resolve_restore_root,
rule_is_applicable, target_matches_rule, ResolveSaveRulesInput,
build_context, capture_store_user_with_components, capture_template, glob_base_path,
path_is_foreign_environment, resolve_path, resolve_restore_root, rule_is_applicable,
target_matches_rule, PathResolutionContext, ResolveSaveRulesInput,
};
use super::metadata::parse_last_modified_at;
@ -158,6 +159,102 @@ fn has_glob(raw_rule: &str) -> bool {
.any(|character| matches!(character, '*' | '?' | '[' | '{'))
}
fn captured_store_user_location_exists(
raw_path: &str,
resolved_path: &str,
concrete_path: &str,
expected_user_id: &str,
case_sensitive: bool,
terminal_is_file: bool,
) -> bool {
let Some(template) = capture_template(raw_path, resolved_path) else {
return false;
};
let Some(capture) =
capture_store_user_with_components(&template, concrete_path, case_sensitive)
else {
return false;
};
let user_matches = if case_sensitive {
capture.value == expected_user_id
} else {
capture.value.to_lowercase() == expected_user_id.to_lowercase()
};
user_matches
&& capture.component_offsets_from_end.iter().all(|offset| {
Path::new(concrete_path)
.ancestors()
.nth(*offset)
.is_some_and(|location| {
if *offset == 0 && terminal_is_file {
location.is_file()
} else {
location.is_dir()
}
})
})
}
fn epic_store_user_location_exists(
raw_path: &str,
root_rule: &str,
root: &str,
target: &str,
expected_user_id: &str,
context: &PathResolutionContext,
directory: bool,
target_directory: bool,
) -> bool {
let root_token_count = root_rule.matches("<storeUserId>").count();
let full_token_count = raw_path.matches("<storeUserId>").count();
if full_token_count == 0 {
return false;
}
if root_token_count > 0
&& !captured_store_user_location_exists(
root_rule,
root,
root,
expected_user_id,
context.platform == "linux" && !context.windows_compatibility,
!directory,
)
{
return false;
}
if root_token_count == full_token_count {
return true;
}
// A glob before the user token leaves it outside the restore root. A file
// rule matches the target; a directory rule matches one of its ancestors.
let concrete_paths = if target_directory {
Path::new(target)
.parent()
.into_iter()
.flat_map(|parent| parent.ancestors())
.map(|path| path.to_string_lossy().replace('\\', "/"))
.collect::<Vec<_>>()
} else {
vec![target.to_string()]
};
resolve_path(raw_path, context)
.paths
.iter()
.any(|candidate| {
concrete_paths.iter().any(|concrete_path| {
captured_store_user_location_exists(
raw_path,
&candidate.path,
concrete_path,
expected_user_id,
candidate.case_sensitive,
!target_directory,
)
})
})
}
fn resolve_restore_targets_inner(
input: ResolveRestoreTargetsInput,
) -> napi::Result<ResolveRestoreTargetsResult> {
@ -309,6 +406,21 @@ fn resolve_restore_targets_inner(
} else {
root.clone()
};
if context.shop.eq_ignore_ascii_case("epic")
&& variant.kind == "opaque-folder"
&& !epic_store_user_location_exists(
&rule.raw_path,
&root_rule,
&root,
&target_path,
variant.concrete_folder_id.as_deref().unwrap_or_default(),
&context,
directory,
rule.kind == "dir",
)
{
continue;
}
if concrete_rule.as_ref().is_some_and(|candidates| {
!target_matches_rule(candidates, &target_path, rule.kind == "dir")
}) {
@ -519,6 +631,28 @@ mod tests {
}
}
fn epic_input(
home: &Path,
variant: SnapshotVariant,
raw_path: &str,
kind: &str,
relative_path: &str,
) -> ResolveRestoreTargetsInput {
let mut remote_file = file(&variant, relative_path);
remote_file.raw_path = raw_path.into();
let mut restore_input = input(home, vec![variant], vec![remote_file]);
restore_input.shop = "epic".into();
restore_input.object_id = "namespace:playable-item".into();
restore_input.approved_rules = vec![ApprovedRestoreRule {
kind: kind.into(),
raw_path: raw_path.into(),
source: "ludusavi".into(),
preferred_path: None,
when: vec![],
}];
restore_input
}
#[test]
fn keeps_an_absolute_custom_path_exact() {
let temp = tempdir().unwrap();
@ -1039,6 +1173,186 @@ mod tests {
.ends_with("/Game/76561197960278073/slot.dat"));
}
#[test]
fn epic_restores_into_an_existing_account_folder() {
let temp = tempdir().unwrap();
let account = temp.path().join("Game/epicusera");
fs::create_dir_all(&account).unwrap();
let result = resolve_restore_targets_inner(epic_input(
temp.path(),
variant("opaque-folder", "epicusera"),
RAW_RULE,
"dir",
"slot.dat",
))
.unwrap();
assert!(result.blocked.is_empty());
assert_eq!(result.actions.len(), 1);
assert_eq!(result.actions[0].action, "create");
assert_eq!(
Path::new(&result.actions[0].target_path),
account.join("slot.dat")
);
}
#[test]
fn epic_blocks_a_missing_source_account_folder_beside_another_account() {
let temp = tempdir().unwrap();
fs::create_dir_all(temp.path().join("Game/epicuserb")).unwrap();
let result = resolve_restore_targets_inner(epic_input(
temp.path(),
variant("opaque-folder", "epicusera"),
RAW_RULE,
"dir",
"slot.dat",
))
.unwrap();
assert!(result.actions.is_empty());
assert_eq!(result.blocked.len(), 1);
assert_eq!(result.blocked[0].reason, "blocked-user-not-found");
assert!(!temp.path().join("Game/epicusera").exists());
}
#[test]
fn epic_can_create_a_save_subdirectory_below_an_existing_account() {
let temp = tempdir().unwrap();
fs::create_dir_all(temp.path().join("Game/epicusera")).unwrap();
let result = resolve_restore_targets_inner(epic_input(
temp.path(),
variant("opaque-folder", "epicusera"),
"<home>/Game/<storeUserId>/Saves",
"dir",
"slot.dat",
))
.unwrap();
assert!(result.blocked.is_empty());
assert_eq!(result.actions.len(), 1);
assert_eq!(result.actions[0].action, "create");
assert!(result.actions[0]
.target_path
.replace('\\', "/")
.ends_with("/Game/epicusera/Saves/slot.dat"));
}
#[test]
fn epic_glob_before_account_allows_a_new_save_in_an_existing_account() {
let temp = tempdir().unwrap();
let account = temp.path().join("Games/Game_one/epicusera");
fs::create_dir_all(&account).unwrap();
let result = resolve_restore_targets_inner(epic_input(
temp.path(),
variant("opaque-folder", "epicusera"),
"<home>/Games/Game_*/<storeUserId>/slot.dat",
"file",
"Game_one/epicusera/slot.dat",
))
.unwrap();
assert!(result.blocked.is_empty());
assert_eq!(result.actions.len(), 1);
assert_eq!(result.actions[0].action, "create");
assert_eq!(
Path::new(&result.actions[0].target_path),
account.join("slot.dat")
);
}
#[test]
fn epic_glob_before_account_blocks_a_missing_account() {
let temp = tempdir().unwrap();
fs::create_dir_all(temp.path().join("Games/Game_one/epicuserb")).unwrap();
let result = resolve_restore_targets_inner(epic_input(
temp.path(),
variant("opaque-folder", "epicusera"),
"<home>/Games/Game_*/<storeUserId>/slot.dat",
"file",
"Game_one/epicusera/slot.dat",
))
.unwrap();
assert!(result.actions.is_empty());
assert_eq!(result.blocked.len(), 1);
assert_eq!(result.blocked[0].reason, "blocked-user-not-found");
assert!(!temp.path().join("Games/Game_one/epicusera").exists());
}
#[test]
fn epic_directory_glob_before_account_allows_a_new_save_in_an_existing_account() {
let temp = tempdir().unwrap();
let account = temp.path().join("Games/Game_one/epicusera");
fs::create_dir_all(&account).unwrap();
let result = resolve_restore_targets_inner(epic_input(
temp.path(),
variant("opaque-folder", "epicusera"),
"<home>/Games/Game_*/<storeUserId>",
"dir",
"Game_one/epicusera/slot.dat",
))
.unwrap();
assert!(result.blocked.is_empty());
assert_eq!(result.actions.len(), 1);
assert_eq!(result.actions[0].action, "create");
assert_eq!(
Path::new(&result.actions[0].target_path),
account.join("slot.dat")
);
}
#[test]
fn epic_directory_glob_before_account_blocks_a_missing_account() {
let temp = tempdir().unwrap();
fs::create_dir_all(temp.path().join("Games/Game_one/epicuserb")).unwrap();
let result = resolve_restore_targets_inner(epic_input(
temp.path(),
variant("opaque-folder", "epicusera"),
"<home>/Games/Game_*/<storeUserId>",
"dir",
"Game_one/epicusera/slot.dat",
))
.unwrap();
assert!(result.actions.is_empty());
assert_eq!(result.blocked.len(), 1);
assert_eq!(result.blocked[0].reason, "blocked-user-not-found");
}
#[test]
fn epic_requires_an_existing_account_named_file() {
let temp = tempdir().unwrap();
let game = temp.path().join("Game");
fs::create_dir_all(&game).unwrap();
fs::write(game.join("PlayerProfileepicuserb.sav"), b"other account").unwrap();
let raw_path = "<home>/Game/PlayerProfile<storeUserId>.sav";
let remote = variant("opaque-folder", "epicusera");
let missing = resolve_restore_targets_inner(epic_input(
temp.path(),
remote.clone(),
raw_path,
"file",
"PlayerProfileepicusera.sav",
))
.unwrap();
assert!(missing.actions.is_empty());
assert_eq!(missing.blocked[0].reason, "blocked-user-not-found");
fs::write(game.join("PlayerProfileepicusera.sav"), b"old save").unwrap();
let existing = resolve_restore_targets_inner(epic_input(
temp.path(),
remote,
raw_path,
"file",
"PlayerProfileepicusera.sav",
))
.unwrap();
assert!(existing.blocked.is_empty());
assert_eq!(existing.actions.len(), 1);
assert_eq!(existing.actions[0].action, "replace");
}
#[test]
fn preserves_different_numeric_profile_folders_as_different_targets() {
let temp = tempdir().unwrap();

View file

@ -1,6 +1,7 @@
mod active_window;
mod cloud_save;
mod constants;
mod linux_process;
mod torrent;
pub use cloud_save::hashing::{build_snapshot_aggregate_hash, hash_local_save_file};
@ -52,6 +53,11 @@ pub struct NativeProcessPayload {
pub name: String,
pub environ: Option<HashMap<String, String>>,
pub cwd: Option<String>,
pub start_time: Option<String>,
pub invocation_id: Option<String>,
pub wine_prefix_path: Option<String>,
pub pe_executable_path: Option<String>,
pub inspection_complete: Option<bool>,
}
#[napi(object)]
@ -185,6 +191,14 @@ pub fn list_processes() -> Vec<NativeProcessPayload> {
.values()
.map(|process| {
let include_linux_extras = !cfg!(target_os = "windows");
let linux = cfg!(target_os = "linux").then(|| {
linux_process::inspect_process(
process.pid().as_u32(),
process.exe(),
&process.name().to_string_lossy(),
process.cwd(),
)
});
NativeProcessPayload {
exe: process
@ -201,20 +215,24 @@ pub fn list_processes() -> Vec<NativeProcessPayload> {
None
},
environ: if include_linux_extras {
let env_map: HashMap<String, String> = process
.environ()
.iter()
.filter_map(|entry| {
let entry_value = entry.to_string_lossy();
entry_value.split_once('=').and_then(|(key, value)| {
if key.is_empty() {
None
} else {
Some((key.to_string(), value.to_string()))
}
let env_map: HashMap<String, String> = if let Some(inspection) = &linux {
inspection.environment.clone()
} else {
process
.environ()
.iter()
.filter_map(|entry| {
let entry_value = entry.to_string_lossy();
entry_value.split_once('=').and_then(|(key, value)| {
if key.is_empty() {
None
} else {
Some((key.to_string(), value.to_string()))
}
})
})
})
.collect();
.collect()
};
if env_map.is_empty() {
None
@ -224,6 +242,15 @@ pub fn list_processes() -> Vec<NativeProcessPayload> {
} else {
None
},
start_time: linux.as_ref().and_then(|value| value.start_time.clone()),
invocation_id: linux.as_ref().and_then(|value| value.invocation_id.clone()),
wine_prefix_path: linux
.as_ref()
.and_then(|value| value.wine_prefix_path.clone()),
pe_executable_path: linux
.as_ref()
.and_then(|value| value.pe_executable_path.clone()),
inspection_complete: linux.as_ref().map(|value| value.complete),
}
})
.collect();

View file

@ -0,0 +1,483 @@
//! Linux process evidence. Raw argv and environment never leave this module.
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
const MAX_PROCESS_FILE_BYTES: u64 = 256 * 1024;
const MAX_PROCESS_MAP_BYTES: u64 = 8 * 1024 * 1024;
const INVOCATION_ENV: &str = "HYDRA_EPIC_INVOCATION_ID";
const ENV_ALLOWLIST: &[&str] = &[
INVOCATION_ENV,
"WINEPREFIX",
"STEAM_COMPAT_DATA_PATH",
"APPIMAGE",
];
#[derive(Default)]
pub struct LinuxProcessInspection {
pub start_time: Option<String>,
pub invocation_id: Option<String>,
pub wine_prefix_path: Option<String>,
pub pe_executable_path: Option<String>,
pub environment: HashMap<String, String>,
pub complete: bool,
}
fn read_bounded(path: &Path, maximum: u64) -> io::Result<Vec<u8>> {
let mut data = Vec::new();
fs::File::open(path)?
.take(maximum + 1)
.read_to_end(&mut data)?;
if data.len() as u64 > maximum {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"process file exceeds limit",
));
}
Ok(data)
}
fn start_ticks(stat: &[u8]) -> Option<String> {
let value = std::str::from_utf8(stat).ok()?;
// comm may itself contain spaces or ')'; fields after its final ')' start at state (3).
let tail = value.rsplit_once(')')?.1;
let ticks = tail.split_whitespace().nth(19)?;
ticks.parse::<u64>().ok()?;
Some(ticks.to_string())
}
fn safe_environment(bytes: &[u8]) -> HashMap<String, String> {
bytes
.split(|byte| *byte == 0)
.filter_map(|entry| {
let text = std::str::from_utf8(entry).ok()?;
let (key, value) = text.split_once('=')?;
ENV_ALLOWLIST
.contains(&key)
.then(|| (key.to_string(), value.to_string()))
})
.collect()
}
fn decode_proc_path(value: &str) -> String {
let mut output = Vec::new();
let bytes = value.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'\\'
&& index + 3 < bytes.len()
&& bytes[index + 1..index + 4]
.iter()
.all(|byte| (b'0'..=b'7').contains(byte))
{
let decoded = u16::from(bytes[index + 1] - b'0') * 64
+ u16::from(bytes[index + 2] - b'0') * 8
+ u16::from(bytes[index + 3] - b'0');
if let Ok(decoded) = u8::try_from(decoded) {
output.push(decoded);
index += 4;
} else {
output.push(bytes[index]);
index += 1;
}
} else {
output.push(bytes[index]);
index += 1;
}
}
String::from_utf8_lossy(&output).into_owned()
}
fn mapped_filename(line: &str) -> Option<String> {
let mut remainder = line;
for _ in 0..5 {
remainder = remainder.trim_start();
let boundary = remainder.find(char::is_whitespace)?;
remainder = &remainder[boundary..];
}
let name = remainder.trim_start();
name.starts_with('/').then(|| decode_proc_path(name))
}
fn is_pe_path(path: &Path) -> bool {
path.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("exe"))
}
fn canonical_pe(path: &Path) -> io::Result<Option<PathBuf>> {
if !is_pe_path(path) {
return Ok(None);
}
let canonical = path.canonicalize()?;
let mut header = [0; 2];
fs::File::open(&canonical)?.read_exact(&mut header)?;
Ok((header == *b"MZ").then_some(canonical))
}
fn resolve_case_insensitive(base: &Path, remainder: &str) -> io::Result<PathBuf> {
let mut current = base.to_path_buf();
for segment in remainder
.split(['/', '\\'])
.filter(|segment| !segment.is_empty())
{
if segment == ".." {
return Err(io::Error::from(io::ErrorKind::InvalidData));
}
if segment == "." {
continue;
}
let exact = current.join(segment);
if exact.exists() {
current = exact;
continue;
}
let mut matches = fs::read_dir(&current)?
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case(segment)
});
let first = matches.next().ok_or(io::ErrorKind::NotFound)?;
if matches.next().is_some() {
return Err(io::Error::from(io::ErrorKind::InvalidData));
}
current = first.path();
}
current.canonicalize()
}
fn windows_path_to_host(
value: &str,
prefix: Option<&str>,
cwd: Option<&Path>,
) -> io::Result<PathBuf> {
if value.starts_with('/') {
return Path::new(value).canonicalize();
}
if value.len() > 2 && value.as_bytes()[0].is_ascii_alphabetic() && value.as_bytes()[1] == b':' {
let prefix = prefix.ok_or(io::ErrorKind::NotFound)?;
let drive = value[..2].to_ascii_lowercase();
let base = Path::new(prefix)
.join("dosdevices")
.join(drive)
.canonicalize()?;
return resolve_case_insensitive(&base, &value[2..]);
}
let base = cwd.ok_or(io::ErrorKind::NotFound)?;
resolve_case_insensitive(base, value)
}
fn wine_loader_name(value: &str) -> bool {
matches!(
value,
"wine" | "wine64" | "wine-preloader" | "wine64-preloader"
)
}
fn command_pe(command: &[u8], prefix: Option<&str>, cwd: Option<&Path>) -> Option<PathBuf> {
let arguments: Vec<_> = command
.split(|byte| *byte == 0)
.filter(|value| !value.is_empty())
.collect();
let first = std::str::from_utf8(arguments.first()?).ok()?;
// Only argv[0], or argv[1] following a recognized Wine loader, identifies the PE.
// Searching arbitrary arguments could mistake a document or an auth value for an executable.
let executable = if wine_loader_name(Path::new(first).file_name()?.to_str()?) {
std::str::from_utf8(arguments.get(1)?).ok()?
} else {
first
};
let host = windows_path_to_host(executable, prefix, cwd).ok()?;
canonical_pe(&host).ok().flatten()
}
pub fn inspect_process(
pid: u32,
host_executable: Option<&Path>,
name: &str,
cwd: Option<&Path>,
) -> LinuxProcessInspection {
inspect_process_at(Path::new("/proc"), pid, host_executable, name, cwd)
}
fn revalidate_identity(directory: &Path, result: &mut LinuxProcessInspection) {
let current = read_bounded(&directory.join("stat"), MAX_PROCESS_FILE_BYTES)
.ok()
.and_then(|stat| start_ticks(&stat));
if current.is_none() || current != result.start_time {
result.complete = false;
result.pe_executable_path = None;
}
}
fn inspect_process_at(
proc_root: &Path,
pid: u32,
host_executable: Option<&Path>,
name: &str,
cwd: Option<&Path>,
) -> LinuxProcessInspection {
let directory = proc_root.join(pid.to_string());
let mut result = LinuxProcessInspection::default();
let Ok(stat) = read_bounded(&directory.join("stat"), MAX_PROCESS_FILE_BYTES) else {
return result;
};
result.start_time = start_ticks(&stat);
let Ok(environment) = read_bounded(&directory.join("environ"), MAX_PROCESS_FILE_BYTES) else {
return result;
};
result.environment = safe_environment(&environment);
result.invocation_id = result.environment.get(INVOCATION_ENV).cloned();
let prefix = result
.environment
.get("WINEPREFIX")
.or_else(|| result.environment.get("STEAM_COMPAT_DATA_PATH"));
result.wine_prefix_path = prefix
.and_then(|value| Path::new(value).canonicalize().ok())
.map(|value| value.to_string_lossy().into_owned());
result.complete = result.start_time.is_some();
let host_name = host_executable
.and_then(Path::file_name)
.and_then(|value| value.to_str())
.unwrap_or("");
let is_wine = wine_loader_name(host_name) || name.to_ascii_lowercase().ends_with(".exe");
if !is_wine {
revalidate_identity(&directory, &mut result);
return result;
}
let Ok(command) = read_bounded(&directory.join("cmdline"), MAX_PROCESS_FILE_BYTES) else {
result.complete = false;
return result;
};
let Ok(maps) = read_bounded(&directory.join("maps"), MAX_PROCESS_MAP_BYTES) else {
result.complete = false;
return result;
};
let expected = command_pe(&command, result.wine_prefix_path.as_deref(), cwd);
let mut candidates = HashSet::new();
for line in String::from_utf8_lossy(&maps).lines() {
let Some(filename) = mapped_filename(line) else {
continue;
};
if !is_pe_path(Path::new(filename.trim_end_matches(" (deleted)"))) {
continue;
}
match canonical_pe(Path::new(&filename)) {
Ok(Some(candidate)) => {
candidates.insert(candidate);
}
Ok(None) => {}
Err(_) => result.complete = false,
}
}
let selected = expected
.filter(|expected| candidates.contains(expected))
.or_else(|| {
if candidates.len() == 1 {
candidates.into_iter().next()
} else {
None
}
});
result.pe_executable_path = selected.map(|value| value.to_string_lossy().into_owned());
// An inspected Wine loader without a proven PE is still an unknown writer.
if result.pe_executable_path.is_none() {
result.complete = false;
}
revalidate_identity(&directory, &mut result);
result
}
#[cfg(test)]
mod tests {
use super::*;
struct FixtureDirectory(PathBuf);
impl FixtureDirectory {
fn new() -> Self {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().canonicalize().unwrap().join(format!(
"hydra-process-fixture-{}-{nonce}",
std::process::id()
));
fs::create_dir(&root).unwrap();
Self(root)
}
}
impl Drop for FixtureDirectory {
fn drop(&mut self) {
if self.0.parent() == std::env::temp_dir().canonicalize().ok().as_deref() {
let _ = fs::remove_dir_all(&self.0);
}
}
}
#[test]
fn start_ticks_are_exact_and_comm_can_contain_parentheses() {
let tail = (4..=21)
.map(|value| value.to_string())
.collect::<Vec<_>>()
.join(" ");
let stat = format!("42 (name ) with spaces) S {tail} 9007199254740993 0");
assert_eq!(
start_ticks(stat.as_bytes()).as_deref(),
Some("9007199254740993")
);
assert_eq!(start_ticks(b"invalid"), None);
}
#[test]
fn projection_drops_secrets_and_preserves_values_with_equals() {
let env = safe_environment(b"AUTH_PASSWORD=secret\0WINEPREFIX=/prefix\0HYDRA_EPIC_INVOCATION_ID=id=part\0LD_PRELOAD=secret\0APPIMAGE=/image\0");
assert_eq!(env.len(), 3);
assert_eq!(env.get(INVOCATION_ENV).map(String::as_str), Some("id=part"));
assert!(!env.contains_key("AUTH_PASSWORD"));
}
#[test]
fn maps_paths_preserve_spaces_unicode_and_escaped_backslashes() {
assert_eq!(
mapped_filename("100-200 r-xp 0000 00:01 123 /Games/Jogo\\040ação\\134game.exe")
.as_deref(),
Some("/Games/Jogo ação\\game.exe")
);
assert_eq!(mapped_filename("100-200 rw-p 0000 00:00 0 [heap]"), None);
assert_eq!(decode_proc_path("\\777"), "\\777");
}
#[test]
fn failed_or_oversized_inspection_never_proves_an_empty_process() {
let result = inspect_process_at(
Path::new("missing-proc-fixture"),
123,
None,
"game.exe",
None,
);
assert!(!result.complete);
assert!(result.pe_executable_path.is_none());
let fixture = FixtureDirectory::new();
let file = fixture.0.join("oversized");
fs::write(&file, [0; 10]).unwrap();
assert_eq!(
read_bounded(&file, 9).unwrap_err().kind(),
io::ErrorKind::InvalidData
);
}
#[test]
fn identity_revalidation_rejects_a_reused_pid() {
let fixture = FixtureDirectory::new();
let tail = (4..=21)
.map(|value| value.to_string())
.collect::<Vec<_>>()
.join(" ");
fs::write(
fixture.0.join("stat"),
format!("42 (process) S {tail} 2000 0"),
)
.unwrap();
let mut result = LinuxProcessInspection {
start_time: Some("1000".to_string()),
pe_executable_path: Some("old-game.exe".to_string()),
complete: true,
..Default::default()
};
revalidate_identity(&fixture.0, &mut result);
assert!(!result.complete);
assert!(result.pe_executable_path.is_none());
}
#[cfg(unix)]
#[test]
fn wine_projection_requires_mapped_pe_and_drops_auth_argv() {
let fixture = FixtureDirectory::new();
let prefix = fixture.0.join("prefix");
let games = fixture.0.join("Games with spaces");
fs::create_dir_all(prefix.join("dosdevices")).unwrap();
fs::create_dir(&games).unwrap();
let game = games.join("Jogo.exe");
fs::write(&game, b"MZfixture").unwrap();
std::os::unix::fs::symlink(&games, prefix.join("dosdevices/q:")).unwrap();
let process = fixture.0.join("42");
fs::create_dir(&process).unwrap();
let tail = (4..=21)
.map(|value| value.to_string())
.collect::<Vec<_>>()
.join(" ");
fs::write(
process.join("stat"),
format!("42 (game.exe) S {tail} 1000 0"),
)
.unwrap();
fs::write(
process.join("environ"),
format!(
"WINEPREFIX={}\0HYDRA_EPIC_INVOCATION_ID=session-a\0AUTH_PASSWORD=secret\0",
prefix.display()
),
)
.unwrap();
fs::write(
process.join("cmdline"),
b"wine64\0Q:\\jogo.exe\0-AUTH_PASSWORD=secret\0",
)
.unwrap();
fs::write(
process.join("maps"),
format!(
"100-200 r-xp 0000 00:01 123 {}\n",
game.to_string_lossy().replace(' ', "\\040")
),
)
.unwrap();
let result = inspect_process_at(
&fixture.0,
42,
Some(Path::new("/runtime/wine64-preloader")),
"Jogo.exe",
Some(&games),
);
assert!(result.complete);
assert_eq!(result.pe_executable_path.as_deref(), game.to_str());
assert_eq!(result.invocation_id.as_deref(), Some("session-a"));
assert!(!result.environment.contains_key("AUTH_PASSWORD"));
assert_eq!(result.start_time.as_deref(), Some("1000"));
fs::write(process.join("maps"), b"100-200 rw-p 0000 00:00 0 [heap]\n").unwrap();
let missing = inspect_process_at(
&fixture.0,
42,
Some(Path::new("/runtime/wine64")),
"Jogo.exe",
Some(&games),
);
assert!(!missing.complete);
assert!(missing.pe_executable_path.is_none());
}
#[cfg(unix)]
#[test]
fn command_arguments_do_not_become_a_game_executable() {
let fixture = FixtureDirectory::new();
let game = fixture.0.join("game.exe");
fs::write(&game, b"MZfixture").unwrap();
let command = format!(
"/usr/bin/python\0--document\0{}\0-AUTH_PASSWORD=secret\0",
game.display()
);
assert!(command_pe(command.as_bytes(), None, Some(&fixture.0)).is_none());
let first = format!("{}\0-AUTH_PASSWORD=secret\0", game.display());
assert_eq!(
command_pe(first.as_bytes(), None, Some(&fixture.0)).as_deref(),
Some(game.as_path())
);
}
}

View file

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View file

@ -0,0 +1,5 @@
# UMU database
Epic-only mapping derived from https://github.com/Open-Wine-Components/umu-database/blob/20819dcb44c0d0d1bbde23915389578bc6d56427/umu-database.csv.
Source CSV SHA-256: 8f89d6e584c45173df42a381cf299b5ce87fd3af8b6061e91c1c185634c8b9dc.
Licensed GPL-3.0-only; see UMU-DATABASE-LICENSE.

View file

@ -0,0 +1,222 @@
const fs = require("node:fs/promises");
const { constants } = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const crypto = require("node:crypto");
const { spawnSync } = require("node:child_process");
const TARGET = "x86_64-pc-windows-gnu";
const FILE_NAME = "epic-prerequisite-runner.exe";
const MANIFEST_NAME = ".build.json";
const SOURCE_DIRECTORY = "native/epic-prerequisite-runner";
const OUTPUT_DIRECTORY = "binaries/epic-prerequisite-runner/x64";
const SYSTEM_EXECUTABLE_DIRECTORIES = [
"/usr/bin",
"/usr/local/bin",
"/usr/lib/wine",
"/usr/lib/x86_64-linux-gnu/wine",
];
async function verifySystemOwnership(file) {
let current = await fs.realpath(file);
for (;;) {
const info = await fs.stat(current);
if (info.uid !== 0 || (info.mode & 0o022) !== 0)
throw new Error("Invalid system executable permissions");
const parent = path.dirname(current);
if (parent === current) return;
current = parent;
}
}
async function findExecutable(candidates, requireSystemOwnership = false) {
for (const candidate of candidates) {
try {
if (!path.isAbsolute(candidate)) continue;
if (!(await fs.stat(candidate)).isFile()) continue;
await fs.access(candidate, constants.X_OK);
if (requireSystemOwnership) {
await verifySystemOwnership(candidate);
await verifySystemOwnership(path.dirname(candidate));
}
// Keep the absolute alias: Rustup and wineapploader dispatch by argv[0].
return candidate;
} catch {
// Try only the next explicit location; never search PATH.
}
}
throw new Error("Required build or fixture executable is unavailable");
}
async function resolveCargoExecutable({
environment = process.env,
homeDirectory = os.homedir(),
} = {}) {
const name = process.platform === "win32" ? "cargo.exe" : "cargo";
const configuredHome = environment.CARGO_HOME;
const homes = [
...(configuredHome && path.isAbsolute(configuredHome)
? [configuredHome]
: []),
path.join(homeDirectory, ".cargo"),
];
const systemDirectories =
process.platform === "win32"
? []
: ["/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"];
return findExecutable([
...homes.map((home) => path.join(home, "bin", name)),
...systemDirectories.map((directory) => path.join(directory, name)),
]);
}
async function resolveSystemExecutable(name) {
if (!/^[A-Za-z0-9_.-]+$/.test(name))
throw new Error("Invalid system executable name");
return findExecutable(
SYSTEM_EXECUTABLE_DIRECTORIES.map((directory) =>
path.join(directory, name)
),
true
);
}
async function sourceDigest(projectDir) {
const directory = path.join(projectDir, SOURCE_DIRECTORY);
const files = ["Cargo.toml", "Cargo.lock"];
async function walk(relative) {
for (const entry of await fs.readdir(path.join(directory, relative), {
withFileTypes: true,
})) {
const file = path.posix.join(relative, entry.name);
if (entry.isSymbolicLink()) throw new Error("Invalid supervisor source");
if (entry.isDirectory()) await walk(file);
else if (entry.isFile()) files.push(file);
}
}
await walk("src");
const digest = crypto.createHash("sha256");
const sortedFiles = files.toSorted((left, right) =>
left.localeCompare(right, "en")
);
for (const file of sortedFiles) {
digest.update(file).update("\0");
digest.update(await fs.readFile(path.join(directory, file))).update("\0");
}
return digest.digest("hex");
}
function verifyPe(bytes) {
if (bytes.length < 64 || bytes.toString("ascii", 0, 2) !== "MZ") return false;
const offset = bytes.readUInt32LE(60);
return (
offset + 26 <= bytes.length &&
bytes.toString("ascii", offset, offset + 4) === "PE\0\0" &&
bytes.readUInt16LE(offset + 4) === 0x8664 &&
bytes.readUInt16LE(offset + 24) === 0x20b
);
}
async function verifyEpicPrerequisiteRunner({ projectDir, directory }) {
try {
const binary = path.join(directory, FILE_NAME);
const stat = await fs.lstat(binary);
if (!stat.isFile() || stat.isSymbolicLink()) return false;
const manifest = JSON.parse(
await fs.readFile(path.join(directory, MANIFEST_NAME), "utf8")
);
const bytes = await fs.readFile(binary);
return (
manifest.schemaVersion === 1 &&
manifest.target === TARGET &&
manifest.sourceSha256 === (await sourceDigest(projectDir)) &&
manifest.sha256 ===
crypto.createHash("sha256").update(bytes).digest("hex") &&
verifyPe(bytes)
);
} catch {
return false;
}
}
async function buildEpicPrerequisiteRunner({
projectDir = path.resolve(__dirname, ".."),
force = false,
} = {}) {
const directory = path.join(projectDir, OUTPUT_DIRECTORY);
if (!force && (await verifyEpicPrerequisiteRunner({ projectDir, directory })))
return directory;
const source = path.join(projectDir, SOURCE_DIRECTORY);
const rustflags = [
`--remap-path-prefix=${projectDir}=/hydra`,
"-C",
"link-arg=-Wl,--no-insert-timestamp",
].join("\x1f");
const result = spawnSync(
await resolveCargoExecutable(),
[
"build",
"--locked",
"--release",
"--target",
TARGET,
"--manifest-path",
path.join(source, "Cargo.toml"),
"--bin",
"epic-prerequisite-runner",
],
{
cwd: projectDir,
stdio: "inherit",
env: {
...process.env,
CARGO_INCREMENTAL: "0",
CARGO_ENCODED_RUSTFLAGS: rustflags,
SOURCE_DATE_EPOCH: "0",
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER:
process.env.CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER ??
"x86_64-w64-mingw32-gcc",
},
}
);
if (result.error || result.status !== 0)
throw new Error("Epic prerequisite supervisor build failed");
const bytes = await fs.readFile(
path.join(source, "target", TARGET, "release", FILE_NAME)
);
if (!verifyPe(bytes))
throw new Error("Invalid Epic prerequisite supervisor PE");
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(path.join(directory, FILE_NAME), bytes);
await fs.writeFile(
path.join(directory, MANIFEST_NAME),
JSON.stringify({
schemaVersion: 1,
target: TARGET,
sourceSha256: await sourceDigest(projectDir),
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
}) + "\n"
);
if (!(await verifyEpicPrerequisiteRunner({ projectDir, directory })))
throw new Error("Epic prerequisite supervisor integrity failed");
return directory;
}
module.exports = {
buildEpicPrerequisiteRunner,
verifyEpicPrerequisiteRunner,
sourceDigest,
resolveCargoExecutable,
resolveSystemExecutable,
verifyPe,
TARGET,
FILE_NAME,
MANIFEST_NAME,
SOURCE_DIRECTORY,
OUTPUT_DIRECTORY,
};
if (require.main === module)
buildEpicPrerequisiteRunner().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});

View file

@ -0,0 +1,136 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import crypto from "node:crypto";
import { createRequire } from "node:module";
import { test } from "node:test";
const require = createRequire(import.meta.url);
const {
verifyPe,
verifyEpicPrerequisiteRunner,
sourceDigest,
resolveCargoExecutable,
resolveSystemExecutable,
TARGET,
FILE_NAME,
MANIFEST_NAME,
} = require("./build-epic-prerequisite-runner.cjs");
test("Cargo resolves its explicit home without searching a poisoned PATH", async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "hydra-epic-cargo-"));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const cargoHome = path.join(root, "Cargo home with spaces");
const name = process.platform === "win32" ? "cargo.exe" : "cargo";
const cargo = path.join(cargoHome, "bin", name);
await fs.mkdir(path.dirname(cargo), { recursive: true });
await fs.writeFile(cargo, "benign resolver fixture", { mode: 0o755 });
assert.equal(
await resolveCargoExecutable({
environment: {
CARGO_HOME: cargoHome,
PATH: path.join(root, "untrusted"),
},
homeDirectory: path.join(root, "unused-home"),
}),
cargo
);
});
test("Cargo rejects a relative configured home and keeps its absolute user-home alias", async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "hydra-epic-cargo-"));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const cargo = path.join(
root,
".cargo",
"bin",
process.platform === "win32" ? "cargo.exe" : "cargo"
);
await fs.mkdir(path.dirname(cargo), { recursive: true });
await fs.writeFile(cargo, "benign resolver fixture", { mode: 0o755 });
assert.equal(
await resolveCargoExecutable({
environment: { CARGO_HOME: "relative-home", PATH: "relative-bin" },
homeDirectory: root,
}),
cargo
);
});
test("system fixture tools reject path components", async () => {
await assert.rejects(resolveSystemExecutable("../wine"), /Invalid system/);
await assert.rejects(resolveSystemExecutable("/tmp/wine"), /Invalid system/);
});
test(
"Linux fixture tools resolve only an executable in root-owned system directories",
{ skip: process.platform !== "linux" },
async () => {
const executable = await resolveSystemExecutable("true");
assert.ok(path.isAbsolute(executable));
assert.equal((await fs.stat(executable)).uid, 0);
assert.equal(path.basename(executable), "true");
}
);
function pe() {
const bytes = Buffer.alloc(128);
bytes.write("MZ");
bytes.writeUInt32LE(64, 60);
bytes.write("PE\0\0", 64);
bytes.writeUInt16LE(0x8664, 68);
bytes.writeUInt16LE(0x20b, 88);
return bytes;
}
test("packaging accepts only PE32+ x64 and bounds the PE offset", () => {
assert.equal(verifyPe(pe()), true);
const wrongArchitecture = pe();
wrongArchitecture.writeUInt16LE(0x14c, 68);
assert.equal(verifyPe(wrongArchitecture), false);
const truncated = pe();
truncated.writeUInt32LE(0xffffffff, 60);
assert.equal(verifyPe(truncated), false);
assert.equal(verifyPe(Buffer.from("MZ")), false);
});
test("packaging detects modified supervisor bytes, stale sources and missing manifests", async (t) => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "hydra-epic-pe-"));
t.after(() => fs.rm(projectDir, { recursive: true, force: true }));
const source = path.join(projectDir, "native/epic-prerequisite-runner");
const directory = path.join(projectDir, "package/epic");
await fs.mkdir(path.join(source, "src"), { recursive: true });
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(path.join(source, "Cargo.toml"), "source manifest");
await fs.writeFile(path.join(source, "Cargo.lock"), "locked dependencies");
await fs.writeFile(path.join(source, "src/main.rs"), "fn main() {}");
const bytes = pe();
await fs.writeFile(path.join(directory, FILE_NAME), bytes);
await fs.writeFile(
path.join(directory, MANIFEST_NAME),
JSON.stringify({
schemaVersion: 1,
target: TARGET,
sourceSha256: await sourceDigest(projectDir),
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
})
);
assert.equal(
await verifyEpicPrerequisiteRunner({ projectDir, directory }),
true
);
await fs.appendFile(path.join(directory, FILE_NAME), "tampered");
assert.equal(
await verifyEpicPrerequisiteRunner({ projectDir, directory }),
false
);
await fs.writeFile(path.join(directory, FILE_NAME), bytes);
await fs.appendFile(path.join(source, "src/main.rs"), "// changed");
assert.equal(
await verifyEpicPrerequisiteRunner({ projectDir, directory }),
false
);
await fs.unlink(path.join(directory, MANIFEST_NAME));
assert.equal(
await verifyEpicPrerequisiteRunner({ projectDir, directory }),
false
);
});

View file

@ -16,10 +16,26 @@ const {
writeEpicProcessSupervisorReceipt,
verifyEpicProcessSupervisor,
} = require("./prepare-epic-process-supervisor.cjs");
const {
verifyEpicPrerequisiteRunner,
} = require("./build-epic-prerequisite-runner.cjs");
async function afterPack(context) {
const platform = context.electronPlatformName;
const arch = Arch[context.arch];
if (
platform === "linux" &&
!(await verifyEpicPrerequisiteRunner({
projectDir: context.packager.projectDir,
directory: path.join(
context.packager.getResourcesDir(context.appOutDir),
"epic"
),
}))
)
throw new Error(
"Packaged Epic prerequisite supervisor is missing or invalid"
);
const artifact = getLegendaryArtifact(platform, arch);
const binaryPath = path.join(

View file

@ -1,7 +1,14 @@
const { Arch } = require("builder-util");
const { prepareLegendary } = require("./prepare-legendary.cjs");
const {
buildEpicPrerequisiteRunner,
} = require("./build-epic-prerequisite-runner.cjs");
async function beforePack(context) {
if (context.electronPlatformName === "linux")
await buildEpicPrerequisiteRunner({
projectDir: context.packager.projectDir,
});
await prepareLegendary({
projectDir: context.packager.projectDir,
platform: context.electronPlatformName,

View file

@ -126,6 +126,16 @@ async function verifyLinuxRuntime(directory, fingerprint, sha256File) {
await fs.readFile(path.join(directory, CACHE_MANIFEST), "utf8")
);
if (fingerprint && installed.fingerprint !== fingerprint) return false;
for (const required of [
"legendary",
"legendary.pyz",
"python-runner",
"python/bin/python3.13",
]) {
const entry = installed.files?.find((file) => file.name === required);
if (!entry?.sha256 || (required !== "legendary.pyz" && !entry.executable))
return false;
}
const actual = await runtimeFiles(directory, sha256File);
return JSON.stringify(actual) === JSON.stringify(installed.files);
} catch (error) {

View file

@ -0,0 +1,448 @@
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const path = require("node:path");
const { registerHooks } = require("node:module");
const { fileURLToPath, pathToFileURL } = require("node:url");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const os = require("node:os");
const ts = require("typescript");
const source = fs.realpathSync(path.resolve(__dirname, ".."));
const sourceRoot = fs.realpathSync(path.join(source, "src"));
const portsKey = Symbol.for("hydra.epic.runtime.smoke.ports");
const portSource =
'module.exports = global[Symbol.for("hydra.epic.runtime.smoke.ports")].get(__filename);';
function parseOptions(argv) {
const allowed = new Set([
"--data-dir",
"--python-runner",
"--umu",
"--helper",
"--fixture",
]);
const options = new Map();
for (let i = 0; i < argv.length; i += 2) {
const name = argv[i];
const value = argv[i + 1];
if (!allowed.has(name) || options.has(name))
throw new Error("Unknown or duplicate smoke option");
if (!value || value.startsWith("--") || value.includes("\0"))
throw new Error("Expected --option path pairs");
options.set(name, path.resolve(value));
}
return options;
}
function trustedSource(file) {
const canonical = fs.realpathSync(file);
const relative = path.relative(sourceRoot, canonical);
if (
!relative ||
relative === ".." ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative) ||
path.extname(canonical) !== ".ts"
)
throw new Error("Smoke TypeScript must be inside this checkout's src");
return canonical;
}
/** Normal Node module loading; CLI paths never select code or dependencies. */
function installSmokeLoader(overrides) {
if (typeof registerHooks !== "function")
throw new Error("The smoke requires Node 22.15 or newer");
if (global[portsKey]) throw new Error("A smoke loader is already active");
const portFiles = new Map();
const portSpecifiers = new Map();
Object.entries(overrides).forEach(([name, value], index) => {
const file = path.join(__dirname, ".epic-smoke-ports", `${index}.cjs`);
portSpecifiers.set(name, pathToFileURL(file).href);
portFiles.set(file, value);
});
const loaded = new Set(portFiles.keys());
const aliases = new Map([
[
"@main/events/helpers/parse-launch-options",
path.join(
sourceRoot,
"main",
"events",
"helpers",
"parse-launch-options.ts"
),
],
[
"@main/helpers/resolve-launch-command",
path.join(sourceRoot, "main", "helpers", "resolve-launch-command.ts"),
],
]);
global[portsKey] = portFiles;
const hooks = registerHooks({
resolve(name, context, nextResolve) {
const port = portSpecifiers.get(name);
if (port) return { url: port, shortCircuit: true };
const alias = aliases.get(name);
if (alias)
return {
url: pathToFileURL(trustedSource(alias)).href,
shortCircuit: true,
};
if (name.startsWith(".") && context.parentURL?.startsWith("file:")) {
const parent = fileURLToPath(context.parentURL);
if (path.extname(parent) === ".ts") {
let file = path.resolve(path.dirname(trustedSource(parent)), name);
if (file.endsWith(".js")) file = file.slice(0, -3) + ".ts";
else if (!path.extname(file)) file += ".ts";
if (path.extname(file) === ".ts")
return {
url: pathToFileURL(trustedSource(file)).href,
shortCircuit: true,
};
}
}
return nextResolve(name, context);
},
load(url, context, nextLoad) {
if (!url.startsWith("file:")) return nextLoad(url, context);
const file = fileURLToPath(url);
if (portFiles.has(file))
return { format: "commonjs", source: portSource, shortCircuit: true };
if (path.extname(file) !== ".ts") return nextLoad(url, context);
const canonical = trustedSource(file);
loaded.add(canonical);
return {
format: "commonjs",
source: ts.transpileModule(fs.readFileSync(canonical, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
esModuleInterop: true,
resolveJsonModule: true,
},
}).outputText,
shortCircuit: true,
};
},
});
return () => {
hooks.deregister();
for (const file of loaded) delete require.cache[file];
delete global[portsKey];
};
}
async function runSmoke(argv) {
if (process.platform !== "linux")
throw new Error(
"This smoke requires Linux x64, the prepared private Python runtime, UMU, and built PE fixtures."
);
const options = parseOptions(argv);
const data =
options.get("--data-dir") ||
fs.mkdtempSync(path.join(os.tmpdir(), "hydra-epic-linux-smoke-"));
const python =
options.get("--python-runner") ||
path.join(source, "binaries", "legendary", "linux-x64", "python-runner");
const umu =
options.get("--umu") || path.join(source, "binaries", "umu", "umu-run");
const helper =
options.get("--helper") ||
path.join(
source,
"binaries",
"epic-prerequisite-runner",
"x64",
"epic-prerequisite-runner.exe"
);
const fixtureSource =
options.get("--fixture") ||
path.join(
source,
"native",
"epic-prerequisite-runner",
"target",
"x86_64-pc-windows-gnu",
"release",
"epic-prerequisite-fixture.exe"
);
const fixture = path.join(
data,
"installed fixture",
"epic-prerequisite-fixture.exe"
);
const runtimeManifest = require(
path.join(source, "src", "shared", "epic-linux-runtime-manifest.json")
);
let calls = 0;
let configured = null;
class EpicManagedError extends Error {
constructor(code) {
super(code);
this.code = code;
}
}
const overrides = {
electron: { app: { isPackaged: true } },
"@main/level": {
db: { get: async () => configured },
levelKeys: { userPreferences: "preferences" },
},
"../system-path": {
SystemPath: {
getPath: (name) => (name === "home" ? os.homedir() : data),
},
},
"./system-path": {
SystemPath: {
getPath: (name) => (name === "home" ? os.homedir() : data),
},
},
"./legendary": {
Legendary: { getBinaryPath: () => path.dirname(python) + "/legendary" },
},
"./legendary-managed-runtime": {
EpicManagedError,
managedWorkspacePath: (_data, owner, artifact, platform) =>
path.join(
data,
"workspaces",
crypto
.createHash("sha256")
.update(JSON.stringify([owner, artifact, platform]))
.digest("hex")
),
},
"../umu": {
Umu: {
isValidProtonPath: (p) =>
fs.existsSync(path.join(p, "proton")) &&
fs.existsSync(path.join(p, "toolmanifest.vdf")),
getSupervisedRuntime: () => ({
binaryPath: umu,
version: runtimeManifest.umu.version,
sha256: runtimeManifest.umu.sha256,
}),
launchSupervised: async (o) => {
calls++;
return require("../src/main/services/umu-supervised-process.ts").spawnSupervisedUmu(
o
);
},
},
},
};
const stopLoader = installSmokeLoader(overrides);
const previousResourcesPath = process.resourcesPath;
process.resourcesPath = data;
async function main() {
await fsp.mkdir(path.join(data, "epic"), { recursive: true, mode: 0o700 });
await fsp.copyFile(
helper,
path.join(data, "epic", "epic-prerequisite-runner.exe")
);
await fsp.mkdir(path.dirname(fixture), { recursive: true });
await fsp.copyFile(fixtureSource, fixture);
const api = require("../src/main/services/epic/linux-runtime.ts");
const owner = {
environment: "staging",
userId: "smoke-user",
epicAccountId: "smoke-account",
};
const target = { owner, artifactId: "SyntheticSmoke", platform: "Windows" };
const game = {
shop: "epic",
objectId: "Smoke:Fixture",
epicInstallation: target,
};
const installed = {
installPath: path.dirname(fixture),
version: "smoke-build",
};
const signal = new AbortController().signal;
console.log("prepare automatic start");
const ctx = await api.ensureEpicLinuxPrerequisites({
game,
target,
installed,
signal,
});
assert.ok(path.isAbsolute(ctx.protonPath));
assert.match(path.basename(ctx.protonPath), /^UMU-Proton-/);
assert.ok(ctx.prefixGeneration);
console.log(
"prepare automatic passed",
ctx.umuVersion,
path.basename(ctx.protonPath)
);
const roundtripFile = path.join(data, "path with spaces Ω.txt");
await fsp.writeFile(roundtripFile, "map");
const mapped = await api.mapEpicLinuxPath(ctx, roundtripFile);
assert.match(mapped, /^[A-Z]:\\/i);
console.log("path roundtrip passed");
const before = calls;
const offline = await api.ensureEpicLinuxPrerequisites({
game,
target,
installed,
signal,
offline: true,
});
assert.equal(calls, before);
assert.equal(offline.prefixGeneration, ctx.prefixGeneration);
console.log("offline cached receipt passed");
const lease = await api.acquireEpicLinuxPrefixSession(ctx);
const record = path.join(data, "launch-record.json");
const recordWin = await api.mapEpicLinuxPath(ctx, record);
const gameCwd = path.join(data, "game working directory");
await fsp.mkdir(gameCwd, { recursive: true });
const gameCwdWin = await api.mapEpicLinuxPath(ctx, gameCwd);
const launched = await api.launchEpicLinuxGame(
{
executable: fixture,
args: [
"--record",
recordWin,
"-AUTH_PASSWORD=synthetic-epic-secret",
"-epicovt=synthetic-ownership-token",
],
workingDirectory: gameCwd,
environment: {
HYDRA_EPIC_SMOKE_SENTINEL: "synthetic-environment-value",
},
},
offline,
{ lease }
);
assert.equal((await launched.exited).code, 0);
await launched.waitForTreeExit();
const output = await launched.output;
assert.equal(output.stdout, "");
assert.equal(output.stderr, "");
lease.release();
const recorded = JSON.parse(await fsp.readFile(record, "utf8"));
await fsp.rm(record);
assert.ok(JSON.stringify(recorded).includes("synthetic-epic-secret"));
assert.equal(
path.win32.normalize(recorded.cwd).toLowerCase(),
path.win32.normalize(gameCwdWin).toLowerCase()
);
assert.equal(
recorded.env.HYDRA_EPIC_SMOKE_SENTINEL,
"synthetic-environment-value"
);
assert.equal(recorded.env.HYDRA_EPIC_INVOCATION_ID, launched.invocationId);
assert.equal(recorded.env.PROTONPATH, ctx.protonPath);
assert.equal(
await fsp.realpath(recorded.env.WINEPREFIX),
ctx.winePrefixPath
);
console.log(
"supervised benign authenticated-like PE passed; private pipe output discarded"
);
const customGame = {
...game,
winePrefixPath: path.join(data, "custom prefix"),
protonPath: ctx.protonPath,
};
console.log("prepare custom start");
const custom = await api.ensureEpicLinuxPrerequisites({
game: customGame,
target,
installed,
signal,
});
assert.equal(custom.winePrefixPath, customGame.winePrefixPath);
assert.notEqual(custom.prefixGeneration, ctx.prefixGeneration);
console.log("custom prefix + selected Proton passed");
const receipts = [];
async function walk(p) {
for (const d of await fsp.readdir(p, { withFileTypes: true })) {
const q = path.join(p, d.name);
if (d.isDirectory()) await walk(q);
else if (d.name === "linux-runtime.json") receipts.push(q);
}
}
await walk(path.join(data, "workspaces"));
for (const r of receipts) {
const s = await fsp.readFile(r, "utf8");
assert.ok(
!/synthetic-epic-secret|synthetic-ownership-token|AUTH_PASSWORD/.test(s)
);
}
async function inspectPrivateFiles(directory) {
for (const entry of await fsp.readdir(directory, {
withFileTypes: true,
})) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) await inspectPrivateFiles(file);
else if (
entry.isFile() &&
/\.(?:log|json|reg|txt)$/.test(entry.name) &&
(await fsp.stat(file)).size <= 2 * 1024 * 1024
) {
const contents = await fsp.readFile(file, "utf8");
assert.ok(
!/synthetic-epic-secret|synthetic-ownership-token|synthetic-environment-value/.test(
contents
),
`Unexpected synthetic credential persisted in ${file}`
);
}
}
}
await api.ensureEpicLinuxPrerequisites({
game: customGame,
target,
installed: {
...installed,
prereq_info: { path: path.basename(fixture), args: "--exit 3010" },
},
signal,
});
console.log(
"Proton prerequisite full DWORD 3010 + supervised system reboot passed"
);
const sourceContext = await api.ensureEpicLinuxPrerequisites({
game: customGame,
target,
installed: {
installPath: path.dirname(fixtureSource),
version: "mounted-source-build",
},
signal,
});
assert.match(
await api.mapEpicLinuxPath(sourceContext, fixtureSource),
/^[A-Z]:\\/i
);
console.log(
"mounted source fixture roundtrip with fixed installation context passed"
);
await inspectPrivateFiles(data);
console.log("credential-free receipts passed");
console.log(
JSON.stringify({
passed: true,
calls,
proton: path.basename(ctx.protonPath),
automaticPrefix: ctx.winePrefixPath,
customPrefix: custom.winePrefixPath,
})
);
}
try {
await main();
} finally {
stopLoader();
if (previousResourcesPath === undefined) delete process.resourcesPath;
else process.resourcesPath = previousResourcesPath;
}
}
module.exports = { installSmokeLoader, parseOptions };
if (require.main === module)
runSmoke(process.argv.slice(2)).catch((e) => {
console.error("SMOKE_FAILED", e.code || e.name, e.message);
process.exitCode = 1;
});

View file

@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import test from "node:test";
const require = createRequire(import.meta.url);
const {
installSmokeLoader,
parseOptions,
} = require("./test-epic-linux-runtime.cjs");
test("smoke CLI accepts only binary/data paths and rejects code/dependency overrides", () => {
const supplied = "opaque-';globalThis.smokeInjected=true;//";
const options = parseOptions(["--data-dir", supplied, "--umu", "umu-run"]);
assert.equal(options.get("--data-dir"), path.resolve(supplied));
assert.equal(options.get("--umu"), path.resolve("umu-run"));
for (const args of [
["--dependencies", "/untrusted-checkout"],
["--source", "/untrusted.ts"],
["--umu", "first", "--umu", "second"],
["--helper"],
["--fixture", "--umu"],
])
assert.throws(() => parseOptions(args));
assert.equal(global.smokeInjected, undefined);
});
test("the normal Node loader rejects TypeScript outside the checkout before executing it", async (t) => {
const directory = await fs.mkdtemp(
path.join(os.tmpdir(), "epic-smoke-boundary-")
);
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const untrusted = path.join(directory, "untrusted.ts");
await fs.writeFile(untrusted, "globalThis.smokeInjected = true; export {};");
const stop = installSmokeLoader({});
try {
assert.throws(() => require(untrusted), /inside this checkout's src/);
assert.equal(global.smokeInjected, undefined);
} finally {
stop();
}
});
test("normal Node imports production runtime modules with object ports and clears its scoped cache", async (t) => {
const directory = await fs.mkdtemp(
path.join(os.tmpdir(), "epic-smoke-import-")
);
t.after(() => fs.rm(directory, { recursive: true, force: true }));
let preferenceReads = 0;
const system = {
SystemPath: {
getPath: (name) => (name === "home" ? os.homedir() : directory),
},
};
const stop = installSmokeLoader({
electron: { app: { isPackaged: true } },
"@main/level": {
db: {
get: async () => {
preferenceReads++;
return null;
},
},
levelKeys: { userPreferences: "preferences" },
},
"../system-path": system,
"./system-path": system,
"./legendary": { Legendary: { getBinaryPath: () => null } },
"./legendary-managed-runtime": {
EpicManagedError: class extends Error {},
managedWorkspacePath: () => directory,
},
"../umu": { Umu: {} },
});
const runtimeFile = require.resolve(
"../src/main/services/epic/linux-runtime.ts"
);
try {
const runtime = require(runtimeFile);
assert.equal(require(runtimeFile), runtime);
assert.equal(typeof runtime.ensureEpicLinuxPrerequisites, "function");
const prefix = await runtime.resolveEpicLinuxPrefixPath(
{ winePrefixPath: path.join(directory, "prefix") },
{ environment: "test", userId: "user", epicAccountId: "account" },
"Artifact"
);
assert.equal(prefix, path.join(await fs.realpath(directory), "prefix"));
assert.equal(preferenceReads, 1);
assert.equal(await runtime.getEpicLinuxPrefixGeneration(prefix), null);
await assert.rejects(fs.stat(prefix), { code: "ENOENT" });
} finally {
stop();
}
assert.equal(require.cache[runtimeFile], undefined);
});

View file

@ -0,0 +1,254 @@
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { promisify } = require("node:util");
const { execFile, spawn } = require("node:child_process");
const execute = promisify(execFile);
const {
buildEpicPrerequisiteRunner,
resolveCargoExecutable,
resolveSystemExecutable,
TARGET,
FILE_NAME,
} = require("./build-epic-prerequisite-runner.cjs");
async function smoke() {
if (process.platform !== "linux")
throw new Error("Wine fixture requires Linux");
const projectDir = path.resolve(__dirname, "..");
const [cargo, wine, wineboot, winepath, wineserver, wixl] = await Promise.all(
[
resolveCargoExecutable(),
...["wine", "wineboot", "winepath", "wineserver", "wixl"].map((name) =>
resolveSystemExecutable(name)
),
]
);
const output = await buildEpicPrerequisiteRunner({ projectDir });
await execute(
cargo,
[
"build",
"--locked",
"--release",
"--target",
TARGET,
"--manifest-path",
path.join(projectDir, "native/epic-prerequisite-runner/Cargo.toml"),
"--bin",
"epic-prerequisite-fixture",
],
{
env: {
...process.env,
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: "x86_64-w64-mingw32-gcc",
},
}
);
const directory = await fs.mkdtemp(
path.join(os.tmpdir(), "hydra-epic-wine-")
);
const env = {
...process.env,
WINEPREFIX: path.join(directory, "prefix"),
WINEARCH: "win64",
WINEDEBUG: "-all",
WINEDLLOVERRIDES: "mscoree,mshtml=",
};
const run = (file, args, options = {}) =>
execute(file, args, {
env,
timeout: 120_000,
maxBuffer: 1024 * 1024,
...options,
});
const windowsPath = async (file) =>
(await run(winepath, ["-w", file])).stdout.trim();
const quiet = (file, args) =>
new Promise((resolve, reject) => {
const child = spawn(file, args, { env, stdio: "ignore" });
child.once("error", reject);
child.once("exit", (code) =>
code === 0 ? resolve() : reject(new Error("Wine preparation failed"))
);
});
try {
await fs.mkdir(env.WINEPREFIX, { recursive: true, mode: 0o700 });
await quiet(wineserver, ["-p"]);
await quiet(wineboot, ["-u"]);
const helper = path.join(output, FILE_NAME);
const fixture = path.join(
projectDir,
"native/epic-prerequisite-runner/target",
TARGET,
"release/epic-prerequisite-fixture.exe"
);
let attempt = 0;
const request = async (args, kind = "exe", executable = fixture) => {
const nonce = `fixture-${++attempt}`;
const result = path.join(directory, `${nonce}.result.json`);
const config = path.join(directory, `${nonce}.config.json`);
await fs.writeFile(
config,
JSON.stringify({
schemaVersion: 1,
kind,
executable: kind === "reboot" ? "" : await windowsPath(executable),
args,
cwd: await windowsPath(directory),
nonce,
resultPath: await windowsPath(result),
}),
{ mode: 0o600 }
);
return { config, result, nonce };
};
const invoke = async (input) => {
await run(wine, [helper, await windowsPath(input.config)]);
const result = JSON.parse(await fs.readFile(input.result, "utf8"));
assert.equal(result.schemaVersion, 1);
assert.equal(result.nonce, input.nonce);
return result;
};
for (const code of [0, 3010, 1641, 194, 105, 0xffffffff]) {
const result = await invoke(await request(["--exit", String(code)]));
assert.equal(result.exitCode, code, `Full Windows exit code ${code}`);
}
console.log("Wine supervisor: full exit codes passed.");
const record = path.join(directory, "argv.json");
const values = [
"",
"two words",
'a"b',
"C:\\trailing\\",
"%PATH% & $(literal)",
"á日本語",
];
await invoke(
await request(["--record", await windowsPath(record), ...values])
);
assert.deepEqual(
JSON.parse(await fs.readFile(record, "utf8")).args,
values
);
const marker = path.join(directory, "child-finished");
await invoke(
await request(["--spawn-child", "200", await windowsPath(marker)])
);
assert.equal(await fs.readFile(marker, "utf8"), "child-complete");
const msi = path.join(directory, "fixture.msi");
await run(wixl, [
"-a",
"x64",
"-o",
msi,
path.join(
projectDir,
"native/epic-prerequisite-runner/tests/fixture.wxs"
),
]);
assert.equal(
(await invoke(await request(["/qn", "/norestart"], "msi", msi))).exitCode,
0
);
const registry = await run(wine, [
"reg",
"query",
"HKCU\\Software\\Hydra\\EpicPrerequisiteFixture",
"/v",
"Installed",
]);
assert.match(registry.stdout, /yes/);
console.log("Wine supervisor: MSI installation effect verified.");
await invoke(
await request(["--schedule-reboot", await windowsPath(directory)])
);
assert.equal((await invoke(await request([], "reboot"))).exitCode, 0);
assert.equal(
await fs.readFile(path.join(directory, "rename-destination.txt"), "utf8"),
"rename-applied"
);
assert.equal(
await fs.stat(path.join(directory, "rename-source.txt")).then(
() => true,
() => false
),
false
);
assert.equal(
await fs.stat(path.join(directory, "delete-on-reboot.txt")).then(
() => true,
() => false
),
false
);
assert.deepEqual(
JSON.parse(
await fs.readFile(path.join(directory, "runonce.json"), "utf8")
).args,
["runonce-executed"]
);
console.log(
"Wine supervisor: reboot rename/delete and RunOnce effects verified."
);
const cancellationMarker = path.join(directory, "cancelled-child");
const input = await request([
"--spawn-child",
"1200",
await windowsPath(cancellationMarker),
]);
const child = spawn(wine, [helper, await windowsPath(input.config)], {
env,
stdio: "pipe",
});
const closed = new Promise((resolve, reject) => {
child.once("close", resolve);
child.once("error", reject);
});
const started = cancellationMarker + ".started";
for (let index = 0; index < 100; index++) {
if (
await fs.stat(started).then(
() => true,
() => false
)
)
break;
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.ok(
await fs.stat(started).then(
() => true,
() => false
)
);
child.kill("SIGKILL");
await closed;
await new Promise((resolve) => setTimeout(resolve, 1500));
assert.equal(
await fs.stat(input.result).then(
() => true,
() => false
),
false
);
assert.equal(
await fs.stat(cancellationMarker).then(
() => true,
() => false
),
false
);
console.log(
"Epic prerequisite Wine fixtures passed: DWORD, argv, descendants, MSI and cancellation."
);
} finally {
await run(wineserver, ["-k"]).catch(() => {});
await fs.rm(directory, { recursive: true, force: true });
}
}
smoke().catch((error) => {
console.error(error.stack);
process.exitCode = 1;
});

View file

@ -0,0 +1,117 @@
const fs = require("node:fs/promises");
const path = require("node:path");
const { createHash } = require("node:crypto");
const REQUEST_TIMEOUT_MS = 30_000;
const REPOSITORY = "Open-Wine-Components/umu-database";
function parseCsv(text) {
const rows = [];
let row = [],
field = "",
quoted = false;
for (let i = 0; i < text.length; i++) {
const character = text[i];
if (character === '"') {
if (quoted && text[i + 1] === '"') {
field += '"';
i++;
} else quoted = !quoted;
} else if (character === "," && !quoted) {
row.push(field);
field = "";
} else if (character === "\n" && !quoted) {
row.push(field.replace(/\r$/, ""));
rows.push(row);
row = [];
field = "";
} else field += character;
}
if (quoted) throw new Error("Unclosed CSV field");
if (field || row.length) {
row.push(field);
rows.push(row);
}
return rows;
}
function generateMap(csv, commit) {
if (!/^[a-f0-9]{40}$/.test(commit))
throw new Error("Expected an immutable source commit");
const [header, ...rows] = parseCsv(csv);
const store = header.indexOf("STORE"),
codename = header.indexOf("CODENAME"),
id = header.indexOf("UMU_ID");
if ([store, codename, id].some((index) => index < 0))
throw new Error("Unsupported upstream CSV schema");
const entries = {};
for (const row of rows) {
if (row[store]?.trim().toLowerCase() !== "egs") continue;
const key = row[codename]?.trim().toLowerCase(),
value = row[id]?.trim().toLowerCase();
if (!key || !/^umu-[a-z0-9_-]+$/.test(value ?? ""))
throw new Error("Invalid Epic mapping");
if (entries[key] && entries[key] !== value)
throw new Error(`Conflicting Epic mapping: ${key}`);
entries[key] = value;
}
if (!Object.keys(entries).length) throw new Error("Epic map is empty");
return {
schemaVersion: 1,
source: `https://github.com/${REPOSITORY}/blob/${commit}/umu-database.csv`,
commit,
csvSha256: createHash("sha256").update(csv).digest("hex"),
license: "GPL-3.0-only",
entries: Object.fromEntries(
Object.entries(entries).sort(([left], [right]) =>
left.localeCompare(right, "en")
)
),
};
}
async function fetchText(url) {
const response = await fetch(url, {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
headers: { "User-Agent": "Hydra-UMU-map-build" },
});
if (!response.ok) throw new Error(`UMU source HTTP ${response.status}`);
return response.text();
}
async function main() {
const commit =
process.argv[2] ??
JSON.parse(
await fetchText(`https://api.github.com/repos/${REPOSITORY}/commits/main`)
).sha;
const base = `https://raw.githubusercontent.com/${REPOSITORY}/${commit}`;
const [csv, license] = await Promise.all([
fetchText(`${base}/umu-database.csv`),
fetchText(`${base}/LICENSE`),
]);
const map = generateMap(csv, commit);
const project = path.resolve(__dirname, "..");
await fs.writeFile(
path.join(project, "src/shared/epic-umu-map.json"),
`${JSON.stringify(map, null, 2)}\n`
);
await fs.mkdir(path.join(project, "resources/epic"), { recursive: true });
await fs.writeFile(
path.join(project, "resources/epic/UMU-DATABASE-LICENSE"),
license
);
await fs.writeFile(
path.join(project, "resources/epic/UMU-DATABASE-NOTICE.md"),
`# UMU database\n\nEpic-only mapping derived from ${map.source}.\nSource CSV SHA-256: ${map.csvSha256}.\nLicensed GPL-3.0-only; see UMU-DATABASE-LICENSE.\n`
);
process.stdout.write(
`Generated ${Object.keys(map.entries).length} Epic mappings from ${commit}\n`
);
}
module.exports = { parseCsv, generateMap };
if (require.main === module)
main().catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exitCode = 1;
});

View file

@ -575,7 +575,8 @@ function DownloadGameModalSession({
const { steam: libraryGame, epic: epicGame } = getOfficialStoreMembers(
presentation?.members ?? []
);
const { available: epicAvailable, connection } = useEpicManagedConnection();
const { installationAvailable: epicAvailable, connection } =
useEpicManagedConnection();
const [epicInstallGame, setEpicInstallGame] = useState<LibraryGame | null>(
null
);

View file

@ -93,15 +93,17 @@ export function EpicInstallModal({
>
{t("epic_retry")}
</Button>
<Button
variant="secondary"
onClick={() => {
onClose();
navigate("/settings?tab=integrations");
}}
>
{t("integrations", { ns: "settings" })}
</Button>
{getEpicManagedError(install.error).ns === "settings" && (
<Button
variant="secondary"
onClick={() => {
onClose();
navigate("/settings?tab=integrations");
}}
>
{t("integrations", { ns: "settings" })}
</Button>
)}
</>
)}
<HorizontalFocusGroup>

View file

@ -13,6 +13,7 @@ import {
} from "../../../common";
import { useUserPreferences } from "../../../../hooks/use-user-preferences.hook";
import { SettingsSection } from "../../../../pages/settings/settings-section";
import { useGameLinuxCompatibility } from "@renderer/hooks/use-game-linux-compatibility";
import "./compatibility-tab.scss";
@ -68,6 +69,8 @@ export function GameCompatibilitySettingsTab({
const userPreferences = useUserPreferences();
const electron = globalThis.window
.electron as unknown as ElectronCompatibilityBridge;
const { compatibility, refresh: refreshCompatibility } =
useGameLinuxCompatibility(game);
const [protonVersions, setProtonVersions] = useState<ProtonVersion[]>([]);
const [gamemodeAvailable, setGamemodeAvailable] = useState(false);
@ -138,7 +141,8 @@ export function GameCompatibilitySettingsTab({
focusId: GAME_COMPATIBILITY_SETTINGS_PROTON_AUTO_ID,
value: "",
title: t("proton_version_auto"),
description: getProtonSourceDescription(null),
description:
compatibility?.protonName ?? getProtonSourceDescription(null),
},
];
@ -152,27 +156,39 @@ export function GameCompatibilitySettingsTab({
}
return options;
}, [protonVersions, t, getProtonSourceDescription]);
}, [
protonVersions,
t,
getProtonSourceDescription,
compatibility?.protonName,
]);
const displayedWinePrefixPath =
winePrefixPath ?? compatibility?.winePrefixPath ?? null;
const handleSelectWinePrefix = useCallback(async () => {
const defaultPath = await electron.getDefaultWinePrefixSelectionPath();
setWinePickerInitialPath(winePrefixPath ?? defaultPath ?? undefined);
setWinePickerInitialPath(
displayedWinePrefixPath ?? defaultPath ?? undefined
);
setWinePickerOpen(true);
}, [electron, winePrefixPath]);
}, [electron, displayedWinePrefixPath]);
const handleWinePrefixPicked = useCallback(
async (path: string) => {
setWinePickerOpen(false);
await electron.selectGameWinePrefix(game.shop, game.objectId, path);
setWinePrefixPath(path);
await refreshCompatibility();
},
[electron, game.shop, game.objectId]
[electron, game.shop, game.objectId, refreshCompatibility]
);
const handleClearWinePrefix = useCallback(async () => {
await electron.selectGameWinePrefix(game.shop, game.objectId, null);
setWinePrefixPath(null);
}, [electron, game.shop, game.objectId]);
await refreshCompatibility();
}, [electron, game.shop, game.objectId, refreshCompatibility]);
const handleChangeProtonVersion = useCallback(
async (value: string) => {
@ -182,8 +198,9 @@ export function GameCompatibilitySettingsTab({
game.objectId,
value || null
);
await refreshCompatibility();
},
[electron, game.shop, game.objectId]
[electron, game.shop, game.objectId, refreshCompatibility]
);
const handleToggleGamemode = useCallback(
@ -243,8 +260,12 @@ export function GameCompatibilitySettingsTab({
<Input
focusId={GAME_COMPATIBILITY_SETTINGS_PRIMARY_CONTROL_ID}
className="game-compatibility-settings-tab__wine-prefix-input"
value={winePrefixPath ?? ""}
placeholder={t("no_directory_selected")}
value={displayedWinePrefixPath ?? ""}
placeholder={
game.shop === "epic"
? t("epic_compatibility_automatic")
: t("no_directory_selected")
}
readOnly
/>

View file

@ -567,6 +567,13 @@
"epic_play_local_saves_description": "افتح هذه الجلسة دون استعادة ملفات الحفظ السحابية. ستظل التغييرات المحلية بانتظار المزامنة.",
"epic_managed_error_generic": "فشلت عملية Epic. تحقق من الاتصال وحاول مجددًا.",
"epic_external_launcher_required": "تتطلب هذه اللعبة مشغّل ألعاب آخر لتثبيتها. ثبّتها عبر المشغّل الرسمي.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "تشغيل — Offline",
"epic_prerequisites_required": "اسمح بمتطلبات اللعبة وحاول مجددًا.",
"epic_update_required": "حدّث اللعبة عبر خيار Epic Games باستخدام المجلد نفسه.",

View file

@ -563,6 +563,13 @@
"epic_play_local_saves_description": "Пачніце гэты сеанс без аднаўлення захаванняў з воблака. Лакальныя змены будуць чакаць сінхранізацыі.",
"epic_managed_error_generic": "Аперацыя Epic не ўдалася. Праверце злучэнне і паспрабуйце зноў.",
"epic_external_launcher_required": "Для ўсталявання гэтай гульні патрэбны іншы лаўнчар. Усталюйце яе праз афіцыйны лаўнчар.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Гуляць — Offline",
"epic_prerequisites_required": "Дазвольце ўсталяванне неабходных кампанентаў гульні і паспрабуйце зноў.",
"epic_update_required": "Абнавіце гульню праз варыянт Epic Games, выкарыстоўваючы тую ж папку.",

View file

@ -722,6 +722,13 @@
"epic_play_local_saves_description": "Отворете тази сесия без възстановяване на облачните записи. Локалните промени остават за синхронизиране.",
"epic_managed_error_generic": "Операцията на Epic е неуспешна. Проверете връзката и опитайте отново.",
"epic_external_launcher_required": "Тази игра изисква друг лаунчер за инсталиране. Инсталирайте я чрез официалния лаунчер.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Играй — Офлайн",
"epic_prerequisites_required": "Разрешете необходимите компоненти на играта и опитайте отново.",
"epic_update_required": "Обновете играта чрез опцията Epic Games, като използвате същата папка.",

View file

@ -566,6 +566,13 @@
"epic_play_local_saves_description": "Obre aquesta sessió sense restaurar les partides del núvol. Els canvis locals quedaran pendents de sincronització.",
"epic_managed_error_generic": "L’operació d’Epic ha fallat. Comprova la connexió i torna-ho a provar.",
"epic_external_launcher_required": "Aquest joc requereix un altre llançador per instal·lar-lo. Instal·la'l a través del llançador oficial.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Inicia — Offline",
"epic_prerequisites_required": "Autoritza els requisits previs del joc i torna-ho a provar.",
"epic_update_required": "Actualitza el joc amb l’opció Epic Games fent servir la mateixa carpeta.",

View file

@ -567,6 +567,13 @@
"epic_play_local_saves_description": "Spusťte tuto relaci bez obnovení pozic z cloudu. Místní změny zůstanou čekat na synchronizaci.",
"epic_managed_error_generic": "Operace Epic se nezdařila. Zkontrolujte připojení a zkuste to znovu.",
"epic_external_launcher_required": "K instalaci této hry je potřeba jiný launcher. Nainstalujte ji prostřednictvím oficiálního launcheru.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Hrát — Offline",
"epic_prerequisites_required": "Povolte požadované součásti hry a zkuste to znovu.",
"epic_update_required": "Aktualizujte hru přes možnost Epic Games do stejné složky.",

View file

@ -567,6 +567,13 @@
"epic_play_local_saves_description": "Åbn denne session uden at gendanne gemte spil fra skyen. Lokale ændringer afventer synkronisering.",
"epic_managed_error_generic": "Epic-handlingen mislykkedes. Kontroller forbindelsen, og prøv igen.",
"epic_external_launcher_required": "Dette spil kræver en anden launcher for at blive installeret. Installer det via den officielle launcher.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Spil — Offline",
"epic_prerequisites_required": "Godkend spillets forudsætninger, og prøv igen.",
"epic_update_required": "Opdater spillet via Epic Games-indstillingen i den samme mappe.",

View file

@ -567,6 +567,13 @@
"epic_play_local_saves_description": "Diese Sitzung ohne Wiederherstellung von Cloud-Spielständen starten. Lokale Änderungen warten auf die Synchronisierung.",
"epic_managed_error_generic": "Der Epic-Vorgang ist fehlgeschlagen. Prüfe die Verbindung und versuche es erneut.",
"epic_external_launcher_required": "Dieses Spiel benötigt zur Installation einen anderen Launcher. Installiere es über den offiziellen Launcher.",
"epic_compatibility_runtime_unavailable": "Die Kompatibilitätsumgebung ist nicht verfügbar. Prüfe die Kompatibilitätseinstellungen dieses Spiels und versuche es erneut.",
"epic_compatibility_runtime_download_failed": "Die Kompatibilitätsumgebung konnte nicht heruntergeladen werden. Stelle eine Internetverbindung her und versuche es erneut. Die Spieldateien wurden beibehalten.",
"epic_compatibility_prefix_invalid": "Das Wine-Präfix konnte nicht vorbereitet werden. Prüfe die Kompatibilitätseinstellungen dieses Spiels und versuche es erneut.",
"epic_compatibility_prefix_busy": "Dieses Wine-Präfix wird von einem anderen Spiel verwendet. Schließe es und versuche es erneut.",
"epic_compatibility_launch_failed": "Die Kompatibilitätsumgebung konnte das Spiel nicht starten. Prüfe die Kompatibilitätseinstellungen dieses Spiels und versuche es erneut.",
"epic_install_platform_unsupported": "Epic-Games-Downloads sind auf dieser Plattform oder Architektur nicht verfügbar.",
"epic_compatibility_automatic": "Automatisch",
"epic_play_offline": "Spielen — Offline",
"epic_prerequisites_required": "Genehmige die Spielvoraussetzungen und versuche es erneut.",
"epic_update_required": "Aktualisiere das Spiel über die Epic Games-Option im selben Ordner.",

View file

@ -768,6 +768,13 @@
"epic_play_local_saves_description": "Ανοίξτε αυτή τη συνεδρία χωρίς επαναφορά αποθηκεύσεων από το cloud. Οι τοπικές αλλαγές παραμένουν σε αναμονή συγχρονισμού.",
"epic_managed_error_generic": "Η λειτουργία Epic απέτυχε. Ελέγξτε τη σύνδεση και δοκιμάστε ξανά.",
"epic_external_launcher_required": "Αυτό το παιχνίδι απαιτεί άλλο πρόγραμμα εκκίνησης για την εγκατάστασή του. Εγκαταστήστε το μέσω του επίσημου προγράμματος εκκίνησης.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Παίξτε — Εκτός σύνδεσης",
"epic_prerequisites_required": "Εγκρίνετε τα προαπαιτούμενα του παιχνιδιού και δοκιμάστε ξανά.",
"epic_update_required": "Ενημερώστε το παιχνίδι μέσω της επιλογής Epic Games στον ίδιο φάκελο.",

View file

@ -1044,7 +1044,7 @@
"epic_install_sizes": "Download: {{download}} · Install: {{disk}}",
"download_size": "Download: <strong>{{size}}</strong>",
"required_storage_space": "Storage: <strong>{{size}}</strong>",
"epic_finalizing_install": "Finalizing installation",
"epic_finalizing_install": "Preparing installation",
"epic_cloud_saves": "Cloud save — Epic Games",
"epic_cloud_saves_description": "Sync this game's saves with your Epic Games account.",
"epic_save_unsupported": "This game does not support cloud saves through Epic Games.",
@ -1071,8 +1071,15 @@
"epic_play_local_saves_description": "Open this session without restoring cloud saves. Local changes remain pending for synchronization.",
"epic_managed_error_generic": "The Epic operation failed. Check your connection and try again.",
"epic_external_launcher_required": "This game requires another launcher to install. Install it through the official launcher.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Play — Offline",
"epic_prerequisites_required": "Authorize the game's prerequisites and try again.",
"epic_prerequisites_required": "The game's dependencies could not be prepared. Try again.",
"epic_update_required": "Update the game through the Epic Games option using the same folder.",
"epic_retry": "Try again"
},

View file

@ -1029,6 +1029,13 @@
"epic_play_local_saves_description": "Abre esta sesión sin restaurar partidas de la nube. Los cambios locales quedarán pendientes de sincronización.",
"epic_managed_error_generic": "La operación de Epic falló. Comprueba la conexión e inténtalo de nuevo.",
"epic_external_launcher_required": "Este juego necesita otro lanzador para instalarse. Instálalo mediante el lanzador oficial.",
"epic_compatibility_runtime_unavailable": "El entorno de compatibilidad no está disponible. Revisa la sección Compatibilidad de este juego e inténtalo de nuevo.",
"epic_compatibility_runtime_download_failed": "No se pudo descargar el entorno de compatibilidad. Conéctate a internet e inténtalo de nuevo. Se han conservado los archivos del juego.",
"epic_compatibility_prefix_invalid": "No se pudo preparar el prefijo Wine. Revisa la sección Compatibilidad de este juego e inténtalo de nuevo.",
"epic_compatibility_prefix_busy": "Otro juego está usando este prefijo Wine. Ciérralo e inténtalo de nuevo.",
"epic_compatibility_launch_failed": "El entorno de compatibilidad no pudo iniciar el juego. Revisa la sección Compatibilidad de este juego e inténtalo de nuevo.",
"epic_install_platform_unsupported": "Las descargas de Epic Games no están disponibles para esta plataforma o arquitectura.",
"epic_compatibility_automatic": "Automático",
"epic_play_offline": "Jugar — Desconectado",
"epic_prerequisites_required": "Autoriza los requisitos previos del juego e inténtalo de nuevo.",
"epic_update_required": "Actualiza el juego desde la opción Epic Games usando la misma carpeta.",

View file

@ -567,6 +567,13 @@
"epic_play_local_saves_description": "Ava see seanss pilvesalvestusi taastamata. Kohalikud muudatused jäävad sünkroonimist ootama.",
"epic_managed_error_generic": "Epici toiming ebaõnnestus. Kontrolli ühendust ja proovi uuesti.",
"epic_external_launcher_required": "Selle mängu paigaldamiseks on vaja teist käivitusprogrammi. Paigalda mäng ametliku käivitusprogrammi kaudu.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Mängi — Offline",
"epic_prerequisites_required": "Luba mängu vajalikud komponendid ja proovi uuesti.",
"epic_update_required": "Uuenda mängu Epic Gamesi valiku kaudu samasse kausta.",

View file

@ -563,6 +563,13 @@
"epic_play_local_saves_description": "این نشست را بدون بازیابی ذخیره‌های ابری باز کنید. تغییرات محلی در انتظار همگام‌سازی می‌مانند.",
"epic_managed_error_generic": "عملیات Epic ناموفق بود. اتصال را بررسی کنید و دوباره تلاش کنید.",
"epic_external_launcher_required": "برای نصب این بازی به لانچر دیگری نیاز دارید. آن را از طریق لانچر رسمی نصب کنید.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "بازی — Offline",
"epic_prerequisites_required": "پیش‌نیازهای بازی را تأیید کنید و دوباره تلاش کنید.",
"epic_update_required": "بازی را از گزینهٔ Epic Games با همان پوشه به‌روزرسانی کنید.",

View file

@ -567,6 +567,13 @@
"epic_play_local_saves_description": "Avaa tämä istunto palauttamatta pilvitallennuksia. Paikalliset muutokset odottavat synkronointia.",
"epic_managed_error_generic": "Epic-toiminto epäonnistui. Tarkista yhteys ja yritä uudelleen.",
"epic_external_launcher_required": "Tämän pelin asentamiseen tarvitaan toinen käynnistysohjelma. Asenna peli virallisen käynnistysohjelman kautta.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Pelaa — Offline",
"epic_prerequisites_required": "Hyväksy pelin edellyttämät osat ja yritä uudelleen.",
"epic_update_required": "Päivitä peli Epic Games -vaihtoehdolla samaan kansioon.",

View file

@ -1035,6 +1035,13 @@
"epic_play_local_saves_description": "Ouvrir cette session sans restaurer les sauvegardes du cloud. Les modifications locales restent en attente de synchronisation.",
"epic_managed_error_generic": "L’opération Epic a échoué. Vérifiez la connexion et réessayez.",
"epic_external_launcher_required": "Ce jeu nécessite un autre lanceur pour être installé. Installez-le via le lanceur officiel.",
"epic_compatibility_runtime_unavailable": "L’environnement de compatibilité est indisponible. Vérifiez les paramètres de compatibilité du jeu et réessayez.",
"epic_compatibility_runtime_download_failed": "Impossible de télécharger l’environnement de compatibilité. Connectez-vous à Internet et réessayez. Les fichiers du jeu ont été conservés.",
"epic_compatibility_prefix_invalid": "Impossible de préparer le préfixe Wine. Vérifiez les paramètres de compatibilité du jeu et réessayez.",
"epic_compatibility_prefix_busy": "Un autre jeu utilise ce préfixe Wine. Fermez-le et réessayez.",
"epic_compatibility_launch_failed": "L’environnement de compatibilité n’a pas pu lancer le jeu. Vérifiez les paramètres de compatibilité du jeu et réessayez.",
"epic_install_platform_unsupported": "Les téléchargements Epic Games ne sont pas disponibles sur cette plateforme ou architecture.",
"epic_compatibility_automatic": "Automatique",
"epic_play_offline": "Jouer — Hors ligne",
"epic_prerequisites_required": "Autorisez les prérequis du jeu et réessayez.",
"epic_update_required": "Mettez le jeu à jour via l’option Epic Games en utilisant le même dossier.",

View file

@ -589,6 +589,13 @@
"epic_play_local_saves_description": "Indítsd el ezt a munkamenetet a felhőmentések visszaállítása nélkül. A helyi változások szinkronizálásra várnak.",
"epic_managed_error_generic": "Az Epic művelet sikertelen. Ellenőrizd a kapcsolatot, és próbáld újra.",
"epic_external_launcher_required": "A játék telepítéséhez egy másik indítóprogramra van szükség. Telepítsd a hivatalos indítóprogramon keresztül.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Játék — Offline",
"epic_prerequisites_required": "Engedélyezd a játék előfeltételeit, és próbáld újra.",
"epic_update_required": "Frissítsd a játékot az Epic Games lehetőséggel ugyanabba a mappába.",

View file

@ -739,6 +739,13 @@
"epic_play_local_saves_description": "Buka sesi ini tanpa memulihkan penyimpanan awan. Perubahan lokal akan menunggu sinkronisasi.",
"epic_managed_error_generic": "Operasi Epic gagal. Periksa koneksi dan coba lagi.",
"epic_external_launcher_required": "Gim ini memerlukan launcher lain untuk diinstal. Instal gim melalui launcher resmi.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Main — Offline",
"epic_prerequisites_required": "Izinkan prasyarat gim dan coba lagi.",
"epic_update_required": "Perbarui gim melalui opsi Epic Games menggunakan folder yang sama.",

View file

@ -768,6 +768,13 @@
"epic_play_local_saves_description": "Apri questa sessione senza ripristinare i salvataggi cloud. Le modifiche locali restano in attesa di sincronizzazione.",
"epic_managed_error_generic": "L’operazione Epic non è riuscita. Controlla la connessione e riprova.",
"epic_external_launcher_required": "Questo gioco richiede un altro launcher per essere installato. Installalo tramite il launcher ufficiale.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Gioca — Offline",
"epic_prerequisites_required": "Autorizza i prerequisiti del gioco e riprova.",
"epic_update_required": "Aggiorna il gioco dall’opzione Epic Games usando la stessa cartella.",

View file

@ -589,6 +589,13 @@
"epic_play_local_saves_description": "クラウドセーブを復元せずに今回のプレイを開始します。ローカルの変更は同期待ちになります。",
"epic_managed_error_generic": "Epic の操作に失敗しました。接続を確認して再試行してください。",
"epic_external_launcher_required": "このゲームをインストールするには別のランチャーが必要です。公式ランチャーからインストールしてください。",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "プレイ — Offline",
"epic_prerequisites_required": "ゲームの前提コンポーネントを許可して再試行してください。",
"epic_update_required": "Epic Games のオプションから同じフォルダーにゲームを更新してください。",

View file

@ -566,6 +566,13 @@
"epic_play_local_saves_description": "Бұл сеансты бұлттағы сақтауларды қалпына келтірмей ашыңыз. Жергілікті өзгерістер синхрондауды күтеді.",
"epic_managed_error_generic": "Epic әрекеті орындалмады. Қосылымды тексеріп, қайталап көріңіз.",
"epic_external_launcher_required": "Бұл ойынды орнату үшін басқа іске қосу бағдарламасы қажет. Ойынды ресми іске қосу бағдарламасы арқылы орнатыңыз.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Ойнау — Offline",
"epic_prerequisites_required": "Ойынға қажетті құрамдастарға рұқсат беріп, қайталап көріңіз.",
"epic_update_required": "Ойынды Epic Games опциясы арқылы сол қалтаға жаңартыңыз.",

View file

@ -563,6 +563,13 @@
"epic_play_local_saves_description": "이번 세션은 클라우드 저장을 복원하지 않고 시작합니다. 로컬 변경 사항은 동기화 대기 상태로 남습니다.",
"epic_managed_error_generic": "Epic 작업에 실패했습니다. 연결을 확인하고 다시 시도하세요.",
"epic_external_launcher_required": "이 게임을 설치하려면 다른 런처가 필요합니다. 공식 런처를 통해 설치하세요.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "실행 — Offline",
"epic_prerequisites_required": "게임 필수 구성 요소를 허용하고 다시 시도하세요.",
"epic_update_required": "Epic Games 옵션에서 같은 폴더를 사용해 게임을 업데이트하세요.",

View file

@ -567,6 +567,13 @@
"epic_play_local_saves_description": "Atveriet šo sesiju, neatjaunojot mākoņa saglabājumus. Vietējās izmaiņas gaidīs sinhronizēšanu.",
"epic_managed_error_generic": "Epic darbība neizdevās. Pārbaudiet savienojumu un mēģiniet vēlreiz.",
"epic_external_launcher_required": "Šīs spēles instalēšanai nepieciešams cits palaidējs. Instalējiet spēli, izmantojot oficiālo palaidēju.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Spēlēt — Offline",
"epic_prerequisites_required": "Atļaujiet spēles nepieciešamos komponentus un mēģiniet vēlreiz.",
"epic_update_required": "Atjauniniet spēli ar Epic Games opciju tajā pašā mapē.",

View file

@ -567,6 +567,13 @@
"epic_play_local_saves_description": "Åpne denne økten uten å gjenopprette skylagringer. Lokale endringer venter på synkronisering.",
"epic_managed_error_generic": "Epic-handlingen mislyktes. Kontroller tilkoblingen og prøv igjen.",
"epic_external_launcher_required": "Dette spillet krever en annen spillstarter for å kunne installeres. Installer det via den offisielle spillstarteren.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Spil — Offline",
"epic_prerequisites_required": "Godkjenn spillets forutsetninger og prøv igjen.",
"epic_update_required": "Oppdater spillet via Epic Games-alternativet i den samme mappen.",

View file

@ -564,6 +564,13 @@
"epic_play_local_saves_description": "Open deze sessie zonder cloudgegevens te herstellen. Lokale wijzigingen wachten op synchronisatie.",
"epic_managed_error_generic": "De Epic-bewerking is mislukt. Controleer je verbinding en probeer opnieuw.",
"epic_external_launcher_required": "Om dit spel te installeren, heb je een andere launcher nodig. Installeer het via de officiële launcher.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Speel — Offline",
"epic_prerequisites_required": "Sta de vereisten van het spel toe en probeer opnieuw.",
"epic_update_required": "Werk het spel bij via de Epic Games-optie met dezelfde map.",

View file

@ -777,6 +777,13 @@
"epic_play_local_saves_description": "Rozpocznij tę sesję bez przywracania zapisów z chmury. Lokalne zmiany będą oczekiwać na synchronizację.",
"epic_managed_error_generic": "Operacja Epic nie powiodła się. Sprawdź połączenie i spróbuj ponownie.",
"epic_external_launcher_required": "Do zainstalowania tej gry potrzebny jest inny launcher. Zainstaluj ją za pomocą oficjalnego launchera.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Graj — Offline",
"epic_prerequisites_required": "Zezwól na instalację składników wymaganych przez grę i spróbuj ponownie.",
"epic_update_required": "Zaktualizuj grę przez opcję Epic Games, używając tego samego folderu.",

View file

@ -1037,6 +1037,13 @@
"epic_play_local_saves_description": "Abra esta sessão sem restaurar saves da nuvem. As alterações locais ficam pendentes de sincronização.",
"epic_managed_error_generic": "A operação Epic falhou. Verifique a conexão e tente novamente.",
"epic_external_launcher_required": "Este jogo depende de outro launcher para ser instalado. Instale-o pelo launcher oficial.",
"epic_compatibility_runtime_unavailable": "O ambiente de compatibilidade está indisponível. Verifique a seção Compatibilidade deste jogo e tente novamente.",
"epic_compatibility_runtime_download_failed": "Não foi possível baixar o ambiente de compatibilidade. Conecte-se à internet e tente novamente. Os arquivos do jogo foram preservados.",
"epic_compatibility_prefix_invalid": "Não foi possível preparar o prefixo Wine. Verifique a seção Compatibilidade deste jogo e tente novamente.",
"epic_compatibility_prefix_busy": "Este prefixo Wine está sendo usado por outro jogo. Feche o jogo e tente novamente.",
"epic_compatibility_launch_failed": "O ambiente de compatibilidade não conseguiu iniciar o jogo. Verifique a seção Compatibilidade deste jogo e tente novamente.",
"epic_install_platform_unsupported": "Os downloads da Epic Games não estão disponíveis nesta plataforma ou arquitetura.",
"epic_compatibility_automatic": "Automático",
"epic_play_offline": "Jogar — Offline",
"epic_prerequisites_required": "Autorize os pré-requisitos do jogo e tente novamente.",
"epic_update_required": "Atualize o jogo pela opção Epic Games, usando a mesma pasta.",

View file

@ -618,6 +618,13 @@
"epic_play_local_saves_description": "Abra esta sessão sem restaurar os ficheiros guardados da nuvem. As alterações locais ficam pendentes de sincronização.",
"epic_managed_error_generic": "A operação Epic falhou. Verifique a ligação e tente novamente.",
"epic_external_launcher_required": "Este jogo depende de outro launcher para ser instalado. Instale-o pelo launcher oficial.",
"epic_compatibility_runtime_unavailable": "O ambiente de compatibilidade está indisponível. Verifique a secção Compatibilidade deste jogo e tente novamente.",
"epic_compatibility_runtime_download_failed": "Não foi possível transferir o ambiente de compatibilidade. Ligue-se à internet e tente novamente. Os ficheiros do jogo foram preservados.",
"epic_compatibility_prefix_invalid": "Não foi possível preparar o prefixo Wine. Verifique a secção Compatibilidade deste jogo e tente novamente.",
"epic_compatibility_prefix_busy": "Este prefixo Wine está a ser usado por outro jogo. Feche o jogo e tente novamente.",
"epic_compatibility_launch_failed": "O ambiente de compatibilidade não conseguiu iniciar o jogo. Verifique a secção Compatibilidade deste jogo e tente novamente.",
"epic_install_platform_unsupported": "Os downloads da Epic Games não estão disponíveis nesta plataforma ou arquitetura.",
"epic_compatibility_automatic": "Automático",
"epic_play_offline": "Jogar — Offline",
"epic_prerequisites_required": "Autorize os pré-requisitos do jogo e tente novamente.",
"epic_update_required": "Atualize o jogo pela opção Epic Games, utilizando a mesma pasta.",

View file

@ -563,6 +563,13 @@
"epic_play_local_saves_description": "Deschide această sesiune fără restaurarea salvărilor din cloud. Modificările locale rămân în așteptarea sincronizării.",
"epic_managed_error_generic": "Operațiunea Epic a eșuat. Verifică conexiunea și încearcă din nou.",
"epic_external_launcher_required": "Acest joc necesită un alt launcher pentru instalare. Instalează-l prin launcherul oficial.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Joacă — Offline",
"epic_prerequisites_required": "Autorizează componentele necesare jocului și încearcă din nou.",
"epic_update_required": "Actualizează jocul prin opțiunea Epic Games folosind același folder.",

View file

@ -1031,6 +1031,13 @@
"epic_play_local_saves_description": "Начните этот сеанс без восстановления сохранений из облака. Локальные изменения будут ожидать синхронизации.",
"epic_managed_error_generic": "Операция Epic не удалась. Проверьте соединение и повторите попытку.",
"epic_external_launcher_required": "Для установки этой игры требуется другой лаунчер. Установите её через официальный лаунчер.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Играть — Не в сети",
"epic_prerequisites_required": "Разрешите установку необходимых компонентов игры и повторите попытку.",
"epic_update_required": "Обновите игру через вариант Epic Games, используя ту же папку.",

View file

@ -569,6 +569,13 @@
"epic_play_local_saves_description": "Odprite to sejo brez obnovitve shranjenih iger iz oblaka. Lokalne spremembe čakajo na sinhronizacijo.",
"epic_managed_error_generic": "Operacija Epic ni uspela. Preverite povezavo in poskusite znova.",
"epic_external_launcher_required": "Za namestitev te igre je potreben drug zaganjalnik. Namestite jo prek uradnega zaganjalnika.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Igraj — Offline",
"epic_prerequisites_required": "Dovolite zahtevane komponente igre in poskusite znova.",
"epic_update_required": "Posodobite igro prek možnosti Epic Games v isti mapi.",

View file

@ -567,6 +567,13 @@
"epic_play_local_saves_description": "Öppna denna session utan att återställa molnsparfiler. Lokala ändringar väntar på synkronisering.",
"epic_managed_error_generic": "Epic-åtgärden misslyckades. Kontrollera anslutningen och försök igen.",
"epic_external_launcher_required": "Det här spelet kräver en annan spelklient för installation. Installera det via den officiella spelklienten.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Spela — Offline",
"epic_prerequisites_required": "Godkänn spelets förutsättningar och försök igen.",
"epic_update_required": "Uppdatera spelet via Epic Games-alternativet i samma mapp.",

View file

@ -567,6 +567,13 @@
"epic_play_local_saves_description": "Bu oturumu bulut kayıtlarını geri yüklemeden açın. Yerel değişiklikler eşitleme için bekleyecek.",
"epic_managed_error_generic": "Epic işlemi başarısız oldu. Bağlantınızı kontrol edip tekrar deneyin.",
"epic_external_launcher_required": "Bu oyunu yüklemek için başka bir başlatıcı gerekiyor. Oyunu resmî başlatıcı üzerinden yükleyin.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Oyna — Offline",
"epic_prerequisites_required": "Oyunun ön gereksinimlerine izin verin ve tekrar deneyin.",
"epic_update_required": "Oyunu aynı klasörü kullanarak Epic Games seçeneğinden güncelleyin.",

View file

@ -567,6 +567,13 @@
"epic_play_local_saves_description": "Почніть цей сеанс без відновлення збережень із хмари. Локальні зміни очікуватимуть синхронізації.",
"epic_managed_error_generic": "Операція Epic не вдалася. Перевірте з’єднання та спробуйте ще раз.",
"epic_external_launcher_required": "Для встановлення цієї гри потрібен інший лаунчер. Встановіть її через офіційний лаунчер.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Грати — Offline",
"epic_prerequisites_required": "Дозвольте встановлення необхідних компонентів гри та спробуйте ще раз.",
"epic_update_required": "Оновіть гру через варіант Epic Games, використовуючи ту саму папку.",

View file

@ -567,6 +567,13 @@
"epic_play_local_saves_description": "Bu seansni bulut saqlanmalarini tiklamasdan oching. Mahalliy o‘zgarishlar sinxronlashni kutadi.",
"epic_managed_error_generic": "Epic amali bajarilmadi. Ulanishni tekshirib, qayta urinib ko‘ring.",
"epic_external_launcher_required": "Bu o‘yinni o‘rnatish uchun boshqa ishga tushirish dasturi kerak. Uni rasmiy ishga tushirish dasturi orqali o‘rnating.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "O'ynash — Offline",
"epic_prerequisites_required": "O‘yin uchun zarur komponentlarga ruxsat bering va qayta urinib ko‘ring.",
"epic_update_required": "O‘yinni Epic Games opsiyasi orqali o‘sha papkaga yangilang.",

View file

@ -777,6 +777,13 @@
"epic_play_local_saves_description": "Mở phiên chơi này mà không khôi phục bản lưu đám mây. Thay đổi cục bộ sẽ chờ đồng bộ hóa.",
"epic_managed_error_generic": "Thao tác Epic thất bại. Kiểm tra kết nối và thử lại.",
"epic_external_launcher_required": "Trò chơi này cần một trình khởi chạy khác để cài đặt. Hãy cài đặt trò chơi qua trình khởi chạy chính thức.",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Chơi — Ngoại tuyến",
"epic_prerequisites_required": "Cho phép cài đặt các thành phần cần thiết của trò chơi và thử lại.",
"epic_update_required": "Cập nhật trò chơi qua tùy chọn Epic Games vào cùng thư mục.",

View file

@ -567,6 +567,13 @@
"epic_play_local_saves_description": "此次游玩不恢复云端存档。本地更改将等待同步。",
"epic_managed_error_generic": "Epic 操作失败。请检查连接并重试。",
"epic_external_launcher_required": "安装此游戏需要其他启动器。请通过官方启动器安装。",
"epic_compatibility_runtime_unavailable": "The compatibility runtime is unavailable. Check this game's Compatibility settings and try again.",
"epic_compatibility_runtime_download_failed": "The compatibility runtime could not be downloaded. Connect to the internet and try again. Your game files have been preserved.",
"epic_compatibility_prefix_invalid": "The Wine prefix could not be prepared. Check this game's Compatibility settings and try again.",
"epic_compatibility_prefix_busy": "This Wine prefix is in use by another game. Close it and try again.",
"epic_compatibility_launch_failed": "The compatibility runtime could not start the game. Check this game's Compatibility settings and try again.",
"epic_install_platform_unsupported": "Epic Games downloads are not available for this platform or architecture.",
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "开始游戏 — Offline",
"epic_prerequisites_required": "请授权安装游戏所需组件并重试。",
"epic_update_required": "请通过 Epic Games 选项将游戏更新到同一文件夹。",

View file

@ -1,5 +1,6 @@
import {
getEpicConnection,
getGameLinuxCompatibility,
startEpicAuth,
cancelEpicAuth,
disconnectEpic,
@ -106,6 +107,14 @@ registerEvent("cancelEpicGameInstallOptions", (event, requestId: unknown) => {
const key = parseEpicInstallOptionsRequestId(requestId);
installOptionsRequests.get(event.sender)?.controllers.get(key)?.abort();
});
registerEvent(
"getGameLinuxCompatibility",
(event, shop: unknown, objectId: unknown) => {
assertTrustedSender(event, true);
if (shop !== "epic") return null;
return getGameLinuxCompatibility(shop, parseEpicManagedObjectId(objectId));
}
);
registerEvent("startEpicGameDownload", (event, payload: unknown) => {
assertTrustedSender(event, true);
return startEpicGameDownload(parseEpicDownloadPayload(payload));

View file

@ -80,6 +80,7 @@ describe("managed Epic IPC contracts", () => {
const conflict = {
epicObjectId: "namespace:item",
expectedRemoteManifest: "2026.10.14-12.00.00.manifest",
expectedRemoteModifiedAt: "2026-10-15T12:00:00.000Z",
choice: "remote",
};
assert.deepEqual(parseEpicCloudSaveConflict(conflict), conflict);
@ -97,6 +98,15 @@ describe("managed Epic IPC contracts", () => {
assert.throws(() =>
parseEpicCloudSaveConflict({ ...conflict, choice: "both" })
);
for (const expectedRemoteModifiedAt of [
undefined,
"invalid",
"2026-02-31T12:00:00Z",
1,
])
assert.throws(() =>
parseEpicCloudSaveConflict({ ...conflict, expectedRemoteModifiedAt })
);
assert.throws(() =>
parseEpicCloudSaveConflict({ ...conflict, force: true })
);

View file

@ -12,6 +12,7 @@ import {
} from "../../services/epic/auth-protocol.js";
import { isEpicObjectId } from "../../services/epic/library-normalizer.js";
import { isSafeEpicArtifact } from "../../services/epic/installation.js";
import { isEpicRemoteSaveTimestamp } from "../../services/epic/cloud-save-protocol.js";
const MAX_EPIC_LOCAL_PATH_LENGTH = 32_768;
const EPIC_SAVE_MANIFEST_PATTERN =
@ -114,12 +115,15 @@ export function parseEpicCloudSaveConflict(
!hasOnlyFields(value, [
"epicObjectId",
"expectedRemoteManifest",
"expectedRemoteModifiedAt",
"choice",
]) ||
!isEpicObjectId(value.epicObjectId) ||
(value.expectedRemoteManifest !== null &&
(typeof value.expectedRemoteManifest !== "string" ||
!EPIC_SAVE_MANIFEST_PATTERN.test(value.expectedRemoteManifest))) ||
(value.expectedRemoteModifiedAt !== null &&
!isEpicRemoteSaveTimestamp(value.expectedRemoteModifiedAt)) ||
(value.choice !== "local" && value.choice !== "remote")
) {
return invalid();
@ -127,6 +131,7 @@ export function parseEpicCloudSaveConflict(
return {
epicObjectId: value.epicObjectId,
expectedRemoteManifest: value.expectedRemoteManifest,
expectedRemoteModifiedAt: value.expectedRemoteModifiedAt,
choice: value.choice,
};
}

View file

@ -16,6 +16,7 @@ function fixture(overrides: Record<string, unknown> = {}) {
const services = Object.fromEntries(
[
"getEpicConnection",
"getGameLinuxCompatibility",
"startEpicAuth",
"cancelEpicAuth",
"disconnectEpic",
@ -81,6 +82,7 @@ test("managed operations reject an untrusted sender before any process or save a
for (const name of [
"getEpicGameInstallOptions",
"cancelEpicGameInstallOptions",
"getGameLinuxCompatibility",
"startEpicGameDownload",
"getEpicCloudSaveState",
"setEpicCloudSaveSettings",
@ -180,6 +182,7 @@ test("desktop and Big Picture pass only parsed Epic inputs to managed services",
f.handlers.get("resolveEpicCloudSaveConflict")!(event, {
epicObjectId: payload.epicObjectId,
expectedRemoteManifest: null,
expectedRemoteModifiedAt: null,
choice: "local",
});
assert.equal(f.calls.length, 7);
@ -197,4 +200,21 @@ test("desktop and Big Picture pass only parsed Epic inputs to managed services",
f.handlers.get("syncEpicCloudSaves")!(event, "../steam-game")
);
assert.equal(f.calls.length, 7);
f.handlers.get("getGameLinuxCompatibility")!(
event,
"epic",
payload.epicObjectId
);
assert.deepEqual(f.calls[7], {
name: "getGameLinuxCompatibility",
args: ["epic", payload.epicObjectId],
});
assert.throws(() =>
f.handlers.get("getGameLinuxCompatibility")!(event, "epic", "../escape")
);
assert.equal(
f.handlers.get("getGameLinuxCompatibility")!(event, "steam", "123"),
null
);
assert.equal(f.calls.length, 8);
});

View file

@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import fs from "node:fs";
import { test } from "node:test";
import ts from "typescript";
function fixture(managed: boolean, open: (id: string) => Promise<boolean>) {
let handler!: (...args: unknown[]) => Promise<boolean>;
let legacyLookups = 0;
const spawns: { binary: string; options: { env: Record<string, string> } }[] =
[];
const modules: Record<string, unknown> = {
"../register-event": {
registerEvent: (_name: string, callback: typeof handler) => {
handler = callback;
},
},
"@main/level": {
levelKeys: { game: (shop: string, id: string) => `${shop}:${id}` },
gamesSublevel: {
get: async () => ({
winePrefixPath: "/custom/prefix",
...(managed ? { epicInstallation: {} } : {}),
}),
},
},
"@main/services": {
logger: { error: () => {} },
Wine: {
getEffectivePrefixPath: () => {
legacyLookups++;
return "/custom/prefix";
},
},
},
"@main/services/epic": { openManagedEpicWinetricks: open },
"node:child_process": {
spawn: (binary: string, options: { env: Record<string, string> }) => {
spawns.push({ binary, options });
const child = Object.assign(new EventEmitter(), { unref: () => {} });
queueMicrotask(() => child.emit("spawn"));
return child;
},
},
};
const { outputText } = ts.transpileModule(
fs.readFileSync(
new URL("./open-game-winetricks.ts", import.meta.url),
"utf8"
),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ESNext,
},
}
);
new Function("exports", "require", "process", outputText)(
{},
(name: string) => modules[name] ?? assert.fail(name),
{ platform: "linux", env: { DISPLAY: ":0" } }
);
return {
invoke: (shop: string) => handler({}, shop, "real:epic"),
spawns,
legacyLookups: () => legacyLookups,
};
}
test("managed Epic Winetricks uses the guarded service with the real identity and no system Wine fallback", async () => {
const calls: string[] = [];
const f = fixture(true, async (id) => {
calls.push(id);
return true;
});
assert.equal(await f.invoke("epic"), true);
assert.deepEqual(calls, ["real:epic"]);
assert.equal(f.legacyLookups(), 0);
assert.equal(f.spawns.length, 0);
});
test("managed runtime or prefix failure stays recoverable without launching system Winetricks", async () => {
const f = fixture(true, async () => {
throw new Error("compatibility-prefix-busy");
});
assert.equal(await f.invoke("epic"), false);
assert.equal(f.legacyLookups(), 0);
assert.equal(f.spawns.length, 0);
});
test("existing unmanaged games retain their Winetricks launcher and effective prefix", async () => {
const f = fixture(false, async () =>
assert.fail("managed service must not run")
);
assert.equal(await f.invoke("steam"), true);
assert.equal(f.legacyLookups(), 1);
assert.equal(f.spawns[0].binary, "winetricks");
assert.equal(f.spawns[0].options.env.WINEPREFIX, "/custom/prefix");
});

View file

@ -18,12 +18,21 @@ const openGameWinetricks = async (
if (!game) return false;
const winePrefixPath = Wine.getEffectivePrefixPath(
if (shop === "epic" && game.epicInstallation) {
try {
return await (
await import("@main/services/epic")
).openManagedEpicWinetricks(objectId);
} catch {
return false;
}
}
const effectivePrefix = Wine.getEffectivePrefixPath(
game.winePrefixPath,
objectId
);
if (!winePrefixPath) {
if (!effectivePrefix) {
return false;
}
@ -35,7 +44,7 @@ const openGameWinetricks = async (
shell: false,
env: {
...process.env,
WINEPREFIX: winePrefixPath,
WINEPREFIX: effectivePrefix,
},
});

View file

@ -51,6 +51,7 @@ import {
prepareManagedEpicLaunch,
cancelManagedEpicLaunch,
recordManagedEpicProcess,
launchPreparedEpicLinuxGame,
} from "@main/services/epic/launch";
import type { EpicLaunchParameters } from "@main/services/epic/launch-protocol";
import { EpicSaveFailure } from "@main/services/epic/cloud-save-protocol";
@ -744,24 +745,35 @@ const launchGameWithCloudSaveChecks = async (
clearGameLauncherStatuses(gameKey);
await WindowManager.createGameLauncherWindow(shop, objectId);
const shouldRunV2AutomaticSync = await canRunAutomaticCloudSaveSync(
objectId,
shop
);
const shouldRunV2AutomaticSync =
!managedEpic && (await canRunAutomaticCloudSaveSync(objectId, shop));
let preparedCompatibility: PreparedLinuxCompatibility;
if (managedEpic) {
preparedCompatibility = {
context: null,
prefixReadyForRestore: false,
prefixSafeForUpload: false,
prefixGenerationOverride: undefined,
};
} else if (steamCompatibilityPrefixPath) {
preparedCompatibility = prepareSteamCompatibilityForCloudSave(
steamCompatibilityPrefixPath
);
} else {
preparedCompatibility = await prepareLinuxCompatibilityForLaunch(
parsedPath,
launchGameRecord,
objectId,
shop,
shouldRunV2AutomaticSync || managedEpic
);
}
const {
context: compatibilityContext,
prefixReadyForRestore,
prefixSafeForUpload,
prefixGenerationOverride,
} = steamCompatibilityPrefixPath
? prepareSteamCompatibilityForCloudSave(steamCompatibilityPrefixPath)
: await prepareLinuxCompatibilityForLaunch(
parsedPath,
launchGameRecord,
objectId,
shop,
shouldRunV2AutomaticSync || managedEpic
);
} = preparedCompatibility;
if (managedEpic && launchGameRecord) {
return getEpicCloudSaveService().runLaunchGate(objectId, async () => {
@ -773,23 +785,39 @@ const launchGameWithCloudSaveChecks = async (
winePrefixPath: compatibilityContext?.winePrefixPath,
epicSavePolicy: options.epicSavePolicy,
epicOffline: options.epicOffline,
onStatus: createUmuStatusReporter(gameKey),
}
);
await runCommonRedistPreflight(shop, objectId);
await markManagedEpicLaunched(prepared.session);
let pid: number;
try {
await markManagedEpicLaunched(prepared.session);
getEpicCloudSaveService().assertSessionCurrent(prepared.session);
pid = await launchAuthenticatedEpicGame(
prepared.parameters,
launchOptions
);
if (prepared.linuxContext) {
const handle = await launchPreparedEpicLinuxGame(
prepared.session,
prepared.parameters,
{
launchOptions,
useGamemode,
useMangohud,
onStatus: createUmuStatusReporter(gameKey),
}
);
pid = handle.pid;
PowerSaveBlockerManager.markCompatibilityLaunchStarted(gameKey);
} else {
pid = await launchAuthenticatedEpicGame(
prepared.parameters,
launchOptions
);
recordManagedEpicProcess(prepared.session, pid);
}
} catch (error) {
cancelManagedEpicLaunch(prepared.session);
await cancelManagedEpicLaunch(prepared.session);
throw error;
}
launchedGamePids.set(gameKey, pid);
recordManagedEpicProcess(prepared.session, pid);
return pid;
} catch (error) {
WindowManager.closeGameLauncherWindow();

View file

@ -72,7 +72,7 @@ describe("cloud save automatic sync mode", () => {
);
});
it("keeps non-Steam games on legacy or disabled", () => {
it("keeps custom games on legacy or disabled", () => {
assert.equal(
resolveStoredCloudSaveAutomaticSyncModeForShop("custom", true, undefined),
"legacy"
@ -87,6 +87,27 @@ describe("cloud save automatic sync mode", () => {
);
});
it("uses Epic V2 by default, honors opt-outs and ignores stale legacy flags", () => {
for (const legacy of [false, true]) {
assert.equal(
resolveStoredCloudSaveAutomaticSyncModeForShop(
"epic",
legacy,
undefined
),
"v2"
);
assert.equal(
resolveStoredCloudSaveAutomaticSyncModeForShop("epic", legacy, true),
"v2"
);
assert.equal(
resolveStoredCloudSaveAutomaticSyncModeForShop("epic", legacy, false),
"disabled"
);
}
});
it("defaults supported emulator games to V2 and preserves later opt-outs", () => {
for (const platform of [
"Sony PlayStation 3",

View file

@ -38,7 +38,7 @@ export const resolveStoredCloudSaveAutomaticSyncModeForShop = (
storedV2Enabled
);
}
if (getCloudSaveEmulatorProvider(shop, platform)) {
if (shop === "epic" || getCloudSaveEmulatorProvider(shop, platform)) {
return resolveCloudSaveAutomaticSyncMode({
legacyEnabled: false,
v2Enabled: storedV2Enabled ?? true,

View file

@ -79,7 +79,7 @@ export const canRunAutomaticCloudSaveSync = async (
.get(levelKeys.game(shop, objectId))
.catch(() => null);
if (!game || !isCloudSaveV2Eligible(shop, game.platform)) return false;
if (shop === "steam") return Boolean(game.executablePath);
if (shop === "steam" || shop === "epic") return Boolean(game.executablePath);
if (
getCloudSaveEmulatorProvider(shop, game.platform) === "rpcs3" &&
!hasRpcs3CloudSaveDisc(game)

View file

@ -22,6 +22,32 @@ const file = (variantId: string) => ({
});
describe("Cloud Save launcher API contract", () => {
it("accepts Epic restores without accepting Steam account variants", () => {
const manifest = {
snapshot: {
id: "epic-snapshot",
version: 1,
shop: "epic",
objectId: "namespace:game",
},
customPathRawPaths: [],
variants: [{ variantId: firstVariantId, kind: "default" }],
files: [{ ...file(firstVariantId), rawPath: "<winAppData>/Game" }],
};
assert.equal(validateRestoreManifest(manifest).snapshot.shop, "epic");
assert.throws(() =>
validateRestoreManifest({
...manifest,
variants: [
{
variantId: firstVariantId,
kind: "steam-account",
steamId64: "76561197960265729",
},
],
})
);
});
it("downloads Steam files and emulator states with optional metadata", () => {
const steamFile = {
...file(firstVariantId),

View file

@ -304,7 +304,9 @@ export const validateRestoreManifest = (
typeof snapshot.version !== "number" ||
!Number.isSafeInteger(snapshot.version) ||
snapshot.version < 1 ||
(snapshot.shop !== "steam" && snapshot.shop !== "launchbox") ||
(snapshot.shop !== "steam" &&
snapshot.shop !== "epic" &&
snapshot.shop !== "launchbox") ||
!isNonEmptyString(snapshot.objectId)
) {
throw new Error("Invalid restore manifest snapshot");

View file

@ -49,7 +49,10 @@ export const getCloudSaveGameContext = async (
const game = await gamesSublevel
.get(levelKeys.game(shop, objectId))
.catch(() => undefined);
const steamPath = await getSteamLocation().catch(() => undefined);
const steamPath =
shop === "epic"
? undefined
: await getSteamLocation().catch(() => undefined);
const storeUserContext =
shop === "steam" && steamPath
? await getSteamStoreUserContext(steamPath)

View file

@ -197,9 +197,11 @@ export class DownloadOrchestrator {
download.shop,
download.objectId
);
// A failed Epic startup parks its own record as paused; while the
// ticket is current no user pause or cancel has replaced it.
if (
this.acceptedEpicActivations.get(downloadId) !== ticket ||
current?.status !== "active" ||
(current?.status !== "active" && current?.status !== "paused") ||
current.timestamp !== activeDownload.timestamp
)
return;

View file

@ -2172,6 +2172,26 @@ export class DownloadManager {
this.isPreparingDownload = false;
} catch (error) {
if (this.startGeneration === myGeneration) {
// A startup/preflight failure has no progress callback to repair its
// persisted active record. Confirm cleanup before releasing the slot.
await getEpicDownloadService().stop(downloadId);
const current = await downloadsSublevel.get(downloadId);
if (this.startGeneration !== myGeneration) throw error;
if (
current?.downloader === Downloader.Epic &&
current.timestamp === download.timestamp &&
current.epic
) {
await downloadsSublevel.put(downloadId, {
...current,
status: "paused",
queued: false,
pinnedToHero: false,
epic: { ...current.epic, error: epicManagedErrorCode(error) },
});
WindowManager.sendDownloadsUpdated();
}
if (this.startGeneration !== myGeneration) throw error;
this.downloadingGameId = null;
this.isPreparingDownload = false;
this.usingEpicDownloader = false;

View file

@ -557,6 +557,34 @@ test("Epic 100 percent telemetry does not complete or advance the queue before v
assert.equal(f.state.notifications.length, 0);
});
test("failed Epic startup persists a retryable paused record after confirmed cleanup", async () => {
const f = fixture();
const download = {
...epic("Namespace:Startup"),
status: "active" as const,
queued: true,
};
f.add(download);
let rejectStart!: (reason: Error) => void;
const cleanup = deferred<void>();
f.state.startGate = new Promise<void>((_resolve, reject) => {
rejectStart = reject;
});
f.state.stopGate = cleanup.promise;
const started = f.manager.startDownload(download);
const rejected = assert.rejects(started, /session-expired/);
await settle();
rejectStart(new Error("session-expired"));
await settle();
assert.equal(f.manager.getActiveDownloadId(), key(download));
assert.equal(f.downloads.get(key(download))?.status, "active");
cleanup.resolve();
await rejected;
assert.equal(f.manager.getActiveDownloadId(), null);
assert.equal(f.downloads.get(key(download))?.status, "paused");
assert.equal(f.downloads.get(key(download))?.queued, false);
});
test("verified Epic completion publishes once and starts the next common queued download", async () => {
const f = fixture();
const first = epic("Namespace:One"),
@ -653,3 +681,55 @@ test("Epic runtime failure waits for shutdown then marks error and advances the
assert.equal(f.manager.getActiveDownloadId(), key(second));
assert.equal(f.requests.length, 2);
});
test("a busy Linux prefix waits for cleanup and resumes finalization at full progress", async () => {
const f = fixture();
const download = {
...epic("Namespace:BusyPrefix"),
progress: 1,
bytesDownloaded: 1000,
epic: {
...epic("Namespace:BusyPrefix").epic!,
phase: "prerequisites" as const,
},
};
f.add(download);
await f.orchestrator.startPreparedDownload(download);
const stopped = deferred<void>();
f.state.stopGate = stopped.promise;
f.requests[0].failure({ code: "compatibility-prefix-busy" });
await settle();
assert.equal(f.manager.getActiveDownloadId(), key(download));
assert.equal(f.downloads.get(key(download))?.status, "active");
assert.equal(f.state.notifications.length, 0);
stopped.resolve();
await settle();
const failed = f.downloads.get(key(download))!;
assert.equal(f.manager.getActiveDownloadId(), null);
assert.equal(failed.status, "error");
assert.equal(failed.epic?.error, "compatibility-prefix-busy");
assert.equal(failed.progress, download.progress);
assert.equal(failed.bytesDownloaded, download.bytesDownloaded);
assert.equal(failed.folderName, download.folderName);
assert.equal(failed.epic?.artifactId, download.epic.artifactId);
assert.equal(f.state.notifications.length, 0);
assert.equal(
await f.orchestrator.resumeDownload(download.shop, download.objectId),
true
);
assert.equal(f.requests.length, 2);
assert.equal(f.requests[0].current(), false);
assert.equal(f.requests[1].current(), true);
assert.equal(f.requests[1].download.progress, download.progress);
assert.equal(
f.requests[1].download.bytesDownloaded,
download.bytesDownloaded
);
f.state.completed.add(key(download));
await f.manager.watchDownloads();
assert.equal(f.downloads.get(key(download))?.status, "complete");
assert.equal(f.state.notifications.length, 1);
assert.equal(f.manager.getActiveDownloadId(), null);
});

View file

@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test, { type TestContext } from "node:test";
import {
linuxEpicSaveVariables,
resolveLinuxEpicSavePath,
} from "./cloud-save-linux-path.js";
async function fixture(t: TestContext) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "epic-save-path-"));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const prefix = path.join(root, "prefix");
const profile = path.join(prefix, "drive_c", "users", "steamuser");
await fs.mkdir(profile, { recursive: true });
return { root, prefix, profile };
}
test("automatic Windows save references keep real casing and preserve a missing suffix", async (t) => {
const f = await fixture(t);
const local = path.join(f.profile, "AppData", "local");
await fs.mkdir(local, { recursive: true });
const result = await resolveLinuxEpicSavePath(
path.join(f.profile, "appdata", "Local", "New Game", "Saves")
);
assert.equal(result, path.join(local, "New Game", "Saves"));
const variables = await linuxEpicSaveVariables(f.prefix, { path: f.profile });
assert.equal(variables["{appdata}"], local);
});
test("known folder symlinks resolve to the actual Wine user directory without a host fallback", async (t) => {
const f = await fixture(t);
const documents = path.join(f.root, "Documents on another volume");
await fs.mkdir(documents);
await fs.symlink(
documents,
path.join(f.profile, "Documents"),
process.platform === "win32" ? "junction" : "dir"
);
const variables = await linuxEpicSaveVariables(f.prefix, { path: f.profile });
assert.equal(variables["{userdir}"], await fs.realpath(documents));
assert.equal(variables["{userprofile}"], await fs.realpath(f.profile));
});
test(
"registry known folders use prefix drive mappings, expanded values and actual casing",
{ skip: process.platform === "win32" },
async (t) => {
const f = await fixture(t);
await fs.mkdir(path.join(f.prefix, "dosdevices"));
await fs.symlink(
path.join(f.prefix, "drive_c"),
path.join(f.prefix, "dosdevices", "c:")
);
const documents = path.join(f.profile, "Meus Documentos");
const local = path.join(f.profile, "AppData", "LOCAL");
const roaming = path.join(f.profile, "Roaming custom");
await fs.mkdir(documents);
await fs.mkdir(local, { recursive: true });
await fs.mkdir(roaming);
await fs.writeFile(
path.join(f.prefix, "user.reg"),
String.raw`WINE REGISTRY Version 2
[Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders]
"Personal"="%USERPROFILE%\\unused"
"AppData"=str(2):"%USERPROFILE%\\roaming custom"
[Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders]
"Personal"="C:\\users\\steamuser\\meus documentos"
"Local AppData"="C:\\users\\steamuser\\AppData\\local"
`
);
const variables = await linuxEpicSaveVariables(f.prefix, {
path: f.profile,
windowsPath: "C:/users/steamuser",
});
assert.equal(variables["{userdir}"], documents);
assert.equal(variables["{appdata}"], local);
assert.equal(variables["{roaming}"], roaming);
}
);
test(
"ambiguous Windows save references are rejected instead of choosing an unrelated Linux directory",
{ skip: process.platform === "win32" },
async (t) => {
const f = await fixture(t);
await fs.mkdir(path.join(f.profile, "Saves"));
await fs.mkdir(path.join(f.profile, "SAVES"));
await assert.rejects(
resolveLinuxEpicSavePath(path.join(f.profile, "saves")),
/invalid-path/
);
}
);

View file

@ -0,0 +1,97 @@
import fs from "node:fs/promises";
import path from "node:path";
import { parseRegFile } from "../../helpers/reg-parser.js";
import { EpicSaveFailure } from "./cloud-save-protocol.js";
/** Keep existing Linux casing/symlinks; only Windows references get case lookup. */
export async function resolveLinuxEpicSavePath(value: string): Promise<string> {
const absolute = path.resolve(value);
const root = path.parse(absolute).root;
let current = root;
const segments = path
.relative(root, absolute)
.split(path.sep)
.filter(Boolean);
for (let index = 0; index < segments.length; index++) {
const segment = segments[index];
let entries: string[];
try {
entries = await fs.readdir(current);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT")
return path.join(current, ...segments.slice(index));
throw error;
}
const matches = entries.includes(segment)
? [segment]
: entries.filter(
(entry) => entry.toLowerCase() === segment.toLowerCase()
);
if (matches.length > 1) throw new EpicSaveFailure("invalid-path");
if (!matches.length) return path.join(current, ...segments.slice(index));
current = await fs.realpath(path.join(current, matches[0]));
}
return current;
}
export async function linuxEpicSaveVariables(
prefix: string,
profile: { path: string; windowsPath?: string }
): Promise<Record<string, string>> {
const variables: Record<string, string> = {
"{userprofile}": profile.path,
"{userdir}": path.join(profile.path, "Documents"),
"{usersavedgames}": path.join(profile.path, "Saved Games"),
"{appdata}": path.join(profile.path, "AppData", "Local"),
"{roaming}": path.join(profile.path, "AppData", "Roaming"),
"{locallow}": path.join(profile.path, "AppData", "LocalLow"),
};
const knownNames: Record<string, string[]> = {
"{userdir}": ["personal", "{fdd39ad0-238f-46af-adb4-6c85480369c7}"],
"{usersavedgames}": ["{4c5c32ff-bb9d-43b0-bf2b-84d269d740f9}"],
"{appdata}": ["local appdata"],
"{roaming}": ["appdata"],
"{locallow}": ["{a520a1a4-1780-4ff6-bd18-167343c5af16}"],
};
const folders = new Map<string, string>();
try {
const registry = parseRegFile(
await fs.readFile(path.join(prefix, "user.reg"), "utf8")
);
// Expanded Shell Folders wins over its expandable User Shell Folders source.
for (const kind of ["user shell folders", "shell folders"]) {
for (const entry of registry) {
const key = entry.path.replace(/\\+/g, "\\").toLowerCase();
if (!key.endsWith(`\\explorer\\${kind}`)) continue;
for (const [name, value] of Object.entries(entry.values))
if (typeof value === "string") folders.set(name.toLowerCase(), value);
}
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
for (const [variable, names] of Object.entries(knownNames)) {
const stored = names.map((name) => folders.get(name)).find(Boolean);
if (!stored) continue;
const raw = /^str\(2\):"(.*)"$/.exec(stored)?.[1] ?? stored;
const expanded = raw
.replace(/%USERPROFILE%/gi, profile.windowsPath ?? "")
.replaceAll("/", "\\")
.replace(/\\+/g, "\\");
if (expanded.includes("%") || !/^[A-Za-z]:\\/.test(expanded)) continue;
const drive = path.join(
prefix,
"dosdevices",
expanded.slice(0, 2).toLowerCase()
);
const root = await fs.realpath(drive).catch(() => null);
if (!root) continue;
const relative = expanded.slice(3).split("\\");
if (relative.some((segment) => segment === ".."))
throw new EpicSaveFailure("invalid-path");
variables[variable] = path.join(root, ...relative);
}
for (const [name, value] of Object.entries(variables))
variables[name] = await resolveLinuxEpicSavePath(value);
return variables;
}

View file

@ -4,7 +4,7 @@ import test from "node:test";
import {
assertEpicSaveCommandSucceeded,
decideEpicSaveSync,
getEpicRemoteManifestModifiedAt,
isEpicRemoteSaveTimestamp,
isEpicSaveFile,
matchesEpicSaveFilter,
parseEpicRemoteManifest,
@ -12,18 +12,27 @@ import {
resolveEpicSavePath,
} from "./cloud-save-protocol.js";
test("remote save manifest dates are UTC and invalid dates remain unknown", () => {
assert.equal(
getEpicRemoteManifestModifiedAt("2026.10.14-10.00.00.manifest"),
"2026-10-14T10:00:00.000Z"
);
for (const manifest of [
const accountId = "a".repeat(32);
const manifestPath = (
name: string,
artifact = "Artifact",
account = accountId
) => `savesync/${account}/${artifact}/manifests/${name}`;
test("remote upload dates must be valid UTC timestamps, retaining microseconds", () => {
for (const timestamp of [
"2026-10-14T10:00:00Z",
"2026-10-14T10:00:00.000Z",
"2026-10-14T10:00:00.123456Z",
])
assert.equal(isEpicRemoteSaveTimestamp(timestamp), true);
for (const timestamp of [
null,
"invalid",
"2026.02.31-10.00.00.manifest",
"2026.10.14-25.00.00.manifest",
"2026-02-31T10:00:00.000Z",
"2026-10-14T25:00:00.000Z",
])
assert.equal(getEpicRemoteManifestModifiedAt(manifest), null);
assert.equal(isEpicRemoteSaveTimestamp(timestamp), false);
});
test("reads actual Epic save metadata and Legendary inclusion/exclusion semantics", () => {
@ -79,37 +88,97 @@ test("resolves save folders only from known variables and absolute paths", () =>
assert.equal(resolveEpicSavePath("C:/", {}, path.win32), null);
});
test("reads latest exact-artifact manifest and rejects malformed/foreign responses", () => {
test("selects the most recent upload even when its save filename is older", () => {
const older = "2026.10.13-12.00.00.manifest";
const newer = "2026.10.14-12.00.00.manifest";
assert.deepEqual(
parseEpicRemoteManifest(
{
files: {
[manifestPath(older)]: { lastModified: "2026-10-15T12:00:00.000Z" },
[manifestPath(newer)]: { lastModified: "2026-10-14T12:00:00.000Z" },
},
},
"Artifact",
accountId
),
{ name: older, lastModified: "2026-10-15T12:00:00.000Z" }
);
});
test("matches Legendary ordering for equal dates and sub-millisecond uploads", () => {
const names = [
"2026.10.13-12.00.00.manifest",
"2026.10.14-12.00.00.manifest",
];
for (const dates of [
["2026-10-15T12:00:00.123457Z", "2026-10-15T12:00:00.123456Z"],
["2026-10-15T12:00:00.1Z", "2026-10-15T12:00:00.099999Z"],
])
assert.equal(
parseEpicRemoteManifest(
{
files: Object.fromEntries(
names.map((name, index) => [
manifestPath(name),
{ lastModified: dates[index] },
])
),
},
"Artifact",
accountId
)?.name,
names[0]
);
assert.equal(
parseEpicRemoteManifest(
'Save games:\n- Example ("Artifact")\n + 2026.10.14-10.00.00.manifest\n + 2026.10.14-11.00.00.manifest\n',
"Artifact"
),
"2026.10.14-11.00.00.manifest"
{
files: Object.fromEntries(
names.map((name) => [
manifestPath(name),
{ lastModified: "2026-10-15T12:00:00Z" },
])
),
},
"Artifact",
accountId
)?.name,
names[1]
);
assert.equal(parseEpicRemoteManifest("Save games:\n", "Artifact"), null);
assert.throws(() => parseEpicRemoteManifest("", "Artifact"));
assert.throws(() =>
parseEpicRemoteManifest("Save games:\nUnknown response", "Artifact")
);
assert.throws(() =>
parseEpicRemoteManifest("Save games:\nSave games:\n", "Artifact")
);
assert.throws(() =>
parseEpicRemoteManifest('Save games:\n- Example ("Artifact")\n', "Artifact")
);
assert.throws(() =>
parseEpicRemoteManifest(
'Save games:\n- Example ("Other")\n + 2026.10.14-10.00.00.manifest',
"Artifact"
)
);
assert.throws(() =>
parseEpicRemoteManifest(
'Save games:\n- Example ("Artifact")\n + broken',
"Artifact"
)
});
test("rejects malformed, foreign or undated listings rather than assuming no save exists", () => {
const name = "2026.10.14-12.00.00.manifest";
assert.equal(
parseEpicRemoteManifest({ files: {} }, "Artifact", accountId),
null
);
for (const input of [
null,
"Save games:\n",
{},
{ files: [] },
{
files: {
[manifestPath(name, "Other")]: { lastModified: "2026-10-14T12:00:00Z" },
},
},
{
files: {
[manifestPath(name, "Artifact", "other")]: {
lastModified: "2026-10-14T12:00:00Z",
},
},
},
{
files: {
[manifestPath("broken")]: { lastModified: "2026-10-14T12:00:00Z" },
},
},
{ files: { [manifestPath(name)]: {} } },
{ files: { [manifestPath(name)]: { lastModified: "invalid" } } },
])
assert.throws(() => parseEpicRemoteManifest(input, "Artifact", accountId));
});
test("does not treat exit zero, skips or incomplete chunks as successful transfers", () => {
@ -151,37 +220,66 @@ test("does not treat exit zero, skips or incomplete chunks as successful transfe
});
test("anchors detect local/remote changes without trusting dates and preserve pending local saves", () => {
const anchor = { localDigest: "local", remoteManifest: "manifest" };
const remote = { name: "manifest", lastModified: "2026-10-14T12:00:00Z" };
const changed = { ...remote, name: "changed" };
const anchor = {
localDigest: "local",
remoteManifest: remote.name,
remoteModifiedAt: remote.lastModified,
};
assert.equal(
decideEpicSaveSync(undefined, "local", 1, "manifest", false),
decideEpicSaveSync(undefined, "local", 1, remote, false),
"conflict"
);
assert.equal(
decideEpicSaveSync(undefined, "empty", 0, "manifest", false),
decideEpicSaveSync(undefined, "empty", 0, remote, false),
"download"
);
assert.equal(
decideEpicSaveSync(undefined, "local", 1, null, false),
"upload"
);
assert.equal(decideEpicSaveSync(anchor, "local", 1, remote, true), "same");
assert.equal(
decideEpicSaveSync(anchor, "local", 1, "manifest", true),
"same"
);
assert.equal(
decideEpicSaveSync(anchor, "changed", 1, "manifest", false),
decideEpicSaveSync(anchor, "changed", 1, remote, false),
"upload"
);
assert.equal(
decideEpicSaveSync(anchor, "local", 1, "changed", false),
decideEpicSaveSync(anchor, "local", 1, changed, false),
"download"
);
assert.equal(
decideEpicSaveSync(anchor, "changed", 1, "changed", false),
decideEpicSaveSync(anchor, "changed", 1, changed, false),
"conflict"
);
assert.equal(
decideEpicSaveSync(anchor, "local", 1, "changed", true),
decideEpicSaveSync(anchor, "local", 1, changed, true),
"conflict"
);
});
test("detects same-name reuploads and safely replaces anchors without upload metadata", () => {
const remote = {
name: "2026.10.14-12.00.00.manifest",
lastModified: "2026-10-15T12:00:00Z",
};
for (const remoteModifiedAt of [undefined, "2026-10-14T12:00:00Z"]) {
const anchor = {
localDigest: "local",
remoteManifest: remote.name,
remoteModifiedAt,
};
assert.equal(
decideEpicSaveSync(anchor, "local", 1, remote, false),
"download"
);
assert.equal(
decideEpicSaveSync(anchor, "edited", 1, remote, false),
"conflict"
);
assert.equal(
decideEpicSaveSync(anchor, "local", 1, remote, true),
"conflict"
);
}
});

View file

@ -100,51 +100,62 @@ export function resolveEpicSavePath(
}
const manifestPattern = /^\d{4}\.\d{2}\.\d{2}-\d{2}\.\d{2}\.\d{2}\.manifest$/;
export function getEpicRemoteManifestModifiedAt(
manifest: string | null
): string | null {
if (!manifest || !manifestPattern.test(manifest)) return null;
const [year, month, day, hour, minute, second] = manifest
.replace(".manifest", "")
.split(/[.-]/);
// Legendary generates manifest names with datetime.utcnow().
const timestamp = `${year}-${month}-${day}T${hour}:${minute}:${second}.000Z`;
const date = new Date(timestamp);
return Number.isFinite(date.getTime()) && date.toISOString() === timestamp
? timestamp
: null;
export interface EpicRemoteSaveManifest {
name: string;
lastModified: string;
}
export function parseEpicRemoteManifest(
stdout: string,
artifactId: string
): string | null {
const lines = stdout.replace(/\r/g, "").split("\n");
const nonempty = lines.filter((line) => line.trim());
if (nonempty.shift() !== "Save games:")
throw new EpicSaveFailure("save-sync-failed");
let selected = false;
const manifests: string[] = [];
for (const line of nonempty) {
if (line.startsWith("- ")) {
if (selected || !line.endsWith(`("${artifactId}")`))
throw new EpicSaveFailure("save-sync-failed");
selected = true;
} else if (line.startsWith(" +")) {
const manifest = line.slice(2).trim();
if (!selected || !manifestPattern.test(manifest))
throw new EpicSaveFailure("save-sync-failed");
manifests.push(manifest);
} else throw new EpicSaveFailure("save-sync-failed");
}
if (selected && !manifests.length)
throw new EpicSaveFailure("save-sync-failed");
export function isEpicRemoteSaveTimestamp(value: unknown): value is string {
if (
typeof value !== "string" ||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(value)
)
return false;
const date = new Date(value);
return (
manifests.sort((left, right) => left.localeCompare(right, "en")).at(-1) ??
null
Number.isFinite(date.getTime()) &&
date.toISOString().slice(0, 19) === value.slice(0, 19)
);
}
export const sameEpicRemoteSaveManifest = (
left: EpicRemoteSaveManifest | null,
right: EpicRemoteSaveManifest | null
) => left?.name === right?.name && left?.lastModified === right?.lastModified;
export function parseEpicRemoteManifest(
input: unknown,
artifactId: string,
accountId: string
): EpicRemoteSaveManifest | null {
if (!isRecord(input) || !isRecord(input.files))
throw new EpicSaveFailure("save-sync-failed");
const prefix = `savesync/${accountId}/${artifactId}/manifests/`;
let selected: EpicRemoteSaveManifest | null = null;
let latest = "";
for (const [filename, metadata] of Object.entries(input.files)) {
const name = filename.slice(prefix.length);
if (
!filename.startsWith(prefix) ||
!manifestPattern.test(name) ||
!isRecord(metadata) ||
!isEpicRemoteSaveTimestamp(metadata.lastModified)
)
throw new EpicSaveFailure("save-sync-failed");
// Legendary sorts by remote lastModified, retaining response order for ties.
// Preserve Python datetime's microsecond precision rather than truncating to JS milliseconds.
const [seconds, fraction = ""] = metadata.lastModified
.slice(0, -1)
.split(".");
const timestamp = `${seconds}.${fraction.padEnd(6, "0")}Z`;
if (timestamp >= latest) {
latest = timestamp;
selected = { name, lastModified: metadata.lastModified };
}
}
return selected;
}
export function assertEpicSaveCommandSucceeded(
result: { stdout: string; stderr: string },
transfer?: "upload" | "download"
@ -169,9 +180,10 @@ export function decideEpicSaveSync(
anchor: EpicInstallation["saveAnchor"],
localDigest: string,
localFiles: number,
remoteManifest: string | null,
remote: EpicRemoteSaveManifest | null,
pending: boolean
) {
const remoteManifest = remote?.name ?? null;
if (!anchor) {
if (localFiles && remoteManifest) return "conflict" as const;
return remoteManifest
@ -181,7 +193,9 @@ export function decideEpicSaveSync(
: ("same" as const);
}
const localChanged = localDigest !== anchor.localDigest;
const remoteChanged = remoteManifest !== anchor.remoteManifest;
const remoteChanged =
remoteManifest !== anchor.remoteManifest ||
(remote?.lastModified ?? null) !== (anchor.remoteModifiedAt ?? null);
if ((localChanged || pending) && remoteChanged) return "conflict" as const;
if (remoteChanged)
return remoteManifest

View file

@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import test from "node:test";
import { EpicCloudSaveSource } from "./cloud-save-source.js";
import {
EPIC_SAVE_MAX_RESPONSE_BYTES,
EPIC_SAVE_STORAGE_ORIGIN,
} from "./constants.js";
import type { EpicManagedTarget } from "./legendary-managed-runtime";
const accountId = "a".repeat(32);
const target = {
auth: { generation: 1 },
connection: { epicAccountId: accountId },
owner: { epicAccountId: accountId },
artifactId: "Artifact",
} as EpicManagedTarget;
const name = "2026.10.13-12.00.00.manifest";
const lastModified = "2026-10-15T12:00:00.000Z";
const listing = {
files: {
[`savesync/${accountId}/Artifact/manifests/${name}`]: {
lastModified,
readLink: "https://unused.example/signed-url",
},
},
};
test("reads scoped remote upload metadata and refreshes an expired token", async () => {
const refreshes: boolean[] = [];
const source = new EpicCloudSaveSource(
{
token: async (_auth, _connection, _signal, force) => {
refreshes.push(force === true);
return force ? "fresh" : "expired";
},
},
async (url, options) => {
assert.equal(
url,
`${EPIC_SAVE_STORAGE_ORIGIN}/api/v1/access/egstore/savesync/${accountId}/Artifact/manifests/`
);
assert.equal(options?.redirect, "error");
const authorization = new Headers(options?.headers).get("Authorization");
return authorization === "Bearer fresh"
? Response.json(listing)
: new Response("expired", { status: 401 });
}
);
assert.deepEqual(await source.latest(target, new AbortController().signal), {
name,
lastModified,
});
assert.deepEqual(refreshes, [false, true]);
});
test("rejects malformed and oversized listings without treating failures as an empty cloud", async () => {
for (const body of [
"broken JSON",
JSON.stringify({}),
" ".repeat(EPIC_SAVE_MAX_RESPONSE_BYTES + 1),
]) {
const source = new EpicCloudSaveSource(
{ token: async () => "token" },
async () => new Response(body)
);
await assert.rejects(
source.latest(target, new AbortController().signal),
/save-sync-failed/
);
}
});
test("cancellation cannot publish a listing returned after the operation was invalidated", async () => {
const controller = new AbortController();
const source = new EpicCloudSaveSource(
{ token: async () => "token" },
async () => {
controller.abort();
return Response.json(listing);
}
);
await assert.rejects(source.latest(target, controller.signal));
});

View file

@ -0,0 +1,82 @@
import type { EpicManagedTarget } from "./legendary-managed-runtime";
import type { EpicSessionProvider } from "./session-provider";
import {
EpicSaveFailure,
parseEpicRemoteManifest,
} from "./cloud-save-protocol.js";
import { fetchEpicWithRetry } from "./source-retry.js";
import {
EPIC_SAVE_STORAGE_ORIGIN,
EPIC_SAVE_MAX_RESPONSE_BYTES,
EPIC_SOURCE_TIMEOUT_MS,
} from "./constants.js";
export class EpicCloudSaveSource {
constructor(
private readonly session: Pick<EpicSessionProvider, "token">,
private readonly fetchImpl = fetch
) {}
async latest(target: EpicManagedTarget, parent: AbortSignal) {
const signal = AbortSignal.any([
parent,
AbortSignal.timeout(EPIC_SOURCE_TIMEOUT_MS),
]);
let token = await this.session.token(
target.auth,
target.connection,
signal
);
// list-saves only prints names, discarding the upload dates used by sync-saves.
const url = `${EPIC_SAVE_STORAGE_ORIGIN}/api/v1/access/egstore/savesync/${encodeURIComponent(target.owner.epicAccountId)}/${encodeURIComponent(target.artifactId)}/manifests/`;
const response = await fetchEpicWithRetry(
() =>
this.fetchImpl(url, {
signal,
redirect: "error",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"User-Agent": "EpicGamesLauncher/15.0.0",
},
}),
signal,
async () => {
token = await this.session.token(
target.auth,
target.connection,
signal,
true
);
}
);
if (!response.body) throw new EpicSaveFailure("save-sync-failed");
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let bytes = 0;
try {
for (;;) {
const next = await reader.read();
if (next.done) break;
bytes += next.value.byteLength;
if (bytes > EPIC_SAVE_MAX_RESPONSE_BYTES)
throw new EpicSaveFailure("save-sync-failed");
chunks.push(Buffer.from(next.value));
}
signal.throwIfAborted();
let input: unknown;
try {
input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw new EpicSaveFailure("save-sync-failed");
}
return parseEpicRemoteManifest(
input,
target.artifactId,
target.owner.epicAccountId
);
} finally {
await reader.cancel().catch(() => {});
}
}
}

View file

@ -9,11 +9,18 @@ import { setTimeout as delay } from "node:timers/promises";
import test, { type TestContext } from "node:test";
import ts from "typescript";
import * as protocol from "./cloud-save-protocol.js";
import { EpicCloudSaveSource } from "./cloud-save-source.js";
import * as files from "./cloud-save-files.js";
import { isRecord } from "./auth-protocol.js";
import * as linuxPaths from "./cloud-save-linux-path.js";
import { resolveWinePrefixPath } from "../wine-prefix.js";
import { queueEpicOperation } from "./operation-queue.js";
import { deferred } from "./store-test-helpers.js";
import type { Game } from "@types";
async function fixture(t: TestContext) {
const TEST_SAVE_SYNC_TIMEOUT_MS = 10_000;
async function fixture(t: TestContext, options: { linux?: boolean } = {}) {
const root = await fsp.mkdtemp(path.join(os.tmpdir(), "epic-save-service-"));
t.after(() => fsp.rm(root, { recursive: true, force: true }));
const local = path.join(root, "local"),
@ -24,6 +31,9 @@ async function fixture(t: TestContext) {
fsp.mkdir(remote),
fsp.mkdir(workspace),
]);
const prefix = path.join(root, "prefix");
const profile = path.join(prefix, "drive_c", "users", "steamuser");
await fsp.mkdir(path.join(profile, "AppData", "Local"), { recursive: true });
const owner = {
environment: "https://test.example",
userId: "user",
@ -53,16 +63,39 @@ async function fixture(t: TestContext) {
} as Game;
const state = {
manifest: null as string | null,
lastModified: "2026-10-14T10:00:00.000Z",
otherManifests: [] as protocol.EpicRemoteSaveManifest[],
current: true,
running: false,
partialUpload: false,
offline: false,
beforeUpload: null as (() => Promise<void>) | null,
beforeDownload: null as (() => Promise<void>) | null,
uploads: 0,
downloads: 0,
commands: [] as string[][],
events: [] as unknown[],
folder: local as string | null,
prefixPath: prefix,
prefixReady: true,
prefixGeneration: "generation-a" as string | null,
prefixResolutions: 0,
prefixBusy: false,
leaseId: "own-launch-lease",
mutationLeases: [] as Array<string | undefined>,
workspaceTail: undefined as Promise<unknown> | undefined,
workspaceSignals: [] as Array<AbortSignal | undefined>,
workspaceExecutions: 0,
onWorkspaceQueued: null as (() => void) | null,
beforeMetadata: null as (() => Promise<void>) | null,
captureSignals: [] as AbortSignal[],
};
const installed = {
const installed: {
app_name: string;
save_path: string | null;
installPath: string;
executablePath: string;
} = {
app_name: "Artifact",
save_path: local,
installPath: root,
@ -81,8 +114,22 @@ async function fixture(t: TestContext) {
},
serializeWorkspace: async (
_target: unknown,
task: (directory: string) => Promise<unknown>
) => task(workspace),
task: (directory: string) => Promise<unknown>,
signal?: AbortSignal
) => {
const { queued, result } = queueEpicOperation(
state.workspaceTail,
() => {
state.workspaceExecutions++;
return task(workspace);
},
signal
);
state.workspaceTail = queued;
state.workspaceSignals.push(signal);
state.onWorkspaceQueued?.();
return result;
},
runRawUnlocked: async (
_target: unknown,
args: string[],
@ -90,11 +137,6 @@ async function fixture(t: TestContext) {
) => {
options.signal.throwIfAborted();
state.commands.push(args);
if (args[0] === "list-saves")
return {
stdout: `Save games:\n${state.manifest ? `- Example ("Artifact")\n + ${state.manifest}\n` : ""}`,
stderr: "",
};
const savePath = args[args.indexOf("--save-path") + 1];
assert.equal(args.includes("--upload-only"), false);
assert.equal(args.includes("--download-only"), false);
@ -127,12 +169,14 @@ async function fixture(t: TestContext) {
"corrupt partial PUT"
);
state.manifest = `2026.10.14-10.00.${String(state.uploads).padStart(2, "0")}.manifest`;
state.lastModified = `2026-10-15T10:00:${String(state.uploads).padStart(2, "0")}.000Z`;
return {
stdout: "",
stderr: "[Core] INFO: Finished uploading savegame.",
};
}
state.downloads++;
await state.beforeDownload?.();
const snapshot = await files.inspectEpicSaveFiles(
remote,
snapshotMetadata
@ -166,7 +210,36 @@ async function fixture(t: TestContext) {
},
},
"../game-running-state": { isGameRunning: () => state.running },
"../wine": { Wine: { getPrefixUserProfile: () => null } },
"../wine": {
Wine: {
getPrefixUserProfile: () =>
state.prefixReady
? { path: profile, windowsPath: "C:/users/steamuser" }
: null,
isPrefixReadyForRestore: () => state.prefixReady,
resolvePrefixPath: (value: string | null) =>
resolveWinePrefixPath(value, root),
},
},
"./linux-runtime": {
resolveEpicLinuxPrefixPath: async (game: Game) => {
state.prefixResolutions++;
return game.winePrefixPath ?? state.prefixPath;
},
getEpicLinuxPrefixGeneration: async () =>
state.prefixReady ? state.prefixGeneration : null,
withEpicLinuxPrefixMutation: async (
_prefix: string,
task: () => Promise<unknown>,
lease?: string
) => {
state.mutationLeases.push(lease);
if (state.prefixBusy && lease !== state.leaseId)
throw new protocol.EpicSaveFailure("compatibility-prefix-busy");
return task();
},
},
"./cloud-save-linux-path.js": linuxPaths,
"./legendary-managed-runtime": {
epicManagedErrorCode: () => "process-failed",
epicOwnerMatches: (left: typeof owner, right: typeof owner) =>
@ -183,7 +256,7 @@ async function fixture(t: TestContext) {
"./cloud-save-protocol.js": protocol,
"./cloud-save-files.js": files,
"./constants.js": {
EPIC_SAVE_SYNC_TIMEOUT_MS: 1000,
EPIC_SAVE_SYNC_TIMEOUT_MS: TEST_SAVE_SYNC_TIMEOUT_MS,
EPIC_SAVE_STABILITY_DELAY_MS: 1,
EPIC_SAVE_STABILITY_ATTEMPTS: 3,
},
@ -207,7 +280,7 @@ async function fixture(t: TestContext) {
assert.ok(name in modules, `Unexpected import ${name}`);
return modules[name];
},
process,
process: { ...process, platform: options.linux ? "linux" : "win32" },
setTimeout,
clearTimeout,
AbortController,
@ -216,23 +289,61 @@ async function fixture(t: TestContext) {
);
const service = exported.configureEpicCloudSaves({
runtime,
source: new EpicCloudSaveSource(
{ token: async () => "test-token" },
async (_url, options) => {
options?.signal?.throwIfAborted();
state.commands.push(["remote-manifests"]);
return Response.json({
files: Object.fromEntries(
[
...state.otherManifests,
...(state.manifest
? [{ name: state.manifest, lastModified: state.lastModified }]
: []),
].map((manifest) => [
`savesync/${owner.epicAccountId}/Artifact/manifests/${manifest.name}`,
{ lastModified: manifest.lastModified },
])
),
});
}
),
installation: {
capture: async () => ({
capture: async (_objectId: string, signal: AbortSignal) => {
state.captureSignals.push(signal);
if (state.offline) throw new Error("offline");
return {
...target,
game,
owner,
artifactIds: ["Artifact"],
};
},
captureLocal: async () => ({
...target,
game,
owner,
artifactIds: ["Artifact"],
}),
assertPlatform: () => {},
readInstalled: async () => installed,
metadata: async () => ({
metadata: {
customAttributes: {
CloudSaveFolder: { value: local },
CloudIncludeList: { value: "*.sav" },
},
},
readInstalled: async () => ({
...installed,
...JSON.parse(
await fsp.readFile(path.join(workspace, "installed.json"), "utf8")
).Artifact,
}),
metadata: async () => {
await state.beforeMetadata?.();
return {
metadata: {
customAttributes: {
CloudSaveFolder: { value: state.folder },
CloudIncludeList: { value: "*.sav" },
},
},
};
},
},
emit: (event) => state.events.push(event),
} as never);
@ -241,6 +352,9 @@ async function fixture(t: TestContext) {
local,
remote,
workspace,
prefix,
profile,
installed,
state,
service,
exported,
@ -248,6 +362,91 @@ async function fixture(t: TestContext) {
};
}
test("persists the real path before the first transfer can be interrupted", async (t) => {
const f = await fixture(t);
delete f.game().epicInstallation!.savePath;
await fsp.writeFile(path.join(f.local, "profile.sav"), "local save");
f.state.beforeUpload = async () => {
assert.equal(f.game().epicInstallation?.savePath, f.local);
};
assert.equal((await f.service.sync("Namespace:Item")).status, "up-to-date");
assert.equal(f.state.uploads, 1);
});
test("recovers interrupted transfer paths before capture, including legacy transfers", async (t) => {
for (const persisted of [true, false]) {
const f = await fixture(t);
if (!persisted) delete f.game().epicInstallation!.savePath;
const staging = path.join(
f.workspace,
"save-transfer-interrupted",
"staging"
);
const installedFile = path.join(f.workspace, "installed.json");
const installed = JSON.parse(await fsp.readFile(installedFile, "utf8"));
installed.Artifact.save_path = staging;
await fsp.writeFile(installedFile, JSON.stringify(installed));
const session = await f.service.capture(
"Namespace:Item",
new AbortController().signal
);
assert.equal(session.savePath, f.local);
assert.equal(session.installed.save_path, f.local);
assert.equal(f.game().epicInstallation?.savePath, f.local);
assert.equal(
JSON.parse(await fsp.readFile(installedFile, "utf8")).Artifact.save_path,
f.local
);
assert.equal(f.state.commands.length, 0);
}
});
test("Linux interrupted recovery preserves paths that still need prefix validation or an explicit choice", async (t) => {
for (const prefixReady of [false, true]) {
const f = await fixture(t, { linux: true });
f.state.folder = "{appdata}/Example";
f.state.prefixReady = prefixReady;
if (!prefixReady) f.game().epicInstallation!.savePathSource = "custom";
const installedFile = path.join(f.workspace, "installed.json");
const installed = JSON.parse(await fsp.readFile(installedFile, "utf8"));
installed.Artifact.save_path = path.join(
f.workspace,
"save-transfer-interrupted",
"staging"
);
await fsp.writeFile(installedFile, JSON.stringify(installed));
const session = await f.service.capture(
"Namespace:Item",
new AbortController().signal
);
assert.equal(session.savePath, null);
assert.equal(f.game().epicInstallation?.savePath, f.local);
assert.equal(
f.game().epicInstallation?.savePathSource,
prefixReady ? undefined : "custom"
);
assert.equal(session.installed.save_path, null);
assert.equal(
JSON.parse(await fsp.readFile(installedFile, "utf8")).Artifact.save_path,
null
);
assert.equal(f.state.commands.length, 0);
}
});
test("local recovery settings remain writable while Epic is offline", async (t) => {
const f = await fixture(t);
f.state.offline = true;
const savePath = path.join(f.root, "replacement");
await f.service.setSettings("Namespace:Item", {
automaticSync: false,
savePath,
});
assert.equal(f.game().epicInstallation?.automaticSaveSync, false);
assert.equal(f.game().epicInstallation?.savePath, savePath);
assert.equal(f.state.commands.length, 0);
});
test("uploads a private stable copy, verifies readback and repeats without accumulating work", async (t) => {
const f = await fixture(t);
await fsp.writeFile(path.join(f.local, "profile.sav"), "local save");
@ -327,12 +526,15 @@ test("two initial copies require a choice and stale conflict choices cannot over
);
assert.equal(conflict.remoteModifiedAt, "2026-10-14T10:00:00.000Z");
const old = f.state.manifest;
const oldModifiedAt = f.state.lastModified;
f.state.manifest = "2026.10.14-11.00.00.manifest";
f.state.lastModified = "2026-10-14T11:00:00.000Z";
assert.equal(
(
await f.service.resolveConflict({
epicObjectId: "Namespace:Item",
expectedRemoteManifest: old,
expectedRemoteModifiedAt: oldModifiedAt,
choice: "local",
})
).status,
@ -348,6 +550,7 @@ test("two initial copies require a choice and stale conflict choices cannot over
await f.service.resolveConflict({
epicObjectId: "Namespace:Item",
expectedRemoteManifest: f.state.manifest,
expectedRemoteModifiedAt: f.state.lastModified,
choice: "local",
})
).status,
@ -379,6 +582,7 @@ test("exit zero with partial PUT never confirms upload and keeps pending local s
await f.service.resolveConflict({
epicObjectId: "Namespace:Item",
expectedRemoteManifest: f.state.manifest,
expectedRemoteModifiedAt: f.state.lastModified,
choice: "local",
})
).status,
@ -387,6 +591,118 @@ test("exit zero with partial PUT never confirms upload and keeps pending local s
assert.equal(f.game().epicInstallation?.pendingSaveUpload, false);
});
test("a later upload of an older backup downloads or conflicts instead of silently uploading", async (t) => {
for (const edited of [false, true]) {
const f = await fixture(t);
f.state.manifest = "2026.10.14-12.00.00.manifest";
f.state.lastModified = "2026-10-14T12:00:00.000Z";
await fsp.writeFile(
path.join(f.remote, "profile.sav"),
"initial cloud save"
);
assert.equal((await f.service.sync("Namespace:Item")).status, "up-to-date");
f.state.otherManifests.push({
name: f.state.manifest,
lastModified: f.state.lastModified,
});
f.state.manifest = "2026.10.13-12.00.00.manifest";
f.state.lastModified = "2026-10-15T12:00:00.000Z";
await fsp.writeFile(
path.join(f.remote, "profile.sav"),
"reuploaded backup"
);
if (edited)
await fsp.writeFile(path.join(f.local, "profile.sav"), "local edits");
const result = await f.service.sync("Namespace:Item");
assert.equal(result.status, edited ? "conflict" : "up-to-date");
assert.equal(result.remoteManifest, f.state.manifest);
assert.equal(result.remoteModifiedAt, f.state.lastModified);
assert.equal(f.state.uploads, 0);
assert.equal(f.state.downloads, edited ? 1 : 2);
assert.equal(
await fsp.readFile(path.join(f.local, "profile.sav"), "utf8"),
edited ? "local edits" : "reuploaded backup"
);
assert.equal(
await fsp.readFile(path.join(f.remote, "profile.sav"), "utf8"),
"reuploaded backup"
);
if (!edited) {
assert.equal(
f.game().epicInstallation?.saveAnchor?.remoteManifest,
f.state.manifest
);
assert.equal(
f.game().epicInstallation?.saveAnchor?.remoteModifiedAt,
f.state.lastModified
);
assert.equal(
(await f.service.sync("Namespace:Item")).status,
"up-to-date"
);
assert.equal(f.state.downloads, 2);
}
}
});
test("a same-name reupload invalidates the anchor and the displayed conflict choice", async (t) => {
const f = await fixture(t);
f.state.manifest = "2026.10.14-10.00.00.manifest";
await fsp.writeFile(path.join(f.remote, "profile.sav"), "initial remote");
assert.equal((await f.service.sync("Namespace:Item")).status, "up-to-date");
await fsp.writeFile(path.join(f.local, "profile.sav"), "local edits");
f.state.lastModified = "2026-10-15T12:00:00.000Z";
await fsp.writeFile(path.join(f.remote, "profile.sav"), "reuploaded remote");
const conflict = await f.service.sync("Namespace:Item");
assert.equal(conflict.status, "conflict");
f.state.lastModified = "2026-10-15T13:00:00.000Z";
const result = await f.service.resolveConflict({
epicObjectId: "Namespace:Item",
expectedRemoteManifest: conflict.remoteManifest,
expectedRemoteModifiedAt: conflict.remoteModifiedAt ?? null,
choice: "local",
});
assert.equal(result.status, "conflict");
assert.equal(f.state.uploads, 0);
assert.equal(f.state.downloads, 1);
assert.equal(
await fsp.readFile(path.join(f.remote, "profile.sav"), "utf8"),
"reuploaded remote"
);
});
test("a same-name reupload during download cannot replace local files or confirm an upload", async (t) => {
for (const direction of ["upload", "download"]) {
const f = await fixture(t);
if (direction === "download") {
f.state.manifest = "2026.10.14-10.00.00.manifest";
await fsp.writeFile(path.join(f.remote, "profile.sav"), "cloud save");
} else await fsp.writeFile(path.join(f.local, "profile.sav"), "local save");
f.state.beforeDownload = async () => {
f.state.lastModified = "2026-10-15T14:00:00.000Z";
};
const result = await f.service.sync("Namespace:Item");
assert.equal(
result.status,
direction === "download" ? "conflict" : "pending-upload"
);
assert.equal(
result.error,
direction === "download" ? "save-conflict" : "save-verification-failed"
);
assert.equal(f.game().epicInstallation?.saveAnchor, undefined);
if (direction === "download")
await assert.rejects(fsp.stat(path.join(f.local, "profile.sav")), {
code: "ENOENT",
});
else
assert.equal(
await fsp.readFile(path.join(f.local, "profile.sav"), "utf8"),
"local save"
);
}
});
test("a session captures local-once without changing automatic settings or running Hydra concurrently", async (t) => {
const f = await fixture(t);
const session = await f.service.capture(
@ -553,6 +869,116 @@ test("invalidated post-close inspection publishes nothing to the replacement con
);
});
for (const linux of [false, true]) {
test(`${linux ? "Linux" : "Windows"} invalidation cancels saves waiting for a busy workspace without releasing its active task`, async (t) => {
const f = await fixture(t, { linux });
const releaseWorkspace = deferred<void>();
const waiting = deferred<void>();
let activeFinished = false;
const active = queueEpicOperation(undefined, async () => {
await releaseWorkspace.promise;
activeFinished = true;
});
f.state.workspaceTail = active.queued;
f.state.onWorkspaceQueued = () => waiting.resolve();
f.game().epicInstallation!.pendingSaveUpload = true;
await fsp.writeFile(path.join(f.local, "profile.sav"), "preserve local");
const operation = f.service.sync("Namespace:Item");
const cancelled = assert.rejects(operation, /operation-cancelled/);
await waiting.promise;
let invalidated = false;
const invalidation = f.service.invalidate().then(() => {
invalidated = true;
});
try {
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(invalidated, true);
assert.equal(activeFinished, false);
assert.equal(f.state.workspaceSignals.at(-1)?.aborted, true);
assert.equal(f.state.workspaceExecutions, 0);
assert.equal(f.state.commands.length, 0);
assert.equal(f.game().epicInstallation?.pendingSaveUpload, true);
} finally {
releaseWorkspace.resolve();
await Promise.all([invalidation, cancelled, active.result]);
await f.state.workspaceTail?.catch(() => undefined);
}
assert.equal(f.state.workspaceExecutions, 0);
assert.equal(f.state.commands.length, 0);
assert.equal(
await fsp.readFile(path.join(f.local, "profile.sav"), "utf8"),
"preserve local"
);
assert.equal((await f.service.sync("Namespace:Item")).status, "up-to-date");
assert.equal(f.state.uploads, 1);
});
}
test("invalidation waits for an active capture and prevents its old generation from persisting or returning", async (t) => {
const f = await fixture(t, { linux: true });
const entered = deferred<void>();
const release = deferred<void>();
f.state.beforeMetadata = async () => {
entered.resolve();
await release.promise;
};
const capture = f.service.capture(
"Namespace:Item",
new AbortController().signal
);
const cancelled = assert.rejects(capture, /operation-cancelled/);
await entered.promise;
let invalidated = false;
const invalidation = f.service.invalidate().then(() => {
invalidated = true;
});
try {
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(invalidated, false);
assert.equal(f.state.workspaceSignals.at(-1)?.aborted, true);
} finally {
release.resolve();
await Promise.all([invalidation, cancelled]);
}
assert.equal(f.game().epicInstallation?.savePathSource, undefined);
assert.equal(f.game().epicInstallation?.savePathPrefixGeneration, undefined);
assert.equal(f.state.events.length, 0);
assert.equal(f.state.commands.length, 0);
});
test("state invalidation cannot start a fresh local fallback capture", async (t) => {
const f = await fixture(t);
const entered = deferred<void>();
const release = deferred<void>();
f.state.beforeMetadata = async () => {
entered.resolve();
await release.promise;
};
const state = f.service.getState("Namespace:Item");
await entered.promise;
const invalidation = f.service.invalidate();
release.resolve();
await invalidation;
assert.equal((await state).error, "operation-cancelled");
assert.equal(f.state.workspaceExecutions, 1);
assert.equal(f.state.events.length, 0);
});
test("settled captures detach cancellation after success and ownership lookup failure", async (t) => {
for (const offline of [false, true]) {
const f = await fixture(t);
f.state.offline = offline;
const controller = new AbortController();
const capture = f.service.capture("Namespace:Item", controller.signal);
if (offline) await assert.rejects(capture, /offline/);
else await capture;
const capturedSignal = f.state.captureSignals.at(-1)!;
await f.service.invalidate();
controller.abort();
assert.equal(capturedSignal.aborted, false);
}
});
test("a launched session blocks manual restore before the watcher first detects its process", async (t) => {
const f = await fixture(t);
await fsp.writeFile(path.join(f.remote, "profile.sav"), "cloud save");
@ -690,3 +1116,224 @@ test("all Hydra v2 sync entry points take the provider gate before the per-game
"hydra-transfer",
]);
});
test("Linux default prefix is resolved for state, manual sync and anchors without launch-only context", async (t) => {
const f = await fixture(t, { linux: true });
f.state.folder = "{appdata}/Example";
f.game().epicInstallation!.savePath = undefined;
f.installed.save_path = null;
await fsp.writeFile(
path.join(f.workspace, "installed.json"),
JSON.stringify({ Artifact: f.installed })
);
const expected = path.join(f.profile, "AppData", "Local", "Example");
const captured = await f.service.capture(
"Namespace:Item",
new AbortController().signal
);
assert.equal(captured.savePath, expected);
assert.equal(captured.winePrefixPath, f.prefix);
assert.equal(captured.prefixGeneration, "generation-a");
assert.equal(captured.savePathSource, "automatic");
assert.equal((await f.service.getState("Namespace:Item")).savePath, expected);
await fsp.mkdir(expected);
await fsp.writeFile(path.join(expected, "profile.sav"), "linux save");
assert.equal((await f.service.sync("Namespace:Item")).status, "up-to-date");
assert.equal(f.game().epicInstallation?.savePathSource, "automatic");
assert.equal(
f.game().epicInstallation?.savePathPrefixGeneration,
"generation-a"
);
assert.ok(f.state.prefixResolutions > 3);
});
test("Linux invalid prefix never falls back to host HOME, even for a retained absolute save path", async (t) => {
const f = await fixture(t, { linux: true });
f.state.prefixReady = false;
f.state.folder = "{userprofile}/Game";
const captured = await f.service.capture(
"Namespace:Item",
new AbortController().signal
);
assert.equal(captured.savePath, null);
const state = await f.service.getState("Namespace:Item");
assert.equal(state.savePath, null);
assert.equal(state.error, "save-path-required");
assert.equal(
(await f.service.sync("Namespace:Item")).error,
"save-path-required"
);
assert.equal(f.state.commands.length, 0);
});
test("Linux recreated prefix recalculates automatic paths and invalidates an old anchor before sync", async (t) => {
const f = await fixture(t, { linux: true });
const installation = f.game().epicInstallation!;
installation.savePathSource = "automatic";
installation.savePathPrefixGeneration = "generation-old";
installation.saveAnchor = {
localDigest: "old",
remoteManifest: "2026.10.14-09.00.00.manifest",
};
f.state.folder = "{appdata}/Example";
const expected = path.join(f.profile, "AppData", "Local", "Example");
await fsp.mkdir(expected);
await fsp.writeFile(path.join(expected, "profile.sav"), "new prefix local");
await fsp.writeFile(path.join(f.remote, "profile.sav"), "remote");
f.state.manifest = "2026.10.14-10.00.00.manifest";
const captured = await f.service.capture(
"Namespace:Item",
new AbortController().signal
);
assert.equal(captured.savePath, expected);
assert.equal(f.game().epicInstallation?.saveAnchor, undefined);
assert.equal((await f.service.sync("Namespace:Item")).status, "conflict");
assert.equal(f.state.uploads, 0);
assert.equal(f.state.downloads, 0);
});
test("Linux first migration cannot adopt an ambiguous legacy path without recorded generation", async (t) => {
const f = await fixture(t, { linux: true });
f.state.folder = "{appdata}/Example";
f.game().epicInstallation!.saveAnchor = {
remoteManifest: null,
localDigest: "legacy",
};
assert.equal(
(await f.service.getState("Namespace:Item")).error,
"save-path-required"
);
assert.equal(f.game().epicInstallation?.savePath, f.local);
assert.equal(f.game().epicInstallation?.savePathSource, undefined);
assert.equal(f.game().epicInstallation?.savePathPrefixGeneration, undefined);
assert.equal(f.game().epicInstallation?.saveAnchor, undefined);
assert.equal(
(await f.service.sync("Namespace:Item")).error,
"save-path-required"
);
assert.equal(f.state.commands.length, 0);
await f.service.setSettings("Namespace:Item", {
automaticSync: true,
savePath: f.local,
});
const captured = await f.service.capture(
"Namespace:Item",
new AbortController().signal
);
assert.equal(captured.savePath, f.local);
assert.equal(captured.savePathSource, "custom");
assert.equal(
f.game().epicInstallation?.savePathPrefixGeneration,
"generation-a"
);
});
test("Linux custom paths survive prefix changes while ambiguous legacy paths require an explicit choice", async (t) => {
const f = await fixture(t, { linux: true });
f.state.folder = "{appdata}/Example";
const installation = f.game().epicInstallation!;
installation.savePathPrefixGeneration = "generation-old";
installation.saveAnchor = { remoteManifest: null, localDigest: "old" };
let captured = await f.service.capture(
"Namespace:Item",
new AbortController().signal
);
assert.equal(captured.savePath, null);
assert.equal(f.game().epicInstallation?.savePath, f.local);
assert.equal(
f.game().epicInstallation?.savePathPrefixGeneration,
"generation-old"
);
assert.equal(f.game().epicInstallation?.saveAnchor, undefined);
captured = await f.service.capture(
"Namespace:Item",
new AbortController().signal
);
assert.equal(captured.savePath, null);
await f.service.setSettings("Namespace:Item", {
automaticSync: true,
savePath: f.local,
});
captured = await f.service.capture(
"Namespace:Item",
new AbortController().signal
);
assert.equal(captured.savePath, f.local);
assert.equal(captured.savePathSource, "custom");
f.state.prefixGeneration = "generation-next";
captured = await f.service.capture(
"Namespace:Item",
new AbortController().signal
);
assert.equal(captured.savePath, f.local);
assert.equal(captured.savePathSource, "custom");
});
test("Linux clearing custom saves returns to metadata and commitAnchor preserves explicit provenance", async (t) => {
const f = await fixture(t, { linux: true });
f.state.folder = "{appdata}/Example";
await f.service.setSettings("Namespace:Item", {
automaticSync: true,
savePath: f.local,
});
await fsp.writeFile(path.join(f.local, "profile.sav"), "custom");
assert.equal((await f.service.sync("Namespace:Item")).status, "up-to-date");
assert.equal(f.game().epicInstallation?.savePathSource, "custom");
await f.service.setSettings("Namespace:Item", {
automaticSync: true,
savePath: null,
});
const captured = await f.service.capture(
"Namespace:Item",
new AbortController().signal
);
assert.equal(
captured.savePath,
path.join(f.profile, "AppData", "Local", "Example")
);
assert.equal(captured.savePathSource, "automatic");
assert.equal(f.game().epicInstallation?.saveAnchor, undefined);
});
test("Linux prefix lease blocks manual work but permits its own prelaunch restore and post-exit upload", async (t) => {
const f = await fixture(t, { linux: true });
await fsp.writeFile(path.join(f.local, "profile.sav"), "local");
f.state.prefixBusy = true;
assert.equal(
(await f.service.sync("Namespace:Item")).error,
"compatibility-prefix-busy"
);
assert.equal(f.state.commands.length, 0);
const captured = await f.service.capture(
"Namespace:Item",
new AbortController().signal
);
captured.linuxPrefixLeaseId = f.state.leaseId;
await f.service.runLaunchGate("Namespace:Item", () =>
f.service.beforeLaunch(captured)
);
await f.service.markSessionStarted(captured);
await fsp.writeFile(path.join(f.local, "profile.sav"), "played");
assert.equal((await f.service.afterClose(captured))?.status, "up-to-date");
assert.equal(f.state.mutationLeases.at(-1), f.state.leaseId);
assert.equal(f.state.uploads, 2);
});
test("Linux generation changes after capture block launch and preserve pending post-exit saves", async (t) => {
const f = await fixture(t, { linux: true });
const captured = await f.service.capture(
"Namespace:Item",
new AbortController().signal
);
await f.service.markSessionStarted(captured);
f.state.prefixGeneration = "recreated";
await assert.rejects(
f.service.markSessionStarted(captured),
/compatibility-prefix-invalid/
);
const result = await f.service.afterClose(captured);
assert.equal(result?.status, "pending-upload");
assert.equal(result?.error, "compatibility-prefix-invalid");
assert.equal(f.state.commands.length, 0);
assert.equal(f.game().epicInstallation?.pendingSaveUpload, true);
});

View file

@ -13,6 +13,15 @@ import { gamesSublevel, levelKeys } from "@main/level";
import { updateGameRecord } from "../game-record-updater";
import { isGameRunning } from "../game-running-state";
import { Wine } from "../wine";
import {
getEpicLinuxPrefixGeneration,
resolveEpicLinuxPrefixPath,
withEpicLinuxPrefixMutation,
} from "./linux-runtime";
import {
linuxEpicSaveVariables,
resolveLinuxEpicSavePath,
} from "./cloud-save-linux-path.js";
import type {
LegendaryManagedRuntime,
EpicManagedTarget,
@ -36,13 +45,14 @@ import {
import {
assertEpicSaveCommandSucceeded,
decideEpicSaveSync,
getEpicRemoteManifestModifiedAt,
EpicSaveFailure,
parseEpicRemoteManifest,
sameEpicRemoteSaveManifest,
parseEpicSaveMetadata,
resolveEpicSavePath,
type EpicSaveMetadata,
type EpicRemoteSaveManifest,
} from "./cloud-save-protocol.js";
import type { EpicCloudSaveSource } from "./cloud-save-source";
import {
applyEpicSaveSnapshot,
copyEpicSaveSnapshot,
@ -55,6 +65,7 @@ import {
export interface EpicCloudSaveDependencies {
runtime: LegendaryManagedRuntime;
installation: EpicInstallationService;
source: Pick<EpicCloudSaveSource, "latest">;
emit(state: EpicCloudSaveState): void;
}
export interface EpicSaveSession {
@ -65,6 +76,11 @@ export interface EpicSaveSession {
savePath: string | null;
automaticSync: boolean;
generation: number;
winePrefixPath?: string;
prefixGeneration?: string | null;
savePathSource?: "automatic" | "custom";
/** The launch owns this lease until its post-exit save operation finishes. */
linuxPrefixLeaseId?: string;
}
interface EpicPostExitTask {
session: EpicSaveSession;
@ -102,6 +118,7 @@ export class EpicCloudSaveService {
private readonly operations = new Map<string, Promise<EpicCloudSaveState>>();
private readonly postExits = new Map<string, EpicPostExitTask>();
private readonly controllers = new Set<AbortController>();
private readonly captures = new Set<Promise<EpicSaveSession>>();
private readonly sessions = new Map<string, EpicSaveSession>();
private readonly launching = new Set<string>();
private generation = 0;
@ -117,6 +134,7 @@ export class EpicCloudSaveService {
}
private async assertStopped(session: EpicSaveSession) {
this.assertCurrent(session);
await this.assertPrefixCurrent(session);
const games = await gamesSublevel.values().all();
const selected = games.find(
(game) => game.shop === "epic" && game.objectId === session.objectId
@ -134,6 +152,25 @@ export class EpicCloudSaveService {
)
throw new EpicSaveFailure("game-running");
}
private async assertPrefixCurrent(session: EpicSaveSession) {
if (!session.winePrefixPath) return;
const game = await gamesSublevel.get(
levelKeys.game("epic", session.objectId)
);
if (
!game ||
!session.prefixGeneration ||
(await getEpicLinuxPrefixGeneration(session.winePrefixPath)) !==
session.prefixGeneration ||
(await resolveEpicLinuxPrefixPath(
game,
session.target.owner,
session.target.artifactId
)) !== session.winePrefixPath
)
throw new EpicSaveFailure("compatibility-prefix-invalid");
this.assertCurrent(session);
}
private publish(
session: EpicSaveSession,
patch: Partial<EpicCloudSaveState>
@ -143,13 +180,13 @@ export class EpicCloudSaveService {
const state: EpicCloudSaveState = {
epicObjectId: session.objectId,
status: "idle",
supportsCloudSaves: !!session.metadata.folder,
automaticSync: session.automaticSync,
savePath: session.savePath,
pendingUpload: false,
remoteManifest: null,
canRunOffline: session.installed.can_run_offline === true,
...previous,
supportsCloudSaves: !!session.metadata.folder,
automaticSync: session.automaticSync,
savePath: session.savePath,
...patch,
};
this.states.set(session.objectId, state);
@ -158,11 +195,12 @@ export class EpicCloudSaveService {
}
private async persist(
session: EpicSaveSession,
patch: Partial<EpicInstallation>
patch: Partial<EpicInstallation>,
signal?: AbortSignal
) {
this.assertCurrent(session);
this.assertCurrent(session, signal);
await updateGameRecord(levelKeys.game("epic", session.objectId), (game) => {
this.assertCurrent(session);
this.assertCurrent(session, signal);
if (
!game.epicInstallation ||
!epicOwnerMatches(game.epicInstallation.owner, session.target.owner) ||
@ -175,23 +213,30 @@ export class EpicCloudSaveService {
};
});
}
private variables(
private async variables(
owned: EpicOwnedGame,
installed: EpicInstalledGame,
winePrefixPath?: string | null
) {
const profile =
process.platform === "linux" && winePrefixPath
? Wine.getPrefixUserProfile(winePrefixPath)?.path
: app.getPath("home");
if (process.platform === "linux") {
const profile =
winePrefixPath && Wine.isPrefixReadyForRestore(winePrefixPath)
? Wine.getPrefixUserProfile(winePrefixPath)
: null;
return {
"{installdir}": installed.installPath,
"{epicid}": owned.owner.epicAccountId,
...(profile && winePrefixPath
? await linuxEpicSaveVariables(winePrefixPath, profile)
: {}),
};
}
const profile = app.getPath("home");
const variables: Record<string, string> = {
"{installdir}": installed.installPath,
"{epicid}": owned.owner.epicAccountId,
"{userprofile}": profile ?? "",
"{userdir}":
process.platform === "linux" && profile
? path.join(profile, "Documents")
: app.getPath("documents"),
"{userdir}": app.getPath("documents"),
"{usersavedgames}": profile ? path.join(profile, "Saved Games") : "",
};
if (process.platform === "win32")
@ -203,22 +248,54 @@ export class EpicCloudSaveService {
"Application Support"
);
variables["{userlibrary}"] = path.join(app.getPath("home"), "Library");
} else if (profile) {
variables["{appdata}"] = path.join(profile, "AppData", "Local");
variables["{roaming}"] = path.join(profile, "AppData", "Roaming");
variables["{locallow}"] = path.join(profile, "AppData", "LocalLow");
}
return variables;
}
async capture(
capture(
objectId: string,
signal: AbortSignal,
winePrefixPath?: string | null,
localOnly = false
): Promise<EpicSaveSession> {
const generation = this.generation;
const controller = new AbortController();
const cancel = () => controller.abort(signal.reason);
signal.addEventListener("abort", cancel, { once: true });
if (signal.aborted) cancel();
this.controllers.add(controller);
const promise = this.captureCurrent(
objectId,
controller.signal,
generation,
winePrefixPath,
localOnly
);
this.captures.add(promise);
void promise
.finally(() => {
this.captures.delete(promise);
this.controllers.delete(controller);
signal.removeEventListener("abort", cancel);
})
.catch(() => undefined);
return promise;
}
private async captureCurrent(
objectId: string,
signal: AbortSignal,
generation: number,
winePrefixPath?: string | null,
localOnly = false
): Promise<EpicSaveSession> {
const assertCaptureCurrent = () => {
if (signal.aborted || generation !== this.generation)
throw new EpicSaveFailure("operation-cancelled");
};
assertCaptureCurrent();
const owned = localOnly
? await this.dependencies.installation.captureLocal(objectId, signal)
: await this.dependencies.installation.capture(objectId, signal);
assertCaptureCurrent();
const installation = owned.game.epicInstallation;
if (!installation || !epicOwnerMatches(installation.owner, owned.owner))
throw new EpicSaveFailure("installation-missing");
@ -231,53 +308,170 @@ export class EpicCloudSaveService {
...(localOnly ? { localOnly: true } : {}),
};
this.dependencies.installation.assertPlatform(target.platform);
const installed = await this.dependencies.installation.readInstalled(
return this.dependencies.runtime.serializeWorkspace(
target,
objectId
);
if (!installed) throw new EpicSaveFailure("installation-missing");
const metadata = parseEpicSaveMetadata(
await this.dependencies.installation.metadata(target),
installation.platform
);
const configured = installation.savePath ?? installed.save_path;
const variables = this.variables(
owned,
installed,
winePrefixPath ?? owned.game.winePrefixPath
);
const resolved = configured
? resolveEpicSavePath(configured, variables)
: resolveEpicSavePath(
async (workspace) => {
assertCaptureCurrent();
const installed = await this.dependencies.installation.readInstalled(
target,
objectId
);
if (!installed) throw new EpicSaveFailure("installation-missing");
const metadata = parseEpicSaveMetadata(
await this.dependencies.installation.metadata(target),
installation.platform
);
let effectivePrefix: string | undefined;
let prefixGeneration: string | null | undefined;
if (process.platform === "linux") {
effectivePrefix = await resolveEpicLinuxPrefixPath(
owned.game,
owned.owner,
installation.artifactId
);
if (
winePrefixPath &&
(await Wine.resolvePrefixPath(winePrefixPath)) !== effectivePrefix
)
throw new EpicSaveFailure("compatibility-prefix-invalid");
prefixGeneration =
await getEpicLinuxPrefixGeneration(effectivePrefix);
}
const variables = await this.variables(
owned,
installed,
effectivePrefix
);
const interruptedTransfer =
typeof installed.save_path === "string" &&
/^save-transfer-[^\\/]+(?:[\\/]|$)/.test(
path.relative(workspace, installed.save_path)
);
const configured =
installation.savePath ??
(interruptedTransfer ? undefined : installed.save_path);
const automatic = resolveEpicSavePath(
metadata.folder?.replace(/\\/g, "/") ?? null,
variables
);
const home = path.resolve(app.getPath("home"));
const savePath =
resolved && path.resolve(resolved) !== home ? resolved : null;
return {
objectId,
target,
installed,
metadata,
savePath,
automaticSync:
installation.automaticSaveSync !== false && !!metadata.folder,
generation: this.generation,
};
let source = installation.savePathSource;
let resolved =
source === "automatic" || !configured
? automatic
: resolveEpicSavePath(configured, variables);
let clearAnchor = false;
let needsPathChoice = false;
if (process.platform === "linux") {
if (!prefixGeneration || !variables["{userprofile}"]) resolved = null;
else {
const automaticPath = automatic
? await resolveLinuxEpicSavePath(automatic)
: null;
const configuredPath = resolved
? await Wine.resolvePrefixPath(resolved)
: null;
if (!source && (!configured || configuredPath === automaticPath))
source = "automatic";
const changedGeneration =
installation.savePathPrefixGeneration !== prefixGeneration;
// A legacy absolute path has no proven provenance. It may belong to an
// older prefix even when no generation was ever recorded.
needsPathChoice =
!source &&
Boolean(configured) &&
configuredPath !== automaticPath;
clearAnchor =
changedGeneration ||
(source === "automatic" &&
installation.savePath !== automaticPath);
if (needsPathChoice) resolved = null;
else
resolved =
source === "automatic" ? automaticPath : configuredPath;
}
}
const home = path.resolve(app.getPath("home"));
const savePath =
resolved && path.resolve(resolved) !== home ? resolved : null;
const session: EpicSaveSession = {
objectId,
target,
installed,
metadata,
savePath,
automaticSync:
installation.automaticSaveSync !== false && !!metadata.folder,
generation,
...(effectivePrefix
? { winePrefixPath: effectivePrefix, prefixGeneration }
: {}),
...(source ? { savePathSource: source } : {}),
};
this.assertCurrent(session, signal);
if (
process.platform === "linux" &&
prefixGeneration &&
(clearAnchor ||
source !== installation.savePathSource ||
(!needsPathChoice &&
installation.savePathPrefixGeneration !== prefixGeneration))
) {
await this.persist(
session,
{
...(source ? { savePathSource: source } : {}),
...(!needsPathChoice
? { savePathPrefixGeneration: prefixGeneration }
: {}),
...(source === "automatic" ? { savePath } : {}),
...(clearAnchor ? { saveAnchor: undefined } : {}),
},
signal
);
this.assertCurrent(session, signal);
this.states.delete(objectId);
}
if (interruptedTransfer) {
// An unresolved Linux prefix or legacy path still requires an explicit choice.
if (process.platform !== "linux" || savePath !== null)
await this.persist(session, { savePath }, signal);
this.assertCurrent(session, signal);
await this.restoreRealSavePath(
session,
path.join(workspace, "installed.json")
);
installed.save_path = savePath;
}
this.assertCurrent(session, signal);
return session;
},
signal
);
}
async getState(objectId: string): Promise<EpicCloudSaveState> {
const retained = this.states.get(objectId);
if (retained) return retained;
if (retained && process.platform !== "linux") return retained;
const generation = this.generation;
const controller = new AbortController();
try {
const session = await this.capture(objectId, controller.signal);
const session = await this.capture(
objectId,
controller.signal,
undefined,
process.platform === "linux" && Boolean(retained)
);
const game = await gamesSublevel.get(levelKeys.game("epic", objectId));
const error = !session.metadata.folder
? "saves-unsupported"
: !session.savePath
? "save-path-required"
: undefined;
const currentState = this.states.get(objectId);
if (!error && currentState && currentState.savePath === session.savePath)
return this.publish(session, {
...currentState,
automaticSync: session.automaticSync,
});
return this.publish(session, {
status: error
? "unavailable"
@ -287,9 +481,12 @@ export class EpicCloudSaveService {
pendingUpload: game?.epicInstallation?.pendingSaveUpload === true,
remoteManifest:
game?.epicInstallation?.saveAnchor?.remoteManifest ?? null,
remoteModifiedAt:
game?.epicInstallation?.saveAnchor?.remoteModifiedAt ?? null,
error,
});
} catch (error) {
if (generation !== this.generation) controller.abort();
// A previously verified installed game can advertise its offline capability
// without performing a remote save operation or trusting a new identity.
try {
@ -305,6 +502,8 @@ export class EpicCloudSaveService {
pendingUpload: game?.epicInstallation?.pendingSaveUpload === true,
remoteManifest:
game?.epicInstallation?.saveAnchor?.remoteManifest ?? null,
remoteModifiedAt:
game?.epicInstallation?.saveAnchor?.remoteModifiedAt ?? null,
error: this.errorCode(error),
});
} catch {
@ -323,7 +522,12 @@ export class EpicCloudSaveService {
}
async setSettings(objectId: string, settings: EpicCloudSaveSettings) {
const controller = new AbortController();
const session = await this.capture(objectId, controller.signal);
const session = await this.capture(
objectId,
controller.signal,
undefined,
true
);
await this.assertStopped(session);
if (
this.operations.has(objectId) ||
@ -337,30 +541,40 @@ export class EpicCloudSaveService {
!resolveEpicSavePath(settings.savePath, {})
)
throw new EpicSaveFailure("invalid-path");
await this.persist(session, {
automaticSaveSync: settings.automaticSync,
...(settings.savePath !== undefined
? { savePath: settings.savePath, saveAnchor: undefined }
: {}),
});
const saveSettings = () =>
this.persist(session, {
automaticSaveSync: settings.automaticSync,
...(settings.savePath !== undefined
? {
savePath: settings.savePath,
savePathSource:
settings.savePath === null ? "automatic" : "custom",
savePathPrefixGeneration: session.prefixGeneration ?? undefined,
saveAnchor: undefined,
}
: {}),
});
await withSaveFileGate(() =>
session.winePrefixPath
? withEpicLinuxPrefixMutation(
session.winePrefixPath,
saveSettings,
session.linuxPrefixLeaseId
)
: saveSettings()
);
this.states.delete(objectId);
return { ok: true as const };
}
private async listRemote(session: EpicSaveSession, signal: AbortSignal) {
const result = await this.dependencies.runtime.runRawUnlocked(
const manifest = await this.dependencies.source.latest(
session.target,
["list-saves", session.target.artifactId],
{ signal, timeout: EPIC_SAVE_SYNC_TIMEOUT_MS }
signal
);
assertEpicSaveCommandSucceeded(result);
this.assertCurrent(session, signal);
const manifest = parseEpicRemoteManifest(
result.stdout,
session.target.artifactId
);
this.publish(session, {
remoteManifest: manifest,
remoteModifiedAt: getEpicRemoteManifestModifiedAt(manifest),
remoteManifest: manifest?.name ?? null,
remoteModifiedAt: manifest?.lastModified ?? null,
});
return manifest;
}
@ -372,6 +586,8 @@ export class EpicCloudSaveService {
) {
const workspace = this.dependencies.runtime.workspace(session.target);
const installedFile = path.join(workspace, "installed.json");
// This durable path survives a crash before the CLI's staging path is restored.
await this.persist(session, { savePath: session.savePath });
try {
const result = await this.dependencies.runtime.runRawUnlocked(
session.target,
@ -406,11 +622,22 @@ export class EpicCloudSaveService {
private async commitAnchor(
session: EpicSaveSession,
snapshot: EpicSaveSnapshot,
remoteManifest: string | null
remote: EpicRemoteSaveManifest | null
) {
await this.assertPrefixCurrent(session);
await this.persist(session, {
savePath: session.savePath,
saveAnchor: { remoteManifest, localDigest: snapshot.digest },
...(session.savePathSource
? { savePathSource: session.savePathSource }
: {}),
...(session.prefixGeneration
? { savePathPrefixGeneration: session.prefixGeneration }
: {}),
saveAnchor: {
remoteManifest: remote?.name ?? null,
remoteModifiedAt: remote?.lastModified ?? null,
localDigest: snapshot.digest,
},
pendingSaveUpload: false,
});
return this.publish(session, {
@ -418,14 +645,19 @@ export class EpicCloudSaveService {
localModifiedAt: getEpicSaveSnapshotModifiedAt(snapshot),
phase: undefined,
pendingUpload: false,
remoteManifest,
remoteManifest: remote?.name ?? null,
remoteModifiedAt: remote?.lastModified ?? null,
error: undefined,
});
}
private async synchronize(
session: EpicSaveSession,
signal: AbortSignal,
choice?: { remoteManifest: string | null; value: "local" | "remote" },
choice?: {
remoteManifest: string | null;
remoteModifiedAt: string | null;
value: "local" | "remote";
},
postExit?: EpicPostExitTask
): Promise<EpicCloudSaveState> {
if (!session.metadata.folder)
@ -439,149 +671,178 @@ export class EpicCloudSaveService {
error: "save-path-required",
});
await this.assertStopped(session);
return withSaveFileGate(() =>
this.dependencies.runtime.serializeWorkspace(
session.target,
async (workspace) => {
await this.assertStopped(session);
this.publish(session, {
status: "syncing",
phase: "checking",
error: undefined,
localModifiedAt: null,
remoteModifiedAt: null,
});
const before = await inspectEpicSaveFiles(
session.savePath!,
session.metadata,
signal
);
this.assertCurrent(session, signal);
this.publish(session, {
localModifiedAt: getEpicSaveSnapshotModifiedAt(before),
});
// Reopening can cancel inspection until the first cloud command starts.
// Once a command is requested, its captured task must finish before launch.
if (postExit) postExit.commandStarted = true;
const remote = await this.listRemote(session, signal);
const game = await gamesSublevel.get(
levelKeys.game("epic", session.objectId)
);
this.assertCurrent(session, signal);
if (choice && choice.remoteManifest !== remote)
return this.publish(session, {
status: "conflict",
phase: undefined,
remoteManifest: remote,
error: "save-conflict",
return withSaveFileGate(() => {
const synchronizeFiles = () =>
this.dependencies.runtime.serializeWorkspace(
session.target,
async (workspace) => {
await this.assertStopped(session);
this.publish(session, {
status: "syncing",
phase: "checking",
error: undefined,
localModifiedAt: null,
remoteModifiedAt: null,
});
const action = choice
? choice.value === "local"
? "upload"
: "download"
: decideEpicSaveSync(
game?.epicInstallation?.saveAnchor,
before.digest,
before.files.length,
remote,
game?.epicInstallation?.pendingSaveUpload === true
const before = await inspectEpicSaveFiles(
session.savePath!,
session.metadata,
signal
);
this.assertCurrent(session, signal);
this.publish(session, {
localModifiedAt: getEpicSaveSnapshotModifiedAt(before),
});
// Reopening can cancel inspection until the first cloud command starts.
// Once a command is requested, its captured task must finish before launch.
if (postExit) postExit.commandStarted = true;
const remote = await this.listRemote(session, signal);
const game = await gamesSublevel.get(
levelKeys.game("epic", session.objectId)
);
this.assertCurrent(session, signal);
if (
choice &&
(choice.remoteManifest !== (remote?.name ?? null) ||
choice.remoteModifiedAt !== (remote?.lastModified ?? null))
)
return this.publish(session, {
status: "conflict",
phase: undefined,
remoteManifest: remote?.name ?? null,
error: "save-conflict",
});
const action = choice
? choice.value === "local"
? "upload"
: "download"
: decideEpicSaveSync(
game?.epicInstallation?.saveAnchor,
before.digest,
before.files.length,
remote,
game?.epicInstallation?.pendingSaveUpload === true
);
if (action === "conflict")
return this.publish(session, {
status: "conflict",
phase: undefined,
remoteManifest: remote?.name ?? null,
error: "save-conflict",
});
if (action === "same")
return this.commitAnchor(session, before, remote);
const temporary = await fs.mkdtemp(
path.join(workspace, "save-transfer-")
);
let preserveRecovery = false;
try {
const staging = path.join(temporary, "staging");
await fs.mkdir(staging, { mode: 0o700 });
if (action === "download") {
if (!remote)
return this.publish(session, {
status: "conflict",
phase: undefined,
remoteManifest: null,
error: "save-conflict",
});
this.publish(session, { phase: "downloading" });
await this.transfer(session, signal, staging, "download");
if (
!sameEpicRemoteSaveManifest(
await this.listRemote(session, signal),
remote
)
)
throw new EpicSaveFailure("save-conflict");
await this.assertStopped(session);
this.publish(session, { phase: "restoring" });
await this.persist(session, { pendingSaveUpload: true });
const restored = await applyEpicSaveSnapshot(
staging,
session.savePath!,
path.join(temporary, "backup"),
session.metadata,
before.digest,
() => this.assertCurrent(session, signal)
);
return this.commitAnchor(session, restored, remote);
}
// An upload always starts from a stable private copy, never from live save files.
await this.persist(session, { pendingSaveUpload: true });
await copyEpicSaveSnapshot(session.savePath!, staging, before);
const stable = await inspectEpicSaveFiles(
staging,
session.metadata,
signal
);
if (action === "conflict")
return this.publish(session, {
status: "conflict",
phase: undefined,
remoteManifest: remote,
error: "save-conflict",
});
if (action === "same")
return this.commitAnchor(session, before, remote);
const temporary = await fs.mkdtemp(
path.join(workspace, "save-transfer-")
);
let preserveRecovery = false;
try {
const staging = path.join(temporary, "staging");
await fs.mkdir(staging, { mode: 0o700 });
if (action === "download") {
if (!remote)
return this.publish(session, {
status: "conflict",
phase: undefined,
remoteManifest: remote,
error: "save-conflict",
});
this.publish(session, { phase: "downloading" });
await this.transfer(session, signal, staging, "download");
if ((await this.listRemote(session, signal)) !== remote)
if (
stable.digest !== before.digest ||
(
await inspectEpicSaveFiles(
session.savePath!,
session.metadata,
signal
)
).digest !== before.digest
)
throw new EpicSaveFailure("save-operation-active");
if (!stable.files.length)
throw new EpicSaveFailure("save-verification-failed");
if (
!sameEpicRemoteSaveManifest(
await this.listRemote(session, signal),
remote
)
)
throw new EpicSaveFailure("save-conflict");
await this.assertStopped(session);
this.publish(session, { phase: "restoring" });
await this.persist(session, { pendingSaveUpload: true });
const restored = await applyEpicSaveSnapshot(
staging,
session.savePath!,
path.join(temporary, "backup"),
this.publish(session, {
phase: "uploading",
pendingUpload: true,
});
await this.transfer(session, signal, staging, "upload");
const uploadedManifest = await this.listRemote(session, signal);
if (!uploadedManifest)
throw new EpicSaveFailure("save-verification-failed");
this.publish(session, { phase: "verifying" });
const verification = path.join(temporary, "verification");
await fs.mkdir(verification);
await this.transfer(session, signal, verification, "download");
const readback = await inspectEpicSaveFiles(
verification,
session.metadata,
before.digest,
() => this.assertCurrent(session, signal)
signal
);
return this.commitAnchor(session, restored, remote);
}
// An upload always starts from a stable private copy, never from live save files.
await this.persist(session, { pendingSaveUpload: true });
await copyEpicSaveSnapshot(session.savePath!, staging, before);
const stable = await inspectEpicSaveFiles(
staging,
session.metadata,
signal
);
if (
stable.digest !== before.digest ||
(
await inspectEpicSaveFiles(
session.savePath!,
session.metadata,
signal
if (
readback.digest !== stable.digest ||
!sameEpicRemoteSaveManifest(
await this.listRemote(session, signal),
uploadedManifest
)
).digest !== before.digest
)
throw new EpicSaveFailure("save-operation-active");
if (!stable.files.length)
throw new EpicSaveFailure("save-verification-failed");
if ((await this.listRemote(session, signal)) !== remote)
throw new EpicSaveFailure("save-conflict");
await this.assertStopped(session);
this.publish(session, { phase: "uploading", pendingUpload: true });
await this.transfer(session, signal, staging, "upload");
const uploadedManifest = await this.listRemote(session, signal);
if (!uploadedManifest)
throw new EpicSaveFailure("save-verification-failed");
this.publish(session, { phase: "verifying" });
const verification = path.join(temporary, "verification");
await fs.mkdir(verification);
await this.transfer(session, signal, verification, "download");
const readback = await inspectEpicSaveFiles(
verification,
session.metadata,
signal
);
if (
readback.digest !== stable.digest ||
(await this.listRemote(session, signal)) !== uploadedManifest
)
throw new EpicSaveFailure("save-verification-failed");
// The anchor records what was uploaded, so an external local edit remains detectable.
return this.commitAnchor(session, stable, uploadedManifest);
} catch (error) {
preserveRecovery = error instanceof EpicSaveRollbackFailure;
throw error;
} finally {
if (!preserveRecovery)
await fs.rm(temporary, { recursive: true, force: true });
}
}
)
);
)
throw new EpicSaveFailure("save-verification-failed");
// The anchor records what was uploaded, so an external local edit remains detectable.
return this.commitAnchor(session, stable, uploadedManifest);
} catch (error) {
preserveRecovery = error instanceof EpicSaveRollbackFailure;
throw error;
} finally {
if (!preserveRecovery)
await fs.rm(temporary, { recursive: true, force: true });
}
},
signal
);
return session.winePrefixPath
? withEpicLinuxPrefixMutation(
session.winePrefixPath,
synchronizeFiles,
session.linuxPrefixLeaseId
)
: synchronizeFiles();
});
}
private errorCode(
error: unknown
@ -592,7 +853,11 @@ export class EpicCloudSaveService {
}
private operation(
session: EpicSaveSession,
choice?: { remoteManifest: string | null; value: "local" | "remote" },
choice?: {
remoteManifest: string | null;
remoteModifiedAt: string | null;
value: "local" | "remote";
},
postExit?: EpicPostExitTask
) {
if (
@ -667,6 +932,7 @@ export class EpicCloudSaveService {
throw new EpicSaveFailure("save-conflict");
return this.operation(session, {
remoteManifest: payload.expectedRemoteManifest,
remoteModifiedAt: payload.expectedRemoteModifiedAt,
value: payload.choice,
});
}
@ -706,6 +972,7 @@ export class EpicCloudSaveService {
}
async markSessionStarted(session: EpicSaveSession) {
this.assertCurrent(session);
await this.assertPrefixCurrent(session);
if (session.metadata.folder && session.automaticSync)
await this.persist(session, { pendingSaveUpload: true });
this.assertCurrent(session);
@ -754,6 +1021,7 @@ export class EpicCloudSaveService {
) {
const signal = task.controller.signal;
this.assertCurrent(session, signal);
await this.assertPrefixCurrent(session);
if (!session.metadata.folder || !session.automaticSync) return;
if (session.savePath) {
let previous = await inspectEpicSaveFiles(
@ -793,6 +1061,7 @@ export class EpicCloudSaveService {
for (const controller of this.controllers) controller.abort();
for (const task of this.postExits.values()) task.controller.abort();
await Promise.allSettled([
...this.captures,
...this.operations.values(),
...[...this.postExits.values()].map((task) => task.promise),
]);

View file

@ -43,6 +43,9 @@ export const EPIC_PENDING_EXPIRATION_MS = 60 * 60_000;
export const EPIC_RUNNING_EXPIRATION_MS = 15 * 60_000;
export const EPIC_DOWNLOAD_STALL_TIMEOUT_MS = 5 * 60_000;
export const EPIC_SAVE_SYNC_TIMEOUT_MS = 5 * 60_000;
export const EPIC_SAVE_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
export const EPIC_SAVE_STORAGE_ORIGIN =
"https://datastorage-public-service-liveegs.live.use1a.on.epicgames.com";
export const EPIC_PROGRESS_PERSIST_INTERVAL_MS = 1_000;
export const EPIC_PROCESS_MAX_LINE_BYTES = 65_536;
export const EPIC_PROCESS_TERMINATION_TIMEOUT_MS = 10_000;

Some files were not shown because too many files have changed in this diff Show more