본문 바로가기
C.W.K.
Stream
Lesson 05 of 05 · published

ClipDeck just-in-time permission — End-to-end 전체 flow

~14 min · clipdeck, optional_permissions, ux, export, host-request

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"설치는 최소한으로 받고, 나머지는 필요해질 때 받아. Track 6은 끝까지 연결된 opt-in 흐름 두 개로 닫아. Export Clips는 user가 누를 때 downloads를 달라고 하고, Enable on This Site는 여태 빠져 있던 주소에서 ClipDeck을 쓰고 싶을 때 host 권한을 달라고 해."

두 flow

Track 5 에서는 ClipDeck에 downloadsoptional_host_permissions를 적어 두기만 하고 실제로 달라고는 안 했어. 이 lesson에서 둘 다 불을 켤 거야:

  1. Export Clips — popup 이나 side panel의 버튼이야. 누르면 아직 권한이 없을 때 downloads를 요청하고, 받고 나서 SW를 통해 실제로 내보내.
  2. Enable on This Site — 지금 tab에 content script가 안 도는 경우에만 뜨는 popup 버튼이야 (주소가 content_scripts.matches에 안 걸렸거나 exclude_matches에 들어 있어서). 누르면 https://<지금-host>/*를 host 권한으로 요청하고, 받으면 그 자리에서 content script를 밀어 넣어.

둘이 모양이 같아. 살펴보고 → 물어보고 → 실행하고 → 거절당하면 곱게 물러나기. 워낙 똑같아서 작은 helper 하나로 묶어 둘 값어치가 있어.

ensurePermission helper

popup 이랑 side panel이 같이 쓸 수 있는 함수 하나야:

async function ensurePermission(req) {
  const has = await chrome.permissions.contains(req);
  if (has) return true;
  return chrome.permissions.request(req);
}

'있나 확인하고 없으면 요청하기' 를 한 번의 호출로 접어 버린 거야. 부르는 쪽은 참/거짓만 보고 갈라지면 돼.

'Enable on This Site' 흐름

이쪽이 더 재밌어. 돌아가는 도중에 extension이 닿는 범위를 바꾸는 거니까. 순서는 이래:

  1. popup이 열리면 지금 tab의 주소를 읽어.
  2. content script가 이미 들어가 있는지 확인해. ping 메시지를 하나 던져 보고, 받을 listener가 없어서 실패하면 그걸 잡는 거야.
  3. 이미 돌고 있으면 버튼에 "ClipDeck is active on this site", 아니면 "Enable ClipDeck on this site" 라고 띄워.
  4. 누르면 https://<host>/*에 대한 host 권한을 요청해.
  5. 허락이 떨어지면 SW 한테 chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] })로 content script를 넣어 달라고 해. 그 순간부터 이 tab에서 ClipDeck이 살아나.
  6. 새로고침해도 계속 붙어 있게 하려면 chrome.scripting.registerContentScripts로 동적 content script도 같이 등록해. 그러면 같은 host를 다음에 열 때 알아서 들어가.

Persistent dynamic content script

chrome.scripting.registerContentScripts (Chrome 96부터)로 등록해 두면 SW가 evict 돼도, 브라우저를 껐다 켜도 살아남아. 범위는 host 권한을 받은 주소로만 묶이고. 모양은 이래:

await chrome.scripting.registerContentScripts([{
  id: 'clipdeck-dynamic-acme',
  matches: ['https://acme.com/*'],
  js: ['content.js'],
  runAt: 'document_idle',
}]);

지금 뭐가 등록돼 있는지 보려면 getRegisteredContentScripts, 빼려면 unregisterContentScripts({ ids: ['...'] })를 써. 'Disable on this site' 를 만든다면 등록을 빼는 것과 chrome.permissions.remove({ origins: ['https://acme.com/*'] })를 둘 다 불러 줘야 해.

Privacy 신뢰 이야기

이 lesson을 끝내면 설치 시점의 ClipDeck은 이렇게 돼:

  • 큰 경고 하나 (좁혀 둔 content_scripts.matches), 중간 경고 하나 (tabs), 그리고 아무 말 없이 지나가는 나머지 (storage / sidePanel / contextMenus / scripting / activeTab / alarms).
  • 내보내기, 알림, "다른 사이트에서도 켜기" 는 전부 그 기능을 쓰려는 순간에 물어보는 문 뒤에 있어.
  • 프라이버시에 예민한 user는 downloads도 추가 host도 끝까지 안 주고서 핵심 기능을 다 쓸 수 있어. 자기가 정말 원하는 확장에만 값을 치르는 거지.

Chrome Web Store 심사자가 보는 기준이 딱 이거야. "설치 경고가 이 extension이 설치 직후 실제로 하는 일에 걸맞나?" 설치할 때 다 달라고 하는 extension은 나중에 그걸 정말 다 쓴다고 해도 이 기준에서 떨어져. 물어보는 시점을 그 기능을 쓰는 순간으로 미룬 extension이 통과하고.

Track 6 마무리

여섯 track이 끝났어. 지금 ClipDeck이 가진 건 이래:

  • 권한이 갈래별로 정리된 MV3 manifest.
  • service worker, popup, side panel, content script, 우클릭 메뉴, 단축키, 주소창 키워드, badge.
  • Create (Track 3)와 Read (Track 4).
  • tab 별 멈춤 (Track 5).
  • 필요한 순간에 받는 downloads와 host 권한 (이 track).

