Skip to content

Commit cdf2030

Browse files
committed
feat(studio-bridge): add Linux/Wine support for headless Studio
Adds the ability to run Roblox Studio under Wine on Linux, enabling headless CI testing and devcontainer workflows. New modules: - linux-config: environment-aware configuration resolution - linux-wine-env: Wine process environment assembly - linux-credential-writer: compile write-cred.c with MinGW, inject credentials into Wine's Credential Manager via Roblox API - linux-display-manager: Xvfb/openbox lifecycle management - linux-fflags: write ClientAppSettings.json with D3D11 renderer flags - linux-shader-patcher: patch #version 150 → 420 in shader pack - linux-studio-installer: download and extract Studio from CDN - linux-version-resolver: resolve latest version via clientsettingscdn - linux-prerequisites: dependency checker and installer New CLI commands (studio-bridge linux <cmd>): - setup: full environment provisioning (--install-deps for CI) - auth: credential injection from ROBLOSECURITY cookie - status: prerequisite health check Also moves shared auth code from nevermore-cli to nevermore-cli-helpers so studio-bridge can reuse cookie parsing and credential retrieval. Includes unit tests, GitHub Actions E2E workflow, and README.
1 parent 78aa686 commit cdf2030

36 files changed

Lines changed: 2378 additions & 195 deletions
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: studio-linux-e2e
2+
3+
on:
4+
workflow_dispatch:
5+
pull_request:
6+
paths:
7+
- 'tools/studio-bridge/src/linux/**'
8+
- 'tools/studio-bridge/src/process/**'
9+
- 'tools/studio-bridge/src/commands/linux/**'
10+
- 'tools/nevermore-cli-helpers/src/auth/**'
11+
- '.github/workflows/studio-linux-e2e.yml'
12+
13+
jobs:
14+
studio-linux-e2e:
15+
runs-on: ubuntu-latest
16+
timeout-minutes: 30
17+
steps:
18+
- name: Checkout repository
19+
uses: actions/checkout@v6
20+
21+
- name: Setup node
22+
uses: actions/setup-node@v6
23+
with:
24+
node-version: '21'
25+
26+
- name: Setup pnpm
27+
uses: pnpm/action-setup@v4
28+
with:
29+
cache: true
30+
31+
- name: Setup registries
32+
run: |
33+
echo -e "\n//npm.pkg.github.com/:_authToken=$GITHUB_TOKEN" >> .npmrc
34+
echo -e "\n//npm.pkg.github.com/:_authToken=$GITHUB_TOKEN" >> ~/.npmrc
35+
echo -e "\n//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> .npmrc
36+
echo -e "\n//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc
37+
env:
38+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
40+
41+
- name: Install dependencies
42+
run: pnpm install --frozen-lockfile
43+
44+
- name: Setup Aftman
45+
uses: ok-nick/setup-aftman@v0.4.2
46+
with:
47+
version: 'v0.3.0'
48+
token: ${{ secrets.GITHUB_TOKEN }}
49+
cache: true
50+
51+
- name: Build all tools
52+
run: pnpm -r --filter './tools/**' run build
53+
54+
- name: Setup Linux environment
55+
run: node tools/studio-bridge/dist/src/cli/cli.js linux setup --install-deps
56+
57+
- name: Verify environment health (pre-auth)
58+
run: node tools/studio-bridge/dist/src/cli/cli.js linux status
59+
60+
- name: Inject authentication
61+
if: ${{ env.ROBLOSECURITY != '' }}
62+
run: node tools/studio-bridge/dist/src/cli/cli.js linux auth
63+
env:
64+
ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }}
65+
66+
- name: Execute script through Studio bridge
67+
if: ${{ env.ROBLOSECURITY != '' }}
68+
run: node tools/studio-bridge/dist/src/cli/cli.js process run 'print("E2E test passed!")'
69+
timeout-minutes: 5
70+
env:
71+
ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }}

docs/testing/testing.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,23 @@ When tests fail in CI, the `post-test-results` command parses Jest-lua output an
204204

205205
The resolver code lives in `tools/nevermore-cli/src/utils/sourcemap/` and is shared with the `strip-sourcemap-jest` command.
206206

207+
## Linux headless testing
208+
209+
Studio can run headlessly on Linux via Wine, enabling E2E tests in devcontainers and GitHub Actions without a display or GPU. The `studio-bridge` CLI handles all environment setup:
210+
211+
```bash
212+
# One-time setup
213+
studio-bridge linux setup --install-deps
214+
studio-bridge linux auth # reads $ROBLOSECURITY env var
215+
216+
# Run tests the same as on Windows/macOS
217+
nevermore test
218+
```
219+
220+
Prerequisites (Wine 11, Xvfb, openbox, Mesa llvmpipe) are documented in `tools/studio-bridge/src/linux/README.md`. The `linux setup --install-deps` flag installs everything on Debian/Ubuntu but is opt-in — it never runs sudo automatically.
221+
222+
For CI, set `ROBLOSECURITY` as a repository or Codespace secret. The `.github/workflows/studio-linux-e2e.yml` workflow demonstrates the full flow.
223+
207224
## CI design principles
208225

