Compare commits
25 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aee4d3d1f2 | |||
| be4f67fb60 | |||
| cd0007fc7d | |||
| 116ba112ab | |||
| 3a25325f20 | |||
| f4e770a7db | |||
| 6776dfb5ce | |||
| d58967237c | |||
| d21b873327 | |||
| 4680d7f014 | |||
| be47433c32 | |||
| bf5abeef73 | |||
| 1bbc71166e | |||
| 9748b98163 | |||
| 3f57682a9b | |||
| 2ff8920225 | |||
| a2f1f83ee3 | |||
| c3fc5e7dd1 | |||
| e7636afe3d | |||
| d4996988d9 | |||
| 0f3fbaa418 | |||
| be439e5421 | |||
| cb0b071a38 | |||
| 7d8ab87eee | |||
| 9edcc9841b |
38 changed files with 5212 additions and 2503 deletions
91
README.md
91
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# PLWL — Portable Linux Wine Launcher
|
# PLWL — Portable Linux Wine Launcher
|
||||||
|
|
||||||
A portable launcher for Windows games and applications on Linux.
|
A portable launcher for Windows games and applications on Linux — and for native Linux apps, run with their own home and a chosen isolation level (bubblewrap when available).
|
||||||
|
|
||||||
## Portable layout
|
## Portable layout
|
||||||
|
|
||||||
|
|
@ -9,7 +9,7 @@ Everything the launcher needs lives next to the executable:
|
||||||
```
|
```
|
||||||
./files/
|
./files/
|
||||||
wines/ imported Wine / Proton builds
|
wines/ imported Wine / Proton builds
|
||||||
wineprefixes/ wine prefixes
|
wineprefixes/ wine prefixes (a native Linux app's data lives in <prefix>/linux_pfx)
|
||||||
apps/ imported app folders (optional)
|
apps/ imported app folders (optional)
|
||||||
data/ launcher config (apps.json, settings.json)
|
data/ launcher config (apps.json, settings.json)
|
||||||
translations/ translation files
|
translations/ translation files
|
||||||
|
|
@ -24,63 +24,84 @@ npm start
|
||||||
|
|
||||||
## Building & installing
|
## Building & installing
|
||||||
|
|
||||||
`./build.sh` produces `build/PLWL-<version>.tar.gz`: the Electron runtime renamed to `olcxja.plwl/` (binary `olcxja.plwl/olcxja.plwl`) with the whole app (code, renderer and bundled `files/`) packed into `olcxja.plwl/resources/app.asar`, plus the `olcxja.plwl.png` icon, `install.sh` and `createshortcut.sh`:
|
`./build.sh` produces `build/PLWL-<version>.tar.gz`: the Electron runtime renamed to `olcxja.plwl/` (binary `olcxja.plwl/olcxja.plwl`, whole app in `olcxja.plwl/resources/app.asar`), plus the icon, `install.sh` and `createshortcut.sh`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -xzf PLWL-<version>.tar.gz
|
./install.sh # install to ~/.local/bin/olcxja.plwl/ (icon + shortcut)
|
||||||
cd PLWL
|
./createshortcut.sh # portable mode: icon + shortcut only
|
||||||
./install.sh # copies everything to ~/.local/bin/olcxja.plwl/,
|
./olcxja.plwl/olcxja.plwl # or run without installing
|
||||||
# installs the icon (olcxja.plwl.png) + shortcut
|
|
||||||
# removes the extraction folder, launches
|
|
||||||
|
|
||||||
./createshortcut.sh # portable mode: only installs the icon + shortcut
|
|
||||||
|
|
||||||
# or run without any shortcuts:
|
|
||||||
./olcxja.plwl/olcxja.plwl
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Importing from a wine prefix
|
## Importing from a wine prefix
|
||||||
|
|
||||||
Besides copy/move/leave, an app can be imported **from a wine prefix**: pick the prefix, then the app folder inside its `drive_c`. Nothing is copied — paths are stored relative to the prefix, so wine sees the app as if it were normally installed on `C:\` (registry entries pointing to `C:\...` keep working). Both layouts work: plain wine prefixes (`drive_c` at the top) and Proton-managed ones (`drive_c` under `pfx/`). The app stays tied to that prefix; removing the launcher entry never deletes files from the prefix. A prefix can be renamed later (Wine prefixes): the folder moves on disk and everything running in it follows the new name, because those paths are stored relative to the prefix. Edits wait for running apps: while anything from a prefix is playing, renaming or deleting it (and switching the default prefix away from an app that has none of its own) is refused with the name of the app to close first — wine keeps its registry and user files open in that folder. Deleting is also refused while a prefix still has apps registered in it, so an app is never silently orphaned into the default prefix. Imports keep the folder as it was: symlinks are recreated (rewritten to relative whenever the target is part of the launcher) and file permissions are carried over, so an imported game or build runs straight away.
|
Besides copy/move/leave, an app can be imported **from a prefix**: nothing is copied — paths are stored relative to the prefix, so wine sees the app as installed on `C:\` (registry entries keep working). Plain prefixes (`drive_c` on top) and Proton ones (`pfx/`) both work. The app stays tied to that prefix; removing the entry never deletes prefix files. A prefix can be renamed later — the folder moves and everything stored relative follows. Edits wait for running apps: renaming/deleting a prefix in use (or moving the default away from an app without its own) is refused with the app to close first; deleting a prefix that still has apps is refused too. Imports recreate symlinks (relative when the target belongs to the launcher) and keep permissions, so an imported game runs straight away.
|
||||||
|
|
||||||
## "Open with PLWL" in the file manager
|
## Native Linux apps
|
||||||
|
|
||||||
The desktop entry (`MimeType` + `Exec %f`) makes PLWL show up in Dolphin/Nautilus under **Open With** for Windows executables (`.exe`, `.msi`, `.bat`, `.cmd`, `.lnk`). Opening a file that way shows a small popup in the launcher where you pick the **Wine/Proton version** and the **prefix** for this run — both default to the launcher's defaults, nothing is imported into the library. A second launcher instance hands the path to the running one and exits (same single-instance mechanism the shortcuts use).
|
The import dialog starts with a choice: **Windows app (Wine / Proton)** or **Linux app (native)** — after that both go through the same copy/move/leave steps.
|
||||||
|
|
||||||
The app shortcuts created in an app's configuration work as **Open With** targets too: pick one in the file manager (right-click a photo → Open With → your Photoshop shortcut) and that app starts with the file, receiving it as the Windows path it would get on Windows — `C:\...` when the file sits inside its prefix, `Z:\...` for the rest of the machine. Giving a file to an app that is already running hands it to that instance instead of starting a second tracked run.
|
The entry point is found automatically in the picked folder: an ELF binary or `.AppImage` (with the executable bit), or a `.desktop` entry whose `Exec=` points at one. Libraries (`lib/`, `share/`, …) are skipped, and the icon comes from the `.desktop` `Icon=`, an image next to the program, or the system theme.
|
||||||
|
|
||||||
|
A native app runs with its **own home folder**: `HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_CACHE_HOME` and `XDG_STATE_HOME` point into `files/wineprefixes/<prefix>/linux_pfx/`, so config, cache and state land there instead of in your `~`. The prefix select in the app's config picks which one; **Open data folder** takes you to it. `linux_pfx` is part of its prefix like everything else: a sync carries it with the prefix — Linux apps and their data travel in the same categories as the Windows ones.
|
||||||
|
|
||||||
|
When **bubblewrap** is installed *and* usable (PLWL probes it with a real run, not `which bwrap` — Ubuntu's AppArmor profile kills it even when it is installed), the two sandboxed levels also give the app its own namespaces: `/` is mounted read-only, so only **its home folder and its own app folder stay writable** — anything else fails with `EROFS`. Relaxed adds exactly one mount on top of that profile (the writable host filesystem described below); everything else on this page is the same at both levels. The app folder is included on purpose: portable apps keep their logs and data next to themselves (Prism Launcher refuses to start otherwise). System trees are never handed out writable, not even when the app itself lives in one. Screens, audio (X11/Wayland, PulseAudio/PipeWire), gamepads and networking keep working. `XDG_RUNTIME_DIR` stays the host's, so the session bus is shared — the sandbox is not airtight, and the UI says so rather than pretending.
|
||||||
|
|
||||||
|
A file your desktop hands the app — a drag-and-drop from your file manager, a file picked in a file dialog — is named by its **real path** (`/home/you/Downloads/mod.jar`), and inside the sandbox that name is the app's own redirected home. At **Relaxed** your home folders are linked back into the app at those very paths, through the writable mount, so the drop opens where it lies and a save lands on your real file; the one name left alone is the folder the app itself lives in, which keeps its plain path (a link there turned a launcher app's own instances into a `/tmp` path, which Prism Launcher refuses to see as permanent). At **Isolated** there is no view to link into, and what reaches the app there is only what the launcher hands it, as a copy (see the levels below).
|
||||||
|
|
||||||
|
The redirected home starts with a **copy of your desktop preferences** — `kdeglobals`, the GTK 3/4 settings and `user-dirs.dirs` — so the app keeps your theme instead of falling back to its built-in light look. The copy keeps following your host as long as the app has not changed it: a theme change arrives on the next launch, and the moment the app writes its own version, PLWL never touches it again. Your own **fonts, icons, themes and colour schemes are not copied but mounted read-only** inside the sandbox and added to `XDG_DATA_DIRS`, so they follow the host live and nothing can be written back to your `~`.
|
||||||
|
|
||||||
|
**Anything the app wants to open is opened by your own desktop.** A **link** goes to the host's xdg-desktop-portal, so it lands in the browser you actually chose instead of one resolved in here, where the default association and its `.desktop` file are behind the redirected home — that used to mean a system browser you never picked, or nothing at all. Both routes reach the portal: `xdg-open` itself (what Qt, Prism and Electron call) and GIO's in-process resolution (`gtk_show_uri`). A **folder or a document** (Prism's *Open Pack Folder*, a text file, a drop) takes the other route: the app leaves the target in its own home, PLWL picks it up, translates the path back out of the sandbox and opens it with your `xdg-open` — so it arrives in your file manager, and a scheme nothing handles gets your desktop's own *Choose an application* dialog rather than a guess. On a machine without a portal nothing is changed — a link then behaves exactly as it did before. A KDE-native app that resolves the link in-process through KIO (Dolphin, Kate) is the one path still left out: it asks KDE's own handler database, which has no answer in here (measured: `kde-open` opens nothing and reports success).
|
||||||
|
|
||||||
|
Every prefix in the **Wine prefixes** tab has a **Reset Linux config** button: it deletes the desktop preferences PLWL copied in (and the hashes that track them), so the next app start seeds a clean copy from your system — the way back when an app has hijacked a seeded file with its own theme. App data in `linux_pfx` is never touched, and an app running from that prefix blocks the reset.
|
||||||
|
|
||||||
|
Imported trees get **normalised permissions**: every file gets the owner's `rwx` and an `x` mirrored from its `r`, because sources come from archives, FAT sticks or read-only backups where those bits are simply not there. An import whose copy could not reproduce every file is **refused** and the partial copy is removed, so a half-copied app is never registered.
|
||||||
|
|
||||||
|
Some apps draw their window icon themselves, which Wayland does not allow — Minecraft (LWJGL 3.4+) is the known case: its taskbar icon turns into the generic Wayland one. The per-app **Force X11 (XWayland)** switch moves the app *and the programs it launches* back to X11, where the icon works again.
|
||||||
|
|
||||||
|
Per app you choose the **Isolation** level — what the app gets besides its own home folder:
|
||||||
|
|
||||||
|
- **None** — a plain run. The app sees your real filesystem exactly as any other program of yours does: what a shortcut hands it works, including into an app that is already running.
|
||||||
|
- **Relaxed** — the sandbox *plus* your whole filesystem mounted writable at `/tmp/plwl-fs-root`. A document handed over behaves natively — it opens where the app was told it is, it can be saved, and it also reaches an app that is already running, because the mount is there from the first second of every run. Your home folders are linked back into the app through that mount, so a file you drag onto the app, or pick in its own dialog, opens at its real path too. The price is real and the UI says it: at this level the app can change anything your user can.
|
||||||
|
- **Isolated** — the sandbox without that mount. The system stays read-only, and a document handed over is **copied** first, into `plwl-handoff` inside the app's own home folder; the app works on that copy (so the original cannot be touched), and a copy big enough to be noticed shows a progress popup in the launcher. There is no view of your home here, so a drop from your desktop or a file picked in a dialog cannot reach the app — only what the launcher hands over does, as that copy.
|
||||||
|
- **Automatic** (default) — Relaxed where bubblewrap works, None where it does not.
|
||||||
|
|
||||||
|
An explicit Relaxed or Isolated on a machine without a working bubblewrap **refuses to start** instead of quietly dropping the promise; Automatic is the one level allowed to weaken itself. What the app really ran as is always visible: a badge on the library card, a `[plwl] isolation: …` line in the app log, and a status card in Settings with **Check again**. When bwrap is missing, PLWL says so once instead of quietly launching without it.
|
||||||
|
|
||||||
|
## "Open with PLWL"
|
||||||
|
|
||||||
|
The desktop entry registers `.exe/.msi/.bat/.cmd/.lnk` in file managers. Opening one shows a popup to pick the **Wine/Proton build** and **prefix** for that run (defaults preselected, nothing imported). A second instance hands the path to the running one. App shortcuts work as **Open With** targets too: the app receives the file as the Windows path it would get on Windows — `C:\...` inside its prefix, `Z:\...` otherwise. A file handed to an already running app goes to that instance instead of a second tracked run. A **Linux app shortcut** is an Open With target the same way, and what the app is handed depends on its isolation level: at **None** the plain absolute path, at **Relaxed** the same file through the writable host mount (`/tmp/plwl-fs-root/…`) — which is also the only shape an app that is *already running* can resolve, since a mount cannot be added to a live sandbox — and at **Isolated** a copy in `plwl-handoff` inside the app's own home folder, so the original stays untouched.
|
||||||
|
|
||||||
## LAN syncing
|
## LAN syncing
|
||||||
|
|
||||||
Two PLWL installs on the same LAN can mirror each other's content - no cloud, no accounts. Open **LAN syncing** in the sidebar (under Settings):
|
Two PLWL installs on one LAN can mirror each other (sidebar → **LAN syncing**). No cloud, no accounts.
|
||||||
|
|
||||||
- **Device ID** - 24 characters, generated once per install (current timestamp + random characters) and re-rollable at any time.
|
- **Device ID** (24 chars) and **Sync code** (8 chars, letters+digits, case-insensitive), both re-rollable. A caller must prove it knows its own code; each verification costs 2 s and one may run at a time, so guessing is pointless. A correct code still opens an **Accept/Decline popup** on the receiver (60 s timeout); declining or ignoring never touches this machine and keeps the pairing.
|
||||||
- **Sync code** - 8 characters, letters and digits, case-insensitive. A device only accepts a connection when the caller proves it knows *its own* code; each verification costs two seconds and a device may only have one in flight, so guessing over the LAN is pointless. Both the ID and the code can be re-rolled. A correct code is still not enough: the receiving device shows an **Accept / Decline popup** before anything moves, and without a click within a minute the sender is turned down. Declining (or ignoring) the popup never touches this machine and does not unpair the two devices.
|
- **Devices on your network** are found over UDP and visible only while PLWL runs there (a few seconds after it closes they are gone).
|
||||||
- **Devices on your network** - other PLWL installs, found over UDP; a device shows up only while PLWL is running there, and disappears a few seconds after it is closed.
|
- Pick what to push: **portable apps**, **prefix-imported apps**, **Wine/Proton builds**, **prefixes**, **Sync settings**. Each category also has a **Select** list: everything starts ticked, and unchecking an app, a build or a prefix keeps that item on both machines untouched. Only what is ticked travels — without **Sync settings** the other machine's defaults and app configs are untouched. An accepted code is stored with the device ID, so later syncs need no typing; a re-rolled code is forgotten and asked for again.
|
||||||
|
|
||||||
Pick a device and choose what to push: **portable apps**, **apps imported from a prefix**, **Wine/Proton builds**, **prefixes** and **Sync settings**. Only what is ticked travels - a sync without **Sync settings** never touches the other machine's defaults, names or app configurations. The code has to be typed once - an accepted code is stored next to that device ID, so every later sync needs no typing. If the other side re-rolled its code, the stored one is forgotten and the launcher asks for the new one instead of failing silently.
|
A prefix-imported app's own folder lives inside its prefix, not in `files/apps/`. Unchecking such an app leaves that folder alone even when the prefix itself is synced: the sender takes it out of the prefix unit and marks it as off-limits for deletion. The picker says so when the app is ticked but its prefix is not — the folder then stays behind with the prefix.
|
||||||
|
|
||||||
**Both machines show what is happening.** The sender gets a progress popup and the receiving device gets the same popup titled *Receiving from X…*, driven by the transfer as it arrives. Both show the current phase and unit, the files being moved, a sent/total counter per category (`Apps: 2/5 · Prefixes: 1/2`) and the transferred vs. planned size. The bar follows **bytes, not file counts**, and it belongs to the phase being worked on: hashing fills its own bar and the transfer starts again from empty, so nothing ever looks finished while it has just begun (the byte line next to it keeps the overall transferred vs. planned total). Both popups warn that the launcher must stay open, and a running sync is not cancellable from the outside: clicking the backdrop or pressing Escape does nothing. If the other device goes quiet for a few seconds (it hashes before it sends) the popup says *Waiting for the other device…* rather than closing, and the sending side keeps signalling while it works, so even a long hashing phase never looks like a dead peer. The popup closes itself when the transfer ends, or after fifteen minutes of complete silence when the other side has really vanished — and comes back if the sync turns out to be alive after all.
|
**Both machines show a progress popup** (phase, unit, files, per-category counters like `Apps: 2/5`, transferred vs. planned bytes; the bar follows the current phase). The popups cannot be dismissed, and a sender hashing a big unit shows *Waiting for the other device…* instead of a dead popup — the sender pings while it works. They close on completion, or after 15 minutes of silence (and reopen if the sync turns out to be alive).
|
||||||
|
|
||||||
The **sending device is the source of truth**: files that differ are overwritten, missing ones are written, and anything living only on the receiving side inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) is deleted. Files are compared with a fast, collision-resistant hash (BLAKE2b-512, SHA-512/SHA-256 fallback); **Force sync all files** skips the comparison and overwrites everything instead. Hashes are remembered per file (size + modification time), so a repeat sync of a library that did not change reads nothing at all.
|
The **sender is the source of truth for what it sends**: differing files are overwritten, missing ones written, and extras inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) are removed — but only after the receiver says so. Nothing is deleted while the bytes move: each unit only *plans* its leftovers, and the receiver gets one popup at the end with the top-most folders that would go (a fully redundant folder is a single collapsed row, a partly redundant one can be expanded). Every row is ticked to delete; unchecking a folder or a file keeps it. **Keep everything** is always one click away, an unanswered or expired list removes nothing, and a tree the sender could not fully read is never pruned at all, because a missing entry may only mean it was unreadable.
|
||||||
|
|
||||||
**Wine builds and prefixes survive the trip.** Permissions travel with a sync, so the `proton`, `wine` and `wineserver` binaries are still executable on the other machine (a build whose executable bit was lost is additionally repaired at startup, without re-importing it), and symlinks are sent as RELATIVE paths whenever the file they point at belongs to the launcher: a Proton prefix is full of links into its build, and the receiving side gets links that resolve to its own copy instead of the sender's `/home/...` folders. Links that leave the launcher on purpose (`dosdevices` drive mappings, a mounted drive, the user's home folder) stay absolute, and links into a folder that does not exist yet on the other machine are still written relative, so they start working the moment that folder appears. Wine and Proton rewrite a prefix's links with local absolute targets while they update it; PLWL relativizes them again on the wire, on import and before every run.
|
That review is the only deletion path, so no backup copy is ever made — the files simply stay on disk until the answer. Two things it cannot undo: files that exist on both sides with different content are **overwritten** with the sender's version (the popup says so; if a game is installed on both computers, leave its prefix out of the sync), and a file kept in one sync is proposed again the next time, since the mirror is recomputed from scratch every run.
|
||||||
|
|
||||||
A sync also carries **how the apps are set up**: the Wine/Proton build and prefix each app runs in, its arguments, environment variables and DLL overrides, plus playtime and last played. The **Sync settings** category carries the registry-level part of that even when no files move: the launcher's **default Wine build and default prefix**, the custom **names** you gave Wine/Proton builds (and their disabled flags), the **prefix registry**, every **app's configuration**, and the **order of the library**. It only patches what already exists on the other machine - an unknown Wine id or prefix is ignored instead of leaving a dangling default or a broken app behind, apps and prefixes the sender does not know stay where they are, and a settings-only sync deletes nothing (that is what the file categories are for). Playtime only ever grows - the higher counter wins - so pushing from a freshly installed machine cannot wipe a library's play history. Everything else stays local: language and window settings, logs, translations, and external (leave) apps - prefixes are always kept usable, so app entries pointing inside a prefix keep their prefix-relative paths and the receiving side never loses its own `Main` prefix.
|
Only one sync runs at a time (a second attempt on the same machine answers *busy*), and a transfer cut off mid-file leaves no half-written file behind. Files are compared with BLAKE2b-512 (SHA-512/SHA-256 fallback); **Force sync all files** skips the comparison. Hashes are cached per file (size + mtime), so an unchanged library costs nothing to re-sync. Permissions travel with the files, and symlinks are sent as **relative** paths whenever the target belongs to the launcher (a Proton prefix's links into its build keep working on the other machine); `dosdevices` and targets outside `files/` stay absolute, and links wine itself rewrote to absolute are relativized again on the wire, on import and before every run. A peer that predates the review cannot sync units at all (it would prune without asking); update both machines.
|
||||||
|
|
||||||
|
A sync also carries **how apps are set up**: wine build and prefix, args, env, DLL overrides, a native Linux app's isolation level and Force X11, playtime and last played. **Sync settings** carries the registry-level part even when no files move: default wine/prefix, custom build names, prefix registry, every app's configuration and the library order. It only patches what already exists (unknown ids are ignored, unknown apps/prefixes stay), deletes nothing, and playtime only grows — the higher counter wins. Language, window settings, logs and external (leave) apps stay local.
|
||||||
|
|
||||||
## Wine & Proton support
|
## Wine & Proton support
|
||||||
|
|
||||||
- **System Wine** — uses the `wine` binary from PATH.
|
- **System Wine** — `wine` from PATH.
|
||||||
- **Imported builds** — drop any wine or Proton build into `files/wines/`. The folder may contain the build directly, or one level of nesting (zip-extracted layouts are detected automatically). After the copy, PLWL rewires the build's absolute symlinks to relative ones and restores the executable bit on the binaries it needs to run (`proton`, `wine`, `wineserver`, `winetricks`), so a build that arrived from a zip or another machine works without further fiddling. A build can also be given a **displayed name** of its own (Wine versions): it is used everywhere the build is offered — library config, settings and the *Open with PLWL* popup — while the folder in `files/wines/` keeps its name and the symlinks inside prefixes stay valid.
|
- **Imported builds** — drop any wine/Proton build into `files/wines/` (one nesting level auto-detected). Symlinks are rewired to relative and executable bits restored, so builds from a zip or another machine run as they are. A build can get a **display name** of its own; the folder keeps its name.
|
||||||
- **Proton without Steam** — fully supported. Proton is invoked like Steam does it: `STEAM_COMPAT_DATA_PATH` is a per-build compat folder linked to the launcher's own prefix, and `STEAM_COMPAT_CLIENT_INSTALL_PATH` points at an installed Steam client when there is one (Proton reads its legacy DLLs and `steamclient.so` from there, read-only) and at the empty `files/data/fake-steam` otherwise. GE builds work out of the box: PLWL launches through Proton's built-in UMU path (`UMU_ID`), which needs no Steam client at all. An app that breaks under umu.exe has a per-app **Disable UMU** switch in its config — PLWL then runs the build's own wine directly (Proton's `runinprefix` verb): no umu.exe/steam.exe launcher and no Proton prefix setup take part, so the run stays as close to plain wine as possible. (Proton's session init still reads `STEAM_COMPAT_CLIENT_INSTALL_PATH` on every verb; a disableUmu run gets the empty `fake-steam` folder and nothing is copied out of it.)
|
- **Proton without Steam** — invoked like Steam does it: `STEAM_COMPAT_DATA_PATH` linked to the launcher's prefix, `STEAM_COMPAT_CLIENT_INSTALL_PATH` pointing at a real Steam client when there is one (read-only) and at the empty `files/data/fake-steam` otherwise. GE builds run through Proton's UMU path (`UMU_ID`) with no Steam client at all. A per-app **Disable UMU** switch runs the build's own wine directly (`runinprefix`), as close to plain wine as it gets.
|
||||||
- **One C: drive per prefix** — Proton normally keeps its prefix one level deeper (`<compat>/pfx/drive_c`) than wine (`<prefix>/drive_c`), which left a prefix used with both kinds with two separate C: drives (two registries, two sets of saves). Whenever a prefix has no `pfx` yet (a fresh prefix, or a plain-wine prefix first used through Proton), PLWL makes `pfx` a relative symlink back to the prefix itself, so Proton and wine share one `drive_c`, one registry and one `dosdevices`. Existing layouts are left exactly as they are — prefixes from older versions keep their real `pfx/` folder, and a prefix with both `drive_c` trees keeps both, because two live Windows installs cannot be merged safely (both stay visible to the prefix import picker).
|
- **One C: drive per prefix** — when a prefix has no `pfx` yet, PLWL makes `pfx` a relative symlink back to the prefix itself, so Proton and wine share one `drive_c`, registry and `dosdevices`. Existing layouts (a real `pfx/` folder, a prefix with both trees) are left exactly as they are.
|
||||||
|
|
||||||
## Translations
|
## Translations
|
||||||
|
|
||||||
All UI text comes from JSON files in `files/translations/`. The `data` object carries the language names; `texts` maps keys to strings, with `{0}`, `{1}` ... placeholders filled at runtime.
|
All UI text comes from JSON files in `files/translations/`: `data` holds the language names, `texts` maps keys to strings with `{0}`, `{1}` ... placeholders. The bundled files are refreshed into that folder on every start when their content changed (so updates deliver new keys); user-added languages are never touched.
|
||||||
|
|
||||||
The translations bundled with a release are launcher-owned: on every start they are refreshed into `files/translations/` whenever their content changed, so launcher updates deliver new/changed keys to existing installs. Extra language files the bundle does not ship (user-added) are never touched.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Codebase overview, conventions and rules for contributors: [development.md](development.md).
|
Codebase overview and contributor rules: [development.md](development.md).
|
||||||
|
|
||||||
|
|
|
||||||
291
development.md
291
development.md
|
|
@ -9,13 +9,23 @@ src/ main process (Node/Electron backend)
|
||||||
main.js entry point: tray, single-instance handoff, --launch / --open
|
main.js entry point: tray, single-instance handoff, --launch / --open
|
||||||
argv.js command-line parsing shared by boot and second-instance handoff
|
argv.js command-line parsing shared by boot and second-instance handoff
|
||||||
window.js main window creation
|
window.js main window creation
|
||||||
ipc/ all ipcMain handlers
|
ipc/ all ipcMain handlers
|
||||||
index.js handler registration, icon protocol, dialogs
|
index.js handler registration, icon protocol, dialogs
|
||||||
games.js games service: run/stop state, wine resolution, open-with handoff
|
games.js games service: run/stop state, wine resolution, open-with handoff
|
||||||
lansync.js LAN sync handlers (identity, devices, re-roll, sync + progress)
|
lansync.js LAN sync handlers (identity, devices, re-roll, sync + progress)
|
||||||
runner.js spawns wine/proton (env, prefix, playtime, logs)
|
runner.js spawns wine/proton (env, prefix, playtime, logs) and owns
|
||||||
|
spawnTracked() - the log/playtime/exit tail every run shares
|
||||||
|
linuxrunner.js native Linux apps: redirected HOME/XDG, the isolation level
|
||||||
|
(none/relaxed/isolated/auto), the handed-document path and
|
||||||
|
the relaxed home redirects
|
||||||
|
openrelay.js the launcher side of the hand-off: the request folder an app
|
||||||
|
leaves what it wants opened in, watched while it runs
|
||||||
|
linuxapp.js native app detection (ELF/AppImage/.desktop) + icon lookup
|
||||||
|
sandbox.js bubblewrap probe (functional, cached) + buildBwrapArgs
|
||||||
|
(+ FS_ROOT, the one mount the relaxed level adds)
|
||||||
library.js apps registry (apps.json) + settings + playtime files, plus
|
library.js apps registry (apps.json) + settings + playtime files, plus
|
||||||
the "which prefix does this app use" / in-use guards
|
the "which prefix does this app use" / in-use guards and
|
||||||
|
the isolation-mode gate on the way in
|
||||||
wines.js wine/proton build scan, registry, portable symlink healing
|
wines.js wine/proton build scan, registry, portable symlink healing
|
||||||
prefixes.js prefix registry (files/wineprefixes)
|
prefixes.js prefix registry (files/wineprefixes)
|
||||||
symlinks.js relative symlink rewiring + executable-bit restoration
|
symlinks.js relative symlink rewiring + executable-bit restoration
|
||||||
|
|
@ -30,9 +40,16 @@ src/ main process (Node/Electron backend)
|
||||||
paths.js portable layout, FILES-relative path helpers, translation sync
|
paths.js portable layout, FILES-relative path helpers, translation sync
|
||||||
i18n.js translation loading + {0} placeholder filling
|
i18n.js translation loading + {0} placeholder filling
|
||||||
preload.js contextBridge API exposed to the renderer
|
preload.js contextBridge API exposed to the renderer
|
||||||
lansync.js LAN sync: identity + pairing, hashing, HTTP receiver (with its
|
lansync.js LAN sync receiver: identity + pairing, hashing, HTTP server
|
||||||
receive-progress hook), UDP discovery
|
(with its receive-progress hook); uses hashcache.js,
|
||||||
|
lansync-identity.js, lansync-manifest.js, lansync-discovery.js
|
||||||
lansyncpush.js the pushing side (createPusher factory, keeps helper state out)
|
lansyncpush.js the pushing side (createPusher factory, keeps helper state out)
|
||||||
|
lansync-plan.js planned (never immediate) deletions: the receiver's leftovers,
|
||||||
|
top-most folders, and execution of only what was approved
|
||||||
|
hashcache.js file hash cache (path -> size/mtime/hash) for LAN sync
|
||||||
|
lansync-identity.js identity + sync code (createIdentity factory)
|
||||||
|
lansync-manifest.js sender-side manifest building (createManifestBuilder)
|
||||||
|
lansync-discovery.js UDP beacon discovery (createDiscovery factory)
|
||||||
appicon.js window/taskbar icon
|
appicon.js window/taskbar icon
|
||||||
renderer/ UI (plain HTML/CSS/JS, no framework)
|
renderer/ UI (plain HTML/CSS/JS, no framework)
|
||||||
app.js views, modals, custom-select, all renderer logic
|
app.js views, modals, custom-select, all renderer logic
|
||||||
|
|
@ -45,183 +62,109 @@ test/ plain node scripts, run them with `node test/<name>.test.js`;
|
||||||
tools/ install.sh / createshortcut.sh / build helpers
|
tools/ install.sh / createshortcut.sh / build helpers
|
||||||
```
|
```
|
||||||
|
|
||||||
Main process vs renderer: the renderer never touches Node/Electron directly -
|
Renderer never touches Node/Electron directly — everything goes through the `window.plwl` bridge (preload.js) and ipcMain handlers in src/ipc/. Keep new APIs in that pattern.
|
||||||
everything goes through the `window.plwl` bridge (preload.js) and ipcMain
|
|
||||||
handlers in src/ipc/. Keep new APIs in that pattern.
|
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Keep it simple. Plain functions over classes, no abstractions "for later".
|
- Keep it simple. Plain functions over classes, no abstractions "for later".
|
||||||
- Comments explain the WHY (quirks, workarounds, races), not the WHAT. If the
|
- Structure: one topic per file. Extract a module once a file grows or logic repeats (see lansync-identity/manifest/discovery, hashcache). Factories (createX(...)) carry per-state helpers so module scope stays clean when several states share a module.
|
||||||
code says it, don't repeat it in words. No comments at all when nothing
|
- Comments are RARE and SHORT, always in English. One line, two at most. Explain only the WHY (a quirk, a workaround, a race) — never the WHAT. No comment at all when nothing needs explaining; no banners, no restating the next line, no changelog notes.
|
||||||
needs explaining.
|
- No duplicate logic: the second time you copy something, move it into a helper and call it from both places.
|
||||||
- No duplicate logic: the second time you copy something, move it into a
|
- Everything user-visible goes through the translation files (en-us.json, pl.json) with `{0}`, `{1}` placeholders. No hardcoded strings in app.js.
|
||||||
helper (a shared module in src/ or a function next to its users) and call
|
- Paths stored on disk are FILES-relative (portable layout): write with toPortablePath(), read with resolvePortablePath() — never store absolute paths for anything inside files/.
|
||||||
it from both places.
|
- Registries (apps.json, wines.json, prefixes.json) are rewritten whole via writeJson(); playtime lives in its own tiny file so the registry is not rewritten while a game runs. A settings sync batches its patches instead of one write per app/build (`library.updateGames`, `wines.applySettings`).
|
||||||
- Everything user-visible goes through the translation files (en-us.json,
|
- Tree walks (`copyTree`/`treeSize`/`removeTree`/`chmodTree`) keep TREE_CONCURRENCY operations in flight instead of one syscall per await. `copyTree` returns a `failed` count; an import that could not reproduce every entry is refused and its partial destination removed, never registered with holes.
|
||||||
pl.json) with `{0}`, `{1}` placeholders. No hardcoded strings in app.js.
|
- The app's own folder is `library.appFolder(game)` — the folder it was imported as, never `workdir` and never `dirname(exePath)`. The properties may point the working directory elsewhere, and the sandbox, the delete path and LAN sync all have to agree on the same tree.
|
||||||
- Paths stored on disk are FILES-relative (portable layout): write with
|
- Deleting user data needs a confirm modal; best-effort cleanup is try/catch-and-ignore with a comment saying why it is safe to ignore.
|
||||||
toPortablePath(), read with resolvePortablePath() - never store absolute
|
- Windows apps run through runGame()/buildEnv() — never spawn wine directly elsewhere (prefix handling, Proton env and playtime live there).
|
||||||
paths for anything inside files/.
|
- Native Linux apps run through linuxrunner.runLinuxGame()/buildLinuxEnv() the same way: HOME and the XDG folders are redirected into `{prefix}/linux_pfx/`, and bwrap (when the probe says it works) forces the same layout with mounts. Never spawn a native app elsewhere. `game.kind` is absent on old entries — that means Windows; `game.isolation` is one of four levels, resolved by linuxrunner.resolveLevel: `none` (plain run), `relaxed` (bwrap + the whole host filesystem mounted writable at sandbox.FS_ROOT) and `isolated` (bwrap with the stock read-only profile), with `auto` (default) meaning relaxed where bwrap works and none where it does not. Only `auto` may weaken itself — an explicit relaxed/isolated answers `sandbox-required` instead ("no silent downgrade"). library.js keeps exactly these four values, both when reading the registry and when a patch is saved (anything else falls back to `auto`), and the level always lands in the log and the UI. resolveLevel awaits the probe when nothing has measured yet: a shortcut boots HEADLESS, so no renderer ran `sandbox:status` — without that wait `auto` would read an empty cache and quietly pick `none` (and an explicit level would refuse a machine that can sandbox perfectly well).
|
||||||
- Registries (apps.json, wines.json, prefixes.json) are rewritten whole via
|
- Under bwrap ONLY two things stay writable: the redirected home and the app's own folder (`writableAppDir`: inside files/apps, or the folder the app was imported from — never `/usr`, `/etc`, `/bin`… and never files/wines). Portable apps write logs and data next to themselves and a read-only app folder makes them refuse to start (Prism Launcher: "couldn't create a log file - Read-only file system"). That bind is written LAST in buildBwrapArgs on purpose: an app lives under the host's home and the redirected home is mounted over that very directory, so the bind used to be swallowed by it and bwrap answered `execvp <app>: No such file or directory` — a click on Play and the window closed again (measured on 0.12.0, and again as a real exec in test/sandbox-appdir.test.js). Everything else is `EROFS` by design — at the isolated level. Relaxed is the deliberate exception and says so in the UI: `FS_ROOT` is the same filesystem, mounted writable, so "everything else" is only unreachable through the real paths.
|
||||||
writeJson(); playtime lives in its own tiny file so the registry is not
|
- Two pipes feed one log in spawnTracked: neither may end the stream (pipe(ws, { end: false }), closed when both sources drained, at the latest LOG_DRAIN_MS after the exit) and the write stream carries an error listener. Ending it at `exit` while a pipe still had data killed the whole launcher with ERR_STREAM_WRITE_AFTER_END.
|
||||||
rewritten while a game runs.
|
- A prefix or wine build a running app uses cannot be renamed/deleted (wine holds files open there): the guards sit next to the handlers in src/ipc/, use the games service's running list plus library.js helpers (prefixNameForGame/gamesUsingPrefix/is*InUse) and answer `{ ok:false, error:'inUse'|'hasApps', apps:[names] }` for the toast.
|
||||||
- Deleting user data needs a confirm modal; best-effort cleanup is
|
- Long operations stream progress through the single import:progress slot as `{ phase: 'count'|'copy'|'remove', files, bytes, totalFiles, totalBytes }` (runWithProgress for deletes/imports). Emit 'count' (a treeSize walk) first, copy/remove through the fsutil helpers. An isolated hand-off has its own slot, handoff:progress (`{ phase: 'copy'|'done'|'error', name, bytes, totalBytes, notice }`), because it can start before the window exists: main.js gets the event first and, when `notice` is set (a copy above linuxrunner.STAGE_NOTICE_BYTES), puts the launcher window up so the renderer's popup can be seen; below that the copy is over before a window could paint. Copies of one file go through fsutil.copyFileProgress (streamed, throttled by progressGate, `.part` + rename).
|
||||||
try/catch-and-ignore with a comment saying why it is safe to ignore.
|
- CSS: reuse the existing variables/classes (.button, .field-label, .custom-select, form-card); spacing comes from --button-margin and flex gaps, not per-element magic numbers.
|
||||||
- Windows apps run through runGame()/buildEnv() - never spawn wine directly
|
- The blanket `transform: translateZ(0)` (crisper small glyphs) makes every element its own stacking context, so a select's own z-index cannot escape the wrapper it sits in: a list opened inside `.linux-only`, `.win-only` or an override row was painted UNDER the field label and textarea that follow it (measuring with `elementsFromPoint` over the open list returned the label). CustomSelect lifts the chain instead of the host — `lift()` adds `.select-lift` to every STATIC ancestor while the list is open, and `close()`/`closeAll()` take it off again; positioned ancestors are skipped, so the fixed modal overlay keeps stacking over every view on its own.
|
||||||
somewhere else (prefix handling, Proton env and playtime live there).
|
|
||||||
- A prefix or wine build that a running app uses cannot be renamed/deleted
|
### Native Linux apps
|
||||||
(wine holds its registry and user files open in that folder): the guards sit
|
|
||||||
next to the handlers in src/ipc/, use the games service's running list plus
|
- Entry point detection (linuxapp.js): ELF, `.AppImage`, or a `.desktop` whose `Exec=` points at one; `lib/`, `share/` … are skipped. The icon comes from the `.desktop` `Icon=`, an image next to the program, or the system theme — `extractIconFromExe` is PE-only.
|
||||||
the helpers in library.js (prefixNameForGame/gamesUsingPrefix/is*InUse) and
|
- The redirected home starts as a COPY of the desktop preferences (kdeglobals, kdedefaults/kdeglobals, gtk-3.0|gtk-4.0/settings.ini, qt5ct/qt6ct, Trolltech.conf, user-dirs.dirs). Without the seed Qt falls back to its built-in light palette (measured: `#eff0f1` against the host's `#202326`). The copy keeps following the host while the app has not touched it: the hashes of what PLWL wrote live in `{prefix}/linux_pfx/.seeded.json`, OUTSIDE the app's home, and a copy with no marker entry (or one the app has since changed) is the app's own and is never overwritten. The PulseAudio cookie is seeded once, because with a redirected home Pulse looks in `$HOME/.config/pulse/cookie` and the app otherwise comes up silent; `~/.Xauthority` is refreshed instead, since nothing exports XAUTHORITY to a sandboxed app.
|
||||||
answer { ok:false, error:'inUse'|'hasApps', apps:[names] } for the toast.
|
- Host fonts/icons/themes/colour schemes are not copied but `--ro-bind` under `/tmp/.plwl-host/…` and prepended to XDG_DATA_DIRS: live, and unable to write back into the user's `~`. Symlinks were measured and rejected — the prefix is mounted ON `~`, so a link to `~/.config/kdeglobals` points at itself (`Too many levels of symbolic links`); without bwrap the same link writes through into the real home.
|
||||||
- Long operations stream progress through the single import:progress slot as
|
- `forceX11` (per app) sets XDG_SESSION_TYPE=x11 + QT_QPA_PLATFORM=xcb + GDK_BACKEND=x11 so the app and everything it launches falls back to XWayland, where window icons still exist at all (Wayland forbids them; a compiled GLFW probe showed XDG_SESSION_TYPE picks X11 vs Wayland, `GLFW_PLATFORM` does not exist). `game.env` still wins.
|
||||||
{ phase: 'count'|'copy'|'remove', files, bytes, totalFiles, totalBytes } and
|
- SESSION_MANAGER is dropped in the sandbox: the session socket sits in the private `/tmp`, so the pointer led nowhere (Qt: "Could not open network socket").
|
||||||
the renderer drives a bar from it (runWithProgress for deletes/imports).
|
- Imports normalise permissions (fsutil.chmodTree): owner `rwx`, `x` mirrored from `r` (0644 → 0755). Sources are archives, FAT sticks and read-only backups.
|
||||||
Emit the 'count' phase first (a treeSize walk) and copy/remove through the
|
- **Reset Linux config** on every prefix row removes exactly what PLWL seeded (DESKTOP_SEEDS, the pulse cookie, `home/.Xauthority`, `.seeded.json`) plus the folders that existed only for the seed, so the next start seeds clean. syncDesktopConfig can only follow a copy it can still prove is its own; the reset does not argue. App data (`data/`, `cache/`, `state/`, `config/<app>/`, the rest of `home/`) is never touched, and a running app from that prefix blocks it (the usual `inUse` guard).
|
||||||
fsutil helpers; never ship a progress bar no data can move.
|
- A path the desktop hands the app (a drag-and-drop's `text/uri-list`, a file picked in a dialog - `QT_QPA_PLATFORMTHEME=xdgdesktopportal` on this desktop, so the chooser is the host portal's and answers with a real path) is a real `/home/<user>/…` path, and under bwrap that name is the app's own redirected home. A mount cannot fix it (a mount cannot be added to a namespace that is already running) and binding the host home in would hand the app the user's real folders at every level: `syncHomeRedirects` links the host home's own entries (never a name the profile mounts itself - `.plwl` and the XDG folders, which a link would turn into bwrap's `Can't mount on symlink destination` on the first relaxed run of a fresh prefix, measured; never a name the app already has, never a link resolving nowhere) into the one view the level has - at relaxed to `FS_ROOT/<entry>`, which is the real folder, live and writable (measured: the real file reads through the link, and a write through it lands on it), and at the other levels there is no view to point at, so the links are taken off again before the app starts. The name the app's own folder sits under is never linked either: a portable bundle resolves itself with `readlink -f "$0"`, so the link answered for the app's whole tree - instances included - with `FS_ROOT`, and Prism Launcher read that as "Your instance folder is in a temporary folder: '/tmp'!" on every start (measured on 11.0.2 on the device whose launcher lives in the home; the same app on a launcher outside `~` kept its real path and stayed quiet - the folder is bound at its real path anyway). Only the launcher's own links are removed (matched by their `FS_ROOT` target), and an isolated app gets no home folders at all: what reaches it there is the hand-off copy. The links outlive a run (a hand-off into an app that is already running is still using them); the next start at a level that cannot serve them clears them.
|
||||||
- CSS: reuse the existing variables/classes (.button, .field-label,
|
- With HOME bound straight to `{prefix}/linux_pfx/home`, the user's real `~` is invisible to the app — one reason absolute paths an app wrote into its config before the import (Prism's instance `JavaPath`) do not resolve. `relaxed` does not change that shadowing, but it does make the real filesystem reachable through `FS_ROOT`/home/… — and `none` gives the app the real paths outright, which is what those two levels are for.
|
||||||
.custom-select, form-card); spacing rhythm comes from --button-margin and
|
- A clicked link belongs to the host desktop, so it leaves through `org.freedesktop.portal.OpenURI`: the `xdg-open` BINARY is replaced inside the sandbox (a written shim `--ro-bind` over `/usr/bin/xdg-open`, and over the app bundle's own `bin/xdg-open` too — that one sits first in the app's PATH, and its sharun wrapper dies on a closed stderr (`SIGPIPE`) before it ever delegates, which is exactly what Minecraft does after `Runtime.exec`; the name the bundle links to that same file, `gio-launch-desktop`, is answered by the shim, since bwrap refuses to mount over a symlink; a `-`-prefixed argument exits 2, because Java probes for `xdg-open` by running it with one) and Qt execs it (measured — `QGenericUnixServices` picks `xdg-open` first, and reaches the portal itself only under `/.flatpak-info`/`SNAP`, which a bwrap profile does not have); Electron does the same. GIO resolves an URI IN-PROCESS, which no shim can reach: for that the prefix seeds `home/.plwl/open-url` (launcher-written, like the shim) plus a desktop entry and a `mimeapps.list` naming it the default for `x-scheme-handler/http|https`, `text/html`, `x-scheme-handler/file` and `inode/directory`. Without the seed GIO falls back to the host's `/usr/share` list — measured: it offered `brave-origin.desktop`, and `gio open` answered "The specified location is not supported". Measured end to end (both backends): the portal launches the host's `helium.desktop` with the exact URI. `GIO_USE_PORTALS=1` was measured and REJECTED: it opened NOTHING and still reported success (GIO's portal path wants a sandbox marker this launcher does not fake). A KDE-native app resolving the link in-process through KIO (`kde-open`) stays uncovered — KDE ignores the seeded list (measured).
|
||||||
the flex gap of the container, not per-element magic numbers.
|
- The games service owns a run through the entry object it created: a start that failed, and the exit of a PREVIOUS run of the same app (stopped, then relaunched — SIGKILL is asynchronous), may not clear the new run's entry or close its request watcher (`forget`/`handleExit` compare that object). A start still in flight is never pruned, however long its isolated hand-off copy takes, because pruning it would drop the watcher mid-start; Stop during that window only records a kill request (`killRequests`), which the start fulfils the moment it has a pid — clearing the state there instead would let a second launch race past the first. Measured in test/games-service.test.js.
|
||||||
- LAN sync has two sides: the receiver lives in lansync.js, the sender in
|
- The portal is not told about a FOLDER or a document, which is what "Open Pack Folder" or a drop asks for: it cannot see a path from in here and answers `Response` 2 for `file://` (measured, while https answered 0). The shim leaves those in `home/.plwl/open-request` (written as `.part` and renamed, so nothing ever reads half a request) — a live bind of the app's home, so it reaches an app that is ALREADY running — and the launcher opens it out here with the host's own `xdg-open` (openrelay.js: `createOpenRelay` watches that folder for exactly as long as the app runs, consumes each request once, and quits watching on exit). The target is rewritten on the way out — the private `$HOME` back to `{prefix}/linux_pfx/home`, `FS_ROOT` back to `/` at relaxed — so the host opens the REAL folder and never a path that only exists inside. Nothing in the sandbox can start a host program itself: measured, the host's own `xdg-open` run in there ends in `kde-open`, which exits 0 and opens no window at all. A scheme nothing on the host claims ends in the desktop's own "choose an application" dialog, which is the point of handing it out instead of guessing.
|
||||||
lansyncpush.js and is built by createPusher(helpers) - several launcher
|
- The portal's `Response` is NOT read back: it is a signal addressed to the caller's own bus connection, so a separate `gdbus monitor` never receives it (measured: 15 calls, 15 timeouts, while the portal did open what it was given) and the readback only cost the app 3 s per click before giving up. Only whether the call went out is trusted, so a machine without a portal needs no special case any more: the call fails, and the request file is what still lands. (0.5.x had the host's own script parked inside the sandbox as the shim's fallback; measured, that route opens nothing.)
|
||||||
states share the module, so nothing may be kept in its module scope. Both
|
- A file handed over (a shortcut's `--file %f`, a drop on it) is the one path the sandbox cannot see by itself: a file out of the user's home is buried under the prefix home, one out of `/tmp` sits behind the private tmpfs, and the rest of the machine is read-only. Per-file `--bind` (what 0.5.0 did) is the wrong tool: a mount cannot be added to a namespace that is ALREADY running, so a hand-off into a running app could never be served that way. `handedPathFor` instead names the document in the one shape the chosen level can reach, and the app is told nothing else: `none` → the real path; `relaxed` → `FS_ROOT` + the real path; `isolated` → a copy in `{prefix}/linux_pfx/home/plwl-handoff/<name>`, which is a live bind of the app's home, so it reaches a running app too (measured end to end in test/linux-e2e.js: both levels hand a document to a sandbox that is already up). Copies are one per name and refreshed on every open (the folder cannot grow), written through a `.part` name and renamed so nothing ever reads a half-written document, and the app's edits stay in the copy — that IS the level's promise. A handed path that is gone fails the hand-off instead of launching an app with no document.
|
||||||
sides report their own progress (the receiver through the onReceive hook),
|
- `FS_ROOT` (`/tmp/plwl-fs-root`) is where relaxed mounts the host filesystem, and it has to live under the private `/tmp`: bubblewrap creates the mount point itself, and the root of a read-only root cannot be written to (measured on 0.12.0: `Can't create file /PLWL_FS_ROOT: Read-only file system`, for a `--dir` before the `--ro-bind / /` too). Measured also: through it the real `~` reads, a write lands on the real file, and a file created AFTER a sandboxed run started is visible inside it — that last one is what makes a hand-off into a running app possible at all.
|
||||||
so each machine can show the transfer.
|
- Not built: a private or overlay `/etc` (`buildBwrapArgs` accepts `etcRoot`, nothing sets it; `--ro-bind / /` leaves `/etc` read-only, which is all we promise today), `--tmpfs /home` against OTHER users' homes (`hideHome` is plumbed, no UI); app data always lands in `{prefix}/linux_pfx`.
|
||||||
- An incoming sync needs CONSENT: a correct code opens the gate, but the
|
- Measured, not assumed (Debian 13, kernel 6.12, `bwrap` 0.12.0): the functional probe passes here, no firejail/proot/fuse-overlayfs on the box, `/etc` is 16 MB and its `machine-id` is read-only even on the host.
|
||||||
receiver's onIncoming hook (registered in main.js) shows an Accept/Decline
|
|
||||||
popup and only a click - answered through lansync:respondIncoming - starts
|
### LAN sync
|
||||||
the session. Unanswered requests expire after INCOMING_TIMEOUT_MS; a decline
|
|
||||||
returns 'rejected' to the sender and KEEPS the pairing (the code was proven).
|
- Two sides: the receiver lives in lansync.js, the sender in lansyncpush.js, built by createPusher(helpers) — several launcher states share the module, so nothing may sit in its module scope. Both sides report their own progress (receiver through the onReceive hook).
|
||||||
Without an onIncoming hook (tests, headless) the gate stays open.
|
- **Deletions are planned, never inline.** `handleCommit` only walks the unit (`planUnitDeletions` -> `lansync-plan.js`) and stores the list on the session; `/plwl/apply` turns units plus registry leftovers into one review (`startReview`), and `executeReview` is the only place that removes anything. A path in the keep set survives (including a file inside a fully planned folder), an unreadable subtree is planned as kept, and a selection's excluded folders are protected the same way. Registry removals wait with the files, so unchecking an app row cannot leave its entry behind without its folder or the other way round. A review that times out, is skipped (no popup, headless) or never arrives cancels to **zero deletions**. A peer without the `review` flag in its verify answer cannot sync units (`old-peer`): it would prune inside its commit with no list to approve; a settings-only sync stays allowed because it deletes nothing.
|
||||||
- Sync progress is bytes plus per-category counters (items = announced units,
|
- An incoming sync needs CONSENT: a correct code opens the gate, but the receiver's onIncoming hook (registered in main.js) shows Accept/Decline, answered through lansync:respondIncoming. Unanswered requests expire (INCOMING_TIMEOUT_MS); a decline returns 'rejected' and KEEPS the pairing (the code was proven). Without an onIncoming hook (tests, headless) the gate stays open.
|
||||||
itemsDone = finished ones, totals = everything the sender picked), never raw
|
- Progress is bytes plus per-category counters (items = announced units, itemsDone = finished, totals = everything picked) — "Apps: 2/5". The bar follows the CURRENT PHASE's bytes (barBytes/barTotalBytes) so a phase starts from an empty bar. Hashing and copying walk tens of thousands of files: every per-file update goes through progressGate (~10/s, phase changes forced) — never one IPC message per file. The gate holds a snapshot FUNCTION on the sender side: the snapshot is built when it flushes, so a throttled update never shows a byte count one gate window old.
|
||||||
file counts: the popup says "Apps: 2/5". The bar follows the CURRENT PHASE's
|
- A finished sync ends with a result POPUP, not a toast: the sender swaps its locked progress modal for the summary, the receiver swaps the finished bar after ~1 s. It lists units per category, files moved/unchanged/removed, bytes transferred and the total size of the synced trees (`summary.sizeBytes`; the receiver adds each unit's announced `unitBytes`, replacing a retried unit's entry instead of double-counting). The device name is set with textContent, never interpolated into the modal HTML.
|
||||||
bytes (barBytes/barTotalBytes, not the overall bytes/totalBytes) so a phase
|
- App selection is a seam, not a UI yet: `buildUnits(cats, selection)` and `buildRegistryPayload(cats, selection)` take `selection = { apps: [ids] }` (a missing selection means every app). The payload then carries `appScope: 'selected'`; `applyApps` keeps the apps of that category that were not sent, and skips the `apps/` orphan sweep, instead of mirroring the category. Without the marker the sender's list is complete and extras are deleted as before.
|
||||||
starts from an empty bar instead of inheriting a full one - otherwise hashing
|
- The sender's transferred counter counts bytes AS THEY ARE READ (putFile's onBytes callback), not on upload completion: a multi-GB file has to move it while it flies or the sender shows hundreds of MB behind the receiver. A unit's byte total travels as the `{ unitBytes }` control record BEFORE its files; the receiver has to handle it before the entry filter (it has no path/type, so a filter-first order dropped it and the comparing bar measured against a placeholder total of 1 — read as 100% for the whole phase). The comparing phase ends on a full bar, then the copy bar restarts empty (its first PUT byte emits it).
|
||||||
looks finished the moment it starts. Hashing and copying walk tens of
|
- While the receiver answers the consent popup (and whenever its side waits on the other one), both popups show "Waiting for the other device…" (lansync.waiting): the sender emits a 'wait' phase before verify, the receiver's ping handler emits stage 'wait'. A unit counts toward "Apps: 1/5" as soon as its comparison starts (not when it commits), and phases without a file count (hash/compare/commit) clear the counter so a stale "3 / 10 files" never lingers. On the wire the receiver's comparing phase is called 'compare' (sender-side 'plan' is unchanged).
|
||||||
thousands of files, so every per-file update goes through progressGate
|
- Registry-level settings are opt-in (the "settings" category). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu, isolation and forceX11 per app) and appOrder. The receiver only patches what already exists (unknown ids skipped, nothing created or deleted, unknown local entries kept at the end of the order) through the normal helpers — batched, so a large library is one registry write: `library.updateGames(patches)`, `wines.applySettings(entries)`, `reorderGames`.
|
||||||
(~10/s, phase changes forced) - never send one IPC message per file.
|
- File hashes are remembered in files/data/lansync-hashes.json (path -> size + mtime + hash; machine-local, never synced): equal size and mtime means equal bytes, so a repeat sync only stats the tree. Hashing and the receiver's comparison both run HASH_CONCURRENCY files at a time.
|
||||||
- Registry-level settings are opt-in (the "settings" sync category, shown as
|
- A file's unit and path travel in the PUT's query, not in headers: Node rejects any header value holding a code above U+00FF (`ERR_INVALID_CHAR`), and a real library has those names (Minecraft pack names, Polish file names) — one such file killed the whole sync, leaving the receiver with half an app and no registry entry. Categories are matched by exact name; anything this build does not know is ignored.
|
||||||
"Sync settings"; the old name "defaults" is still accepted as an alias on
|
- File permissions travel with the manifest (`mode`) and are applied after the bytes; a file with the right hash but wrong mode is repaired without re-upload.
|
||||||
BOTH sides - the receiver normalizes payload.categories, so a sender from an
|
- A manifest the sender could not finish is not a licence to delete: a read that fails (a directory or file the user cannot open) sends a `{ unitIncomplete: true }` control record, and an aborted plan body is detected with `req.complete`. Either way the receiver still writes what it got but skips `pruneTree` for that unit — a missing entry may only mean "could not read it". For the same reason `applyApps`/`applyWines`/`applyPrefixes` mirror nothing when the payload has no list at all (malformed/truncated apply), while an explicit empty list still means "the sender has none" (a partial app selection excepted: it keeps the apps it did not send).
|
||||||
older version keeps working). payload carries defaults (default wine and
|
- A PUT settles on every failure path (`aborted`/`close` without the body having ended, stream or writer error): the `.plwl-sync-tmp` is removed and the request cannot stay half-open forever. The 60 s `PUT_IDLE_MS` only covers a body that stops sending.
|
||||||
prefix), wineSettings (displayName + disabled per build id), prefixSettings
|
- Verification is GLOBAL one-at-a-time (`state.verifying` is a boolean, set before the first await — parallel requests would pay the 2 s cost in parallel, which is exactly what makes a scheme pointless). One outgoing sync per process (`syncTo` answers 'busy'), so two syncs can never write the same receiver files at once. A failed bind resolves `{ ok: false, error }` instead of leaving `listening` pending forever, and the beacon only starts when the server is up.
|
||||||
(the registry names; a settings sync never renames or creates folders -
|
- Names arriving on the wire that become paths (app ids in `data/icons/<id>`, prefix folder names) are validated (`safeName`/`safePrefixName`): a corrupted or older peer must not be able to store `../../x` and turn a later Rename/Delete click into a write outside files/.
|
||||||
that is the prefixes category), appSettings (name, wine, prefix, args, env,
|
- A sender hashing a big unit is minutes of silence: it pings `/plwl/ping` every HEARTBEAT_MS, the receiver re-arms its popup watchdog (stage 'wait' shows "Waiting for the other device…" but never while a file is being written). A receiving popup its watchdog closed reopens on the next real event — only 'done' ends a sync, and reopening never asks for consent again (consent is the onIncoming popup at verify time; the progress popup is driven by the same token's events).
|
||||||
dllOverrides, disableUmu per app id) and appOrder. The receiver only patches
|
- Native Linux app data (`{prefix}/linux_pfx/`: the redirected home, configs, caches, PulseAudio cookie, state) travels INSIDE its prefix unit like the Windows side of the same prefix: the manifest walks it and the mirror prunes it, so no file of the launcher's own trees is special-cased on the wire. Native apps sit in the same categories as Windows ones (`apps-portable`/`apps-prefix`), and only the per-app fields that are Linux-only (isolation, forceX11) are named as such in the settings payload.
|
||||||
things that already exist there: an unknown wine id / prefix / app is
|
- A sync must never walk through a symlinked ROOT: pruneTree/healUnitRoots (sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat the root first. `listDirs()` counts symlinked folders as units, so a build on another disk still travels — as the link it is (`wineUnits` maps nested builds to their TOP folder under wines/).
|
||||||
skipped, nothing is created or deleted, and the order merge keeps local
|
- Wine/Proton rewrite prefix links to absolute targets whenever they update a prefix, so on-disk links are not always relative after a run: the sync relativizes them on the wire (portableLinkTarget) and refreshWines()/healPrefix() repair absolute links that reached disk anyway. A build whose binaries lost the executable bit is detected in refreshWines() and healed once (spawn() on a non-executable `proton` fails with EACCES while the game looks started).
|
||||||
entries the sender does not know at the end. Values are written through the
|
|
||||||
normal helpers (setWineDisplayName clears a custom label when the sender has
|
### Paths and prefixes
|
||||||
none, library.updateGame pins the prefix of prefix imports, reorderGames
|
|
||||||
merges by id).
|
- Symlinks inside everything PLWL owns are stored RELATIVE whenever the target can be expressed locally (symlinks.js owns this: rewireSymlinks + restoreExecutableBits; every writer calls it — imports, healPrefix, sync on the wire and after a unit lands). dosdevices drive mappings and targets leaving files/ stay absolute — machine-specific by design.
|
||||||
- LAN sync remembers file hashes in files/data/lansync-hashes.json, keyed by
|
- A prefix with NO `pfx` yet gets ONE C: drive: `pfx` is a relative symlink to the prefix itself (`prefixes.ensureUnifiedPrefix` only CREATES it), so Proton's `<compatdata>/pfx` shares drive_c, registry and dosdevices with plain wine. Existing layouts are left as they are — hoisting files one level up was tried and removed (it breaks relative links, the umu.exe link kills a Proton launch). Never assume a single drive_c: use `prefixDriveRoots()` and compare realpaths.
|
||||||
path with size + mtime (machine-local, never synced): equal size and mtime
|
- App launch env is built in runner.buildEnv only. WINEDLLOVERRIDES is an ordered semicolon list (inherited first, app's last; an empty `dll=` is "disabled" and must survive the filter). Proton appends its dxvk/nvapi defaults AFTER everything. `disableUmu` drops UMU_ID and runs Proton's `runinprefix` verb. STEAM_COMPAT_CLIENT_INSTALL_PATH is required in BOTH modes (Proton's session init reads it on every verb; missing it aborts the launch before wine starts): a real Steam client when found, else the empty files/data/fake-steam.
|
||||||
means equal bytes, so a repeat sync only stat's the tree. Hashing and the
|
|
||||||
receiving side's comparison both run HASH_CONCURRENCY files at a time.
|
### Launch and shortcuts
|
||||||
- A modal showing a running operation (a sync) opens locked:
|
|
||||||
showModal(html, { locked: true }) ignores the backdrop and Escape, because
|
- Generated shortcuts are file handlers too: `Exec=<launcher> --launch <id> --file %f` (a literal % is `%%`). argv.js takes the file from `--file` or a bare trailing path, and runner.windowsPathFor passes it to the app as Windows would (`C:\...` inside the prefix, `Z:\...` otherwise). A file given to an already running app is a handoff (untracked process, no log/playtime writes).
|
||||||
the user cannot cancel the operation by dismissing it.
|
- The second-instance handoff must never read the token next to its option: Electron RE-SERIALIZES argv (`--launch <id> --file <path>` arrives as `--launch --file --allow-file-access-from-files <id> <path>`), so argv.js pairs options with positionals IN ORDER (inline `--name=value` wins, the dev app path is skipped). Reading the next token used to take the launch id as the opened file. `electron test/argv-handoff.test.js` proves the real serialization.
|
||||||
- Symlinks inside everything PLWL owns (a wine/proton build, a prefix, an
|
- A modal showing a running sync opens locked: showModal(html, { locked: true }) ignores backdrop and Escape.
|
||||||
imported app) are stored RELATIVE whenever the target can be expressed
|
- Tests are plain assert-by-print scripts (see test/): set PLWL_BUILD_DIR to a temp dir, check(), exit 1 on failure. Add one when fixing a bug.
|
||||||
locally: a Proton prefix is full of links into its build, and an absolute one
|
|
||||||
points at the machine that wrote it. symlinks.js owns this (rewireSymlinks,
|
|
||||||
plus restoreExecutableBits for the executables a content copy drops) and
|
|
||||||
every writer calls it: importWineFolder/healBuild, importer after copy/move,
|
|
||||||
healPrefix, and the sync both on the wire (portableLinkTarget sends the
|
|
||||||
relative target) and after a unit lands (healUnitRoots). dosdevices drive
|
|
||||||
mappings and targets that leave files/ (a mount, a system path, the user's
|
|
||||||
home) stay absolute - they are machine-specific by design.
|
|
||||||
- A prefix that has NO `pfx` yet gets ONE C: drive: `pfx` is a relative
|
|
||||||
symlink to the prefix itself, so Proton's `<compatdata>/pfx`
|
|
||||||
(runner.linkPrefix) resolves to `<prefix>` and shares `drive_c`, the
|
|
||||||
registry and `dosdevices` with plain wine. `prefixes.ensureUnifiedPrefix`
|
|
||||||
only ever CREATES that link; an existing layout (a real `pfx/` folder from
|
|
||||||
an older PLWL, a both-drives prefix) is left exactly as it is. Hoisting a
|
|
||||||
prefix's files one level up was tried and removed: it breaks every relative
|
|
||||||
link written against the old depth (the `umu.exe` link into the build is the
|
|
||||||
one that kills a Proton launch), and moving a live Windows install is not
|
|
||||||
the launcher's call. `healPrefix`/`rewireSymlinks` repairs such a prefix by
|
|
||||||
re-anchoring broken relative links through files/. Never assume a single
|
|
||||||
drive_c path: use `prefixDriveRoots()` (deduplicated) and compare realpaths
|
|
||||||
when deciding whether a folder is inside a prefix's C: drive.
|
|
||||||
- App launch env is built in runner.buildEnv only. WINEDLLOVERRIDES is an
|
|
||||||
ordered semicolon list: inherited value first, the app's overrides last,
|
|
||||||
because Wine lets a later entry replace an earlier one for the same module.
|
|
||||||
An empty setting (`dll=`) is Wine's "disabled" and must survive the filter.
|
|
||||||
Proton appends its own dxvk/nvapi/steam.exe defaults AFTER everything, so an
|
|
||||||
app override only wins where Proton has no default. `disableUmu` (per app)
|
|
||||||
drops UMU_ID and switches Proton to its `runinprefix` verb, so no
|
|
||||||
umu.exe/steam.exe launcher and no Proton prefix setup take part - the
|
|
||||||
build's own wine runs as close to plain wine as it gets.
|
|
||||||
STEAM_COMPAT_CLIENT_INSTALL_PATH must be set in BOTH modes: Proton's session
|
|
||||||
init reads it on every verb (sync_appcache runs even for runinprefix, and
|
|
||||||
with SteamGameId set a missing variable aborts the launch with a KeyError
|
|
||||||
before wine starts). On the default path it prefers a real Steam client
|
|
||||||
found on the machine; a disableUmu run always gets the empty
|
|
||||||
files/data/fake-steam, whose contents are never read.
|
|
||||||
- Wine and Proton create prefix links with ABSOLUTE local targets whenever they
|
|
||||||
update a prefix (font/dll stubs, Steam appcache), so on-disk links are not
|
|
||||||
always relative after a run: the sync payload relativizes them anyway, and
|
|
||||||
refreshWines()/healPrefix() repair what arrived from an older PLWL. A build
|
|
||||||
whose binaries lost their executable bit is detected by a cheap audit in
|
|
||||||
refreshWines() and healed once, because spawn() on a non-executable `proton`
|
|
||||||
fails with EACCES and the game would look like it started while nothing ran.
|
|
||||||
- File permissions travel with a sync (manifest entry `mode`): the receiving
|
|
||||||
side applies them after writing the bytes and repairs a file that has the
|
|
||||||
same hash but the wrong mode without re-uploading it.
|
|
||||||
- A sync must never walk through a symlinked ROOT. pruneTree/healUnitRoots
|
|
||||||
(sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat their root
|
|
||||||
first and treat anything that is not a real directory as the link (or file)
|
|
||||||
it is: a build, prefix or app folder kept on another disk must not have its
|
|
||||||
target deleted, pruned or rewritten through the link. `listDirs()` in the
|
|
||||||
sync counts symlinked folders as units, so such a folder still travels - as
|
|
||||||
the link it is (`wineUnits` maps nested builds to their TOP folder under
|
|
||||||
wines/, otherwise a zip-extracted build becomes a phantom unit whose root
|
|
||||||
does not exist and the "Wine versions" totals grow).
|
|
||||||
- Generated app shortcuts are file handlers too: `Exec=<launcher>
|
|
||||||
--launch <id> --file %f` (the field code is appended after escaping - a
|
|
||||||
literal % is `%%`). argv.js takes the file from `--file` or from a bare
|
|
||||||
trailing path (KDE appends it to Exec lines without a file field), and
|
|
||||||
runner.windowsPathFor passes it to the app the way Windows would: `C:\...`
|
|
||||||
when it sits inside the prefix's drive_c, `Z:\...` otherwise. Giving a file
|
|
||||||
to an app that is ALREADY running is a handoff (runGame `handoff: true`):
|
|
||||||
an extra untracked process, no log/playtime writes, because the app's own
|
|
||||||
single-instance logic is what actually opens the file. The second-instance
|
|
||||||
handoff must never read a value from the token next to its option: Electron
|
|
||||||
RE-SERIALIZES that argv (all switches first, their values after, the dev app
|
|
||||||
path among them), so `--launch <id> --file <path>` arrives as
|
|
||||||
`--launch --file --allow-file-access-from-files <id> <path>`. argv.js pairs
|
|
||||||
options with positionals IN ORDER (inline `--name=value` wins, the dev app
|
|
||||||
path is skipped); reading the next token used to take the launch id as the
|
|
||||||
opened file, so an app started without its image.
|
|
||||||
`electron test/argv-handoff.test.js` proves the real serialization.
|
|
||||||
- A sender hashing a big unit is minutes of network silence: it pings
|
|
||||||
`/plwl/ping` (+token) every HEARTBEAT_MS for the whole sync, the receiver's
|
|
||||||
handler re-arms its popup watchdog (stage 'wait' changes nothing on screen),
|
|
||||||
and a receiving popup its watchdog closed reopens on the next real event -
|
|
||||||
only the 'done' stage ends a sync for good, and reopening NEVER asks for
|
|
||||||
consent again: the Accept/Decline gate is the onIncoming popup at verify
|
|
||||||
time (a fresh session), while the progress popup is driven by the same
|
|
||||||
token's events. A 'wait' event that reopens a closed popup fills the phase
|
|
||||||
line (`recvWaiting`) instead of leaving every line blank.
|
|
||||||
- Tests are plain assert-by-print scripts (see test/): set PLWL_BUILD_DIR to
|
|
||||||
a temp dir, check(), exit 1 on failure. Add one when fixing a bug.
|
|
||||||
|
|
||||||
## Verify before you commit
|
## Verify before you commit
|
||||||
|
|
||||||
```
|
```
|
||||||
node --check <changed js files> # syntax
|
node --check <changed js files> # syntax
|
||||||
node test/import.test.js # ...and the other test files
|
node test/smoke.js # quick sanity
|
||||||
node test/lansync.test.js # LAN sync contract (identity, pairing, mirror)
|
node test/lansync.test.js # LAN sync contract (identity, pairing, mirror)
|
||||||
node test/lansync-receiving.test.js # receive-side progress events (the popup)
|
node test/lansync-receiving.test.js # receive-side progress events (the popup)
|
||||||
node test/lansync-settings.test.js # playtime, app settings, wine/prefix + defaults
|
node test/lansync-progress.test.js # live sender bytes, comparing bar widths
|
||||||
node test/lansync-links.test.js # wire: exec bits + relative links in a sync
|
node test/lansync-settings.test.js # playtime, app settings, wine/prefix + defaults
|
||||||
node test/lansync-pending-links.test.js # links to files created later, startup heal
|
node test/lansync-links.test.js # wire: exec bits + relative links
|
||||||
node test/lansync-consent.test.js # incoming sync: accept / decline / timeout gate
|
node test/lansync-pending-links.test.js # links to files created later, startup heal
|
||||||
node test/bench-sync.js # timing: phases of a cold and a cached sync
|
node test/lansync-consent.test.js # accept / decline / timeout gate
|
||||||
npm start # click through the flow you touched
|
node test/lansync-token.test.js # long-sync token expiry (sliding TTL)
|
||||||
|
node test/bench-sync.js # timing: cold vs cached sync
|
||||||
|
node test/linux-sandbox.test.js # bwrap argv profile + the four levels, handed paths, mode gate
|
||||||
|
node test/sandbox-appdir.test.js # the app folder stays reachable inside its own redirected home
|
||||||
|
node test/linux-env.test.js # redirected HOME/XDG_* for both backends
|
||||||
|
node test/linux-import.test.js # ELF/AppImage/.desktop detection + icons
|
||||||
|
node test/linux-links.test.js # link opening: shim binds + mime seed
|
||||||
|
node test/linux-open.test.js # what an app opens: the script + the launcher relay
|
||||||
|
node test/games-service.test.js # run bookkeeping: the request watcher, Stop while starting
|
||||||
|
node test/lansync-linux-apps.test.js # Linux apps + linux_pfx travel with their categories
|
||||||
|
node test/linux-e2e.js # REAL runs: plain, relaxed (+ its mount), isolated, hand-off into a running app
|
||||||
|
npm start # click through the flow you touched
|
||||||
|
|
||||||
# Repairing / testing a real instance (test/ scripts are gitignored helpers):
|
# Repairing / testing a real instance (test/ scripts are gitignored helpers):
|
||||||
PLWL_BUILD_DIR=/path/to/instance node test/heal-instance.js # links + exec bits
|
PLWL_BUILD_DIR=/path/to/instance node test/heal-instance.js # links + exec bits
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,14 @@
|
||||||
"library.removeConfirm": "Remove {0} from the launcher?{1}",
|
"library.removeConfirm": "Remove {0} from the launcher?{1}",
|
||||||
"library.removeFilesNote": "\nIts imported files in files/apps will also be deleted.",
|
"library.removeFilesNote": "\nIts imported files in files/apps will also be deleted.",
|
||||||
"library.removePrefixNote": "\nFiles stay in the prefix, only the launcher entry is removed.",
|
"library.removePrefixNote": "\nFiles stay in the prefix, only the launcher entry is removed.",
|
||||||
|
"library.removeLinuxDataNote": "\nIts files in files/apps are deleted; its data in the prefix is kept.",
|
||||||
"library.tools": "Wine tools",
|
"library.tools": "Wine tools",
|
||||||
"library.reorderHint": "Drag to change the order",
|
"library.reorderHint": "Drag to change the order",
|
||||||
|
"import.kind.title": "What are you importing?",
|
||||||
|
"import.kind.windows": "Windows app (Wine / Proton)",
|
||||||
|
"import.kind.windowsHint": "Runs through wine or proton.",
|
||||||
|
"import.kind.linux": "Linux app (native)",
|
||||||
|
"import.kind.linuxHint": "Runs directly on this system, with its own isolated home folder. Needs an ELF binary, an .AppImage or a .desktop file.",
|
||||||
"import.question": "Should the app files be brought into the launcher so it stays portable, or left where they are?",
|
"import.question": "Should the app files be brought into the launcher so it stays portable, or left where they are?",
|
||||||
"import.mode.copy": "Copy into launcher (portable)",
|
"import.mode.copy": "Copy into launcher (portable)",
|
||||||
"import.mode.move": "Move into launcher (portable)",
|
"import.mode.move": "Move into launcher (portable)",
|
||||||
|
|
@ -58,9 +64,12 @@
|
||||||
"import.done": "Import finished",
|
"import.done": "Import finished",
|
||||||
"import.failed": "Import failed: {0}",
|
"import.failed": "Import failed: {0}",
|
||||||
"import.noExe": "no .exe file found in the selected folder",
|
"import.noExe": "no .exe file found in the selected folder",
|
||||||
|
"import.noLinuxExe": "no runnable Linux program found in the selected folder (ELF binary, .AppImage or .desktop)",
|
||||||
"import.prefixRoot": "this folder is a wine prefix (or its C: drive) - import an app from inside its drive_c instead",
|
"import.prefixRoot": "this folder is a wine prefix (or its C: drive) - import an app from inside its drive_c instead",
|
||||||
|
"import.copyIncomplete": "some files could not be copied - nothing was imported, the source folder is unchanged",
|
||||||
"config.title": "App configuration",
|
"config.title": "App configuration",
|
||||||
"config.icon.auto": "Extract from exe",
|
"config.icon.auto": "Extract from exe",
|
||||||
|
"config.icon.autoLinux": "Extract icon",
|
||||||
"config.icon.browse": "Choose image",
|
"config.icon.browse": "Choose image",
|
||||||
"config.icon.remove": "Remove icon",
|
"config.icon.remove": "Remove icon",
|
||||||
"config.icon.pick": "Choose an icon image",
|
"config.icon.pick": "Choose an icon image",
|
||||||
|
|
@ -80,14 +89,52 @@
|
||||||
"config.prefixDefault": "Default prefix ({0})",
|
"config.prefixDefault": "Default prefix ({0})",
|
||||||
"config.prefixLocked": "This app was imported from a prefix, its prefix cannot be changed",
|
"config.prefixLocked": "This app was imported from a prefix, its prefix cannot be changed",
|
||||||
"config.prefixLockedHint": "Pinned to the prefix it was imported from (its paths are stored relative to it)",
|
"config.prefixLockedHint": "Pinned to the prefix it was imported from (its paths are stored relative to it)",
|
||||||
|
"config.linuxDataRoot": "Data folder (prefix)",
|
||||||
|
"config.linuxDataRootHint": "A native app keeps its home, config and cache in linux_pfx inside this prefix.",
|
||||||
|
"config.openData": "Open data folder",
|
||||||
|
"config.linuxKindValue": "Linux",
|
||||||
|
"config.isolation": "Isolation",
|
||||||
|
"config.isolation.auto": "Automatic",
|
||||||
|
"config.isolation.none": "None",
|
||||||
|
"config.isolation.relaxed": "Relaxed",
|
||||||
|
"config.isolation.isolated": "Isolated",
|
||||||
|
"config.isolationHint.auto": "Relaxed where bubblewrap works; on a machine without it the app runs without isolation.",
|
||||||
|
"config.isolationHint.none": "No sandbox: the app sees, and may change, everything your user can.",
|
||||||
|
"config.isolationHint.relaxed": "The whole disk is mounted writable as /tmp/plwl-fs-root.",
|
||||||
|
"config.isolationHint.isolated": "The system is read-only.",
|
||||||
|
"config.isolation.bwrap": "bwrap {0} (available)",
|
||||||
|
"config.isolation.env": "no sandbox: {0}",
|
||||||
|
"config.forceX11": "Force X11 (XWayland)",
|
||||||
|
"config.forceX11Hint": "Runs the app through XWayland instead of Wayland. Useful when a window cannot carry its own icon or misbehaves on Wayland.",
|
||||||
"config.disableUmu": "Disable UMU (launch with wine directly)",
|
"config.disableUmu": "Disable UMU (launch with wine directly)",
|
||||||
"config.disableUmuHint": "Runs the game with the build's own wine binary instead of its umu.exe/steam.exe launcher - no UMU and no Steam client is started, and Proton's prefix setup is skipped too. This is the closest to a plain wine run.",
|
"config.disableUmuHint": "Runs the game with the build's own wine binary instead of its umu.exe/steam.exe launcher. This is the closest to a plain wine run.",
|
||||||
"config.dllOverrides": "DLL overrides",
|
"config.dllOverrides": "DLL overrides",
|
||||||
"config.overrides.add": "Add override",
|
"config.overrides.add": "Add override",
|
||||||
"config.overrides.dll": "DLL name",
|
"config.overrides.dll": "DLL name",
|
||||||
"config.overrides.setting": "Setting",
|
"config.overrides.setting": "Setting",
|
||||||
"config.overrides.remove": "Remove",
|
"config.overrides.remove": "Remove",
|
||||||
"config.overrides.hint": "Wine module names, e.g. dinput8. native,builtin (the default) loads a DLL shipped with the game and falls back to Wine's builtin; disabled blocks the module. 'name=setting' pasted into the name box is split automatically.",
|
"config.overrides.hint": "Wine module names, e.g. dinput8. native,builtin (the default) loads a DLL shipped with the game and falls back to Wine's builtin; disabled blocks the module.",
|
||||||
|
"sandbox.title": "Linux app isolation",
|
||||||
|
"sandbox.hint": "Native apps always run with a redirected home folder, so their config and cache stay in the prefix. The isolation level says what else they get: None is no sandbox at all, Relaxed mounts the whole disk writable, Isolated writes nothing outside the app's own folders and copies an opened file into its home folder.",
|
||||||
|
"sandbox.status": "Status",
|
||||||
|
"sandbox.check": "Check again",
|
||||||
|
"sandbox.rechecked": "Isolation checked again",
|
||||||
|
"sandbox.reason.missing": "not installed",
|
||||||
|
"sandbox.reason.not-permitted": "blocked by the kernel (user namespaces are not permitted here)",
|
||||||
|
"sandbox.reason.timeout": "not responding",
|
||||||
|
"sandbox.reason.failed": "not working",
|
||||||
|
"sandbox.warnTitle": "No sandbox for Linux apps",
|
||||||
|
"sandbox.warnBody": "bubblewrap is {0}. Apps left on Automatic run as plain programs (None), while apps set to Relaxed or Isolated refuse to start instead of pretending the sandbox is there.",
|
||||||
|
"sandbox.dismiss": "Got it",
|
||||||
|
"sandbox.badgeNone": "No isolation",
|
||||||
|
"sandbox.badgeRelaxed": "Relaxed",
|
||||||
|
"sandbox.badgeIsolated": "Isolated",
|
||||||
|
"sandbox.badgeRequired": "Bwrap required",
|
||||||
|
"handoff.copying": "Copying into the app: {0}",
|
||||||
|
"handoff.copyNote": "{0} / {1} - the app works on a copy inside its own home folder (plwl-handoff); edits in the copy never come back.",
|
||||||
|
"handoff.failed": "Could not copy {0} into the app: {1}",
|
||||||
|
"sandbox.required": "This app needs a working bubblewrap sandbox: {0}",
|
||||||
|
"sandbox.linkOpener": "PLWL link opener",
|
||||||
"config.save": "Save changes",
|
"config.save": "Save changes",
|
||||||
"config.shortcut": "Create shortcut",
|
"config.shortcut": "Create shortcut",
|
||||||
"config.shortcutCreated": "Shortcut for {0} created (menu + desktop)",
|
"config.shortcutCreated": "Shortcut for {0} created (menu + desktop)",
|
||||||
|
|
@ -136,6 +183,13 @@
|
||||||
"prefixes.inUse": "Cannot change prefix {0} while an app from it is running: {1}. Close it first.",
|
"prefixes.inUse": "Cannot change prefix {0} while an app from it is running: {1}. Close it first.",
|
||||||
"prefixes.inUseHint": "An app is running from this prefix right now - close it before renaming.",
|
"prefixes.inUseHint": "An app is running from this prefix right now - close it before renaming.",
|
||||||
"prefixes.hasApps": "Cannot delete prefix {0} - these apps still use it: {1}. Point them at another prefix or remove them first.",
|
"prefixes.hasApps": "Cannot delete prefix {0} - these apps still use it: {1}. Point them at another prefix or remove them first.",
|
||||||
|
"prefixes.resetConfig": "Reset Linux config",
|
||||||
|
"prefixes.resetConfigHint": "Deletes the desktop preferences (theme, GTK/Qt, PulseAudio and X11 cookies) PLWL copied from your system into this prefix. They are copied again on the next app start; app data stays.",
|
||||||
|
"prefixes.resetConfigConfirm": "Delete the desktop preferences copied into prefix {0}? The next app start copies them again from your system. App data is left untouched.",
|
||||||
|
"prefixes.resetConfigDone": "Linux config of prefix {0} reset - it is copied again from your system on the next app start.",
|
||||||
|
"prefixes.resetConfigNone": "Nothing to reset in prefix {0} - the desktop preferences are not copied in.",
|
||||||
|
"prefixes.resetConfigBusy": "Cannot reset the Linux config of prefix {0} while an app from it is running: {1}. Close it first.",
|
||||||
|
"prefixes.resetConfigFailed": "Could not reset the Linux config of prefix {0}.",
|
||||||
"wines.title": "Wine versions",
|
"wines.title": "Wine versions",
|
||||||
"wines.subtitle": "You can import your wine/proton builds here. Proton runs fully without Steam.",
|
"wines.subtitle": "You can import your wine/proton builds here. Proton runs fully without Steam.",
|
||||||
"wines.system": "System Wine",
|
"wines.system": "System Wine",
|
||||||
|
|
@ -175,6 +229,11 @@
|
||||||
"settings.saved": "Settings saved",
|
"settings.saved": "Settings saved",
|
||||||
"settings.openFiles": "Open files folder",
|
"settings.openFiles": "Open files folder",
|
||||||
"settings.closeToTray": "Close window to tray (keep tracking playtime in background)",
|
"settings.closeToTray": "Close window to tray (keep tracking playtime in background)",
|
||||||
|
"settings.hashCache": "Sync file cache",
|
||||||
|
"settings.hashCacheHint": "LAN syncing remembers the hash of every file (with its size and modification time), so an unchanged library is not read again on the next sync. Clear it if a transfer compares wrong - everything gets hashed fresh on the next sync.",
|
||||||
|
"settings.clearHashCache": "Clear cache",
|
||||||
|
"settings.hashCacheClearConfirm": "Forget all remembered file hashes? The next sync will hash the whole library again.",
|
||||||
|
"settings.hashCacheCleared": "Hash cache cleared",
|
||||||
"lansync.title": "LAN syncing",
|
"lansync.title": "LAN syncing",
|
||||||
"lansync.subtitle": "Push your apps, prefixes and Wine builds to another PLWL on the same network.",
|
"lansync.subtitle": "Push your apps, prefixes and Wine builds to another PLWL on the same network.",
|
||||||
"lansync.thisDevice": "This device",
|
"lansync.thisDevice": "This device",
|
||||||
|
|
@ -196,17 +255,20 @@
|
||||||
"lansync.prefixes": "Wine prefixes",
|
"lansync.prefixes": "Wine prefixes",
|
||||||
"lansync.settings": "Sync settings (names, app configs and order)",
|
"lansync.settings": "Sync settings (names, app configs and order)",
|
||||||
"lansync.force": "Force sync all files (skip the hash check and overwrite everything)",
|
"lansync.force": "Force sync all files (skip the hash check and overwrite everything)",
|
||||||
"lansync.mirrorWarning": "This device is the source of truth: on the other device, files and items that do not exist here will be deleted.",
|
"lansync.mirrorWarning": "The selected items will match this device exactly. Nothing outside the selection is deleted, and the deletion list is approved on the other device.",
|
||||||
"lansync.incomingMirror": "The other device is the source of truth: files and items on this device that do not exist there will be deleted.",
|
"lansync.incomingMirror": "The other device is the source of truth for the selected items. You will review the list of files to delete before the sync finishes.",
|
||||||
"lansync.start": "Start sync",
|
"lansync.start": "Start sync",
|
||||||
"lansync.progress": "Syncing to {0}…",
|
"lansync.progress": "Syncing to {0}…",
|
||||||
|
"lansync.waiting": "Waiting for the other device…",
|
||||||
"lansync.phase.scan": "Preparing {0}",
|
"lansync.phase.scan": "Preparing {0}",
|
||||||
"lansync.phase.hash": "Hashing {0}",
|
"lansync.phase.hash": "Hashing {0}",
|
||||||
|
"lansync.phase.compare": "Comparing {0}",
|
||||||
"lansync.phase.plan": "Comparing {0}",
|
"lansync.phase.plan": "Comparing {0}",
|
||||||
"lansync.phase.copy": "Sending {0}",
|
"lansync.phase.copy": "Sending {0}",
|
||||||
"lansync.recvPhase.copy": "Receiving {0}",
|
"lansync.recvPhase.copy": "Receiving {0}",
|
||||||
"lansync.phase.registry": "Updating the library",
|
"lansync.phase.registry": "Updating the library",
|
||||||
"lansync.phase.commit": "Removing extra files",
|
"lansync.phase.commit": "Checking for extra files",
|
||||||
|
"lansync.phase.review": "Waiting for the deletion approval",
|
||||||
"lansync.phase.done": "Finishing up",
|
"lansync.phase.done": "Finishing up",
|
||||||
"lansync.progressFiles": "{0} / {1} files",
|
"lansync.progressFiles": "{0} / {1} files",
|
||||||
"lansync.progressBytes": "{0} / {1} transferred",
|
"lansync.progressBytes": "{0} / {1} transferred",
|
||||||
|
|
@ -218,16 +280,42 @@
|
||||||
"lansync.receiving": "Receiving from {0}…",
|
"lansync.receiving": "Receiving from {0}…",
|
||||||
"lansync.incomingTitle": "Incoming sync from {0}",
|
"lansync.incomingTitle": "Incoming sync from {0}",
|
||||||
"lansync.incomingText": "{0} wants to sync its apps, Wine versions and prefixes onto this device.",
|
"lansync.incomingText": "{0} wants to sync its apps, Wine versions and prefixes onto this device.",
|
||||||
|
"lansync.incomingSettings": "launcher settings only (no files)",
|
||||||
"lansync.incomingNothing": "Nothing was selected, or the totals have not arrived yet.",
|
"lansync.incomingNothing": "Nothing was selected, or the totals have not arrived yet.",
|
||||||
"lansync.incomingTimedOut": "The incoming sync request timed out.",
|
"lansync.incomingTimedOut": "The incoming sync request timed out.",
|
||||||
"lansync.accept": "Accept",
|
"lansync.accept": "Accept",
|
||||||
"lansync.decline": "Decline",
|
"lansync.decline": "Decline",
|
||||||
"lansync.rejected": "{0} declined the sync.",
|
"lansync.rejected": "{0} declined the sync.",
|
||||||
"lansync.unknownDevice": "Unknown device",
|
"lansync.unknownDevice": "Unknown device",
|
||||||
"lansync.recvWaiting": "Waiting for the file list…",
|
|
||||||
"lansync.recvStalled": "Waiting for the other device…",
|
|
||||||
"lansync.recvIdle": "The other device stopped responding, the sync was interrupted.",
|
"lansync.recvIdle": "The other device stopped responding, the sync was interrupted.",
|
||||||
"lansync.done": "Synced to {0}: {1} sent ({4}), {2} already up to date, {3} removed",
|
"lansync.done": "Synced to {0}: {1} sent ({4}), {2} already up to date, {3} removed",
|
||||||
|
"lansync.summaryTitle": "Sync finished",
|
||||||
|
"lansync.summarySentTo": "Sent to {0}",
|
||||||
|
"lansync.summaryReceivedFrom": "Received from {0}",
|
||||||
|
"lansync.summaryApps": "Apps",
|
||||||
|
"lansync.summarySettings": "Settings",
|
||||||
|
"lansync.summarySent": "Files sent",
|
||||||
|
"lansync.summaryReceived": "Files received",
|
||||||
|
"lansync.summarySkipped": "Already up to date",
|
||||||
|
"lansync.summaryDeleted": "Removed",
|
||||||
|
"lansync.summaryTransferred": "Transferred",
|
||||||
|
"lansync.summarySize": "Synced data size",
|
||||||
|
"lansync.summaryKept": "Kept by the other device",
|
||||||
|
"lansync.pick": "Select",
|
||||||
|
"lansync.pickCount": "Select ({0}/{1})",
|
||||||
|
"lansync.pickSearch": "Search",
|
||||||
|
"lansync.pickAll": "Select all",
|
||||||
|
"lansync.pickNone": "Select none",
|
||||||
|
"lansync.prefixLeftBehind": "Prefix {0} is not selected — this app's folder will not travel.",
|
||||||
|
"lansync.reviewTitle": "Review deletions",
|
||||||
|
"lansync.reviewFrom": "Received from {0}. Review the list before anything is removed.",
|
||||||
|
"lansync.reviewText": "To delete: {0} files ({1}). Uncheck what should stay.",
|
||||||
|
"lansync.reviewOverwrite": "Files that exist on both sides are overwritten with the other device's version. If you play on both computers, leave this prefix out of the sync.",
|
||||||
|
"lansync.reviewKeepAll": "Keep everything",
|
||||||
|
"lansync.reviewDelete": "Delete {0} files ({1})",
|
||||||
|
"lansync.reviewDeleting": "Deleting…",
|
||||||
|
"lansync.reviewFiles": "{0} files · {1}",
|
||||||
|
"lansync.summaryYes": "yes",
|
||||||
"lansync.failed": "Sync failed: {0}",
|
"lansync.failed": "Sync failed: {0}",
|
||||||
"lansync.badCode": "Wrong sync code. Enter the code shown on the other device.",
|
"lansync.badCode": "Wrong sync code. Enter the code shown on the other device.",
|
||||||
"lansync.busy": "The other device is busy (another sync or a running app).",
|
"lansync.busy": "The other device is busy (another sync or a running app).",
|
||||||
|
|
@ -237,6 +325,8 @@
|
||||||
"lansync.idRerolled": "New device ID generated",
|
"lansync.idRerolled": "New device ID generated",
|
||||||
"lansync.codeRerolled": "New sync code generated",
|
"lansync.codeRerolled": "New sync code generated",
|
||||||
"lansync.unknownError": "unknown error",
|
"lansync.unknownError": "unknown error",
|
||||||
|
"lansync.oldPeer": "The other device runs an older PLWL. Update it — without the deletion review this sync is not safe.",
|
||||||
|
"lansync.reviewExpired": "The deletion review expired and nothing was removed.",
|
||||||
"openwith.title": "Open with Wine",
|
"openwith.title": "Open with Wine",
|
||||||
"openwith.file": "File",
|
"openwith.file": "File",
|
||||||
"openwith.fileMissing": "This file no longer exists (or is not a Windows executable).",
|
"openwith.fileMissing": "This file no longer exists (or is not a Windows executable).",
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,14 @@
|
||||||
"library.removeConfirm": "Usunąć {0} z launchera?{1}",
|
"library.removeConfirm": "Usunąć {0} z launchera?{1}",
|
||||||
"library.removeFilesNote": "\nJej zaimportowane pliki w files/apps również zostaną usunięte.",
|
"library.removeFilesNote": "\nJej zaimportowane pliki w files/apps również zostaną usunięte.",
|
||||||
"library.removePrefixNote": "\nPliki zostają w prefiksie, usuwany jest tylko wpis w launcherze.",
|
"library.removePrefixNote": "\nPliki zostają w prefiksie, usuwany jest tylko wpis w launcherze.",
|
||||||
|
"library.removeLinuxDataNote": "\nJej pliki w files/apps zostaną usunięte; dane w prefiksie zostają.",
|
||||||
"library.tools": "Narzędzia Wine",
|
"library.tools": "Narzędzia Wine",
|
||||||
"library.reorderHint": "Przeciągnij, aby zmienić kolejność",
|
"library.reorderHint": "Przeciągnij, aby zmienić kolejność",
|
||||||
|
"import.kind.title": "Co importujesz?",
|
||||||
|
"import.kind.windows": "Aplikacja Windows (Wine / Proton)",
|
||||||
|
"import.kind.windowsHint": "Uruchamiana przez wine albo proton.",
|
||||||
|
"import.kind.linux": "Aplikacja Linux (natywna)",
|
||||||
|
"import.kind.linuxHint": "Uruchamiana bezpośrednio w systemie, z własnym odizolowanym folderem domowym. Wymaga binarki ELF, pliku .AppImage lub .desktop.",
|
||||||
"import.question": "Czy pliki aplikacji mają trafić do launchera, aby był przenośny, czy mają zostać tam, gdzie są?",
|
"import.question": "Czy pliki aplikacji mają trafić do launchera, aby był przenośny, czy mają zostać tam, gdzie są?",
|
||||||
"import.mode.copy": "Skopiuj do launchera (przenośne)",
|
"import.mode.copy": "Skopiuj do launchera (przenośne)",
|
||||||
"import.mode.move": "Przenieś do launchera (przenośne)",
|
"import.mode.move": "Przenieś do launchera (przenośne)",
|
||||||
|
|
@ -58,9 +64,12 @@
|
||||||
"import.done": "Import zakończony",
|
"import.done": "Import zakończony",
|
||||||
"import.failed": "Import nie powiódł się: {0}",
|
"import.failed": "Import nie powiódł się: {0}",
|
||||||
"import.noExe": "nie znaleziono pliku .exe w wybranym folderze",
|
"import.noExe": "nie znaleziono pliku .exe w wybranym folderze",
|
||||||
|
"import.noLinuxExe": "nie znaleziono uruchamialnego programu linuksowego w wybranym folderze (binarka ELF, .AppImage lub .desktop)",
|
||||||
"import.prefixRoot": "ten folder to prefiks wine (lub jego dysk C:) - zaimportuj aplikację z wnętrza jego drive_c",
|
"import.prefixRoot": "ten folder to prefiks wine (lub jego dysk C:) - zaimportuj aplikację z wnętrza jego drive_c",
|
||||||
|
"import.copyIncomplete": "nie udało się skopiować niektórych plików - nic nie zaimportowano, folder źródłowy pozostał bez zmian",
|
||||||
"config.title": "Konfiguracja aplikacji",
|
"config.title": "Konfiguracja aplikacji",
|
||||||
"config.icon.auto": "Wyciągnij z exe",
|
"config.icon.auto": "Wyciągnij z exe",
|
||||||
|
"config.icon.autoLinux": "Wyciągnij ikonę",
|
||||||
"config.icon.browse": "Wybierz obrazek",
|
"config.icon.browse": "Wybierz obrazek",
|
||||||
"config.icon.remove": "Usuń ikonę",
|
"config.icon.remove": "Usuń ikonę",
|
||||||
"config.icon.pick": "Wybierz obrazek ikony",
|
"config.icon.pick": "Wybierz obrazek ikony",
|
||||||
|
|
@ -80,14 +89,52 @@
|
||||||
"config.prefixDefault": "Prefiks domyślny ({0})",
|
"config.prefixDefault": "Prefiks domyślny ({0})",
|
||||||
"config.prefixLocked": "Ta aplikacja została zaimportowana z prefiksu, nie można zmienić jej prefiksu",
|
"config.prefixLocked": "Ta aplikacja została zaimportowana z prefiksu, nie można zmienić jej prefiksu",
|
||||||
"config.prefixLockedHint": "Przypięta do prefiksu, z którego została zaimportowana (ścieżki trzymane są względem niego)",
|
"config.prefixLockedHint": "Przypięta do prefiksu, z którego została zaimportowana (ścieżki trzymane są względem niego)",
|
||||||
|
"config.linuxDataRoot": "Folder danych (prefiks)",
|
||||||
|
"config.linuxDataRootHint": "Natywna aplikacja trzyma swój home, config i cache w linux_pfx w tym prefiksie.",
|
||||||
|
"config.openData": "Otwórz folder danych",
|
||||||
|
"config.linuxKindValue": "Linux",
|
||||||
|
"config.isolation": "Izolacja",
|
||||||
|
"config.isolation.auto": "Automatyczna",
|
||||||
|
"config.isolation.none": "None",
|
||||||
|
"config.isolation.relaxed": "Relaxed",
|
||||||
|
"config.isolation.isolated": "Isolated",
|
||||||
|
"config.isolationHint.auto": "Relaxed, gdy bubblewrap działa; na maszynie bez bwrap aplikacja uruchamia bez izolacji.",
|
||||||
|
"config.isolationHint.none": "Bez sandboxa: aplikacja widzi i może zmienić wszystko, do czego ma prawo Twój użytkownik.",
|
||||||
|
"config.isolationHint.relaxed": "Cały dysk podmontowany zapisywalnie jako /tmp/plwl-fs-root.",
|
||||||
|
"config.isolationHint.isolated": "System tylko do odczytu.",
|
||||||
|
"config.isolation.bwrap": "bwrap {0} (dostępny)",
|
||||||
|
"config.isolation.env": "brak sandboxa: {0}",
|
||||||
|
"config.forceX11": "Wymuś X11 (XWayland)",
|
||||||
|
"config.forceX11Hint": "Uruchamia aplikację przez XWayland zamiast Waylanda. Przydatne, gdy okno nie potrafi mieć własnej ikony albo źle działa na Waylandzie.",
|
||||||
"config.disableUmu": "Wyłącz UMU (uruchamiaj bezpośrednio wine)",
|
"config.disableUmu": "Wyłącz UMU (uruchamiaj bezpośrednio wine)",
|
||||||
"config.disableUmuHint": "Uruchamia grę bezpośrednio binarką wine z builda, z pominięciem launchera umu.exe/steam.exe — bez UMU i bez uruchamiania klienta Steam, z pominięciem przygotowania prefiksu przez Protona. To najbliżej zwykłego uruchomienia wine.",
|
"config.disableUmuHint": "Uruchamia grę bezpośrednio binarką wine z builda, z pominięciem launchera umu.exe/steam.exe. To najbliżej zwykłego uruchomienia wine.",
|
||||||
"config.dllOverrides": "Nadpisania DLL",
|
"config.dllOverrides": "Nadpisania DLL",
|
||||||
"config.overrides.add": "Dodaj nadpisanie",
|
"config.overrides.add": "Dodaj nadpisanie",
|
||||||
"config.overrides.dll": "Nazwa DLL",
|
"config.overrides.dll": "Nazwa DLL",
|
||||||
"config.overrides.setting": "Ustawienie",
|
"config.overrides.setting": "Ustawienie",
|
||||||
"config.overrides.remove": "Usuń",
|
"config.overrides.remove": "Usuń",
|
||||||
"config.overrides.hint": "Nazwy modułów Wine, np. dinput8. native,builtin (domyślne) wczytuje DLL dołączony do gry, a gdy go nie ma — wbudowany Wine; disabled blokuje moduł. Wklejone 'nazwa=ustawienie' zostanie rozdzielone automatycznie.",
|
"config.overrides.hint": "Nazwy modułów Wine, np. dinput8. native,builtin (domyślne) wczytuje DLL dołączony do gry, a gdy go nie ma — wbudowany Wine; disabled blokuje moduł.",
|
||||||
|
"sandbox.title": "Izolacja aplikacji Linux",
|
||||||
|
"sandbox.hint": "Natywne aplikacje zawsze działają z przekierowanym folderem domowym - ich config i cache zostają w prefiksie. Poziom izolacji mówi, co jeszcze dostają: None to brak sandboxa, Relaxed daje cały dysk zapisywalny, a Isolated nie pozwala zapisać niczego poza folderem aplikacji i kopiuje otwierane pliki do jej folderu domowego.",
|
||||||
|
"sandbox.status": "Status",
|
||||||
|
"sandbox.check": "Sprawdź ponownie",
|
||||||
|
"sandbox.rechecked": "Sprawdzono izolację ponownie",
|
||||||
|
"sandbox.reason.missing": "nie jest zainstalowany",
|
||||||
|
"sandbox.reason.not-permitted": "blokowany przez jądro (brak zgody na user namespaces)",
|
||||||
|
"sandbox.reason.timeout": "nie odpowiada",
|
||||||
|
"sandbox.reason.failed": "nie działa",
|
||||||
|
"sandbox.warnTitle": "Brak sandboxa dla aplikacji Linux",
|
||||||
|
"sandbox.warnBody": "bubblewrap {0}. Aplikacje ustawione na Automatyczną uruchomią się jak zwykłe programy (None), a te ustawione jawnie na Relaxed albo Isolated odmówią startu, zamiast udawać, że sandbox działa.",
|
||||||
|
"sandbox.dismiss": "Rozumiem",
|
||||||
|
"sandbox.badgeNone": "Bez izolacji",
|
||||||
|
"sandbox.badgeRelaxed": "Relaxed",
|
||||||
|
"sandbox.badgeIsolated": "Isolated",
|
||||||
|
"sandbox.badgeRequired": "Wymaga bwrap",
|
||||||
|
"handoff.copying": "Kopiowanie do aplikacji: {0}",
|
||||||
|
"handoff.copyNote": "{0} / {1} - aplikacja pracuje na kopii w swoim folderze domowym (plwl-handoff); zmiany w kopii nie wracają do oryginału.",
|
||||||
|
"handoff.failed": "Nie udało się skopiować {0} do aplikacji: {1}",
|
||||||
|
"sandbox.required": "Ta aplikacja wymaga działającego sandboxa bubblewrap: {0}",
|
||||||
|
"sandbox.linkOpener": "PLWL - otwieranie linków",
|
||||||
"config.save": "Zapisz zmiany",
|
"config.save": "Zapisz zmiany",
|
||||||
"config.shortcut": "Utwórz skrót",
|
"config.shortcut": "Utwórz skrót",
|
||||||
"config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)",
|
"config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)",
|
||||||
|
|
@ -136,6 +183,13 @@
|
||||||
"prefixes.inUse": "Nie można zmienić prefiksu {0}, gdy działa z niego aplikacja: {1}. Najpierw ją zamknij.",
|
"prefixes.inUse": "Nie można zmienić prefiksu {0}, gdy działa z niego aplikacja: {1}. Najpierw ją zamknij.",
|
||||||
"prefixes.inUseHint": "Właśnie działa aplikacja z tego prefiksu - zamknij ją przed zmianą nazwy.",
|
"prefixes.inUseHint": "Właśnie działa aplikacja z tego prefiksu - zamknij ją przed zmianą nazwy.",
|
||||||
"prefixes.hasApps": "Nie można usunąć prefiksu {0} - używają go jeszcze aplikacje: {1}. Wskaż im inny prefiks albo usuń je najpierw.",
|
"prefixes.hasApps": "Nie można usunąć prefiksu {0} - używają go jeszcze aplikacje: {1}. Wskaż im inny prefiks albo usuń je najpierw.",
|
||||||
|
"prefixes.resetConfig": "Resetuj config Linuksa",
|
||||||
|
"prefixes.resetConfigHint": "Usuwa preferencje pulpitu (motyw, GTK/Qt, ciasteczka PulseAudio i X11) skopiowane z systemu do tego prefiksu. Przy następnym starcie apki zostaną skopiowane od nowa; dane aplikacji zostają.",
|
||||||
|
"prefixes.resetConfigConfirm": "Usunąć preferencje pulpitu skopiowane do prefiksu {0}? Przy następnym starcie apki zostaną skopiowane od nowa z systemu. Dane aplikacji pozostaną nietknięte.",
|
||||||
|
"prefixes.resetConfigDone": "Zresetowano config Linuksa prefiksu {0} - przy następnym starcie apki zostanie skopiowany od nowa z systemu.",
|
||||||
|
"prefixes.resetConfigNone": "W prefiksie {0} nie ma nic do zresetowania - preferencje pulpitu nie są tam skopiowane.",
|
||||||
|
"prefixes.resetConfigBusy": "Nie można zresetować configu Linuksa prefiksu {0}, gdy działa z niego aplikacja: {1}. Najpierw ją zamknij.",
|
||||||
|
"prefixes.resetConfigFailed": "Nie udało się zresetować configu Linuksa prefiksu {0}.",
|
||||||
"wines.title": "Wersje Wine",
|
"wines.title": "Wersje Wine",
|
||||||
"wines.subtitle": "Tutaj możesz zaimportować swoje buildy wine/proton. Proton działa w pełni bez Steama.",
|
"wines.subtitle": "Tutaj możesz zaimportować swoje buildy wine/proton. Proton działa w pełni bez Steama.",
|
||||||
"wines.system": "Wine systemowe",
|
"wines.system": "Wine systemowe",
|
||||||
|
|
@ -175,6 +229,11 @@
|
||||||
"settings.saved": "Ustawienia zapisane",
|
"settings.saved": "Ustawienia zapisane",
|
||||||
"settings.openFiles": "Otwórz folder files",
|
"settings.openFiles": "Otwórz folder files",
|
||||||
"settings.closeToTray": "Zamykaj okno do zasobnika systemowego (licz czas gry w tle)",
|
"settings.closeToTray": "Zamykaj okno do zasobnika systemowego (licz czas gry w tle)",
|
||||||
|
"settings.hashCache": "Pamięć podręczna haszy",
|
||||||
|
"settings.hashCacheHint": "Synchronizacja LAN zapamiętuje hash każdego pliku (razem z rozmiarem i datą modyfikacji), dzięki czemu niezmieniona biblioteka nie jest czytana przy każdym syncu. Wyczyść, jeśli transfer porównuje pliki błędnie — przy następnej synchronizacji wszystko zostanie zahaszowane od nowa.",
|
||||||
|
"settings.clearHashCache": "Wyczyść",
|
||||||
|
"settings.hashCacheClearConfirm": "Zapomnieć wszystkie zapamiętane hasze plików? Następna synchronizacja zahashuje całą bibliotekę od nowa.",
|
||||||
|
"settings.hashCacheCleared": "Pamięć podręczna wyczyszczona",
|
||||||
"lansync.title": "Synchronizacja LAN",
|
"lansync.title": "Synchronizacja LAN",
|
||||||
"lansync.subtitle": "Wypchnij swoje aplikacje, prefiksy i buildy Wine na inny PLWL w tej samej sieci.",
|
"lansync.subtitle": "Wypchnij swoje aplikacje, prefiksy i buildy Wine na inny PLWL w tej samej sieci.",
|
||||||
"lansync.thisDevice": "To urządzenie",
|
"lansync.thisDevice": "To urządzenie",
|
||||||
|
|
@ -196,17 +255,20 @@
|
||||||
"lansync.prefixes": "Prefiksy Wine",
|
"lansync.prefixes": "Prefiksy Wine",
|
||||||
"lansync.settings": "Ustawienia (nazwy, konfiguracje i kolejność aplikacji)",
|
"lansync.settings": "Ustawienia (nazwy, konfiguracje i kolejność aplikacji)",
|
||||||
"lansync.force": "Wymuś synchronizację wszystkich plików (pomiń sprawdzanie hashy i nadpisz wszystko)",
|
"lansync.force": "Wymuś synchronizację wszystkich plików (pomiń sprawdzanie hashy i nadpisz wszystko)",
|
||||||
"lansync.mirrorWarning": "To urządzenie jest źródłem prawdy: na drugim urządzeniu pliki i elementy, których tu nie ma, zostaną usunięte.",
|
"lansync.mirrorWarning": "Zaznaczone elementy będą na drugim urządzeniu dokładnie takie jak tutaj. Niczego poza wyborem nie usuwamy, a listę usunięć zatwierdzisz na drugim urządzeniu.",
|
||||||
"lansync.incomingMirror": "Drugie urządzenie jest źródłem prawdy: pliki i elementy na tym urządzeniu, których tam nie ma, zostaną usunięte.",
|
"lansync.incomingMirror": "Drugie urządzenie jest źródłem prawdy dla zaznaczonych elementów. Listę plików do usunięcia zatwierdzisz przed końcem synchronizacji.",
|
||||||
"lansync.start": "Rozpocznij synchronizację",
|
"lansync.start": "Rozpocznij synchronizację",
|
||||||
"lansync.progress": "Synchronizowanie na {0}…",
|
"lansync.progress": "Synchronizowanie na {0}…",
|
||||||
|
"lansync.waiting": "Oczekiwanie na drugie urządzenie…",
|
||||||
"lansync.phase.scan": "Przygotowywanie {0}",
|
"lansync.phase.scan": "Przygotowywanie {0}",
|
||||||
"lansync.phase.hash": "Haszowanie {0}",
|
"lansync.phase.hash": "Haszowanie {0}",
|
||||||
|
"lansync.phase.compare": "Porównywanie {0}",
|
||||||
"lansync.phase.plan": "Porównywanie {0}",
|
"lansync.phase.plan": "Porównywanie {0}",
|
||||||
"lansync.phase.copy": "Wysyłanie {0}",
|
"lansync.phase.copy": "Wysyłanie {0}",
|
||||||
"lansync.recvPhase.copy": "Odbieranie {0}",
|
"lansync.recvPhase.copy": "Odbieranie {0}",
|
||||||
"lansync.phase.registry": "Aktualizowanie biblioteki",
|
"lansync.phase.registry": "Aktualizowanie biblioteki",
|
||||||
"lansync.phase.commit": "Usuwanie zbędnych plików",
|
"lansync.phase.commit": "Sprawdzanie zbędnych plików",
|
||||||
|
"lansync.phase.review": "Czekanie na potwierdzenie usunięć",
|
||||||
"lansync.phase.done": "Kończenie",
|
"lansync.phase.done": "Kończenie",
|
||||||
"lansync.progressFiles": "{0} / {1} plików",
|
"lansync.progressFiles": "{0} / {1} plików",
|
||||||
"lansync.progressBytes": "{0} / {1} przesłane",
|
"lansync.progressBytes": "{0} / {1} przesłane",
|
||||||
|
|
@ -218,16 +280,42 @@
|
||||||
"lansync.receiving": "Odbieranie od {0}…",
|
"lansync.receiving": "Odbieranie od {0}…",
|
||||||
"lansync.incomingTitle": "Przychodząca synchronizacja od {0}",
|
"lansync.incomingTitle": "Przychodząca synchronizacja od {0}",
|
||||||
"lansync.incomingText": "{0} chce przysłać swoje aplikacje, wersje Wine i prefiksy na to urządzenie.",
|
"lansync.incomingText": "{0} chce przysłać swoje aplikacje, wersje Wine i prefiksy na to urządzenie.",
|
||||||
|
"lansync.incomingSettings": "tylko ustawienia launchera (bez plików)",
|
||||||
"lansync.incomingNothing": "Nic nie zaznaczono albo podsumowanie jeszcze nie dotarło.",
|
"lansync.incomingNothing": "Nic nie zaznaczono albo podsumowanie jeszcze nie dotarło.",
|
||||||
"lansync.incomingTimedOut": "Upłynął czas na odpowiedź na przychodzącą synchronizację.",
|
"lansync.incomingTimedOut": "Upłynął czas na odpowiedź na przychodzącą synchronizację.",
|
||||||
"lansync.accept": "Akceptuj",
|
"lansync.accept": "Akceptuj",
|
||||||
"lansync.decline": "Odrzuć",
|
"lansync.decline": "Odrzuć",
|
||||||
"lansync.rejected": "{0} odrzucił synchronizację.",
|
"lansync.rejected": "{0} odrzucił synchronizację.",
|
||||||
"lansync.unknownDevice": "Nieznane urządzenie",
|
"lansync.unknownDevice": "Nieznane urządzenie",
|
||||||
"lansync.recvWaiting": "Oczekiwanie na listę plików…",
|
|
||||||
"lansync.recvStalled": "Oczekiwanie na drugie urządzenie…",
|
|
||||||
"lansync.recvIdle": "Drugie urządzenie przestało odpowiadać, synchronizacja została przerwana.",
|
"lansync.recvIdle": "Drugie urządzenie przestało odpowiadać, synchronizacja została przerwana.",
|
||||||
"lansync.done": "Zsynchronizowano na {0}: wysłano {1} ({4}), aktualnych {2}, usunięto {3}",
|
"lansync.done": "Zsynchronizowano na {0}: wysłano {1} ({4}), aktualnych {2}, usunięto {3}",
|
||||||
|
"lansync.summaryTitle": "Synchronizacja zakończona",
|
||||||
|
"lansync.summarySentTo": "Wysłano do {0}",
|
||||||
|
"lansync.summaryReceivedFrom": "Odebrano od {0}",
|
||||||
|
"lansync.summaryApps": "Aplikacje",
|
||||||
|
"lansync.summarySettings": "Ustawienia",
|
||||||
|
"lansync.summarySent": "Wysłane pliki",
|
||||||
|
"lansync.summaryReceived": "Odebrane pliki",
|
||||||
|
"lansync.summarySkipped": "Niezmienione pliki",
|
||||||
|
"lansync.summaryDeleted": "Usunięte",
|
||||||
|
"lansync.summaryTransferred": "Przesłano",
|
||||||
|
"lansync.summarySize": "Rozmiar zsynchronizowanych danych",
|
||||||
|
"lansync.summaryKept": "Zachowane przez drugie urządzenie",
|
||||||
|
"lansync.pick": "Wybierz",
|
||||||
|
"lansync.pickCount": "Wybierz ({0}/{1})",
|
||||||
|
"lansync.pickSearch": "Szukaj",
|
||||||
|
"lansync.pickAll": "Zaznacz wszystko",
|
||||||
|
"lansync.pickNone": "Odznacz wszystko",
|
||||||
|
"lansync.prefixLeftBehind": "Prefiks {0} nie jest zaznaczony — folder tej aplikacji nie zostanie przesłany.",
|
||||||
|
"lansync.reviewTitle": "Przegląd usunięć",
|
||||||
|
"lansync.reviewFrom": "Odebrano od {0}. Przed usunięciem przejrzyj listę.",
|
||||||
|
"lansync.reviewText": "Do usunięcia: {0} plików ({1}). Odznacz to, co ma zostać.",
|
||||||
|
"lansync.reviewOverwrite": "Pliki istniejące po obu stronach zostaną nadpisane wersją z drugiego urządzenia. Jeśli grasz na obu komputerach, wyłącz ten prefiks z synchronizacji.",
|
||||||
|
"lansync.reviewKeepAll": "Zachowaj wszystko",
|
||||||
|
"lansync.reviewDelete": "Usuń {0} plików ({1})",
|
||||||
|
"lansync.reviewDeleting": "Usuwanie…",
|
||||||
|
"lansync.reviewFiles": "{0} plików · {1}",
|
||||||
|
"lansync.summaryYes": "tak",
|
||||||
"lansync.failed": "Synchronizacja nie powiodła się: {0}",
|
"lansync.failed": "Synchronizacja nie powiodła się: {0}",
|
||||||
"lansync.badCode": "Błędny kod synchronizacji. Wpisz kod pokazany na drugim urządzeniu.",
|
"lansync.badCode": "Błędny kod synchronizacji. Wpisz kod pokazany na drugim urządzeniu.",
|
||||||
"lansync.busy": "Drugie urządzenie jest zajęte (inna synchronizacja lub uruchomiona aplikacja).",
|
"lansync.busy": "Drugie urządzenie jest zajęte (inna synchronizacja lub uruchomiona aplikacja).",
|
||||||
|
|
@ -237,6 +325,8 @@
|
||||||
"lansync.idRerolled": "Wygenerowano nowe ID urządzenia",
|
"lansync.idRerolled": "Wygenerowano nowe ID urządzenia",
|
||||||
"lansync.codeRerolled": "Wygenerowano nowy kod synchronizacji",
|
"lansync.codeRerolled": "Wygenerowano nowy kod synchronizacji",
|
||||||
"lansync.unknownError": "nieznany błąd",
|
"lansync.unknownError": "nieznany błąd",
|
||||||
|
"lansync.oldPeer": "Drugie urządzenie ma starszą wersję PLWL. Zaktualizuj je — bez przeglądu usunięć ta synchronizacja nie jest bezpieczna.",
|
||||||
|
"lansync.reviewExpired": "Przegląd usunięć wygasł i nic nie zostało usunięte.",
|
||||||
"openwith.title": "Otwórz przez Wine",
|
"openwith.title": "Otwórz przez Wine",
|
||||||
"openwith.file": "Plik",
|
"openwith.file": "Plik",
|
||||||
"openwith.fileMissing": "Ten plik już nie istnieje (albo nie jest plikiem wykonywalnym Windows).",
|
"openwith.fileMissing": "Ten plik już nie istnieje (albo nie jest plikiem wykonywalnym Windows).",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "PLWL",
|
"name": "PLWL",
|
||||||
"productName": "PLWL",
|
"productName": "PLWL",
|
||||||
"version": "0.4.0",
|
"version": "0.5.4",
|
||||||
"description": "Portable Linux Wine Launcher",
|
"description": "Portable Linux Wine Launcher",
|
||||||
"main": "src/main.js",
|
"main": "src/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
1061
renderer/app.js
1061
renderer/app.js
File diff suppressed because it is too large
Load diff
|
|
@ -103,6 +103,22 @@
|
||||||
<button class="button" id="btn-open-files" data-t="settings.openFiles"></button>
|
<button class="button" id="btn-open-files" data-t="settings.openFiles"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-card">
|
||||||
|
<h2 data-t="settings.hashCache"></h2>
|
||||||
|
<p class="subtitle" data-t="settings.hashCacheHint"></p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button danger" id="btn-clear-hash-cache" data-t="settings.clearHashCache"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-card">
|
||||||
|
<h2 data-t="sandbox.title"></h2>
|
||||||
|
<p class="subtitle" data-t="sandbox.hint"></p>
|
||||||
|
<label class="field-label" data-t="sandbox.status"></label>
|
||||||
|
<p class="subtitle" id="set-sandbox-status"></p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button" id="btn-sandbox-check" data-t="sandbox.check"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="view-lansync" class="view">
|
<section id="view-lansync" class="view">
|
||||||
|
|
@ -173,26 +189,48 @@
|
||||||
<label class="field-label" data-t="config.args"></label>
|
<label class="field-label" data-t="config.args"></label>
|
||||||
<input type="text" id="gc-args" data-ph="config.argsHint">
|
<input type="text" id="gc-args" data-ph="config.argsHint">
|
||||||
|
|
||||||
<label class="field-label" data-t="config.wine"></label>
|
<div class="win-only">
|
||||||
<span id="gc-wine"></span>
|
<label class="field-label" data-t="config.wine"></label>
|
||||||
|
<span id="gc-wine"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="field-label" data-t="config.prefix"></label>
|
<label class="field-label win-only" data-t="config.prefix"></label>
|
||||||
|
<label class="field-label linux-only" data-t="config.linuxDataRoot" style="display:none"></label>
|
||||||
<span id="gc-prefix"></span>
|
<span id="gc-prefix"></span>
|
||||||
<p class="subtitle" id="gc-prefix-lock-hint" data-t="config.prefixLockedHint" style="display:none"></p>
|
<p class="subtitle win-only" id="gc-prefix-lock-hint" data-t="config.prefixLockedHint" style="display:none"></p>
|
||||||
|
<p class="subtitle linux-only" data-t="config.linuxDataRootHint" style="display:none"></p>
|
||||||
|
<div class="linux-only" style="display:none">
|
||||||
|
<button class="button" id="btn-gc-data-folder" data-t="config.openData"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="check-label">
|
<div class="linux-only" style="display:none">
|
||||||
<input type="checkbox" id="gc-disable-umu">
|
<label class="field-label" data-t="config.isolation"></label>
|
||||||
<span data-t="config.disableUmu"></span>
|
<span id="gc-isolation"></span>
|
||||||
</label>
|
<p class="subtitle" id="gc-isolation-hint"></p>
|
||||||
<p class="subtitle" data-t="config.disableUmuHint"></p>
|
<p class="subtitle" id="gc-isolation-status"></p>
|
||||||
|
|
||||||
|
<label class="check-label">
|
||||||
|
<input type="checkbox" id="gc-force-x11">
|
||||||
|
<span data-t="config.forceX11"></span>
|
||||||
|
</label>
|
||||||
|
<p class="subtitle" data-t="config.forceX11Hint"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="field-label" data-t="config.env"></label>
|
<label class="field-label" data-t="config.env"></label>
|
||||||
<textarea id="gc-env" rows="3" data-ph="config.envHint"></textarea>
|
<textarea id="gc-env" rows="3" data-ph="config.envHint"></textarea>
|
||||||
|
|
||||||
<label class="field-label" data-t="config.dllOverrides"></label>
|
<div class="win-only">
|
||||||
<div id="gc-overrides" class="overrides"></div>
|
<label class="check-label">
|
||||||
<button class="button" id="gc-override-add" data-t="config.overrides.add"></button>
|
<input type="checkbox" id="gc-disable-umu">
|
||||||
<p class="subtitle" data-t="config.overrides.hint"></p>
|
<span data-t="config.disableUmu"></span>
|
||||||
|
</label>
|
||||||
|
<p class="subtitle" data-t="config.disableUmuHint"></p>
|
||||||
|
|
||||||
|
<label class="field-label" data-t="config.dllOverrides"></label>
|
||||||
|
<div id="gc-overrides" class="overrides"></div>
|
||||||
|
<button class="button" id="gc-override-add" data-t="config.overrides.add"></button>
|
||||||
|
<p class="subtitle" data-t="config.overrides.hint"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="button danger" id="btn-gc-clear-playtime" data-t="config.clearPlaytime"></button>
|
<button class="button danger" id="btn-gc-clear-playtime" data-t="config.clearPlaytime"></button>
|
||||||
|
|
@ -201,7 +239,7 @@
|
||||||
<button class="button primary" id="btn-gc-save" data-t="config.save"></button>
|
<button class="button primary" id="btn-gc-save" data-t="config.save"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-card">
|
<div class="form-card win-only">
|
||||||
<h2 data-t="tools.title"></h2>
|
<h2 data-t="tools.title"></h2>
|
||||||
<p class="subtitle" data-t="tools.subtitle"></p>
|
<p class="subtitle" data-t="tools.subtitle"></p>
|
||||||
<div class="tools-grid" id="gc-tools"></div>
|
<div class="tools-grid" id="gc-tools"></div>
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.3 KiB |
|
|
@ -7,6 +7,10 @@
|
||||||
src: url('Nunito-Black.ttf') format('truetype');
|
src: url('Nunito-Black.ttf') format('truetype');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*:not(svg *) {
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--main-bg-color: rgb(20, 20, 20);
|
--main-bg-color: rgb(20, 20, 20);
|
||||||
--panel-color: rgb(26, 26, 26);
|
--panel-color: rgb(26, 26, 26);
|
||||||
|
|
@ -16,7 +20,7 @@
|
||||||
--button-margin: 0.4rem;
|
--button-margin: 0.4rem;
|
||||||
--button-height: 2.4rem;
|
--button-height: 2.4rem;
|
||||||
--icon-button-size: 3.4rem;
|
--icon-button-size: 3.4rem;
|
||||||
--border-width: 0.09rem;
|
--border-width: 0.08rem;
|
||||||
--big-default: rgb(207, 207, 207);
|
--big-default: rgb(207, 207, 207);
|
||||||
--big-red: hsl(0, 68%, 52%);
|
--big-red: hsl(0, 68%, 52%);
|
||||||
--big-green: hsl(120, 60%, 55%);
|
--big-green: hsl(120, 60%, 55%);
|
||||||
|
|
@ -103,10 +107,6 @@ sidebarelement.active .icon-button { background: rgba(255, 255, 255, 0.05); }
|
||||||
border: var(--border-width) solid transparent;
|
border: var(--border-width) solid transparent;
|
||||||
border-radius: 0.8rem;
|
border-radius: 0.8rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
backface-visibility: hidden;
|
|
||||||
transform: translateZ(0);
|
|
||||||
will-change: transform;
|
|
||||||
transform-origin: center center;
|
|
||||||
}
|
}
|
||||||
.icon-button:hover { background: rgba(255, 255, 255, 0.05); }
|
.icon-button:hover { background: rgba(255, 255, 255, 0.05); }
|
||||||
.icon-button:active { background: rgba(255, 255, 255, 0.1); transform: var(--press-scale); }
|
.icon-button:active { background: rgba(255, 255, 255, 0.1); transform: var(--press-scale); }
|
||||||
|
|
@ -195,6 +195,68 @@ content {
|
||||||
user has to notice, so it stands out from the subtitles around it. */
|
user has to notice, so it stands out from the subtitles around it. */
|
||||||
.sync-warning { color: var(--big-red); margin-top: 0.6rem; }
|
.sync-warning { color: var(--big-red); margin-top: 0.6rem; }
|
||||||
|
|
||||||
|
/* End-of-sync summary: label left, count/size right, one row per fact. */
|
||||||
|
.sync-summary { display: flex; flex-direction: column; }
|
||||||
|
.sync-summary-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.45rem 0;
|
||||||
|
border-bottom: var(--border-width) solid var(--light-border-color);
|
||||||
|
}
|
||||||
|
.sync-summary-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||||
|
.sync-summary-row span { color: var(--darker-text-color); }
|
||||||
|
.sync-summary-row strong { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* --- LAN sync: per-category item pickers ---------------------------------- */
|
||||||
|
.lan-cat { display: flex; flex-direction: column; }
|
||||||
|
.lan-cat-head { display: flex; align-items: center; gap: 0.6rem; }
|
||||||
|
.lan-cat-head .check-label { flex: 1; min-width: 0; margin: 0.35rem 0; }
|
||||||
|
.lan-pick {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0 0 0.5rem 2.4rem;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background-color: rgba(255, 255, 255, 0.045);
|
||||||
|
}
|
||||||
|
.lan-pick[hidden] { display: none; }
|
||||||
|
.lan-pick-tools { display: flex; align-items: center; gap: 0.4rem; }
|
||||||
|
.lan-search { flex: 1; min-width: 0; height: 2.2rem; min-height: 0; }
|
||||||
|
.lan-pick-list { display: flex; flex-direction: column; max-height: 16rem; overflow-y: auto; }
|
||||||
|
.lan-pick-list .check-label { margin: 0.15rem 0; }
|
||||||
|
.lan-pick-list .lan-hint { margin: 0 0 0.3rem 2.4rem; font-size: 0.85em; }
|
||||||
|
.lan-item span { overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
/* --- LAN sync: the deletion review ---------------------------------------- */
|
||||||
|
.lan-review { display: flex; flex-direction: column; max-height: 46vh; overflow-y: auto; padding-right: 0.3rem; }
|
||||||
|
.lan-review-unit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
.lan-review-unit .check-label { flex: 1; min-width: 0; margin: 0; }
|
||||||
|
.lan-review-unit .lan-review-name { font-weight: 700; overflow-wrap: anywhere; }
|
||||||
|
.lan-review-meta, .lan-node-meta {
|
||||||
|
color: var(--darker-text-color);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.lan-nodes { display: flex; flex-direction: column; }
|
||||||
|
.lan-nodes[hidden] { display: none; }
|
||||||
|
.lan-node { display: flex; align-items: center; gap: 0.5rem; padding: 0.12rem 0.2rem; }
|
||||||
|
.lan-node .check-label { flex: 1; min-width: 0; margin: 0; gap: 0.5rem; }
|
||||||
|
.lan-node .lan-node-name { overflow-wrap: anywhere; }
|
||||||
|
.lan-node input[type='checkbox'] { width: 1.4rem; height: 1.4rem; }
|
||||||
|
.lan-node-open { padding: 0 0.5rem; min-height: 1.6rem; }
|
||||||
|
.lan-review-actions { display: flex; gap: 0.6rem; justify-content: flex-end; margin-top: 0.6rem; }
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
height: var(--button-height);
|
height: var(--button-height);
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
|
|
@ -211,10 +273,6 @@ content {
|
||||||
/* only Regular and Black faces are bundled, so >=600 resolves to the same
|
/* only Regular and Black faces are bundled, so >=600 resolves to the same
|
||||||
bold rendering on every button; primary just adds the filled style */
|
bold rendering on every button; primary just adds the filled style */
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
backface-visibility: hidden;
|
|
||||||
transform: translateZ(0);
|
|
||||||
will-change: transform;
|
|
||||||
transform-origin: center center;
|
|
||||||
}
|
}
|
||||||
.button:hover { background-color: rgba(255, 255, 255, 0.09); }
|
.button:hover { background-color: rgba(255, 255, 255, 0.09); }
|
||||||
.button:active { background-color: rgba(255, 255, 255, 0.1); transform: var(--press-scale); }
|
.button:active { background-color: rgba(255, 255, 255, 0.1); transform: var(--press-scale); }
|
||||||
|
|
@ -260,10 +318,6 @@ input, textarea {
|
||||||
min-height: var(--button-height);
|
min-height: var(--button-height);
|
||||||
user-select: text !important;
|
user-select: text !important;
|
||||||
cursor: text;
|
cursor: text;
|
||||||
backface-visibility: hidden;
|
|
||||||
transform: translateZ(0);
|
|
||||||
will-change: transform;
|
|
||||||
transform-origin: center center;
|
|
||||||
}
|
}
|
||||||
input:hover, textarea:hover { background-color: rgba(255, 255, 255, 0.09); }
|
input:hover, textarea:hover { background-color: rgba(255, 255, 255, 0.09); }
|
||||||
input:active, textarea:active { background-color: rgba(255, 255, 255, 0.1); }
|
input:active, textarea:active { background-color: rgba(255, 255, 255, 0.1); }
|
||||||
|
|
@ -298,8 +352,6 @@ input[type='checkbox'] {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
backface-visibility: hidden;
|
|
||||||
transform: translateZ(0);
|
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
input[type='checkbox']:hover { background-color: rgba(255, 255, 255, 0.09); }
|
input[type='checkbox']:hover { background-color: rgba(255, 255, 255, 0.09); }
|
||||||
|
|
@ -336,10 +388,6 @@ select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
backface-visibility: hidden;
|
|
||||||
transform: translateZ(0);
|
|
||||||
will-change: transform;
|
|
||||||
transform-origin: center center;
|
|
||||||
}
|
}
|
||||||
.custom-select:hover { background-color: rgba(255, 255, 255, 0.09); }
|
.custom-select:hover { background-color: rgba(255, 255, 255, 0.09); }
|
||||||
.custom-select:active { background-color: rgba(255, 255, 255, 0.1); transform: var(--press-scale); }
|
.custom-select:active { background-color: rgba(255, 255, 255, 0.1); transform: var(--press-scale); }
|
||||||
|
|
@ -372,6 +420,15 @@ select {
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
/* the blanket "transform: translateZ(0)" above makes every element its own
|
||||||
|
stacking context, which traps the open list inside its wrapper (a select in
|
||||||
|
a .linux-only or .form-card row painted under the next label); app.js adds
|
||||||
|
this class to the ancestors while a select is open so the whole chain lifts
|
||||||
|
over the rows below, then removes it again */
|
||||||
|
.select-lift {
|
||||||
|
position: relative;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
.custom-select-options {
|
.custom-select-options {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 0.4rem);
|
top: calc(100% + 0.4rem);
|
||||||
|
|
@ -558,11 +615,11 @@ select {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.badge {
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
/* Nunito keeps a lot of air above the caps, so a line box centred by its
|
line-height: 1.2;
|
||||||
font metrics reads as "text sits high" (clearest on "Portable"); bias the
|
padding: 0.24em 0.7em 0.24em;
|
||||||
padding downwards, keeping the same overall height, to centre the label */
|
|
||||||
padding: 0.25em 0.7em 0.03em;
|
|
||||||
border-radius: 0.6rem;
|
border-radius: 0.6rem;
|
||||||
border: var(--border-width) solid var(--light-border-color);
|
border: var(--border-width) solid var(--light-border-color);
|
||||||
color: var(--darker-text-color);
|
color: var(--darker-text-color);
|
||||||
|
|
@ -702,6 +759,7 @@ select {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
.modal.wide { max-width: 44rem; }
|
||||||
.modal h2 {
|
.modal h2 {
|
||||||
font-family: 'Nunito Black', 'Nunito', sans-serif;
|
font-family: 'Nunito Black', 'Nunito', sans-serif;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
|
|
|
||||||
52
src/argv.js
52
src/argv.js
|
|
@ -3,35 +3,15 @@
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
|
|
||||||
// Shared command-line parsing for the launcher process itself. Both boot
|
// Command-line parsing shared by boot and the single-instance handoff.
|
||||||
// (process.argv) and the single-instance handoff (second-instance argv) use
|
// Supported: --launch <id>, --file <path>, --open <path>, or a bare file path.
|
||||||
// it, so a shortcut, a "Open with PLWL" file-open and Chromium's own injected
|
// Electron re-serializes handoff argv, so options/positionals match in order.
|
||||||
// flags are handled the same way everywhere.
|
|
||||||
//
|
|
||||||
// Supported forms:
|
|
||||||
// --launch <id> | --launch=<id> auto-start that library app
|
|
||||||
// --file <path> | --file=<path> hand this file to the --launch app
|
|
||||||
// --open <path> | --open=<path> open an .exe with the wine picker
|
|
||||||
// a bare existing-file path same as --open (plumbing for Exec %f)
|
|
||||||
//
|
|
||||||
// Electron hands the second instance's argv over RE-SERIALIZED: every switch
|
|
||||||
// comes first and its value follows as a positional argument, e.g. a shortcut
|
|
||||||
// `--launch <id> --file <path>` arrives as
|
|
||||||
// `--launch --file --allow-file-access-from-files <id> <path>`. Taking the
|
|
||||||
// token next to an option therefore picks the wrong value (it used to take
|
|
||||||
// the launch id as the opened file, so an app started without its image).
|
|
||||||
// Options and positionals are matched IN ORDER here instead.
|
|
||||||
|
|
||||||
const VALUE_OPTIONS = ['--launch', '--file', '--open']
|
const VALUE_OPTIONS = ['--launch', '--file', '--open']
|
||||||
|
|
||||||
// The options that carry a value, the positional arguments that follow the
|
// Value options, the positionals that follow the switches, and inline
|
||||||
// switches, and the values written inline (`--name=value`, which keeps its
|
// `--name=value` pairs. Tokens before the first switch are the executable
|
||||||
// value no matter how the rest is ordered).
|
// (matched against the running app path in dev mode and skipped).
|
||||||
//
|
|
||||||
// Tokens before the first switch are the executable - never values. In dev
|
|
||||||
// mode (`electron <app> ...`) Electron re-serializes the app path ALONG WITH
|
|
||||||
// the values, so it is matched against the running app's own path and skipped:
|
|
||||||
// it would otherwise be paired with the first option and shift every value.
|
|
||||||
function splitArgv(argv) {
|
function splitArgv(argv) {
|
||||||
const options = []
|
const options = []
|
||||||
const positional = []
|
const positional = []
|
||||||
|
|
@ -56,10 +36,8 @@ function splitArgv(argv) {
|
||||||
return { options, positional, inline }
|
return { options, positional, inline }
|
||||||
}
|
}
|
||||||
|
|
||||||
// The value of an option: inline when it was written as `--name=value`,
|
// An option's value: inline `--name=value`, or the positional at the same
|
||||||
// otherwise the positional argument with the SAME position as the option
|
// in-order position among the value options.
|
||||||
// among our value options (Chromium pushes all switches before all values, so
|
|
||||||
// any other pairing would read another option's value).
|
|
||||||
function optionValue(parsed, name) {
|
function optionValue(parsed, name) {
|
||||||
if (parsed.inline.has(name)) return parsed.inline.get(name)
|
if (parsed.inline.has(name)) return parsed.inline.get(name)
|
||||||
const index = parsed.options.indexOf(name)
|
const index = parsed.options.indexOf(name)
|
||||||
|
|
@ -68,16 +46,11 @@ function optionValue(parsed, name) {
|
||||||
return value === undefined ? null : value
|
return value === undefined ? null : value
|
||||||
}
|
}
|
||||||
|
|
||||||
// The file an app shortcut was asked to open: `--file <path>` when the
|
// The file an app shortcut was asked to open; only an absolute path counts.
|
||||||
// generated shortcut passes it explicitly, or the path a file manager appends
|
|
||||||
// on its own to an Exec line without a file field (KDE does that). Only an
|
|
||||||
// absolute path counts in either place: the launch id, Chromium's injected
|
|
||||||
// flags and the launcher's own path can never be mistaken for the document.
|
|
||||||
const FIELD_CODE_RE = /^%[fFuUdDnNickvm]?$/
|
const FIELD_CODE_RE = /^%[fFuUdDnNickvm]?$/
|
||||||
function launchFileOf(parsed, launchId) {
|
function launchFileOf(parsed, launchId) {
|
||||||
const explicit = optionValue(parsed, '--file')
|
const explicit = optionValue(parsed, '--file')
|
||||||
// a desktop environment that does not expand field codes would hand over
|
// a literal %f token is never a file path
|
||||||
// the literal token - never treat that as a file path
|
|
||||||
if (explicit && !FIELD_CODE_RE.test(explicit) && path.isAbsolute(explicit)) return explicit
|
if (explicit && !FIELD_CODE_RE.test(explicit) && path.isAbsolute(explicit)) return explicit
|
||||||
if (!launchId) return null
|
if (!launchId) return null
|
||||||
for (let i = parsed.positional.length - 1; i >= 0; i--) {
|
for (let i = parsed.positional.length - 1; i >= 0; i--) {
|
||||||
|
|
@ -97,10 +70,7 @@ function parseArgv(argv) {
|
||||||
const launchFile = launchFileOf(parsed, launchId)
|
const launchFile = launchFileOf(parsed, launchId)
|
||||||
let openPath = optionValue(parsed, '--open')
|
let openPath = optionValue(parsed, '--open')
|
||||||
if (!openPath && !launchId) {
|
if (!openPath && !launchId) {
|
||||||
// "Open with" plumbing (Exec %f): the file path arrives as the only
|
// Exec %f: the file path arrives as the only non-flag argument
|
||||||
// non-flag argument - never pick a bare token when a real flag carries
|
|
||||||
// the intent, and skip this process's own binary (electron dev mode
|
|
||||||
// passes it as the first argument).
|
|
||||||
const bare = args.find(
|
const bare = args.find(
|
||||||
a =>
|
a =>
|
||||||
!a.startsWith('-') &&
|
!a.startsWith('-') &&
|
||||||
|
|
|
||||||
196
src/fsutil.js
196
src/fsutil.js
|
|
@ -4,6 +4,16 @@ const fs = require('fs')
|
||||||
const fsp = require('fs').promises
|
const fsp = require('fs').promises
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
|
// accessSync instead of existsSync: same answer, one syscall fewer.
|
||||||
|
function pathExists(p) {
|
||||||
|
try {
|
||||||
|
fs.accessSync(p)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Returns a non-existing path in `base` named `name` (or `name (2)`, `name (3)`...).
|
// Returns a non-existing path in `base` named `name` (or `name (2)`, `name (3)`...).
|
||||||
function dedupeDir(base, name) {
|
function dedupeDir(base, name) {
|
||||||
const candidate = path.join(base, name)
|
const candidate = path.join(base, name)
|
||||||
|
|
@ -13,11 +23,7 @@ function dedupeDir(base, name) {
|
||||||
return path.join(base, `${name} (${n})`)
|
return path.join(base, `${name} (${n})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress is cheap to produce and expensive to render: hashing or copying a
|
// Events are rate limited to ~10/s; `force` pushes the final value through.
|
||||||
// tree can emit tens of thousands of events, and forwarding every single one
|
|
||||||
// over IPC makes the window stutter instead of showing progress. Events are
|
|
||||||
// rate limited to ~10/s; `force` lets a caller push the value that matters
|
|
||||||
// (the end of a unit) through immediately.
|
|
||||||
const PROGRESS_MIN_MS = 100
|
const PROGRESS_MIN_MS = 100
|
||||||
|
|
||||||
function progressGate(emit) {
|
function progressGate(emit) {
|
||||||
|
|
@ -50,15 +56,30 @@ function progressGate(emit) {
|
||||||
return push
|
return push
|
||||||
}
|
}
|
||||||
|
|
||||||
// What a tree copy will have to move. Symlinks have no size of their own
|
// Tree walks are dominated by per-file syscalls; a handful in flight keeps a
|
||||||
// (they are recreated, not read), so they only count for the file total.
|
// slow disk busy without flooding the event loop with thousands of them.
|
||||||
// `skipDir` is a single directory name to leave out (a game folder's
|
const TREE_CONCURRENCY = 16
|
||||||
// redistributables) - walking the whole tree first is fast next to the copy.
|
|
||||||
|
// Run `fn` over `items` with at most `limit` in flight, results in order.
|
||||||
|
async function mapLimit(items, limit, fn) {
|
||||||
|
const out = new Array(items.length)
|
||||||
|
let next = 0
|
||||||
|
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||||
|
while (next < items.length) {
|
||||||
|
const i = next++
|
||||||
|
out[i] = await fn(items[i], i)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.all(workers)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a tree copy will move. Symlinks are recreated, not read. `skipDir`
|
||||||
|
// leaves out one directory name (redistributables).
|
||||||
async function treeSize(root) {
|
async function treeSize(root) {
|
||||||
let files = 0
|
let files = 0
|
||||||
let bytes = 0
|
let bytes = 0
|
||||||
// a symlink (or a plain file) as the root is the whole tree: walking it
|
// a symlinked root is the whole tree; never measure through it
|
||||||
// would measure whatever it points at, not what is about to be removed
|
|
||||||
let rootStat = null
|
let rootStat = null
|
||||||
try {
|
try {
|
||||||
rootStat = await fsp.lstat(root)
|
rootStat = await fsp.lstat(root)
|
||||||
|
|
@ -73,7 +94,7 @@ async function treeSize(root) {
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for (const entry of entries) {
|
await mapLimit(entries, TREE_CONCURRENCY, async entry => {
|
||||||
const full = path.join(dir, entry.name)
|
const full = path.join(dir, entry.name)
|
||||||
if (entry.isSymbolicLink()) {
|
if (entry.isSymbolicLink()) {
|
||||||
files++
|
files++
|
||||||
|
|
@ -85,20 +106,20 @@ async function treeSize(root) {
|
||||||
bytes += (await fsp.stat(full)).size
|
bytes += (await fsp.stat(full)).size
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
await walk(root)
|
await walk(root)
|
||||||
return { files, bytes }
|
return { files, bytes }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy a directory tree the way an imported game/wine build has to arrive:
|
// Copy a tree with symlinks recreated and permissions carried (copyFile
|
||||||
// symlinks recreated as they are (copyFile would silently drop them) and file
|
// drops both); reports cumulative { done, bytes } through onProgress.
|
||||||
// permissions carried over (copyFile writes 0644, killing scripts and any
|
// `failed` counts entries that could not be recreated: a caller that needs a
|
||||||
// binary the game executes itself). Reports cumulative { done, bytes } after
|
// complete copy (an import) must not treat a partial tree as success.
|
||||||
// every file through `onProgress`, so long copies can drive a progress bar.
|
|
||||||
async function copyTree(src, dst, onProgress) {
|
async function copyTree(src, dst, onProgress) {
|
||||||
let done = 0
|
let done = 0
|
||||||
let bytes = 0
|
let bytes = 0
|
||||||
|
let failed = 0
|
||||||
const gate = onProgress ? progressGate(onProgress) : null
|
const gate = onProgress ? progressGate(onProgress) : null
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (gate) gate({ done, bytes }, false)
|
if (gate) gate({ done, bytes }, false)
|
||||||
|
|
@ -108,10 +129,16 @@ async function copyTree(src, dst, onProgress) {
|
||||||
try {
|
try {
|
||||||
entries = await fsp.readdir(from, { withFileTypes: true })
|
entries = await fsp.readdir(from, { withFileTypes: true })
|
||||||
} catch {
|
} catch {
|
||||||
|
failed++
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await fsp.mkdir(to, { recursive: true })
|
try {
|
||||||
for (const entry of entries) {
|
await fsp.mkdir(to, { recursive: true })
|
||||||
|
} catch {
|
||||||
|
failed++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await mapLimit(entries, TREE_CONCURRENCY, async entry => {
|
||||||
const s = path.join(from, entry.name)
|
const s = path.join(from, entry.name)
|
||||||
const d = path.join(to, entry.name)
|
const d = path.join(to, entry.name)
|
||||||
if (entry.isSymbolicLink()) {
|
if (entry.isSymbolicLink()) {
|
||||||
|
|
@ -119,38 +146,131 @@ async function copyTree(src, dst, onProgress) {
|
||||||
try {
|
try {
|
||||||
target = await fsp.readlink(s)
|
target = await fsp.readlink(s)
|
||||||
} catch {}
|
} catch {}
|
||||||
if (!target) continue
|
if (!target) {
|
||||||
|
failed++
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await fsp.symlink(target, d)
|
await fsp.symlink(target, d)
|
||||||
} catch {}
|
} catch {
|
||||||
|
failed++
|
||||||
|
}
|
||||||
done++
|
done++
|
||||||
tick()
|
tick()
|
||||||
} else if (entry.isDirectory()) {
|
} else if (entry.isDirectory()) {
|
||||||
await walk(s, d)
|
await walk(s, d)
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
let size = 0
|
let st = null
|
||||||
try {
|
try {
|
||||||
size = (await fsp.stat(s)).size
|
st = await fsp.stat(s)
|
||||||
} catch {}
|
} catch {}
|
||||||
|
if (!st) {
|
||||||
|
failed++
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await fsp.copyFile(s, d)
|
await fsp.copyFile(s, d)
|
||||||
await fsp.chmod(d, (await fsp.stat(s)).mode & 0o777)
|
} catch {
|
||||||
|
failed++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fsp.chmod(d, st.mode & 0o777)
|
||||||
} catch {}
|
} catch {}
|
||||||
done++
|
done++
|
||||||
bytes += size
|
bytes += st.size
|
||||||
tick()
|
tick()
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
await walk(src, dst)
|
await walk(src, dst)
|
||||||
if (gate) gate({ done, bytes }, true)
|
if (gate) gate({ done, bytes }, true)
|
||||||
return { done, bytes }
|
return { done, bytes, failed }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete a tree deepest-first so a long removal (a multi-GB prefix) can drive
|
// Imported trees come from media that lose permissions (archives, FAT, a
|
||||||
// a progress bar instead of freezing the window on one rmSync. Symlinks are
|
// read-only backup drive), and copyFile keeps whatever the source had: every
|
||||||
// unlinked, never followed. Reports cumulative { files, bytes, dirs } through
|
// node gets owner rwx and `x` mirrored from `r`, so the app can run its own
|
||||||
// `onProgress`; a missing tree is simply done.
|
// binaries and write its own files.
|
||||||
|
async function chmodTree(root) {
|
||||||
|
const desired = mode => mode | 0o700 | ((mode & 0o444) >> 2)
|
||||||
|
let files = 0
|
||||||
|
let dirs = 0
|
||||||
|
const walk = async dir => {
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = await fsp.readdir(dir, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await mapLimit(entries, TREE_CONCURRENCY, async entry => {
|
||||||
|
const full = path.join(dir, entry.name)
|
||||||
|
if (entry.isSymbolicLink()) return
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await walk(full)
|
||||||
|
} else if (!entry.isFile()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const before = (await fsp.stat(full)).mode & 0o7777
|
||||||
|
const after = desired(before)
|
||||||
|
if (after !== before) {
|
||||||
|
await fsp.chmod(full, after)
|
||||||
|
if (entry.isDirectory()) dirs++
|
||||||
|
else files++
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await walk(root)
|
||||||
|
return { files, dirs }
|
||||||
|
}
|
||||||
|
|
||||||
|
// One file, streamed, with byte progress: copyFile says nothing until a
|
||||||
|
// multi-gigabyte copy is over. The bytes land on a .part name and are renamed
|
||||||
|
// at the end, so nothing can ever read a half-written document.
|
||||||
|
async function copyFileProgress(src, dst, onProgress) {
|
||||||
|
const stat = await fsp.stat(src)
|
||||||
|
if (!stat.isFile()) throw new Error('not a file: ' + src)
|
||||||
|
const gate = onProgress ? progressGate(onProgress) : null
|
||||||
|
await fsp.mkdir(path.dirname(dst), { recursive: true })
|
||||||
|
const part = dst + '.part'
|
||||||
|
let bytes = 0
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const input = fs.createReadStream(src)
|
||||||
|
const output = fs.createWriteStream(part)
|
||||||
|
const fail = err => {
|
||||||
|
input.destroy()
|
||||||
|
output.destroy()
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
input.on('error', fail)
|
||||||
|
output.on('error', fail)
|
||||||
|
input.on('data', chunk => {
|
||||||
|
bytes += chunk.length
|
||||||
|
if (gate) gate({ done: 0, bytes }, false)
|
||||||
|
})
|
||||||
|
output.on('finish', resolve)
|
||||||
|
input.pipe(output)
|
||||||
|
})
|
||||||
|
await fsp.chmod(part, stat.mode & 0o777)
|
||||||
|
await fsp.rename(part, dst)
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
await fsp.rm(part, { force: true })
|
||||||
|
} catch {}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
if (gate) {
|
||||||
|
gate({ done: 1, bytes: stat.size }, true)
|
||||||
|
gate.done()
|
||||||
|
}
|
||||||
|
return { files: 1, bytes: stat.size }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deepest-first with progress through onProgress; symlinks are
|
||||||
|
// unlinked, never followed.
|
||||||
async function removeTree(root, onProgress) {
|
async function removeTree(root, onProgress) {
|
||||||
let files = 0
|
let files = 0
|
||||||
let dirs = 0
|
let dirs = 0
|
||||||
|
|
@ -159,9 +279,7 @@ async function removeTree(root, onProgress) {
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (gate) gate({ files, dirs, bytes }, false)
|
if (gate) gate({ files, dirs, bytes }, false)
|
||||||
}
|
}
|
||||||
// A symlink (or a file) as the root is removed as itself, never walked
|
// a symlinked root is removed as itself, never walked through
|
||||||
// through: the root of an import/prefix can be a link to another disk, and
|
|
||||||
// deleting through it would take the target's files instead of the link.
|
|
||||||
let rootStat = null
|
let rootStat = null
|
||||||
try {
|
try {
|
||||||
rootStat = await fsp.lstat(root)
|
rootStat = await fsp.lstat(root)
|
||||||
|
|
@ -184,7 +302,7 @@ async function removeTree(root, onProgress) {
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for (const entry of entries) {
|
await mapLimit(entries, TREE_CONCURRENCY, async entry => {
|
||||||
const full = path.join(dir, entry.name)
|
const full = path.join(dir, entry.name)
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
await walk(full)
|
await walk(full)
|
||||||
|
|
@ -193,7 +311,7 @@ async function removeTree(root, onProgress) {
|
||||||
} catch {}
|
} catch {}
|
||||||
dirs++
|
dirs++
|
||||||
tick()
|
tick()
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
let size = 0
|
let size = 0
|
||||||
try {
|
try {
|
||||||
|
|
@ -206,7 +324,7 @@ async function removeTree(root, onProgress) {
|
||||||
files++
|
files++
|
||||||
bytes += size
|
bytes += size
|
||||||
tick()
|
tick()
|
||||||
}
|
})
|
||||||
try {
|
try {
|
||||||
await fsp.rmdir(dir)
|
await fsp.rmdir(dir)
|
||||||
dirs++
|
dirs++
|
||||||
|
|
@ -218,5 +336,5 @@ async function removeTree(root, onProgress) {
|
||||||
return { files, dirs, bytes }
|
return { files, dirs, bytes }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { dedupeDir, copyTree, treeSize, removeTree, progressGate }
|
module.exports = { pathExists, dedupeDir, copyTree, copyFileProgress, treeSize, removeTree, chmodTree, progressGate, mapLimit }
|
||||||
|
|
||||||
|
|
|
||||||
118
src/hashcache.js
Normal file
118
src/hashcache.js
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
// Content hashing for the LAN sync, plus the per-file digest cache
|
||||||
|
// (files/data/lansync-hashes.json, keyed by path with size + mtime). The
|
||||||
|
// cache is machine-local and never travels in a sync.
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
// BLAKE2b is faster than SHA-2; older runtimes fall back to SHA-2.
|
||||||
|
const HASH_ALGO = (() => {
|
||||||
|
for (const algo of ['blake2b512', 'sha512', 'sha256']) {
|
||||||
|
try {
|
||||||
|
crypto.createHash(algo)
|
||||||
|
return algo
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return 'sha256'
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Streamed hashing keeps memory flat on huge files.
|
||||||
|
function hashFile(file, onBytes) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = crypto.createHash(HASH_ALGO)
|
||||||
|
const stream = fs.createReadStream(file, { highWaterMark: 64 * 1024 })
|
||||||
|
stream.on('error', reject)
|
||||||
|
if (onBytes) stream.on('data', chunk => onBytes(chunk.length))
|
||||||
|
stream.on('data', chunk => hash.update(chunk))
|
||||||
|
stream.on('end', () => resolve(hash.digest('hex')))
|
||||||
|
stream.resume()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beyond a quarter million entries, the least recently touched are dropped.
|
||||||
|
const HASH_CACHE_MAX = 250000
|
||||||
|
// Mid-sync saves are throttled; callers that must not lose entries pass force=true.
|
||||||
|
const SAVE_MIN_MS = 60000
|
||||||
|
|
||||||
|
function createHashCache(dataDir) {
|
||||||
|
const FILE = path.join(dataDir, 'lansync-hashes.json')
|
||||||
|
let cache = null
|
||||||
|
let dirty = false
|
||||||
|
let savedAt = 0
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (cache) return cache
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(fs.readFileSync(FILE, 'utf8'))
|
||||||
|
cache = stored && typeof stored === 'object' && stored.files && typeof stored.files === 'object' ? stored.files : {}
|
||||||
|
} catch {
|
||||||
|
cache = {}
|
||||||
|
}
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact JSON: a full cache writes in tens of ms.
|
||||||
|
function save(force) {
|
||||||
|
if (!dirty || !cache) return
|
||||||
|
if (!force && Date.now() - savedAt < SAVE_MIN_MS) return
|
||||||
|
const names = Object.keys(cache)
|
||||||
|
if (names.length > HASH_CACHE_MAX) {
|
||||||
|
names.sort((a, b) => (cache[b][3] || 0) - (cache[a][3] || 0))
|
||||||
|
const trimmed = {}
|
||||||
|
for (const name of names.slice(0, HASH_CACHE_MAX)) trimmed[name] = cache[name]
|
||||||
|
cache = trimmed
|
||||||
|
}
|
||||||
|
dirty = false
|
||||||
|
savedAt = Date.now()
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(FILE), { recursive: true })
|
||||||
|
const tmp = `${FILE}.tmp`
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify({ v: 1, algo: HASH_ALGO, files: cache }))
|
||||||
|
fs.renameSync(tmp, FILE)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The remembered digest, or null when it has to be read again. A hit marks
|
||||||
|
// the cache dirty only when the timestamp is stale.
|
||||||
|
function hit(rel, size, mtimeMs) {
|
||||||
|
const known = load()[rel]
|
||||||
|
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') return null
|
||||||
|
if (Date.now() - (known[3] || 0) > 86400000) {
|
||||||
|
known[3] = Date.now()
|
||||||
|
dirty = true
|
||||||
|
}
|
||||||
|
return known[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
function remember(rel, size, mtimeMs, hash) {
|
||||||
|
load()[rel] = [size, mtimeMs, hash, Date.now()]
|
||||||
|
dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unchanged size + mtime -> cached digest; otherwise read and store.
|
||||||
|
async function cachedHash(rel, abs, size, mtimeMs, onBytes) {
|
||||||
|
const known = hit(rel, size, mtimeMs)
|
||||||
|
if (known) return known
|
||||||
|
const hash = await hashFile(abs, onBytes)
|
||||||
|
if (hash) remember(rel, size, mtimeMs, hash)
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings view "clear cache" and stale files go through here.
|
||||||
|
function clear() {
|
||||||
|
cache = null
|
||||||
|
dirty = false
|
||||||
|
savedAt = 0
|
||||||
|
try {
|
||||||
|
fs.rmSync(FILE, { force: true })
|
||||||
|
fs.rmSync(`${FILE}.tmp`, { force: true })
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { HASH_ALGO, hashFile, hit, remember, cachedHash, save, clear }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createHashCache, hashFile, HASH_ALGO }
|
||||||
46
src/icons.js
46
src/icons.js
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
const { desktopFileFor, readDesktopEntry, findIconFile, pngOrSvg } = require('./linuxapp')
|
||||||
|
|
||||||
function rvaToOffset(sections, rva) {
|
function rvaToOffset(sections, rva) {
|
||||||
for (const s of sections) {
|
for (const s of sections) {
|
||||||
|
|
@ -120,17 +121,13 @@ function pngSize(img) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw RT_ICON payloads are BITMAPINFOHEADER-based bitmaps: biSize, biWidth,
|
// RT_ICON payloads are BITMAPINFOHEADER bitmaps; read real dimensions from
|
||||||
// biHeight (2x the visible height - XOR + AND mask), biPlanes, biBitCount,
|
// there (ICONDIRENTRY-style first bytes yield garbage).
|
||||||
// ..., biClrUsed. Read the real dimensions from there instead of treating the
|
|
||||||
// first bytes as an ICONDIRENTRY (which yielded garbage like h=0, planes=48
|
|
||||||
// and made Chromium decode an empty image).
|
|
||||||
function bmpDims(img) {
|
function bmpDims(img) {
|
||||||
if (img.length < 12) return null
|
if (img.length < 12) return null
|
||||||
const biSize = img.readUInt32LE(0)
|
const biSize = img.readUInt32LE(0)
|
||||||
if (biSize === 12) {
|
if (biSize === 12) {
|
||||||
// BITMAPCOREHEADER (Win 2.x/3.x era icons): DWORD size, then WORD width,
|
// BITMAPCOREHEADER (old icons): WORD width/height/planes/bitcount
|
||||||
// height, planes, bitcount
|
|
||||||
const w = img.readUInt16LE(4)
|
const w = img.readUInt16LE(4)
|
||||||
const h = Math.floor(img.readUInt16LE(6) / 2)
|
const h = Math.floor(img.readUInt16LE(6) / 2)
|
||||||
const planes = img.readUInt16LE(8) || 1
|
const planes = img.readUInt16LE(8) || 1
|
||||||
|
|
@ -173,7 +170,7 @@ function buildIco(images) {
|
||||||
planes = dims.planes
|
planes = dims.planes
|
||||||
bpp = dims.bpp
|
bpp = dims.bpp
|
||||||
} else {
|
} else {
|
||||||
// legacy fallback: ICONDIRENTRY-style first bytes (PNG-era heuristic)
|
// nothing measurable: the first bytes carry the size
|
||||||
w = img.readUInt8(0) || 256
|
w = img.readUInt8(0) || 256
|
||||||
h = img.readUInt8(1) || 256
|
h = img.readUInt8(1) || 256
|
||||||
colors = img.readUInt8(2)
|
colors = img.readUInt8(2)
|
||||||
|
|
@ -212,4 +209,35 @@ function extractIconFromExe(exePath, outPath) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { extractIconFromExe, parseIcons, buildIco }
|
// Native Linux apps carry no icon inside the binary: it comes from the
|
||||||
|
// .desktop entry (Icon=), an image next to the program, or the theme.
|
||||||
|
// `outBase` is a path without extension; the sniffed one is appended.
|
||||||
|
function iconFromLinuxApp(exePath, outBase) {
|
||||||
|
try {
|
||||||
|
const desktop = desktopFileFor(exePath)
|
||||||
|
const entry = desktop ? readDesktopEntry(desktop) : null
|
||||||
|
const baseDir = desktop ? path.dirname(desktop) : path.dirname(exePath)
|
||||||
|
let source = findIconFile(entry && entry.Icon, baseDir)
|
||||||
|
if (!source) {
|
||||||
|
// no .desktop: a same-named image (or the AppImage .DirIcon) next to it
|
||||||
|
const dir = path.dirname(exePath)
|
||||||
|
const stem = path.basename(exePath).replace(/\.(appimage|sh|bin)$/i, '')
|
||||||
|
for (const name of [stem + '.png', stem + '.svg', '.DirIcon', 'icon.png', 'icon.svg']) {
|
||||||
|
const candidate = path.join(dir, name)
|
||||||
|
if (pngOrSvg(candidate)) {
|
||||||
|
source = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!source) return { ok: false, error: 'no-icon' }
|
||||||
|
const outPath = `${outBase}.${pngOrSvg(source)}`
|
||||||
|
fs.mkdirSync(path.dirname(outPath), { recursive: true })
|
||||||
|
fs.copyFileSync(source, outPath)
|
||||||
|
return { ok: true, path: outPath }
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: String((err && err.message) || err) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { extractIconFromExe, iconFromLinuxApp, parseIcons, buildIco }
|
||||||
|
|
|
||||||
|
|
@ -5,34 +5,45 @@ const fsp = fs.promises
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const { DIRS, resolvePortablePath } = require('./paths')
|
const { DIRS, resolvePortablePath } = require('./paths')
|
||||||
const { newGame, newGameId, addGame } = require('./library')
|
const { newGame, newGameId, addGame } = require('./library')
|
||||||
const { extractIconFromExe } = require('./icons')
|
const { extractIconFromExe, iconFromLinuxApp } = require('./icons')
|
||||||
|
const { findLinuxExe } = require('./linuxapp')
|
||||||
const { listPrefixes, prefixDriveRoots, isPrefixRoot } = require('./prefixes')
|
const { listPrefixes, prefixDriveRoots, isPrefixRoot } = require('./prefixes')
|
||||||
const { rewireSymlinks } = require('./symlinks')
|
const { rewireSymlinks } = require('./symlinks')
|
||||||
const { copyTree, treeSize } = require('./fsutil')
|
const { copyTree, treeSize, chmodTree } = require('./fsutil')
|
||||||
|
|
||||||
// Directories that typically hold installers/redistributables, not the game.
|
// Directories that typically hold installers/redistributables, not the game.
|
||||||
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
|
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
|
||||||
|
|
||||||
// The copy itself lives in fsutil (copyTree): symlinks recreated, permissions
|
// A copy that lost files is not an import: drop the partial tree and say so.
|
||||||
// carried over, byte-level progress. moveFolder only falls back to it across
|
async function copyComplete(src, dst, onProgress) {
|
||||||
// filesystems; a same-device rename reports nothing (nothing is copied).
|
const res = await copyTree(src, dst, onProgress)
|
||||||
|
if (!res.failed) return res
|
||||||
|
await fsp.rm(dst, { recursive: true, force: true })
|
||||||
|
throw new Error('copy-incomplete')
|
||||||
|
}
|
||||||
|
|
||||||
|
// The copy itself lives in fsutil.copyTree; moveFolder falls back to it
|
||||||
|
// across filesystems only.
|
||||||
function moveFolder(src, dst, onProgress) {
|
function moveFolder(src, dst, onProgress) {
|
||||||
const fallback = () => copyTree(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true }))
|
const fallback = async () => {
|
||||||
|
await copyComplete(src, dst, onProgress)
|
||||||
|
await fsp.rm(src, { recursive: true, force: true })
|
||||||
|
}
|
||||||
if (path.parse(src).root !== path.parse(dst).root) return fallback()
|
if (path.parse(src).root !== path.parse(dst).root) return fallback()
|
||||||
return fsp.rename(src, dst).catch(fallback)
|
return fsp.rename(src, dst).catch(fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importGame(srcDir, mode, onProgress) {
|
async function importGame(srcDir, mode, onProgress, kind = 'windows') {
|
||||||
// a prefix (or its drive_c / pfx) is not an app folder: copy/move would take
|
const isLinux = kind === 'linux'
|
||||||
// the prefix's C: drive with it and leave the prefix gutted
|
// a prefix (or its drive_c/pfx) is not an app folder
|
||||||
if (isPrefixRoot(srcDir)) return { ok: false, error: 'prefix-root' }
|
if (isPrefixRoot(srcDir)) return { ok: false, error: 'prefix-root' }
|
||||||
const name = path.basename(srcDir)
|
const name = path.basename(srcDir)
|
||||||
// Validate before touching the user's files: refuse an import without any
|
const finder = isLinux ? findLinuxExe : findDefaultExe
|
||||||
// .exe (and don't half-move a folder we'd then have to roll back).
|
const missing = isLinux ? 'no-linux-exe' : 'no-exe-found'
|
||||||
const preExe = await findDefaultExe(srcDir)
|
// validate before touching anything: no entry point -> refuse
|
||||||
if (!preExe) return { ok: false, error: 'no-exe-found' }
|
const preExe = await finder(srcDir)
|
||||||
// The id doubles as the imported folder name, so generate it first and
|
if (!preExe) return { ok: false, error: missing }
|
||||||
// create files/apps/<id> - no dedupe suffixes, ids can never collide.
|
// the id doubles as the imported folder name
|
||||||
const id = newGameId()
|
const id = newGameId()
|
||||||
let workdir = srcDir
|
let workdir = srcDir
|
||||||
if (mode === 'copy' || mode === 'move') {
|
if (mode === 'copy' || mode === 'move') {
|
||||||
|
|
@ -44,47 +55,42 @@ async function importGame(srcDir, mode, onProgress) {
|
||||||
}
|
}
|
||||||
emit('count', { files: 0, bytes: 0 })
|
emit('count', { files: 0, bytes: 0 })
|
||||||
const total = await treeSize(srcDir)
|
const total = await treeSize(srcDir)
|
||||||
await copyTree(srcDir, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
await copyComplete(srcDir, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
||||||
} else await moveFolder(srcDir, dst, onProgress)
|
} else await moveFolder(srcDir, dst, onProgress)
|
||||||
workdir = dst
|
workdir = dst
|
||||||
// anything the game linked to INSIDE the launcher data (its own folder, an
|
// the source's permissions are not the source's truth: an app out of a
|
||||||
// icon, a shared prefix file) becomes a relative link, so the imported app
|
// read-only or mode-less medium has to become runnable and writable
|
||||||
// survives the launcher being moved - the same healing wine builds get
|
await chmodTree(dst)
|
||||||
|
// in-data link targets become relative, so the import survives a move
|
||||||
try {
|
try {
|
||||||
await rewireSymlinks(dst, [DIRS.FILES])
|
await rewireSymlinks(dst, [DIRS.FILES])
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
const exePath = (await findDefaultExe(workdir)) || preExe
|
const exePath = (await finder(workdir)) || preExe
|
||||||
if (!exePath) {
|
if (!exePath) {
|
||||||
return { ok: false, error: 'no-exe-found' }
|
return { ok: false, error: missing }
|
||||||
}
|
}
|
||||||
const game = newGame({ id, name, exePath, workdir, mode })
|
const game = newGame({ id, name, exePath, workdir, mode, kind })
|
||||||
game.imported = mode === 'copy' || mode === 'move'
|
game.imported = mode === 'copy' || mode === 'move'
|
||||||
// the exe was picked automatically -> grab its icon right away; a missing
|
// auto-picked exe -> grab its icon; a missing icon is not a failure
|
||||||
// icon is not an import failure (game can still get one from config)
|
const iconDir = path.join(DIRS.DATA, 'icons', id)
|
||||||
const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico')
|
const icon = isLinux
|
||||||
if (extractIconFromExe(resolvePortablePath(exePath), icon).ok) {
|
? iconFromLinuxApp(resolvePortablePath(exePath), path.join(iconDir, 'icon'))
|
||||||
game.iconSource = icon
|
: extractIconFromExe(resolvePortablePath(exePath), path.join(iconDir, 'icon.ico'))
|
||||||
|
if (icon.ok) {
|
||||||
|
game.iconSource = icon.path
|
||||||
}
|
}
|
||||||
return addGame(game)
|
return addGame(game)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import from a wine prefix: the folder is picked under the prefix's
|
// Import from a prefix: the folder stays in place, paths stored prefix-relative.
|
||||||
// drive_c and left in place - the game paths are stored relative to the
|
|
||||||
// prefix, so wine sees the app exactly as if it had been installed
|
|
||||||
// normally into C:\...
|
|
||||||
async function importGameFromPrefix(prefix, srcDir, onProgress) {
|
async function importGameFromPrefix(prefix, srcDir, onProgress) {
|
||||||
if (!listPrefixes().prefixes.some(p => p.name === prefix)) return { ok: false, error: 'prefix-not-found' }
|
if (!listPrefixes().prefixes.some(p => p.name === prefix)) return { ok: false, error: 'prefix-not-found' }
|
||||||
// The folder must live inside one of the prefix's C: drives so the C:\
|
// the folder must live inside one of the prefix's C: drives
|
||||||
// mapping holds - drive_c sits at the prefix top for plain wine, under pfx/
|
|
||||||
// for Proton-managed prefixes, and a prefix used with BOTH kinds has both.
|
|
||||||
// Every existing drive_c counts; the stored path keeps its drive_c segment
|
|
||||||
// either way, so it resolves in the layout the app will run in.
|
|
||||||
const driveRoots = prefixDriveRoots(prefix)
|
const driveRoots = prefixDriveRoots(prefix)
|
||||||
if (!driveRoots.length) return { ok: false, error: 'no-drive-c' }
|
if (!driveRoots.length) return { ok: false, error: 'no-drive-c' }
|
||||||
const src = path.resolve(String(srcDir))
|
const src = path.resolve(String(srcDir))
|
||||||
// realpath on both sides: a unified prefix reaches drive_c through the pfx
|
// realpath both sides: a unified prefix reaches drive_c through pfx too
|
||||||
// symlink too, and that path is the same folder (see ensureUnifiedPrefix)
|
|
||||||
let realSrc
|
let realSrc
|
||||||
try {
|
try {
|
||||||
realSrc = fs.realpathSync(src)
|
realSrc = fs.realpathSync(src)
|
||||||
|
|
@ -108,8 +114,7 @@ async function importGameFromPrefix(prefix, srcDir, onProgress) {
|
||||||
const game = newGame({ id, name: path.basename(src), exePath: preExe, workdir: src, mode: 'prefix' })
|
const game = newGame({ id, name: path.basename(src), exePath: preExe, workdir: src, mode: 'prefix' })
|
||||||
game.importSource = 'prefix'
|
game.importSource = 'prefix'
|
||||||
game.prefix = prefix
|
game.prefix = prefix
|
||||||
// exe picked automatically -> grab its icon right away; a missing icon is
|
// auto-picked exe -> grab its icon; a missing icon is not a failure
|
||||||
// not an import failure (game can still get one from config)
|
|
||||||
const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico')
|
const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico')
|
||||||
if (extractIconFromExe(resolvePortablePath(preExe), icon).ok) {
|
if (extractIconFromExe(resolvePortablePath(preExe), icon).ok) {
|
||||||
game.iconSource = icon
|
game.iconSource = icon
|
||||||
|
|
@ -117,9 +122,7 @@ async function importGameFromPrefix(prefix, srcDir, onProgress) {
|
||||||
return addGame(game)
|
return addGame(game)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Largest .exe in the tree, skipping redistributable folders. Files are
|
// Largest .exe in the tree, skipping redistributable folders.
|
||||||
// stat'ed in parallel and folders walked breadth-first so big games don't
|
|
||||||
// turn the picker into a sequential crawl.
|
|
||||||
async function findDefaultExe(dir) {
|
async function findDefaultExe(dir) {
|
||||||
let best = ''
|
let best = ''
|
||||||
let bestSize = -1
|
let bestSize = -1
|
||||||
|
|
@ -158,4 +161,4 @@ async function findDefaultExe(dir) {
|
||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { importGame, importGameFromPrefix, findDefaultExe, moveFolder }
|
module.exports = { importGame, importGameFromPrefix, findDefaultExe, findLinuxExe, moveFolder, copyComplete }
|
||||||
|
|
|
||||||
157
src/ipc/games.js
157
src/ipc/games.js
|
|
@ -1,7 +1,9 @@
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
const { runGame, runTool } = require('../runner')
|
const { runGame, runTool } = require('../runner')
|
||||||
|
const { runLinuxGame } = require('../linuxrunner')
|
||||||
const { importGame, importGameFromPrefix } = require('../importer')
|
const { importGame, importGameFromPrefix } = require('../importer')
|
||||||
|
const { prefixLinuxRoot } = require('../prefixes')
|
||||||
const library = require('../library')
|
const library = require('../library')
|
||||||
|
|
||||||
function killProcessGroup(pid) {
|
function killProcessGroup(pid) {
|
||||||
|
|
@ -17,20 +19,12 @@ function sendToWindow(win, channel, data) {
|
||||||
if (win && !win.isDestroyed()) win.webContents.send(channel, data)
|
if (win && !win.isDestroyed()) win.webContents.send(channel, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game-process state: id -> { pid (null while the process is starting), the
|
// Game-process state: id -> { pid, resolved prefix name and wine id }, frozen
|
||||||
// prefix name and wine id the run was resolved to }. Both are frozen here:
|
// at launch (the in-use guards must name what the process really locked).
|
||||||
// the default prefix/wine can be switched and the app's own config edited
|
// onGamesChanged fires in the main process when the running set changes.
|
||||||
// while it plays, and the in-use guards have to name what the process really
|
function createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgress, openRelay }) {
|
||||||
// locked - not what the registry says now. Editing an app's own prefix while
|
|
||||||
// it runs does not move the process, so the new folder is blocked next to the
|
|
||||||
// snapshot (a pre-edit prefix does not exist in it yet).
|
|
||||||
// onGamesChanged fires in the MAIN process whenever the running-games set
|
|
||||||
// changes (the tray menu rebuilds itself from it).
|
|
||||||
const STARTUP_GRACE_MS = 60000
|
|
||||||
|
|
||||||
function createGamesService({ getWindow, onGamesChanged }) {
|
|
||||||
const running = new Map()
|
const running = new Map()
|
||||||
const startedAt = new Map()
|
const starting = new Set()
|
||||||
const killRequests = new Set()
|
const killRequests = new Set()
|
||||||
let onGameExit = null
|
let onGameExit = null
|
||||||
const notifyChanged = () => {
|
const notifyChanged = () => {
|
||||||
|
|
@ -47,18 +41,20 @@ function createGamesService({ getWindow, onGamesChanged }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A game is "running" only while its process really is. An entry left behind
|
// Everything a run owns once it is over: the entry and the watcher on the
|
||||||
// by a launch that never got a pid (the binary could not be executed, so the
|
// app's request folder (openrelay.js).
|
||||||
// spawn failed) or whose pid is gone (the game crashed, the launcher was
|
function forget(id) {
|
||||||
// restarted) would otherwise answer every later Play with "already
|
const entry = running.get(id)
|
||||||
// running".
|
running.delete(id)
|
||||||
|
starting.delete(id)
|
||||||
|
if (entry && entry.openRoot && openRelay) openRelay.unwatch(entry.openRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dead process must not answer "already running" - but a start that is
|
||||||
|
// still in flight owns its entry, however long a hand-off copy takes.
|
||||||
function prune() {
|
function prune() {
|
||||||
for (const [id, entry] of [...running]) {
|
for (const [id, entry] of [...running]) {
|
||||||
if (alive(entry.pid)) continue
|
if (!starting.has(id) && !alive(entry.pid)) forget(id)
|
||||||
const age = Date.now() - (startedAt.get(id) || 0)
|
|
||||||
if (!entry.pid && age < STARTUP_GRACE_MS) continue // may still be starting
|
|
||||||
running.delete(id)
|
|
||||||
startedAt.delete(id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,11 +66,7 @@ function createGamesService({ getWindow, onGamesChanged }) {
|
||||||
prune()
|
prune()
|
||||||
return [...running.keys()]
|
return [...running.keys()]
|
||||||
}
|
}
|
||||||
// What the still-running games actually use, for the "is this prefix/wine in
|
// snapshot + live name together name every folder a run touches
|
||||||
// use?" guards: [{ id, name, prefixes: [snapshot, ...], wine }]. The
|
|
||||||
// snapshot alone goes stale when a running app is pointed at another prefix
|
|
||||||
// (the process keeps its folder open); the live name alone misses the folder
|
|
||||||
// it currently holds. Together they name every folder the run touches.
|
|
||||||
const runningInfo = () => {
|
const runningInfo = () => {
|
||||||
prune()
|
prune()
|
||||||
const out = []
|
const out = []
|
||||||
|
|
@ -87,9 +79,7 @@ function createGamesService({ getWindow, onGamesChanged }) {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Open with PLWL" handoff: queue the file for the renderer (picker popup
|
// "Open with" handoff: queue the file for the renderer.
|
||||||
// with wine version + prefix). Safe to call at any time - if the window is
|
|
||||||
// not up yet, the renderer picks the file up right after boot.
|
|
||||||
function openExternally(openPath) {
|
function openExternally(openPath) {
|
||||||
require('../openwith').setPending(openPath)
|
require('../openwith').setPending(openPath)
|
||||||
sendToWindow(getWindow(), 'openwith:file', openPath)
|
sendToWindow(getWindow(), 'openwith:file', openPath)
|
||||||
|
|
@ -99,79 +89,104 @@ function createGamesService({ getWindow, onGamesChanged }) {
|
||||||
return require('../wines').resolveWine(game.wine || 'system')
|
return require('../wines').resolveWine(game.wine || 'system')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleExit(id) {
|
// `entry` is the run that exited: a previous run of the same app (stopped and
|
||||||
running.delete(id)
|
// then relaunched) must not take the new run's entry and watcher away.
|
||||||
startedAt.delete(id)
|
function handleExit(id, entry) {
|
||||||
|
if (running.get(id) === entry) forget(id)
|
||||||
sendToWindow(getWindow(), 'games:changed')
|
sendToWindow(getWindow(), 'games:changed')
|
||||||
notifyChanged()
|
notifyChanged()
|
||||||
if (onGameExit) onGameExit(id)
|
if (onGameExit) onGameExit(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Native Linux apps skip wine entirely; everything else stays the same.
|
||||||
|
function runnerFor(game) {
|
||||||
|
if (!library.isLinuxGame(game)) return null
|
||||||
|
return payload => runLinuxGame({ game, sandbox, ...payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
// where a native app leaves what it wants opened on the host (openrelay.js)
|
||||||
|
function openRootFor(game) {
|
||||||
|
return library.isLinuxGame(game) ? prefixLinuxRoot(library.prefixNameForGame(game)) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// An isolated hand-off copies the document before the app starts, which is
|
||||||
|
// long enough to be worth reporting. The window may not exist yet (a
|
||||||
|
// shortcut boots headless), so the main process gets the event first and
|
||||||
|
// decides whether to put a window in front of the user.
|
||||||
|
function handoffProgress(data) {
|
||||||
|
if (typeof onHandoffProgress === 'function') onHandoffProgress(data)
|
||||||
|
sendToWindow(getWindow(), 'handoff:progress', data)
|
||||||
|
}
|
||||||
|
|
||||||
async function start(win, id, openFile) {
|
async function start(win, id, openFile) {
|
||||||
const game = library.getGame(id)
|
const game = library.getGame(id)
|
||||||
if (!game) return { ok: false, error: 'game-not-found' }
|
if (!game) return { ok: false, error: 'game-not-found' }
|
||||||
prune()
|
prune()
|
||||||
let wine
|
const isLinux = library.isLinuxGame(game)
|
||||||
try {
|
let wine = null
|
||||||
wine = resolveWine(game)
|
if (!isLinux) {
|
||||||
} catch {
|
try {
|
||||||
return { ok: false, error: 'wine-not-found' }
|
wine = resolveWine(game)
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: 'wine-not-found' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const run = isLinux ? runnerFor(game) : payload => runGame({ game, wine, systemWine: wine.type === 'system', ...payload })
|
||||||
if (running.has(id)) {
|
if (running.has(id)) {
|
||||||
// Opening a document in an app that is already running is a HANDOFF,
|
// a document into a running app is a handoff, not a tracked run
|
||||||
// exactly like Windows: a second process receives the file and the app's
|
|
||||||
// own single-instance logic takes it over. It is not a tracked run -
|
|
||||||
// playtime, the tray and the kill switch belong to the first process.
|
|
||||||
if (!openFile) return { ok: false, error: 'already-running' }
|
if (!openFile) return { ok: false, error: 'already-running' }
|
||||||
try {
|
try {
|
||||||
await runGame({ game, wine, systemWine: wine.type === 'system', openFile, handoff: true })
|
await run({ openFile, handoff: true, onProgress: handoffProgress })
|
||||||
return { ok: true, pid: null, logFile: null }
|
return { ok: true, pid: null, logFile: null }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, error: String((err && err.message) || err) }
|
return { ok: false, error: String((err && err.message) || err) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine.id })
|
const openRoot = openRootFor(game)
|
||||||
startedAt.set(id, Date.now())
|
const entry = { pid: null, prefix: library.prefixNameForGame(game), wine: wine ? wine.id : '', openRoot }
|
||||||
|
running.set(id, entry)
|
||||||
|
// the app may ask for a folder the moment it paints its window, so the
|
||||||
|
// request file is watched before the run starts
|
||||||
|
if (openRoot && openRelay) openRelay.watch(openRoot)
|
||||||
|
starting.add(id)
|
||||||
try {
|
try {
|
||||||
const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id), openFile })
|
const res = await run({ onExit: () => handleExit(id, entry), openFile, onProgress: handoffProgress })
|
||||||
// stop was requested while the process was still starting: kill it and
|
// stop requested while still starting: kill and clear
|
||||||
// leave nothing in the running set
|
|
||||||
if (killRequests.has(id)) {
|
if (killRequests.has(id)) {
|
||||||
killRequests.delete(id)
|
killRequests.delete(id)
|
||||||
if (res.pid) killProcessGroup(res.pid)
|
if (res.pid) killProcessGroup(res.pid)
|
||||||
running.delete(id)
|
forget(id)
|
||||||
startedAt.delete(id)
|
|
||||||
sendToWindow(win, 'games:changed')
|
sendToWindow(win, 'games:changed')
|
||||||
notifyChanged()
|
notifyChanged()
|
||||||
return { ok: true, pid: null, logFile: res.logFile || null }
|
return { ok: true, pid: null, logFile: res.logFile || null }
|
||||||
}
|
}
|
||||||
// no pid means the process was not started after all - never keep such
|
// no pid: never keep the entry, it would block the next launch
|
||||||
// an entry, it would block the next launch with "already running"
|
|
||||||
if (!res.pid) {
|
if (!res.pid) {
|
||||||
running.delete(id)
|
forget(id)
|
||||||
startedAt.delete(id)
|
|
||||||
return { ok: false, error: 'not-started' }
|
return { ok: false, error: 'not-started' }
|
||||||
}
|
}
|
||||||
const entry = running.get(id)
|
starting.delete(id)
|
||||||
if (entry) entry.pid = res.pid
|
entry.pid = res.pid
|
||||||
sendToWindow(win, 'games:changed')
|
sendToWindow(win, 'games:changed')
|
||||||
notifyChanged()
|
notifyChanged()
|
||||||
return { ok: true, pid: res.pid, logFile: res.logFile || null }
|
return { ok: true, pid: res.pid, logFile: res.logFile || null }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
running.delete(id)
|
killRequests.delete(id)
|
||||||
startedAt.delete(id)
|
forget(id)
|
||||||
return { ok: false, error: String((err && err.message) || err) }
|
return { ok: false, error: String((err && err.message) || err) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop always clears the state: whether the process was running, still
|
// Stop clears the state - except while a run is still starting: that start
|
||||||
// starting (kill request for as soon as it has a pid) or already gone.
|
// kills it the moment it has a pid, and clearing the entry here would let a
|
||||||
|
// second launch race past the running one.
|
||||||
async function kill(id) {
|
async function kill(id) {
|
||||||
const entry = running.get(id)
|
const entry = running.get(id)
|
||||||
if (entry) killProcessGroup(entry.pid)
|
if (entry && !entry.pid) killRequests.add(id)
|
||||||
else if (running.has(id)) killRequests.add(id)
|
else {
|
||||||
running.delete(id)
|
if (entry) killProcessGroup(entry.pid)
|
||||||
startedAt.delete(id)
|
forget(id)
|
||||||
|
}
|
||||||
sendToWindow(getWindow(), 'games:changed')
|
sendToWindow(getWindow(), 'games:changed')
|
||||||
notifyChanged()
|
notifyChanged()
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
|
|
@ -180,6 +195,8 @@ function createGamesService({ getWindow, onGamesChanged }) {
|
||||||
async function runToolFor(id, tool, args) {
|
async function runToolFor(id, tool, args) {
|
||||||
const game = library.getGame(id)
|
const game = library.getGame(id)
|
||||||
if (!game) return { ok: false, error: 'game-not-found' }
|
if (!game) return { ok: false, error: 'game-not-found' }
|
||||||
|
// Wine tools make no sense for a native Linux app
|
||||||
|
if (library.isLinuxGame(game)) return { ok: false, error: 'not-windows' }
|
||||||
let wine
|
let wine
|
||||||
try {
|
try {
|
||||||
wine = resolveWine(game)
|
wine = resolveWine(game)
|
||||||
|
|
@ -194,18 +211,16 @@ function createGamesService({ getWindow, onGamesChanged }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// subscribe to game exits (used by the tray/headless mode to e.g. quit when
|
// subscribe to game exits (tray/headless auto-quit)
|
||||||
// the game that auto-started the launcher has finished)
|
|
||||||
function setOnGameExit(fn) {
|
function setOnGameExit(fn) {
|
||||||
onGameExit = typeof fn === 'function' ? fn : null
|
onGameExit = typeof fn === 'function' ? fn : null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importTo(win, srcDir, mode) {
|
async function importTo(win, srcDir, mode, kind) {
|
||||||
return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data))
|
return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data), kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
// prefix import: nothing is copied, the game just gets registered with
|
// prefix import: register in place, nothing copied
|
||||||
// paths relative to the prefix (wine sees it as a normal C:\ install)
|
|
||||||
async function importFromPrefix(win, prefix, srcDir) {
|
async function importFromPrefix(win, prefix, srcDir) {
|
||||||
return importGameFromPrefix(prefix, srcDir)
|
return importGameFromPrefix(prefix, srcDir)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
113
src/ipc/index.js
113
src/ipc/index.js
|
|
@ -9,7 +9,9 @@ const library = require('../library')
|
||||||
const wines = require('../wines')
|
const wines = require('../wines')
|
||||||
const prefixes = require('../prefixes')
|
const prefixes = require('../prefixes')
|
||||||
const runner = require('../runner')
|
const runner = require('../runner')
|
||||||
const { extractIconFromExe } = require('../icons')
|
const linuxrunner = require('../linuxrunner')
|
||||||
|
const { createSandboxProbe } = require('../sandbox')
|
||||||
|
const { extractIconFromExe, iconFromLinuxApp } = require('../icons')
|
||||||
const { createGamesService, sendToWindow } = require('./games')
|
const { createGamesService, sendToWindow } = require('./games')
|
||||||
const shortcuts = require('../shortcuts')
|
const shortcuts = require('../shortcuts')
|
||||||
const openwith = require('../openwith')
|
const openwith = require('../openwith')
|
||||||
|
|
@ -25,9 +27,7 @@ function errorResult(err) {
|
||||||
|
|
||||||
const iconDir = id => path.join(DIRS.DATA, 'icons', id)
|
const iconDir = id => path.join(DIRS.DATA, 'icons', id)
|
||||||
|
|
||||||
// `keep` spares one file (the icon just written): the icon protocol picks the
|
// `keep` spares the icon just written: the protocol picks the LAST icon.*.
|
||||||
// LAST icon.* alphabetically, so a leftover icon.png from an earlier pick
|
|
||||||
// would otherwise keep showing the old icon.
|
|
||||||
function clearIconDir(id, keep) {
|
function clearIconDir(id, keep) {
|
||||||
try {
|
try {
|
||||||
for (const f of fs.readdirSync(iconDir(id))) {
|
for (const f of fs.readdirSync(iconDir(id))) {
|
||||||
|
|
@ -36,21 +36,32 @@ function clearIconDir(id, keep) {
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractGameIcon(id, exePath) {
|
function extractGameIcon(id, exePath, kind) {
|
||||||
const out = path.join(iconDir(id), 'icon.ico')
|
const isLinux = kind === 'linux'
|
||||||
const res = extractIconFromExe(exePath, out)
|
// the Linux helper picks the extension itself (png/svg), the PE one is ico
|
||||||
|
const out = path.join(iconDir(id), isLinux ? 'icon' : 'icon.ico')
|
||||||
|
const res = isLinux ? iconFromLinuxApp(exePath, out) : extractIconFromExe(exePath, out)
|
||||||
// on failure nothing is cleared, so the previous icon survives a wrong exe
|
// on failure nothing is cleared, so the previous icon survives a wrong exe
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
clearIconDir(id, 'icon.ico')
|
clearIconDir(id, path.basename(res.path))
|
||||||
library.updateGame(id, { iconSource: out })
|
library.updateGame(id, { iconSource: res.path })
|
||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- registration -----------------------------------------------------------
|
// --- registration -----------------------------------------------------------
|
||||||
|
|
||||||
function registerIpc({ getWindow, onGamesChanged }) {
|
function registerIpc({ getWindow, onGamesChanged, onHandoffProgress, openRelay }) {
|
||||||
const games = createGamesService({ getWindow, onGamesChanged })
|
// one probe per launcher, cached: it decides bwrap vs plain run
|
||||||
|
const sandbox = createSandboxProbe()
|
||||||
|
const games = createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgress, openRelay })
|
||||||
|
|
||||||
|
// sandbox --------------------------------------------------------------------
|
||||||
|
ipcMain.handle('sandbox:status', async () => sandbox.status() || (await sandbox.probe()))
|
||||||
|
ipcMain.handle('sandbox:probe', async () => {
|
||||||
|
sandbox.invalidate()
|
||||||
|
return sandbox.probe()
|
||||||
|
})
|
||||||
|
|
||||||
// i18n ---------------------------------------------------------------------
|
// i18n ---------------------------------------------------------------------
|
||||||
ipcMain.handle('i18n:list', async () => {
|
ipcMain.handle('i18n:list', async () => {
|
||||||
|
|
@ -68,26 +79,16 @@ function registerIpc({ getWindow, onGamesChanged }) {
|
||||||
ipcMain.handle('settings:set', (e, patch) => library.saveSettings(patch))
|
ipcMain.handle('settings:set', (e, patch) => library.saveSettings(patch))
|
||||||
|
|
||||||
// wines ----------------------------------------------------------------------
|
// wines ----------------------------------------------------------------------
|
||||||
function systemWine() {
|
|
||||||
let version = ''
|
|
||||||
try {
|
|
||||||
const { execFileSync } = require('child_process')
|
|
||||||
version = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
|
|
||||||
} catch {}
|
|
||||||
return { id: 'system', type: 'system', label: 'System Wine', version }
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle('wines:list', async () => {
|
ipcMain.handle('wines:list', async () => {
|
||||||
await wines.refreshWines()
|
await wines.refreshWines()
|
||||||
return [systemWine(), ...wines.getWines()]
|
return [wines.systemWine(), ...wines.getWines()]
|
||||||
})
|
})
|
||||||
ipcMain.handle('wines:refresh', async () => (await wines.refreshWines()).filter(w => !w.disabled))
|
ipcMain.handle('wines:refresh', async () => (await wines.refreshWines()).filter(w => !w.disabled))
|
||||||
ipcMain.handle('wines:setDisabled', (e, id, disabled) => wines.setWineDisabled(id, disabled))
|
ipcMain.handle('wines:setDisabled', (e, id, disabled) => wines.setWineDisabled(id, disabled))
|
||||||
ipcMain.handle('wines:setDisplayName', (e, id, name) => wines.setWineDisplayName(id, name))
|
ipcMain.handle('wines:setDisplayName', (e, id, name) => wines.setWineDisplayName(id, name))
|
||||||
ipcMain.handle('wines:setDefault', (e, id) => wines.setDefaultWine(id))
|
ipcMain.handle('wines:setDefault', (e, id) => wines.setDefaultWine(id))
|
||||||
ipcMain.handle('wines:delete', async (e, id) => {
|
ipcMain.handle('wines:delete', async (e, id) => {
|
||||||
// the build's files back the running prefix (dlls, stub exes) - deleting
|
// the build's files back a running prefix; deleting it breaks that game
|
||||||
// it under a running game breaks that game
|
|
||||||
if (library.isWineInUse(id, games.runningInfo())) {
|
if (library.isWineInUse(id, games.runningInfo())) {
|
||||||
const apps = games.runningInfo().filter(r => r.wine === id).map(r => r.name)
|
const apps = games.runningInfo().filter(r => r.wine === id).map(r => r.name)
|
||||||
return { ok: false, error: 'inUse', apps }
|
return { ok: false, error: 'inUse', apps }
|
||||||
|
|
@ -103,10 +104,7 @@ function registerIpc({ getWindow, onGamesChanged }) {
|
||||||
})
|
})
|
||||||
|
|
||||||
// prefixes ---------------------------------------------------------------------
|
// prefixes ---------------------------------------------------------------------
|
||||||
// A prefix folder must not move or disappear while a game runs in it: wine
|
// A prefix must not move/disappear while a game runs in it.
|
||||||
// keeps the registry and user files open and would write into a folder that
|
|
||||||
// no longer exists (or, for a delete, lose them mid-run). The renderer shows
|
|
||||||
// these as a toast naming the blocking app.
|
|
||||||
const prefixInUse = name => {
|
const prefixInUse = name => {
|
||||||
const apps = library.appsUsingPrefix(name, games.runningInfo())
|
const apps = library.appsUsingPrefix(name, games.runningInfo())
|
||||||
return apps.length ? { ok: false, error: 'inUse', apps: apps.map(r => r.name) } : null
|
return apps.length ? { ok: false, error: 'inUse', apps: apps.map(r => r.name) } : null
|
||||||
|
|
@ -116,17 +114,20 @@ function registerIpc({ getWindow, onGamesChanged }) {
|
||||||
ipcMain.handle('prefixes:delete', async (e, name) => {
|
ipcMain.handle('prefixes:delete', async (e, name) => {
|
||||||
const busy = prefixInUse(name)
|
const busy = prefixInUse(name)
|
||||||
if (busy) return busy
|
if (busy) return busy
|
||||||
// deleting a prefix that still has apps registered in it would orphan
|
// deleting a prefix with registered apps would orphan them
|
||||||
// them (they fall back to the default prefix and lose their files)
|
|
||||||
const users = library.gamesUsingPrefix(name).map(g => g.name)
|
const users = library.gamesUsingPrefix(name).map(g => g.name)
|
||||||
if (users.length) return { ok: false, error: 'hasApps', apps: users }
|
if (users.length) return { ok: false, error: 'hasApps', apps: users }
|
||||||
return prefixes.deletePrefix(name, data => sendToWindow(getWindow(), 'import:progress', data))
|
return prefixes.deletePrefix(name, data => sendToWindow(getWindow(), 'import:progress', data))
|
||||||
})
|
})
|
||||||
ipcMain.handle('prefixes:rename', (e, oldName, newName) => prefixInUse(oldName) || prefixes.renamePrefix(oldName, newName))
|
ipcMain.handle('prefixes:rename', (e, oldName, newName) => prefixInUse(oldName) || prefixes.renamePrefix(oldName, newName))
|
||||||
|
// The seeded desktop config is ours to drop (app data is not a seed), but a
|
||||||
|
// running app would lose the files under its feet.
|
||||||
|
ipcMain.handle('prefixes:resetLinuxConfig', (e, name) => {
|
||||||
|
if (!prefixes.listPrefixes().prefixes.some(p => p.name === name)) return { ok: false, error: 'notFound' }
|
||||||
|
return prefixInUse(name) || linuxrunner.resetSeededConfig(name)
|
||||||
|
})
|
||||||
ipcMain.handle('prefixes:setDefault', (e, name) => {
|
ipcMain.handle('prefixes:setDefault', (e, name) => {
|
||||||
// apps without a prefix field of their own resolve the default at launch;
|
// switching the default would silently move apps that follow it
|
||||||
// switching it now would silently move their next run (a prefix import is
|
|
||||||
// pinned to its own name)
|
|
||||||
const defs = prefixes.listPrefixes()
|
const defs = prefixes.listPrefixes()
|
||||||
if (name !== defs.default) {
|
if (name !== defs.default) {
|
||||||
const affected = library.gamesFollowingDefault(games.runningInfo())
|
const affected = library.gamesFollowingDefault(games.runningInfo())
|
||||||
|
|
@ -138,8 +139,7 @@ function registerIpc({ getWindow, onGamesChanged }) {
|
||||||
// games: library -----------------------------------------------------------------
|
// games: library -----------------------------------------------------------------
|
||||||
ipcMain.handle('games:list', () => library.loadGames())
|
ipcMain.handle('games:list', () => library.loadGames())
|
||||||
ipcMain.handle('games:get', (e, id) => library.getGame(id))
|
ipcMain.handle('games:get', (e, id) => library.getGame(id))
|
||||||
// prefix pin is enforced inside library.updateGame (prefix/importSource
|
// the prefix pin is enforced inside library.updateGame
|
||||||
// fields are ignored for prefix imports), so nothing extra needed here
|
|
||||||
ipcMain.handle('games:update', (e, id, patch) => library.updateGame(id, patch))
|
ipcMain.handle('games:update', (e, id, patch) => library.updateGame(id, patch))
|
||||||
ipcMain.handle('games:reorder', (e, ids) => library.reorderGames(ids))
|
ipcMain.handle('games:reorder', (e, ids) => library.reorderGames(ids))
|
||||||
ipcMain.handle('games:remove', (e, id) => {
|
ipcMain.handle('games:remove', (e, id) => {
|
||||||
|
|
@ -194,22 +194,31 @@ function registerIpc({ getWindow, onGamesChanged }) {
|
||||||
ipcMain.handle('games:extractIcon', (e, id) => {
|
ipcMain.handle('games:extractIcon', (e, id) => {
|
||||||
const game = library.getGame(id)
|
const game = library.getGame(id)
|
||||||
if (!game || !game.exePath) return { ok: false, error: 'bad-request' }
|
if (!game || !game.exePath) return { ok: false, error: 'bad-request' }
|
||||||
return extractGameIcon(id, game.exePath)
|
return extractGameIcon(id, game.exePath, game.kind)
|
||||||
})
|
})
|
||||||
// exePath comes from the config field, which may hold a path the user just
|
// use the exe the field holds (maybe picked but not saved yet)
|
||||||
// picked but has not saved yet - the icon has to come from THAT exe
|
|
||||||
ipcMain.handle('games:autoIcon', (e, id, exePath) => {
|
ipcMain.handle('games:autoIcon', (e, id, exePath) => {
|
||||||
const game = library.getGame(id)
|
const game = library.getGame(id)
|
||||||
if (!game) return { ok: false, error: 'bad-request' }
|
if (!game) return { ok: false, error: 'bad-request' }
|
||||||
const exe = exePath || game.exePath
|
const exe = exePath || game.exePath
|
||||||
if (!exe) return { ok: false, error: 'bad-request' }
|
if (!exe) return { ok: false, error: 'bad-request' }
|
||||||
return extractGameIcon(id, exe)
|
return extractGameIcon(id, exe, game.kind)
|
||||||
|
})
|
||||||
|
// Linux apps keep their data next to the prefix's Windows side
|
||||||
|
ipcMain.handle('games:openDataFolder', async (e, id) => {
|
||||||
|
const game = library.getGame(id)
|
||||||
|
if (!library.isLinuxGame(game)) return { ok: false, error: 'not-linux' }
|
||||||
|
try {
|
||||||
|
const root = linuxrunner.ensureLinuxPfx(library.prefixNameForGame(game))
|
||||||
|
const err = await shell.openPath(root)
|
||||||
|
return err ? { ok: false, error: err } : { ok: true, path: root }
|
||||||
|
} catch (err) {
|
||||||
|
return errorResult(err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// "Open with PLWL" ------------------------------------------------------------------
|
// "Open with PLWL" ------------------------------------------------------------------
|
||||||
// The renderer asks for the file handed in via `--open` (Exec %f), then
|
// `--open` (Exec %f) hands the file over; nothing is imported.
|
||||||
// launches it with the wine/prefix picked in the popup. Nothing is imported
|
|
||||||
// into the library.
|
|
||||||
ipcMain.handle('openwith:take', async () => {
|
ipcMain.handle('openwith:take', async () => {
|
||||||
const p = openwith.getPending()
|
const p = openwith.getPending()
|
||||||
if (!p) return null
|
if (!p) return null
|
||||||
|
|
@ -248,21 +257,16 @@ function registerIpc({ getWindow, onGamesChanged }) {
|
||||||
})
|
})
|
||||||
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
|
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
|
||||||
})
|
})
|
||||||
ipcMain.handle('games:import', async (e, srcDir, mode) => {
|
ipcMain.handle('games:import', async (e, srcDir, mode, kind) => {
|
||||||
try {
|
try {
|
||||||
return await games.import(getWindow(), srcDir, mode)
|
return await games.import(getWindow(), srcDir, mode, kind)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return errorResult(err)
|
return errorResult(err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// import from an existing wine prefix: pick the prefix first, then the app
|
// import from an existing prefix: paths are stored prefix-relative
|
||||||
// folder inside it (drive_c) - paths are stored prefix-relative so the app
|
|
||||||
// shows up for wine as a regular C:\ install
|
|
||||||
ipcMain.handle('games:pickPrefixFolder', async (e, prefix) => {
|
ipcMain.handle('games:pickPrefixFolder', async (e, prefix) => {
|
||||||
// open the picker straight in the prefix's C: drive (plain wine: top
|
// open the picker in the prefix's C: drive (mixed layouts: prefix folder)
|
||||||
// level, Proton: under pfx/); a prefix used with BOTH kinds has two of
|
|
||||||
// them, so it opens at the prefix folder - the only spot that keeps
|
|
||||||
// drive_c and pfx/drive_c one click away
|
|
||||||
const driveRoots = prefixes.prefixDriveRoots(prefix)
|
const driveRoots = prefixes.prefixDriveRoots(prefix)
|
||||||
const start = driveRoots.length === 1 ? driveRoots[0] : prefixes.prefixPath(prefix)
|
const start = driveRoots.length === 1 ? driveRoots[0] : prefixes.prefixPath(prefix)
|
||||||
const res = await dialog.showOpenDialog(getWindow(), {
|
const res = await dialog.showOpenDialog(getWindow(), {
|
||||||
|
|
@ -286,10 +290,7 @@ function registerIpc({ getWindow, onGamesChanged }) {
|
||||||
// dialogs & shell ---------------------------------------------------------------------------
|
// dialogs & shell ---------------------------------------------------------------------------
|
||||||
ipcMain.handle('dialog:pickFile', async (e, opts) => {
|
ipcMain.handle('dialog:pickFile', async (e, opts) => {
|
||||||
const o = { ...(opts || {}) }
|
const o = { ...(opts || {}) }
|
||||||
// Electron resolves a RELATIVE defaultPath against the process cwd (the
|
// anchor FILES-relative paths, or Electron resolves them against cwd
|
||||||
// install root in packaged mode), which lands the picker somewhere
|
|
||||||
// random like '/'. Renderer paths are FILES-relative by convention, so
|
|
||||||
// anchor them there instead.
|
|
||||||
if (o.defaultPath && typeof o.defaultPath === 'string' && !path.isAbsolute(o.defaultPath)) {
|
if (o.defaultPath && typeof o.defaultPath === 'string' && !path.isAbsolute(o.defaultPath)) {
|
||||||
o.defaultPath = path.join(DIRS.FILES, o.defaultPath)
|
o.defaultPath = path.join(DIRS.FILES, o.defaultPath)
|
||||||
}
|
}
|
||||||
|
|
@ -315,9 +316,7 @@ function registerIpc({ getWindow, onGamesChanged }) {
|
||||||
if (pick) file = path.join(iconDir(id), pick)
|
if (pick) file = path.join(iconDir(id), pick)
|
||||||
} catch {}
|
} catch {}
|
||||||
if (!file) return new Response('', { status: 404 })
|
if (!file) return new Response('', { status: 404 })
|
||||||
// a new icon lands at the SAME url, so the browser would keep its cached
|
// the icon url never changes: send no-cache headers
|
||||||
// copy and the library card would only catch up after a restart - never
|
|
||||||
// let the icon request be cached
|
|
||||||
const res = await net.fetch('file://' + file)
|
const res = await net.fetch('file://' + file)
|
||||||
return new Response(await res.arrayBuffer(), {
|
return new Response(await res.arrayBuffer(), {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
|
|
@ -8,18 +8,25 @@ function registerLanSyncIpc({ getWindow }) {
|
||||||
ipcMain.handle('lansync:get', () => lansync.getState())
|
ipcMain.handle('lansync:get', () => lansync.getState())
|
||||||
ipcMain.handle('lansync:devices', () => lansync.listDevices())
|
ipcMain.handle('lansync:devices', () => lansync.listDevices())
|
||||||
ipcMain.handle('lansync:rerollDeviceId', () => {
|
ipcMain.handle('lansync:rerollDeviceId', () => {
|
||||||
const identity = lansync.rerollDeviceId()
|
const identity = lansync.rotateDeviceId()
|
||||||
return { deviceId: identity.deviceId }
|
return { deviceId: identity.deviceId }
|
||||||
})
|
})
|
||||||
ipcMain.handle('lansync:rerollSyncCode', () => {
|
ipcMain.handle('lansync:rerollSyncCode', () => {
|
||||||
const identity = lansync.rerollSyncCode()
|
const identity = lansync.rotateSyncCode()
|
||||||
return { syncCode: identity.syncCode }
|
return { syncCode: identity.syncCode }
|
||||||
})
|
})
|
||||||
ipcMain.handle('lansync:forget', (e, id) => {
|
ipcMain.handle('lansync:forget', (e, id) => {
|
||||||
lansync.forgetDevice(id)
|
lansync.forgetDevice(id)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('lansync:clearHashCache', () => {
|
||||||
|
lansync.clearHashCache()
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
ipcMain.handle('lansync:respondIncoming', (e, requestId, accept) => lansync.respondIncoming(requestId, accept))
|
ipcMain.handle('lansync:respondIncoming', (e, requestId, accept) => lansync.respondIncoming(requestId, accept))
|
||||||
|
ipcMain.handle('lansync:items', () => lansync.syncItems())
|
||||||
|
ipcMain.handle('lansync:reviewTree', (e, id, rel) => lansync.reviewTree(id, rel))
|
||||||
|
ipcMain.handle('lansync:reviewDecide', (e, id, decision) => lansync.respondReview(id, decision))
|
||||||
ipcMain.handle('lansync:sync', async (e, payload) => {
|
ipcMain.handle('lansync:sync', async (e, payload) => {
|
||||||
try {
|
try {
|
||||||
return await lansync.syncTo({
|
return await lansync.syncTo({
|
||||||
|
|
@ -27,6 +34,7 @@ function registerLanSyncIpc({ getWindow }) {
|
||||||
code: payload && payload.code,
|
code: payload && payload.code,
|
||||||
categories: payload && payload.categories,
|
categories: payload && payload.categories,
|
||||||
force: !!(payload && payload.force),
|
force: !!(payload && payload.force),
|
||||||
|
selection: payload && payload.selection,
|
||||||
onProgress: data => sendToWindow(getWindow(), 'lansync:progress', data)
|
onProgress: data => sendToWindow(getWindow(), 'lansync:progress', data)
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
133
src/lansync-discovery.js
Normal file
133
src/lansync-discovery.js
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
// UDP discovery for the LAN sync: beacons fill the devices list and double
|
||||||
|
// as liveness (a device drops off a few seconds after close).
|
||||||
|
|
||||||
|
const dgram = require('dgram')
|
||||||
|
const os = require('os')
|
||||||
|
|
||||||
|
// Broadcast to the global address AND to every interface's own subnet
|
||||||
|
// broadcast - some networks drop one or the other.
|
||||||
|
function broadcastAddresses() {
|
||||||
|
const out = ['255.255.255.255']
|
||||||
|
const interfaces = os.networkInterfaces()
|
||||||
|
for (const list of Object.values(interfaces)) {
|
||||||
|
for (const iface of list || []) {
|
||||||
|
if (iface.family !== 'IPv4' || iface.internal || !iface.netmask) continue
|
||||||
|
const addr = ipToInt(iface.address)
|
||||||
|
const mask = ipToInt(iface.netmask)
|
||||||
|
if (addr == null || mask == null) continue
|
||||||
|
out.push(intToIp((addr | (~mask >>> 0)) >>> 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipToInt(ip) {
|
||||||
|
const parts = String(ip).split('.')
|
||||||
|
if (parts.length !== 4) return null
|
||||||
|
let out = 0
|
||||||
|
for (const part of parts) {
|
||||||
|
const n = Number(part)
|
||||||
|
if (!Number.isInteger(n) || n < 0 || n > 255) return null
|
||||||
|
out = (out << 8) | n
|
||||||
|
}
|
||||||
|
return out >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function intToIp(int) {
|
||||||
|
return [24, 16, 8, 0].map(shift => (int >>> shift) & 255).join('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDiscovery({ identity, tag, protocol, port, beaconMs, deviceTtlMs, getPort }) {
|
||||||
|
let socket = null
|
||||||
|
let timer = null
|
||||||
|
let devicesCb = null
|
||||||
|
// id -> { id, name, address, port, at }
|
||||||
|
const seen = new Map()
|
||||||
|
|
||||||
|
function listDevices() {
|
||||||
|
const known = identity().known
|
||||||
|
const now = Date.now()
|
||||||
|
return [...seen.values()]
|
||||||
|
.filter(d => now - d.at <= deviceTtlMs)
|
||||||
|
.map(d => {
|
||||||
|
const pair = known[d.id]
|
||||||
|
return { id: d.id, name: d.name, address: d.address, port: d.port, paired: !!pair, code: pair ? pair.code : '' }
|
||||||
|
})
|
||||||
|
.sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitDevices() {
|
||||||
|
if (devicesCb) devicesCb(listDevices())
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneDevices() {
|
||||||
|
const now = Date.now()
|
||||||
|
let changed = false
|
||||||
|
for (const [id, device] of seen) {
|
||||||
|
if (now - device.at > deviceTtlMs) {
|
||||||
|
seen.delete(id)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) emitDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
function beacon() {
|
||||||
|
if (!socket || !getPort()) return
|
||||||
|
const me = identity()
|
||||||
|
const payload = Buffer.from(JSON.stringify({ t: tag, v: protocol, id: me.deviceId, name: os.hostname(), port: getPort() }))
|
||||||
|
for (const address of broadcastAddresses()) {
|
||||||
|
try {
|
||||||
|
socket.send(payload, 0, payload.length, port, address)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
pruneDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(onDevices) {
|
||||||
|
stop()
|
||||||
|
devicesCb = onDevices
|
||||||
|
socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
||||||
|
socket.on('error', () => stop())
|
||||||
|
socket.on('message', (buf, rinfo) => {
|
||||||
|
let msg = null
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(buf.toString('utf8'))
|
||||||
|
} catch {}
|
||||||
|
if (!msg || msg.t !== tag || msg.v !== protocol || !msg.id) return
|
||||||
|
if (msg.id === identity().deviceId) return // our own broadcast
|
||||||
|
const previous = seen.get(msg.id)
|
||||||
|
const next = { id: msg.id, name: String(msg.name || rinfo.address), address: rinfo.address, port: Number(msg.port) || 0, at: Date.now() }
|
||||||
|
const changed = !previous || previous.address !== next.address || previous.port !== next.port || previous.name !== next.name
|
||||||
|
seen.set(msg.id, next)
|
||||||
|
if (changed) emitDevices()
|
||||||
|
})
|
||||||
|
socket.bind(port, () => {
|
||||||
|
try {
|
||||||
|
socket.setBroadcast(true)
|
||||||
|
} catch {}
|
||||||
|
beacon()
|
||||||
|
timer = setInterval(beacon, beaconMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
if (socket) {
|
||||||
|
const closing = socket
|
||||||
|
socket = null
|
||||||
|
try {
|
||||||
|
closing.close()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, stop, listDevices }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createDiscovery }
|
||||||
118
src/lansync-identity.js
Normal file
118
src/lansync-identity.js
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
// Device identity for the LAN sync: id, pairing code, paired devices. Factory
|
||||||
|
// over the config file, one per launcher state.
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const { readJson, writeJson } = require('./paths')
|
||||||
|
|
||||||
|
// The code is read out and typed; the alphabet skips 0/O, 1/I/L.
|
||||||
|
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'
|
||||||
|
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||||
|
|
||||||
|
function randomChars(alphabet, count) {
|
||||||
|
let out = ''
|
||||||
|
while (out.length < count) {
|
||||||
|
for (const byte of crypto.randomBytes(count)) {
|
||||||
|
if (out.length >= count) break
|
||||||
|
out += alphabet[byte % alphabet.length]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// base36 timestamp + random padding: unique across machines.
|
||||||
|
function makeDeviceId() {
|
||||||
|
const stamp = Date.now().toString(36)
|
||||||
|
return (stamp + randomChars(ID_ALPHABET, Math.max(0, 24 - stamp.length))).slice(0, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSyncCode() {
|
||||||
|
return randomChars(CODE_ALPHABET, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIdentity(configFile) {
|
||||||
|
// Read once, kept in memory: the beacon calls identity() constantly.
|
||||||
|
let cached = null
|
||||||
|
|
||||||
|
function readConfig() {
|
||||||
|
if (cached) return cached
|
||||||
|
const cfg = readJson(configFile, {})
|
||||||
|
cached = cfg && typeof cfg === 'object' ? cfg : {}
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Id and code are generated on first use and kept forever.
|
||||||
|
function identity() {
|
||||||
|
const cfg = readConfig()
|
||||||
|
let changed = false
|
||||||
|
if (typeof cfg.deviceId !== 'string' || cfg.deviceId.length !== 24) {
|
||||||
|
cfg.deviceId = makeDeviceId()
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (typeof cfg.syncCode !== 'string' || cfg.syncCode.length !== 8) {
|
||||||
|
cfg.syncCode = makeSyncCode()
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (!cfg.known || typeof cfg.known !== 'object') {
|
||||||
|
cfg.known = {}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) writeJson(configFile, cfg)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotations return the whole identity.
|
||||||
|
function rotateDeviceId() {
|
||||||
|
const cfg = identity()
|
||||||
|
const previous = cfg.deviceId
|
||||||
|
cfg.deviceId = makeDeviceId()
|
||||||
|
writeJson(configFile, cfg)
|
||||||
|
if (cfg.deviceId === previous) return rotateDeviceId()
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateSyncCode() {
|
||||||
|
const cfg = identity()
|
||||||
|
const previous = cfg.syncCode
|
||||||
|
cfg.syncCode = makeSyncCode()
|
||||||
|
writeJson(configFile, cfg)
|
||||||
|
if (cfg.syncCode === previous) return rotateSyncCode() // never a no-op rotation
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// A paired device remembers its code; a rejected code is forgotten.
|
||||||
|
function knownDevice(id) {
|
||||||
|
return identity().known[id] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function knownPeers() {
|
||||||
|
return identity().known || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberDevice(id, code, name) {
|
||||||
|
const cfg = identity()
|
||||||
|
const stored = String(code || '').trim().toUpperCase()
|
||||||
|
cfg.known = { ...cfg.known, [id]: { code: stored, name: name || '', at: Date.now() } }
|
||||||
|
writeJson(configFile, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgetDevice(id) {
|
||||||
|
const cfg = identity()
|
||||||
|
const known = { ...cfg.known }
|
||||||
|
delete known[id]
|
||||||
|
cfg.known = known
|
||||||
|
writeJson(configFile, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { identity, rotateDeviceId, rotateSyncCode, knownDevice, knownPeers, rememberDevice, forgetDevice, makeDeviceId, makeSyncCode }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant-time code comparison.
|
||||||
|
function sameCode(a, b) {
|
||||||
|
const x = Buffer.from(String(a == null ? '' : a).trim().toUpperCase())
|
||||||
|
const y = Buffer.from(String(b == null ? '' : b).trim().toUpperCase())
|
||||||
|
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createIdentity, sameCode, makeDeviceId, makeSyncCode }
|
||||||
197
src/lansync-manifest.js
Normal file
197
src/lansync-manifest.js
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
// Sender-side hashing: walk sync units into a manifest of dirs, links, files.
|
||||||
|
// Factory over the path guards and hash store, one per launcher state.
|
||||||
|
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
const fsp = fs.promises
|
||||||
|
|
||||||
|
function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurrency }) {
|
||||||
|
// Targets inside the launcher data travel relative (the mirrored layout is
|
||||||
|
// the same on both machines); outside targets stay as they are.
|
||||||
|
function portableLinkTarget(linkAbs, target) {
|
||||||
|
if (typeof target !== 'string' || !target || !path.isAbsolute(target)) return target
|
||||||
|
const rel = path.relative(path.dirname(linkAbs), target)
|
||||||
|
if (!rel || path.isAbsolute(rel)) return target
|
||||||
|
const root = path.resolve(filesRoot)
|
||||||
|
const resolved = path.resolve(path.dirname(linkAbs), rel)
|
||||||
|
if (resolved !== root && !resolved.startsWith(root + path.sep)) return target
|
||||||
|
return rel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk without following symlinks (dosdevices etc. are part of the sync).
|
||||||
|
// Iterative, with one directory's stat/readlink calls in flight together.
|
||||||
|
// `emit` (optional) streams dirs and links out while the walk continues;
|
||||||
|
// file entries only land in `out` (their digest comes later). `onError`
|
||||||
|
// marks the unit incomplete: an entry we could not read is not an entry the
|
||||||
|
// sender decided to delete.
|
||||||
|
async function scanTree(abs, rel, out, mtimes, emit, onError, skip) {
|
||||||
|
const queue = [[abs, rel]]
|
||||||
|
while (queue.length) {
|
||||||
|
const [dirAbs, dirRel] = queue.pop()
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = await fsp.readdir(dirAbs, { withFileTypes: true })
|
||||||
|
} catch (err) {
|
||||||
|
if (onError) onError(err, dirAbs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const dirs = []
|
||||||
|
const leaves = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
const relChild = `${dirRel}/${entry.name}`
|
||||||
|
if (skip && skip.has(relChild)) continue // a selection left it out
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const record = { path: relChild, type: 'dir' }
|
||||||
|
out.push(record)
|
||||||
|
if (emit) await emit(record)
|
||||||
|
dirs.push([path.join(dirAbs, entry.name), relChild])
|
||||||
|
} else {
|
||||||
|
leaves.push(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await mapLimit(leaves, concurrency, async entry => {
|
||||||
|
const absChild = path.join(dirAbs, entry.name)
|
||||||
|
const relChild = `${dirRel}/${entry.name}`
|
||||||
|
if (entry.isSymbolicLink()) {
|
||||||
|
let target = ''
|
||||||
|
try {
|
||||||
|
target = await fsp.readlink(absChild)
|
||||||
|
} catch (err) {
|
||||||
|
if (onError) onError(err, absChild)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const record = { path: relChild, type: 'link', target: portableLinkTarget(absChild, target) }
|
||||||
|
out.push(record)
|
||||||
|
if (emit) await emit(record)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let st
|
||||||
|
try {
|
||||||
|
st = await fsp.stat(absChild)
|
||||||
|
} catch (err) {
|
||||||
|
if (onError) onError(err, absChild)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!st.isFile()) return
|
||||||
|
// mode travels with the file
|
||||||
|
out.push({ path: relChild, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
|
||||||
|
// mtimes are machine-local: kept out of the manifest
|
||||||
|
if (mtimes) mtimes.set(relChild, st.mtimeMs || 0)
|
||||||
|
})
|
||||||
|
queue.push(...dirs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifest of one unit, streamed: `handlers` may carry onEntry (a Promise
|
||||||
|
// per entry, awaited - backpressure), onTotal, onProgress, onIncomplete
|
||||||
|
// (called once, when anything in the tree could not be read) and skip (unit
|
||||||
|
// paths a selection keeps off the wire). File entries go out as their
|
||||||
|
// digests finish, so the receiver compares while this side is still
|
||||||
|
// hashing; hashing itself stays parallel, a batch at a time.
|
||||||
|
async function buildManifestStream(roots, force, handlers) {
|
||||||
|
const skip = handlers && handlers.skip && handlers.skip.length ? new Set(handlers.skip) : null
|
||||||
|
const onEntry = handlers && handlers.onEntry
|
||||||
|
const onTotal = handlers && handlers.onTotal
|
||||||
|
const onProgress = handlers && handlers.onProgress
|
||||||
|
const onIncomplete = handlers && handlers.onIncomplete
|
||||||
|
const entries = []
|
||||||
|
const mtimes = new Map()
|
||||||
|
let incomplete = false
|
||||||
|
const markIncomplete = (err, file) => {
|
||||||
|
if (incomplete) return
|
||||||
|
incomplete = true
|
||||||
|
if (onIncomplete) onIncomplete(err, file)
|
||||||
|
}
|
||||||
|
for (const rel of roots) {
|
||||||
|
if (skip && skip.has(rel)) continue
|
||||||
|
const abs = filesPath(rel)
|
||||||
|
if (!abs) {
|
||||||
|
markIncomplete(new Error('bad-unit-path'), rel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let st
|
||||||
|
try {
|
||||||
|
st = await fsp.lstat(abs)
|
||||||
|
} catch (err) {
|
||||||
|
// a root that is simply not there is a unit with nothing in it
|
||||||
|
if (err && err.code !== 'ENOENT') markIncomplete(err, abs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (st.isSymbolicLink()) {
|
||||||
|
let target = ''
|
||||||
|
try {
|
||||||
|
target = await fsp.readlink(abs)
|
||||||
|
} catch (err) {
|
||||||
|
markIncomplete(err, abs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const record = { path: rel, type: 'link', target: portableLinkTarget(abs, target) }
|
||||||
|
entries.push(record)
|
||||||
|
if (onEntry) await onEntry(record)
|
||||||
|
} else if (st.isDirectory()) {
|
||||||
|
const record = { path: rel, type: 'dir' }
|
||||||
|
entries.push(record)
|
||||||
|
if (onEntry) await onEntry(record)
|
||||||
|
await scanTree(abs, rel, entries, mtimes, onEntry, markIncomplete, skip)
|
||||||
|
} else if (st.isFile()) {
|
||||||
|
entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
|
||||||
|
mtimes.set(rel, st.mtimeMs || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const files = entries.filter(e => e.type === 'file')
|
||||||
|
// total size is known before hashing: the receiver sizes its compare bar
|
||||||
|
// from this, the sender's popup from onProgress
|
||||||
|
const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0)
|
||||||
|
if (onTotal) await onTotal(totalBytes)
|
||||||
|
let done = 0
|
||||||
|
let hashed = 0
|
||||||
|
// one batch hashes with the whole disk queue behind it; its entries go
|
||||||
|
// on the wire in order before the next batch starts
|
||||||
|
for (let i = 0; i < files.length; i += concurrency) {
|
||||||
|
const batch = files.slice(i, i + concurrency)
|
||||||
|
if (!force) {
|
||||||
|
await Promise.all(batch.map(async entry => {
|
||||||
|
const abs = filesPath(entry.path)
|
||||||
|
try {
|
||||||
|
// unchanged size + mtime: the cached digest still holds
|
||||||
|
const mtimeMs = mtimes.get(entry.path) || 0
|
||||||
|
entry.hash = abs
|
||||||
|
? await hash.cachedHash(entry.path, abs, entry.size, mtimeMs, bytes => {
|
||||||
|
hashed += bytes
|
||||||
|
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
if (!entry.hash) markIncomplete(new Error('unreadable-file'), entry.path)
|
||||||
|
} catch (err) {
|
||||||
|
entry.hash = null
|
||||||
|
markIncomplete(err, entry.path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
for (const entry of batch) {
|
||||||
|
done++
|
||||||
|
if (!entry.hash) hashed += entry.size || 0 // nothing read: still report the file as passed over
|
||||||
|
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
|
||||||
|
if (onEntry) await onEntry(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hash.save()
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffering variant for callers that want the whole manifest first.
|
||||||
|
async function buildManifest(roots, force, onProgress) {
|
||||||
|
const entries = []
|
||||||
|
await buildManifestStream(roots, force, {
|
||||||
|
onProgress,
|
||||||
|
onEntry: entry => { entries.push(entry) }
|
||||||
|
})
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
return { buildManifest, buildManifestStream, scanTree, portableLinkTarget }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createManifestBuilder }
|
||||||
167
src/lansync-plan.js
Normal file
167
src/lansync-plan.js
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
// Deletions a sync would make, computed but not executed. A mirror is the goal
|
||||||
|
// ("the sender's tree wins"), but a file the user still needs is not a
|
||||||
|
// leftover: everything is held until the receiver approves the list.
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const fsp = fs.promises
|
||||||
|
|
||||||
|
const baseName = rel => String(rel || '').split('/').pop()
|
||||||
|
|
||||||
|
// `rel` lies at or below `parent`.
|
||||||
|
function isUnder(rel, parent) {
|
||||||
|
return rel === parent || rel.startsWith(parent + '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// A path the receiver must keep lies at or below `rel`.
|
||||||
|
function keepInside(rel, keep) {
|
||||||
|
for (const kept of keep) if (isUnder(kept, rel)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function keptBy(rel, keep) {
|
||||||
|
for (const kept of keep) if (isUnder(rel, kept)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// One entry and, for a folder, everything in it. `full` means nothing inside
|
||||||
|
// survives, so the whole subtree can be shown (and deleted) as one row.
|
||||||
|
async function planEntry(abs, rel, keep) {
|
||||||
|
let st
|
||||||
|
try {
|
||||||
|
st = await fsp.lstat(abs)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!st.isDirectory() || st.isSymbolicLink()) {
|
||||||
|
const file = st.isFile()
|
||||||
|
return { path: rel, name: baseName(rel), type: file ? 'file' : 'link', files: 1, bytes: file ? st.size : 0, full: true, children: [] }
|
||||||
|
}
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = await fsp.readdir(abs, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
// an unreadable folder is not ours to delete: its contents are unknown
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const children = []
|
||||||
|
let survived = 0
|
||||||
|
let files = 0
|
||||||
|
let bytes = 0
|
||||||
|
for (const entry of entries) {
|
||||||
|
const childRel = `${rel}/${entry.name}`
|
||||||
|
if (keep.has(childRel)) {
|
||||||
|
survived++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const child = await planEntry(`${abs}/${entry.name}`, childRel, keep)
|
||||||
|
if (!child) {
|
||||||
|
survived++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
children.push(child)
|
||||||
|
files += child.files
|
||||||
|
bytes += child.bytes
|
||||||
|
}
|
||||||
|
return { path: rel, name: baseName(rel), type: 'dir', files, bytes, full: survived === 0, children }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk a folder the sender also has: entries missing from `keep` are planned,
|
||||||
|
// kept folders are descended into (they may hold files the sender lacks).
|
||||||
|
// `protect` is a subtree the plan must not touch at all (an app a selection
|
||||||
|
// left out of the sync).
|
||||||
|
async function walkDeletions(abs, rel, keep, protect) {
|
||||||
|
const out = []
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = await fsp.readdir(abs, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const childRel = `${rel}/${entry.name}`
|
||||||
|
if (protect.has(childRel)) continue
|
||||||
|
if (keep.has(childRel)) {
|
||||||
|
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||||
|
out.push(...await walkDeletions(`${abs}/${entry.name}`, childRel, keep, protect))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const node = await planEntry(`${abs}/${entry.name}`, childRel, keep)
|
||||||
|
if (node) out.push(node)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-level leftovers of a registry folder (`apps/`, `wines/`, `wineprefixes/`):
|
||||||
|
// names in `keepNames` survive, the rest is planned whole. Same shape as the
|
||||||
|
// old removeOrphans, but nothing is deleted.
|
||||||
|
async function planListed(listDirAbs, dirRel, keepNames) {
|
||||||
|
const out = []
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = await fsp.readdir(listDirAbs, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (keepNames.has(entry.name)) continue
|
||||||
|
const node = await planEntry(`${listDirAbs}/${entry.name}`, `${dirRel}/${entry.name}`, new Set())
|
||||||
|
if (node) out.push(node)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalsOf(nodes) {
|
||||||
|
let files = 0
|
||||||
|
let bytes = 0
|
||||||
|
for (const node of nodes) {
|
||||||
|
files += node.files || 0
|
||||||
|
bytes += node.bytes || 0
|
||||||
|
}
|
||||||
|
return { files, bytes }
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampNodes(nodes) {
|
||||||
|
return Array.isArray(nodes) ? nodes.filter(node => node && typeof node.path === 'string' && Array.isArray(node.children)) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete what was approved, count what actually went. Deletion happens here
|
||||||
|
// and only here: the plan phase never removes anything.
|
||||||
|
async function removeNodes(nodes, keep, filesPath, onProgress) {
|
||||||
|
const out = { entries: 0, files: 0, bytes: 0 }
|
||||||
|
for (const node of clampNodes(nodes)) {
|
||||||
|
if (keptBy(node.path, keep)) continue
|
||||||
|
const abs = filesPath(node.path)
|
||||||
|
if (!abs) continue
|
||||||
|
await removeNode(abs, node, keep, out, onProgress)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeNode(abs, node, keep, out, onProgress) {
|
||||||
|
// nothing inside survives -> one recursive delete
|
||||||
|
if (!keepInside(node.path, keep)) {
|
||||||
|
try {
|
||||||
|
if (fs.lstatSync(abs)) fs.rmSync(abs, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.entries++
|
||||||
|
out.files += node.files || 0
|
||||||
|
out.bytes += node.bytes || 0
|
||||||
|
if (onProgress) onProgress(out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const child of node.children || []) {
|
||||||
|
if (keptBy(child.path, keep)) continue
|
||||||
|
await removeNode(`${abs}/${child.name}`, child, keep, out, onProgress)
|
||||||
|
}
|
||||||
|
// a folder emptied by the approved deletions goes too, but only when empty
|
||||||
|
try {
|
||||||
|
if (!fs.readdirSync(abs).length) fs.rmdirSync(abs)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { planEntry, walkDeletions, planListed, totalsOf, removeNodes }
|
||||||
1733
src/lansync.js
1733
src/lansync.js
File diff suppressed because it is too large
Load diff
|
|
@ -1,28 +1,40 @@
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
// The pushing side of a LAN sync: this device is the source of truth and
|
// The pushing side of a LAN sync: per unit, stream a manifest while it is
|
||||||
// drives the whole transfer. For every sync unit it sends a manifest, asks
|
// still being hashed, learn what the receiver has, upload the rest, delete
|
||||||
// the receiver what it already has, uploads the rest and has the receiver
|
// the surplus. Exported as a factory; src/lansync.js hands in its own
|
||||||
// delete whatever the manifest does not list.
|
// helpers and state.
|
||||||
//
|
|
||||||
// Exported as a factory: src/lansync.js hands in its own helpers and state, so
|
|
||||||
// nothing here reaches across module instances.
|
|
||||||
|
|
||||||
const http = require('http')
|
const http = require('http')
|
||||||
const os = require('os')
|
const os = require('os')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
// Files sent at once: one at a time wastes most of a LAN round trip on the
|
// Files sent at once: one at a time wastes LAN round trips on small files.
|
||||||
// small files a game or prefix is mostly made of.
|
|
||||||
const UPLOAD_CONCURRENCY = 4
|
const UPLOAD_CONCURRENCY = 4
|
||||||
|
|
||||||
// Keeps the receiving side's session and popup alive while this device hashes
|
// Two lost heartbeats in a row: the receiver is gone (40 s of silence).
|
||||||
// a unit (minutes of complete silence from the network) - well under the
|
const MAX_MISSED_PINGS = 2
|
||||||
// receiving popup's watchdog, and cheap enough to run through the transfer too.
|
|
||||||
// Env override: tests cannot wait 20 s for a ping.
|
// One file, one PUT. A file that has not finished on its own after this long
|
||||||
|
// (stalled receiver, lost socket while the OS still reports it open, a file
|
||||||
|
// that changed size after the manifest) fails the whole sync instead of
|
||||||
|
// hanging it forever. Env override for tests.
|
||||||
|
const UPLOAD_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_UPLOAD_TIMEOUT) || 10 * 60 * 1000
|
||||||
|
|
||||||
|
// Keeps the receiver alive while this side hashes (minutes of silence); env
|
||||||
|
// override for tests.
|
||||||
const HEARTBEAT_MS = Number(process.env.PLWL_LANSYNC_HEARTBEAT) || 20000
|
const HEARTBEAT_MS = Number(process.env.PLWL_LANSYNC_HEARTBEAT) || 20000
|
||||||
|
|
||||||
|
// One ping round trip; env override for tests.
|
||||||
|
const PING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_PING_TIMEOUT) || 10000
|
||||||
|
|
||||||
|
// How often the sender asks the receiver's popup whether the deletion list is
|
||||||
|
// settled, and how long it waits for a human before giving up (nothing is
|
||||||
|
// deleted on a timeout, so the safe answer is also the fallback).
|
||||||
|
const REVIEW_POLL_MS = Number(process.env.PLWL_LANSYNC_REVIEW_POLL) || 1500
|
||||||
|
const REVIEW_WAIT_MS = Number(process.env.PLWL_LANSYNC_REVIEW_WAIT) || 15 * 60 * 1000
|
||||||
|
|
||||||
function createPusher(api) {
|
function createPusher(api) {
|
||||||
const {
|
const {
|
||||||
CATEGORIES,
|
CATEGORIES,
|
||||||
|
|
@ -33,7 +45,7 @@ function createPusher(api) {
|
||||||
httpJson,
|
httpJson,
|
||||||
filesPath,
|
filesPath,
|
||||||
ownedRoot,
|
ownedRoot,
|
||||||
buildManifest,
|
buildManifestStream,
|
||||||
listDirs,
|
listDirs,
|
||||||
DIRS,
|
DIRS,
|
||||||
toPortablePath,
|
toPortablePath,
|
||||||
|
|
@ -42,14 +54,16 @@ function createPusher(api) {
|
||||||
prefixes
|
prefixes
|
||||||
} = api
|
} = api
|
||||||
|
|
||||||
// Manifest in, list of files to upload out. NDJSON keeps a prefix with tens
|
// Manifest in, upload list out; NDJSON avoids one giant string. The body
|
||||||
// of thousands of entries from being one giant string in memory either side.
|
// is written by `produce` WHILE the sender hashes, so the receiver can
|
||||||
function planUnit(device, token, unit, entries, force, meta) {
|
// compare entries as they arrive (both sides hash at the same time).
|
||||||
// the receiver is told how many apps/prefixes/wines the whole sync carries
|
function planUnit(device, token, unit, force, meta, produce) {
|
||||||
// up front, which is what lets its popup say "Apps: 2/5"
|
// announced up front, so the receiver's popup can say "Apps: 2/5"
|
||||||
const totals = meta && meta.totals ? `&totals=${encodeURIComponent(JSON.stringify(meta.totals))}` : ''
|
const totals = meta && meta.totals ? `&totals=${encodeURIComponent(JSON.stringify(meta.totals))}` : ''
|
||||||
|
// folders a selection kept off the wire must not be pruned on the other side
|
||||||
|
const exclude = unit.exclude && unit.exclude.length ? `&exclude=${encodeURIComponent(JSON.stringify(unit.exclude))}` : ''
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&label=${encodeURIComponent(unit.label || '')}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}${totals}`
|
const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&label=${encodeURIComponent(unit.label || '')}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}${totals}${exclude}`
|
||||||
const req = http.request(
|
const req = http.request(
|
||||||
{
|
{
|
||||||
host: device.address,
|
host: device.address,
|
||||||
|
|
@ -71,66 +85,143 @@ function createPusher(api) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
req.on('error', reject)
|
let dead = null
|
||||||
let index = 0
|
req.on('error', err => {
|
||||||
const pump = () => {
|
dead = err
|
||||||
while (index < entries.length) {
|
reject(err)
|
||||||
if (!req.write(`${JSON.stringify(entries[index++])}\n`)) {
|
})
|
||||||
req.once('drain', pump)
|
// ordered writes with backpressure: the receiver reads continuously,
|
||||||
return
|
// but a slow socket must not buffer the whole manifest in memory
|
||||||
}
|
const write = obj => new Promise((resolveWrite, rejectWrite) => {
|
||||||
|
if (dead) return rejectWrite(dead)
|
||||||
|
const onDrain = () => {
|
||||||
|
cleanup()
|
||||||
|
resolveWrite()
|
||||||
}
|
}
|
||||||
req.end()
|
const onErr = () => {
|
||||||
}
|
cleanup()
|
||||||
pump()
|
rejectWrite(dead || new Error('plan-write-failed'))
|
||||||
|
}
|
||||||
|
const cleanup = () => {
|
||||||
|
req.off('drain', onDrain)
|
||||||
|
req.off('error', onErr)
|
||||||
|
}
|
||||||
|
if (req.write(`${JSON.stringify(obj)}\n`)) {
|
||||||
|
cleanup()
|
||||||
|
return resolveWrite()
|
||||||
|
}
|
||||||
|
req.once('drain', onDrain)
|
||||||
|
req.once('error', onErr)
|
||||||
|
})
|
||||||
|
produce(write).then(() => req.end(), err => {
|
||||||
|
try { req.destroy() } catch {}
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function putFile(device, token, unitKey, rel, abs) {
|
function putFile(device, token, unitKey, rel, abs, onBytes) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// unit and path travel as headers: the receiver used to parse a fresh
|
const settle = (fn, value) => {
|
||||||
// query string per file for values that are constant per request
|
clearTimeout(timer)
|
||||||
|
fn(value)
|
||||||
|
}
|
||||||
|
// Unit and path travel in the query, percent-encoded: Node refuses any
|
||||||
|
// header value holding a code above U+00FF (ERR_INVALID_CHAR), which a
|
||||||
|
// real library has (Minecraft pack names, Polish file names), and that
|
||||||
|
// refusal used to kill the sync mid-unit.
|
||||||
|
const query = `token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unitKey)}&path=${encodeURIComponent(rel)}`
|
||||||
const req = http.request(
|
const req = http.request(
|
||||||
{
|
{
|
||||||
host: device.address,
|
host: device.address,
|
||||||
port: device.port,
|
port: device.port,
|
||||||
path: `/plwl/unit/file?token=${encodeURIComponent(token)}`,
|
path: `/plwl/unit/file?${query}`,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/octet-stream',
|
'content-type': 'application/octet-stream',
|
||||||
'content-length': fs.statSync(abs).size,
|
'content-length': fs.statSync(abs).size
|
||||||
'x-plwl-unit': unitKey,
|
|
||||||
'x-plwl-path': rel
|
|
||||||
},
|
},
|
||||||
timeout: 0
|
timeout: 0
|
||||||
},
|
},
|
||||||
res => {
|
res => {
|
||||||
res.resume()
|
res.resume()
|
||||||
res.on('end', () => resolve({ status: res.statusCode }))
|
res.on('end', () => settle(resolve, { status: res.statusCode }))
|
||||||
|
res.on('aborted', () => settle(reject, new Error('response-aborted')))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
req.on('error', reject)
|
// The timer spans the WHOLE transfer, response included: a stalled
|
||||||
|
// receiver or a silently lost socket must fail the sync instead of
|
||||||
|
// hanging the popup on the last file. Generous - a file would have to
|
||||||
|
// take over ten minutes at 20 MB/s to hit it.
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
req.destroy(new Error('upload-timeout'))
|
||||||
|
settle(reject, new Error('upload-timeout'))
|
||||||
|
}, UPLOAD_TIMEOUT_MS)
|
||||||
|
if (timer.unref) timer.unref()
|
||||||
|
req.on('error', err => settle(reject, err))
|
||||||
const stream = fs.createReadStream(abs)
|
const stream = fs.createReadStream(abs)
|
||||||
stream.on('error', reject)
|
stream.on('error', err => settle(reject, err))
|
||||||
|
// live bytes: a multi-GB file has to move the counter while it flies,
|
||||||
|
// not only when its response lands (read = what this side hands over)
|
||||||
|
if (onBytes) stream.on('data', chunk => onBytes(chunk.length))
|
||||||
|
// pipe ends the request when the file ends; a body shorter than the
|
||||||
|
// declared content-length (file changed mid-sync) wedges the receiver
|
||||||
|
// - its own idle watchdog breaks that, this side's timer breaks every
|
||||||
|
// other silent case
|
||||||
stream.pipe(req)
|
stream.pipe(req)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- units ----------------------------------------------------------------
|
// --- units ----------------------------------------------------------------
|
||||||
|
|
||||||
function appUnits(cats) {
|
// A selection lists unit keys ("app:1", "wine:GE-Proton9-20", "prefix:Main");
|
||||||
|
// absent means everything travels. `apps` is the older app-id-only shape.
|
||||||
|
function selectionOf(selection) {
|
||||||
|
const units = selection && Array.isArray(selection.units) ? new Set(selection.units.map(String)) : null
|
||||||
|
const apps = selection && Array.isArray(selection.apps) ? new Set(selection.apps.map(String)) : null
|
||||||
|
return {
|
||||||
|
active: !!units || !!apps,
|
||||||
|
has(key, id) {
|
||||||
|
if (units && !units.has(key)) return false
|
||||||
|
if (apps && String(key).startsWith('app:') && !apps.has(String(id))) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appUnits(cats, sel) {
|
||||||
const units = []
|
const units = []
|
||||||
for (const game of library.loadGames()) {
|
for (const game of library.loadGames()) {
|
||||||
const category = api.appCategory(game)
|
const category = api.appCategory(game)
|
||||||
if (!category || !cats.has(category)) continue
|
if (!category || !cats.has(category)) continue
|
||||||
|
if (!sel.has(`app:${game.id}`, game.id)) continue
|
||||||
const roots = [`data/icons/${game.id}`]
|
const roots = [`data/icons/${game.id}`]
|
||||||
if (category === 'apps-portable') roots.unshift(`apps/${game.id}`)
|
if (category === 'apps-portable') {
|
||||||
|
// the app's own folder, never apps/<id> guessed from the entry
|
||||||
|
const rel = String(toPortablePath(library.appFolder(game)) || '')
|
||||||
|
roots.unshift(rel.startsWith('apps/') ? rel : `apps/${game.id}`)
|
||||||
|
}
|
||||||
units.push({ key: `app:${game.id}`, kind: 'app', id: game.id, label: game.name, roots })
|
units.push({ key: `app:${game.id}`, kind: 'app', id: game.id, label: game.name, roots })
|
||||||
}
|
}
|
||||||
return units
|
return units
|
||||||
}
|
}
|
||||||
|
|
||||||
function wineUnits() {
|
// A prefix app a selection leaves out stays on this side: its folder must not
|
||||||
|
// travel with the prefix, and the receiver must not prune it as a leftover.
|
||||||
|
function prefixExcludes(name, sel) {
|
||||||
|
const rootRel = `wineprefixes/${name}`
|
||||||
|
const out = []
|
||||||
|
for (const game of library.loadGames()) {
|
||||||
|
if (api.appCategory(game) !== 'apps-prefix' || game.prefix !== name) continue
|
||||||
|
if (sel.has(`app:${game.id}`, game.id)) continue
|
||||||
|
const rel = String(toPortablePath(library.appFolder(game)) || '')
|
||||||
|
if (rel === rootRel || !rel.startsWith(rootRel + '/')) continue
|
||||||
|
out.push(rel)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function wineUnits(sel) {
|
||||||
const units = new Map()
|
const units = new Map()
|
||||||
const add = (root, label) => {
|
const add = (root, label) => {
|
||||||
if (units.has(root)) return
|
if (units.has(root)) return
|
||||||
|
|
@ -139,65 +230,68 @@ function createPusher(api) {
|
||||||
for (const wine of wines.getWines()) {
|
for (const wine of wines.getWines()) {
|
||||||
const rel = String(toPortablePath(wine.path) || '')
|
const rel = String(toPortablePath(wine.path) || '')
|
||||||
const parts = rel.split('/')
|
const parts = rel.split('/')
|
||||||
// a build folder can sit one level down (zip-extracted layouts are
|
// the unit is the TOP folder under wines/, not the detected build dir
|
||||||
// detected at any depth): the unit is always the TOP folder under
|
|
||||||
// wines/ - that is the folder mirrored as a whole, and listDirs()
|
|
||||||
// names it as well. Using the basename here would point the unit at a
|
|
||||||
// folder that does not exist and inflate the "Wine versions" totals
|
|
||||||
// with a phantom unit.
|
|
||||||
add(parts[0] === 'wines' && parts[1] ? `wines/${parts[1]}` : `wines/${path.basename(wine.path)}`, wine.label)
|
add(parts[0] === 'wines' && parts[1] ? `wines/${parts[1]}` : `wines/${path.basename(wine.path)}`, wine.label)
|
||||||
}
|
}
|
||||||
for (const name of listDirs(DIRS.WINES)) add(`wines/${name}`)
|
for (const name of listDirs(DIRS.WINES)) add(`wines/${name}`)
|
||||||
return [...units.values()]
|
return [...units.values()].filter(unit => sel.has(unit.key, unit.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
function prefixUnits() {
|
function prefixUnits(sel) {
|
||||||
const names = new Set(prefixes.listPrefixes().prefixes.map(p => p.name))
|
const names = new Set(prefixes.listPrefixes().prefixes.map(p => p.name))
|
||||||
for (const name of listDirs(DIRS.PREFIXES)) names.add(name)
|
for (const name of listDirs(DIRS.PREFIXES)) names.add(name)
|
||||||
return [...names].map(name => ({ key: `prefix:${name}`, kind: 'prefix', id: name, label: name, roots: [`wineprefixes/${name}`] }))
|
const units = []
|
||||||
|
for (const name of names) {
|
||||||
|
if (!sel.has(`prefix:${name}`)) continue
|
||||||
|
const unit = { key: `prefix:${name}`, kind: 'prefix', id: name, label: name, roots: [`wineprefixes/${name}`] }
|
||||||
|
const exclude = prefixExcludes(name, sel)
|
||||||
|
if (exclude.length) unit.exclude = exclude
|
||||||
|
units.push(unit)
|
||||||
|
}
|
||||||
|
return units
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUnits(cats) {
|
function buildUnits(cats, selection) {
|
||||||
|
const sel = selectionOf(selection)
|
||||||
const units = []
|
const units = []
|
||||||
if (cats.has('apps-portable') || cats.has('apps-prefix')) units.push(...appUnits(cats))
|
if (cats.has('apps-portable') || cats.has('apps-prefix')) units.push(...appUnits(cats, sel))
|
||||||
if (cats.has('wines')) units.push(...wineUnits())
|
if (cats.has('wines')) units.push(...wineUnits(sel))
|
||||||
if (cats.has('prefixes')) units.push(...prefixUnits())
|
if (cats.has('prefixes')) units.push(...prefixUnits(sel))
|
||||||
return units
|
return units
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- registry payload -----------------------------------------------------
|
// --- registry payload -----------------------------------------------------
|
||||||
|
|
||||||
// Everything the app config holds travels: paths (portableGame rewrites them
|
// The whole app config travels (paths rewritten to relative form).
|
||||||
// to the FILES/prefix relative form both machines understand), the wine
|
|
||||||
// build and prefix it runs in, args/env/dllOverrides and the play counters.
|
|
||||||
function portableApp(game) {
|
function portableApp(game) {
|
||||||
return library.portableGame(game)
|
return library.portableGame(game)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRegistryPayload(categories) {
|
function buildRegistryPayload(categories, selection) {
|
||||||
const cats = new Set(categories)
|
const cats = new Set(categories)
|
||||||
|
const sel = selectionOf(selection)
|
||||||
const payload = { categories: [...cats] }
|
const payload = { categories: [...cats] }
|
||||||
if (cats.has('apps-portable') || cats.has('apps-prefix')) {
|
if (cats.has('apps-portable') || cats.has('apps-prefix')) {
|
||||||
payload.apps = library
|
payload.apps = library
|
||||||
.loadGames()
|
.loadGames()
|
||||||
.filter(game => {
|
.filter(game => {
|
||||||
const category = api.appCategory(game)
|
const category = api.appCategory(game)
|
||||||
return category ? cats.has(category) : false
|
if (!category || !cats.has(category)) return false
|
||||||
|
return sel.has(`app:${game.id}`, game.id)
|
||||||
})
|
})
|
||||||
.map(portableApp)
|
.map(portableApp)
|
||||||
|
// A partial list means "only these apps travel"; without the marker the
|
||||||
|
// receiver mirrors the category and drops the apps that were left out.
|
||||||
|
if (sel.active) payload.appScope = 'selected'
|
||||||
}
|
}
|
||||||
if (cats.has('wines')) {
|
if (cats.has('wines')) {
|
||||||
// the receiver prunes its own builds down to this list and rescans
|
// the receiver mirrors its own builds down to this list and rescans
|
||||||
payload.wines = wineUnits().map(unit => ({ name: unit.id }))
|
payload.wines = wineUnits(sel).map(unit => ({ name: unit.id }))
|
||||||
|
if (sel.active) payload.wineScope = 'selected'
|
||||||
}
|
}
|
||||||
if (cats.has('settings')) {
|
if (cats.has('settings')) {
|
||||||
// "Sync settings": every registry-level value that describes how the
|
// "Sync settings": registry-level values only; opt-in - apps must not
|
||||||
// launcher is set up here - the defaults, the names/flags the user gave
|
// silently rewrite the other machine's settings.
|
||||||
// wine builds, the prefix registry, and each app's configuration plus
|
|
||||||
// its position in the library. The receiver only patches things it
|
|
||||||
// already has; this category creates and deletes no files. Opt-in on
|
|
||||||
// purpose - a sync of apps must not silently rewrite the other
|
|
||||||
// machine's settings.
|
|
||||||
const defs = prefixes.listPrefixes()
|
const defs = prefixes.listPrefixes()
|
||||||
const games = library.loadGames()
|
const games = library.loadGames()
|
||||||
payload.defaults = {
|
payload.defaults = {
|
||||||
|
|
@ -220,7 +314,10 @@ function createPusher(api) {
|
||||||
args: g.args || '',
|
args: g.args || '',
|
||||||
env: g.env || {},
|
env: g.env || {},
|
||||||
dllOverrides: g.dllOverrides || [],
|
dllOverrides: g.dllOverrides || [],
|
||||||
disableUmu: !!g.disableUmu
|
disableUmu: !!g.disableUmu,
|
||||||
|
// Linux-only app config, part of the app's configuration as well
|
||||||
|
isolation: g.isolation,
|
||||||
|
forceX11: !!g.forceX11
|
||||||
}))
|
}))
|
||||||
payload.appOrder = games.map(g => g.id)
|
payload.appOrder = games.map(g => g.id)
|
||||||
}
|
}
|
||||||
|
|
@ -228,8 +325,11 @@ function createPusher(api) {
|
||||||
const defs = prefixes.listPrefixes()
|
const defs = prefixes.listPrefixes()
|
||||||
payload.prefixes = {
|
payload.prefixes = {
|
||||||
default: defs.default,
|
default: defs.default,
|
||||||
prefixes: defs.prefixes.map(p => ({ name: p.name, created: p.created, locked: !!p.locked }))
|
prefixes: defs.prefixes
|
||||||
|
.filter(p => sel.has(`prefix:${p.name}`))
|
||||||
|
.map(p => ({ name: p.name, created: p.created, locked: !!p.locked }))
|
||||||
}
|
}
|
||||||
|
if (sel.active) payload.prefixScope = 'selected'
|
||||||
}
|
}
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
@ -242,113 +342,47 @@ function createPusher(api) {
|
||||||
uploaded: 0,
|
uploaded: 0,
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
deleted: 0,
|
deleted: 0,
|
||||||
|
// files the receiver asked to keep when the deletion list was reviewed
|
||||||
|
kept: 0,
|
||||||
bytes: 0,
|
bytes: 0,
|
||||||
// how many apps / wine builds / prefixes this sync walked over: the
|
// size of the synced trees as they now sit on the other device
|
||||||
// progress popup reports "3 games, 1 prefix" instead of file counts,
|
sizeBytes: 0,
|
||||||
// which mean nothing to the user
|
// the popup reports "3 games, 1 prefix" instead of file counts
|
||||||
items: { apps: 0, wines: 0, prefixes: 0 },
|
items: { apps: 0, wines: 0, prefixes: 0 }
|
||||||
apps: { copied: 0, skipped: 0, deleted: 0 },
|
|
||||||
wines: { copied: 0, skipped: 0, deleted: 0 },
|
|
||||||
prefixes: { copied: 0, skipped: 0, deleted: 0 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every sync resolves to the same shape, successes and failures alike, so
|
// Every sync resolves to the same shape, success or failure.
|
||||||
// callers can read counters/events without checking first.
|
function syncResult(ok, error, summary) {
|
||||||
function syncResult(ok, error, summary, events) {
|
return { ok, error: error || null, summary }
|
||||||
const buckets = { apps: summary.apps, prefixes: summary.prefixes, wines: summary.wines }
|
|
||||||
summary.progress = events.length
|
|
||||||
summary.events = events.length
|
|
||||||
return {
|
|
||||||
ok,
|
|
||||||
error: error || null,
|
|
||||||
summary,
|
|
||||||
stats: buckets,
|
|
||||||
report: buckets,
|
|
||||||
counts: buckets,
|
|
||||||
details: buckets,
|
|
||||||
perCategory: buckets,
|
|
||||||
categories: buckets,
|
|
||||||
apps: summary.apps,
|
|
||||||
prefixes: summary.prefixes,
|
|
||||||
wines: summary.wines,
|
|
||||||
progress: events,
|
|
||||||
events,
|
|
||||||
progressEvents: events,
|
|
||||||
progressLog: events,
|
|
||||||
progressCount: events.length
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push the selected categories onto the peer. A rejected code comes back as
|
// Push to the peer; a rejected code comes back as 'bad-code'.
|
||||||
// error 'bad-code', so the UI can offer to type it again.
|
async function syncToDevice({ deviceId, device: target, code, categories, force, selection, onProgress }) {
|
||||||
async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) {
|
const fail = (error, summary) => syncResult(false, error, summary || newSummary(0))
|
||||||
const fail = (error, summary, events) => syncResult(false, error, summary || newSummary(0), events || [])
|
|
||||||
const device = target || api.listDevices().find(d => d.id === deviceId)
|
const device = target || api.listDevices().find(d => d.id === deviceId)
|
||||||
if (!device || !device.address || !device.port) return fail('offline')
|
if (!device || !device.address || !device.port) return fail('offline')
|
||||||
const cats = new Set(normalizeCategories(categories))
|
const cats = new Set(normalizeCategories(categories))
|
||||||
if (!cats.size) return fail('nothing-selected')
|
if (!cats.size) return fail('nothing-selected')
|
||||||
// The units are known before anything is read, so how many apps / wine
|
// units are known before anything is read: announce them with the verify
|
||||||
// builds / prefixes this sync carries is announced with the verification:
|
const units = buildUnits(cats, selection)
|
||||||
// the other side can show "Apps: 0/5" from its first event.
|
|
||||||
const units = buildUnits(cats)
|
|
||||||
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
|
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
|
||||||
const totals = { apps: 0, wines: 0, prefixes: 0 }
|
const totals = { apps: 0, wines: 0, prefixes: 0, settings: 0 }
|
||||||
for (const unit of units) totals[bucketOf(unit.kind)] += 1
|
for (const unit of units) totals[bucketOf(unit.kind)] += 1
|
||||||
// every progress event is kept (and handed to onProgress when given) so
|
if (cats.has('settings')) totals.settings = 1
|
||||||
// callers can report on the transfer without subscribing first
|
|
||||||
const events = []
|
|
||||||
const progress = data => {
|
const progress = data => {
|
||||||
events.push(data)
|
|
||||||
if (onProgress) onProgress(data)
|
if (onProgress) onProgress(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
let verified
|
|
||||||
try {
|
|
||||||
// longer than the receiver's incoming-request timeout: the user over
|
|
||||||
// there gets a popup and this side waits for the click
|
|
||||||
verified = await httpJson(device, 'POST', '/plwl/verify', {
|
|
||||||
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase(), totals },
|
|
||||||
timeout: 90000
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
return fail(String((err && err.message) || err))
|
|
||||||
}
|
|
||||||
if (!verified.data || !verified.data.ok) {
|
|
||||||
const error = (verified.data && verified.data.error) || `http-${verified.status}`
|
|
||||||
// the peer rotated its code: drop the stored pairing, so the next
|
|
||||||
// attempt asks for the new one instead of failing silently. A plain
|
|
||||||
// 'rejected' (the user over there declined the popup) keeps the pairing
|
|
||||||
// - the code was proven correct, the popup is the consent gate.
|
|
||||||
if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId)
|
|
||||||
return fail(error)
|
|
||||||
}
|
|
||||||
const token = verified.data.token
|
|
||||||
// the code worked, so this device is paired now: the next sync needs no
|
|
||||||
// typing (a rejected code is never stored)
|
|
||||||
rememberDevice(device.id || deviceId, code, device.name)
|
|
||||||
|
|
||||||
// Hashing a big unit happens here, before a single byte is sent: without a
|
|
||||||
// sign of life the other side would see minutes of silence and its
|
|
||||||
// "receiving" popup could close while this device is still working.
|
|
||||||
const heartbeat = setInterval(() => {
|
|
||||||
httpJson(device, 'POST', `/plwl/ping?token=${encodeURIComponent(token)}`, { json: {}, timeout: 10000 }).catch(() => {})
|
|
||||||
}, HEARTBEAT_MS)
|
|
||||||
if (heartbeat.unref) heartbeat.unref()
|
|
||||||
|
|
||||||
const summary = newSummary(units.length)
|
const summary = newSummary(units.length)
|
||||||
const finished = { apps: 0, wines: 0, prefixes: 0 }
|
const finished = { apps: 0, wines: 0, prefixes: 0 }
|
||||||
// bytes the units planned so far will put on the wire: the popup can then
|
// planned bytes so far, so the popup can show "1.2 GB / 3.4 GB"
|
||||||
// show "1.2 GB / 3.4 GB" instead of only what has already arrived
|
|
||||||
let plannedBytes = 0
|
let plannedBytes = 0
|
||||||
let itemsSeen = 0
|
let itemsSeen = 0
|
||||||
// files uploaded/announced in PREVIOUS units: the popup's file counter is
|
// files from previous units: the popup counter is cumulative
|
||||||
// cumulative across the whole sync (a per-unit counter resets with every
|
|
||||||
// next app, which reads like progress going backwards)
|
|
||||||
let allDone = 0
|
let allDone = 0
|
||||||
let allTotal = 0
|
let allTotal = 0
|
||||||
// the bar tracks the PHASE being worked on (its own bytes), never a
|
// the bar tracks the current phase only: hashing starts empty
|
||||||
// leftover from the phase before: hashing starts from an empty bar
|
|
||||||
let barBytes = 0
|
let barBytes = 0
|
||||||
let barTotal = 0
|
let barTotal = 0
|
||||||
const snapshot = (phase, unit, done, total) => {
|
const snapshot = (phase, unit, done, total) => {
|
||||||
|
|
@ -371,54 +405,147 @@ function createPusher(api) {
|
||||||
barBytes,
|
barBytes,
|
||||||
barTotalBytes: barTotal
|
barTotalBytes: barTotal
|
||||||
}
|
}
|
||||||
// only phases that actually move the file counter carry one (the copy
|
// only the copy phase carries a file count, so the popup never jumps back
|
||||||
// phase passes absolute, cumulative values); the rest omit it, so the
|
|
||||||
// popup freezes the last count instead of jumping backwards while this
|
|
||||||
// unit is still being scanned or compared
|
|
||||||
if (Number.isFinite(done) && Number.isFinite(total)) {
|
if (Number.isFinite(done) && Number.isFinite(total)) {
|
||||||
data.done = done
|
data.done = done
|
||||||
data.total = total
|
data.total = total
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
// hashing and copying walk thousands of files: the state is snapshotted on
|
// Snapshot per file, forward a few times per second (phase changes
|
||||||
// every file but forwarded a few times per second (phase changes always
|
// always). The snapshot is built when the gate flushes, not when it is
|
||||||
// go through, so nothing on screen ever jumps backwards)
|
// pushed: a throttled byte count must not be one gate window old.
|
||||||
const gate = typeof api.progressGate === 'function' ? api.progressGate(progress) : data => progress(data)
|
const emitNow = data => progress(typeof data === 'function' ? data() : data)
|
||||||
const emit = (phase, unit, done, total, force) => gate(snapshot(phase, unit, done, total), !!force)
|
const gate = typeof api.progressGate === 'function' ? api.progressGate(emitNow) : emitNow
|
||||||
|
const emit = (phase, unit, done, total, force) => gate(() => snapshot(phase, unit, done, total), !!force)
|
||||||
|
// consent may wait on a human: say so before anything else happens
|
||||||
|
emit('wait', null, null, null, true)
|
||||||
|
|
||||||
|
let verified
|
||||||
|
try {
|
||||||
|
// longer than the receiver's incoming-request timeout (popup wait)
|
||||||
|
verified = await httpJson(device, 'POST', '/plwl/verify', {
|
||||||
|
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase(), totals },
|
||||||
|
timeout: 90000
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
return fail(String((err && err.message) || err))
|
||||||
|
}
|
||||||
|
if (!verified.data || !verified.data.ok) {
|
||||||
|
const error = (verified.data && verified.data.error) || `http-${verified.status}`
|
||||||
|
// a rotated code drops the pairing; a plain 'rejected' keeps it
|
||||||
|
if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId)
|
||||||
|
return fail(error)
|
||||||
|
}
|
||||||
|
// An older peer prunes inside its commit, with no list to approve: units
|
||||||
|
// must not travel there at all. Settings alone are additive and fine.
|
||||||
|
if (!verified.data.review && units.length) return fail('old-peer')
|
||||||
|
const token = verified.data.token
|
||||||
|
// paired now: the next sync needs no typing
|
||||||
|
rememberDevice(device.id || deviceId, code, device.name)
|
||||||
|
|
||||||
|
// The heartbeat keeps the receiver's popup alive while this side hashes
|
||||||
|
// (minutes of silence). Consecutive ping failures mean the peer dropped
|
||||||
|
// (crash, sleep, lost network): without this a dead receiver hangs the
|
||||||
|
// sync until the next request notices, which may be never.
|
||||||
|
let missedPings = 0
|
||||||
|
const abort = { dead: false, reject: null }
|
||||||
|
const abortPromise = new Promise((resolve, reject) => { abort.reject = reject })
|
||||||
|
abortPromise.catch(() => {}) // only awaited via Promise.race below
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
httpJson(device, 'POST', `/plwl/ping?token=${encodeURIComponent(token)}`, { json: {}, timeout: PING_TIMEOUT_MS })
|
||||||
|
.then(() => { missedPings = 0 })
|
||||||
|
.catch(() => {
|
||||||
|
missedPings++
|
||||||
|
if (missedPings >= MAX_MISSED_PINGS && !abort.dead) {
|
||||||
|
abort.dead = true
|
||||||
|
abort.reject(new Error('receiver-lost'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, HEARTBEAT_MS)
|
||||||
|
if (heartbeat.unref) heartbeat.unref()
|
||||||
|
|
||||||
const end = async () => {
|
const end = async () => {
|
||||||
try {
|
try {
|
||||||
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
|
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
try {
|
// The receiver's popup is a human, so the sender waits - visibly, and with
|
||||||
|
// a deadline. A timeout deletes nothing on purpose.
|
||||||
|
const waitForReview = async (device, token, review, emit, abort) => {
|
||||||
|
emit('review', null, null, null, true)
|
||||||
|
const deadline = Date.now() + REVIEW_WAIT_MS
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (abort.dead) throw new Error('receiver-lost')
|
||||||
|
try {
|
||||||
|
const res = await httpJson(device, 'GET', `/plwl/review?token=${encodeURIComponent(token)}&id=${encodeURIComponent(review.id)}`, { timeout: 20000 })
|
||||||
|
const data = res && res.data ? res.data : {}
|
||||||
|
if (data.state === 'done' || data.state === 'timeout' || data.state === 'skipped') {
|
||||||
|
return { deleted: Number(data.deleted) || 0, kept: Number(data.kept) || 0 }
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, REVIEW_POLL_MS))
|
||||||
|
}
|
||||||
|
return { deleted: 0, kept: 0 }
|
||||||
|
}
|
||||||
|
const runUnits = async () => {
|
||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
|
if (abort.dead) throw new Error('receiver-lost')
|
||||||
const roots = unit.roots.filter(ownedRoot)
|
const roots = unit.roots.filter(ownedRoot)
|
||||||
if (!roots.length) continue
|
if (!roots.length) continue
|
||||||
const kind = bucketOf(unit.kind)
|
const kind = bucketOf(unit.kind)
|
||||||
const bucket = summary[kind]
|
|
||||||
summary.items[kind] += 1
|
summary.items[kind] += 1
|
||||||
itemsSeen += 1
|
itemsSeen += 1
|
||||||
barBytes = 0
|
barBytes = 0
|
||||||
barTotal = 0
|
barTotal = 0
|
||||||
emit('scan', unit, null, null, true)
|
emit('scan', unit, null, null, true)
|
||||||
const entries = await buildManifest(roots, !!force, (done, total, hashedBytes, totalBytes) => {
|
// The manifest streams WHILE this side hashes its tail: the receiver
|
||||||
barBytes = hashedBytes
|
// compares entries as they arrive, so both devices hash at the same
|
||||||
barTotal = totalBytes
|
// time. Once this side is done hashing, the popup keeps a full bar
|
||||||
// the file counter is not touched here: until the plan comes back
|
// and the waiting text until the plan answers (a cached sender waits
|
||||||
// nobody knows how many of this unit's files will actually move
|
// for a receiver that still has to hash).
|
||||||
emit('hash', unit, null, null)
|
const entries = []
|
||||||
|
let writes = Promise.resolve()
|
||||||
|
// A read failure means this manifest is not the sender's whole tree:
|
||||||
|
// tell the receiver, so it never prunes what only looked missing.
|
||||||
|
const incomplete = () => {
|
||||||
|
writes = writes.then(() => write({ unitIncomplete: true }))
|
||||||
|
}
|
||||||
|
const plan = await planUnit(device, token, { ...unit, roots }, !!force, { totals }, async write => {
|
||||||
|
await buildManifestStream(roots, !!force, {
|
||||||
|
// an app the selection left out does not travel with its prefix
|
||||||
|
skip: unit.exclude,
|
||||||
|
// the receiver sizes its compare bar from this, before any hashing
|
||||||
|
onTotal: totalBytes => {
|
||||||
|
writes = writes.then(() => write({ unitBytes: totalBytes }))
|
||||||
|
},
|
||||||
|
onEntry: entry => {
|
||||||
|
entries.push(entry)
|
||||||
|
writes = writes.then(() => write(entry))
|
||||||
|
},
|
||||||
|
onIncomplete: incomplete,
|
||||||
|
onProgress: (hashedDone, hashedTotal, hashedBytes, totalBytes) => {
|
||||||
|
barBytes = hashedBytes
|
||||||
|
barTotal = totalBytes
|
||||||
|
emit('hash', unit, null, null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await writes
|
||||||
|
// this side is done hashing; the receiver may still be comparing
|
||||||
|
barTotal = barTotal || 1
|
||||||
|
barBytes = barTotal
|
||||||
|
emit('wait', unit, null, null, true)
|
||||||
})
|
})
|
||||||
emit('plan', unit, null, null, true)
|
if (abort.dead) throw new Error('receiver-lost')
|
||||||
const plan = await planUnit(device, token, { ...unit, roots }, entries, !!force, { totals })
|
|
||||||
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
|
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
|
||||||
|
// every file in the unit, including the ones already identical there
|
||||||
|
for (const entry of entries) if (entry.type === 'file') summary.sizeBytes += entry.size || 0
|
||||||
const upload = new Set(plan.data.upload || [])
|
const upload = new Set(plan.data.upload || [])
|
||||||
const same = new Set(plan.data.same || [])
|
const same = new Set(plan.data.same || [])
|
||||||
// per category every manifest entry counts: created/overwritten vs
|
const filePaths = new Set(entries.filter(e => e.type === 'file').map(e => e.path))
|
||||||
// already identical (the plan reports both)
|
let sameFiles = 0
|
||||||
bucket.copied += entries.length - same.size
|
for (const p of same) if (filePaths.has(p)) sameFiles++
|
||||||
bucket.skipped += same.size
|
summary.skipped += sameFiles
|
||||||
summary.skipped += same.size
|
|
||||||
const pending = []
|
const pending = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.type !== 'file') continue
|
if (entry.type !== 'file') continue
|
||||||
|
|
@ -426,44 +553,48 @@ function createPusher(api) {
|
||||||
pending.push(entry)
|
pending.push(entry)
|
||||||
plannedBytes += entry.size || 0
|
plannedBytes += entry.size || 0
|
||||||
}
|
}
|
||||||
// a few files in flight at once: with thousands of small ones the
|
// a few files in flight at once: small files cost round trips; the
|
||||||
// round trip to the other machine costs more than the bytes do
|
// bar counts only bytes that move and starts empty for the copy
|
||||||
// the files that were already identical never move, so the bar counts
|
|
||||||
// only the bytes that do - and starts empty for the transfer phase
|
|
||||||
if (pending.length) {
|
if (pending.length) {
|
||||||
barBytes = 0
|
barBytes = 0
|
||||||
barTotal = pending.reduce((sum, entry) => sum + (entry.size || 0), 0)
|
barTotal = pending.reduce((sum, entry) => sum + (entry.size || 0), 0)
|
||||||
emit('copy', unit, allDone, allTotal + pending.length, true)
|
emit('copy', unit, allDone, allTotal + pending.length, true)
|
||||||
}
|
let failed = null
|
||||||
let failed = null
|
let done = 0
|
||||||
let done = 0
|
const send = async entry => {
|
||||||
const send = async entry => {
|
if (failed || abort.dead) return
|
||||||
if (failed) return
|
const res = await putFile(device, token, unit.key, entry.path, filesPath(entry.path), delta => {
|
||||||
const res = await putFile(device, token, unit.key, entry.path, filesPath(entry.path))
|
summary.bytes += delta
|
||||||
if (res.status !== 200) {
|
barBytes += delta
|
||||||
failed = new Error(`upload-failed-${res.status}`)
|
emit('copy', unit, allDone + done, allTotal + pending.length)
|
||||||
return
|
})
|
||||||
|
if (res.status !== 200) {
|
||||||
|
failed = new Error(`upload-failed-${res.status}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
done++
|
||||||
|
summary.uploaded++
|
||||||
|
// the byte counters already moved while the file was read; the
|
||||||
|
// completion still has to push the file count next
|
||||||
|
emit('copy', unit, allDone + done, allTotal + pending.length)
|
||||||
}
|
}
|
||||||
done++
|
if (typeof api.mapLimit === 'function') await api.mapLimit(pending, UPLOAD_CONCURRENCY, send)
|
||||||
summary.uploaded++
|
else for (const entry of pending) await send(entry)
|
||||||
summary.bytes += entry.size || 0
|
if (failed) throw failed
|
||||||
barBytes += entry.size || 0
|
// A dead receiver made `send` skip files; committing now would prune
|
||||||
emit('copy', unit, allDone + done, allTotal + pending.length)
|
// exactly the files that never arrived (the commit itself could even
|
||||||
|
// succeed while only the heartbeats were failing).
|
||||||
|
if (abort.dead) throw new Error('receiver-lost')
|
||||||
}
|
}
|
||||||
if (typeof api.mapLimit === 'function') await api.mapLimit(pending, UPLOAD_CONCURRENCY, send)
|
emit('copy', unit, allDone + pending.length, allTotal + pending.length, true)
|
||||||
else for (const entry of pending) await send(entry)
|
// the totals count a unit's files only once they are on the wire
|
||||||
if (failed) throw failed
|
|
||||||
emit('copy', unit, allDone + done, allTotal + pending.length, true)
|
|
||||||
// this unit's files count towards the running totals only once they
|
|
||||||
// are really on the wire - the counter holds there while the receiver
|
|
||||||
// prunes and the next unit is scanned/compared
|
|
||||||
allDone += pending.length
|
allDone += pending.length
|
||||||
allTotal += pending.length
|
allTotal += pending.length
|
||||||
const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} })
|
const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} })
|
||||||
if (commit.data && commit.data.deleted) {
|
// a unit that did not commit (unknown unit, failed prune) must not be
|
||||||
summary.deleted += commit.data.deleted
|
// counted as done while the sync moves on to the registry
|
||||||
bucket.deleted += commit.data.deleted
|
if (!commit.data || !commit.data.ok) throw new Error((commit.data && commit.data.error) || `commit-${commit.status}`)
|
||||||
}
|
if (commit.data.deleted) summary.deleted += commit.data.deleted
|
||||||
finished[kind] += 1
|
finished[kind] += 1
|
||||||
// a finished unit leaves a finished bar behind, even one that sent none
|
// a finished unit leaves a finished bar behind, even one that sent none
|
||||||
barTotal = barTotal || 1
|
barTotal = barTotal || 1
|
||||||
|
|
@ -471,25 +602,57 @@ function createPusher(api) {
|
||||||
emit('commit', unit, null, null, true)
|
emit('commit', unit, null, null, true)
|
||||||
}
|
}
|
||||||
emit('registry', null, null, null, true)
|
emit('registry', null, null, null, true)
|
||||||
const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats]), timeout: 60000 })
|
if (abort.dead) throw new Error('receiver-lost')
|
||||||
|
const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats], selection), timeout: 60000 })
|
||||||
if (!applied.data || !applied.data.ok) throw new Error((applied.data && applied.data.error) || `http-${applied.status}`)
|
if (!applied.data || !applied.data.ok) throw new Error((applied.data && applied.data.error) || `http-${applied.status}`)
|
||||||
for (const key of ['apps', 'wines', 'prefixes']) {
|
|
||||||
const deleted = applied.data[key] && applied.data[key].deleted
|
|
||||||
if (deleted) summary[key].deleted += deleted
|
|
||||||
}
|
|
||||||
if (applied.data.deleted) summary.deleted += applied.data.deleted
|
if (applied.data.deleted) summary.deleted += applied.data.deleted
|
||||||
|
// Deletions wait for the receiver's approval; nothing is removed on a
|
||||||
|
// timeout, so a lost popup can only ever keep too much, never too little.
|
||||||
|
if (applied.data.review && applied.data.review.id) {
|
||||||
|
const outcome = await waitForReview(device, token, applied.data.review, emit, abort)
|
||||||
|
summary.deleted += outcome.deleted || 0
|
||||||
|
summary.kept += outcome.kept || 0
|
||||||
|
}
|
||||||
|
// a sync with nothing to send still ends with a full bar
|
||||||
|
barTotal = barTotal || 1
|
||||||
|
barBytes = barTotal
|
||||||
emit('done', null, allDone, allTotal, true)
|
emit('done', null, allDone, allTotal, true)
|
||||||
await end()
|
await end()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Promise.race([runUnits(), abortPromise])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
abort.dead = true
|
||||||
await end()
|
await end()
|
||||||
return fail(String((err && err.message) || err), summary, events)
|
return fail(String((err && err.message) || err), summary)
|
||||||
} finally {
|
} finally {
|
||||||
clearInterval(heartbeat)
|
clearInterval(heartbeat)
|
||||||
}
|
}
|
||||||
return syncResult(true, null, summary, events)
|
return syncResult(true, null, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { syncToDevice, buildUnits, buildRegistryPayload, CATEGORIES }
|
// Everything a selection can name, in the same ids buildUnits uses - the UI
|
||||||
|
// must never derive a unit key on its own.
|
||||||
|
function items() {
|
||||||
|
const all = { active: false, has: () => true }
|
||||||
|
return {
|
||||||
|
apps: library
|
||||||
|
.loadGames()
|
||||||
|
.filter(game => api.appCategory(game))
|
||||||
|
.map(game => ({
|
||||||
|
key: `app:${game.id}`,
|
||||||
|
name: game.name || game.id,
|
||||||
|
category: api.appCategory(game),
|
||||||
|
prefix: game.prefix || ''
|
||||||
|
})),
|
||||||
|
wines: wineUnits(all).map(unit => ({ key: unit.key, name: unit.label })),
|
||||||
|
prefixes: prefixUnits(all).map(unit => ({ key: unit.key, name: unit.label }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// putFile is exposed for the stall tests (it is the piece that must never
|
||||||
|
// leave a request half-open)
|
||||||
|
return { syncToDevice, buildUnits, buildRegistryPayload, items, putFile, CATEGORIES }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = createPusher
|
module.exports = createPusher
|
||||||
|
|
|
||||||
177
src/library.js
177
src/library.js
|
|
@ -3,14 +3,13 @@
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
|
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
|
||||||
const { prefixPath } = require('./prefixes')
|
const { prefixPath, isPrefixRel } = require('./prefixes')
|
||||||
|
|
||||||
const REGISTRY = path.join(DIRS.DATA, 'apps.json')
|
const REGISTRY = path.join(DIRS.DATA, 'apps.json')
|
||||||
const SETTINGS = path.join(DIRS.DATA, 'settings.json')
|
const SETTINGS = path.join(DIRS.DATA, 'settings.json')
|
||||||
|
|
||||||
// Playtime lives in its own tiny file holding ONLY a number of seconds
|
// Playtime lives in its own tiny file (files/data/appdata/<id>/playtime.txt),
|
||||||
// (files/data/appdata/<id>/playtime.txt) so the running game can update it
|
// updated every ~30s without touching apps.json.
|
||||||
// every ~30s without rewriting the whole apps.json registry on each tick.
|
|
||||||
function playtimeFile(id) {
|
function playtimeFile(id) {
|
||||||
return path.join(DIRS.DATA, 'appdata', id, 'playtime.txt')
|
return path.join(DIRS.DATA, 'appdata', id, 'playtime.txt')
|
||||||
}
|
}
|
||||||
|
|
@ -29,33 +28,24 @@ function writePlaySeconds(id, seconds) {
|
||||||
fs.writeFileSync(file, String(Math.max(0, Math.round(seconds))))
|
fs.writeFileSync(file, String(Math.max(0, Math.round(seconds))))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefix imports keep exe/workdir paths relative to the PREFIX ROOT
|
|
||||||
// ("drive_c/..." for plain wine, "pfx/drive_c/..." for Proton-managed
|
|
||||||
// prefixes) so wine sees them under C:\... - everything else is
|
|
||||||
// FILES-relative/absolute.
|
|
||||||
const isPrefixRel = v => typeof v === 'string' && /^(pfx[/\\])?drive_c([/\\]|$)/i.test(v)
|
|
||||||
|
|
||||||
function loadGames() {
|
function loadGames() {
|
||||||
const games = readJson(REGISTRY, [])
|
const games = readJson(REGISTRY, [])
|
||||||
for (const g of games) {
|
for (const g of games) {
|
||||||
// stored relative to FILES (portable) -> resolve to absolute for use.
|
// portable paths resolve against FILES, prefix imports against their prefix
|
||||||
// Prefix imports resolve against their own prefix instead (never the
|
|
||||||
// launcher-wide default).
|
|
||||||
const usePrefix = g.importSource === 'prefix' && g.prefix ? prefixPath(g.prefix) : null
|
const usePrefix = g.importSource === 'prefix' && g.prefix ? prefixPath(g.prefix) : null
|
||||||
for (const k of ['path', 'exePath', 'workdir']) {
|
for (const k of ['path', 'exePath', 'workdir']) {
|
||||||
if (!g[k]) continue
|
if (!g[k]) continue
|
||||||
g[k] = usePrefix && isPrefixRel(g[k]) ? path.join(usePrefix, g[k]) : resolvePortablePath(g[k])
|
g[k] = usePrefix && isPrefixRel(g[k]) ? path.join(usePrefix, g[k]) : resolvePortablePath(g[k])
|
||||||
}
|
}
|
||||||
if (g.iconSource) g.iconSource = resolvePortablePath(g.iconSource)
|
if (g.iconSource) g.iconSource = resolvePortablePath(g.iconSource)
|
||||||
|
if (g.isolation !== undefined) g.isolation = isolationMode(g.isolation)
|
||||||
const ps = readPlaySeconds(g.id)
|
const ps = readPlaySeconds(g.id)
|
||||||
if (ps != null) g.playSeconds = ps
|
if (ps != null) g.playSeconds = ps
|
||||||
}
|
}
|
||||||
return games
|
return games
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paths of anything living inside FILES/ are stored RELATIVE to FILES so the
|
// Paths inside FILES/ are stored FILES-relative (portable); others as-is.
|
||||||
// whole launcher folder can be moved (portable mode). Absolute paths outside
|
|
||||||
// FILES (apps left in place) are kept as-is.
|
|
||||||
function portableGame(g) {
|
function portableGame(g) {
|
||||||
const out = { ...g }
|
const out = { ...g }
|
||||||
const pfx = out.importSource === 'prefix' && out.prefix ? prefixPath(out.prefix) : null
|
const pfx = out.importSource === 'prefix' && out.prefix ? prefixPath(out.prefix) : null
|
||||||
|
|
@ -63,8 +53,7 @@ function portableGame(g) {
|
||||||
const v = out[k]
|
const v = out[k]
|
||||||
if (!v) continue
|
if (!v) continue
|
||||||
if (pfx && k !== 'iconSource' && !isPrefixRel(v)) {
|
if (pfx && k !== 'iconSource' && !isPrefixRel(v)) {
|
||||||
// prefix game: store relative to the PREFIX ROOT (keeps the drive_c
|
// prefix game: store relative to the prefix root ("drive_c/...")
|
||||||
// segment, e.g. "drive_c/Games/..." or "pfx/drive_c/Games/...")
|
|
||||||
const rel = path.relative(pfx, resolvePortablePath(v))
|
const rel = path.relative(pfx, resolvePortablePath(v))
|
||||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) out[k] = rel
|
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) out[k] = rel
|
||||||
} else if (!(pfx && isPrefixRel(v))) {
|
} else if (!(pfx && isPrefixRel(v))) {
|
||||||
|
|
@ -82,46 +71,33 @@ function getGame(id) {
|
||||||
return loadGames().find(g => g.id === id) || null
|
return loadGames().find(g => g.id === id) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Which prefix a game's runs land in: an app without one follows the launcher
|
// Which prefix a game's runs land in (launcher default when unset).
|
||||||
// default. The single place for that fallback (runner.buildEnv and the in-use
|
|
||||||
// guards both use it, so a rename/delete can never blame the wrong folder).
|
|
||||||
function prefixNameForGame(game) {
|
function prefixNameForGame(game) {
|
||||||
if (game && game.prefix) return game.prefix
|
if (game && game.prefix) return game.prefix
|
||||||
const defs = readJson(path.join(DIRS.DATA, 'prefixes.json'), { default: 'Main', prefixes: [] })
|
const defs = readJson(path.join(DIRS.DATA, 'prefixes.json'), { default: 'Main', prefixes: [] })
|
||||||
return defs.default || 'Main'
|
return defs.default || 'Main'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apps whose runs land in this prefix - used to keep a prefix that any app
|
// Apps whose runs land in this prefix (in-use guard).
|
||||||
// still references from being deleted out from under it.
|
|
||||||
function gamesUsingPrefix(name) {
|
function gamesUsingPrefix(name) {
|
||||||
if (!name) return []
|
if (!name) return []
|
||||||
return loadGames().filter(g => prefixNameForGame(g) === name)
|
return loadGames().filter(g => prefixNameForGame(g) === name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-use guards for registry edits: a prefix folder being renamed or deleted
|
// In-use guards: rename/delete of a prefix or build waits until no live run
|
||||||
// (or a wine build deleted) while a game runs in it would leave that process
|
// uses it. `running` is the games service's list of live runs.
|
||||||
// without its registry/files - the folder move/removal has to wait until the
|
|
||||||
// game stops. `running` is the games service's list of live runs
|
|
||||||
// ([{ name, prefixes, wine }]); prefixes lists every folder a run holds (its
|
|
||||||
// snapshot plus a new one it was pointed at mid-run).
|
|
||||||
function appsUsingPrefix(name, running) {
|
function appsUsingPrefix(name, running) {
|
||||||
if (!name) return []
|
if (!name) return []
|
||||||
return (running || []).filter(r => (r.prefixes || []).includes(name))
|
return (running || []).filter(r => (r.prefixes || []).includes(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPrefixInUse(name, running) {
|
// Only an exact match on a registered build counts.
|
||||||
return appsUsingPrefix(name, running).length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// A run with no explicit wine falls back to System Wine, which cannot be
|
|
||||||
// deleted anyway - only an exact match on a registered build counts.
|
|
||||||
function isWineInUse(id, running) {
|
function isWineInUse(id, running) {
|
||||||
if (!id || id === 'system') return false
|
if (!id || id === 'system') return false
|
||||||
return (running || []).some(r => r.wine === id)
|
return (running || []).some(r => r.wine === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switching the default prefix would silently move the next launch of every
|
// Switching the default prefix waits while any default-running app is live.
|
||||||
// app that has no prefix of its own - so it waits while any of them runs.
|
|
||||||
function gamesFollowingDefault(running) {
|
function gamesFollowingDefault(running) {
|
||||||
return (running || []).filter(r => {
|
return (running || []).filter(r => {
|
||||||
const game = getGame(r.id)
|
const game = getGame(r.id)
|
||||||
|
|
@ -129,6 +105,30 @@ function gamesFollowingDefault(running) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `kind` is only ever written as 'linux'; a missing one is a Windows app.
|
||||||
|
function isLinuxGame(game) {
|
||||||
|
return !!game && game.kind === 'linux'
|
||||||
|
}
|
||||||
|
|
||||||
|
// none = plain run, relaxed = whole host filesystem mounted writable, isolated
|
||||||
|
// = the stock read-only profile, auto = relaxed where bwrap works.
|
||||||
|
const ISOLATION_MODES = ['auto', 'none', 'relaxed', 'isolated']
|
||||||
|
|
||||||
|
// One known mode, always: an unknown value would otherwise reach the runner and
|
||||||
|
// pick a level nobody asked for.
|
||||||
|
function isolationMode(value) {
|
||||||
|
return ISOLATION_MODES.includes(value) ? value : 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeLinuxPatch(patch) {
|
||||||
|
if (!patch) return patch
|
||||||
|
const out = { ...patch }
|
||||||
|
if (out.kind !== undefined) out.kind = out.kind === 'linux' ? 'linux' : 'windows'
|
||||||
|
if (out.isolation !== undefined) out.isolation = isolationMode(out.isolation)
|
||||||
|
if (out.forceX11 !== undefined) out.forceX11 = !!out.forceX11
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
function addGame(game) {
|
function addGame(game) {
|
||||||
const games = loadGames()
|
const games = loadGames()
|
||||||
games.push(game)
|
games.push(game)
|
||||||
|
|
@ -136,50 +136,81 @@ function addGame(game) {
|
||||||
return game
|
return game
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefix imports are pinned; their patch fields are ignored. One place, so a
|
||||||
|
// batch update behaves exactly like a single one.
|
||||||
|
function normalizeGamePatch(game, patch) {
|
||||||
|
if (!patch) return {}
|
||||||
|
const out = { ...patch }
|
||||||
|
if (game.importSource === 'prefix') {
|
||||||
|
delete out.prefix
|
||||||
|
delete out.importSource
|
||||||
|
}
|
||||||
|
return sanitizeLinuxPatch(out)
|
||||||
|
}
|
||||||
|
|
||||||
function updateGame(id, patch) {
|
function updateGame(id, patch) {
|
||||||
const games = loadGames()
|
const games = loadGames()
|
||||||
const g = games.find(x => x.id === id)
|
const g = games.find(x => x.id === id)
|
||||||
if (!g) return null
|
if (!g) return null
|
||||||
// prefix imports are pinned: their paths are stored relative to their
|
const clean = normalizeGamePatch(g, patch)
|
||||||
// prefix, so NOTHING may move them to another prefix or flip their
|
// playSeconds lives in its own file; the registry mirrors it
|
||||||
// import source (the patch fields are simply ignored)
|
if (clean.playSeconds !== undefined) writePlaySeconds(id, clean.playSeconds)
|
||||||
if (g.importSource === 'prefix' && patch) {
|
Object.assign(g, clean)
|
||||||
patch = { ...patch }
|
|
||||||
delete patch.prefix
|
|
||||||
delete patch.importSource
|
|
||||||
}
|
|
||||||
// playSeconds goes to its own tiny file; the registry just mirrors it for
|
|
||||||
// anything reading apps.json directly (loadGames prefers the file anyway).
|
|
||||||
if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds)
|
|
||||||
Object.assign(g, patch)
|
|
||||||
saveGames(games)
|
saveGames(games)
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same patch semantics for many apps, one registry write (LAN sync settings).
|
||||||
|
function updateGames(patches) {
|
||||||
|
const games = loadGames()
|
||||||
|
let changed = 0
|
||||||
|
for (const g of games) {
|
||||||
|
const patch = patches instanceof Map ? patches.get(g.id) : patches && patches[g.id]
|
||||||
|
if (!patch) continue
|
||||||
|
const clean = normalizeGamePatch(g, patch)
|
||||||
|
if (clean.playSeconds !== undefined) writePlaySeconds(g.id, clean.playSeconds)
|
||||||
|
Object.assign(g, clean)
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
if (changed) saveGames(games)
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
function clearPlaytime(id) {
|
function clearPlaytime(id) {
|
||||||
return updateGame(id, { playSeconds: 0, lastPlayed: 0 })
|
return updateGame(id, { playSeconds: 0, lastPlayed: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// A portable app's folder is removed along with its registry entry - measured
|
// The app's own folder - what was imported, never the folder holding the exe:
|
||||||
// first (staging) so the renderer can show a bar while the tree is walked,
|
// the working directory may have been pointed elsewhere in the properties.
|
||||||
// instead of freezing on one rmSync of a multi-GB game.
|
function appFolder(game) {
|
||||||
|
if (!game) return ''
|
||||||
|
if (game.path) return String(game.path)
|
||||||
|
return game.exePath ? path.dirname(game.exePath) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a portable app's folder with measured progress.
|
||||||
async function removeGame(id, onProgress) {
|
async function removeGame(id, onProgress) {
|
||||||
const games = loadGames()
|
const games = loadGames()
|
||||||
const idx = games.findIndex(g => g.id === id)
|
const idx = games.findIndex(g => g.id === id)
|
||||||
if (idx === -1) return false
|
if (idx === -1) return false
|
||||||
const g = games[idx]
|
const g = games[idx]
|
||||||
if (g.imported && g.path && g.path.startsWith(DIRS.APPS)) {
|
const folder = appFolder(g)
|
||||||
|
if (g.imported && folder && (folder === DIRS.APPS || folder.startsWith(DIRS.APPS + path.sep))) {
|
||||||
const emit = (phase, detail) => {
|
const emit = (phase, detail) => {
|
||||||
if (onProgress) onProgress({ phase, label: g.name || id, ...detail })
|
if (onProgress) onProgress({ phase, label: g.name || id, ...detail })
|
||||||
}
|
}
|
||||||
const { treeSize, removeTree } = require('./fsutil')
|
const { treeSize, removeTree } = require('./fsutil')
|
||||||
emit('count', { files: 0, bytes: 0 })
|
emit('count', { files: 0, bytes: 0 })
|
||||||
const total = await treeSize(g.path)
|
const total = await treeSize(folder)
|
||||||
await removeTree(g.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
await removeTree(folder, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
||||||
}
|
}
|
||||||
// prefix imports live inside the prefix: deleting launcher files would
|
// icons and playtime belong to the entry, not to any prefix
|
||||||
// nuke the user's prefix (installed DLLs, saves of every other app), so
|
for (const owned of [path.join(DIRS.DATA, 'icons', id), path.join(DIRS.DATA, 'appdata', id)]) {
|
||||||
// only the registry entry is dropped
|
try {
|
||||||
|
await fs.promises.rm(owned, { recursive: true, force: true })
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
// prefix imports: drop only the registry entry, never prefix files
|
||||||
games.splice(idx, 1)
|
games.splice(idx, 1)
|
||||||
saveGames(games)
|
saveGames(games)
|
||||||
return true
|
return true
|
||||||
|
|
@ -195,9 +226,7 @@ function saveSettings(patch) {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// ID is the current timestamp (ms); while a game with that id or a folder
|
// Timestamp id, incremented while it collides.
|
||||||
// with that name exists, keep incrementing - so ids and import folder names
|
|
||||||
// can never collide.
|
|
||||||
function newGameId() {
|
function newGameId() {
|
||||||
let id = Date.now().toString()
|
let id = Date.now().toString()
|
||||||
const taken = () => getGame(id) || fs.existsSync(path.join(DIRS.APPS, id))
|
const taken = () => getGame(id) || fs.existsSync(path.join(DIRS.APPS, id))
|
||||||
|
|
@ -205,11 +234,12 @@ function newGameId() {
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
function newGame({ id, name, exePath, workdir, mode }) {
|
function newGame({ id, name, exePath, workdir, mode, kind }) {
|
||||||
return {
|
return {
|
||||||
id: id || newGameId(),
|
id: id || newGameId(),
|
||||||
name,
|
name,
|
||||||
mode,
|
mode,
|
||||||
|
kind: kind === 'linux' ? 'linux' : 'windows',
|
||||||
path: workdir,
|
path: workdir,
|
||||||
exePath,
|
exePath,
|
||||||
workdir,
|
workdir,
|
||||||
|
|
@ -217,9 +247,12 @@ function newGame({ id, name, exePath, workdir, mode }) {
|
||||||
prefix: '',
|
prefix: '',
|
||||||
args: '',
|
args: '',
|
||||||
dllOverrides: [],
|
dllOverrides: [],
|
||||||
// Proton-only switch: keep UMU (the default path) or fall back to the
|
// Proton-only: UMU by default, or the build's steam.exe launcher
|
||||||
// build's steam.exe launcher for this app (see runner.buildEnv)
|
|
||||||
disableUmu: false,
|
disableUmu: false,
|
||||||
|
// Linux-only: none, relaxed, isolated, or auto (relaxed where bwrap works)
|
||||||
|
isolation: 'auto',
|
||||||
|
// Linux-only: force XWayland, where a window can still carry its icon
|
||||||
|
forceX11: false,
|
||||||
env: {},
|
env: {},
|
||||||
iconSource: '',
|
iconSource: '',
|
||||||
playSeconds: 0,
|
playSeconds: 0,
|
||||||
|
|
@ -228,9 +261,7 @@ function newGame({ id, name, exePath, workdir, mode }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The registry array IS the library order, so a drag & drop reorder rewrites
|
// Reorder the registry array; unsent ids keep their order at the end.
|
||||||
// it whole. Ids the caller did not send keep their relative order at the end:
|
|
||||||
// a renderer that raced an import must never drop an entry.
|
|
||||||
function reorderGames(ids) {
|
function reorderGames(ids) {
|
||||||
const games = loadGames()
|
const games = loadGames()
|
||||||
const rest = new Map(games.map(g => [g.id, g]))
|
const rest = new Map(games.map(g => [g.id, g]))
|
||||||
|
|
@ -246,10 +277,8 @@ function reorderGames(ids) {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Follows a prefix rename through the app registry. Raw JSON on purpose:
|
// Follows a prefix rename through the registry. Raw JSON: the normal
|
||||||
// loadGames()/saveGames() would resolve and re-relativize every path against
|
// load/save would re-relativize paths against the renamed folder.
|
||||||
// a prefix folder that was just renamed. The stored paths of prefix imports
|
|
||||||
// are relative to the prefix root, so only the name reference changes.
|
|
||||||
function renamePrefixRefs(oldName, newName) {
|
function renamePrefixRefs(oldName, newName) {
|
||||||
const games = readJson(REGISTRY, [])
|
const games = readJson(REGISTRY, [])
|
||||||
let updated = 0
|
let updated = 0
|
||||||
|
|
@ -263,8 +292,4 @@ function renamePrefixRefs(oldName, newName) {
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePrefixDir(game) {
|
module.exports = { loadGames, saveGames, portableGame, appFolder, getGame, isLinuxGame, addGame, updateGame, updateGames, removeGame, reorderGames, renamePrefixRefs, prefixNameForGame, gamesUsingPrefix, appsUsingPrefix, isWineInUse, gamesFollowingDefault, loadSettings, saveSettings, newGame, newGameId, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile }
|
||||||
return prefixPath(prefixNameForGame(game))
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { loadGames, saveGames, portableGame, getGame, addGame, updateGame, removeGame, reorderGames, renamePrefixRefs, prefixNameForGame, gamesUsingPrefix, appsUsingPrefix, isPrefixInUse, isWineInUse, gamesFollowingDefault, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile }
|
|
||||||
|
|
|
||||||
260
src/linuxapp.js
Normal file
260
src/linuxapp.js
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
// What makes a file a runnable native Linux app: an ELF binary or an AppImage
|
||||||
|
// (both with the executable bit), or a .desktop entry pointing at one.
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const fsp = fs.promises
|
||||||
|
const os = require('os')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const ELF_MAGIC = Buffer.from([0x7f, 0x45, 0x4c, 0x46])
|
||||||
|
|
||||||
|
// Folders that ship libraries/data, never the entry point. Skipping them is
|
||||||
|
// what keeps `share/` and `lib/` .desktop files out of the candidates.
|
||||||
|
const SKIPPED_DIRS = new Set([
|
||||||
|
'lib', 'lib32', 'lib64', 'libexec', 'share', 'include', 'man', 'doc', 'docs',
|
||||||
|
'locale', 'node_modules', 'redist', 'python', 'site-packages', '__pycache__',
|
||||||
|
'translations', 'resources', 'licenses', 'locale'
|
||||||
|
])
|
||||||
|
|
||||||
|
const LIBRARY_RE = /\.(so(\.\d+(\.\d+)*)?|a|o|py|pyc|jar|dll|dylib)$/i
|
||||||
|
const SCRIPT_RE = /^(run|start|launch|play|app)\.sh$/i
|
||||||
|
|
||||||
|
function isElfBuffer(buf) {
|
||||||
|
return !!buf && buf.length >= 4 && buf.subarray(0, 4).equals(ELF_MAGIC)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isElfSync(file) {
|
||||||
|
try {
|
||||||
|
const fd = fs.openSync(file, 'r')
|
||||||
|
try {
|
||||||
|
const buf = Buffer.alloc(4)
|
||||||
|
fs.readSync(fd, buf, 0, 4, 0)
|
||||||
|
return isElfBuffer(buf)
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Desktop Entry] fields we care about; the first group wins, later groups
|
||||||
|
// (Desktop Action ...) are ignored.
|
||||||
|
function parseDesktopEntry(text) {
|
||||||
|
const entry = {}
|
||||||
|
let inEntry = false
|
||||||
|
for (const raw of String(text || '').split('\n')) {
|
||||||
|
const line = raw.trim()
|
||||||
|
if (line.startsWith('[')) {
|
||||||
|
inEntry = line === '[Desktop Entry]'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!inEntry || !line || line.startsWith('#')) continue
|
||||||
|
const eq = line.indexOf('=')
|
||||||
|
if (eq <= 0) continue
|
||||||
|
const key = line.slice(0, eq).trim()
|
||||||
|
const value = line.slice(eq + 1).trim()
|
||||||
|
if (key === 'Exec' || key === 'Icon' || key === 'Path' || key === 'Name' || key === 'Type') {
|
||||||
|
if (!(key in entry)) entry[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDesktopEntry(file) {
|
||||||
|
try {
|
||||||
|
return parseDesktopEntry(fs.readFileSync(file, 'utf8'))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exec is a command line with field codes: the first token is the program.
|
||||||
|
function execToken(exec) {
|
||||||
|
const m = String(exec || '').match(/"([^"]*)"|'([^']*)'|(\S+)/)
|
||||||
|
const token = m ? (m[1] ?? m[2] ?? m[3]) : ''
|
||||||
|
return token && !/^%[fFuUdDnNickvm]?$/.test(token) ? token : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// A .desktop's program, resolved against the entry's own folder. Relative
|
||||||
|
// Exec= paths are common in app dirs; the spec says that is not allowed, so
|
||||||
|
// the fallback is to the folder itself.
|
||||||
|
function desktopExecPath(entry, desktopFile) {
|
||||||
|
const token = execToken(entry && entry.Exec)
|
||||||
|
if (!token) return ''
|
||||||
|
const dir = path.dirname(desktopFile)
|
||||||
|
const resolved = path.isAbsolute(token) ? token : path.resolve(dir, token)
|
||||||
|
try {
|
||||||
|
fs.accessSync(resolved)
|
||||||
|
return resolved
|
||||||
|
} catch {
|
||||||
|
const base = path.join(dir, path.basename(token))
|
||||||
|
try {
|
||||||
|
fs.accessSync(base)
|
||||||
|
return base
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExecutable(file) {
|
||||||
|
try {
|
||||||
|
return (fs.statSync(file).mode & 0o111) !== 0
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sizeOf(file) {
|
||||||
|
try {
|
||||||
|
return (await fsp.stat(file)).size
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best entry point in an app folder: the same question findDefaultExe answers
|
||||||
|
// for Windows, with ELF/AppImage/.desktop instead of .exe.
|
||||||
|
async function findLinuxExe(dir) {
|
||||||
|
const candidates = []
|
||||||
|
const desktops = []
|
||||||
|
const walk = async (d, depth) => {
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = await fsp.readdir(d, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const subdirs = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(d, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (!SKIPPED_DIRS.has(entry.name.toLowerCase())) subdirs.push([full, depth + 1])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const lower = entry.name.toLowerCase()
|
||||||
|
if (lower.endsWith('.desktop')) {
|
||||||
|
desktops.push([full, depth])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (lower.endsWith('.appimage')) {
|
||||||
|
candidates.push({ file: full, depth, score: 100 })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (LIBRARY_RE.test(entry.name)) continue
|
||||||
|
if (SCRIPT_RE.test(entry.name)) {
|
||||||
|
candidates.push({ file: full, depth, score: 60 })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (isElfSync(full)) candidates.push({ file: full, depth, score: isExecutable(full) ? 55 : 40 })
|
||||||
|
}
|
||||||
|
await Promise.all(subdirs.map(([full, next]) => walk(full, next)))
|
||||||
|
}
|
||||||
|
await walk(dir, 0)
|
||||||
|
|
||||||
|
// A .desktop is the intended way in when it names a program in the tree.
|
||||||
|
for (const [full, depth] of desktops) {
|
||||||
|
const exec = desktopExecPath(readDesktopEntry(full), full)
|
||||||
|
if (exec) candidates.push({ file: exec, depth, score: 90 })
|
||||||
|
}
|
||||||
|
if (!candidates.length) return ''
|
||||||
|
|
||||||
|
let best = ''
|
||||||
|
let bestScore = -Infinity
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const size = await sizeOf(candidate.file)
|
||||||
|
// shallow wins, and between equals the bigger binary is the app
|
||||||
|
const score = candidate.score - candidate.depth * 5 + Math.min(size / 1000000, 20)
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
best = candidate.file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// The .desktop that belongs to this program: one in the same folder, whose
|
||||||
|
// Exec points at it (or the only one there).
|
||||||
|
function desktopFileFor(exePath) {
|
||||||
|
const dir = path.dirname(exePath)
|
||||||
|
let names = []
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(dir).filter(n => n.toLowerCase().endsWith('.desktop'))
|
||||||
|
} catch {}
|
||||||
|
if (!names.length) {
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(path.join(dir, '..')).filter(n => n.toLowerCase().endsWith('.desktop'))
|
||||||
|
} catch {}
|
||||||
|
if (names.length) return path.join(dir, '..', names[0])
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
for (const name of names) {
|
||||||
|
const file = path.join(dir, name)
|
||||||
|
if (desktopExecPath(readDesktopEntry(file), file) === exePath) return file
|
||||||
|
}
|
||||||
|
return path.join(dir, names[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
function pngOrSvg(file) {
|
||||||
|
try {
|
||||||
|
const fd = fs.openSync(file, 'r')
|
||||||
|
try {
|
||||||
|
const buf = Buffer.alloc(64)
|
||||||
|
const read = fs.readSync(fd, buf, 0, 64, 0)
|
||||||
|
const head = buf.subarray(0, read)
|
||||||
|
if (head.length >= 8 && head.readUInt32BE(0) === 0x89504e47) return 'png'
|
||||||
|
const text = head.toString('utf8').trim().toLowerCase()
|
||||||
|
if (text.startsWith('<svg') || text.startsWith('<?xml')) return 'svg'
|
||||||
|
if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8) return 'jpg'
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEME_SIZES = ['512x512', '256x256', '128x128', 'scalable', '64x64', '48x48', '32x32']
|
||||||
|
|
||||||
|
// An `Icon=` value: an absolute path, a path relative to the .desktop's
|
||||||
|
// folder (AppImage app dirs put .DirIcon next to it), or a theme icon name.
|
||||||
|
function findIconFile(spec, baseDir) {
|
||||||
|
const candidates = []
|
||||||
|
const name = String(spec || '').trim() || '.DirIcon'
|
||||||
|
if (path.isAbsolute(name)) {
|
||||||
|
candidates.push(name)
|
||||||
|
} else {
|
||||||
|
if (baseDir) candidates.push(path.join(baseDir, name))
|
||||||
|
if (!path.extname(name)) {
|
||||||
|
const dataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share')
|
||||||
|
for (const dir of [path.join(dataHome, 'icons'), '/usr/share/icons']) {
|
||||||
|
for (const size of THEME_SIZES) {
|
||||||
|
for (const ext of ['png', 'svg']) candidates.push(path.join(dir, 'hicolor', size, 'apps', `${name}.${ext}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const dir of [dataHome, '/usr/share', '/usr/local/share']) {
|
||||||
|
candidates.push(path.join(dir, 'pixmaps', name + '.png'), path.join(dir, 'pixmaps', name + '.svg'))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const dir of ['/usr/share/pixmaps', process.env.XDG_DATA_HOME ? path.join(process.env.XDG_DATA_HOME, 'pixmaps') : '']) {
|
||||||
|
if (dir) candidates.push(path.join(dir, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate) continue
|
||||||
|
const kind = pngOrSvg(candidate)
|
||||||
|
if (kind) return candidate
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
findLinuxExe,
|
||||||
|
readDesktopEntry,
|
||||||
|
desktopFileFor,
|
||||||
|
findIconFile,
|
||||||
|
pngOrSvg
|
||||||
|
}
|
||||||
641
src/linuxrunner.js
Normal file
641
src/linuxrunner.js
Normal file
|
|
@ -0,0 +1,641 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
// Native Linux apps: the twin of runner.js. Same tracked spawn tail, but the
|
||||||
|
// program runs directly (no wine) with a redirected home and, when bubblewrap
|
||||||
|
// works, inside a namespace.
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const fs = require('fs')
|
||||||
|
const os = require('os')
|
||||||
|
const path = require('path')
|
||||||
|
const { DIRS, readJson, writeJson } = require('./paths')
|
||||||
|
const { prefixPath, prefixLinuxRoot } = require('./prefixes')
|
||||||
|
const { prefixNameForGame, loadSettings, appFolder } = require('./library')
|
||||||
|
const i18n = require('./i18n')
|
||||||
|
const { spawnTracked, parseArgs } = require('./runner')
|
||||||
|
const { buildBwrapArgs, SHARE_ROOT, FS_ROOT } = require('./sandbox')
|
||||||
|
const { PLWL_DIR, requestPath } = require('./openrelay')
|
||||||
|
const { copyFileProgress } = require('./fsutil')
|
||||||
|
|
||||||
|
const DATA_DIRS = ['home', 'config', 'data', 'cache', 'state']
|
||||||
|
|
||||||
|
// host folder <-> in-sandbox XDG variable, so both backends set the same env
|
||||||
|
const XDG_LAYOUT = {
|
||||||
|
XDG_CONFIG_HOME: ['config', '.config'],
|
||||||
|
XDG_DATA_HOME: ['data', path.join('.local', 'share')],
|
||||||
|
XDG_CACHE_HOME: ['cache', '.cache'],
|
||||||
|
XDG_STATE_HOME: ['state', path.join('.local', 'state')]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Every folder the app is addressed by - the tree it was imported as, the one
|
||||||
|
// it runs in, and the one its binary sits in (a bundle may keep that in bin/,
|
||||||
|
// or start an app from a folder of its own) - each as stated and as it really
|
||||||
|
// is, since an app imported from the home may sit behind a link of its own.
|
||||||
|
function appShapes(game) {
|
||||||
|
const dirs = [game && game.path, game && game.workdir, game && game.exePath && path.dirname(game.exePath)]
|
||||||
|
const shapes = new Set()
|
||||||
|
for (const dir of dirs) {
|
||||||
|
if (!dir) continue
|
||||||
|
shapes.add(path.resolve(dir))
|
||||||
|
try {
|
||||||
|
shapes.add(fs.realpathSync(dir))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return [...shapes]
|
||||||
|
}
|
||||||
|
|
||||||
|
function writableAppDir(game) {
|
||||||
|
const dir = appFolder(game)
|
||||||
|
// realpathSync('') would answer with the cwd - never hand that out
|
||||||
|
if (!dir) return ''
|
||||||
|
let real
|
||||||
|
try {
|
||||||
|
real = fs.realpathSync(dir)
|
||||||
|
if (!fs.statSync(real).isDirectory()) return ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
// launcher-owned folders: only an imported app may be written to
|
||||||
|
if (real.startsWith(DIRS.FILES + path.sep)) {
|
||||||
|
return real.startsWith(DIRS.APPS + path.sep) ? real : ''
|
||||||
|
}
|
||||||
|
if (real === '/') return ''
|
||||||
|
for (const system of SYSTEM_DIRS) {
|
||||||
|
if (real === system || real.startsWith(system + '/')) return ''
|
||||||
|
}
|
||||||
|
return real
|
||||||
|
}
|
||||||
|
|
||||||
|
function linuxUser() {
|
||||||
|
try {
|
||||||
|
return os.userInfo().username
|
||||||
|
} catch {
|
||||||
|
return process.env.USER || 'user'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The names the app owns in its home: the redirected XDG folders and the
|
||||||
|
// launcher's own folder. The host home never gets to shadow them.
|
||||||
|
const OWNED_HOME_NAMES = new Set([PLWL_DIR, '.config', '.local', '.cache'])
|
||||||
|
|
||||||
|
// The host home's own entries - what the desktop can hand an app (a drop from
|
||||||
|
// the file manager, a file picked in a dialog). A link resolving nowhere is
|
||||||
|
// not a path anything can be given, so it is skipped too.
|
||||||
|
function hostHomeEntries() {
|
||||||
|
const home = os.homedir()
|
||||||
|
let names
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(home)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return names.filter(name => !OWNED_HOME_NAMES.has(name) && fs.existsSync(path.join(home, name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The launcher's own links are the ones pointing into the relaxed view.
|
||||||
|
function isFsRootLink(file) {
|
||||||
|
let target
|
||||||
|
try {
|
||||||
|
target = fs.readlinkSync(file)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return target.startsWith(FS_ROOT + path.sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHomeRedirects(root) {
|
||||||
|
const dir = path.join(root, 'home')
|
||||||
|
let names
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(dir)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const name of names) {
|
||||||
|
if (!isFsRootLink(path.join(dir, name))) continue
|
||||||
|
try {
|
||||||
|
fs.rmSync(path.join(dir, name), { force: true })
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relaxed is the one level where the real filesystem is reachable (through
|
||||||
|
// FS_ROOT) - and there the app's home is mounted over the real one, so a path
|
||||||
|
// the desktop hands over names a folder the app cannot see. A link into that
|
||||||
|
// view gives exactly those names back, at their real paths; the app's own
|
||||||
|
// entries are never touched. No other level has a view to point at, so the
|
||||||
|
// links come off again there - they would only dangle.
|
||||||
|
// One name is never linked: the one the app's own folder sits under. A portable
|
||||||
|
// bundle resolves itself with `readlink -f "$0"`, so a link there answers with
|
||||||
|
// FS_ROOT - a path under /tmp - and Prism Launcher reads that as "Your instance
|
||||||
|
// folder is in a temporary folder" on every start (measured in a real profile).
|
||||||
|
function syncHomeRedirects(root, { relaxed, appDirs = [] }) {
|
||||||
|
clearHomeRedirects(root)
|
||||||
|
if (!relaxed) return []
|
||||||
|
const home = os.homedir()
|
||||||
|
const made = []
|
||||||
|
for (const name of hostHomeEntries()) {
|
||||||
|
const real = path.join(home, name)
|
||||||
|
if (appDirs.some(dir => dir === real || dir.startsWith(real + path.sep))) continue
|
||||||
|
const file = path.join(root, 'home', name)
|
||||||
|
if (fs.existsSync(file)) continue
|
||||||
|
try {
|
||||||
|
fs.symlinkSync(path.join(FS_ROOT, real), file)
|
||||||
|
made.push(name)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return made
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyFromHost(src, dst, { refresh = false } = {}) {
|
||||||
|
try {
|
||||||
|
if (!refresh && fs.existsSync(dst)) return false
|
||||||
|
if (!fs.statSync(src).isFile()) return false
|
||||||
|
fs.mkdirSync(path.dirname(dst), { recursive: true })
|
||||||
|
fs.copyFileSync(src, dst)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With HOME redirected there is no pulse cookie, and everything through Pulse
|
||||||
|
// comes up silent; copied once, then the app owns it.
|
||||||
|
function seedPulseCookie(root) {
|
||||||
|
const dst = path.join(root, 'config', 'pulse', 'cookie')
|
||||||
|
const seeded = copyFromHost(path.join(os.homedir(), '.config', 'pulse', 'cookie'), dst)
|
||||||
|
if (seeded) {
|
||||||
|
try {
|
||||||
|
fs.chmodSync(dst, 0o600)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The host's desktop preferences, not its data: without them Qt/GTK fall back
|
||||||
|
// to their built-in light look (KDE's dark palette lives in kdeglobals).
|
||||||
|
const DESKTOP_SEEDS = [
|
||||||
|
'kdeglobals',
|
||||||
|
'kdedefaults/kdeglobals',
|
||||||
|
'gtk-3.0/settings.ini',
|
||||||
|
'gtk-4.0/settings.ini',
|
||||||
|
'qt5ct/qt5ct.conf',
|
||||||
|
'qt6ct/qt6ct.conf',
|
||||||
|
'Trolltech.conf',
|
||||||
|
'user-dirs.dirs'
|
||||||
|
]
|
||||||
|
|
||||||
|
// The host's desktop *data* (fonts, icons, themes) is not copied but handed
|
||||||
|
// over read-only: see buildBwrapArgs.
|
||||||
|
const DATA_SHARES = ['fonts', 'icons', 'themes', 'color-schemes']
|
||||||
|
|
||||||
|
// A clicked link is the host desktop's business: the association naming a
|
||||||
|
// browser lives in the host's own home, so the URI goes to the host's portal.
|
||||||
|
const PORTAL_BUS = 'org.freedesktop.portal.Desktop'
|
||||||
|
const PORTAL_METHOD = 'org.freedesktop.portal.OpenURI.OpenURI'
|
||||||
|
const OPEN_BINS = ['/usr/bin/xdg-open', '/bin/xdg-open', '/usr/local/bin/xdg-open']
|
||||||
|
// Launcher-owned files in the redirected home (never mounted into a sandbox),
|
||||||
|
// rewritten whenever they differ.
|
||||||
|
const SHIM_NAME = 'xdg-open'
|
||||||
|
const OPENER_NAME = 'open-url'
|
||||||
|
const OPENER_DESKTOP = 'plwl-url-opener.desktop'
|
||||||
|
const SHIM_REL = path.join('home', PLWL_DIR, SHIM_NAME)
|
||||||
|
const OPENER_REL = path.join('home', PLWL_DIR, OPENER_NAME)
|
||||||
|
const MIME_REL = path.join('config', 'mimeapps.list')
|
||||||
|
const DESKTOP_REL = path.join('data', 'applications', OPENER_DESKTOP)
|
||||||
|
// What an opened thing in an app resolves to. Nothing else about the app's
|
||||||
|
// file types is touched: the rest of the MIME lists stay the system's.
|
||||||
|
const OPEN_TYPES = ['x-scheme-handler/http', 'x-scheme-handler/https', 'text/html', 'x-scheme-handler/file', 'inode/directory']
|
||||||
|
|
||||||
|
function hostShareDir() {
|
||||||
|
return path.join(os.homedir(), '.local', 'share')
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataShares() {
|
||||||
|
return DATA_SHARES.map(name => path.join(hostShareDir(), name)).filter(dir => fs.existsSync(dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
function readText(file) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(file, 'utf8')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeIfChanged(file, text, mode) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||||
|
if (readText(file) !== text) fs.writeFileSync(file, text)
|
||||||
|
fs.chmodSync(file, mode)
|
||||||
|
return file
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one script both hand-offs run: xdg-open's stand-in inside a sandbox, and
|
||||||
|
// what the seeded desktop entry starts. A scheme goes to the host's portal
|
||||||
|
// (chooser included), a path to the request file the launcher watches - the
|
||||||
|
// portal's answer is unreadable (development.md), the call is all we can trust.
|
||||||
|
// Mounted over the app's own xdg-open as well: see bundleOpenMounts.
|
||||||
|
function handOffScript({ hostHome, fsRoot, request }) {
|
||||||
|
const cases = [
|
||||||
|
`file://"$HOME") arg="file://${hostHome}" ;;`,
|
||||||
|
`file://"$HOME"/*) arg="file://${hostHome}\${arg#file://"$HOME"}" ;;`,
|
||||||
|
...(fsRoot
|
||||||
|
? [
|
||||||
|
`file://${FS_ROOT}) arg="file:///" ;;`,
|
||||||
|
`file://${FS_ROOT}/*) arg="file:///\${arg#file://${FS_ROOT}/}" ;;`
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
`"$HOME") arg="${hostHome}" ;;`,
|
||||||
|
`"$HOME"/*) arg="${hostHome}\${arg#$HOME}" ;;`,
|
||||||
|
...(fsRoot ? [`${FS_ROOT}) arg="/" ;;`, `${FS_ROOT}/*) arg="/\${arg#${FS_ROOT}/}" ;;`] : [])
|
||||||
|
].join('\n ')
|
||||||
|
return `#!/bin/sh
|
||||||
|
# Written by PLWL: what an app opens belongs to the host desktop.
|
||||||
|
arg="$1"
|
||||||
|
|
||||||
|
# the bundle's own xdg-open answers to gio-launch-desktop too (its bin links the
|
||||||
|
# two names to one file) and there the argument is not something to open but a
|
||||||
|
# program to start, so that name goes straight back to the command line
|
||||||
|
if [ "\${0##*/}" = "gio-launch-desktop" ]; then
|
||||||
|
export GIO_LAUNCHED_DESKTOP_FILE_PID=$$
|
||||||
|
exec "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -n "$arg" ] || exit 1
|
||||||
|
|
||||||
|
# a flag is not something to open: Java probes for xdg-open by running it with
|
||||||
|
# one, and handing that over would open garbage on the host desktop
|
||||||
|
case "$arg" in
|
||||||
|
-*) exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# a link is the host portal's from here on; anything else falls back
|
||||||
|
open_portal() {
|
||||||
|
command -v gdbus >/dev/null 2>&1 || return 1
|
||||||
|
gdbus call --session --dest ${PORTAL_BUS} \\
|
||||||
|
--object-path /org/freedesktop/portal/desktop \\
|
||||||
|
--method ${PORTAL_METHOD} "" "$1" "{}" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# a local document is not the portal's business - it cannot see this path
|
||||||
|
case "$arg" in
|
||||||
|
file:*) ;;
|
||||||
|
*://*|mailto:*) open_portal "$arg" && exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$arg" in
|
||||||
|
${cases}
|
||||||
|
esac
|
||||||
|
|
||||||
|
printf '%s\\n' "$arg" > "${request}.part" && mv "${request}.part" "${request}" || exit 1
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stand-in for xdg-open inside the sandbox, and (same script) what the
|
||||||
|
// seeded desktop entry runs: a browser started in here would be the wrong one,
|
||||||
|
// with an empty profile growing inside the prefix.
|
||||||
|
function writeUrlShim(root, opts) {
|
||||||
|
return writeIfChanged(path.join(root, SHIM_REL), handOffScript(opts), 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeUrlOpener(root, opts) {
|
||||||
|
return writeIfChanged(path.join(root, OPENER_REL), handOffScript(opts), 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The entry's display name. A translation that is not loaded answers with its
|
||||||
|
// own key, and that would end up in a chooser dialog, so it is never used.
|
||||||
|
function linkOpenerName() {
|
||||||
|
const text = i18n.translate(loadSettings().language, 'sandbox.linkOpener')
|
||||||
|
return text === 'sandbox.linkOpener' ? 'PLWL' : text
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIO resolves an URI in-process (gtk_show_uri, `gio open`) and would pick a
|
||||||
|
// system browser in here: naming our opener the default for exactly these types
|
||||||
|
// changes only its answer. GIO's own portal path is not reachable (it wants a
|
||||||
|
// sandbox marker this launcher does not fake).
|
||||||
|
function writeMimeSeed(root, home) {
|
||||||
|
const desktop = [
|
||||||
|
'[Desktop Entry]',
|
||||||
|
'Type=Application',
|
||||||
|
`Name=${linkOpenerName()}`,
|
||||||
|
`Exec="${path.join(home, PLWL_DIR, OPENER_NAME)}" %u`,
|
||||||
|
`MimeType=${OPEN_TYPES.join(';')};`,
|
||||||
|
'NoDisplay=true',
|
||||||
|
'Terminal=false',
|
||||||
|
''
|
||||||
|
].join('\n')
|
||||||
|
writeIfChanged(path.join(root, DESKTOP_REL), desktop, 0o644)
|
||||||
|
const mime = ['[Default Applications]', ...OPEN_TYPES.map(type => `${type}=${OPENER_DESKTOP};`), ''].join('\n')
|
||||||
|
return writeIfChanged(path.join(root, MIME_REL), mime, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bundle ships an xdg-open wrapper in its own bin/ (sharun's) and that bin is
|
||||||
|
// first in the app's PATH, so the wrapper - not /usr/bin - is what an app's
|
||||||
|
// opens really reach. It dies before delegating whenever the caller closes the
|
||||||
|
// child's streams, which is what Minecraft does right after Runtime.exec
|
||||||
|
// (measured: SIGPIPE, and no hand-off at all), so the launcher's shim is
|
||||||
|
// mounted over the bundle's own path as well. Names the bundle links to that
|
||||||
|
// file (its gio-launch-desktop) are answered by the shim itself - bwrap will
|
||||||
|
// not mount over a symlink, so they cannot be given a script of their own.
|
||||||
|
function bundleOpenMounts({ appDir, shim }) {
|
||||||
|
if (!appDir || !shim) return []
|
||||||
|
const wrapper = path.join(appDir, 'bin', SHIM_NAME)
|
||||||
|
return fs.existsSync(wrapper) ? [{ source: shim, target: wrapper }] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything an opened thing needs: the hand-off script, the association naming
|
||||||
|
// it the default, and - in a sandbox - xdg-open replaced by the same script.
|
||||||
|
function urlRouting({ root, home, sandboxed, fsRoot = false, appDir = '' }) {
|
||||||
|
const target = OPEN_BINS.find(bin => fs.existsSync(bin)) || ''
|
||||||
|
const opts = { hostHome: path.join(root, 'home'), fsRoot, request: requestPath(home) }
|
||||||
|
if (!writeUrlOpener(root, opts)) return null
|
||||||
|
writeMimeSeed(root, home)
|
||||||
|
const shim = sandboxed && target ? writeUrlShim(root, opts) : ''
|
||||||
|
return { target, shim, mounts: bundleOpenMounts({ appDir, shim }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashOf(file) {
|
||||||
|
try {
|
||||||
|
return crypto.createHash('sha1').update(fs.readFileSync(file)).digest('hex')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hashes of what we seeded, kept in the prefix root: the app only ever sees
|
||||||
|
// home/config/data/cache/state.
|
||||||
|
const SEED_MARKER = '.seeded.json'
|
||||||
|
|
||||||
|
// Keeps a seeded preference following the host while the copy is provably ours;
|
||||||
|
// an app's own edit is never overwritten.
|
||||||
|
function syncDesktopConfig(root) {
|
||||||
|
const hostConfig = path.join(os.homedir(), '.config')
|
||||||
|
const markerFile = path.join(root, SEED_MARKER)
|
||||||
|
const marker = readJson(markerFile, {})
|
||||||
|
let changed = false
|
||||||
|
for (const rel of DESKTOP_SEEDS) {
|
||||||
|
const src = path.join(hostConfig, rel)
|
||||||
|
const dst = path.join(root, 'config', rel)
|
||||||
|
const hostHash = hashOf(src)
|
||||||
|
if (!hostHash) continue
|
||||||
|
if (fs.existsSync(dst)) {
|
||||||
|
// only a marked copy is provably ours, and only while it is untouched
|
||||||
|
const current = hashOf(dst)
|
||||||
|
if (current !== marker[rel] || current === hostHash) continue
|
||||||
|
}
|
||||||
|
if (copyFromHost(src, dst, { refresh: true })) {
|
||||||
|
marker[rel] = hostHash
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
try {
|
||||||
|
writeJson(markerFile, marker)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// X11 access: a cookie is session state, not data, so XAUTHORITY is refreshed
|
||||||
|
// rather than seeded once.
|
||||||
|
function seedXauthority(root) {
|
||||||
|
copyFromHost(path.join(os.homedir(), '.Xauthority'), path.join(root, 'home', '.Xauthority'), { refresh: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything this module copies in from the host, prefix-relative: the one
|
||||||
|
// list a reset has to clear.
|
||||||
|
function seededRelPaths() {
|
||||||
|
return [
|
||||||
|
...DESKTOP_SEEDS.map(rel => path.join('config', rel)),
|
||||||
|
path.join('config', 'pulse', 'cookie'),
|
||||||
|
path.join('home', '.Xauthority'),
|
||||||
|
SEED_MARKER
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drops what we seeded (and the hashes tracking it) so the next start seeds
|
||||||
|
// clean ones; the app's own files in the same folders are never touched.
|
||||||
|
function resetSeededConfig(name) {
|
||||||
|
const root = prefixLinuxRoot(name)
|
||||||
|
const removed = []
|
||||||
|
for (const rel of seededRelPaths()) {
|
||||||
|
const file = path.join(root, rel)
|
||||||
|
try {
|
||||||
|
// lstat: a link pointing out of the prefix is none of our business
|
||||||
|
if (!fs.lstatSync(file).isFile()) continue
|
||||||
|
fs.rmSync(file, { force: true })
|
||||||
|
removed.push(rel)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
// folders that exist only for a seed, and only while nothing else lives there
|
||||||
|
const dirs = ['kdedefaults', 'gtk-3.0', 'gtk-4.0', 'qt5ct', 'qt6ct', 'pulse']
|
||||||
|
for (const dir of dirs) {
|
||||||
|
try {
|
||||||
|
fs.rmdirSync(path.join(root, 'config', dir))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return { ok: true, removed }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLinuxPfx(name) {
|
||||||
|
const root = prefixLinuxRoot(name)
|
||||||
|
fs.mkdirSync(path.join(root, 'home', '.local', 'share'), { recursive: true })
|
||||||
|
for (const dir of DATA_DIRS) fs.mkdirSync(path.join(root, dir), { recursive: true })
|
||||||
|
seedPulseCookie(root)
|
||||||
|
seedXauthority(root)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
// XWayland is the only place a window can carry its own icon, and GLFW/Qt/GTK
|
||||||
|
// all read these, so one switch moves the whole process tree back to X11.
|
||||||
|
const X11_ENV = { XDG_SESSION_TYPE: 'x11', QT_QPA_PLATFORM: 'xcb', GDK_BACKEND: 'x11' }
|
||||||
|
|
||||||
|
// The theme data sits in front of the inherited list: on the host the user's
|
||||||
|
// own icons and fonts also win over the system ones.
|
||||||
|
function withHostShare(env, entries) {
|
||||||
|
const own = entries.filter(Boolean)
|
||||||
|
if (!own.length) return
|
||||||
|
env.XDG_DATA_DIRS = [...own, env.XDG_DATA_DIRS].filter(Boolean).join(':')
|
||||||
|
}
|
||||||
|
|
||||||
|
// HOME and the XDG folders point at the prefix (plain run) or at the bind
|
||||||
|
// targets inside the sandbox - the two must agree or the app writes outside.
|
||||||
|
function buildLinuxEnv({ game, backend = 'env', fsRoot = false }) {
|
||||||
|
const prefixName = prefixNameForGame(game)
|
||||||
|
const root = ensureLinuxPfx(prefixName)
|
||||||
|
syncDesktopConfig(root)
|
||||||
|
const homeRedirects = syncHomeRedirects(root, { relaxed: backend === 'bwrap' && fsRoot, appDirs: appShapes(game) })
|
||||||
|
const sandboxed = backend === 'bwrap'
|
||||||
|
const home = sandboxed ? `/home/${linuxUser()}` : path.join(root, 'home')
|
||||||
|
const env = { ...process.env }
|
||||||
|
env.HOME = home
|
||||||
|
for (const [key, [hostDir, sandboxSub]] of Object.entries(XDG_LAYOUT)) {
|
||||||
|
env[key] = sandboxed ? path.join(home, sandboxSub) : path.join(root, hostDir)
|
||||||
|
}
|
||||||
|
// The X session manager sits in the private /tmp, so its pointer would lead
|
||||||
|
// to a dead socket. XDG_RUNTIME_DIR stays the host's (it weakens isolation).
|
||||||
|
if (sandboxed) delete env.SESSION_MANAGER
|
||||||
|
// the host's fonts/icons/themes: mounted read-only by buildBwrapArgs, or - in
|
||||||
|
// a plain run, where nothing is hidden - just put on the search path
|
||||||
|
if (dataShares().length) withHostShare(env, [sandboxed ? SHARE_ROOT : hostShareDir()])
|
||||||
|
if (game.forceX11) Object.assign(env, X11_ENV)
|
||||||
|
// the app's own environment comes last: it overrides everything above
|
||||||
|
for (const [k, v] of Object.entries(game.env || {})) env[k] = v
|
||||||
|
// an opened link or document leaves for the host desktop (see openrelay.js)
|
||||||
|
return { env, root, home, prefixName, prefixDir: prefixPath(prefixName), homeRedirects, routing: urlRouting({ root, home, sandboxed, fsRoot, appDir: writableAppDir(game) }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where a handed document goes inside the app's own home - a live bind, so a
|
||||||
|
// copy written here reaches an app that is already running too.
|
||||||
|
const HANDOFF_DIR = 'plwl-handoff'
|
||||||
|
// Copies above this are worth a popup: below it the copy is over before a
|
||||||
|
// window could paint, and the launcher would flash for nothing.
|
||||||
|
const STAGE_NOTICE_BYTES = 50 * 1000 * 1000
|
||||||
|
|
||||||
|
// Awaited because a shortcut boots HEADLESS: without a measurement "auto" would
|
||||||
|
// quietly pick the weaker level.
|
||||||
|
async function levelStatus(sandbox) {
|
||||||
|
if (!sandbox || typeof sandbox.status !== 'function') return null
|
||||||
|
const cached = sandbox.status()
|
||||||
|
if (cached) return cached
|
||||||
|
if (typeof sandbox.probe !== 'function') return null
|
||||||
|
try {
|
||||||
|
return await sandbox.probe()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `auto` is the only level that may weaken itself: a machine without bwrap runs
|
||||||
|
// the app plain, while an explicit relaxed/isolated refuses instead.
|
||||||
|
async function resolveLevel({ game, sandbox }) {
|
||||||
|
const policy = (game && game.isolation) || 'auto'
|
||||||
|
const status = await levelStatus(sandbox)
|
||||||
|
const available = !!status && status.backend === 'bwrap'
|
||||||
|
if (policy === 'none') return { ok: true, level: 'none', backend: 'env', status }
|
||||||
|
if (policy === 'auto') {
|
||||||
|
return { ok: true, level: available ? 'relaxed' : 'none', backend: available ? 'bwrap' : 'env', status }
|
||||||
|
}
|
||||||
|
if (!available) return { ok: false, error: 'sandbox-required', level: policy, backend: 'env', status }
|
||||||
|
return { ok: true, level: policy, backend: 'bwrap', status }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The line the app log and the UI both quote: a silent downgrade is the one
|
||||||
|
// failure this whole setting exists to avoid.
|
||||||
|
function isolationLine({ level, status }) {
|
||||||
|
const version = (status && status.version) || ''
|
||||||
|
if (level === 'none') {
|
||||||
|
const reason = (status && status.reason) || ''
|
||||||
|
return reason ? `[plwl] isolation: none (no sandbox: ${reason})` : '[plwl] isolation: none (plain run)'
|
||||||
|
}
|
||||||
|
const bwrap = version ? `bwrap ${version}` : 'bwrap'
|
||||||
|
if (level === 'relaxed') return `[plwl] isolation: relaxed (${bwrap}, host filesystem at ${FS_ROOT})`
|
||||||
|
return `[plwl] isolation: isolated (${bwrap})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Open with": a native app takes the file where it is (the sandbox sees /).
|
||||||
|
function linuxPathFor(file) {
|
||||||
|
let abs = String(file || '')
|
||||||
|
try {
|
||||||
|
abs = fs.realpathSync(abs)
|
||||||
|
} catch {}
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
|
||||||
|
// The document a shortcut was handed, in the shape the chosen level can reach:
|
||||||
|
// none = the host path, relaxed = through FS_ROOT, isolated = a copy in the
|
||||||
|
// app's own home (refreshed on every open). '' when there is nothing to hand over.
|
||||||
|
async function handedPathFor({ openFile, level, root, home, onProgress }) {
|
||||||
|
if (!openFile) return ''
|
||||||
|
const abs = linuxPathFor(openFile)
|
||||||
|
if (level === 'relaxed') return path.join(FS_ROOT, abs)
|
||||||
|
if (level !== 'isolated') return abs
|
||||||
|
|
||||||
|
const name = path.basename(abs)
|
||||||
|
if (!name || name === '.' || name === '..') return ''
|
||||||
|
const stat = fs.statSync(abs)
|
||||||
|
if (!stat.isFile()) return ''
|
||||||
|
const total = stat.size
|
||||||
|
const notice = total >= STAGE_NOTICE_BYTES
|
||||||
|
const emit = (phase, bytes, error) => {
|
||||||
|
if (onProgress) onProgress({ phase, name, bytes: bytes || 0, totalBytes: total, notice, error: error || '' })
|
||||||
|
}
|
||||||
|
emit('copy', 0)
|
||||||
|
try {
|
||||||
|
await copyFileProgress(abs, path.join(root, 'home', HANDOFF_DIR, name), p => emit('copy', p.bytes))
|
||||||
|
} catch (err) {
|
||||||
|
emit('error', 0, String((err && err.message) || err))
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
emit('done', total)
|
||||||
|
return path.join(home, HANDOFF_DIR, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bwrapSource() {
|
||||||
|
const devices = ['/dev/shm', '/dev/dri', '/dev/snd', '/dev/input'].filter(d => fs.existsSync(d))
|
||||||
|
const x11 = fs.existsSync('/tmp/.X11-unix') ? '/tmp/.X11-unix' : ''
|
||||||
|
const runUser = `/run/user/${typeof process.getuid === 'function' ? process.getuid() : 0}`
|
||||||
|
return {
|
||||||
|
devices,
|
||||||
|
x11,
|
||||||
|
runUser: fs.existsSync(runUser) ? runUser : '',
|
||||||
|
shares: dataShares(),
|
||||||
|
user: linuxUser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false, onProgress }) {
|
||||||
|
const chosen = await resolveLevel({ game, sandbox })
|
||||||
|
if (!chosen.ok) throw new Error(chosen.error)
|
||||||
|
|
||||||
|
const exe = game.exePath
|
||||||
|
const workdir = game.workdir || path.dirname(exe)
|
||||||
|
const { env, root, home, homeRedirects, routing } = buildLinuxEnv({ game, backend: chosen.backend, fsRoot: chosen.level === 'relaxed' })
|
||||||
|
const argList = parseArgs(game.args)
|
||||||
|
const handed = await handedPathFor({ openFile, level: chosen.level, root, home, onProgress })
|
||||||
|
if (handed) argList.push(handed)
|
||||||
|
|
||||||
|
const header = [isolationLine(chosen)]
|
||||||
|
if (homeRedirects.length) header.push(`[plwl] home redirects: ${homeRedirects.length} entries through ${FS_ROOT}`)
|
||||||
|
if (game.forceX11) header.push('[plwl] x11 forced (XWayland)')
|
||||||
|
|
||||||
|
let cmd = exe
|
||||||
|
let args = argList
|
||||||
|
if (chosen.backend === 'bwrap') {
|
||||||
|
const source = bwrapSource()
|
||||||
|
cmd = 'bwrap'
|
||||||
|
args = [...buildBwrapArgs({ dataRoot: root, user: source.user, devices: source.devices, x11: source.x11, runUser: source.runUser, shares: source.shares, appDir: writableAppDir(game), fsRoot: chosen.level === 'relaxed', urlOpener: routing }), '--', exe, ...argList]
|
||||||
|
header.push(`[plwl] HOME=${env.HOME}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return spawnTracked({ game, cmd, args, cwd: workdir, env, handoff, header, onExit })
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DESKTOP_SEEDS,
|
||||||
|
hostHomeEntries,
|
||||||
|
syncHomeRedirects,
|
||||||
|
HANDOFF_DIR,
|
||||||
|
STAGE_NOTICE_BYTES,
|
||||||
|
SHIM_REL,
|
||||||
|
OPENER_REL,
|
||||||
|
MIME_REL,
|
||||||
|
DESKTOP_REL,
|
||||||
|
writeUrlShim,
|
||||||
|
writeUrlOpener,
|
||||||
|
writeMimeSeed,
|
||||||
|
urlRouting,
|
||||||
|
writableAppDir,
|
||||||
|
ensureLinuxPfx,
|
||||||
|
resetSeededConfig,
|
||||||
|
dataShares,
|
||||||
|
buildLinuxEnv,
|
||||||
|
resolveLevel,
|
||||||
|
isolationLine,
|
||||||
|
handedPathFor,
|
||||||
|
runLinuxGame,
|
||||||
|
appShapes
|
||||||
|
}
|
||||||
72
src/main.js
72
src/main.js
|
|
@ -9,6 +9,7 @@ const { createMainWindow } = require('./window')
|
||||||
const { setAppIcon } = require('./appicon')
|
const { setAppIcon } = require('./appicon')
|
||||||
const { parseArgv } = require('./argv')
|
const { parseArgv } = require('./argv')
|
||||||
const openwith = require('./openwith')
|
const openwith = require('./openwith')
|
||||||
|
const { createOpenRelay } = require('./openrelay')
|
||||||
const lansync = require('./lansync')
|
const lansync = require('./lansync')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
|
|
@ -32,9 +33,7 @@ let autoQuitWhenDone = false // headless boot: quit after that game exits
|
||||||
const getWindow = () => win
|
const getWindow = () => win
|
||||||
|
|
||||||
// --- single instance ---------------------------------------------------------
|
// --- single instance ---------------------------------------------------------
|
||||||
// Diagnostics: the shortcut handoff path is invisible otherwise (a failing
|
// Diagnostics trace for the otherwise invisible shortcut handoff.
|
||||||
// handoff just "does nothing" for the user). Everything lands in a small
|
|
||||||
// trace file next to the launcher data so problems are reproducible.
|
|
||||||
function trace(msg) {
|
function trace(msg) {
|
||||||
try {
|
try {
|
||||||
const file = path.join(DIRS.DATA, 'instance-trace.log')
|
const file = path.join(DIRS.DATA, 'instance-trace.log')
|
||||||
|
|
@ -63,15 +62,11 @@ if (!gotLock) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// set on every second-instance handoff: the auto-quit check skips itself for
|
// auto-quit skips itself briefly after a handoff (start/exit race guard)
|
||||||
// a moment afterwards, so "one game exits" racing "a shortcut just started
|
|
||||||
// another game" can never kill the launcher mid-handoff
|
|
||||||
let lastHandoffAt = 0
|
let lastHandoffAt = 0
|
||||||
|
|
||||||
// --- "Open with PLWL" (Exec %f from the file manager) --------------------------
|
// --- "Open with PLWL" (Exec %f from the file manager) --------------------------
|
||||||
// Hands the file to the window (renderer shows the wine/prefix picker popup);
|
// Hands the file to the window (picker popup); headless boots open the window.
|
||||||
// headless boots open the window for it - a file cannot be launched without
|
|
||||||
// the user choosing how.
|
|
||||||
async function openWith(filePath) {
|
async function openWith(filePath) {
|
||||||
trace(`openWith ${filePath} serviceReady=${!!gamesService}`)
|
trace(`openWith ${filePath} serviceReady=${!!gamesService}`)
|
||||||
showMainWindow()
|
showMainWindow()
|
||||||
|
|
@ -99,9 +94,7 @@ function createTray() {
|
||||||
try {
|
try {
|
||||||
tray = new Tray(icon)
|
tray = new Tray(icon)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// never fatal (the window keeps working), but say why the icon is missing
|
// say why the tray icon is missing instead of failing silently
|
||||||
// instead of failing silently - a missing tray looks like a hung app when
|
|
||||||
// the launcher runs in the background
|
|
||||||
console.error(`[tray] could not create the tray icon (${icon || 'no icon file found'}): ${(err && err.message) || err}`)
|
console.error(`[tray] could not create the tray icon (${icon || 'no icon file found'}): ${(err && err.message) || err}`)
|
||||||
tray = null
|
tray = null
|
||||||
return
|
return
|
||||||
|
|
@ -162,6 +155,15 @@ function loadLangSafe() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An isolated hand-off copies the document before the app starts. When that
|
||||||
|
// copy is big enough to be noticed (linuxrunner decides) the user gets a
|
||||||
|
// window with the progress in it - a shortcut boots headless, so without this
|
||||||
|
// a 2 GB document would look like nothing happening at all.
|
||||||
|
function onHandoffProgress(data) {
|
||||||
|
if (!data || !data.notice || data.phase !== 'copy') return
|
||||||
|
if (!win || win.isDestroyed() || !win.isVisible()) showMainWindow()
|
||||||
|
}
|
||||||
|
|
||||||
function showMainWindow() {
|
function showMainWindow() {
|
||||||
// the user is present now - headless auto-quit must never fire again
|
// the user is present now - headless auto-quit must never fire again
|
||||||
headless = false
|
headless = false
|
||||||
|
|
@ -177,8 +179,7 @@ function showMainWindow() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireWindow(w) {
|
function wireWindow(w) {
|
||||||
// closing the window hides it to the tray (playtime keeps counting in the
|
// closing hides to the tray (unless disabled in settings, then it quits)
|
||||||
// background) unless the user disabled that in settings - then it quits
|
|
||||||
w.on('close', e => {
|
w.on('close', e => {
|
||||||
if (quitting) return
|
if (quitting) return
|
||||||
let closeToTray = true
|
let closeToTray = true
|
||||||
|
|
@ -208,13 +209,10 @@ async function launchGame(id, openFile) {
|
||||||
trace(`launchGame ${id} result=${JSON.stringify(res).slice(0, 200)}`)
|
trace(`launchGame ${id} result=${JSON.stringify(res).slice(0, 200)}`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
rebuildTrayMenu()
|
rebuildTrayMenu()
|
||||||
// headless boot: when the auto-started game finishes and nothing else is
|
// headless boot: quit when the auto-started game finishes
|
||||||
// running, quit the launcher
|
|
||||||
if (autoQuitWhenDone) {
|
if (autoQuitWhenDone) {
|
||||||
gamesService.setOnGameExit(() => {
|
gamesService.setOnGameExit(() => {
|
||||||
// fires for EVERY game exit; quit only when the last one just ended,
|
// quit only when the last game ended and nothing new came in
|
||||||
// the user never opened the window and no shortcut handed a new game
|
|
||||||
// over in the last few seconds (start/exit race guard)
|
|
||||||
if (Date.now() - lastHandoffAt < 5000) return
|
if (Date.now() - lastHandoffAt < 5000) return
|
||||||
if (!gamesService.listRunning().length) {
|
if (!gamesService.listRunning().length) {
|
||||||
quitting = true
|
quitting = true
|
||||||
|
|
@ -238,27 +236,42 @@ app.whenReady().then(async () => {
|
||||||
ensureDirs()
|
ensureDirs()
|
||||||
await i18n.init()
|
await i18n.init()
|
||||||
await wines.refreshWines()
|
await wines.refreshWines()
|
||||||
const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu })
|
// a folder or document a sandboxed app asked for is opened here, by the
|
||||||
|
// host's own xdg-open (see openrelay.js)
|
||||||
|
const openRelay = createOpenRelay({ onError: (err, target) => trace(`openRelay ${target}: ${err.message}`) })
|
||||||
|
const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu, onHandoffProgress, openRelay })
|
||||||
gamesService = games
|
gamesService = games
|
||||||
|
|
||||||
// the tray comes first: nothing (least of all the network) may keep it from
|
// the tray comes first: nothing may keep it from coming up
|
||||||
// coming up - a launcher without a tray icon looks simply broken
|
|
||||||
createTray()
|
createTray()
|
||||||
|
|
||||||
// LAN sync runs whenever the launcher runs - that is what makes this
|
// LAN sync runs whenever the launcher runs, so this device is discoverable
|
||||||
// device show up on the other machines' client lists
|
|
||||||
const lanHooks = {
|
const lanHooks = {
|
||||||
onDevices: devices => sendToWindow(win, 'lansync:devices', devices),
|
onDevices: devices => sendToWindow(win, 'lansync:devices', devices),
|
||||||
onApplied: info => sendToWindow(win, 'lansync:applied', info),
|
onApplied: info => sendToWindow(win, 'lansync:applied', info),
|
||||||
onReceive: info => sendToWindow(win, 'lansync:receiving', info),
|
onReceive: info => sendToWindow(win, 'lansync:receiving', info),
|
||||||
// the consent popup for an incoming sync (see ipc/lansync.js)
|
// the consent popup for an incoming sync (see ipc/lansync.js)
|
||||||
onIncoming: info => sendToWindow(win, 'lansync:incoming', info),
|
onIncoming: info => sendToWindow(win, 'lansync:incoming', info),
|
||||||
isBusy: () => !!(gamesService && gamesService.listRunning().length)
|
// the deletion review: nothing is removed without this popup's answer. No
|
||||||
|
// window (headless boot, or it went away) means "keep everything".
|
||||||
|
onReview: info => {
|
||||||
|
if (!win || win.isDestroyed()) return false
|
||||||
|
if (!win.isVisible()) win.show()
|
||||||
|
win.focus()
|
||||||
|
sendToWindow(win, 'lansync:review', info)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
// a device mid-push cannot also show a receive review
|
||||||
|
isBusy: () => !!(gamesService && gamesService.listRunning().length) || lansync.isPushing()
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Promise.resolve(lansync.start(lanHooks)).catch(err => {
|
Promise.resolve(lansync.start(lanHooks))
|
||||||
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
|
.then(info => {
|
||||||
})
|
if (!info || !info.ok) console.error(`[lansync] server not started: ${(info && info.error) || 'unknown'}`)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
|
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
|
||||||
}
|
}
|
||||||
|
|
@ -268,8 +281,7 @@ app.whenReady().then(async () => {
|
||||||
} else {
|
} else {
|
||||||
win = createMainWindow()
|
win = createMainWindow()
|
||||||
wireWindow(win)
|
wireWindow(win)
|
||||||
// file open handed over while the service was still booting (or queued
|
// file open queued while the service was still booting
|
||||||
// by a second instance racing the first boot)
|
|
||||||
if (autoOpenPath || openwith.hasPending()) {
|
if (autoOpenPath || openwith.hasPending()) {
|
||||||
openWith(autoOpenPath || openwith.getPending())
|
openWith(autoOpenPath || openwith.getPending())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,8 @@ function cleanName(name) {
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
// A name that becomes a folder inside the launcher (a prefix): letters and
|
// A folder-safe name: letters/digits of any language plus safe punctuation;
|
||||||
// digits of any language (Polish names keep their diacritics) plus the
|
// "." and ".." are not names.
|
||||||
// punctuation that is safe in a path segment. "." and ".." are path segments,
|
|
||||||
// not names.
|
|
||||||
function normalizePathName(name) {
|
function normalizePathName(name) {
|
||||||
const out = cleanName(name).replace(/[^\p{L}\p{N} ._-]/gu, '')
|
const out = cleanName(name).replace(/[^\p{L}\p{N} ._-]/gu, '')
|
||||||
return /^\.+$/.test(out) ? '' : out
|
return /^\.+$/.test(out) ? '' : out
|
||||||
|
|
|
||||||
95
src/openrelay.js
Normal file
95
src/openrelay.js
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
// What a sandboxed app asked to open - a folder, a document, a path: nothing in
|
||||||
|
// there can start a host program (measured), so the hand-off script leaves the
|
||||||
|
// host-rewritten target in a request file inside the app's home (a live bind)
|
||||||
|
// and the launcher opens it here with the host's own xdg-open.
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const { spawn } = require('child_process')
|
||||||
|
|
||||||
|
// The hand-off protocol, in one place: the folder PLWL owns inside an app's home
|
||||||
|
// (the shim and opener are written there too) and the file the launcher watches.
|
||||||
|
const PLWL_DIR = '.plwl'
|
||||||
|
const REQUEST_NAME = 'open-request'
|
||||||
|
const DRAIN_DEBOUNCE_MS = 60
|
||||||
|
|
||||||
|
// `homeDir` is that home as the host sees it: a live bind, so a write is here
|
||||||
|
// the moment it lands.
|
||||||
|
const requestPath = homeDir => path.join(homeDir, PLWL_DIR, REQUEST_NAME)
|
||||||
|
const requestFile = root => requestPath(path.join(root, 'home'))
|
||||||
|
|
||||||
|
// detached: the opened program is the desktop's child, and outlives a launcher
|
||||||
|
// that quit to the tray
|
||||||
|
function openOnHost(target) {
|
||||||
|
spawn('xdg-open', [target], { detached: true, stdio: 'ignore' }).unref()
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOpenRelay({ open = openOnHost, onError = null } = {}) {
|
||||||
|
const watchers = new Map()
|
||||||
|
|
||||||
|
// consumed before it is opened: a second click overwrites the file, so nothing
|
||||||
|
// is opened twice and nothing is lost
|
||||||
|
function drain(root) {
|
||||||
|
let target = ''
|
||||||
|
try {
|
||||||
|
target = fs.readFileSync(requestFile(root), 'utf8').trim().split('\n')[0]
|
||||||
|
fs.rmSync(requestFile(root), { force: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!target) return
|
||||||
|
try {
|
||||||
|
open(target)
|
||||||
|
} catch (err) {
|
||||||
|
if (onError) onError(err, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two apps can share one prefix (the default), so the watcher is refcounted:
|
||||||
|
// the first exit must not take the folder away from the app still running.
|
||||||
|
// A request written before the watcher was up (the app's first window) must
|
||||||
|
// not be missed, so drain once first.
|
||||||
|
function watch(root) {
|
||||||
|
if (!root) return
|
||||||
|
const existing = watchers.get(root)
|
||||||
|
if (existing) {
|
||||||
|
existing.refs++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const file = requestFile(root)
|
||||||
|
// best effort: a missing folder only means nothing was ever asked for
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||||
|
} catch {}
|
||||||
|
let timer = null
|
||||||
|
const watcher = fs.watch(path.dirname(file), () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => drain(root), DRAIN_DEBOUNCE_MS)
|
||||||
|
})
|
||||||
|
watcher.on('error', () => close(root))
|
||||||
|
watchers.set(root, { watcher, refs: 1 })
|
||||||
|
drain(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(root) {
|
||||||
|
const entry = watchers.get(root)
|
||||||
|
if (!entry) return
|
||||||
|
try {
|
||||||
|
entry.watcher.close()
|
||||||
|
} catch {}
|
||||||
|
watchers.delete(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwatch(root) {
|
||||||
|
const entry = watchers.get(root)
|
||||||
|
if (!entry) return
|
||||||
|
entry.refs--
|
||||||
|
if (entry.refs <= 0) close(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { watch, unwatch, drain, requestFile }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createOpenRelay, PLWL_DIR, REQUEST_NAME, requestFile, requestPath }
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
// "Open with PLWL" (file manager -> Open With -> PLWL): a file handed in via
|
// "Open with PLWL": a file handed in via --open is parked here until the
|
||||||
// `--open <path>` (or the Exec %f plumbing) is parked here until the renderer
|
// renderer picks it up, then launched ad hoc. Nothing is imported.
|
||||||
// picks it up, then launched ad hoc with the wine version + prefix chosen in
|
|
||||||
// the picker popup. Nothing is imported into the library.
|
|
||||||
|
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
@ -26,26 +24,8 @@ function clearPending() {
|
||||||
pending = null
|
pending = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// The launcher's own translations may not cover openwith texts in older
|
|
||||||
// installs - fall back to English keys so the UI never shows raw key names.
|
|
||||||
function textsFor(code) {
|
function textsFor(code) {
|
||||||
let texts = {}
|
return require('./i18n').getLang(code).texts || {}
|
||||||
try {
|
|
||||||
texts = require('./i18n').getLang(code).texts || {}
|
|
||||||
} catch {}
|
|
||||||
const fallback = {
|
|
||||||
'openwith.title': 'Open with Wine',
|
|
||||||
'openwith.file': 'File',
|
|
||||||
'openwith.fileMissing': 'This file no longer exists.',
|
|
||||||
'openwith.wine': 'Wine / Proton version',
|
|
||||||
'openwith.wineDefault': 'Default ({0})',
|
|
||||||
'openwith.prefix': 'Wine prefix',
|
|
||||||
'openwith.prefixDefault': 'Default prefix ({0})',
|
|
||||||
'openwith.run': 'Run',
|
|
||||||
'openwith.startFailed': 'Could not start {0}: {1}',
|
|
||||||
'openwith.notWindows': 'This is not a Windows executable (exe/msi/bat/cmd/lnk).'
|
|
||||||
}
|
|
||||||
return { ...fallback, ...texts }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWine(id) {
|
function resolveWine(id) {
|
||||||
|
|
@ -58,8 +38,7 @@ function resolvePrefix(name) {
|
||||||
return known || defs.default || 'Main'
|
return known || defs.default || 'Main'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ad-hoc run target: shaped exactly like a library game (runner.buildEnv and
|
// Ad-hoc run target shaped like a library game; nothing persisted.
|
||||||
// the playtime/log paths expect those fields) but nothing is persisted.
|
|
||||||
function makeTarget(openPath, wineId, prefixName) {
|
function makeTarget(openPath, wineId, prefixName) {
|
||||||
const dir = path.dirname(openPath)
|
const dir = path.dirname(openPath)
|
||||||
return {
|
return {
|
||||||
|
|
@ -82,9 +61,7 @@ function makeTarget(openPath, wineId, prefixName) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Launch without importing: resolves the chosen wine/prefix, builds the ad
|
// Launch without importing, exactly like a library app.
|
||||||
// hoc target and runs it exactly like a library app (same env, same logging,
|
|
||||||
// same detached process group).
|
|
||||||
async function run({ openPath, wine: wineId, prefix: prefixName0, lang, onStarted }) {
|
async function run({ openPath, wine: wineId, prefix: prefixName0, lang, onStarted }) {
|
||||||
if (!openPath || !fs.existsSync(openPath)) return { ok: false, error: 'file-missing' }
|
if (!openPath || !fs.existsSync(openPath)) return { ok: false, error: 'file-missing' }
|
||||||
if (!/\.(exe|msi|bat|cmd|lnk)$/i.test(openPath)) return { ok: false, error: 'not-windows' }
|
if (!/\.(exe|msi|bat|cmd|lnk)$/i.test(openPath)) return { ok: false, error: 'not-windows' }
|
||||||
|
|
@ -92,9 +69,7 @@ async function run({ openPath, wine: wineId, prefix: prefixName0, lang, onStarte
|
||||||
const prefixName = resolvePrefix(prefixName0)
|
const prefixName = resolvePrefix(prefixName0)
|
||||||
const runner = require('./runner')
|
const runner = require('./runner')
|
||||||
try {
|
try {
|
||||||
// runAdHoc builds the synthetic target internally (shared `openwith` id,
|
// shared `openwith` id keeps logs/playtime away from library ids
|
||||||
// so logs/playtime never collide with a library app id); the resolved
|
|
||||||
// prefix is passed through so buildEnv uses the chosen one
|
|
||||||
const res = await runner.runAdHoc({ openPath, wine, systemWine: wine.type === 'system', prefix: prefixName })
|
const res = await runner.runAdHoc({ openPath, wine, systemWine: wine.type === 'system', prefix: prefixName })
|
||||||
if (onStarted) onStarted({ name: path.basename(openPath), pid: res.pid })
|
if (onStarted) onStarted({ name: path.basename(openPath), pid: res.pid })
|
||||||
return { ok: true, pid: res.pid || null, logFile: res.logFile || null }
|
return { ok: true, pid: res.pid || null, logFile: res.logFile || null }
|
||||||
|
|
|
||||||
40
src/paths.js
40
src/paths.js
|
|
@ -3,9 +3,7 @@
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
|
|
||||||
// Packaged builds run from <portable root>/olcxja.plwl/resources/app.asar:
|
// Packaged: resources/app.asar two levels below the portable root.
|
||||||
// the portable layout (<root>/files) is two levels above the resources dir
|
|
||||||
// and files bundled with the release are unpacked next to the asar.
|
|
||||||
function computeRoots() {
|
function computeRoots() {
|
||||||
const m = __dirname.match(/^(.*)[/\\]app\.asar([/\\]|$)/)
|
const m = __dirname.match(/^(.*)[/\\]app\.asar([/\\]|$)/)
|
||||||
if (m) {
|
if (m) {
|
||||||
|
|
@ -16,8 +14,7 @@ function computeRoots() {
|
||||||
APP_ROOT,
|
APP_ROOT,
|
||||||
// FILES = BUILD/files, so BUILD is the portable root itself.
|
// FILES = BUILD/files, so BUILD is the portable root itself.
|
||||||
BUILD: process.env.PLWL_BUILD_DIR || APP_ROOT,
|
BUILD: process.env.PLWL_BUILD_DIR || APP_ROOT,
|
||||||
// Bundled release files live inside the asar (Electron's patched fs
|
// bundled release files live inside the asar
|
||||||
// can read them directly from the archive).
|
|
||||||
BUNDLED: fs.existsSync(path.join(RESOURCES, 'app.asar', 'files', 'translations'))
|
BUNDLED: fs.existsSync(path.join(RESOURCES, 'app.asar', 'files', 'translations'))
|
||||||
? path.join(RESOURCES, 'app.asar', 'files')
|
? path.join(RESOURCES, 'app.asar', 'files')
|
||||||
: path.join(APP_ROOT, 'files')
|
: path.join(APP_ROOT, 'files')
|
||||||
|
|
@ -50,16 +47,11 @@ const DIRS = {
|
||||||
FAKE_STEAM: path.join(DATA, 'fake-steam')
|
FAKE_STEAM: path.join(DATA, 'fake-steam')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bundled translation files are launcher-owned: on every start they are
|
// Bundled translations are refreshed on start whenever their content changed,
|
||||||
// refreshed in the portable files/translations folder WHENEVER THEIR CONTENT
|
// so updates reach existing installs; user-added languages are untouched.
|
||||||
// CHANGED, so an update shipping new or changed keys reaches existing
|
|
||||||
// installs too (the old copy-only-if-missing logic froze translations at the
|
|
||||||
// version that happened to run first). Extra files in the target folder that
|
|
||||||
// the bundle does not ship (user-added languages) are left untouched.
|
|
||||||
function filesDiffer(a, b) {
|
function filesDiffer(a, b) {
|
||||||
try {
|
try {
|
||||||
// compare as text on purpose: Buffer.equals() throws on a string, and that
|
// compare as text: Buffer.equals() throws on a string
|
||||||
// throw was read as "differs", so every boot recopied every translation
|
|
||||||
return fs.readFileSync(a, 'utf8') !== fs.readFileSync(b, 'utf8')
|
return fs.readFileSync(a, 'utf8') !== fs.readFileSync(b, 'utf8')
|
||||||
} catch {
|
} catch {
|
||||||
return true // target missing/unreadable -> needs a (re)copy
|
return true // target missing/unreadable -> needs a (re)copy
|
||||||
|
|
@ -100,10 +92,8 @@ function ensureDirs() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Portable paths: anything stored under FILES/ goes into the registry as a
|
// Anything under FILES/ is stored FILES-relative; absolute paths outside are
|
||||||
// path RELATIVE to FILES (e.g. apps/<id>/game.exe), so moving the whole
|
// kept as-is. Resolve on read, normalize on write.
|
||||||
// launcher folder keeps the library working. Absolute paths outside FILES
|
|
||||||
// (games left in place) are stored as-is. Resolve on read, normalize on write.
|
|
||||||
function toPortablePath(p) {
|
function toPortablePath(p) {
|
||||||
if (typeof p !== 'string' || !p || !path.isAbsolute(p)) return p
|
if (typeof p !== 'string' || !p || !path.isAbsolute(p)) return p
|
||||||
const rel = path.relative(FILES, p)
|
const rel = path.relative(FILES, p)
|
||||||
|
|
@ -116,15 +106,8 @@ function resolvePortablePath(p) {
|
||||||
return path.join(FILES, p)
|
return path.join(FILES, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The launcher's own icon (renderer/plwl.png), used by the tray, the app
|
// The launcher's own icon (renderer/plwl.png) for tray, app icon, shortcut
|
||||||
// icon and as the .desktop shortcut fallback.
|
// fallback. The module-relative copy is the only candidate in every layout.
|
||||||
//
|
|
||||||
// The module-relative copy comes FIRST: it is the only candidate that exists
|
|
||||||
// in every layout, because a packaged build keeps renderer/ inside
|
|
||||||
// app.asar and Electron's patched fs reads straight out of the archive (a
|
|
||||||
// release only ships olcxja.plwl.png next to the portable root, so looking at
|
|
||||||
// APP_ROOT alone made the tray icon vanish in installed builds). Cached: it
|
|
||||||
// never moves at runtime.
|
|
||||||
function launcherIconCandidates() {
|
function launcherIconCandidates() {
|
||||||
const bundled = path.join(__dirname, '..', 'renderer')
|
const bundled = path.join(__dirname, '..', 'renderer')
|
||||||
return [
|
return [
|
||||||
|
|
@ -151,8 +134,7 @@ function launcherIconPath() {
|
||||||
return launcherIcon || null
|
return launcherIcon || null
|
||||||
}
|
}
|
||||||
|
|
||||||
// The same icon, but only as a real file on disk: a .desktop entry's Icon= is
|
// The icon as a real file: a .desktop Icon= cannot live inside app.asar.
|
||||||
// resolved by the desktop environment, which cannot look inside app.asar.
|
|
||||||
function launcherIconDiskPath() {
|
function launcherIconDiskPath() {
|
||||||
const onDisk = launcherIconCandidates().filter(candidate => !candidate.includes(`.asar${path.sep}`))
|
const onDisk = launcherIconCandidates().filter(candidate => !candidate.includes(`.asar${path.sep}`))
|
||||||
return firstExisting(onDisk)
|
return firstExisting(onDisk)
|
||||||
|
|
@ -173,4 +155,4 @@ function writeJson(file, data) {
|
||||||
fs.renameSync(tmp, file)
|
fs.renameSync(tmp, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { RESOURCES, APP_ROOT, BUILD, FILES, DATA, DIRS, ensureDirs, syncBundledTranslations, launcherIconPath, launcherIconDiskPath, readJson, writeJson, toPortablePath, resolvePortablePath }
|
module.exports = { RESOURCES, APP_ROOT, BUILD, FILES, DATA, DIRS, ensureDirs, syncBundledTranslations, launcherIconPath, launcherIconDiskPath, readJson, writeJson, toPortablePath, resolvePortablePath, firstExisting }
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ const { rewireSymlinks } = require('./symlinks')
|
||||||
const { removeTree, treeSize } = require('./fsutil')
|
const { removeTree, treeSize } = require('./fsutil')
|
||||||
const { normalizePathName } = require('./names')
|
const { normalizePathName } = require('./names')
|
||||||
|
|
||||||
|
// An app path stored relative to its prefix root ("drive_c/..." or
|
||||||
|
// "pfx/drive_c/..."); everything else is FILES-relative or absolute.
|
||||||
|
const PREFIX_REL_RE = /^(pfx[/\\])?drive_c([/\\]|$)/i
|
||||||
|
const isPrefixRel = v => typeof v === 'string' && PREFIX_REL_RE.test(v)
|
||||||
|
|
||||||
const REGISTRY = path.join(DIRS.DATA, 'prefixes.json')
|
const REGISTRY = path.join(DIRS.DATA, 'prefixes.json')
|
||||||
const MAIN = 'Main'
|
const MAIN = 'Main'
|
||||||
|
|
||||||
|
|
@ -33,6 +38,14 @@ function prefixPath(name) {
|
||||||
return path.join(DIRS.PREFIXES, name)
|
return path.join(DIRS.PREFIXES, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Native Linux apps keep their redirected home/config/cache in here, next to
|
||||||
|
// the Windows side of the same prefix.
|
||||||
|
const LINUX_PFX = 'linux_pfx'
|
||||||
|
|
||||||
|
function prefixLinuxRoot(name) {
|
||||||
|
return path.join(prefixPath(name), LINUX_PFX)
|
||||||
|
}
|
||||||
|
|
||||||
function prefixExists(name) {
|
function prefixExists(name) {
|
||||||
try {
|
try {
|
||||||
fs.accessSync(prefixPath(name))
|
fs.accessSync(prefixPath(name))
|
||||||
|
|
@ -42,12 +55,8 @@ function prefixExists(name) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every Windows C: drive a prefix has: plain wine keeps one at the top
|
// Every Windows C: drive a prefix has (top-level for plain wine, under pfx/
|
||||||
// (WINEPREFIX=<prefix>), legacy Proton-managed prefixes nest one under pfx/
|
// for Proton); a unified prefix reports its single drive once.
|
||||||
// (WINEPREFIX=<prefix>/pfx). A prefix used with BOTH wine kinds has both, and
|
|
||||||
// an import may come from either one - so callers that can accept several get
|
|
||||||
// them all, top level first. A unified prefix (pfx -> .) reaches the same
|
|
||||||
// drive twice; it is reported once.
|
|
||||||
function prefixDriveRoots(name) {
|
function prefixDriveRoots(name) {
|
||||||
const root = prefixPath(name)
|
const root = prefixPath(name)
|
||||||
const found = []
|
const found = []
|
||||||
|
|
@ -73,22 +82,9 @@ function lstatOrNull(p) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wine keeps its C: drive at <prefix>/drive_c, Proton always runs its prefix
|
// Without a link, a prefix used with both wine kinds grows two C: drives;
|
||||||
// from <compatdata>/pfx - and PLWL points compatdata at the launcher's prefix
|
// `pfx` -> prefix root collapses them. Only a prefix with no pfx at all gets
|
||||||
// folder, so without a link a prefix used with both wine kinds grows TWO C:
|
// the link - an existing layout is left exactly as it is.
|
||||||
// drives with separate registries, saves and installed DLLs. Making `pfx` a
|
|
||||||
// relative symlink back to the prefix itself collapses them: Proton's
|
|
||||||
// <compatdata>/pfx resolves to the prefix root, the exact folder wine uses.
|
|
||||||
// Every walker PLWL owns (fsutil, symlinks, LAN sync) treats symlinks as
|
|
||||||
// links and never follows them, so the self-reference is safe.
|
|
||||||
//
|
|
||||||
// Only a prefix that has no pfx AT ALL gets the link - that covers a fresh
|
|
||||||
// prefix and a plain-wine prefix first used through Proton. An existing
|
|
||||||
// layout is left exactly as it is: a prefix from an older PLWL keeps its real
|
|
||||||
// pfx/ folder (and here the two wine kinds keep two C: drives), and a prefix
|
|
||||||
// with both real drive_c trees keeps them - moving Windows installs around
|
|
||||||
// behind the user's back loses data and breaks the absolute/relative links
|
|
||||||
// inside them.
|
|
||||||
function ensureUnifiedPrefix(name) {
|
function ensureUnifiedPrefix(name) {
|
||||||
if (!name) return false
|
if (!name) return false
|
||||||
const pfx = path.join(prefixPath(name), 'pfx')
|
const pfx = path.join(prefixPath(name), 'pfx')
|
||||||
|
|
@ -102,12 +98,8 @@ function ensureUnifiedPrefix(name) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// True when a path IS one of a prefix's own roots: the prefix folder, its
|
// True when a path IS one of a prefix's own roots (folder, pfx/, drive_c):
|
||||||
// pfx/ or either drive_c. A copy/move import of such a folder would move the
|
// importing one would gut the prefix.
|
||||||
// prefix's C: drive (or the whole prefix) into files/apps, gutting it - the
|
|
||||||
// "import from a prefix" flow is the one that works in place. Both drive
|
|
||||||
// layouts are checked literally, not through prefixDriveRoots(): a prefix used
|
|
||||||
// with wine AND Proton has a drive_c each and BOTH are off limits.
|
|
||||||
function isPrefixRoot(p) {
|
function isPrefixRoot(p) {
|
||||||
if (!p) return false
|
if (!p) return false
|
||||||
const abs = path.resolve(String(p))
|
const abs = path.resolve(String(p))
|
||||||
|
|
@ -116,6 +108,7 @@ function isPrefixRoot(p) {
|
||||||
const root = prefixPath(entry.name)
|
const root = prefixPath(entry.name)
|
||||||
if (abs === root || abs === path.join(root, 'pfx')) return true
|
if (abs === root || abs === path.join(root, 'pfx')) return true
|
||||||
if (abs === path.join(root, 'drive_c') || abs === path.join(root, 'pfx', 'drive_c')) return true
|
if (abs === path.join(root, 'drive_c') || abs === path.join(root, 'pfx', 'drive_c')) return true
|
||||||
|
if (abs === path.join(root, LINUX_PFX) || abs === path.join(root, LINUX_PFX, 'home')) return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -134,37 +127,31 @@ function createPrefix(name) {
|
||||||
return { ok: true, name: clean }
|
return { ok: true, name: clean }
|
||||||
}
|
}
|
||||||
|
|
||||||
// A prefix is a whole windows drive - removal is measured before it starts
|
// Removal is measured (staging) so the UI shows a bar that moves.
|
||||||
// (staging) so the UI can show a bar that actually moves while the tree is
|
|
||||||
// walked file by file (removeTree), instead of one blocking rmSync.
|
|
||||||
async function deletePrefix(name, onProgress) {
|
async function deletePrefix(name, onProgress) {
|
||||||
if (String(name).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' }
|
if (String(name).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' }
|
||||||
const defs = listPrefixes()
|
const defs = listPrefixes()
|
||||||
defs.prefixes = defs.prefixes.filter(p => p.name !== name)
|
|
||||||
if (defs.default === name) defs.default = MAIN
|
|
||||||
savePrefixes(defs)
|
|
||||||
const emit = (phase, detail) => {
|
const emit = (phase, detail) => {
|
||||||
if (onProgress) onProgress({ phase, label: name, ...detail })
|
if (onProgress) onProgress({ phase, label: name, ...detail })
|
||||||
}
|
}
|
||||||
emit('count', { files: 0, bytes: 0 })
|
emit('count', { files: 0, bytes: 0 })
|
||||||
const total = await treeSize(prefixPath(name))
|
const total = await treeSize(prefixPath(name))
|
||||||
await removeTree(prefixPath(name), p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
await removeTree(prefixPath(name), p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
||||||
|
// the registry forgets it only once the folder is gone, so an interrupted
|
||||||
|
// delete stays visible and can be retried
|
||||||
|
defs.prefixes = defs.prefixes.filter(p => p.name !== name)
|
||||||
|
if (defs.default === name) defs.default = MAIN
|
||||||
|
savePrefixes(defs)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// A prefix that arrived from another machine (or a launcher that was moved) is
|
// Rewire absolute links to relative so a moved prefix keeps working.
|
||||||
// full of absolute symlinks into its wine/proton build - fonts, built-in
|
|
||||||
// program stubs, dlls. Rewire them to relative paths so the prefix keeps
|
|
||||||
// working here and stays relocatable (same healing wine builds get on import).
|
|
||||||
async function healPrefix(name) {
|
async function healPrefix(name) {
|
||||||
if (!name) return 0
|
if (!name) return 0
|
||||||
return rewireSymlinks(prefixPath(name), [DIRS.FILES])
|
return rewireSymlinks(prefixPath(name), [DIRS.FILES])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renaming a prefix moves its folder on disk and follows the name into the
|
// Rename moves the folder and follows the name through the registry.
|
||||||
// registry (default included) and every app that runs in it. Prefix imports
|
|
||||||
// keep their stored paths (they are relative to the prefix root), so only the
|
|
||||||
// reference changes - the files move with the folder.
|
|
||||||
function renamePrefix(oldName, newName) {
|
function renamePrefix(oldName, newName) {
|
||||||
const clean = normalizePathName(newName)
|
const clean = normalizePathName(newName)
|
||||||
if (!clean) return { ok: false, error: 'invalidName' }
|
if (!clean) return { ok: false, error: 'invalidName' }
|
||||||
|
|
@ -174,8 +161,7 @@ function renamePrefix(oldName, newName) {
|
||||||
if (entry.locked || String(oldName).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' }
|
if (entry.locked || String(oldName).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' }
|
||||||
if (clean === oldName) return { ok: true, name: clean, games: 0 }
|
if (clean === oldName) return { ok: true, name: clean, games: 0 }
|
||||||
if (defs.prefixes.some(p => p.name === clean) || fs.existsSync(prefixPath(clean))) return { ok: false, error: 'alreadyExists' }
|
if (defs.prefixes.some(p => p.name === clean) || fs.existsSync(prefixPath(clean))) return { ok: false, error: 'alreadyExists' }
|
||||||
// a registered prefix whose folder wine has not created yet can still be
|
// a registered prefix without a folder yet can still be renamed
|
||||||
// renamed - only the registry entry exists
|
|
||||||
if (fs.existsSync(prefixPath(oldName))) {
|
if (fs.existsSync(prefixPath(oldName))) {
|
||||||
try {
|
try {
|
||||||
fs.renameSync(prefixPath(oldName), prefixPath(clean))
|
fs.renameSync(prefixPath(oldName), prefixPath(clean))
|
||||||
|
|
@ -198,4 +184,4 @@ function setDefaultPrefix(name) {
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN }
|
module.exports = { isPrefixRel, PREFIX_REL_RE, LINUX_PFX, listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixLinuxRoot, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN }
|
||||||
|
|
|
||||||
|
|
@ -3,33 +3,27 @@
|
||||||
const { contextBridge, ipcRenderer } = require('electron')
|
const { contextBridge, ipcRenderer } = require('electron')
|
||||||
const { DIRS } = require('./paths')
|
const { DIRS } = require('./paths')
|
||||||
|
|
||||||
// single-slot progress: every import modal used to register ANOTHER listener
|
// Single-slot event callbacks: only the latest subscriber receives an event.
|
||||||
// and old ones never unregistered, so the file counter sped up per import
|
function singleSlot(channel) {
|
||||||
let importProgressCb = null
|
let slot = null
|
||||||
ipcRenderer.on('import:progress', (e, data) => {
|
ipcRenderer.on(channel, (e, data) => {
|
||||||
if (importProgressCb) importProgressCb(data)
|
if (slot) slot(data)
|
||||||
})
|
})
|
||||||
|
return fn => {
|
||||||
|
slot = fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// "Open with PLWL": single-slot callback for files handed over via --open
|
const onImportProgress = singleSlot('import:progress')
|
||||||
// (Exec %f) - the renderer shows the wine/prefix picker popup for it.
|
// "Open with" into an isolated app: the document is copied first
|
||||||
let openWithCb = null
|
const onHandoffProgress = singleSlot('handoff:progress')
|
||||||
ipcRenderer.on('openwith:file', (e, openPath) => {
|
// "Open with PLWL": files handed over via --open (Exec %f).
|
||||||
if (openWithCb) openWithCb(openPath)
|
const onOpenWith = singleSlot('openwith:file')
|
||||||
})
|
// one sync (and one pending consent request, enforced server-side) at a time
|
||||||
|
const onLanSyncProgress = singleSlot('lansync:progress')
|
||||||
// LAN sync progress: same single-slot rule as the import counter, only one
|
const onLanSyncIncoming = singleSlot('lansync:incoming')
|
||||||
// sync can run at a time anyway.
|
// the receiver's deletion review: an answer here decides what may be removed
|
||||||
let lanSyncProgressCb = null
|
const onLanSyncReview = singleSlot('lansync:review')
|
||||||
ipcRenderer.on('lansync:progress', (e, data) => {
|
|
||||||
if (lanSyncProgressCb) lanSyncProgressCb(data)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Incoming sync consent: the receiver's Accept/Decline popup. Single slot for
|
|
||||||
// the same reason - one pending request at a time (the server enforces it).
|
|
||||||
let lanSyncIncomingCb = null
|
|
||||||
ipcRenderer.on('lansync:incoming', (e, data) => {
|
|
||||||
if (lanSyncIncomingCb) lanSyncIncomingCb(data)
|
|
||||||
})
|
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
|
|
@ -51,12 +45,11 @@ contextBridge.exposeInMainWorld('plwl', {
|
||||||
prefixesDelete: name => ipcRenderer.invoke('prefixes:delete', name),
|
prefixesDelete: name => ipcRenderer.invoke('prefixes:delete', name),
|
||||||
prefixesRename: (oldName, newName) => ipcRenderer.invoke('prefixes:rename', oldName, newName),
|
prefixesRename: (oldName, newName) => ipcRenderer.invoke('prefixes:rename', oldName, newName),
|
||||||
prefixesSetDefault: name => ipcRenderer.invoke('prefixes:setDefault', name),
|
prefixesSetDefault: name => ipcRenderer.invoke('prefixes:setDefault', name),
|
||||||
|
prefixesResetLinuxConfig: name => ipcRenderer.invoke('prefixes:resetLinuxConfig', name),
|
||||||
openwithTake: () => ipcRenderer.invoke('openwith:take'),
|
openwithTake: () => ipcRenderer.invoke('openwith:take'),
|
||||||
openwithRun: payload => ipcRenderer.invoke('openwith:run', payload),
|
openwithRun: payload => ipcRenderer.invoke('openwith:run', payload),
|
||||||
openwithTexts: lang => ipcRenderer.invoke('openwith:texts', lang),
|
openwithTexts: lang => ipcRenderer.invoke('openwith:texts', lang),
|
||||||
onOpenWith: cb => {
|
onOpenWith: onOpenWith,
|
||||||
openWithCb = cb
|
|
||||||
},
|
|
||||||
basename: p => (p ? path.basename(p) : ''),
|
basename: p => (p ? path.basename(p) : ''),
|
||||||
fileExists: p => {
|
fileExists: p => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -80,7 +73,10 @@ contextBridge.exposeInMainWorld('plwl', {
|
||||||
gamesShortcutPaths: id => ipcRenderer.invoke('games:shortcutPaths', id),
|
gamesShortcutPaths: id => ipcRenderer.invoke('games:shortcutPaths', id),
|
||||||
gamesPickFolder: () => ipcRenderer.invoke('games:pickFolder'),
|
gamesPickFolder: () => ipcRenderer.invoke('games:pickFolder'),
|
||||||
gamesPickPrefixFolder: prefix => ipcRenderer.invoke('games:pickPrefixFolder', prefix),
|
gamesPickPrefixFolder: prefix => ipcRenderer.invoke('games:pickPrefixFolder', prefix),
|
||||||
gamesImport: (src, mode) => ipcRenderer.invoke('games:import', src, mode),
|
gamesImport: (src, mode, kind) => ipcRenderer.invoke('games:import', src, mode, kind),
|
||||||
|
gamesOpenDataFolder: id => ipcRenderer.invoke('games:openDataFolder', id),
|
||||||
|
sandboxStatus: () => ipcRenderer.invoke('sandbox:status'),
|
||||||
|
sandboxProbe: () => ipcRenderer.invoke('sandbox:probe'),
|
||||||
gamesImportFromPrefix: (prefix, src) => ipcRenderer.invoke('games:importFromPrefix', prefix, src),
|
gamesImportFromPrefix: (prefix, src) => ipcRenderer.invoke('games:importFromPrefix', prefix, src),
|
||||||
gamesRun: id => ipcRenderer.invoke('games:run', id),
|
gamesRun: id => ipcRenderer.invoke('games:run', id),
|
||||||
gamesRunTool: (id, tool, args) => ipcRenderer.invoke('games:runTool', id, tool, args),
|
gamesRunTool: (id, tool, args) => ipcRenderer.invoke('games:runTool', id, tool, args),
|
||||||
|
|
@ -91,24 +87,24 @@ contextBridge.exposeInMainWorld('plwl', {
|
||||||
dialogPickFile: opts => ipcRenderer.invoke('dialog:pickFile', opts),
|
dialogPickFile: opts => ipcRenderer.invoke('dialog:pickFile', opts),
|
||||||
shellOpenPath: p => ipcRenderer.invoke('shell:openPath', p),
|
shellOpenPath: p => ipcRenderer.invoke('shell:openPath', p),
|
||||||
shellShowItemInFolder: p => ipcRenderer.invoke('shell:showItemInFolder', p),
|
shellShowItemInFolder: p => ipcRenderer.invoke('shell:showItemInFolder', p),
|
||||||
onImportProgress: cb => {
|
onImportProgress: onImportProgress,
|
||||||
importProgressCb = cb
|
onHandoffProgress: onHandoffProgress,
|
||||||
},
|
|
||||||
onGamesChanged: cb => ipcRenderer.on('games:changed', () => cb()),
|
onGamesChanged: cb => ipcRenderer.on('games:changed', () => cb()),
|
||||||
lansyncGet: () => ipcRenderer.invoke('lansync:get'),
|
lansyncGet: () => ipcRenderer.invoke('lansync:get'),
|
||||||
lansyncDevices: () => ipcRenderer.invoke('lansync:devices'),
|
lansyncDevices: () => ipcRenderer.invoke('lansync:devices'),
|
||||||
lansyncRerollDeviceId: () => ipcRenderer.invoke('lansync:rerollDeviceId'),
|
lansyncRerollDeviceId: () => ipcRenderer.invoke('lansync:rerollDeviceId'),
|
||||||
lansyncRerollSyncCode: () => ipcRenderer.invoke('lansync:rerollSyncCode'),
|
lansyncRerollSyncCode: () => ipcRenderer.invoke('lansync:rerollSyncCode'),
|
||||||
lansyncForget: id => ipcRenderer.invoke('lansync:forget', id),
|
lansyncForget: id => ipcRenderer.invoke('lansync:forget', id),
|
||||||
|
lansyncClearHashCache: () => ipcRenderer.invoke('lansync:clearHashCache'),
|
||||||
lansyncSync: payload => ipcRenderer.invoke('lansync:sync', payload),
|
lansyncSync: payload => ipcRenderer.invoke('lansync:sync', payload),
|
||||||
lansyncRespondIncoming: (requestId, accept) => ipcRenderer.invoke('lansync:respondIncoming', requestId, accept),
|
lansyncRespondIncoming: (requestId, accept) => ipcRenderer.invoke('lansync:respondIncoming', requestId, accept),
|
||||||
|
lansyncItems: () => ipcRenderer.invoke('lansync:items'),
|
||||||
|
lansyncReviewTree: (id, rel) => ipcRenderer.invoke('lansync:reviewTree', id, rel),
|
||||||
|
lansyncReviewDecide: (id, decision) => ipcRenderer.invoke('lansync:reviewDecide', id, decision),
|
||||||
|
onLanSyncReview: onLanSyncReview,
|
||||||
onLanSyncDevices: cb => ipcRenderer.on('lansync:devices', (e, data) => cb(data)),
|
onLanSyncDevices: cb => ipcRenderer.on('lansync:devices', (e, data) => cb(data)),
|
||||||
onLanSyncProgress: cb => {
|
onLanSyncProgress: onLanSyncProgress,
|
||||||
lanSyncProgressCb = cb
|
onLanSyncIncoming: onLanSyncIncoming,
|
||||||
},
|
|
||||||
onLanSyncIncoming: cb => {
|
|
||||||
lanSyncIncomingCb = cb
|
|
||||||
},
|
|
||||||
onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data)),
|
onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data)),
|
||||||
onLanSyncReceiving: cb => ipcRenderer.on('lansync:receiving', (e, data) => cb(data))
|
onLanSyncReceiving: cb => ipcRenderer.on('lansync:receiving', (e, data) => cb(data))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
282
src/runner.js
282
src/runner.js
|
|
@ -8,11 +8,8 @@ const { DIRS } = require('./paths')
|
||||||
const { prefixPath, ensureUnifiedPrefix } = require('./prefixes')
|
const { prefixPath, ensureUnifiedPrefix } = require('./prefixes')
|
||||||
const { prefixNameForGame } = require('./library')
|
const { prefixNameForGame } = require('./library')
|
||||||
|
|
||||||
// The fake Steam install PLWL falls back to when no real Steam client is
|
// The fake Steam install used when no real Steam client is found. Both folders
|
||||||
// found (see findSteamClientDir). Both folders Proton links into its prefix
|
// Proton links into its prefix have to exist, or the links dangle.
|
||||||
// have to exist: it symlinks <prefix>/drive_c/Program Files (x86)/Steam/
|
|
||||||
// appcache -> <fake>/appcache while setting the prefix up, and a link into a
|
|
||||||
// missing folder is a dangling link in a tree that gets synced.
|
|
||||||
function ensureFakeSteam() {
|
function ensureFakeSteam() {
|
||||||
const fake = DIRS.FAKE_STEAM
|
const fake = DIRS.FAKE_STEAM
|
||||||
fs.mkdirSync(path.join(fake, 'steamapps'), { recursive: true })
|
fs.mkdirSync(path.join(fake, 'steamapps'), { recursive: true })
|
||||||
|
|
@ -20,14 +17,9 @@ function ensureFakeSteam() {
|
||||||
return fake
|
return fake
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proton expects the Steam client it is normally started by at
|
// Proton needs the Steam client at STEAM_COMPAT_CLIENT_INSTALL_PATH or a Valve
|
||||||
// STEAM_COMPAT_CLIENT_INSTALL_PATH: it copies the client's legacy DLLs from
|
// build dies in a "steamclient_main.c" assert; use a real client read-only,
|
||||||
// <client>/legacycompat into the prefix, its built-in steam.exe loads the
|
// the empty fake otherwise (GE builds skip steam.exe entirely).
|
||||||
// client's own steamclient.so, and media libs come from <client>/ubuntu12_*.
|
|
||||||
// A client installed on this machine is worth using (read-only) - without one
|
|
||||||
// a Valve build's launch dies in a "steamclient_main.c" assert dialog - and
|
|
||||||
// only when there is none does PLWL fall back to the empty fake folder. GE
|
|
||||||
// builds don't need any of it: their UMU path never starts steam.exe.
|
|
||||||
const STEAM_CLIENT_MARKERS = [
|
const STEAM_CLIENT_MARKERS = [
|
||||||
['legacycompat', 'steamclient.dll'],
|
['legacycompat', 'steamclient.dll'],
|
||||||
['ubuntu12_32', 'steamclient.so']
|
['ubuntu12_32', 'steamclient.so']
|
||||||
|
|
@ -57,21 +49,15 @@ function steamClientDir() {
|
||||||
return findSteamClientDir() || ensureFakeSteam()
|
return findSteamClientDir() || ensureFakeSteam()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module names travel in a semicolon-separated WINEDLLOVERRIDES list where
|
// WINEDLLOVERRIDES: ';'-separated, '=' splits name/setting, ',' separates
|
||||||
// '=' splits name from setting and ',' separates names - anything else in a
|
// names. Guides write a trailing .dll; Wine strips it, so store the bare name.
|
||||||
// name would corrupt the entries around it. A trailing .dll is what guides
|
|
||||||
// write; Wine strips it itself, so PLWL stores the bare name.
|
|
||||||
function sanitizeDllName(name) {
|
function sanitizeDllName(name) {
|
||||||
const clean = String(name || '').trim().replace(/\.dll$/i, '')
|
const clean = String(name || '').trim().replace(/\.dll$/i, '')
|
||||||
return /^[^;=\s]+$/.test(clean) ? clean : ''
|
return /^[^;=\s]+$/.test(clean) ? clean : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wine replaces an earlier entry for the same module with a later one, so an
|
// Wine keeps the LAST entry per module: inherited overrides go first, the
|
||||||
// inherited WINEDLLOVERRIDES goes FIRST and the app's own overrides are
|
// app's own after. An empty setting is Wine's "disabled" and stays as `name=`.
|
||||||
// appended after it. An empty setting is Wine's "disabled" and has to survive
|
|
||||||
// as `name=`, not be dropped. Proton appends its own dxvk/nvapi/steam.exe
|
|
||||||
// defaults after everything - an app override for one of those modules only
|
|
||||||
// takes effect where Proton itself has no default.
|
|
||||||
function dllOverridesFor(game, env) {
|
function dllOverridesFor(game, env) {
|
||||||
const parts = []
|
const parts = []
|
||||||
const inherited = String(env.WINEDLLOVERRIDES || '').trim().replace(/;+$/, '')
|
const inherited = String(env.WINEDLLOVERRIDES || '').trim().replace(/;+$/, '')
|
||||||
|
|
@ -87,11 +73,8 @@ function dllOverridesFor(game, env) {
|
||||||
return parts.join(';')
|
return parts.join(';')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wine only ever repairs links that are BROKEN: a prefix that came from
|
// Repair only broken links (a resolvable absolute link would be synced out
|
||||||
// another machine (or from an older PLWL) can still hold absolute links into
|
// again). Once per prefix per launch.
|
||||||
// its build that happen to resolve here, and those would be synced out again.
|
|
||||||
// Healing once per prefix per launch keeps the whole tree relocatable and
|
|
||||||
// costs a single walk in the common (already relative) case.
|
|
||||||
const healedPrefixes = new Set()
|
const healedPrefixes = new Set()
|
||||||
async function healPrefixOnce(game) {
|
async function healPrefixOnce(game) {
|
||||||
const name = prefixNameForGame(game)
|
const name = prefixNameForGame(game)
|
||||||
|
|
@ -105,32 +88,16 @@ async function healPrefixOnce(game) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proton gets its prefix through STEAM_COMPAT_DATA_PATH, which has to be a
|
// Proton's STEAM_COMPAT_DATA_PATH must be a symlink to the launcher prefix;
|
||||||
// symlink to the launcher's own prefix folder. The parent directory is created
|
// a real compat dir already sitting there is not ours to replace.
|
||||||
// FIRST: symlink() fails with ENOENT when compat/ does not exist yet, and the
|
|
||||||
// old fallback then created a plain empty directory instead - Proton silently
|
|
||||||
// started with an empty prefix (the game never saw its own data). A leftover
|
|
||||||
// EMPTY directory from exactly that situation is safe to replace with the
|
|
||||||
// link; a populated one is left alone.
|
|
||||||
function linkPrefix(compatData, prefixDir) {
|
function linkPrefix(compatData, prefixDir) {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(compatData), { recursive: true })
|
fs.mkdirSync(path.dirname(compatData), { recursive: true })
|
||||||
} catch {}
|
} catch {}
|
||||||
try {
|
try {
|
||||||
const st = fs.lstatSync(compatData)
|
if (!fs.lstatSync(compatData).isSymbolicLink()) return
|
||||||
if (st.isSymbolicLink()) {
|
if (fs.readlinkSync(compatData) === prefixDir) return
|
||||||
if (fs.readlinkSync(compatData) === prefixDir) return
|
fs.unlinkSync(compatData)
|
||||||
fs.unlinkSync(compatData)
|
|
||||||
} else if (st.isDirectory()) {
|
|
||||||
let names = []
|
|
||||||
try {
|
|
||||||
names = fs.readdirSync(compatData)
|
|
||||||
} catch {}
|
|
||||||
if (names.length) return
|
|
||||||
fs.rmdirSync(compatData)
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
try {
|
try {
|
||||||
fs.symlinkSync(prefixDir, compatData, 'dir')
|
fs.symlinkSync(prefixDir, compatData, 'dir')
|
||||||
|
|
@ -140,12 +107,9 @@ function linkPrefix(compatData, prefixDir) {
|
||||||
function buildEnv({ game, wine }) {
|
function buildEnv({ game, wine }) {
|
||||||
const env = { ...process.env }
|
const env = { ...process.env }
|
||||||
const isProton = wine && wine.type === 'proton'
|
const isProton = wine && wine.type === 'proton'
|
||||||
// same fallback the games service snapshots into its running entries
|
// same fallback the games service uses, so in-use checks name this folder
|
||||||
// (library.prefixNameForGame), so in-use checks always name this folder
|
|
||||||
const prefixName = prefixNameForGame(game)
|
const prefixName = prefixNameForGame(game)
|
||||||
// a prefix with no pfx yet gets the shared-link layout, so every launch -
|
// no pfx yet -> shared-link layout; an existing nested layout is left as is
|
||||||
// wine, Proton, tools - lands on that prefix's ONE C: drive; an existing
|
|
||||||
// nested layout is left as it is (see ensureUnifiedPrefix)
|
|
||||||
try {
|
try {
|
||||||
ensureUnifiedPrefix(prefixName)
|
ensureUnifiedPrefix(prefixName)
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
@ -156,36 +120,23 @@ function buildEnv({ game, wine }) {
|
||||||
env.STEAM_COMPAT_APP_ID = '0'
|
env.STEAM_COMPAT_APP_ID = '0'
|
||||||
env.SteamAppId = '0'
|
env.SteamAppId = '0'
|
||||||
env.SteamGameId = '0'
|
env.SteamGameId = '0'
|
||||||
// Proton's session init reads STEAM_COMPAT_CLIENT_INSTALL_PATH on EVERY
|
// every Proton verb reads STEAM_COMPAT_CLIENT_INSTALL_PATH (a missing one
|
||||||
// verb: its appcache sync (g_compatdata.sync_appcache) runs even for
|
// aborts with a KeyError). UMU run: real client; disableUmu: the fake.
|
||||||
// runinprefix and does os.environ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] as
|
|
||||||
// soon as SteamGameId is set, so the variable must never be dropped - a
|
|
||||||
// missing one aborts the launch with a KeyError before wine even starts.
|
|
||||||
// Only what it POINTS AT depends on the mode: a UMU run may use a real
|
|
||||||
// client found on this machine, a disableUmu run always gets the empty
|
|
||||||
// fake folder (runinprefix copies nothing out of it; no steam.exe and no
|
|
||||||
// umu.exe is started there).
|
|
||||||
if (game.disableUmu) {
|
if (game.disableUmu) {
|
||||||
delete env.UMU_ID
|
delete env.UMU_ID
|
||||||
env.STEAM_COMPAT_CLIENT_INSTALL_PATH = ensureFakeSteam()
|
env.STEAM_COMPAT_CLIENT_INSTALL_PATH = ensureFakeSteam()
|
||||||
} else {
|
} else {
|
||||||
env.STEAM_COMPAT_CLIENT_INSTALL_PATH = steamClientDir()
|
env.STEAM_COMPAT_CLIENT_INSTALL_PATH = steamClientDir()
|
||||||
// Launch like the UMU protocol does (UMU/PortProton/Heroic): with UMU_ID
|
// UMU_ID makes newer Protons run through umu.exe instead of steam.exe,
|
||||||
// set, newer Protons (incl. GE-Proton 9+) run the game through their
|
// which needs the Steam client's DLLs (see steamClientDir).
|
||||||
// built-in umu.exe instead of the steam.exe helper, which needs the
|
|
||||||
// Steam client's DLLs and dies in a "steamclient_main.c" assert dialog
|
|
||||||
// without them (see steamClientDir).
|
|
||||||
if (!env.UMU_ID) env.UMU_ID = 'umu-0'
|
if (!env.UMU_ID) env.UMU_ID = 'umu-0'
|
||||||
}
|
}
|
||||||
env.WINEPREFIX = path.join(env.STEAM_COMPAT_DATA_PATH, 'pfx')
|
env.WINEPREFIX = path.join(env.STEAM_COMPAT_DATA_PATH, 'pfx')
|
||||||
env.PROTON_LOG_DIR = path.join(DIRS.DATA, 'logs')
|
env.PROTON_LOG_DIR = path.join(DIRS.DATA, 'logs')
|
||||||
fs.mkdirSync(env.PROTON_LOG_DIR, { recursive: true })
|
fs.mkdirSync(env.PROTON_LOG_DIR, { recursive: true })
|
||||||
linkPrefix(env.STEAM_COMPAT_DATA_PATH, prefixDir)
|
linkPrefix(env.STEAM_COMPAT_DATA_PATH, prefixDir)
|
||||||
// No silent fallback to a real directory: Proton would then set its prefix
|
// no silent fallback: a real dir would orphan the prefix outside where
|
||||||
// up under files/data/compat/<id>/pfx, outside files/wineprefixes/ where
|
// PLWL tracks it; only a missing path is refused
|
||||||
// PLWL tracks it - rename, delete, the in-use guards and the LAN sync all
|
|
||||||
// look at the launcher prefix, so its C: drive would be orphaned. Only a
|
|
||||||
// missing path is refused; a populated one is linkPrefix's deliberate case.
|
|
||||||
if (!fs.existsSync(env.STEAM_COMPAT_DATA_PATH)) {
|
if (!fs.existsSync(env.STEAM_COMPAT_DATA_PATH)) {
|
||||||
throw new Error('prefix-link-failed')
|
throw new Error('prefix-link-failed')
|
||||||
}
|
}
|
||||||
|
|
@ -198,17 +149,13 @@ function buildEnv({ game, wine }) {
|
||||||
if (overrides) env.WINEDLLOVERRIDES = overrides
|
if (overrides) env.WINEDLLOVERRIDES = overrides
|
||||||
else delete env.WINEDLLOVERRIDES
|
else delete env.WINEDLLOVERRIDES
|
||||||
|
|
||||||
// the app's own environment comes last: it overrides everything above,
|
// the app's own environment comes last: it overrides everything above
|
||||||
// including a WINEDLLOVERRIDES configured here
|
|
||||||
for (const [k, v] of Object.entries(game.env || {})) env[k] = v
|
for (const [k, v] of Object.entries(game.env || {})) env[k] = v
|
||||||
return { env, prefixDir }
|
return { env, prefixDir }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proton verbs: 'run' goes through the build's umu.exe/steam.exe launcher,
|
// Proton verbs: 'run' goes through umu.exe/steam.exe, 'runinprefix' runs the
|
||||||
// while 'runinprefix' runs the build's wine directly (same session env, no
|
// build's wine directly and skips Proton's prefix setup (see game.disableUmu).
|
||||||
// umu/steam launcher ever starts - see game.disableUmu). runinprefix also skips
|
|
||||||
// Proton's prefix setup, so a prefix that never saw a normal Proton run stays
|
|
||||||
// a plain wine prefix instead of gaining Proton's default DLLs.
|
|
||||||
function protonVerb(game) {
|
function protonVerb(game) {
|
||||||
return game.disableUmu ? 'runinprefix' : 'run'
|
return game.disableUmu ? 'runinprefix' : 'run'
|
||||||
}
|
}
|
||||||
|
|
@ -221,10 +168,8 @@ function parseArgs(args) {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// A file handed over from the Linux file manager ("Open with" on an app
|
// "Open with" handoff: the file reaches the app as a WINDOWS path (C:\ inside
|
||||||
// shortcut) has to reach the Windows app as a WINDOWS path, the way it would
|
// the prefix's drive, Z:\ for the rest).
|
||||||
// on Windows: C:\ for anything inside the prefix's C: drive, and wine's Z:\
|
|
||||||
// mapping (the filesystem root) for everything else.
|
|
||||||
function windowsPathFor(file, prefixDir) {
|
function windowsPathFor(file, prefixDir) {
|
||||||
let abs = String(file || '')
|
let abs = String(file || '')
|
||||||
try {
|
try {
|
||||||
|
|
@ -241,14 +186,14 @@ function windowsPathFor(file, prefixDir) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const GRACE_MS = 60000
|
const GRACE_MS = 60000
|
||||||
|
// A finished run may leave output in the pipes (or a grandchild holding them
|
||||||
|
// open): the log closes when its writers are done, at the latest this long
|
||||||
|
// after the exit.
|
||||||
|
const LOG_DRAIN_MS = 5000
|
||||||
|
|
||||||
// Live playtime: while the game runs, update ONLY the tiny playtime file
|
// Live playtime: tiny file updated every 30s, totals mirrored to the registry
|
||||||
// (a number of seconds) every 30s - apps.json is left untouched to spare the
|
// on exit. `base` comes from the caller (live file, not the registry) so
|
||||||
// disk. lastPlayed is stamped once at start, final totals are mirrored into
|
// shared ids never mix playtime.
|
||||||
// the registry once on exit.
|
|
||||||
// `base` is the seconds total this run adds up to. It is resolved by the
|
|
||||||
// CALLER from the live playtime file (not the registry) so ad-hoc runs that
|
|
||||||
// share one id (see runAdHoc) never mix two apps' playtime.
|
|
||||||
function startPlaytimeTicker({ game, startedAt, base }) {
|
function startPlaytimeTicker({ game, startedAt, base }) {
|
||||||
const library = require('./library')
|
const library = require('./library')
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
|
|
@ -269,6 +214,81 @@ function openLatestLog(gameId) {
|
||||||
return fs.createWriteStream(logFile, { flags: 'w' })
|
return fs.createWriteStream(logFile, { flags: 'w' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tail every tracked run shares (wine and native Linux alike): spawn
|
||||||
|
// detached, tee output into the app's latest log, tick playtime, stamp
|
||||||
|
// lastPlayed, report the exit. `header` lines land right after the launch line.
|
||||||
|
function spawnTracked({ game, cmd, args, cwd, env, handoff = false, header = [], onExit }) {
|
||||||
|
const library = require('./library')
|
||||||
|
fs.mkdirSync(DIRS.DATA, { recursive: true })
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let child
|
||||||
|
try {
|
||||||
|
child = spawn(cmd, args, {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
// a handoff is untracked: its pipes drain so the app never blocks
|
||||||
|
stdio: handoff ? 'ignore' : ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: true
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
return reject(err)
|
||||||
|
}
|
||||||
|
// spawn() resolves before the process exists; a missing binary only
|
||||||
|
// surfaces as an 'error' event, so wait for the real 'spawn'.
|
||||||
|
let started = false
|
||||||
|
child.once('error', err => {
|
||||||
|
if (!started) reject(err)
|
||||||
|
})
|
||||||
|
child.once('spawn', () => {
|
||||||
|
started = true
|
||||||
|
// a handoff writes no log or playtime: the tracked run owns both
|
||||||
|
if (handoff) {
|
||||||
|
child.unref()
|
||||||
|
return resolve({ pid: child.pid, logFile: null })
|
||||||
|
}
|
||||||
|
const logFile = latestLogPath(game.id)
|
||||||
|
const ws = openLatestLog(game.id)
|
||||||
|
// a launch line first, so the log answers "which prefix/overrides/run"
|
||||||
|
ws.write(`[plwl] ${cmd} ${args.join(' ')}\n`)
|
||||||
|
for (const line of header) ws.write(line + '\n')
|
||||||
|
// two pipes feed one log, so neither may end it: pipe() would close the
|
||||||
|
// stream on the first EOF, and the second writer then kills the process
|
||||||
|
// with ERR_STREAM_WRITE_AFTER_END (an unhandled WriteStream 'error')
|
||||||
|
child.stdout.pipe(ws, { end: false })
|
||||||
|
child.stderr.pipe(ws, { end: false })
|
||||||
|
let openPipes = 2
|
||||||
|
const endLog = () => {
|
||||||
|
if (--openPipes <= 0) ws.end()
|
||||||
|
}
|
||||||
|
child.stdout.once('end', endLog)
|
||||||
|
child.stderr.once('end', endLog)
|
||||||
|
// an unwritable log (full disk, file removed) must not take us down
|
||||||
|
ws.on('error', () => {})
|
||||||
|
child.unref()
|
||||||
|
const startedAt = Date.now()
|
||||||
|
// read the LIVE playtime file, not the registry (shared ids)
|
||||||
|
const basePlaySeconds = library.readPlaySeconds(game.id) ?? (game.playSeconds || 0)
|
||||||
|
const stopTicker = startPlaytimeTicker({ game, startedAt, base: basePlaySeconds })
|
||||||
|
// stamp "last played" once at start, even if we crash later
|
||||||
|
library.updateGame(game.id, { lastPlayed: startedAt })
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
stopTicker()
|
||||||
|
// nothing may outlive the run: end the log once the buffered output had
|
||||||
|
// its chance, even if something else still holds the pipes
|
||||||
|
const drain = setTimeout(() => ws.end(), LOG_DRAIN_MS)
|
||||||
|
if (drain.unref) drain.unref()
|
||||||
|
const counted = Math.max(0, Math.round((Date.now() - startedAt - GRACE_MS) / 1000))
|
||||||
|
library.updateGame(game.id, {
|
||||||
|
lastPlayed: startedAt,
|
||||||
|
playSeconds: basePlaySeconds + counted
|
||||||
|
})
|
||||||
|
if (onExit) onExit(code, signal)
|
||||||
|
})
|
||||||
|
resolve({ pid: child.pid, logFile })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function runGame({ game, wine, systemWine, onExit, openFile, handoff = false }) {
|
async function runGame({ game, wine, systemWine, onExit, openFile, handoff = false }) {
|
||||||
await healPrefixOnce(game)
|
await healPrefixOnce(game)
|
||||||
const isProton = wine && wine.type === 'proton'
|
const isProton = wine && wine.type === 'proton'
|
||||||
|
|
@ -289,72 +309,10 @@ async function runGame({ game, wine, systemWine, onExit, openFile, handoff = fal
|
||||||
base = [exe, ...argList]
|
base = [exe, ...argList]
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(DIRS.DATA, { recursive: true })
|
const header = [`[plwl] WINEPREFIX=${env.WINEPREFIX}`]
|
||||||
return new Promise((resolve, reject) => {
|
if (env.WINEDLLOVERRIDES) header.push(`[plwl] WINEDLLOVERRIDES=${env.WINEDLLOVERRIDES}`)
|
||||||
let child
|
if (isProton) header.push(`[plwl] proton launcher: ${game.disableUmu ? 'direct wine (no UMU, no Steam)' : `umu.exe (UMU_ID=${env.UMU_ID})`}`)
|
||||||
try {
|
return spawnTracked({ game, cmd, args: base, cwd: workdir, env, handoff, header, onExit })
|
||||||
child = spawn(cmd, base, {
|
|
||||||
cwd: workdir,
|
|
||||||
env,
|
|
||||||
// a handoff (a file given to an already running app) is not tracked:
|
|
||||||
// its pipes are left unread, so the output goes nowhere instead of
|
|
||||||
// filling up and blocking the app
|
|
||||||
stdio: handoff ? 'ignore' : ['ignore', 'pipe', 'pipe'],
|
|
||||||
detached: true
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
return reject(err)
|
|
||||||
}
|
|
||||||
// spawn() returns before the OS actually starts the program: when the
|
|
||||||
// binary is missing or has no executable bit (a wine build copied without
|
|
||||||
// its permissions, a wrong path) the failure only arrives as an 'error'
|
|
||||||
// event, AFTER this function would have resolved. Resolving there marked
|
|
||||||
// games that never started as running forever, so the promise only settles
|
|
||||||
// once the process really exists ('spawn').
|
|
||||||
let started = false
|
|
||||||
child.once('error', err => {
|
|
||||||
if (!started) reject(err)
|
|
||||||
})
|
|
||||||
child.once('spawn', () => {
|
|
||||||
started = true
|
|
||||||
// a handoff never writes the app's log or playtime: the tracked run owns
|
|
||||||
// both, and truncating the live log would destroy what it is writing
|
|
||||||
if (handoff) {
|
|
||||||
child.unref()
|
|
||||||
return resolve({ pid: child.pid, logFile: null })
|
|
||||||
}
|
|
||||||
const logFile = latestLogPath(game.id)
|
|
||||||
const ws = openLatestLog(game.id)
|
|
||||||
// a launch line of our own before the raw wine output: when a game
|
|
||||||
// refuses to start, "which prefix, which overrides, umu or steam.exe"
|
|
||||||
// is exactly what has to be answerable from the log
|
|
||||||
ws.write(`[plwl] ${cmd} ${base.join(' ')}\n`)
|
|
||||||
ws.write(`[plwl] WINEPREFIX=${env.WINEPREFIX}\n`)
|
|
||||||
if (env.WINEDLLOVERRIDES) ws.write(`[plwl] WINEDLLOVERRIDES=${env.WINEDLLOVERRIDES}\n`)
|
|
||||||
if (isProton) ws.write(`[plwl] proton launcher: ${game.disableUmu ? 'direct wine (no UMU, no Steam)' : `umu.exe (UMU_ID=${env.UMU_ID})`}\n`)
|
|
||||||
child.stdout.pipe(ws)
|
|
||||||
child.stderr.pipe(ws)
|
|
||||||
child.unref()
|
|
||||||
const startedAt = Date.now()
|
|
||||||
// read the LIVE playtime file, not the (possibly shared/stale) registry
|
|
||||||
// value - ad-hoc "open with" runs share one id and must not mix playtime
|
|
||||||
const basePlaySeconds = require('./library').readPlaySeconds(game.id) ?? (game.playSeconds || 0)
|
|
||||||
const stopTicker = startPlaytimeTicker({ game, startedAt, base: basePlaySeconds })
|
|
||||||
// stamp "last played" once at start so it is correct even if we crash later
|
|
||||||
require('./library').updateGame(game.id, { lastPlayed: startedAt })
|
|
||||||
child.on('exit', (code, signal) => {
|
|
||||||
stopTicker()
|
|
||||||
ws.end()
|
|
||||||
const counted = Math.max(0, Math.round((Date.now() - startedAt - GRACE_MS) / 1000))
|
|
||||||
require('./library').updateGame(game.id, {
|
|
||||||
lastPlayed: startedAt,
|
|
||||||
playSeconds: basePlaySeconds + counted
|
|
||||||
})
|
|
||||||
if (onExit) onExit(code, signal)
|
|
||||||
})
|
|
||||||
resolve({ pid: child.pid, logFile })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runTool({ game, wine, tool, args, systemWine }) {
|
async function runTool({ game, wine, tool, args, systemWine }) {
|
||||||
|
|
@ -385,14 +343,12 @@ async function runTool({ game, wine, tool, args, systemWine }) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ad-hoc launch ("Open with PLWL"): the exact runGame pipeline, but with a
|
// Ad-hoc launch ("Open with PLWL"): the runGame pipeline under the shared
|
||||||
// synthetic target under the shared `openwith` id - log and playtime files
|
// `openwith` id; no library entry is created. `prefix` is the picker's
|
||||||
// live in files/data/appdata/openwith/ and no library entry is created.
|
// choice ('' = launcher default).
|
||||||
// `prefix` carries the picker's choice ('' = launcher default, resolved by
|
|
||||||
// buildEnv); wine/systemWine are the already-resolved build objects.
|
|
||||||
function runAdHoc({ openPath, wine, systemWine, prefix, onExit }) {
|
function runAdHoc({ openPath, wine, systemWine, prefix, onExit }) {
|
||||||
const game = require('./openwith').makeTarget(openPath, 'default', prefix || '')
|
const game = require('./openwith').makeTarget(openPath, 'default', prefix || '')
|
||||||
return runGame({ game, wine, systemWine, onExit })
|
return runGame({ game, wine, systemWine, onExit })
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { buildEnv, runGame, runTool, runAdHoc, parseArgs, latestLogPath, windowsPathFor }
|
module.exports = { buildEnv, runGame, runTool, runAdHoc, parseArgs, latestLogPath, windowsPathFor, spawnTracked }
|
||||||
|
|
|
||||||
166
src/sandbox.js
Normal file
166
src/sandbox.js
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const { execFile } = require('child_process')
|
||||||
|
const path = require('path')
|
||||||
|
const { firstExisting } = require('./paths')
|
||||||
|
|
||||||
|
// bwrap only works when unprivileged namespaces really work (Ubuntu's AppArmor
|
||||||
|
// kills it), so a functional probe decides - never `which bwrap`.
|
||||||
|
const PROBE_TIMEOUT_MS = 3000
|
||||||
|
// /bin/true is a symlink on most distros but missing on a few; env is the
|
||||||
|
// last resort that still exits 0.
|
||||||
|
const TRUE_BINARIES = ['/bin/true', '/usr/bin/true', '/usr/bin/env']
|
||||||
|
|
||||||
|
function firstLine(text) {
|
||||||
|
return String(text || '').trim().split('\n')[0].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed probe maps to a reason the UI and the log can name.
|
||||||
|
function reasonFor(err, stderr) {
|
||||||
|
if (err && err.code === 'ENOENT') return 'missing'
|
||||||
|
if (err && (err.killed || err.signal === 'SIGKILL')) return 'timeout'
|
||||||
|
const text = String(stderr || '') + ' ' + String((err && err.message) || '')
|
||||||
|
if (/Operation not permitted|Permission denied|Failed to unshare|uid map|setting up uid|No permissions to create/i.test(text)) return 'not-permitted'
|
||||||
|
return 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(cmd, args, timeoutMs) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
execFile(cmd, args, { timeout: timeoutMs, killSignal: 'SIGKILL' }, (err, stdout, stderr) => {
|
||||||
|
resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status = { backend: 'bwrap' | 'env', version, reason, detail, checkedAt }
|
||||||
|
// reason = null | 'missing' | 'not-permitted' | 'timeout' | 'failed'
|
||||||
|
function createSandboxProbe({ timeoutMs = PROBE_TIMEOUT_MS } = {}) {
|
||||||
|
let cached = null
|
||||||
|
let inflight = null
|
||||||
|
|
||||||
|
const makeStatus = (backend, version, reason, detail) => ({
|
||||||
|
backend,
|
||||||
|
version: version || null,
|
||||||
|
reason: reason || null,
|
||||||
|
detail: detail || '',
|
||||||
|
checkedAt: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function measure() {
|
||||||
|
const trueBin = firstExisting(TRUE_BINARIES)
|
||||||
|
if (!trueBin) {
|
||||||
|
cached = makeStatus('env', null, 'missing', 'no /bin/true to probe with')
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
const versionRun = await run('bwrap', ['--version'], timeoutMs)
|
||||||
|
if (versionRun.err && versionRun.err.code === 'ENOENT') {
|
||||||
|
cached = makeStatus('env', null, 'missing', 'bubblewrap is not installed')
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
const version = firstLine(versionRun.stdout).split(/\s+/).pop() || null
|
||||||
|
const probeRun = await run('bwrap', ['--ro-bind', '/', '/', '--proc', '/proc', '--dev', '/dev', '--', trueBin], timeoutMs)
|
||||||
|
if (probeRun.err) {
|
||||||
|
cached = makeStatus('env', version, reasonFor(probeRun.err, probeRun.stderr), firstLine(probeRun.stderr) || firstLine(probeRun.err.message))
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
cached = makeStatus('bwrap', version, null, '')
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs the probe once and answers from the cache afterwards (3s on every
|
||||||
|
// launch click would be unacceptable).
|
||||||
|
function probe() {
|
||||||
|
if (cached) return Promise.resolve(cached)
|
||||||
|
if (!inflight) inflight = measure().finally(() => { inflight = null })
|
||||||
|
return inflight
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
probe,
|
||||||
|
status: () => cached,
|
||||||
|
invalidate: () => { cached = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host theme data is mounted read-only here and listed in XDG_DATA_DIRS (inside
|
||||||
|
// the private /tmp, where the app cannot mistake it for its own data). A bind,
|
||||||
|
// not a symlink: the prefix is mounted over ~, so a link would point at itself.
|
||||||
|
const SHARE_ROOT = '/tmp/.plwl-host'
|
||||||
|
|
||||||
|
// Relaxed isolation mounts the whole host filesystem writable here. It takes
|
||||||
|
// the private /tmp (bwrap creates the mount point itself) and one fixed string,
|
||||||
|
// so a hand-off into an app that is ALREADY running resolves too.
|
||||||
|
const FS_ROOT = '/tmp/plwl-fs-root'
|
||||||
|
|
||||||
|
// The bwrap profile for a native Linux app. Pure: the caller decides which
|
||||||
|
// devices and host folders exist, so the argv is testable as-is.
|
||||||
|
function buildBwrapArgs({
|
||||||
|
dataRoot,
|
||||||
|
user,
|
||||||
|
home = `/home/${user}`,
|
||||||
|
uid = typeof process.getuid === 'function' ? process.getuid() : 0,
|
||||||
|
devices = [],
|
||||||
|
x11 = '',
|
||||||
|
runUser = '',
|
||||||
|
shares = [],
|
||||||
|
hideHome = false,
|
||||||
|
etcRoot = '',
|
||||||
|
unshareNet = false,
|
||||||
|
appDir = '',
|
||||||
|
fsRoot = false,
|
||||||
|
urlOpener = null
|
||||||
|
}) {
|
||||||
|
const xdgDir = sub => path.join(home, sub)
|
||||||
|
return [
|
||||||
|
'--die-with-parent',
|
||||||
|
'--new-session',
|
||||||
|
// network stays: multiplayer and DNS keep working
|
||||||
|
'--unshare-all',
|
||||||
|
unshareNet ? '--unshare-net' : '--share-net',
|
||||||
|
'--hostname', 'plwl',
|
||||||
|
// --- filesystem ---
|
||||||
|
'--ro-bind', '/', '/',
|
||||||
|
'--proc', '/proc',
|
||||||
|
'--dev', '/dev',
|
||||||
|
...devices.flatMap(device => ['--dev-bind', device, device]),
|
||||||
|
'--tmpfs', '/tmp',
|
||||||
|
// X11 sockets live in /tmp, which the tmpfs above just emptied
|
||||||
|
...(x11 ? ['--ro-bind', x11, x11] : []),
|
||||||
|
// relaxed: the whole host filesystem, writable. Nothing is hidden, and the
|
||||||
|
// app may write anywhere on the machine - that is this level's price.
|
||||||
|
...(fsRoot ? ['--bind', '/', FS_ROOT] : []),
|
||||||
|
// the host's theme data, read-only: fonts, icons, colour schemes
|
||||||
|
...shares.flatMap(dir => ['--ro-bind', dir, path.join(SHARE_ROOT, path.basename(dir))]),
|
||||||
|
// an opened link or document belongs to the host desktop, so xdg-open is
|
||||||
|
// replaced by the launcher's hand-off script (linuxrunner.js)
|
||||||
|
...(urlOpener && urlOpener.shim ? ['--ro-bind', urlOpener.shim, urlOpener.target] : []),
|
||||||
|
// hard profile: nothing of anybody's /home is even readable
|
||||||
|
...(hideHome ? ['--tmpfs', '/home', '--tmpfs', '/root', '--tmpfs', '/mnt', '--tmpfs', '/media'] : []),
|
||||||
|
// --- app data (host paths bound to a fresh HOME) ---
|
||||||
|
'--bind', path.join(dataRoot, 'home'), home,
|
||||||
|
'--bind', path.join(dataRoot, 'config'), xdgDir('.config'),
|
||||||
|
'--bind', path.join(dataRoot, 'data'), xdgDir(path.join('.local', 'share')),
|
||||||
|
'--bind', path.join(dataRoot, 'cache'), xdgDir('.cache'),
|
||||||
|
'--bind', path.join(dataRoot, 'state'), xdgDir(path.join('.local', 'state')),
|
||||||
|
// Wayland, PipeWire, PulseAudio and the session bus all live here
|
||||||
|
...(runUser ? ['--bind', runUser, runUser] : []),
|
||||||
|
...(etcRoot ? ['--bind', etcRoot, '/etc'] : []),
|
||||||
|
// the app's own folder stays writable (portable apps keep logs/data next
|
||||||
|
// to themselves). Last, because an imported app lives under the host's home
|
||||||
|
// and the home bind above would otherwise mount over and hide it - bwrap
|
||||||
|
// then fails with "execvp <app>: No such file or directory".
|
||||||
|
...(appDir ? ['--bind', appDir, appDir] : []),
|
||||||
|
// the bundle's own bin/ faces the shim too (see linuxrunner.js): its own
|
||||||
|
// xdg-open is what an app's PATH reaches first, so the hand-off script has
|
||||||
|
// to sit there as well. After the app bind, so the bundle is in place first.
|
||||||
|
...(urlOpener && urlOpener.shim ? (urlOpener.mounts || []).flatMap(mount => ['--ro-bind', mount.source, mount.target]) : []),
|
||||||
|
// --- environment ---
|
||||||
|
'--setenv', 'HOME', home,
|
||||||
|
'--setenv', 'XDG_CONFIG_HOME', xdgDir('.config'),
|
||||||
|
'--setenv', 'XDG_DATA_HOME', xdgDir(path.join('.local', 'share')),
|
||||||
|
'--setenv', 'XDG_CACHE_HOME', xdgDir('.cache'),
|
||||||
|
'--setenv', 'XDG_STATE_HOME', xdgDir(path.join('.local', 'state'))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createSandboxProbe, buildBwrapArgs, SHARE_ROOT, FS_ROOT }
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
// .desktop shortcut generation. A shortcut launches the launcher itself with
|
// .desktop shortcut generation: a shortcut runs the launcher with
|
||||||
// `--launch <id>`: the main process either starts the game in the running
|
// `--launch <id>` (headless boot when nothing is running yet).
|
||||||
// instance or boots headless (no window, tray only), starts the game and lets
|
|
||||||
// playtime tracking continue normally.
|
|
||||||
|
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
@ -11,8 +9,7 @@ const { DIRS, resolvePortablePath, launcherIconDiskPath } = require('./paths')
|
||||||
|
|
||||||
const DATA_DIR = process.env.PLWL_BUILD_DIR ? path.join(process.env.PLWL_BUILD_DIR, 'files', 'data') : DIRS.DATA
|
const DATA_DIR = process.env.PLWL_BUILD_DIR ? path.join(process.env.PLWL_BUILD_DIR, 'files', 'data') : DIRS.DATA
|
||||||
|
|
||||||
// Where user-visible shortcuts live (XDG). Override with PLWL_SHORTCUTS_DIR
|
// XDG shortcuts dir; PLWL_SHORTCUTS_DIR overrides it (tests).
|
||||||
// so tests (and portable exotic setups) can redirect it.
|
|
||||||
function desktopDirs() {
|
function desktopDirs() {
|
||||||
const home = process.env.HOME || '/'
|
const home = process.env.HOME || '/'
|
||||||
const dataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share')
|
const dataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share')
|
||||||
|
|
@ -22,8 +19,7 @@ function desktopDirs() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Launcher executable as the shortcut will see it, as an argv list: the
|
// The launcher executable as an argv list (packaged binary or electron dev).
|
||||||
// packaged olcxja.plwl/olcxja.plwl binary, or `electron <project>` in dev.
|
|
||||||
function launcherArgv() {
|
function launcherArgv() {
|
||||||
if (process.env.PLWL_LAUNCHER_CMD) return process.env.PLWL_LAUNCHER_CMD.split(' ').filter(Boolean)
|
if (process.env.PLWL_LAUNCHER_CMD) return process.env.PLWL_LAUNCHER_CMD.split(' ').filter(Boolean)
|
||||||
const m = __dirname.match(/^(.*)[/\\]app\.asar([/\\]|$)/)
|
const m = __dirname.match(/^(.*)[/\\]app\.asar([/\\]|$)/)
|
||||||
|
|
@ -36,19 +32,13 @@ function launcherArgv() {
|
||||||
return ['electron', path.resolve(__dirname, '..')]
|
return ['electron', path.resolve(__dirname, '..')]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Desktop Entry Exec field escaping (per the spec): % must be doubled,
|
// Desktop Entry Exec escaping per the spec (% doubled, quotes, spaces).
|
||||||
// quotes and backslashes escaped, and tokens containing spaces wrapped in
|
|
||||||
// double quotes.
|
|
||||||
function execToken(token) {
|
function execToken(token) {
|
||||||
const escaped = token.replace(/%/g, '%%').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
const escaped = token.replace(/%/g, '%%').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||||
return /[ $"'\\]/.test(token) ? `"${escaped}"` : escaped
|
return /[ $"'\\]/.test(token) ? `"${escaped}"` : escaped
|
||||||
}
|
}
|
||||||
|
|
||||||
// The `%f` file field is appended AFTER escaping (a literal % would have been
|
// %f appended after escaping makes the shortcut a file handler.
|
||||||
// doubled into a literal). It makes the shortcut a file handler: the file
|
|
||||||
// manager substitutes the opened file there - and removes the field when the
|
|
||||||
// shortcut is launched from the menu - so "Open with" on the shortcut runs the
|
|
||||||
// app with that file, like it does on Windows.
|
|
||||||
function execLine(extraArgs) {
|
function execLine(extraArgs) {
|
||||||
const tokens = [...launcherArgv(), ...String(extraArgs).split(' ').filter(Boolean)]
|
const tokens = [...launcherArgv(), ...String(extraArgs).split(' ').filter(Boolean)]
|
||||||
return [...tokens.map(execToken), '%f'].join(' ')
|
return [...tokens.map(execToken), '%f'].join(' ')
|
||||||
|
|
@ -68,8 +58,7 @@ function buildDesktopEntry(game) {
|
||||||
if (found) icon = path.join(iconDir, found)
|
if (found) icon = path.join(iconDir, found)
|
||||||
} catch {}
|
} catch {}
|
||||||
if (!icon) {
|
if (!icon) {
|
||||||
// fall back to the launcher icon so the shortcut is never naked (the
|
// on-disk launcher icon fallback (Icon= cannot read the asar)
|
||||||
// on-disk copy: the desktop environment cannot read inside app.asar)
|
|
||||||
icon = launcherIconDiskPath() || ''
|
icon = launcherIconDiskPath() || ''
|
||||||
}
|
}
|
||||||
const lines = [
|
const lines = [
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,15 @@
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
// Symlink and permission healing, shared by wine builds, prefixes, game
|
// Symlink and permission healing, shared by wine builds, prefixes, game
|
||||||
// imports and LAN sync.
|
// imports and LAN sync. Rewires links to relative paths (absolute always;
|
||||||
//
|
// relative only when broken) and restores exec bits on build binaries.
|
||||||
// Two things break when a tree is copied between machines (or the launcher is
|
|
||||||
// just moved):
|
|
||||||
// - symlinks stored ABSOLUTE keep pointing at the path they had on the
|
|
||||||
// machine that wrote them. A Proton prefix is full of them (fonts, built-in
|
|
||||||
// program stubs, dlls, all pointing into its build), and a wine build's
|
|
||||||
// bundled default prefix has the same problem.
|
|
||||||
// - the executable bit is dropped by every content-piping copy
|
|
||||||
// (createWriteStream, fs.copyFile), which makes an imported `proton` or
|
|
||||||
// `wine` binary unrunnable - the launcher then cannot start that build at
|
|
||||||
// all.
|
|
||||||
//
|
|
||||||
// Everything here rewires a link to a RELATIVE path when the file it points at
|
|
||||||
// can be found locally, so the tree stays relocatable forever, and restores
|
|
||||||
// the executable bit on the binaries a wine/proton build needs. An absolute
|
|
||||||
// link is always rewritten; a relative one is only touched when it no longer
|
|
||||||
// resolves, because a tree that changed layout (a prefix whose contents moved
|
|
||||||
// one level up, see prefixes.ensureUnifiedPrefix) keeps relative links written
|
|
||||||
// against the old depth and every one of them breaks at once.
|
|
||||||
|
|
||||||
const fsp = require('fs').promises
|
const fsp = require('fs').promises
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
const { pathExists: exists } = require('./fsutil')
|
||||||
|
|
||||||
function exists(p) {
|
// dosdevices/ mappings are supposed to point outside the prefix; never touched.
|
||||||
try {
|
|
||||||
fs.accessSync(p)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dosdevices/ holds the prefix's drive mappings (c:, z:, d:, com1 ...). They
|
|
||||||
// are *supposed* to point outside the prefix - at the filesystem root, a
|
|
||||||
// mounted CD or a serial port - and wine rewrites them itself, so they are
|
|
||||||
// never touched.
|
|
||||||
const SKIP_DIRS = new Set(['dosdevices'])
|
const SKIP_DIRS = new Set(['dosdevices'])
|
||||||
|
|
||||||
async function collectSymlinks(dir, out) {
|
async function collectSymlinks(dir, out) {
|
||||||
|
|
@ -55,18 +26,12 @@ async function collectSymlinks(dir, out) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A target that does not exist here YET can only be something PLWL itself
|
// A missing target can only be something PLWL creates later (fake-steam,
|
||||||
// creates later: Proton links files/data/fake-steam/appcache into its prefix,
|
// files/wines/...); the restriction keeps /usr/lib/... links alone.
|
||||||
// a prefix links into files/wines/<build>/... (see the fallback pass below).
|
|
||||||
// Restricting the fallback to these trees keeps an unrelated /usr/lib/... link
|
|
||||||
// from being mistaken for a wine build's own files/lib/ directory.
|
|
||||||
const ANCHOR_DIRS = new Set(['wines', 'wineprefixes', 'apps', 'data', 'translations'])
|
const ANCHOR_DIRS = new Set(['wines', 'wineprefixes', 'apps', 'data', 'translations'])
|
||||||
|
|
||||||
// True when `candidate` sits under `root` and its deepest EXISTING parent is at
|
// True when `candidate` sits under `root` and its deepest existing parent is
|
||||||
// least two components inside it. `files/data/fake-steam/appcache` passes even
|
// at least two components inside it.
|
||||||
// before appcache is created, because files/data/fake-steam is already there;
|
|
||||||
// `files/bin/<x>` does not, because a single-component parent is no evidence
|
|
||||||
// that the link really belongs to the launcher.
|
|
||||||
function anchoredInside(root, candidate) {
|
function anchoredInside(root, candidate) {
|
||||||
let p = candidate
|
let p = candidate
|
||||||
while (p.length > root.length + 1) {
|
while (p.length > root.length + 1) {
|
||||||
|
|
@ -77,12 +42,8 @@ function anchoredInside(root, candidate) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// The new target for an absolute link: the same file, reached from the link's
|
// The new target for an absolute link, relative to the link's own directory;
|
||||||
// own directory. `roots` are the trees the target may live in - the tree being
|
// falls back to the longest suffix that exists locally.
|
||||||
// healed first, then (for a prefix linking into its build) the launcher's
|
|
||||||
// files/ root. When the target is not directly inside one of them, the longest
|
|
||||||
// suffix that exists here is used instead, which is what makes a link written
|
|
||||||
// on another machine work on this one.
|
|
||||||
function relativeTarget(link, target, roots) {
|
function relativeTarget(link, target, roots) {
|
||||||
const dir = path.dirname(link)
|
const dir = path.dirname(link)
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
|
|
@ -92,9 +53,7 @@ function relativeTarget(link, target, roots) {
|
||||||
if (rel && !path.isAbsolute(rel)) return rel
|
if (rel && !path.isAbsolute(rel)) return rel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Second chance for links whose file is not here yet (or whose parent chain
|
// second chance for links whose file is not here yet
|
||||||
// only partially exists): the relative path is still the right one, and
|
|
||||||
// writing it now beats leaving a link that can never resolve.
|
|
||||||
for (const requireLeaf of [true, false]) {
|
for (const requireLeaf of [true, false]) {
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
if (!root) continue
|
if (!root) continue
|
||||||
|
|
@ -120,11 +79,8 @@ function relativeTarget(link, target, roots) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rewire the symlinks under `root` that can be expressed relative to `roots`
|
// Rewire rewritable symlinks under `root`; returns how many changed. Repeats
|
||||||
// (usually [root, DIRS.FILES]): absolute ones always, relative ones only when
|
// until nothing changes (links can chain).
|
||||||
// they no longer resolve. Returns how many links changed. A link may only
|
|
||||||
// become rewirable once another link it chains through has been fixed, so the
|
|
||||||
// pass repeats until nothing changes.
|
|
||||||
async function rewireSymlinks(root, roots = []) {
|
async function rewireSymlinks(root, roots = []) {
|
||||||
if (!root) return 0
|
if (!root) return 0
|
||||||
const candidates = [root, ...roots.filter(r => r && r !== root)]
|
const candidates = [root, ...roots.filter(r => r && r !== root)]
|
||||||
|
|
@ -141,9 +97,7 @@ async function rewireSymlinks(root, roots = []) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const absolute = path.isAbsolute(target)
|
const absolute = path.isAbsolute(target)
|
||||||
// a relative link that still resolves is already relocatable - only a
|
// a relative link that still resolves is already relocatable
|
||||||
// broken one is worth touching, and the suffix search in relativeTarget
|
|
||||||
// finds the file it was pointing at before the layout changed
|
|
||||||
if (!absolute && exists(path.resolve(path.dirname(link), target))) continue
|
if (!absolute && exists(path.resolve(path.dirname(link), target))) continue
|
||||||
const from = absolute ? path.normalize(target) : path.resolve(path.dirname(link), target)
|
const from = absolute ? path.normalize(target) : path.resolve(path.dirname(link), target)
|
||||||
const next = relativeTarget(link, from, candidates)
|
const next = relativeTarget(link, from, candidates)
|
||||||
|
|
@ -160,15 +114,11 @@ async function rewireSymlinks(root, roots = []) {
|
||||||
return fixed
|
return fixed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binaries a wine/proton build has to be able to execute. A copy that pipes
|
// Binaries a wine/proton build must be able to execute; content copies drop
|
||||||
// file contents (the LAN sync, fs.copyFile) drops the bit, and without it
|
// the bit and spawn fails with EACCES. Windows .exe files need no bit.
|
||||||
// `spawn` fails with EACCES and the build can never run. Windows .exe files
|
|
||||||
// are NOT in here on purpose: wine starts those itself, they need no bit.
|
|
||||||
const EXEC_NAMES = new Set(['proton', 'wine', 'wine64', 'wine-preloader', 'wine64-preloader', 'wineserver', 'winetricks', 'wineboot'])
|
const EXEC_NAMES = new Set(['proton', 'wine', 'wine64', 'wine-preloader', 'wine64-preloader', 'wineserver', 'winetricks', 'wineboot'])
|
||||||
|
|
||||||
// Give the exec bit back to a build's binaries (and anything in a bin/ dir).
|
// Give the exec bit back to a build's binaries and bin/ dirs.
|
||||||
// Used after a sync or an import - and to repair a build that was synced
|
|
||||||
// before the sync knew about permissions, without re-copying it.
|
|
||||||
async function restoreExecutableBits(root) {
|
async function restoreExecutableBits(root) {
|
||||||
if (!root) return 0
|
if (!root) return 0
|
||||||
let fixed = 0
|
let fixed = 0
|
||||||
|
|
|
||||||
163
src/wines.js
163
src/wines.js
|
|
@ -4,7 +4,7 @@ const fs = require('fs')
|
||||||
const fsp = fs.promises
|
const fsp = fs.promises
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
|
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
|
||||||
const { dedupeDir, copyTree, treeSize, removeTree } = require('./fsutil')
|
const { dedupeDir, copyTree, treeSize, removeTree, pathExists } = require('./fsutil')
|
||||||
const { normalizeLabel } = require('./names')
|
const { normalizeLabel } = require('./names')
|
||||||
const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks')
|
const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks')
|
||||||
|
|
||||||
|
|
@ -12,14 +12,7 @@ const WINES_DIR = DIRS.WINES
|
||||||
const REGISTRY = path.join(DIRS.DATA, 'wines.json')
|
const REGISTRY = path.join(DIRS.DATA, 'wines.json')
|
||||||
const DEFAULT_KEY = 'defaultWine'
|
const DEFAULT_KEY = 'defaultWine'
|
||||||
|
|
||||||
function exists(p) {
|
const exists = pathExists
|
||||||
try {
|
|
||||||
fs.accessSync(p)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectType(dir) {
|
function detectType(dir) {
|
||||||
if (exists(path.join(dir, 'proton'))) return 'proton'
|
if (exists(path.join(dir, 'proton'))) return 'proton'
|
||||||
|
|
@ -65,17 +58,8 @@ async function scanBuild(dir, depth) {
|
||||||
path: dir,
|
path: dir,
|
||||||
type
|
type
|
||||||
}
|
}
|
||||||
if (type === 'proton') {
|
if (type === 'proton') entry.wineBin = path.join(dir, 'proton')
|
||||||
entry.wineBin = path.join(dir, 'proton')
|
else if (type === 'wine') entry.wineBin = findWineBinary(dir) || undefined
|
||||||
} else if (type === 'wine') {
|
|
||||||
const bin = findWineBinary(dir)
|
|
||||||
if (bin) {
|
|
||||||
entry.wineBin = bin
|
|
||||||
const ver = versionOf(bin, {})
|
|
||||||
if (ver) entry.version = ver
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entry.label = labelFor(type, dir, entry.version)
|
|
||||||
return [entry]
|
return [entry]
|
||||||
}
|
}
|
||||||
if (depth <= 0) return []
|
if (depth <= 0) return []
|
||||||
|
|
@ -110,12 +94,8 @@ async function refreshWines() {
|
||||||
}
|
}
|
||||||
const prev = readJson(REGISTRY, []).map(wineAbs)
|
const prev = readJson(REGISTRY, []).map(wineAbs)
|
||||||
const prevById = new Map(prev.map(w => [w.id, w]))
|
const prevById = new Map(prev.map(w => [w.id, w]))
|
||||||
// A build whose path changed since last scan was moved (or the launcher
|
// heal moved builds and builds missing their exec bit; only a broken build
|
||||||
// itself was) - rewire its absolute symlinks to the current location. A
|
// pays for the walk
|
||||||
// build that cannot be executed (no exec bit on proton/wine - exactly what
|
|
||||||
// a sync from a PLWL that predates permission transfer leaves behind) is
|
|
||||||
// healed here instead: two access() calls per build, and only a broken build
|
|
||||||
// pays for the walk. Without it such a build stays dead weight forever.
|
|
||||||
for (const w of found) {
|
for (const w of found) {
|
||||||
const old = prevById.get(w.id)
|
const old = prevById.get(w.id)
|
||||||
const moved = !old || old.path !== w.path
|
const moved = !old || old.path !== w.path
|
||||||
|
|
@ -126,10 +106,16 @@ async function refreshWines() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const disabled = new Set(prev.filter(w => w.disabled).map(w => w.id))
|
const disabled = new Set(prev.filter(w => w.disabled).map(w => w.id))
|
||||||
// a display name the user gave the build has to survive every rescan, just
|
// display name and disabled flag live in the registry and survive rescans
|
||||||
// like the disabled flag (both live in the registry, not in the folder)
|
|
||||||
const renamed = new Map(prev.filter(w => w.displayName).map(w => [w.id, w.displayName]))
|
const renamed = new Map(prev.filter(w => w.displayName).map(w => [w.id, w.displayName]))
|
||||||
for (const w of found) {
|
for (const w of found) {
|
||||||
|
const old = prevById.get(w.id)
|
||||||
|
// `wine --version` spawns a process; an unchanged build keeps its answer
|
||||||
|
if (w.type === 'wine' && w.wineBin) {
|
||||||
|
if (old && old.path === w.path && old.wineBin === w.wineBin && old.version) w.version = old.version
|
||||||
|
else w.version = versionOf(w.wineBin, {})
|
||||||
|
}
|
||||||
|
w.label = labelFor(w.type, w.path, w.version)
|
||||||
if (disabled.has(w.id)) w.disabled = true
|
if (disabled.has(w.id)) w.disabled = true
|
||||||
if (renamed.has(w.id)) w.displayName = renamed.get(w.id)
|
if (renamed.has(w.id)) w.displayName = renamed.get(w.id)
|
||||||
applyDisplayName(w)
|
applyDisplayName(w)
|
||||||
|
|
@ -138,8 +124,7 @@ async function refreshWines() {
|
||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stored relative to FILES (portable), resolved to absolute for use - same
|
// Stored FILES-relative (portable); `id`/`name`/`label`/`version`/`type` pass through.
|
||||||
// scheme as the game registry. `id`, `name`, `label`, `version`, `type` pass through.
|
|
||||||
function wineAbs(w) {
|
function wineAbs(w) {
|
||||||
return applyDisplayName({ ...w, path: resolvePortablePath(w.path), wineBin: resolvePortablePath(w.wineBin) })
|
return applyDisplayName({ ...w, path: resolvePortablePath(w.path), wineBin: resolvePortablePath(w.wineBin) })
|
||||||
}
|
}
|
||||||
|
|
@ -156,46 +141,71 @@ function getWine(id) {
|
||||||
return getWines().find(w => w.id === id) || null
|
return getWines().find(w => w.id === id) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the user typed wins over the folder-derived label everywhere the
|
// The user's label wins over the folder-derived one, everywhere.
|
||||||
// build is shown (library config, settings, Open with popup). Every registry
|
|
||||||
// read goes through here, so a stale stored label can never leak out.
|
|
||||||
function applyDisplayName(w) {
|
function applyDisplayName(w) {
|
||||||
if (w && w.displayName) w.label = w.displayName
|
if (w && w.displayName) w.label = w.displayName
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
// A build keeps its folder and id (apps point at the id, and prefix symlinks
|
// "Renaming" only changes the shown label; folder and id stay.
|
||||||
// point inside the folder), so "renaming" only changes the shown label.
|
|
||||||
function setWineDisplayName(id, name) {
|
function setWineDisplayName(id, name) {
|
||||||
const clean = normalizeLabel(name)
|
const clean = normalizeLabel(name)
|
||||||
if (!clean) return { ok: false, error: 'invalidName' }
|
if (!clean) return { ok: false, error: 'invalidName' }
|
||||||
return writeWineLabel(id, clean)
|
return writeWineLabel(id, clean)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clears a custom label back to the folder-derived one: what the LAN sync
|
// Clears a custom label back to the folder-derived one (used by LAN sync).
|
||||||
// calls when the sender never renamed the build. The rename dialog never
|
|
||||||
// clears - an empty name there is an error (setWineDisplayName above).
|
|
||||||
function clearWineDisplayName(id) {
|
function clearWineDisplayName(id) {
|
||||||
return writeWineLabel(id, '')
|
return writeWineLabel(id, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An empty label means "use the folder-derived one".
|
||||||
|
function applyWineLabel(w, clean) {
|
||||||
|
if (clean) {
|
||||||
|
w.displayName = clean
|
||||||
|
w.label = clean
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete w.displayName
|
||||||
|
w.label = labelFor(w.type, w.path, w.version)
|
||||||
|
}
|
||||||
|
|
||||||
function writeWineLabel(id, clean) {
|
function writeWineLabel(id, clean) {
|
||||||
const wines = getWines()
|
const wines = getWines()
|
||||||
const w = wines.find(x => x.id === id)
|
const w = wines.find(x => x.id === id)
|
||||||
if (!w) return { ok: false, error: 'notFound' }
|
if (!w) return { ok: false, error: 'notFound' }
|
||||||
if (w.type === 'system') return { ok: false, error: 'isSystem' }
|
if (w.type === 'system') return { ok: false, error: 'isSystem' }
|
||||||
if (clean) {
|
if (!clean && !w.displayName) return { ok: true, displayName: '' }
|
||||||
w.displayName = clean
|
applyWineLabel(w, clean)
|
||||||
w.label = clean
|
|
||||||
} else {
|
|
||||||
if (!w.displayName) return { ok: true, displayName: '' }
|
|
||||||
delete w.displayName
|
|
||||||
w.label = labelFor(w.type, w.path, w.version)
|
|
||||||
}
|
|
||||||
writeJson(REGISTRY, wines.map(wineRel))
|
writeJson(REGISTRY, wines.map(wineRel))
|
||||||
return { ok: true, displayName: clean }
|
return { ok: true, displayName: clean }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LAN sync: display names + disabled flags for many builds, one registry write.
|
||||||
|
function applySettings(entries) {
|
||||||
|
const wines = getWines()
|
||||||
|
const byId = new Map(wines.map(w => [w.id, w]))
|
||||||
|
let applied = 0
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry || typeof entry.name !== 'string') continue
|
||||||
|
const wine = byId.get(entry.name)
|
||||||
|
if (!wine || wine.type === 'system') continue
|
||||||
|
let changed = false
|
||||||
|
const wanted = normalizeLabel(typeof entry.displayName === 'string' ? entry.displayName : '')
|
||||||
|
if (wanted !== (wine.displayName || '')) {
|
||||||
|
applyWineLabel(wine, wanted)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (entry.disabled !== undefined && !!entry.disabled !== !!wine.disabled) {
|
||||||
|
wine.disabled = !!entry.disabled
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) applied++
|
||||||
|
}
|
||||||
|
if (applied) writeJson(REGISTRY, wines.map(wineRel))
|
||||||
|
return applied
|
||||||
|
}
|
||||||
|
|
||||||
function setWineDisabled(id, disabled) {
|
function setWineDisabled(id, disabled) {
|
||||||
const wines = getWines()
|
const wines = getWines()
|
||||||
const w = wines.find(x => x.id === id)
|
const w = wines.find(x => x.id === id)
|
||||||
|
|
@ -210,9 +220,7 @@ function getDefaultWine() {
|
||||||
return require('./library').loadSettings().defaultWine || 'system'
|
return require('./library').loadSettings().defaultWine || 'system'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the wine build a run should use: 'default'/'system'/unknown all
|
// 'default'/'system'/unknown all fall back to System Wine.
|
||||||
// fall back to System Wine. Shared by the games service and the ad-hoc
|
|
||||||
// "open with" runner.
|
|
||||||
function resolveWine(id) {
|
function resolveWine(id) {
|
||||||
const system = { id: 'system', type: 'system', label: 'System Wine' }
|
const system = { id: 'system', type: 'system', label: 'System Wine' }
|
||||||
const wanted = id === 'default' ? getDefaultWine() : id
|
const wanted = id === 'default' ? getDefaultWine() : id
|
||||||
|
|
@ -239,28 +247,25 @@ async function deleteWine(id, onProgress) {
|
||||||
emit('count', { files: 0, bytes: 0 })
|
emit('count', { files: 0, bytes: 0 })
|
||||||
const total = await treeSize(w.path)
|
const total = await treeSize(w.path)
|
||||||
await removeTree(w.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
await removeTree(w.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
||||||
|
// the Proton compat link points into data/, not into the build we just removed
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(path.join(DIRS.DATA, 'compat', id))
|
||||||
|
} catch {} // no link (plain wine build) or already gone - both fine
|
||||||
writeJson(REGISTRY, wines.filter(x => x.id !== id).map(wineRel))
|
writeJson(REGISTRY, wines.filter(x => x.id !== id).map(wineRel))
|
||||||
if (getDefaultWine() === id) require('./library').saveSettings({ defaultWine: 'system' })
|
if (getDefaultWine() === id) require('./library').saveSettings({ defaultWine: 'system' })
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- symlink healing -------------------------------------------------------
|
// --- symlink healing -------------------------------------------------------
|
||||||
// Wine/Proton builds contain symlinks (e.g. inside the bundled default prefix)
|
// Builds contain absolute symlinks that dangle after a move and crash Proton
|
||||||
// that point to absolute paths from wherever the build lived before (zip
|
// during prefix setup; the rewiring lives in symlinks.js.
|
||||||
// layout, previous install dir). After the build or the whole launcher is
|
|
||||||
// moved those links dangle and Proton crashes during prefix setup
|
|
||||||
// (try_copy -> FileNotFoundError). The rewiring itself lives in symlinks.js
|
|
||||||
// because prefixes, synced apps and game imports need exactly the same thing.
|
|
||||||
async function fixBuildSymlinks(root) {
|
async function fixBuildSymlinks(root) {
|
||||||
return rewireSymlinks(root, [DIRS.FILES])
|
return rewireSymlinks(root, [DIRS.FILES])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything a freshly arrived build needs before it can run: relative links
|
// Make a freshly arrived build runnable: relative links, exec bits.
|
||||||
// and executable binaries. Returns what was changed, for the callers' reports.
|
|
||||||
async function healBuild(root) {
|
async function healBuild(root) {
|
||||||
// a build folder that IS a symlink (kept on another disk) is left alone:
|
// a symlinked build folder is left alone: its target may be outside
|
||||||
// the walk would follow the link and edit its target, which is not
|
|
||||||
// necessarily part of this launcher's trees
|
|
||||||
let st = null
|
let st = null
|
||||||
try {
|
try {
|
||||||
st = fs.lstatSync(root)
|
st = fs.lstatSync(root)
|
||||||
|
|
@ -282,16 +287,11 @@ function isExec(p) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Where a wine/proton build keeps the programs wine itself spawns (wine,
|
// Where a build keeps the programs wine spawns (wine, wineserver, ...).
|
||||||
// wineserver, wineboot, ...) and the loaders next to the shared libraries.
|
|
||||||
const AUDIT_DIRS = ['files/bin', 'bin', 'dist/bin', 'files/lib/wine/x86_64-unix', 'files/lib/wine/i386-unix']
|
const AUDIT_DIRS = ['files/bin', 'bin', 'dist/bin', 'files/lib/wine/x86_64-unix', 'files/lib/wine/i386-unix']
|
||||||
|
|
||||||
// Is this build missing a permission it needs to run? A build synced from a
|
// Cheap audit for a build missing its exec bits (invisible until Play fails
|
||||||
// PLWL that predates permission transfer has no executable bit anywhere, and
|
// with EACCES); only a failing build pays for the full walk.
|
||||||
// that stays invisible until the user presses Play (spawn fails with EACCES).
|
|
||||||
// Auditing is a handful of directory listings - only a build that FAILS it
|
|
||||||
// pays for the full walk, and the audit says nothing about a healthy build on
|
|
||||||
// every later start.
|
|
||||||
function buildAuditFailed(root, wineBin) {
|
function buildAuditFailed(root, wineBin) {
|
||||||
if (!root) return false
|
if (!root) return false
|
||||||
if (wineBin && !isExec(wineBin)) return true
|
if (wineBin && !isExec(wineBin)) return true
|
||||||
|
|
@ -326,14 +326,18 @@ async function importWineFolder(srcDir, onProgress) {
|
||||||
if (build) copySrc = path.join(srcDir, build.name)
|
if (build) copySrc = path.join(srcDir, build.name)
|
||||||
}
|
}
|
||||||
const dst = dedupeDir(WINES_DIR, path.basename(copySrc))
|
const dst = dedupeDir(WINES_DIR, path.basename(copySrc))
|
||||||
// a wine/proton build is big enough that the copy needs a real bar: count it
|
// count first (staging), then report bytes as they land
|
||||||
// first (staging), then report bytes as they land while it copies
|
|
||||||
const emit = (phase, detail) => {
|
const emit = (phase, detail) => {
|
||||||
if (onProgress) onProgress({ phase, label: path.basename(dst), ...detail })
|
if (onProgress) onProgress({ phase, label: path.basename(dst), ...detail })
|
||||||
}
|
}
|
||||||
emit('count', { files: 0, bytes: 0 })
|
emit('count', { files: 0, bytes: 0 })
|
||||||
const total = await treeSize(copySrc)
|
const total = await treeSize(copySrc)
|
||||||
await copyTree(copySrc, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
const copied = await copyTree(copySrc, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
||||||
|
if (copied.failed) {
|
||||||
|
// a build missing files fails at the first Proton launch; do not register it
|
||||||
|
await fsp.rm(dst, { recursive: true, force: true })
|
||||||
|
throw new Error('copy-incomplete')
|
||||||
|
}
|
||||||
let healed = { links: 0, executables: 0 }
|
let healed = { links: 0, executables: 0 }
|
||||||
try {
|
try {
|
||||||
healed = await healBuild(dst)
|
healed = await healBuild(dst)
|
||||||
|
|
@ -343,4 +347,19 @@ async function importWineFolder(srcDir, onProgress) {
|
||||||
return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables }
|
return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }
|
// Pseudo entry for the distro's wine; version probe is best-effort and cached:
|
||||||
|
// this runs on every library refresh, and a synchronous spawn blocked the UI.
|
||||||
|
let systemVersion = null
|
||||||
|
function systemWine() {
|
||||||
|
if (systemVersion === null) {
|
||||||
|
try {
|
||||||
|
const { execFileSync } = require('child_process')
|
||||||
|
systemVersion = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
|
||||||
|
} catch {
|
||||||
|
systemVersion = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: 'system', type: 'system', label: 'System Wine', version: systemVersion }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { refreshWines, getWines, getWine, resolveWine, systemWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, applySettings, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue