Skip to content

Commit aebc17c

Browse files
committed
추가: 헤드 브라우저를 전용 데스크톱에 띄워 사용자의 전면을 빼앗지 않음
headed Chrome과 Edge는 시작할 때 사용자가 다른 프로그램에 입력 중이어도 자기 창을 활성화한다(전면 잠금 시간이 최대여도, 표시 상태 SW_SHOWNOACTIVATE, background target, focused:false 확장 창 모두 실측에서 전면을 가져갔다). 사람이 치던 키가 과업 페이지로 갈 수 있는 안전 문제다. browser.desktop: "private"(Windows, nativeCdp, headed)는 새 도우미 pyproc-browser-desktop.exe가 브라우저 하나만을 위한 데스크톱을 만들고, 자기가 받은 표준 핸들과 C runtime 기술자 표(DevTools pipe fd 3, 4)를 그대로 넘겨 그 데스크톱에서 브라우저를 시작한다. Windows는 데스크톱마다 전면 창을 따로 두므로 열기, 탭 전환, popup, 입력, screenshot 어느 것도 사용자의 데스크톱에 닿지 않고, 브라우저는 headed 그대로 렌더링과 입력과 캡처를 하며 headless 표식이 없다. 도우미는 win_amd64 platform wheel에 싣고(다음 commit에서 고정), 없으면 PYPROC_BROWSER_DESKTOP_HELPER가 가리킨 cargo 빌드를 쓴다. 둘 다 없으면 브라우저를 띄우기 전에 BROWSER_DESKTOP_UNAVAILABLE로 끝난다. Windows native host 빌드를 구성요소 단위로 일반화했다: buildNativeHost.mjs --component userBrowserHost|browserDesktop, native-hosts workflow(구성요소마다 두 번 빌드, byte 대조, 출처 증명), Python 배포 lock의 nativeHosts(toolchain 하나, host마다 한 항목). 사용자 브라우저 host의 보관 파일 형식은 그대로라 이미 고정한 자산을 다시 만들 필요가 없다. 검증: 실측(Windows 11, Edge 153)에서 전용 데스크톱 세션은 0.8초에 준비, rAF 약 146/s, 클릭과 입력 적용, screenshot 150ms, 일반 Edge UA, 사용자 전면 창 불변. test:contracts 54 suites(새 browserDesktop 계약: 설정 규칙, 이름과 실패 코드의 일치, 도우미의 핸들 전달), pythonDistributions 계약, npm test 13/13, test:browser-control 75/75, test:python-sdk 6/6, test:control-product 34/34, test:permission-revision 17/17, test:types, test:package. 새 게이트 test:browser-desktop은 자기 창을 앞에 두고 전면과 사용자 데스크톱의 창을 재므로, 사용자가 쓰는 컴퓨터가 아닌 Windows CI에서 돈다.
1 parent 3d39ee4 commit aebc17c

28 files changed

Lines changed: 999 additions & 160 deletions

‎.github/workflows/ci.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,14 @@ jobs:
200200
- run: npm run test:browser-control-stress
201201
- run: npm run test:request-guard
202202
- run: npm run test:permission-revision
203+
- name: Build the browser desktop helper outside the checkout
204+
if: runner.os == 'Windows'
205+
shell: pwsh
206+
run: |
207+
cargo build --release --locked --manifest-path scripts/browserControl/browserDesktop/nativeHelper/Cargo.toml --target-dir "$env:RUNNER_TEMP/browser-desktop"
208+
"PYPROC_BROWSER_DESKTOP_HELPER=$env:RUNNER_TEMP/browser-desktop/release/pyproc-browser-desktop.exe" >> $env:GITHUB_ENV
209+
- run: npm run test:browser-desktop
210+
if: runner.os == 'Windows'
203211
- run: npm run test:automation-lifecycle
204212
- run: npm run test:action-convergence
205213
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,31 @@
1-
name: user-browser-host
1+
name: native-hosts
22

