← Posts

Building a Chrome Extension with React (Gachon-Tools)

A journey of building a Chrome extension from scratch

·14 min read
#gachon-tools#크롬익스텐션

가천대학교 사이버캠퍼스에서 과제를 한눈에 모아볼 수 있는 익스텐션을 개발했다.

크롬 익스텐션 스토어

깃허브에도 확인할 수 있다.

개발 이유?

작년에 코로나가 막 풀렸을 때라 대학교에서 대면과 비대면을 병행해서 수업을 진행했었다.

그러다 한번 교수님이 휴강을 하시고 녹화 강의로 출석을 대체했다.

당시에 21학점을 듣고 있었는데 딴 과제랑 할 일을 하느라 녹화 강의를 들어야 할 게 있었다는 걸 완전히 잊고 있었다. 마감기한이 다음 강의 시작 전이었는데, 강의실에 도착해서 여유롭게 있다가 옆에서 친구가 강의 들었냐는 말에 잊었던 녹화 강의가 떠올랐다.

결국 지각 처리가 되었고, 억울했지만 까먹은 내 탓이라 할 말이 없었다..

나처럼 과제를 깜빡해서 녹화 강의를 안 들었거나 과제를 제출하지 못한 학생들이 꽤 있을 것이다. 특히 학점을 많이 듣는 학생들이라면 더욱 과제를 관리하기 힘들거라 생각했다. 그래서 지금 해야 하는 과제가 무엇이고, 언제 마감인지 한눈에 볼 수 있는 기능이 들어간 익스텐션으로 만들어보았다.


익스텐션은 어떻게 만들까?

크롬 익스텐션을 만들기 위해선 어떻게 구성되어있는지 알아야 한다.

manifest.json - manifest 버전과 익스텐션 이름, 버전 등이 들어있는 명세서 같은 파일

background - service worker로 브라우저 이벤트를 반응해서 동작하는 스크립트

content script - 웹페이지에 직접 주입할 수 있는 스크립트

popup - 익스텐션 아이콘 누르면 보이는 팝업 창

option - 익스텐션 관리 페이지에서 설정 누르면 보이는 페이지

리액트로도 익스텐션을 만들 수 있을까?

보일러플레이트 사용

다행히 보일러플레이트가 있어서 참고하여 세팅을 했다.

⚠️주의사항

content script는 `safe js environment`, 즉 독립된 공간에서 실행되는데 이곳에서는 ES Module을 사용할 수 없으므로 dynamic import를 해주어야 한다. 참고

보일러플레이트에서도 이 때문에 이런 식으로 구성했다.

// pages/content/index.ts
import('./main');

export {};

// pages/content/main.tsx
import Content from '@pages/content/Content';
import { createRoot } from 'react-dom/client';

const root = document.createElement('div');
root.id = 'root';

document.body.appendChild(root);

createRoot(root).render(<Content />);

import()로 dynamic import를 해서 main.tsx에 접근하도록 했다.

문제 1: React.Lazy로 코드 스플리팅 시 에러 발생

Suspense 기능을 사용하기 위해 React의 lazy로 코드 스플리팅을 해서 빌드했는데, 테스트 시 에러가 발생했다.

ERROR: Expected ")" but found "."

에러에서 보이는 dynamicImport는 보일러플레이트에 있던 커스텀 플러그인에서 변환해준건데, 그 플러그인 때문에 생긴 오류였다.

import type { PluginOption } from 'vite'

export default function customDynamicImport(): PluginOption {
  return {
    name: 'custom-dynamic-import',
    renderDynamicImport() {
      return {
        left: `
        {
          const dynamicImport = (path) => import(path);
          dynamicImport(
          `,
        right: ')}',
      }
    },
  }
}

끝에 중괄호로 감싸주기 때문에 에러가 뜬 듯 하다.

시도 1 - 플러그인 적용 해제 (해결)

그래서 이 플러그인을 적용하지 않고 진행하니 해당 에러는 나타나지 않았다. 하지만 바로 다음 에러가 발생했다.

문제 2:  익스텐션 테스트 시 브라우저 콘솔에서 export를 읽을 수 없다는 에러 발생

  1. vite의 rollupOptions를 통해 build를 커스텀해서 생긴 오류인가? (❌)
  • rollupOptions를 지우고 번들링 된 파일만 가지고 manifest를 작성해서 테스트해 봄 -> 동일한 에러 발생
  1. 추가 설정이 필요한 건가? (△)
  • vite.config 파일에서 base 설정을 하면 에셋 파일들을 정확히 읽지 않을까?
