Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/pr-build-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,11 @@ jobs:
VITE_VOC_ENDPOINT: ${{ vars.VITE_VOC_ENDPOINT }}
run: pnpm run build:local

- name: Test stateless template sharing
run: pnpm run test:template-share

- name: Build GitHub Pages share viewer
run: pnpm run build:gh-pages

- name: Build success
run: echo "✅ Build completed successfully!"
25 changes: 24 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ extension build는 다음 entry를 `dist/`에 생성합니다.

## 주요 데이터 흐름

### 개인 템플릿과 공유

개인 템플릿 CRUD는 LinKU backend와 분리되어 있습니다. popup과 editor는
`src/utils/templateStorage.ts`의 저장소 경계만 사용하고, 실제 템플릿·draft는
`linku` IndexedDB에 저장합니다. 사용자가 올린 아이콘도 256px 이하 WebP로
정규화한 뒤 같은 DB의 별도 store에 저장합니다.

기존 `localStorage` 템플릿과 draft는 popup이 처음 저장소를 열 때 한 번
IndexedDB로 복사합니다. 이전 값은 한 릴리즈 동안 rollback 원본으로 남기므로
마이그레이션 실패가 기존 데이터 삭제로 이어지지 않습니다.

작은 템플릿 공유 링크는 압축한 payload를 GitHub Pages URL의 fragment(`#`)에
담습니다. fragment는 HTTP 요청에 포함되지 않으며 Pages의 `/share/` 화면에서만
검증·해제됩니다. URL 제한을 넘는 템플릿은 서버에 자동 업로드하지 않고
`.linku.json` 파일로 내보냅니다. Pages에서 확장 프로그램으로 가져오는 외부
메시지는 manifest와 background 양쪽에서 LinKU share 경로로 제한합니다.

계정 로그인, 여러 기기 동기화, 충돌 처리와 cloud share는 이 로컬 저장소 위에
별도 계층으로 추가하며, 로컬 저장 성공 여부와 분리해야 합니다. 상세 경계는
`docs/LOCAL_FIRST.md`를 참고합니다.

### Backend와 인증

`src/apis/client.ts`가 `VITE_API_BASE_URL`을 기준으로 backend 요청, bearer token,
Expand Down Expand Up @@ -80,7 +101,9 @@ popup이 닫힌 동안 background polling은 실행하지 않습니다.

- `chrome.storage.local`: auth, 설정, todo, badge, 공지 캐시, 시간표 metadata와
snapshot/override.
- `localStorage`: template draft와 local template.
- IndexedDB `linku`: 개인 template, draft, 사용자 icon blob.
- `localStorage`: non-extension 시간표 fallback과 이전 template/draft의 1회
마이그레이션 원본. 새 template 데이터는 쓰지 않습니다.
- IndexedDB: 사용자가 직접 올린 시간표 이미지 blob.

시간표 metadata의 read-modify-write는 Web Locks로 popup과 background 사이에서
Expand Down
3 changes: 3 additions & 0 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ TypeScript, React hook, shared utility를 수정했다면 lint를 실행합니
```bash
pnpm run lint
pnpm run test:timetable
pnpm run test:template-share
```

변경 유형별 추가 확인:
Expand All @@ -65,6 +66,8 @@ pnpm run test:timetable
- 외부 사이트 parser: 실제 페이지·응답과 fallback. 로그인 정보나 원문 응답을
로그 또는 fixture에 남기지 않습니다.
- Permission: 추가된 API/domain이 최소 범위인지 확인합니다.
- Template share: codec test와 `pnpm run build:gh-pages`를 함께 실행하고,
fragment가 네트워크 요청에 포함되지 않는지 확인합니다.
- 배너 운영 기간: `startAt`/`endAt`에 timezone이 포함된 ISO 8601 값을 사용하고,
즉시 내려야 하는 배너는 이전 확장도 고려해 목록에서 제거합니다.

Expand Down
56 changes: 56 additions & 0 deletions docs/LOCAL_FIRST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Local-first 경계

LinKU의 개인화 기능은 서버가 없어도 먼저 동작하고, 계정 기능은 그 위에 선택적으로
붙이는 구조를 사용합니다. 이 문서는 stateless 기반과 후속 stateful 계층 사이의
계약을 설명합니다.

## Stateless 기반

현재 기반에서 서버 없이 완결되는 기능은 다음과 같습니다.