3-
# 사용자 브라우저 native host를 같은 runner image에서 두 번 따로 빌드해 byte가 같은지 보고, 같으면 build provenance를
4-
# 붙인 검증 자산으로 남긴다. 게시(프로젝트 release 자산과 Python 배포 lock 고정)는 정본 절차
5-
# skills/ship-pyproc/references/release.md를 따른다.
3+
# pyproc의 Windows native host(사용자 브라우저 host, 브라우저 데스크톱 도우미)를 host마다 같은 runner image에서 두 번 따로
4+
# 빌드해 byte가 같은지 보고, 같으면 build provenance를 붙인 검증 자산으로 남긴다. 게시(프로젝트 release 자산과 Python
5+
# 배포 lock 고정)는 정본 절차 skills/ship-pyproc/references/release.md를 따른다.
66
on:
77
workflow_dispatch:
88
push:
99
branches: [main]
1010
paths:
1111
- "scripts/browserControl/userBrowser/nativeHost/**"
12-
- "scripts/userBrowserHostBuilder/**"
12+
- "scripts/browserControl/browserDesktop/nativeHelper/**"
13+
- "scripts/nativeHostBuilder/**"
1314
- "scripts/pythonSdkBuilder/pythonDistributionLock.json"
14-
- ".github/workflows/user-browser-host.yml"
15+
- ".github/workflows/native-hosts.yml"
1516

1617
permissions:
1718
contents: read
1819

1920
jobs:
2021
build:
21-
name: build-${{ matrix.slot }}
22+
name: build-${{ matrix.component }}-${{ matrix.slot }}
2223
runs-on: windows-2025
2324
timeout-minutes: 30
2425
strategy:
2526
fail-fast: false
2627
matrix:
28+
component: [userBrowserHost, browserDesktop]
2729
slot: [a, b]
2830
steps:
2931
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -35,24 +37,28 @@ jobs:
3537
- name: Install the Rust toolchain the lock pins
3638
shell: pwsh
3739
run: |
38-
$toolchain = node -p "require('./scripts/pythonSdkBuilder/pythonDistributionLock.json').userBrowserHost.toolchain"
40+
$toolchain = node -p "require('./scripts/pythonSdkBuilder/pythonDistributionLock.json').nativeHosts.toolchain"
3941
rustup toolchain install $toolchain --profile minimal --target x86_64-pc-windows-msvc
4042
- name: Build the host from the exact Git tree
4143
shell: pwsh
42-
run: node scripts/userBrowserHostBuilder/buildUserBrowserHost.mjs build --tree $env:GITHUB_SHA --out (Join-Path $env:RUNNER_TEMP "user-browser-host")
44+
run: node scripts/nativeHostBuilder/buildNativeHost.mjs build --component ${{ matrix.component }} --tree $env:GITHUB_SHA --out (Join-Path $env:RUNNER_TEMP "native-host")
4345
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
4446
with:
45-
name: pyproc-user-browser-host-${{ matrix.slot }}
46-
path: ${{ runner.temp }}/user-browser-host/
47+
name: pyproc-${{ matrix.component }}-${{ matrix.slot }}
48+
path: ${{ runner.temp }}/native-host/
4749
if-no-files-found: error
4850
compression-level: 0
4951
retention-days: 30
5052

5153
verify:
52-
name: verify-byte-identical
54+
name: verify-${{ matrix.component }}-byte-identical
5355
needs: build
5456
runs-on: ubuntu-24.04
5557
timeout-minutes: 10
58+
strategy:
59+
fail-fast: false
60+
matrix:
61+
component: [userBrowserHost, browserDesktop]
5662
permissions:
5763
contents: read
5864
id-token: write # build provenance 서명
@@ -64,21 +70,21 @@ jobs:
6470
node-version: 22.19.0
6571
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
6672
with:
67-
name: pyproc-user-browser-host-a
68-
path: .cache/user-browser-host/a
73+
name: pyproc-${{ matrix.component }}-a
74+
path: .cache/native-host/a
6975
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
7076
with:
71-
name: pyproc-user-browser-host-b
72-
path: .cache/user-browser-host/b
77+
name: pyproc-${{ matrix.component }}-b
78+
path: .cache/native-host/b
7379
- name: Verify independent build identity
74-
run: node scripts/userBrowserHostBuilder/buildUserBrowserHost.mjs verify --left .cache/user-browser-host/a --right .cache/user-browser-host/b
80+
run: node scripts/nativeHostBuilder/buildNativeHost.mjs verify --component ${{ matrix.component }} --left .cache/native-host/a --right .cache/native-host/b
7581
- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
7682
with:
77-
subject-path: .cache/user-browser-host/a/*.zip
83+
subject-path: .cache/native-host/a/*.zip
7884
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
7985
with:
80-
name: pyproc-user-browser-host-verified
81-
path: .cache/user-browser-host/a/
86+
name: pyproc-${{ matrix.component }}-verified
87+
path: .cache/native-host/a/
8288
if-no-files-found: error
8389
compression-level: 0
8490
retention-days: 90

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,4 @@ tests/browser/wasiWheels/*.whl
3939
vendor/
4040
# Buildroot 자체 guest 빌드 작업공간과 산출물. recipe/config/manifest 계약만 추적한다.
4141
.cache/buildrootGuest/
42+
**/nativeHelper/target/