// vite.config.js
export default defineConfig({
  build: {
    base: './',
    rollupOptions: {
      // ...
    },
  },
})

이렇게 base root를 명시하고 번들링 하니 엔트리 파일에서 import.meta.url을 사용하고 있어서 또 에러가 떴다.

// content script entry file
const __vitePreload = function preload(baseModule, deps, importerUrl) {
  //...
}

__vitePreload(
  () => import('../../../assets/js/main.096ede36.js'),
  true ? ['../../../assets/js/main.096ede36.js', '../../../assets/js/client.8f342e30.js'] : void 0,
  import.meta.url
)

시도 1: 커스텀 플러그인 만들기

직접 저걸 바꿀 수 있는 방법이 있을까 알아보기 위해 vite 공식문서와 rollup 공식문서를 뒤져보았다.

한참을 찾아본 끝에 Rollup에서 resolveImportMeta라는 훅을 찾았다. 이 훅은 ES Module에서 사용하는 import.meta를 직접 조작할 수 있게 한다.

import.meta.url은 해당 모듈의 전체 url을 알 수 있는데, 이걸 익스텐션의 파일 경로로 가리키게 하려면 chrome.runtime.getURL을 사용하면 된다. 아래처럼 직접 커스텀 플러그인을 만들어보았다.

import type { PluginOption } from 'vite'

export default function resolveMetaChromeExtension(): PluginOption {
  return {
    name: 'resolve-meta-chrome-extension',
    resolveImportMeta: (property) => {
      if (property === 'url') {
        return 'chrome.runtime.getURL("")'
      }
    },
  }
}

이 플러그인을 적용하니 base root 때문에 났던 에러는 해결할 수 있었지만, 처음에 발생한 에러인 export 문제는 없어지지 않았다..

시도 2: vite config의 build.modulePreload를 false로 하기 (해결)

vite에서 build 시 미리 모듈을 캐싱한다. 그래서 vite가 알아서 modulePreload를 해주는 __vitePreload라는 함수를 엔트리 파일에서 export 시켜주고 있게 된다. 이러면 위의 주의사항에서 말했듯이 ESM을 엔트리파일에서 사용할 수 없으므로 에러가 띄워지게 된다.

왜 React.Lazy를 썼을 때 에러가 떴을까?

엔트리 파일에서 main.tsx를 import 하고, main.tsx에서 App.tsx 파일을 렌더링하고 있다.

하지만 Lazy를 통해 컴포넌트를 동적으로 import하고 있기 때문에, 엔트리 파일에 있는  __vitePreload를 가져와야 한다. 그래서 vite가 엔트리 파일에서 저 함수를 export 하게 된 것이다.

const A = reactExports.lazy(() =>
  __vitePreload(
    () => import('./A-192aa1d7.js'),
    true
      ? ['assets/A-192aa1d7.js', 'assets/globals-0034e337.js', 'assets/globals-14f4290f.css']
      : void 0
  )
)

vitePreload가 뭔데?

공식 문서에 설명이 있었다.

vite는 빌드 시 Direct Import 구문에 대해 <link ref="modulepreload">로 미리 모듈을 캐싱하도록 자동으로 변환한다.
해당 모듈을 필요로 하는 경우 이를 바로 사용할 수 있게 된다.

여기서 나오는 Direct Import는 dynamic import를 말하는 것 같다.

미리 모듈을 캐싱해서 말 그대로 module을 preload 해주는 것이다.

vite에서 build option으로 modulePreload 하는 것을 설정할 수 있어서 위의 문제를 해결할 수 있었다.

CRXJS 라이브러리 사용

MVP를 배포할 때까지는 보일러플레이트를 활용해서 세팅했지만, 번거로운 점이 있어 CRXJS 라이브러리에서 제공하는 vite 플러그인을 사용하게 되었다.

이 라이브러리를 사용하면서 많은 이점을 얻을 수 있었다.

  1. vite의 HMR 기능을 사용할 수 있어서 개발 속도가 빨라짐
  • 빌드 -> dev 키고 익스텐션을 로드하면 변경사항이 있을 때마다 바로바로 확인할 수 있다.
  1. 익스텐션의 버전이 업데이트될 때 package.json의 버전과 manifest.json의 버전을 각각 수정할 필요가 없어짐
  • package.json만 변경하면 된다.

  • manifest를 빌드할 때 자동으로 생성되므로 관리하기 편해졌다.

