Compare commits
16 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aee4d3d1f2 | |||
| be4f67fb60 | |||
| cd0007fc7d | |||
| 116ba112ab | |||
| 3a25325f20 | |||
| f4e770a7db | |||
| 6776dfb5ce | |||
| d58967237c | |||
| d21b873327 | |||
| 4680d7f014 | |||
| be47433c32 | |||
| bf5abeef73 | |||
| 1bbc71166e | |||
| 9748b98163 | |||
| 3f57682a9b | |||
| 2ff8920225 |
32 changed files with 2644 additions and 759 deletions
35
README.md
35
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 — and for native Linux apps, run with their own isolated home (bubblewrap when available).
|
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
|
||||||
|
|
||||||
|
|
@ -42,23 +42,34 @@ The import dialog starts with a choice: **Windows app (Wine / Proton)** or **Lin
|
||||||
|
|
||||||
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.
|
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 machine-local — a LAN sync never pushes it and never deletes it.
|
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 app also gets its own namespaces: `/` is mounted read-only, so only **its home folder and its own app folder stay writable** — anything else fails with `EROFS`. 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.
|
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 `~`.
|
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.
|
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.
|
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.
|
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** policy: *Automatic* (bwrap when it works, plain run when it does not), *Off*, or *Required* (refuse to start instead of running unsandboxed). The backend actually used 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.
|
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"
|
## "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.
|
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
|
||||||
|
|
||||||
|
|
@ -66,13 +77,19 @@ Two PLWL installs on one LAN can mirror each other (sidebar → **LAN syncing**)
|
||||||
|
|
||||||
- **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.
|
- **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.
|
||||||
- **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** are found over UDP and visible only while PLWL runs there (a few seconds after it closes they are gone).
|
||||||
- Pick what to push: **portable apps**, **prefix-imported apps**, **Wine/Proton builds**, **prefixes**, **Sync settings**. 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 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.
|
||||||
|
|
||||||
|
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 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).
|
**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 **sender is the source of truth**: differing files are overwritten, missing ones written, extras inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) deleted. 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.
|
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.
|
||||||
|
|
||||||
A sync also carries **how apps are set up**: wine build and prefix, args, env, DLL overrides, 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.
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,17 @@ src/ main process (Node/Electron backend)
|
||||||
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) and owns
|
runner.js spawns wine/proton (env, prefix, playtime, logs) and owns
|
||||||
spawnTracked() - the log/playtime/exit tail every run shares
|
spawnTracked() - the log/playtime/exit tail every run shares
|
||||||
linuxrunner.js native Linux apps: redirected HOME/XDG, bwrap or plain run
|
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
|
linuxapp.js native app detection (ELF/AppImage/.desktop) + icon lookup
|
||||||
sandbox.js bubblewrap probe (functional, cached) + buildBwrapArgs
|
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
|
||||||
|
|
@ -38,6 +44,8 @@ src/ main process (Node/Electron backend)
|
||||||
(with its receive-progress hook); uses hashcache.js,
|
(with its receive-progress hook); uses hashcache.js,
|
||||||
lansync-identity.js, lansync-manifest.js, lansync-discovery.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
|
hashcache.js file hash cache (path -> size/mtime/hash) for LAN sync
|
||||||
lansync-identity.js identity + sync code (createIdentity factory)
|
lansync-identity.js identity + sync code (createIdentity factory)
|
||||||
lansync-manifest.js sender-side manifest building (createManifestBuilder)
|
lansync-manifest.js sender-side manifest building (createManifestBuilder)
|
||||||
|
|
@ -64,43 +72,61 @@ Renderer never touches Node/Electron directly — everything goes through the `w
|
||||||
- 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 helper and call it from both places.
|
||||||
- Everything user-visible goes through the translation files (en-us.json, pl.json) with `{0}`, `{1}` placeholders. No hardcoded strings in app.js.
|
- Everything user-visible goes through the translation files (en-us.json, pl.json) with `{0}`, `{1}` placeholders. No hardcoded strings in app.js.
|
||||||
- Paths stored on disk are FILES-relative (portable layout): write with toPortablePath(), read with resolvePortablePath() — never store absolute paths for anything inside files/.
|
- Paths stored on disk are FILES-relative (portable layout): write with toPortablePath(), read with resolvePortablePath() — never store absolute paths for anything inside files/.
|
||||||
- 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.
|
- 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`).
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- Windows apps run through runGame()/buildEnv() — never spawn wine directly elsewhere (prefix handling, Proton env and playtime live there).
|
- Windows apps run through runGame()/buildEnv() — never spawn wine directly elsewhere (prefix handling, Proton env and playtime live there).
|
||||||
- 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 `auto` (bwrap when usable, plain run otherwise), `off` or `require` (refuse to start instead of quietly weakening). The chosen backend always lands in the log and the UI (a silent downgrade is the failure mode to avoid).
|
- 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).
|
||||||
- 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"). Everything else is `EROFS` by design.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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).
|
||||||
- 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.
|
- 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
### Native Linux apps
|
### Native Linux apps
|
||||||
|
|
||||||
- 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.
|
- 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 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 prefix from an older launcher is adopted on evidence (byte-identical with the host, or the copy is older than the host's file). 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- `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.
|
- `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.
|
||||||
- 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").
|
- 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").
|
||||||
- Imports normalise permissions (fsutil.chmodTree): owner `rwx`, `x` mirrored from `r` (0644 → 0755). Sources are archives, FAT sticks and read-only backups.
|
- Imports normalise permissions (fsutil.chmodTree): owner `rwx`, `x` mirrored from `r` (0644 → 0755). Sources are archives, FAT sticks and read-only backups.
|
||||||
- **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).
|
- **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).
|
||||||
- The user's real `~` is deliberately invisible in the sandbox (HOME is bound straight to `{prefix}/linux_pfx/home`), so absolute paths an app wrote into its config before the import (Prism's instance `JavaPath`) do not resolve — that is what `isolation: 'off'` is for.
|
- 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.
|
||||||
- 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) and `dataMode` — app data always lands in `{prefix}/linux_pfx`.
|
- 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.
|
||||||
|
- 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 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.
|
||||||
|
- 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.
|
||||||
|
- 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.)
|
||||||
|
- 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.
|
||||||
|
- `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.
|
||||||
|
- 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`.
|
||||||
- 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.
|
- 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.
|
||||||
|
|
||||||
### LAN sync
|
### LAN sync
|
||||||
|
|
||||||
- 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).
|
- 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).
|
||||||
|
- **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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
- 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).
|
- 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).
|
||||||
- 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).
|
- 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).
|
||||||
- Registry-level settings are opt-in (the "settings" category, old name "defaults" still accepted as an alias on both sides). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu 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 (setWineDisplayName, library.updateGame, reorderGames).
|
- 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`.
|
||||||
- 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.
|
- 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.
|
||||||
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
|
- 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).
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- 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/.
|
||||||
- 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).
|
- 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).
|
||||||
- Native Linux app data (`{prefix}/linux_pfx/`: the redirected home, caches, PulseAudio cookie, machine-local state) is MACHINE-LOCAL: the sender's manifest skips it and the receiver's pruneTree refuses to delete it (prefixes.isLocalOnlyRel, shared by lansync-manifest.js and lansync.js). Otherwise a mirrored prefix sync would either push a huge cache or delete the other machine's app data.
|
- 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.
|
||||||
- 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/).
|
- 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/).
|
||||||
- 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 what arrived from an older PLWL. 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).
|
- 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).
|
||||||
|
|
||||||
### Paths and prefixes
|
### Paths and prefixes
|
||||||
|
|
||||||
|
|
@ -129,11 +155,15 @@ node test/lansync-pending-links.test.js # links to files created later, startup
|
||||||
node test/lansync-consent.test.js # accept / decline / timeout gate
|
node test/lansync-consent.test.js # accept / decline / timeout gate
|
||||||
node test/lansync-token.test.js # long-sync token expiry (sliding TTL)
|
node test/lansync-token.test.js # long-sync token expiry (sliding TTL)
|
||||||
node test/bench-sync.js # timing: cold vs cached sync
|
node test/bench-sync.js # timing: cold vs cached sync
|
||||||
node test/linux-sandbox.test.js # bwrap argv profile + auto/off/require choice
|
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-env.test.js # redirected HOME/XDG_* for both backends
|
||||||
node test/linux-import.test.js # ELF/AppImage/.desktop detection + icons
|
node test/linux-import.test.js # ELF/AppImage/.desktop detection + icons
|
||||||
node test/linux-sync.test.js # linux_pfx stays out of a sync
|
node test/linux-links.test.js # link opening: shim binds + mime seed
|
||||||
node test/linux-e2e.js # REAL run: plain + bwrap (/etc read-only, home in prefix)
|
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
|
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):
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@
|
||||||
"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.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.autoLinux": "Extract icon",
|
||||||
|
|
@ -93,10 +94,15 @@
|
||||||
"config.openData": "Open data folder",
|
"config.openData": "Open data folder",
|
||||||
"config.linuxKindValue": "Linux",
|
"config.linuxKindValue": "Linux",
|
||||||
"config.isolation": "Isolation",
|
"config.isolation": "Isolation",
|
||||||
"config.isolation.auto": "Automatic (strongest available)",
|
"config.isolation.auto": "Automatic",
|
||||||
"config.isolation.off": "Off (plain run)",
|
"config.isolation.none": "None",
|
||||||
"config.isolation.require": "Required (refuse to run without bwrap)",
|
"config.isolation.relaxed": "Relaxed",
|
||||||
"config.isolation.bwrap": "bwrap {0} (strong)",
|
"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.isolation.env": "no sandbox: {0}",
|
||||||
"config.forceX11": "Force X11 (XWayland)",
|
"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.forceX11Hint": "Runs the app through XWayland instead of Wayland. Useful when a window cannot carry its own icon or misbehaves on Wayland.",
|
||||||
|
|
@ -109,7 +115,7 @@
|
||||||
"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.",
|
"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.title": "Linux app isolation",
|
||||||
"sandbox.hint": "Native apps always run with a redirected home folder. With bubblewrap they get their own namespaces too: only their home folder and their own app folder stay writable, the rest of the system is read-only.",
|
"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.status": "Status",
|
||||||
"sandbox.check": "Check again",
|
"sandbox.check": "Check again",
|
||||||
"sandbox.rechecked": "Isolation checked again",
|
"sandbox.rechecked": "Isolation checked again",
|
||||||
|
|
@ -118,13 +124,17 @@
|
||||||
"sandbox.reason.timeout": "not responding",
|
"sandbox.reason.timeout": "not responding",
|
||||||
"sandbox.reason.failed": "not working",
|
"sandbox.reason.failed": "not working",
|
||||||
"sandbox.warnTitle": "No sandbox for Linux apps",
|
"sandbox.warnTitle": "No sandbox for Linux apps",
|
||||||
"sandbox.warnBody": "bubblewrap is {0}. Linux apps will run with only a redirected home folder - they can still read the rest of your system, and write to anything your user can.",
|
"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.dismiss": "Got it",
|
||||||
"sandbox.badgeStrong": "bwrap",
|
"sandbox.badgeNone": "No isolation",
|
||||||
"sandbox.badgeWeak": "no sandbox",
|
"sandbox.badgeRelaxed": "Relaxed",
|
||||||
"sandbox.badgeOff": "isolation off",
|
"sandbox.badgeIsolated": "Isolated",
|
||||||
"sandbox.badgeRequired": "bwrap required",
|
"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.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)",
|
||||||
|
|
@ -245,8 +255,8 @@
|
||||||
"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.waiting": "Waiting for the other device…",
|
||||||
|
|
@ -257,7 +267,8 @@
|
||||||
"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",
|
||||||
|
|
@ -278,6 +289,33 @@
|
||||||
"lansync.unknownDevice": "Unknown device",
|
"lansync.unknownDevice": "Unknown 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).",
|
||||||
|
|
@ -287,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).",
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@
|
||||||
"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.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.autoLinux": "Wyciągnij ikonę",
|
||||||
|
|
@ -93,10 +94,15 @@
|
||||||
"config.openData": "Otwórz folder danych",
|
"config.openData": "Otwórz folder danych",
|
||||||
"config.linuxKindValue": "Linux",
|
"config.linuxKindValue": "Linux",
|
||||||
"config.isolation": "Izolacja",
|
"config.isolation": "Izolacja",
|
||||||
"config.isolation.auto": "Automatyczna (najmocniejsza dostępna)",
|
"config.isolation.auto": "Automatyczna",
|
||||||
"config.isolation.off": "Wyłączona (zwykłe uruchomienie)",
|
"config.isolation.none": "None",
|
||||||
"config.isolation.require": "Wymagana (odmów bez bwrap)",
|
"config.isolation.relaxed": "Relaxed",
|
||||||
"config.isolation.bwrap": "bwrap {0} (mocna)",
|
"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.isolation.env": "brak sandboxa: {0}",
|
||||||
"config.forceX11": "Wymuś X11 (XWayland)",
|
"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.forceX11Hint": "Uruchamia aplikację przez XWayland zamiast Waylanda. Przydatne, gdy okno nie potrafi mieć własnej ikony albo źle działa na Waylandzie.",
|
||||||
|
|
@ -109,7 +115,7 @@
|
||||||
"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ł.",
|
"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.title": "Izolacja aplikacji Linux",
|
||||||
"sandbox.hint": "Natywne aplikacje zawsze działają z przekierowanym folderem domowym. Z bubblewrap dostają dodatkowo własne przestrzenie nazw: zapisywać można tylko w swoim folderze domowym i w swoim folderze aplikacji, reszta systemu jest tylko do odczytu.",
|
"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.status": "Status",
|
||||||
"sandbox.check": "Sprawdź ponownie",
|
"sandbox.check": "Sprawdź ponownie",
|
||||||
"sandbox.rechecked": "Sprawdzono izolację ponownie",
|
"sandbox.rechecked": "Sprawdzono izolację ponownie",
|
||||||
|
|
@ -118,13 +124,17 @@
|
||||||
"sandbox.reason.timeout": "nie odpowiada",
|
"sandbox.reason.timeout": "nie odpowiada",
|
||||||
"sandbox.reason.failed": "nie działa",
|
"sandbox.reason.failed": "nie działa",
|
||||||
"sandbox.warnTitle": "Brak sandboxa dla aplikacji Linux",
|
"sandbox.warnTitle": "Brak sandboxa dla aplikacji Linux",
|
||||||
"sandbox.warnBody": "bubblewrap {0}. Aplikacje linuksowe będą działać tylko z przekierowanym folderem domowym - nadal mogą czytać resztę systemu i pisać wszędzie, gdzie pozwala Twój użytkownik.",
|
"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.dismiss": "Rozumiem",
|
||||||
"sandbox.badgeStrong": "bwrap",
|
"sandbox.badgeNone": "Bez izolacji",
|
||||||
"sandbox.badgeWeak": "bez sandboxa",
|
"sandbox.badgeRelaxed": "Relaxed",
|
||||||
"sandbox.badgeOff": "izolacja wyłączona",
|
"sandbox.badgeIsolated": "Isolated",
|
||||||
"sandbox.badgeRequired": "wymaga bwrap",
|
"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.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)",
|
||||||
|
|
@ -245,8 +255,8 @@
|
||||||
"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.waiting": "Oczekiwanie na drugie urządzenie…",
|
||||||
|
|
@ -257,7 +267,8 @@
|
||||||
"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",
|
||||||
|
|
@ -278,6 +289,33 @@
|
||||||
"lansync.unknownDevice": "Nieznane urządzenie",
|
"lansync.unknownDevice": "Nieznane 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).",
|
||||||
|
|
@ -287,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.5.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": {
|
||||||
|
|
|
||||||
661
renderer/app.js
661
renderer/app.js
|
|
@ -138,6 +138,8 @@ function settleModal(value) {
|
||||||
|
|
||||||
function showModal(html, opts) {
|
function showModal(html, opts) {
|
||||||
modalLocked = !!(opts && opts.locked)
|
modalLocked = !!(opts && opts.locked)
|
||||||
|
// a review list needs the room a narrow form does not
|
||||||
|
qs('#modal').classList.toggle('wide', !!(opts && opts.wide))
|
||||||
settleModal(null)
|
settleModal(null)
|
||||||
if (modalTeardownTimer) {
|
if (modalTeardownTimer) {
|
||||||
clearTimeout(modalTeardownTimer)
|
clearTimeout(modalTeardownTimer)
|
||||||
|
|
@ -206,7 +208,7 @@ function closeModal() {
|
||||||
modalSwapTimer = null
|
modalSwapTimer = null
|
||||||
}
|
}
|
||||||
overlay.style.display = 'none'
|
overlay.style.display = 'none'
|
||||||
modal.classList.remove('closing', 'swapping', 'resizing')
|
modal.classList.remove('closing', 'swapping', 'resizing', 'wide')
|
||||||
modal.style.height = ''
|
modal.style.height = ''
|
||||||
modal.innerHTML = ''
|
modal.innerHTML = ''
|
||||||
modalLocked = false
|
modalLocked = false
|
||||||
|
|
@ -335,11 +337,24 @@ class CustomSelect {
|
||||||
if (this.disabled) return
|
if (this.disabled) return
|
||||||
const wasOpen = this.host.classList.contains('open')
|
const wasOpen = this.host.classList.contains('open')
|
||||||
CustomSelect.closeAll()
|
CustomSelect.closeAll()
|
||||||
if (!wasOpen) this.host.classList.add('open')
|
if (!wasOpen) {
|
||||||
|
this.host.classList.add('open')
|
||||||
|
this.lift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the transform on every element (see style.css) makes each one its own
|
||||||
|
// stacking context, so an ancestor wrapper would still paint the open list
|
||||||
|
// under the rows that follow it; positioned ancestors already stack right
|
||||||
|
lift() {
|
||||||
|
for (let el = this.host.parentElement; el && el !== document.body; el = el.parentElement) {
|
||||||
|
if (getComputedStyle(el).position === 'static') el.classList.add('select-lift')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.host.classList.remove('open')
|
this.host.classList.remove('open')
|
||||||
|
CustomSelect.unlift()
|
||||||
}
|
}
|
||||||
|
|
||||||
setValue(v) {
|
setValue(v) {
|
||||||
|
|
@ -358,6 +373,11 @@ class CustomSelect {
|
||||||
|
|
||||||
static closeAll() {
|
static closeAll() {
|
||||||
qsa('.custom-select.open').forEach(el => el.classList.remove('open'))
|
qsa('.custom-select.open').forEach(el => el.classList.remove('open'))
|
||||||
|
CustomSelect.unlift()
|
||||||
|
}
|
||||||
|
|
||||||
|
static unlift() {
|
||||||
|
qsa('.select-lift').forEach(el => el.classList.remove('select-lift'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -434,12 +454,21 @@ function isolationStatusText(status) {
|
||||||
return t('config.isolation.env', t('sandbox.reason.' + (status.reason || 'failed')))
|
return t('config.isolation.env', t('sandbox.reason.' + (status.reason || 'failed')))
|
||||||
}
|
}
|
||||||
|
|
||||||
function isolationBadge(game) {
|
// The badge names the level the app would really run at, not the policy:
|
||||||
|
// automatic is a choice, and what it resolves to depends on this machine.
|
||||||
|
function isolationLevel(game) {
|
||||||
const policy = (game && game.isolation) || 'auto'
|
const policy = (game && game.isolation) || 'auto'
|
||||||
if (policy === 'off') return `<span class="badge">${t('sandbox.badgeOff')}</span>`
|
if (policy === 'none') return 'none'
|
||||||
if (sandboxStrong()) return `<span class="badge primary">${t('sandbox.badgeStrong')}</span>`
|
if (policy === 'auto') return sandboxStrong() ? 'relaxed' : 'none'
|
||||||
if (policy === 'require') return `<span class="badge">${t('sandbox.badgeRequired')}</span>`
|
return sandboxStrong() ? policy : 'unavailable'
|
||||||
return `<span class="badge">${t('sandbox.badgeWeak')}</span>`
|
}
|
||||||
|
|
||||||
|
function isolationBadge(game) {
|
||||||
|
const level = isolationLevel(game)
|
||||||
|
if (level === 'none') return `<span class="badge">${t('sandbox.badgeNone')}</span>`
|
||||||
|
if (level === 'unavailable') return `<span class="badge">${t('sandbox.badgeRequired')}</span>`
|
||||||
|
if (level === 'isolated') return `<span class="badge primary">${t('sandbox.badgeIsolated')}</span>`
|
||||||
|
return `<span class="badge primary">${t('sandbox.badgeRelaxed')}</span>`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Portable apps show files/-relative paths; external apps keep absolute ones.
|
// Portable apps show files/-relative paths; external apps keep absolute ones.
|
||||||
|
|
@ -629,7 +658,8 @@ function renderLibrary() {
|
||||||
applyRunning(false)
|
applyRunning(false)
|
||||||
}
|
}
|
||||||
card.querySelector('[data-act=config]').onclick = () => openGameConfig(game.id)
|
card.querySelector('[data-act=config]').onclick = () => openGameConfig(game.id)
|
||||||
card.querySelector('[data-act=folder]').onclick = () => window.plwl.shellOpenPath(game.workdir)
|
// the app's own folder, not its working directory
|
||||||
|
card.querySelector('[data-act=folder]').onclick = () => window.plwl.shellOpenPath(game.path || game.workdir)
|
||||||
card.querySelector('[data-act=remove]').onclick = async () => {
|
card.querySelector('[data-act=remove]').onclick = async () => {
|
||||||
const note = game.mode === 'copy' || game.mode === 'move'
|
const note = game.mode === 'copy' || game.mode === 'move'
|
||||||
? t('library.removeFilesNote')
|
? t('library.removeFilesNote')
|
||||||
|
|
@ -791,6 +821,17 @@ async function startPrefixImportFlow() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One place for the technical error tokens the import handlers answer with.
|
||||||
|
function importErrorText(raw) {
|
||||||
|
if (raw === 'no-exe-found') return t('import.noExe')
|
||||||
|
if (raw === 'no-linux-exe') return t('import.noLinuxExe')
|
||||||
|
if (raw === 'prefix-root') return t('import.prefixRoot')
|
||||||
|
if (raw === 'no-drive-c') return t('import.prefix.noDriveC')
|
||||||
|
if (raw === 'outside-drive-c') return t('import.prefix.outside')
|
||||||
|
if (raw === 'copy-incomplete') return t('import.copyIncomplete')
|
||||||
|
return raw || ''
|
||||||
|
}
|
||||||
|
|
||||||
async function doImport(srcDir, mode, kind) {
|
async function doImport(srcDir, mode, kind) {
|
||||||
const name = srcDir.split('/').filter(Boolean).pop()
|
const name = srcDir.split('/').filter(Boolean).pop()
|
||||||
const modal = showModal(`
|
const modal = showModal(`
|
||||||
|
|
@ -815,13 +856,7 @@ async function doImport(srcDir, mode, kind) {
|
||||||
const game = await window.plwl.gamesImport(srcDir, mode, kind)
|
const game = await window.plwl.gamesImport(srcDir, mode, kind)
|
||||||
closeModal()
|
closeModal()
|
||||||
if (!game || game.ok === false) {
|
if (!game || game.ok === false) {
|
||||||
const raw = (game && game.error) || ''
|
toast(t('import.failed', importErrorText((game && game.error) || '')), 'error')
|
||||||
const errText =
|
|
||||||
raw === 'no-exe-found' ? t('import.noExe')
|
|
||||||
: raw === 'no-linux-exe' ? t('import.noLinuxExe')
|
|
||||||
: raw === 'prefix-root' ? t('import.prefixRoot')
|
|
||||||
: (raw || 'unknown error')
|
|
||||||
toast(t('import.failed', errText), 'error')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.games = await window.plwl.gamesList()
|
state.games = await window.plwl.gamesList()
|
||||||
|
|
@ -936,6 +971,36 @@ function wireOpenWith() {
|
||||||
window.plwl.openwithTake().then(handleOpenWith)
|
window.plwl.openwithTake().then(handleOpenWith)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- isolated hand-off -------------------------------------------------------
|
||||||
|
|
||||||
|
// A document handed to an isolated app is copied in first. Only copies big
|
||||||
|
// enough to be noticed arrive here (main decides, and puts a window up so this
|
||||||
|
// popup can be seen at all).
|
||||||
|
function wireHandoffProgress() {
|
||||||
|
window.plwl.onHandoffProgress(data => {
|
||||||
|
if (!data || !data.notice) return
|
||||||
|
if (data.phase === 'copy') return showHandoffCopy(data)
|
||||||
|
if (data.phase !== 'done' && data.phase !== 'error') return
|
||||||
|
// only ever close our own modal: another one may have opened over it
|
||||||
|
if (qs('#modal').querySelector('#ho-progress')) closeModal()
|
||||||
|
if (data.phase === 'error') toast(t('handoff.failed', data.name, data.error || ''), 'error')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHandoffCopy(data) {
|
||||||
|
const modal = qs('#modal').querySelector('#ho-progress')
|
||||||
|
? qs('#modal')
|
||||||
|
: showModal(`
|
||||||
|
<h2>${t('handoff.copying', data.name)}</h2>
|
||||||
|
<div class="progress-row"><progress id="ho-progress"></progress><span class="progress-pct"></span></div>
|
||||||
|
<p class="subtitle" id="ho-status"></p>
|
||||||
|
`)
|
||||||
|
renderProgressBar(modal.querySelector('#ho-progress'), data.bytes, data.totalBytes)
|
||||||
|
const pct = modal.querySelector('.progress-pct')
|
||||||
|
pct.textContent = data.totalBytes ? Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' : ''
|
||||||
|
modal.querySelector('#ho-status').textContent = t('handoff.copyNote', formatBytes(data.bytes), formatBytes(data.totalBytes))
|
||||||
|
}
|
||||||
|
|
||||||
// Import step 2: register the app in place, nothing is copied.
|
// Import step 2: register the app in place, nothing is copied.
|
||||||
async function doPrefixImport(prefix, srcDir) {
|
async function doPrefixImport(prefix, srcDir) {
|
||||||
const name = srcDir.split('/').filter(Boolean).pop()
|
const name = srcDir.split('/').filter(Boolean).pop()
|
||||||
|
|
@ -948,13 +1013,7 @@ async function doPrefixImport(prefix, srcDir) {
|
||||||
const game = await window.plwl.gamesImportFromPrefix(prefix, srcDir)
|
const game = await window.plwl.gamesImportFromPrefix(prefix, srcDir)
|
||||||
closeModal()
|
closeModal()
|
||||||
if (!game || game.ok === false) {
|
if (!game || game.ok === false) {
|
||||||
const raw = (game && game.error) || ''
|
toast(t('import.failed', importErrorText((game && game.error) || '')), 'error')
|
||||||
const errText =
|
|
||||||
raw === 'no-exe-found' ? t('import.noExe')
|
|
||||||
: raw === 'no-drive-c' ? t('import.prefix.noDriveC')
|
|
||||||
: raw === 'outside-drive-c' ? t('import.prefix.outside')
|
|
||||||
: (raw || 'unknown error')
|
|
||||||
toast(t('import.failed', errText), 'error')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.games = await window.plwl.gamesList()
|
state.games = await window.plwl.gamesList()
|
||||||
|
|
@ -1314,14 +1373,24 @@ async function openGameConfig(id) {
|
||||||
|
|
||||||
const isolationOptions = () => [
|
const isolationOptions = () => [
|
||||||
{ value: 'auto', label: t('config.isolation.auto') },
|
{ value: 'auto', label: t('config.isolation.auto') },
|
||||||
{ value: 'off', label: t('config.isolation.off') },
|
{ value: 'none', label: t('config.isolation.none') },
|
||||||
{ value: 'require', label: t('config.isolation.require') }
|
{ value: 'relaxed', label: t('config.isolation.relaxed') },
|
||||||
|
{ value: 'isolated', label: t('config.isolation.isolated') }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// The level names are one word each - what they mean has to be spelled out
|
||||||
|
// right under the select, because the price of relaxed is not visible anywhere
|
||||||
|
// else in the UI.
|
||||||
|
function renderIsolationHint(value) {
|
||||||
|
qs('#gc-isolation-hint').textContent = t(`config.isolationHint.${value || 'auto'}`)
|
||||||
|
}
|
||||||
|
|
||||||
function renderIsolationOptions(selected) {
|
function renderIsolationOptions(selected) {
|
||||||
const opts = isolationOptions()
|
const opts = isolationOptions()
|
||||||
if (!selects['gc-isolation']) selects['gc-isolation'] = new CustomSelect(qs('#gc-isolation'), opts, selected)
|
if (!selects['gc-isolation']) {
|
||||||
else selects['gc-isolation'].update(opts, selected)
|
selects['gc-isolation'] = new CustomSelect(qs('#gc-isolation'), opts, selected, renderIsolationHint)
|
||||||
|
} else selects['gc-isolation'].update(opts, selected)
|
||||||
|
renderIsolationHint(selected)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAppShortcut() {
|
async function createAppShortcut() {
|
||||||
|
|
@ -1530,10 +1599,10 @@ function renderProgressBar(bar, bytes, totalBytes) {
|
||||||
bar.value = Math.min(sent, bar.max)
|
bar.value = Math.min(sent, bar.max)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Older peers only send the overall byte totals - then those drive the bar.
|
// The bar follows the current phase's bytes (a phase starts empty).
|
||||||
function barValues(info) {
|
function barValues(info) {
|
||||||
let bytes = Math.max(0, Number(info.barBytes == null ? info.bytes : info.barBytes) || 0)
|
let bytes = Math.max(0, Number(info.barBytes) || 0)
|
||||||
let total = Math.max(0, Number(info.barTotalBytes == null ? info.totalBytes : info.barTotalBytes) || 0)
|
let total = Math.max(0, Number(info.barTotalBytes) || 0)
|
||||||
// a provisional total can be smaller than what already moved (it grows as
|
// a provisional total can be smaller than what already moved (it grows as
|
||||||
// entries arrive): the bar reads full, never over 100%
|
// entries arrive): the bar reads full, never over 100%
|
||||||
if (total > 0 && bytes > total) bytes = total
|
if (total > 0 && bytes > total) bytes = total
|
||||||
|
|
@ -1566,6 +1635,54 @@ async function runWithProgress({ title, action }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// End-of-sync popup: what moved, what was already there, and how much space
|
||||||
|
// the synced trees take. A toast is too easy to miss for a transfer that can
|
||||||
|
// run for minutes.
|
||||||
|
function showSyncSummary({ from, received, categories, summary }) {
|
||||||
|
const items = (summary && summary.items) || {}
|
||||||
|
const cats = new Set(categories || [])
|
||||||
|
const apps = Math.max(0, Number(items.apps) || 0)
|
||||||
|
const wines = Math.max(0, Number(items.wines) || 0)
|
||||||
|
const prefixes = Math.max(0, Number(items.prefixes) || 0)
|
||||||
|
const files = Math.max(0, Number(summary && summary.uploaded) || 0)
|
||||||
|
const skipped = Math.max(0, Number(summary && summary.skipped) || 0)
|
||||||
|
const deleted = Math.max(0, Number(summary && summary.deleted) || 0)
|
||||||
|
const kept = Math.max(0, Number(summary && summary.kept) || 0)
|
||||||
|
const bytes = Math.max(0, Number(summary && summary.bytes) || 0)
|
||||||
|
const sizeBytes = Math.max(0, Number(summary && summary.sizeBytes) || 0)
|
||||||
|
const rows = []
|
||||||
|
const row = (label, value) => rows.push(
|
||||||
|
`<div class="sync-summary-row"><span>${label}</span><strong>${value}</strong></div>`
|
||||||
|
)
|
||||||
|
// a count alone also shows for a peer that did not send its categories
|
||||||
|
if (apps || cats.has('apps-portable') || cats.has('apps-prefix')) row(t('lansync.summaryApps'), apps)
|
||||||
|
if (wines || cats.has('wines')) row(t('lansync.wines'), wines)
|
||||||
|
if (prefixes || cats.has('prefixes')) row(t('lansync.prefixes'), prefixes)
|
||||||
|
if (cats.has('settings')) row(t('lansync.summarySettings'), t('lansync.summaryYes'))
|
||||||
|
if (files || skipped || deleted || kept) {
|
||||||
|
row(t(received ? 'lansync.summaryReceived' : 'lansync.summarySent'), files)
|
||||||
|
if (skipped) row(t('lansync.summarySkipped'), skipped)
|
||||||
|
if (deleted) row(t('lansync.summaryDeleted'), deleted)
|
||||||
|
if (kept) row(t('lansync.summaryKept'), kept)
|
||||||
|
}
|
||||||
|
if (bytes) row(t('lansync.summaryTransferred'), formatBytes(bytes))
|
||||||
|
if (sizeBytes) row(t('lansync.summarySize'), formatBytes(sizeBytes))
|
||||||
|
const modal = showModal(`
|
||||||
|
<h2>${t('lansync.summaryTitle')}</h2>
|
||||||
|
<p class="subtitle" id="ls-summary-from"></p>
|
||||||
|
<div class="sync-summary">${rows.join('')}</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="button primary" id="ls-summary-ok">${t('dialog.ok')}</button>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
// the device name comes off the network: never interpolate it into HTML
|
||||||
|
modal.querySelector('#ls-summary-from').textContent = t(
|
||||||
|
received ? 'lansync.summaryReceivedFrom' : 'lansync.summarySentTo',
|
||||||
|
from || ''
|
||||||
|
)
|
||||||
|
modal.querySelector('#ls-summary-ok').onclick = closeModal
|
||||||
|
}
|
||||||
|
|
||||||
// "340 MB / 1.2 GB" once the total is known, otherwise just the moved bytes.
|
// "340 MB / 1.2 GB" once the total is known, otherwise just the moved bytes.
|
||||||
function lanBytesLine(bytes, totalBytes) {
|
function lanBytesLine(bytes, totalBytes) {
|
||||||
const sent = formatBytes(bytes)
|
const sent = formatBytes(bytes)
|
||||||
|
|
@ -1645,15 +1762,7 @@ async function openLanSyncModal(device, errorKey) {
|
||||||
code.focus()
|
code.focus()
|
||||||
}
|
}
|
||||||
modal.querySelector('#ls-what-label').textContent = t('lansync.what')
|
modal.querySelector('#ls-what-label').textContent = t('lansync.what')
|
||||||
const box = modal.querySelector('#ls-categories')
|
const pickers = await wireLanPickers(modal)
|
||||||
for (const category of LAN_CATEGORIES) {
|
|
||||||
const label = document.createElement('label')
|
|
||||||
label.className = 'check-label'
|
|
||||||
label.innerHTML = '<input type="checkbox" checked><span></span>'
|
|
||||||
label.querySelector('input').id = 'ls-cat-' + category.id
|
|
||||||
label.querySelector('span').textContent = t(category.label)
|
|
||||||
box.appendChild(label)
|
|
||||||
}
|
|
||||||
modal.querySelector('#ls-force-label').textContent = t('lansync.force')
|
modal.querySelector('#ls-force-label').textContent = t('lansync.force')
|
||||||
modal.querySelector('#ls-warning').textContent = t('lansync.mirrorWarning')
|
modal.querySelector('#ls-warning').textContent = t('lansync.mirrorWarning')
|
||||||
modal.querySelector('#ls-cancel').textContent = t('dialog.cancel')
|
modal.querySelector('#ls-cancel').textContent = t('dialog.cancel')
|
||||||
|
|
@ -1668,16 +1777,172 @@ async function openLanSyncModal(device, errorKey) {
|
||||||
code.focus()
|
code.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const categories = LAN_CATEGORIES.map(c => c.id).filter(id => modal.querySelector('#ls-cat-' + id).checked)
|
const picked = pickers.collect()
|
||||||
if (!categories.length) {
|
if (!picked.categories.length) {
|
||||||
toast(t('lansync.noneSelected'), 'error')
|
toast(t('lansync.noneSelected'), 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await runLanSync(device, value, categories, modal.querySelector('#ls-force').checked)
|
await runLanSync(device, value, picked.categories, modal.querySelector('#ls-force').checked, picked.selection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runLanSync(device, code, categories, force) {
|
// A category's checkbox says "this category travels"; the picker says which of
|
||||||
|
// its items. Everything starts selected, and a selection only ever narrows what
|
||||||
|
// is sent - nothing outside it may be deleted on the other side.
|
||||||
|
async function wireLanPickers(modal) {
|
||||||
|
const box = modal.querySelector('#ls-categories')
|
||||||
|
box.innerHTML = ''
|
||||||
|
let info = { apps: [], wines: [], prefixes: [] }
|
||||||
|
try {
|
||||||
|
info = await window.plwl.lansyncItems()
|
||||||
|
} catch {}
|
||||||
|
const groups = new Map([
|
||||||
|
['apps-portable', info.apps.filter(app => app.category === 'apps-portable')],
|
||||||
|
['apps-prefix', info.apps.filter(app => app.category === 'apps-prefix')],
|
||||||
|
['wines', info.wines || []],
|
||||||
|
['prefixes', info.prefixes || []],
|
||||||
|
['settings', []]
|
||||||
|
])
|
||||||
|
const chosen = new Map()
|
||||||
|
for (const [id, list] of groups) chosen.set(id, new Set(list.map(item => item.key)))
|
||||||
|
const rows = new Map()
|
||||||
|
|
||||||
|
const refreshButton = id => {
|
||||||
|
const row = rows.get(id)
|
||||||
|
if (!row || !row.pick) return
|
||||||
|
const list = groups.get(id) || []
|
||||||
|
row.pick.textContent = chosen.get(id).size === list.length
|
||||||
|
? t('lansync.pick')
|
||||||
|
: t('lansync.pickCount', chosen.get(id).size, list.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const category of LAN_CATEGORIES) {
|
||||||
|
const list = groups.get(category.id) || []
|
||||||
|
const wrapper = document.createElement('div')
|
||||||
|
wrapper.className = 'lan-cat'
|
||||||
|
const head = document.createElement('div')
|
||||||
|
head.className = 'lan-cat-head'
|
||||||
|
const label = document.createElement('label')
|
||||||
|
label.className = 'check-label'
|
||||||
|
label.innerHTML = '<input type="checkbox" checked><span></span>'
|
||||||
|
const input = label.querySelector('input')
|
||||||
|
input.id = 'ls-cat-' + category.id
|
||||||
|
label.querySelector('span').textContent = t(category.label)
|
||||||
|
head.appendChild(label)
|
||||||
|
const row = { input, list: null, search: null, pick: null }
|
||||||
|
rows.set(category.id, row)
|
||||||
|
if (list.length) {
|
||||||
|
const pick = document.createElement('button')
|
||||||
|
pick.type = 'button'
|
||||||
|
pick.className = 'button small'
|
||||||
|
head.appendChild(pick)
|
||||||
|
row.pick = pick
|
||||||
|
const panel = document.createElement('div')
|
||||||
|
panel.className = 'lan-pick'
|
||||||
|
panel.hidden = true
|
||||||
|
const tools = document.createElement('div')
|
||||||
|
tools.className = 'lan-pick-tools'
|
||||||
|
if (list.length > 6) {
|
||||||
|
const search = document.createElement('input')
|
||||||
|
search.type = 'search'
|
||||||
|
search.className = 'lan-search'
|
||||||
|
search.placeholder = t('lansync.pickSearch')
|
||||||
|
tools.appendChild(search)
|
||||||
|
row.search = search
|
||||||
|
search.oninput = () => renderList(category.id)
|
||||||
|
}
|
||||||
|
const all = document.createElement('button')
|
||||||
|
all.type = 'button'
|
||||||
|
all.className = 'button small'
|
||||||
|
all.textContent = t('lansync.pickAll')
|
||||||
|
const none = document.createElement('button')
|
||||||
|
none.type = 'button'
|
||||||
|
none.className = 'button small'
|
||||||
|
none.textContent = t('lansync.pickNone')
|
||||||
|
tools.appendChild(all)
|
||||||
|
tools.appendChild(none)
|
||||||
|
const listEl = document.createElement('div')
|
||||||
|
listEl.className = 'lan-pick-list'
|
||||||
|
row.list = listEl
|
||||||
|
panel.appendChild(tools)
|
||||||
|
panel.appendChild(listEl)
|
||||||
|
pick.onclick = () => {
|
||||||
|
panel.hidden = !panel.hidden
|
||||||
|
if (!panel.hidden) renderList(category.id)
|
||||||
|
}
|
||||||
|
all.onclick = () => {
|
||||||
|
for (const item of list) chosen.get(category.id).add(item.key)
|
||||||
|
renderList(category.id)
|
||||||
|
}
|
||||||
|
none.onclick = () => {
|
||||||
|
chosen.get(category.id).clear()
|
||||||
|
renderList(category.id)
|
||||||
|
}
|
||||||
|
wrapper.appendChild(panel)
|
||||||
|
refreshButton(category.id)
|
||||||
|
}
|
||||||
|
wrapper.insertBefore(head, wrapper.firstChild)
|
||||||
|
box.appendChild(wrapper)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(id) {
|
||||||
|
const row = rows.get(id)
|
||||||
|
if (!row || !row.list) return
|
||||||
|
const list = groups.get(id) || []
|
||||||
|
const set = chosen.get(id)
|
||||||
|
const query = row.search ? row.search.value.trim().toLowerCase() : ''
|
||||||
|
row.list.innerHTML = ''
|
||||||
|
for (const item of list) {
|
||||||
|
if (query && !String(item.name || '').toLowerCase().includes(query)) continue
|
||||||
|
const label = document.createElement('label')
|
||||||
|
label.className = 'check-label lan-item'
|
||||||
|
label.innerHTML = '<input type="checkbox"><span></span>'
|
||||||
|
const input = label.querySelector('input')
|
||||||
|
input.checked = set.has(item.key)
|
||||||
|
label.querySelector('span').textContent = item.name
|
||||||
|
input.onchange = () => {
|
||||||
|
if (input.checked) set.add(item.key)
|
||||||
|
else set.delete(item.key)
|
||||||
|
refreshButton(id)
|
||||||
|
// a prefix app's own folder travels with its prefix, not with the app
|
||||||
|
if (id === 'apps-prefix') renderList('apps-prefix')
|
||||||
|
}
|
||||||
|
row.list.appendChild(label)
|
||||||
|
if (id === 'apps-prefix' && input.checked && item.prefix) {
|
||||||
|
const prefixKey = `prefix:${item.prefix}`
|
||||||
|
const prefixesOn = rows.get('prefixes').input.checked
|
||||||
|
if (prefixesOn && !chosen.get('prefixes').has(prefixKey)) {
|
||||||
|
const hint = document.createElement('p')
|
||||||
|
hint.className = 'subtitle lan-hint'
|
||||||
|
hint.textContent = t('lansync.prefixLeftBehind', item.prefix)
|
||||||
|
row.list.appendChild(hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
collect() {
|
||||||
|
const categories = []
|
||||||
|
const units = []
|
||||||
|
let partial = false
|
||||||
|
for (const category of LAN_CATEGORIES) {
|
||||||
|
const input = rows.get(category.id).input
|
||||||
|
if (!input.checked) continue
|
||||||
|
const list = groups.get(category.id) || []
|
||||||
|
const set = chosen.get(category.id)
|
||||||
|
// a checked category with nothing ticked inside it sends nothing
|
||||||
|
if (list.length && !set.size) continue
|
||||||
|
categories.push(category.id)
|
||||||
|
for (const item of list) if (set.has(item.key)) units.push(item.key)
|
||||||
|
if (set.size !== list.length) partial = true
|
||||||
|
}
|
||||||
|
return { categories, selection: partial ? { units } : undefined }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLanSync(device, code, categories, force, selection) {
|
||||||
// locked: a running sync is not cancellable by a stray click on the backdrop
|
// locked: a running sync is not cancellable by a stray click on the backdrop
|
||||||
const modal = showModal(`
|
const modal = showModal(`
|
||||||
<h2 id="ls-run-title"></h2>
|
<h2 id="ls-run-title"></h2>
|
||||||
|
|
@ -1711,22 +1976,23 @@ async function runLanSync(device, code, categories, force) {
|
||||||
})
|
})
|
||||||
let res = null
|
let res = null
|
||||||
try {
|
try {
|
||||||
res = await window.plwl.lansyncSync({ deviceId: device.id, code, categories, force })
|
res = await window.plwl.lansyncSync({ deviceId: device.id, code, categories, force, selection })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res = { ok: false, error: (err && err.message) || '' }
|
res = { ok: false, error: (err && err.message) || '' }
|
||||||
} finally {
|
} finally {
|
||||||
active = false
|
active = false
|
||||||
state.lanPushing = false
|
state.lanPushing = false
|
||||||
}
|
}
|
||||||
closeModal()
|
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
const summary = res.summary || {}
|
// swap the locked progress popup into the result, not a toast
|
||||||
toast(t('lansync.done', device.name, summary.uploaded || 0, summary.skipped || 0, summary.deleted || 0, formatBytes(summary.bytes || 0)), 'success')
|
showSyncSummary({ from: device.name, received: false, categories, summary: res.summary || {} })
|
||||||
await renderLanSync()
|
await renderLanSync()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
closeModal()
|
||||||
const err = (res && res.error) || ''
|
const err = (res && res.error) || ''
|
||||||
if (err === 'bad-code') {
|
// the server answers a wrong code as 'auth-failed'; both mean "ask again"
|
||||||
|
if (err === 'bad-code' || err === 'auth-failed') {
|
||||||
await window.plwl.lansyncForget(device.id)
|
await window.plwl.lansyncForget(device.id)
|
||||||
toast(t('lansync.badCode'), 'error')
|
toast(t('lansync.badCode'), 'error')
|
||||||
openLanSyncModal({ ...device, code: '' }, 'lansync.badCode')
|
openLanSyncModal({ ...device, code: '' }, 'lansync.badCode')
|
||||||
|
|
@ -1734,9 +2000,10 @@ async function runLanSync(device, code, categories, force) {
|
||||||
}
|
}
|
||||||
const message =
|
const message =
|
||||||
err === 'busy' ? t('lansync.busy')
|
err === 'busy' ? t('lansync.busy')
|
||||||
: err === 'offline' ? t('lansync.offline')
|
: err === 'offline' || err === 'receiver-lost' ? t('lansync.offline')
|
||||||
: err === 'rejected' ? t('lansync.rejected')
|
: err === 'rejected' ? t('lansync.rejected')
|
||||||
: err === 'incoming-timeout' ? t('lansync.incomingTimedOut')
|
: err === 'incoming-timeout' ? t('lansync.incomingTimedOut')
|
||||||
|
: err === 'old-peer' ? t('lansync.oldPeer')
|
||||||
: (err || t('lansync.unknownError'))
|
: (err || t('lansync.unknownError'))
|
||||||
toast(t('lansync.failed', message), 'error')
|
toast(t('lansync.failed', message), 'error')
|
||||||
}
|
}
|
||||||
|
|
@ -1810,6 +2077,9 @@ function onLanIncoming(info) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let receiving = false
|
let receiving = false
|
||||||
|
// The last apply payload, for the end-of-sync summary (it carries files,
|
||||||
|
// bytes, skipped files and the total size of the synced trees).
|
||||||
|
let lastApplied = null
|
||||||
// Only the sender's 'done' ends this; a watchdog-closed popup may reopen.
|
// Only the sender's 'done' ends this; a watchdog-closed popup may reopen.
|
||||||
let receiveEnded = false
|
let receiveEnded = false
|
||||||
let receiveWatchdog = null
|
let receiveWatchdog = null
|
||||||
|
|
@ -1868,12 +2138,32 @@ function armReceiveWatchdog() {
|
||||||
function onLanReceiving(info) {
|
function onLanReceiving(info) {
|
||||||
if (!info || !info.stage) return
|
if (!info || !info.stage) return
|
||||||
if (state.lanPushing) return // our own push owns the (single) popup
|
if (state.lanPushing) return // our own push owns the (single) popup
|
||||||
if (info.stage === 'start') receiveEnded = false
|
if (info.stage === 'start') {
|
||||||
if (info.stage === 'done') receiveEnded = true
|
receiveEnded = false
|
||||||
|
lastApplied = null
|
||||||
|
}
|
||||||
|
if (info.stage === 'done') {
|
||||||
|
receiveEnded = true
|
||||||
|
// the review's decision was the last thing on screen: this is its result
|
||||||
|
if (reviewFlow) {
|
||||||
|
reviewFlow = false
|
||||||
|
closeModal()
|
||||||
|
showReceiveSummary(info)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// the popup may have been closed by the watchdog: still show the result
|
||||||
|
// if nothing else owns the modal slot
|
||||||
|
if (!receiving) {
|
||||||
|
if (lastApplied && qs('#modal-overlay').style.display === 'none') showReceiveSummary(info)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!receiving) {
|
if (!receiving) {
|
||||||
// reopen a watchdog-closed popup unless the sync already ended
|
// reopen a watchdog-closed popup unless the sync already ended
|
||||||
if (receiveEnded) return
|
if (receiveEnded) return
|
||||||
if (qs('#modal-overlay').style.display !== 'none') return
|
// a completion summary underneath gives way to the next sync's progress
|
||||||
|
const summaryOpen = !!qs('#modal').querySelector('#ls-summary-ok')
|
||||||
|
if (!summaryOpen && qs('#modal-overlay').style.display !== 'none') return
|
||||||
receiving = true
|
receiving = true
|
||||||
const modal = showModal(`
|
const modal = showModal(`
|
||||||
<h2 id="ls-recv-title"></h2>
|
<h2 id="ls-recv-title"></h2>
|
||||||
|
|
@ -1912,15 +2202,269 @@ function renderReceiving(info) {
|
||||||
parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals)
|
parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals)
|
||||||
parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes)
|
parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes)
|
||||||
if (info.stage === 'done') {
|
if (info.stage === 'done') {
|
||||||
// let the totals be read, then close (the library reloads itself)
|
// hold the finished bar for a moment, then swap it for the summary
|
||||||
if (receiveCloseTimer) clearTimeout(receiveCloseTimer)
|
if (receiveCloseTimer) clearTimeout(receiveCloseTimer)
|
||||||
receiveCloseTimer = setTimeout(() => {
|
receiveCloseTimer = setTimeout(() => {
|
||||||
receiveCloseTimer = null
|
receiveCloseTimer = null
|
||||||
endReceiving('')
|
showReceiveSummary(info)
|
||||||
}, 4000)
|
}, 1200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Swap the finished progress popup for the result; the library has already
|
||||||
|
// been reloaded by the apply event.
|
||||||
|
function showReceiveSummary(info) {
|
||||||
|
if (receiveWatchdog) {
|
||||||
|
clearTimeout(receiveWatchdog)
|
||||||
|
receiveWatchdog = null
|
||||||
|
}
|
||||||
|
if (receiveStall) {
|
||||||
|
clearTimeout(receiveStall)
|
||||||
|
receiveStall = null
|
||||||
|
}
|
||||||
|
if (receiveCloseTimer) {
|
||||||
|
clearTimeout(receiveCloseTimer)
|
||||||
|
receiveCloseTimer = null
|
||||||
|
}
|
||||||
|
receiving = false
|
||||||
|
const applied = lastApplied || {}
|
||||||
|
showSyncSummary({
|
||||||
|
from: applied.from || info.from || '',
|
||||||
|
received: true,
|
||||||
|
categories: applied.categories || [],
|
||||||
|
summary: {
|
||||||
|
uploaded: applied.files ?? info.done,
|
||||||
|
skipped: applied.skipped || 0,
|
||||||
|
deleted: applied.deleted ?? info.deleted,
|
||||||
|
kept: applied.kept ?? 0,
|
||||||
|
bytes: applied.bytes ?? info.bytes,
|
||||||
|
sizeBytes: applied.sizeBytes ?? info.sizeBytes,
|
||||||
|
items: applied.items || info.items || {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the receiver's deletion review ----------------------------------------
|
||||||
|
|
||||||
|
// Nothing the sender left out is removed before this: the list is planned, the
|
||||||
|
// user decides, and only then does anything get deleted.
|
||||||
|
let reviewFlow = false
|
||||||
|
// matches the receiver's own review timeout: an unanswered list deletes nothing
|
||||||
|
const REVIEW_WATCHDOG_MS = 10 * 60 * 1000
|
||||||
|
let reviewTimer = null
|
||||||
|
|
||||||
|
function clearReviewTimer() {
|
||||||
|
if (!reviewTimer) return
|
||||||
|
clearTimeout(reviewTimer)
|
||||||
|
reviewTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLanReview(info) {
|
||||||
|
if (!info || !info.id) return
|
||||||
|
clearReviewTimer()
|
||||||
|
// the review replaces the receive progress popup and owns the modal slot
|
||||||
|
if (receiveWatchdog) {
|
||||||
|
clearTimeout(receiveWatchdog)
|
||||||
|
receiveWatchdog = null
|
||||||
|
}
|
||||||
|
if (receiveStall) {
|
||||||
|
clearTimeout(receiveStall)
|
||||||
|
receiveStall = null
|
||||||
|
}
|
||||||
|
if (receiveCloseTimer) {
|
||||||
|
clearTimeout(receiveCloseTimer)
|
||||||
|
receiveCloseTimer = null
|
||||||
|
}
|
||||||
|
receiving = false
|
||||||
|
// no progress popup may reopen between the decision and the sender's 'done'
|
||||||
|
receiveEnded = true
|
||||||
|
reviewFlow = true
|
||||||
|
reviewTimer = setTimeout(() => {
|
||||||
|
reviewTimer = null
|
||||||
|
reviewFlow = false
|
||||||
|
closeModal()
|
||||||
|
toast(t('lansync.reviewExpired'), 'error')
|
||||||
|
}, REVIEW_WATCHDOG_MS)
|
||||||
|
renderReview(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReview(info) {
|
||||||
|
const units = Array.isArray(info.units) ? info.units : []
|
||||||
|
const files = Math.max(0, Number(info.files) || 0)
|
||||||
|
const bytes = Math.max(0, Number(info.bytes) || 0)
|
||||||
|
const kept = new Set()
|
||||||
|
const keptUnits = new Set()
|
||||||
|
const nodeInfo = new Map()
|
||||||
|
const modal = showModal(`
|
||||||
|
<h2>${t('lansync.reviewTitle')}</h2>
|
||||||
|
<p class="subtitle" id="ls-review-from"></p>
|
||||||
|
<p class="subtitle" id="ls-review-text"></p>
|
||||||
|
<div class="lan-review" id="ls-review-list"></div>
|
||||||
|
<p class="subtitle sync-warning" id="ls-review-warning"></p>
|
||||||
|
<div class="lan-review-actions">
|
||||||
|
<button class="button" id="ls-review-keep"></button>
|
||||||
|
<button class="button primary" id="ls-review-go"></button>
|
||||||
|
</div>
|
||||||
|
`, { locked: true, wide: true })
|
||||||
|
// the device name comes off the network: never interpolate it into HTML
|
||||||
|
modal.querySelector('#ls-review-from').textContent = t('lansync.reviewFrom', info.name || info.deviceId || '')
|
||||||
|
modal.querySelector('#ls-review-text').textContent = t('lansync.reviewText', files, formatBytes(bytes))
|
||||||
|
modal.querySelector('#ls-review-warning').textContent = t('lansync.reviewOverwrite')
|
||||||
|
const list = modal.querySelector('#ls-review-list')
|
||||||
|
const go = modal.querySelector('#ls-review-go')
|
||||||
|
const keepAll = modal.querySelector('#ls-review-keep')
|
||||||
|
keepAll.textContent = t('lansync.reviewKeepAll')
|
||||||
|
|
||||||
|
const keptBy = path => [...kept].some(entry => path === entry || path.startsWith(entry + '/'))
|
||||||
|
const keptChild = path => [...kept].some(entry => entry !== path && entry.startsWith(path + '/'))
|
||||||
|
|
||||||
|
const keptTotals = () => {
|
||||||
|
let keptFiles = 0
|
||||||
|
let keptBytes = 0
|
||||||
|
for (const unit of units) {
|
||||||
|
if (!keptUnits.has(unit.key)) continue
|
||||||
|
keptFiles += Math.max(0, Number(unit.files) || 0)
|
||||||
|
keptBytes += Math.max(0, Number(unit.bytes) || 0)
|
||||||
|
}
|
||||||
|
for (const path of kept) {
|
||||||
|
if ([...kept].some(entry => entry !== path && path.startsWith(entry + '/'))) continue
|
||||||
|
const unit = units.find(entry => (nodeInfo.get(path) || {}).unit === entry.key)
|
||||||
|
if (unit && keptUnits.has(unit.key)) continue
|
||||||
|
const node = nodeInfo.get(path)
|
||||||
|
if (!node) continue
|
||||||
|
keptFiles += node.files
|
||||||
|
keptBytes += node.bytes
|
||||||
|
}
|
||||||
|
return { files: keptFiles, bytes: keptBytes }
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
for (const row of modal.querySelectorAll('.lan-node')) {
|
||||||
|
const input = row.querySelector('input')
|
||||||
|
const path = row.dataset.path
|
||||||
|
const unitKept = keptUnits.has(row.dataset.unit)
|
||||||
|
input.checked = !unitKept && !keptBy(path)
|
||||||
|
input.indeterminate = input.checked && keptChild(path)
|
||||||
|
// kept through an ancestor: re-enable that ancestor instead
|
||||||
|
input.disabled = unitKept || (keptBy(path) && !kept.has(path))
|
||||||
|
}
|
||||||
|
for (const row of modal.querySelectorAll('.lan-review-unit')) {
|
||||||
|
const input = row.querySelector('input')
|
||||||
|
const key = row.dataset.key
|
||||||
|
const unit = units.find(entry => entry.key === key)
|
||||||
|
input.checked = !keptUnits.has(key)
|
||||||
|
input.indeterminate = input.checked && [...kept].some(entry => (nodeInfo.get(entry) || {}).unit === key)
|
||||||
|
row.querySelector('.lan-review-meta').textContent = t('lansync.reviewFiles', unit ? unit.files : 0, formatBytes(unit ? unit.bytes : 0))
|
||||||
|
}
|
||||||
|
const left = keptTotals()
|
||||||
|
go.textContent = t('lansync.reviewDelete', Math.max(0, files - left.files), formatBytes(Math.max(0, bytes - left.bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeRow = (node, unitKey, depth) => {
|
||||||
|
nodeInfo.set(node.path, { files: node.files || 0, bytes: node.bytes || 0, unit: unitKey })
|
||||||
|
const row = document.createElement('div')
|
||||||
|
row.className = 'lan-node'
|
||||||
|
row.dataset.path = node.path
|
||||||
|
row.dataset.unit = unitKey
|
||||||
|
row.style.paddingLeft = `${depth * 18 + 6}px`
|
||||||
|
row.innerHTML = `
|
||||||
|
<label class="check-label"><input type="checkbox"><span class="lan-node-name"></span></label>
|
||||||
|
<span class="lan-node-meta"></span>
|
||||||
|
<button type="button" class="button small lan-node-open" hidden></button>
|
||||||
|
`
|
||||||
|
row.querySelector('.lan-node-name').textContent = node.name
|
||||||
|
row.querySelector('.lan-node-meta').textContent = t('lansync.reviewFiles', node.files || 0, formatBytes(node.bytes || 0))
|
||||||
|
const input = row.querySelector('input')
|
||||||
|
input.onchange = () => {
|
||||||
|
if (input.checked) {
|
||||||
|
kept.delete(node.path)
|
||||||
|
// keep the descendants' own entries from overriding an ancestor
|
||||||
|
for (const entry of [...kept]) if (entry.startsWith(node.path + '/')) kept.delete(entry)
|
||||||
|
} else {
|
||||||
|
for (const entry of [...kept]) if (entry.startsWith(node.path + '/')) kept.delete(entry)
|
||||||
|
kept.add(node.path)
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
const childBox = document.createElement('div')
|
||||||
|
childBox.className = 'lan-nodes'
|
||||||
|
childBox.hidden = true
|
||||||
|
const open = row.querySelector('.lan-node-open')
|
||||||
|
if (node.hasChildren) {
|
||||||
|
open.hidden = false
|
||||||
|
open.textContent = '+'
|
||||||
|
open.onclick = async () => {
|
||||||
|
if (!childBox.dataset.loaded) {
|
||||||
|
childBox.dataset.loaded = '1'
|
||||||
|
const res = await window.plwl.lansyncReviewTree(info.id, node.path)
|
||||||
|
for (const child of (res && res.nodes) || []) childBox.appendChild(nodeRow(child, unitKey, depth + 1))
|
||||||
|
}
|
||||||
|
childBox.hidden = !childBox.hidden
|
||||||
|
open.textContent = childBox.hidden ? '+' : '−'
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const wrap = document.createElement('div')
|
||||||
|
wrap.className = 'lan-node-wrap'
|
||||||
|
wrap.appendChild(row)
|
||||||
|
wrap.appendChild(childBox)
|
||||||
|
return wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const unit of units) {
|
||||||
|
const row = document.createElement('div')
|
||||||
|
row.className = 'lan-review-unit'
|
||||||
|
row.dataset.key = unit.key
|
||||||
|
row.innerHTML = `
|
||||||
|
<label class="check-label"><input type="checkbox" checked><span class="lan-review-name"></span></label>
|
||||||
|
<span class="lan-review-meta"></span>
|
||||||
|
`
|
||||||
|
row.querySelector('.lan-review-name').textContent = unit.label || unit.key
|
||||||
|
row.querySelector('input').onchange = async event => {
|
||||||
|
if (event.target.checked) {
|
||||||
|
keptUnits.delete(unit.key)
|
||||||
|
for (const entry of [...kept]) if ((nodeInfo.get(entry) || {}).unit === unit.key) kept.delete(entry)
|
||||||
|
} else {
|
||||||
|
keptUnits.add(unit.key)
|
||||||
|
for (const entry of [...kept]) if ((nodeInfo.get(entry) || {}).unit === unit.key) kept.delete(entry)
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
list.appendChild(row)
|
||||||
|
const children = document.createElement('div')
|
||||||
|
children.className = 'lan-nodes'
|
||||||
|
list.appendChild(children)
|
||||||
|
// top folders load on first paint; a unit's list is small (collapsed rows)
|
||||||
|
window.plwl.lansyncReviewTree(info.id, unit.key).then(res => {
|
||||||
|
for (const node of (res && res.nodes) || []) children.appendChild(nodeRow(node, unit.key, 1))
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
keepAll.onclick = () => {
|
||||||
|
for (const unit of units) keptUnits.add(unit.key)
|
||||||
|
kept.clear()
|
||||||
|
refresh()
|
||||||
|
decide()
|
||||||
|
}
|
||||||
|
|
||||||
|
let deciding = false
|
||||||
|
const decide = async () => {
|
||||||
|
if (deciding) return
|
||||||
|
deciding = true
|
||||||
|
clearReviewTimer()
|
||||||
|
go.disabled = true
|
||||||
|
keepAll.disabled = true
|
||||||
|
go.textContent = t('lansync.reviewDeleting')
|
||||||
|
try {
|
||||||
|
await window.plwl.lansyncReviewDecide(info.id, { keep: [...kept], keepUnits: [...keptUnits] })
|
||||||
|
} catch {}
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
go.onclick = decide
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
// A received sync changed local files: reload every list (settings included).
|
// A received sync changed local files: reload every list (settings included).
|
||||||
async function reloadLocalData() {
|
async function reloadLocalData() {
|
||||||
const [wines, prefixes, games, settings] = await Promise.all([
|
const [wines, prefixes, games, settings] = await Promise.all([
|
||||||
|
|
@ -1946,8 +2490,10 @@ function wireLanSync() {
|
||||||
})
|
})
|
||||||
window.plwl.onLanSyncReceiving(info => onLanReceiving(info))
|
window.plwl.onLanSyncReceiving(info => onLanReceiving(info))
|
||||||
window.plwl.onLanSyncIncoming(info => onLanIncoming(info))
|
window.plwl.onLanSyncIncoming(info => onLanIncoming(info))
|
||||||
|
window.plwl.onLanSyncReview(info => onLanReview(info))
|
||||||
window.plwl.onLanSyncApplied(async info => {
|
window.plwl.onLanSyncApplied(async info => {
|
||||||
toast(t('lansync.applied', info.from || '', info.files || 0, formatBytes(info.bytes || 0)), 'success')
|
// the summary popup replaces the toast; reload behind it
|
||||||
|
lastApplied = info
|
||||||
await reloadLocalData()
|
await reloadLocalData()
|
||||||
})
|
})
|
||||||
qs('#ls-reroll-id').addEventListener('click', async () => {
|
qs('#ls-reroll-id').addEventListener('click', async () => {
|
||||||
|
|
@ -2012,7 +2558,7 @@ function wireEvents() {
|
||||||
toast(t('wines.imported', res.path), 'success')
|
toast(t('wines.imported', res.path), 'success')
|
||||||
state.wines = await window.plwl.winesList()
|
state.wines = await window.plwl.winesList()
|
||||||
} else {
|
} else {
|
||||||
toast(t('toast.importFailed') + ': ' + (res.error || ''), 'error')
|
toast(t('import.failed', importErrorText((res && res.error) || '')), 'error')
|
||||||
}
|
}
|
||||||
renderWines()
|
renderWines()
|
||||||
})
|
})
|
||||||
|
|
@ -2058,7 +2604,7 @@ function wireEvents() {
|
||||||
})
|
})
|
||||||
qs('#gc-icon-browse').addEventListener('click', async () => {
|
qs('#gc-icon-browse').addEventListener('click', async () => {
|
||||||
// start in the app's own folder, where the icon exe most likely lives
|
// start in the app's own folder, where the icon exe most likely lives
|
||||||
const start = absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame)
|
const start = state.currentGame.path || absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame)
|
||||||
const p = await window.plwl.dialogPickFile({
|
const p = await window.plwl.dialogPickFile({
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
defaultPath: start || window.plwl.FILES_DIR,
|
defaultPath: start || window.plwl.FILES_DIR,
|
||||||
|
|
@ -2096,6 +2642,7 @@ async function boot() {
|
||||||
wireEvents()
|
wireEvents()
|
||||||
wireRealtime()
|
wireRealtime()
|
||||||
wireOpenWith()
|
wireOpenWith()
|
||||||
|
wireHandoffProgress()
|
||||||
wireLanSync()
|
wireLanSync()
|
||||||
qs('#loading').remove()
|
qs('#loading').remove()
|
||||||
qs('#main').style.display = 'flex'
|
qs('#main').style.display = 'flex'
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,7 @@
|
||||||
<div class="linux-only" style="display:none">
|
<div class="linux-only" style="display:none">
|
||||||
<label class="field-label" data-t="config.isolation"></label>
|
<label class="field-label" data-t="config.isolation"></label>
|
||||||
<span id="gc-isolation"></span>
|
<span id="gc-isolation"></span>
|
||||||
|
<p class="subtitle" id="gc-isolation-hint"></p>
|
||||||
<p class="subtitle" id="gc-isolation-status"></p>
|
<p class="subtitle" id="gc-isolation-status"></p>
|
||||||
|
|
||||||
<label class="check-label">
|
<label class="check-label">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -358,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);
|
||||||
|
|
@ -688,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;
|
||||||
|
|
|
||||||
108
src/fsutil.js
108
src/fsutil.js
|
|
@ -56,6 +56,10 @@ function progressGate(emit) {
|
||||||
return push
|
return push
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tree walks are dominated by per-file syscalls; a handful in flight keeps a
|
||||||
|
// slow disk busy without flooding the event loop with thousands of them.
|
||||||
|
const TREE_CONCURRENCY = 16
|
||||||
|
|
||||||
// Run `fn` over `items` with at most `limit` in flight, results in order.
|
// Run `fn` over `items` with at most `limit` in flight, results in order.
|
||||||
async function mapLimit(items, limit, fn) {
|
async function mapLimit(items, limit, fn) {
|
||||||
const out = new Array(items.length)
|
const out = new Array(items.length)
|
||||||
|
|
@ -90,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++
|
||||||
|
|
@ -102,7 +106,7 @@ 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 }
|
||||||
|
|
@ -110,9 +114,12 @@ async function treeSize(root) {
|
||||||
|
|
||||||
// Copy a tree with symlinks recreated and permissions carried (copyFile
|
// Copy a tree with symlinks recreated and permissions carried (copyFile
|
||||||
// drops both); reports cumulative { done, bytes } through onProgress.
|
// drops both); reports cumulative { done, bytes } through onProgress.
|
||||||
|
// `failed` counts entries that could not be recreated: a caller that needs a
|
||||||
|
// complete copy (an import) must not treat a partial tree as success.
|
||||||
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)
|
||||||
|
|
@ -122,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
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await fsp.mkdir(to, { recursive: true })
|
await fsp.mkdir(to, { recursive: true })
|
||||||
for (const entry of entries) {
|
} 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()) {
|
||||||
|
|
@ -133,32 +146,46 @@ 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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Imported trees come from media that lose permissions (archives, FAT, a
|
// Imported trees come from media that lose permissions (archives, FAT, a
|
||||||
|
|
@ -176,13 +203,13 @@ async function chmodTree(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()) continue
|
if (entry.isSymbolicLink()) return
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
await walk(full)
|
await walk(full)
|
||||||
} else if (!entry.isFile()) {
|
} else if (!entry.isFile()) {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const before = (await fsp.stat(full)).mode & 0o7777
|
const before = (await fsp.stat(full)).mode & 0o7777
|
||||||
|
|
@ -193,12 +220,55 @@ async function chmodTree(root) {
|
||||||
else files++
|
else files++
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
await walk(root)
|
await walk(root)
|
||||||
return { files, dirs }
|
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
|
// Delete deepest-first with progress through onProgress; symlinks are
|
||||||
// unlinked, never followed.
|
// unlinked, never followed.
|
||||||
async function removeTree(root, onProgress) {
|
async function removeTree(root, onProgress) {
|
||||||
|
|
@ -232,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)
|
||||||
|
|
@ -241,7 +311,7 @@ async function removeTree(root, onProgress) {
|
||||||
} catch {}
|
} catch {}
|
||||||
dirs++
|
dirs++
|
||||||
tick()
|
tick()
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
let size = 0
|
let size = 0
|
||||||
try {
|
try {
|
||||||
|
|
@ -254,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++
|
||||||
|
|
@ -266,5 +336,5 @@ async function removeTree(root, onProgress) {
|
||||||
return { files, dirs, bytes }
|
return { files, dirs, bytes }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { pathExists, dedupeDir, copyTree, treeSize, removeTree, chmodTree, progressGate, mapLimit }
|
module.exports = { pathExists, dedupeDir, copyTree, copyFileProgress, treeSize, removeTree, chmodTree, progressGate, mapLimit }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ function createHashCache(dataDir) {
|
||||||
let cache = null
|
let cache = null
|
||||||
let dirty = false
|
let dirty = false
|
||||||
let savedAt = 0
|
let savedAt = 0
|
||||||
const stats = { hits: 0, misses: 0 }
|
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
if (cache) return cache
|
if (cache) return cache
|
||||||
|
|
@ -80,11 +79,7 @@ function createHashCache(dataDir) {
|
||||||
// the cache dirty only when the timestamp is stale.
|
// the cache dirty only when the timestamp is stale.
|
||||||
function hit(rel, size, mtimeMs) {
|
function hit(rel, size, mtimeMs) {
|
||||||
const known = load()[rel]
|
const known = load()[rel]
|
||||||
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') {
|
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') return null
|
||||||
stats.misses++
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
stats.hits++
|
|
||||||
if (Date.now() - (known[3] || 0) > 86400000) {
|
if (Date.now() - (known[3] || 0) > 86400000) {
|
||||||
known[3] = Date.now()
|
known[3] = Date.now()
|
||||||
dirty = true
|
dirty = true
|
||||||
|
|
@ -111,35 +106,13 @@ function createHashCache(dataDir) {
|
||||||
cache = null
|
cache = null
|
||||||
dirty = false
|
dirty = false
|
||||||
savedAt = 0
|
savedAt = 0
|
||||||
stats.hits = 0
|
|
||||||
stats.misses = 0
|
|
||||||
try {
|
try {
|
||||||
fs.rmSync(FILE, { force: true })
|
fs.rmSync(FILE, { force: true })
|
||||||
fs.rmSync(`${FILE}.tmp`, { force: true })
|
fs.rmSync(`${FILE}.tmp`, { force: true })
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dropping the memory copy makes the next lookup re-read from disk (tests).
|
return { HASH_ALGO, hashFile, hit, remember, cachedHash, save, clear }
|
||||||
function forget() {
|
|
||||||
cache = null
|
|
||||||
dirty = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
HASH_ALGO,
|
|
||||||
hashFile,
|
|
||||||
hit,
|
|
||||||
remember,
|
|
||||||
cachedHash,
|
|
||||||
save,
|
|
||||||
clear,
|
|
||||||
forget,
|
|
||||||
stats: () => ({ ...stats, size: cache ? Object.keys(cache).length : 0, algo: HASH_ALGO }),
|
|
||||||
resetStats: () => {
|
|
||||||
stats.hits = 0
|
|
||||||
stats.misses = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createHashCache, hashFile, HASH_ALGO }
|
module.exports = { createHashCache, hashFile, HASH_ALGO }
|
||||||
|
|
|
||||||
|
|
@ -170,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)
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,21 @@ 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'])
|
||||||
|
|
||||||
|
// A copy that lost files is not an import: drop the partial tree and say so.
|
||||||
|
async function copyComplete(src, dst, onProgress) {
|
||||||
|
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
|
// The copy itself lives in fsutil.copyTree; moveFolder falls back to it
|
||||||
// across filesystems only.
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
@ -44,7 +55,7 @@ async function importGame(srcDir, mode, onProgress, kind = 'windows') {
|
||||||
}
|
}
|
||||||
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
|
||||||
// the source's permissions are not the source's truth: an app out of a
|
// the source's permissions are not the source's truth: an app out of a
|
||||||
|
|
@ -150,4 +161,4 @@ async function findDefaultExe(dir) {
|
||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { importGame, importGameFromPrefix, findDefaultExe, findLinuxExe, moveFolder }
|
module.exports = { importGame, importGameFromPrefix, findDefaultExe, findLinuxExe, moveFolder, copyComplete }
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
const { runGame, runTool } = require('../runner')
|
const { runGame, runTool } = require('../runner')
|
||||||
const { runLinuxGame } = require('../linuxrunner')
|
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) {
|
||||||
|
|
@ -21,11 +22,9 @@ function sendToWindow(win, channel, data) {
|
||||||
// Game-process state: id -> { pid, resolved prefix name and wine id }, frozen
|
// Game-process state: id -> { pid, resolved prefix name and wine id }, frozen
|
||||||
// at launch (the in-use guards must name what the process really locked).
|
// at launch (the in-use guards must name what the process really locked).
|
||||||
// onGamesChanged fires in the main process when the running set changes.
|
// onGamesChanged fires in the main process when the running set changes.
|
||||||
const STARTUP_GRACE_MS = 60000
|
function createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgress, openRelay }) {
|
||||||
|
|
||||||
function createGamesService({ getWindow, onGamesChanged, sandbox }) {
|
|
||||||
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 = () => {
|
||||||
|
|
@ -42,14 +41,20 @@ function createGamesService({ getWindow, onGamesChanged, sandbox }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// a stale entry (no pid yet / dead pid) must not answer "already running"
|
// Everything a run owns once it is over: the entry and the watcher on the
|
||||||
|
// app's request folder (openrelay.js).
|
||||||
|
function forget(id) {
|
||||||
|
const entry = running.get(id)
|
||||||
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,9 +89,10 @@ function createGamesService({ getWindow, onGamesChanged, sandbox }) {
|
||||||
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)
|
||||||
|
|
@ -98,6 +104,20 @@ function createGamesService({ getWindow, onGamesChanged, sandbox }) {
|
||||||
return payload => runLinuxGame({ game, sandbox, ...payload })
|
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' }
|
||||||
|
|
@ -116,51 +136,57 @@ function createGamesService({ getWindow, onGamesChanged, sandbox }) {
|
||||||
// a document into a running app is a handoff, not a tracked run
|
// a document into a running app is a handoff, not a tracked run
|
||||||
if (!openFile) return { ok: false, error: 'already-running' }
|
if (!openFile) return { ok: false, error: 'already-running' }
|
||||||
try {
|
try {
|
||||||
await run({ 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 ? 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 run({ onExit: () => handleExit(id), openFile })
|
const res = await run({ onExit: () => handleExit(id, entry), openFile, onProgress: handoffProgress })
|
||||||
// stop requested while still starting: kill and clear
|
// stop requested while still starting: kill and clear
|
||||||
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: never keep the entry, it would block the next launch
|
// no pid: never keep the entry, it would block the next launch
|
||||||
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.
|
// Stop clears the state - except while a run is still starting: that start
|
||||||
|
// 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 && !entry.pid) killRequests.add(id)
|
||||||
|
else {
|
||||||
if (entry) killProcessGroup(entry.pid)
|
if (entry) killProcessGroup(entry.pid)
|
||||||
else if (running.has(id)) killRequests.add(id)
|
forget(id)
|
||||||
running.delete(id)
|
}
|
||||||
startedAt.delete(id)
|
|
||||||
sendToWindow(getWindow(), 'games:changed')
|
sendToWindow(getWindow(), 'games:changed')
|
||||||
notifyChanged()
|
notifyChanged()
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,10 @@ function extractGameIcon(id, exePath, kind) {
|
||||||
|
|
||||||
// --- registration -----------------------------------------------------------
|
// --- registration -----------------------------------------------------------
|
||||||
|
|
||||||
function registerIpc({ getWindow, onGamesChanged }) {
|
function registerIpc({ getWindow, onGamesChanged, onHandoffProgress, openRelay }) {
|
||||||
// one probe per launcher, cached: it decides bwrap vs plain run
|
// one probe per launcher, cached: it decides bwrap vs plain run
|
||||||
const sandbox = createSandboxProbe()
|
const sandbox = createSandboxProbe()
|
||||||
const games = createGamesService({ getWindow, onGamesChanged, sandbox })
|
const games = createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgress, openRelay })
|
||||||
|
|
||||||
// sandbox --------------------------------------------------------------------
|
// sandbox --------------------------------------------------------------------
|
||||||
ipcMain.handle('sandbox:status', async () => sandbox.status() || (await sandbox.probe()))
|
ipcMain.handle('sandbox:status', async () => sandbox.status() || (await sandbox.probe()))
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ 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) => {
|
||||||
|
|
@ -24,6 +24,9 @@ function registerLanSyncIpc({ getWindow }) {
|
||||||
return { ok: true }
|
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({
|
||||||
|
|
@ -31,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) {
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ function createIdentity(configFile) {
|
||||||
function rememberDevice(id, code, name) {
|
function rememberDevice(id, code, name) {
|
||||||
const cfg = identity()
|
const cfg = identity()
|
||||||
const stored = String(code || '').trim().toUpperCase()
|
const stored = String(code || '').trim().toUpperCase()
|
||||||
cfg.known = { ...cfg.known, [id]: { code: stored, syncCode: stored, name: name || '', at: Date.now() } }
|
cfg.known = { ...cfg.known, [id]: { code: stored, name: name || '', at: Date.now() } }
|
||||||
writeJson(configFile, cfg)
|
writeJson(configFile, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const fsp = fs.promises
|
const fsp = fs.promises
|
||||||
const { isLocalOnlyRel } = require('./prefixes')
|
|
||||||
|
|
||||||
function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurrency }) {
|
function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurrency }) {
|
||||||
// Targets inside the launcher data travel relative (the mirrored layout is
|
// Targets inside the launcher data travel relative (the mirrored layout is
|
||||||
|
|
@ -24,23 +23,25 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
|
||||||
// Walk without following symlinks (dosdevices etc. are part of the sync).
|
// Walk without following symlinks (dosdevices etc. are part of the sync).
|
||||||
// Iterative, with one directory's stat/readlink calls in flight together.
|
// Iterative, with one directory's stat/readlink calls in flight together.
|
||||||
// `emit` (optional) streams dirs and links out while the walk continues;
|
// `emit` (optional) streams dirs and links out while the walk continues;
|
||||||
// file entries only land in `out` (their digest comes later).
|
// file entries only land in `out` (their digest comes later). `onError`
|
||||||
async function scanTree(abs, rel, out, mtimes, emit) {
|
// 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]]
|
const queue = [[abs, rel]]
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
const [dirAbs, dirRel] = queue.pop()
|
const [dirAbs, dirRel] = queue.pop()
|
||||||
let entries
|
let entries
|
||||||
try {
|
try {
|
||||||
entries = await fsp.readdir(dirAbs, { withFileTypes: true })
|
entries = await fsp.readdir(dirAbs, { withFileTypes: true })
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (onError) onError(err, dirAbs)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const dirs = []
|
const dirs = []
|
||||||
const leaves = []
|
const leaves = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const relChild = `${dirRel}/${entry.name}`
|
const relChild = `${dirRel}/${entry.name}`
|
||||||
// native Linux app data never leaves this machine
|
if (skip && skip.has(relChild)) continue // a selection left it out
|
||||||
if (isLocalOnlyRel(relChild)) continue
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
const record = { path: relChild, type: 'dir' }
|
const record = { path: relChild, type: 'dir' }
|
||||||
out.push(record)
|
out.push(record)
|
||||||
|
|
@ -57,7 +58,10 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
|
||||||
let target = ''
|
let target = ''
|
||||||
try {
|
try {
|
||||||
target = await fsp.readlink(absChild)
|
target = await fsp.readlink(absChild)
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
if (onError) onError(err, absChild)
|
||||||
|
return
|
||||||
|
}
|
||||||
const record = { path: relChild, type: 'link', target: portableLinkTarget(absChild, target) }
|
const record = { path: relChild, type: 'link', target: portableLinkTarget(absChild, target) }
|
||||||
out.push(record)
|
out.push(record)
|
||||||
if (emit) await emit(record)
|
if (emit) await emit(record)
|
||||||
|
|
@ -66,7 +70,8 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
|
||||||
let st
|
let st
|
||||||
try {
|
try {
|
||||||
st = await fsp.stat(absChild)
|
st = await fsp.stat(absChild)
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (onError) onError(err, absChild)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!st.isFile()) return
|
if (!st.isFile()) return
|
||||||
|
|
@ -80,29 +85,48 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manifest of one unit, streamed: `handlers` may carry onEntry (a Promise
|
// Manifest of one unit, streamed: `handlers` may carry onEntry (a Promise
|
||||||
// per entry, awaited - backpressure), onTotal and onProgress. File entries
|
// per entry, awaited - backpressure), onTotal, onProgress, onIncomplete
|
||||||
// go out as their digests finish, so the receiver compares while this side
|
// (called once, when anything in the tree could not be read) and skip (unit
|
||||||
// is still hashing; hashing itself stays parallel, a batch at a time.
|
// 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) {
|
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 onEntry = handlers && handlers.onEntry
|
||||||
const onTotal = handlers && handlers.onTotal
|
const onTotal = handlers && handlers.onTotal
|
||||||
const onProgress = handlers && handlers.onProgress
|
const onProgress = handlers && handlers.onProgress
|
||||||
|
const onIncomplete = handlers && handlers.onIncomplete
|
||||||
const entries = []
|
const entries = []
|
||||||
const mtimes = new Map()
|
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) {
|
for (const rel of roots) {
|
||||||
|
if (skip && skip.has(rel)) continue
|
||||||
const abs = filesPath(rel)
|
const abs = filesPath(rel)
|
||||||
if (!abs) continue
|
if (!abs) {
|
||||||
|
markIncomplete(new Error('bad-unit-path'), rel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
let st
|
let st
|
||||||
try {
|
try {
|
||||||
st = await fsp.lstat(abs)
|
st = await fsp.lstat(abs)
|
||||||
} catch {
|
} 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
|
continue
|
||||||
}
|
}
|
||||||
if (st.isSymbolicLink()) {
|
if (st.isSymbolicLink()) {
|
||||||
let target = ''
|
let target = ''
|
||||||
try {
|
try {
|
||||||
target = await fsp.readlink(abs)
|
target = await fsp.readlink(abs)
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
markIncomplete(err, abs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
const record = { path: rel, type: 'link', target: portableLinkTarget(abs, target) }
|
const record = { path: rel, type: 'link', target: portableLinkTarget(abs, target) }
|
||||||
entries.push(record)
|
entries.push(record)
|
||||||
if (onEntry) await onEntry(record)
|
if (onEntry) await onEntry(record)
|
||||||
|
|
@ -110,7 +134,7 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
|
||||||
const record = { path: rel, type: 'dir' }
|
const record = { path: rel, type: 'dir' }
|
||||||
entries.push(record)
|
entries.push(record)
|
||||||
if (onEntry) await onEntry(record)
|
if (onEntry) await onEntry(record)
|
||||||
await scanTree(abs, rel, entries, mtimes, onEntry)
|
await scanTree(abs, rel, entries, mtimes, onEntry, markIncomplete, skip)
|
||||||
} else if (st.isFile()) {
|
} else if (st.isFile()) {
|
||||||
entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
|
entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
|
||||||
mtimes.set(rel, st.mtimeMs || 0)
|
mtimes.set(rel, st.mtimeMs || 0)
|
||||||
|
|
@ -139,8 +163,10 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
|
||||||
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
|
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
} catch {
|
if (!entry.hash) markIncomplete(new Error('unreadable-file'), entry.path)
|
||||||
|
} catch (err) {
|
||||||
entry.hash = null
|
entry.hash = null
|
||||||
|
markIncomplete(err, entry.path)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 }
|
||||||
790
src/lansync.js
790
src/lansync.js
File diff suppressed because it is too large
Load diff
|
|
@ -29,6 +29,12 @@ const HEARTBEAT_MS = Number(process.env.PLWL_LANSYNC_HEARTBEAT) || 20000
|
||||||
// One ping round trip; env override for tests.
|
// One ping round trip; env override for tests.
|
||||||
const PING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_PING_TIMEOUT) || 10000
|
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,
|
||||||
|
|
@ -54,8 +60,10 @@ function createPusher(api) {
|
||||||
function planUnit(device, token, unit, force, meta, produce) {
|
function planUnit(device, token, unit, force, meta, produce) {
|
||||||
// announced up front, so the receiver's popup can 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,
|
||||||
|
|
@ -118,18 +126,20 @@ function createPusher(api) {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
fn(value)
|
fn(value)
|
||||||
}
|
}
|
||||||
// unit and path travel as headers: constant per request
|
// 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
|
||||||
},
|
},
|
||||||
|
|
@ -164,19 +174,54 @@ function createPusher(api) {
|
||||||
|
|
||||||
// --- 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
|
||||||
|
|
@ -189,20 +234,29 @@ function createPusher(api) {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,21 +267,27 @@ function createPusher(api) {
|
||||||
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": registry-level values only; opt-in - apps must not
|
// "Sync settings": registry-level values only; opt-in - apps must not
|
||||||
|
|
@ -254,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)
|
||||||
}
|
}
|
||||||
|
|
@ -262,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
|
||||||
}
|
}
|
||||||
|
|
@ -276,58 +342,35 @@ 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,
|
||||||
|
// size of the synced trees as they now sit on the other device
|
||||||
|
sizeBytes: 0,
|
||||||
// the popup reports "3 games, 1 prefix" instead of file counts
|
// 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, success or failure.
|
// Every sync resolves to the same shape, success or failure.
|
||||||
function syncResult(ok, error, summary, events) {
|
function syncResult(ok, error, summary) {
|
||||||
const buckets = { apps: summary.apps, prefixes: summary.prefixes, wines: summary.wines }
|
return { ok, error: error || null, summary }
|
||||||
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 to the peer; a rejected code comes back as 'bad-code'.
|
// Push to the peer; a rejected code comes back as 'bad-code'.
|
||||||
async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) {
|
async function syncToDevice({ deviceId, device: target, code, categories, force, selection, onProgress }) {
|
||||||
const fail = (error, summary, events) => syncResult(false, error, summary || newSummary(0), events || [])
|
const fail = (error, summary) => syncResult(false, error, summary || newSummary(0))
|
||||||
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')
|
||||||
// units are known before anything is read: announce them with the verify
|
// units are known before anything is read: announce them with the verify
|
||||||
const units = buildUnits(cats)
|
const units = buildUnits(cats, selection)
|
||||||
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, settings: 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
|
||||||
if (cats.has('settings')) totals.settings = 1
|
if (cats.has('settings')) totals.settings = 1
|
||||||
// every progress event is kept, so callers can report without subscribing
|
|
||||||
const events = []
|
|
||||||
const progress = data => {
|
const progress = data => {
|
||||||
events.push(data)
|
|
||||||
if (onProgress) onProgress(data)
|
if (onProgress) onProgress(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -394,6 +437,9 @@ function createPusher(api) {
|
||||||
if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId)
|
if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId)
|
||||||
return fail(error)
|
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
|
const token = verified.data.token
|
||||||
// paired now: the next sync needs no typing
|
// paired now: the next sync needs no typing
|
||||||
rememberDevice(device.id || deviceId, code, device.name)
|
rememberDevice(device.id || deviceId, code, device.name)
|
||||||
|
|
@ -424,13 +470,30 @@ function createPusher(api) {
|
||||||
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
|
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
// 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 () => {
|
const runUnits = async () => {
|
||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
if (abort.dead) throw new Error('receiver-lost')
|
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
|
||||||
|
|
@ -443,8 +506,15 @@ function createPusher(api) {
|
||||||
// for a receiver that still has to hash).
|
// for a receiver that still has to hash).
|
||||||
const entries = []
|
const entries = []
|
||||||
let writes = Promise.resolve()
|
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 => {
|
const plan = await planUnit(device, token, { ...unit, roots }, !!force, { totals }, async write => {
|
||||||
await buildManifestStream(roots, !!force, {
|
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
|
// the receiver sizes its compare bar from this, before any hashing
|
||||||
onTotal: totalBytes => {
|
onTotal: totalBytes => {
|
||||||
writes = writes.then(() => write({ unitBytes: totalBytes }))
|
writes = writes.then(() => write({ unitBytes: totalBytes }))
|
||||||
|
|
@ -453,6 +523,7 @@ function createPusher(api) {
|
||||||
entries.push(entry)
|
entries.push(entry)
|
||||||
writes = writes.then(() => write(entry))
|
writes = writes.then(() => write(entry))
|
||||||
},
|
},
|
||||||
|
onIncomplete: incomplete,
|
||||||
onProgress: (hashedDone, hashedTotal, hashedBytes, totalBytes) => {
|
onProgress: (hashedDone, hashedTotal, hashedBytes, totalBytes) => {
|
||||||
barBytes = hashedBytes
|
barBytes = hashedBytes
|
||||||
barTotal = totalBytes
|
barTotal = totalBytes
|
||||||
|
|
@ -467,15 +538,13 @@ function createPusher(api) {
|
||||||
})
|
})
|
||||||
if (abort.dead) throw new Error('receiver-lost')
|
if (abort.dead) throw new Error('receiver-lost')
|
||||||
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: files created/overwritten vs already identical
|
const filePaths = new Set(entries.filter(e => e.type === 'file').map(e => e.path))
|
||||||
const fileEntries = entries.filter(e => e.type === 'file')
|
|
||||||
const filePaths = new Set(fileEntries.map(e => e.path))
|
|
||||||
let sameFiles = 0
|
let sameFiles = 0
|
||||||
for (const p of same) if (filePaths.has(p)) sameFiles++
|
for (const p of same) if (filePaths.has(p)) sameFiles++
|
||||||
bucket.copied += fileEntries.length - sameFiles
|
|
||||||
bucket.skipped += sameFiles
|
|
||||||
summary.skipped += sameFiles
|
summary.skipped += sameFiles
|
||||||
const pending = []
|
const pending = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
|
@ -512,16 +581,20 @@ function createPusher(api) {
|
||||||
if (typeof api.mapLimit === 'function') await api.mapLimit(pending, UPLOAD_CONCURRENCY, send)
|
if (typeof api.mapLimit === 'function') await api.mapLimit(pending, UPLOAD_CONCURRENCY, send)
|
||||||
else for (const entry of pending) await send(entry)
|
else for (const entry of pending) await send(entry)
|
||||||
if (failed) throw failed
|
if (failed) throw failed
|
||||||
|
// A dead receiver made `send` skip files; committing now would prune
|
||||||
|
// 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')
|
||||||
}
|
}
|
||||||
emit('copy', unit, allDone + pending.length, allTotal + pending.length, true)
|
emit('copy', unit, allDone + pending.length, allTotal + pending.length, true)
|
||||||
// the totals count a unit's files only once they are on the wire
|
// the totals count a unit's files only once they are on the wire
|
||||||
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
|
||||||
|
|
@ -529,13 +602,17 @@ 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
|
// a sync with nothing to send still ends with a full bar
|
||||||
barTotal = barTotal || 1
|
barTotal = barTotal || 1
|
||||||
barBytes = barTotal
|
barBytes = barTotal
|
||||||
|
|
@ -547,16 +624,35 @@ function createPusher(api) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
abort.dead = true
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// putFile is exposed for the stall tests (it is the piece that must never
|
||||||
// leave a request half-open)
|
// leave a request half-open)
|
||||||
return { syncToDevice, buildUnits, buildRegistryPayload, putFile, CATEGORIES }
|
return { syncToDevice, buildUnits, buildRegistryPayload, items, putFile, CATEGORIES }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = createPusher
|
module.exports = createPusher
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ function loadGames() {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
@ -90,10 +91,6 @@ function appsUsingPrefix(name, running) {
|
||||||
return (running || []).filter(r => (r.prefixes || []).includes(name))
|
return (running || []).filter(r => (r.prefixes || []).includes(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPrefixInUse(name, running) {
|
|
||||||
return appsUsingPrefix(name, running).length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only an exact match on a registered build counts.
|
// 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
|
||||||
|
|
@ -108,23 +105,26 @@ function gamesFollowingDefault(running) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Windows is the default: entries written before Linux apps existed have no
|
// `kind` is only ever written as 'linux'; a missing one is a Windows app.
|
||||||
// kind at all.
|
|
||||||
function isLinuxGame(game) {
|
function isLinuxGame(game) {
|
||||||
return !!game && game.kind === 'linux'
|
return !!game && game.kind === 'linux'
|
||||||
}
|
}
|
||||||
|
|
||||||
const ISOLATION_MODES = ['auto', 'off', 'require']
|
// 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'
|
||||||
|
}
|
||||||
|
|
||||||
// Only known values reach the registry; anything else falls back to the safe
|
|
||||||
// default (a typo must not silently disable the sandbox).
|
|
||||||
function sanitizeLinuxPatch(patch) {
|
function sanitizeLinuxPatch(patch) {
|
||||||
if (!patch) return patch
|
if (!patch) return patch
|
||||||
const out = { ...patch }
|
const out = { ...patch }
|
||||||
if (out.kind !== undefined) out.kind = out.kind === 'linux' ? 'linux' : 'windows'
|
if (out.kind !== undefined) out.kind = out.kind === 'linux' ? 'linux' : 'windows'
|
||||||
if (out.isolation !== undefined) {
|
if (out.isolation !== undefined) out.isolation = isolationMode(out.isolation)
|
||||||
out.isolation = ISOLATION_MODES.includes(out.isolation) ? out.isolation : 'auto'
|
|
||||||
}
|
|
||||||
if (out.forceX11 !== undefined) out.forceX11 = !!out.forceX11
|
if (out.forceX11 !== undefined) out.forceX11 = !!out.forceX11
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
@ -136,41 +136,79 @@ 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 patch fields are ignored
|
const clean = normalizeGamePatch(g, patch)
|
||||||
if (g.importSource === 'prefix' && patch) {
|
|
||||||
patch = { ...patch }
|
|
||||||
delete patch.prefix
|
|
||||||
delete patch.importSource
|
|
||||||
}
|
|
||||||
// playSeconds lives in its own file; the registry mirrors it
|
// playSeconds lives in its own file; the registry mirrors it
|
||||||
if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds)
|
if (clean.playSeconds !== undefined) writePlaySeconds(id, clean.playSeconds)
|
||||||
Object.assign(g, sanitizeLinuxPatch(patch))
|
Object.assign(g, clean)
|
||||||
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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The app's own folder - what was imported, never the folder holding the exe:
|
||||||
|
// the working directory may have been pointed elsewhere in the properties.
|
||||||
|
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.
|
// 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 }))
|
||||||
|
}
|
||||||
|
// icons and playtime belong to the entry, not to any prefix
|
||||||
|
for (const owned of [path.join(DIRS.DATA, 'icons', id), path.join(DIRS.DATA, 'appdata', id)]) {
|
||||||
|
try {
|
||||||
|
await fs.promises.rm(owned, { recursive: true, force: true })
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
// prefix imports: drop only the registry entry, never prefix files
|
// prefix imports: drop only the registry entry, never prefix files
|
||||||
games.splice(idx, 1)
|
games.splice(idx, 1)
|
||||||
|
|
@ -211,7 +249,7 @@ function newGame({ id, name, exePath, workdir, mode, kind }) {
|
||||||
dllOverrides: [],
|
dllOverrides: [],
|
||||||
// Proton-only: UMU by default, or the build's steam.exe launcher
|
// Proton-only: UMU by default, or the build's steam.exe launcher
|
||||||
disableUmu: false,
|
disableUmu: false,
|
||||||
// Linux-only: how hard to try the bwrap sandbox before falling back
|
// Linux-only: none, relaxed, isolated, or auto (relaxed where bwrap works)
|
||||||
isolation: 'auto',
|
isolation: 'auto',
|
||||||
// Linux-only: force XWayland, where a window can still carry its icon
|
// Linux-only: force XWayland, where a window can still carry its icon
|
||||||
forceX11: false,
|
forceX11: false,
|
||||||
|
|
@ -254,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, isLinuxGame, addGame, updateGame, removeGame, reorderGames, renamePrefixRefs, prefixNameForGame, gamesUsingPrefix, appsUsingPrefix, isPrefixInUse, isWineInUse, gamesFollowingDefault, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile }
|
|
||||||
|
|
|
||||||
|
|
@ -252,13 +252,8 @@ function findIconFile(spec, baseDir) {
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ELF_MAGIC,
|
|
||||||
isElfBuffer,
|
|
||||||
isElfSync,
|
|
||||||
findLinuxExe,
|
findLinuxExe,
|
||||||
parseDesktopEntry,
|
|
||||||
readDesktopEntry,
|
readDesktopEntry,
|
||||||
desktopExecPath,
|
|
||||||
desktopFileFor,
|
desktopFileFor,
|
||||||
findIconFile,
|
findIconFile,
|
||||||
pngOrSvg
|
pngOrSvg
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,12 @@ const os = require('os')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const { DIRS, readJson, writeJson } = require('./paths')
|
const { DIRS, readJson, writeJson } = require('./paths')
|
||||||
const { prefixPath, prefixLinuxRoot } = require('./prefixes')
|
const { prefixPath, prefixLinuxRoot } = require('./prefixes')
|
||||||
const { prefixNameForGame } = require('./library')
|
const { prefixNameForGame, loadSettings, appFolder } = require('./library')
|
||||||
|
const i18n = require('./i18n')
|
||||||
const { spawnTracked, parseArgs } = require('./runner')
|
const { spawnTracked, parseArgs } = require('./runner')
|
||||||
const { buildBwrapArgs, SHARE_ROOT } = require('./sandbox')
|
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']
|
const DATA_DIRS = ['home', 'config', 'data', 'cache', 'state']
|
||||||
|
|
||||||
|
|
@ -24,14 +27,26 @@ const XDG_LAYOUT = {
|
||||||
XDG_STATE_HOME: ['state', path.join('.local', 'state')]
|
XDG_STATE_HOME: ['state', path.join('.local', 'state')]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write access is handed out to the folder the app was imported from and to
|
|
||||||
// nothing else outside its home: a portable app writes its logs and data next
|
// Every folder the app is addressed by - the tree it was imported as, the one
|
||||||
// to itself (Prism Launcher refuses to start otherwise), while system trees
|
// it runs in, and the one its binary sits in (a bundle may keep that in bin/,
|
||||||
// must never come back writable.
|
// or start an app from a folder of its own) - each as stated and as it really
|
||||||
const SYSTEM_DIRS = ['/bin', '/sbin', '/lib', '/lib32', '/lib64', '/libx32', '/usr', '/etc', '/boot', '/proc', '/sys', '/dev', '/run']
|
// 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) {
|
function writableAppDir(game) {
|
||||||
const dir = (game && (game.path || (game.exePath && path.dirname(game.exePath)))) || ''
|
const dir = appFolder(game)
|
||||||
// realpathSync('') would answer with the cwd - never hand that out
|
// realpathSync('') would answer with the cwd - never hand that out
|
||||||
if (!dir) return ''
|
if (!dir) return ''
|
||||||
let real
|
let real
|
||||||
|
|
@ -60,6 +75,79 @@ function linuxUser() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 } = {}) {
|
function copyFromHost(src, dst, { refresh = false } = {}) {
|
||||||
try {
|
try {
|
||||||
if (!refresh && fs.existsSync(dst)) return false
|
if (!refresh && fs.existsSync(dst)) return false
|
||||||
|
|
@ -72,9 +160,8 @@ function copyFromHost(src, dst, { refresh = false } = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PulseAudio looks for its cookie under $HOME/.config/pulse; with HOME
|
// With HOME redirected there is no pulse cookie, and everything through Pulse
|
||||||
// redirected there is none, and sound dies for everything going through
|
// comes up silent; copied once, then the app owns it.
|
||||||
// Pulse. Copied once - the app owns it afterwards.
|
|
||||||
function seedPulseCookie(root) {
|
function seedPulseCookie(root) {
|
||||||
const dst = path.join(root, 'config', 'pulse', 'cookie')
|
const dst = path.join(root, 'config', 'pulse', 'cookie')
|
||||||
const seeded = copyFromHost(path.join(os.homedir(), '.config', 'pulse', 'cookie'), dst)
|
const seeded = copyFromHost(path.join(os.homedir(), '.config', 'pulse', 'cookie'), dst)
|
||||||
|
|
@ -85,9 +172,8 @@ function seedPulseCookie(root) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The host's desktop preferences, not its data: a redirected home has no
|
// The host's desktop preferences, not its data: without them Qt/GTK fall back
|
||||||
// theme at all, and Qt/GTK then fall back to their built-in light look (KDE's
|
// to their built-in light look (KDE's dark palette lives in kdeglobals).
|
||||||
// dark palette lives in kdeglobals).
|
|
||||||
const DESKTOP_SEEDS = [
|
const DESKTOP_SEEDS = [
|
||||||
'kdeglobals',
|
'kdeglobals',
|
||||||
'kdedefaults/kdeglobals',
|
'kdedefaults/kdeglobals',
|
||||||
|
|
@ -99,11 +185,28 @@ const DESKTOP_SEEDS = [
|
||||||
'user-dirs.dirs'
|
'user-dirs.dirs'
|
||||||
]
|
]
|
||||||
|
|
||||||
// The host's desktop *data* - fonts, icons, themes, colour schemes - is not
|
// The host's desktop *data* (fonts, icons, themes) is not copied but handed
|
||||||
// copied but handed over read-only (see buildBwrapArgs), so it follows the
|
// over read-only: see buildBwrapArgs.
|
||||||
// host without an import.
|
|
||||||
const DATA_SHARES = ['fonts', 'icons', 'themes', 'color-schemes']
|
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() {
|
function hostShareDir() {
|
||||||
return path.join(os.homedir(), '.local', 'share')
|
return path.join(os.homedir(), '.local', 'share')
|
||||||
}
|
}
|
||||||
|
|
@ -112,6 +215,149 @@ function dataShares() {
|
||||||
return DATA_SHARES.map(name => path.join(hostShareDir(), name)).filter(dir => fs.existsSync(dir))
|
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) {
|
function hashOf(file) {
|
||||||
try {
|
try {
|
||||||
return crypto.createHash('sha1').update(fs.readFileSync(file)).digest('hex')
|
return crypto.createHash('sha1').update(fs.readFileSync(file)).digest('hex')
|
||||||
|
|
@ -120,22 +366,12 @@ function hashOf(file) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mtimeOf(file) {
|
// Hashes of what we seeded, kept in the prefix root: the app only ever sees
|
||||||
try {
|
// home/config/data/cache/state.
|
||||||
return fs.statSync(file).mtimeMs
|
|
||||||
} catch {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hashes of what we put in the prefix, so a host change can be followed while
|
|
||||||
// the app has not touched our copy. Kept in the prefix root: the app only
|
|
||||||
// ever sees home/config/data/cache/state.
|
|
||||||
const SEED_MARKER = '.seeded.json'
|
const SEED_MARKER = '.seeded.json'
|
||||||
|
|
||||||
// Copies a preference in, then keeps following it for as long as we can prove
|
// Keeps a seeded preference following the host while the copy is provably ours;
|
||||||
// the copy is still ours: an unusable theme is worse than a stale one, and an
|
// an app's own edit is never overwritten.
|
||||||
// app's own edit is never overwritten.
|
|
||||||
function syncDesktopConfig(root) {
|
function syncDesktopConfig(root) {
|
||||||
const hostConfig = path.join(os.homedir(), '.config')
|
const hostConfig = path.join(os.homedir(), '.config')
|
||||||
const markerFile = path.join(root, SEED_MARKER)
|
const markerFile = path.join(root, SEED_MARKER)
|
||||||
|
|
@ -146,20 +382,11 @@ function syncDesktopConfig(root) {
|
||||||
const dst = path.join(root, 'config', rel)
|
const dst = path.join(root, 'config', rel)
|
||||||
const hostHash = hashOf(src)
|
const hostHash = hashOf(src)
|
||||||
if (!hostHash) continue
|
if (!hostHash) continue
|
||||||
const known = marker[rel]
|
|
||||||
if (fs.existsSync(dst)) {
|
if (fs.existsSync(dst)) {
|
||||||
|
// only a marked copy is provably ours, and only while it is untouched
|
||||||
const current = hashOf(dst)
|
const current = hashOf(dst)
|
||||||
if (known) {
|
if (current !== marker[rel] || current === hostHash) continue
|
||||||
// the app owns it now, or there is nothing to do
|
|
||||||
if (current !== known || current === hostHash) continue
|
|
||||||
} else if (current !== hostHash && mtimeOf(dst) >= mtimeOf(src)) {
|
|
||||||
// a prefix from an older launcher: an identical copy is ours, and so is
|
|
||||||
// one the host's file has since outgrown. A copy that is not older than
|
|
||||||
// the host's is the app's own - ties stay with the app
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// refresh: this is provably still our own copy
|
|
||||||
if (copyFromHost(src, dst, { refresh: true })) {
|
if (copyFromHost(src, dst, { refresh: true })) {
|
||||||
marker[rel] = hostHash
|
marker[rel] = hostHash
|
||||||
changed = true
|
changed = true
|
||||||
|
|
@ -172,9 +399,8 @@ function syncDesktopConfig(root) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// X11 access: XAUTHORITY usually lives in /run/user (bound), but a launcher
|
// X11 access: a cookie is session state, not data, so XAUTHORITY is refreshed
|
||||||
// started without it would leave X11 dead inside. Refreshed: a cookie is
|
// rather than seeded once.
|
||||||
// session state, not data.
|
|
||||||
function seedXauthority(root) {
|
function seedXauthority(root) {
|
||||||
copyFromHost(path.join(os.homedir(), '.Xauthority'), path.join(root, 'home', '.Xauthority'), { refresh: true })
|
copyFromHost(path.join(os.homedir(), '.Xauthority'), path.join(root, 'home', '.Xauthority'), { refresh: true })
|
||||||
}
|
}
|
||||||
|
|
@ -190,10 +416,8 @@ function seededRelPaths() {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncDesktopConfig only follows the host while the copy is provably ours, so
|
// Drops what we seeded (and the hashes tracking it) so the next start seeds
|
||||||
// an app that edited its own theme keeps it forever. The reset drops our
|
// clean ones; the app's own files in the same folders are never touched.
|
||||||
// copies (and the hashes that track them) and lets the next start seed clean
|
|
||||||
// ones - the app's own files in the same folders are never touched.
|
|
||||||
function resetSeededConfig(name) {
|
function resetSeededConfig(name) {
|
||||||
const root = prefixLinuxRoot(name)
|
const root = prefixLinuxRoot(name)
|
||||||
const removed = []
|
const removed = []
|
||||||
|
|
@ -225,9 +449,8 @@ function ensureLinuxPfx(name) {
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wayland cannot carry a window icon and the compositor only guesses one from
|
// XWayland is the only place a window can carry its own icon, and GLFW/Qt/GTK
|
||||||
// the app id; XWayland can, and GLFW/Qt/GTK all read these, so one flag moves
|
// all read these, so one switch moves the whole process tree back to X11.
|
||||||
// the whole process tree (Prism and the Minecraft it launches) back to X11.
|
|
||||||
const X11_ENV = { XDG_SESSION_TYPE: 'x11', QT_QPA_PLATFORM: 'xcb', GDK_BACKEND: '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
|
// The theme data sits in front of the inherited list: on the host the user's
|
||||||
|
|
@ -240,10 +463,11 @@ function withHostShare(env, entries) {
|
||||||
|
|
||||||
// HOME and the XDG folders point at the prefix (plain run) or at the bind
|
// 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.
|
// targets inside the sandbox - the two must agree or the app writes outside.
|
||||||
function buildLinuxEnv({ game, backend = 'env' }) {
|
function buildLinuxEnv({ game, backend = 'env', fsRoot = false }) {
|
||||||
const prefixName = prefixNameForGame(game)
|
const prefixName = prefixNameForGame(game)
|
||||||
const root = ensureLinuxPfx(prefixName)
|
const root = ensureLinuxPfx(prefixName)
|
||||||
syncDesktopConfig(root)
|
syncDesktopConfig(root)
|
||||||
|
const homeRedirects = syncHomeRedirects(root, { relaxed: backend === 'bwrap' && fsRoot, appDirs: appShapes(game) })
|
||||||
const sandboxed = backend === 'bwrap'
|
const sandboxed = backend === 'bwrap'
|
||||||
const home = sandboxed ? `/home/${linuxUser()}` : path.join(root, 'home')
|
const home = sandboxed ? `/home/${linuxUser()}` : path.join(root, 'home')
|
||||||
const env = { ...process.env }
|
const env = { ...process.env }
|
||||||
|
|
@ -251,10 +475,8 @@ function buildLinuxEnv({ game, backend = 'env' }) {
|
||||||
for (const [key, [hostDir, sandboxSub]] of Object.entries(XDG_LAYOUT)) {
|
for (const [key, [hostDir, sandboxSub]] of Object.entries(XDG_LAYOUT)) {
|
||||||
env[key] = sandboxed ? path.join(home, sandboxSub) : path.join(root, hostDir)
|
env[key] = sandboxed ? path.join(home, sandboxSub) : path.join(root, hostDir)
|
||||||
}
|
}
|
||||||
// XDG_RUNTIME_DIR stays the host's: Wayland, PipeWire and the session bus
|
// The X session manager sits in the private /tmp, so its pointer would lead
|
||||||
// live there. It weakens the isolation, and the UI says so.
|
// to a dead socket. XDG_RUNTIME_DIR stays the host's (it weakens isolation).
|
||||||
// The X session manager sits in the private /tmp, so its pointer would only
|
|
||||||
// lead to a dead socket; clients skip it when it is absent.
|
|
||||||
if (sandboxed) delete env.SESSION_MANAGER
|
if (sandboxed) delete env.SESSION_MANAGER
|
||||||
// the host's fonts/icons/themes: mounted read-only by buildBwrapArgs, or - in
|
// 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
|
// a plain run, where nothing is hidden - just put on the search path
|
||||||
|
|
@ -262,24 +484,56 @@ function buildLinuxEnv({ game, backend = 'env' }) {
|
||||||
if (game.forceX11) Object.assign(env, X11_ENV)
|
if (game.forceX11) Object.assign(env, X11_ENV)
|
||||||
// the app's own environment comes last: it overrides everything above
|
// the app's own environment comes last: it overrides everything above
|
||||||
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, root, home, prefixName, prefixDir: prefixPath(prefixName) }
|
// 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) }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// bwrap when it works, plain run otherwise - unless the app demands a sandbox.
|
// Where a handed document goes inside the app's own home - a live bind, so a
|
||||||
function pickBackend({ game, sandbox }) {
|
// 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 policy = (game && game.isolation) || 'auto'
|
||||||
const status = sandbox && typeof sandbox.status === 'function' ? sandbox.status() : null
|
const status = await levelStatus(sandbox)
|
||||||
const available = !!status && status.backend === 'bwrap'
|
const available = !!status && status.backend === 'bwrap'
|
||||||
if (policy === 'off') return { ok: true, backend: 'env', policy, status }
|
if (policy === 'none') return { ok: true, level: 'none', backend: 'env', status }
|
||||||
if (available) return { ok: true, backend: 'bwrap', policy, status }
|
if (policy === 'auto') {
|
||||||
if (policy === 'require') return { ok: false, error: 'sandbox-required', policy, status }
|
return { ok: true, level: available ? 'relaxed' : 'none', backend: available ? 'bwrap' : 'env', status }
|
||||||
return { ok: true, backend: 'env', policy, status }
|
}
|
||||||
|
if (!available) return { ok: false, error: 'sandbox-required', level: policy, backend: 'env', status }
|
||||||
|
return { ok: true, level: policy, backend: 'bwrap', status }
|
||||||
}
|
}
|
||||||
|
|
||||||
function isolationLine({ backend, status }) {
|
// The line the app log and the UI both quote: a silent downgrade is the one
|
||||||
if (backend === 'bwrap') return `[plwl] isolation: bwrap ${(status && status.version) || ''}`.trim()
|
// failure this whole setting exists to avoid.
|
||||||
const reason = (status && status.reason) || 'missing'
|
function isolationLine({ level, status }) {
|
||||||
return `[plwl] isolation: env-only (reason: ${reason})`
|
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 /).
|
// "Open with": a native app takes the file where it is (the sandbox sees /).
|
||||||
|
|
@ -291,6 +545,35 @@ function linuxPathFor(file) {
|
||||||
return abs
|
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() {
|
function bwrapSource() {
|
||||||
const devices = ['/dev/shm', '/dev/dri', '/dev/snd', '/dev/input'].filter(d => fs.existsSync(d))
|
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 x11 = fs.existsSync('/tmp/.X11-unix') ? '/tmp/.X11-unix' : ''
|
||||||
|
|
@ -304,17 +587,19 @@ function bwrapSource() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false }) {
|
async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false, onProgress }) {
|
||||||
const chosen = pickBackend({ game, sandbox })
|
const chosen = await resolveLevel({ game, sandbox })
|
||||||
if (!chosen.ok) throw new Error(chosen.error)
|
if (!chosen.ok) throw new Error(chosen.error)
|
||||||
|
|
||||||
const exe = game.exePath
|
const exe = game.exePath
|
||||||
const workdir = game.workdir || path.dirname(exe)
|
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 argList = parseArgs(game.args)
|
||||||
if (openFile) argList.push(linuxPathFor(openFile))
|
const handed = await handedPathFor({ openFile, level: chosen.level, root, home, onProgress })
|
||||||
|
if (handed) argList.push(handed)
|
||||||
|
|
||||||
const { env, root } = buildLinuxEnv({ game, backend: chosen.backend })
|
|
||||||
const header = [isolationLine(chosen)]
|
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)')
|
if (game.forceX11) header.push('[plwl] x11 forced (XWayland)')
|
||||||
|
|
||||||
let cmd = exe
|
let cmd = exe
|
||||||
|
|
@ -322,7 +607,7 @@ async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false }
|
||||||
if (chosen.backend === 'bwrap') {
|
if (chosen.backend === 'bwrap') {
|
||||||
const source = bwrapSource()
|
const source = bwrapSource()
|
||||||
cmd = 'bwrap'
|
cmd = 'bwrap'
|
||||||
args = [...buildBwrapArgs({ dataRoot: root, user: source.user, devices: source.devices, x11: source.x11, runUser: source.runUser, shares: source.shares, appDir: writableAppDir(game) }), '--', exe, ...argList]
|
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}`)
|
header.push(`[plwl] HOME=${env.HOME}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,20 +615,27 @@ async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
DATA_DIRS,
|
|
||||||
XDG_LAYOUT,
|
|
||||||
SYSTEM_DIRS,
|
|
||||||
DESKTOP_SEEDS,
|
DESKTOP_SEEDS,
|
||||||
DATA_SHARES,
|
hostHomeEntries,
|
||||||
X11_ENV,
|
syncHomeRedirects,
|
||||||
|
HANDOFF_DIR,
|
||||||
|
STAGE_NOTICE_BYTES,
|
||||||
|
SHIM_REL,
|
||||||
|
OPENER_REL,
|
||||||
|
MIME_REL,
|
||||||
|
DESKTOP_REL,
|
||||||
|
writeUrlShim,
|
||||||
|
writeUrlOpener,
|
||||||
|
writeMimeSeed,
|
||||||
|
urlRouting,
|
||||||
writableAppDir,
|
writableAppDir,
|
||||||
ensureLinuxPfx,
|
ensureLinuxPfx,
|
||||||
resetSeededConfig,
|
resetSeededConfig,
|
||||||
dataShares,
|
dataShares,
|
||||||
syncDesktopConfig,
|
|
||||||
buildLinuxEnv,
|
buildLinuxEnv,
|
||||||
pickBackend,
|
resolveLevel,
|
||||||
isolationLine,
|
isolationLine,
|
||||||
linuxPathFor,
|
handedPathFor,
|
||||||
runLinuxGame
|
runLinuxGame,
|
||||||
|
appShapes
|
||||||
}
|
}
|
||||||
|
|
|
||||||
33
src/main.js
33
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')
|
||||||
|
|
@ -154,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
|
||||||
|
|
@ -226,7 +236,10 @@ 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 may keep it from coming up
|
// the tray comes first: nothing may keep it from coming up
|
||||||
|
|
@ -239,10 +252,24 @@ app.whenReady().then(async () => {
|
||||||
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))
|
||||||
|
.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}`)
|
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
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 }
|
||||||
|
|
@ -24,25 +24,8 @@ function clearPending() {
|
||||||
pending = null
|
pending = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to English keys on older installs.
|
|
||||||
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) {
|
||||||
|
|
|
||||||
|
|
@ -155,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 }
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,6 @@ function prefixLinuxRoot(name) {
|
||||||
return path.join(prefixPath(name), LINUX_PFX)
|
return path.join(prefixPath(name), LINUX_PFX)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Native Linux app data is machine-local (sockets, caches, machine-id, the
|
|
||||||
// sandbox's home): a sync neither sends it nor deletes it as an extra.
|
|
||||||
function isLocalOnlyRel(rel) {
|
|
||||||
return String(rel || '').split('/').includes(LINUX_PFX)
|
|
||||||
}
|
|
||||||
|
|
||||||
function prefixExists(name) {
|
function prefixExists(name) {
|
||||||
try {
|
try {
|
||||||
fs.accessSync(prefixPath(name))
|
fs.accessSync(prefixPath(name))
|
||||||
|
|
@ -137,15 +131,17 @@ function createPrefix(name) {
|
||||||
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,4 +184,4 @@ function setDefaultPrefix(name) {
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { isPrefixRel, PREFIX_REL_RE, LINUX_PFX, isLocalOnlyRel, listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixLinuxRoot, 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 }
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,15 @@ function singleSlot(channel) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const onImportProgress = singleSlot('import:progress')
|
const onImportProgress = singleSlot('import:progress')
|
||||||
|
// "Open with" into an isolated app: the document is copied first
|
||||||
|
const onHandoffProgress = singleSlot('handoff:progress')
|
||||||
// "Open with PLWL": files handed over via --open (Exec %f).
|
// "Open with PLWL": files handed over via --open (Exec %f).
|
||||||
const onOpenWith = singleSlot('openwith:file')
|
const onOpenWith = singleSlot('openwith:file')
|
||||||
// one sync (and one pending consent request, enforced server-side) at a time
|
// one sync (and one pending consent request, enforced server-side) at a time
|
||||||
const onLanSyncProgress = singleSlot('lansync:progress')
|
const onLanSyncProgress = singleSlot('lansync:progress')
|
||||||
const onLanSyncIncoming = singleSlot('lansync:incoming')
|
const onLanSyncIncoming = singleSlot('lansync:incoming')
|
||||||
|
// the receiver's deletion review: an answer here decides what may be removed
|
||||||
|
const onLanSyncReview = singleSlot('lansync:review')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
|
|
@ -84,6 +88,7 @@ contextBridge.exposeInMainWorld('plwl', {
|
||||||
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: onImportProgress,
|
onImportProgress: onImportProgress,
|
||||||
|
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'),
|
||||||
|
|
@ -93,6 +98,10 @@ contextBridge.exposeInMainWorld('plwl', {
|
||||||
lansyncClearHashCache: () => ipcRenderer.invoke('lansync:clearHashCache'),
|
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: onLanSyncProgress,
|
onLanSyncProgress: onLanSyncProgress,
|
||||||
onLanSyncIncoming: onLanSyncIncoming,
|
onLanSyncIncoming: onLanSyncIncoming,
|
||||||
|
|
|
||||||
|
|
@ -88,28 +88,16 @@ async function healPrefixOnce(game) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proton's STEAM_COMPAT_DATA_PATH must be a symlink to the launcher prefix.
|
// Proton's STEAM_COMPAT_DATA_PATH must be a symlink to the launcher prefix;
|
||||||
// A leftover EMPTY compat dir (an older PLWL bug) is replaced by the link; a
|
// a real compat dir already sitting there is not ours to replace.
|
||||||
// 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')
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,16 @@
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
const { execFile } = require('child_process')
|
const { execFile } = require('child_process')
|
||||||
const fs = require('fs')
|
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
const { firstExisting } = require('./paths')
|
||||||
|
|
||||||
// bubblewrap is only usable when unprivileged user namespaces actually work:
|
// bwrap only works when unprivileged namespaces really work (Ubuntu's AppArmor
|
||||||
// Ubuntu 24.04 ships bwrap but AppArmor kills it ("Failed to unshare user
|
// kills it), so a functional probe decides - never `which bwrap`.
|
||||||
// namespace"), so a functional probe decides - not `which bwrap`.
|
|
||||||
const PROBE_TIMEOUT_MS = 3000
|
const PROBE_TIMEOUT_MS = 3000
|
||||||
// /bin/true is a symlink on most distros but missing on a few; env is the
|
// /bin/true is a symlink on most distros but missing on a few; env is the
|
||||||
// last resort that still exits 0.
|
// last resort that still exits 0.
|
||||||
const TRUE_BINARIES = ['/bin/true', '/usr/bin/true', '/usr/bin/env']
|
const TRUE_BINARIES = ['/bin/true', '/usr/bin/true', '/usr/bin/env']
|
||||||
|
|
||||||
function firstExisting(candidates) {
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
try {
|
|
||||||
fs.accessSync(candidate)
|
|
||||||
return candidate
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstLine(text) {
|
function firstLine(text) {
|
||||||
return String(text || '').trim().split('\n')[0].trim()
|
return String(text || '').trim().split('\n')[0].trim()
|
||||||
}
|
}
|
||||||
|
|
@ -93,15 +82,18 @@ function createSandboxProbe({ timeoutMs = PROBE_TIMEOUT_MS } = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Host theme data (fonts, icons, colour schemes) is mounted read-only under
|
// Host theme data is mounted read-only here and listed in XDG_DATA_DIRS (inside
|
||||||
// this path - inside the private /tmp the app cannot mistake it for its own
|
// the private /tmp, where the app cannot mistake it for its own data). A bind,
|
||||||
// data, and the caller lists it in XDG_DATA_DIRS. A bind, not a symlink: the
|
// not a symlink: the prefix is mounted over ~, so a link would point at itself.
|
||||||
// prefix is mounted over ~, so a link back into ~ would point at itself.
|
|
||||||
const SHARE_ROOT = '/tmp/.plwl-host'
|
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
|
// The bwrap profile for a native Linux app. Pure: the caller decides which
|
||||||
// devices and host folders exist on this machine, so the argv is testable
|
// devices and host folders exist, so the argv is testable as-is.
|
||||||
// as-is.
|
|
||||||
function buildBwrapArgs({
|
function buildBwrapArgs({
|
||||||
dataRoot,
|
dataRoot,
|
||||||
user,
|
user,
|
||||||
|
|
@ -114,7 +106,9 @@ function buildBwrapArgs({
|
||||||
hideHome = false,
|
hideHome = false,
|
||||||
etcRoot = '',
|
etcRoot = '',
|
||||||
unshareNet = false,
|
unshareNet = false,
|
||||||
appDir = ''
|
appDir = '',
|
||||||
|
fsRoot = false,
|
||||||
|
urlOpener = null
|
||||||
}) {
|
}) {
|
||||||
const xdgDir = sub => path.join(home, sub)
|
const xdgDir = sub => path.join(home, sub)
|
||||||
return [
|
return [
|
||||||
|
|
@ -132,13 +126,16 @@ function buildBwrapArgs({
|
||||||
'--tmpfs', '/tmp',
|
'--tmpfs', '/tmp',
|
||||||
// X11 sockets live in /tmp, which the tmpfs above just emptied
|
// X11 sockets live in /tmp, which the tmpfs above just emptied
|
||||||
...(x11 ? ['--ro-bind', x11, x11] : []),
|
...(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
|
// the host's theme data, read-only: fonts, icons, colour schemes
|
||||||
...shares.flatMap(dir => ['--ro-bind', dir, path.join(SHARE_ROOT, path.basename(dir))]),
|
...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
|
// hard profile: nothing of anybody's /home is even readable
|
||||||
...(hideHome ? ['--tmpfs', '/home', '--tmpfs', '/root', '--tmpfs', '/mnt', '--tmpfs', '/media'] : []),
|
...(hideHome ? ['--tmpfs', '/home', '--tmpfs', '/root', '--tmpfs', '/mnt', '--tmpfs', '/media'] : []),
|
||||||
// the app's own folder stays writable (portable apps keep logs/data next
|
|
||||||
// to themselves); the rest of the read-only root does not
|
|
||||||
...(appDir ? ['--bind', appDir, appDir] : []),
|
|
||||||
// --- app data (host paths bound to a fresh HOME) ---
|
// --- app data (host paths bound to a fresh HOME) ---
|
||||||
'--bind', path.join(dataRoot, 'home'), home,
|
'--bind', path.join(dataRoot, 'home'), home,
|
||||||
'--bind', path.join(dataRoot, 'config'), xdgDir('.config'),
|
'--bind', path.join(dataRoot, 'config'), xdgDir('.config'),
|
||||||
|
|
@ -148,6 +145,15 @@ function buildBwrapArgs({
|
||||||
// Wayland, PipeWire, PulseAudio and the session bus all live here
|
// Wayland, PipeWire, PulseAudio and the session bus all live here
|
||||||
...(runUser ? ['--bind', runUser, runUser] : []),
|
...(runUser ? ['--bind', runUser, runUser] : []),
|
||||||
...(etcRoot ? ['--bind', etcRoot, '/etc'] : []),
|
...(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 ---
|
// --- environment ---
|
||||||
'--setenv', 'HOME', home,
|
'--setenv', 'HOME', home,
|
||||||
'--setenv', 'XDG_CONFIG_HOME', xdgDir('.config'),
|
'--setenv', 'XDG_CONFIG_HOME', xdgDir('.config'),
|
||||||
|
|
@ -157,4 +163,4 @@ function buildBwrapArgs({
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createSandboxProbe, buildBwrapArgs, SHARE_ROOT, PROBE_TIMEOUT_MS }
|
module.exports = { createSandboxProbe, buildBwrapArgs, SHARE_ROOT, FS_ROOT }
|
||||||
|
|
|
||||||
94
src/wines.js
94
src/wines.js
|
|
@ -58,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 []
|
||||||
|
|
@ -118,6 +109,13 @@ async function refreshWines() {
|
||||||
// display name and disabled flag live in the registry and survive rescans
|
// display name and disabled flag live in the registry and survive rescans
|
||||||
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)
|
||||||
|
|
@ -161,23 +159,53 @@ 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)
|
||||||
|
|
@ -219,6 +247,10 @@ 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 }
|
||||||
|
|
@ -300,7 +332,12 @@ async function importWineFolder(srcDir, onProgress) {
|
||||||
}
|
}
|
||||||
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)
|
||||||
|
|
@ -310,14 +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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pseudo entry for the distro's wine; version probe is best-effort.
|
// 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() {
|
function systemWine() {
|
||||||
let version = ''
|
if (systemVersion === null) {
|
||||||
try {
|
try {
|
||||||
const { execFileSync } = require('child_process')
|
const { execFileSync } = require('child_process')
|
||||||
version = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
|
systemVersion = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
|
||||||
} catch {}
|
} catch {
|
||||||
return { id: 'system', type: 'system', label: 'System Wine', version }
|
systemVersion = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: 'system', type: 'system', label: 'System Wine', version: systemVersion }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { refreshWines, getWines, getWine, resolveWine, systemWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }
|
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