‎CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,23 @@ happen only on an explicit maintainer decision; the Unreleased section accumulat
1212
소비자가 핀한 버전에 아직 없는 subpath 목록이다(위 주석이 기계 판독 정본). 출하 문서가 이 이름을
1313
예시로 쓰면 미출하 표식이 함께 있어야 하고, tests/contracts/publicSurface.mjs가 그것을 문다.
1414

15+
### Added
16+
17+
- **Private browser desktop.** `browser.desktop: "private"` (Windows, `nativeCdp`, `headed: true`) starts the headed
18+
browser on a desktop of its own through the new helper `pyproc-browser-desktop.exe`, with the same DevTools pipe.
19+
Windows keeps one foreground window per desktop, so the task browser can no longer take the user's foreground or
20+
keyboard (a headed Chrome or Edge activates its window at start even while the user types elsewhere), while it
21+
still renders, answers input, and captures as a headed browser with a headed identity. Without the helper the launch
22+
fails with `BROWSER_DESKTOP_UNAVAILABLE` before any browser starts. The gate `test:browser-desktop` (Windows CI)
23+
holds its own window in front and checks the foreground never leaves it and no window of that browser reaches the
24+
user's desktop, with a headed-on-the-user's-desktop control.
25+
26+
### Changed
27+
28+
- The Windows native hosts share one reproducible build: `scripts/nativeHostBuilder/buildNativeHost.mjs --component`
29+
builds `userBrowserHost` or `browserDesktop`, the `native-hosts` workflow builds each twice and attests it, and the
30+
Python distribution lock pins them under `nativeHosts` (one Rust toolchain, one entry per host).
31+
1532
### Fixed
1633