UI를 어떻게 하지? 최대한 유저들에게 거슬리지 않게 하고 싶은데...

  1. 처음에 생각한 위치는 메인 페이지의 강의 리스트가 보이는 곳이다.

강좌 전체 보기 바로 아래 공간에 두려고 했지만 강의 리스트 위치가 아래로 내려가서 패스했다.

  1. 페이지 우측 하단

채널톡처럼 우측 하단에 원 모양의 버튼을 만들어 켰을 때 모달이 띄워져도 좋을 것 같다고 생각했다.

근데 우측 하단에 이미 맨 위로 올려주는 TOP 버튼이 존재해서 UI를 해치게 된다. 이것도 pass

  1. ⭐️페이지 중앙 하단

2번과 비슷한데, 중앙 하단에 두어 누르면 모달이 띄워지게 했다. 최대한 유저들이 거슬리지 않으면서 누르기 쉬운 곳에 배치했다.

위치가 완전 마음에 들진 않지만, 일단 이 방법을 택했다. (추후 유저가 버튼 위치를 수정하도록 업데이트할 예정)

직접 이용해 보니까 동그란 원이 은근히 작아서 잘 눌리지 않았다. 그래서 원 위에 마우스를 올려두면 원이 좌우로 늘어나 누를 수 있는 범위를 넓혔다.

애니메이션은 Framer-motion 라이브러리를 사용해서 간단히 구현할 수 있었다.

추가로 모달이 나타났다가 사라질 때, 필터가 켜지고 꺼질 때에도 자연스러운 효과를 내기 위해 위 라이브러리를 사용했다.

과제 데이터 가져오기

처음에는 get요청으로 페이지 document를 가져와 자바스크립트에서 제공하는 메서드로 직접 파싱 하여 데이터들을 가져왔었다.

하지만 점점 가져와야 할 데이터들도 많아지고 조작하기 어려운 부분이 있어 cheerio 라이브러리를 사용하게 되었다.

크롤링 로직

  1. 사이버캠퍼스의 나의 강좌 페이지에서 id, title 가져오기

  2. 강좌 id를 가지고 해당 강좌 페이지의 document를 가져와서 원하는 정보(과제, 녹화강의) 가져오기

문제 1: 강좌 페이지에서는 제출 여부/시청 여부를 확인할 수 없다..!

다행히 과제 페이지가 따로 있어서 이 페이지를 크롤링했다. 대신 여기서는 시작 일시를 알 수 없어 강좌 페이지에서 크롤링한 데이터와 과제 페이지에서 크롤링한 데이터를 id 값을 통해 매핑시켰다.

문제 2: 녹화강의 페이지에서 녹화강의의 id를 가져올 수 없다

학습진도현황 or 온라인출석부 페이지에서 시청 시간과 출석인정 요구시간을 비교해서 하면 얻을 수 있는데, 문제는 해당 녹화강의의 id를 얻을 수 없었다.

그래서 과제에서 했던 것처럼 고유한 값으로 매핑시킬 수 없었다.

시도 1: 녹화강의 제목으로 매핑하기

처음에는 제목 값으로 매핑했다. 적어도 내가 수강하고 있는 과목들은 녹화강의 제목이 같은 경우가 없어서 테스트할 때는 잘 되었다. 과제 제목이 같은 경우에도 잘 되겠지~ 하면서 넘어갔던 것 같다.

하지만 역시나 문제가 터져버렸다. 여러 사용자들이 피드백을 통해 문제가 생긴 부분을 알려주었는데, 녹화강의의 시청 여부가 잘못 체크되는 문제가 있었다.

스크린샷으로 봤을 때에도 크롤링 로직에는 이상이 없어서 난감했는데, 다행히 그 강좌를 듣고 있는 친구가 있어 그 계정으로 테스트해 본 결과 이 부분에서 문제가 발생한 것이다.

시도 2: 녹화강의 제목 + 주차 제목으로 매핑하기

고유한 값이 없기 때문에 어쩔 수 없이 위 방법을 택했다. 그나마 데이터가 겹치는 부분이 주차 제목이었다.

