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

ClipDeck Mode Toggle — Per-tab pause / resume

~14 min · clipdeck, mode-toggle, badge, per-tab, storage

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Track 5는 작지만 정직한 권한 하나로 닫아. ClipDeck이 언제 눈을 뜨고 있을지 user가 정하는 거야. 세금 신고서를 치는 페이지에선 멈춰 두고, 기사로 돌아오면 다시 켜고. 그 상태가 storage에 살아 있으니 모든 표면이 같은 걸 보고."

이유

user가 늘 ClipDeck을 켜 두고 싶어 하진 않아. 프라이버시 때문일 수도 있고, 엉뚱한 게 저장되는 걸 피하고 싶어서일 수도 있고, 그냥 민감한 서류를 채우는 동안엔 꺼져 있다는 걸 아는 편안함 때문일 수도 있어. 이 멈춤 스위치가 신뢰를 재는 눈금이야:

  • tab 단위라서 나중에 다시 켜는 걸 기억할 필요가 없어.
  • SW가 evict 돼도 남아 있어서, 멈춰 놓은 게 슬그머니 풀리지 않아.
  • badge에 드러나니까 한눈에 알아볼 수 있어.
  • popup 에서도, 단축키로도, 원하면 side panel 에서도 손이 닿아.

State shape

chrome.storage.session의 단순 배열:

{ pausedTabs: number[] }

tab ID는 브라우저를 다시 켜면 안 남아 (Chrome이 새로 매겨). 그래도 괜찮아. 멈춤이란 건 그 tab이 살아 있는 동안만 의미가 있으니까. tab을 닫으면 id도 사라지고, 나중에 같은 id를 받은 새 tab은 안 멈춘 상태로 시작해.

storage.session은 browser restart 때 비워져. Session 안에서는 chrome.tabs.onRemoved에서 닫힌 tab ID를 배열에서 빼면 끝이야. Daily alarm이나 startup sweep은 필요 없어.

세 touch point

멈춤 상태가 중요한 자리마다 같은 storage 값을 읽어:

  • popup 버튼. 지금 tab의 상태를 읽고, 뒤집고, 다시 써. 그러면 storage.onChanged가 badge 랑 content script를 알아서 따라오게 해.
  • 단축키. 로직은 같아. toggle-pause command의 chrome.commands.onCommand에서 부르면 돼 (Lesson 4에서 연결해 뒀어).
  • content script의 문지기. 뭘 담기 전에 content script가 storage를 확인해. 멈춰 있으면 아무것도 안 하고 그대로 답하고, SW도 아무것도 안 남겨.

SW는 지금 tab의 멈춤 상태가 바뀔 때마다 badge도 같이 고쳐. 멈춰 있으면 setBadgeText({ tabId, text: 'II' }), 아니면 빈 값으로.

Race-free update

배열을 읽고 나서 쓰는 사이에 event 두 개가 붙어서 오면 (user가 popup 버튼을 누르는 동시에 단축키도 눌렀다든지) 서로 밟을 수 있어. 방어하는 방법은, 고칠 때마다 그 자리에서 다시 읽는 작은 helper를 하나 두는 거야:

async function togglePauseForTab(tabId) {
  const { pausedTabs = [] } = await chrome.storage.session.get('pausedTabs');
  const next = pausedTabs.includes(tabId)
    ? pausedTabs.filter((t) => t !== tabId)
    : [...pausedTabs, tabId];
  await chrome.storage.session.set({ pausedTabs: next });
  return next.includes(tabId);
}

엄밀히 원자적이진 않아. get과 set 사이에 다른 event가 끼어들 수 있거든. 그래도 사람 손 속도로 누르는 토글이라면 실전에선 충분해. 나중에 ClipDeck이 창 여러 개를 맞춰야 할 만큼 자라면, 그때 sequence id를 붙인 제대로 된 잠금 방식으로 갈아타면 돼.

시각 피드백

badge가 정직한 신호 노릇을 해. 상태는 셋이야:

  • 켜져 있고 오늘 저장한 게 있으면 → 브랜드 색에 숫자 ("3").
  • 켜져 있는데 오늘 저장한 게 없으면 → 빈 badge.
  • 멈춰 있으면 → 회색 바탕에 "II" 나 "PAUSE".

