화면(surface)
설정 폼과 홈 카드를 선언형 JSON으로 정의하는 방법. 필드 종류, 값 저장 위치, 홈 카드 캐시.
앱은 화면 코드를 직접 짜지 않습니다. 매니페스트의 surfaces.settings와 surfaces.homeCard가 번들 안 JSON 파일을 가리키면, 플랫폼이 그 선언을 읽어 /assist/apps/[appId] 화면을 그립니다.
두 surface 모두 앱 코드가 직접 부르는 API가 아니라, 매니페스트에 파일 경로를 적어 두는 선언입니다. tiffy-app init이 만드는 surfaces/settings.json, surfaces/home-card.json이 그 자리입니다.
설정(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 키 |
toggle | boolean | 없음 | 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> 형태로 갈립니다.