data-original-title 값을 가져와 녹화강의 데이터에 넣고,

강좌 페이지의 주차 제목을 녹화강의 데이터에 넣어서 두 데이터를 녹화강의 제목 + 주차 제목으로 매핑시켰다.

하지만 같은 주차에 같은 제목의 녹화강의가 있으면 골치 아프다..

이런 경우에는 사용자가 직접 시청 여부를 수정할 수 있도록 하면 될 것 같다.


배포

배포는 번들링 된 파일을 압축해서 올리기만 하면 끝이다. 처음 배포한 후 바로 에브리타임(학교 커뮤니티)에 홍보하지 않고 며칠 동안은 주변 친구들에게만 홍보해서 테스트시켜 보았다.

아니나 다를까 에러가 나는 부분이 꽤 있어서 리팩토링을 많이 할 수 있었다.

Sentry 도입

그리고 에타에 홍보하기 전에 혹시 모를 에러를 수집하기 위해 Sentry라는 에러 로깅 시스템을 도입했다. 에러가 나면 난 위치를 추적해 줄 뿐만 아니라 에러가 나기 전까지 유저가 취한 행동을 영상으로 리플레이해주는 등 다양한 기능을 제공해주고 있다.

세팅은 간단했다.

init 세팅만 하면 에러가 발생했을 때 로그가 쌓이는데, Sentry에서 제공하는 ErrorBoundary로 감싸서 에러가 발생하면 Toast가 띄워지도록 구현했다.

Sentry.init({
  dsn: import.meta.env.VITE_APP_SENTRY_DSN,
  environment: import.meta.env.MODE,
  release: version,
  integrations: [
    new Sentry.Integrations.Breadcrumbs({ console: true }),
    new Sentry.BrowserTracing(),
  ],
  tracesSampleRate: 1.0,
  replaysSessionSampleRate: 0.1,
  replaysOnErrorSampleRate: 1.0,
})
import { ErrorBoundary } from '@sentry/react'
import { motion } from 'framer-motion'
import { useRef, useState } from 'react'

import ContentModal from '@/components/domains/ContentModal'
import Toast from '@/components/uis/Toast'
import Portal from '@/helpers/portal'

export default function App() {
  const [isModalOpen, setIsModalOpen] = useState(false)
  const modalRef = useRef()

  const handleModalClick = (event: React.MouseEvent) => {
    if (event.target === modalRef.current) setIsModalOpen(false)
  }

  return (
    <div className="fixed bottom-[25px] left-1/2 translate-x-[-50%]">
      <motion.div
        initial={{ width: '40px', height: '40px' }}
        whileHover={{ width: '100px', height: '50px' }}
        className="cursor-pointer rounded-[50px] bg-[#2F6EA2] shadow-md shadow-[#2F6EA2]"
        onClick={() => setIsModalOpen((prev) => !prev)}
      ></motion.div>
      <Portal elementId="modal">
        <ErrorBoundary fallback={<Toast message="에러가 발생했습니다." type="error" />}>
          <ContentModal ref={modalRef} onClick={handleModalClick} isOpen={isModalOpen} />
        </ErrorBoundary>
      </Portal>
    </div>
  )
}

그리고 크롤링 과정에서 예외 상황이 있는지 파악하기 위해 captureException을 사용해서 로그를 받아왔다.

if (!id) {
  captureException(
    new Error(`getVideoAtCourseDocument에서 id 없음. ${title} / ${startAt} / ${endAt}`)
  )
}

하지만 따로 어드민 계정이 없어 에러를 정확히 파악할 수 없었다. 지금은 그냥 엣지 케이스 확인용으로만 사용하고 있다.

배포 자동화 (Github Actions)

버전업이 될 때마다 일일이 압축하고 배포하기 번거로웠다. 그래서 자동화를 진행했다. 업로드해 주는 Action을 사용해서 구현했다.

name: Publish

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    name: Publish webextension
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 16.x
      - name: Build
        run: |
          yarn install
          yarn build
      - name: Make zip file
        run: zip -r ./build.zip ./dist
        shell: bash
      - name: Upload & release
        uses: mnao305/chrome-extension-upload@v4.0.1
        with:
          file-path: ./build.zip
          extension-id: 'ogldncimhepjdfadhjjhkchknloncnmg'
          client-id: ${{ secrets.CLIENT_ID }}
          client-secret: ${{ secrets.CLIENT_SECRET }}
          refresh-token: ${{ secrets.REFRESH_TOKEN }}

