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 = ``;
+ 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,