storage가 바뀔 때마다 개수 모드랑 멈춤 모드 사이를 오가. popup도 버튼 글자를 뒤집어 — "이 사이트에서 멈추기" ↔ "이 사이트에서 다시 켜기". side panel은 user가 멈춰 둔 tab을 보고 있으면 띠를 하나 띄워 줘도 좋아 ("지금은 안 담고 있어. 다시 켤까?").

Track 5 마무리

이 lesson까지 오면 ClipDeck이 toolbar 쪽 표면을 다 갖춰:

  • 오늘 개수랑 멈춤 표시를 겸하는 badge를 단 icon.
  • 요약이랑 주요 동작 셋, 그리고 눈에 띄는 설정 링크가 있는 popup.
  • 고른 텍스트가 그대로 이름에 들어가는 우클릭 메뉴.
  • user가 원하는 대로 바꿀 수 있는 단축키 넷.
  • 주소창 검색 키워드 clip.
  • 어디에서 봐도 똑같이 반영되는 tab 별 멈춤과 재개.

다음으로 자연스러운 건 Track 6의 permission 모델이야. ClipDeck이 왜 하필 그 권한을 달라고 하는지, 꼭 필요한 순간에만 묻는 법, 그리고 거절하는 user를 어떻게 대접할지.

멈춤 스위치는 user가 신뢰를 조절하는 다이얼이야. tab 단위로 걸리고, storage에 남고, badge와 popup에 똑같이 비쳐. 표면은 셋인데 상태는 하나야. 그래서 놀랄 일이 없고.
수명이 ownership을 정해. Clip과 표시 설정은 browser restart를 넘어야 하니 storage.local, tab 단위 pause는 그 tab과 browser session 안에서만 의미가 있으니 storage.session에 둬. Content script를 경쟁 writer로 만들지 말고 service worker 하나가 pause gate와 cleanup을 맡아.

Tab ID는 runtime 식별자라 browser restart 뒤 재사용될 수 있어. pausedTabsstorage.session에 두고 tabs.onRemoved에서 치워. Gate는 service worker가 맡아. Clip과 display preference는 storage.local, tab-scoped pause state는 browser session에만 남겨.

Code

background.js — toggle + badge refresh + tab close cleanup·javascript
// background.js — one queued writer for browser-session pause state
let pauseMutation = Promise.resolve();

function queuePauseMutation(task) {
  const result = pauseMutation.then(task, task);
  pauseMutation = result.then(() => undefined, () => undefined);
  return result;
}

function togglePauseForTab(tabId) {
  return queuePauseMutation(async () => {
    const { pausedTabs = [] } =
      await chrome.storage.session.get("pausedTabs");
    const next = pausedTabs.includes(tabId)
      ? pausedTabs.filter((id) => id !== tabId)
      : [...pausedTabs, tabId];
    await chrome.storage.session.set({ pausedTabs: next });
    await refreshBadgeForTab(tabId);
    return next.includes(tabId);
  });
}

async function refreshBadgeForTab(tabId) {
  const [{ pausedTabs = [] }, { clips = [] }] = await Promise.all([
    chrome.storage.session.get("pausedTabs"),
    chrome.storage.local.get("clips"),
  ]);
  if (pausedTabs.includes(tabId)) {
    await chrome.action.setBadgeText({ tabId, text: "II" });
    await chrome.action.setBadgeBackgroundColor({ tabId, color: "#666" });
    await chrome.action.setTitle({ tabId, title: "ClipDeck — paused on this tab" });
    return;
  }
  const todayStart = new Date();
  todayStart.setHours(0, 0, 0, 0);
  const count = clips.filter((clip) => clip.savedAt >= todayStart.getTime()).length;
  await chrome.action.setBadgeText({
    tabId,
    text: count === 0 ? "" : String(count > 999 ? "999+" : count),
  });
  await chrome.action.setBadgeBackgroundColor({ tabId, color: "#1a6bd6" });
}

chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  if (message?.type !== "togglePause") return;
  togglePauseForTab(message.tabId)
    .then((paused) => sendResponse({ ok: true, paused }))
    .catch((error) => sendResponse({ ok: false, error: error.message }));
  return true;
});