이제는 태그 푸시를 하면 알아서 배포를 진행해 줘서 편해졌다.

홍보, 하지만...

이렇게 홍보 글을 올렸다. 다행히 관심을 많이 주셔서 바로 HOT 게시판에 올라갈 수 있었다.

하지만 문제가 생겼다.

그날 작업한 것들을 배포하고 검토 대기 중 상태에서 자려고 누웠는데, 생각해 보니 잘못 올린 걸 깨달아서 급하게 취소를 했다.

여기서 게시 취소 버튼을 눌렀는데, 이게 검토 중인 걸 취소하는 게 아닌 스토어에 게시된 프로그램을 게시 취소하는 거였다...ㅋㅋㅋ

그래서 스토어에 내 프로그램이 내려갔고, 홍보 글에 올린 스토어 링크에는 404가 뜨는 이상한 상황이 펼쳐졌다..

취소를 취소하는 방법을 찾아보았지만, 방법이 없었다. 게시 취소하는 것도 구글에서 검토를 하고 승인받아야 했다. 언제 승인될지 몰라 내가 할 수 있는 건 하염없이 기다리는 것 밖에 없었다ㅠ 그래서 일단 홍보 글에 복구 중이라고 양해를 구하고 기다렸다.

다행히 다음 날 저녁에 승인을 받아서 빠르게 재업로드를 했다..ㅎㅎ (오히려 링크 막혀서 사람들이 많이 스크랩해 주신 것 같다ㅎ)

홍보 결과

에브리타임에브리타임 크롬 스토어크롬 스토어 웨일 스토어웨일 스토어

감사하게도 많은 분들이 관심을 주셔서 놀랐다. 댓글과 피드백 폼에 적극적으로 피드백 주셔서 도움이 많이 되었다.

덕분에 해결한 에러들도 있었고, 추가하면 좋은 기능들도 많이 알려주셔서 적극 반영할 예정이다.

이제부터 시작

홍보를 하고 Sentry에 많은 에러 로그들이 찍혀서 당황했다. 그제야 테스트의 중요성을 깨달아 조금조금씩 테스트 코드를 작성하고 있다.

배포하고 끝! 이 아니라 이제 시작인 것 같다. 앞으로 계속 개선해 나가면서 사용자들에게 더 좋은 기능을 제공하고 싶다.

후기

이렇게 첫 익스텐션을 개발하고 배포까지 해봤는데, 감사하게도 많은 학우분들이 관심을 가지고 이용해 주셔서 많은 경험을 할 수 있었다.

실 사용자가 이용하는 서비스를 만든 건 처음이라서 더욱 얻어간 게 많은 프로젝트였다.

크롬 익스텐션으로 프로젝트를 한 번 해보니 이거로 할 수 있는 게 많다고 느꼈다. 다음 사이드 프로젝트를 할 때 익스텐션을 만드는 것도 괜찮을 것 같다!

I developed a Chrome extension that lets you view all your assignments at a glance on Gachon University's Cyber Campus.

Chrome Extension Store

You can also check it out on GitHub.

Why I Built This

Last year, when COVID restrictions had just been lifted, our university was running classes with a mix of in-person and online sessions.

At one point, a professor canceled class and replaced attendance with a recorded lecture.

At the time, I was taking 21 credits, and between other assignments and tasks, I completely forgot that I had a recorded lecture to watch. The deadline was before the next class session, and I was sitting in the classroom relaxed when a friend next to me asked if I had watched the lecture -- that's when I suddenly remembered.

I ended up being marked as late, and while I felt it was unfair, I had no excuse since it was my own fault for forgetting.

There must be quite a few students like me who forgot about assignments, missed recorded lectures, or failed to submit their work. Especially for students taking many credits, managing assignments must be even harder. So I decided to build an extension that lets you see at a glance what assignments you need to do and when they're due.


How Do You Build an Extension?

To build a Chrome extension, you need to understand how it's structured.

manifest.json - A specification file containing the manifest version, extension name, version, etc.

background - A script that runs as a service worker, responding to browser events

content script - A script that can be directly injected into web pages

popup - The popup window that appears when you click the extension icon

option - The page that appears when you click settings in the extension management page

Can You Build Extensions with React?

Using a Boilerplate

