← 샘플

클라우드 안드로이드 앱을 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

프롬프트

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