Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <BASE>/itunes-assets/... → reverse proxy to sylvan.apple.com
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 38 additions & 0 deletions SAFARI.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
87 changes: 87 additions & 0 deletions scripts/prepare-safari-dist.mjs
Original file line number Diff line number Diff line change
@@ -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');
8 changes: 5 additions & 3 deletions src/lib/defaults.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const isSafari = import.meta.env.VITE_BUILD_TARGET === 'safari';

export const DEFAULTS = Object.freeze({
userLanguage: 'auto',
city: 'San Francisco',
Expand All @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions src/lib/storage.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
}
},
};
19 changes: 16 additions & 3 deletions src/lib/topsites.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const isSafari = import.meta.env.VITE_BUILD_TARGET === 'safari';

export async function getTopSites() {
if (!chrome.topSites?.get) return [];
try {
Expand All @@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect width="${size}" height="${size}" rx="${radius}" fill="rgba(255,255,255,0.18)"/><text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif" font-size="${fontSize}" font-weight="600" fill="white">${label || '?'}</text></svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}

function hostnameOf(url) {
Expand Down
8 changes: 5 additions & 3 deletions src/lib/video-source.js
Original file line number Diff line number Diff line change
Expand Up @@ -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=<token>
// 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
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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),
Expand Down
Loading