1734
- A new tab whose page moves to another origin by itself right after it loads is held (or, on a host without

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@
128128
"test:browser-control-stress": "node tests/browser/browserControlStress.mjs",
129129
"test:request-guard": "node tests/browser/requestGuardProduct.mjs",
130130
"test:permission-revision": "node tests/browser/permissionRevisionProduct.mjs",
131+
"test:browser-desktop": "node tests/browser/browserDesktopProduct.mjs",
131132
"test:automation-lifecycle": "node tests/browser/automationLifecycleProduct.mjs",
132133
"test:action-convergence": "node tests/browser/actionConvergenceProduct.mjs",
133134
"test:hardware-visual-oracle": "node tests/browser/hardwareVisualOracleProduct.mjs",
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// browserDesktopHelper.mjs - which desktop a launched browser's windows live on, and the Windows helper that starts a
2+
// browser on a desktop of its own. Windows gives each desktop its own foreground window, so no window of a browser on a
3+
// private desktop can take the foreground or the keyboard of the desktop the user works on (a headed browser started
4+
// normally activates its window even while the user types elsewhere). The browser is still headed there: it renders,
5+
// lays out, and answers input and screenshots as it would on screen, with the identity of a headed browser. The helper
6+
// comes with the win_amd64 platform wheel, pinned by SHA-256 in the wheel's host descriptor; without the wheel (a source
7+
// checkout, the npm package) PYPROC_BROWSER_DESKTOP_HELPER names one built with cargo from nativeHelper.
8+
import { createHash } from "node:crypto";
9+
import { existsSync, readFileSync } from "node:fs";
10+
import { dirname, isAbsolute, join, resolve } from "node:path";
11+
import { fileURLToPath } from "node:url";
12+
13+
const HERE = dirname(fileURLToPath(import.meta.url));
14+
const PACKAGE_ROOT = resolve(HERE, "..", "..", "..");
15+
export const BROWSER_DESKTOP_SOURCE_ROOT = join(HERE, "nativeHelper");
16+
export const BROWSER_DESKTOP_HELPER_FILE = "pyproc-browser-desktop.exe";
17+
// `user`: the desktop the user works on (the default). `private`: a desktop created for this one browser.
18+
export const BROWSER_DESKTOPS = Object.freeze(["user", "private"]);
19+
// The helper's own failure (it never ran the browser), distinct from any browser exit code.
20+
export const BROWSER_DESKTOP_HELPER_FAILED = 0x70d;
21+
22+
let helperPath = null;
23+
24+
function unavailable(message) {
25+
return Object.assign(new Error(message), { code: "BROWSER_DESKTOP_UNAVAILABLE" });
26+
}
27+
28+
/** The desktop a launch asks for (`opts.desktop`, else PYPROC_BROWSER_DESKTOP, else `user`), checked for this host. */
29+
export function browserDesktopOf({ desktop, headed } = {}, env = process.env) {
30+
const value = desktop ?? (env.PYPROC_BROWSER_DESKTOP || "user");
31+
if (!BROWSER_DESKTOPS.includes(value)) {
32+
throw new TypeError(`browser desktop must be one of ${BROWSER_DESKTOPS.join(", ")}`);
33+
}
34+
if (value === "private") {
35+
if (process.platform !== "win32") throw new TypeError("a private browser desktop is Windows-only");
36+
if (!(headed === true || env.PYPROC_HEADED === "1")) {
37+
throw new TypeError("a private browser desktop is for a headed browser; a headless one has no windows");
38+
}
39+
}
40+
return value;
41+
}
42+
43+
function wheelHelper() {
44+
const descriptorPath = join(PACKAGE_ROOT, "..", "host.json");
45+
if (!existsSync(descriptorPath)) return null;
46+
const descriptor = JSON.parse(readFileSync(descriptorPath, "utf8"));
47+
const { version } = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
48+
const entry = descriptor?.browserDesktop;
49+
if (descriptor?.package?.name !== "pyproc" || descriptor.package.version !== version || !entry) return null;
50+
const path = join(dirname(descriptorPath), entry.path);
51+
const digest = createHash("sha256").update(readFileSync(path)).digest("hex");
52+
if (digest !== entry.sha256) throw unavailable(`the platform wheel's browser desktop helper ${path} does not match its SHA-256`);
53+
return path;
54+
}
55+
56+
/** The helper to start a browser on a private desktop: the platform wheel's, else the one PYPROC_BROWSER_DESKTOP_HELPER
57+
* names. */
58+
export function browserDesktopHelper(env = process.env) {
59+
if (helperPath) return helperPath;
60+
const named = env.PYPROC_BROWSER_DESKTOP_HELPER || "";
61+
if (named && (!isAbsolute(named) || !existsSync(named))) {
62+
throw unavailable(`PYPROC_BROWSER_DESKTOP_HELPER must name an existing absolute file: ${named}`);
63+
}
64+
const found = wheelHelper() || named;
65+
if (!found) {
66+
throw unavailable("a private browser desktop needs pyproc's browser desktop helper: install the pyproc-control "
67+
+ "win_amd64 platform wheel, or build it (cargo build --release --manifest-path "
68+
+ `${join(BROWSER_DESKTOP_SOURCE_ROOT, "Cargo.toml")} --target-dir <outside the checkout>) and set `
69+
+ "PYPROC_BROWSER_DESKTOP_HELPER to the built pyproc-browser-desktop.exe");
70+
}
71+
helperPath = found;
72+
return found;
73+
}

‎scripts/browserControl/browserDesktop/nativeHelper/Cargo.lock‎

Lines changed: 155 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)