diff --git a/.env.example b/.env.example index 1998992..6589bb8 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,8 @@ # code; non-prefixed vars are silently ignored. # --------------------------------------------------------------------------- -# REQUIRED — build fails fast if missing. +# REQUIRED for Chrome builds — build fails fast if missing. +# Optional for `npm run build:safari`. # # Macify CDN base URL. Single host that serves both: # - GET /itunes-assets/... → reverse proxy to sylvan.apple.com diff --git a/.gitignore b/.gitignore index 266d215..2a68717 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ dist/ releases/ *.zip +# Generated by `xcrun safari-web-extension-packager`; contains local signing settings. +Macify - Gorgeous Aerial Videos as Your New Tab/ + # Private AI context (per-machine, not for the public repo) local-context.md CLAUDE.md diff --git a/README.md b/README.md index 331feb9..1166096 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Replace Chrome's new tab page with macOS's aerial screensaver videos and a small [Install from Chrome Web Store](https://chromewebstore.google.com/detail/macify-macos-screensaver/lgdipcalomggcjkohjhkhkbcpgladnoe). Building from source or contributing? See [DEVELOPMENT.md](DEVELOPMENT.md). +For an experimental local Safari build, see [SAFARI.md](SAFARI.md). ## Choosing a video source diff --git a/SAFARI.md b/SAFARI.md new file mode 100644 index 0000000..2a8117d --- /dev/null +++ b/SAFARI.md @@ -0,0 +1,38 @@ +# Safari self-use build + +This fork defaults to direct Apple streaming for Safari: + +- video source: Apple Server +- reverse proxy: off +- local video server: not required +- zen music: off unless `VITE_MACIFY_BASE` is configured + +The video playlist still points at Apple's `https://sylvan.apple.com` aerial +assets. Nothing needs to be downloaded locally. + +## Build + +```bash +npm install +npm run build:safari +``` + +The extension bundle is written to `dist/`. + +## Package for Safari + +Install full Xcode first. Apple's Safari converter is not included with the +Command Line Tools-only install. + +```bash +npm run package:safari +``` + +Open the generated Xcode project, sign it for local development, run the app, +then enable the extension in Safari settings. + +## Optional proxy + +If direct Apple playback fails in Safari, set up your own Cloudflare Worker +using `cloudflare-worker/worker.js`, set `VITE_MACIFY_BASE`, rebuild, and turn +on "Use reverse proxy" in the extension settings. diff --git a/package.json b/package.json index e2a26f4..540ac4f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build --mode chrome", + "build:safari": "vite build --mode safari && node scripts/prepare-safari-dist.mjs", + "package:safari": "npm run build:safari && xcrun safari-web-extension-packager dist --macos-only", "preview": "vite preview", "zip": "node scripts/zip.js", "build:quotes": "node scripts/build-quotes.mjs", diff --git a/scripts/prepare-safari-dist.mjs b/scripts/prepare-safari-dist.mjs new file mode 100644 index 0000000..c7409ce --- /dev/null +++ b/scripts/prepare-safari-dist.mjs @@ -0,0 +1,87 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); +const dist = resolve(root, 'dist'); +const manifestPath = resolve(dist, 'manifest.json'); +const safariLocaleOverrides = { + en: { + ext_description: + "Transform your new tab into macOS's gorgeous aerial screensaver videos.", + video_apple_help_intro: + "Some browsers may not trust Apple's sylvan.apple.com certificate, so direct video loading can fail. Two ways to make it work:", + video_apple_help_option2_body_post: + 'in the target browser once. If you see a security warning, open the advanced option and proceed. The browser should remember your trust after that.', + options_translation_description: + 'Translates English mottos into your display language when the browser provides an on-device Translator API. No new network requests beyond the one-time model download.', + options_translation_status_no_api: + "Your browser doesn't support the Translator API", + }, + ja: { + ext_description: + '新しいタブをmacOSの美しいエアリアル・スクリーンセーバー動画に変えます。', + video_apple_help_intro: + '一部のブラウザでは Apple の sylvan.apple.com の証明書を信頼せず、動画の直接読み込みに失敗する可能性があります。2つの解決方法:', + video_apple_help_option2_body_pre: '対象ブラウザで', + video_apple_help_option2_body_post: + 'を一度開きます。セキュリティ警告が表示されたら、詳細オプションを開いて続行してください。ブラウザが信頼を記憶すれば、以降は直接接続が機能します。', + options_translation_description: + 'ブラウザが on-device Translator API を提供している場合、英語の格言を表示言語に翻訳します。初回のモデルダウンロード以外、新たなネットワークリクエストはありません。', + options_translation_status_no_api: + 'お使いのブラウザは Translator API 未対応です', + }, + zh_CN: { + ext_description: '将新标签页变成 macOS 的华丽航拍屏保视频。', + video_apple_help_intro: + '部分浏览器不一定信任苹果 sylvan.apple.com 的证书,直连视频可能失败。两种解决方案:', + video_apple_help_option2_body_pre: '在目标浏览器里打开', + video_apple_help_option2_body_post: + '一次。如果出现安全警告,打开高级选项并继续访问。浏览器通常会记住你的信任,之后直连就没问题了。', + options_translation_description: + '当浏览器提供 on-device Translator API 时,把英文格言翻译成你设置的显示语言。除首次模型下载外,无任何新的网络请求。', + options_translation_status_no_api: '你的浏览器不支持 Translator API', + }, + zh_TW: { + ext_description: '將新分頁變成 macOS 的華麗空拍螢幕保護影片。', + video_apple_help_intro: + '部分瀏覽器不一定信任蘋果 sylvan.apple.com 的憑證,直連影片可能失敗。兩種解決方案:', + video_apple_help_option2_body_pre: '在目標瀏覽器裡開啟', + video_apple_help_option2_body_post: + '一次。如果出現安全性警告,請開啟進階選項並繼續前往。瀏覽器通常會記住你的信任,之後直連就沒問題了。', + options_translation_description: + '當瀏覽器提供 on-device Translator API 時,把英文格言翻譯成你設定的顯示語言。除首次模型下載外,無任何新的網路請求。', + options_translation_status_no_api: '你的瀏覽器不支援 Translator API', + }, +}; + +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + +// The background script only opens the options page on install/update. +// Removing it avoids Safari service-worker module compatibility warnings. +delete manifest.background; + +if (Array.isArray(manifest.permissions)) { + manifest.permissions = manifest.permissions.filter( + (p) => !['topSites', 'favicon', 'idle'].includes(p), + ); + if (manifest.permissions.length === 0) { + delete manifest.permissions; + } +} + +await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + +for (const [locale, overrides] of Object.entries(safariLocaleOverrides)) { + const messagesPath = resolve(dist, '_locales', locale, 'messages.json'); + const messages = JSON.parse(await readFile(messagesPath, 'utf8')); + for (const [key, message] of Object.entries(overrides)) { + if (messages[key]) { + messages[key].message = message; + } + } + await writeFile(messagesPath, JSON.stringify(messages, null, 2) + '\n'); +} + +console.log('Prepared Safari-compatible dist assets'); diff --git a/src/lib/defaults.js b/src/lib/defaults.js index e067b32..642d612 100644 --- a/src/lib/defaults.js +++ b/src/lib/defaults.js @@ -1,3 +1,5 @@ +const isSafari = import.meta.env.VITE_BUILD_TARGET === 'safari'; + export const DEFAULTS = Object.freeze({ userLanguage: 'auto', city: 'San Francisco', @@ -9,18 +11,18 @@ export const DEFAULTS = Object.freeze({ hourSystem: '24', showWeather: true, showMotto: true, - showTopSites: true, + showTopSites: !isSafari, showZenMode: true, videoSourceUrl: 'http://localhost:18000/videos/', refreshButton: true, tempUnit: 'celsius', authorInfo: true, videoSrc: 'apple', - reverseProxy: true, + reverseProxy: !isSafari, shuffleScopes: ['all'], showVideoMetadata: true, translateMotto: false, - zenMusic: true, + zenMusic: !isSafari, zenBreathingPattern: 'coherent', // 'off' | 'coherent' | 'box' | '478' zenReminderEnabled: false, zenReminderMinutes: 60, diff --git a/src/lib/storage.js b/src/lib/storage.js index 8169d5a..86beb4f 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -1,5 +1,7 @@ import { DEFAULTS, KNOWN_KEYS } from './defaults.js'; +const memorySession = new Map(); + // Convert legacy stored keys to their replacements before defaults are // filled in. Each migration is one-shot: it runs only if the legacy // key is still present, then deletes it. Adding a new entry here is @@ -54,3 +56,52 @@ export const cache = { await chrome.storage.local.remove(key); }, }; + +export const sessionCache = { + area() { + return chrome.storage?.session; + }, + async get(keys) { + const area = this.area(); + if (area) return area.get(keys); + + if (keys == null) { + return Object.fromEntries(memorySession.entries()); + } + const result = {}; + if (typeof keys === 'object' && !Array.isArray(keys)) { + for (const [key, fallback] of Object.entries(keys)) { + result[key] = memorySession.has(key) ? memorySession.get(key) : fallback; + } + return result; + } + const list = Array.isArray(keys) ? keys : [keys]; + for (const key of list) { + if (memorySession.has(key)) { + result[key] = memorySession.get(key); + } + } + return result; + }, + async set(values) { + const area = this.area(); + if (area) { + await area.set(values); + return; + } + for (const [key, value] of Object.entries(values)) { + memorySession.set(key, value); + } + }, + async remove(keys) { + const area = this.area(); + if (area) { + await area.remove(keys); + return; + } + const list = Array.isArray(keys) ? keys : [keys]; + for (const key of list) { + memorySession.delete(key); + } + }, +}; diff --git a/src/lib/topsites.js b/src/lib/topsites.js index acefcbd..9d3f357 100644 --- a/src/lib/topsites.js +++ b/src/lib/topsites.js @@ -1,3 +1,5 @@ +const isSafari = import.meta.env.VITE_BUILD_TARGET === 'safari'; + export async function getTopSites() { if (!chrome.topSites?.get) return []; try { @@ -13,9 +15,20 @@ export async function getTopSites() { } export function faviconUrlFor(pageUrl, size = 32) { - return chrome.runtime.getURL( - `_favicon/?pageUrl=${encodeURIComponent(pageUrl)}&size=${size}`, - ); + if (!isSafari) { + return chrome.runtime.getURL( + `_favicon/?pageUrl=${encodeURIComponent(pageUrl)}&size=${size}`, + ); + } + + const label = hostnameOf(pageUrl) + .replace(/^www\./, '') + .slice(0, 1) + .toUpperCase(); + const radius = Math.round(size * 0.22); + const fontSize = Math.round(size * 0.52); + const svg = `${label || '?'}`; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; } function hostnameOf(url) { diff --git a/src/lib/video-source.js b/src/lib/video-source.js index a5dabf2..abac543 100644 --- a/src/lib/video-source.js +++ b/src/lib/video-source.js @@ -4,7 +4,8 @@ import { cache } from './storage.js'; // Apple's modern aerial CDN. Reverse proxy mode swaps the host; // the path stays the same (Worker must accept /itunes-assets/*). // -// VITE_MACIFY_BASE is required at build time (vite.config.js enforces). +// VITE_MACIFY_BASE is optional. When omitted, Apple mode streams directly +// from sylvan.apple.com, which is the Safari-friendly self-use default. // VITE_APPLE_PROXY_KEY is optional — when set, it's appended as ?k= // to every video request so a Cloudflare firewall rule in front of the // worker can drop callers that don't know it. Token is build-time inlined @@ -13,6 +14,7 @@ import { cache } from './storage.js'; const APPLE_HOST = 'https://sylvan.apple.com'; const APPLE_PROXY_HOST = import.meta.env.VITE_MACIFY_BASE; const APPLE_PROXY_KEY = import.meta.env.VITE_APPLE_PROXY_KEY ?? ''; +const HAS_APPLE_PROXY = Boolean(APPLE_PROXY_HOST); const LOCAL_CACHE_KEY = 'localVideoList'; const LOCAL_CACHE_TTL_MS = 24 * 60 * 60 * 1000; @@ -33,7 +35,7 @@ export function isAppleProxyFailed() { } function applyProxy(url, useProxy) { - if (!useProxy) return url; + if (!useProxy || !HAS_APPLE_PROXY) return url; const proxied = url.replace(APPLE_HOST, APPLE_PROXY_HOST); return APPLE_PROXY_KEY ? `${proxied}?k=${APPLE_PROXY_KEY}` : proxied; } @@ -69,7 +71,7 @@ export async function getPlaylist({ videoSrc, videoSourceUrl, reverseProxy }) { } function getApplePlaylist(reverseProxy) { - const useProxy = reverseProxy && !appleProxyFailedThisSession; + const useProxy = reverseProxy && HAS_APPLE_PROXY && !appleProxyFailedThisSession; const items = videos.map((v) => ({ url: applyProxy(v.url, useProxy), meta: metaFromVideo(v), diff --git a/src/lib/zen-tracking.js b/src/lib/zen-tracking.js index 958fc27..1a4e028 100644 --- a/src/lib/zen-tracking.js +++ b/src/lib/zen-tracking.js @@ -11,8 +11,11 @@ // fully resets the accumulator — the user already took the break we // would have nudged them toward. // -// All state lives in chrome.storage.session so it wipes on Chrome cold -// start; opening a fresh browser shouldn't surface a stale pill. +// All state lives in sessionCache, which delegates to chrome.storage.session +// when available so it wipes on Chrome cold start. Safari builds that lack +// storage.session fall back to in-memory state, preserving the same +// non-persistent cold-start reset semantics without carrying stale reminders +// across restarts. // // Storage shape: // zenActiveStartedAt — ms; current active period began here. @@ -22,6 +25,8 @@ // Cleared on return. // zenSnoozedUntil — ms; reminder is suppressed until this time. +import { sessionCache } from './storage.js'; + // Treat away periods of at least this long as a real break. // Shorter than this: pause and resume so a quick coffee run doesn't // reset a real working session. Longer: assume the body got what the @@ -47,7 +52,7 @@ const KEYS = [ ]; async function read() { - return chrome.storage.session.get(KEYS); + return sessionCache.get(KEYS); } /** @@ -63,8 +68,8 @@ export async function beginAway(now = Date.now()) { const delta = Math.max(0, now - s.zenActiveStartedAt); set.zenAccumulatedActiveMs = (s.zenAccumulatedActiveMs || 0) + delta; } - await chrome.storage.session.set(set); - await chrome.storage.session.remove('zenActiveStartedAt'); + await sessionCache.set(set); + await sessionCache.remove('zenActiveStartedAt'); } /** @@ -78,22 +83,22 @@ export async function endAway(now = Date.now()) { const s = await read(); if (s.zenAwayAt == null) { if (s.zenActiveStartedAt == null) { - await chrome.storage.session.set({ zenActiveStartedAt: now }); + await sessionCache.set({ zenActiveStartedAt: now }); } return; } const breakMs = now - s.zenAwayAt; if (breakMs >= NATURAL_BREAK_MS) { - await chrome.storage.session.set({ + await sessionCache.set({ zenActiveStartedAt: now, zenAccumulatedActiveMs: 0, }); // The user already broke; clear any pending snooze too — they // shouldn't come back to a silenced reminder from before the break. - await chrome.storage.session.remove(['zenAwayAt', 'zenSnoozedUntil']); + await sessionCache.remove(['zenAwayAt', 'zenSnoozedUntil']); } else { - await chrome.storage.session.set({ zenActiveStartedAt: now }); - await chrome.storage.session.remove('zenAwayAt'); + await sessionCache.set({ zenActiveStartedAt: now }); + await sessionCache.remove('zenAwayAt'); } } @@ -116,18 +121,18 @@ export async function getEffectiveActiveMs(now = Date.now()) { /** Called by enterZen(): the user is taking a break, restart the cycle. */ export async function resetTracking(now = Date.now()) { - await chrome.storage.session.set({ + await sessionCache.set({ zenAccumulatedActiveMs: 0, zenActiveStartedAt: now, }); - await chrome.storage.session.remove(['zenAwayAt', 'zenSnoozedUntil']); + await sessionCache.remove(['zenAwayAt', 'zenSnoozedUntil']); } export async function snoozeReminder(durationMs, now = Date.now()) { - await chrome.storage.session.set({ zenSnoozedUntil: now + durationMs }); + await sessionCache.set({ zenSnoozedUntil: now + durationMs }); } export async function isSnoozed(now = Date.now()) { - const { zenSnoozedUntil } = await chrome.storage.session.get('zenSnoozedUntil'); + const { zenSnoozedUntil } = await sessionCache.get('zenSnoozedUntil'); return !!zenSnoozedUntil && now < zenSnoozedUntil; } diff --git a/src/lib/zen.svelte.js b/src/lib/zen.svelte.js index 24b8c6d..56e94be 100644 --- a/src/lib/zen.svelte.js +++ b/src/lib/zen.svelte.js @@ -10,7 +10,8 @@ import { settings } from './settings.svelte.js'; import { resetTracking } from './zen-tracking.js'; -const MUSIC_BASE = `${import.meta.env.VITE_MACIFY_BASE}/music/`; +const MACIFY_BASE = import.meta.env.VITE_MACIFY_BASE; +const MUSIC_BASE = MACIFY_BASE ? `${MACIFY_BASE}/music/` : ''; const TRACK_COUNT = 40; // Final 5 seconds of an auto-exit session: ramp music volume to 0 so @@ -32,6 +33,7 @@ export function bindAudioElement(el) { } function randomTrackUrl() { + if (!MUSIC_BASE) return ''; const n = Math.floor(Math.random() * TRACK_COUNT) + 1; return MUSIC_BASE + `music${String(n).padStart(5, '0')}.mp3`; } @@ -106,7 +108,7 @@ export async function enterZen() { } // Music — opt-out via settings. - if (settings.zenMusic && audioEl) { + if (settings.zenMusic && audioEl && MUSIC_BASE) { try { // Reset to full volume in case a previous session faded it down. audioEl.volume = 1; diff --git a/vite.config.js b/vite.config.js index 4889d02..f7ed464 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,30 +5,29 @@ import tailwindcss from '@tailwindcss/vite'; import Icons from 'unplugin-icons/vite'; import manifest from './src/manifest.config.js'; -// Required env vars — build fails fast if any of these are missing or -// blank. Inlined into the published bundle. See .env.example for what -// each one is for. const REQUIRED_ENV = ['VITE_MACIFY_BASE']; export default defineConfig(({ mode }) => { - // loadEnv() resolves its envDir argument relative to process.cwd(), - // which is already the project root when invoked via `npm run build`. - // The vite `envDir` config is resolved relative to vite's `root` - // (= 'src'), so '..' points to the same project root. + const target = mode === 'safari' ? 'safari' : 'chrome'; const env = loadEnv(mode, process.cwd(), 'VITE_'); - const missing = REQUIRED_ENV.filter((k) => !env[k] || !env[k].trim()); - if (missing.length > 0) { - throw new Error( - `\n\nMissing required env var(s): ${missing.join(', ')}\n` + - `Set them in .env (or .env.local). See .env.example for what each one is for.\n`, - ); + if (target === 'chrome') { + const missing = REQUIRED_ENV.filter((k) => !env[k] || !env[k].trim()); + if (missing.length > 0) { + throw new Error( + `\n\nMissing required env var(s): ${missing.join(', ')}\n` + + `Set them in .env (or .env.local). See .env.example for what each one is for.\n`, + ); + } } return { root: 'src', envDir: '..', publicDir: false, + define: { + 'import.meta.env.VITE_BUILD_TARGET': JSON.stringify(target), + }, build: { outDir: '../dist', emptyOutDir: true,