diff --git a/electron/main.cjs b/electron/main.cjs index aafbc408..3c8cb03c 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -2,11 +2,153 @@ const { app, BrowserWindow, ipcMain, shell, Menu } = require('electron') const fs = require('fs') +const http = require('http') const path = require('path') /** True when running from source (`electron .`); false when packaged. */ const isDev = !app.isPackaged +/** Stable loopback origin for packaged builds so YouTube embeds get a real `origin` (see YoutubeEmbeddedPlayer). */ +const PACKAGED_STATIC_PORT_START = 45279 +const PACKAGED_STATIC_PORT_TRIES = 40 + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.mjs': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.webmanifest': 'application/manifest+json', + '.txt': 'text/plain; charset=utf-8', + '.wasm': 'application/wasm', + '.map': 'application/json' +} + +let packagedStaticServer = null +/** @type {string | null} */ +let packagedStaticBaseUrl = null +/** @type {Promise | null} */ +let packagedStaticStartPromise = null + +function resolveUnderDist(distDir, pathname) { + let rel = pathname + try { + rel = decodeURIComponent(pathname) + } catch { + return null + } + rel = rel.replace(/^\/+/, '') + const candidate = path.resolve(distDir, rel) + const root = path.resolve(distDir) + const prefix = root.endsWith(path.sep) ? root : root + path.sep + if (candidate !== root && !candidate.startsWith(prefix)) return null + return candidate +} + +function createPackagedStaticHandler(distDir) { + const indexPath = path.join(distDir, 'index.html') + + return function handleRequest(req, res) { + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405).end() + return + } + + let pathname = '/' + try { + pathname = new URL(req.url || '/', 'http://127.0.0.1').pathname + } catch { + pathname = '/' + } + + const sendIndex = () => { + fs.readFile(indexPath, (err, data) => { + if (err) { + res.writeHead(500).end('Missing index.html') + return + } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': data.length }) + res.end(req.method === 'HEAD' ? undefined : data) + }) + } + + const filePath = resolveUnderDist(distDir, pathname) + if (!filePath) { + res.writeHead(403).end() + return + } + + fs.stat(filePath, (err, st) => { + if (!err && st.isFile()) { + const ext = path.extname(filePath).toLowerCase() + const ct = MIME[ext] || 'application/octet-stream' + res.writeHead(200, { 'Content-Type': ct, 'Content-Length': st.size }) + if (req.method === 'HEAD') { + res.end() + return + } + fs.createReadStream(filePath).pipe(res) + return + } + sendIndex() + }) + } +} + +function startPackagedStaticServer(distDir) { + return new Promise((resolve, reject) => { + const handler = createPackagedStaticHandler(distDir) + const server = http.createServer(handler) + + const listenOn = (port, attempt) => { + if (attempt >= PACKAGED_STATIC_PORT_TRIES) { + reject(new Error('No free port for packaged static server')) + return + } + const onErr = (err) => { + server.removeListener('error', onErr) + if (err && err.code === 'EADDRINUSE') { + listenOn(port + 1, attempt + 1) + } else { + reject(err) + } + } + server.on('error', onErr) + server.listen(port, '127.0.0.1', () => { + server.removeListener('error', onErr) + packagedStaticServer = server + const addr = server.address() + const p = typeof addr === 'object' && addr ? addr.port : port + packagedStaticBaseUrl = `http://127.0.0.1:${p}` + resolve(packagedStaticBaseUrl) + }) + } + + listenOn(PACKAGED_STATIC_PORT_START, 0) + }) +} + +function ensurePackagedStaticBaseUrl() { + if (packagedStaticBaseUrl) return Promise.resolve(packagedStaticBaseUrl) + if (!packagedStaticStartPromise) { + const distDir = path.join(__dirname, '..', 'dist') + packagedStaticStartPromise = startPackagedStaticServer(distDir).catch((err) => { + packagedStaticStartPromise = null + throw err + }) + } + return packagedStaticStartPromise +} + function resolveWindowIcon() { const candidates = isDev ? [path.join(__dirname, '..', 'public', 'favicon.png')] @@ -30,7 +172,20 @@ function loadRenderer(win) { } return } - void win.loadFile(path.join(__dirname, '..', 'dist', 'index.html')) + void ensurePackagedStaticBaseUrl() + .then((baseUrl) => { + if (win.isDestroyed()) return + const u = new URL('/', baseUrl) + u.search = '' + u.hash = '' + void win.loadURL(u.href) + }) + .catch((err) => { + console.error('Packaged static server failed, falling back to file://', err) + if (!win.isDestroyed()) { + void win.loadFile(path.join(__dirname, '..', 'dist', 'index.html')) + } + }) } function createWindow() { @@ -107,6 +262,19 @@ app.whenReady().then(() => { }) }) +app.on('will-quit', () => { + if (packagedStaticServer) { + try { + packagedStaticServer.close() + } catch { + // ignore + } + packagedStaticServer = null + packagedStaticBaseUrl = null + packagedStaticStartPromise = null + } +}) + app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() }) diff --git a/package-lock.json b/package-lock.json index 6f630d0d..09d7d2b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "22.5.4", + "version": "22.5.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "22.5.4", + "version": "22.5.6", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 68ce4250..61169be2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "22.5.4", + "version": "22.5.6", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", @@ -27,7 +27,7 @@ "i18n:gaps": "npx tsx scripts/export-en-parity-gaps.ts", "i18n:translate-de": "PYTHONUNBUFFERED=1 .venv-i18n/bin/python scripts/auto_translate_i18n.py de", "electron:dev": "concurrently -k -n vite,electron -c blue,green \"vite --host\" \"wait-on http://127.0.0.1:5173 && cross-env NODE_ENV=development electron .\"", - "build:electron": "tsc -b && vite build --base ./", + "build:electron": "tsc -b && vite build", "electron:pack": "npm run build:electron && electron-builder", "og:image": "node scripts/generate-og-png.mjs" }, diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 58f58854..fbae491c 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -33,6 +33,7 @@ import { } from '@/constants' import { isSpotifyOpenUrl } from '@/lib/spotify-url' import { canonicalZapStreamWatchUrl, isZapStreamWatchUrl } from '@/lib/zap-stream-url' +import { isEmbeddableYoutubeUrl } from '@/lib/youtube-url' import { EMOJI_SHORT_CODE_REGEX, NOSTR_URI_INLINE_REGEX } from '@/lib/content-patterns' import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { TEmoji, TImetaInfo } from '@/types' @@ -3408,7 +3409,7 @@ function parseMarkdownContentMarked( if (/^https?:\/\/\S+$/i.test(line)) { const cleaned = cleanUrl(line) if (cleaned) { - if (isYouTubeUrl(cleaned)) { + if (isEmbeddableYoutubeUrl(cleaned)) { return (
+ +
+ ) + } + if (soleHref && isSpotifyUrl(soleHref)) { + return ( +
+ +
+ ) + } + if (soleHref && isZapStreamUrl(soleHref)) { + return ( +
+ +
+ ) + } + } + const parseNostrHref = (href: string): string | null => { if (!href.toLowerCase().startsWith('nostr:')) return null const raw = href.slice(6).trim() @@ -3677,6 +3711,47 @@ function parseMarkdownContentMarked( let segmentIdx = 0 paragraphTokens.forEach((t: any, idx: number) => { + // Same paragraph can mix ![](video) with GFM autolinks (single line break → one

in marked). + // Without this, YouTube/Spotify/zap.stream links are pushed into inlineSegment and render as plain . + if (t?.type === 'link') { + const cleaned = cleanUrl(String(t.href ?? '')) + if (cleaned && isEmbeddableYoutubeUrl(cleaned)) { + flushInlineSegment(segmentIdx++) + nodes.push( +

+ +
+ ) + return + } + if (cleaned && isSpotifyUrl(cleaned)) { + flushInlineSegment(segmentIdx++) + nodes.push( +
+ +
+ ) + return + } + if (cleaned && isZapStreamUrl(cleaned)) { + flushInlineSegment(segmentIdx++) + nodes.push( +
+ +
+ ) + return + } + } if (t?.type !== 'image') { inlineSegment.push(t) return @@ -3766,7 +3841,7 @@ function parseMarkdownContentMarked( const hasInlineYouTubeLink = paragraphTokens.some((t: any) => { if (t?.type !== 'link') return false const cleaned = cleanUrl(String(t.href ?? '')) - return !!cleaned && isYouTubeUrl(cleaned) + return !!cleaned && isEmbeddableYoutubeUrl(cleaned) }) if (hasInlineYouTubeLink) { const nodes: React.ReactNode[] = [] @@ -3788,7 +3863,7 @@ function parseMarkdownContentMarked( return } const cleaned = cleanUrl(String(t.href ?? '')) - if (!cleaned || !isYouTubeUrl(cleaned)) { + if (!cleaned || !isEmbeddableYoutubeUrl(cleaned)) { inlineSegment.push(t) return } diff --git a/src/components/YoutubeEmbeddedPlayer/index.tsx b/src/components/YoutubeEmbeddedPlayer/index.tsx index b8bebe18..59590451 100644 --- a/src/components/YoutubeEmbeddedPlayer/index.tsx +++ b/src/components/YoutubeEmbeddedPlayer/index.tsx @@ -38,9 +38,9 @@ export default function YoutubeEmbeddedPlayer({ * YouTube in Electron: * - **Iframe API** (`YT.Player`) against `http(s)://localhost` often ends in error **153** (player configuration) * in recent Chromium/Electron builds; it worked more reliably in plain browsers only. - * - **Native `/embed/` iframe** works if the `origin` query param matches the real page origin. Use - * `window.location.origin` for dev (`http://127.0.0.1:5173`, etc.). On **`file:`** there is no valid https - * origin — omit `origin` (a fake `https://…` origin caused **150**). + * - **Native `/embed/` iframe** works if the `origin` query param matches the real page origin (dev server, + * or packaged app: loopback static server — see `electron/main.cjs`). On raw **`file:`** omit `origin` + * (a fake `https://…` origin caused **150**). * Non-Electron: keep the Iframe API (unchanged from pre–Electron-split behavior). */ const useNativeEmbed = isImwaldElectron() diff --git a/src/constants.ts b/src/constants.ts index 1cfa608f..c3ec31b4 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -42,11 +42,8 @@ const _moduleHref: string = import.meta.url /** * URL for a file from `public/` (banner, favicon, payto logos, etc.). - * Uses Vite `base`: `/` on the web, `./` when built for Electron (`loadFile` + `file:`). - * - * Electron packaged builds use `file:` + client-side history paths like `/notes/…`, which replace - * the document URL with `file:///notes/…`. Relative `BASE_URL` links would then resolve next to that - * bogus path and 404. + * Uses Vite `base`: `/` for web and packaged Electron (renderer is served from `http://127.0.0.1:*`; see + * `electron/main.cjs`). The `file:` branch remains for opening `dist/index.html` directly from disk. * * For `file:` we derive the `dist/` root from the chunk's own URL. The chunk lives at * `dist/assets/*.js`, so `/assets/` marks the boundary: everything before it is the dist root.