| 데이터/기능 | 저장 또는 전달 위치 | 서버 장애 시 동작 |
| --- | --- | --- |
| 개인 템플릿 | Chrome IndexedDB `linku/templates` | 생성·조회·수정·삭제 가능 |
| 편집 draft | Chrome IndexedDB `linku/drafts` | 편집 복구 가능 |
| 사용자 아이콘 | Chrome IndexedDB `linku/assets` | 업로드·이름 변경·삭제 가능 |
| 적용 중인 템플릿 ID | `chrome.storage.local` | popup 재실행 후 유지 |
| 작은 템플릿 공유 | GitHub Pages URL fragment | 서버 저장 없이 미리보기·가져오기 가능 |
| 큰 템플릿 공유 | `.linku.json` 파일 | 파일 전달로 내보내기·가져오기 가능 |

`templateStorage.ts`의 저장 함수 이름은 기존 호출부와 migration 의미를 드러내기
위해 유지하지만 모든 읽기와 쓰기는 비동기 IndexedDB 작업입니다. 과거
`localStorage` 값은
`local-storage-templates-v1` migration이 완료되기 전에 복사하며, migration 완료
기록과 데이터 저장을 같은 transaction에서 처리합니다. rollback을 위해 원본 값은
남겨 두되, 사용자가 IndexedDB에서 템플릿을 삭제하면 같은 legacy 항목도 함께
삭제하여 다음 migration에서 되살아나지 않게 합니다.

## 공유 보안 경계

- URL payload는 gzip 후 base64url로 인코딩하며 `#v1.` 뒤에 둡니다.
- payload는 template 1개, item 최대 36개, 6×6 grid, HTTP(S) 링크만 허용합니다.
- 압축 해제 결과와 파일은 256KB 이하만 처리합니다.
- 실행 가능한 SVG data URL은 받지 않고 PNG, JPEG, WebP base64만 허용합니다.
- 외부 URL 아이콘은 내보낼 때 기본 링크 아이콘으로 바꾸고, 가져올 때는 거부해
미리보기만으로 제3자 서버에 요청하지 않게 합니다.
- Pages의 외부 extension message는
`https://turtle-hwan.github.io/LinKU/share/`에서만 받습니다.
- Pages에서 보낸 가져오기 요청은 service worker가 `chrome.storage.local` queue에
보관하고, popup이 열릴 때 검증 후 IndexedDB에 저장합니다.

## 후속 stateful 계층의 계약

계정 동기화 PR은 다음 원칙을 지켜 이 기반 위에 추가합니다.

1. IndexedDB 저장은 항상 먼저 완료하고 성공 UI를 반환합니다.
2. 동기화는 durable outbox로 별도 수행하며 네트워크 실패가 로컬 저장을 rollback하지
않습니다.
3. DB schema를 확장할 때 version을 올리고 기존 `templates`, `drafts`, `assets`,
`migrations` store를 그대로 보존합니다.
4. Google 로그인은 동기화와 여러 기기 사용을 위한 선택 기능입니다. 개인 템플릿
편집 자체의 선행 조건이 아닙니다.
5. Worker는 인증, 사용자별 object namespace, optimistic concurrency와 공유 수명만
담당합니다. 템플릿 편집·검증·압축·미리보기는 프론트에 둡니다.

stateful 계층이 추가되기 전에는 로그인, 여러 기기 동기화, cloud share, 커뮤니티
게시를 제공한다고 표시하지 않습니다.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"test:timetable": "node --experimental-strip-types --test tests/timetable/*.test.ts",
"test:feedback": "node --experimental-strip-types --test tests/feedback/*.test.ts",
"test:forms": "node --experimental-strip-types --test tests/forms/*.test.ts",
"test:template-share": "node --experimental-strip-types --test tests/templates/*.test.ts",
"test:todo": "node --experimental-strip-types --test tests/todo/*.test.ts",
"preview": "vite preview",
"deploy": "git subtree push --prefix gh-pages origin gh-pages"
Expand All @@ -38,6 +39,7 @@
"cmdk": "^1.1.1",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"idb": "^8.0.3",
"lucide-react": "^1.28.0",
"qrcode": "^1.5.4",
"react": "^19.2.8",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@
"https://www.google-analytics.com/*",
"https://ku-linku.store/*"
],
"externally_connectable": {
"matches": [
"https://turtle-hwan.github.io/LinKU/*"
]
},
"commands": {
"_execute_action": {
"suggested_key": {
Expand All @@ -50,4 +55,4 @@
"description": "Open LinKU extension"
}
}
}
}
19 changes: 18 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { ErrorBoundary } from "react-error-boundary";
import { Toaster } from "./components/ui/sonner";
import { PostedTemplatesProvider } from "./contexts/PostedTemplatesContext";
import { sendExtensionOpen, sendPageView, sendError } from "./utils/analytics";
import { debugLog } from "@/utils/logger";
import { debugLog, errorLog } from "@/utils/logger";
import { consumePendingTemplateImports } from "@/utils/pendingTemplateImports";
import { portablePayloadToTemplate } from "@/utils/templateShare";
import { importSharedTemplate } from "@/utils/templateStorage";
import "./App.css";

