spplice-api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ import * as Spplice from "..";
2
+ import path from "node:path";
3
+
4
+ // Start Steam interface - Steam must be running, user must be logged in
5
+ const steamClient = new Spplice.SteamClient();
6
+ // Get Portal 2 game directory
7
+ const gameDir = steamClient.getGameDir();
8
+
9
+ // Load default package repository/repositories
10
+ const repositories = await Promise.all([
11
+ Spplice.Repository.fromURL("https://p2r3.github.io/spplice-repo/index.json")
12
+ ]);
13
+ // Aggregate all packages into a single list
14
+ const packages = repositories.map(r => Array.from(r.packages)).flat(1);
15
+
16
+ // Pick a package to install - for this example, the first one on the list
17
+ const packageToInstall = packages[0]!;
18
+ // Extract the package files to portal2_tempcontent
19
+ packageToInstall.extract(path.join(gameDir, "portal2_tempcontent"));
20
+ // Tell Steam to start the game
21
+ Spplice.SteamClient.launchSteam([
22
+ "-applaunch", steamClient.appID.toString(),
23
+ "-tempcontent", // Enable reading from portal2_tempcontent
24
+ "-netconport 22333" // Enable console TCP socket
25
+ ]);
26
+
27
+ // Connect to the game's developer console
28
+ const gameConsole = new Spplice.Console(22333);
29
+ await gameConsole.connect();
30
+ // Print something to the console
31
+ gameConsole.send("echo Hello from Spplice API!");
32
+ // Monitor and print console output
33
+ setInterval(() => {
34
+ let line;
35
+ while (line = gameConsole.readLine()) {
36
+ console.log("[P2]", line);
37
+ }
38
+ }, 200);
package/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export {
2
+ SteamClient
3
+ } from "./src/SteamTools";
4
+ export {
5
+ SpplicePackage as Package,
6
+ SppliceRepository as Repository
7
+ } from "./src/PackageTools";
8
+ export {
9
+ Console
10
+ } from "./src/ConsoleTools";
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "spplice-api",
3
+ "version": "0.1.0",
4
+ "module": "index.ts",
5
+ "type": "module",
6
+ "private": false,
7
+ "devDependencies": {
8
+ "@types/bun": "latest"
9
+ },
10
+ "peerDependencies": {
11
+ "typescript": "^5"
12
+ },
13
+ "dependencies": {
14
+ "@types/lzma-native": "^4.0.4",
15
+ "@types/tar-stream": "^3.1.4",
16
+ "lzma-native": "^8.0.6",
17
+ "ps-list": "^9.0.0",
18
+ "steamworks-ffi-node": "^0.10.3",
19
+ "tar-stream": "^3.2.0"
20
+ },
21
+ "trustedDependencies": [
22
+ "steamworks-ffi-node"
23
+ ]
24
+ }
@@ -0,0 +1,120 @@
1
+ import net from "net";
2
+
3
+ export class Console {
4
+
5
+ // TCP socket handle
6
+ socket: net.Socket;
7
+ // Network port number
8
+ port: number;
9
+ // Last connection error, or null if none
10
+ error: Error | null = null;
11
+ // Connection state - true if connected, false otherwise
12
+ connected: boolean = false;
13
+
14
+ // Whether we expect the socket to close intentionally.
15
+ // Determines whether a repeat connection is attempted.
16
+ private closing: boolean = false;
17
+ // Stores unflushed writes, re-sent if the socket reconnects.
18
+ private writeBuffer: string = "";
19
+ // Stores output read from the socket, but not yet read by the client.
20
+ private readBuffer: string = "";
21
+
22
+ /**
23
+ * Creates and configures TCP socket for Portal 2 netcon.
24
+ * @param port Network port number for connection.
25
+ */
26
+ constructor (port: number) {
27
+ this.socket = new net.Socket();
28
+ this.socket.setNoDelay(true);
29
+ this.socket.setKeepAlive(false);
30
+ this.port = port;
31
+ // Listen for incoming data and log to read buffer
32
+ this.socket.on("data", data => {
33
+ this.readBuffer += data;
34
+ });
35
+ // Clear pending write buffer when data gets flushed
36
+ this.socket.on("drain", () => {
37
+ this.writeBuffer = "";
38
+ });
39
+ // Reconnect if the socket ever closes unexpectedly
40
+ this.socket.on("close", () => {
41
+ this.connected = false;
42
+ if (!this.closing) {
43
+ setTimeout(() => this.connect(), 200);
44
+ }
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Connects to the Portal 2 console TCP socket.
50
+ */
51
+ async connect () {
52
+ // Attempt connection to the socket
53
+ this.error = await new Promise(resolve => {
54
+ let onConnect: any, onError: any;
55
+ this.socket.once("error", onError = (err: Error) => {
56
+ this.socket.off("connect", onConnect);
57
+ resolve(err);
58
+ });
59
+ this.socket.once("connect", onConnect = () => {
60
+ this.socket.off("error", onError);
61
+ resolve(null);
62
+ });
63
+ this.socket.connect(this.port, "127.0.0.1");
64
+ });
65
+ if (!this.error) this.connected = true;
66
+ // Re-send anything that remains in the write buffer from the last session
67
+ if (this.writeBuffer) {
68
+ const success = this.socket.write(this.writeBuffer);
69
+ if (success) this.writeBuffer = "";
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Disconnects from the Portal 2 console socket.
75
+ */
76
+ disconnect () {
77
+ this.closing = true;
78
+ this.writeBuffer = "";
79
+ this.readBuffer = "";
80
+ this.socket.end();
81
+ }
82
+
83
+ /**
84
+ * Sends a console command to the game.
85
+ * @param command Command to execute.
86
+ */
87
+ send (command: string) {
88
+ const data = command + "\n";
89
+ const success = this.socket.write(data);
90
+ if (!success) this.writeBuffer += data;
91
+ }
92
+
93
+ /**
94
+ * Reads output from the game console.
95
+ * @param length Max amount of bytes to read. Default is -1 (read all).
96
+ */
97
+ read (length: number = -1): string {
98
+ let output: string;
99
+ if (length < 0) {
100
+ output = this.readBuffer;
101
+ this.readBuffer = "";
102
+ } else {
103
+ output = this.readBuffer.slice(0, length);
104
+ this.readBuffer = this.readBuffer.slice(length);
105
+ }
106
+ return output;
107
+ }
108
+
109
+ /**
110
+ * Reads and returns one full line of console output. Does not read
111
+ * incomplete lines. Removes newline and carriage return characters.
112
+ * @returns One full line of console output, or `null` if no output.
113
+ */
114
+ readLine (): string | null {
115
+ const newlineIndex = this.readBuffer.indexOf("\n");
116
+ if (newlineIndex === -1) return null;
117
+ return this.read(newlineIndex + 1).slice(0, -1).replaceAll("\r", "");
118
+ }
119
+
120
+ }
@@ -0,0 +1,170 @@
1
+ import { Readable } from "node:stream";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import * as tar from "tar-stream";
5
+ import * as lzma from "lzma-native";
6
+
7
+ class SpplicePackage {
8
+
9
+ title: string;
10
+ author: string;
11
+ description: string = "";
12
+ version: string = "1.0.0";
13
+ args: string[] = [];
14
+ file: string;
15
+ icon: string;
16
+
17
+ /**
18
+ * @param foreignPackage Untrusted "foreign" object (e.g. from an online repo),
19
+ * assumed to be shaped roughly like a Spplice package.
20
+ */
21
+ constructor (foreignPackage: any) {
22
+ // Title is required
23
+ this.title = foreignPackage.title.toString().trim();
24
+ if (!this.title) throw "Invalid package title";
25
+ // Author is required
26
+ this.author = foreignPackage.author.toString().trim();
27
+ if (!this.author) throw "Invalid package author";
28
+ // Description is optional
29
+ if (typeof foreignPackage.description === "string") {
30
+ this.description = foreignPackage.description.trim();
31
+ }
32
+ // File URL is required
33
+ if (typeof foreignPackage.file !== "string") throw "Invalid package file URL";
34
+ this.file = foreignPackage.file;
35
+ // Icon URL is required
36
+ if (typeof foreignPackage.icon !== "string") throw "Invalid package icon URL";
37
+ this.icon = foreignPackage.icon;
38
+ // Version is optional
39
+ if (typeof foreignPackage.version === "string") {
40
+ this.version = foreignPackage.version;
41
+ }
42
+ // Command line arguments are optional
43
+ if (Array.isArray(foreignPackage.args)) {
44
+ this.args = foreignPackage.args;
45
+ } else if (typeof foreignPackage.args === "string") {
46
+ this.args = [foreignPackage.args];
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Extracts a readable stream of a tar.xz archive to the given folder.
52
+ * @param inputStream Readable stream of input tar.xz file.
53
+ * @param destinationPath Path to directory in which to extract the archive.
54
+ */
55
+ static async extractFromStream (inputStream: Readable, destinationPath: string){
56
+ return await new Promise((resolve, reject) => {
57
+ try {
58
+ const lzmaDecompressor = lzma.createDecompressor();
59
+ const tarExtractor = tar.extract();
60
+ tarExtractor.on("entry", (header, stream, next) => {
61
+ // Get just entry name and type from header.
62
+ // It's probably best not to replicate permissions/users/groups.
63
+ const { name, type } = header;
64
+ const fullPath = path.join(destinationPath, name);
65
+ // Set up handler to continue when this entry has been parsed
66
+ stream.on("end", () => next());
67
+ // For files, pipe them to a write stream.
68
+ // For directories, quietly create them recursively.
69
+ if (type === "file") {
70
+ stream.pipe(fs.createWriteStream(fullPath));
71
+ } else if (type === "directory") {
72
+ fs.mkdirSync(fullPath, { recursive: true });
73
+ }
74
+ // Drain the rest of the stream if anything remains
75
+ stream.resume();
76
+ });
77
+ // Resolve promise once archive has been extracted
78
+ tarExtractor.on("finish", resolve);
79
+ // Pipe the input file - first decompress, then extract
80
+ inputStream.pipe(lzmaDecompressor).pipe(tarExtractor);
81
+ } catch (err) {
82
+ reject(err);
83
+ }
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Extracts a tar.xz archive file to the given folder.
89
+ * @param filePath Input tar.xz archive path.
90
+ * @param destinationPath Path to directory in which to extract the archive.
91
+ */
92
+ static async extractFromFile (filePath: string, destinationPath: string) {
93
+ const inputStream = fs.createReadStream(filePath);
94
+ return await SpplicePackage.extractFromStream(inputStream, destinationPath);
95
+ }
96
+
97
+ /**
98
+ * Downloads a tar.xz archive from a URL and extracts it to the given folder.
99
+ * @param fileURL Input tar.xz archive URL.
100
+ * @param destinationPath Path to directory in which to extract the archive.
101
+ */
102
+ static async extractFromURL (fileURL: string, destinationPath: string) {
103
+ const response = await fetch(fileURL);
104
+ if (response.status !== 200) throw `Server returned response ${response.status}`;
105
+ if (!response.body) throw `Server did not return any content.`;
106
+ const inputStream = Readable.fromWeb(response.body);
107
+ return await SpplicePackage.extractFromStream(inputStream, destinationPath);
108
+ }
109
+
110
+ /**
111
+ * Downloads and extracts the package's file archive to the given folder.
112
+ * @param destinationPath Path to directory in which to extract the archive.
113
+ */
114
+ async extract (destinationPath: string) {
115
+ return await SpplicePackage.extractFromURL(this.file, destinationPath);
116
+ }
117
+
118
+ }
119
+
120
+ class SppliceRepository {
121
+
122
+ packages: Set<SpplicePackage> = new Set();
123
+
124
+ addPackage (newPackage: SpplicePackage) {
125
+ this.packages.add(newPackage);
126
+ }
127
+ removePackage (removedPackage: SpplicePackage) {
128
+ this.packages.delete(removedPackage);
129
+ }
130
+
131
+ /**
132
+ * Fetches a Spplice repository from its URL.
133
+ * @param repositoryURL Link to Spplice repository index (JSON).
134
+ * @param strict Whether to throw an error when package parsing fails.
135
+ * @returns A new Spplice repository.
136
+ */
137
+ static async fromURL (repositoryURL: string, strict: boolean = false): Promise<SppliceRepository> {
138
+
139
+ // Get JSON data from input URL
140
+ const response = await fetch(repositoryURL.trim());
141
+ if (response.status !== 200) throw `Server returned response ${response.status}`;
142
+ const repositoryData = (await response.json()) as any;
143
+
144
+ // Validate repository structure
145
+ if (!Array.isArray(repositoryData.packages)) {
146
+ throw "Malformed repository index.";
147
+ }
148
+
149
+ const repository = new SppliceRepository();
150
+
151
+ // Try to convert each entry to a Spplice package, skip any that fail
152
+ for (const entry of repositoryData.packages) {
153
+ try {
154
+ const newPackage = new SpplicePackage(entry);
155
+ repository.addPackage(newPackage);
156
+ } catch (err) {
157
+ if (strict) throw err;
158
+ }
159
+ }
160
+
161
+ return repository;
162
+
163
+ }
164
+
165
+ }
166
+
167
+ export {
168
+ SpplicePackage,
169
+ SppliceRepository
170
+ };
@@ -0,0 +1,195 @@
1
+ import SteamworksSDK from "steamworks-ffi-node";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import { execSync, execFile } from "node:child_process";
6
+ import psList from "ps-list";
7
+
8
+ type UserDetails = {
9
+ steamid: BigInt,
10
+ name: string
11
+ };
12
+
13
+ class SteamClient {
14
+
15
+ steamworksClient: SteamworksSDK;
16
+ appID: number;
17
+
18
+ /**
19
+ * Initialize Steamworks API.
20
+ * @param appID Game's Steam AppID (default 620 = Portal 2)
21
+ * Note: This makes Steam think that the specified game (Portal 2) is running.
22
+ */
23
+ constructor (appID: number = 620) {
24
+ this.appID = appID;
25
+ // HACK: Temporarily use AppID of Spacewar - Steam otherwise won't let us launch Portal 2
26
+ // Fixed by implementing manual startup of Portal 2
27
+ appID = 480;
28
+ // The Steamworks SDK requires steam_appid.txt in the working directory when
29
+ // the app is not launched through Steam, regardless of the envvar.
30
+ fs.writeFileSync(path.join(process.cwd(), "steam_appid.txt"), appID.toString());
31
+ this.steamworksClient = SteamworksSDK.getInstance();
32
+ const success = this.steamworksClient.init({ appId: appID });
33
+ if (!success) throw "Failed to initialize Steam API";
34
+ }
35
+
36
+ /**
37
+ * @returns {string} Absolute path to game directory.
38
+ */
39
+ getGameDir (): string {
40
+ const pathString = this.steamworksClient.apps.getAppInstallDir(this.appID) as string;
41
+ return path.resolve(pathString);
42
+ }
43
+
44
+ /**
45
+ * @returns {UserDetails} Current user's SteamID64 and username.
46
+ */
47
+ getUserDetails (): UserDetails {
48
+ const steamid = BigInt(this.steamworksClient.getStatus().steamId);
49
+ const name = this.steamworksClient.friends.getPersonaName();
50
+ return { steamid, name };
51
+ }
52
+
53
+ /**
54
+ * Checks whether the currently logged-in user has access to the game on Steam.
55
+ * @returns {boolean} true if current user has the game, false otherwise.
56
+ */
57
+ doesUserOwnGame (): boolean {
58
+ return this.steamworksClient.apps.isSubscribed();
59
+ }
60
+
61
+ /**
62
+ * Searches for Steam binaries and returns all that were found. Output is
63
+ * sorted by how relevant the results are. The most useful result is first.
64
+ *
65
+ * @returns {string[]} Array of absolute paths to found Steam executables,
66
+ * sorted by descending relevance.
67
+ */
68
+ static async findSteam (): Promise<string[]> {
69
+
70
+ const candidates: string[] = [];
71
+
72
+ /**
73
+ * First pass:
74
+ *
75
+ * Start by looking at the list of running system processes.
76
+ * The most relevant binary is likely one that is currently running.
77
+ */
78
+ const processList = await psList();
79
+ // Sort the process list, putting the most recent processes first.
80
+ // The more relevant process is the one that was last opened.
81
+ processList.sort((a, b) => Number(b.startTime) - Number(a.startTime));
82
+ // Filter for processes with name "steam".
83
+ const steamProcesses = processList.filter(p =>
84
+ p.name === "steam" ||
85
+ p.name === "steam.exe"
86
+ );
87
+
88
+ // TODO: Read registry on Windows
89
+
90
+ /**
91
+ * Second pass:
92
+ *
93
+ * Look for processes listening on port 27036. Steam uses this port to do
94
+ * game streaming, making it a fairly reliable way to fingerprint Steam.
95
+ */
96
+ try {
97
+ let steamPortPIDs: number[] = [];
98
+ // There's no neat package for this, so we use OS-dependent shells.
99
+ if (process.platform === "win32") {
100
+ const stdout = execSync("netstat -ano | findstr :27036");
101
+ const matches = stdout.toString().match(/\s+(\d+)$/gm);
102
+ if (matches) steamPortPIDs = [...new Set(matches.map(m => Number(m.trim())))];
103
+ } else {
104
+ const stdout = execSync("lsof -i :27036 -t");
105
+ const result = stdout.toString();
106
+ steamPortPIDs = result.split("\n").map(Number);
107
+ }
108
+ // Filter for processes with the PIDs we found.
109
+ const steamPortProcesses = processList.filter(p => steamPortPIDs.includes(p.pid));
110
+ steamProcesses.push(...steamPortProcesses);
111
+ } catch (_) { }
112
+
113
+ // Push process paths to candidate list.
114
+ for (const process of steamProcesses) {
115
+ if (process.path) candidates.push(process.path);
116
+ else if (process.cmd) candidates.push(process.cmd.split(" ")[0]!);
117
+ }
118
+
119
+ /**
120
+ * Third pass:
121
+ *
122
+ * Blindly push common Steam installation paths as a last resort.
123
+ * These will get validated in the final filtering step.
124
+ */
125
+ candidates.push(
126
+ `C:\\Program Files (x86)\\Steam\\steam.exe`,
127
+ `${os.homedir()}/.steam/steam/ubuntu12_32/steam`,
128
+ `${os.homedir()}/.local/share/Steam/ubuntu12_32/steam`,
129
+ `${os.homedir()}/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam`
130
+ );
131
+
132
+ // Filter and normalize candidate list.
133
+ for (let i = candidates.length - 1; i >= 0; i--) {
134
+ // Remove any empty entries
135
+ if (!candidates[i]?.trim()) {
136
+ candidates.splice(i, 1);
137
+ continue;
138
+ }
139
+ // Resolve candidate path
140
+ candidates[i] = path.resolve(candidates[i]!);
141
+ const candidate = candidates[i]!;
142
+ // Check that the path actually points to something
143
+ if (!fs.existsSync(candidate)) {
144
+ candidates.splice(i, 1);
145
+ continue;
146
+ }
147
+ // Check that the destination is a file
148
+ const stat = fs.lstatSync(candidate);
149
+ if (!stat.isFile()) {
150
+ candidates.splice(i, 1);
151
+ continue;
152
+ }
153
+ }
154
+
155
+ // Return list of candidates without duplicates
156
+ return [...new Set(candidates)];
157
+
158
+ }
159
+
160
+ /**
161
+ * Runs the Steam executable with the given command-line arguments.
162
+ * Can be used to start Steam, or to start a Steam game by using "-applaunch".
163
+ *
164
+ * TODO: Replace with manual game launch, hooking virtual file system.
165
+ *
166
+ * @param args Command-line arguments for Steam.
167
+ */
168
+ static async launchSteam (args: string[] = []) {
169
+
170
+ // Get list of canidate Steam executables
171
+ // TODO: Use Steamworks for this somehow?
172
+ const steamExes = await SteamClient.findSteam();
173
+
174
+ // Try each executable until one works, i.e. doesn't fail before `timeout`
175
+ const timeout = 5000;
176
+ for (const steam of steamExes) {
177
+ const error = await Promise.all([
178
+ new Promise(resolve => {
179
+ execFile(steam, args, (error) => {
180
+ resolve(error);
181
+ });
182
+ }),
183
+ new Promise(resolve => setTimeout(resolve, timeout, false))
184
+ ]);
185
+ if (!error) break;
186
+ }
187
+
188
+ }
189
+
190
+ }
191
+
192
+ export {
193
+ type UserDetails,
194
+ SteamClient
195
+ };
@@ -0,0 +1,11 @@
1
+ import { expect, test } from "bun:test";
2
+ import fs from "node:fs/promises";
3
+ import * as Spplice from "../index";
4
+
5
+ test("repository package extraction", async () => {
6
+ const repo = await Spplice.Repository.fromURL("https://p2r3.github.io/spplice-repo/index.json");
7
+ const pkg = Array.from(repo.packages)[0]!;
8
+ await pkg.extract(".tmp");
9
+ expect(await Bun.file(".tmp/scripts/vscripts/mapspawn.nut").exists()).toBeTrue();
10
+ await fs.rm(".tmp", { recursive: true, force: true });
11
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ }
29
+ }