클라우드 안드로이드 앱을 Dock 에
android 가상 기기를 띄우고, 설치된 앱 목록에서 하나를 골라 그 기기의 앱으로 저장해 Dock 1단에도 올린 뒤 열어서 화면을 저장해요.
무엇을 하나
저장한 앱(app_)은 기기에 속해요(device_id + package). 가상 기기든 내가 연결한 폰(actual Device)이든 같은 Device API 라서, 같은 스크립트가 클라우드에 띄운 android 와 연결한 폰(ANDROID_DEVICE_ID)에서 똑같이 동작해요.
가상 기기는 켜져 있는 동안 크레딧을 써서, 샘플이 직접 만든 가상 기기(와 Station)는 끝날 때 멈추고 지워요. ANDROID_DEVICE_ID 로 넘긴 기기는 그대로 둬요.
이 샘플은 이 사이트를 만들며 돌려 보지 않았어요. 로컬 스택의 android 가상 기기는 호스트에 전용 에뮬레이터를 띄워요(한 번에 하나, 몇 분 걸려요). 같은 흐름(설치된 앱 → 앱 저장 → Dock → 실행 → 화면)은 Device 전환 전의 API 로 로컬 e2e(
node local/e2e/run.mjs android)가 22/22 로 확인했어요 — Device API 로는 아직 다시 돌려 보지 않았어요.
부르는 API
- POST
/stationsStation 만들기 - POST
/stations/{station_id}/devices기기 만들기·연결 - GET
/devices/{device_id}기기 조회 - GET
/devices/{device_id}/installed-apps설치된 앱 목록 - POST
/devices/{device_id}/apps앱 저장하기 - GET
/stations/{station_id}/dockDock 조회 - POST
/apps/{app_id}/launch앱 실행 - GET
/devices/{device_id}/screen화면 한 장 - POST
/devices/{device_id}/stop기기 정지 (virtual) - DELETE
/devices/{device_id}기기 지우기 - DELETE
/stations/{station_id}Station 지우기
프롬프트
Claude Code 같은 AI 코딩 에이전트에 그대로 붙여 넣으세요. 아래 코드가 이 프롬프트로 만든 결과예요.
endue OS 공개 REST API 로 "클라우드 안드로이드의 앱을 Dock 에 올리고 여는" Node.js 스크립트를 만들어 줘.
의존성 없이 Node 22 의 fetch 만, 파일 하나(android-shortcut.mjs). 사용법: node android-shortcut.mjs <package> <out.png>
API 규칙: 기본 주소 ENDUE_OS_URL (없으면 http://127.0.0.1:5173), 경로 /api/v1/..., Authorization: Bearer <토큰>
(ENDUE_OS_TOKEN, 없으면 POST {ENDUE_OS_URL}/idp/token?sub=<이름>&ttl=3600 의 access_token). 성공은 { data }.
402 INSUFFICIENT_CREDITS 면 크레딧 부족(error.details.balance)이라고 알려 줘.
할 일:
1. ANDROID_DEVICE_ID 가 있으면 그 기기(이미 연결한 폰)를 쓴다. 없으면 Station 을 만들고
POST /stations/{id}/devices {type: "virtual", kind: "android", name} → data.device 후
GET /devices/{device_id} 를 3초마다 — compute_status "running"·online 까지 (7분 제한).
2. GET /devices/{device_id}/installed-apps → [{package, label, icon_data_url, app_id}]. 인자의 package 를 고른다.
3. POST /devices/{device_id}/apps {package, label, pin_to_dock: true} → data.app_id (같은 package 면 있는 것을 200 으로 돌려준다).
4. GET /stations/{id}/dock 의 items 를 "type:label" 로 찍는다.
5. POST /apps/{app_id}/launch → data.status ("succeeded" 또는 10초 넘으면 "dispatched").
6. 1.5초 뒤 GET /devices/{device_id}/screen?max_age_ms=500 의 이미지 바이트를 파일로 저장한다.
7. finally: 1 에서 스크립트가 가상 기기를 만들었으면 POST /devices/{id}/stop → DELETE /devices/{id},
Station 도 만들었으면 DELETE /stations/{id} (409 면 몇 번 재시도). ANDROID_DEVICE_ID 로 받은 기기는 건드리지 않는다.결과 코드 android-shortcut.mjs
#!/usr/bin/env node
// 클라우드 안드로이드 앱을 Dock 에 — endue OS 샘플.
// android 가상 기기를 띄우고 → 설치된 앱 목록에서 하나를 골라 그 기기의 앱으로 저장해 Dock 1단에도 고정하고
// → 그 앱을 열고 → 화면을 저장한다.
//
// node android-shortcut.mjs com.android.settings settings.png
// ENDUE_OS_TOKEN=… ENDUE_OS_URL=… node … 운영 — endue 계정의 토큰
// ANDROID_DEVICE_ID=res_… node … 가상 기기 대신 이미 연결한 폰(actual Device)을 쓴다 — 같은 방식으로 된다
import { writeFile } from 'node:fs/promises';
const [pkg = 'com.android.settings', out = 'android.png'] = process.argv.slice(2);
const BASE = (process.env.ENDUE_OS_URL ?? 'http://127.0.0.1:5173').replace(/\/+$/, '');
const API = `${BASE}/api/v1`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const token =
process.env.ENDUE_OS_TOKEN ??
(await (await fetch(`${BASE}/idp/token?sub=${process.env.ENDUE_OS_SUB ?? `sample-${Date.now().toString(36)}`}&ttl=3600`, { method: 'POST' })).json()).access_token;
async function api(method, path, body, { raw = false } = {}) {
const res = await fetch(`${API}${path}`, {
method,
headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
if (raw) return res;
const json = res.status === 204 ? null : await res.json();
if (res.status === 402) throw new Error(`${method} ${path} → 402 ${json?.error?.code}: 크레딧이 부족해요 (잔액 ${json?.error?.details?.balance ?? '?'}). 충전한 뒤 다시 돌려 주세요.`);
if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${json?.error?.code}`);
return json?.data;
}
let stationId = process.env.STATION_ID;
let did = process.env.ANDROID_DEVICE_ID ?? process.env.ANDROID_RESOURCE_ID; // device_id 는 res_ 그대로다
// 이 스크립트가 만든 것만 끝에서 멈추고 지운다 — 이미 연결한 폰(ANDROID_DEVICE_ID)은 건드리지 않는다.
let createdDevice = null;
let createdStation = null;
if (!did) {
if (!stationId) stationId = createdStation = (await api('POST', '/stations', { name: 'phone-lab' })).station_id;
}
try {
if (!did) {
// 에뮬레이터가 뜨는 데 몇 분 걸릴 수 있다. 가상 기기는 만들 때 Dock 1단에 붙는다.
let device = (await api('POST', `/stations/${stationId}/devices`, { type: 'virtual', kind: 'android', name: 'pixel' })).device;
createdDevice = device.device_id;
const until = Date.now() + 420_000;
while (!(device.compute_status === 'running' && device.online)) {
if (device.compute_status === 'failed') throw new Error(device.status_reason);
if (Date.now() > until) throw new Error('7분 안에 running 이 되지 않았다');
await sleep(3000);
device = await api('GET', `/devices/${device.device_id}`);
}
did = device.device_id;
console.log(`android running — device ${did}`);
} else {
stationId ??= (await api('GET', `/devices/${did}`)).station_id;
}
// 설치된 앱은 OS 가 저장하지 않는다 — 에이전트에게 그때그때 묻는다 (명령 기록에 남지 않는다).
const installed = await api('GET', `/devices/${did}/installed-apps`);
console.log(`설치된 앱 ${installed.length}개: ${installed.slice(0, 6).map((a) => a.label).join(', ')} …`);
const target = installed.find((a) => a.package === pkg) ?? installed[0];
// 같은 기기의 같은 package 면 있는 앱을 돌려준다 — 몇 번을 돌려도 하나.
const app = await api('POST', `/devices/${did}/apps`, {
package: target.package,
label: target.label,
pin_to_dock: true,
});
const dock = await api('GET', `/stations/${stationId}/dock`);
console.log(`Dock: ${dock.items.map((i) => `${i.type}:${i.label}`).join(' | ')}`);
const launched = await api('POST', `/apps/${app.app_id}/launch`);
console.log(`${target.label} 열기 → ${launched.status}`);
await sleep(1500);
const shot = await api('GET', `/devices/${did}/screen?max_age_ms=500`, undefined, { raw: true });
if (shot.status !== 200) throw new Error(`screen → ${shot.status}`);
await writeFile(out, Buffer.from(await shot.arrayBuffer()));
console.log(`${out} 저장`);
} finally {
// 가상 기기는 켜져 있는 동안 크레딧을 쓴다 — 만든 것은 멈추고 지운다.
if (createdDevice) {
await api('POST', `/devices/${createdDevice}/stop`).catch(() => {});
await api('DELETE', `/devices/${createdDevice}`).catch((e) => console.warn(String(e)));
}
if (createdStation) {
for (let i = 0; i < 15 && !(await api('DELETE', `/stations/${createdStation}`).then(() => true, () => false)); i++) await sleep(2000);
}
if (createdDevice || createdStation) console.log('정리했다');
}돌려 보기
# endue-os 제품 레포에서 로컬 스택을 띄운 뒤 local/up.sh node site/samples/android-shortcut.mjs # 운영 (예정) — endue 계정의 토큰으로 ENDUE_OS_URL=https://api.endueos.com ENDUE_OS_TOKEN=… node android-shortcut.mjs