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/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Minimum 으로 install. 의도로 나머지 얻기. Track 6 가 end-to-end wire 된 opt-in flow 둘로 끝남: Export Clips 가 user click 시 downloads 요청, Enable on This Site 가 user 가 이전 excluded URL 에 ClipDeck 원할 때 host permission 요청."

두 flow

Track 5 가 ClipDeck 에 `downloads` 와 `optional_host_permissions` 선언만 했지 요청은 안 함. 이 lesson 이 둘 다 light:

  1. Export Clips — popup 이나 side-panel 버튼. Click 시 grant 안 됐으면 downloads 요청, 다음 SW 통해 실제 export trigger.
  2. Enable on This Site — content script 가 현재 tab 에 안 도는 경우만 (URL 이 content_scripts.matches 매칭 안 했거나 exclude_matches 에 있어서) 나타나는 popup 버튼. Click 시 https://<current-host>/* 를 host permission 으로 요청, 다음 programmatic 으로 content script inject.

둘 다 같은 shape 따름: detect → ask → run → deny 시 graceful fallback. Shape 이 너무 consistent 해서 작은 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);
}

Contains-then-request 춤을 한 호출로 collapse. Caller 가 boolean result 에만 branch.

'Enable on This Site' flow

이게 더 흥미로움 — runtime 에 extension 의 reach 를 바꿈. Flow:

  1. Popup 열림. 현재 tab 의 URL read.
  2. Content script 가 이미 inject 됐는지 확인: ping 메시지 보내고 listener 없으면 실패 catch.
  3. 도는 중이면 버튼이 "ClipDeck is active on this site" 읽음. 아니면 버튼이 "Enable ClipDeck on this site" 읽음.
  4. Click 시 https://<host>/* 의 host permission 요청.
  5. Grant 되면 chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] }) 통해 content script inject 하도록 SW 한테 요청. 이제 ClipDeck 이 이 tab 에서 동작.
  6. Reload 너머 persistent injection 위해 chrome.scripting.registerContentScripts 통해 dynamic content script 도 추가, 같은 host 의 future load 가 auto-inject.

Persistent dynamic content script

chrome.scripting.registerContentScripts (Chrome 96+) 가 SW eviction 과 browser restart 살아남는 content script 등록, host permission grant 한 URL 에 scoped. Shape:

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

기존 거 list 하려면 getRegisteredContentScripts; 제거하려면 unregisterContentScripts({ ids: ['...'] }). 'Disable on this site' 대응이 unregister 하고 AND chrome.permissions.remove({ origins: ['https://acme.com/*'] }) 호출.

Privacy 신뢰 이야기

이 lesson 끝에 ClipDeck 이 install 시 가짐:

  • 한 install 경고 (좁힌 content_scripts.matches), 한 moderate 경고 (tabs), 한 silent set (storage / sidePanel / contextMenus / scripting / activeTab / alarms).
  • Export / notification / "추가 site 에서 활성화" 모두 per-feature in-product prompt 뒤 gating.
  • Privacy-minded user 가 downloads 나 추가 host grant 절대 안 하고 entire core feature set 사용 가능; 실제로 원하는 upgrade 에만 지불.

이게 Chrome Web Store reviewer 찾는 bar: "install 경고가 extension 이 install 시 실제로 하는 거에 비례해?" 모든 거 install 시 요청하는 extension 이 기술적으로 모든 거 쓸 거여도 bar 실패. 요청을 in-product 순간으로 defer 하는 extension 이 통과.

Track 6 마무리

여섯 track 끝. ClipDeck 이 가짐:

  • 깔끔한 permission 카테고리 가진 MV3 manifest.
  • Service worker / popup / side panel / content script / context menu / keyboard shortcut / omnibox / badge.
  • Full CRUD-C (Track 3) 와 CRUD-R (Track 4).
  • Per-tab pause (Track 5).
  • Just-in-time downloads 와 host permission (이 track).