209226
- **Workflows should be thin.** All logic lives in `nevermore-cli` commands — GitHub Actions workflows just call them. This keeps CI debuggable locally.

pnpm-lock.yaml

Lines changed: 7 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tools/nevermore-cli-helpers/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
],
2424
"dependencies": {
2525
"@quenty/cli-output-helpers": "workspace:*",
26+
"inquirer": "^13.2.0",
2627
"latest-version": "^9.0.0",
2728
"semver": "^7.6.0"
2829
},
@@ -31,12 +32,15 @@
3132
"@types/semver": "^7.5.0",
3233
"prettier": "2.7.1",
3334
"typescript": "^5.9.3",
34-
"typescript-memoize": "^1.1.1"
35+
"typescript-memoize": "^1.1.1",
36+
"vitest": "^3.0.0"
3537
},
3638
"scripts": {
3739
"build": "tsc --build",
3840
"build:watch": "tsc --build --watch",
3941
"build:clean": "tsc --build --clean",
42+
"test": "vitest run",
43+
"test:watch": "vitest",
4044
"preinstall": "npx only-allow pnpm"
4145
},
4246
"publishConfig": {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { COOKIE_NAME, parseStudioCookieValue } from './cookie-parser.js';
3+
4+
describe('COOKIE_NAME', () => {
5+
it('equals .ROBLOSECURITY', () => {
6+
expect(COOKIE_NAME).toBe('.ROBLOSECURITY');
7+
});
8+
});
9+
10+
describe('parseStudioCookieValue', () => {
11+
it('parses COOK::<value> format with angle brackets', () => {
12+
const result = parseStudioCookieValue('COOK::<abc123>');
13+
expect(result).toBe('abc123');
14+
});
15+
16+
it('parses value from comma-separated list', () => {
17+
const result = parseStudioCookieValue('OTHER::stuff,COOK::<secret>');
18+
expect(result).toBe('secret');
19+
});
20+
21+
it('returns undefined for plain text', () => {
22+
expect(parseStudioCookieValue('just a string')).toBeUndefined();
23+
});
24+
25+
it('returns undefined for COOK:: without angle brackets', () => {
26+
expect(parseStudioCookieValue('COOK::noBrackets')).toBeUndefined();
27+
});
28+
29+
it('returns undefined for empty string', () => {
30+
expect(parseStudioCookieValue('')).toBeUndefined();
31+
});
32+
33+
it('handles a realistic cookie value', () => {
34+
const cookie = '_|WARNING:-DO-NOT-SHARE|_abc123def456';
35+
const result = parseStudioCookieValue(`COOK::<${cookie}>`);
36+
expect(result).toBe(cookie);
37+
});
38+
});

tools/nevermore-cli/src/utils/auth/roblox-auth/cookie-parser.ts renamed to tools/nevermore-cli-helpers/src/auth/roblox-auth/cookie-parser.ts

File renamed without changes.
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/**
2+
* Cookie auth and place creation for Roblox legacy APIs.
3+
* Based on Mantle: https://github.com/blake-mealey/mantle
4+
*/
5+
6+
import inquirer from 'inquirer';
7+
import { OutputHelper } from '@quenty/cli-output-helpers';
8+
import { COOKIE_NAME } from './cookie-parser.js';
9+
import { readCookie as readWindowsCookie } from './windows.js';
10+
import { readCookie as readMacOSCookie } from './macos.js';
11+
import { readCookie as readLinuxCookie } from './linux.js';
12+
13+
/**
14+
* Resolve the .ROBLOSECURITY cookie for legacy Roblox API calls.
15+
*
16+
* Resolution order (matching Mantle's rbx_cookie crate):
17+
* 1. ROBLOSECURITY environment variable
18+
* 2. Platform credential store (Windows Credential Manager / macOS HTTPStorages / Wine Credential Manager)
19+
* 3. Platform legacy store (Windows Registry / macOS plist)
20+
* 4. Interactive prompt
21+
*/
22+
export async function getRobloxCookieAsync(): Promise<string> {
23+
const envCookie = process.env.ROBLOSECURITY;
24+
if (envCookie) {
25+
return envCookie;
26+
}
27+
28+
const platformCookie = readPlatformCookie();
29+
if (platformCookie) {
30+
return platformCookie;
31+
}
32+
33+
// No interactive prompt in non-TTY environments (CI)
34+
if (!process.stdin.isTTY) {
35+
throw new Error(
36+
'No .ROBLOSECURITY cookie available (set ROBLOSECURITY env var for CI)'
37+
);
38+
}
39+
40+
const { cookie } = await inquirer.prompt([
41+
{
42+
type: 'password',
43+
name: 'cookie',
44+
message: 'Enter your .ROBLOSECURITY cookie (from browser or Studio):',
45+
mask: '*',
46+
validate: (input: string) => input.length > 0 || 'Cookie cannot be empty',
47+
},
48+
]);
49+
50+
return cookie;
51+
}
52+
53+
function readPlatformCookie(): string | undefined {
54+
switch (process.platform) {
55+
case 'win32':
56+
return readWindowsCookie();
57+
case 'darwin':
58+
return readMacOSCookie();
59+
case 'linux':
60+
return readLinuxCookie();
61+
default:
62+
return undefined;
63+
}
64+
}
65+
66+
/**
67+
* Make a cookie-authenticated request to Roblox, handling CSRF token exchange.
68+
*/
69+
async function fetchWithCsrfAsync(
70+
url: string,
71+
cookie: string,
72+
options: RequestInit = {}
73+
): Promise<Response> {
74+
const headers: Record<string, string> = {
75+
Cookie: `${COOKIE_NAME}=${cookie}`,
76+
'User-Agent': 'Roblox/WinInet',
77+
...(options.headers as Record<string, string> | undefined),
78+
};
79+
80+
let response = await fetch(url, {
81+
...options,
82+
headers,
83+
});
84+
85+
if (response.status === 403) {
86+
const csrfToken = response.headers.get('x-csrf-token');
87+
if (csrfToken) {
88+
headers['X-CSRF-TOKEN'] = csrfToken;
89+
response = await fetch(url, {
90+
...options,
91+
headers,
92+
});
93+
}
94+
}
95+
96+
return response;
97+
}
98+
99+
/**
100+
* Create a new place in a universe using the legacy cookie-authenticated API.
101+
* Returns the new place ID.
102+
*/
103+
export async function createPlaceInUniverseAsync(
104+
cookie: string,
105+
universeId: number,
106+
placeName: string
107+
): Promise<number> {
108+
OutputHelper.verbose(
109+
`Creating place "${placeName}" in universe ${universeId}...`
110+
);
111+
112+
const createResponse = await fetchWithCsrfAsync(
113+
`https://apis.roblox.com/universes/v1/user/universes/${universeId}/places`,
114+
cookie,
115+
{
116+
method: 'POST',
117+
headers: {
118+
'Content-Type': 'application/json',
119+
},
120+
body: JSON.stringify({ templatePlaceId: 95206881 }),
121+
}
122+
);
123+
124+
if (!createResponse.ok) {
125+
const text = await createResponse.text();
126+
throw new Error(
127+
`Failed to create place: ${createResponse.status} ${createResponse.statusText}: ${text}`
128+
);
129+
}
130+
131+
const createData = (await createResponse.json()) as { placeId: number };
132+
const placeId = createData.placeId;
133+
134+
const renameResponse = await fetchWithCsrfAsync(
135+
`https://develop.roblox.com/v2/places/${placeId}`,
136+
cookie,
137+
{
138+
method: 'PATCH',
139+
headers: {
140+
'Content-Type': 'application/json',
141+
},
142+
body: JSON.stringify({ name: placeName }),
143+
}
144+
);
145+
146+
if (!renameResponse.ok) {
147+
OutputHelper.warn(
148+
`Place created (${placeId}) but rename failed — you can rename it manually.`
149+
);
150+
}
151+
152+
OutputHelper.verbose(`Created place "${placeName}" — ID: ${placeId}`);
153+
return placeId;
154+
}
155+
156+
export interface RenamePlaceResult {
157+
success: boolean;
158+
reason?: 'no_cookie' | 'api_error';
159+
status?: number;
160+
}
161+
162+
/**
163+
* Try to rename an existing place via the develop.roblox.com API.
164+
*/
165+
export async function tryRenamePlaceAsync(
166+
placeId: number,
167+
placeName: string
168+
): Promise<RenamePlaceResult> {
169+
let cookie: string;
170+
try {
171+
cookie = await getRobloxCookieAsync();
172+
} catch {
173+
return { success: false, reason: 'no_cookie' };
174+
}
175+
176+
const response = await fetchWithCsrfAsync(
177+
`https://develop.roblox.com/v2/places/${placeId}`,
178+
cookie,
179+
{
180+
method: 'PATCH',
181+
headers: {
182+
'Content-Type': 'application/json',
183+
},
184+
body: JSON.stringify({ name: placeName }),
185+
}
186+
);
187+
188+
if (response.ok) {
189+
OutputHelper.verbose(`Renamed place ${placeId} to "${placeName}"`);
190+
return { success: true };
191+
}
192+
193+
return { success: false, reason: 'api_error', status: response.status };
194+
}

0 commit comments

Comments
 (0)