function App() {
Expand All @@ -24,6 +27,20 @@ function App() {
sendPageView("LinKU Extension - Popup");
}, []);

useEffect(() => {
void consumePendingTemplateImports(async (payload) => {
await importSharedTemplate(portablePayloadToTemplate(payload));
})
.then((importedCount) => {
if (importedCount > 0) {
window.dispatchEvent(new Event("linku:templates-changed"));
}
})
.catch((error: unknown) => {
errorLog("Failed to process pending template imports", error);
});
}, []);

return (
<ErrorBoundary
onError={(error: unknown) => {
Expand Down
117 changes: 86 additions & 31 deletions src/apis/icons.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,109 @@
/**
* Icons API
* Icon asset management
* Frontend-owned icon repository.
* Bundled icons and custom IndexedDB assets keep the editor usable offline.
*/

import { get, post, put, del, publicRequest, ENDPOINTS } from './client';
import type { ApiResponse, Icon, CreateIconResponse, DeleteResponse } from '../types/api';
import { getBundledTemplateIcons } from "@/constants/templateIcons";
import {
deleteAsset,
listAssets,
renameAsset,
saveAsset,
} from "@/storage/assetRepository";
import type {
ApiResponse,
CreateIconResponse,
DeleteResponse,
Icon,
} from "@/types/api";

/**
* Upload and create a new icon
*/
export async function createIcon(
iconName: string,
iconFile: File | Blob
iconFile: File | Blob,
): Promise<ApiResponse<CreateIconResponse>> {
const formData = new FormData();
formData.append('name', iconName);
formData.append('file', iconFile);

return post<CreateIconResponse>(ENDPOINTS.ICONS.BASE, formData);
try {
const asset = await saveAsset(iconName, iconFile);
return {
success: true,
data: { id: asset.numericId, name: asset.name, imageUrl: asset.dataUrl },
};
} catch (error) {
return {
success: false,
error: {
code: "ICON_STORAGE_ERROR",
message:
error instanceof Error
? error.message
: "아이콘을 저장하지 못했습니다.",
},
};
}
}

/**
* Get list of default system icons (public endpoint, no auth required)
*/
export async function getDefaultIcons(): Promise<ApiResponse<Icon[]>> {
return publicRequest<Icon[]>(ENDPOINTS.ICONS.DEFAULT, "GET");
return { success: true, data: getBundledTemplateIcons() };
}

/**
* Get list of user's custom icons
*/
export async function getMyIcons(): Promise<ApiResponse<Icon[]>> {
return get<Icon[]>(ENDPOINTS.ICONS.MY);
const assets = await listAssets();
return {
success: true,
data: assets.map((asset) => ({
id: asset.numericId,
name: asset.name,
imageUrl: asset.dataUrl,
isDefault: false,
createdAt: new Date(asset.createdAt).toISOString(),
})),
};
}

/**
* Rename an existing icon
*/
export async function renameIcon(
iconId: number,
newName: string
newName: string,
): Promise<ApiResponse<Icon>> {
return put<Icon>(ENDPOINTS.ICONS.RENAME(iconId), { name: newName });
try {
const assets = await listAssets();
const asset = assets.find((candidate) => candidate.numericId === iconId);
if (!asset) {
return {
success: false,
error: { code: "ICON_NOT_FOUND", message: "아이콘을 찾을 수 없습니다." },
};
}
const renamed = await renameAsset(asset.id, newName);
return {
success: true,
data: {
id: renamed.numericId,
name: renamed.name,
imageUrl: renamed.dataUrl,
isDefault: false,
},
};
} catch (error) {
return {
success: false,
error: {
code: "ICON_STORAGE_ERROR",
message: error instanceof Error ? error.message : "아이콘 이름을 바꾸지 못했습니다.",
},
};
}
}

/**
* Delete a custom icon
*/
export async function deleteIcon(
iconId: number
iconId: number,
): Promise<ApiResponse<DeleteResponse>> {
return del<DeleteResponse>(ENDPOINTS.ICONS.DELETE(iconId));
const assets = await listAssets();
const asset = assets.find((candidate) => candidate.numericId === iconId);
if (!asset) {
return {
success: false,
error: { code: "ICON_NOT_FOUND", message: "아이콘을 찾을 수 없습니다." },
};
}
await deleteAsset(asset.id);
return { success: true, data: { message: "아이콘을 삭제했습니다." } };
}
Loading