Track 7 이 CRUD 나머지 — Update 와 Delete — 와 사람들이 실제로 쓰는 messy, framework-heavy site 에서 기존 reach 가 동작하게 하는 더 큰 DOM-handling toolkit 추가.

가볍게 install. In-product 에서 upgrade. 모든 optional permission 이 user 가 opt-in 한 feature 지, install 시 지불한 세금 아님. Reviewer 와 user 둘 다 그 규율 보상.
chrome://extensions 에서 user 가 보는 거. Grant 된 optional permission 이 'Site access' 와 'Permissions' 아래 revoke 제어와 함께 보임. 이게 visible audit trail. User 가 언제든 extension 이 뭘 가졌는지 확인하고 access 회수 가능. 모든 user 가 내일 이 page 확인할 수 있다고 design — 일부는 함.

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();
background.js — 현재 load 위해 inject + future load 위해 register·javascript
// background.js — SW 가 inject + register flow 처리
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type === "enableOnSite") {
    (async () => {
      const { tabId, pattern } = message;
      // 1) 현재 tab 에 content script 지금 inject
      try {
        await chrome.scripting.executeScript({
          target: { tabId },
          files: ["content.js"],
        });
      } catch (err) {
        sendResponse({ ok: false, reason: err.message });
        return;
      }
      // 2) 이 host 의 future load 위해 dynamic content script 등록
      const id = `clipdeck-dynamic-${btoa(pattern).replace(/=/g, "")}`;
      const existing = await chrome.scripting.getRegisteredContentScripts({ ids: [id] });
      if (existing.length === 0) {
        await chrome.scripting.registerContentScripts([{
          id,
          matches: [pattern],
          js: ["content.js"],
          runAt: "document_idle",
        }]);
      }
      sendResponse({ ok: true });
    })();
    return true;
  }
});

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type === "ping") {
    // content.js 가 이걸로 응답; content script 없으면 sendMessage throw.
    sendResponse({ ok: true });
    return;
  }
});
content.js — ping 응답기가 popup 한테 injection 상태 detect 하게 해 줘·javascript
// content.js — isContentScriptLive 확인 위한 ping 응답기 추가
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type === "ping") {
    sendResponse({ ok: true });
    return;
  }
  // ...이전 lesson 의 다른 message handler 는 여기 유지...
});

External links

Exercise

clipdeck/popup.html 에 <button id="enableBtn"> 추가 (default hidden; JS 가 필요할 때 표시). 첫 번째 code block 을 clipdeck/popup.js 에 추가. 두 번째 code block 의 enable-on-site 메시지 router 를 clipdeck/background.js 에, 세 번째 code block 의 ping 응답기를 clipdeck/content.js 에 추가. Extension reload. content_scripts.matches 를 ["https://wikipedia.org/*"] 로 잠시 narrow 해서 ClipDeck 이 default 로 github.com 에 없게. github.com 방문 — popup 이 Enable ClipDeck on github.com 버튼과 함께 열림. Click — Chrome 이 "Allow on github.com?" prompt. Allow click. 버튼 숨고; content script 가 이제 이 tab 에 inject. Page refresh — content script 가 dynamic 등록 통해 auto-inject. 다음 chrome://extensions → ClipDeck → Site access — github.com revoke; dynamic script 사라지고 popup 버튼 다시 나타남.
Hint
Popup 버튼이 절대 안 나타나면, isContentScriptLive 확인이 안 그래야 할 때도 success — ping 응답기가 다른 lesson 에서 이미 content.js 에 있을 수도. Ping 응답기 제거하고 popup 다시 확인해 검증. chrome.scripting.registerContentScripts 가 Cannot register scripts before user permission throw 하면, host permission 아직 grant 안 된 것 — chrome.permissions.request 호출 await 하고 register 손 뻗기 전 boolean 결과 확인. Host revoke 했는데 dynamic script 남아 있으면, unregister flow 가 wire 안 됨 — unregisterContentScripts 와 chrome.permissions.remove 둘 다 호출하는 대응 'disable on this site' 메시지 추가.

Progress

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

댓글 0

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

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