188 lines
7.2 KiB
TypeScript
188 lines
7.2 KiB
TypeScript
import { lstat } from "node:fs/promises";
|
|
import { realpathSync } from "node:fs";
|
|
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
|
|
/**
|
|
* How one MSIX-packaged client reads its AppData configuration. A package
|
|
* with write virtualization saves AppData files to its private
|
|
* `LocalCache\Roaming` copy, then reads that copy whenever it exists and the
|
|
* ordinary AppData file otherwise.
|
|
*/
|
|
export interface MsixPackageView {
|
|
readonly packageFamilyName: string;
|
|
/** The package-private copy, which shadows the AppData file once it exists. */
|
|
readonly privateConfigPath: string;
|
|
/** The AppData path that unpackaged processes read and write. */
|
|
readonly appDataConfigPath: string;
|
|
/** Roots whose writes the package redirects for processes it starts. */
|
|
readonly virtualizedRoots: readonly string[];
|
|
/**
|
|
* `redirected`: this process sees one of the package's private files at its
|
|
* AppData path, so the running package started it. `direct`: it does not.
|
|
* `unknown`: no private file could be compared.
|
|
*/
|
|
readonly processView: "redirected" | "direct" | "unknown";
|
|
}
|
|
|
|
/** Windows' roaming and local application data roots; empty means unset. */
|
|
export const windowsAppDataRoots = (
|
|
home: string,
|
|
env: Readonly<NodeJS.ProcessEnv>,
|
|
): { readonly roaming: string; readonly local: string } => ({
|
|
roaming: env.APPDATA?.trim() || join(home, "AppData", "Roaming"),
|
|
local: env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local"),
|
|
});
|
|
|
|
/** The per-user root of MSIX package data containers. */
|
|
export const msixPackagesDirectory = (
|
|
home: string,
|
|
env: Readonly<NodeJS.ProcessEnv>,
|
|
): string => join(windowsAppDataRoots(home, env).local, "Packages");
|
|
|
|
/** Whether `path` is `root` or inside it, using the host's path comparison. */
|
|
const pathWithin = (path: string, root: string): boolean => {
|
|
const inner = relative(root, path);
|
|
return (
|
|
inner === "" ||
|
|
(inner !== ".." && !inner.startsWith(`..${sep}`) && !isAbsolute(inner))
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Name the package whose private `LocalCache` holds a canonical path. Its
|
|
* processes see those files at virtual AppData locations; other processes do
|
|
* not, and Windows removes them with the package.
|
|
*/
|
|
export const packagePrivateStorageFamily = (
|
|
canonicalPath: string,
|
|
packagesDirectory: string,
|
|
): string | undefined => {
|
|
if (!pathWithin(canonicalPath, packagesDirectory)) return undefined;
|
|
const [family, storage] = relative(packagesDirectory, canonicalPath).split(
|
|
/[\\/]/u,
|
|
);
|
|
return family !== undefined &&
|
|
family !== "" &&
|
|
storage?.toLowerCase() === "localcache"
|
|
? family
|
|
: undefined;
|
|
};
|
|
|
|
/**
|
|
* Explain why setup must not register a launcher that exists only in one
|
|
* package's private storage. Other clients could not start it, and doctor
|
|
* outside that package could not verify it.
|
|
*/
|
|
export const packagePrivateLauncherBlocker = (
|
|
command: readonly string[],
|
|
packagesDirectory: string,
|
|
canonicalPath: (path: string) => string = realpathSync.native,
|
|
): string | undefined => {
|
|
// Compare canonical forms: a short-name, junctioned or substituted profile
|
|
// spells the same directory differently.
|
|
let packages = packagesDirectory;
|
|
try {
|
|
packages = canonicalPath(packagesDirectory);
|
|
} catch (cause: unknown) {
|
|
// Without package storage, the spelled path is the only comparison.
|
|
void cause;
|
|
}
|
|
for (const part of command) {
|
|
if (!isAbsolute(part)) continue;
|
|
let resolved: string;
|
|
try {
|
|
resolved = canonicalPath(part);
|
|
} catch (cause: unknown) {
|
|
// A launcher that cannot be resolved is not evidence of package storage.
|
|
void cause;
|
|
continue;
|
|
}
|
|
const family = packagePrivateStorageFamily(resolved, packages);
|
|
if (family !== undefined)
|
|
return `The REA launcher ${part} is stored at ${resolved}, inside the private storage of MSIX package ${family}. Only processes started by that app can see it at ${part}, and Windows removes it with the package, so other clients could not start this registration. Install REA from a terminal that the app did not start (for example with \`npm install -g rea-agents\`), then rerun setup from that terminal.`;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
/**
|
|
* Explain why this process must not write `targets`: the running package
|
|
* started it, so writes under its virtualized roots land in that package's
|
|
* private storage, which the targets' owners never read.
|
|
*/
|
|
const canonicalDestination = (
|
|
path: string,
|
|
canonicalPath: (path: string) => string,
|
|
): Result<string, { readonly path: string; readonly detail: string }> => {
|
|
let ancestor = path;
|
|
const missing: string[] = [];
|
|
while (true) {
|
|
try {
|
|
return ok(join(canonicalPath(ancestor), ...missing));
|
|
} catch (cause: unknown) {
|
|
if (
|
|
cause instanceof Error &&
|
|
"code" in cause &&
|
|
(cause.code === "ENOENT" || cause.code === "ENOTDIR") &&
|
|
dirname(ancestor) !== ancestor
|
|
) {
|
|
missing.unshift(basename(ancestor));
|
|
ancestor = dirname(ancestor);
|
|
continue;
|
|
}
|
|
return err({
|
|
path,
|
|
detail: cause instanceof Error ? cause.message : String(cause),
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
export const redirectedWriteBlocker = (
|
|
view: MsixPackageView | undefined,
|
|
targets: readonly string[],
|
|
operation: "setup" | "uninstall",
|
|
canonicalPath: (path: string) => string = realpathSync.native,
|
|
): string | undefined => {
|
|
if (view?.processView !== "redirected" || targets.length === 0)
|
|
return undefined;
|
|
const roots: string[] = [];
|
|
const redirected: string[] = [];
|
|
for (const root of view.virtualizedRoots) {
|
|
const resolved = canonicalDestination(root, canonicalPath);
|
|
if (!resolved.ok)
|
|
return `Cannot establish the MSIX write location for ${resolved.error.path}: ${resolved.error.detail}. Rerun ${operation} from a terminal that this app did not start.`;
|
|
roots.push(resolved.value);
|
|
}
|
|
for (const target of targets) {
|
|
const resolved = canonicalDestination(target, canonicalPath);
|
|
if (!resolved.ok)
|
|
return `Cannot establish the MSIX write location for ${resolved.error.path}: ${resolved.error.detail}. Rerun ${operation} from a terminal that this app did not start.`;
|
|
if (roots.some((root) => pathWithin(resolved.value, root)))
|
|
redirected.push(target);
|
|
}
|
|
if (redirected.length === 0) return undefined;
|
|
return `This process was started by MSIX package ${view.packageFamilyName}, which redirects its writes under ${view.virtualizedRoots.join(" and ")} into the package's private storage. Changes to ${redirected.join(", ")} would not reach the files other apps read. Rerun ${operation} from a terminal that this app did not start.`;
|
|
};
|
|
|
|
/**
|
|
* Whether the package began reading its private copy after the AppData file
|
|
* was chosen, for example by saving its settings when it quit.
|
|
*/
|
|
export const packageReadTargetChanged = async (
|
|
view: MsixPackageView,
|
|
configPath: string,
|
|
): Promise<boolean> => {
|
|
if (configPath !== view.appDataConfigPath) return false;
|
|
try {
|
|
await lstat(view.privateConfigPath);
|
|
return true;
|
|
} catch (cause: unknown) {
|
|
return !(
|
|
cause instanceof Error &&
|
|
"code" in cause &&
|
|
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
|
|
);
|
|
}
|
|
};
|