화면(surface)

설정 폼과 홈 카드를 선언형 JSON으로 정의하는 방법. 필드 종류, 값 저장 위치, 홈 카드 캐시.

앱은 화면 코드를 직접 짜지 않습니다. 매니페스트의 surfaces.settings와 surfaces.homeCard가 번들 안 JSON 파일을 가리키면, 플랫폼이 그 선언을 읽어 /assist/apps/[appId] 화면을 그립니다.

설정(settings) surface

surfaces/settings.json은 이런 모양입니다.

{
  "version": 1,
  "title": { "ko": "메일 앱 설정", "en": "Mail app settings" },
  "description": { "ko": "메일함 연결과 알림 방식을 설정합니다.", "en": "Connect your mailbox and set notification style." },
  "fields": [
    { "key": "imapHost", "kind": "text", "label": { "ko": "IMAP 호스트", "en": "IMAP host" }, "required": true, "maxLength": 200 },
    { "key": "apiKey", "kind": "secret", "label": { "ko": "API 키", "en": "API key" }, "required": true },
    { "key": "digestFrequency", "kind": "select", "label": { "ko": "요약 주기", "en": "Digest frequency" }, "options": [
      { "value": "daily", "label": { "ko": "매일", "en": "Daily" } },
      { "value": "weekly", "label": { "ko": "매주", "en": "Weekly" } }
    ] },
    { "key": "notifyOnArrival", "kind": "toggle", "label": { "ko": "도착 즉시 알림", "en": "Notify on arrival" } },
    { "key": "gmail", "kind": "oauth", "label": { "ko": "Gmail 연결", "en": "Connect Gmail" }, "provider": "google" },
    { "key": "note", "kind": "info", "label": { "ko": "이 앱은 받은 메일을 읽기만 합니다.", "en": "This app only reads your incoming mail." } }
  ]
}

필드 종류(kind)는 여섯 가지입니다. key는 영문으로 시작하는 64자 이하 식별자여야 하고(^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$), label과 description은 ko/en 둘 다 있어야 합니다.

kind값 모양그 외 필드값 저장 위치
text문자열placeholder?, multiline(기본 false), maxLength(1~10,000, 기본 1,000), networkHost(기본 false)app_kv의 settings 키
secret문자열placeholder?app_secrets에 필드 key 이름으로 암호화 저장. 화면은 값을 절대 되읽지 않고 "설정됨" 여부만 보여준다
select옵션 중 하나options: { value, label } 배열, 최소 1개app_kv의 settings 키
toggleboolean없음app_kv의 settings 키
oauth없음(값이 아니라 연결 버튼)provider저장 없음. 연결 흐름은 플랫폼이 대행(§10, S7 예정)
info없음(안내문)없음저장 없음

값이 있는 필드(text, secret, select, toggle)만 isValuedField가 참입니다. 그중 secret을 뺀 나머지만 isKvField가 참이라 app_kv의 settings 오브젝트 하나에 { [key]: value }로 함께 저장됩니다. secret은 app_secrets로 따로 갑니다. 제출값이 빈 문자열이면 "기존 값 유지"로 취급합니다(화면이 기존 비밀 값을 되읽지 못하므로 지우는 뜻일 수 없습니다). oauth와 info는 저장할 값이 아예 없습니다.

사용자가 넣는 호스트: networkHost

text 필드에 "networkHost": true를 적으면 그 필드에 사용자가 넣은 값이 이 설치만의 network 허용 목록에 들어갑니다. 매니페스트의 network: 목록은 발행 시점에 고정이라, 메일 앱의 IMAP 서버처럼 사용자마다 다른 호스트를 미리 적을 수 없기 때문입니다.

