TutuHai 미니앱은 TutuHai 안에서 실행되는 가벼운 앱입니다. 개발자는 프런트엔드 코드 번들만 업로드하며, 플랫폼이 호스팅합니다. 각종 기능은 window.tt SDK를 통해 TutuHai와 통신하며 자체 백엔드가 필요 없습니다(비즈니스 데이터는 TutuHai 클라우드 데이터를 거칩니다). SDK 통합 · 기능 API · 클라우드 데이터 · 권한 부여 · 배포 — 모든 것이 복사해서 바로 실행할 수 있는 예제와 함께 한 페이지에 있습니다.
목차 · 26개 주제
시작하기
데이터 기능
대화 & 멀티플레이어
파일 & 클라우드 드라이브
앱 간 상호작용
UI · 창 · 호스트
레퍼런스
시작하기
소개
TutuHai 미니앱은 TutuHai 안에서 실행되는 가벼운 앱입니다. 개발자는 프런트엔드 코드 번들만 업로드하며, 플랫폼이 호스팅합니다. 각종 기능은 window.tt SDK를 통해 TutuHai와 통신하며 자체 백엔드가 필요 없습니다(비즈니스 데이터는 TutuHai 클라우드 데이터를 거칩니다).
격리 및 보안: 미니앱은 격리된 오리진의 샌드박스 iframe에서 실행되며, 호스트의 로그인 세션은 절대 미니앱 안으로 들어가지 않습니다. 호출마다 호스트가 수명이 짧고 제한된 토큰을 발급하며, 백엔드는 기능(scope)별로 다시 검증합니다.
빠른 시작
- 미니앱 콘솔(
/applets)에서 "새로 만들기"를 눌러 미니앱을 만듭니다(이름 + 고유 slug). - 단일 파일 HTML을 작성합니다(SDK를 포함하고,
window.tt.*로 기능을 호출). - 버전 생성 → 요청할 기능 입력 → 코드 번들 업로드(단일 파일 HTML).
- 심사 제출 → 관리자 승인 → 원클릭으로 프로덕션에 배포.
- 사용자는 "탐색" 검색으로 찾아 열거나, 개발자가 카드를 공유하거나 링크를 복사해 바로 접근하도록 합니다.
패키징 규격
미니앱은 두 가지 업로드 형식을 지원합니다: ① 단일 파일 HTML 번들(자체 완결형, 진입점 = 루트, 가장 단순); ② 실제 프레임워크 빌드 산출물 zip(npm run build의 dist/, index.html + 여러 파일에 걸친 assets — "프레임워크 빌드 산출물" 참고). 플랫폼은 업로드 전에 규격 검사와 최적화를 수행합니다.
번들 구조(빌드 산출물)
your-applet/ # dev directory (any structure: src, components, assets…)
├─ src/ … # your source (React / Vue / Svelte / vanilla)
└─ dist/index.html # ★build output: single-file HTML (← upload this)
# inlined CSS/JS, or referencing whitelisted CDNs; self-contained, no server
코드 번들은 다음을 충족해야 합니다(업로드 시 자동 검사):
- 진입점:
<!doctype html>과 루트<html>을 가진 단일 HTML 파일. - 모바일 대응: 반드시
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">를 포함해야 합니다. - SDK:
<script src="/applet-sdk.js">를 포함합니다(플랫폼이 호스트의 절대 주소로 재작성합니다). - 자체 완결형: CSS/JS를 인라인화합니다. 외부 스크립트가 필요하면 플랫폼 SDK + 잘 알려진 프레임워크 CDN(unpkg / jsdelivr / cdnjs / esm.sh)만 허용되며, 임의의 원격 스크립트는 금지됩니다(보안). 이미지와 기타 미디어는
tt.uploadImage나 CDN을 거칩니다. - 크기: 단일 파일 HTML ≤ 1MB; 다중 파일 zip은 전체 ≤ 8MB, 파일당 ≤ 1MB, ≤ 100개 파일; 업로드 이미지 ≤ 4MB.
- 데이터: 자체 백엔드 없음 — 비즈니스 데이터는
tt.cloud클라우드 데이터 /tt.*storage를 거칩니다.
📱🖥 모바일 / 데스크톱 공용(하나의 코드베이스, 두 화면 모두)
동일한 번들이 TutuHai 안의 격리된 iframe에서 실행되며, 호스트가 **모바일(전체 화면)**과 데스크톱(패널 / 전체 화면 전환 가능) 양쪽에서 이를 담습니다. 반응형 레이아웃으로 하나의 공용 코드베이스를 작성하세요: ① viewport-fit=cover + 안전 영역 env(safe-area-inset-*); ② 오버레이는 바텀 시트(모바일) ↔ 중앙 정렬(데스크톱, @media(min-width:480px)); ③ 터치 영역 ≥ 44px; ④ 호스트의 다크/라이트 따르기(tt.onThemeChange / [data-theme]); ⑤ 순수 DOM, 하드코딩된 너비 없음. 이렇게 하면 휴대폰과 컴퓨터 양쪽에서 일관된 경험을 유지합니다.
프레임워크 빌드 산출물(dist.zip)
단일 파일 HTML 외에도 실제 프레임워크의 빌드 산출물을 업로드할 수 있습니다 — React / Vue / Svelte / Angular / Solid / Astro / Next(정적 export) / vanilla… 어떤 툴체인의 npm run build든 사용해, dist/(index.html + assets/*.js/css + 폰트/이미지 포함)를 zip으로 묶어 업로드하면 호스팅됩니다.
프레임워크 비종속: 플랫폼은 하나의 "범용 정적 번들 계약"만 인식합니다 — 진입점
index.html+ 상대 경로 asset 참조 + SDK. 이 계약을 충족하는 정적dist를 만들 수 있는 모든 프레임워크는 지원됩니다. 아래의 스캐폴드는 엄선된 지름길일 뿐이며 지원의 한계가 아닙니다.
zip 구조(빌드 산출물, zip 루트 = 번들 루트)
myapp.zip
├─ index.html # ★entry (zip root)
├─ manifest.json # declares slug/name/version/scopes (see below)
└─ assets/
├─ index-*.js # built JS (relative references)
├─ index-*.css
└─ font/img…
3단계 적응(모든 프레임워크에 적용):
- 상대 base 설정(권장, 가장 안전) — 산출물이 asset을 상대 경로(
./assets/x.js, 루트 절대 경로/assets/x.js가 아님)로 참조하게 만들어/<slug>/아래 호스팅이 확실히 동작하게 합니다. (기본 base도 동작합니다: 플랫폼이 HTML/CSS의 루트 절대 정적 참조를 자동 재작성하고, 런타임에 생성되는 루트 절대 asset — 예: 코드 분할 CSS의 preload — 에는 Referer 폴백을 사용합니다. 다만 엄격한Referrer-Policy/ 오프라인 prefetch 같은 엣지 케이스에서는 Referer가 없어 폴백이 실패할 수 있으므로 상대 base가 가장 안전합니다.) - manifest 추가 —
manifest.json을 정적 디렉터리(예: Vite/SvelteKit의static/, 대부분 프레임워크의public/)에 두어 빌드 후dist루트에 오도록 합니다. 또는 manifest를 생략하고index.html에<meta name="tt:slug" content="…">(및tt:name / tt:version / tt:scopes)를 폴백으로 추가합니다. - SDK 포함 — 두 가지 방법: ① npm install(권장, 실제 스캐폴드에 최적):
npm i @tutuhai/applet-sdk후import { tt } from '@tutuhai/applet-sdk'— 빌드 시 산출물에 번들되며 TypeScript 타입 제공,index.html수정 불필요; ② 또는index.html에<script src="/applet-sdk.js">를 작성(플랫폼이 호스트의 절대 주소로 재작성)하고 전역window.tt를 사용.
📦 npm SDK(React / Vue / Svelte 공식 스캐폴드로 테스트됨)
npm create vite@latest -- --template react-ts | vue-ts | svelte-ts로 프로젝트를 만들고, 동일한 @tutuhai/applet-sdk를 설치한 뒤 import하세요. 실제 다중 파일 소스이며 npm run build가 여러 청크 + 하나의 진입점 index.html을 생성합니다. 이를 zip으로 묶어 업로드하세요.
# 1) Create a project with an official scaffold
npm create vite@latest my-applet -- --template react-ts # or vue-ts / svelte-ts
# 2) Install the SDK (same package for all three frameworks)
npm i @tutuhai/applet-sdk
# 3) import and use in your source (typed)
# src/App.tsx / App.vue / App.svelte
import { tt } from '@tutuhai/applet-sdk';
tt.ready((ctx) => {
tt.getProfile().then((me) => console.log('hi', me?.nickname));
});
await tt.cloud.add('notes', { text: 'hello' }); // cloud data, no backend of your own
# 4) vite.config: relative base; public/manifest.json declares slug/name/scopes
# export default { base: './', plugins: [react()] }
# 5) official build → zip dist → upload
npm run build && cd dist && zip -r ../my-applet.zip .
저장소의 실행 가능한 예제: applets/frameworks/{react,vue,svelte}(세 개의 실제 공식 스캐폴드 프로젝트로, 모두 동일한 SDK 패키지를 import합니다). SDK 패키지 소스: applet-sdk/.
manifest.json 필드
{
"slug": "myapp", // ★required, globally unique, determines hosting path /myapp/
"name": "My Mini-App", // ★required, display name
"version": "1.0.0", // ★required, must increment on every upload
"scopes": ["user.profile"], // requested capabilities (see "Authorization model")
"description": "One-line summary", // optional, shown on discover/detail
"icon": "icon.png", // optional, relative path in the bundle (or change it in the console after upload)
"display": "fullscreen", // optional, default open mode: window (floating, default) | fullscreen
"fileHandlers": [ // optional, declares "Open with" — which file kinds you can handle from a chat
{ "kinds": ["image"], "role": "editor", "label": "TutuEdit · Retouch" }
]
}
- display: 데스크톱 열기 형식을 선언합니다 — 캔버스/화이트보드/에디터 앱은
fullscreen을 선호하고, 가벼운 카드/폼은 기본값window를 사용합니다. 이는 초기값일 뿐입니다. 배포 후에는 콘솔에서 언제든 "열기 모드"를 변경할 수 있습니다(콘솔이 우선합니다). 모바일은 항상 전체 화면이며 이 필드의 영향을 받지 않습니다. manifest를 생략한 경우<meta name="tt:display" content="fullscreen">이 폴백으로 동작합니다. - fileHandlers: 미니앱이 채팅에서 어떤 종류의 파일을 처리할 수 있는지 선언합니다 — 사용자가 채팅의 파일에서 "연결 프로그램"을 탭하면, 일치하는 유형을 선언한 미니앱이 후보로 나타납니다. 하나를 탭하면 그 파일이 곧장 미니앱으로 전달됩니다("채팅 파일 처리" 참고). 각 항목:
kinds는image / video / audio / pdf / office / text / any중 하나(복수 허용,any= 모든 파일);role은editor(에디터에서 열기) 또는viewer(미리보기);label은 선택(≤20자, 후보의 표시 이름). 최대 8개 항목. 게이팅은 가시성과 일치합니다:private/self-use는 심사 없이 동작(본인의 "연결 프로그램"에만 표시);public은 관리자 승인이 있어야 모두에게 적용됩니다.
프레임워크별 한 줄 "상대 base" 설정:
// Vite (React/Vue/Svelte/Solid/Preact/Lit…)
export default { base: './' }
// SvelteKit (client-side routing → base must = slug) — static export + base set to your slug (else routes 404)
import adapter from '@sveltejs/adapter-static';
export default { kit: {
adapter: adapter({ fallback: 'index.html' }),
paths: { base: '/your-slug', relative: true }
} };
// Astro — astro.config.mjs
export default { base: './', build: { assets: 'assets' } }
# Angular — set a relative base href at build time
ng build --base-href ./ --output-path dist
// Next.js (static export) — next.config.js
module.exports = { output: 'export', images: { unoptimized: true }, assetPrefix: './' }
// Nuxt 3 (static) — nuxt.config.ts
export default defineNuxtConfig({ app: { baseURL: './', cdnURL: './' }, ssr: false })
// Vue CLI / webpack — vue.config.js (or webpack output.publicPath)
module.exports = { publicPath: './' }
<!-- Vanilla / no build: just use relative paths -->
<script src="./app.js"></script>
<link rel="stylesheet" href="./style.css">
⚠ 클라이언트 사이드 라우팅 SPA(SvelteKit / React Router / Vue Router / Angular) 미니앱은
/<slug>/하위 경로 아래에서 호스팅됩니다. 클라이언트 사이드 라우팅 SPA는 반드시 "라우터 base"를 slug로 설정해야 합니다. 그렇지 않으면 프레임워크의 라우터가 현재 경로를 매칭하지 못해 → 전체 페이지 404가 됩니다(asset은 로드되지만 라우팅이 찾을 수 없음으로 보고). 상대 asset base만 설정하는 것으로는 충분하지 않습니다 — 그것은 asset URL만 고칠 뿐 라우팅은 고치지 못합니다. 프레임워크별: SvelteKitkit.paths.base='/<slug>'; React Router<BrowserRouter basename="/<slug>">; Vue RoutercreateWebHistory('/<slug>/'); AngularAPP_BASE_HREF='/<slug>/'. (클라이언트 사이드 라우팅이 없는 앱 — 순수 렌더링 / 라우터 없는 단일 페이지 React / vanilla — 은 영향을 받지 않습니다.)
⚠ 업로드 점검 업로드 시 플랫폼은 dist에 대해 "계약 점검"을 수행합니다: 진입점 / 상대 경로 / manifest / SDK 참조 / 제한 / MIME 각각을 인라인 힌트와 함께 검증합니다. 전체 ≤ 8MB, 파일당 ≤ 1MB, ≤ 100개 파일, 화이트리스트 MIME만(html/css/js/json/이미지/폰트/map/wasm). 폰트를 압축하고 서브셋 처리해 크기를 낮추세요. 기본 파일당/전체 제한을 초과하는 무거운 프레임워크 산출물(예: 단일 청크가 >1MB인 tldraw / excalidraw)의 경우, 운영팀에 관리자 패널에서 "파일당 바이트" / "압축 해제 전체" 제한을 올려달라고 요청하세요(런타임에 변경 가능, 즉시 적용).
manualChunks를 분할하면 vendor를 제한 아래로 낮출 수도 있습니다.
최소 예제
완전하고 실행 가능한 미니앱 — SDK를 포함하고 사용자의 닉네임을 읽습니다:
<!doctype html>
<html>
<body>
<div id="who">Loading…</div>
<!-- Relative path — maintenance-free: survives domain changes/blocks (platform rewrites to the host's absolute URL) -->
<script src="/applet-sdk.js"></script>
<script>
window.tt.ready(function () {
window.tt.getProfile().then(function (me) {
document.getElementById('who').textContent = 'Hi, ' + me.nickname;
});
});
</script>
</body>
</html>
SDK 통합
미니앱 HTML에 SDK 스크립트를 포함한 다음 window.tt를 사용하세요:
<!-- Include the SDK in your mini-app HTML. Use a relative path — don't hardcode a domain -->
<script src="/applet-sdk.js"></script>
호스트 도메인을 미니앱에 하드코딩하지 마세요. 상대 경로
/applet-sdk.js(또는 임의의 자리표시자 오리진)를 작성하세요 — 앱이 iframe에서 실행될 때 플랫폼이 서빙 시점에 SDK 스크립트 URL을 현재 호스트로 재작성합니다. 그래서 TutuHai가 도메인을 바꾸거나 도메인이 차단되어도 배포된 모든 미니앱은 코드 변경이나 재배포 없이 계속 동작합니다 — 운영자가 설정값 하나만 바꾸면 됩니다.
Ready 콜백, 컨텍스트, 테마/로케일:
// After ready you get the context (appId / granted scopes / deep-link path / query / theme / locale / whether inline)
window.tt.ready(function (ctx) {
console.log(ctx.appId, ctx.scopes, ctx.path, ctx.query, ctx.theme, ctx.colorScheme, ctx.locale, ctx.inline);
});
window.tt.context(); // get the current context snapshot anytime (same as ready's ctx)
// Theme switching (fires live when the host toggles light/dark)
window.tt.onThemeChange(function (theme) {
document.documentElement.setAttribute('data-theme', theme);
});
// Locale switching (synced with the host's i18n; fires live when the host changes language — same mechanism as theme)
window.tt.onLocaleChange(function (locale) { // e.g. 'zh-CN' / 'en-US'
document.documentElement.setAttribute('lang', locale); // the SDK sets it already; you can also localize your own copy
});
프레임워크 예제
window.tt는 프레임워크 비종속이며 모든 주류 프레임워크에서 바로 동작합니다(단일 파일, 빌드 불필요). 각 예제는 성공 ✅과 실패 ❌(권한 거부 / 네트워크 오류) 처리를 모두 포함합니다:
Vanilla JS
// No framework — vanilla DOM
window.tt.ready(function () {
window.tt.getProfile()
.then(function (me) { // ✅ success
document.getElementById('who').textContent = 'Hi, ' + me.nickname;
})
.catch(function (err) { // ❌ failure (user denied authorization / network error)
document.getElementById('who').textContent = 'Failed: ' + err.message;
});
});
React
// React 18 + htm (no build)
const { useState, useEffect } = React;
function App() {
const [me, setMe] = useState(null);
const [err, setErr] = useState('');
useEffect(() => {
window.tt.ready(() =>
window.tt.getProfile().then(setMe).catch((e) => setErr(e.message))
);
}, []);
if (err) return html`<div>Failed: ${err}</div>`; // ❌
return html`<div>Hi ${me ? me.nickname : '…'}</div>`; // ✅
}
Preact
// Preact + htm (no build)
const { useState, useEffect } = preactHooks;
function App() {
const [me, setMe] = useState(null), [err, setErr] = useState('');
useEffect(() => {
window.tt.ready(() =>
window.tt.getProfile().then(setMe).catch((e) => setErr(e.message))
);
}, []);
return html`<div>${err ? 'Failed: ' + err : 'Hi ' + (me ? me.nickname : '…')}</div>`;
}
Vue 3
// Vue 3 (CDN)
const { createApp, ref, onMounted } = Vue;
createApp({
setup() {
const me = ref(null), err = ref('');
onMounted(() => window.tt.ready(() =>
window.tt.getProfile()
.then((p) => (me.value = p)) // ✅
.catch((e) => (err.value = e.message)) // ❌
));
return { me, err };
},
template: `<div>{{ err ? 'Failed: ' + err : 'Hi ' + (me?.nickname ?? '…') }}</div>`
}).mount('#app');
Svelte
// Svelte (runtime compile)
let me = $state(null), err = $state('');
window.tt.ready(() =>
window.tt.getProfile()
.then((p) => (me = p)) // ✅
.catch((e) => (err = e.message)) // ❌
);
// template: <div>{err ? 'Failed: ' + err : 'Hi ' + (me?.nickname ?? '…')}</div>
Solid
// SolidJS
import { createSignal, onMount } from 'solid-js';
function App() {
const [me, setMe] = createSignal(null), [err, setErr] = createSignal('');
onMount(() => window.tt.ready(() =>
window.tt.getProfile().then(setMe).catch((e) => setErr(e.message))
));
return <div>{err() ? 'Failed: ' + err() : 'Hi ' + (me()?.nickname ?? '…')}</div>;
}
Alpine.js
<!-- Alpine.js: declarative in HTML, zero build -->
<div x-data="{ me: null, err: '' }"
x-init="window.tt.ready(() =>
window.tt.getProfile()
.then(p => me = p) /* ✅ */
.catch(e => err = e.message))"> <!-- ❌ -->
<span x-text="err ? 'Failed: ' + err : 'Hi ' + (me?.nickname ?? '…')"></span>
</div>
Lit
// Lit (Web Components)
import { LitElement, html } from 'lit';
class MyApp extends LitElement {
static properties = { me: {}, err: {} };
connectedCallback() {
super.connectedCallback();
window.tt.ready(() =>
window.tt.getProfile()
.then((p) => (this.me = p)) // ✅
.catch((e) => (this.err = e.message)) // ❌
);
}
render() {
return html`<div>${this.err ? 'Failed: ' + this.err : 'Hi ' + (this.me?.nickname ?? '…')}</div>`;
}
}
customElements.define('my-app', MyApp);
jQuery
// jQuery
$(function () {
window.tt.ready(function () {
window.tt.getProfile()
.then(function (me) { $('#who').text('Hi, ' + me.nickname); }) // ✅
.catch(function (err) { $('#who').text('Failed: ' + err.message); }); // ❌
});
});
Angular
// Angular (component)
@Component({ selector: 'app-root', template: `<div>{{ msg }}</div>` })
export class AppComponent implements OnInit {
msg = 'Loading…';
ngOnInit() {
const tt = (window as any).tt;
tt.ready(() =>
tt.getProfile()
.then((me: any) => (this.msg = 'Hi, ' + me.nickname)) // ✅
.catch((e: any) => (this.msg = 'Failed: ' + e.message)) // ❌
);
}
}
저장소에 완전히 실행 가능한 예제가 있습니다: applet-platform/samples/demo-react.html, demo-svelte.html, demo-vue.html.
데이터 기능
사용자 프로필 · user.profile
// Get the current user's profile (needs user.profile; on first call the host prompts for authorization as needed)
try {
const me = await window.tt.getProfile(); // ✅ success
console.log(me.userId, me.nickname, me.avatarUrl);
} catch (err) { // ❌ failure
// err.message: "User denied authorization" (tapped deny) / network error
console.warn('Failed to get profile:', err.message);
}
// Profile changes (you or someone in the room changed nickname/avatar) → re-fetch and refresh display
window.tt.onProfileChange(() => refreshWhoUI());
클라우드 데이터 · cloud.data
미니앱이 자체 백엔드 없이 비즈니스 데이터를 영속화할 수 있게 해주는 호스팅형 구조화 컬렉션. 세 가지 가시성 계층:
mine: 본인의 문서만 읽기/쓰기(기본값).all: 모든 것을 읽기/쓰기, 미니앱 개발자(소유자)만 — "판매자 콘솔"이 모든 주문/티켓을 볼 때 사용.public: 로그인한 모든 사용자가 모든 것을 읽을 수 있음, 컬렉션 이름이 반드시pub_로 시작해야 함 — 커뮤니티/마켓플레이스/포럼용; 쓰기와 편집은 여전히 작성자로 제한됨.
// Cloud data: no backend of your own, business data hosted by the TutuHai platform. All calls return a Promise — always handle failure.
try {
// Create a document (owned by the current user)
const { id } = await window.tt.cloud.add('orders', { items: cart, total: 68, status: 'pending' });
// Idempotent upsert: create or update by docKey (most common for "one vote per person" / one record per user, avoids fetch-id-then-update)
await window.tt.cloud.put('votes', me.userId, { choice: 'A' });
// My documents
const mine = await window.tt.cloud.list('orders', { scope: 'mine' });
// Read one (by id; returns null if not found)
const doc = await window.tt.cloud.get('orders', id);
// All documents (developer/owner only, for the merchant console; regular users → 403)
const all = await window.tt.cloud.list('orders', { scope: 'all' });
// Public collection: name starts with pub_ → any logged-in user can read everything (community/marketplace)
const posts = await window.tt.cloud.list('pub_posts', { scope: 'public' });
// where equality filter (server filters on a single data field; on large collections it narrows by parent key to avoid child docs being cut off by the 200 cap)
const votes = await window.tt.cloud.list('pub_votes', { scope: 'public', where: { pollId: id } });
// Beyond 200 rows: listPage cursor pagination (mine/all; can take where), returns { docs, nextCursor }
const pg = await window.tt.cloud.listPage('orders', { scope: 'mine', limit: 100, before: cursor });
// Field-level update (owner or developer; patch merges with the original data)
await window.tt.cloud.update('orders', id, { status: 'done' });
// Delete a document (owner or developer; idempotent) — completes CRUD, no need to pile up soft-delete flags
await window.tt.cloud.delete('orders', id);
} catch (err) { // ❌ failure
// Insufficient permission (403) / using public on a non-pub_ collection (400) / quota exceeded / network
console.warn('Cloud data error:', err.message);
}
각 행은 { id, ownerId, mine, data:{…your fields}, createdAt, updatedAt } 형태로 읽혀집니다 — 여러분의 필드는 모두 data 안에 있습니다(예: row.data.title).
KV 저장소 · storage.kv
(미니앱, 사용자)별로 격리된 키-값 저장소로, 체크인 횟수, 임시 저장 같은 작은 비공개 상태에 적합합니다. tt.cloudStorage(setItem/getItem/getKeys/removeItem)는 Telegram 스타일의 별칭입니다.
// Hosted KV (needs storage.kv): isolated per (mini-app, user), stores private state
try {
await window.tt.setStorage('count', 3);
const n = await window.tt.getStorage('count'); // 3 (returns null if absent)
await window.tt.removeStorage('count');
const keys = await window.tt.getStorageKeys();
} catch (err) { // ❌ quota (≤64 keys / 8KB) / network
console.warn('Storage failed:', err.message);
}
// Telegram-style alias (same as above, needs storage.kv):
await window.tt.cloudStorage.setItem('draft', 'unsent content');
const draft = await window.tt.cloudStorage.getItem('draft'); // returns null if absent
const ks = await window.tt.cloudStorage.getKeys();
await window.tt.cloudStorage.removeItem('draft');
대화 & 멀티플레이어
대화 기능 · im.share / im.send / im.read / media.upload
TutuHai 대화와의 모든 상호작용은 호스트가 중개합니다(사용자가 능동적으로 대화를 선택). 미니앱은 전체 대화 목록을 얻을 수 없습니다. im.read는 민감 기능입니다.
// Conversation capabilities are all host-mediated (the user actively picks a conversation); always handle "user cancelled" and failure.
try {
// Share this mini-app's card to a conversation (needs im.share; if no conversation is passed the host shows a picker)
await window.tt.shareToChat({ title: 'Come vote for lunch 🍜' });
// Send a text notification to a conversation (needs im.send; the host shows a picker + preview, signed "via the X mini-app")
await window.tt.sendMessage('Vote result: Lanzhou beef noodles win');
// Read conversation messages (needs im.read, sensitive; the user picks a conversation each time, non-text is redacted)
const r = await window.tt.readMessages({ limit: 30 });
// Upload an image (needs media.upload; pass a dataURL, returns an absolute URL)
const url = await window.tt.uploadImage(dataUrl);
} catch (err) { // ❌ failure
// "User cancelled" (picker/preview cancelled) / authorization denied / "Call timed out" / network
console.warn('Capability call failed:', err.message);
}
멀티플레이어 룸 · im.room
"대화"를 미니게임 / 협업을 위한 실시간 룸으로 전환합니다: 룸 생성/참가, 룸 내 영속 메시지와 실시간 시그널(상태 동기화, ≤2KB, 일시적, 영속되지 않음). 호스트가 여러분 대신 실시간 프레임을 중개하며, 호스트 JWT는 절대 미니앱 안으로 들어가지 않습니다. 이 미니앱이 만든 룸 / 여러분이 공유받은 대화로 제한되며, 사용자의 다른 개인 채팅에는 접근할 수 없습니다.
// Multiplayer rooms (needs im.room): create/join/leave + in-room persistent messages + realtime signals (≤2KB, ephemeral, not persisted).
const { conversationId } = await window.tt.room.create({ title: 'Gomoku match' }); // create room, host auto-subscribes
await window.tt.room.join(conversationId); // idempotent join; host auto-subscribes to realtime frames
await window.tt.room.subscribe(conversationId); // subscribe to an existing conversation's realtime frames (e.g. a group you were shared into)
await window.tt.room.send(conversationId, 'Game on!'); // persistent text (visible even without opening the mini-app)
window.tt.room.signal(conversationId, { type:'move', cell:4 }); // send a realtime signal (state sync)
window.tt.room.setTyping(conversationId, true); // typing state (transient)
const members = await window.tt.room.members(conversationId); // roster [{userId,nickname,avatarUrl,online,isOwner}]
const past = await window.tt.room.history(conversationId, { limit: 50 }); // hydrate on reconnect (ascending)
await window.tt.room.leave(conversationId); // leave (empty rooms are auto-reclaimed)
// Realtime events (all under tt.room):
window.tt.room.onMessage((m) => appendMsg(m)); // new message {conversationId,id,senderId,senderName,kind,text,createdAt}
window.tt.room.onSignal((s) => applyMove(s.payload));// opponent's realtime action {conversationId,senderId,payload}
window.tt.room.onPresence((p) => refreshOnline(p)); // online/offline {userId,online}
window.tt.room.onTyping((t) => showTyping(t)); // typing {conversationId,userId,typing}
window.tt.room.onMember(() => reloadMembers()); // member joined/left {conversationId} → re-fetch members()
window.tt.room.onReconnect(() => rehydrate()); // dropped & reconnected → re-hydrate current state from history()/cloud
재연결 하이드레이션: 시그널은 최선 노력(best-effort)이며 연결 끊김 시 프레임 손실은 정상입니다.
onReconnect에서는room.history()나 클라우드 데이터로부터 최종 상태를 다시 하이드레이션하세요 — 시그널을 단일 진실 공급원으로 의존하지 마세요.
인라인 컴포넌트 · 네이티브 컴포넌트 유사 · 프라이버시 안전
shareToChat({inline:true})로 보낸 카드는 채팅 버블 안에서 곧바로 인터랙티브 컴포넌트를 렌더링합니다(예: 투표, 평점). 받는 사람은 창을 열지 않고도 네이티브 기능처럼 조작합니다. 미니앱은 ctx.inline을 기반으로 컴팩트 UI를 렌더링하며, 버블은 콘텐츠에 맞춰 자동 크기 조정되고 호스트의 라이트/다크에 맞춰 실시간으로 테마가 바뀝니다. 프라이버시: 인라인 인스턴스는 "cloud.data만, 부작용 없음"의 제한된 토큰만 받습니다 — 공개 컬렉션을 읽고 자신의 문서를 쓸 수 있으며, 타인의 개인 데이터에는 접근할 수 없고, 권한 부여를 요청하지 않습니다.
// —— Inline components: make a mini-app interact right in the chat bubble like a native feature (polls/ratings/relays…) ——
// 1) Send an inline card to a conversation (needs im.share): recipients operate it in the bubble without opening the mini-app
await window.tt.shareToChat({ inline: true, query: { pollId }, title: 'Poll', height: 200 });
// 2) The mini-app renders two forms based on ctx.inline
window.tt.ready((ctx) => {
if (ctx.inline) {
renderCompact(ctx.query.pollId); // inline: a compact "native-component-like" UI
window.tt.reportHeight(); // report height (the SDK also auto-reports via ResizeObserver; the bubble auto-sizes to content)
// When full functionality is needed, open the full page (floating/fullscreen) from the inline card:
// openBtn.onclick = () => window.tt.openFullPage('/detail?pollId=' + ctx.query.pollId);
} else {
renderFull(); // floating window: full creation UI
}
});
// Privacy: an inline instance gets only a "cloud.data only, no side effects" restricted token — it can read public collections + write its own documents,
// can't touch others' private data; doesn't prompt for authorization or pollute the authorization list. Sensitive capabilities (upload/send/location) are unavailable inline.
// Dark/mobile: inline cards switch light/dark live with the host and auto-fit width — no extra work for the developer.
단어 조회 / 번역 · text.lookup / text.provider
단어 조회 팝오버는 그 자체가 "provider 미니앱"의 인라인 페이지입니다 — 그 콘텐츠/기능은 모두 해당 미니앱이 렌더링하며, 호스트는 선택 영역 + 호버 위치 지정 + 유연한 SDK만 제공합니다. 소비자(자신의 미니앱 안 텍스트를 선택 가능하게 만들기): text.lookup을 선언하면 코드 없이, 텍스트를 선택하면 선택 영역 바로 아래에 provider의 인라인 페이지가 뜹니다(채팅 메시지 텍스트도 지원). 제공자(조회 미니앱 만들기): text.provider를 선언(심사 후 부여)하면 인라인 페이지가 tt.text.onLookup으로 단어를 받아 스스로 렌더링합니다. 어떤 provider가 활성화되는지는 관리자 패널에서 설정합니다 — 아무것도 설정/승인되지 않으면 조회가 비활성화됩니다. 특정 영역을 제외하려면 data-tt-no-lookup을 추가하세요.
// ── A. Make "text inside your mini-app" selectable for lookup (consumer, needs text.lookup) ──【zero code】
// After declaring "text.lookup" in the manifest, when a user selects text in your mini-app, a popover
// 【the provider mini-app's inline page】pops up right below the selection (auto dictionary/translation). Chat message text is also supported (host built-in).
// Tap blank/scroll/Esc to hide; operating inside the popover doesn't close it. No JS needed.
// Opt out: add data-tt-no-lookup to regions you don't want selectable; <input type=password> is auto-excluded.
// ── B. Build a "word-lookup provider mini-app" (provider, needs text.provider, granted after review) ──
// The provider page (inline mode) receives the selected text pushed by the host via onLookup (pushed initially + on every new word, stays resident without reload),
// and looks up/translates/renders it into any UI; you can use tt.text.lookup to call the built-in engine, or your own cloud.data dictionary.
tt.ready(function () {
tt.text.onLookup(function (text) { // host pushes the selected text
tt.text.lookup(text).then(function (r) { // r={kind:'dict'|'translate',...}
render(r); // render into your own UI (auto-height)
});
});
});
// ── Escape hatch / flexible SDK primitives (available to any mini-app) ──
const r = await window.tt.text.lookup('lazy', { to:'en' }); // look up / translate on demand
window.tt.text.onSelect(function (sel){ /* {text, rect}; registering takes over, the default popover steps aside */ });
window.tt.openFloating({ path:'/detail', anchor: sel.rect, width:320, height:220 }); // open a floating window at the selection
window.tt.floating.moveTo(100, 200); // the floating window's position/size are fully adjustable via the SDK: setRect/moveTo/resize/close
파일 & 클라우드 드라이브
채팅 파일 처리 · media.upload / im.share
사용자가 채팅의 이미지/파일에서 "연결 프로그램"을 탭하면, manifest.json의 fileHandlers(image/video/audio/pdf/office/text/any)에 일치하는 파일 유형을 선언한 경우 여러분의 미니앱을 선택해 이를 처리할 수 있습니다. 일단 열리면: getContextFile()로 파일을 얻고, readFile()로 호스트를 통해 동일 오리진에서 바이트를 가져오며(CORS 회피, 이미지/오디오/비디오/모든 형식 분석 가능), 처리 후 uploadFile() + sendFileToChat()로 대화에 다시 보내거나 saveFile()로 다운로드합니다 — 매끄러운 흐름입니다.
// —— Handling chat files: the user taps "Open with" on a file and picks your mini-app (must declare matching kinds in manifest.fileHandlers) ——
const f = window.tt.getContextFile();
// f = {url,name,mime,size,kind:'image'|'file',conversationId,messageId} or null (when opened standalone)
if (f) {
const bytes = await window.tt.readFile(f.url); // host fetches bytes same-origin (avoids iframe CORS; limited to this site's /uploads output)
// bytes = {dataUrl, mime, name, size, url} — feed to <img>/<video>/<audio>/canvas to analyze any format
imgEl.src = bytes.dataUrl;
}
// —— Process the output → send back to the conversation or download (needs media.upload / im.share) ——
const out = canvas.toDataURL('image/webp', 0.9); // e.g. convert image to WebP
const up = await window.tt.uploadFile({ dataUrl: out, name: 'result.webp' }); // {url,name,size,mime} (≤20MB)
await window.tt.sendFileToChat({
url: up.url, name: up.name, mime: up.mime, size: up.size,
conversationId: f.conversationId // pass it to send straight to the original conversation (skip the picker); omit it and the host shows a conversation picker
});
await window.tt.saveFile({ dataUrl: out, name: 'result.webp' }); // or: download locally (host downloads on your behalf)
Tutu 드라이브 · tt.drive(media.upload 필요)
Tutu 드라이브에서 파일을 골라오거나, 산출물을 드라이브에 저장합니다(호스트 중개: 사용자가 호스트 페이지 안의 드라이브 선택기에서 파일을 하나씩 고르며, 미니앱은 드라이브 토큰을 보유하지 않습니다). 드라이브에 연결할 수 없거나 Tutu ID가 페더레이션되지 않은 경우 거부합니다 — .catch 후 미니앱은 로컬 uploadFile로 폴백할 수 있습니다.
// Pick files from the drive (the user picks in the host's drive picker; returns [] on cancel):
const picked = await window.tt.drive.pick({ multiple: true, accept: 'image/*' });
// picked = [{ nodeId, name, size, mime, downloadUrl }]
for (const file of picked) {
const bytes = await window.tt.readFile(file.downloadUrl); // fetch bytes to analyze/display
render(bytes.dataUrl);
}
// Save a file into the drive (pass this site's /uploads output absolutized as pullUrl, or a dataUrl directly):
const saved = await window.tt.drive.save({ pullUrl: up.url, name: 'export-result.png' });
// saved = { nodeId, name }
앱 간 상호작용
앱 간 상호작용 · 앱 간 드래그 · tt.link / tt.tray / tt.drag / tt.drop(권한 부여 불필요)
여러 미니앱을 동시에 열 수 있으며(원클릭으로 함께 여는 콤보로 저장, 멀티 윈도우 2/3/4 레이아웃, 도킹된 사이드바 — 모두 호스트가 관리하며 코드 불필요), 다음 기능을 통해 상호작용합니다:
tt.link: 열려 있는 다른 미니앱과 이벤트/상태를 실시간 동기화(브로드캐스트 또는 대상 지정, ≤2KB, 속도 제한 25/s; 창 닫힘 시 소멸, 영속되지 않음, 사용자/대화 간 아님).tt.tray: 콘텐츠를 호스트 트레이로 집어 올려 중계한 뒤, 다른 미니앱이나 대화에 주입.tt.drag.start/tt.drag.bind: 드래그 시작 / 요소를 다른 미니앱으로 곧장 드래그할 수 있는 드래그 소스로 바인딩(동일 오리진 미니앱 간 네이티브 HTML5 드래그 앤 드롭).tt.drop.accept: 미니앱 전체가 드롭 / 트레이 주입을 받을 수 있음.tt.drop.zone: ★특정 내부 요소가 드롭을 감지(호버 하이라이트 피드백 포함)하고 이에 작동하게 함. 하나의 미니앱은 여러 zone을 가질 수 있으며, 각각 독립적으로 감지.
보안: file로 감싼 url은 이 사이트의 /uploads/ 핸들만 허용하며(수신자는 tt.readFile로 바이트를 가져옴), 위조된 크로스 오리진 외부 링크는 폐기되고, text/json/name에는 모두 크기 상한이 있습니다.
// —— ① Event/state sync tt.link (broadcast in realtime with "other open mini-apps"; drops on window close, not persisted, not cross-user/conversation) ——
tt.link.send({ type: 'color', color: '#ef4444' }); // broadcast to all linked mini-apps (≤2KB, rate-limited 25/s)
tt.link.sendTo(appId, { type: 'ping' }); // send to a specific peer
tt.link.on((from, msg) => { /* from={appId,slug,name} */ apply(msg); });
const peers = await tt.link.peers(); // the other linked mini-apps right now [{appId,slug,name}]
// —— ② Tray relay tt.tray (pick up → drop into another mini-app / conversation) ——
await tt.tray.put({ kind:'json', name:'color', data:{ color:'#ef4444' } }); // put into the host tray
const items = await tt.tray.list(); // view the tray [parcel]
const p = await tt.tray.take(id); // take a parcel out
// parcel = { kind:'file'|'text'|'json', url?/text?/data?, name?, mime? }
// —— ③ Direct drag between apps 【drag source】tt.drag ——
tt.drag.start({ kind:'json', name:'color', data:{ color:'#ef4444' } }); // start a drag, returns {id}
tt.drag.bind(swatchEl, () => ({ kind:'json', name:'color', data:{ type:'color', color:'#ef4444' } }));
// getParcel() returns this drag's parcel; return null to not start. Between same-origin mini-apps it uses native HTML5 drag-and-drop with the browser's built-in ghost.
// —— ④ Receive 【whole window】tt.drop.accept ——
tt.drop.accept(['json','file'], (parcel) => { apply(parcel); }); // callback on drop anywhere in this window / tray injection
// —— ⑤ ★Receive 【element-level】tt.drop.zone ——
tt.drop.zone(slotEl, ['json','file'], {
onEnter: () => slotEl.classList.add('hot'), // drag into this element → only it highlights (internal elements sense independently)
onOver: () => {}, // while hovering (do continuous feedback)
onLeave: () => slotEl.classList.remove('hot'), // move out → clear highlight
onDrop: (parcel) => fill(slotEl, parcel), // dropped on this element → only it receives and acts
});
UI · 창 · 호스트
UI · 창 · 디바이스(권한 부여 불필요)
WeChat wx.* 유사 UI 기능 — 호스트가 실제 토스트/다이얼로그/이미지 뷰어를 렌더링하고, 미니앱의 캡슐 창을 제어하며, 클립보드/진동/전화 걸기/위치/네트워크에 접근합니다 — 권한 부여 요청 없이 호출 가능하여, 미니앱을 네이티브 앱만큼 강력하게 만듭니다.
// —— Interaction feedback ——
window.tt.showToast({ title: 'Saved', icon: 'success' }); // icon: success|error|loading|none
window.tt.hideToast();
window.tt.showLoading({ title: 'Processing…' }); // pair with window.tt.hideLoading()
window.tt.hideLoading();
const { confirm } = await window.tt.showModal({ title: 'Confirm', content: 'Delete this?' });
const { tapIndex } = await window.tt.showActionSheet({ itemList: ['Camera', 'Album'] }); // rejects on cancel
// —— Window / system info ——
window.tt.setNavigationBarTitle({ title: 'My page' }); // change the mini-app capsule title
const info = await window.tt.getSystemInfo(); // {theme, platform, windowWidth, windowHeight, safeAreaInsets, appName, version}
// —— Device ——
await window.tt.setClipboardData({ data: 'copied text' });
const { data } = await window.tt.getClipboardData();
window.tt.vibrateShort(); window.tt.vibrateLong(); // haptic feedback
window.tt.makePhoneCall({ phoneNumber: '10086' });
// —— Media ——
window.tt.previewImage({ urls: [url1, url2], current: url1 }); // fullscreen image preview
// —— Location / external link / network ——
const loc = await window.tt.getLocation(); // browser prompts for permission → {latitude, longitude, accuracy, speed}
window.tt.openLocation({ latitude: loc.latitude, longitude: loc.longitude, name: 'Store' }); // view on a map
window.tt.openLink({ url: 'https://example.com' }); // open in a new tab (http/https only)
const net = await window.tt.getNetworkType(); // {isConnected, networkType: wifi|4g|...}
플로팅 창 · 전체 화면 · 브랜딩
미니앱 창 제어: 인라인/선택 영역에서 드래그 가능한 플로팅 창 열기, 전체 화면으로 확장 / 복원, 호스트 캡슐 색조 지정, 표시/크기 변경 수신 대기.
// —— Floating window (open a draggable floating window from inline / a selection) ——
window.tt.openFloating({ path:'/detail', anchor: rect, x:100, y:120, width:320, height:220 });
window.tt.floating.setRect({ x, y, width, height }); // also moveTo(x,y) / resize(w,h) / close()
window.tt.openFullPage('/detail'); // open the full page from an inline card (floating/fullscreen)
// —— Fullscreen / restore / close (desktop; mobile is already fullscreen) ——
window.tt.expand(); window.tt.collapse(); window.tt.close();
const dm = await window.tt.getDisplayMode(); // {maximized, mobile} — whether fullscreen / whether mobile
window.tt.onEvent('displayChanged', (p) => updateFullscreenChip(p.maximized)); // two-way sync with the host capsule's "fullscreen/restore"
window.tt.onEvent('viewportChanged', (p) => relayout(p.width, p.height)); // iframe size change → responsive re-layout
// —— Branding: tint the host capsule header/background ——
window.tt.setHeaderColor('#4f46e5');
window.tt.setBackgroundColor('#fdf6e3');
호스트 버튼 · 햅틱 · Telegram 스타일(권한 부여 불필요)
미니앱은 호스트 크롬의 버튼을 제어하고 그 클릭 이벤트를 받습니다(양방향) — 하단 메인 버튼 mainButton, 헤더 뒤로 가기 버튼 backButton, 햅틱 hapticFeedback. 이를 통해 미니앱이 (고립된 페이지가 아니라) 호스트 UI와 깊이 통합됩니다.
// MainButton (the host's big bottom button, controlled by the mini-app + receives clicks) — Telegram-style
window.tt.mainButton.setText('Submit order').show(); // chainable; setText/setParams/show/hide/enable/disable/showProgress/hideProgress
window.tt.mainButton.onClick(() => { // click callback (host → mini-app event); offClick to unbind
window.tt.mainButton.showProgress();
submit().finally(() => window.tt.mainButton.hideProgress());
});
// BackButton (the host header's back button)
window.tt.backButton.show(); // show/hide/onClick/offClick
window.tt.backButton.onClick(() => history.back());
// Generic event listening (same as the onClick above)
window.tt.onEvent('mainButtonClicked', handler);
window.tt.onEvent('backButtonClicked', handler);
window.tt.offEvent('mainButtonClicked', handler); // unbind
// HapticFeedback
window.tt.hapticFeedback.impactOccurred('light'); // light|medium|heavy|rigid|soft
window.tt.hapticFeedback.notificationOccurred('success'); // error|success|warning
window.tt.hapticFeedback.selectionChanged();
테마 · 클라우드 저장소 · Telegram 스타일
colorScheme / themeParams는 미니앱의 색상을 호스트와 일치시키고 라이트/다크에 맞춰 전환합니다. locale은 호스트의 i18n과 동기화됩니다.
// —— Theme (consistent with the host's colors, switches with light/dark) ——
window.tt.colorScheme; // 'light' | 'dark'
window.tt.themeParams; // {bgColor,textColor,hintColor,linkColor,buttonColor,buttonTextColor,secondaryBgColor}
document.body.style.background = window.tt.themeParams.bgColor; // use the host color, consistent with the host
window.tt.onEvent('themeChanged', () => { // fires when the host toggles light/dark (same as onThemeChange)
applyTheme(window.tt.colorScheme, window.tt.themeParams);
});
// —— Locale i18n (synced with the host; the SDK already sets <html lang>) ——
window.tt.locale; // e.g. 'zh-CN' / 'en-US'; same as tt.context().locale
window.tt.onLocaleChange((locale) => renderInLang(locale)); // or onEvent('localeChanged')
레퍼런스
오류 처리
모든 window.tt.*는 Promise를 반환하며 실패 시 Error를 reject합니다. 프로덕션 미니앱은 모든 호출을 .catch / try-catch로 처리해야 합니다. 흔한 err.message:
| err.message | 의미 / 권장 처리 |
|---|---|
User denied authorization |
온디맨드 권한 부여 프롬프트가 거부됨 → 재시도 안내 |
User cancelled |
대화 선택기/미리보기가 취소됨 → 조용히 유지 |
Call timed out |
호스트가 오랫동안 응답하지 않음(드묾) → 재시도 안내 |
Data too large / too many documents / too many storage items |
할당량 초과 → 데이터 축소 |
public reads are limited to pub_-prefixed public collections |
컬렉션 명명 불일치 → pub_ 접두사 사용 |
Forbidden (403) |
비개발자가 scope=all 사용 → 권한 없음 |
Network error / Failed to fetch |
네트워크 실패 → 친절한 안내 + 재시도 |
// Every tt.* returns a Promise and rejects an Error on failure; handle with .catch / try-catch.
window.tt.getProfile()
.then((me) => { /* … */ })
.catch((err) => {
switch (err.message) {
case 'User denied authorization': /* guide the user to retry authorization */ break;
case 'User cancelled': /* the user cancelled the conversation picker, stay silent */ break;
case 'Call timed out': /* the host was unresponsive for a long time (rare), prompt a retry */ break;
default: /* quota / permission (403) / network, etc — give a friendly prompt */
}
});
권한 부여 모델
온디맨드 권한 부여: 미니앱을 여는 것만으로는 모든 권한을 미리 부여할 필요가 없습니다. 호스트는 어떤 기능이 처음 호출될 때만 개별 항목을 요청합니다(허용/거부). 사용자는 "이 미니앱 신뢰"로 한 번에 모두 부여하거나, 항목별로 개별 토글하고 설정 페이지에서 사용 기록을 볼 수 있습니다. 백엔드는 여전히 매 호출마다 다시 검증합니다(심층 방어). UI/창/디바이스/테마/앱 간 기능은 권한 부여 없이 사용할 수 있습니다.
| 기능(scope) | 설명 | 민감 |
|---|---|---|
user.profile |
닉네임과 아바타 가져오기 | — |
cloud.data |
클라우드 데이터(컬렉션; 주문/기록 등) | — |
storage.kv |
데이터 저장소(KV) | — |
media.upload |
이미지/파일 업로드(드라이브 tt.drive 포함) | — |
im.share |
채팅에 카드 공유 | — |
im.send |
메시지 전송 | — |
im.read |
대화 기록 읽기 | 민감 |
im.room |
멀티플레이어 룸: 여러분 대신 메시지 송수신 및 룸 채팅 읽기 | 민감 |
text.lookup |
단어 조회/번역(선택한 텍스트가 번역 서비스로 전송됨) | 민감 |
text.provider |
조회 제공자(이 미니앱의 페이지가 조회/번역 팝오버 역할을 함) | 민감 |
가시성
- 공개: "탐색"과 검색에 표시됨; 누구나 찾을 수 있음.
- 비공개(Unlisted): 탐색/검색에 없음; 공유된 카드나 복사된 링크(딥 링크)로만 접근 가능 — 공개 노출 없이 사적 유입을 확산하기 위함. 콘솔에서 원클릭으로 토글.
- 열기 모드(데스크톱): 콘솔의 "열기 모드"가 "플로팅(기본) / 전체 화면"을 토글함 — 캔버스/화이트보드/에디터 앱은
fullscreen으로 설정해 열 때 화면을 채우고, 가벼운 카드/폼은 플로팅을 사용.manifest.json의display에서 초기값을 선언할 수도 있음. 모바일은 항상 전체 화면이며 이 설정의 영향을 받지 않음.
버전 & 배포
버전 모델: 초안 → 심사 중 → 준비됨 → 라이브.
모든 과거 버전이 보관되며, 임의의 과거 버전으로 원클릭 롤백(라이브 버전 즉시 교체)이 가능합니다. 거부 시 알림 센터에 사유가 표시됩니다.
제약 & 할당량
- 클라우드 데이터: 단일 문서 ≤ 8KB, (미니앱, 사용자)당 ≤ 500 문서,
list()는 한 번에 ≤ 200(최신순); 그 이상은listPage()커서 페이지네이션(mine/all)을 사용. 참고:list()로 직접 프런트엔드 집계(개수/평균)를 하면 컬렉션이 200을 초과할 때 가장 오래된 부분이 과소 집계되어 낮은 결과가 나옴 — 전체 합계는 listPage 페이지네이션을 사용하거나 근사치를 받아들일 것. - KV: 단일 값 ≤ 8KB, (미니앱, 사용자)당 ≤ 64 키.
- 업로드 이미지 ≤ 4MB; 업로드 파일 ≤ 20MB; 메시지 전송은 속도 제한됨(사용자당 분당 ≤ 20); 룸 시그널 페이로드 ≤ 2KB; tt.link 단일 메시지 ≤ 2KB, 25/s로 스로틀링.
- 코드 번들은 단일 파일 HTML(순수 DOM 렌더링 선호; XSS 방지를 위해
innerHTML회피). - 토큰은 수명이 짧음(약 2시간); 만료 후 호스트가 조용히 갱신함; 기능 호출은 백엔드가 다시 검증함.
참고 예제: 저장소의
applets/food(주문, 클라우드 데이터)와applets/repair(수리 요청)는 모두 순수 프런트엔드 + 클라우드 데이터 미니앱입니다.