Track 7에서는 CRUD의 나머지인 Update와 Delete를 채우고, 사람들이 실제로 쓰는 어수선하고 framework로 뒤덮인 사이트에서도 여태 만든 게 제대로 굴러가게 해 줄 DOM 연장통을 붙일 거야.

설치는 가볍게, 확장은 쓰는 자리에서. 선택 권한은 하나하나가 user가 스스로 켠 기능이어야지, 설치할 때 미리 뜯긴 세금이면 안 돼. 심사자도 user도 그 절제를 알아봐 줘.
chrome://extensions에서 user 눈에 보이는 것. 내준 선택 권한은 'Site access' 와 'Permissions' 아래에 회수 버튼과 함께 그대로 드러나. 눈에 보이는 감사 기록인 셈이야. user는 언제든 이 extension이 뭘 쥐고 있는지 확인하고 도로 뺏을 수 있어. 내일 모든 user가 이 화면을 열어 본다고 생각하고 설계해. 실제로 열어 보는 사람이 있거든.

Dynamic registration lifecycle. http:/https: origin만 받고 registration ID는 raw Base64 말고 안전한 hex/hash alphabet으로 만들어. User가 site를 끄면 unregisterContentScripts를 명시적으로 불러. Registration은 기본적으로 계속 남고, host grant 취소가 injection을 막을 수는 있어도 feature-state cleanup을 대신하지는 않아.

Code

popup.js — detect, host permission prompt, 그 다음 enable·javascript
// popup.js — Enable on this site flow
function hostPatternFor(url) {
  try {
    const u = new URL(url);
    return `${u.protocol}//${u.host}/*`;
  } catch {
    return null;
  }
}

async function isContentScriptLive(tabId) {
  try {
    await chrome.tabs.sendMessage(tabId, { type: "ping" });
    return true;
  } catch {
    return false;
  }
}

async function refreshEnableButton() {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab?.id || !tab.url) return;
  const live = await isContentScriptLive(tab.id);
  const btn = document.getElementById("enableBtn");
  btn.style.display = live ? "none" : "block";
  btn.textContent = `Enable ClipDeck on ${new URL(tab.url).host}`;
}

document.getElementById("enableBtn").addEventListener("click", async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab?.id || !tab.url) return;
  const pattern = hostPatternFor(tab.url);
  if (!pattern) return;
  const granted = await chrome.permissions.request({ origins: [pattern] });
  if (!granted) {
    alert("ClipDeck needs site access to capture clips here.");
    return;
  }
  await chrome.runtime.sendMessage({ type: "enableOnSite", tabId: tab.id, pattern });
  await refreshEnableButton();
});

chrome.permissions.onAdded.addListener(refreshEnableButton);
chrome.permissions.onRemoved.addListener(refreshEnableButton);
refreshEnableButton();
content.js — ping은 받는 쪽에 둬·javascript
// content.js — tabs.sendMessage reaches the content script, not the worker
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  if (message?.type !== "ping") return;
  sendResponse({ ok: true });
});
content.js — ping은 받는 쪽에 둬·javascript
// content.js — tabs.sendMessage reaches the content script, not the worker
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  if (message?.type !== "ping") return;
  sendResponse({ ok: true });
});

External links

Exercise

clipdeck/popup.html에 <button id="enableBtn"> 을 넣어 (기본은 숨겨 두고 JS가 필요할 때 꺼내). 첫 번째 code block은 clipdeck/popup.js에, 두 번째 code block의 enable-on-site 메시지 라우터는 clipdeck/background.js에, 세 번째 code block의 ping 응답기는 clipdeck/content.js에 넣어. Reload 하고, 시험 삼아 content_scripts.matches를 ["https://wikipedia.org/*"] 로 좁혀서 ClipDeck이 github.com 에는 기본으로 안 붙게 만들어. 그 상태로 github.com에 가서 popup을 열면 'Enable ClipDeck on github.com' 버튼이 보일 거야. 누르면 Chrome이 "Allow on github.com?" 을 물어. 허락하면 버튼이 사라지고 content script가 이 tab에 들어가. 페이지를 새로고침해도 동적 등록 덕분에 알아서 다시 들어가고. 마지막으로 chrome://extensions → ClipDeck → Site access에서 github.com을 회수해 봐. 동적 script가 사라지고 popup 버튼이 다시 나타나야 해. Disable on this site action도 더해 optional origin을 remove하고 derived ID를 unregisterContentScripts로 지워. 다음 navigation에 더는 inject되지 않는지 확인해.
Hint
popup 버튼이 아예 안 나타나면 isContentScriptLive 확인이 아닐 때도 성공으로 나오는 거야. 다른 lesson에서 넣은 ping 응답기가 이미 content.js에 있을 수 있어. 그 응답기를 빼고 popup을 다시 열어 보면 확인돼. chrome.scripting.registerContentScripts가 Cannot register scripts before user permission 을 뱉으면 host 권한이 아직 안 떨어진 거야. chrome.permissions.request를 await 하고 참/거짓을 확인한 다음에 등록으로 넘어가. host를 회수했는데 동적 script가 남아 있으면 해제 흐름을 안 걸어 준 거고. unregisterContentScripts 랑 chrome.permissions.remove를 둘 다 부르는 'disable on this site' 메시지를 짝으로 만들어 줘.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.