{ "key": "imapHost", "kind": "text", "label": { "ko": "IMAP 서버", "en": "IMAP host" }, "required": true, "networkHost": true }
  • ctx.fetch는 매니페스트 network: 목록과 이 설치 목록을 합쳐 봅니다. 둘 중 하나에 있으면 나갈 수 있습니다.
  • 값은 호스트명만 남깁니다. imaps://IMAP.Example.com:993/INBOX를 넣어도 imap.example.com으로 저장됩니다. 호스트명 모양이 아니면 목록에 들어가지 않습니다.
  • 와일드카드는 매니페스트 쪽에만 있습니다. 설치 목록은 정확히 일치하는 호스트명만 받습니다.
  • 나머지 방어는 그대로입니다. 사설, 루프백, 링크로컬, 메타데이터 주소로 풀리면 여전히 거부하고, 리다이렉트는 hop마다 다시 검사합니다.
  • 설정 화면에 "여기 적은 서버로만 나갈 수 있다"는 설명을 함께 적어 주세요. 사용자가 무엇을 여는지 알아야 합니다.

폼 하나에 필드는 최대 50개(fields.max(50))까지입니다. surfaces.settings를 아예 선언하지 않거나 파일을 못 읽으면 앱 상세 화면은 "설정 항목이 없습니다"만 보여줍니다. surface가 깨져 있다고 화면이 죽지는 않습니다.

홈 카드(homeCard) surface

surfaces/home-card.json은 화면에 뭘 그릴지가 아니라 틀만 선언합니다. 실제 데이터는 앱의 homeCard 훅이 만듭니다.

{
  "version": 1,
  "kind": "metric",
  "title": { "ko": "안 읽은 메일", "en": "Unread mail" },
  "href": "/assist/apps/inst_abc123"
}

kind는 세 가지입니다.

kind훅이 돌려주는 HomeCardData 모양예
metric{ kind: "metric", value: string | number, label?, hint? }{ "kind": "metric", "value": 12, "label": "안 읽은 메일", "hint": "5분 전 갱신" }
list{ kind: "list", items: HomeCardListItem[] }, 최대 5줄. 줄 하나는 { title, subtitle?, href? }{ "kind": "list", "items": [{ "title": "회의록 초안", "subtitle": "오늘 10:00", "href": "/assist/notes/n1" }] }
status{ kind: "status", tone: "ok"|"info"|"warning"|"error"|"idle", label, message? }{ "kind": "status", "tone": "ok", "label": "동기화됨" }

href는 앱 안 상대 경로가 아니라 절대 경로입니다. 안 주면 카드를 눌렀을 때 앱 설정 화면으로 갑니다.

캐시가 어디서 오는가

홈 화면은 카드 목록을 계산할 때 앱의 homeCard 훅을 직접 부르지 않고, 설치별로 캐시된 값만 읽습니다. 캐시 봉투는 { data: HomeCardData, updatedAt?: string } 모양이고 파싱에 실패하면 조용히 무시됩니다.

캐시를 채우는 것은 플랫폼의 스케줄러입니다. homeCard surface를 선언한 설치마다 10분에 한 번 훅을 부르고 그 값을 캐시에 넣습니다. 그래서 홈을 여는 사람은 훅이 IMAP에 붙든 무엇을 하든 기다리지 않습니다.

  • 훅이 실패하면 이전 캐시를 그대로 둡니다. 한 번 못 불렀다고 카드가 비어 보이지 않습니다.
  • 연속 5회 실패하면 그때 카드가 tone: "error" 상태 카드로 바뀝니다. 사용자에게 무언가 잘못됐다고 알리는 시점입니다.
  • 훅이 돌려준 값이 위 표의 모양과 다르면 실패로 셉니다. type/state가 아니라 kind/tone입니다.
  • 훅은 60초 안에 끝나야 합니다. 넘으면 그 회차는 실패입니다.
  • 아직 설정하지 않은 계정처럼 "오류가 아닌 빈 상태"는 던지지 말고 tone: "idle" 상태 카드로 돌려주세요. 던지면 다섯 번 만에 오류로 바뀝니다.

설정 값을 저장하는 경로

화면이 폼을 제출하면 saveInstallSettings가 normalizeSettingsInput으로 surface 선언에 맞춰 입력을 거릅니다. surface에 없는 키는 무시되고(선언이 곧 계약), required인데 빈 값이면 { ok: false, error: "required", field }를, select에 없는 값을 보내면 { ok: false, error: "invalid", field }를 돌려줍니다. multiInstance 앱은 설치마다 값이 달라야 하므로 kv 키가 settings:<instanceKey> 형태로 갈립니다.