Node.js와 TypeScript

Node.js SDK의 모든 것을 다룹니다. 설치, 연결, 옵션 객체로 명령 호출, 파일, 대기, 에러, Promise.all로 여러 폰 병렬 처리, 알림, TypeScript.

번역된 페이지입니다. 영어 문서와 내용이 다르면 영어 문서가 기준입니다. English

Node.js 패키지는 droidline serve를 위한 얇은 클라이언트입니다. 대기, 재시도, 대체 동작은 서버와 폰이 맡고, 패키지는 명령을 보내고, 응답으로 resolve하고, 무언가 실패하면 DroidlineError로 reject합니다. Node.js 18 이상이 필요하고, ES 모듈로 배포되며 TypeScript 타입이 들어 있습니다.

설치

npm install droidline

스크립트는 ES 모듈이어야 합니다. 파일 이름을 something.mjs로 하거나, package.json에 "type": "module"을 설정하세요. 서버를 실행한 상태에서 잘 동작하는지 확인합니다.

node -e "import('droidline').then(async ({ connect }) => console.log(await (await connect()).info()))"

연결

import { connect } from "droidline";

const d = await connect();              // 온라인인 유일한 폰
const e = await connect("shelf-01");    // 이름이나 ID로 지정한 폰

connect()는 Device로 resolve합니다. 메서드를 부르면 폰 한 대에 명령을 보내는 객체입니다. droidline serve가 실행 중이 아니면 바로 SERVER_NOT_RUNNING 코드로 reject합니다.

다른 컴퓨터의 서버에 접속하려면 옵션을 넘기거나, DROIDLINE_HOST, DROIDLINE_PORT, DROIDLINE_TOKEN, DROIDLINE_DEVICE를 설정합니다.

const d = await connect({ device: "shelf-01", host: "192.168.0.12", port: 8780, token: "dlc_..." });

여러 폰을 다루거나 devices, pair 같은 서버 명령을 쓰려면 Droidline 클라이언트를 만들고 거기서 기기를 받아 옵니다.

import { Droidline } from "droidline";

const dl = new Droidline();          // 같은 host, port, token 옵션
console.log(await dl.devices());
const a = dl.device("shelf-01");
const b = dl.device("shelf-02");

대기 중인 작업이 없으면 프로세스가 알아서 끝나므로 스크립트에서 close()를 부를 필요가 없습니다. 오래 실행되는 프로그램은 일이 끝났을 때 dl.close()를 부르면 됩니다.

명령 호출

명령어 레퍼런스의 모든 명령은 메서드입니다. 필수 파라미터가 먼저 오고, 선택 파라미터는 위치 인자로 이어서 넘기거나 마지막 인자인 옵션 객체에 넣습니다.

await d.touch("text", "Log in");
await d.touch("text", "Log in", 0, 30);          // 위치 인자로 nth=0, timeout=30
await d.touch("text", "Log in", { timeout: 30 }); // 더 명확함: 옵션 객체
await d.swipe("up");                             // 방향을 받거나...
await d.swipe(540, 1600, 540, 400, 300);         // ...좌표와 시간을 받음
await d.chrome.go("droidline.dev");              // 점이 들어간 명령은 프로퍼티로 접근

여러 단어로 된 이름은 get_text와 getText, scroll_to와 scrollTo처럼 두 형태가 모두 있습니다. 이 가이드에서는 camelCase를 씁니다. 가장 많이 쓰는 선택자 속성에는 축약형이 있습니다. d.touchById("login"), d.touchByText("OK"), d.touchByDesc("Search")입니다.

모든 메서드는 promise를 돌려주므로 await를 잊지 마세요. 가장 흔한 실수가 await를 빠뜨리는 것입니다. 그러면 폰이 아무것도 하기 전에 스크립트가 다음으로 넘어갑니다.

돌려받는 값

명령이 돌려주는 것promise가 resolve하는 값예
특별한 것 없음via, ms 같은 세부 정보가 담긴 객체await d.touch(...)는 { via: "node", ms: 208 }
값 하나그 값 자체await d.exists(...)는 true, await d.getText(...)는 문자열
여러 항목객체await d.battery()는 { level: 84, charging: false, temperature: 31.5 }

파일: 화면 덤프와 스크린샷

await d.dump("screen.json");                           // 화면 트리를 PC의 파일로 저장
const tree = await d.dump();                           // 또는 트리로 resolve

await d.screenshot("shot.png");                        // 확장자에 따라 PNG 또는 JPEG
await d.screenshot("small.jpg", { scale: 0.5, quality: 70 });
const png = await d.screenshot({ format: "png" });     // 경로가 없으면 Buffer

경로는 PC 기준이며, 스크립트를 실행한 폴더에 대한 상대 경로입니다.

대기와 타임아웃

요소를 다루는 명령은 그 요소를 기다립니다. 기본값은 10초입니다.

await d.touch("text", "Next", { timeout: 30 });
await d.wait("text", "Upload complete", 120);   // 기다리기만 함
await d.waitGone("id", "progress");             // 무언가 사라질 때까지 기다림