Fortunately, there was a boilerplate available, so I used it as a reference for the setup.

Warning

Content scripts run in a safe js environment, meaning an isolated space where ES Modules cannot be used, so you need to use dynamic imports. Reference

The boilerplate was structured this way for that reason.

// pages/content/index.ts
import('./main');

export {};

// pages/content/main.tsx
import Content from '@pages/content/Content';
import { createRoot } from 'react-dom/client';

const root = document.createElement('div');
root.id = 'root';

document.body.appendChild(root);

createRoot(root).render(<Content />);

It uses import() for dynamic importing to access main.tsx.

Problem 1: Error When Code Splitting with React.Lazy

I used React's lazy for code splitting to enable Suspense functionality, but when I built and tested it, an error occurred.

ERROR: Expected ")" but found "."

The dynamicImport shown in the error was something converted by a custom plugin in the boilerplate, and the error was caused by that plugin.

import type { PluginOption } from 'vite'

export default function customDynamicImport(): PluginOption {
  return {
    name: 'custom-dynamic-import',
    renderDynamicImport() {
      return {
        left: `
        {
          const dynamicImport = (path) => import(path);
          dynamicImport(
          `,
        right: ')}',
      }
    },
  }
}

It seems the error occurred because it wraps with curly braces at the end.

Attempt 1 - Disabling the Plugin (Resolved)

So I proceeded without applying this plugin, and that particular error disappeared. However, the next error appeared immediately.

Problem 2: Browser Console Error Saying exports Cannot Be Read When Testing the Extension

  1. Is this an error caused by customizing the build through Vite's rollupOptions? (No)
  • Removed rollupOptions and tested with just the bundled files and a manually written manifest -> Same error occurred
  1. Is additional configuration needed? (Partially)
  • What if I set the base configuration in vite.config so that asset files are read correctly?
// vite.config.js
export default defineConfig({
  build: {
    base: './',
    rollupOptions: {
      // ...
    },
  },
})

After specifying the base root like this and bundling, another error appeared because the entry file was using import.meta.url.

// content script entry file
const __vitePreload = function preload(baseModule, deps, importerUrl) {
  //...
}

__vitePreload(
  () => import('../../../assets/js/main.096ede36.js'),
  true ? ['../../../assets/js/main.096ede36.js', '../../../assets/js/client.8f342e30.js'] : void 0,
  import.meta.url
)

Attempt 1: Creating a Custom Plugin

I looked into whether there was a way to change that by digging through the Vite documentation and the Rollup documentation.

After a long search, I found a hook called resolveImportMeta in Rollup. This hook allows you to directly manipulate import.meta used in ES Modules.

import.meta.url provides the full URL of the module, and to point it to the extension's file path, you can use chrome.runtime.getURL. I created a custom plugin like this:

import type { PluginOption } from 'vite'

export default function resolveMetaChromeExtension(): PluginOption {
  return {
    name: 'resolve-meta-chrome-extension',
    resolveImportMeta: (property) => {
      if (property === 'url') {
        return 'chrome.runtime.getURL("")'
      }
    },
  }
}

Applying this plugin resolved the error caused by the base root, but the original export issue from the first error still remained.

Attempt 2: Setting build.modulePreload to false in Vite Config (Resolved)

Vite pre-caches modules during the build. So Vite exports a function called __vitePreload in the entry file, which handles module preloading automatically. This causes an error because, as mentioned in the warning above, ESM cannot be used in the entry file.

Why did the error occur when using React.Lazy?

The entry file imports main.tsx, and main.tsx renders App.tsx.

However, since Lazy dynamically imports components, it needs to access the __vitePreload function in the entry file. That's why Vite exports that function from the entry file.

const A = reactExports.lazy(() =>
  __vitePreload(
    () => import('./A-192aa1d7.js'),
    true
      ? ['assets/A-192aa1d7.js', 'assets/globals-0034e337.js', 'assets/globals-14f4290f.css']
      : void 0
  )
)

What is vitePreload?

There was an explanation in the official documentation.

Vite automatically transforms Direct Import statements to <link ref="modulepreload"> during the build to pre-cache modules.
When the module is needed, it can be used immediately.

The "Direct Import" mentioned here seems to refer to dynamic imports.

It literally pre-loads modules by caching them in advance.

Vite allows configuring modulePreload as a build option, which resolved the issue above.

Using the CRXJS Library