chrome.tabs.onRemoved.addListener((tabId) => {
  void queuePauseMutation(async () => {
    const { pausedTabs = [] } =
      await chrome.storage.session.get("pausedTabs");
    await chrome.storage.session.set({
      pausedTabs: pausedTabs.filter((id) => id !== tabId),
    });
  });
});
popup.js — Pause 버튼 + live label update·javascript
// popup.js — Pause 버튼을 SW-side helper 에 wire
document.getElementById("pauseBtn").addEventListener("click", async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab?.id) return;
  await chrome.runtime.sendMessage({ type: "togglePause", tabId: tab.id });
});

async function refreshPauseLabel() {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  const { pausedTabs = [] } = await chrome.storage.session.get("pausedTabs");
  const paused = tab?.id ? pausedTabs.includes(tab.id) : false;
  document.getElementById("pauseBtn").textContent = paused
    ? "Resume on this tab"
    : "Pause on this tab";
}

chrome.storage.onChanged.addListener((c, a) => {
  if (a === "session" && "pausedTabs" in c) refreshPauseLabel();
});

refreshPauseLabel();

// background.js 에서 메시지 route:
// chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
//   if (message?.type !== 'togglePause') return;
//   (async () => {
//     await togglePauseForTab(message.tabId);
//     sendResponse({ ok: true });
//   })();
//   return true;
// });
content.js는 stateless, background.js가 tab별 capture gate·javascript
// content.js — capture page facts only; no storage.session access
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  if (message?.type !== "captureSelection") return;
  const text = window.getSelection()?.toString() ?? "";
  sendResponse(text.trim()
    ? { ok: true, payload: { text, url: location.href, title: document.title } }
    : { ok: false, reason: "no-selection" });
});

// background.js — the trusted context knows tabId and owns the pause gate
chrome.commands.onCommand.addListener(async (command) => {
  if (command !== "save-clip") return;
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab?.id) return;
  const { pausedTabs = [] } =
    await chrome.storage.session.get("pausedTabs");
  if (pausedTabs.includes(tab.id)) return;
  // Continue with the serialized save flow from Track 3.
  const captured =
    await chrome.tabs.sendMessage(tab.id, { type: "captureSelection" });
  if (captured?.ok) {
    await saveCapturedClip(captured.payload);
  }
});

External links

Exercise

첫 번째 code block을 clipdeck/background.js에 넣어 — togglePauseForTab helper, tab 별 badge 갱신, command 연결, tab 닫을 때 정리까지 들어 있어. 두 번째 code block은 clipdeck/popup.js에 넣고 (Pause 버튼 handler 랑 실시간 라벨), 그 아래 주석으로 달린 togglePause 메시지 라우터는 background.js에 넣어. 기존 save-clip handler는 세 번째 code block의 문지기 달린 버전으로 갈아. Reload 하고 아무 페이지에서 popup을 열면 Pause on this tab 이 보일 거야. 눌러 봐 — badge가 회색 II 로 바뀌고 글자가 Resume on this tab 으로 뒤집혀. 그 상태에서 Ctrl+Shift+K를 눌러도 아무것도 안 저장돼야 해. 다시 Resume on this tab 을 누르면 badge가 돌아오고 저장도 다시 되고. tab을 옮겨 다니면서 badge가 tab 마다 따로 논다는 것도 확인해. 마지막으로 멈춰 둔 tab을 닫았다가 같은 주소를 다시 열어 봐 — 안 멈춘 상태로 시작하는 게 맞아.
Hint
멈췄는데 badge가 II 로 안 바뀌면 chrome.action.setBadgeText 에 await를 안 건 거야. 그거 Promise 야. popup에서 togglePauseReceiving end does not exist 를 뱉으면 SW 쪽에 그 메시지를 받을 listener를 안 걸어 둔 거고 (두 번째 code block 맨 아래 주석으로 달아 둔 라우터를 넣어). 멈춘 상태인데도 save-clip이 계속 저장하면 SW 쪽 문지기가 handler에 안 들어간 거야. save-clip 을 받는 기존 chrome.commands.onCommand listener를 찾아서 맨 앞에 멈춤 확인을 끼워 넣어.

Progress

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

댓글 0

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

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