명령 앞에 setTimeout으로 멈출 필요가 없습니다. 스크립트만 느려집니다.

조건 확인

조건 확인은 바로 응답하고, 요소가 없어도 reject하지 않습니다.

if (await d.exists("text", "Allow")) await d.touch("text", "Allow");

if (!(await d.inApp("com.android.chrome"))) await d.launch("com.android.chrome");

const state = await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 });  // 0, 1, -1 중 하나

에러

모든 실패는 DroidlineError이고, code, retryable, 그리고 서버가 보낸 추가 항목을 담은 data가 있습니다.

import { connect, DroidlineError } from "droidline";

const d = await connect();
try {
  await d.touch("text", "Log in", { timeout: 5 });
} catch (e) {
  if (!(e instanceof DroidlineError)) throw e;
  switch (e.code) {
    case "NOT_FOUND":
      console.log("not on screen:", e.data.screen);
      break;
    case "DEVICE_OFFLINE":
      console.log("the phone is offline");
      break;
    default:
      console.log(e.code, e.retryable, e.message);
  }
}

에러 코드에 있는 코드 말고도 SDK는 SERVER_NOT_RUNNING(포트에서 기다리는 프로그램이 없음)과 CONNECTION_LOST(호출 도중 연결이 끊김. 명령이 실행됐을 수도, 안 됐을 수도 있음)를 씁니다.

재시도할 만한 에러로 표시된 경우에 쓰는 간단한 재시도 함수입니다.

async function retry(fn, attempts = 3) {
  for (let i = 0; ; i++) {
    try {
      return await fn();
    } catch (e) {
      if (!(e instanceof DroidlineError) || !e.retryable || i >= attempts - 1) throw e;
      await new Promise((r) => setTimeout(r, 2000));
    }
  }
}

await retry(() => d.touch("text", "Refresh"));

여러 폰 병렬 처리

한 폰에 보낸 명령은 순서대로 실행되고, 서로 다른 폰에 보낸 명령은 동시에 실행됩니다. Promise.all로 선반 전체를 한꺼번에 다룰 수 있습니다.

import { Droidline } from "droidline";

const dl = new Droidline();
const online = (await dl.devices()).filter((p) => p.online);

const results = await Promise.all(online.map(async (p) => {
  const d = dl.device(p.name);
  await d.wake();
  await d.launch("com.android.chrome");
  await d.chrome.go("droidline.dev");
  return [p.name, (await d.current()).package];
}));
console.log(results);

폰 한 대가 실패해도 나머지는 멈추지 않게 하려면 대신 Promise.allSettled를 쓰세요. 더 많은 패턴은 여러 폰에 있습니다.

알림과 이벤트

먼저 폰이 어떤 앱의 알림을 넘길지 notifyFilter나 앱의 상태 탭에서 고르세요. 그런 다음 알림 하나를 기다리거나, 알림마다 콜백을 받습니다.

await d.notifyFilter(["com.google.android.gm"]);

const n = await d.waitNotification("textContains", "verification code", 60);
console.log(n.title, n.text);

const stop = await d.onNotification({ package: "com.google.android.gm" }, (n) => console.log(n.title, n.text));
// ... 나중에
stop();

알림 리스너가 동작하는 동안에는 프로세스가 계속 실행됩니다. 종료하게 하려면 stop()을 부르세요. 답장, 클릭, 웹훅은 알림에서 다룹니다.

SDK가 아직 모르는 명령

서버가 패키지보다 새 버전이라면 명령을 이름으로 호출합니다.

await d.call("some_new_command", { value: 1 });

TypeScript

패키지에 자체 타입이 들어 있습니다. 파라미터 이름, 옵션 객체, 결과 모두 타입이 지정되어 있습니다.

import { connect, DroidlineError, type Device } from "droidline";

async function login(d: Device, email: string): Promise<boolean> {
  await d.input("id", "email", email);
  await d.touchById("login");
  return (await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 })) === 1;
}

const d = await connect();
console.log(await login(d, "knife"));

TypeScript는 npx tsx script.ts로 바로 실행하거나, 평소처럼 tsc로 컴파일합니다.

완성된 스크립트

첫 스크립트의 스크립트에 깔끔한 종료 코드를 더한 것입니다.

import { connect, DroidlineError } from "droidline";

async function main() {
  const d = await connect();
  await d.launch("dev.droidline.demo");
  await d.wait("text", "Sign in");
  if (await d.exists("text", "Close ad")) await d.touch("text", "Close ad");
  await d.input("id", "email", "knife");
  if (!(await d.checked("id", "auto_login"))) await d.touch("id", "auto_login");
  await d.touch("text", "Log in");
  if ((await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 })) !== 1) {
    await d.screenshot("login-failed.png");
    return 1;
  }
  console.log(await d.getText("id", "greeting"));
  return 0;
}

try {
  process.exitCode = await main();
} catch (e) {
  if (!(e instanceof DroidlineError)) throw e;
  console.error(`${e.code}: ${e.message}`);
  process.exitCode = 2;
}