Until deploying the MVP, I used the boilerplate for setup, but there were some cumbersome aspects, so I switched to the Vite plugin provided by the CRXJS library.

Using this library provided many benefits.

  1. Being able to use Vite's HMR feature sped up development
  • Build -> start dev and load the extension, then you can see changes immediately whenever something is modified.
  1. No longer needed to separately update the version in both package.json and manifest.json when the extension version is updated
  • Only package.json needs to be changed.

  • Since the manifest is automatically generated during the build, management became much easier.

How Should the UI Look? I Want It to Be as Non-intrusive as Possible...

  1. The first location I considered was where the course list is shown on the main page.

I wanted to place it right below View All Courses, but the course list would be pushed down, so I passed on that.

  1. Bottom-right corner of the page

I thought it would be nice to create a circular button in the bottom-right corner like Channel Talk, showing a modal when clicked.

But there was already a TOP button in the bottom-right corner that scrolls to the top, which would ruin the UI. Pass on this too.

  1. Bottom-center of the page

Similar to option 2, but placed at the bottom-center with a modal appearing when clicked. I placed it where it would be easy to click while being as non-intrusive as possible to users.

The position wasn't perfect, but I went with this approach for now. (Planning to update so users can customize the button position later.)

When I actually used it, the small circle was surprisingly hard to click. So I made the circle expand horizontally when you hover over it, widening the clickable area.

The animation was easily implemented using the Framer-motion library.

Additionally, I used the same library to create smooth effects for when the modal appears and disappears, and when filters are toggled on and off.

Fetching Assignment Data

Initially, I fetched page documents via GET requests and parsed the data manually using JavaScript's built-in methods.

However, as the amount of data to fetch grew and some parts became difficult to manipulate, I switched to using the cheerio library.

Crawling Logic

  1. Get the id and title from the My Courses page on Cyber Campus

  2. Use the course id to fetch the document of that course page and extract the desired information (assignments, recorded lectures)

Problem 1: The Course Page Doesn't Show Submission/Viewing Status!

Fortunately, there was a separate assignment page, so I crawled that page. However, the start date wasn't available there, so I mapped the data from the course page with the data from the assignment page using the id value.

Problem 2: Can't Get Recorded Lecture IDs from the Recorded Lecture Page

By comparing the viewing time and the required attendance time on the Learning Progress or Online Attendance page, you could get this information, but the problem was that I couldn't get the id of the specific recorded lecture.

So I couldn't map them using a unique value like I did with assignments.

Attempt 1: Mapping by Recorded Lecture Title

At first, I mapped by title value. At least for the courses I was taking, there were no cases of duplicate recorded lecture titles, so it worked fine during testing. I think I just assumed it would work fine even if assignment titles were the same.

But of course, the problem eventually surfaced. Several users reported issues through feedback, saying the viewing status of recorded lectures was being marked incorrectly.

Even from the screenshots, the crawling logic seemed fine, so I was puzzled. Fortunately, a friend who was taking that course let me test with their account, and I discovered the issue was in this part.

Attempt 2: Mapping by Recorded Lecture Title + Week Title

Since there was no unique value, I had no choice but to go with this approach. The week title was the data that had the least overlap.

I retrieved the data-original-title value and added it to the recorded lecture data,

and added the week title from the course page to the recorded lecture data, then mapped the two datasets by recorded lecture title + week title.

However, if there are recorded lectures with the same title in the same week, it becomes problematic.

In such cases, I think the solution would be to let users manually modify the viewing status.


Deployment

Deployment is simply done by compressing the bundled files and uploading them. After the initial deployment, instead of immediately promoting on Everytime (the university community), I spent a few days only promoting to friends for testing.

As expected, there were quite a few bugs, which gave me plenty of opportunities to refactor.

Introducing Sentry

Before promoting on Everytime, I introduced Sentry, an error logging system, to catch any potential errors. It not only tracks where errors occur but also provides features like replaying user actions as video before the error happened.

The setup was straightforward.

Just do the init setup and error logs start accumulating when errors occur. I wrapped it with Sentry's ErrorBoundary so that a Toast would appear when an error occurs.

