گنجه افزونهای است که صفحهی «تب جدید» Chrome و Firefox را با داشبوردی عوض میکند که کاربر خودش میچیند: ساعت، آبوهوا، تقویم شمسی، کارها، یادداشت، قیمت ارز و رمزارز و میانبرها. اینجا مینویسم چطور ساخته شده و انتشارش در Chrome Web Store چه چیزهایی لازم داشت.
ایده: میزکاری آرام روی تصویر زمینه
تب جدید را روزی دهها بار باز میکنیم و هر بار چند ثانیه نگاهش میکنیم؛ پس نباید برای جلب توجه بجنگد. اصل اول طراحی این است که تصویر زمینه بوم کار است و هر سطحی روی آن شیشهی نیمهشفاف است، نه پنلی مات. چند token مثل --glass-bg و --glass-border در index.css برای تم روشن و تیره تعریف شده و همهی مودالها، منوها و ویجتها از همانها استفاده میکنند. ترتیب لایهها را شدت blur و سایه نشان میدهد و هر سطح رنگ پایهی خودش را دارد تا خوانایی متن به تصویر زمینه وابسته نباشد.
پشتهی فرانتاند و گرید
افزونه با React 18 و TypeScript نوشته شده، Vite آن را bundle میکند، استایل با Tailwind است و state با zustand. تقویم شمسی روی date-fns-jalali ساخته شده و تعطیلات هر ماه از سرور گنجه میآید. زبان پیشفرض فارسی است و هر سطح با dir آینه میشود.
ویجتها روی react-grid-layout مینشینند. هر ویجت یک WidgetItem است با شناسه، x، y، w، h، نوع و یک شیء settings آزاد؛ سیزده نوع داریم، از CLOCK تا PORTFOLIO. مختصات همیشه روی گرید پایهی 24ستونه ذخیره میشود و برای عرضهای کوچکتر فقط هنگام نمایش مقیاس میخورد. ذخیره فقط در onDragStop و onResizeStop و در حالت ویرایش انجام میشود؛ onLayoutChange فقط لاگ میگیرد، چون کتابخانه آن را هنگام mount و تغییر breakpoint هم صدا میزند و نوشتن در آن موقعیتها را خراب میکرد.
رفتار کشیدن را دقیق میخواستم: ویجتی که زیر ویجت در حال کشیدن قرار میگیرد فقط به اندازهی لازم کنار برود، اگر از رویش رد شدید همان لحظه برگردد، و جای خالی خودکار پر نشود. جابهجایی داخلی کتابخانه انباشتی است، پس در هر رویداد drag کل چیدمان را از snapshotِ شروع کشیدن دوباره میسازم. این فقط با این props جواب داد:
<ResponsiveGridLayout
width={gridWidth}
layouts={layouts}
cols={COLS}
rowHeight={80}
isDraggable={isEditMode}
compactType={null}
preventCollision={false}
allowOverlap={true}
>
بدون allowOverlap، کتابخانه موقعیتهای محاسبهشده را در همان رویداد بازنویسی میکرد و ویجتها تا رها شدن تکان نمیخوردند.
دوازده نما
«نما» در گنجه پوستهی کامل است، نه فقط رنگ. classic و apple پوستههای مستقلاند و ده نمای دیگر، از پوستر و ترمینال تا کاشی، دفترچه، رترو و فضاپیما، روی لایهی مشترک viewkit ساخته شدهاند و هر کدام پوشه و CSS خودش را دارد. ترمینال خط فرمان واقعی دارد و کاشی هر روز بیتی از حافظ نشان میدهد. نمای انتخابشده در تنظیمات (uiVariant) میماند، جدا از فضاها و ویجتها.
فضاها و ذخیرهی محلی
هر فضا (Workspace) نام، آیکون، تصویر زمینه و ویجتهای خودش را دارد؛ مثلاً یکی برای کار و یکی برای خانه. گنجه بدون حساب کامل کار میکند: storeهای zustand با persist در localStorage نوشته میشوند. دو مشکل واقعی پیش آمد: تصویر آپلودی بزرگ سهمیه را پر میکرد و QuotaExceededError وسط اکشنها پرتاب میشد، که حالا یک storage محافظ آن را میگیرد؛ و تب جدید قدیمی، تغییرهای تبهای دیگر را بیصدا بازنویسی میکرد، که با گوش دادن به رویداد storage و rehydrate کردن store حل شد.
همگامسازی با Django
بکاند یک پروژهی Django REST Framework است که سایت، صفحهی پرداخت و API افزونه را با هم سرو میکند. مدل Workspace ویجتها را در JSONField نگه میدارد، چون هر ویجت شکل settings خودش را دارد. نسخهی اول، PUT /workspaces کل مجموعه را جایگزین میکرد و دستگاهی با دادهی کهنه میتوانست تغییرهای دستگاه دیگر را پاک کند. حالا مجموعه یک etag دارد (SHA-256 از JSON مرتبشده) و هر PUT etag آخرین نسخهی دیدهشده را میفرستد:
with transaction.atomic():
lock_workspaces_of(request.user)
current, current_etag = workspaces_with_etag(
get_workspaces_for_user(request.user)
)
if base != current_etag:
return Response(
{"error": "workspaces_conflict", "code": "WORKSPACES_CONFLICT",
"workspaces": current, "etag": current_etag},
status=status.HTTP_409_CONFLICT,
)
در کلاینت، syncStore هر تغییر محلی را «معوق» علامت میزند و با کمی تأخیر میفرستد؛ با 409، کاربر در یک مودال بین نسخهی خودش و سرور انتخاب میکند. مقایسه معنایی است، چون jsonb در PostgreSQL ترتیب کلیدها را عوض میکند. احراز هویت فقط با هدر Authorization: Bearer است؛ کوکی را کنار گذاشتم، چون CORS هر شناسهی افزونه را میپذیرفت و افزونهی دیگری میتوانست سوارش شود. توکن GitHub پیش از هر PUT حذف میشود و دادهی ویجتها از سرور گنجه میآید، پس هیچ کلید API داخل افزونه نیست.
پلنهای رایگان و پولی
پلن رایگان یک فضا و ده لینک شخصی دارد (لینکهای آماده شمرده نمیشوند) و یک پیام حمایتی کوچک و بدون ردیابی نشان میدهد. پلنهای ماهانه تا سالانه فقط در مدت و قیمت فرق دارند: بیسقفاند، پیام حمایتی ندارند و ویجتهای ویژه (عادت، پرتفوی و GitHub) را باز میکنند. سقفها روی سرور اعمال میشوند و فقط رشد را میبندند: ذخیرهی بیش از سقف با 403 PLAN_LIMIT رد میشود و چیزی پاک نمیشود. با پایان پلن، ویجت ویژه قفل میشود، نه حذف. پرداخت در سایت گنجه و با زیبال یا زرینپال انجام میشود و پلن فقط بعد از تأیید درگاه فعال میشود.
Manifest V3، مجوزها و build
manifest را اسکریپت manifest.mjs هنگام build برای هر مرورگر میسازد:
chrome_url_overrides: { newtab: 'index.html' },
permissions: ['search'],
host_permissions: [...(origin ? [`${origin}/*`] : []), ...DIRECT_HOSTS],
تنها مجوز search است، چون سیاست Chrome Web Store میگوید تب جدیدِ دارای جستجو باید از موتور پیشفرض کاربر استفاده کند؛ پس متن به chrome.search.query() میرود. host_permissions فقط مبدأ API گنجه و دو آدرس ویجت GitHub است. بستهی Chrome دستکم نسخهی 116 را میخواهد (minimum_chrome_version) و بستهی Firefox بخش gecko را میگیرد، با حداقل نسخهی 140 و فهرست دادههایی که از مرورگر بیرون میروند؛ دادههای حساب آنجا اختیاریاند و فقط هنگام ورود پرسیده میشوند.
npm run package اول tsc --noEmit را اجرا میکند و بعد سه zip میسازد: Chrome، Firefox و کد منبع برای داور AMO. دو بسته bundle یکسان دارند و فقط manifestشان فرق دارد. اگر آدرس API یا شناسهی Firefox در .env.production نباشد، بستهای ساخته نمیشود. هیچ کدی از شبکه بارگذاری نمیشود و فونتها داخل بستهاند.
Chrome Web Store
بیشتر کار انتشار، کد نبود. همه را در پوشهی store/ جمع کردم:
- متنها: توضیح فارسی و انگلیسی، دستهی Productivity و یادداشت داور با دو حساب آزمایشی، یکی رایگان و یکی پولی.
- تصویرها: هفت اسکرینشات 1280×800 از نماها و زبانهای مختلف (Chrome حداکثر پنج تا میگیرد) و کاشی تبلیغاتی 440×280.
- تب Privacy: «هدف واحد»، توجیه هر مجوز، «نه» به remote code و فقط چهار دستهی داده: هویتی، مالی، احراز هویت و مکان.
- سیاست حریم خصوصی: صفحهی
/fa/privacy/خود سایت، با نسخهی انگلیسی برای داورها؛ میگوید چه چیزی روی دستگاه میماند، هر ویجت چه میفرستد و اینکه analytics نداریم.
یک مانع هم بیرون از کد بود: طبق مستندات گوگل، ایران در فهرست کشورهایی نیست که Chrome Web Store از آنها حساب توسعهدهنده میپذیرد. راه درست، ناشری در یک کشور پشتیبانیشده است که حساب را به نام خودش میسازد و مرا عضو publisher میکند؛ ثبتنام با VPN یا اطلاعات نادرست ممکن است حساب را ببندد. بعد از تأیید هم شناسهی افزونه در CORS_ALLOWED_ORIGINS ثابت و GANJE_ALLOW_ANY_EXTENSION خاموش میشود.
آنچه یاد گرفتم
- داده را مستقل از اندازهی صفحه ذخیره کنید.
- سینکی که کل مجموعه را جایگزین میکند بدون etag دیر یا زود دادهی کسی را پاک میکند.
- سقف پلن را با رد کردن اعمال کنید، نه با حذف.
- هر مجوز یک جمله توجیه لازم دارد؛ مجوز بیشتر از نیاز از دلیلهای رایج رد شدن است.