Sentry.init({
  dsn: import.meta.env.VITE_APP_SENTRY_DSN,
  environment: import.meta.env.MODE,
  release: version,
  integrations: [
    new Sentry.Integrations.Breadcrumbs({ console: true }),
    new Sentry.BrowserTracing(),
  ],
  tracesSampleRate: 1.0,
  replaysSessionSampleRate: 0.1,
  replaysOnErrorSampleRate: 1.0,
})
import { ErrorBoundary } from '@sentry/react'
import { motion } from 'framer-motion'
import { useRef, useState } from 'react'

import ContentModal from '@/components/domains/ContentModal'
import Toast from '@/components/uis/Toast'
import Portal from '@/helpers/portal'

export default function App() {
  const [isModalOpen, setIsModalOpen] = useState(false)
  const modalRef = useRef()

  const handleModalClick = (event: React.MouseEvent) => {
    if (event.target === modalRef.current) setIsModalOpen(false)
  }

  return (
    <div className="fixed bottom-[25px] left-1/2 translate-x-[-50%]">
      <motion.div
        initial={{ width: '40px', height: '40px' }}
        whileHover={{ width: '100px', height: '50px' }}
        className="cursor-pointer rounded-[50px] bg-[#2F6EA2] shadow-md shadow-[#2F6EA2]"
        onClick={() => setIsModalOpen((prev) => !prev)}
      ></motion.div>
      <Portal elementId="modal">
        <ErrorBoundary fallback={<Toast message="An error has occurred." type="error" />}>
          <ContentModal ref={modalRef} onClick={handleModalClick} isOpen={isModalOpen} />
        </ErrorBoundary>
      </Portal>
    </div>
  )
}

I also used captureException to receive logs for identifying edge cases during the crawling process.

if (!id) {
  captureException(
    new Error(`getVideoAtCourseDocument: id is missing. ${title} / ${startAt} / ${endAt}`)
  )
}

However, since I didn't have a separate admin account, I couldn't accurately identify errors. Currently, I'm only using it to check for edge cases.

Deployment Automation (Github Actions)

It was cumbersome to compress and deploy manually with every version update. So I automated the process. I implemented it using an Action that handles uploads.

name: Publish

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    name: Publish webextension
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 16.x
      - name: Build
        run: |
          yarn install
          yarn build
      - name: Make zip file
        run: zip -r ./build.zip ./dist
        shell: bash
      - name: Upload & release
        uses: mnao305/chrome-extension-upload@v4.0.1
        with:
          file-path: ./build.zip
          extension-id: 'ogldncimhepjdfadhjjhkchknloncnmg'
          client-id: ${{ secrets.CLIENT_ID }}
          client-secret: ${{ secrets.CLIENT_SECRET }}
          refresh-token: ${{ secrets.REFRESH_TOKEN }}

Now a tag push automatically handles the deployment, which is much more convenient.

Promotion, But...

I posted this promotional post. Thankfully, it received a lot of attention and quickly made it to the HOT board.

But a problem arose.

After deploying that day's work, I was about to go to sleep while it was in review pending status. Then I realized I had uploaded the wrong version, so I rushed to cancel it.

I clicked the Unpublish button, but this wasn't for canceling the review -- it was for unpublishing the program from the store... lol

So my program was taken down from the store, and the store link in my promotional post started showing a 404 -- a very awkward situation.

I searched for a way to undo the cancellation, but there was no way. Even unpublishing required Google's review and approval. I had no idea when it would be approved, so all I could do was wait endlessly. I posted on the promotional thread asking for understanding while it was being restored.

Fortunately, it was approved the next evening, and I quickly re-uploaded it. (Ironically, the broken link might have led more people to bookmark the post.)

Promotion Results

EverytimeEverytime Chrome StoreChrome Store Whale StoreWhale Store

I was surprised by how much interest people showed. The comments and active feedback through the feedback form were incredibly helpful.

Thanks to the feedback, I was able to fix some errors and learned about many useful features to add, which I plan to actively incorporate.

Just the Beginning

After promoting, I was startled by the flood of error logs in Sentry. That's when I truly realized the importance of testing, and I've been gradually writing test code since then.

It's not "deploy and done!" -- it feels like this is just the beginning. I want to keep improving and provide users with even better features going forward.

Reflections

Having developed and deployed my first extension like this, I'm grateful that many fellow students showed interest and used it, which gave me a lot of valuable experience.

This was my first time creating a service used by real users, which made this project one where I learned more than any other.

Having done a project as a Chrome extension, I realized there's so much you can do with it. I think building an extension for my next side project could be a great idea!